Skip to content
Comfy Engine

Game engine architecture for small 2D engines, layer by layer

Game engine architecture for small 2D engines, layer by layer

Game engine architecture sounds like a topic for thick books about consoles and streaming open worlds. For a small 2D engine it is much simpler, and much more useful to think about early. The question is only this: which piece of code is allowed to know about which other piece, and which way does data move each frame? Get those two answers right and the engine stays easy to change. Get them wrong and every new feature touches six files.

This piece describes the structure I use for small engines and for games that carry their own engine inside them. It assumes you have a loop running already. If you do not, the walkthrough on how to make a game engine builds one from a window up.

The six layers

A small engine has roughly six layers. They are not folders or crates by necessity, though they can be. They are rules about dependency.

Layer Owns Knows about
Platform Window, events, clock, file access, GPU device The operating system and backends
Core loop Frame timing, update order, frame begin and end Platform (through a trait)
Renderer Pipelines, vertex buffers, textures on the GPU Platform's GPU handle
Assets Loading, caching, handles, hot reload if any Platform's file access, renderer for uploads
Audio Mixer, voices, music streams Platform's audio device
Game code Every rule of your actual game Core, assets, and the public faces of renderer and audio

The arrow of knowledge points down the table and never up. The renderer does not know what an enemy is. The asset loader does not know there is a level select screen. Game code knows about everything else but only through small public interfaces.

When someone says their engine has become a tangle, it is almost always an upward arrow. The renderer reaches into game state to find the camera. The audio system checks whether the player is underwater. Each shortcut is harmless alone, and twenty of them make the engine untestable.

Platform: the only layer that touches the outside

Everything that depends on the operating system lives here: opening the window, translating key codes, reading the high precision clock, creating the GPU device, reading files. On desktop that might be winit plus wgpu. On the web the same layer talks to the browser.

The rest of the engine talks to the platform through a narrow trait or a small struct of plain data. Keep it narrow. Mine has about a dozen functions. If you port to a new backend, you rewrite this layer and nothing else, and the number of functions is a direct measure of how painful that port will be.

Core loop: the conductor

The core loop does not do any work of its own. It decides when each other layer runs and in what order. A typical order looks like this:

frame:
  platform.poll_events  -> Input snapshot
  time.advance          -> dt, fixed steps due
  for each fixed step:
      game.fixed_update(input, step)
  game.update(input, dt)
  game.draw(&mut draw_list)
  renderer.submit(draw_list, interpolation_alpha)
  audio.flush(sound_commands)
  input.end_frame

Two things stand out. First, game code runs in the middle and the engine layers run at the edges. Second, the arrows between steps are values, not references. The input snapshot is a struct. The draw list is a Vec. Sound commands are a Vec. Each layer consumes a value produced by the previous step.

That second point is the heart of the design, so it gets its own section.

Data flow per frame

Picture a single frame as water running through pipes. Events go in at the top and pixels and samples come out at the bottom. At each joint, data is handed over as a plain value, and the giver does not keep a reference to it.

pub struct FrameIn<'a> {
    pub input: &'a Input,
    pub dt: f32,
    pub assets: &'a Assets,
}

pub struct FrameOut {
    pub draws: Vec<DrawCmd>,
    pub sounds: Vec<SoundCmd>,
    pub camera: Camera,
}

pub trait GameCode {
    fn fixed_update(&mut self, input: &Input, step: f32);
    fn frame(&mut self, inp: FrameIn, out: &mut FrameOut);
}

Game code reads FrameIn and writes into FrameOut. It never calls the GPU and never touches a sound voice. The renderer reads out.draws and out.camera and has no idea where they came from.

This layout has a few practical payoffs:

  • Testing. You can run game code with a fake FrameIn and assert on FrameOut without a window.
  • Replays. If inputs and dt are recorded, a frame can be replayed exactly, as long as your update is deterministic.
  • Threads. The renderer can work on last frame's draw list while game code builds the next one, because they share nothing mutable.
  • Borrowing. In Rust, handing values across instead of holding references means you rarely fight the borrow checker at layer boundaries.

Let us put numbers on it. At 60 frames per second the budget is 16.7 milliseconds. A reasonable split for a small 2D game on a modest laptop might look like this:

