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

boxes and spheres as bounding volumes

meshes

the node hierarchy and walk

scene graphs

the window, the surface, the fixed step with its cap

the device chapter

the pipeline, vertex and index buffers

the pipeline chapter

the cube, depth, instancing, per-fragment light

the GPU scene chapter

key_pressed and scancodes

the 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 does.

The world is plain data#

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

Collision: boxes and a sphere#

# 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 the meshes chapter introduced, the axis-aligned box and the sphere, and the scene-graph chapter 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 is for when there are thousands; it would answer the same question with fewer tests, and nothing else would change.

Update#

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.

Input#

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

The craft is a small hierarchy#

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

The chase camera#

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

One draw for everything#

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: 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 gives: the surface stores sRGB and the lighting is computed in linear light.

The shader: per-fragment light, and fog#

my $WGSL = <<'WGSL';
struct Scene {
    view_proj: mat4x4<f32>,
    light_dir: vec4<f32>,     // xyz: toward the light, world space
    eye:       vec4<f32>,     // xyz: camera position, world space
    fog:       vec4<f32>,     // rgb: the colour distance fades to
};
struct Instance {
    model:  mat4x4<f32>,
    colour: vec4<f32>,
};
@group(0) @binding(0) var<uniform> scene: Scene;
@group(0) @binding(1) var<storage, read> inst: array<Instance>;

struct VOut {
    @builtin(position) pos: vec4<f32>,
    @location(0) world:  vec3<f32>,
    @location(1) normal: vec3<f32>,
    @location(2) colour: vec3<f32>,
};

@vertex
fn vs(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>,
      @builtin(instance_index) i: u32) -> VOut {
    let m = inst[i].model;
    let w = m * vec4<f32>(p, 1.0);
    var o: VOut;
    o.pos    = scene.view_proj * w;
    o.world  = w.xyz;
    o.normal = (m * vec4<f32>(n, 0.0)).xyz;
    o.colour = inst[i].colour.rgb;
    return o;
}

@fragment
fn fs(v: VOut) -> @location(0) vec4<f32> {
    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<f32>(0.4) * spec, vec3<f32>(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<f32>(mix(c, scene.fog.rgb, f), 1.0);
}
WGSL

Everything above the fog is the GPU scene chapter’s shader: instanced model matrices from a storage buffer, the normal turned with w = 0, and shading’s 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 gives, and it makes the uniform 28 floats: 16 for the matrix and three vec4s.

The device and the buffers#

The cube is the GPU scene chapter’s, unchanged:

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:

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.

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: without it, anything closer to the eye than about twice the near distance is silently cut away.

Score and game over, without text#

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.

The loop#

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 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:

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.

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:

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:

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. Run this way, the behaviour described in this chapter was checked against the game’s own subs:

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 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:

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.