Capstone: A Complete 2D Game
Fourteen subsystems, one machine. This is the payoff: the wiring diagram and the small game that exercises all of it. The loop drives a simulation; input feeds it; the simulation pushes sounds through the lock-free ring; the renderer batches an interpolated snapshot into a few draw calls; physics resolves collisions; the state machine runs the menu and the waves. We assemble it in C++ and Rust, and you can play the result in the browser.
01What done looks like
You have fourteen subsystems sitting in separate tutorials. This module is the connective tissue: the main() that brings them up, the frame that ties them together, and a small game that proves it works. On the series hub's layer diagram, the capstone is the top "Game" layer, it may call down into every layer below, and nothing calls it.
Concrete and well-scoped: a ship you steer, enemies that spawn in waves, bullets, collisions, sounds on events, and a score. We pick an arena shooter over a platformer deliberately, its collision is symmetric AABB/circle with no one-way platforms or coyote-time edge cases, so it exercises the subsystems without platformer-specific tuning eating the word count. Every later section points at this game.
02The shape of main()
Bring subsystems up in dependency order, run the loop, tear down in reverse. The ordering is the same discipline as the Vulkan object lifetimes, just applied to the whole engine: platform → input → GPU device + swapchain → assets (mount the package) → renderer → audio (device + ring + thread) → world → state machine. Shutdown reverses it, and the order is load-bearing[6].
int main() {
Platform platform; // window + OS event pump
InputState input;
Renderer renderer(platform); // Vulkan device, swapchain, sprite batcher
AudioEngine audio; // opens device, spawns the audio thread
SpscRing<AudioCommand, 1024> audioRing;
audio.bind(&audioRing); // the audio thread is the single consumer
Assets assets("assets.pak"); // mount the cooked package
World world = loadArena(assets, renderer);
GameState state = GameState::Menu;
using Clock = std::chrono::steady_clock;
const double dt = 1.0 / 60.0; // fixed simulation step
double accumulator = 0.0;
auto currentTime = Clock::now();
while (platform.running()) {
auto newTime = Clock::now();
double frameTime = std::chrono::duration<double>(newTime - currentTime).count();
if (frameTime > 0.25) frameTime = 0.25; // spiral-of-death clamp (Fiedler)
currentTime = newTime;
platform.pumpEvents(input); // drain OS events -> per-frame snapshot
accumulator += frameTime;
while (accumulator >= dt) { // 0, 1, or N steps to catch up
previousState = currentState; // keep the prior state for interpolation
simulate(world, input, audioRing, dt, state);
accumulator -= dt;
}
double alpha = accumulator / dt; // 0..1 leftover
renderer.render(buildBatch(world, alpha)); // interpolated, read-only
}
audio.shutdown(); // JOIN the audio thread before its ring dies
renderer.shutdown(); // free GPU resources before the device
platform.shutdown();
}
fn main() {
let mut platform = Platform::new(); // window + event pump
let mut input = InputState::default();
let mut renderer = Renderer::new(&platform); // device, swapchain, batcher
let (audio, audio_ring) = AudioEngine::spawn(); // audio thread = single consumer
let assets = Assets::mount("assets.pak");
let mut world = load_arena(&assets, &mut renderer);
let mut state = GameState::Menu;
let dt = 1.0 / 60.0; // fixed simulation step
let mut accumulator = 0.0;
let mut current_time = Instant::now();
while platform.running() {
let new_time = Instant::now();
let mut frame_time = (new_time - current_time).as_secs_f64();
if frame_time > 0.25 { frame_time = 0.25; } // spiral-of-death clamp
current_time = new_time;
platform.pump_events(&mut input); // OS events -> snapshot
accumulator += frame_time;
while accumulator >= dt { // 0, 1, or N steps
previous_state = current_state;
simulate(&mut world, &input, &audio_ring, dt, &mut state);
accumulator -= dt;
}
let alpha = accumulator / dt; // 0..1
renderer.render(build_batch(&world, alpha)); // interpolated, read-only
}
// drop order: audio (joins thread) before its ring; renderer before device
}
Error handling, swapchain recreation on resize, asset streaming/eviction, sub-stepped physics CCD, gamepad hot-plug, voice stealing in the mixer, and save/load. Each is in its own tutorial; here the point is the wiring, not the production hardening.
03Frame anatomy
The per-frame order is the heart of the whole thing, and it's exact: poll input into a snapshot, step the fixed-timestep simulation zero/one/several times to consume the elapsed time, then render once from an interpolated snapshot[1][2].
- Poll input → fold OS events into a per-frame snapshot (the Input tutorial's once-per-frame sample).
- Step the sim: the accumulator banks elapsed time,
accumulator += frameTime, thenwhile (accumulator >= dt) { simulate(dt); accumulator -= dt; }. - During each step, the sim pushes audio commands into the ring (it never calls the mixer).
- Render once, reading
lerp(previousState, currentState, alpha).
Render the raw currentState and the picture quantizes to the sim rate (stutter); render an interpolated blend of the last two states and it's smooth, at the cost of ≤1 step of latency. Use interpolation (blend two already-computed states, can't overshoot), not extrapolation (predict ahead, overshoots on a bounce and snaps back)[1]. And the render pass must be read-only: advancing an animation timer or moving anything during draw breaks determinism and tears the interpolation. The 0.25 clamp is Fiedler's example value, not a universal constant.
The widget lays out one frame and lets you inject a lag spike to watch the accumulator run catch-up steps (and the spiral-of-death guard cap them):
04The game-object model
How you store entities is a tradeoff, not a religion. Three honest points on the spectrum:
- Plain OOP entities (a struct with all the fields, in a vector): simplest, fine for a few hundred entities.
- Components on an entity (composition without a full ECS): Nystrom's Component pattern[3].
- A small archetype / struct-of-arrays ECS: the cache-locality and composition win at scale, with the machinery cost[7].
ECS earns its keep on cache locality (sequential scans over typed arrays) and composition at scale, it's how Overwatch and Unity DOTS are built[8][7]. But for a few-hundred-entity arena game a component-struct or a thin struct-of-arrays model is the sweet spot, and Nystrom's own Component chapter cautions against engineering for a problem you don't have[3]. Scope it: ECS "at scale," not "always." The ECS tutorial builds the deep version if you need it.
05World → render
The world is the authoritative state. Each frame a build-the-batch pass walks it and emits a , a list of quads (position, atlas UV, tint, layer), which the sprite batcher turns into a few draw calls.
This separation is the load-bearing architectural rule, and it buys more than one thing: a headless sim you can test, the interpolation (render reads a snapshot), and the serializable sim the future netcode module needs. The sim produces a renderable description; the renderer consumes it. And the description is built from the interpolated snapshot, not the raw tick state.
struct Transform { float x, y, rot; };
struct Sprite { uint16_t atlasRect; uint32_t tint; uint8_t layer; };
// Read-only. Interpolated positions in; quads out. Never mutates the world.
void buildBatch(const World& world, float alpha, SpriteBatch& out) {
for (EntityId e : world.withSprites()) {
const Transform& prev = world.previous(e);
const Transform& cur = world.current(e);
float x = lerp(prev.x, cur.x, alpha); // blend the last two sim states
float y = lerp(prev.y, cur.y, alpha);
const Sprite& s = world.sprite(e);
out.quad(x, y, cur.rot, s.atlasRect, s.tint, s.layer);
}
}
06Input & audio
Input is read inside simulate(dt), resolved through the action map ("move", "fire", not raw scancodes). When a game event fires, the sim pushes a small command into the SPSC ring; the audio thread drains it in its callback.
One producer (the sim), one consumer (the audio thread): exactly the single-producer/single-consumer contract, so no CAS-heavy MPMC machinery is justified, don't over-build it. The callback can't take a lock (priority inversion past a hard deadline), which is why it's a lock-free ring. Reference the audio system through an interface with a null-audio fallback (Nystrom's Service Locator) so a headless build swaps in a no-op[5].
if (input.action(Action::Fire) && ship.cooldown <= 0) {
spawnBullet(world, ship);
audioRing.tryPush({ SoundId::Shot, 1.0f, panFor(ship.x) }); // SPSC: sim is the sole producer
ship.cooldown = FIRE_INTERVAL;
}
if input.action(Action::Fire) && ship.cooldown <= 0.0 {
spawn_bullet(world, ship);
let _ = audio_ring.push(AudioCommand { sound: SoundId::Shot, vol: 1.0, pan: pan_for(ship.x) });
ship.cooldown = FIRE_INTERVAL; // drop-on-full; never block the audio thread
}
07Physics in one accumulator
Collision is symmetric for an arena: AABB and circle shapes, a broadphase (a uniform grid or sweep-and-prune), and overlap resolution (push apart, reflect velocity). Bullet-vs-enemy is a broadphase query then a circle test.
The Physics tutorial runs its own accumulator and fixed step in isolation. In the capstone, physics is stepped inside the game loop's fixed simulate(dt), so there is one accumulator, not two nested ones. Nest them and you double-step the simulation (everything runs at 2× speed, or stutters). The loop owns the timestep; physics is just one of the things simulate does each step.
08Assets & the state machine
At startup the engine mounts the cooked package (assets.pak) and loads the atlas, sounds, and level. For an arena game, load-all-up-front is honest; streaming is what you'd reach for at open-world scale. The game runs as an explicit state machine, menu / playing / paused / game-over[4].
Paused stops stepping the sim but still renders the last frame; transitions fire on input or game events. The dataflow widget traces how the pieces connect, and what is not allowed to connect:
09Play it
The arena shooter, running the whole loop in your browser: a fixed-timestep simulation, an input snapshot, circle-overlap collision, "audio" events on shots and hits, and an interpolated render. It's the toy version of everything above.
10The frame budget
At 60 fps you have about 16.6 ms, split across poll-input, the fixed sim step(s), building the batch, recording the render, and present. Two things to keep straight: the sim cost is per step and can run several times in a heavy frame (the spiral guard caps it), while build-batch, render, and present happen once. Vsync paces present, and the accumulator absorbs the jitter. These are a typical small-game split, not a datasheet figure, profile your own.
Wrong answers, and why: the sim stays fixed-rate (smoothness is interpolation, not a faster sim, and not overshoot-prone extrapolation); and physics belongs in the one fixed-step update, not the variable render pass or the real-time audio thread.
11Pitfalls
12What's next
That's the 2D milestone: a complete, playable game built on subsystems you wrote from the cache up. The series now turns to 3D, starting with the jump from this sprite renderer to a perspective camera, a depth buffer, and real meshes, then materials, lighting, shadows, animation, and a 3D capstone. The full path is on the series hub.
- Glenn Fiedler. "Fix Your Timestep!" gafferongames.com. The accumulator loop, the 0.25 s clamp, the interpolation alpha, and the spiral of death.
- Robert Nystrom. "Game Loop," Game Programming Patterns. gameprogrammingpatterns.com. The fixed-update / variable-render split and the step-cap guard.
- Robert Nystrom. "Component," Game Programming Patterns. gameprogrammingpatterns.com. Composition over monolithic entities, and the caution against over-engineering.
- Robert Nystrom. "State," Game Programming Patterns. gameprogrammingpatterns.com. The finite state machine for menu / playing / paused / game-over.
- Robert Nystrom. "Service Locator," Game Programming Patterns. gameprogrammingpatterns.com. Referencing the audio system through an interface with a null fallback.
- Jason Gregory. Game Engine Architecture. gameenginebook.com. Subsystem start-up / shut-down ordering and the layered runtime architecture.
- Sander Mertens. ECS FAQ. github.com/SanderMertens/ecs-faq. ECS as separating data from behavior, stored cache-friendly; the AoS-vs-SoA tradeoff.
- Timothy Ford. "Overwatch Gameplay Architecture and Netcode." GDC 2017. gdcvault.com. A shipped AAA ECS with deterministic fixed command frames.
- Unity Technologies. Rigidbody interpolation and FixedUpdate docs. docs.unity3d.com. FixedUpdate runs 0/1/N times per frame; interpolation blends the previous two physics updates.
- Valve. Source Multiplayer Networking. developer.valvesoftware.com. A fixed server tick (about 15 ms) decoupled from the render frame rate.