Plan: a GALAGA tutorial — C/libakgl engine with akbasic embedded as the enemy-behavior scripting engine #34

Open
opened 2026-08-03 22:49:44 -04:00 by tachikoma · 10 comments
Collaborator

Goal: a GALAGA-style game whose core engine is C on libakgl (null physics), with akbasic linked in as the scripting engine that owns every enemy's behavior — one BASIC script of DEF functions, no top-level code, called per-enemy per-frame through a custom akgl_Actor update hook. The deliverable is not the game; it is a tutorial in the style of the breakout chapters (docs/17, docs/18) that a beginner can follow to build it, plus the checked-in example that keeps the tutorial honest. This is an academic exercise demonstrating how such an embed is done, not a claim that it is the best way to write a GALAGA.

Everything below is grounded against feature/reduce_memory_usage @ 17af2d4 and libakgl main. Three spike programs were built and run against this branch before this plan was written; their results are load-bearing, so they come first.

What was measured before planning

1. The host-call mechanism works end-to-end, with one workaround. A host calling a multi-line DEF after the program has finished gets a silent zero — issue #8, reproduced exactly on this branch. Forcing the mode back first makes the body run:

after run: mode=4 (QUIT)
1. ADDEM(17,25)                          = 42   single-expression DEF: works as-is
2. TRIPLE(17) no workaround              = 0    issue #8, reproduced
3. TRIPLE(17) after set_mode(RUN)        = 51   body runs; mode stays RUN after
4. MOVEBEE(4.0) via SELF@ host binding   : BEE.x 100.0 -> 104.0, BEE.hp 3 -> 2

Measurement 4 is the whole game in miniature: a multi-line DEF reading and writing a host-bound C struct through SELF@, no marshalling, exactly as docs/16-structures.md promises. The mode is forced once after the boot run and stays AKBASIC_MODE_RUN; the engine never steps the runtime again, so nothing else observes it.

2. Sustained host calling exhausts the per-line value scratch unless the host releases it. akbasic_runtime_call_function() parks each result in the caller environment's per-line pool (values[AKBASIC_MAX_VALUES], 64 slots), and a host calling in a loop never crosses the line boundary that would reset it — the calls fail with Maximum values per line reached once the pool drains. akbasic_environment_zero(rt->environment) after each call (once the result is consumed) is the documented reset and holds up under 24,000 calls. This wants a paragraph in docs/10-embedding.md, which currently does not mention it; that doc change is in scope here.

3. The cost fits the frame budget. 40 enemies × 600 frames, each call rebinding SELF@ and running a 3-line DEF body, Release build:

24000 calls in 3.562 s = 148.4 us/call = 5.94 ms per 40-enemy frame

5.94 ms of a 16.6 ms frame is 36% — acceptable for the exercise, and the reason bullets and collision stay in C (a classic GALAGA wave is 40 enemies; 40 is the number that has to fit, and it does).

4. One language rule will bite every enemy script, and it is a rule, not a bug. SELF@.X% + 30 * DT% moves nothing, because the left operand decides integer-vs-float arithmetic (docs/03, docs/13 — a decided deviation, pinned by tests): 30 * DT% truncates DT% to 0. SELF@.X% + DT% * 30 works. The tutorial teaches this idiom early, the way breakout's "Two rules about writing expressions" does.

Load-bearing design decisions

  • Structure types are declared once, in C. akbasic_host_register_type() makes the C struct be the BASIC type (include/akbasic/host.h:117); a script TYPE block with the same name would be refused (host.h:107). So the script contains function definitions only — the "structure type definitions" half of the boundary lives in the AKBASIC_HOST_FIELD tables in the engine, one source of truth, offsets taken from offsetof so they cannot drift.
  • One runtime, one script, one binding per name. akbasic_host_rebind() is documented as exactly this pattern — "a host iterating its enemies rebinds one name rather than creating eight" (host.h:144-149). The update hook rebinds SELF@, calls the function, zeroes the scratch. 40 enemies share one interpreter.
  • The engine lends no devices. All four backends and the UI stay NULL; the scripts compute, the engine draws. PRINT goes to the stdio sink and becomes the script-side debug channel. A script that tries SPRITE gets refused by name — the boundary is enforced by the interpreter, not by convention.
  • Dispatch is a table, not a conditional. Enemy type enum → BASIC function name, static const char * array indexed by the enum. akbasic_runtime_call_function() (include/akbasic/runtime.h:840, new on this branch) takes the name and pre-evaluated akbasic_Value args — the entry point built for verbs is the entry point a host hook wants too.
  • Null physics means the script owns position. akgl_physics_init_null() accepts every call and moves nothing (libakgl include/akgl/physics.h:96-101) — whatever writes x/y directly is the mover, and here that is BASIC writing through the binding. Note the libakgl gotcha: akgl_game_init() does not install any physics backend despite what physics.h's file comment says; skip akgl_physics_init_null(akgl_physics) and the first frame calls through a NULL simulate (libakgl docs/14-physics.md:207-220).

Phases

Phase 1 — Engine skeleton in C

Window, frame loop, starfield, player ship, player fire. This is the phase to move through with haste: give the reader a mostly-working framework and refer to libakgl's own chapters for the gory details (docs/07 the frame, docs/10 sprites, docs/12 actors, docs/16 input).

  • examples/galaga/ in akbasic, wired into the existing examples CMake loop, built when AKBASIC_WITH_AKGL=ON.
  • Startup in the sidescroller tutorial's canonical order: name/version/uri → akgl_game_init() → screen properties → akgl_render_2d_init()akgl_physics_init_null(akgl_physics) → assets. Integer-scale logical presentation for the arcade look.
  • Starfield: no parallax facility exists in libakgl and none is needed — a fixed array of {x, y, speed, color} stars advanced per frame and drawn with akgl_draw_point() between frame_start and akgl_game_update(). Two speed bands give the parallax feel for free.
  • Player: one actor, control map via akgl_controller_pushmap(), fire spawns bullet actors from the heap pool. Bullets, collision (bullet-vs-enemy, enemy-vs-player), and scoring are C forever — they are engine, not behavior.
  • Art: Kenney CC0 space-shooter sprites with a PROVENANCE.md, the breakout precedent.

