Im currently trying to make my own game engine but keep running into the same issues

Lyric Game Engine

After taking some time to read over a few rust books and learning the BEVY game engine I've decided I want to take a shot at making my own game engine. Eventually I might even get to a point where I can release games made in said engine, thats the hope anyways. Now to get straight to the point coding isn't something I'm new to and out of the many languages I have used rust by far is my favorite, yet fighting with the borrow checker is hell. Regardless for the past few hours I've started to make the basics and I've reached a standstill.

Table 1-0: File Structure

App Design Pattern

To start these are the necessary files.
Filename: src/prelude/app/mod.rs

use std::rc::Rc;
use std::cell::RefCell;
use std::sync::Arc;

use super::Plugin;

mod thread_pool;
use thread_pool::ThreadPool;

pub enum Process {
    Init,
    Update,
}

trait System: 'static + Send + Sync {
    fn execute(&self);
}

impl<T> System for T
where
    T: 'static + Fn() + Send + Sync,
{
    fn execute(&self) {
        self();
    }
}

enum Task {
    Init(Arc<dyn System>),
    Update(Arc<dyn System>),
}

struct Schedule {
    tasks: Vec<Task>,
    plugins: Vec<Box<dyn Plugin>>,
}

impl Schedule {
    fn new() -> Schedule {
        Schedule {
            tasks: vec![],
            plugins: vec![],
        }
    }

    fn add_plugins<T>(&mut self, plugins: Vec<T>)
    where
        T: Plugin,
    {
        self.plugins.extend(
            plugins
                .into_iter()
                .map(|plugin| Box::new(plugin) as Box<dyn Plugin>),
        );
    }

    fn add_systems<T>(&mut self, process: Process, systems: Vec<T>)
    where
        T: System,
    {
        println!("added system!");
        self.tasks.extend(systems.into_iter().map(|system| {
            let system = Arc::new(system);
            match process {
                Process::Init => Task::Init(system),
                Process::Update => Task::Update(system),
            }
        }));
    }

    fn run(&self, app: Rc<RefCell<&mut App>>) {
        for plugin in &self.plugins {
            plugin.build(&mut app.borrow_mut());
        }

        let pool = ThreadPool::new(4);

        for task in &self.tasks {
            if let Task::Init(system) = task {
                let system = Arc::clone(system);
                pool.execute(move || {
                    system.execute();
                });
            }
        }

        loop {
            for task in &self.tasks {
                if let Task::Update(system) = task {
                    let system = Arc::clone(system);
                    pool.execute(move || {
                        system.execute();
                    });
                }
            }
        }
    }
}

pub struct App {
    schedule: Schedule,
}

impl App {
    pub fn new() -> App {
        App {
            schedule: Schedule::new(),
        }
    }

    pub fn add_plugins<T>(&mut self, plugins: Vec<T>) -> &mut Self
    where
        T: Plugin,
    {
        self.schedule.add_plugins(plugins);
        self
    }

    pub fn add_systems<T>(&mut self, process: Process, systems: Vec<T>) -> &mut Self
    where
        T: System,
    {
        self.schedule.add_systems(process, systems);
        self
    }

    pub fn run(&mut self) {
        let app = Rc::new(RefCell::new(self));
        self.schedule.run(Rc::clone(&app));
    }
}

Filename: src/prelude/app/thread_pool/mod.rs

use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;

use super::System;

