Writing a Game Engine with Rust

Let's make a simple game engine in Rust, and use it to create a bouncy ball game that we can control with our keyboard!

Writing a Game Engine with Rust
Photo by Will Porada / Unsplash

Computer games have been a part of our lives since quite a while, with many of them being a crucial part of our childhood memories. Let’s try and whip up a fairly simple game engine and make our very own game — a bouncy ball that can be controlled with the keyboard!

This won’t hold so much as a candle to any of the modern game engines, but it’ll hopefully be good enough for what we’re going to be doing with it — making simple pixel games.

Creating a Window

The first thing a game does when you launch it is opening a window, which is what we’ll also do first. Since we’re using Rust, we can use a super handy library called minifb, which gives us a window, a way to react to keyboard events, as well as a super simple way to manipulate the contents of the window — a Vec of all the pixels that, when we assign a color value to, is reflected respectfully in the window.

use minifb::{Key, Window, WindowOptions};

const WIDTH: usize = 640;
const HEIGHT: usize = 360;

fn main() {
    let mut buffer: Vec<u32> = vec![0; WIDTH * HEIGHT];

    let mut window = Window::new(
        "Test Window Title",
        WIDTH,
        HEIGHT,
        WindowOptions::default(),
    ).unwrap();

    while window.is_open() {
        for i in buffer.iter_mut() {
            // set all pixels to black
            *i = 0;
        }
        
        for i in 20000..40000 {
            // set the pixels from 20000 to 40000 to red
            buffer[i] = 0xf44336;
        }

        window
            .update_with_buffer(&buffer, WIDTH, HEIGHT)
            .unwrap();
    }
}

This gives us:

This is a great way to create a window and manipulate it’s contents because it gives us a lot of power in just a few lines of code, and interacting with it is super simple! Now that we have a window, we can move ahead.

Laying the foundations

Now is a great time to think about how we want the engine APIs to look like. But for that, we need a barebones engine first, so let’s implement that.

Two things we’ll need in this game are the Engine and Game Objects. The engine itself manages the window and rendering and global effects, while the game objects are ‘things’ in the game, such as the bouncy ball we want to add to our game.

The engine needs to know what game objects exist in the game, and needs have the capability to add game objects to the game. It also needs to do the following on the game objects:

  • Apply global effects like gravity, air drag, or ground drag
  • Perform collision detection and decide what happens when a game object collides with another
  • Decide whether a game object is moving, and update it’s coordinates accordingly if it is
#[derive(Clone)]
pub struct WindowSize {
    pub height: usize,
    pub width: usize,
}

pub struct Engine {
    window: Option<Window>,
    buffer: Vec<u32>,
    window_size: WindowSize,
    objects: Vec<Box<dyn GameObject>>,
}

Looks reasonable — for a game engine, we need a minifb Window (so that we can perform input handling and the like), the buffer associated with that window (so that we can render things), the size of the window (required when rendering things, for collision detection and the like), and a vector of game objects (to know what to render and how).

Now we have a good grasp over what the Engine is meant to be able to do. We’ll explore the implementations on it in a while, but let’s talk about game objects first.

pub trait GameObject {
    fn common(&mut self) -> &mut GameObjectCommon;

    fn weight_factor(&self) -> f64;

    fn bounciness(&self) -> f64 {
        DEFAULT_COLLISION_DAMPING_FACTOR
    }

    fn collision_shape(&self) -> CollisionShape;

    fn draw(&self) -> Vec<Vec<u32>>;

    fn handle_input(&mut self, _keys: &[Key]) {}
}

There’s a lot going on here. Let’s go through it one by one. First of all, GameObject is a trait – this means that any struct can implement our trait to also become a GameObject. Here’s a small explanation of all the functions in this trait:

Weight Factor and Bounciness

There’s two things that are easily explainable — the weight_factor and the bounciness.

  • weight_factor – when things fall down in real life, how quick they fall depends on their weight. An anvil will fall down much faster than a feather, for example. The weight factor is just the factor by which the fall rate will be modified, so it could be 3.0 for an anvil and 0.2 for a feather.
  • bounciness – when something collides with another thing, it's 'bounciness' determines what happens (there might be many factors involved in actual physics, but let's keep things ultra simple for now). For example, when a ball collides with the ground, it's propelled back up with just a little bit of it's velocity lost, but when I fall to the ground, I (unfortunately) won't bounce back. Hence, the bounciness of a person could be 0, and that of a ball could be 0.9.