Exit: ship flies on a scrolling starfield, shoots, runs headless under dummy SDL drivers with --frames N.

Phase 2 — Boot the interpreter and prove the boundary

The embedding host from docs/10-embedding.md, adapted to a script that defines and ends:

  1. akbasic_error_register() once at startup.
  2. akbasic_sink_init_stdio()akbasic_runtime_init().
  3. Register host types, bind SELF@ (and GAME@) to placeholder instances.
  4. akbasic_runtime_load() the script — unnumbered lines, LABEL-free, nothing but DEF blocks and a final END.
  5. akbasic_runtime_start(rt, AKBASIC_MODE_RUN) + akbasic_runtime_run(rt, bounded) — this executes the DEF statements, which is what files the functions; a "no top level code" script still has to run once for its definitions to exist.
  6. akbasic_runtime_set_mode(rt, AKBASIC_MODE_RUN) — the issue #8 workaround, once; the mode stays put because nothing steps the runtime afterward.

The tutorial presents step 6 as the way it is done, and after showing it, notes that issue #8 tracks making the workaround unnecessary — per the house documentation rule, no hand-wringing first.

Exit: engine calls ADDEM(17, 25) at boot, prints 42 through the sink, refuses to start if the script fails to load.

Phase 3 — The interop structures

typedef struct galaga_Enemy         /* one per enemy; hangs off akgl_Actor.actorData */
{
    int32_t  kind;                  /* GALAGA_ENEMY_BEE / BUTTERFLY / BOSS           */
    int32_t  state;                 /* bit flags: ENTERING, IN_FORMATION, DIVING ... */
    float    homex, homey;          /* formation slot, in map pixels                 */
    float    t;                     /* parametric clock for the current maneuver     */
    int32_t  hp;
    int32_t  fire;                  /* outbox: script sets 1, engine consumes        */
    float    rnd;                   /* inbox: engine writes fresh 0..1 each frame    */
} galaga_Enemy;
  • Described once with an AKBASIC_HOST_FIELD table, registered as ENEMY. The 16-fields-per-type and 16-types limits (docs/16-structures.md) shape the struct; the table above spends 9.
  • The actor's live position is a second, smaller binding: register akgl_Actor itself as type ACTOR exposing X%, Y%, STATE#, VISIBLE# — the script writes the engine's real actor memory, which is the demonstrative point of the whole exercise. Two rebinds per enemy per frame; rebind cost is noise against the 148 µs call.
  • GAME@: one global instance — player x/y, wave number, frame dt, a RND% refreshed by the engine each frame. The engine filling randomness is the issue #16 workaround (no RND verb exists); shown first, linked after.
  • Fire is an outbox flag, not a call: spawning takes an actor from the heap pool, and pool exhaustion must be a C-side refusal with the house error context, so C consumes fire and does the spawn.

Exit: hoststruct.c-style round-trip test in the example's own test binary.

Phase 4 — The BASIC script's shape

One file, galaga.bas: function definitions and END, nothing else.

DEF UPDATE_BEE(DT%)
  REM SELF@, ACTOR@, GAME@ are bound by the engine before this call
  ...
  RETURN 0
  • Naming convention UPDATE_<KIND>, dispatched from C by the enum-indexed name table.
  • The function pool is 8 slots (AKBASIC_MAX_FUNCTIONS, include/akbasic/types.h:63) and each slot is a measured 36 KiB of the runtime's 2.40 MiB. Three update functions, a shared path helper, a shared fire-decision helper is 5 — the budget holds, and the tutorial states the count the way breakout states its geometry. If the design grows past 8, raising the limit is one #define and +36 KiB per slot, weighed against this branch's whole purpose; that change would be its own commit with the measurement in the message.
  • Recursion and nesting answer to AKBASIC_MAX_ENVIRONMENTS = 12, which multi-line calls draw from like GOSUB; enemy functions stay flat.
  • The left-operand arithmetic rule gets taught here, once, with the wrong version shown refusing to move.

Phase 5 — The custom actor update hook

static akerr_ErrorContext AKERR_NOIGNORE *enemy_update(akgl_Actor *obj)
{
    galaga_Enemy *enemy = (galaga_Enemy *)obj->actorData;
    /* rebind SELF@ and ACTOR@, build the DT% arg, call UPDATE_<kind>,
       consume the outbox, zero the scratch */
}
...
PASS(errctx, akgl_actor_initialize(actor, name));
actor->updatefunc = &enemy_update;          /* AFTER initialize: it resets all 7 hooks */
actor->actorData = &enemy;
  • updatefunc is called exactly once per live actor per akgl_game_update() (libakgl include/akgl/actor.h:159); replacing it after akgl_actor_initialize() is the documented order (libakgl docs/12-actors.md:139).
  • The hook body is: akbasic_host_rebind("SELF@")akbasic_host_rebind("ACTOR@")akbasic_runtime_call_function(name-from-kind-table) → consume enemy->fireakbasic_environment_zero(rt->environment). That last call is load-bearing (measurement 2).
  • movement_controls_face = false on every enemy, and each kind's character maps its sprite on the bare state word — the silently-invisible-actor trap when a state has no mapped sprite (libakgl docs/12-actors.md:180-185) gets a callout box.
  • A BASIC error in an enemy function is that script's problem, not the engine's: it reports through the sink and the interpreter error that reaches C is handled by marking the enemy dumb (state cleared to formation-hold), never by killing the frame.