type Task = Box<dyn System>;

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Sender<Task>,
}

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        let (sender, receiver) = mpsc::channel();
        let receiver = Arc::new(Mutex::new(receiver));
        let mut workers = Vec::new();
        for _i in 0..size {
            workers.push(Worker::new(Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<T>(&self, task: T)
    where
        T: System,
    {
        let task = Box::new(task);
        self.sender
            .send(task)
            .expect("Failed to send task to thread pool")
    }
}

struct Worker {
    thread: Option<thread::JoinHandle<()>>,
}

impl Worker {
    fn new(receiver: Arc<Mutex<Receiver<Task>>>) -> Worker {
        let thread = thread::spawn(move || loop {
            let task = receiver.lock().unwrap().recv().unwrap();
            task.execute();
        });

        Worker {
            thread: Some(thread),
        }
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in self.workers.iter_mut() {
            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

Filename: src/main.rs

use std::thread;
use std::time::Duration;

use lyric_game_engine::prelude::*;

fn greeting() {
    println!("hello!");
}

fn farewell() {
    println!("goodbye!");
    thread::sleep(Duration::from_secs(3));
}

struct MyPlugin;

impl Plugin for MyPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Process::Update, vec![farewell]);
    }
}

fn main() {
    App::new()
        .add_plugins(vec![MyPlugin])
        .add_systems(Process::Init, vec![greeting])
        .run();
}

Problems

  • Plugins don't work correctly because of the way they get a reference to the app struct, I get an already borrowed error

  • And parallelism works for the most part except that the farewell function gets run 4 times instead of 1

This architecture is a very traditional OO-style unconstrained series of mutable references. This will not work in Rust because its fundamental design violates the Shared XOR Mutable principle.

You will have to redesign it from the ground up to not require cyclic mutable references as shown within Schedule::run():

  1. It takes a mutable reference to its parent App.
  2. It passes the App reference to each plugin. Meaning it holds a shared reference to self.plugins while iterating: plugins cannot add more plugins.
  3. Plugins then call App methods. If this was valid, misbehaving plugins could call App::run() while it's already running.

If you want to allow things like "plugins can add more plugins while iterating the list of plugins", you will have to be very careful about how that orchestration happens. One easy way to do that is construct a new temporary list of plugins that each existing plugin can add anything into. Then recursively extend the temporary lists into self.plugins. Take a multi-phased approach to building up the plugin list, avoiding the iterator invalidation problem.

That's just one such instance of the problem, but it runs deep. This kind of design favors seeing the world as if "everything is available all at once". A better design is one that provides access to only what is important to complete the task. For instance, maybe you didn't intend to allow plugins to add more plugins? If they only need to add tasks, they should receive a reference to the task list, not the entire app.


Suppose you had used the same design in any other language. It would have probably allowed this to work without complaining. But when a plugin does add another plugin, you would get unintended behavior like crashes or runtime exceptions. Rust's borrow checker is detecting that pushing to a list while iterating it is invalid.

The takeaway is that "fighting with the borrow checker is hell" is the wrong mindset for writing robust software. You should become aware that "crashes and runtime exceptions" are an even worse hell.

2 Likes

After reading your post your totally right that is definitely a huge design flaw on my end as I'm currently conditioned to object oriented programming in lua. It went right over my head that I was giving plugins the ability to not only add more plugins to the app but also run the app. I understood thats how BEVY implemented it, monkey see monkey do I guess. As for your solution "If they only need to add tasks, they should receive a reference to the task list, not the entire app." that would totally work however I was wondering if you think theres a better way of going about the design of the engine. Should I continue down the route I am on right now or will it come back to haunt me? And if so where should I start? What resources or examples should I take a look at?

I think after the initial design, you should focus on delivering a minimal viable product (MVP) first, then take an iterative approach by refactoring multiple times. A game engine has too many intersecting elements, so if you refactored after every feature, it would take longer. Worse, your motivation could die down, though that part is subjective.

1 Like

I would 100% agree with you and in no one, at least for now, do I have plans to make my engine one of the few leading engines competing with bevy, godot, unreal, untiy, etc... The main point of this projects is learn and grow as a developer. As for you concern I think that while true if I continue to implement a bad design at a certain point I would no longer be able to refactor and instead would just have to start from scratch. So my question still remains @parasyte? I've been looking at other rust game engines but I still think by far my favorite implementation I've seen is bevy.

Yeah, that's true, in that case, my approach would only breed bad habits

I think it is without question that you should change direction. Below I discuss in detail why I feel this way.

"Keep it simple, stupid!" KISS principle - Wikipedia

Make your first engine by designing it so that it can run a single game.

An engine that can run any kind of game will have a lot of moving parts and incidental complexity that the few games you build with it won't be able to fully utilize. You'll spend most of your time spinning your wheels, letting perfect be the enemy of good. [1]

General-purpose engine code should be a DSL over a virtual machine. [2]

So far, I've linked a few examples for general-purpose engines, mostly in footnotes. Be sure to click through those. There are also plenty of examples in the other direction, which I'll start to get into now.

I put together the simplest possible game engine (as in, not a general-purpose engine, it's an engine whose only purpose is to run the game it's written for) quite a number of years back for a simple Space Invaders clone:

pixels/examples/invaders at main · parasyte/pixels

The engine is in the simple_invaders directory. This link points at the shell, which uses the engine as a library and displays on screen whatever texture data the engine writes.

The architecture of this engine is literally as simple as it gets. You construct a World, periodically call the update() method, passing in player control inputs, and then call the draw() method to create the texture data. The meat of the work is just a few loops inside update() to process entity AI, animations, collision events... It contains absolutely none of the dynamic runtime decisions that your design has, like plugins or arbitrary function pointers.

So, why bring it up if it's so simple and practically incomparable to your architecture? Well, I wrote the whole thing in about a week, according to git history. On the one hand, that's a pretty rapid development cycle. On the other hand, there isn't much to Space Invaders, the whole game can be described by a state machine. And that's essentially what was written. My point is that game engines do not require everything to be dynamic and defined at runtime. Maybe growing and shrinking a few vectors is enough.

Another game that I wrote (from scratch, including the engine) in a week was an entirely original concept:

blipjoy/sombervale: Sombervale, a Rusty Jam 2021 game

For this one, I started out with a very different architecture. Instead of hard coding the various entities as vector fields of the world, I decided to make the whole world a single vector of dynamically dispatchable types, leaving a comment that I might want to replace it with an ECS in the future. Sure enough, this design bit me almost right away and I replaced it with shipyard the following day.

The problem is that this design is just downright awful. You run into mutable aliasing problems trivially because the only way to update entities in a single vector is to iterate over it. And as soon as entities have any cross relationships (like collision detection), the whole thing falls apart.

In the much simpler hard coded design, cross cutting concerns like collision detection are dead simple -- as long as entities of the same type do not need to interact with one another. Space Invaders makes an excellent case study for this: Invaders do not collide with other invaders. Lasers/bullets do not collide with other lasers/bullets. Shields do not collide with other shields. Everything is separate by design. You can iterate these entities without needing to also mutate them at the same time.

Could Sombervale have been written with the same simple architecture instead of relying on ECS? Maybe! Frogs don't collide with other frogs. Shadow creatures don't collide with other shadow creatures. Wall tiles don't collide with other wall tiles. There's only one player entity. Seems like it could have worked. But I poisoned the engine in the very first commit by trying to cram everything into a single vector. ECS was my escape hatch to keep that unsatisfactory design.

Quick side note on ECS

The good news? ECS is very powerful if you want to create emergent experiences for players. When digging into the topic, you will hear of "fire-ice swords" and the like, where elemental powerups can compose with weapons in unintentional but unique and compelling ways. You may not have intended fire-ice swords to exist, but they can be created as a side effect of the dynamic runtime. That emergent behavior can serve as a provocative game mechanic itself.

The bad news? ECS is complete overkill if your game designs are linear and rigid, like classic arcade games or modern casual games. And it's really difficult to get right! Use a library, there's no use trying to write your own. Rolling your own is just a distraction.

To me, bevy kind of misses the point. It's a general-purpose engine, but there is no VM. Rust is playing a dual role as both the implementation language and the interface for a DSL composed of systems. It feels out of touch with what general-purpose game engines actually do in practice. I choose to believe this is because it's still very early in the design phase. It will still take some time for it to evolve beyond "Rust is the DSL."

It's probably worth talking about DSLs

DSL is a term that is easy to throw around without being clear about its meaning. The way I am using the term is in the sense of Greenspun's tenth rule. As a game grows in scope, it's wise to move away from hard coding things like level structure, animation stacks, AI, etc. toward defining the atomic pieces as data that can be loaded separately from the game and manipulated at runtime. This makes modding straightforward: just replace the data that defines levels and scripts with modified variations.

Levels can be edited in tile map editors and saved as JSON. The level data can contain entity references like the player's spawn point, doors that lead to other levels, enemy spawners, and so on. These pieces of data are a DSL, and the engine is its interpreter. Very much like a VM that interprets byte code. Some VMs are explicitly designed as such, others are implied Greenspun-style.

DSLs are bad when they are too rigid to do what is required by the designer. The hardcoded systems need to resemble machine instructions for the DSL to be capable of "total conversion" mods. Or scripting needs to be exposed as part of the data, part of the DSL.

Check out CellPond and the associated videos. This is a good DSL. One of the fascinating things about this area of research is that I don't personally have any good way of articulating what a DSL is. You just kind of know it when you see it. Something like code that consumes data and makes dynamic decisions based on how that data is interpreted.

So, to that end, DSLs are often opaque and hidden in plain sight. It's really hard to identify them, and more difficult to extract them into general-purpose VMs.

This is the secret ingredient that bevy's architecture is missing. The DSL doesn't fit the purpose of, well, general-purpose engines. My hope is that they will get there eventually. But as of now, it's not what you want to be inspired by.


  1. And then you'll blame the tools for your mistake. Don't be this guy. ↩︎

  2. There is a lot to say on this topic, but some prominent examples are ScummVM and the Another World engine. "Fantasy computers" like CHIP-8 and PICO-8 fall into this category. Do these examples seem too old and simple? What about Doom 3? Some engines use Lua, some use Python, some go all-in on real-time visual design, etc. ↩︎

3 Likes