Common

#[derive(Clone, Default)]
pub struct XYPair {
    pub x: f64,
    pub y: f64,
}

#[derive(Default)]
pub struct GameObjectCommon {
    pub coords: XYPair,
    pub velocities: XYPair,
    pub object_info: Option<ObjectInfo>,
}

Since each GameObject will have a couple of common properties like the coordinates of the object and the velocities of the object on either axis, we can put them in a separate struct and have an instance of that here, since we can't have fields in the trait itself.

Object Info

object_info here is just information about the object that the engine sets every frame. This is how it's defined:

#[derive(Clone)]
pub struct ObjectInfo {
    // in case we want to support window resizing in the
    // future, the objects might want to know the size.
    // this is however a bad example, because it doesn't
    // need to be set per game object, so let's fix that in part 2
    pub window_size: WindowSize,
}

Draw

This is pretty straightforward — it’s a Vec of Vec of pixel colors to denote how the GameObject is to be drawn/rendered.

[
    [0,0,1,0,0],
    [0,1,1,1,0],
    [1,1,1,1,1],
    [0,1,1,1,0],
    [0,0,1,0,0],
]

Collision Shape

pub enum CollisionShape {
    Circle(f64),
}

The collision shape is just the collision box — the shape that collides with the window; we’ll use this later for collision with other game objects as well. This is imperative to have in our case because we’re representing game object graphics with a Vec<Vec<u32>>, which means the only representable shape is a quadrilateral. For example, we can draw a circle for our ball, but the pixels for the area around the edges will still exist, and hence we can't use that as the basis for our collision box. This is why we need a collision shape to be defined, so custom logic can be used for collision detection.

Handle Input

This function is also pretty straightforward. Every frame, it provides to the GameObject what keys have been pressed on the keyboard, and allows it to perform any actions it wants to using that information.

Creating our Game Object

Now that we know what game objects look like, we can get started with creating the game object for the ball in our bouncy ball game.

pub struct Ball {
    radius: f64,
    diameter: f64,
    color: u32,

    common: GameObjectCommon,
}

impl Ball {
    pub fn new(coords: XYPair, radius: f64, color_hex: &str) -> Self {
        let diameter = radius * 2.0;
        
        // convert hex color to u32, or default to white
        let color = from_str_radix(&color_hex[1..], 16).unwrap_or(0xFFFFFF);

        let common = GameObjectCommon {
            coords,
            ..GameObjectCommon::default()
        };

        Self {
            color,
            radius,
            diameter,

            common,
        }
    }
}

Nothing too special worth mentioning here; we just create the Ball struct and create a constructor for it. Let's now move forward while implementing everything we need to for the GameObject trait:

Common

This is extremely simple since we can just use the default function provided by the Default derived trait.

fn common(&mut self) -> &mut GameObjectCommon {
    &mut self.common
}

Weight Factor and Bounciness

Since this is a ball, it’s light and bouncy. Let’s set the weight factor and bounciness accordingly.

fn weight_factor(&self) -> f64 {
    0.8
}

fn bounciness(&self) -> f64 {
    0.6
}

Collision Shape

This is a ball, and balls are round (surprise!).

fn collision_shape(&self) -> CollisionShape {
    CollisionShape::Circle(self.radius)
}

Handle Input

Let’s make the ball move when left or right when A or D are pressed respectively. We’ll also make the ball jump when we’re firmly seated on the ground. Moving and jumping are as simple as adding or subtracting a certain value from the velocities.

const KB_X_BOOST: f64 = 0.2;
const KB_Y_BOOST: f64 = 16.0;

