Some checks failed
akbasic CI Build / cmake_build (push) Failing after 5m32s
akbasic CI Build / sanitizers (push) Failing after 15m58s
akbasic CI Build / coverage (push) Failing after 20m7s
akbasic CI Build / mutation_test (push) Failing after 4m29s
akbasic CI Build / akgl_build (push) Failing after 12m28s
Three more Haiku-class cold reads of the chapters, each against the amended text. What each surfaced is now shown rather than described: the akbasic_host_register_type()/akbasic_host_bind() boot calls, the declare_play() label listing, the akgl_UiMenu static and its handle_event signature, one control-handler pair, and main()'s ATTEMPT/HANDLE_DEFAULT/FINISH_NORETURN shape with the CATCH-inside- ATTEMPT rule stated. By the fourth read the generated player.c and enemies.c compiled untouched and every remaining guess was a tuning value the chapters deliberately leave open. Co-authored-by: andrew <andrew@aklabs.net> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
586 lines
24 KiB
Markdown
586 lines
24 KiB
Markdown
# 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 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 11](#step-11-the-cost-measured)** — measure what thinking in BASIC
|
|
costs, against the same logic in C
|
|
|
|
---
|
|
|
|
## 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.
|
|
|
|
Registration and the first binding happen at boot, before the script loads —
|
|
between `akbasic_runtime_init()` and `akbasic_runtime_load()` in Chapter 20's
|
|
boot sequence. A binding is **borrowed, never copied**, so the placeholders it
|
|
points at must be static storage:
|
|
|
|
```c wrap=galagacalls requires=akgl
|
|
CATCH(errctx, akbasic_host_register_type(&SCRIPT, &ENEMY_TYPE));
|
|
CATCH(errctx, akbasic_host_register_type(&SCRIPT, &ACTOR_TYPE));
|
|
CATCH(errctx, akbasic_host_register_type(&SCRIPT, &GAME_TYPE));
|
|
|
|
CATCH(errctx, akbasic_host_bind(&SCRIPT, "SELF@", "ENEMY", &SCRATCH_ENEMY));
|
|
CATCH(errctx, akbasic_host_bind(&SCRIPT, "ACTOR@", "ACTOR", &SCRATCH_ACTOR));
|
|
CATCH(errctx, akbasic_host_bind(&SCRIPT, "GAME@", "GAME", &galaga_shared));
|
|
```
|
|
|
|
`akbasic_host_bind()` takes the script name, the registered type's name, and
|
|
the instance; after that, `SELF@` and `ACTOR@` are only ever *re*bound.
|
|
|
|
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
|
|
```
|
|
|
|
## Step 11: The cost, measured
|
|
|
|
**Goal: the real price of the boundary, in numbers, next to the same logic in C.**
|
|
|
|
The interop test binary ends with a benchmark: 24,000 formation-hold updates —
|
|
forty enemies at sixty frames a second for ten seconds — once through
|
|
`galaga_script_update_enemy()` and once through a line-for-line C translation
|
|
of `UPDATEBEE` with its helpers inlined. Same guard, same branches, same
|
|
arithmetic; the difference is the interpreter. On this repository's build
|
|
machine (a two-core VM, the interpreter built `-O2`):
|
|
|
|
```text
|
|
benchmark: 24000 formation-hold updates, dt 0.016
|
|
BASIC through the boundary: 21.147 s 881.11 us/call 35.245 ms per 40-enemy frame
|
|
the same logic in C: 0.000 s 0.01 us/call 0.001 ms per 40-enemy frame
|
|
ratio: 61022x
|
|
```
|
|
|
|
The facts, without decoration:
|
|
|
|
- **A BASIC-driven update costs about four orders of magnitude more than the
|
|
same logic compiled.** The C translation of the whole state machine costs
|
|
tens of *nano*seconds; the scripted call costs high hundreds of
|
|
*micro*seconds.
|
|
- **The cost is per line executed, not per call.** The interpreter scans and
|
|
parses each body line from source text on every call; a 3-line body measured
|
|
~148 us on this class of machine, and this ~15-line body measures ~881 us.
|
|
Body length is the knob.
|
|
- **At this cost, forty thinking enemies spend ~35 ms per frame on this
|
|
hardware** — more than two 60 Hz frames. The shipped example visibly runs
|
|
below 60 fps on this machine while the whole wave is alive, and exactly at
|
|
its frame pace once the wave thins. A faster machine moves the numbers, not
|
|
the shape.
|
|
|
|
This is the measured version of decisions the chapters already made on
|
|
architectural grounds. Bullets, collision and the starfield are C
|
|
([Chapter 20](20-tutorial-galaga.md), Steps 2 and 4) — at two shots and forty
|
|
tests a frame, scripting them would multiply the call count for things that
|
|
decide nothing. The fire decision is one flag rather than a per-bullet
|
|
callback (Step 6): the script's call budget is bounded by the enemy count and
|
|
nothing else. C owns the formation and the spawn timing (Step 8), so zero
|
|
calls happen for enemies that do not exist yet. And the 36 KiB function slots
|
|
and 2.40 MiB runtime (Step 5) are the memory half of the same bill.
|
|
|
|
What the cost buys is the previous ten steps: behavior as data, edited and
|
|
swapped without a compiler. Whether ~900 us per thinking entity per frame is
|
|
acceptable is a per-project decision — fewer thinkers, shorter bodies, or a
|
|
lower think rate (every Nth frame) are the standard levers, and all three are
|
|
host-side choices this architecture leaves open.
|
|
|
|
---
|
|
|
|
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.
|