Files
akbasic/docs/21-tutorial-galaga-enemies.md
Tachikoma dd10dc143a
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
Close the remaining cold-read gaps: bind, labels, menus and main's shape
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
2026-08-04 09:22:43 -04:00

24 KiB

21. Tutorial: GALAGA — the structures and the AI

Chapter 20 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

The finished script is 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 — declare the enemy record once, in C, and register it as a BASIC type
  • Step 2 — bind the engine's own actor as the second type, which is the point of the whole exercise
  • Step 3 — share the frame state, and give the script randomness it cannot make itself
  • Step 4 — see why the structures are bindings rather than function arguments
  • Step 5 — learn the three language rules that shape every enemy function
  • Step 6 — write the shared maneuvers: glide home, dive, decide to fire
  • Step 7 — write the bee, the butterfly and the boss
  • Step 8 — decide who owns the formation, and lay it out
  • Step 9 — decide what a script error does to the game, and make it do that
  • Step 10 — prove the boundary with a test that links the real files
  • Step 11 — 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:

#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):

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). 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). 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:

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:

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

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:

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.

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) — and a host can construct those argument values, so the obvious alternative interface is honest functions:

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 DIMmed 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 LABELs. Three rules of the dialect shape every body in it.

Rule 1: the left operand decides integer or float arithmetic (Chapter 3). This will bite every enemy script exactly once, so meet it now. The natural spelling of "move by speed times dt" moves nothing:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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):

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 nanoseconds; the scripted call costs high hundreds of microseconds.
  • 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, 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.