fn handle_input(&mut self, keys: &[Key]) {
    if keys.contains(&Key::A) {
        self.common.velocities.x -= KB_X_BOOST;
    }

    if keys.contains(&Key::D) {
        self.common.velocities.x += KB_X_BOOST;
    }

    // jump if we are on the ground AND have 0 or lesser y velocity
    if keys.contains(&Key::W) {
        if let Some(info) = &self.common.object_info {
            let window_height = info.window_size.height as f64;
            if self.common.velocities.y < 0.0
                && self.common.coords.y + self.diameter == window_height
            {
                self.common.velocities.y -= KB_Y_BOOST;
            }
        }
    }
}

Draw

This is a crucial aspect of the game object.

fn draw(&self) -> Vec<Vec<u32>> {
        let mut raster = vec![
         vec![
             0; self.diameter as usize
            ]; self.diameter as usize
        ];
        
        let h = self.radius;
        let k = self.radius;

        for y in 0..self.diameter as usize {
            for x in 0..self.diameter as usize {
                let dx = (x as f64 - h).abs();
                let dy = (y as f64 - k).abs();
                if (dx * dx + dy * dy).sqrt() <= self.radius {
                    raster[y][x] = self.color;
                }
            }
        }

        raster
}

First, we create a Vec<Vec<u32>>, which is just a vec of rows of pixels that form our GameObject. This effectively means that every GameObject must be a quadrilateral, but we can just draw the outer parts of our ball, with the black color for now, so that it effectively merges with the background.

Next, we use a bit of math. Since our rendering starts at the 0th pixel of the 0th row, the coords of the center of the circle will be (radius, radius). Next, we go over every pixel in our raster array, and calculate it's distance from the center of the circle using the Pythagoras theorem. If the distance is lesser than or equal to the radius, this pixel is to be included in the circle, and so we set it to the color we want the ball to be. Not that hard after all!

Engine Internals

Now that we’ve created the GameObject for our bouncy ball and know what the Engine struct looks like, let’s have a look at how the Engine functions internally. Here’s the Engine struct once again:

pub struct Engine {
    window: Option<Window>,
    buffer: Vec<u32>,
    window_size: WindowSize,
    objects: Vec<Box<dyn GameObject>>,
}

Public functions (excluding the game loop runner)

// public functions
impl Engine {
    pub fn new(window_size: &WindowSize) -> Result<Self, anyhow::Error> {
        Ok(Self {
            buffer: vec![0; window_size.width * window_size.height],
            window: None,
            window_size: window_size.clone(),
            objects: Vec::new(),
        })
    }

    pub fn add_game_object(&mut self, game_object: impl GameObject + 'static) {
        self.objects.push(Box::new(game_object))
    }
}

We’ve got two fairly simple functions here:

  • new – instantiates a new Engine with a black buffer, no window, a window size from the params, and an empty vec for objects.
  • add_game_object – simply appends the provided game_object to self.objects. Here, game_object is anything that implements the trait GameObject.

Internal functions

There are several here, so let’s go over them one by one:

fn calc_velocities(object: &mut Box<dyn GameObject>) {
    let mut velocities = object.common().velocities.clone();

    // apply gravity
    let gravity = GRAVITY * object.weight_factor() * DT;
    velocities.y += gravity;

    // apply air drag
    velocities.x *= 1.0 - (AIR_RESISTANCE_FACTOR * DT);
    velocities.y *= 1.0 - (AIR_RESISTANCE_FACTOR * DT);

    object.common().velocities = velocities;
}

This function will be run every frame. What is does is not very complex — it takes the velocities of the object, applies gravity and air drag to them. Here, we’re using DT, but it’s not very useful since DT is hardcoded. It’s meant to negate the effects of having varying fps on the physics, and we’ll probably get around to doing it properly later.

fn apply_velocities(object: &mut Box<dyn GameObject>) {
    let common = object.common();
    let coords = common.coords.clone();
    let velocities = common.velocities.clone();

    object.common().coords = XYPair {
        x: coords.x + velocities.x,
        y: coords.y + velocities.y,
    };
}

This function is also not very complex. All it does is apply the velocities to the coordinates of the game object for that specific frame. Every object moves velocities.x in the x direction, and velocities.y in the y direction.

fn update_object_info(window_size: &WindowSize, object: &mut Box<dyn GameObject>) {
    object.common().object_info = Some(ObjectInfo {
        window_size: window_size.clone(),
    });
}

