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
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.
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 Texture
s 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 parentTextureCreator
.
- 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.