Files
akbasic/docs/20-tutorial-galaga.md
Tachikoma d5a0edd692 Write the GALAGA tutorial chapters and the repeated-host-calls guide
docs/20 builds the engine and the boundary: the startup order, the
starfield, actors and collision, booting a DEF-only script, the issue #8
mode workaround, the custom update hook, first light, screens, and the
headless harness. docs/21 builds the three shared structures and the AI:
the host type tables, the actor binding, the randomness route around
issue #16, the measured case against structure arguments (issue #36),
the three language rules that shape the script, the maneuvers, the
argued formation decision, the script-death policy, and the interop
proof. Every fenced block runs under tests/docs_examples.sh in both
build configurations; five new preludes carry the C fragments.

docs/10 gains the 'Calling a function every frame' section the chapters
lean on: the per-call akbasic_environment_zero() rule, the set_mode(RUN)
workaround, the clear_error() revival, and the case for rebinding over
structure arguments. Index rows and chapter counts updated.

Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 08:47:43 -04:00

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

A full wave: four green bosses, two rows of butterflies, bees still streaming into the grid, the player firing

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

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:

  • updatefunc after akgl_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 full akgl_CollisionWorld machinery earns its keep on tilemaps, not here. The shot_box/enemy_box helpers 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 because akgl_game_update() re-reads each slot's refcount as it goes.
  • Names carry a serialpshot17, not pshot1 reused — 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.

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. In CMake:

target_link_libraries(akbasic_example_galaga PRIVATE akbasic akgl
  SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)

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:

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.

The title screen: the banner, the menu, the starfield

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.