Plan: a GALAGA tutorial — C/libakgl engine with akbasic embedded as the enemy-behavior scripting engine #34
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
DEFfunctions, no top-level code, called per-enemy per-frame through a customakgl_Actorupdate 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@17af2d4and 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
DEFafter the program has finished gets a silent zero — issue #8, reproduced exactly on this branch. Forcing the mode back first makes the body run:Measurement 4 is the whole game in miniature: a multi-line
DEFreading and writing a host-bound C struct throughSELF@, no marshalling, exactly as docs/16-structures.md promises. The mode is forced once after the boot run and staysAKBASIC_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 withMaximum values per line reachedonce 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-lineDEFbody, Release build: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%truncatesDT%to 0.SELF@.X% + DT% * 30works. The tutorial teaches this idiom early, the way breakout's "Two rules about writing expressions" does.Load-bearing design decisions
akbasic_host_register_type()makes the C struct be the BASIC type (include/akbasic/host.h:117); a scriptTYPEblock 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 theAKBASIC_HOST_FIELDtables in the engine, one source of truth, offsets taken fromoffsetofso they cannot drift.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 rebindsSELF@, calls the function, zeroes the scratch. 40 enemies share one interpreter.PRINTgoes to the stdio sink and becomes the script-side debug channel. A script that triesSPRITEgets refused by name — the boundary is enforced by the interpreter, not by convention.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-evaluatedakbasic_Valueargs — the entry point built for verbs is the entry point a host hook wants too.akgl_physics_init_null()accepts every call and moves nothing (libakgl include/akgl/physics.h:96-101) — whatever writesx/ydirectly 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; skipakgl_physics_init_null(akgl_physics)and the first frame calls through a NULLsimulate(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 whenAKBASIC_WITH_AKGL=ON.akgl_game_init()→ screen properties →akgl_render_2d_init()→akgl_physics_init_null(akgl_physics)→ assets. Integer-scale logical presentation for the arcade look.{x, y, speed, color}stars advanced per frame and drawn withakgl_draw_point()betweenframe_startandakgl_game_update(). Two speed bands give the parallax feel for free.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.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:
akbasic_error_register()once at startup.akbasic_sink_init_stdio()→akbasic_runtime_init().SELF@(andGAME@) to placeholder instances.akbasic_runtime_load()the script — unnumbered lines,LABEL-free, nothing butDEFblocks and a finalEND.akbasic_runtime_start(rt, AKBASIC_MODE_RUN)+akbasic_runtime_run(rt, bounded)— this executes theDEFstatements, which is what files the functions; a "no top level code" script still has to run once for its definitions to exist.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
AKBASIC_HOST_FIELDtable, registered asENEMY. The 16-fields-per-type and 16-types limits (docs/16-structures.md) shape the struct; the table above spends 9.akgl_Actoritself as typeACTORexposingX%,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, aRND%refreshed by the engine each frame. The engine filling randomness is the issue #16 workaround (noRNDverb exists); shown first, linked after.fireand 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 andEND, nothing else.UPDATE_<KIND>, dispatched from C by the enum-indexed name table.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#defineand +36 KiB per slot, weighed against this branch's whole purpose; that change would be its own commit with the measurement in the message.AKBASIC_MAX_ENVIRONMENTS= 12, which multi-line calls draw from likeGOSUB; enemy functions stay flat.Phase 5 — The custom actor update hook
updatefuncis called exactly once per live actor perakgl_game_update()(libakgl include/akgl/actor.h:159); replacing it afterakgl_actor_initialize()is the documented order (libakgl docs/12-actors.md:139).akbasic_host_rebind("SELF@")→akbasic_host_rebind("ACTOR@")→akbasic_runtime_call_function(name-from-kind-table)→ consumeenemy->fire→akbasic_environment_zero(rt->environment). That last call is load-bearing (measurement 2).movement_controls_face = falseon 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.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, scriptPRINTs it from BASIC, same numbers, one memory.Phase 7 — Formation layout: C or BASIC?
Both work; the tutorial argues it rather than asserting it:
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:GAME@for the player's x to lead the dive.SELF@.T%; state transitions flip bits inSELF@.STATE#; firing isSELF@.FIRE# = 1when a dive crosses the player's column andGAME@.RND%clears a threshold.RNDverb exists (issue #16): the engine refreshesGAME@.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_Screenenum,akgl_ui_init()+ one font,akgl_ui_label()withAKGL_UI_ANCHOR_CENTERfor TITLE / GAME OVER / VICTORY,akgl_ui_menu()for start/quit, score and lives as anchored labels during play. UI frame bracket sits betweenakgl_game_update()andframe_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 Nflags per the sidescroller pattern; synthetic input goes throughakgl_controller_handle_event(), never the handlers directly.SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=softwarebecomes the CTest entry; final readout line (frames, score, enemies remaining, ms/frame for the script calls) is the tutorial's closingtextblock, breakout-style.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:
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):Interpreter, per the 2.40 MiB runtime on this branch:
Known defects and gaps this plan routes around
DEFfrom a host returns silent zeroset_mode(RUN)once after boot; measured workingRNDverbGAME@.RND%each frameakbasic_environment_zero()per call; docs/10 addendum in scope30 * DT%to 0akgl_game_init()installs no physics backendakgl_physics_init_null(akgl_physics); already documented libakgl-sideDeliverables
examples/galaga/— C engine,galaga.bas, assets with PROVENANCE.md, CMake wiring, headless CTest entrydocs/20-tutorial-galaga.md,docs/21-tutorial-galaga-enemies.md, images, docs/README.md index rowsFiled 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:
REMtoken (every indented line after aREMin an unnumbered program silently skipped). The plan's spikes missed both because their bodies were 3 lines and numbered.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)
Beginning work on issue #34.
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.
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
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.
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 branch34), commitdd10dc1, based onfeature/reduce_memory_usage@17af2d4exactly 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.
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.
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.
Beginning work on the GALAGA tutorial plan: C/libakgl engine with akbasic embedded as the enemy-behavior scripting engine.
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.