The 3D-Game Capstone
Every subsystem in this series works in its own demo. Put them in one program and a new kind of bug shows up: physics that steps twice per frame, enemies that react to where their target was a step ago, a draw call that nudges the simulation, an audio thread that stalls on a lock. This page wires the series into a small, playable 3D survival arena and lays out the integration rules that prevent those bugs: one fixed-step loop, a fixed update order, a renderer that only reads, and a lock-free handoff to the audio thread.
- Write the master frame loop: one accumulator, a fixed update order inside the step, and a renderer that only reads an interpolated snapshot.
- Trace one enemy through the four AI layers (decide, plan, steer, resolve) and say which module owns each.
- Put the deferred render passes in order, and say why TAA runs before bloom and tonemapping.
- Read a frame budget: find the widest slice, and tell when catch-up steps turn into the spiral of death.
- Say what networking this simulation would take, and why a fixed step alone isn't enough.
01What "done" looks like
Picking the game is the first integration decision. A puzzle game would barely touch physics or AI, and a racing game's AI follows a racing line with no navmesh to path across. The capstone game is a third-person survival arena, a small design that forces every subsystem to fire without genre-specific tuning eating the budget:
One level (a glTF scene with a baked navmesh), a player capsule (move + look, the character controller), waves of AI enemies that path and steer toward the player and attack in melee range, a dodge/attack combat loop, rendered with the PBR pipeline + a shadow + TAA/bloom/tonemap, audio cues on hits and spawns, and an ImGui debug overlay showing the live frame profiler. Win = survive N waves; lose = health hits zero. It has the shape of a (every system, end to end, on one small level) without the near-final polish a production slice carries: every subsystem fires once and nothing is tuned to ship.
02The engine, layer by layer
The renderer needs a window before it can make a swapchain, and the asset loader needs the job system before it can read a file in the background. Start them in the wrong order and the first frame dereferences something that doesn't exist yet. The series built Gregory's runtime architecture bottom-up[1]; the capstone stacks it back together. Subsystems start up in dependency order and shut down in reverse, so the renderer releases its GPU resources before the allocators and the window it depends on go away:
- Platform & core: the window, the memory model + allocators, the job system, lock-free queues.
- Resources: asset pipeline, streaming, compression.
- The frame spine: the loop, input.
- Runtime systems: the renderer, animation, physics, audio.
- Gameplay & AI: the object model, behavior trees, navmesh + steering.
- The game on top, calling down, called by nothing.
This is one common decomposition (layered, deferred, fixed-step). Forward+, an entity-component-system everywhere, and a variable tick with fixed physics substeps are all valid alternatives.
03The master frame loop
On a 144 Hz monitor the game renders 144 frames a second, on a slow laptop maybe 40, and the simulation has to behave the same on both. The loop is the single integration point: the whole engine hangs off the from The Game Loop. One accumulator drives the fixed sim, the render interpolates with alpha[2] and only reads the sim, and the sim never calls the GPU.
Worked example
With dt = 1/60 s = 16.7 ms, a frame that took 25 ms arrives with 5 ms already left in the accumulator: 30 ms in all. One step runs and leaves 13.3 ms, less than a step, so the loop stops and renders with alpha = 13.3 / 16.7 = 0.8, drawing every object 80% of the way from its previous position to its current one. A one-second stall (a breakpoint, a window drag) is clamped to 0.25 s, so the next frame runs 0.25 × 60 = 15 catch-up steps instead of 60.
const double dt = 1.0 / 60.0; // the fixed sim step
double accumulator = 0.0;
while (running) {
platform.pumpEvents(input); // OS events -> this frame's input snapshot (Input module)
double frameTime = clock.tick(); // seconds since the last frame, from a monotonic clock
accumulator += std::min(frameTime, 0.25); // clamp = the spiral-of-death guard (Fiedler)
while (accumulator >= dt) { // 0, 1, or N fixed steps, ONE accumulator
world.previousTransforms = world.transforms; // keep last step's transforms for interpolation
simulate(world, input, dt); // the whole fixed step (order below)
accumulator -= dt;
}
double alpha = accumulator / dt; // 0..1 blend between previous and current
renderFrame(world, alpha); // READ-ONLY: deferred PBR + shadows + skinned meshes + post
imguiOverlay(profiler); // debug UI on top of the final image (Tooling module)
present();
}
void simulate(World& world, const Input& input, double dt) {
applyInput(world, input); // 1. resolve player actions
aiTick(world, dt); // 2. behavior trees -> navmesh -> steering: desired velocities
gameplayUpdate(world, dt); // 3. component logic, events, spawns
physicsStep(world, dt); // 4. solver + character controllers resolve the motion
animationUpdate(world, dt); // 5. advance clips, sample poses from the resolved motion
reapDestroyed(world); // 6. deferred destruction at step end
}
const DT: f64 = 1.0 / 60.0; // the fixed sim step
let mut accumulator = 0.0;
while running {
platform.pump_events(&mut input); // OS events -> this frame's input snapshot
let frame_time = clock.tick(); // seconds since the last frame, monotonic
accumulator += frame_time.min(0.25); // spiral-of-death clamp (Fiedler)
while accumulator >= DT { // ONE accumulator drives the fixed sim
world.previous_transforms.clone_from(&world.transforms); // for interpolation; reuses the allocation
simulate(&mut world, &input, DT);
accumulator -= DT;
}
let alpha = accumulator / DT;
render_frame(&world, alpha); // READ-ONLY interpolated render
imgui_overlay(&profiler);
present();
}
fn simulate(world: &mut World, input: &Input, dt: f64) {
apply_input(world, input); // 1. actions
ai_tick(world, dt); // 2. BT -> navmesh -> steering: desired velocities
gameplay_update(world, dt); // 3. logic, events, spawns
physics_step(world, dt); // 4. solver + controllers resolve the motion
animation_update(world, dt); // 5. advance clips, sample poses
reap_destroyed(world); // 6. deferred destruction
}
One accumulator, not N. Physics, AI, and animation are all things the one simulate(dt) does. Nest a second accumulator inside it (the physics tutorial runs one in isolation), feed it the frame time, and physics double-steps. A subsystem that needs a different rate derives it from the one step: AI every third step, physics as a fixed number of substeps. Render is read-only. No advancing an animation timer or moving an entity during draw; render consumes lerp(previous, current, alpha) for positions and slerp (or nlerp) for rotations. A raw component-wise lerp of a rotation gives a non-rigid, shrunken in-between, the same failure as LBS in Skeletal Animation. The sim never calls the GPU. It produces a scene snapshot the renderer consumes, which buys headless tests, clean interpolation, and a sim with the shape netcode needs.
Worked example
Nest a second accumulator for physics inside simulate and feed it the frame time. A 33.3 ms frame runs simulate twice, and each call adds 33.3 ms to the physics accumulator, so physics takes 4 steps of 16.7 ms while the game advanced 2: physics runs at double speed. And lerping a rotation component-wise goes wrong too. Halfway through a 90° turn about the vertical axis, the lerp of the two 2×2 rotation blocks is [[0.5, −0.5], [0.5, 0.5]]: it points the right way, 45°, but each column has length √(0.5² + 0.5²) = 0.71, so the model is drawn at 71% of its size for that frame. Slerp gives the same 45° at full size.
04Update order & the AI stack
The player presses attack this step. If AI runs before input is applied, the enemies decide without knowing the attack started, and react a step late. The order inside simulate is one common choice, and each edge has a reason: input precedes AI (AI reacts to this step's commands), AI precedes gameplay (decisions set intents gameplay executes), gameplay precedes physics (physics resolves the requested motion into authoritative post-collision transforms), and animation follows physics (poses match the motion that actually happened). The subtlety is consistency: an object that reads a peer's transform partway through the update sees some peers already stepped and some not, the bug Gregory calls a one-frame-off lag[1]. Reading from the previous step's snapshot, or phasing the update so every read comes after the writes it depends on, avoids it.
Worked example
Enemy A updates before enemy B, and each aims at the other, both moving at 6 m/s with 60 steps a second. A reads B's position from the previous step, 6 / 60 = 0.1 m out of date, while B reads A's position from this step. Swap their order in the entity list and the error swaps with it. Reading every position from last step's snapshot makes both 0.1 m stale, the same for everyone, whatever the order.
"The enemy moves toward the player" is four different modules: the behavior tree decides (returns SUCCESS/FAILURE/RUNNING), the navmesh + funnel plans the path (requestPath, stringPull), steering produces motion (arrive, clamped to maxForce/maxSpeed), and the character controller resolves that motion against geometry (collide-and-slide). Pathfinding is global and discrete; steering is local and continuous; they're not the same function.
// Runs inside aiTick (step 2): DECIDE, PLAN, MOVE. Writes a desired velocity, never a position.
void updateEnemyAI(Enemy& enemy, float dt) {
enemy.behaviorTree->tick(); // DECIDE: the MoveTo leaf (built with refs to enemy.blackboard
// and enemy.navAgent) calls navAgent.requestPath(target)
Vec2 steering = {0.0f, 0.0f};
if (enemy.navAgent.hasPath()) { // PLAN: the funnel's corners on the floor plane (hasPath and
Vec2 corner = enemy.navAgent.currentCorner(); // currentCorner are read-only accessors added to NavAgent)
steering = arrive(enemy.agent, corner, enemy.slowingRadius); // MOVE: local force, clamped to maxForce
} else {
steering = clampMag(enemy.agent.velocity * (-1.0f / dt), enemy.agent.maxForce); // no path: brake toward rest
}
applySteering(enemy.agent, steering, dt); // velocity only: the character controller owns position (step 4)
}
// Runs inside physicsStep (step 4): RESOLVE the requested motion against the level.
void resolveEnemy(Enemy& enemy, const World& world, float dt) {
Vec3 desired = {enemy.agent.velocity.x, 0.0f, enemy.agent.velocity.y}; // navmesh plane (x, z) -> world, y up
Vec3 resolved = enemy.controller.moveAndSlide(desired, world.collision, dt); // capsule collide-and-slide
enemy.agent.position = {resolved.x, resolved.z}; // next tick steers from where the capsule actually stopped
}
// Runs inside ai_tick (step 2): DECIDE, PLAN, MOVE. Writes a desired velocity, never a position.
fn update_enemy_ai(enemy: &mut Enemy, dt: f32) {
enemy.behavior_tree.tick(&mut enemy.context); // DECIDE: MoveTo calls context.nav_agent.request_path(target)
let nav_agent = &enemy.context.nav_agent; // blackboard + nav_agent travel in one Context (BT module)
let steering = if nav_agent.has_path() { // PLAN: the funnel's corners on the floor plane
let corner = nav_agent.current_corner();
arrive(&enemy.agent, corner, enemy.slowing_radius) // MOVE: clamped to max_force
} else {
clamp_mag(enemy.agent.velocity * (-1.0 / dt), enemy.agent.max_force) // no path: brake toward rest
};
apply_steering(&mut enemy.agent, steering, dt); // velocity only: the character controller owns position (step 4)
}
// Runs inside physics_step (step 4): RESOLVE the requested motion against the level.
fn resolve_enemy(enemy: &mut Enemy, world: &World, dt: f32) {
let desired = Vec3::new(enemy.agent.velocity.x, 0.0, enemy.agent.velocity.y); // navmesh plane (x, z) -> world, y up
let resolved = enemy.controller.move_and_slide(desired, &world.collision, dt); // capsule collide-and-slide
enemy.agent.position = Vec2::new(resolved.x, resolved.z); // next tick steers from where the capsule actually stopped
}
The character controller is the one piece with no prior tutorial: a thin kinematic capsule that collide-and-slides against the collision shapes the physics module built, added here as glue. The split into two functions follows the update order: AI writes only a desired velocity in step 2, and the controller, in the physics step, is the only thing that writes position. arrive, clampMag and applySteering are the steering module's; applySteering writes velocity only, which is what lets the controller own position.
05Assembling the render frame
Each pass reads what an earlier pass wrote: lighting needs the G-buffer, and the shadow test needs the shadow map. Run bloom before TAA and bright edge pixels that flicker from frame to frame, which TAA would have settled, can get spread into a shimmering glow. renderFrame reads the interpolated, read-only snapshot and runs the deferred passes in order:
- Shadow depth pass from the light (Shadows).
- G-buffer geometry pass (Deferred): static meshes and skinned meshes via vertex-shader skinning from the per-frame matrix palette (Skeletal Animation), writing
gAlbedo/gNormal/gMaterial. - SSAO (AO/GI).
- Full-screen lighting: the Deferred module's per-light loop calling
cookTorrance(the PBR BRDF), withshadowFactor+ PCF folded in. - Forward pass for transparency (a G-buffer holds one opaque surface per pixel, so blended transparents get their own forward pass).
- Post: TAA resolve on the HDR image, then bloom pyramid → tonemap (Post-Processing). TAA commonly runs before the rest of post, on the HDR image, so bloom and tonemap see the resolved, stable frame[3].
- ImGui overlay → present.
The only new content here is the ordering. Everything else is a call into a module you already built, reading transforms that were lerp/slerp-interpolated and mutating nothing.
06Making it a game
An enemy holds a pointer to its target, the target dies, and next step the enemy reads freed memory. A renderer and a physics engine aren't a game yet; the gameplay layer supplies the rest, including the fix for that crash. Entities are game objects built from components; the event bus decouples a hit from the health update, the UI, and the audio cue; generational handles keep an AI's reference to its target safe across that target's death (resolving a stale handle returns null instead of dangling); deferred destruction reaps the dead at step end. A game-state machine (menu / playing / paused / game-over) gates the sim[4], and waves spawn prefabs through the factory, configured by a Lua table so tuning needs no recompile.
An attack is one physics overlap query → an event on the bus → a damage-component update and an audio command pushed to the SPSC ring (Audio; the audio thread, fed lock-free, must never block) → and if health hits zero, the entity is marked for deferred destruction. Win when the survived-wave count reaches the target; lose when player health reaches zero. That one interaction touches physics, the event bus, audio, the component model, and, when it ends the last wave or the player, the state machine.
Worked example
In the arena below, a swing hits every enemy within 80 units for 30 damage, and enemies spawn with 30 health, so one swing kills everything in range; you can swing every 0.45 s. An enemy touching you drains 14 health a second, so your 100 health lasts about 7.1 s against one enemy and 1.4 s against five. The waves bring 5, 7, 9, 11 and 13 enemies, 45 in all, at 10 points each: a perfect run scores 450.
07Play it
A top-down canvas stand-in for the 3D arena. The loop is the real one: a fixed-timestep accumulator with interpolation. The other layers are small versions of the modules': A* on a grid with line-of-sight smoothing in place of the navmesh and funnel, arrive-style steering with separation, circle-versus-box collide-and-slide for the capsule, a pulse for each audio cue, and the wave/win/lose loop. Press start (or click the arena and press Enter), move with WASD / arrows, attack with space:
08The frame budget
In the model below, the frame fits the budget with 40 enemies and drops to about 38 fps with 100. Which system do you optimize? A model of this engine's frame budget, drawn the way the Tooling module's profiler draws a frame. The slice costs are illustrative, not measured from the arena above. Each subsystem gets a slice; the sim slices repeat per fixed step while the render slices run once. Push the load: the answer to "where do I optimize" is whichever slice is widest.
Worked example
At the widget's defaults (40 enemies, render load 40), AI costs 0.5 + 40 × 0.05 = 2.5 ms, the whole sim group 5.7 ms and the render 9.0 ms: one step and a 14.7 ms frame, inside the budget. At 100 enemies AI is 5.5 ms and the sim group 8.7 ms; one step plus the render would be 17.7 ms, so the accumulator owes a second step and the frame becomes 2 × 8.7 + 9.0 = 26.4 ms, about 38 fps. AI is the widest slice, so that's where to spend. At 300 enemies one sim step alone costs 18.7 ms, more than the 16.7 ms of game time it simulates: the clamp holds it at 15 steps, a 289.5 ms frame (about 3.5 fps), and the game runs at 250 / 289.5 ≈ 86% speed.
09The data flow
A teammate wants the physics step to write its debug lines straight into a GPU buffer. It would work today and make the sim impossible to run without a GPU tomorrow. How the subsystems connect: input feeds the sim; the sim produces a render description and audio commands; the renderer and audio thread consume them. Click a node (or focus the diagram and use the arrow keys) to read its role, and try the forbidden edge:
10If you added networking
A friend asks for co-op. The good news is that the sim is already a self-contained simulate(world, input, dt) on a fixed timestep, the structural precondition netcode needs. Lockstep would exchange input per tick and run the identical simulate on every peer; rollback would snapshot world every tick, predict the remote player's input, and on a misprediction restore the snapshot and re-simulate to the present with the corrected inputs.
Worked example
Say the remote player's input arrives 4 steps late. A rollback restores the snapshot from 4 steps ago and re-simulates those 4 steps plus the current one inside a single frame. At the frame-budget model's default of 5.7 ms per sim step, that's 5 × 5.7 = 28.5 ms, well past the 16.7 ms budget before rendering even starts. A rollback game needs a step several times cheaper than this one.
A fixed step is necessary but not sufficient: float results can differ across compilers and CPUs, so making this net-ready is a project (fixed-point or tightly controlled float), not a drop-in. This capstone is single-player, and its float nondeterminism is fine: bit-determinism starts to matter with lockstep, rollback, or replays recorded as inputs. Don't claim it's net-ready; claim its shape is.
11What each module gave
When something breaks in the arena, the first question is which module owns it. Each module the capstone draws on, and what it uses it for:
lerp for positions and slerp for render rotationsdt/alpha, interpolation, the spiral clamp, the spinepumpEvents drains, the surface the swapchain presents toworld = parent · local)cookTorrance evaluates; HDR tonemappingMoveTo leaf and the RUNNING/SUCCESS/FAILURE protocolrequestPath/hasArrived, the funnel, seek/arrive, local avoidanceWrong answers, and why: the loop owns one accumulator and the render is read-only/interpolated (independent per-subsystem clocks make every run timing-dependent; no accumulator couples the sim to the frame rate); and the AI stack is four distinct layers (steering alone gets stuck on walls; the behavior tree decides and requests, it doesn't path or move).
12Retrospective & pitfalls
What this isn't: production-complete. The capstone exercises every subsystem once. It skips open-world streaming and save/load, which the two Advanced modules after it cover (Open-World Streaming, Save Games); animation state machines and IK (Animation: Blending, State Machines & IK) and the audio DSP chain (Audio DSP), which earlier modules built but the slice doesn't need; and a full editor, GPU-driven rendering, LOD, and networking polish, which no module here covers.
13What's next
That completes the core series: from the cache line to a playable 3D game, in C++ and Rust, every subsystem built from scratch and assembled here. Two Advanced modules extend it toward a shipping open-world game: Open-World Streaming, next in the series, and then Save Games. Beyond those, pick one subsystem and go deep (an editor, deterministic netcode, GPU-driven rendering), or build a second game on the same engine, which shows which parts are reusable engine code and which are game-specific. The full map is on the series hub.
- Jason Gregory. Game Engine Architecture, 3rd ed. gameenginebook.com. The layered runtime architecture, ordered subsystem startup/shutdown, and the game-object update structure (phased updates, one-frame-off lag) this capstone assembles.
- Glenn Fiedler. "Fix Your Timestep!" gafferongames.com. The accumulator, the 0.25 spiral-of-death clamp, and
alphainterpolation (not extrapolation) the master loop is built on. - Alex Tardif. "Temporal Antialiasing Starter Pack." alextardif.com. Places the TAA resolve after the lighting pass and before any post-processing, on the HDR targets ahead of tonemapping.
- Robert Nystrom. Game Programming Patterns ("Game Loop", "Update Method", "State"). gameprogrammingpatterns.com. The input→update→render sequencing, per-object update, and the game-state machine.