Files
akbasic/docs/20-tutorial-galaga.md

752 lines
27 KiB
Markdown
Raw Normal View History

# 20. Tutorial: GALAGA — a C engine with a BASIC brain
This chapter and [Chapter 21](21-tutorial-galaga-enemies.md) build a GALAGA-style
fixed shooter from an empty file. The engine — window, starfield, bullets,
collision, score, screens — is C on libakgl. The enemies think in BASIC: one
script of `DEF` functions is called once per enemy per frame, and it reads and
writes the engine's own structures with no marshalling in either direction.
This chapter builds the engine and proves the boundary works; the next one
fills in the data structures and the AI.
The split is the point. Everything mechanical stays compiled, and everything an
enemy *decides* is a text file you can edit and re-run without rebuilding. It is
an academic exercise in *how* such an embed is done, not a claim that it is the
best way to write a GALAGA.
This is what the two chapters build:
![A full wave: four green bosses, two rows of butterflies, bees still streaming into the grid, the player firing](images/galaga-wave.png)
The finished program is [`examples/galaga/`](../examples/galaga/): four C files,
one `galaga.bas`, and the assets. You do not need it to follow along, but it is
the same program assembled.
```sh norun
$ cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
$ cmake --build build-akgl --target akbasic_example_galaga
$ ./build-akgl/akbasic_example_galaga
```
| Key | Does |
|---|---|
| left / right | move the ship |
| space | fire — two shots on screen at a time, the classic rule |
| return | choose a menu entry |
## What you will do
- **[Step 1](#step-1-open-a-window)** — open a window, in the one startup order
that works
- **[Step 2](#step-2-scatter-a-starfield)** — scatter a starfield and scroll it,
with no parallax machinery at all
- **[Step 3](#step-3-put-a-ship-on-screen)** — put a ship on screen from a
sprite and a character file, and drive it from the keyboard
- **[Step 4](#step-4-shots-and-collision)** — spawn shots from the actor heap
and collide them by hand
- **[Step 5](#step-5-boot-the-interpreter)** — link the interpreter in, load a
script of definitions, and call one from C
- **[Step 6](#step-6-the-update-hook)** — replace an actor's update hook so its
every frame is a BASIC call
- **[Step 7](#step-7-first-light)** — watch one enemy move under BASIC control,
and read the same numbers from both sides
- **[Step 8](#step-8-screens)** — add the title, game over and victory screens
- **[Step 9](#step-9-run-it-headless)** — run the whole game headless, so CI can
play it every night
Each step compiles and runs. The C fragments quote the finished example; the
file layout there — `main.c` for the harness, `script.c` for the boundary,
`enemies.c` and `player.c` for the actors — is a good one to copy.
---
## Step 1: Open a window
**Goal: a black window with a title, from the canonical startup order.**
libakgl has one startup sequence that works, documented at the top of its
`include/akgl/game.h` and walked through in its own tutorial (libakgl
docs/20-tutorial-sidescroller.md). The order matters twice: the screen
properties are read by the renderer, so they must be set before it exists, and
`akgl_game_init()` does **not** install a physics backend, so the application
must.
```c wrap=galagatypes requires=akgl
static akerr_ErrorContext *startup(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, aksl_strncpy((char *)&akgl_game.name, sizeof(akgl_game.name),
"akbasic galaga tutorial", sizeof(akgl_game.name) - 1));
PASS(errctx, aksl_strncpy((char *)&akgl_game.version, sizeof(akgl_game.version),
"1.0.0", sizeof(akgl_game.version) - 1));
PASS(errctx, aksl_strncpy((char *)&akgl_game.uri, sizeof(akgl_game.uri),
"net.aklabs.akbasic.galaga", sizeof(akgl_game.uri) - 1));
PASS(errctx, akgl_game_init());
PASS(errctx, akgl_set_property("game.screenwidth", "1280"));
PASS(errctx, akgl_set_property("game.screenheight", "960"));
PASS(errctx, akgl_render_2d_init(akgl_renderer));
FAIL_ZERO_RETURN(
errctx,
SDL_SetRenderLogicalPresentation(
akgl_renderer->sdl_renderer,
1280,
960,
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
AKGL_ERR_SDL,
"%s",
SDL_GetError()
);
akgl_camera->x = 0.0f;
akgl_camera->y = 0.0f;
akgl_camera->w = 1280.0f;
akgl_camera->h = 960.0f;
PASS(errctx, akgl_physics_init_null(akgl_physics));
SUCCEED_RETURN(errctx);
}
```
Three of those lines deserve their reasons.
**The view is 1280x960 because the artwork is ~100 pixels wide.** libakgl draws
a sprite at the sprite's own size — `akgl_Actor.scale` is overwritten every
frame, so there is no way to draw one smaller (libakgl docs/12-actors.md) — and
a ten-column formation of 100-pixel ships needs 1120 pixels plus margins. The
view is sized to the art rather than the art resized to a view.
**`akgl_physics_init_null()` is not optional.** Skip it and the first
`akgl_game_update()` calls through a NULL `simulate` pointer. Null physics
accepts every call and moves nothing, which is exactly right here: whatever
writes `x` and `y` directly is the mover, and in this game that will be BASIC.
**Error handling is the house protocol.** Every function returns
`akerr_ErrorContext *`, `PASS` propagates, `ATTEMPT`/`CATCH`/`CLEANUP` brackets
anything that must unwind. libakgl's docs/04-errors.md teaches it; this chapter
just uses it. The status codes this game raises are `AKERR_NULLPOINTER`,
`AKERR_VALUE`, `AKERR_KEY`, `AKERR_IO`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL`
and `AKGL_ERR_HEAP` — there is no code this tutorial invents.
The includes the engine files draw on, so nothing later has to be guessed —
the SDL satellites use their own prefixes (`SDL3_ttf/SDL_ttf.h`, not
`SDL3/SDL_ttf.h`):
```c wrap=galagatypes requires=akgl
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/controller.h>
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/sprite.h>
#include <akgl/text.h>
#include <akgl/ui.h>
#include <akgl/util.h>
```
The frame loop is the standard bracket, with one addition you will meet in
Step 6 — for now, events in, world drawn, frame out:
```c wrap=galagahost requires=akgl
while ( SDL_PollEvent(&event) == true ) {
CATCH(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
}
CATCH(errctx, akgl_renderer->frame_start(akgl_renderer));
CATCH(errctx, akgl_game_update(NULL));
CATCH(errctx, akgl_renderer->frame_end(akgl_renderer));
```
`akgl_game_update(NULL)` is update-every-actor, step-the-physics,
draw-the-world. It neither clears nor presents; the `frame_start` and
`frame_end` calls own that.
## Step 2: Scatter a starfield
**Goal: a scrolling two-depth starfield, from an array and one draw call.**
No parallax facility exists in libakgl and none is needed. A fixed array of
stars, advanced per frame and drawn with `akgl_draw_point()` between
`frame_start` and `akgl_game_update()`, is the whole feature. Two speed bands
give the depth for free — the slow band reads as far away:
```c wrap=galagatypes requires=akgl
#define GALAGA_STARS 96
static struct
{
float x;
float y;
float speed;
Uint8 bright;
} STARS[GALAGA_STARS];
static akerr_ErrorContext *starfield_draw(float dt)
{
SDL_Color color = { 255, 255, 255, 255 };
int i = 0;
PREPARE_ERROR(errctx);
for ( i = 0; i < GALAGA_STARS; i++ ) {
STARS[i].y += STARS[i].speed * dt;
if ( STARS[i].y > 960.0f ) {
STARS[i].y -= 960.0f;
}
color.r = STARS[i].bright;
color.g = STARS[i].bright;
color.b = STARS[i].bright;
PASS(errctx, akgl_draw_point(akgl_renderer, STARS[i].x, STARS[i].y, color));
}
SUCCEED_RETURN(errctx);
}
```
Seed the array once at startup — even indexes slow and dim (speed 40, bright
110), odd indexes fast and bright (speed 110, bright 220) — and the effect is
done. A point is exactly one pixel (libakgl docs/09-drawing.md).
## Step 3: Put a ship on screen
**Goal: a player actor, drawn from a character file, moving on key input.**
The art is Kenney's Space Shooter pack, CC0, used byte for byte — see
[`examples/galaga/assets/art/PROVENANCE.md`](../examples/galaga/assets/art/PROVENANCE.md)
for what each file is. An actor gets its looks from a **character**, which maps
actor state words to **sprites** (libakgl docs/10 and 12). Both are JSON; load
sprites first, because a character names its sprites and a character loaded
first fails on the first name it cannot find.
These are the names, so the loading lists and every
`akgl_actor_set_character()` call in both chapters agree — each `sprite_*.json`
and `character_*.json` lives in `assets/`:
| Character | Sprite(s) it maps | Worn by |
|---|---|---|
| `galaga_player` | `galaga_player` | the ship |
| `galaga_bee` | `galaga_bee` | bees |
| `galaga_butterfly` | `galaga_butterfly` | butterflies |
| `galaga_boss` | `galaga_boss`, and `galaga_boss_hurt` on state bit 13 | bosses |
| `galaga_playershot` | `galaga_playershot` | the ship's shots |
| `galaga_enemyshot` | `galaga_enemyshot` | enemy shots |
| `galaga_boom` | `galaga_boom` | explosions |
The spawn is four decisions after the two boilerplate calls:
```c wrap=galagagame requires=akgl
static akerr_ErrorContext *galaga_player_spawn(void)
{
akgl_Actor *player = NULL;
PREPARE_ERROR(errctx);
PASS(errctx, akgl_heap_next_actor(&player));
PASS(errctx, akgl_actor_initialize(player, "player"));
PASS(errctx, akgl_actor_set_character(player, "galaga_player"));
/* AFTER initialize: it resets all seven hooks. */
player->updatefunc = &player_update;
player->movement_controls_face = false;
player->state = AKGL_ACTOR_STATE_ALIVE;
player->visible = true;
player->x = 590.0f;
player->y = 860.0f;
galaga_game.player = player;
SUCCEED_RETURN(errctx);
}
```
Each of the four lines under the comment closes a trap:
- **`updatefunc` after `akgl_actor_initialize()`**, never before — initialize
installs all seven default hooks, and a hook set first is a hook reset.
- **`movement_controls_face = false`.** The default facing logic edits the
state word, a character mapping matches the **whole** word, and an actor
whose state matches no mapping is *silently not drawn*. Nothing here moves by
state bits, so facing stays out of the word entirely.
- **`state = AKGL_ACTOR_STATE_ALIVE`** — the word the character mapping names.
- **`visible = true`.** `akgl_actor_initialize()` does not raise it. In a
tilemap game the map loader copies visibility from map data; there is no map
here, so an actor that skips this line exists, moves, fires and collides —
invisibly. This one line cost this example its first screenshot.
Input goes through a control map: push a control per key with handlers that set
flags, and let the actor's update hook read the flags. The full recipe is in
`examples/galaga/player.c` and libakgl docs/16-input.md; the shape is:
```c wrap=galagagame requires=akgl
static akerr_ErrorContext *galaga_player_controls(void)
{
akgl_Control control;
PREPARE_ERROR(errctx);
memset(&control, 0, sizeof(control));
control.event_on = SDL_EVENT_KEY_DOWN;
control.event_off = SDL_EVENT_KEY_UP;
control.key = SDLK_LEFT;
control.handler_on = &left_on;
control.handler_off = &left_off;
PASS(errctx, akgl_controller_pushmap(0, &control));
control.key = SDLK_RIGHT;
control.handler_on = &right_on;
control.handler_off = &right_off;
PASS(errctx, akgl_controller_pushmap(0, &control));
control.key = SDLK_SPACE;
control.handler_on = &fire_on;
control.handler_off = &fire_off;
PASS(errctx, akgl_controller_pushmap(0, &control));
akgl_controlmaps[0].target = galaga_game.player;
SUCCEED_RETURN(errctx);
}
```
Hand **every** polled event to `akgl_controller_handle_event()` — one that no
control binds is not an error, it is a call that did nothing.
## Step 4: Shots and collision
**Goal: bullets that fly, hit, and give their actor slot back.**
Bullets and collision are C forever — they are engine, not behavior. A shot is
an actor from the same 64-slot heap pool, with its own tiny update hook: move,
test, release.
```c wrap=galagagame requires=akgl
static akerr_ErrorContext *player_shot_update(akgl_Actor *obj)
{
SDL_FRect mine;
SDL_FRect theirs;
bool hit = false;
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
obj->y -= 900.0f * galaga_game.dt;
if ( obj->y < -60.0f ) {
galaga_game.player_shots_live -= 1;
PASS(errctx, akgl_heap_release_actor(obj));
SUCCEED_RETURN(errctx);
}
shot_box(obj, &mine);
for ( i = 0; i < GALAGA_MAX_ENEMIES; i++ ) {
if ( galaga_enemy_actors[i] == NULL ) {
continue;
}
enemy_box(galaga_enemy_actors[i], &theirs);
PASS(errctx, akgl_collide_rectangles(&mine, &theirs, &hit));
if ( !hit ) {
continue;
}
galaga_enemies[i].hp -= 1;
if ( galaga_enemies[i].hp <= 0 ) {
PASS(errctx, kill_enemy(i));
}
galaga_game.player_shots_live -= 1;
PASS(errctx, akgl_heap_release_actor(obj));
SUCCEED_RETURN(errctx);
}
SUCCEED_RETURN(errctx);
}
```
Four conventions worth keeping:
- **`akgl_collide_rectangles()` is the whole collision system.** At most 2
shots x 40 enemies of axis-aligned tests per frame is noise; the full
`akgl_CollisionWorld` machinery earns its keep on tilemaps, not here. The
`shot_box`/`enemy_box` helpers inset each box from the artwork's rectangle,
because the PNGs carry transparent margin that should not kill anybody.
- **Releasing is despawning.** `akgl_heap_release_actor()` unregisters the
actor and stops it drawing; releasing mid-sweep is safe because
`akgl_game_update()` re-reads each slot's refcount as it goes.
- **Names carry a serial** — `pshot17`, not `pshot1` reused — because the actor
registry is keyed by name, and two live actors with one name is a fight.
- **Spawn caps are C-side refusals.** Two player shots, eight enemy shots; the
spawn functions simply decline past the cap.
Give the enemy shots the same shape falling downward, and the ship a sweep over
both — `examples/galaga/player.c` has all three loops.
Explosions are the fourth actor kind, and they carry the one place this game
*absorbs* an error instead of propagating it. `HANDLE` names the status it
forgives; everything else still travels:
```c wrap=galagagame requires=akgl
static float BOOM_TTL[AKGL_MAX_HEAP_ACTOR];
static uint32_t BOOM_SERIAL = 0;
static akerr_ErrorContext *boom_update(akgl_Actor *obj)
{
ptrdiff_t slot = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
slot = obj - akgl_heap_actors;
BOOM_TTL[slot] -= galaga_game.dt;
if ( BOOM_TTL[slot] <= 0.0f ) {
PASS(errctx, akgl_heap_release_actor(obj));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *galaga_boom_spawn(float x, float y)
{
akgl_Actor *boom = NULL;
char name[32];
int count = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_heap_next_actor(&boom));
BOOM_SERIAL += 1;
CATCH(errctx, aksl_snprintf(&count, name, sizeof(name), "boom%u", BOOM_SERIAL));
CATCH(errctx, akgl_actor_initialize(boom, name));
CATCH(errctx, akgl_actor_set_character(boom, "galaga_boom"));
boom->updatefunc = &boom_update;
boom->movement_controls_face = false;
boom->state = AKGL_ACTOR_STATE_ALIVE;
boom->visible = true;
boom->x = x;
boom->y = y;
BOOM_TTL[boom - akgl_heap_actors] = 0.25f;
} CLEANUP {
} PROCESS(errctx) {
} HANDLE(errctx, AKGL_ERR_HEAP) {
/* Explosions are decoration. When the heap is momentarily full the
* right outcome is no explosion, not a dead frame. */
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
```
## Step 5: Boot the interpreter
**Goal: the engine calls a BASIC function and prints its answer.**
Everything so far was libakgl. Now link the interpreter into the same
executable. The whole CMake recipe, inside an akbasic checkout with
`AKBASIC_WITH_AKGL=ON`:
```cmake
add_executable(mygalaga
main.c
script.c
enemies.c
player.c)
target_compile_options(mygalaga PRIVATE -Wall -Wextra)
target_compile_definitions(mygalaga PRIVATE
GALAGA_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets"
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/galaga.bas"
GALAGA_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf")
target_link_libraries(mygalaga PRIVATE akbasic akgl
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)
```
The three baked-in paths are what let the program launch from any working
directory; `--assets` and `--script` flags can override them at runtime.
Link `akbasic` — the interpreter only. Not `akbasic_akgl` (the device backends
that let a script draw), and not `akbasic_frontend` (the standalone program's
host). This game lends the script **no devices at all**: the scripts compute,
the engine draws, and a script that tries `SPRITE` is refused by name. That
refusal is enforced by the interpreter, not by convention —
[Chapter 10](10-embedding.md) explains the device-lending model this game
declines to use.
The boot is the embedding host from Chapter 10, adapted to a script that only
defines. Keep every line that touches the interpreter in one file — the
example's `script.c` — so the boundary stays a place rather than a habit. That
file's interpreter-facing includes and statics, exactly:
```c wrap=galagatypes requires=akgl
#include <akbasic/environment.h>
#include <akbasic/error.h>
#include <akbasic/host.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
/* Static because an akbasic_Runtime is far too big for a stack frame --
* 2.40 MiB on this branch. */
static akbasic_Runtime SCRIPT;
static akbasic_TextSink SINK;
static akbasic_StdioSink SINKSTATE;
static char SOURCE[16384];
```
The boot itself:
```c wrap=galagacalls requires=akgl
CATCH(errctx, akbasic_error_register());
CATCH(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, NULL));
CATCH(errctx, akbasic_runtime_init(&SCRIPT, &SINK));
CATCH(errctx, akbasic_runtime_load(&SCRIPT, SOURCE));
CATCH(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
CATCH(errctx, akbasic_runtime_run(&SCRIPT, 4 * AKBASIC_MAX_SOURCE_LINES));
CATCH(errctx, akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN));
```
Two of those lines are the ones a first embedding gets wrong.
**A "no top level code" script still has to run once.** The script is nothing
but `DEF` blocks and a final `END`, and executing the `DEF` statements is what
files the functions. The run is bounded — a script that is all definitions has
no business taking more than a few steps per line, and an accidental loop at
boot should be a diagnosis, not a hang.
**The `set_mode` after the run is load-bearing.** The program has now ended and
the runtime sits in QUIT mode, where a multi-line `DEF` called from the host
returns a silent zero. Forcing the mode back to RUN makes the bodies run, and it
stays put because nothing here ever steps the runtime again. Issue #8 tracks
making this workaround unnecessary.
`PRINT` inside the script goes through the stdio sink and lands on stdout —
that is the script's debug channel for the rest of both chapters.
Prove the wiring with one function. Put this in the script:
```basic
DEF ADDEM(A#, B#) = A# + B#
END
```
And call it from C, with values you already have:
```c wrap=galagacalls requires=akgl
memset(&args[0], 0, sizeof(args[0]));
memset(&args[1], 0, sizeof(args[1]));
args[0].valuetype = AKBASIC_TYPE_INTEGER;
args[0].intval = 17;
args[1].valuetype = AKBASIC_TYPE_INTEGER;
args[1].intval = 25;
argp[0] = &args[0];
argp[1] = &args[1];
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "ADDEM", argp, 2, &result));
printf("ADDEM(17, 25) = %lld\n", (long long)result->intval);
```
```text
ADDEM(17, 25) = 42
```
`akbasic_runtime_call_function()` is the host's entry point: a name and
already-evaluated values in, the function's result out. The engine refuses to
start when the script will not boot — a game whose enemies cannot think is not
a game missing a feature, it is a game that does not run.
## Step 6: The update hook
**Goal: one actor whose every frame is a BASIC call.**
`akgl_game_update()` calls each live actor's `updatefunc` exactly once per
frame. Replacing that pointer is the whole integration: the actor's frame *is*
a script call.
```c wrap=galagagame requires=akgl
static akerr_ErrorContext *enemy_update(akgl_Actor *obj)
{
galaga_Enemy *enemy = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
enemy = (galaga_Enemy *)obj->actorData;
FAIL_ZERO_RETURN(errctx, enemy, AKERR_NULLPOINTER, "an enemy actor with no galaga_Enemy attached");
enemy->rnd = galaga_random();
PASS(errctx, galaga_script_update_enemy(enemy, obj, galaga_game.dt));
if ( enemy->fire != 0 ) {
PASS(errctx, enemy_fire(enemy, obj));
}
SUCCEED_RETURN(errctx);
}
```
The hook's body is a protocol, and `galaga_script_update_enemy()` is its
middle: **rebind, call, recover, reset.**
```c wrap=galagacalls requires=akgl
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "SELF@", enemy));
CATCH(errctx, akbasic_host_rebind(&SCRIPT, "ACTOR@", actor));
memset(&dtval, 0, sizeof(dtval));
dtval.valuetype = AKBASIC_TYPE_FLOAT;
dtval.floatval = (double)dt;
argp[0] = &dtval;
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "UPDATEBEE", argp, 1, &result));
CATCH(errctx, akbasic_environment_zero(SCRIPT.environment));
```
`SELF@` and `ACTOR@` are **host bindings** — the enemy's record and the
engine's live actor, shared with the script as structures it can read and
write directly. [Chapter 21](21-tutorial-galaga-enemies.md) builds them; for
this chapter, know that `akbasic_host_rebind()` points an existing binding at
a different instance, which is how forty enemies share one script: one name,
rebound per enemy, rather than forty names.
**The `akbasic_environment_zero()` after every call is load-bearing.** Each
call parks its result in the caller environment's per-line value scratch, and a
host calling in a loop never crosses the line boundary that would reset it.
Without this line the scratch drains in under two frames of a 40-enemy wave and
every later call fails with `Maximum values per line reached`. Chapter 10's
["Calling a function every frame"](10-embedding.md#calling-a-function-every-frame)
section is the rule's home.
## Step 7: First light
**Goal: a C actor moving under BASIC control, and proof it is one memory.**
Before any real AI, the smallest demonstration. One enemy, one function, a sine
drift written entirely in BASIC through the actor binding:
```basic
DEF UPDATEBEE(DT%)
SELF@.T% = SELF@.T% + DT%
ACTOR@.X% = 590.0 + SIN(SELF@.T%) * 200
ACTOR@.Y% = 300.0
PRINT "BASIC SEES X = " + ACTOR@.X%
RETURN 0
END
```
Spawn one enemy with the hook from Step 6, and have the engine print the same
actor's position each frame from C:
```c wrap=galagahost requires=akgl
SDL_Log("C SEES X = %f", galaga_enemy_actors[0]->x);
```
```text
BASIC SEES X = 593.191094
INFO: C SEES X = 593.191094
BASIC SEES X = 596.378593
INFO: C SEES X = 596.378593
```
Same numbers, one memory. The script wrote `ACTOR@.X%`; the renderer read
`akgl_Actor.x`; nothing copied anything anywhere. The ship swings in a slow
arc, and the whole architecture is visible in that one motion: C owns the
frame, BASIC owns the decision, and the actor is the same bytes to both.
## Step 8: Screens
**Goal: title, playing, game over, victory — a state machine around the loop.**
The screens are libakgl's UI layer, in the three-state pattern of its uidemo
example (libakgl docs/22-ui.md). A `galaga_Screen` enum, one `declare_*()`
function per screen, and the UI bracket between `akgl_game_update()` and
`frame_end` — exactly where the frame contract puts it:
```c wrap=galagahost requires=akgl
CATCH(errctx, akgl_ui_frame_begin());
switch ( galaga_game.screen ) {
case GALAGA_SCREEN_TITLE:
CATCH(errctx, declare_title());
break;
case GALAGA_SCREEN_PLAY:
CATCH(errctx, declare_play());
break;
case GALAGA_SCREEN_GAMEOVER:
case GALAGA_SCREEN_VICTORY:
CATCH(errctx, declare_end());
break;
}
CATCH(errctx, akgl_ui_frame_end(akgl_renderer));
```
The playing screen is two `akgl_ui_label()` calls — score top-left, lives and
wave top-right — formatted into `static` buffers, because the UI borrows label
text until `frame_end` and a local buffer would be dangling by the time it
draws. The title and end screens are an `akgl_ui_menu()` at the center.
The big **GALAGA** headline is direct text rather than a label:
```c wrap=galagagame requires=akgl
static akerr_ErrorContext *draw_banner(char *text)
{
SDL_Color ink = { 235, 235, 235, 255 };
TTF_Font *font = NULL;
int w = 0;
int h = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text");
font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "banner", NULL);
FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "the banner font is not loaded");
PASS(errctx, akgl_text_measure(font, text, &w, &h));
PASS(errctx, akgl_text_rendertextat(font, text, ink, 0, (1280 - w) / 2, 280));
SUCCEED_RETURN(errctx);
}
```
The menu owns `AKGL_UI_ANCHOR_CENTER`, a label anchored there disappears
behind it, and there is no top-center anchor — so the headline measures itself
and draws at a coordinate, before the UI bracket so the menu still paints over
it if the two ever meet.
![The title screen: the banner, the menu, the starfield](images/galaga-title.png)
Screen transitions are three rules read after the world updates: lives spent is
GAME OVER, an empty wave is VICTORY, and a menu activation either restarts or
quits. The menu never clears its own `activated` flag — the state machine that
acts on it does.
## Step 9: Run it headless
**Goal: the same game, playable by a script, in CI every night.**
The example takes five flags, in the pattern of libakgl's sidescroller:
```sh norun
$ SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy SDL_RENDER_DRIVER=software \
./build-akgl/akbasic_example_galaga --frames 600 --autoplay
```
`--frames N` bounds the run; `--autoplay` is a scripted pilot that starts the
game, sweeps the floor and holds fire until the wave assembles; `--screenshot
PATH --screenshot-frame N` write a PNG from the render target — the figures in
this chapter are that flag's output, not pictures somebody took once. Synthetic
input goes through `akgl_controller_handle_event()` with constructed
`SDL_Event`s, never by calling the handlers directly — the point of autoplay is
to exercise the same path a keyboard does.
The last line of every run is the evidence:
```text
galaga: 600 frames, screen 1, score 1910, alive 9, kills bee 19 bfly 12 boss 0, shots bee 0 bfly 3 boss 0, script errors 0
```
Exiting 0 is not proof the wave flew. The readout is: kills and shots counted
per kind say the enemies entered, thought and fired, and **`script errors 0`**
says every one of the ~24,000 BASIC calls in those ten seconds came back clean.
A wave of dumb enemies still exits 0, and that count is how you notice. The
CTest entry `example_galaga` runs exactly this under the dummy SDL drivers,
which is what keeps both chapters honest.
---
That is the engine: a window, a starfield, a ship, bullets, screens, and an
interpreter that answers when called. Everything on screen so far is C. What
turns it into a GALAGA is [Chapter 21](21-tutorial-galaga-enemies.md) — the
three shared structures, the script that thinks through them, and a full wave
entering, breathing, diving and firing without another line of engine code.