All tutorials Mighty Professional
Build a Game Engine · A small 3D game

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.

Time~60 min LevelSenior · capstone PrereqsMost of the series. Each section links the module it draws on. StackC++ & Rust
◂ Build a Game Engine Milestone · A small 3D game Next · Open-World Streaming ▸
What you'll be able to do by the end

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:

The vertical slice

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:

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.

The master loop (the spine that ties every subsystem together)
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
}
Three integration rules

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 AI stack is four layers; don't conflate them

"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.

One enemy through the four-layer AI stack
// 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:

  1. Shadow depth pass from the light (Shadows).
  2. 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.
  3. SSAO (AO/GI).
  4. Full-screen lighting: the Deferred module's per-light loop calling cookTorrance (the PBR BRDF), with shadowFactor + PCF folded in.
  5. Forward pass for transparency (a G-buffer holds one opaque surface per pixel, so blended transparents get their own forward pass).
  6. 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].
  7. 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.

A melee hit, end to end

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:

Press start and play a wave, then try the toggles. Enemies run a reduced AI stack: the decision is fixed (chase, strike in melee range), then path, steer, resolve. The audio pulse in the top-right corner fires on each cue: an attack that lands, a wave that spawns. Render raw sim draws the latest sim state without interpolation; the stutter is clearest on a high-refresh display, where several frames repeat the same step. Lag spike adds 200 ms to one frame, so that frame runs about 13 steps instead of 1 (216.7 ms / 16.7 ms on a 60 Hz display): every enemy jumps, and the step counter's peak holds for a second. Show AI layers draws each enemy's remaining path corners (blue) and its steering vector (amber).

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.

Drag the two sliders and watch the bar against the dashed 16.6 ms line; the readout under the legend gives the frame time and the sim steps per frame. Each colored slice is a subsystem; the sim group repeats once per fixed step the accumulator runs. Crank enemies and the AI slice grows until the frame crosses the 16.6 ms line; that's where to spend (time-slice the pathfinds, fewer per step). Crank render load and lighting/post grow instead. Over budget, the accumulator owes catch-up steps and the sim group repeats: the frame rate drops, but the sim keeps real time as long as one step costs less than 16.6 ms. Past that, every frame owes more steps than it can run, the spiral of death; the loop's 0.25 s clamp caps it at 15 steps a frame, trading a freeze for slow motion.

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:

Click a node to read its role, then tick the forbidden edge. The clean shape: input → sim → (render description → renderer → GPU) and (audio commands → audio thread, lock-free). Enable the sim → GPU edge and it flashes red: that coupling costs headless tests, clean interpolation, and the shape netcode needs.

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.

The blocker is determinism, not architecture

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:

ModuleWhat the capstone uses it for
Memory ModelThe acquire/release ordering the audio ring and the job queues rely on
AllocatorsArena/pool for per-frame and entity allocation; the handle backing store
Job SystemsFork/join within a frame: per-enemy AI inside the sim step; culling and skinning-palette builds for the render
Lock-free QueuesThe SPSC ring to the audio thread; the job pool's bounded MPMC queue
3D MathVec/Mat/Quat, the MVP chain, lerp for positions and slerp for render rotations
Floating PointWhy this single-player sim needn't be bit-deterministic; the netcode precondition
Game LoopThe one accumulator, dt/alpha, interpolation, the spiral clamp, the spine
Platform & WindowThe window, the OS event pump pumpEvents drains, the surface the swapchain presents to
InputThe per-frame snapshot, the action map (move/look/attack/dodge)
File StreamingAsync reads of the level package, so loading never blocks the frame
Asset PipelineMounting the package; the glTF/texture/skin/navmesh load; prefabs; hot-reload
CompressionDecompressing the package at load, where decode speed matters more than ratio
GPU PipelineThe rasterization model every pass configures: clip space, the depth test, perspective-correct interpolation
Vulkan TriangleThe device, swapchain, command buffers, and the semaphores and fences every frame submits and presents through
TexturesMaterial textures: images, samplers, descriptors, layout transitions, the sRGB pipeline
Going 3DPerspective camera, depth, glTF meshes, the scene graph (world = parent · local)
PBRThe GGX/Smith/Schlick BRDF the lighting pass's cookTorrance evaluates; HDR tonemapping
ShadowsThe directional shadow map + PCF, folded into the lighting pass
DeferredThe G-buffer, the full-screen lighting loop, the forward pass for transparency
AO/GIThe SSAO pass between geometry and lighting
Post/AATAA, then the bloom pyramid and tonemap, at the back of the frame
Skeletal AnimationSkinned characters: the matrix palette, vertex-shader skinning, clip slerp
Gameplay LayerGame objects, the event bus, generational handles, deferred destruction, prefabs, Lua
Behavior TreesEnemy decisions; the MoveTo leaf and the RUNNING/SUCCESS/FAILURE protocol
Navmesh & SteeringrequestPath/hasArrived, the funnel, seek/arrive, local avoidance
Graph TraversalA* over the navmesh dual graph
PhysicsGJK/SAT + the impulse solver; the shapes the character controller slides against
Spatial PartitioningBroad-phase pruning for the melee overlap query; frustum culling before the geometry pass
AudioThe mixer, voices, equal-power pan + distance attenuation, the hard-real-time callback
NetcodeThe "how it would slot in" section: the deterministic-sim precondition
ToolingThe ImGui overlay and the scoped-timer profiler watching the frame

Wrong 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.

Two accumulatorsThe loop owns the timestep. Physics/AI/anim run inside the one step.
Mutating sim in renderRender is read-only. Consume lerp/slerp; emit GPU commands only.
Sim calls the GPUBreaks headless tests + interpolation. Sim makes a snapshot; renderer reads it.
Lerping rotationsSlerp or nlerp them; a raw lerp shrinks the rotation (the LBS failure).
Conflating the AI stackDecide (BT) → plan (navmesh) → move (steering) → resolve (controller).
Deleting mid-iterationMark for death; reap at step end.
Blocking the audio callbackNo locks/alloc/IO. Drain the SPSC ring wait-free.
"It's net-ready"The shape is; bit-determinism isn't. Float results can vary across compilers and CPUs.

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.

  1. 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.
  2. Glenn Fiedler. "Fix Your Timestep!" gafferongames.com. The accumulator, the 0.25 spiral-of-death clamp, and alpha interpolation (not extrapolation) the master loop is built on.
  3. 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.
  4. 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.

See also