Files
akbasic/docs/21-tutorial-galaga-enemies.md

568 lines
23 KiB
Markdown
Raw Normal View History

# 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 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.