Skip to content
Comfy Engine

How to make a game engine: a tiny 2D engine in Rust, step by step

How to make a game engine: a tiny 2D engine in Rust, step by step

Most people who ask how to make a game engine are really asking how to make the smallest thing that can run their game. That is good news, because the smallest thing is not very big. A window, a loop, some input, a way to draw coloured rectangles and then textured ones, a place to keep your objects, and a sound when something explodes. You can have all of that in a weekend or two of evenings, and every step teaches you something you will use even if you later move to a full engine.

This is the order I build things in when I start a toy engine in Rust. It is not the only order, but each step gives you something visible on screen, which is the best cure for the motivation dip that kills most engine projects around week two.

Step zero: decide what the engine is for

Write down one game. Not a genre, a game. "A top-down arena shooter with one level, twenty enemies and a score" is a spec. "A general 2D engine" is a hobby that never ends.

The game decides what you need. My arena shooter list looks like this:

Need Why Can wait?
Window and loop Nothing works without it No
Keyboard input Movement and shooting No
Coloured quads Prototype everything as boxes No
Textures Sprites instead of boxes A week
Scene of objects Enemies, bullets, player No
Collision Bullets hit things A few days
Audio Feedback, feel Until the end
Editor, scripting, networking None of this game needs it Forever

That last row is the important one. Every line you skip is a line you do not have to debug.

Step one: a window and a loop

In Rust the usual choice for opening a window is winit, which handles the operating system's event loop on desktop and the web. For drawing you either use wgpu directly or a smaller layer such as miniquad. I will not pretend to quote any of their APIs from memory here, because they change between releases and the exact names matter less than the shape. Every platform layer gives you roughly the same deal: it calls you when events arrive, and it lets you know when it is time to draw a frame.

So I hide the platform behind a small trait of my own. The engine core never touches winit types directly.

pub trait Platform {
    fn poll_events(&mut self, input: &mut Input) -> bool; // false when the window closes
    fn now_seconds(&self) -> f64;
    fn present(&mut self, frame: &Frame);
}

pub fn run(platform: &mut impl Platform, game: &mut Game) {
    let mut input = Input::default();
    let mut last = platform.now_seconds();
    while platform.poll_events(&mut input) {
        let now = platform.now_seconds();
        let dt = (now - last).min(0.1) as f32;
        last = now;
        game.update(&input, dt);
        let frame = game.draw();
        platform.present(&frame);
        input.end_frame();
    }
}

This is a pull style loop, written as if you own the main loop. Some platform layers want to own it themselves and call you back instead, in which case run becomes the body of that callback. The trait still works, and that is the point: when the platform layer changes, only one file changes.

Note the min(0.1) on the delta time. If you drag the window or hit a breakpoint, the next frame might report two seconds of elapsed time and your player will teleport through a wall. Clamping it is the cheapest fix. A proper fixed timestep is better, and it is worth its own article, but a clamp gets you through the first week.

Step two: input as plain data

Input is where beginner engines get tangled, because events arrive at odd times and game code wants to ask simple questions. "Is jump held?" "Was fire pressed this frame?" The trick is to turn events into a small struct of state and let the game read only that struct.

#[derive(Default)]
pub struct Input {
    held: std::collections::HashSet<Key>,
    pressed: std::collections::HashSet<Key>,
    pub mouse: (f32, f32),
}

impl Input {
    pub fn key_down(&mut self, k: Key) {
        if self.held.insert(k) { self.pressed.insert(k); }
    }
    pub fn key_up(&mut self, k: Key) { self.held.remove(&k); }
    pub fn is_held(&self, k: Key) -> bool { self.held.contains(&k) }
    pub fn was_pressed(&self, k: Key) -> bool { self.pressed.contains(&k) }
    pub fn end_frame(&mut self) { self.pressed.clear(); }
}

Key is your own enum. The platform layer translates its key codes into it. Keeping your own enum means your game code compiles the same way on every backend, and you can add a gamepad later by mapping buttons onto the same keys.

The one bug to watch: key repeat. Operating systems send repeated "down" events while a key is held. Because insert returns false for a key already in the set, the repeat does not count as a new press. That single if saves an afternoon of confusion.

Step three: drawing coloured quads

Here is where many engine projects stall, because the graphics API wants a lot of setup before the first triangle. My advice is to accept that setup once and then never touch it again for the rest of the project. You need exactly one pipeline: draw a list of rectangles, each with a position, a size, a colour and (later) a texture region.

The engine side does not need to know anything about the GPU. It just collects quads into a list during the frame.

#[derive(Clone, Copy)]
pub struct Quad {
    pub pos: [f32; 2],
    pub size: [f32; 2],
    pub color: [f32; 4],
    pub uv: [f32; 4], // x, y, w, h in the texture, 0..1
    pub layer: i32,
}

#[derive(Default)]
pub struct Frame { pub quads: Vec<Quad>, pub clear: [f32; 4] }

impl Frame {
    pub fn rect(&mut self, pos: [f32; 2], size: [f32; 2], color: [f32; 4]) {
        self.quads.push(Quad { pos, size, color, uv: [0.0, 0.0, 1.0, 1.0], layer: 0 });
    }
}

