The interop test now ends with a measured comparison: 24,000 formation updates through the script boundary against a line-for-line C translation of the same state machine. 881 us against 0.01 us per call on this machine, quoted verbatim in the new chapter 21 Step 11 with the architectural decisions it prices. A Haiku-class cold read of the chapters produced a build whose failures were all mechanical -- invented include paths, never-shown sink statics, guessed status codes and character names. The chapters now carry the include lists, the script.c statics, the status-code roster, the sprite/character table, the full CMake recipe and the explosion spawn's HANDLE example, so none of those have to be guessed again. Co-authored-by: andrew <andrew@aklabs.net> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
568 lines
23 KiB
Markdown
568 lines
23 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.
|
|
|
|
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.
|