Developing a Game Engine in Rust

Edward Wibowo,

For me, this is the difference between an “engine” and a “library”. With libraries, you own the main game loop and call into the library. An engine owns the loop and calls into your code.

Currently, I am developing a 2D game engine in Rust: ctrait. The game engine is mostly an experiment to help me understand how game engines work; more specifically, I yearned to understand the inner workings of entities and how they intertwine with rendering and physics processes. I also wanted to weave in unique concepts found in Rust, mostly to justify my choice of programming language. Thus, I based the entire engine on Rust’s traits, hence the engine’s name 😉.

For a sneak-peek, click below for a basic example of the engine’s usage:

Basic example

The following is a basic example that simply renders a red square.

use ctrait::{
    camera::Camera,
    entity, entities,
    game::Game,
    rect::Rect,
    render::{RenderContext, Renderer},
    traits::Renderable,
    Color,
};

#[derive(Debug)]
struct Player {
    rect: Rect,
}

impl Player {
    fn new() -> Self {
        Self {
            // Create a red rectangle with a width and height of 50 pixels.
            rect: Rect::from_center(0, 0, 50, 50).with_color(&Color::RED),
        }
    }
}

// Allow the player to be rendered.
impl Renderable for Player {
    fn render(&self, camera: &Camera, context: &mut RenderContext) {
        // Since Rect implements Renderable, it can be rendered with a single function call.
        self.rect.render(camera, context);
    }
}

let mut renderer = Renderer::default().with_camera(Camera::default());

let player = entity!(Player::new());

let mut game = Game::default();
// Register the player entity as a Renderable entity.
game.renderable_entities
    .add_entities(&entities!(Renderable; player));
game.start(&mut renderer)?;

Be warned: the engine is in an “alpha” stage right now. Things are subject to change.

Trait-based Entity Functionality

I originally wanted to implement an Entity Component System (ECS) with traits; however, I quickly realized my idea of an ECS was vastly different, so really, the engine evolved to something quite different. In essence, an entity in ctrait is expected to implement at least one of the following traits:

  • FixedUpdate
  • Interactive
  • Renderable
  • Update

Each trait declares a required method that does what the trait name suggests. For example, a type implementing Renderable could be rendered by calling the trait method .render(). Likewise, a type implementing Interactive can be interacted with (respond to events) by calling the trait method on_event(). Defining entities using traits makes it easy to split and organize entities by their intended functionality. Rendering all entities in a game is simply a matter of collecting all Renderable entities and calling .render() for each one.

The definition of the Renderable trait can be found below:

pub trait Renderable: Send {
    fn render(&self, camera: &Camera, context: &mut RenderContext);
}

Listing 1: The Renderable trait.

For example, in a platformer game, the player’s character struct would probably implement (at the very least) FixedUpdate, Interactive, and Renderable. The struct would need FixedUpdate to update its position in the world, Interactive so it can be controlled, and Renderable so it’s visible in the game.

In contrast, a simpler object like the ball in Pong wouldn’t need to implement Interactive as it is “controlled” autonomously.

Entity Containers

Entity Container

A container is needed for each trait to encapsulate entities that share the same functionality. Hence, there would be a container full of entities that implement FixedUpdate, Interactive, and so on. However, since a type can implement multiple traits, storing entities isn’t as simple as passing it to some vector and letting it own the entity. A reference to an entity that implements 2 of the traits should exist in 2 separate entity containers. Initially, I solve this by using Rc, a reference-counting pointer permitting shared ownership. How neat. Thus, my initial implementation of an entity was as follows:

pub type Entity<T> = Rc<RefCell<T>>;

Listing 2: Reference-Counted entity type.

As seen in listing 2, I also used RefCell in conjunction with Rc to allow interior mutability.

Although this worked for a while, this implementation conflicted with the FixedUpdate trait. The idea behind FixedUpdate is that unlike Update, it calls its update function at a fixed rate. This is important for games and especially time-dependent operations because not all devices have the same processor. Without some FixedUpdate equivalent, players with faster computers would see entities move much faster than players with slower computers. I implemented FixedUpdate by separating the fixed update calls from the main game loop (which deals with rendering, updating, and event handling). So basically, entities that implement FixedUpdate have their functions called on a separate thread.

Entity Container

I used a second thread because I wanted to be certain that FixedUpdate method calls could be called in an orderly and fixed fashion. However, using threads meant that I couldn’t use Rc as it doesn’t implement Send. Luckily, Rust has a thread-safe reference-counting pointer named Arc 🥳.

Thus, the Arc-based entity:

pub type Entity<T> = Arc<Mutex<T>>;

Listing 3: Atomically Reference-Counted entity type.

One of the caveats to being thread-safe in this manner is that mutating the inner-type requires locking the Mutex which is prone to poison errors. However, this only happens if some panic occurred in a thread where the entity is also being used.

Next, pushing an entity to an entity container of trait objects can be implemented as follows:

use std::sync::{Arc, Mutex};

// Some trait such as Renderable or FixedUpdate...
pub trait Bar {}

struct Foo;
impl Bar for Foo {}

fn main() {
    // The entity.
    let foo = Arc::new(Mutex::new(Foo {}));

    // Entity container of trait objects.
    let mut container: Vec<Arc<Mutex<dyn Bar>>> = Vec::new();

    container.push(Arc::clone(&foo) as Arc<Mutex<dyn Bar>>);
}