Stage Budget
Events and input 0.2 ms
Fixed updates (one or two steps) 2.0 ms
Variable update and draw list 2.5 ms
Renderer CPU side (sort, build buffers) 1.5 ms
Audio flush 0.1 ms
Headroom for spikes about 10 ms

The point of writing a table like this is not that the numbers are exact, but that you know which stage to measure first when the frame drops. Small games almost never run out of budget in the renderer. They run out in game code doing something quadratic.

Immediate versus retained APIs

Every engine layer that game code talks to has to pick one of two API styles.

An immediate API takes a full description every frame. You call draw_sprite(tex, pos) each frame you want the sprite visible. Stop calling it and it is gone. macroquad and many small frameworks lean this way, and so does egui for user interfaces.

A retained API keeps objects alive between frames. You create a sprite once, get a handle, move it by changing its fields, and destroy it when done. Scene graphs in larger engines work this way.

// Immediate: state lives in the game, the renderer forgets every frame.
out.draws.push(DrawCmd::sprite(self.player_tex, self.player.pos));

// Retained: state lives in the renderer, the game holds a handle.
let id = scene.spawn_sprite(player_tex, start_pos);
scene.set_position(id, new_pos);
scene.despawn(id);

For small 2D engines I prefer immediate APIs at the renderer and audio edges. There is one source of truth (your game state) and nothing to keep in sync. You cannot leak a sprite by forgetting to destroy it. The cost is rebuilding the draw list every frame, which for a few thousand quads is cheap.

Retained makes more sense in two places. Expensive things that rarely change, such as a big static tile layer you upload once into a vertex buffer, are better kept on the GPU between frames. And long-lived audio voices like music need handles so you can fade them. The usual compromise is an immediate API on top with a little caching underneath, where the renderer notices that the tile layer has not changed and reuses last frame's buffer.

Where state lives

Most architecture arguments are really arguments about this. A simple rule helps: each piece of state has exactly one owner, and that owner is the layer that needs it to survive between frames.

Game state (positions, health, score, the current level) belongs to game code. It is the only thing that saves and loads, so it should be easy to serialise, ideally with serde and plain structs.

GPU state (textures, buffers, pipelines) belongs to the renderer. Game code refers to textures by handle, a small copyable integer, never by holding the texture.

Asset state (what is loaded, reference counts, file paths) belongs to the asset layer. It hands out handles and answers "is this ready?"

Input state belongs to the platform and core loop, and is handed to game code as a read-only snapshot.

Two kinds of state cause trouble. The first is state that two layers both want to own, such as a camera that the game moves and the renderer needs. Solve it by making the game the owner and passing a copy out each frame in FrameOut. The second is hidden global state, a static mut or a lazily created singleton that any code can reach. It feels convenient for a week. Then you try to run two instances of the game in tests, or reload a level, and it bites.

A note on game code structure

Inside the game layer you have a separate choice: plain structs and Vecs, or an entity component system such as hecs or bevy_ecs. The architecture above works the same either way, because both live entirely inside the game layer and both produce the same FrameOut. That is a good test of the layering. If swapping your object model would force changes to the renderer, an arrow is pointing the wrong way.

The engines section has more on the object model question, and the other engines page shows how a range of existing engines and frameworks draw their own lines.

Common mistakes and what they cost

  • Renderer reads game structs. Changing your Enemy struct now breaks the renderer. Fix: draw commands only.
  • Assets loaded from game update. File access stalls the frame. Fix: request loads, poll for readiness, show a placeholder.
  • Audio triggered from deep inside physics. Sounds fire twice on replays and in tests. Fix: push a SoundCmd into the output.
  • Platform types in game code. Porting means editing gameplay. Fix: your own Key, MouseButton and Gamepad enums.
  • Everything in one giant update. Order becomes accidental. Fix: name the stages and call them from the core loop in a fixed order.

As an exercise, take your current project and draw the six layers on paper. Draw an arrow for each use statement that crosses a layer. Any arrow that points upward is the first thing to fix, and it is usually a single function that needs to take a value instead of reaching for one.

Filed in Engines & Architecture

Written by Tendai Mukwena

Tendai writes about architecture: game loops, entity component systems, collision, pathfinding and procedural generation, usually with a profiler open. He works on simulation tools by day and builds roguelikes at night in Harare.

Keep reading