This function updates ObjectInfo for some game object. We'll use it to update it for all the objects, and have something meaningful in it later on.

fn draw(buffer: &mut Vec<u32>, window_size: &WindowSize, object: &mut Box<dyn GameObject>) {
    let raster_vecs = object.draw();

    let common = object.common();
    let coords = &common.coords;

    Engine::draw_at(
        buffer,
        window_size.width,
        window_size.height,
        raster_vecs,
        coords,
    );
}

This function renders a game object at it’s supposed coords. It uses the Engine::draw_at internal util, which we'll have a look at in a while.

fn collision_checks(window_size: &WindowSize, object: &mut Box<dyn GameObject>) {
    match object.collision_shape() {
        CollisionShape::Circle(radius) => {
            let mut coords = object.common().coords.clone();
            let mut velocities = object.common().velocities.clone();
            let diameter = 2.0 * radius;

            let on_ground = if coords.y + diameter >= window_size.height as f64 {
                true
            } else {
                false
            };

            let on_x_collision =
                |velocities: &mut XYPair| velocities.x = -velocities.x * object.bounciness();

            let on_y_collision = |velocities: &mut XYPair| {
                velocities.y = -velocities.y * object.bounciness();

                // if we're just rolling on the ground, apply ground drag
                if on_ground && velocities.y.abs() <= 1.0 {
                    velocities.x -= velocities.x * GROUND_DRAG_FACTOR
                }
            };

            // x axis window collision
            if coords.x <= 0.0 {
                coords.x = 0.0;
                on_x_collision(&mut velocities);
            }
            if coords.x + diameter > window_size.width as f64 {
                coords.x = window_size.width as f64 - diameter;
                on_x_collision(&mut velocities);
            }

            // y axis window collision
            if coords.y - diameter < 0.0 {
                coords.y = diameter;
                on_y_collision(&mut velocities);
            }
            if coords.y + diameter > window_size.height as f64 {
                coords.y = window_size.height as f64 - diameter;
                on_y_collision(&mut velocities);
            }

            object.common().coords = coords;
            object.common().velocities = velocities;
        }
    }
}

Now this is a chonky function. It performs collision checks on a game object, and deals with what happens after a collision. Currently, we only check for collisions with the window borders, which is surprisingly simple to do.

Since collision logic is based on the shape of the game object, we add a check for object.collision_shape(), but since we only have one supported collision shape right now, that makes things simple for us.

Checking for a collision is simple:

  • X axis — if coords.x is less than 0 (the left edge of the circle is outside the left edge of the window) or coords.x + diameter is greater than window_size.width (the right edge of the circle is outside the right edge of the window)
  • Y axis — if coords.y - diameter is less than 0 (the top edge of the circle is outside the top edge of the window) or coords.y + diameter is greater than window_size.height (the bottom edge of the circle is outside the bottom edge of the window)

Once we detect a collision, we first snap the circle back to the closest edge. Then, we reverse the direction of it’s velocity, so something falling toward the ground will now have velocity in the other direction and be propelled up. Next, we multiply the velocity of the object with the object bounciness. A value of 1 or above will simply not work here, since that'll cause the ball to infinitely jump from ground to ceiling and possibly keep accelerating. However, values less than 1 apply a dampening effect, which is what we want.

In case there’s a y collision at the bottom edge and the velocity on the y axis is minimal, we also apply ground drag. This is because any object that moves while rubbing against the ground will face friction and hence be slower.

That concludes the collision detection and reaction!

Internal Utils

fn draw_at(
    buffer: &mut Vec<u32>,
    buffer_width: usize,
    buffer_height: usize,
    raster_vecs: Vec<Vec<u32>>,
    coords: &XYPair,
) {
    let object_width = raster_vecs.iter().map(|row| row.len()).max().unwrap_or(0);

    for (dy, row) in raster_vecs.iter().enumerate() {
        for dx in 0..object_width {
            let x = (coords.x + dx as f64) as usize;
            let y = (coords.y + dy as f64) as usize;

            // make sure this is not out of the buffer
            if x < buffer_width && y < buffer_height {
                let index = y * buffer_width + x;

                let maybe_pixel = row.get(dx).cloned();
                if let Some(pixel) = maybe_pixel {
                    buffer[index] = pixel;
                }
            }
        }
    }
}

