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
This commit is contained in:
2026-08-04 08:47:43 -04:00
parent 47c6be58c5
commit d5a0edd692
14 changed files with 1471 additions and 3 deletions

View File

@@ -26,7 +26,7 @@ scripting engine for game authors.
| [the issue tracker](https://source.starfort.tech/andrew/akbasic/issues) | **Outstanding defects and gaps.** Labelled by kind and blast radius; `status::grooming` means the scope is not settled yet | | [the issue tracker](https://source.starfort.tech/andrew/akbasic/issues) | **Outstanding defects and gaps.** Labelled by kind and blast radius; `status::grooming` means the scope is not settled yet |
| [`TODO.md`](TODO.md) | The record: settled design decisions, the deviation register, defects already fixed, and the reasoning behind the measurements. §0.1 first — it retires the byte-for-byte fidelity constraint several later sections were written on | | [`TODO.md`](TODO.md) | The record: settled design decisions, the deviation register, defects already fixed, and the reasoning behind the measurements. §0.1 first — it retires the byte-for-byte fidelity constraint several later sections were written on |
| [`README.md`](README.md) | What the project is and why, for somebody who has not seen it | | [`README.md`](README.md) | What the project is and why, for somebody who has not seen it |
| [`docs/`](docs/README.md) | The language itself: eighteen chapters, verb and function reference. [Chapter 14](docs/14-architecture.md) is the interpreter's architecture — the step loop, the pools, the two kinds of error, and how to debug it. [Chapter 15](docs/15-error-codes.md) is the error-code appendix. [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) are tutorials that build the games in `examples/breakout/` | | [`docs/`](docs/README.md) | The language itself: twenty-one chapters, verb and function reference. [Chapter 14](docs/14-architecture.md) is the interpreter's architecture — the step loop, the pools, the two kinds of error, and how to debug it. [Chapter 15](docs/15-error-codes.md) is the error-code appendix. [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) are tutorials that build the games in `examples/breakout/`; [Chapters 20](docs/20-tutorial-galaga.md) and [21](docs/21-tutorial-galaga-enemies.md) build the embedding host in `examples/galaga/` |
| `deps/libakerror/AGENTS.md` | The `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH` protocol, authoritatively | | `deps/libakerror/AGENTS.md` | The `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH` protocol, authoritatively |
| `deps/libakerror/UPGRADING.md` | 1.0.0's status registry. Required before writing an error code | | `deps/libakerror/UPGRADING.md` | 1.0.0's status registry. Required before writing an error code |
| `deps/<library>/AGENTS.md` | Per-repo rules. Read the relevant one **before editing a submodule** | | `deps/<library>/AGENTS.md` | Per-repo rules. Read the relevant one **before editing a submodule** |

View File

@@ -126,7 +126,7 @@ version are catalogued in [`TODO.md`](TODO.md) and summarised for a BASIC progra
| | | | | |
|---|---| |---|---|
| [`docs/`](docs/README.md) | The guide: eighteen chapters, the language then each hardware area then a reference section for every verb and function, [Chapter 14](docs/14-architecture.md) on the interpreter's own architecture, [Chapter 15](docs/15-error-codes.md) listing every error code, and [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) building a whole game twice | | [`docs/`](docs/README.md) | The guide: twenty-one chapters, the language then each hardware area then a reference section for every verb and function, [Chapter 14](docs/14-architecture.md) on the interpreter's own architecture, [Chapter 15](docs/15-error-codes.md) listing every error code, [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) building a whole game twice, and [Chapters 20](docs/20-tutorial-galaga.md) and [21](docs/21-tutorial-galaga-enemies.md) building a C game that embeds the interpreter |
| [`MAINTENANCE.md`](MAINTENANCE.md) | For contributors and maintainers: the documentation-example harness, the three test lists, mutation testing, error-code allocation, style | | [`MAINTENANCE.md`](MAINTENANCE.md) | For contributors and maintainers: the documentation-example harness, the three test lists, mutation testing, error-code allocation, style |
| [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence | | [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence |
| [`tests/reference/README.md`](tests/reference/README.md) | Where the golden corpus came from, and the rule for changing it | | [`tests/reference/README.md`](tests/reference/README.md) | Where the golden corpus came from, and the rule for changing it |

View File

@@ -100,6 +100,50 @@ bounded run is usually inside a `FOR` or `GOSUB` body, and a variable created th
dies when the body pops — silently, with the script reading it correctly right up until dies when the body pops — silently, with the script reading it correctly right up until
it stops. it stops.
## Calling a function every frame
`akbasic_runtime_call_function()` calls a `DEF` by name with values you already
hold — the entry point a game loop wants. A host that calls it repeatedly signs
up for three rules the one-shot examples never meet:
```c wrap=hostcalls
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "THINK", argp, 1, &result));
/* ...consume the result... */
CATCH(errctx, akbasic_environment_zero(SCRIPT.environment));
```
1. **Reset the value scratch after every call, once the result is consumed.**
Each call parks its result in the caller environment's per-line scratch
(`AKBASIC_MAX_VALUES` slots), and a host calling in a loop never crosses the
line boundary that would reset it. Skip the `akbasic_environment_zero()` and
the pool drains — measured at under two frames of forty calls — after which
every call fails with `Maximum values per line reached`. The reset also
invalidates `result`, which is why it comes after the consumption.
2. **Force RUN mode once after the boot run.** A multi-line `DEF` body only
runs while the runtime is in RUN mode, and by the time a host can call, the
program that filed the definitions has ended. One
`akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN)` after
`akbasic_runtime_run()` makes the bodies run, and the mode stays put because
nothing steps the runtime between calls. Issue #8 tracks making this
unnecessary.
3. **Revive after a script error, deliberately.** A BASIC-level error inside a
called body reports through the sink, answers a stale value, and latches:
the runtime leaves RUN mode and every later call does nothing. When your
policy is to absorb the error and keep calling — a game marking one actor
dumb rather than killing the frame — the revival is two calls:
`akbasic_runtime_clear_error()`, then `akbasic_runtime_set_mode(RUN)` again.
The latch is deliberate for *programs* — the first error ends a run, once,
with one line — so nothing clears it for you.
Do not pass structures as per-frame arguments. A structure or pointer parameter
spends a value-pool slot on every call and the pool never reclaims, so the
interface dies after about a thousand calls — issue #36 has the measurements.
Bind the instance once with `akbasic_host_bind()` and point it at each object
with `akbasic_host_rebind()` ([Chapter 16](16-structures.md)), which spends
nothing per call. The GALAGA tutorial ([Chapters 20](20-tutorial-galaga.md)
and [21](21-tutorial-galaga-enemies.md)) is this whole recipe as a working
game, forty calls a frame.
## Where the output goes ## Where the output goes
`PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus `PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus

619
docs/20-tutorial-galaga.md Normal file
View File

@@ -0,0 +1,619 @@
# 20. Tutorial: GALAGA — a C engine with a BASIC brain
This chapter and [Chapter 21](21-tutorial-galaga-enemies.md) 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](images/galaga-wave.png)
The finished program is [`examples/galaga/`](../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.
```sh norun
$ 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](#step-1-open-a-window)** — open a window, in the one startup order
that works
- **[Step 2](#step-2-scatter-a-starfield)** — scatter a starfield and scroll it,
with no parallax machinery at all
- **[Step 3](#step-3-put-a-ship-on-screen)** — put a ship on screen from a
sprite and a character file, and drive it from the keyboard
- **[Step 4](#step-4-shots-and-collision)** — spawn shots from the actor heap
and collide them by hand
- **[Step 5](#step-5-boot-the-interpreter)** — link the interpreter in, load a
script of definitions, and call one from C
- **[Step 6](#step-6-the-update-hook)** — replace an actor's update hook so its
every frame is a BASIC call
- **[Step 7](#step-7-first-light)** — watch one enemy move under BASIC control,
and read the same numbers from both sides
- **[Step 8](#step-8-screens)** — add the title, game over and victory screens
- **[Step 9](#step-9-run-it-headless)** — 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.
```c wrap=galagatypes requires=akgl
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:
```c wrap=galagahost requires=akgl
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:
```c wrap=galagatypes requires=akgl
#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`](../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:
```c wrap=galagagame requires=akgl
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:
```c wrap=galagagame requires=akgl
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.
```c wrap=galagagame requires=akgl
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 serial** — `pshot17`, 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:
```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](10-embedding.md) 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:
```c wrap=galagacalls requires=akgl
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:
```basic
DEF ADDEM(A#, B#) = A# + B#
END
```
And call it from C, with values you already have:
```c wrap=galagacalls requires=akgl
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);
```
```text
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.
```c wrap=galagagame requires=akgl
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.**
```c wrap=galagacalls requires=akgl
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](21-tutorial-galaga-enemies.md) 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"](10-embedding.md#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:
```basic
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:
```c wrap=galagahost requires=akgl
SDL_Log("C SEES X = %f", galaga_enemy_actors[0]->x);
```
```text
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:
```c wrap=galagahost requires=akgl
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:
```c wrap=galagagame requires=akgl
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](images/galaga-title.png)
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:
```sh norun
$ 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_Event`s, 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:
```text
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](21-tutorial-galaga-enemies.md) — 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.

View File

@@ -0,0 +1,515 @@
# 21. Tutorial: GALAGA — the structures and the AI
[Chapter 20](20-tutorial-galaga.md) built a C engine that boots the interpreter
and hands one actor to BASIC. This chapter builds everything that crosses the
boundary — the three shared structures — and then the script that thinks
through them: a full wave that enters, forms up, breathes, dives, fires and
dies, without another line of engine code.
![The wave assembling: bosses, butterflies and bees under BASIC control](images/galaga-wave.png)
The finished script is
[`examples/galaga/galaga.bas`](../examples/galaga/galaga.bas) — six `DEF`
functions and an `END`, nothing else. Editing it and re-running the game is the
whole development loop; the engine never rebuilds.
## What you will do
- **[Step 1](#step-1-declare-the-enemy-once-in-c)** — declare the enemy record
once, in C, and register it as a BASIC type
- **[Step 2](#step-2-bind-the-engines-own-actor)** — bind the engine's own
actor as the second type, which is the point of the whole exercise
- **[Step 3](#step-3-share-the-frame-and-the-dice)** — share the frame state,
and give the script randomness it cannot make itself
- **[Step 4](#step-4-why-bindings-and-not-arguments)** — see why the structures
are bindings rather than function arguments
- **[Step 5](#step-5-the-shape-of-the-script)** — learn the three language
rules that shape every enemy function
- **[Step 6](#step-6-the-shared-maneuvers)** — write the shared maneuvers:
glide home, dive, decide to fire
- **[Step 7](#step-7-the-three-kinds)** — write the bee, the butterfly and the
boss
- **[Step 8](#step-8-the-formation-c-or-basic)** — decide who owns the
formation, and lay it out
- **[Step 9](#step-9-when-a-script-dies)** — decide what a script error does to
the game, and make it do that
- **[Step 10](#step-10-prove-it)** — prove the boundary with a test that links
the real files
---
## Step 1: Declare the enemy once, in C
**Goal: one struct that both languages read and write, with one source of truth.**
An enemy is what the state machine needs to remember between frames, plus one
inbox and one outbox:
```c wrap=galagatypes requires=akgl
#define GALAGA_ENEMY_BEE 0
#define GALAGA_ENEMY_BUTTERFLY 1
#define GALAGA_ENEMY_BOSS 2
/*
* galaga_Enemy.state bits. The script owns these transitions; the engine only
* writes the word at spawn.
*
* 8 0
* 0 0 0 0 0 1 1 1
* | | `-- ENTERING: flying its entry path toward the formation slot
* | `---- FORMATION: holding (and breathing around) homex/homey
* `------ DIVING: attacking, off the grid until it leaves the screen
*/
#define GALAGA_ES_ENTERING (1 << 0)
#define GALAGA_ES_FORMATION (1 << 1)
#define GALAGA_ES_DIVING (1 << 2)
typedef struct galaga_Enemy
{
int32_t kind; /* GALAGA_ENEMY_BEE / BUTTERFLY / BOSS */
int32_t state; /* GALAGA_ES_* bit flags */
float homex; /* formation slot, in map pixels */
float homey;
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 call */
} galaga_Enemy;
```
The C struct *is* the BASIC type. `akbasic_host_register_type()` takes a table
of field descriptors — the BASIC name with its suffix, the C representation,
and where the member sits — and after that the language's own machinery works
across the boundary with no second set of rules
([Chapter 16](16-structures.md)):
```c wrap=galagatypes requires=akgl
typedef struct galaga_Enemy
{
int32_t kind;
int32_t state;
float homex;
float homey;
float t;
int32_t hp;
int32_t fire;
float rnd;
} galaga_Enemy;
static const akbasic_HostField ENEMY_FIELDS[] = {
/* struct member BASIC name C representation */
AKBASIC_HOST_FIELD( galaga_Enemy, kind, "KIND#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Enemy, state, "STATE#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Enemy, homex, "HOMEX%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( galaga_Enemy, homey, "HOMEY%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( galaga_Enemy, t, "T%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( galaga_Enemy, hp, "HP#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Enemy, fire, "FIRE#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( galaga_Enemy, rnd, "RND%", AKBASIC_HOSTFIELD_FLOAT )
};
static const akbasic_HostType ENEMY_TYPE = {
"ENEMY", sizeof(galaga_Enemy), ENEMY_FIELDS, 8
};
```
Three decisions are load-bearing here:
- **`AKBASIC_HOST_FIELD` takes the offset and the width from the member
itself**, via `offsetof` — so the two sides cannot drift. Writing them out by
hand is two chances to name the wrong member and no way to notice.
- **The script never declares a `TYPE`.** A host type and a script `TYPE` share
one namespace, and a script that tries to redeclare `ENEMY` is refused. The
"structure definitions" half of the boundary lives here, once.
- **The suffixes are the dialect's**: `#` is integer, `%` is float
([Chapter 3](03-the-language.md)). `HOMEX%` because a formation slot is a
pixel coordinate the glide arithmetic must not truncate.
The limits that shape the struct: a type may carry 16 fields and the runtime 16
types ([Chapter 16](16-structures.md)). `ENEMY` spends 8 fields; the game
spends 3 types.
## Step 2: Bind the engine's own actor
**Goal: the script writes the same bytes the renderer reads.**
The enemy record is the game's own invention. The second type is not — it is
libakgl's `akgl_Actor`, registered field-for-field over the engine's real
struct:
```c wrap=galagatypes requires=akgl
static const akbasic_HostField ACTOR_FIELDS[] = {
AKBASIC_HOST_FIELD( akgl_Actor, x, "X%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( akgl_Actor, y, "Y%", AKBASIC_HOSTFIELD_FLOAT ),
AKBASIC_HOST_FIELD( akgl_Actor, state, "STATE#", AKBASIC_HOSTFIELD_INT32 ),
AKBASIC_HOST_FIELD( akgl_Actor, visible, "VISIBLE#", AKBASIC_HOSTFIELD_BOOL )
};
static const akbasic_HostType ACTOR_TYPE = {
"ACTOR", sizeof(akgl_Actor), ACTOR_FIELDS, 4
};
```
This is the demonstrative point of the whole exercise. When the script writes
`ACTOR@.X%`, it writes `akgl_Actor.x` — the same memory the renderer reads on
the same frame. There is no copy going in, no copy coming out, and no code
between the script's decision and the engine's pixel. Null physics
(Chapter 20, Step 1) is what makes that safe: nothing else is trying to move
the actor.
The per-frame call binds both names to *this* enemy before dispatching — one
binding per name, pointed at forty enemies in turn, which is what
`akbasic_host_rebind()` is for:
```c wrap=galagacalls requires=akgl
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy));
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor));
```
## Step 3: Share the frame, and the dice
**Goal: everything a diving enemy needs to know about the world, in one record.**
```c wrap=galagatypes requires=akgl
typedef struct galaga_Shared
{
float playerx; /* the player actor's position, this frame */
float playery;
int32_t wave;
float rnd; /* fresh 0..1 each frame; the issue #16 route */
} galaga_Shared;
```
`GAME@` is bound once at boot to this one global instance and never rebound;
the engine refreshes it at the top of every frame. The boss reads
`GAME@.PLAYERX%` to lead its dive; the fire decision reads it to know whether
anything is worth shooting at.
The `rnd` fields — one here per frame, one on each enemy per call — exist
because the engine's PRNG is the script's **only** source of randomness: write
`SELF@.RND% < DT% * 1.5` and an enemy's trigger finger is a dice roll. There
is no `RND` verb in this dialect; issue #16 tracks adding one, and Chapter
17's breakout hand-rolls a linear congruential generator in BASIC as the other
route. Here the engine fills the field, which also keeps a headless run the
same game on every machine — the PRNG is the example's own, not libc's.
## Step 4: Why bindings, and not arguments
**Goal: know why `SELF@` is a bound global rather than a parameter.**
The language can pass structures to functions — by value with `E@ AS ENEMY`,
by reference with `E@ AS PTR TO ENEMY` ([Chapter 16](16-structures.md)) — and
a host can construct those argument values, so the obvious alternative
interface is honest functions:
```basic norun
DEF UPDATEBEE(E@ AS PTR TO ENEMY, A@ AS PTR TO ACTOR, G@ AS PTR TO GAME, DT%)
```
It was measured before this chapter chose. Pointer arguments work — writes
through `E@->X%` land in the host struct, the type check refuses a wrong type,
by-value copies exactly as documented. What rules them out is the pool math:
| | bound globals | pointer arguments |
|---|---|---|
| value-pool slots per call | 0 | 1 per structure parameter, never returned |
| calls before exhaustion | unbounded | 1,015 measured (2,048-slot pool, 2 pointer args) |
| at 40 enemies per frame | unbounded | 25 frames |
| per-call cost | 148 us | 251 us |
A `@`-suffixed name always takes value-pool storage, and that pool never
reclaims — a documented property of structures, because a pointer may outlive
the scope that `DIM`med it. A *parameter* is a local that dies with the call,
but it pays the storage price of a `DIM` that must survive one; the pool
drains, and the wave stops thinking mid-flight. Issue #36 tracks it, with the
reduction for whoever fixes it. Until then: **bind and rebind for per-frame
host calls; pass structures only to functions called a bounded number of
times.**
## Step 5: The shape of the script
**Goal: the three rules every enemy function is written under.**
`galaga.bas` is definitions and an `END` — no top-level code, no line numbers,
no `LABEL`s. Three rules of the dialect shape every body in it.
**Rule 1: the left operand decides integer or float arithmetic**
([Chapter 3](03-the-language.md)). This will bite every enemy script exactly
once, so meet it now. The natural spelling of "move by speed times dt" moves
nothing:
```basic norun
ACTOR@.Y% = ACTOR@.Y% + 260 * DT%
```
`260` is an integer, it is on the left of `*`, so `DT%` — a float around
0.016 — is converted to integer **zero** before the multiply. Nothing fails;
the enemy simply does not move. The working spelling puts the float first:
```basic norun
ACTOR@.Y% = ACTOR@.Y% + SPD% * DT%
SPD% = SELF@.T% * 150 + 260
```
Every expression in the finished script is written float-first. When an enemy
of yours will not move, this is the first thing to check.
**Rule 2: only the last `RETURN` may start a line.** A multi-line `DEF` body
runs until `RETURN` — and the *definition* is scanned the same way, ending at
the first line that begins with one. An early return therefore always rides an
`IF ... THEN RETURN 0` on one line, and exactly one line-leading `RETURN` ends
each function. The stagger guard at the top of every update function is the
idiom:
```basic norun
DEF UPDATEBEE(DT%)
SELF@.T% = SELF@.T% + DT%
IF SELF@.T% < 0 THEN RETURN 0
```
**Rule 3: the budgets are small and named.** Eight function slots exist
(`AKBASIC_MAX_FUNCTIONS`), each a measured 36 KiB of the runtime's 2.40 MiB.
This game defines six: three update functions, two shared maneuvers, one fire
decision. Nesting draws from the twelve-slot environment pool exactly as
`GOSUB` does; the deepest chain here is three (update → maneuver → nothing).
If a design needs a ninth function, raising the limit is one `#define` and
+36 KiB per slot — weighed, not assumed.
## Step 6: The shared maneuvers
**Goal: three helpers that make the three kinds one page each.**
Ease toward the formation slot, with a little entry swirl. Answers 1 once the
slot is reached — the caller flips the state on that answer:
```basic
DEF GLIDEHOME(DT%)
DX% = SELF@.HOMEX% - ACTOR@.X%
DY% = SELF@.HOMEY% - ACTOR@.Y%
K% = DT% * 4.5
IF K% > 1 THEN K% = 1
ACTOR@.X% = ACTOR@.X% + DX% * K% + SIN(SELF@.T% * 6) * 90 * DT%
ACTOR@.Y% = ACTOR@.Y% + DY% * K%
IF ABS(DX%) < 3 AND ABS(DY%) < 3 THEN RETURN 1
RETURN 0
END
```
One frame of a dive: accelerate downward, weave, lean toward the player's
column, and glide back in from the top after falling out the bottom. The
weave and the lean are parameters, which is what makes three kinds out of one
maneuver:
```basic
DEF DIVESTEP(DT%, WEAVE%, LEAD%)
SPD% = SELF@.T% * 150 + 260
ACTOR@.Y% = ACTOR@.Y% + SPD% * DT%
ACTOR@.X% = ACTOR@.X% + SIN(SELF@.T% * 4) * WEAVE% * DT%
DX% = GAME@.PLAYERX% - ACTOR@.X%
IF DX% > 220 THEN DX% = 220
IF DX% < -220 THEN DX% = -220
ACTOR@.X% = ACTOR@.X% + DX% * LEAD% * DT%
IF ACTOR@.Y% > 1040 THEN BEGIN
ACTOR@.Y% = 0.0 - 90
SELF@.STATE# = 1
SELF@.T% = 0
BEND
RETURN 0
END
```
Note the off-screen exit: state back to `1` (ENTERING), clock to zero, and the
glide brings it home — a dive that misses rejoins the formation, which is the
classic loop. `0.0 - 90` rather than `0 - 90` is Rule 1 again: the float goes
first even to make a negative.
The fire decision raises the flag when diving roughly above the player. The
engine consumes `FIRE#` and does the spawning — the script only wishes,
because spawning takes an actor from a bounded pool and pool exhaustion must
be a C-side refusal with the house error context, not a script mystery:
```basic
DEF DECIDEFIRE(DT%)
DX% = GAME@.PLAYERX% - ACTOR@.X%
IF ABS(DX%) > 140 THEN RETURN 0
IF ACTOR@.Y% > GAME@.PLAYERY% THEN RETURN 0
IF SELF@.RND% < DT% * 1.5 THEN SELF@.FIRE# = 1
RETURN 0
END
```
## Step 7: The three kinds
**Goal: bee, butterfly, boss — one state machine, three characters.**
Every kind is the same three-state machine, dispatched by the bits of
`SELF@.STATE#`. The bee is the reference implementation:
```basic
DEF GLIDEHOME(DT%)
ACTOR@.X% = SELF@.HOMEX%
ACTOR@.Y% = SELF@.HOMEY%
RETURN 1
DEF DIVESTEP(DT%, WEAVE%, LEAD%)
RETURN 0
DEF DECIDEFIRE(DT%)
RETURN 0
DEF UPDATEBEE(DT%)
SELF@.T% = SELF@.T% + DT%
IF SELF@.T% < 0 THEN RETURN 0
S# = SELF@.STATE#
IF (S# AND 1) > 0 THEN BEGIN
R# = GLIDEHOME(DT%)
IF R# = 1 THEN SELF@.STATE# = 2 : SELF@.T% = 0
BEND
IF (S# AND 2) > 0 THEN BEGIN
ACTOR@.X% = SELF@.HOMEX% + SIN(SELF@.T% * 1.7) * 16
ACTOR@.Y% = SELF@.HOMEY%
IF SELF@.RND% < DT% * 0.04 THEN SELF@.STATE# = 4 : SELF@.T% = 0
BEND
IF (S# AND 4) > 0 THEN BEGIN
R# = DIVESTEP(DT%, 130, 0.2)
R# = DECIDEFIRE(DT%)
BEND
RETURN 0
END
```
(The three helpers above are stubs so this listing runs alone; the real ones
are Step 6's. The listing in `galaga.bas` is this function verbatim.)
The shape to notice: `S#` is read **once**, so a state flipped this frame does
not also run its new state's block this frame — transitions are frame-atomic.
Each block is one `IF ... BEGIN`/`BEND`, never nested. The formation block
computes position *relative to home* every frame — `HOMEX% + SIN(...)` — so
the grid's idle breathing belongs to the script even though C placed the grid.
The butterfly is the bee with a wide lateral weave — `DIVESTEP(DT%, 260, 0.1)`
— and a slightly itchier trigger. The boss differs three ways: two hit points
(C fills `HP#` at spawn), a dive that leads the player —
`DIVESTEP(DT%, 60, 0.9)` — and one line that crosses the boundary in the other
direction:
```basic norun
IF SELF@.HP# = 1 THEN ACTOR@.STATE# = ACTOR@.STATE# OR 8192
```
8192 is `AKGL_ACTOR_STATE_UNDEFINED_13`, one of the actor state bits libakgl
reserves for the game. The boss's character file maps the state word
`ALIVE` to the green sprite and `ALIVE`+bit-13 to the drained one — so when
the script raises the bit, the engine's own character machinery swaps the
sprite. BASIC decides *that* the boss looks hurt; C never hears about it.
## Step 8: The formation: C or BASIC?
**Goal: decide who owns the grid, from the trade-offs rather than taste.**
Both can lay out the formation. The choice is argued, not asserted:
| | C lays out the grid | BASIC lays out the grid |
|---|---|---|
| actor pool safety | refusal at spawn, house error path | script can ask for more than 64 exist |
| tuning without rebuild | no | yes |
| call budget | zero calls | one call per spawn |
| who knows the screen size | the engine owns it anyway | needs it exported through `GAME@` |
**Decision: C owns the grid, the wave table and the spawn timing; BASIC owns
everything an enemy does after it exists.** The slot arrives in
`SELF@.HOMEX%`/`HOMEY%`, so the breathing stays the script's (Step 7), and the
pool stays behind a C-side refusal. The wave is the aligned table house style
already prescribes for tabular data — one row per formation row:
```c wrap=galagagame requires=akgl
static const struct
{
int32_t kind; /* GALAGA_ENEMY_* */
int row; /* formation row */
int first; /* first column filled */
int count; /* columns filled */
int32_t hp;
}
WAVE_ROWS[] = {
/* kind row first count hp */
{ GALAGA_ENEMY_BOSS, 0, 3, 4, 2 },
{ GALAGA_ENEMY_BUTTERFLY, 1, 1, 8, 1 },
{ GALAGA_ENEMY_BUTTERFLY, 2, 1, 8, 1 },
{ GALAGA_ENEMY_BEE, 3, 0, 10, 1 },
{ GALAGA_ENEMY_BEE, 4, 0, 10, 1 }
};
```
Forty enemies: 4 bosses, 16 butterflies, 20 bees. The actor heap holds 64:
```text
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 a timer */
---
59 of 64
```
The spawn walks the table, fills each `galaga_Enemy`, and staggers the entry
clocks — `t = -0.08 * index`, so each enemy holds still until its own clock
crosses zero and the wave pours in as a stream rather than a wall. The full
loop is `examples/galaga/enemies.c`.
## Step 9: When a script dies
**Goal: a script error costs one enemy's wits, never the frame.**
A BASIC-level error in an enemy's function — a misspelled field, arithmetic on
the wrong type — reports through the sink and stops the script. The engine's
policy, implemented around the call in `script.c`:
- **The enemy goes dumb**: state cleared to a formation hold it will never
leave, outbox cleared. The other thirty-nine keep thinking.
- **The runtime is revived**: a run's first error latches, and while it stands
every later call answers a stale value after doing nothing. Revival is two
calls — `akbasic_runtime_clear_error()`, then the same
`akbasic_runtime_set_mode(RUN)` the boot needed (issue #8's mechanics).
- **The first failure is logged, the rest are counted.** Sixty a second of the
same message is how a log stops being read; the count lands in the closing
readout as `script errors N`, where a headless run cannot miss it.
The same detection runs at boot: every function in the dispatch table is
called once against a zeroed scratch enemy, so a script that cannot run fails
at startup with the function's name in the message — not on frame one of the
first wave.
## Step 10: Prove it
**Goal: a test that fails the moment the two sides disagree.**
`examples/galaga/interop_test.c` links the real `script.c` and loads the real
`galaga.bas` — not copies — and pins the four claims this chapter made:
```text
ok: a formation bee's sway is written into akgl_Actor.x/y by the script
ok: a diving bee above the player raises FIRE# for the engine to consume
ok: a boss at one hit point raises actor state bit 13 from BASIC
ok: 24000 calls survive the per-call akbasic_environment_zero() regime
```
That last claim is the per-frame contract from Chapter 20 Step 6 under a full
game's load — forty enemies at sixty frames a second for ten seconds. CTest
runs it as `example_galaga_interop` beside the headless game itself.
And because the script is data, the proof extends to scripts nobody planned:
run the game with `--script` pointing at a variant — enemies that never dive,
enemies that always dive — and the engine neither knows nor cares. That
swap-a-brain-without-rebuilding property is what the two chapters were about;
the readout tells you how each brain did:
```text
galaga: 3000 frames, screen 2, score 2350, alive 0, kills bee 20 bfly 15 boss 1, shots bee 1 bfly 1 boss 1, script errors 0
```
---
Where to go from here: more waves are rows in the table; a new enemy kind is
one table row, one character file and one `DEF`; a smarter boss is edits to a
text file while the game is closed — or a different file handed to
`--script`. The engine is done. That is the point.

View File

@@ -16,7 +16,10 @@ embedding it, debugging it or changing it.
**[Chapters 17](17-tutorial-breakout.md)** and **[18](18-tutorial-breakout-artwork.md)** **[Chapters 17](17-tutorial-breakout.md)** and **[18](18-tutorial-breakout-artwork.md)**
are tutorials rather than reference: they build one complete game twice, two different are tutorials rather than reference: they build one complete game twice, two different
ways, in numbered steps you can type in one at a time. Start with 17 — it needs nothing ways, in numbered steps you can type in one at a time. Start with 17 — it needs nothing
but the earlier chapters, and 18 assumes it. but the earlier chapters, and 18 assumes it. **[Chapters 20](20-tutorial-galaga.md)** and
**[21](21-tutorial-galaga-enemies.md)** are the third tutorial, from the other side of
the boundary: a C game on libakgl that embeds the interpreter as its enemy-behavior
engine, for anyone whose question is "how do I put this in *my* game".
## Chapters ## Chapters
@@ -41,6 +44,8 @@ but the earlier chapters, and 18 assumes it.
| **[17. Tutorial: Breakout](17-tutorial-breakout.md)** | Build a whole game out of the text grid and two `DATA` sprites, in sixteen steps | | **[17. Tutorial: Breakout](17-tutorial-breakout.md)** | Build a whole game out of the text grid and two `DATA` sprites, in sixteen steps |
| **[18. Tutorial: Breakout with artwork](18-tutorial-breakout-artwork.md)** | Build it again out of loaded artwork, powerups and a drawn colour HUD, in thirteen | | **[18. Tutorial: Breakout with artwork](18-tutorial-breakout-artwork.md)** | Build it again out of loaded artwork, powerups and a drawn colour HUD, in thirteen |
| **[19. Menus and dialogs](19-user-interface.md)** | `MENU`, `DIALOG`, `HUD` and `UISTYLE` — the widgets, and who owns the keyboard | | **[19. Menus and dialogs](19-user-interface.md)** | `MENU`, `DIALOG`, `HUD` and `UISTYLE` — the widgets, and who owns the keyboard |
| **[20. Tutorial: GALAGA](20-tutorial-galaga.md)** | Build a C engine on libakgl that embeds the interpreter, boots a script and hands it an actor |
| **[21. Tutorial: GALAGA enemies](21-tutorial-galaga-enemies.md)** | Share three C structs with the script, then write the wave's whole brain in BASIC |
## The shortest possible start ## The shortest possible start

View File

@@ -0,0 +1,8 @@
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
(void)SCRIPT; (void)SINK; (void)SINKSTATE; (void)SOURCE;
(void)args; (void)argp; (void)dtval; (void)result;
(void)enemy; (void)actor; (void)dt;
SUCCEED_RETURN(errctx);
}

View File

@@ -0,0 +1,53 @@
/*
* Prelude for the interpreter-facing fragments in docs/20: the boot sequence,
* the ADDEM proof and the rebind-call-reset protocol, shown as runs of CATCH
* calls. The statics are the ones examples/galaga/script.c keeps; the locals
* are the superset every fragment draws from, void-cast in the postlude so an
* unused one is not a warning.
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/actor.h>
#include <akbasic/environment.h>
#include <akbasic/error.h>
#include <akbasic/host.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
typedef struct galaga_docs_Enemy
{
int32_t kind;
int32_t state;
float homex;
float homey;
float t;
int32_t hp;
int32_t fire;
float rnd;
} galaga_docs_Enemy;
static akbasic_Runtime SCRIPT;
static akbasic_TextSink SINK;
static akbasic_StdioSink SINKSTATE;
static char SOURCE[16384];
akerr_ErrorContext AKERR_NOIGNORE *galaga_docs_fragment(galaga_docs_Enemy *enemy, akgl_Actor *actor, float dt);
akerr_ErrorContext AKERR_NOIGNORE *galaga_docs_fragment(galaga_docs_Enemy *enemy, akgl_Actor *actor, float dt)
{
PREPARE_ERROR(errctx);
akbasic_Value args[2];
akbasic_Value *argp[2];
akbasic_Value dtval;
akbasic_Value *result = NULL;
ATTEMPT {

View File

@@ -0,0 +1,121 @@
/*
* Prelude for file-scope fragments in docs/20 and docs/21 that assume the
* galaga example's own declarations already exist -- the shared structures
* from examples/galaga/galaga.h and the helpers a fragment calls but does not
* define. The types are copied rather than included so a fragment compiles
* against exactly what the chapter has shown so far; the helper declarations
* are invented prototypes, per the prelude policy in MAINTENANCE.md.
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <SDL3/SDL.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>
#include <akbasic/environment.h>
#include <akbasic/error.h>
#include <akbasic/host.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
#define GALAGA_ENEMY_BEE 0
#define GALAGA_ENEMY_BUTTERFLY 1
#define GALAGA_ENEMY_BOSS 2
#define GALAGA_ENEMY_KINDS 3
#define GALAGA_MAX_ENEMIES 40
#define GALAGA_MAX_PLAYER_SHOTS 2
#define GALAGA_MAX_ENEMY_SHOTS 8
#define GALAGA_ES_ENTERING (1 << 0)
#define GALAGA_ES_FORMATION (1 << 1)
#define GALAGA_ES_DIVING (1 << 2)
typedef struct galaga_Enemy
{
int32_t kind;
int32_t state;
float homex;
float homey;
float t;
int32_t hp;
int32_t fire;
float rnd;
} galaga_Enemy;
typedef struct galaga_Shared
{
float playerx;
float playery;
int32_t wave;
float rnd;
} galaga_Shared;
typedef enum
{
GALAGA_SCREEN_TITLE = 0,
GALAGA_SCREEN_PLAY,
GALAGA_SCREEN_GAMEOVER,
GALAGA_SCREEN_VICTORY
} galaga_Screen;
typedef struct galaga_Game
{
galaga_Screen screen;
int frame;
float dt;
bool autoplay;
int score;
int lives;
int kills[GALAGA_ENEMY_KINDS];
int shots[GALAGA_ENEMY_KINDS];
int script_errors;
akgl_Actor *player;
float fire_cooldown;
float respawn_timer;
bool firing;
bool moveleft;
bool moveright;
int player_shots_live;
int enemy_shots_live;
} galaga_Game;
extern galaga_Game galaga_game;
extern galaga_Shared galaga_shared;
extern galaga_Enemy galaga_enemies[GALAGA_MAX_ENEMIES];
extern akgl_Actor *galaga_enemy_actors[GALAGA_MAX_ENEMIES];
float galaga_random(void);
akerr_ErrorContext AKERR_NOIGNORE *galaga_script_update_enemy(galaga_Enemy *enemy, akgl_Actor *actor, float dt);
akerr_ErrorContext AKERR_NOIGNORE *galaga_boom_spawn(float x, float y);
akerr_ErrorContext AKERR_NOIGNORE *enemy_fire(galaga_Enemy *enemy, akgl_Actor *from);
akerr_ErrorContext AKERR_NOIGNORE *kill_enemy(int index);
akerr_ErrorContext AKERR_NOIGNORE *player_update(akgl_Actor *obj);
akerr_ErrorContext AKERR_NOIGNORE *left_on(akgl_Actor *obj, SDL_Event *event);
akerr_ErrorContext AKERR_NOIGNORE *left_off(akgl_Actor *obj, SDL_Event *event);
akerr_ErrorContext AKERR_NOIGNORE *right_on(akgl_Actor *obj, SDL_Event *event);
akerr_ErrorContext AKERR_NOIGNORE *right_off(akgl_Actor *obj, SDL_Event *event);
akerr_ErrorContext AKERR_NOIGNORE *fire_on(akgl_Actor *obj, SDL_Event *event);
akerr_ErrorContext AKERR_NOIGNORE *fire_off(akgl_Actor *obj, SDL_Event *event);
void shot_box(akgl_Actor *actor, SDL_FRect *dest);
void enemy_box(akgl_Actor *actor, SDL_FRect *dest);
void player_box(akgl_Actor *actor, SDL_FRect *dest);

View File

@@ -0,0 +1,6 @@
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
(void)event;
SUCCEED_RETURN(errctx);
}

View File

@@ -0,0 +1,55 @@
/*
* Prelude for statement-context fragments in docs/20: runs of CATCH calls
* from the galaga frame loop, shown without their scaffolding because the
* ATTEMPT protocol is the scaffolding. Same policy as hostcalls.pre.
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/draw.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/renderer.h>
#include <akgl/ui.h>
#include <akbasic/error.h>
#include <akbasic/runtime.h>
typedef enum
{
GALAGA_SCREEN_TITLE = 0,
GALAGA_SCREEN_PLAY,
GALAGA_SCREEN_GAMEOVER,
GALAGA_SCREEN_VICTORY
} galaga_Screen;
struct galaga_docs_Game
{
galaga_Screen screen;
float dt;
akgl_Actor *player;
};
extern struct galaga_docs_Game galaga_game;
extern akgl_Actor *galaga_enemy_actors[40];
akerr_ErrorContext AKERR_NOIGNORE *declare_title(void);
akerr_ErrorContext AKERR_NOIGNORE *declare_play(void);
akerr_ErrorContext AKERR_NOIGNORE *declare_end(void);
akerr_ErrorContext AKERR_NOIGNORE *galaga_docs_fragment(void);
akerr_ErrorContext AKERR_NOIGNORE *galaga_docs_fragment(void)
{
PREPARE_ERROR(errctx);
SDL_Event event;
ATTEMPT {

View File

@@ -0,0 +1,37 @@
/*
* Prelude for the self-contained file-scope fragments in docs/20 and docs/21:
* blocks that define a struct, a table or a whole function from scratch need
* only the includes. Only compiled in the AKBASIC_WITH_AKGL build.
*/
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <SDL3/SDL.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>
#include <akbasic/environment.h>
#include <akbasic/error.h>
#include <akbasic/host.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>

View File

@@ -2,5 +2,7 @@
} PROCESS(errctx) { } PROCESS(errctx) {
} FINISH(errctx, true); } FINISH(errctx, true);
(void)score; (void)score;
(void)argp;
(void)result;
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }

View File

@@ -7,6 +7,7 @@
* surrounding prose says exists but does not print. * surrounding prose says exists but does not print.
*/ */
#include <akerror.h> #include <akerror.h>
#include <akbasic/environment.h>
#include <akbasic/error.h> #include <akbasic/error.h>
#include <akbasic/runtime.h> #include <akbasic/runtime.h>
#include <akbasic/variable.h> #include <akbasic/variable.h>
@@ -23,5 +24,7 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_docs_fragment(void)
{ {
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
int64_t score = 0; int64_t score = 0;
akbasic_Value *argp[4];
akbasic_Value *result = NULL;
ATTEMPT { ATTEMPT {