The interop test now ends with a measured comparison: 24,000 formation updates through the script boundary against a line-for-line C translation of the same state machine. 881 us against 0.01 us per call on this machine, quoted verbatim in the new chapter 21 Step 11 with the architectural decisions it prices. A Haiku-class cold read of the chapters produced a build whose failures were all mechanical -- invented include paths, never-shown sink statics, guessed status codes and character names. The chapters now carry the include lists, the script.c statics, the status-code roster, the sprite/character table, the full CMake recipe and the explosion spawn's HANDLE example, so none of those have to be guessed again. Co-authored-by: andrew <andrew@aklabs.net> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
27 KiB
20. Tutorial: GALAGA — a C engine with a BASIC brain
This chapter and Chapter 21 build a GALAGA-style
fixed shooter from an empty file. The engine — window, starfield, bullets,
collision, score, screens — is C on libakgl. The enemies think in BASIC: one
script of DEF functions is called once per enemy per frame, and it reads and
writes the engine's own structures with no marshalling in either direction.
This chapter builds the engine and proves the boundary works; the next one
fills in the data structures and the AI.
The split is the point. Everything mechanical stays compiled, and everything an enemy decides is a text file you can edit and re-run without rebuilding. It is an academic exercise in how such an embed is done, not a claim that it is the best way to write a GALAGA.
This is what the two chapters build:
The finished program is examples/galaga/: four C files,
one galaga.bas, and the assets. You do not need it to follow along, but it is
the same program assembled.
$ cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
$ cmake --build build-akgl --target akbasic_example_galaga
$ ./build-akgl/akbasic_example_galaga
| Key | Does |
|---|---|
| left / right | move the ship |
| space | fire — two shots on screen at a time, the classic rule |
| return | choose a menu entry |
What you will do
- Step 1 — open a window, in the one startup order that works
- Step 2 — scatter a starfield and scroll it, with no parallax machinery at all
- Step 3 — put a ship on screen from a sprite and a character file, and drive it from the keyboard
- Step 4 — spawn shots from the actor heap and collide them by hand
- Step 5 — link the interpreter in, load a script of definitions, and call one from C
- Step 6 — replace an actor's update hook so its every frame is a BASIC call
- Step 7 — watch one enemy move under BASIC control, and read the same numbers from both sides
- Step 8 — add the title, game over and victory screens
- Step 9 — run the whole game headless, so CI can play it every night
Each step compiles and runs. The C fragments quote the finished example; the
file layout there — main.c for the harness, script.c for the boundary,
enemies.c and player.c for the actors — is a good one to copy.
Step 1: Open a window
Goal: a black window with a title, from the canonical startup order.
libakgl has one startup sequence that works, documented at the top of its
include/akgl/game.h and walked through in its own tutorial (libakgl
docs/20-tutorial-sidescroller.md). The order matters twice: the screen
properties are read by the renderer, so they must be set before it exists, and
akgl_game_init() does not install a physics backend, so the application
must.
static akerr_ErrorContext *startup(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, aksl_strncpy((char *)&akgl_game.name, sizeof(akgl_game.name),
"akbasic galaga tutorial", sizeof(akgl_game.name) - 1));
PASS(errctx, aksl_strncpy((char *)&akgl_game.version, sizeof(akgl_game.version),
"1.0.0", sizeof(akgl_game.version) - 1));
PASS(errctx, aksl_strncpy((char *)&akgl_game.uri, sizeof(akgl_game.uri),
"net.aklabs.akbasic.galaga", sizeof(akgl_game.uri) - 1));
PASS(errctx, akgl_game_init());
PASS(errctx, akgl_set_property("game.screenwidth", "1280"));
PASS(errctx, akgl_set_property("game.screenheight", "960"));
PASS(errctx, akgl_render_2d_init(akgl_renderer));
FAIL_ZERO_RETURN(
errctx,
SDL_SetRenderLogicalPresentation(
akgl_renderer->sdl_renderer,
1280,
960,
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
AKGL_ERR_SDL,
"%s",
SDL_GetError()
);
akgl_camera->x = 0.0f;
akgl_camera->y = 0.0f;
akgl_camera->w = 1280.0f;
akgl_camera->h = 960.0f;
PASS(errctx, akgl_physics_init_null(akgl_physics));
SUCCEED_RETURN(errctx);
}
Three of those lines deserve their reasons.
The view is 1280x960 because the artwork is ~100 pixels wide. libakgl draws
a sprite at the sprite's own size — akgl_Actor.scale is overwritten every
frame, so there is no way to draw one smaller (libakgl docs/12-actors.md) — and
a ten-column formation of 100-pixel ships needs 1120 pixels plus margins. The
view is sized to the art rather than the art resized to a view.
akgl_physics_init_null() is not optional. Skip it and the first
akgl_game_update() calls through a NULL simulate pointer. Null physics
accepts every call and moves nothing, which is exactly right here: whatever
writes x and y directly is the mover, and in this game that will be BASIC.
Error handling is the house protocol. Every function returns
akerr_ErrorContext *, PASS propagates, ATTEMPT/CATCH/CLEANUP brackets
anything that must unwind. libakgl's docs/04-errors.md teaches it; this chapter
just uses it. The status codes this game raises are AKERR_NULLPOINTER,
AKERR_VALUE, AKERR_KEY, AKERR_IO, AKERR_OUTOFBOUNDS, AKGL_ERR_SDL
and AKGL_ERR_HEAP — there is no code this tutorial invents.
The includes the engine files draw on, so nothing later has to be guessed —
the SDL satellites use their own prefixes (SDL3_ttf/SDL_ttf.h, not
SDL3/SDL_ttf.h):
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/controller.h>
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/sprite.h>
#include <akgl/text.h>
#include <akgl/ui.h>
#include <akgl/util.h>
The frame loop is the standard bracket, with one addition you will meet in Step 6 — for now, events in, world drawn, frame out:
while ( SDL_PollEvent(&event) == true ) {
CATCH(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
}
CATCH(errctx, akgl_renderer->frame_start(akgl_renderer));
CATCH(errctx, akgl_game_update(NULL));
CATCH(errctx, akgl_renderer->frame_end(akgl_renderer));
akgl_game_update(NULL) is update-every-actor, step-the-physics,
draw-the-world. It neither clears nor presents; the frame_start and
frame_end calls own that.
Step 2: Scatter a starfield
Goal: a scrolling two-depth starfield, from an array and one draw call.
No parallax facility exists in libakgl and none is needed. A fixed array of
stars, advanced per frame and drawn with akgl_draw_point() between
frame_start and akgl_game_update(), is the whole feature. Two speed bands
give the depth for free — the slow band reads as far away:
#define GALAGA_STARS 96
static struct
{
float x;
float y;
float speed;
Uint8 bright;
} STARS[GALAGA_STARS];
static akerr_ErrorContext *starfield_draw(float dt)
{
SDL_Color color = { 255, 255, 255, 255 };
int i = 0;
PREPARE_ERROR(errctx);
for ( i = 0; i < GALAGA_STARS; i++ ) {
STARS[i].y += STARS[i].speed * dt;
if ( STARS[i].y > 960.0f ) {
STARS[i].y -= 960.0f;
}
color.r = STARS[i].bright;
color.g = STARS[i].bright;
color.b = STARS[i].bright;
PASS(errctx, akgl_draw_point(akgl_renderer, STARS[i].x, STARS[i].y, color));
}
SUCCEED_RETURN(errctx);
}
Seed the array once at startup — even indexes slow and dim (speed 40, bright 110), odd indexes fast and bright (speed 110, bright 220) — and the effect is done. A point is exactly one pixel (libakgl docs/09-drawing.md).
Step 3: Put a ship on screen
Goal: a player actor, drawn from a character file, moving on key input.
The art is Kenney's Space Shooter pack, CC0, used byte for byte — see
examples/galaga/assets/art/PROVENANCE.md
for what each file is. An actor gets its looks from a character, which maps
actor state words to sprites (libakgl docs/10 and 12). Both are JSON; load
sprites first, because a character names its sprites and a character loaded
first fails on the first name it cannot find.
These are the names, so the loading lists and every
akgl_actor_set_character() call in both chapters agree — each sprite_*.json
and character_*.json lives in assets/:
| Character | Sprite(s) it maps | Worn by |
|---|---|---|
galaga_player |
galaga_player |
the ship |
galaga_bee |
galaga_bee |
bees |
galaga_butterfly |
galaga_butterfly |
butterflies |
galaga_boss |
galaga_boss, and galaga_boss_hurt on state bit 13 |
bosses |
galaga_playershot |
galaga_playershot |
the ship's shots |
galaga_enemyshot |
galaga_enemyshot |
enemy shots |
galaga_boom |
galaga_boom |
explosions |
The spawn is four decisions after the two boilerplate calls:
static akerr_ErrorContext *galaga_player_spawn(void)
{
akgl_Actor *player = NULL;
PREPARE_ERROR(errctx);
PASS(errctx, akgl_heap_next_actor(&player));
PASS(errctx, akgl_actor_initialize(player, "player"));
PASS(errctx, akgl_actor_set_character(player, "galaga_player"));
/* AFTER initialize: it resets all seven hooks. */
player->updatefunc = &player_update;
player->movement_controls_face = false;
player->state = AKGL_ACTOR_STATE_ALIVE;
player->visible = true;
player->x = 590.0f;
player->y = 860.0f;
galaga_game.player = player;
SUCCEED_RETURN(errctx);
}
Each of the four lines under the comment closes a trap:
updatefuncafterakgl_actor_initialize(), never before — initialize installs all seven default hooks, and a hook set first is a hook reset.movement_controls_face = false. The default facing logic edits the state word, a character mapping matches the whole word, and an actor whose state matches no mapping is silently not drawn. Nothing here moves by state bits, so facing stays out of the word entirely.state = AKGL_ACTOR_STATE_ALIVE— the word the character mapping names.visible = true.akgl_actor_initialize()does not raise it. In a tilemap game the map loader copies visibility from map data; there is no map here, so an actor that skips this line exists, moves, fires and collides — invisibly. This one line cost this example its first screenshot.
Input goes through a control map: push a control per key with handlers that set
flags, and let the actor's update hook read the flags. The full recipe is in
examples/galaga/player.c and libakgl docs/16-input.md; the shape is:
static akerr_ErrorContext *galaga_player_controls(void)
{
akgl_Control control;
PREPARE_ERROR(errctx);
memset(&control, 0, sizeof(control));
control.event_on = SDL_EVENT_KEY_DOWN;
control.event_off = SDL_EVENT_KEY_UP;
control.key = SDLK_LEFT;
control.handler_on = &left_on;
control.handler_off = &left_off;
PASS(errctx, akgl_controller_pushmap(0, &control));
control.key = SDLK_RIGHT;
control.handler_on = &right_on;
control.handler_off = &right_off;
PASS(errctx, akgl_controller_pushmap(0, &control));
control.key = SDLK_SPACE;
control.handler_on = &fire_on;
control.handler_off = &fire_off;
PASS(errctx, akgl_controller_pushmap(0, &control));
akgl_controlmaps[0].target = galaga_game.player;
SUCCEED_RETURN(errctx);
}
Hand every polled event to akgl_controller_handle_event() — one that no
control binds is not an error, it is a call that did nothing.
Step 4: Shots and collision
Goal: bullets that fly, hit, and give their actor slot back.
Bullets and collision are C forever — they are engine, not behavior. A shot is an actor from the same 64-slot heap pool, with its own tiny update hook: move, test, release.
static akerr_ErrorContext *player_shot_update(akgl_Actor *obj)
{
SDL_FRect mine;
SDL_FRect theirs;
bool hit = false;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
obj->y -= 900.0f * galaga_game.dt;
if ( obj->y < -60.0f ) {
galaga_game.player_shots_live -= 1;
PASS(errctx, akgl_heap_release_actor(obj));
SUCCEED_RETURN(errctx);
}
shot_box(obj, &mine);
for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) {
if ( galaga_enemy_actors[i] == NULL ) {
continue;
}
enemy_box(galaga_enemy_actors[i], &theirs);
PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit));
if ( !hit ) {
continue;
}
galaga_enemies[i].hp -= 1;
if ( galaga_enemies[i].hp <= 0 ) {
PASS(errctx, kill_enemy(i));
}
galaga_game.player_shots_live -= 1;
PASS(errctx, akgl_heap_release_actor(obj));
SUCCEED_RETURN(errctx);
}
SUCCEED_RETURN(errctx);
}
Four conventions worth keeping:
akgl_collide_rectangles()is the whole collision system. At most 2 shots x 40 enemies of axis-aligned tests per frame is noise; the fullakgl_CollisionWorldmachinery earns its keep on tilemaps, not here. Theshot_box/enemy_boxhelpers inset each box from the artwork's rectangle, because the PNGs carry transparent margin that should not kill anybody.- Releasing is despawning.
akgl_heap_release_actor()unregisters the actor and stops it drawing; releasing mid-sweep is safe becauseakgl_game_update()re-reads each slot's refcount as it goes. - Names carry a serial —
pshot17, notpshot1reused — because the actor registry is keyed by name, and two live actors with one name is a fight. - Spawn caps are C-side refusals. Two player shots, eight enemy shots; the spawn functions simply decline past the cap.
Give the enemy shots the same shape falling downward, and the ship a sweep over
both — examples/galaga/player.c has all three loops.
Explosions are the fourth actor kind, and they carry the one place this game
absorbs an error instead of propagating it. HANDLE names the status it
forgives; everything else still travels:
static float BOOM_TTL[AKGL_MAX_HEAP_ACTOR];
static uint32_t BOOM_SERIAL = 0;
static akerr_ErrorContext *boom_update(akgl_Actor *obj)
{
ptrdiff_t slot = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
slot = obj - akgl_heap_actors;
BOOM_TTL[slot] -= galaga_game.dt;
if ( BOOM_TTL[slot] <= 0.0f ) {
PASS(errctx, akgl_heap_release_actor(obj));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *galaga_boom_spawn(float x, float y)
{
akgl_Actor *boom = NULL;
char name[32];
int count = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_next_actor(&boom));
BOOM_SERIAL += 1;
CATCH(errctx, aksl_snprintf(&count, name, sizeof(name), "boom%u", BOOM_SERIAL));
CATCH(errctx, akgl_actor_initialize(boom, name));
CATCH(errctx, akgl_actor_set_character(boom, "galaga_boom"));
boom->updatefunc = &boom_update;
boom->movement_controls_face = false;
boom->state = AKGL_ACTOR_STATE_ALIVE;
boom->visible = true;
boom->x = x;
boom->y = y;
BOOM_TTL[boom - akgl_heap_actors] = 0.25f;
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKGL_ERR_HEAP) {
/* Explosions are decoration. When the heap is momentarily full the
* right outcome is no explosion, not a dead frame. */
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
Step 5: Boot the interpreter
Goal: the engine calls a BASIC function and prints its answer.
Everything so far was libakgl. Now link the interpreter into the same
executable. The whole CMake recipe, inside an akbasic checkout with
AKBASIC_WITH_AKGL=ON:
add_executable(mygalaga
main.c
script.c
enemies.c
player.c)
target_compile_options(mygalaga PRIVATE -Wall -Wextra)
target_compile_definitions(mygalaga PRIVATE
GALAGA_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets"
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/galaga.bas"
GALAGA_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf")
target_link_libraries(mygalaga PRIVATE akbasic akgl
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)
The three baked-in paths are what let the program launch from any working
directory; --assets and --script flags can override them at runtime.
Link akbasic — the interpreter only. Not akbasic_akgl (the device backends
that let a script draw), and not akbasic_frontend (the standalone program's
host). This game lends the script no devices at all: the scripts compute,
the engine draws, and a script that tries SPRITE is refused by name. That
refusal is enforced by the interpreter, not by convention —
Chapter 10 explains the device-lending model this game
declines to use.
The boot is the embedding host from Chapter 10, adapted to a script that only
defines. Keep every line that touches the interpreter in one file — the
example's script.c — so the boundary stays a place rather than a habit. That
file's interpreter-facing includes and statics, exactly:
#include <akbasic/environment.h>
#include <akbasic/error.h>
#include <akbasic/host.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
/* Static because an akbasic_Runtime is far too big for a stack frame --
* 2.40 MiB on this branch. */
static akbasic_Runtime SCRIPT;
static akbasic_TextSink SINK;
static akbasic_StdioSink SINKSTATE;
static char SOURCE[16384];
The boot itself:
CATCH(errctx, akbasic_error_register());
CATCH(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, NULL));
CATCH(errctx, akbasic_runtime_init(&SCRIPT, &SINK));
CATCH(errctx, akbasic_runtime_load(&SCRIPT, SOURCE));
CATCH(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
CATCH(errctx, akbasic_runtime_run(&SCRIPT, 4 * AKBASIC_MAX_SOURCE_LINES));
CATCH(errctx, akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN));
Two of those lines are the ones a first embedding gets wrong.
A "no top level code" script still has to run once. The script is nothing
but DEF blocks and a final END, and executing the DEF statements is what
files the functions. The run is bounded — a script that is all definitions has
no business taking more than a few steps per line, and an accidental loop at
boot should be a diagnosis, not a hang.
The set_mode after the run is load-bearing. The program has now ended and
the runtime sits in QUIT mode, where a multi-line DEF called from the host
returns a silent zero. Forcing the mode back to RUN makes the bodies run, and it
stays put because nothing here ever steps the runtime again. Issue #8 tracks
making this workaround unnecessary.
PRINT inside the script goes through the stdio sink and lands on stdout —
that is the script's debug channel for the rest of both chapters.
Prove the wiring with one function. Put this in the script:
DEF ADDEM(A#, B#) = A# + B#
END
And call it from C, with values you already have:
memset(&args[0], 0, sizeof(args[0]));
memset(&args[1], 0, sizeof(args[1]));
args[0].valuetype = AKBASIC_TYPE_INTEGER;
args[0].intval = 17;
args[1].valuetype = AKBASIC_TYPE_INTEGER;
args[1].intval = 25;
argp[0] = &args[0];
argp[1] = &args[1];
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "ADDEM", argp, 2, &result));
printf("ADDEM(17, 25) = %lld\n", (long long)result->intval);
ADDEM(17, 25) = 42
akbasic_runtime_call_function() is the host's entry point: a name and
already-evaluated values in, the function's result out. The engine refuses to
start when the script will not boot — a game whose enemies cannot think is not
a game missing a feature, it is a game that does not run.
Step 6: The update hook
Goal: one actor whose every frame is a BASIC call.
akgl_game_update() calls each live actor's updatefunc exactly once per
frame. Replacing that pointer is the whole integration: the actor's frame is
a script call.
static akerr_ErrorContext *enemy_update(akgl_Actor *obj)
{
galaga_Enemy *enemy = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
enemy = (galaga_Enemy *)obj->actorData;
FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "an enemy actor with no galaga_Enemy attached");
enemy->rnd = galaga_random();
PASS(errctx, galaga_script_update_enemy(enemy, obj, galaga_game.dt));
if ( enemy->fire != 0 ) {
PASS(errctx, enemy_fire(enemy, obj));
}
SUCCEED_RETURN(errctx);
}
The hook's body is a protocol, and galaga_script_update_enemy() is its
middle: rebind, call, recover, reset.
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy));
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor));
memset(&dtval, 0, sizeof(dtval));
dtval.valuetype = AKBASIC_TYPE_FLOAT;
dtval.floatval = (double)dt;
argp[0] = &dtval;
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "UPDATEBEE", argp, 1, &result));
CATCH(errctx, akbasic_environment_zero(SCRIPT.environment));
SELF@ and ACTOR@ are host bindings — the enemy's record and the
engine's live actor, shared with the script as structures it can read and
write directly. Chapter 21 builds them; for
this chapter, know that akbasic_host_rebind() points an existing binding at
a different instance, which is how forty enemies share one script: one name,
rebound per enemy, rather than forty names.
The akbasic_environment_zero() after every call is load-bearing. Each
call parks its result in the caller environment's per-line value scratch, and a
host calling in a loop never crosses the line boundary that would reset it.
Without this line the scratch drains in under two frames of a 40-enemy wave and
every later call fails with Maximum values per line reached. Chapter 10's
"Calling a function every frame"
section is the rule's home.
Step 7: First light
Goal: a C actor moving under BASIC control, and proof it is one memory.
Before any real AI, the smallest demonstration. One enemy, one function, a sine drift written entirely in BASIC through the actor binding:
DEF UPDATEBEE(DT%)
SELF@.T% = SELF@.T% + DT%
ACTOR@.X% = 590.0 + SIN(SELF@.T%) * 200
ACTOR@.Y% = 300.0
PRINT "BASIC SEES X = " + ACTOR@.X%
RETURN 0
END
Spawn one enemy with the hook from Step 6, and have the engine print the same actor's position each frame from C:
SDL_Log("C SEES X = %f", galaga_enemy_actors[0]->x);
BASIC SEES X = 593.191094
INFO: C SEES X = 593.191094
BASIC SEES X = 596.378593
INFO: C SEES X = 596.378593
Same numbers, one memory. The script wrote ACTOR@.X%; the renderer read
akgl_Actor.x; nothing copied anything anywhere. The ship swings in a slow
arc, and the whole architecture is visible in that one motion: C owns the
frame, BASIC owns the decision, and the actor is the same bytes to both.
Step 8: Screens
Goal: title, playing, game over, victory — a state machine around the loop.
The screens are libakgl's UI layer, in the three-state pattern of its uidemo
example (libakgl docs/22-ui.md). A galaga_Screen enum, one declare_*()
function per screen, and the UI bracket between akgl_game_update() and
frame_end — exactly where the frame contract puts it:
CATCH(errctx, akgl_ui_frame_begin());
switch ( galaga_game.screen ) {
case GALAGA_SCREEN_TITLE:
CATCH(errctx, declare_title());
break;
case GALAGA_SCREEN_PLAY:
CATCH(errctx, declare_play());
break;
case GALAGA_SCREEN_GAMEOVER:
case GALAGA_SCREEN_VICTORY:
CATCH(errctx, declare_end());
break;
}
CATCH(errctx, akgl_ui_frame_end(akgl_renderer));
The playing screen is two akgl_ui_label() calls — score top-left, lives and
wave top-right — formatted into static buffers, because the UI borrows label
text until frame_end and a local buffer would be dangling by the time it
draws. The title and end screens are an akgl_ui_menu() at the center.
The big GALAGA headline is direct text rather than a label:
static akerr_ErrorContext *draw_banner(char *text)
{
SDL_Color ink = { 235, 235, 235, 255 };
TTF_Font *font = NULL;
int w = 0;
int h = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text");
font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "banner", NULL);
FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "the banner font is not loaded");
PASS(errctx, akgl_text_measure(font, text, &w, &h));
PASS(errctx, akgl_text_rendertextat(font, text, ink, 0, (1280 - w) / 2, 280));
SUCCEED_RETURN(errctx);
}
The menu owns AKGL_UI_ANCHOR_CENTER, a label anchored there disappears
behind it, and there is no top-center anchor — so the headline measures itself
and draws at a coordinate, before the UI bracket so the menu still paints over
it if the two ever meet.
Screen transitions are three rules read after the world updates: lives spent is
GAME OVER, an empty wave is VICTORY, and a menu activation either restarts or
quits. The menu never clears its own activated flag — the state machine that
acts on it does.
Step 9: Run it headless
Goal: the same game, playable by a script, in CI every night.
The example takes five flags, in the pattern of libakgl's sidescroller:
$ SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy SDL_RENDER_DRIVER=software \
./build-akgl/akbasic_example_galaga --frames 600 --autoplay
--frames N bounds the run; --autoplay is a scripted pilot that starts the
game, sweeps the floor and holds fire until the wave assembles; --screenshot PATH --screenshot-frame N write a PNG from the render target — the figures in
this chapter are that flag's output, not pictures somebody took once. Synthetic
input goes through akgl_controller_handle_event() with constructed
SDL_Events, never by calling the handlers directly — the point of autoplay is
to exercise the same path a keyboard does.
The last line of every run is the evidence:
galaga: 600 frames, screen 1, score 1910, alive 9, kills bee 19 bfly 12 boss 0, shots bee 0 bfly 3 boss 0, script errors 0
Exiting 0 is not proof the wave flew. The readout is: kills and shots counted
per kind say the enemies entered, thought and fired, and script errors 0
says every one of the ~24,000 BASIC calls in those ten seconds came back clean.
A wave of dumb enemies still exits 0, and that count is how you notice. The
CTest entry example_galaga runs exactly this under the dummy SDL drivers,
which is what keeps both chapters honest.
That is the engine: a window, a starfield, a ship, bullets, screens, and an interpreter that answers when called. Everything on screen so far is C. What turns it into a GALAGA is Chapter 21 — the three shared structures, the script that thinks through them, and a full wave entering, breathing, diving and firing without another line of engine code.

