8 Commits
10 ... 34

Author SHA1 Message Date
dd10dc143a Close the remaining cold-read gaps: bind, labels, menus and main's shape
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 5m32s
akbasic CI Build / sanitizers (push) Failing after 15m58s
akbasic CI Build / coverage (push) Failing after 20m7s
akbasic CI Build / mutation_test (push) Failing after 4m29s
akbasic CI Build / akgl_build (push) Failing after 12m28s
Three more Haiku-class cold reads of the chapters, each against the
amended text. What each surfaced is now shown rather than described: the
akbasic_host_register_type()/akbasic_host_bind() boot calls, the
declare_play() label listing, the akgl_UiMenu static and its
handle_event signature, one control-handler pair, and main()'s
ATTEMPT/HANDLE_DEFAULT/FINISH_NORETURN shape with the CATCH-inside-
ATTEMPT rule stated. By the fourth read the generated player.c and
enemies.c compiled untouched and every remaining guess was a tuning
value the chapters deliberately leave open.

Co-authored-by: andrew <andrew@aklabs.net>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XiGgpHuXUm2mR4Wzndw3dc
2026-08-04 09:22:43 -04:00
54ab85a276 Benchmark the boundary and close the cold read's tutorial gaps
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
2026-08-04 09:01:59 -04:00
d5a0edd692 Write the GALAGA tutorial chapters and the repeated-host-calls guide
docs/20 builds the engine and the boundary: the startup order, the
starfield, actors and collision, booting a DEF-only script, the issue #8
mode workaround, the custom update hook, first light, screens, and the
headless harness. docs/21 builds the three shared structures and the AI:
the host type tables, the actor binding, the randomness route around
issue #16, the measured case against structure arguments (issue #36),
the three language rules that shape the script, the maneuvers, the
argued formation decision, the script-death policy, and the interop
proof. Every fenced block runs under tests/docs_examples.sh in both
build configurations; five new preludes carry the C fragments.

docs/10 gains the 'Calling a function every frame' section the chapters
lean on: the per-call akbasic_environment_zero() rule, the set_mode(RUN)
workaround, the clear_error() revival, and the case for rebinding over
structure arguments. Index rows and chapter counts updated.

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 08:47:43 -04:00
47c6be58c5 Update the breakout chapter's scope-pool figure to this branch's 12
The environment pool shrank from 32 to 12 in the memory-reduction work
and the chapter's exhaustion transcript still asserted the old number,
which is a docs_examples failure on every run of this branch.

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 08:47:42 -04:00
27837aeabc Add the galaga example: a C engine with akbasic as its enemy brain
A GALAGA-style fixed shooter whose engine is C on libakgl (null physics)
with the interpreter embedded as the scripting engine that owns every
enemy's behavior. One DEF-only script is called per enemy per frame
through a custom akgl_Actor update hook; SELF@, ACTOR@ and GAME@ are host
bindings, so the script reads and writes the engine's real memory -- the
boss even swaps its own damage sprite by raising an actor state bit from
BASIC. Bullets, collision, scoring and screens stay C.