Listing 4: Pushing into entity container.

Hmm. This syntax looks a bit chaotic. So here are some good ol’ macros I wrote to deal with entity creation and cloning:

#[macro_export]
macro_rules! entity {
    ($object:expr) => {
        std::sync::Arc::new(std::sync::Mutex::new($object))
    };
}

#[macro_export]
macro_rules! entities {
    ($name:ident; $($entity:expr),+) => {
        [$(ctrait::entity::Entity::clone(&$entity) as ctrait::entity::Entity<dyn $name>),+]
    };
}

Listing 5: Entity-related macros.

Another problem arises, however. While I was using the game engine myself, I tried to instantiate an object at run-time and delete it after some condition is met. To my surprise, it still existed in the entity container after I dropped the original object. So I figured the entity containers needed to store references to the entities without actually owning the entities. Conveniently, Rust had the exact struct I needed: Weak. Weak is like an Rc that doesn’t own the inner-type. It is still possible to mutate and access the entity object from the Weak pointer by upgrading it to an Arc. However, if a Weak pointer is upgraded when the inner value has been dropped, upgrading returns None. I used this mechanic to my advantage by simply removing any entities in the container that cannot be upgraded. In other words, before iterating through the entity container, the engine checks if calling .upgrade() on the Weak pointer to the entity returns Some. If it doesn’t, it would be safe to just remove it from the container.

To streamline this process, I created a method called prune for the EntityContainer which simply removes all entities that have been dropped.

type WeakEntity<T> = Weak<Mutex<T>>;
pub struct EntityContainer<T: ?Sized>(Arc<Mutex<Vec<WeakEntity<T>>>>);

impl<T: ?Sized EntityContainer<T> {
    fn prune(entities: &mut Vec<WeakEntity<T>>) {
        // Whenever the entities are accessed, check if inner values for each entity exists.
        // If an inner value does not exist, it indicates that the original entity has been
        // dropped. Thus, it should be removed from the container as well.
        entities.retain(|entity| entity.upgrade().is_some());
    }
}

Listing 6: EntityContainer pruning method.

Textures

ctrait renders using SDL 2.0. For bindings, I used the Rust-SDL2 crate.

One thing which made it hard for me to implement textures was the lifetime requirements of Textures in Rust-SDL2. To quote from their README.md:

In the sdl2::render module, Texture has by default lifetimes to prevent it from out-living its parent TextureCreator.

  • Rust-SDL2

These lifetime requirements coupled with the fact that entities operate in a multi-threaded environment made it excruciatingly hard for me to implement textures nicely. I found myself rereading the 10.3 “Validating References with Lifetimes” of “The Book” and heavily refactoring the engine. All of this was due to my original goal of having something like this:

struct EntityObject<'a> {
    texture: Texture<'a>
}

Listing 7: Object with texture field.

However, the Texture from Rust-SDL2 doesn’t implement Send or Sync… So I tried doing:

pub struct ThreadSafeTexture<'a>(Texture<'a>);

unsafe impl<'a> Send for ThreadSafeTexture<'a> {}
unsafe impl<'a> Sync for ThreadSafeTexture<'a> {}

Listing 7: Implementing Send and Sync unsafely.

This didn’t work with my engine’s architecture.

So to solve this conundrum, I ditched my goal of allowing entities to have texture fields and wrote a texture resource manager instead. With the resource manager, textures are passed the entity as an argument to the render method in Renderable. This meant that the Texture didn’t need to be thread-safe.

I used the resource manager example from the Rust-SDL2 crate to implement my texture manager. In the end, this was my implementation:

pub struct TextureManager<'a> {
    texture_creator: &'a TextureCreator<WindowContext>,
    cache: HashMap<String, Rc<Texture<'a>>>,
}

impl<'a> TextureManager<'a> {
    /// Create a texture manager from the given [`TextureCreator`].
    pub(crate) fn new(texture_creator: &'a TextureCreator<WindowContext>) -> Self {
        Self {
            texture_creator,
            cache: HashMap::new(),
        }
    }

    /// Load and return a texture from the given path.
    ///
    /// The loaded texture is cached and will be retrieved if loaded again.
    pub fn load(&mut self, path: &str) -> CtraitResult<Rc<Texture>> {
        self.cache.get(path).cloned().map_or_else(
            || {
                let resource = Rc::new(self.texture_creator.load_texture(path)?);
                self.cache.insert(path.to_string(), Rc::clone(&resource));
                Ok(resource)
            },
            Ok,
        )
    }
}

Listing 8: Texture resource manager implementation.

Making the TextureCreator and Texture share the same lifetime 'a was the key to making it work. Further, I used a HashMap as a cache to store textures after they have been loaded for the first time. This is important because the render method from the Renderable trait is called every frame so it is important to avoid superfluous processes.

I then encapsulated this logic under a Sprite struct, making rendering textures very easy:

#[derive(Debug)]
struct Image {
    sprite: Sprite,
}

impl Image {
    const SPRITE_SIZE: u32 = 256;

    fn new(path: &str) -> Self {
        Self {
            sprite: Sprite::new(
                path,
                &Rect::from_center(0, 0, Self::SPRITE_SIZE, Self::SPRITE_SIZE),
            ),
        }
    }
}

impl Renderable for Image {
    fn render(&self, camera: &Camera, context: &mut RenderContext) {
        self.sprite.render(camera, context);
    }
}

Listing 9: Example of an entity with Sprite field.