1 Commits
34 ... 16

Author SHA1 Message Date
699ac9ab93 Add native RND and ASC functions
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 40s
akbasic CI Build / sanitizers (push) Failing after 45s
akbasic CI Build / coverage (push) Failing after 48s
akbasic CI Build / akgl_build (push) Failing after 49s
akbasic CI Build / mutation_test (push) Failing after 45s
Implement bounded random integers with lazy clock seeding and add ASC as the inverse of CHR. Cover dispatch, validation, deterministic LCG output, UTF-8 round trips, function reference, and the breakout tutorial.

Closes #16.

Co-authored-by: andrew <andrew@aklabs.net>
2026-08-05 06:43:17 -04:00
69 changed files with 168 additions and 4792 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: 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/` | | [`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/` |
| `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,69 +268,6 @@ 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: 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 | | [`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 |
| [`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,50 +100,6 @@ 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

@@ -9,6 +9,7 @@ so a call with the wrong number is a syntax error rather than a surprise.
| Function | Args | Form | What it gives | | Function | Args | Form | What it gives |
|---|---|---|---| |---|---|---|---|
| `ABS` | 1 | `ABS(n)` | The absolute value of an integer or float. | | `ABS` | 1 | `ABS(n)` | The absolute value of an integer or float. |
| `ASC` | 1 | `ASC(A$)` | The Unicode code point of a string's first character. |
| `ATN` | 1 | `ATN(n)` | Arctangent, in radians. | | `ATN` | 1 | `ATN(n)` | Arctangent, in radians. |
| `BUMP` | 1 | `BUMP(1)` | Which sprites have collided, as a bitmask. **Reading clears it.** | | `BUMP` | 1 | `BUMP(1)` | Which sprites have collided, as a bitmask. **Reading clears it.** |
| `CHR` | 1 | `CHR(n)` | The character for a Unicode code point, as a string. | | `CHR` | 1 | `CHR(n)` | The character for a Unicode code point, as a string. |
@@ -29,6 +30,7 @@ so a call with the wrong number is a syntax error rather than a surprise.
| `RGR` | 1 | `RGR(f)` | The `GRAPHIC` mode (0), the drawing surface's width (1) or height (2) in pixels, or a character cell's width (3) or height (4). | | `RGR` | 1 | `RGR(f)` | The `GRAPHIC` mode (0), the drawing surface's width (1) or height (2) in pixels, or a character cell's width (3) or height (4). |
| `RIGHT` | 2 | `RIGHT(A$, n)` | The rightmost `n` characters. Clamped. | | `RIGHT` | 2 | `RIGHT(A$, n)` | The rightmost `n` characters. Clamped. |
| `RMENU` | 2 | `RMENU(n, f)` | A menu's state: field 0 the highlighted entry, field 1 whether it has been confirmed. **Reading field 1 clears it.** | | `RMENU` | 2 | `RMENU(n, f)` | A menu's state: field 0 the highlighted entry, field 1 whether it has been confirmed. **Reading field 1 clears it.** |
| `RND` | 1 | `RND(n)` | A random integer from 0 up to but not including `n`. |
| `RWINDOW` | 1 | `RWINDOW(f)` | The current text window's rows (0) or columns (1). Field 2 is a C128 screen mode and is refused. | | `RWINDOW` | 1 | `RWINDOW(f)` | The current text window's rows (0) or columns (1). Field 2 is a C128 screen mode and is refused. |
| `RSPCOLOR` | 1 | `RSPCOLOR(n)` | One of `SPRCOLOR`'s two shared registers, 1 or 2. | | `RSPCOLOR` | 1 | `RSPCOLOR(n)` | One of `SPRCOLOR`'s two shared registers, 1 or 2. |
| `RSPHIT` | 2 | `RSPHIT(n, f)` | One of `SPRHIT`'s settings for sprite `n`, in `SPRHIT`'s own argument order: 0 the kind, 1 to 4 the two corners. | | `RSPHIT` | 2 | `RSPHIT(n, f)` | One of `SPRHIT`'s settings for sprite `n`, in `SPRHIT`'s own argument order: 0 the kind, 1 to 4 the two corners. |

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 12 that jumps out of a `FOR` or a `DO` does not release the loop's scope.** There are 32
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
twelfth one: thirty-second 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 (12 in use) ? 3 : PARSE ERROR Environment pool exhausted at line 3 (32 in use)
``` ```
@@ -1005,20 +1005,48 @@ IF NUDGE# = 1 THEN GOSUB UNSTICK
LABEL UNSTICK LABEL UNSTICK
NUDGE# = 0 NUDGE# = 0
STALL# = 0 STALL# = 0
RMAX# = 4 BVX# = (RND(4) * 3) - 6
GOSUB RANDOM
BVX# = (RND# * 3) - 6
IF BVX# = 0 THEN BVX# = 3 IF BVX# = 0 THEN BVX# = 3
RETURN RETURN
``` ```
### You have to write your own random numbers ### Random numbers are built in
**There is no `RND` in this dialect**, and no `INT`, `SQR`, `ASC` or `TIMER` either. A There is no `INT`, `SQR` or `TIMER` in this dialect, but
linear congruential generator is nine tokens and does the job. Put the number of possible `RND(n)` returns an integer from zero through `n - 1`. It seeds itself
answers in `RMAX#` and read the result from `RND#`: from the host clock the first time it is called, so a program only needs the bound:
```basic ```basic
I# = 0
FOR I# = 1 TO 5
PRINT "ROLL " + (RND(6) + 1)
NEXT I#
END
```
Use `RND` for the serve, too, so the ball does not always leave in the same direction:
```basic norun
LABEL SERVE
PX# = (SCW# - PW#) / 2
HELD# = 1
BX# = PX# + ((PW# / 2) - 4)
BY# = PY# - 10
BVX# = BSPD#
IF RND(2) = 0 THEN BVX# = 0 - BSPD#
BVY# = 0 - BSPD#
PDEC# = 0
GOSUB SHOWSPR
RETURN
```
<details>
<summary>Historical aside: the LCG this chapter used to teach</summary>
Before `RND` existed, this nine-token linear congruential generator was copied into
every program. It remains a useful from-scratch PRNG example:
```basic norun
SEED# = 12345 SEED# = 12345
RMAX# = 6 RMAX# = 6
RND# = 0 RND# = 0
@@ -1035,43 +1063,11 @@ RND# = MOD((SEED# / 65536), RMAX#)
RETURN RETURN
``` ```
```output The multiplication stays inside a 64-bit integer for any seed below 2147483648. The
ROLL 1 answer is taken from the middle bits because the low bits of a power-of-two modulus
ROLL 5 barely change from one call to the next. This used to be required; it is now built in.
ROLL 2
ROLL 1
ROLL 2
```
The multiplication stays inside a 64-bit integer for any seed below 2147483648, which is </details>
why the modulus is that number. The answer is taken from the middle bits — `SEED# / 65536`
— because the low bits of a power-of-two modulus barely change from one call to the next.
Integer division truncating for free is the `INT` you do not have.
Seed it from the clock at startup. `TI#` is the host's uptime in sixtieths of a second,
which is different every time the game is run:
```basic norun
SEED# = TI#
```
Use `RANDOM` for the serve, too, so the ball does not always leave in the same direction:
```basic norun
LABEL SERVE
PX# = (SCW# - PW#) / 2
HELD# = 1
BX# = PX# + ((PW# / 2) - 4)
BY# = PY# - 10
RMAX# = 2
GOSUB RANDOM
BVX# = BSPD#
IF RND# = 0 THEN BVX# = 0 - BSPD#
BVY# = 0 - BSPD#
PDEC# = 0
GOSUB SHOWSPR
RETURN
```
`HELD#` is the flag Step 6's loop tests: while it is 1 the ball sits on the paddle, and `HELD#` is the flag Step 6's loop tests: while it is 1 the ball sits on the paddle, and
`HOLDBAL` keeps it there: `HOLDBAL` keeps it there:
@@ -1422,9 +1418,7 @@ PX# = PX# + D#
RETURN RETURN
LABEL DEMOAIM LABEL DEMOAIM
RMAX# = 81 DOFF# = RND(81) - 40
GOSUB RANDOM
DOFF# = RND# - 40
RETURN RETURN
``` ```
@@ -1501,7 +1495,7 @@ This is the shape of the whole file:
LABEL SETUP the geometry from Step 2 LABEL SETUP the geometry from Step 2
the declaration block from Step 3 the declaration block from Step 3
the brick faces from Step 5 the brick faces from Step 5
SEED# = TI# RND(n) seeds itself from the host clock
the ceiling from Step 9 the ceiling from Step 9
GOSUB MKSPR Step 4 GOSUB MKSPR Step 4
GOSUB SNDPROBE Step 14 GOSUB SNDPROBE Step 14
@@ -1576,10 +1570,7 @@ BB# = 0
RX# = 0 RX# = 0
N# = 0 N# = 0
MROW# = 0 MROW# = 0
RMAX# = 2
RND# = 0
SND# = 0 SND# = 0
SEED# = 0
P$ = "" P$ = ""
H$ = "" H$ = ""
S$ = "" S$ = ""

View File

@@ -1,849 +0,0 @@
# 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.**