Files
libakgl/docs/21-tutorial-jrpg.md
Andrew Kesterson 0972472cfa Take the tutorial figures out of the games themselves
Both examples gain --screenshot PATH and --screenshot-frame N. The capture sits
between the world being drawn and the frame being presented, because
SDL_RenderPresent is where the target stops being readable, and it works under
the dummy video driver and the software renderer like the rest of the headless
path does.

`cmake --build build --target docs_game_figures` regenerates
docs/images/sidescroller.png and docs/images/jrpg.png by running each game to a
chosen frame. Same contract as docs_screenshots: deliberate, never part of a
build, because the PNGs are tracked. There is no --check counterpart, and the
target says why -- the sidescroller drives its physics from the wall clock, so a
byte comparison would fail for reasons that have nothing to do with the
documentation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 08:38:18 -04:00

708 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 21. Tutorial: a top-down JRPG
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
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
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
The library resolves them ([Chapter 15](15-collision.md)); this game says which
things are solid and gets out of the way.
Two rules, and they arrive by two different mechanisms because they are two
different kinds of geometry. Every non-empty cell of the `decoration` layer — a
building, a tree, a lamp post — is solid, and the layer says so itself:
```json excerpt=docs/tutorials/assets/jrpg/town.tmj
"properties": [
{
"name": "collidable",
"type": "bool",
"value": true
}
]
```
That replaces a `#define JRPG_LAYER_SOLID 1`, and the replacement matters more
than it looks. **`akgl_TilemapLayer` has no `name` member** — the loader reads a
layer's `id`, `type`, `opacity`, `visible` and offset and drops the name Tiled
wrote, so a game could not say "the layer called decoration" and had to agree
with the map on an *index*. Insert a layer in Tiled and the game starts colliding
with the scenery. Now the map carries the answer and the game names no layer at
all.
The second rule is the edge of the world, which is not on any layer. That is four
static proxies:
```c excerpt=examples/jrpg/world.c
PASS(errctx, akgl_heap_next_collision_proxy(&jrpg_edges[i]));
PASS(errctx, akgl_collision_shape_box(&jrpg_edge_shapes[i], &sides[i], 0.0f));
jrpg_edge_shapes[i].layermask = AKGL_COLLISION_LAYER_STATIC;
jrpg_edge_shapes[i].collidemask = AKGL_COLLISION_LAYER_NONE;
jrpg_edge_shapes[i].flags |= AKGL_COLLISION_FLAG_STATIC;
```
Four proxies rather than a hundred solid tiles, because a long thin box costs one
pool slot whatever its length — this is what proxies are for, and tiles are the
other mechanism precisely because there are a hundred thousand of them.
Three details in five lines:
- **`layermask` is set by hand.** `akgl_collision_shape_box` defaults a shape to
`AKGL_COLLISION_LAYER_ACTOR`, which is right for an actor and wrong for
scenery. An actor responds to `AKGL_COLLISION_LAYER_STATIC` and nothing else,
so a wall left on the default layer is a wall everything walks through.
- **`collidemask` is cleared.** The wall is not asking to be pushed out of
anything.
- **The acquire and the initialize are adjacent, with nothing between them.**
`akgl_heap_next_collision_proxy` finds a free slot and does *not* claim it; the
initialize takes the reference. Between the two lines a second acquire returns
the same pointer. [Chapter 5](05-the-heap.md) covers why the pools are built
that way.
The walls cover the map's outermost ring of cells *and* extend a tile beyond it.
The ring is what the art expects; the overhang is what stops an actor arriving
fast enough from being resolved out the far side of a wall exactly one tile
thick.
Then the player gets a footprint:
```c excerpt=examples/jrpg/world.c
body = (SDL_FRect){ FEET_X, FEET_TOP, FEET_W, FEET_H };
PASS(errctx, akgl_collision_shape_box(&player->shape, &body, 0.0f));
player->shape_override = true;
```
A 20×10 box 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.
Testing the whole frame would make every corridor two tiles wide.
**Only the player gets one.** The NPCs stand still and are spoken to, not walked
into; shapes on them would make the town a pinball table. The follower is a
child, snapped to the player's position every step, so a shape on it would fight
the snap rather than stop anything. That is what the default masks are for — an
actor with a shape collides with map geometry and with no other actor until you
add a bit.
### What went away
This chapter used to carry a `movementlogicfunc` that *predicted* the step:
```text
if ( feet_blocked(obj->x + (obj->tx * dt), obj->y) ) { obj->tx = 0.0f; ... }
```
It worked, and it came with a qualifier: velocity is recomputed as `e + t` every
step, and it was only exact because this map has zero gravity and zero drag, so
`e` stays zero and `v` *is* `t`. A map with gravity would have had to fold `ey`
into the prediction as well — which is the game re-implementing the library's
integrator, in a file that would not be recompiled when the integrator changed.
Resolution runs after the move now, on a position that already happened, so
there is nothing to predict and the qualifier goes away with it. Both this game
and the sidescroller land in exactly the same pixel they did before: holding left
into a building stops the player at x=122, and holding up into the map's edge
stops them at y=-4, with either implementation.
## 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
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());
```
`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.
- **`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
Four 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. Two more were
here in 0.7.0 and are gone: the hard-coded collision layer index, and the
tile-based blocking in the player's `movementlogicfunc`. Collision closed both.
| # | 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_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 |
| 3 | 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 |
| 4 | `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
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
keystroke ring this game does not use.
- [Chapter 22](22-appendix-limits.md) — every compile-time ceiling in one place,
including the 64-actor pool this town uses four of.