```{index} single: game; 3D ``` # Capstone: a 3D game A complete small game: a craft flies down a lane, blocks stand in its way, gems hang between them, and the lane gets faster the longer the run lasts. The arrow keys steer, each gem scores, and touching a block ends the run. It is the [2D game](../graphics/capstone-2d-game) with a third dimension, and it keeps that game's shape: the world is plain data, an update step moves it, a draw step shows it, and a loop runs both at a fixed rate. Nothing in it is new. Every piece was built in an earlier chapter, and this one puts them together: | Piece | Taught in | |---|---| | vectors, matrices, `m_look_at`, `m_perspective` | [foundations](foundations) | | boxes and spheres as bounding volumes | [meshes](meshes) | | the node hierarchy and `walk` | [scene graphs](scene-graphs) | | the window, the surface, the fixed step with its cap | [the device chapter](gpu-device-and-window) | | the pipeline, vertex and index buffers | [the pipeline chapter](gpu-pipeline) | | the cube, depth, instancing, per-fragment light | [the GPU scene chapter](gpu-3d-scene) | | `key_pressed` and scancodes | [the 2D game](../graphics/capstone-2d-game) | The blocks of code from here to the loop, in order, are the whole program. It loads the subs from foundations from `foundations.pl` beside it, exactly as the [GPU scene chapter](gpu-3d-scene) does. ```{index} single: object recycling single: srand; reproducible runs ``` ## The world is plain data ```perl use strict; use warnings; use SDL2 qw(:all); use Peta::WebGPU; BEGIN { require './foundations.pl' } # the v_* and m_* subs, then 1; use constant { STEP => 16, MAX_STEPS => 5 }; # milliseconds, steps per frame use constant { LANE => 4, AHEAD => 60, N_BLOCKS => 24, N_GEMS => 8 }; my $max_frames = shift // 0; # 0: play until the window is closed my %game; sub new_game { %game = ( craft => { x => 0, z => 0, vx => 0 }, speed => 0.12, # units per step, along -z blocks => [ map { place_block({}, -12 - $_ * AHEAD / N_BLOCKS) } 0 .. N_BLOCKS - 1 ], # -13.25 is halfway between the first two rows of blocks. gems => [ map { place_gem({}, -13.25 - $_ * AHEAD / N_GEMS) } 0 .. N_GEMS - 1 ], score => 0, over => 0, steps => 0, ); } sub place_block { my ($b, $z) = @_; my $w = 0.8 + rand 0.8; %$b = (x => $w / 2 - LANE + rand(2 * LANE - $w), z => $z, w => $w, h => 0.8 + rand 1.2); return $b; } sub place_gem { my ($g, $z) = @_; %$g = (x => 0.5 - LANE + rand(2 * LANE - 1), z => $z, taken => 0); return $g; } ``` The craft flies toward -z, the direction a camera looks in [foundations](foundations), and steers along x. The lane runs from `x = -4` to `x = 4`, with the floor at `y = 0`. The field looks endless and is not. There are always 24 blocks, one row every 2.5 units over the 60 units ahead, and 8 gems, one every 7.5 units, each halfway between two rows of blocks so that no gem is ever inside a block. When one falls behind the camera, `update` moves it 60 units further on and gives it a new position and size. **The number of objects never changes**, which is what lets the draw step below fill one buffer of fixed size every frame. `rand` makes each run different. Calling `srand` with a fixed number before `new_game` makes it the same every time, which is what the checks at the end of this chapter do. ```{index} single: collision detection single: axis-aligned bounding box; overlap test single: bounding sphere; against a box ``` ## Collision: boxes and a sphere ```perl # Boxes are [min corner], [max corner]: the axis-aligned form from meshes. sub craft_box { my $c = $game{craft}; return ([ $c->{x} - 0.8, 0.3, $c->{z} - 0.5 ], [ $c->{x} + 0.8, 0.75, $c->{z} + 0.5 ]); } sub block_box { my ($b) = @_; my $r = $b->{w} / 2; return ([ $b->{x} - $r, 0, $b->{z} - $r ], [ $b->{x} + $r, $b->{h}, $b->{z} + $r ]); } sub boxes_overlap { my ($alo, $ahi, $blo, $bhi) = @_; for my $i (0 .. 2) { # A gap along any one axis separates them; they touch only when # they overlap along all three. return 0 if $ahi->[$i] < $blo->[$i] || $bhi->[$i] < $alo->[$i]; } return 1; } sub sphere_touches_box { my ($c, $r, $lo, $hi) = @_; my $d2 = 0; for my $i (0 .. 2) { # The point of the box nearest the centre: clamp each coordinate. my $p = $c->[$i] < $lo->[$i] ? $lo->[$i] : $c->[$i] > $hi->[$i] ? $hi->[$i] : $c->[$i]; $d2 += ($c->[$i] - $p) ** 2; } return $d2 <= $r * $r; } ``` These are the two [bounding volumes](meshes) the meshes chapter introduced, the axis-aligned box and the sphere, and the [scene-graph chapter](scene-graphs) tested rays against the same two shapes. There they were a cheap first test before an exact one. Here nothing exact follows, because the volumes are close enough to the shapes: a block *is* a box, and a gem spinning in place stays inside the same sphere at every angle, which is the reason to give a turning object a sphere rather than a box. The craft's box ignores its bank. It is the box of the level craft, and banking narrows the craft by up to a tenth of a unit, so the test errs slightly on the side of a crash. Its height never decides anything: every block rises at least 0.8 from the floor and the box tops out at 0.75, so the vertical ranges always overlap and the other two axes decide. Twenty-four box tests per step cost nothing. The [bounding volume hierarchy](scene-graphs) is for when there are thousands; it would answer the same question with fewer tests, and nothing else would change. ## Update ```perl sub update { my ($steer) = @_; # -1 left, 0 straight on, +1 right return if $game{over}; my $c = $game{craft}; # Ease the sideways speed toward the steering rather than setting it: # the craft drifts into a turn and out of it, and its bank follows vx. $c->{vx} += (0.18 * $steer - $c->{vx}) * 0.2; $c->{x} += $c->{vx}; my $edge = LANE - 0.8; if ($c->{x} < -$edge) { $c->{x} = -$edge; $c->{vx} = 0 } if ($c->{x} > $edge) { $c->{x} = $edge; $c->{vx} = 0 } $c->{z} -= $game{speed}; $game{speed} += 0.00004 if $game{speed} < 0.3; $game{steps}++; my ($lo, $hi) = craft_box(); for my $b (@{ $game{blocks} }) { # Behind the camera nobody can see it: reuse it at the far end. place_block($b, $b->{z} - AHEAD) if $b->{z} > $c->{z} + 8; if (boxes_overlap($lo, $hi, block_box($b))) { $game{over} = 1; return } } for my $g (@{ $game{gems} }) { place_gem($g, $g->{z} - AHEAD) if $g->{z} > $c->{z} + 8; next if $g->{taken}; if (sphere_touches_box([ $g->{x}, 0.6, $g->{z} ], 0.35, $lo, $hi)) { $g->{taken} = 1; $game{score}++; } } } ``` `update` takes the steering as an argument instead of reading the keyboard itself. That one decision is what makes the game testable: a list of numbers fed to `update` plays a run exactly as a player would, with no window and no keyboard. Two things in it are load-bearing, and both are the 2D game's lessons again. **The `taken` flag.** The craft overlaps a gem for several steps while it passes through it, and every one of those steps would score without the flag. It is the 2D game's `dy > 0` test in another form: count the event, not the overlap. **The clamp at the lane edges also zeroes `vx`.** Clamping the position alone leaves the craft pressing on the wall at full sideways speed, banked hard over while not moving, and the first frame of steering back has to cancel that speed before anything happens. The speed rises from 0.12 units per step to 0.3, reaching it after 4500 steps, which is 72 seconds of play. At 0.3 a row of blocks arrives about every eight steps. ```{index} single: keyboard state single: SDL_PushEvent; does not change key state ``` ## Input ```perl sub input { SDL_PumpEvents(); my $steer = 0; $steer -= 1 if SDL2::key_pressed(SDL_SCANCODE_LEFT); $steer += 1 if SDL2::key_pressed(SDL_SCANCODE_RIGHT); new_game() if $game{over} && SDL2::key_pressed(SDL_SCANCODE_SPACE); return $steer; } ``` This is the [2D game's](../graphics/capstone-2d-game) input routine: `key_pressed` asks whether a key is held now, rather than waiting for events that report changes, so steering is smooth for as long as the key is down. Holding both arrows cancels out. Space starts a new run, but only once the current one has ended, so a player cannot restart by accident. The loop reads the keyboard once per frame and passes the answer to every fixed step that frame runs. Reading it inside `update` would tie the simulation to SDL again, which is what the argument above avoids. `SDL_PushEvent`, which the [device chapter](gpu-device-and-window) uses to drive a program from a test, does not help here. A pushed `SDL_KEYDOWN` for the left arrow arrives on the event queue with `scancode = 80` as expected, but `key_pressed(SDL_SCANCODE_LEFT)` stays false: the key state is SDL's record of the physical keyboard, and a pushed event does not change it. Tests drive `update` instead. ```{index} single: transform hierarchy; in a game ``` ## The craft is a small hierarchy ```perl sub m_rotate_z { # the pattern of m_rotate_y, about z my ($rad) = @_; my ($c, $s) = (cos $rad, sin $rad); return [ $c,$s,0,0, -$s,$c,0,0, 0,0,1,0, 0,0,0,1 ]; } sub walk { # $parent_world is the identity at the root. Multiplication order # is parent * local: the local transform applies FIRST, then the # parent's, which is the right-to-left rule from foundations. my ($node, $parent_world, $out) = @_; my $world = m_mul($parent_world, $node->{local}); $out->{ $node->{name} } = $world; walk($_, $world, $out) for @{ $node->{children} }; return $out; } # Each part is the unit cube, sized and placed relative to the body. my %PART = ( hull => m_scale(0.5, 0.25, 1.0), cockpit => m_mul(m_translate(0, 0.17, -0.05), m_scale(0.25, 0.12, 0.4)), lwing => m_mul(m_translate(-0.5, 0, 0.15), m_scale(0.6, 0.06, 0.5)), rwing => m_mul(m_translate( 0.5, 0, 0.15), m_scale(0.6, 0.06, 0.5)), ); sub craft_node { my $c = $game{craft}; # Moving right, the craft banks right: clockwise seen from behind, # which is a negative angle about z. return { name => 'craft', local => m_mul(m_translate($c->{x}, 0.5, $c->{z}), m_rotate_z(-2.5 * $c->{vx})), children => [ map { +{ name => $_, local => $PART{$_}, children => [] } } sort keys %PART ] }; } ``` `walk` is the [scene-graph chapter's](scene-graphs), unchanged. The body node carries the craft's position and its bank; the four parts carry only their offset and size relative to the body. Banking rotates the body, and the wings tip with it, one down and one up, without any part knowing the angle. That is the turntable and arm from that chapter again. The parts are cubes stretched by different amounts along each axis, and the [GPU scene chapter](gpu-3d-scene) warned that a non-uniform scale bends normals. It does not bend these: every face normal of a box lies along one of the box's own axes, a scale only lengthens such a vector, and the fragment shader normalises it back. The warning is for slanted faces. ```{index} single: chase camera ``` ## The chase camera ```perl sub camera { my $c = $game{craft}; # Eye and target slide sideways together, by less than the craft # moves: the view never turns, and the craft drifts off centre in a # turn, which is what makes the steering visible. my $x = 0.6 * $c->{x}; return ([ $x, 2.5, $c->{z} + 6 ], [ $x, 0.5, $c->{z} - 6 ]); } ``` The eye is 6 units behind the craft and 2.5 up, looking at a point 6 units ahead of it on the floor. Both move with the craft, so the view matrix from [`m_look_at`](foundations) changes every step while the craft's place in the picture barely does. The `up` hint is `[0, 1, 0]`, safely away from the view direction, which never points straight down. ```{index} single: instancing; whole frame in one draw single: %; negative operand ``` ## One draw for everything ```perl sub decode { return [ map { $_ ** 2.2 } @_ ] } # picked by eye: to linear light my %COLOUR = ( hull => decode(0.85, 0.85, 0.90), cockpit => decode(0.20, 0.50, 0.95), wing => decode(0.95, 0.50, 0.10), crashed => decode(0.90, 0.10, 0.10), block => decode(0.55, 0.35, 0.75), gem => decode(1.00, 0.80, 0.10), rail => decode(0.85, 0.85, 0.90), sky => decode(0.50, 0.62, 0.80), tile0 => decode(0.22, 0.24, 0.28), tile1 => decode(0.30, 0.32, 0.36), ); use constant MAX_INSTANCES => 4 + N_BLOCKS + N_GEMS + 30 + 2 + 20; sub instances { my @inst; my $add = sub { my ($m, $colour) = @_; push @inst, @$m, @$colour, 1 }; my $c = $game{craft}; my $world = walk(craft_node(), m_identity(), {}); for my $name (sort keys %PART) { my $colour = $game{over} ? 'crashed' : $name =~ /wing$/ ? 'wing' : $name; $add->($world->{$name}, $COLOUR{$colour}); } for my $b (@{ $game{blocks} }) { $add->(m_mul(m_translate($b->{x}, $b->{h} / 2, $b->{z}), m_scale($b->{w}, $b->{h}, $b->{w})), $COLOUR{block}); } my $spin = 0.05 * $game{steps}; for my $g (grep { !$_->{taken} } @{ $game{gems} }) { $add->(m_mul(m_translate($g->{x}, 0.6, $g->{z}), m_mul(m_rotate_y($spin), m_mul(m_rotate_z(0.785), m_scale(0.4, 0.4, 0.4)))), $COLOUR{gem}); } # The floor: 30 strips two units deep, from behind the camera to the # far end. A strip's colour comes from its place in the world, not # its place in this list, so the stripes stay put as the craft passes. my $z0 = 2 * int($c->{z} / 2) + 8; for my $k (0 .. 29) { $add->(m_mul(m_translate(0, -0.05, $z0 - 2 * $k - 1), m_scale(2 * LANE, 0.1, 2)), $COLOUR{ ($z0 / 2 - $k) % 2 ? 'tile1' : 'tile0' }); } for my $side (-1, 1) { # rails along the lane edges $add->(m_mul(m_translate($side * (LANE + 0.15), 0.15, $c->{z} - 22), m_scale(0.3, 0.3, 60)), $COLOUR{rail}); } # The score: one small gem per point, in a row above the lane. my $shown = $game{score} < 20 ? $game{score} : 20; for my $k (0 .. $shown - 1) { $add->(m_mul(m_translate(0.6 * $c->{x} + 0.4 * ($k - ($shown - 1) / 2), 3.0, $c->{z} - 4), m_mul(m_rotate_z(0.785), m_scale(0.2, 0.2, 0.2))), $COLOUR{gem}); } return @inst; } ``` Everything on the screen is the one cube from the [GPU scene chapter](gpu-3d-scene): the craft's four parts, the blocks, the gems turned onto a corner and spun, the floor strips, the rails and the score. Each is an instance, a model matrix and a colour, 20 floats, and the whole frame is **one** `draw_indexed` call with however many instances this frame has. The count varies, since taken gems are not drawn and the score row grows, but never beyond `MAX_INSTANCES`, 88, which sizes the buffer once. The floor strips show why instancing and plain data suit each other. There is no floor object to move: each frame computes where the 30 strips are from the craft's position, and the pattern stays fixed to the world because a strip's colour depends on which two-unit band it covers. `%` of a negative number is non-negative in Perl when the right-hand side is positive, so the bands alternate correctly on both sides of zero. The colours are picked by eye and decoded into linear light before upload, for the reason the [GPU scene chapter](gpu-3d-scene) gives: the surface stores sRGB and the lighting is computed in linear light. ```{index} single: fog ``` ## The shader: per-fragment light, and fog ```perl my $WGSL = <<'WGSL'; struct Scene { view_proj: mat4x4, light_dir: vec4, // xyz: toward the light, world space eye: vec4, // xyz: camera position, world space fog: vec4, // rgb: the colour distance fades to }; struct Instance { model: mat4x4, colour: vec4, }; @group(0) @binding(0) var scene: Scene; @group(0) @binding(1) var inst: array; struct VOut { @builtin(position) pos: vec4, @location(0) world: vec3, @location(1) normal: vec3, @location(2) colour: vec3, }; @vertex fn vs(@location(0) p: vec3, @location(1) n: vec3, @builtin(instance_index) i: u32) -> VOut { let m = inst[i].model; let w = m * vec4(p, 1.0); var o: VOut; o.pos = scene.view_proj * w; o.world = w.xyz; o.normal = (m * vec4(n, 0.0)).xyz; o.colour = inst[i].colour.rgb; return o; } @fragment fn fs(v: VOut) -> @location(0) vec4 { let n = normalize(v.normal); let l = normalize(scene.light_dir.xyz); let e = normalize(scene.eye.xyz - v.world); let lambert = max(dot(n, l), 0.0); let h = normalize(l + e); let spec = pow(max(dot(n, h), 0.0), 32.0); let c = min(v.colour * (0.1 + 0.9 * lambert) + vec3(0.4) * spec, vec3(1.0)); // Fade to the fog colour from 25 to 55 units away, so that a block // placed at the far end appears out of the haze instead of popping in. let f = clamp((distance(scene.eye.xyz, v.world) - 25.0) / 30.0, 0.0, 1.0); return vec4(mix(c, scene.fog.rgb, f), 1.0); } WGSL ``` Everything above the fog is the [GPU scene chapter's](gpu-3d-scene) shader: instanced model matrices from a storage buffer, the normal turned with `w = 0`, and [shading's](shading) Lambert and Blinn-Phong terms evaluated per fragment. The fog is two lines and does a real job. A recycled block reappears 58 units from the eye, and without the fog it would appear there in one frame, fully drawn. With it, anything beyond 55 units is exactly the fog colour, and the fog colour is the clear colour, so the block is invisible until it comes within range and then fades in. Both colours are linear, so the `mix` is in linear light like the rest of the lighting. The `fog` member is a `vec4` for the alignment reason the [GPU scene chapter](gpu-3d-scene) gives, and it makes the uniform 28 floats: 16 for the matrix and three `vec4`s. ## The device and the buffers The cube is the [GPU scene chapter's](gpu-3d-scene), unchanged: ```perl my @FACES = ( # normal u (right) v (up) u x v == normal [[ 1, 0, 0], [ 0, 0,-1], [0, 1, 0]], [[-1, 0, 0], [ 0, 0, 1], [0, 1, 0]], [[ 0, 1, 0], [ 1, 0, 0], [0, 0,-1]], [[ 0,-1, 0], [ 1, 0, 0], [0, 0, 1]], [[ 0, 0, 1], [ 1, 0, 0], [0, 1, 0]], [[ 0, 0,-1], [-1, 0, 0], [0, 1, 0]], ); my (@vert, @idx); for my $f (@FACES) { my ($n, $u, $v) = @$f; die "face winds the wrong way\n" unless v_dot(v_cross($u, $v), $n) > 0; my $base = @vert / 6; for my $c ([-1,-1], [1,-1], [1,1], [-1,1]) { my $p = v_add(v_scale($n, 0.5), v_add(v_scale($u, 0.5 * $c->[0]), v_scale($v, 0.5 * $c->[1]))); push @vert, @$p, @$n; } push @idx, map { $base + $_ } 0, 1, 2, 0, 2, 3; } ``` The window, the device and the surface, from the [device chapter](gpu-device-and-window): ```perl my $win = SDL2::Window->new(title => 'pperl run', w => 800, h => 600, resizable => 1); my $gpu = Peta::WebGPU::Device->new(window => $win); my $surf = $gpu->surface; my $FORMAT = $surf->format; my ($W, $H) = $surf->size; ``` These five lines are the only ones that need a window. Everything after them uses `$gpu`, `$FORMAT`, `$W` and `$H`, so replacing them with a device opened without a window and a texture format renders the same game into a texture, which is how it is checked below. ```perl my $pipe = $gpu->render_pipeline($gpu->shader($WGSL), format => $FORMAT, depth => 1, cull => 'back', vertex_buffers => [ { attributes => ['float32x3', 'float32x3'] } ]); my $verts = $gpu->vertex_buffer(pack 'f*', @vert); my $index = $gpu->index_buffer(pack 'L*', @idx); my $depth = $gpu->depth_buffer($W, $H); my $instances = $gpu->storage_buffer(MAX_INSTANCES * 20 * 4); my $scene = $gpu->uniform_buffer(28 * 4); my $gl_to_wgpu = [1,0,0,0, 0,1,0,0, 0,0,0.5,0, 0,0,0.5,1]; sub draw { my ($target) = @_; my @inst = instances(); $instances->write(pack 'f*', @inst); my ($eye, $look) = camera(); my $proj = m_mul($gl_to_wgpu, m_perspective(60, $W / $H, 0.1, 100)); my $vp = m_mul($proj, m_look_at($eye, $look, [0, 1, 0])); $scene->write(pack 'f*', @$vp, @{ v_norm([-0.4, 1.0, 0.6]) }, 0, @$eye, 1, @{ $COLOUR{sky} }, 1); $gpu->render_pass($target, clear => [ @{ $COLOUR{sky} }, 1 ], depth => $depth) ->pipeline($pipe)->bind([$scene, $instances]) ->vertices($verts)->indices($index)->draw_indexed(scalar @idx, @inst / 20)->end; } ``` The storage buffer is created at its largest size, and each frame writes only as many instances as it has. `draw_indexed`'s second argument says how many to draw, so whatever lies beyond them in the buffer is never read. `$gl_to_wgpu` is the depth-range correction from the [GPU scene chapter](gpu-3d-scene): without it, anything closer to the eye than about twice the near distance is silently cut away. ## Score and game over, without text ```perl my $title = ''; sub show_score { my $text = "pperl run - score $game{score}"; $text .= " - crashed, Space to fly again" if $game{over}; return if $text eq $title; # only when it changes $win->set_title($text); $title = $text; } ``` There is no text on the screen, and the game does not need any. The score is in two places: the window's title bar, through the `SDL2` window's `set_title`, and the scene itself, as the row of small gems that grows over the lane. A crash shows the same way: the title says so, the craft turns red, and the world stops, because `update` returns at once when `over` is set. The title changes only when the text does, rather than sending the window system the same string every frame. ```{index} single: fixed timestep; in a game ``` ## The loop ```perl new_game(); my ($last, $acc, $frames, $running) = (SDL_GetTicks(), 0, 0, 1); while ($running) { while (my $ev = SDL_PollEvent()) { if ($ev->{type} == SDL_QUIT) { $running = 0 } elsif ($ev->{type} == SDL_KEYDOWN && $ev->{sym} == SDLK_ESCAPE) { $running = 0 } elsif ($ev->{type} == SDL_WINDOWEVENT && $ev->{event} == SDL_WINDOWEVENT_SIZE_CHANGED) { ($W, $H) = @$ev{qw(data1 data2)}; $surf->configure($W, $H); $depth = $gpu->depth_buffer($W, $H); # must match the frame } } my $steer = input(); my $now = SDL_GetTicks(); $acc += $now - $last; $last = $now; my $steps = 0; while ($acc >= STEP) { update($steer); $acc -= STEP; if (++$steps >= MAX_STEPS) { $acc = 0; last } } show_score(); my $frame = $surf->next_frame or next; draw($frame); $frame->present; $running = 0 if $max_frames && ++$frames >= $max_frames; } printf "%d frames, score %d%s\n", $frames, $game{score}, $game{over} ? ', crashed' : ''; ``` This is the [device chapter's](gpu-device-and-window) loop with two lines added, `input` and `show_score`. The event loop handles quitting and resizing; the keyboard state handles steering. The fixed step keeps the game's speed independent of the display: a 144 Hz display draws more frames of the same run, not a faster run. **The step cap and the dropped backlog matter more in a game than anywhere else.** Without them, a window dragged for a second would come back and run 60 steps at once, and the player would find the craft already wrecked on a block they never saw. ## Running the game Save the blocks above in order as `run.pl`, with `foundations.pl` beside it, and start it: ```bash pperl run.pl ``` The left and right arrow keys steer, Space flies again after a crash, and Escape or the close button quits. An optional number runs that many frames and stops, which is how a script can run it unattended. In a window, the program draws and presents a new frame on every pass of the loop. Captures of the window taken a quarter of a second apart show the blocks coming closer, and when the craft meets one the title bar changes from `pperl run - score 0` to `pperl run - score 0 - crashed, Space to fly again` and the craft turns red. ```{index} single: headless rendering; testing a game single: deterministic replay ``` ## Checking it without a keyboard A player cannot be scripted through the keyboard, as the input section found, and a picture is easiest to check as bytes in a texture, as the GPU arc checked its own. Both point the same way: keep the game and change its two ends. The five window lines are replaced by a device opened without one: ```perl my $gpu = Peta::WebGPU::Device->new; # no window my $FORMAT = 'rgba8unorm-srgb'; # what the window shows, in R, G, B, A order my ($W, $H) = (640, 480); ``` and the last two blocks, the title and the loop, are left out. What remains is the whole game, driven by calls instead of keys: ```perl srand 7; # the same field every time new_game(); update(0) for 1 .. 20; # straight on update(1) for 1 .. 12; # then hard right my $img = $gpu->texture(width => $W, height => $H, format => $FORMAT); draw($img); save_png('turn.png', $img->read, $W, $H); ``` `save_png` is the [device chapter's](gpu-device-and-window). Run this way, the behaviour described in this chapter was checked against the game's own subs: ```text straight on, seed 7 crash on step 91: a block at x = 1.27, width 1.01, its left face at 0.765 against the box's 0.80 60 more updates state unchanged, keys or not right arrow held vx = 0.036 0.065 0.088 0.106 0.121 on steps 1 to 5 held at the edge x = 3.200, vx = 0; the first step back moves at once gem straight ahead inside the box for 14 steps, scored 1 gem at x = 1.1, 1.2 scored 1, 0: the box edge 0.8 plus the radius 0.35 is 1.15 chase camera craft in view space at [0.4 x, -0.986, -6.247] throughout 5000 steps, lane clear speed 0.3 from step 4500, 574 blocks and gems reused, never more than 88 instances same seed, same inputs a recorded run of 472 steering values, replayed, ends in the identical state ``` The camera line is the chase camera's invariant, in the form the [scene-graph chapter](scene-graphs) tested its orbit camera: the craft's position relative to the camera never changes in height or depth, and sideways it moves by `0.4 x`, the part of its movement the camera does not follow. That is what "the camera follows" means as a number. The rendered frames say the rest. The pixel at the point where foundations' screen mapping puts the top of the hull is the hull's colour, lit, in every frame; after the crash the same pixel is red; and the sky reads back as `128 160 205`, within two of the `0.5, 0.62, 0.8` picked by eye, because the decode before upload and the surface's encode on the way out undo each other: ```text frame hull top cockpit sky start 200 200 212 43 118 223 128 160 205 turn 179 179 190 37 105 200 128 160 205 crashed 212 16 16 212 16 16 128 160 205 ``` The turning frame's hull is darker because the bank tips its top away from the light. The frame with two gems collected shows two small gems above the lane, at the pixels where the score row's positions project, `195 157 24` and `194 156 22`. ## What this demonstrates A game is the smallest program that needs every part of this guide at once. The mathematics places the objects and the camera; the meshes chapter's volumes decide what touches what; the scene graph lets a part follow its parent; the GPU arc draws every object in one call and lights every pixel; the fixed step keeps the simulation honest about time. And the shape that made the 2D game easy to reason about carried over unchanged. The world is a hash, `update` is arithmetic on it, and the draw step reads it. Because `update` takes its input as an argument, the whole game can be played by a list of numbers, and that is how it was checked.