Let’s now have a look at the draw_at function we referenced earlier. This function draws our Vec<Vec<u32>> which we use for convenience on the singular Vec<u32> rust_minifb buffer.

First, we calculate the object width, which is the longest row. Now, for each pixel in raster_vecs, its corresponding position in buffer is calculated based on coords. If the resultant position does exist inside the range of the buffer, we obtain the index the pixel should be at, and set it there. This is definitely a little confusing, but reading it a few times should help.

Game Loop Runner

pub fn run(&mut self, window_title: &str) -> Result<(), anyhow::Error> {
    self.window = Some(Window::new(
        window_title,
        self.window_size.width,
        self.window_size.height,
        WindowOptions {
            scale_mode: ScaleMode::AspectRatioStretch,
            ..WindowOptions::default()
        },
    )?);

    let duration_per_frame = std::time::Duration::from_secs(1) / FPS.try_into()?;
    self.window
        .as_mut()
        .unwrap()
        .limit_update_rate(Some(duration_per_frame));

    while self.window.as_ref().unwrap().is_open()
        && !self.window.as_ref().unwrap().is_key_down(Key::Escape)
    {
        let keys = self.window.as_ref().unwrap().get_keys();

        // clear the display buffer
        self.buffer.iter_mut().for_each(|p| *p = 0);

        for object in self.objects.iter_mut() {
            // re-calculate the velocities of the object
            Engine::calc_velocities(object);

            // apply the velocities to the coordinates
            Engine::apply_velocities(object);

            // perform collision checks with the window
            Engine::collision_checks(&self.window_size, object);

            // update the game object's info
            Engine::update_object_info(&self.window_size, object);

            // allow the object to react to pressed keys
            object.handle_input(&keys);

            // draw the object on the buffer at it's coords
            Engine::draw(&mut self.buffer, &self.window_size, object);
        }

        // reflect the display buffer changes
        self.window.as_mut().unwrap().update_with_buffer(
            &self.buffer,
            self.window_size.width,
            self.window_size.height,
        )?;
    }

    Ok(())
}

This is the primary function of the engine that runs the game loop and calls our internal functions on the game objects every frame.

First, we create a window and assign it to self.window. Next, we calculate the duration we want each frame to take, and limit the window update rate using limit_update_rate.

Now begins the game loop. First we get all the keys that have been pressed this frame, and clear the buffer, setting each pixel to black.

Then, until the window is open and the escape key is not pressed, we perform a series of actions on the each game object, which I think is easy enough to understand from just the code, since we already discussed the internal functions used previously.

Finally, at the end of the game loop, we replace the window buffer with the calculated buffer after drawing all our game objects, and the loop goes on!

Main function

Time for the last piece of the puzzle to get our game to run — the main function. This is fairly simple.

fn main() -> Result<(), anyhow::Error> {
  let window_size = WindowSize {width: 800, height: 600};
  let mut engine = Engine::new(&window_size)?;
  
  let radius = 24.0;
  let ball_coords = XYPair {
    x: (&window_size.width / 2) as f64 - radius,
    y: (&window_size.height / 2) as f64 - radius,
  };
  let ball = Ball::new(ball_coords, radius, "#cf5353");

  engine.add_game_object(ball);
  engine.run("Bouncy Ball")
}

All we do is create an Engine, create a Ball, add the ball to the engine, and run the engine. That gives us a very satisfying tiny "game" where we can control a bouncy ball on screen with our keyboard that collides with the window walls and bounces around!

“gameplay”! (reduced motion due to this being a gif)

If you want to try this yourself, feel free to have a look at the code here:

GitHub - uditkarode/rust_game_engine
Contribute to uditkarode/rust_game_engine development by creating an account on GitHub.

Just clone this and run cargo run --release, and assuming you have cargo installed, you’re set!

Hopefully I’ll continue improving this until it can make some bigger games; but for now, adios!