Capstone: A Complete 2D Game
You have a game loop, a sprite renderer, input, audio and an asset package, each working in its own demo. Wiring them into one game raises questions none of them answered alone: what order to start and stop them in, who owns time, and which parts may call which. This page answers them with a wiring diagram and a 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 (named states like menu and playing, with defined transitions) runs the menu and the waves. It's assembled in C++ and Rust, and you can play the result in the browser.
- Write a
main()that starts every subsystem in dependency order and shuts them down safely in reverse. - Run a fixed-timestep simulation with an interpolated render, and guard it against the spiral of death.
- Choose a game-object model that fits the game's scale.
- Keep the simulation, renderer and audio thread separated so each can be tested and changed on its own.
- Pass sounds to a real-time audio thread through a lock-free ring, with the memory ordering that makes it safe, and drop a sound instead of waiting when the ring is full.
- Cut collision tests with a uniform-grid broadphase, and pick its cell size.
- Step physics inside the one game loop, drive menus and waves with a state machine, and run the whole game headless from one file.
01What done looks like
Each subsystem so far has its own tutorial. 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. An arena shooter suits this better than a platformer: 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.
Worked example
A busy moment in the arena: the ship, a wave of 20 enemies and 30 bullets in flight is 51 sprites. With every sprite in one atlas, that's one draw call of 51 quads. Firing ten times a second pushes ten sound commands a second into the audio ring, plus one per hit. The simulation steps 60 times a second whatever the display does, and the renderer draws once per display refresh.
02The shape of main()
Quit the game and it crashes on the way out, because the audio thread made one last read from a ring that had already been freed. 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 → renderer (GPU device, swapchain, sprite batcher) → assets (mount the package) → audio (ring, device, thread) → world → state machine. Shutdown reverses it, and the order is load-bearing: the audio thread must stop before the ring it reads is freed, and every GPU object must go before the device[1].
int main() {
Platform platform; // window + OS event pump
InputState input;
Renderer renderer(platform); // Vulkan device, swapchain, sprite batcher
Assets assets("assets.pak"); // mount the cooked package
SpscRing<AudioCommand, 1024> audioRing; // declared before its consumer, so it outlives it
AudioEngine audio(audioRing); // opens the device, spawns the audio thread (the one consumer)
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
world.savePreviousTransforms(); // the world keeps last tick's transforms for interpolation
simulate(world, input, audioRing, dt, state);
accumulator -= dt;
}
double alpha = accumulator / dt; // 0..1 leftover
buildBatch(world, (float)alpha, renderer.batch()); // interpolated, read-only (§5)
renderer.render();
}
// Scope exit destroys in reverse declaration order: world, then audio (joins its
// thread while audioRing still exists), audioRing, assets, renderer (GPU objects
// before the device), platform.
}
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 assets = Assets::mount("assets.pak");
let (mut audio_ring, audio) = AudioEngine::spawn(1024); // producer end + engine; the audio thread is the one consumer
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
world.save_previous_transforms(); // last tick's transforms, for interpolation
simulate(&mut world, &input, &mut audio_ring, dt, &mut state);
accumulator -= dt;
}
let alpha = accumulator / dt; // 0..1
build_batch(&world, alpha as f32, renderer.batch_mut()); // interpolated, read-only (§5)
renderer.render();
}
// Drop runs in reverse declaration order: world, then audio (joins its thread)
// before audio_ring, assets, renderer (GPU objects before the device), platform.
}
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 display refreshes at 144 Hz on one machine and 60 Hz on another, and the game has to play the same on both. Every frame runs in the same order: poll input into a snapshot, step the fixed-timestep simulation zero, one, or several times to consume the elapsed time, then render once from an interpolated snapshot[2][3]. Shipped engines decouple the same way; Source's multiplayer servers, for one, simulate in fixed 15 ms ticks by default, whatever the clients' frame rate[4].
- 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).
The render blends the last two sim states by how far the leftover time has got toward the next step:
In words: the accumulator holds real time that hasn't been simulated yet. Divide it by the step size to get how far you are between the last tick and the next one, and draw everything that fraction of the way from its previous position to its current one. Hover any term to see what it stands for.
Worked example
On a 144 Hz display each frame adds 6.94 ms to the accumulator and each sim step uses 16.67 ms. Frame 1: 6.94 ms banked, no step, alpha 0.42. Frame 2: 13.89 ms, no step, alpha 0.83. Frame 3: 20.83 ms, so one step runs and 4.17 ms is left, alpha 0.25. If the ship moved from x = 100 to x = 104 in that step, frame 3 draws it at 100 + 4 × 0.25 = 101, and the next frame, at alpha 0.67, draws it at 102.7. The ship glides at 144 Hz while the sim ticks at 60.
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 up to one step of latency. Unity's rigidbody interpolation makes the same trade: it blends "the previous two physics updates" and so trails by one[5]. Use interpolation (blend two already-computed states, can't overshoot), not extrapolation (predict ahead, overshoots on a bounce and snaps back)[2]. And the render pass must be read-only: advancing an animation timer or moving anything during draw ties the simulation to the frame rate, which breaks determinism and the interpolation both. The 0.25 clamp is Fiedler's example value, not a universal constant.
The widget plays one frame every half second, drawn to scale. Each frame's real duration is a fixed 6 ms of input and rendering plus the cost of every sim step it ran, and that duration is the time the next frame has to catch up. Inject a lag spike, or make a sim step cost more than the 16.67 ms it simulates, to meet the spiral of death:
04The game-object model
Every enemy, bullet and pickup has to live somewhere in memory, and the layout you pick decides how fast you can update them and how hard it is to add a new kind. How you store entities is a tradeoff. Three points on the spectrum:
- Plain entity structs (one struct with every field, in a vector): simplest, fine for a few hundred entities.
- Components on an entity (composition without a full ECS): Nystrom's Component pattern[6].
- 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; Overwatch's gameplay code and Unity's DOTS are built on it[8][7]. But for a few-hundred-entity arena game a component-struct or a thin struct-of-arrays model is enough, and Nystrom's own Component chapter warns against "over-engineering a 'solution' to a non-existent problem"[6]. The ECS tutorial builds the deep version if you need it.
Worked example
300 entities stored as 64-byte structs take 19,200 bytes, small enough to sit in a typical CPU's L2 cache, so even a loop that touches every field of every entity stays fast. Stored struct-of-arrays, a pass that only moves things reads just the 8-byte positions: 2,400 bytes. The gap only starts to matter at tens of thousands of entities.
05World → render
If the code that moves the ship also issues Vulkan calls, you can't test the game without a GPU, and you can't interpolate what's drawn. 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.
Worked example
For the 51 sprites from §1, the pass walks 51 entities, blends each one's previous and current position by alpha, and emits 51 quads: 204 vertices of 20 bytes, 4,080 bytes handed to the batcher. Nothing in the world changes. Run the same pass twice in a row and you get the same 4,080 bytes, which is the property that makes the render safe to skip, repeat or move to another thread.
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 entity : world.withSprites()) {
const Transform& prev = world.previous(entity); // saved by savePreviousTransforms()
const Transform& cur = world.current(entity);
float x = std::lerp(prev.x, cur.x, alpha); // blend the last two sim states
float y = std::lerp(prev.y, cur.y, alpha);
// rotation snaps to the current tick here; interpolate it too (shortest arc) if things spin fast
const Sprite& sprite = world.sprite(entity);
out.quad(x, y, cur.rot, sprite.atlasRect, sprite.tint, sprite.layer);
}
}
06Input & audio
The player presses fire, a bullet spawns, and the shot sound has to start on a different thread without the simulation ever waiting on it. 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. The callback mustn'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[9].
Worked example
With a fire interval of 0.1 s the ship fires 10 shots a second, so the sim pushes about 10 commands a second plus one per hit. The 1,024-slot ring in main() holds 51 seconds of that even if every shot hits (20 commands a second), and the audio callback drains it every few milliseconds. The only realistic way to fill it is an audio thread that has stopped draining, which is why tryPush drops the sound instead of waiting.
if (input.action(Action::Fire) && ship.cooldown <= 0) {
spawnBullet(world, ship);
audioRing.tryPush({ SoundId::Shot, 1.0f, panFor(ship.x) }); // full ring: drop the sound, don't stall the sim
ship.cooldown = FIRE_INTERVAL;
}
if input.action(Action::Fire) && ship.cooldown <= 0.0 {
spawn_bullet(world, ship);
let _ = audio_ring.try_push(AudioCommand { sound: SoundId::Shot, volume: 1.0, pan: pan_for(ship.x) }); // full ring: drop the sound, don't stall the sim
ship.cooldown = FIRE_INTERVAL;
}
Why the ring needs no lock, and which orderings it does need
The audio callback runs every few milliseconds on a high-priority thread, and if it misses its deadline the speaker plays a click or a gap. Bencina's rules for that callback: no locks, no memory allocation, no file or network I/O, and nothing else whose worst case is unbounded[10]. A lock is the classic trap. If the game thread holds it when the callback fires, the callback waits for the game thread, and the game thread, running at normal priority, can itself be preempted by anything else on the machine. The ring sidesteps all of it: one array allocated before the audio thread starts, and two counters, each written by exactly one thread.
The counters are a read index and a write index that only ever count up; the slot for index i is i % capacity. The ring is empty when they're equal and full when write - read equals the capacity. What makes this safe across threads is the order in which writes become visible. The compiler may reorder ordinary memory writes, and so may the CPU on weakly ordered chips such as ARM, so without help the audio thread could see the new write index before it sees the sound stored in the slot. Two orderings on the atomic counters prevent that. The producer stores the write index with release, which keeps every write before it (the slot) from moving after it. The consumer loads the write index with acquire, which keeps every read after it (the slot) from moving before it. Once the acquire load sees the released value, the slot's contents are guaranteed to be visible too[11]. The same pair runs the other way on the read index, so the producer never reuses a slot the consumer is still reading. Each side reads its own counter with relaxed, the weakest ordering, because no other thread writes it.
Rigtorp's open-source SPSC queue uses exactly these orderings. It also puts the two counters on separate cache lines, so the threads don't keep stealing one line from each other (false sharing)[12]; that's what the alignas(64) in the complete program does.
Worked example
A 16-slot ring. The sim has pushed 21 sounds (write index 21) and the audio thread has played 18 (read index 18), so 21 − 18 = 3 sounds wait, in slots 18 % 16 = 2, 3 and 4. The next push goes to slot 21 % 16 = 5. The ring is full when the write index reaches 18 + 16 = 34: pushes 21 through 33 succeed, and if the audio thread hasn't read again by then, the next push returns false, because it would land in slot 34 % 16 = 2, which still holds a sound waiting to play. On a 64-bit target the counters never wrap in practice: at a million pushes a second, 264 takes about 585,000 years.
The widget runs a 16-slot ring 10 times slower than real time. The sim pushes its sounds once per 16.67 ms step. The audio callback drains everything waiting once per buffer: every 5.3 ms for a 256-frame buffer at 48 kHz, every 21.3 ms for 1,024 frames.
07Physics in one accumulator
Bullets have to hit enemies and the ship has to bounce off walls, every sim step, at the same rate as everything else. 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.
Worked example
A bullet of radius 2 at (10, 10) and an enemy of radius 8 at (18, 14) collide if the distance between centers is less than 2 + 8 = 10. Compare squares to skip the square root: (18 − 10)² + (14 − 10)² = 64 + 16 = 80, which is less than 10² = 100, so it's a hit. Now the double-counting trap from the callout below: a slow 33.3 ms frame runs simulate twice. If each call also hands physics the 33.3 ms frame time for its own accumulator, physics banks 66.7 ms and runs 4 steps while the rest of the game ran 2, so physics moves at double speed in slow frames.
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 time gets counted twice: if simulate hands the physics module the frame's elapsed time and physics banks it in its own accumulator, every catch-up step re-adds that time and physics runs ahead of the rest of the game; hand it dt instead and the inner accumulator is dead weight. The loop owns the timestep; physics is just one of the things simulate does each step.
A uniform grid for the broadphase
Twenty enemies and fifteen bullets is 300 circle tests a step, which costs nothing. Four hundred enemies and a hundred bullets is 40,000 a step, 2.4 million a second at 60 Hz, and almost all of them compare things on opposite sides of the screen. A uniform grid skips those. Each step, drop every enemy into the square cell its center falls in; then each bullet tests only the enemies in its own cell and the eight around it. That's correct as long as a cell is at least as wide as the largest distance at which two things can touch, here 13 px (enemy radius 10 plus bullet radius 3): anything touching the bullet is then at most one cell away[13].
Worked example
64-pixel cells over the 800 × 600 arena make a 13 × 10 grid. A bullet at (300, 200) sits in cell (4, 3), since 300 ÷ 64 = 4.7 and 200 ÷ 64 = 3.1, rounded down. It checks cells 3 to 5 across and 2 to 4 down: nine cells covering 192 × 192 px, about 7.7% of the arena's 480,000 px². With 400 enemies spread evenly that's about 31 candidates instead of 400. Bucketing costs one insert per enemy per step, so with a single bullet the grid can lose to brute force; with dozens of bullets it wins by a wide margin.
The cell size is the one knob. Too small, and a touching pair can sit two cells apart, outside the 3 × 3 search, so hits go missing unless you widen the search, which visits more cells per bullet. Too large, and each 3 × 3 block covers a big share of the arena, so the grid saves little over brute force. Crowding hurts at any cell size: if every enemy piles into one cell, every bullet near them tests all of them[13]. Sweep-and-prune and AABB trees adapt better to clumps and mixed sizes; the Physics tutorial covers both. Over the whole scripted run in §9, the grid does 2,060 bullet-enemy tests where brute force would do 38,499.
08Assets & the state machine
The game needs a title screen, a pause button and a game-over screen, and each one changes what input does and whether the world moves. 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 the simple, correct choice; streaming is what you'd reach for at open-world scale. The game runs as an explicit state machine, menu / playing / paused / game-over[14].
Paused stops stepping the sim but still renders the last frame; transitions fire on input or game events. A typical set for this game: menu goes to playing on Enter, playing to paused on Esc and back, playing to game-over when the ship is destroyed, and game-over back to menu on Enter. In the paused state the loop still runs and draws, but simulate takes zero steps, so nothing moves. The dataflow widget traces how the pieces connect, and what is not allowed to connect:
09The whole game, headless
The listings so far are fragments that lean on types they never define. This one compiles and runs. It's the arena game's entire simulation in one file per language: the fixed-step loop and its clamp, an input snapshot, the state machine (menu, playing, wave clear, then won or game over), auto-aim, the grid broadphase, an SPSC ring to a real second thread that stands in for audio, and the read-only pass that builds the render description. The window and GPU are left out, and a scripted clock replaces the real one: a 144 Hz display with one 400 ms hitch, and a "player" who circles the arena holding fire. That makes every run print the same thing, so you can change the code and diff the output.
// The arena shooter's simulation, complete and headless: one fixed-step loop,
// a scripted input snapshot, a uniform-grid broadphase, the game-state machine,
// a lock-free ring to a real audio thread, and a read-only pass that builds the
// render description. The window and GPU are the platform and renderer's job.
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <numbers>
#include <thread>
#include <vector>
constexpr double kDt = 1.0 / 60.0, kArenaW = 800, kArenaH = 600, kCell = 64;
constexpr double kFireInterval = 0.1, kBulletSpeed = 480, kEnemySpeed = 100;
constexpr int kGridW = 13, kGridH = 10; // 800 / 64 and 600 / 64, rounded up
// One producer (the sim), one consumer (the audio thread). Each side loads its own
// index relaxed, the other side's index acquire, and publishes its own with release.
template <class T, std::size_t Capacity>
class SpscRing {
std::array<T, Capacity> slots{};
alignas(64) std::atomic<std::size_t> readIndex{0}; // advanced only by the consumer
alignas(64) std::atomic<std::size_t> writeIndex{0}; // advanced only by the producer
public:
bool tryPush(const T& value) {
std::size_t write = writeIndex.load(std::memory_order_relaxed);
if (write - readIndex.load(std::memory_order_acquire) == Capacity) return false; // full: drop it
slots[write % Capacity] = value;
writeIndex.store(write + 1, std::memory_order_release); // the slot is now visible to the consumer
return true;
}
bool tryPop(T& value) {
std::size_t read = readIndex.load(std::memory_order_relaxed);
if (read == writeIndex.load(std::memory_order_acquire)) return false; // empty
value = slots[read % Capacity];
readIndex.store(read + 1, std::memory_order_release); // the slot can be reused
return true;
}
};
enum class Sound : std::uint8_t { Shot, Hit, Wave, Count };
enum class State { Menu, Playing, WaveClear, Won, GameOver };
const char* kStateNames[] = {"Menu", "Playing", "WaveClear", "Won", "GameOver"};
struct Body { double x, y, previousX, previousY, vx, vy; };
struct Enemy { Body body; bool alive; };
struct Bullet { Body body; double life; };
struct World {
Body ship{400, 300, 400, 300, 0, 0};
double health = 100, cooldown = 0, stateTime = 0, time = 0;
State state = State::Menu;
int wave = 0, kills = 0, shots = 0;
std::vector<Enemy> enemies;
std::vector<Bullet> bullets;
long long gridTests = 0, bruteForceTests = 0, soundsDropped = 0;
};
struct Input { double moveX, moveY; bool fire, start; }; // one snapshot per frame
struct Quad { float x, y; std::uint16_t sprite; }; // the render description
void setState(World& world, State next) {
std::printf("%6.2f s %s -> %s\n", world.time, kStateNames[int(world.state)], kStateNames[int(next)]);
world.state = next;
world.stateTime = 0;
}
void spawnWave(World& world, SpscRing<Sound, 1024>& audio) {
++world.wave;
int count = 2 + 6 * world.wave; // 8, 14, 20
for (int i = 0; i < count; ++i) { // just outside the arena, spread around it
double angle = 2 * std::numbers::pi * i / count + 0.3 * world.wave;
double x = 400 + 420 * std::cos(angle), y = 300 + 320 * std::sin(angle);
world.enemies.push_back({{x, y, x, y, 0, 0}, true});
}
if (!audio.tryPush(Sound::Wave)) ++world.soundsDropped;
}
void simulate(World& world, const Input& input, SpscRing<Sound, 1024>& audio) {
world.time += kDt;
world.stateTime += kDt;
// Keep this step's starting positions: the renderer blends from them to the new ones.
auto remember = [](Body& body) { body.previousX = body.x; body.previousY = body.y; };
remember(world.ship);
for (Enemy& enemy : world.enemies) remember(enemy.body);
for (Bullet& bullet : world.bullets) remember(bullet.body);
switch (world.state) { // the game-state machine gates everything below
case State::Menu:
if (input.start) { setState(world, State::Playing); spawnWave(world, audio); }
return;
case State::WaveClear:
if (world.stateTime >= 1.0) { setState(world, State::Playing); spawnWave(world, audio); }
break;
case State::Won: case State::GameOver: return; // the world stops; the renderer keeps drawing it
case State::Playing: break;
}
// The ship moves from the input snapshot and auto-aims at the nearest enemy.
world.ship.x = std::clamp(world.ship.x + input.moveX * 220 * kDt, 12.0, kArenaW - 12);
world.ship.y = std::clamp(world.ship.y + input.moveY * 220 * kDt, 12.0, kArenaH - 12);
world.cooldown -= kDt;
const Enemy* nearest = nullptr;
double nearestDistance2 = 1e18;
for (const Enemy& enemy : world.enemies) {
double dx = enemy.body.x - world.ship.x, dy = enemy.body.y - world.ship.y;
if (dx * dx + dy * dy < nearestDistance2) { nearestDistance2 = dx * dx + dy * dy; nearest = &enemy; }
}
if (input.fire && nearest && world.cooldown <= 0) {
double dx = nearest->body.x - world.ship.x, dy = nearest->body.y - world.ship.y;
double length = std::sqrt(nearestDistance2);
world.bullets.push_back({{world.ship.x, world.ship.y, world.ship.x, world.ship.y,
dx / length * kBulletSpeed, dy / length * kBulletSpeed}, 1.5});
world.cooldown = kFireInterval;
++world.shots;
if (!audio.tryPush(Sound::Shot)) ++world.soundsDropped; // never wait on the audio thread
}
for (Enemy& enemy : world.enemies) { // enemies close in; touching the ship hurts
double dx = world.ship.x - enemy.body.x, dy = world.ship.y - enemy.body.y;
double length = std::sqrt(dx * dx + dy * dy);
if (length > 1e-9) {
enemy.body.x += dx / length * kEnemySpeed * kDt;
enemy.body.y += dy / length * kEnemySpeed * kDt;
}
if (length < 22) world.health -= 20 * kDt;
}
for (Bullet& bullet : world.bullets) {
bullet.body.x += bullet.body.vx * kDt;
bullet.body.y += bullet.body.vy * kDt;
bullet.life -= kDt;
}
// Broadphase: bucket enemies into 64-pixel cells; each bullet tests only the 3x3 cells around it.
std::array<std::vector<int>, kGridW * kGridH> cells;
auto cellOf = [](double value, int size) { return std::clamp(int(std::floor(value / kCell)), 0, size - 1); };
for (int i = 0; i < int(world.enemies.size()); ++i) {
const Body& body = world.enemies[i].body;
cells[cellOf(body.y, kGridH) * kGridW + cellOf(body.x, kGridW)].push_back(i);
}
for (Bullet& bullet : world.bullets) {
if (bullet.life <= 0) continue; // expired this step: it can't hit anything
world.bruteForceTests += long(world.enemies.size());
int cellX = cellOf(bullet.body.x, kGridW), cellY = cellOf(bullet.body.y, kGridH);
for (int y = std::max(0, cellY - 1); y <= std::min(kGridH - 1, cellY + 1) && bullet.life > 0; ++y)
for (int x = std::max(0, cellX - 1); x <= std::min(kGridW - 1, cellX + 1) && bullet.life > 0; ++x)
for (int index : cells[y * kGridW + x]) {
Enemy& enemy = world.enemies[index];
if (!enemy.alive) continue;
++world.gridTests;
double dx = enemy.body.x - bullet.body.x, dy = enemy.body.y - bullet.body.y;
if (dx * dx + dy * dy < 13.0 * 13.0) { // radii 10 + 3, compared squared
enemy.alive = false; bullet.life = 0; ++world.kills;
if (!audio.tryPush(Sound::Hit)) ++world.soundsDropped;
break;
}
}
}
// Remove the dead at the end of the step, never mid-iteration.
std::erase_if(world.enemies, [](const Enemy& enemy) { return !enemy.alive; });
std::erase_if(world.bullets, [](const Bullet& bullet) { return bullet.life <= 0; });
if (world.health <= 0) setState(world, State::GameOver);
else if (world.state == State::Playing && world.enemies.empty())
setState(world, world.wave == 3 ? State::Won : State::WaveClear);
}
// Read-only: interpolate between the last two steps and emit quads. Changes nothing in the world.
void buildRender(const World& world, double alpha, std::vector<Quad>& quads) {
quads.clear();
auto emit = [&](const Body& body, std::uint16_t sprite) {
quads.push_back({float(body.previousX + (body.x - body.previousX) * alpha),
float(body.previousY + (body.y - body.previousY) * alpha), sprite});
};
emit(world.ship, 0);
for (const Enemy& enemy : world.enemies) emit(enemy.body, 1);
for (const Bullet& bullet : world.bullets) emit(bullet.body, 2);
}
int main() {
SpscRing<Sound, 1024> audioRing; // declared before its consumer, so it outlives it
std::atomic<bool> audioRunning{true};
std::array<long, int(Sound::Count)> heard{};
std::thread audioThread([&] { // stands in for the audio callback
Sound sound;
while (audioRunning.load(std::memory_order_acquire)) {
while (audioRing.tryPop(sound)) ++heard[int(sound)]; // a mixer would start a voice here
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
while (audioRing.tryPop(sound)) ++heard[int(sound)]; // drain what's left
});
World world;
std::vector<Quad> quads;
double accumulator = 0;
long frames = 0, steps = 0, mostSteps = 0;
std::size_t mostQuads = 0;
while (world.state != State::Won && world.state != State::GameOver && world.time < 120) {
// A scripted 144 Hz display with one 400 ms hitch, so the run is the same every time.
double frameTime = frames == 900 ? 0.4 : 1.0 / 144.0;
double angle = world.time * 0.8; // the "player" circles the arena and holds fire
Input input{-std::sin(angle), std::cos(angle), true, world.time >= 0.5};
accumulator += std::min(frameTime, 0.25); // spiral-of-death clamp: the hitch counts as 0.25 s
long stepsThisFrame = 0;
while (accumulator >= kDt) { simulate(world, input, audioRing); accumulator -= kDt; ++stepsThisFrame; }
buildRender(world, accumulator / kDt, quads);
++frames; steps += stepsThisFrame;
mostSteps = std::max(mostSteps, stepsThisFrame);
mostQuads = std::max(mostQuads, quads.size());
}
audioRunning.store(false, std::memory_order_release);
audioThread.join(); // stop the consumer before the ring goes away
std::printf("%ld frames, %ld steps, at most %ld steps in one frame, at most %zu quads\n",
frames, steps, mostSteps, mostQuads);
std::printf("%d shots, %d kills, health %.1f\n", world.shots, world.kills, world.health);
std::printf("bullet-enemy tests: %lld with the grid, %lld brute force\n", world.gridTests, world.bruteForceTests);
std::printf("sounds heard: %ld shot, %ld hit, %ld wave; dropped %lld\n",
heard[0], heard[1], heard[2], world.soundsDropped);
}
// The arena shooter's simulation, complete and headless: one fixed-step loop,
// a scripted input snapshot, a uniform-grid broadphase, the game-state machine,
// a lock-free ring to a real audio thread, and a read-only pass that builds the
// render description. The window and GPU are the platform and renderer's job.
use std::cell::UnsafeCell;
use std::f64::consts::PI;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
const DT: f64 = 1.0 / 60.0;
const ARENA_W: f64 = 800.0;
const ARENA_H: f64 = 600.0;
const CELL: f64 = 64.0;
const FIRE_INTERVAL: f64 = 0.1;
const BULLET_SPEED: f64 = 480.0;
const ENEMY_SPEED: f64 = 100.0;
const GRID_W: usize = 13; // 800 / 64 and 600 / 64, rounded up
const GRID_H: usize = 10;
// One producer (the sim), one consumer (the audio thread). Each side loads its own
// index relaxed, the other side's index acquire, and publishes its own with release.
#[repr(align(64))]
struct PaddedIndex(AtomicUsize); // its own cache line, so the two sides don't share one
struct SpscRing<T, const CAPACITY: usize> {
slots: [UnsafeCell<T>; CAPACITY],
read_index: PaddedIndex, // advanced only by the consumer
write_index: PaddedIndex, // advanced only by the producer
}
// Safe to share because only the producer writes a slot before publishing it,
// and only the consumer reads it after the acquire that observes that publish.
unsafe impl<T: Send, const CAPACITY: usize> Sync for SpscRing<T, CAPACITY> {}
// The ring is used only through these two handles. Neither can be cloned, so there is
// exactly one producer and one consumer, which is what makes the plain loads and stores safe.
struct Producer<T, const CAPACITY: usize>(Arc<SpscRing<T, CAPACITY>>);
struct Consumer<T, const CAPACITY: usize>(Arc<SpscRing<T, CAPACITY>>);
fn spsc_ring<T: Copy + Default, const CAPACITY: usize>() -> (Producer<T, CAPACITY>, Consumer<T, CAPACITY>) {
let ring = Arc::new(SpscRing {
slots: std::array::from_fn(|_| UnsafeCell::new(T::default())),
read_index: PaddedIndex(AtomicUsize::new(0)),
write_index: PaddedIndex(AtomicUsize::new(0)),
});
(Producer(Arc::clone(&ring)), Consumer(ring))
}
impl<T: Copy, const CAPACITY: usize> Producer<T, CAPACITY> {
fn try_push(&mut self, value: T) -> bool {
let ring = &*self.0;
let write = ring.write_index.0.load(Ordering::Relaxed);
if write - ring.read_index.0.load(Ordering::Acquire) == CAPACITY { return false; } // full: drop it
unsafe { *ring.slots[write % CAPACITY].get() = value; }
ring.write_index.0.store(write + 1, Ordering::Release); // the slot is now visible to the consumer
true
}
}
impl<T: Copy, const CAPACITY: usize> Consumer<T, CAPACITY> {
fn try_pop(&mut self) -> Option<T> {
let ring = &*self.0;
let read = ring.read_index.0.load(Ordering::Relaxed);
if read == ring.write_index.0.load(Ordering::Acquire) { return None; } // empty
let value = unsafe { *ring.slots[read % CAPACITY].get() };
ring.read_index.0.store(read + 1, Ordering::Release); // the slot can be reused
Some(value)
}
}
#[derive(Clone, Copy, Default)]
enum Sound { #[default] Shot, Hit, Wave }
#[derive(Clone, Copy, PartialEq, Debug)]
enum State { Menu, Playing, WaveClear, Won, GameOver }
#[derive(Clone, Copy)]
struct Body { x: f64, y: f64, previous_x: f64, previous_y: f64, vx: f64, vy: f64 }
impl Body { fn at(x: f64, y: f64) -> Body { Body { x, y, previous_x: x, previous_y: y, vx: 0.0, vy: 0.0 } } }
struct Enemy { body: Body, alive: bool }
struct Bullet { body: Body, life: f64 }
struct World {
ship: Body, health: f64, cooldown: f64, state_time: f64, time: f64,
state: State, wave: i32, kills: i32, shots: i32,
enemies: Vec<Enemy>, bullets: Vec<Bullet>,
grid_tests: u64, brute_force_tests: u64, sounds_dropped: u64,
}
struct Input { move_x: f64, move_y: f64, fire: bool, start: bool } // one snapshot per frame
#[allow(dead_code)] // nothing draws the quads in this headless build
struct Quad { x: f32, y: f32, sprite: u16 } // the render description
type AudioProducer = Producer<Sound, 1024>;
fn set_state(world: &mut World, next: State) {
println!("{:6.2} s {:?} -> {:?}", world.time, world.state, next);
world.state = next;
world.state_time = 0.0;
}
fn spawn_wave(world: &mut World, audio: &mut AudioProducer) {
world.wave += 1;
let count = 2 + 6 * world.wave; // 8, 14, 20
for i in 0..count { // just outside the arena, spread around it
let angle = 2.0 * PI * i as f64 / count as f64 + 0.3 * world.wave as f64;
let body = Body::at(400.0 + 420.0 * angle.cos(), 300.0 + 320.0 * angle.sin());
world.enemies.push(Enemy { body, alive: true });
}
if !audio.try_push(Sound::Wave) { world.sounds_dropped += 1; }
}
fn simulate(world: &mut World, input: &Input, audio: &mut AudioProducer) {
world.time += DT;
world.state_time += DT;
// Keep this step's starting positions: the renderer blends from them to the new ones.
let remember = |body: &mut Body| { body.previous_x = body.x; body.previous_y = body.y; };
remember(&mut world.ship);
world.enemies.iter_mut().for_each(|enemy| remember(&mut enemy.body));
world.bullets.iter_mut().for_each(|bullet| remember(&mut bullet.body));
match world.state { // the game-state machine gates everything below
State::Menu => {
if input.start { set_state(world, State::Playing); spawn_wave(world, audio); }
return;
}
State::WaveClear => {
if world.state_time >= 1.0 { set_state(world, State::Playing); spawn_wave(world, audio); }
}
State::Won | State::GameOver => return, // the world stops; the renderer keeps drawing it
State::Playing => {}
}
// The ship moves from the input snapshot and auto-aims at the nearest enemy.
world.ship.x = (world.ship.x + input.move_x * 220.0 * DT).clamp(12.0, ARENA_W - 12.0);
world.ship.y = (world.ship.y + input.move_y * 220.0 * DT).clamp(12.0, ARENA_H - 12.0);
world.cooldown -= DT;
let mut nearest: Option<usize> = None;
let mut nearest_distance2 = 1e18;
for (index, enemy) in world.enemies.iter().enumerate() {
let (dx, dy) = (enemy.body.x - world.ship.x, enemy.body.y - world.ship.y);
if dx * dx + dy * dy < nearest_distance2 { nearest_distance2 = dx * dx + dy * dy; nearest = Some(index); }
}
if let (true, Some(index), true) = (input.fire, nearest, world.cooldown <= 0.0) {
let target = world.enemies[index].body;
let (dx, dy) = (target.x - world.ship.x, target.y - world.ship.y);
let length = nearest_distance2.sqrt();
let mut body = Body::at(world.ship.x, world.ship.y);
body.vx = dx / length * BULLET_SPEED;
body.vy = dy / length * BULLET_SPEED;
world.bullets.push(Bullet { body, life: 1.5 });
world.cooldown = FIRE_INTERVAL;
world.shots += 1;
if !audio.try_push(Sound::Shot) { world.sounds_dropped += 1; } // never wait on the audio thread
}
for enemy in &mut world.enemies { // enemies close in; touching the ship hurts
let (dx, dy) = (world.ship.x - enemy.body.x, world.ship.y - enemy.body.y);
let length = (dx * dx + dy * dy).sqrt();
if length > 1e-9 {
enemy.body.x += dx / length * ENEMY_SPEED * DT;
enemy.body.y += dy / length * ENEMY_SPEED * DT;
}
if length < 22.0 { world.health -= 20.0 * DT; }
}
for bullet in &mut world.bullets {
bullet.body.x += bullet.body.vx * DT;
bullet.body.y += bullet.body.vy * DT;
bullet.life -= DT;
}
// Broadphase: bucket enemies into 64-pixel cells; each bullet tests only the 3x3 cells around it.
let mut cells: Vec<Vec<usize>> = vec![Vec::new(); GRID_W * GRID_H];
let cell_of = |value: f64, size: usize| ((value / CELL).floor() as i64).clamp(0, size as i64 - 1) as usize;
for (index, enemy) in world.enemies.iter().enumerate() {
cells[cell_of(enemy.body.y, GRID_H) * GRID_W + cell_of(enemy.body.x, GRID_W)].push(index);
}
for bullet in &mut world.bullets {
if bullet.life <= 0.0 { continue; } // expired this step: it can't hit anything
world.brute_force_tests += world.enemies.len() as u64;
let (cell_x, cell_y) = (cell_of(bullet.body.x, GRID_W), cell_of(bullet.body.y, GRID_H));
'search: for y in cell_y.saturating_sub(1)..=(cell_y + 1).min(GRID_H - 1) {
for x in cell_x.saturating_sub(1)..=(cell_x + 1).min(GRID_W - 1) {
for &index in &cells[y * GRID_W + x] {
let enemy = &mut world.enemies[index];
if !enemy.alive { continue; }
world.grid_tests += 1;
let (dx, dy) = (enemy.body.x - bullet.body.x, enemy.body.y - bullet.body.y);
if dx * dx + dy * dy < 13.0 * 13.0 { // radii 10 + 3, compared squared
enemy.alive = false;
bullet.life = 0.0;
world.kills += 1;
if !audio.try_push(Sound::Hit) { world.sounds_dropped += 1; }
break 'search;
}
}
}
}
}
// Remove the dead at the end of the step, never mid-iteration.
world.enemies.retain(|enemy| enemy.alive);
world.bullets.retain(|bullet| bullet.life > 0.0);
if world.health <= 0.0 { set_state(world, State::GameOver); }
else if world.state == State::Playing && world.enemies.is_empty() {
set_state(world, if world.wave == 3 { State::Won } else { State::WaveClear });
}
}
// Read-only: interpolate between the last two steps and emit quads. Changes nothing in the world.
fn build_render(world: &World, alpha: f64, quads: &mut Vec<Quad>) {
quads.clear();
let mut emit = |body: &Body, sprite: u16| quads.push(Quad {
x: (body.previous_x + (body.x - body.previous_x) * alpha) as f32,
y: (body.previous_y + (body.y - body.previous_y) * alpha) as f32,
sprite,
});
emit(&world.ship, 0);
world.enemies.iter().for_each(|enemy| emit(&enemy.body, 1));
world.bullets.iter().for_each(|bullet| emit(&bullet.body, 2));
}
fn main() {
let (mut audio_producer, mut audio_consumer) = spsc_ring::<Sound, 1024>();
let audio_running = Arc::new(AtomicBool::new(true));
let audio_thread = { // stands in for the audio callback
let running = Arc::clone(&audio_running);
thread::spawn(move || {
let mut heard = [0u64; 3];
while running.load(Ordering::Acquire) {
while let Some(sound) = audio_consumer.try_pop() {
heard[sound as usize] += 1; // a mixer would start a voice here
}
thread::sleep(Duration::from_millis(2));
}
while let Some(sound) = audio_consumer.try_pop() { heard[sound as usize] += 1; } // drain what's left
heard
})
};
let mut world = World {
ship: Body::at(400.0, 300.0), health: 100.0, cooldown: 0.0, state_time: 0.0, time: 0.0,
state: State::Menu, wave: 0, kills: 0, shots: 0, enemies: Vec::new(), bullets: Vec::new(),
grid_tests: 0, brute_force_tests: 0, sounds_dropped: 0,
};
let mut quads = Vec::new();
let (mut accumulator, mut frames, mut steps, mut most_steps, mut most_quads) = (0.0, 0u64, 0u64, 0u64, 0usize);
while world.state != State::Won && world.state != State::GameOver && world.time < 120.0 {
// A scripted 144 Hz display with one 400 ms hitch, so the run is the same every time.
let frame_time: f64 = if frames == 900 { 0.4 } else { 1.0 / 144.0 };
let angle = world.time * 0.8; // the "player" circles the arena and holds fire
let input = Input { move_x: -angle.sin(), move_y: angle.cos(), fire: true, start: world.time >= 0.5 };
accumulator += frame_time.min(0.25); // spiral-of-death clamp: the hitch counts as 0.25 s
let mut steps_this_frame = 0;
while accumulator >= DT {
simulate(&mut world, &input, &mut audio_producer);
accumulator -= DT;
steps_this_frame += 1;
}
build_render(&world, accumulator / DT, &mut quads);
frames += 1;
steps += steps_this_frame;
most_steps = most_steps.max(steps_this_frame);
most_quads = most_quads.max(quads.len());
}
audio_running.store(false, Ordering::Release);
let heard = audio_thread.join().expect("audio thread"); // the consumer stops before the program ends
println!("{frames} frames, {steps} steps, at most {most_steps} steps in one frame, at most {most_quads} quads");
println!("{} shots, {} kills, health {:.1}", world.shots, world.kills, world.health);
println!("bullet-enemy tests: {} with the grid, {} brute force", world.grid_tests, world.brute_force_tests);
println!("sounds heard: {} shot, {} hit, {} wave; dropped {}", heard[0], heard[1], heard[2], world.sounds_dropped);
}
0.53 s Menu -> Playing
5.83 s Playing -> WaveClear
6.83 s WaveClear -> Playing
11.02 s Playing -> WaveClear
12.02 s WaveClear -> Playing
16.83 s Playing -> Won
2390 frames, 1010 steps, at most 15 steps in one frame, at most 25 quads
124 shots, 42 kills, health 100.0
bullet-enemy tests: 2060 with the grid, 38499 brute force
sounds heard: 124 shot, 42 hit, 3 wave; dropped 0
The first six lines are the state machine's transitions, stamped with game time: the menu waits for the start input at 0.5 s, and each cleared wave holds for one second before the next spawns. "At most 15 steps in one frame" is the 400 ms hitch after the clamp cuts it to 0.25 s: 0.25 s of banked time at 1/60 s a step is 15 catch-up steps. Without the clamp that frame runs 24. The quad count is the render description's peak size, the ship plus every live enemy and bullet. The test counts are the broadphase at work, about 19 times fewer tests than brute force. "Dropped 0" means the 169 sounds never came close to filling 1,024 slots. Health stays at 100 because auto-aim at ten shots a second kills every enemy before it reaches a ship that keeps moving; slow the fire rate to 0.5 s and the ship starts taking hits.
- A window, a GPU and an audio device.
buildRenderfills a quad list that nothing draws, and the audio thread counts sounds instead of mixing them. The 2D Renderer and Audio tutorials supply both ends. - Real time and real input. The clock and the player are scripted so the output can be diffed. A real loop reads
steady_clockorInstantand an input snapshot from the platform layer. - Reused memory. The grid rebuilds its cell lists every step, which allocates. Production code keeps the storage between steps and only clears it.
- Cached indices in the ring. Rigtorp's queue keeps a local copy of the other side's index and reloads it only when the ring looks full or empty, which cuts cross-core traffic[12].
- Gameplay polish. No pause state, no separation between enemies, no invulnerability after a hit, no score screen.
- Determinism across machines. The same build gives the same output every run. A different compiler, flag set or CPU can change floating-point results, so lockstep multiplayer needs more care (see Pitfalls).
10Play 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.
11The 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. How the 16.6 ms divides up depends entirely on the game and the machine; profile your own.
Worked example
On a 144 Hz display the budget is 1000 ÷ 144 = 6.94 ms. A sim step that costs 4 ms fits when a frame runs one step or none, but a frame that has to catch up with two steps spends 8 ms on simulation alone and misses the refresh. That's why the per-step cost, not the average, decides whether a heavy moment hitches.
Wrong answers, and why: the sim stays fixed-rate (smoothness is interpolation, not a faster sim, and not overshoot-prone extrapolation); physics belongs in the one fixed-step update, not the variable render pass or the real-time audio thread; a full ring drops the sound rather than waiting or reallocating; a relaxed publish lets the index overtake the data; and a 3 × 3 search needs cells at least as wide as the hit distance.
12Pitfalls
13What'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.
- Jason Gregory. Game Engine Architecture. gameenginebook.com. Subsystem start-up / shut-down ordering and the layered runtime architecture.
- 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.
- Valve. Source Multiplayer Networking. developer.valvesoftware.com. A fixed server tick (about 15 ms) decoupled from the render frame rate.
- Unity Technologies. "Apply interpolation to a Rigidbody." Unity Manual. docs.unity3d.com. Interpolate uses "the pose of the Rigidbody from the previous two physics updates" and so trails by one update.
- Robert Nystrom. "Component," Game Programming Patterns. gameprogrammingpatterns.com. Composition over monolithic entities, and the caution against over-engineering.
- 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.
- Robert Nystrom. "Service Locator," Game Programming Patterns. gameprogrammingpatterns.com. Referencing the audio system through an interface with a null fallback.
- Ross Bencina. "Real-time audio programming 101: time waits for nothing." 2011. rossbencina.com. Why the audio callback must not lock, allocate or do I/O, with priority inversion as the first reason against locks.
- Jeff Preshing. "Acquire and Release Semantics." 2012. preshing.com. What a write-release and a read-acquire forbid the compiler and CPU to reorder, and how the pair passes data between threads.
- Erik Rigtorp. SPSCQueue. github.com/rigtorp/SPSCQueue. A single-producer single-consumer ring with relaxed, acquire and release index operations, cache-line-padded indices and locally cached copies of the other side's index.
- Robert Nystrom. "Spatial Partition," Game Programming Patterns. gameprogrammingpatterns.com. The fixed grid, checking neighboring cells when the interaction distance is smaller than a cell, and how clustering regresses to O(n²).
- Robert Nystrom. "State," Game Programming Patterns. gameprogrammingpatterns.com. The finite state machine for menu / playing / paused / game-over.