The renderer sorts the list by layer, turns each quad into four vertices, and draws the whole lot in one or a few draw calls. A white one-pixel texture lets plain coloured rectangles use the same path as sprites, so you never maintain two pipelines.

A quick budget check. A small arena game might have 300 sprites on screen. Four vertices each, with position, UV and colour at 32 bytes per vertex, is 300 times 4 times 32, which comes to 38,400 bytes a frame. That is nothing. You can rebuild the whole vertex buffer every frame and not think about it. Batching and caching are problems for later, if ever.

Step four: textures and a sprite sheet

Once boxes move around and feel right, swap them for sprites. Load PNG files with a decoding crate, upload them as textures, and keep a handle (an index into a Vec of textures) rather than passing textures around.

Pack sprites into one sheet per character or per tileset. If your player is 16 by 16 pixels with eight frames of walk animation, a single row of 128 by 16 pixels holds them all, and the UV for frame n is [n as f32 / 8.0, 0.0, 1.0 / 8.0, 1.0]. If you are drawing your own art, the article on how to make pixel art covers sizes and palettes that keep a small engine's job easy.

Two settings save a lot of grief with pixel art. Use nearest-neighbour filtering so pixels stay crisp, and snap your camera to whole pixels when you draw, or sprites will shimmer as they move.

Step five: a scene, the boring way

You now need somewhere to keep the player, the enemies and the bullets. This is where people reach for an entity component system on day three. Do not, yet. A struct with a few Vecs is honest and fast for a game this size.

pub struct Game {
    player: Player,
    enemies: Vec<Enemy>,
    bullets: Vec<Bullet>,
}

impl Game {
    pub fn update(&mut self, input: &Input, dt: f32) {
        self.player.update(input, dt, &mut self.bullets);
        for e in &mut self.enemies { e.update(self.player.pos, dt); }
        for b in &mut self.bullets { b.pos[1] -= b.speed * dt; }
        self.bullets.retain(|b| b.pos[1] > -10.0 && !b.hit);
        self.enemies.retain(|e| e.hp > 0);
    }
}

The borrow checker likes this layout, because each system borrows the pieces it needs and nothing else. Removal is done with retain at the end of the frame, so you never delete something while iterating over it.

Collision for twenty enemies and fifty bullets is a nested loop: a thousand rectangle overlap tests per frame. That runs in microseconds. A spatial grid only earns its keep once you have thousands of objects.

Step six: audio, last

Sound makes a game feel alive, so leave it until the end of the first pass, when you need the lift. Wrap whatever audio crate you choose (kira and similar libraries exist for this) behind two functions, play(sound) and play_music(track), and resist building a mixer. Load sounds once at startup, keep handles, and fire them from game code.

Browsers will not start audio until the player clicks or presses a key, so if you target the web, start your audio on the first input event and the first explosion will not be silent.

What to skip, on purpose

These are the features that eat engine projects alive. Each one is a project of its own.

  • An editor. Use Tiled or LDtk for levels and load their files. They are better editors than you will write in a month.
  • A scripting language. Rust compiles fast enough for a small game if you keep the crate small. Tune the incremental build before you embed Lua.
  • A general renderer. One quad pipeline, maybe one post effect. No material system.
  • Physics. Rectangles and circles with hand-written responses feel better in most 2D action games than a rigid body simulation does.
  • Plugins and hot reload. Lovely, and for later. Restarting a small game takes a second.

When to use an existing engine instead

Build your own engine if you want to learn how engines work, if your game needs something unusual that existing tools fight you on, or if you simply enjoy it. Those are good reasons, and the last one is the most common.

Use an existing engine if the goal is a finished game by a date. Bevy, macroquad, Fyrox, ggez, Godot and others have already solved text rendering, gamepads, window resizing on odd monitors and a hundred other dull problems. The other engines page lists options worth a look, and the engines section collects more pieces on how they are put together.

There is a middle road I like: build the toy engine above for a fortnight, then move your game to a framework. You will understand every part of the framework's API because you wrote a worse version of it, and that understanding makes you faster with it.

Questions people ask

How long does a tiny engine take?

The steps above take a few evenings each for someone comfortable with Rust. The graphics setup is the slowest single part. A playable box prototype by the end of the first weekend is realistic.

Should I start with 3D?

No. 2D has the same loop, input, assets and scene problems with a fraction of the maths. Everything you learn transfers.

Do I need an ECS?

Not for a game with a handful of object types. Plain structs in Vecs are easier to read and debug. Reach for an ECS when you have many kinds of objects sharing behaviour in shifting combinations.

Which crates should I start with?

A windowing crate, a graphics layer, an image decoder and an audio crate. Read each crate's own examples for the current API rather than trusting snippets in articles, this one included.

For a first exercise, get step three working and draw a grid of 32 coloured squares that change colour when you press space. If that runs at a steady frame rate on your machine, the engine has a heartbeat, and everything after is adding organs.

Filed in Engines & Architecture · Rust Gamedev

Written by Kasimir Lund

Kasimir writes about Rust for games: engine internals, ownership patterns that survive a real game loop, and getting builds onto other people's machines. He has been writing small engines for himself since the SDL days and lives in Gothenburg.

Keep reading