Structure arguments were measured and rejected for the per-frame path:
each pointer parameter spends a value-pool slot the pool never reclaims,
1,015 calls to exhaustion against an unbounded rebind (issue #36).

Built when AKBASIC_WITH_AKGL=ON. Two CTest entries: a 600-frame headless
autoplay run under the dummy SDL drivers, and an interop round-trip test
that links the real script.c and galaga.bas and pins the four boundary
claims, 24,000 sustained calls among them. docs_galaga_figures
regenerates the two checked-in figures. Art is Kenney CC0, byte for
byte, with provenance.

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 08:47:21 -04:00
743e610f8f Reset scratch per line and unwind dead scopes in host function calls
akbasic_runtime_call_function()'s body loop drives process_line_run()
directly, skipping the per-line prologue akbasic_runtime_step() provides.
The call environment's value scratch therefore accumulated across the
whole body, and any body past about ten real lines died with 'Maximum
values per line reached' -- a limit that is supposed to be per line. The
loop now runs the same prologue step() does.

A body that died also left its call scopes active: nothing popped them,
so a host absorbing script errors drained the twelve-slot environment
pool after twelve dead calls. The loop now unwinds to the caller's
environment on every exit path.

New: akbasic_runtime_clear_error(), the missing half of host revival. A
run's first BASIC-level error latches deliberately, and set_mode(RUN)
alone cannot un-decide that; a host that absorbed the error calls this
beside it. Both defects and the revival dance are pinned in
tests/user_functions.c.

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 08:47:05 -04:00
dde1d91c6e Reset the scanner's leftover token type between lines
The REM early-exit leaves tokentype holding AKBASIC_TOK_REM, and the scan
loop's post-switch check reads it before the next line's first character
has assigned anything. A line opening with whitespace then re-triggered
the REM break and scanned to nothing: every indented line after a REM was
silently skipped. Numbered programs never saw it -- the line number is the
first token and overwrites the leftover -- which is why the whole golden
corpus missed it and the unnumbered, indented galaga.bas found it.

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 08:46:51 -04:00
17af2d406c Cut akbasic_Runtime's static footprint from 10.75 MiB to 2.40 MiB
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 3m29s
akbasic CI Build / coverage (push) Failing after 3m40s
akbasic CI Build / sanitizers (push) Failing after 4m37s
akbasic CI Build / mutation_test (push) Failing after 3m35s
akbasic CI Build / akgl_build (push) Failing after 7m20s
Nothing in this interpreter mallocs; every pool is a fixed array sized by an
AKBASIC_MAX_* constant, so sizeof(akbasic_Runtime) is a compile-time number
and most of it was headroom nobody was using. Measured concurrent-use
high-water marks off examples/breakout and examples/megademo -- the two most
demanding programs this interpreter runs -- against each pool's ceiling:

  AKBASIC_MAX_ENVIRONMENTS   32 -> 12    (measured peak concurrency: 6-7)
  AKBASIC_MAX_FUNCTIONS      64 -> 8     (measured: 0, neither program uses DEF FN)
  AKBASIC_MAX_ARRAY_VALUES 4096 -> 2048  (measured peak: 1618 slots)
  AKBASIC_MAX_SOURCE_LINES 9999 -> 2048  (measured: ~1270-1496 non-blank lines)
  AKBASIC_SYMTAB_MAX_SLOTS  256 -> 172   (no caller ever requests more than 128)
  AKBASIC_SYMTAB_MAX_KEY     64 -> 24    (longest identifier measured: 11 chars)
  AKBASIC_MAX_LINE_LENGTH   256 -> 80    (Commodore BASIC's own line limit)

AKBASIC_MAX_VARIABLES (128) is untouched on purpose: breakout alone reaches
121 of 128 concurrent named variables, so it has the least slack of any pool
measured and is not a shrink candidate.

akbasic_Variable.name shrinks from AKBASIC_MAX_STRING_LENGTH (256) to
AKBASIC_SYMTAB_MAX_KEY: every variable name is registered with
akbasic_symtab_set() right after this field is populated
(akbasic_environment_create(), src/environment.c), and that call already
refuses anything AKBASIC_SYMTAB_MAX_KEY characters or longer. The wider field
was headroom nothing could ever put a byte into.

Two defects surfaced while testing the line-length drop against the golden
corpus, both fixed here because the 80-byte ceiling makes them routine rather
than theoretical:

- sourcepath (runtime.h) was borrowing AKBASIC_MAX_LINE_LENGTH by accident.
  It holds a directory, not a line of BASIC, and this checkout's own test
  paths are 81+ characters deep -- every golden test failed to load until
  this split into its own AKBASIC_MAX_SOURCE_PATH_LENGTH, backed by PATH_MAX
  the way libakerror already sizes its own path buffers.

- src/sink_stdio.c's stdio_readline() called aksl_fgets() but never checked
  its own documented contract: a full buffer with no trailing newline means
  the line was longer than the buffer, and the unread remainder is still in
  the stream. Unchecked, the next readline() picks that remainder up as its
  own statement -- a real line silently becomes two wrong ones instead of a
  clean AKBASIC_ERR_BOUNDS refusal. At 256 bytes this was theoretical; at 80
  it is not, so it now refuses loudly.

tests/value_pool.c's test_pool_is_untouched_by_scopes() was pinned to the old
4x1024=4096 pool math (four max-size arrays proving nothing leaked); rewritten
to 2x1024=2048 for the same proof against the new AKBASIC_MAX_ARRAY_VALUES.

Known consequence, tracked in andrew/akbasic#32 rather than worked around
here: two files in the protected tests/reference/ corpus
(language/functions/mod.bas, language/flowcontrol/nestedforloopwaitingfor
command.bas) have 82-character lines and cannot be shortened -- MAINTENANCE.md
and CMakeLists.txt:585 are explicit that tests/reference/ is never edited to
suit this interpreter. Twelve tests/language/ cases and one docs/18 line are
in the same position but are this project's own content. Shipping 80 anyway,
with the fallout tracked rather than hidden, was an explicit call on this PR
rather than something decided here.

Verified: cmake --build build-akgl && ctest --test-dir build-akgl, 97/112 (15
known failures, all AKBASIC_MAX_LINE_LENGTH-related, filed as #32).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-03 21:44:16 -04:00
62 changed files with 4742 additions and 27 deletions

View File

@@ -26,7 +26,7 @@ scripting engine for game authors.
| [the issue tracker](https://source.starfort.tech/andrew/akbasic/issues) | **Outstanding defects and gaps.** Labelled by kind and blast radius; `status::grooming` means the scope is not settled yet | | [the issue tracker](https://source.starfort.tech/andrew/akbasic/issues) | **Outstanding defects and gaps.** Labelled by kind and blast radius; `status::grooming` means the scope is not settled yet |
| [`TODO.md`](TODO.md) | The record: settled design decisions, the deviation register, defects already fixed, and the reasoning behind the measurements. §0.1 first — it retires the byte-for-byte fidelity constraint several later sections were written on | | [`TODO.md`](TODO.md) | The record: settled design decisions, the deviation register, defects already fixed, and the reasoning behind the measurements. §0.1 first — it retires the byte-for-byte fidelity constraint several later sections were written on |
| [`README.md`](README.md) | What the project is and why, for somebody who has not seen it | | [`README.md`](README.md) | What the project is and why, for somebody who has not seen it |
| [`docs/`](docs/README.md) | The language itself: eighteen chapters, verb and function reference. [Chapter 14](docs/14-architecture.md) is the interpreter's architecture — the step loop, the pools, the two kinds of error, and how to debug it. [Chapter 15](docs/15-error-codes.md) is the error-code appendix. [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) are tutorials that build the games in `examples/breakout/` | | [`docs/`](docs/README.md) | The language itself: twenty-one chapters, verb and function reference. [Chapter 14](docs/14-architecture.md) is the interpreter's architecture — the step loop, the pools, the two kinds of error, and how to debug it. [Chapter 15](docs/15-error-codes.md) is the error-code appendix. [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) are tutorials that build the games in `examples/breakout/`; [Chapters 20](docs/20-tutorial-galaga.md) and [21](docs/21-tutorial-galaga-enemies.md) build the embedding host in `examples/galaga/` |
| `deps/libakerror/AGENTS.md` | The `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH` protocol, authoritatively | | `deps/libakerror/AGENTS.md` | The `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH` protocol, authoritatively |
| `deps/libakerror/UPGRADING.md` | 1.0.0's status registry. Required before writing an error code | | `deps/libakerror/UPGRADING.md` | 1.0.0's status registry. Required before writing an error code |
| `deps/<library>/AGENTS.md` | Per-repo rules. Read the relevant one **before editing a submodule** | | `deps/<library>/AGENTS.md` | Per-repo rules. Read the relevant one **before editing a submodule** |

View File

@@ -268,6 +268,69 @@ if(AKBASIC_BUILD_EXAMPLES)
endforeach() endforeach()
endif() endif()
# The galaga example: a C game on libakgl with the interpreter embedded as its
# enemy-behavior engine. Chapters 20 and 21 build it from an empty file, so it
# is compiled and run by every AKGL build rather than rotting in a document.
# The asset, script and font paths are baked in so the smoke test can launch
# from any working directory; --assets and --script override them at runtime.
if(AKBASIC_BUILD_EXAMPLES AND AKBASIC_WITH_AKGL)
add_executable(akbasic_example_galaga
examples/galaga/main.c
examples/galaga/script.c
examples/galaga/enemies.c
examples/galaga/player.c)
target_compile_options(akbasic_example_galaga PRIVATE -Wall -Wextra)
target_compile_definitions(akbasic_example_galaga PRIVATE
GALAGA_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/assets"
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/galaga.bas"
GALAGA_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/assets/fonts/C64_Pro_Mono-STYLE.ttf")
target_link_libraries(akbasic_example_galaga PRIVATE akbasic akgl
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image)
akbasic_instrument(akbasic_example_galaga)
# Ten seconds of scripted play under the headless drivers: the script boots,
# a wave enters and forms, the autoplay pilot shoots at it, and the program
# tears down and exits 0. A tutorial that stops working fails here rather
# than in front of a reader.
_add_test(NAME example_galaga COMMAND akbasic_example_galaga --frames 600 --autoplay)
_set_tests_properties(example_galaga PROPERTIES TIMEOUT 120
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software")
# The boundary's round-trip test: links the real script.c and the real
# galaga.bas, and fails the moment the two sides of the interop disagree.
add_executable(akbasic_example_galaga_interop
examples/galaga/interop_test.c
examples/galaga/script.c)
target_compile_options(akbasic_example_galaga_interop PRIVATE -Wall -Wextra)
target_compile_definitions(akbasic_example_galaga_interop PRIVATE
GALAGA_SCRIPT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/examples/galaga/galaga.bas")
target_link_libraries(akbasic_example_galaga_interop PRIVATE akbasic akgl
SDL3::SDL3 m)
akbasic_instrument(akbasic_example_galaga_interop)
_add_test(NAME example_galaga_interop COMMAND akbasic_example_galaga_interop)
_set_tests_properties(example_galaga_interop PROPERTIES TIMEOUT 120)
# Regenerating the game figures in docs/ is a deliberate act, never part of
# a build, for the same reason docs_screenshots is: the PNGs are checked in.
# Wall-clock dt makes each regeneration differ by a few pixels of starfield,
# so expect a binary diff every time this runs; commit one only when the
# content changed on purpose. (docs_galaga_figures, not docs_game_figures:
# the libakgl submodule already owns that target name.)
add_custom_target(docs_galaga_figures
COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy
SDL_RENDER_DRIVER=software
$<TARGET_FILE:akbasic_example_galaga> --frames 40
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/galaga-title.png"
--screenshot-frame 30
COMMAND ${CMAKE_COMMAND} -E env SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy
SDL_RENDER_DRIVER=software
$<TARGET_FILE:akbasic_example_galaga> --autoplay --frames 370
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/galaga-wave.png"
--screenshot-frame 360
DEPENDS akbasic_example_galaga
COMMENT "Regenerating the galaga figures in docs/images"
VERBATIM)
endif()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tests. # Tests.
# #

View File

@@ -126,7 +126,7 @@ version are catalogued in [`TODO.md`](TODO.md) and summarised for a BASIC progra
| | | | | |
|---|---| |---|---|
| [`docs/`](docs/README.md) | The guide: eighteen chapters, the language then each hardware area then a reference section for every verb and function, [Chapter 14](docs/14-architecture.md) on the interpreter's own architecture, [Chapter 15](docs/15-error-codes.md) listing every error code, and [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) building a whole game twice | | [`docs/`](docs/README.md) | The guide: twenty-one chapters, the language then each hardware area then a reference section for every verb and function, [Chapter 14](docs/14-architecture.md) on the interpreter's own architecture, [Chapter 15](docs/15-error-codes.md) listing every error code, [Chapters 17](docs/17-tutorial-breakout.md) and [18](docs/18-tutorial-breakout-artwork.md) building a whole game twice, and [Chapters 20](docs/20-tutorial-galaga.md) and [21](docs/21-tutorial-galaga-enemies.md) building a C game that embeds the interpreter |
| [`MAINTENANCE.md`](MAINTENANCE.md) | For contributors and maintainers: the documentation-example harness, the three test lists, mutation testing, error-code allocation, style | | [`MAINTENANCE.md`](MAINTENANCE.md) | For contributors and maintainers: the documentation-example harness, the three test lists, mutation testing, error-code allocation, style |
| [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence | | [`TODO.md`](TODO.md) | Outstanding defects, with file, line and consequence |
| [`tests/reference/README.md`](tests/reference/README.md) | Where the golden corpus came from, and the rule for changing it | | [`tests/reference/README.md`](tests/reference/README.md) | Where the golden corpus came from, and the rule for changing it |

View File

@@ -100,6 +100,50 @@ bounded run is usually inside a `FOR` or `GOSUB` body, and a variable created th
dies when the body pops — silently, with the script reading it correctly right up until dies when the body pops — silently, with the script reading it correctly right up until
it stops. it stops.
## Calling a function every frame
`akbasic_runtime_call_function()` calls a `DEF` by name with values you already
hold — the entry point a game loop wants. A host that calls it repeatedly signs
up for three rules the one-shot examples never meet:
```c wrap=hostcalls
CATCH(errctx, akbasic_runtime_call_function(&SCRIPT, "THINK", argp, 1, &result));
/* ...consume the result... */
CATCH(errctx, akbasic_environment_zero(SCRIPT.environment));
```
1. **Reset the value scratch after every call, once the result is consumed.**
Each call parks its result in the caller environment's per-line scratch
(`AKBASIC_MAX_VALUES` slots), and a host calling in a loop never crosses the
line boundary that would reset it. Skip the `akbasic_environment_zero()` and
the pool drains — measured at under two frames of forty calls — after which
every call fails with `Maximum values per line reached`. The reset also
invalidates `result`, which is why it comes after the consumption.
2. **Force RUN mode once after the boot run.** A multi-line `DEF` body only
runs while the runtime is in RUN mode, and by the time a host can call, the
program that filed the definitions has ended. One
`akbasic_runtime_set_mode(&SCRIPT, AKBASIC_MODE_RUN)` after
`akbasic_runtime_run()` makes the bodies run, and the mode stays put because
nothing steps the runtime between calls. Issue #8 tracks making this
unnecessary.
3. **Revive after a script error, deliberately.** A BASIC-level error inside a
called body reports through the sink, answers a stale value, and latches:
the runtime leaves RUN mode and every later call does nothing. When your
policy is to absorb the error and keep calling — a game marking one actor
dumb rather than killing the frame — the revival is two calls:
`akbasic_runtime_clear_error()`, then `akbasic_runtime_set_mode(RUN)` again.
The latch is deliberate for *programs* — the first error ends a run, once,
with one line — so nothing clears it for you.
Do not pass structures as per-frame arguments. A structure or pointer parameter
spends a value-pool slot on every call and the pool never reclaims, so the
interface dies after about a thousand calls — issue #36 has the measurements.
Bind the instance once with `akbasic_host_bind()` and point it at each object
with `akbasic_host_rebind()` ([Chapter 16](16-structures.md)), which spends
nothing per call. The GALAGA tutorial ([Chapters 20](20-tutorial-galaga.md)
and [21](21-tutorial-galaga-enemies.md)) is this whole recipe as a working
game, forty calls a frame.
## Where the output goes ## Where the output goes
`PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus `PRINT` writes through an `akbasic_TextSink`, which is a record of function pointers plus

View File

@@ -685,9 +685,9 @@ seconds asks for fifty frames a second.
### Why `GOTO` rather than `DO ... LOOP` ### Why `GOTO` rather than `DO ... LOOP`
A `DO ... LOOP` around the frame would read better, and it is not usable here: **a `GOTO` A `DO ... LOOP` around the frame would read better, and it is not usable here: **a `GOTO`
that jumps out of a `FOR` or a `DO` does not release the loop's scope.** There are 32 that jumps out of a `FOR` or a `DO` does not release the loop's scope.** There are 12
scopes, so a game that leaves its main loop once per lost life stops on the scopes, so a game that leaves its main loop once per lost life stops on the
thirty-second one: twelfth one:
```basic ```basic
N# = 0 N# = 0
@@ -700,7 +700,7 @@ PRINT "SURVIVED " + N#
``` ```
```output ```output
? 3 : PARSE ERROR Environment pool exhausted at line 3 (32 in use) ? 3 : PARSE ERROR Environment pool exhausted at line 3 (12 in use)
``` ```

849
docs/20-tutorial-galaga.md Normal file
View File

@@ -0,0 +1,849 @@
# 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, with two rules that keep the fragments compiling: **`CATCH` is
only legal inside an `ATTEMPT` block, and `PASS` everywhere else** — swap them
and the compiler objects about a stray `break` — and `main()` alone ends its
block with `FINISH_NORETURN(errctx)` instead of `FINISH`, because `FINISH`
expands a `return` of the context that an `int`-returning function cannot
compile:
```c wrap=galagatypes requires=akgl
static int FAILED = 0;
int main(int argc, char *argv[])
{
PREPARE_ERROR(errctx);
(void)argc; (void)argv;
ATTEMPT {
/* CATCH each stage in order: startup, assets, the script boot,
* the spawns, then the frame loop. */
} CLEANUP {
/* ...teardown, every call wrapped in IGNORE()... */
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "galaga could not run");
/* Set a flag rather than returning: leaving a HANDLE block early
* skips FINISH's release and leaks the context's pool slot. */
FAILED = 1;
} FINISH_NORETURN(errctx);
return FAILED;
}
```
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. A handler receives the
map's target actor and the event, and returns through the error protocol like
everything else — this pair is the whole pattern, repeated per key:
```c wrap=galagagame requires=akgl
static bool MOVELEFT = false;
akerr_ErrorContext *left_on(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
(void)obj; (void)event;
MOVELEFT = true;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *left_off(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
(void)obj; (void)event;
MOVELEFT = false;
SUCCEED_RETURN(errctx);
}
```
(The example keeps the flags in its `galaga_Game` struct rather than statics;
either works.) The bindings themselves are pushes onto map 0:
```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);