Files
libakgl/docs/21-tutorial-jrpg.md

676 lines
32 KiB
Markdown
Raw Normal View History

# 21. Tutorial: a top-down JRPG
Add two tutorial games that build, run in CI, and cannot drift examples/sidescroller and examples/jrpg are complete programs, built with the library and exercised headless by ctest. The chapters quote them with `c excerpt=examples/...` blocks rather than restating the code, so a chapter cannot drift from a program that compiles -- the excerpt check fails the moment the source moves. 34 excerpts in one chapter, 21 in the other. The two are complementary. The sidescroller is the physics tutorial: gravity, a jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map, NPCs spawned from map objects, four-way per-facing animation, a text box, a follower. Both smoke tests drive real SDL_Events through akgl_controller_handle_event and step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run is deterministic and finishes in under five seconds. Writing them is what turned up most of the defects recorded in the next commit, because a game exercises paths a unit test does not. Each workaround says in the chapter which library gap forced it: - collision is written in a custom movementlogicfunc, because akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never calls collide at all; - the sidescroller cancels the step's own gravity when it blocks downward, because otherwise a quarter-pixel of penetration makes the *horizontal* sweep report blocked and the character walks backwards a tile at a time; - both clear movement_controls_face on every map-spawned actor, because the default facefunc leaves a stopped actor with no facing bit, no sprite, and no draw; - the JRPG's follower gets a renderfunc that nulls obj->parent for the duration of the draw, because a child's offset is counted twice. Assets are CC0 from three Kenney packs, vendored with per-pack licence text, per-file provenance, and the geometry contract in docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a reader who copies a tutorial into their own game inherits no obligation. scripts/fetch_tutorial_assets.sh refreshes them in the shape mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status, refuses a pack page that does not say CC0, verifies the archive and the staged dimensions, and leaves the tracked bytes untouched on any failure. Both failure paths were tested, and a no-op refresh is byte-identical. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:59:00 -04:00
A town, three people standing in it, a party member who follows you around, and
a box that tells you what the elder thinks of the road north. About six hundred
lines of C, in `examples/jrpg/`, built by `all` and run headless by `ctest`.
This is the **content-pipeline** tutorial. Its subject is the road from a
directory of JSON and PNG to a world with people in it: twenty-four sprites, three
characters, one Tiled map that spawns its own actors, four-way per-facing
animation, and text on top. [Chapter 20](20-tutorial-sidescroller.md) is the
Add two tutorial games that build, run in CI, and cannot drift examples/sidescroller and examples/jrpg are complete programs, built with the library and exercised headless by ctest. The chapters quote them with `c excerpt=examples/...` blocks rather than restating the code, so a chapter cannot drift from a program that compiles -- the excerpt check fails the moment the source moves. 34 excerpts in one chapter, 21 in the other. The two are complementary. The sidescroller is the physics tutorial: gravity, a jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map, NPCs spawned from map objects, four-way per-facing animation, a text box, a follower. Both smoke tests drive real SDL_Events through akgl_controller_handle_event and step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run is deterministic and finishes in under five seconds. Writing them is what turned up most of the defects recorded in the next commit, because a game exercises paths a unit test does not. Each workaround says in the chapter which library gap forced it: - collision is written in a custom movementlogicfunc, because akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never calls collide at all; - the sidescroller cancels the step's own gravity when it blocks downward, because otherwise a quarter-pixel of penetration makes the *horizontal* sweep report blocked and the character walks backwards a tile at a time; - both clear movement_controls_face on every map-spawned actor, because the default facefunc leaves a stopped actor with no facing bit, no sprite, and no draw; - the JRPG's follower gets a renderfunc that nulls obj->parent for the duration of the draw, because a child's offset is counted twice. Assets are CC0 from three Kenney packs, vendored with per-pack licence text, per-file provenance, and the geometry contract in docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a reader who copies a tutorial into their own game inherits no obligation. scripts/fetch_tutorial_assets.sh refreshes them in the shape mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status, refuses a pack page that does not say CC0, verifies the archive and the staged dimensions, and leaves the tracked bytes untouched on any failure. Both failure paths were tested, and a no-op refresh is byte-identical. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:59:00 -04:00
physics one — gravity, a jump, a coin — and the two are deliberately
complementary. If you want to know how `ey` accumulates, read that one.
Everything the chapter shows is quoted out of the program with `excerpt` blocks
rather than retyped, so a chapter that no longer matches the game fails the
build. The program itself is the specification.
Six things in it are workarounds for gaps in the library rather than choices,
and each one is called out where you would hit it. They are collected at the end
under [What this costs](#what-this-costs).
## Build it and run it
```sh norun
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build --parallel
./build/examples/jrpg/jrpg
```
Arrow keys walk. Space talks to whoever is standing next to you, and dismisses
the box again. There is nothing else; it is a tutorial, not a game.
Two options exist for the sake of the smoke run. `--frames N` stops after N
frames, and `--demo` drives the arrow keys from a script and steps the physics
clock by a fixed 1/60 s so that a run is deterministic and takes no wall-clock
time at all:
```sh norun
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \
./build/examples/jrpg/jrpg --frames 320 --demo
```
Both blocks are `norun`: the first rebuilds the tree the documentation suite is
running inside, and the second is registered as a CTest case already, so running
it from here would run it twice.
## What is in the directory
| File | What it holds |
|---|---|
| `jrpg.h` | Every declaration, the compile-time asset paths, and the constants |
| `jrpg.c` | `main`, startup order, the frame, teardown, the demo script |
| `world.c` | Asset loading, the actors the map spawned, walls, the follower |
| `textbox.c` | The dialogue panel |
| `CMakeLists.txt` | The target, the baked-in asset paths, and the smoke test |
The art and the data are in `docs/tutorials/assets/jrpg/`: `tiles.png`,
three 32×32 character sheets, `town.tmj`, three character definitions and
twenty-four sprite definitions. They are CC0 — `docs/tutorials/assets/PROVENANCE.md`
has the row-per-file provenance — so a reader who copies this into their own game
inherits no obligation.
## Sprites, then characters, then the map
The load order is not a preference. Each step resolves the previous one's output
by name through a registry, so getting it wrong fails on every single mapping:
- `akgl_character_load_json` looks each sprite up in `AKGL_REGISTRY_SPRITE` and
refuses one it cannot find. See [Chapter 11](11-characters.md).
- `akgl_tilemap_load` resolves each actor object's `character` property through
`AKGL_REGISTRY_CHARACTER` while it spawns the actor. See
[Chapter 13](13-tilemaps.md).
```c excerpt=examples/jrpg/world.c
for ( c = 0; c < CAST_COUNT; c++ ) {
for ( m = 0; m < MOTION_COUNT; m++ ) {
for ( f = 0; f < FACING_COUNT; f++ ) {
PASS(errctx, sprite_load(CAST[c], MOTIONS[m], FACINGS[f]));
}
}
}
for ( c = 0; c < CAST_COUNT; c++ ) {
PASS(errctx, character_load(CAST[c]));
}
```
Twenty-four sprites, and not one of them is named in the source. The file names
are a product of three tables, because a product of three tables is what the
naming convention *is* — one sprite per character, per motion, per facing:
```c excerpt=examples/jrpg/world.c
static char *CAST[] = {
"player", /* jrpg_player jrpg_player_* */
"elder", /* jrpg_elder jrpg_elder_* */
"shopkeeper" /* jrpg_shopkeeper jrpg_shopkeeper_* */
};
static char *MOTIONS[] = { "idle", "walk" };
static char *FACINGS[] = { "up", "down", "left", "right" };
```
A missing combination then arrives as a load failure naming the file it could
not open, at startup. Write the twenty-four names out by hand and a missing one
arrives as art that never appears, in the middle of a frame, silently — because
an actor with no sprite for its state is skipped rather than reported. That is
the same trade the whole error protocol is about: fail early and loudly rather
than surprisingly.
### Full four-way idle and walk is eight mappings per character
The state-to-sprite lookup is an **exact match on the whole state word**, with no
subset fallback — `akgl_character_sprite_get` builds a decimal key out of the
`int32_t` and asks the property set for it. [Chapter 11](11-characters.md)
covers the consequences; the one that binds here is that four facings times
{standing, walking} is eight distinct combinations, and every one of them needs
its own mapping or the actor vanishes when it reaches that state:
| State | Value | Sprite |
|---|---|---|
| `ALIVEFACE_DOWN` | 17 | `jrpg_player_idle_down` |
| `ALIVEFACE_DOWNMOVING_DOWN` | 1041 | `jrpg_player_walk_down` |
| `ALIVEFACE_LEFT` | 18 | `jrpg_player_idle_left` |
| `ALIVEFACE_LEFTMOVING_LEFT` | 146 | `jrpg_player_walk_left` |
| `ALIVEFACE_RIGHT` | 20 | `jrpg_player_idle_right` |
| `ALIVEFACE_RIGHTMOVING_RIGHT` | 276 | `jrpg_player_walk_right` |
| `ALIVEFACE_UP` | 24 | `jrpg_player_idle_up` |
| `ALIVEFACE_UPMOVING_UP` | 536 | `jrpg_player_walk_up` |
There is no diagonal row and there does not need to be one. The default arrow
bindings clear *every* facing and movement bit before setting their own
(`akgl_actor_cmhf_left_on` and its five siblings all do), so holding two
directions moves in whichever was pressed last rather than diagonally. That is
documented in [Chapter 16](16-input.md), and it is why an eight-way character
Add two tutorial games that build, run in CI, and cannot drift examples/sidescroller and examples/jrpg are complete programs, built with the library and exercised headless by ctest. The chapters quote them with `c excerpt=examples/...` blocks rather than restating the code, so a chapter cannot drift from a program that compiles -- the excerpt check fails the moment the source moves. 34 excerpts in one chapter, 21 in the other. The two are complementary. The sidescroller is the physics tutorial: gravity, a jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map, NPCs spawned from map objects, four-way per-facing animation, a text box, a follower. Both smoke tests drive real SDL_Events through akgl_controller_handle_event and step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run is deterministic and finishes in under five seconds. Writing them is what turned up most of the defects recorded in the next commit, because a game exercises paths a unit test does not. Each workaround says in the chapter which library gap forced it: - collision is written in a custom movementlogicfunc, because akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never calls collide at all; - the sidescroller cancels the step's own gravity when it blocks downward, because otherwise a quarter-pixel of penetration makes the *horizontal* sweep report blocked and the character walks backwards a tile at a time; - both clear movement_controls_face on every map-spawned actor, because the default facefunc leaves a stopped actor with no facing bit, no sprite, and no draw; - the JRPG's follower gets a renderfunc that nulls obj->parent for the duration of the draw, because a child's offset is counted twice. Assets are CC0 from three Kenney packs, vendored with per-pack licence text, per-file provenance, and the geometry contract in docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a reader who copies a tutorial into their own game inherits no obligation. scripts/fetch_tutorial_assets.sh refreshes them in the shape mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status, refuses a pack page that does not say CC0, verifies the archive and the staged dimensions, and leaves the tracked bytes untouched on any failure. Both failure paths were tested, and a no-op refresh is byte-identical. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:59:00 -04:00
sheet would be wasted on the default bindings.
## The map spawns the actors
`town.tmj` is 30×20 cells of 16×16 tiles with three layers: `ground`,
`decoration`, and an object group holding three objects of type `actor`.
| Object `name` | `character` (string) | `state` (int) | At |
|---|---|---|---|
| `player` | `jrpg_player` | 17 | (224, 256) |
| `shopkeeper` | `jrpg_shopkeeper` | 17 | (80, 128) |
| `elder` | `jrpg_elder` | 17 | (272, 112) |
Loading the map creates all three, publishes them in `AKGL_REGISTRY_ACTOR` under
the object's `name`, binds each to its character, and sets its position,
visibility and layer. No code in this game creates them. The format's rules —
every object needs a `type` string, `state` is an **int** here and not the
string array that character JSON accepts, and the tileset must be **embedded**
are [Chapter 13](13-tilemaps.md)'s subject and are not restated.
The map also declares `physics.model`, and that is worth a paragraph, because
what the library does with it is half of what you would expect:
```c excerpt=examples/jrpg/world.c
if ( akgl_gamemap->use_own_physics ) {
akgl_physics = &akgl_gamemap->physics;
}
```
`akgl_tilemap_load` reads the property, builds a whole `akgl_PhysicsBackend` on
the map from it, and sets `use_own_physics`. It does not switch to it.
`akgl_game_update` simulates through the global `akgl_physics`, whatever that
happens to point at, and nothing in the library ever consults `use_own_physics`.
Honouring the map is the caller's job, and the two lines above are it.
### Every actor the map spawned is invisible on frame one
This is the first workaround, and it is the one most likely to cost you an
afternoon, because the symptom is that nothing happens.
`akgl_actor_initialize` sets `movement_controls_face` to true and installs
`akgl_actor_automatic_face` as the `facefunc`. That function clears every facing
bit and then sets the one matching a *movement* bit. An NPC has no movement
bits. So on the first update its state falls from `ALIVE|FACE_DOWN` (17) to
`ALIVE` (16) — a combination no character JSON maps a sprite to — and
`akgl_actor_render` skips it rather than reporting anything. The player goes the
same way the moment they stop walking.
`actor.h` says as much in a `@note` on `akgl_actor_automatic_face`, and the
implementation carries a `TODO : This doesn't really work properly` above the
line that does it. The fix is one field:
```c excerpt=examples/jrpg/world.c
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
if ( akgl_heap_actors[i].refcount == 0 ) {
continue;
}
akgl_heap_actors[i].movement_controls_face = false;
}
```
Turning the automatic facing *off* is not a compromise here. The arrow-key
handlers already set the facing bit alongside the movement bit on the way down,
and `akgl_actor_cmhf_*_off` clears only the movement bit on the way up — so the
facing an actor is left with is exactly the one it was walking in. The automatic
`facefunc` is for an actor whose facing is not otherwise decided.
## Walls, because the library has none
There is no collision in libakgl. Not "a simple one" — none:
- `akgl_physics_arcade_collide` raises `AKERR_API` with the message
`"Not implemented"`.
- `akgl_physics_simulate` never calls `collide` at all, so that status is
unreachable through the normal path.
- `akgl_physics_arcade_move` is `x += vx * dt` on three axes and consults no
tilemap, no other actor and no bound of any kind.
An actor therefore walks through a building and off the edge of the world.
[Chapter 14](14-physics.md) lists this among what is not implemented. A game
that wants a wall writes one, and the place to write it is the actor's
`movementlogicfunc` — the one hook the physics step calls *before* it commits
anything.
The rule this game applies is that the outer ring of the map is solid and every
non-empty cell of the `decoration` layer is solid:
```c excerpt=examples/jrpg/world.c
if ( (tx <= 0) || (ty <= 0) ||
(tx >= (akgl_gamemap->width - 1)) || (ty >= (akgl_gamemap->height - 1)) ) {
return true;
}
return (akgl_gamemap->layers[JRPG_LAYER_SOLID].data[(ty * akgl_gamemap->width) + tx] != 0);
```
`JRPG_LAYER_SOLID` is the number 1, not the string `"decoration"`, and that is
the second workaround. **`akgl_TilemapLayer` has no `name` member.** The loader
reads a layer's `id`, `type`, `opacity`, `visible` and offset, and drops the
name Tiled wrote — the only `"name"` an object layer keeps is the one on each
*object*. So a game cannot say "the layer called collision"; the map and the
program agree on an index, and if somebody inserts a layer in Tiled the game
starts colliding with the scenery. This is not in `TODO.md`.
Then the hook itself. Two things make the prediction below exact rather than a
guess, and both are worth understanding before you copy it:
- By the time a `movementlogicfunc` runs, `akgl_physics_simulate` has **already**
integrated this step's thrust and capped it against the character's speed
ellipse. `tx` and `ty` are this step's final thrust.
- Velocity is recomputed as `e + t` every step, and this map has zero gravity and
zero drag, so `e` stays zero and `v` *is* `t`. A map with gravity would have to
fold `ey` into the prediction as well.
```c excerpt=examples/jrpg/world.c
if ( feet_blocked(obj->x + (obj->tx * dt), obj->y) ) {
obj->tx = 0.0f;
obj->ax = 0.0f;
}
if ( feet_blocked(obj->x, obj->y + (obj->ty * dt)) ) {
obj->ty = 0.0f;
obj->ay = 0.0f;
}
```
One axis at a time, so walking diagonally into a wall slides along it instead of
stopping dead. Zeroing thrust rather than moving the actor back is what keeps
this inside the library's model: the arcade backend will still do its own
`x += vx * dt` a few lines later, and it will add zero.
`feet_blocked` tests a 20×10 footprint at the bottom of the 32×32 frame rather
than the whole frame, because a character stands on their feet and every
doorway in the town is one tile wide. Four corners is enough only because the
footprint is smaller than a tile in both directions.
## Freezing the world with `AKGL_ERR_LOGICINTERRUPT`
`AKGL_ERR_LOGICINTERRUPT` is the one status in libakgl that is a control-flow
signal rather than a failure. Raised from a `movementlogicfunc` it means *skip
the rest of this tick for this actor*, and `akgl_physics_simulate` swallows it in
a `HANDLE` block: gravity, drag, the velocity recompute and the move are all
skipped, and the frame carries on. Raised from `gravity` or `move` instead it
aborts the whole step for every actor, which is not the same thing at all — see
[Chapter 14](14-physics.md).
A conversation is exactly the case it was made for:
```c excerpt=examples/jrpg/world.c
if ( jrpg_textbox_showing() ) {
obj->tx = 0.0f;
obj->ty = 0.0f;
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_ALL);
FAIL_RETURN(
errctx,
AKGL_ERR_LOGICINTERRUPT,
"%s does not move while a conversation is open",
(char *)obj->name
);
}
```
`FAIL_RETURN` and not `FAIL_BREAK`, because this is not inside an `ATTEMPT`
block — the `_BREAK` variants belong inside one and the `_RETURN` variants
outside, and a `_RETURN` inside an `ATTEMPT` returns straight past `CLEANUP`.
[Chapter 4](04-errors.md) has the whole protocol.
The two lines above the raise are not decoration. Thrust has already been
integrated for this step, so leaving it would let it pile up for as long as the
box is open and lurch the actor forward on the first step after it closes.
Clearing the movement bits stops the walk cycle marching on the spot, at the
price of one honest wart: a direction held down across the dismissal has to be
pressed again, because nothing will re-set the bit until the next key-down. A
game with a real conversation state would push a different control map instead
of leaving the walk bindings live.
## The party member, and a defect in the parent/child mechanism
`akgl_actor_add_child` attaches one actor to another. A child is not simulated:
`akgl_physics_simulate` snaps it to the parent's position plus its own velocity
fields, used as a fixed offset, and `continue`s. The parent takes a reference,
and releasing the parent releases the child with it. For a carried lantern, a
turret on a tank, or a party member walking a step behind you, that is exactly
right.
```c excerpt=examples/jrpg/world.c
PASS(errctx, akgl_heap_next_actor(&follower));
PASS(errctx, akgl_actor_initialize(follower, JRPG_FOLLOWER_NAME));
PASS(errctx, akgl_actor_set_character(follower, "jrpg_elder"));
follower->state = (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN);
follower->movement_controls_face = false;
follower->visible = true;
follower->layer = player->layer;
follower->renderfunc = &jrpg_follower_render;
PASS(errctx, player->addchild(player, follower));
```
Note what the follower costs: one pool slot, and a `basechar` pointer at a
character that is already registered and already dressed in eight sprites. That
is the whole point of splitting the instance from the template
([Chapter 11](11-characters.md)) — the elder and the companion are two actors
sharing one character.
The offset is set **after** `addchild`, because `addchild` is what makes the
velocity fields mean an offset:
```c excerpt=examples/jrpg/world.c
follower->vx = FOLLOWER_OFFSET_X;
follower->vy = FOLLOWER_OFFSET_Y;
```
### The parent's position is counted twice at draw time
This is a real defect, it is not recorded in `TODO.md`, and it makes the
parent/child mechanism unusable as shipped for any parent that is not sitting at
the world origin.
`akgl_physics_simulate` writes a child's position as an **absolute world
coordinate**:
```c excerpt=src/physics.c
if ( actor->parent != NULL ) {
// Children don't move independently of their parents, they just have an offset
actor->x = actor->parent->x + actor->vx;
actor->y = actor->parent->y + actor->vy;
actor->z = actor->parent->z + actor->vz;
continue;
}
```
`akgl_actor_render` then reads the same field as an **offset** and adds the
parent's position to it again:
```c excerpt=src/actor.c
if ( obj->parent != NULL ) {
dest.x = (obj->parent->x + obj->x - akgl_camera->x);
dest.y = (obj->parent->y + obj->y - akgl_camera->y);
} else {
dest.x = (obj->x - akgl_camera->x);
dest.y = (obj->y - akgl_camera->y);
}
```
`actor.h` documents both readings without noticing that they contradict each
other: the comment on `akgl_Actor::x` says *"For a child, an offset from the
parent"*, and the one on `akgl_actor_render` says *"A child actor is drawn at
its parent's position plus its own, which is what makes an offset mean an
offset"* — while the field it is describing has held an absolute position since
the physics step ran. `actor_visible`, three lines further up the same function,
tests the camera against the raw `obj->x`, treating it as absolute. Two readings,
one field, one function.
Measured rather than reasoned about. At frame 300 of the scripted demo the
player is at (280, 146), the follower's own `x` and `y` read (266, 156) — the
player's position plus the (14, +10) offset, so absolute — and the camera is at
(136, 42). The guarded draw puts the sprite at (130, 114), on a 320×240 screen.
The unguarded one computes `280 + 266 136` and `146 + 156 42` and puts it at
(410, 260), off the screen entirely and off the 480×320 map. Rendering the same
frame with and without the guard produces different pixels.
It is invisible only while the parent sits at the origin, which is where a first
test tends to put it.
**The guard**, until the library picks one of the two readings: give the child a
`renderfunc` that detaches the parent for the duration of the draw, so
`akgl_actor_render` takes the branch that does not add it.
```c excerpt=examples/jrpg/world.c
parent = obj->parent;
obj->parent = NULL;
ATTEMPT {
CATCH(errctx, akgl_actor_render(obj));
} CLEANUP {
obj->parent = parent;
} PROCESS(errctx) {
} FINISH(errctx, true);
```
`CLEANUP` restores it on every path, including the failing one. An actor left
holding a `NULL` parent would stop being snapped and start being simulated as a
free agent on the very next step, which is a stranger bug than the one being
worked around.
The alternative was to drive the follower by hand — no `addchild`, a position
written from the game's own logic every frame — and it is a perfectly reasonable
choice. This game does not take it, because the mechanism is worth showing and
because a fifteen-line hook is cheaper than reimplementing the parent/child
lifecycle. The real fix is one line in either `src/physics.c` or `src/actor.c`
and a decision about which reading is canonical.
## The text box
Text in libakgl is immediate mode. Every `akgl_text_rendertextat` call
rasterizes the string through SDL_ttf, uploads it to a texture, blits it, and
destroys the texture and the surface again — there is no glyph atlas and no text
object to keep. [Chapter 17](17-text-and-fonts.md) says plainly that this is
Add two tutorial games that build, run in CI, and cannot drift examples/sidescroller and examples/jrpg are complete programs, built with the library and exercised headless by ctest. The chapters quote them with `c excerpt=examples/...` blocks rather than restating the code, so a chapter cannot drift from a program that compiles -- the excerpt check fails the moment the source moves. 34 excerpts in one chapter, 21 in the other. The two are complementary. The sidescroller is the physics tutorial: gravity, a jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map, NPCs spawned from map objects, four-way per-facing animation, a text box, a follower. Both smoke tests drive real SDL_Events through akgl_controller_handle_event and step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run is deterministic and finishes in under five seconds. Writing them is what turned up most of the defects recorded in the next commit, because a game exercises paths a unit test does not. Each workaround says in the chapter which library gap forced it: - collision is written in a custom movementlogicfunc, because akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never calls collide at all; - the sidescroller cancels the step's own gravity when it blocks downward, because otherwise a quarter-pixel of penetration makes the *horizontal* sweep report blocked and the character walks backwards a tile at a time; - both clear movement_controls_face on every map-spawned actor, because the default facefunc leaves a stopped actor with no facing bit, no sprite, and no draw; - the JRPG's follower gets a renderfunc that nulls obj->parent for the duration of the draw, because a child's offset is counted twice. Assets are CC0 from three Kenney packs, vendored with per-pack licence text, per-file provenance, and the geometry contract in docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a reader who copies a tutorial into their own game inherits no obligation. scripts/fetch_tutorial_assets.sh refreshes them in the shape mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status, refuses a pack page that does not say CC0, verifies the archive and the staged dimensions, and leaves the tracked bytes untouched on any failure. Both failure paths were tested, and a no-op refresh is byte-identical. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:59:00 -04:00
wrong for a page of static prose redrawn sixty times a second, and it is exactly
right for a line of dialogue that is on screen only while somebody is talking.
```c excerpt=examples/jrpg/textbox.c
panel.x = TEXTBOX_MARGIN;
panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN);
panel.h = TEXTBOX_HEIGHT;
panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h;
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL));
PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE));
```
The panel is placed off `akgl_camera->w` and `akgl_camera->h` rather than off a
second copy of the window size, because `akgl_render_2d_init` copies the
`game.screenwidth` and `game.screenheight` properties onto the camera on its way
past. Two numbers, one place. Text coordinates are screen coordinates and do not
go through the camera at all, so the box stays put while the world scrolls under
it.
Three smaller things this box has to know:
- **The empty string is refused, not drawn as nothing.** SDL_ttf reports "Text
has zero width" and libakgl passes it on as `AKERR_NULLPOINTER`, while
`akgl_text_measure` accepts it happily. The two disagree; `TODO.md`, "Known and
still open", carries it. Anything that might draw an empty line checks first.
- **Fonts are not reference counted.** `akgl_text_unloadfont` invalidates every
`TTF_Font *` anybody fetched earlier. Fetching it from `AKGL_REGISTRY_FONT` per
frame is the cheap way to stay honest about that.
- **The font is `tests/assets/akgl_test_mono.ttf`**, which ships beside its
licence file. The two RPG-Maker-named images elsewhere in this tree do not, and
that is recorded in `TODO.md` rather than fixed here.
## Startup, the frame, and teardown
The startup order is [Chapter 7](07-the-game-and-the-frame.md)'s subject. Three
things in it are easy to get wrong, and this block is where two of them land:
```c excerpt=examples/jrpg/jrpg.c
PASS(errctx, akgl_game_init());
akgl_game.lowfpsfunc = &lowfps_quiet;
PASS(errctx, akgl_set_property("game.screenwidth", JRPG_SCREEN_WIDTH));
PASS(errctx, akgl_set_property("game.screenheight", JRPG_SCREEN_HEIGHT));
PASS(errctx, akgl_render_2d_init(akgl_renderer));
```
- **The properties go between `akgl_game_init` and `akgl_render_2d_init`**,
because the second one reads them. Set them earlier and the properties registry
does not exist yet; set them later and you get a zero-sized window.
- **`akgl_game.name`, `.version` and `.uri` have to be filled in before
`akgl_game_init`**, which refuses to run without all three. They are written
with `aksl_strncpy`, never `strncpy`, because they are fixed-width fields.
- **`akgl_game.lowfpsfunc` is worth replacing.** `akgl_game.fps` is a
completed-second average, so it reads 0 for the first second of every process
— which is under the threshold — and the default hook logs a line on *every*
frame until the first second is up. The hook exists to be replaced.
Then the fourth thing, which is the third workaround and the one that segfaults:
```c excerpt=examples/jrpg/jrpg.c
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
```
`akgl_game_init` points `akgl_physics` at `akgl_default_physics` and stops.
That storage is BSS, so all four of its method pointers are `NULL`, and
`akgl_game_update` calls `akgl_physics->simulate(...)` without checking it — a
`NULL` function pointer, and a `SIGSEGV` on frame one rather than an error
context.
Be precise about what saves this particular program: `town.tmj` declares its own
`physics.model`, and the two-line switch above hands `akgl_physics` a backend
that `akgl_tilemap_load` *did* initialize. Remove the line above alone and this
game still runs. Remove it **and** that switch and frame one is a segmentation
fault — measured, by removing both. So the line is not belt and braces for a map
that declares no physics, which is most of them, and it is the only thing
covering the window between `akgl_game_init` and the map being loaded.
Nothing in the library calls the factory for you, and
`physics.h`'s claim that `akgl_game_init` "passes whatever the `physics.engine`
property holds" to the factory is false: no code anywhere reads that property. A top-down game wants the arcade backend with
zero gravity, which is what the property defaults already give you.
### One frame
```c excerpt=examples/jrpg/jrpg.c
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
PASS(errctx, akgl_game_update(&opflags));
PASS(errctx, jrpg_textbox_draw());
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
```
`akgl_game_update` is update-every-actor, step the physics, draw the world. It
does not clear the target and it does not present, so the frame is bracketed by
the backend's own `frame_start` and `frame_end` — and anything drawn between the
update and `frame_end` lands on top of the world. That is the entire trick to a
HUD.
A failing frame is treated as terminal, and that is deliberate:
**every failure path out of `akgl_game_update` returns with the game-state mutex
still held.** SDL's mutexes are recursive and this loop is single-threaded, so
the next frame would not deadlock — it would just be running on top of a frame
that never finished. Bailing out is the honest response.
The loop is the last thing in its `ATTEMPT` block, and that is load-bearing:
```c excerpt=examples/jrpg/jrpg.c
while ( running ) {
started = SDL_GetTicks();
CATCH(errctx, frame(frameno));
frameno += 1;
if ( (frame_limit > 0) && (frameno >= frame_limit) ) {
running = false;
}
```
`CATCH` reports failure by `break`ing, and a `break` inside a loop binds to the
loop rather than to the block. With nothing after the loop, a failing frame
leaves it and falls straight into `CLEANUP`, `PROCESS` and `FINISH`, which is
what is wanted. Put a statement after the loop and it would run after a failure
as well. `akgl_tilemap_load_layer_objects` carries the same note over the same
shape; `AGENTS.md` states the rule.
### Teardown is yours
There is no `akgl_game_shutdown`. Two of these three lines are load-bearing and
the third thing here is a deliberate omission:
```c excerpt=examples/jrpg/jrpg.c
IGNORE(akgl_text_unloadallfonts());
if ( akgl_window != NULL ) {
SDL_DestroyWindow(akgl_window);
akgl_window = NULL;
}
SDL_Quit();
```
- **`akgl_text_unloadallfonts` must run before `SDL_Quit`.** Fonts live in an SDL
property registry, and `SDL_Quit` destroys the registry — taking the last
reference to every font still in it, with no way left to close them.
[Chapter 17](17-text-and-fonts.md) covers the ordering.
Add two tutorial games that build, run in CI, and cannot drift examples/sidescroller and examples/jrpg are complete programs, built with the library and exercised headless by ctest. The chapters quote them with `c excerpt=examples/...` blocks rather than restating the code, so a chapter cannot drift from a program that compiles -- the excerpt check fails the moment the source moves. 34 excerpts in one chapter, 21 in the other. The two are complementary. The sidescroller is the physics tutorial: gravity, a jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map, NPCs spawned from map objects, four-way per-facing animation, a text box, a follower. Both smoke tests drive real SDL_Events through akgl_controller_handle_event and step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run is deterministic and finishes in under five seconds. Writing them is what turned up most of the defects recorded in the next commit, because a game exercises paths a unit test does not. Each workaround says in the chapter which library gap forced it: - collision is written in a custom movementlogicfunc, because akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never calls collide at all; - the sidescroller cancels the step's own gravity when it blocks downward, because otherwise a quarter-pixel of penetration makes the *horizontal* sweep report blocked and the character walks backwards a tile at a time; - both clear movement_controls_face on every map-spawned actor, because the default facefunc leaves a stopped actor with no facing bit, no sprite, and no draw; - the JRPG's follower gets a renderfunc that nulls obj->parent for the duration of the draw, because a child's offset is counted twice. Assets are CC0 from three Kenney packs, vendored with per-pack licence text, per-file provenance, and the geometry contract in docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a reader who copies a tutorial into their own game inherits no obligation. scripts/fetch_tutorial_assets.sh refreshes them in the shape mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status, refuses a pack page that does not say CC0, verifies the archive and the staged dimensions, and leaves the tracked bytes untouched on any failure. Both failure paths were tested, and a no-op refresh is byte-identical. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:59:00 -04:00
- **`akgl_tilemap_release` is not called**, and that is the fourth workaround.
Its layer loop destroys `tilesets[i].texture` rather than `layers[i].texture`,
so it double-frees every tileset texture and never frees an image layer's, and
it NULLs nothing, so a second call is a use-after-free. `TODO.md`, "Known and
still open" item 2, and `tilemap.h` carries the warning too. `SDL_Quit`
reclaims the textures correctly; calling the function that is supposed to
would be worse than not.
- **The pools are static storage.** There is nothing to free and the process is
about to exit. Releasing the characters would be actively risky:
`akgl_heap_release_character` enumerates the state-sprite map through
`akgl_character_state_sprites_iterate`, an SDL callback that returns `void`,
ends in `FINISH_NORETURN`, and therefore **exits the process** on any error.
[Chapter 5](05-the-heap.md) and [Chapter 4](04-errors.md) both cover it.
## The headless smoke run
```c excerpt=examples/jrpg/CMakeLists.txt
add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo)
set_tests_properties(example_jrpg PROPERTIES
TIMEOUT 120
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
)
```
A smoke test that only proves `main` returns is not worth registering, so this
one walks. `--demo` synthesizes real `SDL_Event`s and pushes them through
`akgl_controller_handle_event`, so the run goes through the control-map scan and
the binding exactly as a keypress does:
```c excerpt=examples/jrpg/jrpg.c
static const jrpg_ScriptStep JRPG_DEMO_SCRIPT[] = {
{ 10, SDLK_RIGHT, true }, /* the per-facing walk animation */
{ 70, SDLK_RIGHT, false },
{ 75, SDLK_UP, true }, /* up the map, past the buildings */
{ 205, SDLK_UP, false },
{ 215, SDLK_SPACE, true }, /* the elder is in range: open the box */
{ 216, SDLK_SPACE, false },
{ 225, SDLK_LEFT, true }, /* frozen: AKGL_ERR_LOGICINTERRUPT eats this */
{ 245, SDLK_LEFT, false },
{ 255, SDLK_SPACE, true }, /* dismiss */
{ 256, SDLK_SPACE, false },
{ 265, SDLK_DOWN, true }, /* and walk away */
{ 285, SDLK_DOWN, false }
};
```
320 frames of that would be five and a third seconds of test suite if the loop
ran at 60 Hz, and it does not run at 60 Hz — headless with a software renderer it
runs as fast as it can, which would make the walk cover about a fifth of the
ground. Both problems have the same answer, and it is the one
`tests/physics_sim.c` already uses: **drive the clock, do not sleep on it.**
```c excerpt=examples/jrpg/jrpg.c
akgl_physics->gravity_time = SDL_GetTicksNS() - JRPG_FIXED_STEP_NS;
```
`akgl_physics_simulate` measures `dt` from `gravity_time`, which is a public
field. Setting it one fixed step into the past before each update makes every
frame worth exactly 1/60 s of simulated time no matter how fast the loop
actually runs. The whole 320-frame run finishes in about a third of a second and
lands the player in the same place every time. [Chapter 14](14-physics.md)
covers `dt`, `max_timestep` and why the first step used to be however long the
level took to load.
The run prints where the player ended up, so the result can be read rather than
merely passed:
```text
jrpg: 320 frames, player at (280, 146)
```
## What this costs
Six things in this program exist because the library does not do them. None of
them is hidden, and none of them is a criticism of a design — they are the
current state of a library that is honest about being unfinished.
| # | Gap | What this game does | Recorded in |
|---|---|---|---|
| 1 | `movement_controls_face` erases the facing bit of any actor that is not moving, so every map-spawned actor stops being drawn on frame one | Clears the field on every live actor after loading the map | `actor.h` `@note`; a `TODO` in `src/actor.c`. Not in `TODO.md` |
| 2 | `akgl_TilemapLayer` has no `name`, so a collision layer cannot be found by name | Hard-codes the layer index in `JRPG_LAYER_SOLID` | Not recorded anywhere |
| 3 | `akgl_default_physics` is zeroed BSS and `akgl_game_init` never calls the factory; `akgl_game_update` calls `simulate` through a `NULL` pointer | Calls `akgl_physics_init_arcade` explicitly, and switches to the map's backend when it declares one | The false `physics.engine` claim in `physics.h` is in the plan's out-of-scope list |
| 4 | No collision of any kind: `arcade_collide` raises `AKERR_API`, `simulate` never calls it, `arcade_move` clamps nothing | Tile-based blocking in the player's `movementlogicfunc` | `TODO.md`; [Chapter 14](14-physics.md) |
| 5 | A child actor's position is written as absolute by the physics step and read as relative by the renderer, so the parent's position is added twice | A `renderfunc` that detaches the parent for the duration of the draw | **Not in `TODO.md`.** `actor.h` documents both readings |
| 6 | `akgl_tilemap_release` double-frees tileset textures and never frees image-layer ones | Does not call it; lets `SDL_Quit` reclaim them | `TODO.md`, "Known and still open" item 2 |
Two more that are not workarounds but will bite anyone extending this: `akgl_Actor::layer`
is a `uint32_t` with no bound, while `akgl_render_2d_draw_world` stops at
`AKGL_TILEMAP_MAX_LAYERS` (16) — an actor on layer 20 is simulated and never
drawn. And the SDL enumeration callbacks (`akgl_registry_iterate_actor`,
`akgl_character_state_sprites_iterate`) return `void` and end in
`FINISH_NORETURN`, so an error inside one **exits the process** rather than
failing a frame. [Chapter 4](04-errors.md) covers installing your own
`akerr_handler_unhandled_error` if that is not what you want.
## Where to look next
- [Chapter 20](20-tutorial-sidescroller.md) — the same shape of program with
Add two tutorial games that build, run in CI, and cannot drift examples/sidescroller and examples/jrpg are complete programs, built with the library and exercised headless by ctest. The chapters quote them with `c excerpt=examples/...` blocks rather than restating the code, so a chapter cannot drift from a program that compiles -- the excerpt check fails the moment the source moves. 34 excerpts in one chapter, 21 in the other. The two are complementary. The sidescroller is the physics tutorial: gravity, a jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map, NPCs spawned from map objects, four-way per-facing animation, a text box, a follower. Both smoke tests drive real SDL_Events through akgl_controller_handle_event and step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run is deterministic and finishes in under five seconds. Writing them is what turned up most of the defects recorded in the next commit, because a game exercises paths a unit test does not. Each workaround says in the chapter which library gap forced it: - collision is written in a custom movementlogicfunc, because akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never calls collide at all; - the sidescroller cancels the step's own gravity when it blocks downward, because otherwise a quarter-pixel of penetration makes the *horizontal* sweep report blocked and the character walks backwards a tile at a time; - both clear movement_controls_face on every map-spawned actor, because the default facefunc leaves a stopped actor with no facing bit, no sprite, and no draw; - the JRPG's follower gets a renderfunc that nulls obj->parent for the duration of the draw, because a child's offset is counted twice. Assets are CC0 from three Kenney packs, vendored with per-pack licence text, per-file provenance, and the geometry contract in docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a reader who copies a tutorial into their own game inherits no obligation. scripts/fetch_tutorial_assets.sh refreshes them in the shape mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status, refuses a pack page that does not say CC0, verifies the archive and the staged dimensions, and leaves the tracked bytes untouched on any failure. Both failure paths were tested, and a no-op refresh is byte-identical. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:59:00 -04:00
gravity, a jump and platforms, which is where the physics model earns its keep.
- [Chapter 13](13-tilemaps.md) — the map format, the three libakgl extensions to
Tiled, and the four constraints the loader really enforces.
- [Chapter 11](11-characters.md) and [Chapter 12](12-actors.md) — the template
and the instance, and why the state word is the whole key.
- [Chapter 16](16-input.md) — control maps, the default bindings, and the
Add two tutorial games that build, run in CI, and cannot drift examples/sidescroller and examples/jrpg are complete programs, built with the library and exercised headless by ctest. The chapters quote them with `c excerpt=examples/...` blocks rather than restating the code, so a chapter cannot drift from a program that compiles -- the excerpt check fails the moment the source moves. 34 excerpts in one chapter, 21 in the other. The two are complementary. The sidescroller is the physics tutorial: gravity, a jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map, NPCs spawned from map objects, four-way per-facing animation, a text box, a follower. Both smoke tests drive real SDL_Events through akgl_controller_handle_event and step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run is deterministic and finishes in under five seconds. Writing them is what turned up most of the defects recorded in the next commit, because a game exercises paths a unit test does not. Each workaround says in the chapter which library gap forced it: - collision is written in a custom movementlogicfunc, because akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never calls collide at all; - the sidescroller cancels the step's own gravity when it blocks downward, because otherwise a quarter-pixel of penetration makes the *horizontal* sweep report blocked and the character walks backwards a tile at a time; - both clear movement_controls_face on every map-spawned actor, because the default facefunc leaves a stopped actor with no facing bit, no sprite, and no draw; - the JRPG's follower gets a renderfunc that nulls obj->parent for the duration of the draw, because a child's offset is counted twice. Assets are CC0 from three Kenney packs, vendored with per-pack licence text, per-file provenance, and the geometry contract in docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a reader who copies a tutorial into their own game inherits no obligation. scripts/fetch_tutorial_assets.sh refreshes them in the shape mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status, refuses a pack page that does not say CC0, verifies the archive and the staged dimensions, and leaves the tracked bytes untouched on any failure. Both failure paths were tested, and a no-op refresh is byte-identical. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:59:00 -04:00
keystroke ring this game does not use.
- [Chapter 22](22-appendix-limits.md) — every compile-time ceiling in one place,
Add two tutorial games that build, run in CI, and cannot drift examples/sidescroller and examples/jrpg are complete programs, built with the library and exercised headless by ctest. The chapters quote them with `c excerpt=examples/...` blocks rather than restating the code, so a chapter cannot drift from a program that compiles -- the excerpt check fails the moment the source moves. 34 excerpts in one chapter, 21 in the other. The two are complementary. The sidescroller is the physics tutorial: gravity, a jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map, NPCs spawned from map objects, four-way per-facing animation, a text box, a follower. Both smoke tests drive real SDL_Events through akgl_controller_handle_event and step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run is deterministic and finishes in under five seconds. Writing them is what turned up most of the defects recorded in the next commit, because a game exercises paths a unit test does not. Each workaround says in the chapter which library gap forced it: - collision is written in a custom movementlogicfunc, because akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never calls collide at all; - the sidescroller cancels the step's own gravity when it blocks downward, because otherwise a quarter-pixel of penetration makes the *horizontal* sweep report blocked and the character walks backwards a tile at a time; - both clear movement_controls_face on every map-spawned actor, because the default facefunc leaves a stopped actor with no facing bit, no sprite, and no draw; - the JRPG's follower gets a renderfunc that nulls obj->parent for the duration of the draw, because a child's offset is counted twice. Assets are CC0 from three Kenney packs, vendored with per-pack licence text, per-file provenance, and the geometry contract in docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a reader who copies a tutorial into their own game inherits no obligation. scripts/fetch_tutorial_assets.sh refreshes them in the shape mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status, refuses a pack page that does not say CC0, verifies the archive and the staged dimensions, and leaves the tracked bytes untouched on any failure. Both failure paths were tested, and a no-op refresh is byte-identical. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:59:00 -04:00
including the 64-actor pool this town uses four of.