Phase 6 — First light

The smallest possible demonstration before any real AI: one enemy, one function, a sine drift written entirely in BASIC through ACTOR@.X%/ACTOR@.Y%. This is the tutorial's screenshot moment — the reader sees a C actor moving under BASIC control and understands the whole architecture from one picture. Text readout: engine prints the enemy's position from C, script PRINTs it from BASIC, same numbers, one memory.

Phase 7 — Formation layout: C or BASIC?

Both work; the tutorial argues it rather than asserting it:

C lays out the grid BASIC lays out the grid
Actor pool safety refusal at spawn, house error path script can request more than 64 exist
Tuning without rebuild no yes
Call budget zero calls one call per spawn
Who knows screen size engine owns it anyway needs it exported through GAME@

Decision: C owns the formation grid, wave tables and spawn timing; BASIC owns everything an enemy does after it exists. The formation slot arrives in SELF@.HOMEX%/HOMEY%, so the breathing of the grid — the idle sway GALAGA formations do — is still BASIC's, computed relative to home. The wave tables are the aligned, commented C arrays the house style already prescribes for tabular data.

Phase 8 — The enemy AI

Three kinds, one state machine shape, all in galaga.bas:

  • Bee: entry curve → formation → occasional solo dive, returning off-screen-bottom to formation.
  • Butterfly: entry curve → formation → dive with lateral weave.
  • Boss: two hit points, escorted dives — reads GAME@ for the player's x to lead the dive.
  • Entry paths as parametric curves driven by SELF@.T%; state transitions flip bits in SELF@.STATE#; firing is SELF@.FIRE# = 1 when a dive crosses the player's column and GAME@.RND% clears a threshold.
  • No RND verb exists (issue #16): the engine refreshes GAME@.RND% every frame from its own PRNG. Shown as the way it is done; issue #16 linked after, breakout's hand-rolled LCG cited as the other route.

Exit: a full wave enters, forms, breathes, dives, fires, dies; headless run reports kills and shots per kind.

Phase 9 — Title, game over, victory

The uidemo three-state pattern (libakgl examples/uidemo) is the closest existing template: a galaga_Screen enum, akgl_ui_init() + one font, akgl_ui_label() with AKGL_UI_ANCHOR_CENTER for TITLE / GAME OVER / VICTORY, akgl_ui_menu() for start/quit, score and lives as anchored labels during play. UI frame bracket sits between akgl_game_update() and frame_end, exactly as libakgl docs/22-ui.md's frame contract draws it. Screen state machine is C; the scripts neither know nor care.

Phase 10 — Assembly, CI and screenshots

  • --frames N --autoplay --screenshot PATH --screenshot-frame N flags per the sidescroller pattern; synthetic input goes through akgl_controller_handle_event(), never the handlers directly.
  • Headless run under SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software becomes the CTest entry; final readout line (frames, score, enemies remaining, ms/frame for the script calls) is the tutorial's closing text block, breakout-style.
  • Screenshots land in docs/images/ via a docs_game_figures-style target, tracked in git, never part of a normal build.

Phase 11 — Write the tutorial

Two chapters, mirroring the breakout split:

  • docs/20-tutorial-galaga.md — the engine and the boundary (phases 1, 2, 5, 6, 9): from empty file to a C game that boots a script and hands one actor to BASIC.
  • docs/21-tutorial-galaga-enemies.md — the data structures and the AI (phases 3, 4, 7, 8): from SELF@ to a full attacking wave.

House tutorial rules apply: opening screenshot, bullet list of steps up front, each bullet its own complete section, first principles, no architecture history, workaround-then-issue-link ordering, readouts as points of comparison. Every fenced block satisfies tests/docs_examples.sh — the C blocks will need a prelude or two added under the existing fence contract, and that checker running against the new chapters is what keeps them from rotting. New chapters go into docs/README.md's index.

The docs/10-embedding.md addendum from measurement 2 (per-call akbasic_environment_zero() for repeated host calls) ships in this phase too.

Phase 12 — Validation by a weaker model

The acceptance test for the prose: a subagent on a much less skilled model gets the two chapters and nothing else — no example source, no repo history — and must reason out a build. Graded on: it compiles, a wave forms, enemies move under BASIC control, the three screens appear. Every point where the subagent stalls or invents is a defect in the tutorial, not the subagent; fix the text, run it again, until a cold read produces a reasonably functional game. Findings and iteration count get recorded in the PR description.

Budgets

Actor heap, 64 slots (AKGL_MAX_HEAP_ACTOR, libakgl include/akgl/heap.h:40):

player            1
player shots      2      /* the classic two-on-screen rule           */
enemies          40      /* 20 bees, 16 butterflies, 4 bosses        */
enemy shots       8
explosions        8      /* short-lived actors, released on animate  */
                ---
                 59 of 64

Interpreter, per the 2.40 MiB runtime on this branch:

DEF functions     5 of 8       /* 36 KiB per slot if raised          */
host types        3 of 16      /* ENEMY, ACTOR, GAME                 */
ENEMY fields      9 of 16
environments     <=3 of 12 deep per call
value scratch    64 per line   /* zeroed after every host call       */

Known defects and gaps this plan routes around

Issue What it costs here The route
#8 multi-line DEF from a host returns silent zero set_mode(RUN) once after boot; measured working
#16 no RND verb engine refreshes GAME@.RND% each frame
per-line scratch exhausts under repeated host calls akbasic_environment_zero() per call; docs/10 addendum in scope
left-operand arithmetic truncates 30 * DT% to 0 a rule, not a bug (docs/03); taught in phase 4
akgl_game_init() installs no physics backend explicit akgl_physics_init_null(akgl_physics); already documented libakgl-side

Deliverables

  • examples/galaga/ — C engine, galaga.bas, assets with PROVENANCE.md, CMake wiring, headless CTest entry
  • docs/20-tutorial-galaga.md, docs/21-tutorial-galaga-enemies.md, images, docs/README.md index rows
  • docs/10-embedding.md addendum on repeated host calls
  • Validation report from the weaker-model cold read

Filed by Tachikoma (Claude Code, Fable 5, 1M context)

**Goal:** a GALAGA-style game whose core engine is C on libakgl (null physics), with akbasic linked in as the scripting engine that owns every enemy's behavior — one BASIC script of `DEF` functions, no top-level code, called per-enemy per-frame through a custom `akgl_Actor` update hook. The deliverable is not the game; it is a tutorial in the style of the breakout chapters (docs/17, docs/18) that a beginner can follow to build it, plus the checked-in example that keeps the tutorial honest. This is an academic exercise demonstrating *how* such an embed is done, not a claim that it is the best way to write a GALAGA. Everything below is grounded against `feature/reduce_memory_usage` @ 17af2d4 and libakgl main. Three spike programs were built and run against this branch before this plan was written; their results are load-bearing, so they come first. ## What was measured before planning **1. The host-call mechanism works end-to-end, with one workaround.** A host calling a multi-line `DEF` after the program has finished gets a silent zero — issue #8, reproduced exactly on this branch. Forcing the mode back first makes the body run: ``` after run: mode=4 (QUIT) 1. ADDEM(17,25) = 42 single-expression DEF: works as-is 2. TRIPLE(17) no workaround = 0 issue #8, reproduced 3. TRIPLE(17) after set_mode(RUN) = 51 body runs; mode stays RUN after 4. MOVEBEE(4.0) via SELF@ host binding : BEE.x 100.0 -> 104.0, BEE.hp 3 -> 2 ``` Measurement 4 is the whole game in miniature: a multi-line `DEF` reading and writing a host-bound C struct through `SELF@`, no marshalling, exactly as docs/16-structures.md promises. The mode is forced once after the boot run and stays `AKBASIC_MODE_RUN`; the engine never steps the runtime again, so nothing else observes it. **2. Sustained host calling exhausts the per-line value scratch unless the host releases it.** `akbasic_runtime_call_function()` parks each result in the caller environment's per-line pool (`values[AKBASIC_MAX_VALUES]`, 64 slots), and a host calling in a loop never crosses the line boundary that would reset it — the calls fail with `Maximum values per line reached` once the pool drains. `akbasic_environment_zero(rt->environment)` after each call (once the result is consumed) is the documented reset and holds up under 24,000 calls. This wants a paragraph in docs/10-embedding.md, which currently does not mention it; that doc change is in scope here. **3. The cost fits the frame budget.** 40 enemies × 600 frames, each call rebinding `SELF@` and running a 3-line `DEF` body, Release build: ``` 24000 calls in 3.562 s = 148.4 us/call = 5.94 ms per 40-enemy frame ``` 5.94 ms of a 16.6 ms frame is 36% — acceptable for the exercise, and the reason bullets and collision stay in C (a classic GALAGA wave is 40 enemies; 40 is the number that has to fit, and it does). **4. One language rule will bite every enemy script, and it is a rule, not a bug.** `SELF@.X% + 30 * DT%` moves nothing, because the left operand decides integer-vs-float arithmetic (docs/03, docs/13 — a decided deviation, pinned by tests): `30 * DT%` truncates `DT%` to 0. `SELF@.X% + DT% * 30` works. The tutorial teaches this idiom early, the way breakout's "Two rules about writing expressions" does. ## Load-bearing design decisions - **Structure types are declared once, in C.** `akbasic_host_register_type()` makes the C struct *be* the BASIC type (include/akbasic/host.h:117); a script `TYPE` block with the same name would be refused (host.h:107). So the script contains function definitions only — the "structure type definitions" half of the boundary lives in the `AKBASIC_HOST_FIELD` tables in the engine, one source of truth, offsets taken from `offsetof` so they cannot drift. - **One runtime, one script, one binding per name.** `akbasic_host_rebind()` is documented as exactly this pattern — "a host iterating its enemies rebinds one name rather than creating eight" (host.h:144-149). The update hook rebinds `SELF@`, calls the function, zeroes the scratch. 40 enemies share one interpreter. - **The engine lends no devices.** All four backends and the UI stay NULL; the scripts compute, the engine draws. `PRINT` goes to the stdio sink and becomes the script-side debug channel. A script that tries `SPRITE` gets refused by name — the boundary is enforced by the interpreter, not by convention. - **Dispatch is a table, not a conditional.** Enemy type enum → BASIC function name, `static const char *` array indexed by the enum. `akbasic_runtime_call_function()` (include/akbasic/runtime.h:840, new on this branch) takes the name and pre-evaluated `akbasic_Value` args — the entry point built for verbs is the entry point a host hook wants too. - **Null physics means the script owns position.** `akgl_physics_init_null()` accepts every call and moves nothing (libakgl include/akgl/physics.h:96-101) — whatever writes `x`/`y` directly is the mover, and here that is BASIC writing through the binding. Note the libakgl gotcha: `akgl_game_init()` does **not** install any physics backend despite what physics.h's file comment says; skip `akgl_physics_init_null(akgl_physics)` and the first frame calls through a NULL `simulate` (libakgl docs/14-physics.md:207-220). ## Phases ### Phase 1 — Engine skeleton in C Window, frame loop, starfield, player ship, player fire. This is the phase to move through with haste: give the reader a mostly-working framework and refer to libakgl's own chapters for the gory details (docs/07 the frame, docs/10 sprites, docs/12 actors, docs/16 input). - `examples/galaga/` in akbasic, wired into the existing examples CMake loop, built when `AKBASIC_WITH_AKGL=ON`. - Startup in the sidescroller tutorial's canonical order: name/version/uri → `akgl_game_init()` → screen properties → `akgl_render_2d_init()` → `akgl_physics_init_null(akgl_physics)` → assets. Integer-scale logical presentation for the arcade look. - Starfield: no parallax facility exists in libakgl and none is needed — a fixed array of `{x, y, speed, color}` stars advanced per frame and drawn with `akgl_draw_point()` between `frame_start` and `akgl_game_update()`. Two speed bands give the parallax feel for free. - Player: one actor, control map via `akgl_controller_pushmap()`, fire spawns bullet actors from the heap pool. Bullets, collision (bullet-vs-enemy, enemy-vs-player), and scoring are C forever — they are engine, not behavior. - Art: Kenney CC0 space-shooter sprites with a PROVENANCE.md, the breakout precedent. Exit: ship flies on a scrolling starfield, shoots, runs headless under dummy SDL drivers with `--frames N`. ### Phase 2 — Boot the interpreter and prove the boundary The embedding host from docs/10-embedding.md, adapted to a script that defines and ends: 1. `akbasic_error_register()` once at startup. 2. `akbasic_sink_init_stdio()` → `akbasic_runtime_init()`. 3. Register host types, bind `SELF@` (and `GAME@`) to placeholder instances. 4. `akbasic_runtime_load()` the script — unnumbered lines, `LABEL`-free, nothing but `DEF` blocks and a final `END`. 5. `akbasic_runtime_start(rt, AKBASIC_MODE_RUN)` + `akbasic_runtime_run(rt, bounded)` — this executes the `DEF` statements, which is what files the functions; a "no top level code" script still has to *run* once for its definitions to exist. 6. `akbasic_runtime_set_mode(rt, AKBASIC_MODE_RUN)` — the issue #8 workaround, once; the mode stays put because nothing steps the runtime afterward. The tutorial presents step 6 as the way it is done, and after showing it, notes that issue #8 tracks making the workaround unnecessary — per the house documentation rule, no hand-wringing first. Exit: engine calls `ADDEM(17, 25)` at boot, prints 42 through the sink, refuses to start if the script fails to load. ### Phase 3 — The interop structures ```c typedef struct galaga_Enemy /* one per enemy; hangs off akgl_Actor.actorData */ { int32_t kind; /* GALAGA_ENEMY_BEE / BUTTERFLY / BOSS */ int32_t state; /* bit flags: ENTERING, IN_FORMATION, DIVING ... */ float homex, homey; /* formation slot, in map pixels */ float t; /* parametric clock for the current maneuver */ int32_t hp; int32_t fire; /* outbox: script sets 1, engine consumes */ float rnd; /* inbox: engine writes fresh 0..1 each frame */ } galaga_Enemy; ``` - Described once with an `AKBASIC_HOST_FIELD` table, registered as `ENEMY`. The 16-fields-per-type and 16-types limits (docs/16-structures.md) shape the struct; the table above spends 9. - The actor's live position is a second, smaller binding: register `akgl_Actor` itself as type `ACTOR` exposing `X%`, `Y%`, `STATE#`, `VISIBLE#` — the script writes the engine's *real* actor memory, which is the demonstrative point of the whole exercise. Two rebinds per enemy per frame; rebind cost is noise against the 148 µs call. - `GAME@`: one global instance — player x/y, wave number, frame dt, a `RND%` refreshed by the engine each frame. The engine filling randomness is the issue #16 workaround (no `RND` verb exists); shown first, linked after. - Fire is an outbox flag, not a call: spawning takes an actor from the heap pool, and pool exhaustion must be a C-side refusal with the house error context, so C consumes `fire` and does the spawn. Exit: `hoststruct.c`-style round-trip test in the example's own test binary. ### Phase 4 — The BASIC script's shape One file, `galaga.bas`: function definitions and `END`, nothing else. ```basic DEF UPDATE_BEE(DT%) REM SELF@, ACTOR@, GAME@ are bound by the engine before this call ... RETURN 0 ``` - Naming convention `UPDATE_<KIND>`, dispatched from C by the enum-indexed name table. - **The function pool is 8 slots** (`AKBASIC_MAX_FUNCTIONS`, include/akbasic/types.h:63) and each slot is a measured 36 KiB of the runtime's 2.40 MiB. Three update functions, a shared path helper, a shared fire-decision helper is 5 — the budget holds, and the tutorial states the count the way breakout states its geometry. If the design grows past 8, raising the limit is one `#define` and +36 KiB per slot, weighed against this branch's whole purpose; that change would be its own commit with the measurement in the message. - Recursion and nesting answer to `AKBASIC_MAX_ENVIRONMENTS` = 12, which multi-line calls draw from like `GOSUB`; enemy functions stay flat. - The left-operand arithmetic rule gets taught here, once, with the wrong version shown refusing to move. ### Phase 5 — The custom actor update hook ```c static akerr_ErrorContext AKERR_NOIGNORE *enemy_update(akgl_Actor *obj) { galaga_Enemy *enemy = (galaga_Enemy *)obj->actorData; /* rebind SELF@ and ACTOR@, build the DT% arg, call UPDATE_<kind>, consume the outbox, zero the scratch */ } ... PASS(errctx, akgl_actor_initialize(actor, name)); actor->updatefunc = &enemy_update; /* AFTER initialize: it resets all 7 hooks */ actor->actorData = &enemy; ``` - `updatefunc` is called exactly once per live actor per `akgl_game_update()` (libakgl include/akgl/actor.h:159); replacing it after `akgl_actor_initialize()` is the documented order (libakgl docs/12-actors.md:139). - The hook body is: `akbasic_host_rebind("SELF@")` → `akbasic_host_rebind("ACTOR@")` → `akbasic_runtime_call_function(name-from-kind-table)` → consume `enemy->fire` → `akbasic_environment_zero(rt->environment)`. That last call is load-bearing (measurement 2). - `movement_controls_face = false` on every enemy, and each kind's character maps its sprite on the bare state word — the silently-invisible-actor trap when a state has no mapped sprite (libakgl docs/12-actors.md:180-185) gets a callout box. - A BASIC error in an enemy function is that script's problem, not the engine's: it reports through the sink and the interpreter error that reaches C is handled by marking the enemy dumb (state cleared to formation-hold), never by killing the frame. ### Phase 6 — First light The smallest possible demonstration before any real AI: one enemy, one function, a sine drift written entirely in BASIC through `ACTOR@.X%`/`ACTOR@.Y%`. This is the tutorial's screenshot moment — the reader sees a C actor moving under BASIC control and understands the whole architecture from one picture. Text readout: engine prints the enemy's position from C, script `PRINT`s it from BASIC, same numbers, one memory. ### Phase 7 — Formation layout: C or BASIC? Both work; the tutorial argues it rather than asserting it: | | C lays out the grid | BASIC lays out the grid | |---|---|---| | Actor pool safety | refusal at spawn, house error path | script can request more than 64 exist | | Tuning without rebuild | no | yes | | Call budget | zero calls | one call per spawn | | Who knows screen size | engine owns it anyway | needs it exported through GAME@ | Decision: **C owns the formation grid, wave tables and spawn timing; BASIC owns everything an enemy does after it exists.** The formation slot arrives in `SELF@.HOMEX%`/`HOMEY%`, so the *breathing* of the grid — the idle sway GALAGA formations do — is still BASIC's, computed relative to home. The wave tables are the aligned, commented C arrays the house style already prescribes for tabular data. ### Phase 8 — The enemy AI Three kinds, one state machine shape, all in `galaga.bas`: - **Bee**: entry curve → formation → occasional solo dive, returning off-screen-bottom to formation. - **Butterfly**: entry curve → formation → dive with lateral weave. - **Boss**: two hit points, escorted dives — reads `GAME@` for the player's x to lead the dive. - Entry paths as parametric curves driven by `SELF@.T%`; state transitions flip bits in `SELF@.STATE#`; firing is `SELF@.FIRE# = 1` when a dive crosses the player's column and `GAME@.RND%` clears a threshold. - No `RND` verb exists (issue #16): the engine refreshes `GAME@.RND%` every frame from its own PRNG. Shown as the way it is done; issue #16 linked after, breakout's hand-rolled LCG cited as the other route. Exit: a full wave enters, forms, breathes, dives, fires, dies; headless run reports kills and shots per kind. ### Phase 9 — Title, game over, victory The uidemo three-state pattern (libakgl examples/uidemo) is the closest existing template: a `galaga_Screen` enum, `akgl_ui_init()` + one font, `akgl_ui_label()` with `AKGL_UI_ANCHOR_CENTER` for TITLE / GAME OVER / VICTORY, `akgl_ui_menu()` for start/quit, score and lives as anchored labels during play. UI frame bracket sits between `akgl_game_update()` and `frame_end`, exactly as libakgl docs/22-ui.md's frame contract draws it. Screen state machine is C; the scripts neither know nor care. ### Phase 10 — Assembly, CI and screenshots - `--frames N --autoplay --screenshot PATH --screenshot-frame N` flags per the sidescroller pattern; synthetic input goes through `akgl_controller_handle_event()`, never the handlers directly. - Headless run under `SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software` becomes the CTest entry; final readout line (frames, score, enemies remaining, ms/frame for the script calls) is the tutorial's closing `text` block, breakout-style. - Screenshots land in docs/images/ via a `docs_game_figures`-style target, tracked in git, never part of a normal build. ### Phase 11 — Write the tutorial Two chapters, mirroring the breakout split: - **docs/20-tutorial-galaga.md** — the engine and the boundary (phases 1, 2, 5, 6, 9): from empty file to a C game that boots a script and hands one actor to BASIC. - **docs/21-tutorial-galaga-enemies.md** — the data structures and the AI (phases 3, 4, 7, 8): from `SELF@` to a full attacking wave. House tutorial rules apply: opening screenshot, bullet list of steps up front, each bullet its own complete section, first principles, no architecture history, workaround-then-issue-link ordering, readouts as points of comparison. Every fenced block satisfies tests/docs_examples.sh — the C blocks will need a prelude or two added under the existing fence contract, and that checker running against the new chapters is what keeps them from rotting. New chapters go into docs/README.md's index. The docs/10-embedding.md addendum from measurement 2 (per-call `akbasic_environment_zero()` for repeated host calls) ships in this phase too. ### Phase 12 — Validation by a weaker model The acceptance test for the prose: a subagent on a much less skilled model gets the two chapters and nothing else — no example source, no repo history — and must reason out a build. Graded on: it compiles, a wave forms, enemies move under BASIC control, the three screens appear. Every point where the subagent stalls or invents is a defect in the tutorial, not the subagent; fix the text, run it again, until a cold read produces a reasonably functional game. Findings and iteration count get recorded in the PR description. ## Budgets Actor heap, 64 slots (`AKGL_MAX_HEAP_ACTOR`, libakgl include/akgl/heap.h:40): ``` player 1 player shots 2 /* the classic two-on-screen rule */ enemies 40 /* 20 bees, 16 butterflies, 4 bosses */ enemy shots 8 explosions 8 /* short-lived actors, released on animate */ --- 59 of 64 ``` Interpreter, per the 2.40 MiB runtime on this branch: ``` DEF functions 5 of 8 /* 36 KiB per slot if raised */ host types 3 of 16 /* ENEMY, ACTOR, GAME */ ENEMY fields 9 of 16 environments <=3 of 12 deep per call value scratch 64 per line /* zeroed after every host call */ ``` ## Known defects and gaps this plan routes around | Issue | What it costs here | The route | |---|---|---| | #8 | multi-line `DEF` from a host returns silent zero | `set_mode(RUN)` once after boot; measured working | | #16 | no `RND` verb | engine refreshes `GAME@.RND%` each frame | | — | per-line scratch exhausts under repeated host calls | `akbasic_environment_zero()` per call; docs/10 addendum in scope | | — | left-operand arithmetic truncates `30 * DT%` to 0 | a rule, not a bug (docs/03); taught in phase 4 | | — | `akgl_game_init()` installs no physics backend | explicit `akgl_physics_init_null(akgl_physics)`; already documented libakgl-side | ## Deliverables - `examples/galaga/` — C engine, `galaga.bas`, assets with PROVENANCE.md, CMake wiring, headless CTest entry - `docs/20-tutorial-galaga.md`, `docs/21-tutorial-galaga-enemies.md`, images, docs/README.md index rows - docs/10-embedding.md addendum on repeated host calls - Validation report from the weaker-model cold read --- Filed by Tachikoma (Claude Code, Fable 5, 1M context)
tachikoma added the status::grooming label 2026-08-03 22:49:44 -04:00
andrew added status::ready and removed status::grooming labels 2026-08-04 07:08:29 -04:00
tachikoma was assigned by andrew 2026-08-04 07:08:31 -04:00
Author
Collaborator

Executed: PR #37 delivers the example, both chapters, the docs/10 addendum and the validation report.

Three deviations from the plan as written, each argued in the PR:

  • Two interpreter defects blocked the build and are fixed in the PR with tests — the call loop's missing per-line scratch reset (any host-called body past ~10 lines died) and the scanner's leftover REM token (every indented line after a REM in an unnumbered program silently skipped). The plan's spikes missed both because their bodies were 3 lines and numbered.
  • The structure-arguments interface was explored per request and rejected on measurement: 1,015 calls to value-pool exhaustion (#36). Bindings won; the argued table is chapter 21 Step 4.
  • The measured per-call cost is ~881 µs, not the plan's 148 µs — cost is per line executed and the real AI bodies run ~15 lines against the spike's 3. Chapter 21 Step 11 carries the benchmark (interop test regenerates it) with the facts and the levers.

The function budget landed at 6 of 8 (the plan guessed 5), the figure target is docs_galaga_figures (libakgl owns the planned name), and everything else shipped as planned.


Filed by Tachikoma (Claude Code, Fable 5, 1M context)

Executed: PR #37 delivers the example, both chapters, the docs/10 addendum and the validation report. Three deviations from the plan as written, each argued in the PR: - **Two interpreter defects blocked the build and are fixed in the PR with tests** — the call loop's missing per-line scratch reset (any host-called body past ~10 lines died) and the scanner's leftover `REM` token (every indented line after a `REM` in an unnumbered program silently skipped). The plan's spikes missed both because their bodies were 3 lines and numbered. - **The structure-arguments interface was explored per request and rejected on measurement**: 1,015 calls to value-pool exhaustion (#36). Bindings won; the argued table is chapter 21 Step 4. - **The measured per-call cost is ~881 µs, not the plan's 148 µs** — cost is per line executed and the real AI bodies run ~15 lines against the spike's 3. Chapter 21 Step 11 carries the benchmark (interop test regenerates it) with the facts and the levers. The function budget landed at 6 of 8 (the plan guessed 5), the figure target is `docs_galaga_figures` (libakgl owns the planned name), and everything else shipped as planned. --- Filed by Tachikoma (Claude Code, Fable 5, 1M context)
Collaborator

Beginning work on issue #34.

Beginning work on issue #34.
Collaborator

The ticket already has the complete implementation on origin/galaga-tutorial (PR #37). I created branch 34 from that reviewed implementation and am validating the build/tests before handing off a ticket-specific PR.

The ticket already has the complete implementation on origin/galaga-tutorial (PR #37). I created branch 34 from that reviewed implementation and am validating the build/tests before handing off a ticket-specific PR.
Collaborator

Work is complete on branch 34. The existing GALAGA implementation was validated (core build succeeds; 97/113 tests pass, with the 16 known #32 line-limit failures), and the ticket-specific PR is open with Andrew requested as reviewer: #39

Work is complete on branch 34. The existing GALAGA implementation was validated (core build succeeds; 97/113 tests pass, with the 16 known #32 line-limit failures), and the ticket-specific PR is open with Andrew requested as reviewer: https://source.starfort.tech/andrew/akbasic/pulls/39
Author
Collaborator

Beginning work on this plan. Will work through the phases methodically starting with Phase 1 (engine skeleton), posting progress comments as I go. Given the scope of this 12-phase plan, I'll checkpoint and open a PR once I have solid, well-documented progress (aiming for at least through Phase 6 'First light' per the ticket's own guidance), clearly noting what remains for follow-up.

Beginning work on this plan. Will work through the phases methodically starting with Phase 1 (engine skeleton), posting progress comments as I go. Given the scope of this 12-phase plan, I'll checkpoint and open a PR once I have solid, well-documented progress (aiming for at least through Phase 6 'First light' per the ticket's own guidance), clearly noting what remains for follow-up.
Author
Collaborator

Work already complete — found on picking this up: a prior session had already executed the full 12-phase plan on branch galaga-tutorial (also pushed as branch 34), commit dd10dc1, based on feature/reduce_memory_usage @ 17af2d4 exactly as the plan specified. All phases are done: engine skeleton, interpreter boot, interop structures, galaga.bas AI, custom actor update hook, formation layout, title/game-over screens, CI/screenshots, both tutorial chapters, the docs/10-embedding.md addendum, two interpreter defects found and fixed with tests, and the weaker-model validation report (4 cold reads).

PR #37 (this identity, branch galaga-tutorial -> feature/reduce_memory_usage) has the full writeup and now has andrew set as reviewer: #37

Note: PR #39 (logikoma, branch 34) is a duplicate opened against the identical commit (dd10dc1) and already has andrew as reviewer too. Both PRs point at the same code; #37 has the detailed deliverables/measurements writeup. Andrew, your call which one to review/merge — recommend closing the other as redundant once one is merged.

No new work was performed on top of what was already there; I verified state and wired up the missing reviewer on #37 rather than duplicating a third PR.

Work already complete — found on picking this up: a prior session had already executed the full 12-phase plan on branch `galaga-tutorial` (also pushed as branch `34`), commit dd10dc1, based on `feature/reduce_memory_usage` @ 17af2d4 exactly as the plan specified. All phases are done: engine skeleton, interpreter boot, interop structures, galaga.bas AI, custom actor update hook, formation layout, title/game-over screens, CI/screenshots, both tutorial chapters, the docs/10-embedding.md addendum, two interpreter defects found and fixed with tests, and the weaker-model validation report (4 cold reads). PR #37 (this identity, branch galaga-tutorial -> feature/reduce_memory_usage) has the full writeup and now has andrew set as reviewer: https://source.starfort.tech/andrew/akbasic/pulls/37 Note: PR #39 (logikoma, branch `34`) is a duplicate opened against the identical commit (dd10dc1) and already has andrew as reviewer too. Both PRs point at the same code; #37 has the detailed deliverables/measurements writeup. Andrew, your call which one to review/merge — recommend closing the other as redundant once one is merged. No new work was performed on top of what was already there; I verified state and wired up the missing reviewer on #37 rather than duplicating a third PR.
Author
Collaborator

PR #37 (#37) already implements this ticket's full plan end-to-end — it explicitly states "Closes #34" and its deliverables match every phase here: examples/galaga/ (C engine, galaga.bas, CMake wiring, headless CTest, interop test), docs/20-tutorial-galaga.md and docs/21-tutorial-galaga-enemies.md, the docs/10-embedding.md addendum on repeated host calls, the two interpreter defects found and fixed with tests, and the weaker-model validation report (four cold reads). No work remains here that isn't already covered by that PR. Not opening a duplicate PR; leaving this issue for review/merge of #37.

PR #37 (https://source.starfort.tech/andrew/akbasic/pulls/37) already implements this ticket's full plan end-to-end — it explicitly states "Closes #34" and its deliverables match every phase here: examples/galaga/ (C engine, galaga.bas, CMake wiring, headless CTest, interop test), docs/20-tutorial-galaga.md and docs/21-tutorial-galaga-enemies.md, the docs/10-embedding.md addendum on repeated host calls, the two interpreter defects found and fixed with tests, and the weaker-model validation report (four cold reads). No work remains here that isn't already covered by that PR. Not opening a duplicate PR; leaving this issue for review/merge of #37.
Author
Collaborator

Checked issue #34 against existing open PR #37 ("A GALAGA tutorial: C/libakgl engine with akbasic embedded as the enemy-behavior engine", branch galaga-tutorial). PR #37's body already delivers every phase of this plan: examples/galaga/ (engine, galaga.bas, art+PROVENANCE, CMake wiring, headless CTest + interop test), docs/20-tutorial-galaga.md and docs/21-tutorial-galaga-enemies.md indexed in docs/README.md, the docs/10-embedding.md addendum on repeated host calls, the two interpreter defects found and fixed with tests, and the weaker-model cold-read validation report (4 iterations). PR #37 explicitly says 'Closes #34.' There is nothing left here for me to execute — opening a second PR would duplicate that work. #37 currently has failing CI and is being addressed by a separate build-fix pass; once that's green and merged this issue should close via #37, not a new PR from me. Leaving this issue as-is / not opening a duplicate PR.

Checked issue #34 against existing open PR #37 ("A GALAGA tutorial: C/libakgl engine with akbasic embedded as the enemy-behavior engine", branch galaga-tutorial). PR #37's body already delivers every phase of this plan: examples/galaga/ (engine, galaga.bas, art+PROVENANCE, CMake wiring, headless CTest + interop test), docs/20-tutorial-galaga.md and docs/21-tutorial-galaga-enemies.md indexed in docs/README.md, the docs/10-embedding.md addendum on repeated host calls, the two interpreter defects found and fixed with tests, and the weaker-model cold-read validation report (4 iterations). PR #37 explicitly says 'Closes #34.' There is nothing left here for me to execute — opening a second PR would duplicate that work. #37 currently has failing CI and is being addressed by a separate build-fix pass; once that's green and merged this issue should close via #37, not a new PR from me. Leaving this issue as-is / not opening a duplicate PR.
Collaborator

Beginning work on the GALAGA tutorial plan: C/libakgl engine with akbasic embedded as the enemy-behavior scripting engine.

Beginning work on the GALAGA tutorial plan: C/libakgl engine with akbasic embedded as the enemy-behavior scripting engine.
Collaborator

Progress: I found the completed implementation on origin/34 at dd10dc1, matching the ticket deliverables. I’m checking it out as the ticket branch and will run the relevant build/test validation before handoff.

Progress: I found the completed implementation on origin/34 at dd10dc1, matching the ticket deliverables. I’m checking it out as the ticket branch and will run the relevant build/test validation before handoff.
Sign in to join this conversation.