diff --git a/docs/19-tutorial-sidescroller.md b/docs/19-tutorial-sidescroller.md
new file mode 100644
index 0000000..b760f6a
--- /dev/null
+++ b/docs/19-tutorial-sidescroller.md
@@ -0,0 +1,787 @@
+# 19. Tutorial: a 2D sidescroller
+
+A complete game: a Tiled level, a player who runs and jumps, platforms that hold him up,
+four coins to collect, a blob that patrols and a moth that flies. It is about nine hundred
+lines of C — a header and four translation units, and rather more comment than that — and it
+builds and runs as part of this repository's ordinary `ctest` run.
+
+It is here for one reason above the others. **libakgl has no collision detection at all**,
+and a sidescroller is the shortest path to finding that out. So this chapter is mostly about
+what you write when the engine stops, and where exactly that code has to live for the frame
+to come out right.
+
+The program is `examples/sidescroller/`; the art, the map and the JSON are in
+`docs/tutorials/assets/sidescroller/`. Every listing below is quoted straight out of those
+files by the `docs_examples` test, so this chapter cannot describe a program that no longer
+exists.
+
+## Building it and running it
+
+```sh norun
+cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
+cmake --build build --parallel --target sidescroller
+./build/examples/sidescroller/sidescroller
+```
+
+Arrow keys to run, space to jump, and holding space longer jumps higher. Three flags exist
+for the smoke test and are useful by hand too: `--assets DIR` points somewhere other than
+the compiled-in asset directory, `--frames N` (or `AKGL_SIDESCROLLER_FRAMES`) exits after
+that many frames, and `--autoplay` drives the player from a script instead of the keyboard.
+
+## The level
+
+`level1.tmj` is 40x15 tiles of 16 pixels — a world 640x240 pixels — with three layers:
+a `background` tile layer, a `terrain` tile layer, and an `actors` object group. The camera
+is a 480x240 window onto it that scrolls sideways.
+
+```text
+ 0 1 2 3
+ 0123456789012345678901234567890123456789
+ 0 ........................................
+ 1 ........................................
+ 2 ........................................
+ 3 ........................................
+ 4 ........................................
+ 5 ........................................
+ 6 ............................##..........
+ 7 ..............##........................
+ 8 ......................##................
+ 9 ........##..............................
+ 10 ........................................
+ 11 ...#......#...............#......#......
+ 12 ##################...###################
+ 13 ##################...###################
+ 14 ##################...###################
+
+ the terrain layer, every row of it. `#` is any non-zero tile; the three
+ empty columns at 18-20 are the pit, the two-tile runs on rows 6 to 9 are
+ the platforms, and the four single tiles on row 11 are steps.
+```
+
+The object group places seven actors: `player`, `coin1` through `coin4`, `blob1` and
+`moth1`. Each is a Tiled object of type `actor` with a `character` string property and a
+`state` **integer** property — the two custom properties
+[Chapter 13](13-tilemaps.md) documents, and `state` is a number here even though character
+JSON accepts the symbolic form. `20` is `AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_RIGHT`,
+from the bit table in [Chapter 12](12-actors.md).
+
+Nothing in the program creates an actor. **Loading the map does**, which is why the load
+order below is what it is.
+
+## Starting up
+
+libakgl has exactly one startup order that works and it is written down at the top of
+`include/akgl/game.h`. Three of its steps are the ones a first program gets wrong.
+
+```c excerpt=examples/sidescroller/main.c
+ PASS(errctx, aksl_strncpy(
+ (char *)&akgl_game.name,
+ sizeof(akgl_game.name),
+ "libakgl sidescroller tutorial",
+ sizeof(akgl_game.name) - 1));
+```
+
+`akgl_game.name`, `.version` and `.uri` are required and have no defaults —
+`akgl_game_init` refuses to run without all three, because the window title, SDL's
+application metadata and the savegame compatibility check are built from them. `aksl_strncpy`
+rather than `strncpy`, per [Chapter 18](18-utilities.md) and `AGENTS.md`: these are
+fixed-width fields that end up as registry keys.
+
+```c excerpt=examples/sidescroller/main.c
+ PASS(errctx, akgl_set_property("game.screenwidth", "960"));
+ PASS(errctx, akgl_set_property("game.screenheight", "480"));
+ PASS(errctx, akgl_render_2d_init(akgl_renderer));
+```
+
+**Properties before the renderer.** `akgl_render_2d_init` reads both dimensions out of
+`AKGL_REGISTRY_PROPERTIES` and an unset one defaults to the string `"0"`, which asks SDL for
+a zero-sized window rather than reporting anything. See [Chapter 6](06-the-registry.md).
+
+The window is 960x480 and the view is 480x240, because libakgl draws in map pixels and has
+no scale factor of its own — the only scaling it applies is the tilemap's perspective band,
+and this map has none. A 16-pixel tile would otherwise be 16 screen pixels. SDL's logical
+presentation supplies the missing factor:
+
+```c excerpt=examples/sidescroller/main.c
+ FAIL_ZERO_RETURN(
+ errctx,
+ SDL_SetRenderLogicalPresentation(
+ akgl_renderer->sdl_renderer,
+ SS_VIEW_WIDTH,
+ SS_VIEW_HEIGHT,
+ SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
+ AKGL_ERR_SDL,
+ "%s",
+ SDL_GetError()
+ );
+```
+
+That is an SDL call, not a libakgl one; it is documented in the
+[SDL3 render API](https://wiki.libsdl.org/SDL3/SDL_SetRenderLogicalPresentation). The camera
+then has to be told the same thing, because `akgl_render_2d_init` sized it from the window.
+
+### `akgl_game_init` does not choose a physics backend
+
+This is the one that produces a crash rather than a message, so it gets its own heading.
+
+```c excerpt=examples/sidescroller/main.c
+ PASS(errctx, akgl_physics_init_arcade(akgl_physics));
+```
+
+`akgl_game_init` points `akgl_physics` at `akgl_default_physics`, which is **zeroed storage
+whose four method pointers are all `NULL`**, and never initializes it. There is no
+`physics.engine` property either, whatever the `@file` block in `include/akgl/physics.h`
+says — [Chapter 14](14-physics.md) has the details and the correction. Skip this call and
+the first `akgl_game_update` calls through a `NULL` `simulate`.
+
+### The low-FPS hook fires every frame for the first second
+
+```c excerpt=examples/sidescroller/main.c
+ akgl_game.lowfpsfunc = &ss_lowfps;
+```
+
+`akgl_game.fps` is a completed-second average, so it reads 0 for the whole first second of
+the process — which is under the threshold, so the default `akgl_game_lowfps` logs a line on
+every frame until the second is up. The hook exists to be replaced; a real game sheds work
+there. This one replaces it with an empty function so the log is readable.
+
+## Loading in dependency order
+
+```c excerpt=examples/sidescroller/main.c
+ for ( i = 0; ss_sprite_files[i] != NULL; i++ ) {
+ PASS(errctx, asset_path(assetdir, ss_sprite_files[i], (char *)&path, sizeof(path)));
+ PASS(errctx, akgl_sprite_load_json((char *)&path));
+ }
+ for ( i = 0; ss_character_files[i] != NULL; i++ ) {
+ PASS(errctx, asset_path(assetdir, ss_character_files[i], (char *)&path, sizeof(path)));
+ PASS(errctx, akgl_character_load_json((char *)&path));
+ }
+```
+
+Sprites, then characters, then the map. That is not a preference:
+
+- a character's JSON names its sprites by **registry name**, and
+ `akgl_character_load_json` looks each one up as it reads the mapping, so a character
+ loaded first fails on the first sprite it cannot find ([Chapter 11](11-characters.md));
+- an `actor` object names its character the same way, and the map loader creates the actor
+ and binds the character during the load ([Chapter 13](13-tilemaps.md)).
+
+Nine sprite files and four character files, in a table rather than thirteen calls:
+
+```c excerpt=examples/sidescroller/main.c
+static char *ss_sprite_files[] = {
+ "sprite_ss_player_idle_left.json",
+ "sprite_ss_player_idle_right.json",
+ "sprite_ss_player_run_left.json",
+ "sprite_ss_player_run_right.json",
+ "sprite_ss_player_jump_left.json",
+ "sprite_ss_player_jump_right.json",
+ "sprite_ss_coin.json",
+ "sprite_ss_hazard_blob.json",
+ "sprite_ss_hazard_moth.json",
+ NULL
+};
+```
+
+The map goes into `akgl_gamemap`, which already points at `akgl_default_gamemap`:
+
+```c excerpt=examples/sidescroller/main.c
+ PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path)));
+ PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap));
+```
+
+That is 25 MiB of static storage in the library, and using it rather than declaring one is
+not a micro-optimisation: `sizeof(akgl_Tilemap)` is three times a default 8 MiB thread
+stack, so a local one is a segfault before the loader writes a byte. [Chapter 13](13-tilemaps.md)
+has the size breakdown.
+
+## The map brings its own physics
+
+`level1.tmj` carries `physics.model`, `physics.gravity.y` and `physics.drag.y` as map-level
+custom properties, so the loader built a backend for it and set `use_own_physics`. **Nothing
+in the library acts on that flag** — `akgl_game_update` steps the global `akgl_physics` and
+never looks at the map's — so honouring it is one line the game writes:
+
+```c excerpt=examples/sidescroller/main.c
+ if ( akgl_gamemap->use_own_physics == true ) {
+ akgl_physics = &akgl_gamemap->physics;
+```
+
+The numbers are gravity 900 px/s² and drag 1.5. The drag is doing a job the engine has no
+other word for. **There is no terminal velocity in libakgl**: `akgl_physics_arcade_gravity`
+adds `gravity_y * dt` to the actor's `ey` every step and nothing bounds it — the simulation
+in `tests/physics_sim.c` reaches 560 px/s in 0.7 s and keeps going. Drag is a first-order
+decay applied to the same term, so `ey` converges on `gravity_y / drag_y` instead of
+diverging: 900 / 1.5 = **600 px/s, and that is this level's terminal velocity**. It is the
+only brake there is; `TODO.md`, "Arcade physics feel", records the gap.
+
+Keep `drag * max_timestep` below 1. `ex -= ex * drag_x * dt` only decays while
+`drag * dt < 1`; past that it overshoots zero and past 2 it diverges, and nothing rejects
+it. With `physics.max_timestep` at its default 0.05 s that needs a drag above 20.
+
+One more line before the loop starts:
+
+```c excerpt=examples/sidescroller/main.c
+ akgl_physics->gravity_time = SDL_GetTicksNS();
+```
+
+`dt` is measured from `gravity_time`, not passed in, and everything above — nine sprite
+files, four characters, a map and a tileset image — happened between the backend being
+created and the first step. The `max_timestep` bound would have caught it, at the cost of
+one visibly slow-motion frame. Re-stamping is cheaper. See [Chapter 14](14-physics.md).
+
+## The collision libakgl does not have
+
+Here is the whole problem, stated as three facts about `src/physics.c`:
+
+- **`akgl_physics_arcade_collide` raises `AKERR_API`** with the message "Not implemented".
+- **`akgl_physics_simulate` never calls `collide` at all** — not for the arcade backend, not
+ for the null one. The vtable slot exists and the simulation does not use it.
+- **`akgl_physics_arcade_move` is `position += velocity * dt` and nothing else.** It does
+ not clamp to the map, consult the tilemap, or test anything.
+
+So an actor walks through a wall and off the edge of the world, and none of that announces
+itself at compile time. [Chapter 14](14-physics.md) says so plainly and this game is what
+the consequence looks like.
+
+The only hook the physics step calls is the actor's own `movementlogicfunc`, so that is
+where the collision goes. And that placement is awkward in exactly one way, which shapes
+everything in `collision.c`:
+
+```text
+ akgl_physics_simulate, per actor:
+
+ tx += ax * dt <- thrust, from the previous step's ax
+ cap (tx,ty,tz) to the speed ellipse
+ movementlogicfunc(actor, dt) <- YOU ARE HERE
+ gravity(self, actor, dt) ey += gravity_y * dt
+ ex -= ex * drag_x * dt (and y, z)
+ vx = ex + tx (and y, z)
+ move(self, actor, dt) x += vx * dt
+```
+
+**Your hook runs before the step it has to resolve.** It cannot look at where the actor
+ended up, because the actor has not moved yet. So the game predicts the step instead:
+
+```c excerpt=examples/sidescroller/collision.c
+ ex = obj->ex;
+ ey = obj->ey;
+ if ( akgl_physics->gravity_x != 0 ) {
+ ex -= (float32_t)akgl_physics->gravity_x * dt;
+ }
+ if ( akgl_physics->gravity_y != 0 ) {
+ ey += (float32_t)akgl_physics->gravity_y * dt;
+ }
+ if ( akgl_physics->drag_x != 0 ) {
+ ex -= ex * (float32_t)akgl_physics->drag_x * dt;
+ }
+ if ( akgl_physics->drag_y != 0 ) {
+ ey -= ey * (float32_t)akgl_physics->drag_y * dt;
+ }
+ *dx = (ex + obj->tx) * dt;
+ *dy = (ey + obj->ty) * dt;
+```
+
+That is `akgl_physics_simulate`'s own arithmetic in its own order, `!= 0` guards included —
+dropping them would let a zero-gravity axis pick up drag, which the library does not do. Get
+it wrong and the actor is resolved against a step it never takes.
+
+Be clear about what this is: a copy of somebody else's implementation, in a file that will
+not be recompiled when that implementation changes. It is a liability, and it is not a
+design — it is what the missing `collide` call costs. When `akgl_physics_simulate` grows a
+collision hook, this function is the first thing to delete.
+
+### Solid is a layer, not a flag
+
+There is no per-tile "solid" property. The level says what is solid by which layer a tile is
+drawn on:
+
+```c excerpt=examples/sidescroller/collision.c
+ *dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
+```
+
+Finding that layer takes a small piece of knowledge that is easy to lose an afternoon to:
+**`akgl_TilemapLayer` does not record the layer's name.** `akgl_tilemap_load_layers` reads
+`id`, `opacity`, `visible`, `x`, `y` and `type` and nothing else, so there is no way to ask
+for "the layer called terrain". The game matches on Tiled's numeric layer id instead:
+
+```c excerpt=examples/sidescroller/collision.c
+ if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) &&
+ (map->layers[i].id == SS_TERRAIN_LAYER_ID) ) {
+ ss_terrain = &map->layers[i];
+ }
+```
+
+Off the left or right edge of the map counts as solid, so the level has walls at its ends.
+Off the top or the bottom does not: the sky is open, and falling into the pit is the whole
+point of the pit.
+
+### Sweeping, not testing
+
+A fall at 600 px/s with the step bounded to 0.05 s covers 30 pixels, which is nearly two
+tiles. A single test at the destination walks straight through a floor. So the motion is
+walked in sub-steps of at most half a tile, each axis separately — testing the axes
+separately is what lets an actor slide along a wall instead of sticking to it — and a
+blocked axis is snapped to the boundary it was about to cross rather than simply not moved,
+so an actor lands flush at whatever speed it arrives:
+
+```c excerpt=examples/sidescroller/collision.c
+ if ( stepy != 0.0f ) {
+ trial = *box;
+ trial.y += stepy;
+ PASS(errctx, ss_collide_box_blocked(&trial, &solid));
+ if ( solid == true ) {
+ if ( stepy > 0.0f ) {
+ box->y = (floorf((trial.y + trial.h) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.h;
+ } else {
+ box->y = (floorf(trial.y / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
+ }
+ stepy = 0.0f;
+ dest->blocked_y = true;
+ } else {
+ box->y = trial.y;
+ }
+ }
+```
+
+Only a blocked axis is written back to the actor. The free axis is left for
+`akgl_physics_arcade_move` to advance by exactly the amount that was predicted — writing it
+here as well would move the actor twice.
+
+### The quarter of a pixel that breaks everything
+
+This one cost an afternoon and is the most useful thing in the chapter.
+
+The obvious thing to do on a blocked axis is to zero the environmental term. It is wrong,
+and the reason is the ordering again: the step is about to add `gravity_y * dt` back, and
+`move` commits `gravity_y * dt²` of fall. At 900 px/s² and 60 Hz that is a quarter of a
+pixel. Invisible — and fatal.
+
+A quarter of a pixel of overlap means the actor's box intersects the floor tile. On the next
+step the **horizontal** sweep therefore finds itself blocked wherever it tries to go, and
+snaps the actor back to a tile boundary. The symptom is a character who cannot walk, jerking
+backwards by up to a tile every time it tries. Nothing about it looks like a vertical
+problem.
+
+The fix is to pre-load the cancellation instead of zeroing:
+
+```c excerpt=examples/sidescroller/collision.c
+ if ( dest->blocked_y == true ) {
+ obj->y = box.y - body->y;
+ obj->ey = -(float32_t)akgl_physics->gravity_y * dt;
+ obj->ty = 0.0f;
+ }
+```
+
+After the step's gravity, `ey` is exactly zero; after drag, still zero; `move` commits
+nothing. A standing actor rests exactly on the surface, forever, at a `y` that is a whole
+number of pixels.
+
+### Standing on something is measured, not recorded
+
+Nothing in libakgl knows whether an actor is on the ground. It is one probe, a pixel below
+where the box will be when the step finishes:
+
+```c excerpt=examples/sidescroller/collision.c
+ probe = box;
+ probe.y += 1.0f;
+ PASS(errctx, ss_collide_box_blocked(&probe, &solid));
+ dest->grounded = solid;
+```
+
+The verdict is one frame stale by the time the jump reads it, because this hook runs before
+the step it is deciding about. One frame of coyote time is not something a player can feel.
+
+### Spawning inside the scenery
+
+A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, and `level1.tmj` puts the
+player at x=32 with a step at tile (3,11) — under the right half of the player's frame. The
+swept resolution cannot help: it stops an actor *entering* terrain and has nothing to say
+about one that began inside it. What it does instead is refuse every horizontal move,
+because the box is blocked wherever it goes.
+
+So spawn points get lifted clear once, before the first step:
+
+```c excerpt=examples/sidescroller/collision.c
+ PASS(errctx, ss_collide_box_blocked(&box, &solid));
+ if ( solid == false ) {
+ SUCCEED_RETURN(errctx);
+ }
+ obj->y -= (float32_t)SS_TILE_SIZE;
+```
+
+The player and the blob both need it, and both end up standing on the block they were
+overlapping. Failing after four tiles is deliberate: a spawn point buried that deep is a
+level bug, and a silent nudge would hide it.
+
+## Making it feel like a platformer
+
+Two of libakgl's documented gaps are about feel rather than correctness, and both are in
+`TODO.md` under "Arcade physics feel". The tutorial works around both rather than pretending.
+
+### Releasing a direction stops the actor dead
+
+`akgl_actor_cmhf_left_off` clears the movement bit, zeroes `ax`, **and zeroes `tx`**. There
+is no friction and no deceleration anywhere in the arcade backend, so a character at full
+speed stops within one frame — 0.0 px of drift, measured. That is correct for a top-down
+Zelda and wrong for a sidescroller.
+
+The game binds its own release handlers, which do everything the library's do except the
+last part:
+
+```c excerpt=examples/sidescroller/player.c
+static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event)
+{
+ PREPARE_ERROR(errctx);
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
+ obj->ax = 0.0f;
+ AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT);
+ SUCCEED_RETURN(errctx);
+}
+```
+
+and decays `tx` in the movement logic instead, faster on the ground than in the air:
+
+```c excerpt=examples/sidescroller/player.c
+ friction = SS_FRICTION_AIR;
+ if ( ss_game.grounded == true ) {
+ friction = SS_FRICTION_GROUND;
+ }
+ obj->tx -= obj->tx * friction * dt;
+ if ( fabsf(obj->tx) < 1.0f ) {
+ obj->tx = 0.0f;
+ }
+```
+
+The snap to zero below a pixel per second is there because an exponential decay never
+actually arrives.
+
+The `_on` handlers are the library's unchanged. `akgl_actor_cmhf_right_on` clears
+`FACE_ALL | MOVING_ALL`, sets `MOVING_RIGHT | FACE_RIGHT` and signs `ax` from the character
+— exactly right. Only the release half needed replacing. [Chapter 15](15-input.md) covers
+building a control map; the whole of this game's is six bindings, three keyboard and three
+gamepad.
+
+### A jump is an impulse into `ey`, not thrust
+
+```c excerpt=examples/sidescroller/player.c
+ if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) {
+ obj->ey = -SS_JUMP_SPEED;
+ }
+ ss_game.jump_requested = false;
+```
+
+It has to be the environmental term. `ey` is the axis gravity accumulates on, and the two
+have to cancel for the arc to come back down. Written as thrust it would not work at all:
+`ty` is capped against the character's `speed_y`, which is `0.0` for this character, so
+`akgl_physics_simulate`'s ellipse cap scales it to nothing. See the thrust-versus-velocity
+model in [Chapter 14](14-physics.md).
+
+`ey` being an ordinary field that nothing else owns between steps also buys variable jump
+height for four lines — releasing the button while still rising cuts the remaining
+velocity:
+
+```c excerpt=examples/sidescroller/player.c
+ if ( obj->ey < 0.0f ) {
+ obj->ey *= 0.4f;
+ }
+```
+
+## The state word chooses the sprite, and one bit is missing
+
+An actor's whole 32-bit `state` is the key that selects a sprite ([Chapter 12](12-actors.md)).
+The player's character binds ten combinations: idle and running each way, and jumping each
+way.
+
+The jump sprites are selected by setting `AKGL_ACTOR_STATE_MOVING_UP` whenever the actor is
+not on the ground:
+
+```c excerpt=examples/sidescroller/player.c
+ if ( contact.grounded == true ) {
+ AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
+ } else {
+ AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
+ }
+```
+
+Nothing else reads the bit here. `speed_y` and `acceleration_y` are both `0.0` in
+`character_ss_player.json`, so the vertical thrust it would authorise is capped to nothing —
+the bit is being used purely as an animation selector, which is a thing the state mask is
+good for.
+
+The missing bit is the one that makes the player disappear. The default `facefunc`,
+`akgl_actor_automatic_face`, clears every facing bit and then sets one from the *movement*
+bits — so **an actor that stops moving is left facing nowhere**, its state drops to bare
+`ALIVE`, and there is no sprite for that. It is not an error: `akgl_actor_render` handles
+the `AKERR_KEY`, treats the actor as invisible, and draws nothing. A player who stops
+walking vanishes.
+
+The documented way out is to take the facing bits off the library entirely:
+
+```c excerpt=examples/sidescroller/player.c
+ obj->movement_controls_face = false;
+```
+
+`akgl_actor_automatic_face` leaves an actor alone when that is clear, so the facing bits stay
+wherever the control handlers last put them — and `akgl_actor_cmhf_left_off` clears
+`MOVING_LEFT` without touching `FACE_LEFT`, which is exactly the behaviour a standing sprite
+needs. The alternative, mapping bare `ALIVE` to something, is in
+[Chapter 12](12-actors.md).
+
+## `AKGL_ERR_LOGICINTERRUPT`, in a game
+
+The one status in libakgl that is not a failure. Raised from a `movementlogicfunc` it means
+"skip the rest of this tick for me" — no gravity, no drag, no move — and
+`akgl_physics_simulate` handles it and carries on to the next actor.
+
+Two of this game's three non-player behaviours are built on it, for two different reasons.
+
+The coins need it because **gravity is not thrust**. Their character declares no speed and
+no acceleration, so they cannot thrust; `akgl_physics_arcade_gravity` accumulates into `ey`
+for every actor it is handed regardless, and a coin left to the default logic falls out of
+the level with everything else.
+
+```c excerpt=examples/sidescroller/actors.c
+static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt)
+{
+ PREPARE_ERROR(errctx);
+ (void)dt;
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s does not simulate", (char *)obj->name);
+}
+```
+
+The moth needs it because it flies and the level's physics has gravity, which the player
+needs. Rather than fighting the backend it writes its own position and opts out:
+
+```c excerpt=examples/sidescroller/actors.c
+ data->phase += dt;
+ obj->x = data->home_x + (sinf(data->phase) * 64.0f);
+ obj->y = data->home_y + (sinf(data->phase * 2.0f) * 24.0f);
+```
+
+Two rules go with it, both from [Chapter 4](04-errors.md):
+
+- **Only `movementlogicfunc` gets this treatment.** The backend's `gravity` and `move` are
+ called through `PASS` in the same block, so a raise from either aborts the whole step —
+ leaving every remaining actor unsimulated and `gravity_time` unadvanced.
+- The `FAIL_RETURN` is outside any `ATTEMPT` block, as `AGENTS.md` requires. A `*_RETURN`
+ inside one returns past `CLEANUP`.
+
+The blob is the counter-example: it stays inside the physics step, walks under the level's
+gravity like the player does, and uses the same swept resolution. It turns around when the
+sweep blocks it or when there is no floor one pixel past its leading edge:
+
+```c excerpt=examples/sidescroller/actors.c
+ if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
+ data->facing = -data->facing;
+```
+
+### Per-actor data
+
+`akgl_Actor::actorData` is a `void *` the library never reads or frees, and it is where the
+blob's patrol direction and the moth's flight phase live. It points into a fixed table:
+
+```c excerpt=examples/sidescroller/actors.c
+static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT];
+```
+
+A table rather than an allocation, for the same reason libakgl has pools rather than a
+`malloc`: the level places a known number of things, and a game that cannot run out of
+memory at runtime is one fewer failure mode. See [Chapter 5](05-the-heap.md).
+
+## Picking things up
+
+Collecting a coin is a rectangle test from [Chapter 18](18-utilities.md) and a pool release:
+
+```c excerpt=examples/sidescroller/player.c
+ PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other));
+ PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
+ if ( hit == true ) {
+```
+
+```c excerpt=examples/sidescroller/player.c
+ PASS(errctx, akgl_heap_release_actor(ss_game.coins[i]));
+ ss_game.coins[i] = NULL;
+ ss_game.coins_taken += 1;
+```
+
+**There is no despawn call.** Giving the pool slot back is what clears the registry entry
+and stops the actor being drawn, and it is safe to do from inside `akgl_game_update`'s actor
+sweep: that loop re-reads `refcount` at the top of every iteration and skips a slot that has
+gone free. Releasing an actor does not touch its character, so the other coins are
+unaffected.
+
+This runs in the player's `updatefunc` rather than its `movementlogicfunc`, and the split is
+the frame's own order. `akgl_game_update` calls every actor's `updatefunc`, *then* steps the
+physics, *then* draws. Game logic that is not movement belongs in the first pass; anything
+that has to happen inside the physics step has only the one hook.
+
+## The frame
+
+```c excerpt=examples/sidescroller/main.c
+ PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
+```
+
+```c excerpt=examples/sidescroller/main.c
+ PASS(errctx, akgl_game_update(NULL));
+ 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 or present, so the frame is bracketed by the backend's own calls — see
+[Chapter 7](07-the-game-and-the-frame.md) and [Chapter 8](08-rendering.md).
+
+Note the failure contract. **`akgl_game_update` returns holding the game state lock on every
+one of its failure paths.** SDL mutexes are recursive so a single-threaded loop does not
+deadlock on the next frame, but a frame that failed has left the world half-stepped: some
+actors advanced and some not. Treat it as terminal, which is what `PASS` does here.
+
+Events go through `akgl_controller_handle_event` unconditionally — an event that no control
+map binds is not an error, it is a call that did nothing, which is what lets a host pump
+everything through one path.
+
+## The camera
+
+```c excerpt=examples/sidescroller/main.c
+ limit = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) - akgl_camera->w;
+ akgl_camera->x = (ss_game.player->x + 16.0f) - (akgl_camera->w / 2.0f);
+```
+
+`akgl_camera` is a plain `SDL_FRect` in map pixels that `akgl_tilemap_draw` and
+`akgl_actor_render` both read. Moving it is the whole of scrolling; there is no camera
+object and no follow behaviour to configure. It is floored to a whole pixel afterwards
+because `akgl_tilemap_draw` truncates it when working out how much of the edge tiles to
+show, and a camera that is fractionally different every frame makes the tile grid shimmer.
+
+The camera is updated before `akgl_game_update` and therefore uses the player's position
+from the end of the *previous* step. Correcting that would mean splitting the update from
+the draw, which `akgl_game_update` does not offer.
+
+## Teardown
+
+```c excerpt=examples/sidescroller/main.c
+ IGNORE(akgl_text_unloadallfonts());
+ for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
+ if ( akgl_heap_actors[i].refcount > 0 ) {
+ IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i]));
+ }
+ }
+ if ( akgl_gamemap != NULL ) {
+ IGNORE(akgl_tilemap_release(akgl_gamemap));
+ }
+ TTF_Quit();
+ MIX_Quit();
+ SDL_Quit();
+```
+
+The `NULL` guard on the map is not defensive padding. This function is called from the
+program's `CLEANUP` block, which is reached whether startup succeeded or not — and a bad
+`--assets` path fails before `akgl_game_init` has pointed `akgl_gamemap` at anything.
+
+**There is no `akgl_game_shutdown`.** Teardown belongs to the application, and the order
+matters in one place: `akgl_text_unloadallfonts` has to run before `TTF_Quit` or `SDL_Quit`,
+because those destroy the fonts underneath the registry that still points at them
+([Chapter 16](16-text-and-fonts.md)). This game loads no fonts and calls it anyway, because
+the ordering is the thing worth copying.
+
+What this does **not** do is unwind the sprite, spritesheet and character pools. They are
+static storage in a process that is exiting and the objects still reference each other; a
+game that loads a second level has to do it properly, and this one does not pretend to. Two
+things to know before you write that code:
+
+- `akgl_heap_release_character` calls `akgl_character_state_sprites_iterate` on every
+ release, and that function is an `SDL_EnumerateProperties` callback ending in
+ `FINISH_NORETURN` — **an error inside it exits the process**. This is an ordinary path,
+ not an unusual one. [Chapter 11](11-characters.md) and [Chapter 4](04-errors.md).
+- `akgl_tilemap_release` does not release the actors an object layer created. Those are
+ yours, which is what the loop above is for.
+
+## Known defects you will see running this
+
+Each is cross-referenced to `TODO.md`. None is worked around, because in each case the
+workaround would be worse than the symptom.
+
+**The leftmost column of tiles is drawn from the wrong part of the tileset when the camera
+is within one tile of x=0.** `akgl_tilemap_draw` special-cases the first visible column to
+show a partial tile, and writes `src.x += (int)viewport->x % map->tilewidth` — a `+=` onto
+whatever `src.x` was left holding by the last tile of the previous row, rather than an
+assignment onto that tile's own offset (`src/tilemap.c:766`). At `viewport->x = 0` the
+added term is zero and the stale value is used unchanged. The visible result in this game is
+a stray tile at the bottom-left of the screen, which disappears the moment the camera scrolls
+away from the origin and comes back when the player walks home. `TODO.md`, "Performance"
+item 5, records it alongside the tileset scan it lives in; the same shape applies to `src.y`
+and the top row.
+
+**An actor on layer 16 or higher is never drawn.** `akgl_Actor::layer` is a `uint32_t` and
+nothing range-checks it, but `akgl_render_2d_draw_world` stops at `AKGL_TILEMAP_MAX_LAYERS`.
+This level's object group is layer index 2, so it does not bite here — but a map with
+seventeen layers loses its actors silently. [Chapter 12](12-actors.md).
+
+**A wrong name in an asset file can kill the process rather than raise.** The six
+`SDL_EnumerateProperties` callbacks in libakgl end in `FINISH_NORETURN`, and libakerror's
+default unhandled-error handler calls `akerr_exit()`. `akgl_game_update` does not go through
+one — it sweeps the pool directly and propagates — so this game reaches it only through
+`akgl_heap_release_character`. Install your own `akerr_handler_unhandled_error` if a game
+should survive it, and call `akerr_exit()` from it. [Chapter 4](04-errors.md).
+
+**`akgl_tilemap_load` releases nothing on a failed load.** Textures already uploaded and
+actors already created stay where they are and the destination holds a half-built map. This
+game treats a failed load as fatal, which is the only honest thing to do with one map.
+[Chapter 13](13-tilemaps.md).
+
+## The smoke test
+
+The game is registered as a CTest case, so a change that breaks it turns the suite red
+rather than waiting for a reader to notice:
+
+```cmake
+add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay)
+set_tests_properties(example_sidescroller PROPERTIES
+ TIMEOUT 120
+ ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
+)
+```
+
+Four seconds of scripted play under the headless drivers. `--autoplay` holds *right* and
+requests a jump every forty-five frames by calling the handlers a keyboard would have
+called:
+
+```c excerpt=examples/sidescroller/player.c
+ if ( frame == 1 ) {
+ PASS(errctx, akgl_actor_cmhf_right_on(ss_game.player, &synthetic));
+ }
+ if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == 0 ) {
+ ss_game.jump_requested = true;
+ }
+```
+
+That works because a control-map handler takes the actor and an event, and the keyboard
+handlers do not read the event beyond requiring one to be there. In four seconds the script
+walks the level, jumps, collides with terrain, collects a coin, falls in the pit and
+respawns — so the smoke test exercises the collision and the pickup, not just the startup.
+
+A run ends with a line naming what happened, which is worth reading when a change moves the
+feel:
+
+```text
+sidescroller: 240 frames, 1 of 4 coins, 1 deaths
+```
+
+The two counts are not fixed. `dt` is measured from the wall clock rather than passed in
+([Chapter 14](14-physics.md)), so a busier machine takes slightly different steps and the
+script lands in slightly different places. That is why the smoke test asserts a clean exit
+rather than a score — an assertion on the numbers would be a test of the scheduler.
+
+## Where to look next
+
+- [Chapter 14](14-physics.md) — thrust, environmental velocity, the speed ellipse, and the
+ full list of what is not implemented.
+- [Chapter 12](12-actors.md) — the state mask, the six hooks, and the control handlers.
+- [Chapter 13](13-tilemaps.md) — what the map loader accepts, and the three extensions to
+ Tiled this level uses.
+- [Chapter 15](15-input.md) — control maps, and why a binding that never fires is usually
+ the wrong device id.
+- [Chapter 20](20-tutorial-jrpg.md) — the same library from the other end: a top-down game
+ with no gravity, where the content pipeline is the interesting part.
diff --git a/docs/20-tutorial-jrpg.md b/docs/20-tutorial-jrpg.md
new file mode 100644
index 0000000..fd63e16
--- /dev/null
+++ b/docs/20-tutorial-jrpg.md
@@ -0,0 +1,675 @@
+# 20. 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 19](19-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 |
+|---|---|---|
+| `ALIVE|FACE_DOWN` | 17 | `jrpg_player_idle_down` |
+| `ALIVE|FACE_DOWN|MOVING_DOWN` | 1041 | `jrpg_player_walk_down` |
+| `ALIVE|FACE_LEFT` | 18 | `jrpg_player_idle_left` |
+| `ALIVE|FACE_LEFT|MOVING_LEFT` | 146 | `jrpg_player_walk_left` |
+| `ALIVE|FACE_RIGHT` | 20 | `jrpg_player_idle_right` |
+| `ALIVE|FACE_RIGHT|MOVING_RIGHT` | 276 | `jrpg_player_walk_right` |
+| `ALIVE|FACE_UP` | 24 | `jrpg_player_idle_up` |
+| `ALIVE|FACE_UP|MOVING_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 15](15-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, 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 16](16-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());
+ 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 16](16-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
+
+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 19](19-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 15](15-input.md) — control maps, the default bindings, and the
+ keystroke ring this game does not use.
+- [Chapter 21](21-appendix-limits.md) — every compile-time ceiling in one place,
+ including the 64-actor pool this town uses four of.
diff --git a/docs/tutorials/assets/LICENSE b/docs/tutorials/assets/LICENSE
new file mode 100644
index 0000000..0e259d4
--- /dev/null
+++ b/docs/tutorials/assets/LICENSE
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/docs/tutorials/assets/LICENSE.kenney_music-jingles.txt b/docs/tutorials/assets/LICENSE.kenney_music-jingles.txt
new file mode 100644
index 0000000..52934bc
--- /dev/null
+++ b/docs/tutorials/assets/LICENSE.kenney_music-jingles.txt
@@ -0,0 +1,21 @@
+
+
+ Music Jingles
+
+ by Kenney Vleugels (Kenney.nl)
+
+ ------------------------------
+
+ License (Creative Commons Zero, CC0)
+ http://creativecommons.org/publicdomain/zero/1.0/
+
+ You may use these assets in personal and commercial projects.
+ Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
+
+ ------------------------------
+
+ Donate: http://support.kenney.nl
+ Request: http://request.kenney.nl
+
+ Follow on Twitter for updates:
+ @KenneyNL
\ No newline at end of file
diff --git a/docs/tutorials/assets/LICENSE.kenney_pixel-line-platformer.txt b/docs/tutorials/assets/LICENSE.kenney_pixel-line-platformer.txt
new file mode 100644
index 0000000..d6a39de
--- /dev/null
+++ b/docs/tutorials/assets/LICENSE.kenney_pixel-line-platformer.txt
@@ -0,0 +1,22 @@
+
+
+ Pixel Line Platformer (1.0)
+
+ Created/distributed by Kenney (www.kenney.nl)
+ Creation date: 15-08-2021
+
+ ------------------------------
+
+ License: (Creative Commons Zero, CC0)
+ http://creativecommons.org/publicdomain/zero/1.0/
+
+ This content is free to use in personal, educational and commercial projects.
+ Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
+
+ ------------------------------
+
+ Donate: http://support.kenney.nl
+ Patreon: http://patreon.com/kenney/
+
+ Follow on Twitter for updates:
+ http://twitter.com/KenneyNL
\ No newline at end of file
diff --git a/docs/tutorials/assets/LICENSE.kenney_rpg-urban-pack.txt b/docs/tutorials/assets/LICENSE.kenney_rpg-urban-pack.txt
new file mode 100644
index 0000000..4116e89
--- /dev/null
+++ b/docs/tutorials/assets/LICENSE.kenney_rpg-urban-pack.txt
@@ -0,0 +1,23 @@
+
+
+ RPG Urban Pack 1.0
+
+ Created/distributed by Kenney (www.kenney.nl)
+ Creation date: 05-01-2019
+
+ ------------------------------
+
+ License: (Creative Commons Zero, CC0)
+ http://creativecommons.org/publicdomain/zero/1.0/
+
+ This content is free to use in personal, educational and commercial projects.
+ Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
+
+ ------------------------------
+
+ Donate: http://support.kenney.nl
+ Request: http://request.kenney.nl
+ Patreon: http://patreon.com/kenney/
+
+ Follow on Twitter for updates:
+ http://twitter.com/KenneyNL
\ No newline at end of file
diff --git a/docs/tutorials/assets/PROVENANCE.md b/docs/tutorials/assets/PROVENANCE.md
new file mode 100644
index 0000000..6094209
--- /dev/null
+++ b/docs/tutorials/assets/PROVENANCE.md
@@ -0,0 +1,151 @@
+# Provenance of the tutorial assets
+
+Everything under `docs/tutorials/assets/` is **CC0 1.0** or was written for this
+repository. Nothing here carries an attribution requirement, a share-alike
+clause, or a field-of-use restriction, which is the whole point: a reader who
+copies a tutorial into their own game inherits whatever obligation these assets
+carry, and CC0 carries none. "Free to download" is not the same thing and was
+not accepted -- each pack's licence was read on its Kenney page *and* in the
+`License.txt` it ships, and `scripts/fetch_tutorial_assets.sh` re-checks both on
+every refresh and refuses to vendor a pack that fails either.
+
+`LICENSE` is the CC0 1.0 legal code. The three `LICENSE.kenney_*.txt` files are
+the licence statements the packs themselves ship, kept beside the art in the
+same way `tests/assets/akgl_test_mono.LICENSE.txt` sits beside its font.
+
+The two tables below account for all 56 asset files: 14 carry upstream bytes and
+42 were written here. This file and `README.md` are the remaining two, and are
+documentation of the same kind as the rest of `docs/`.
+
+## Upstream packs
+
+| Pack | URL | Licence | Licence file in this tree |
+|-----------------------|------------------------------------------------|---------|--------------------------------------------|
+| Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | `LICENSE.kenney_pixel-line-platformer.txt` |
+| RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | `LICENSE.kenney_rpg-urban-pack.txt` |
+| Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `LICENSE.kenney_music-jingles.txt` |
+
+Kenney asks for credit and does not require it. Credit is given here and in the
+tutorial chapters.
+
+## Files carrying upstream bytes
+
+| File | Pack | Source URL | Licence | Cropped / repacked into |
+|--------------------------------------------|-----------------------|-----------------------------------------------------------------|---------|----------------------------------------------------------------------------------------------------|
+| `LICENSE` | creativecommons.org | https://creativecommons.org/publicdomain/zero/1.0/legalcode.txt | CC0 1.0 | The CC0 1.0 Universal legal code, fetched verbatim |
+| `LICENSE.kenney_pixel-line-platformer.txt` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
+| `LICENSE.kenney_rpg-urban-pack.txt` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
+| `LICENSE.kenney_music-jingles.txt` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
+| `sidescroller/tiles.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | `Tilemap/tilemap_packed.png` copied verbatim: 160x96, 10x6 tiles of 16x16, no spacing, no margin |
+| `sidescroller/player.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tiles 40, 41, 42 and their horizontal mirrors, bottom-centred in six 32x32 cells |
+| `sidescroller/coin.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tile 44, bottom-centred in one 32x32 cell |
+| `sidescroller/hazard.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tiles 55, 56, 51, 52, bottom-centred in four 32x32 cells |
+| `sidescroller/jingle_start.ogg` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `Audio/8-Bit jingles/jingles_NES00.ogg`, copied verbatim (1.8 s) |
+| `jrpg/tiles.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | `Tilemap/tilemap_packed.png` copied verbatim: 432x288, 27x18 tiles of 16x16, no spacing, no margin |
+| `jrpg/player.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 0 (tileset columns 23-26, rows 0-2) regrouped into twelve 32x32 cells |
+| `jrpg/npc_shopkeeper.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 3 (tileset columns 23-26, rows 9-11) regrouped into twelve 32x32 cells |
+| `jrpg/npc_elder.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 2 (tileset columns 23-26, rows 6-8) regrouped into twelve 32x32 cells |
+| `jrpg/jingle_start.ogg` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `Audio/Pizzicato jingles/jingles_PIZZI07.ogg`, copied verbatim (1.3 s) |
+
+## Files written for this repository
+
+These contain no upstream content. They are libakgl source in JSON form: they
+name the art above, they do not embed it.
+
+| File | Kind | What it is |
+|-------------------------------------------------|-----------|---------------------------------------------------------------------------------|
+| `jrpg/character_jrpg_elder.json` | character | State-name to sprite-name bindings; contains no upstream content |
+| `jrpg/character_jrpg_player.json` | character | State-name to sprite-name bindings; contains no upstream content |
+| `jrpg/character_jrpg_shopkeeper.json` | character | State-name to sprite-name bindings; contains no upstream content |
+| `jrpg/sprite_jrpg_elder_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_elder_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_elder_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_elder_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_elder_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_elder_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_elder_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_elder_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_player_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_player_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_player_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_player_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_player_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_player_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_player_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_player_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_shopkeeper_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_shopkeeper_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_shopkeeper_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_shopkeeper_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_shopkeeper_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_shopkeeper_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_shopkeeper_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/sprite_jrpg_shopkeeper_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `jrpg/town.tmj` | map | Tiled 1.8 TMJ, tileset **embedded**. 30x20 cells, ground + decoration + actors |
+| `sidescroller/character_ss_coin.json` | character | State-name to sprite-name bindings; contains no upstream content |
+| `sidescroller/character_ss_hazard_blob.json` | character | State-name to sprite-name bindings; contains no upstream content |
+| `sidescroller/character_ss_hazard_moth.json` | character | State-name to sprite-name bindings; contains no upstream content |
+| `sidescroller/character_ss_player.json` | character | State-name to sprite-name bindings; contains no upstream content |
+| `sidescroller/level1.tmj` | map | Tiled 1.8 TMJ, tileset **embedded**. 40x15 cells, background + terrain + actors |
+| `sidescroller/sprite_ss_coin.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `sidescroller/sprite_ss_hazard_blob.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `sidescroller/sprite_ss_hazard_moth.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `sidescroller/sprite_ss_player_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `sidescroller/sprite_ss_player_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `sidescroller/sprite_ss_player_jump_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `sidescroller/sprite_ss_player_jump_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `sidescroller/sprite_ss_player_run_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+| `sidescroller/sprite_ss_player_run_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
+
+## How the repacking works, and why
+
+**Tilesets are copied verbatim.** Both packs already ship a 16x16 grid with zero
+spacing and zero margin, which is the only geometry
+`akgl_tilemap_compute_tileset_offsets` gets right -- it adds `spacing` to the
+tile pitch but sets the *first* row's y offset to `spacing` rather than zero,
+and it ignores `margin` completely. A packed sheet with a 1 px gutter, which is
+what most of Kenney's older packs ship, would render every tile in row 0 one
+pixel low. That ruled several otherwise-suitable packs out.
+
+**Character sheets are repacked into one row of 32x32 cells.** The asset
+contract for these tutorials is 16x16 tiles and 32x32 character frames, and
+neither pack ships 32x32 art, so each 16x16 source tile is composited at (8, 16)
+inside its cell -- horizontally centred, sitting on the cell's bottom edge. The
+art is not resampled: a 2x upscale would put 2x2 pixel blocks next to 1x1 tiles,
+and every sprite in the repository would stop matching the source pixel for
+pixel. The cost is that three quarters of each cell is empty, and the benefit is
+one rule the tutorials can rely on: **an actor drawn into a 32x32 destination
+rectangle has its feet on the bottom edge of that rectangle and its body
+centred**, whichever sheet it came from.
+
+**Left-facing frames are stored, not mirrored at draw time.**
+`akgl_actor_render` calls `draw_texture` with `SDL_FLIP_NONE` hard-coded
+(`src/actor.c`), so there is no way to ask for a mirrored blit. The sidescroller
+sheet therefore carries frames 0-2 facing right and frames 3-5 as their
+horizontal mirrors. The JRPG sheets need no mirroring: the RPG Urban Pack draws
+both side views.
+
+**One frame is used twice.** Pixel Line Platformer has no dedicated jump pose.
+Frame 1 is the passing position of the run cycle, which is the airborne one, and
+`ss_player_jump_right` reuses it. Said here rather than left for a reader to
+notice.
+
+**The coin does not animate.** Pixel Line Platformer ships one gold pickup tile
+and no rotation frames, so `sprite_ss_coin.json` is a single frame. Nothing was
+invented to pad it out.
+
+## Reproducing this
+
+```sh
+scripts/fetch_tutorial_assets.sh
+```
+
+Fetches all three packs, checks each page and each shipped `License.txt` says
+CC0, verifies the source images are the size the frame arithmetic assumes,
+repacks into a temporary directory, checks every staged file's dimensions, and
+only then moves anything into place. A failure at any step leaves the tracked
+copies untouched and exits non-zero. The output is byte-for-byte reproducible --
+ImageMagick's wall-clock `date:create` and `date:modify` chunks are stripped, so
+a refresh that changes nothing produces no diff.
+
+The script does not touch the JSON or the TMJ maps. Those are hand-maintained.
diff --git a/docs/tutorials/assets/README.md b/docs/tutorials/assets/README.md
new file mode 100644
index 0000000..92ccbdf
--- /dev/null
+++ b/docs/tutorials/assets/README.md
@@ -0,0 +1,162 @@
+# The tutorial asset contract
+
+What `examples/sidescroller/` and `examples/jrpg/` are drawing, and the exact
+numbers they have to use. Licensing is in `PROVENANCE.md`; refreshing the art
+from upstream is `scripts/fetch_tutorial_assets.sh`.
+
+Every claim below was checked against `src/`, not against header prose, and the
+whole set was loaded through the real library -- `akgl_sprite_load_json`,
+`akgl_character_load_json`, `akgl_tilemap_load` -- before being committed.
+
+## Geometry
+
+| Thing | Value | Why it is that value |
+|-------------------------|--------|---------------------------------------------------------------------------|
+| Tile | 16x16 | Both tilesets ship on a 16x16 grid |
+| Tileset spacing, margin | 0, 0 | `akgl_tilemap_compute_tileset_offsets` gets nothing else right; see below |
+| Character frame | 32x32 | The fixed contract for these tutorials |
+| Art inside a frame | 16x16 | Composited at (8, 16): centred, sitting on the cell's bottom edge |
+| Frames per sheet row | all | Every sheet is a single row, so `coords_for_frame` never has to wrap |
+| Frames per animation | max 16 | `AKGL_SPRITE_MAX_FRAMES`; a 17th is `AKERR_OUTOFBOUNDS` at load |
+| Frame id | 0..255 | `uint8_t`; a larger id is `AKERR_OUTOFBOUNDS` at load |
+
+**The one alignment rule.** `akgl_actor_render` draws into a rectangle of
+`sprite->width` by `sprite->height` with its top-left at the actor's `x`, `y`.
+Because every frame's art is bottom-centred in its cell, an actor at `(x, y)`
+has its feet at `y + 32` and its 16 px body spanning `x + 8` to `x + 23`. Ground
+contact, pickup tests and hitboxes should use that inner rectangle, not the
+32x32 cell.
+
+## Sidescroller sheets
+
+`sidescroller/player.png` -- 192x32, six frames.
+
+| Frame | Facing | Pose | Used by |
+|-------|--------|---------------------------------|-----------------------------------------------|
+| 0 | right | contact | `ss_player_idle_right`, `ss_player_run_right` |
+| 1 | right | passing, airborne | `ss_player_run_right`, `ss_player_jump_right` |
+| 2 | right | opposite contact | `ss_player_run_right` |
+| 3 | left | contact (mirror of 0) | `ss_player_idle_left`, `ss_player_run_left` |
+| 4 | left | passing, airborne (mirror of 1) | `ss_player_run_left`, `ss_player_jump_left` |
+| 5 | left | opposite contact (mirror of 2) | `ss_player_run_left` |
+
+The left-facing frames exist as their own art because `akgl_actor_render` passes
+`SDL_FLIP_NONE` to `draw_texture` unconditionally (`src/actor.c`). There is no
+way to ask the library for a mirrored blit.
+
+`sidescroller/coin.png` -- 32x32, one frame. The pack ships no rotation frames.
+
+`sidescroller/hazard.png` -- 128x32, four frames.
+
+| Frame | What | Used by |
+|-------|--------------------------|------------------|
+| 0 | red blob, ground, pose A | `ss_hazard_blob` |
+| 1 | red blob, ground, pose B | `ss_hazard_blob` |
+| 2 | moth, flying, wings up | `ss_hazard_moth` |
+| 3 | moth, flying, wings down | `ss_hazard_moth` |
+
+## JRPG sheets
+
+`jrpg/player.png`, `jrpg/npc_shopkeeper.png` and `jrpg/npc_elder.png` are
+384x32, twelve frames, and share one layout.
+
+| Frames | Facing | Poses | Idle sprite uses | Walk sprite uses |
+|--------|--------|-----------------------|------------------|------------------|
+| 0-2 | down | stand, step A, step B | 0 | 1, 0, 2, 0 |
+| 3-5 | left | stand, step A, step B | 3 | 4, 3, 5, 3 |
+| 6-8 | right | stand, step A, step B | 6 | 7, 6, 8, 6 |
+| 9-11 | up | stand, step A, step B | 9 | 10, 9, 11, 9 |
+
+The stand frame between the two steps is what makes it read as a walk rather
+than a shuffle; it is the same three-frame cycle the source art was drawn for.
+
+## States
+
+A character's sprite map is keyed by `SDL_itoa(state)` and looked up with
+`SDL_GetPointerProperty` (`akgl_character_sprite_get`), so **the match is on the
+whole integer, not on a mask test**. A state the character has no exact entry
+for makes the actor invisible for that frame -- `actor_visible` treats
+`AKERR_KEY` as "nothing to draw", which is an answer, not an error.
+
+These are the values the library's own input handlers produce.
+`akgl_actor_cmhf_
_on` clears every `FACE_*` and `MOVING_*` bit before
+setting its own pair, and `_off` clears only its `MOVING_*` bit, so the facing
+survives the key release and no two `MOVING_*` bits are ever set at once.
+
+| State | Bits | Sidescroller sprite | JRPG sprite |
+|-------|-------------------------------------|---------------------|------------------|
+| 16 | ALIVE | (coin, hazards) | -- |
+| 17 | ALIVE, FACE_DOWN | `..._idle_right` | `..._idle_down` |
+| 18 | ALIVE, FACE_LEFT | `..._idle_left` | `..._idle_left` |
+| 20 | ALIVE, FACE_RIGHT | `..._idle_right` | `..._idle_right` |
+| 24 | ALIVE, FACE_UP | -- | `..._idle_up` |
+| 146 | ALIVE, FACE_LEFT, MOVING_LEFT | `..._run_left` | `..._walk_left` |
+| 276 | ALIVE, FACE_RIGHT, MOVING_RIGHT | `..._run_right` | `..._walk_right` |
+| 530 | ALIVE, FACE_LEFT, MOVING_UP | `..._jump_left` | -- |
+| 532 | ALIVE, FACE_RIGHT, MOVING_UP | `..._jump_right` | -- |
+| 536 | ALIVE, FACE_UP, MOVING_UP | `..._jump_right` | `..._walk_up` |
+| 658 | ALIVE, FACE_LEFT, MOVING_LEFT, UP | `..._jump_left` | -- |
+| 788 | ALIVE, FACE_RIGHT, MOVING_RIGHT, UP | `..._jump_right` | -- |
+| 1041 | ALIVE, FACE_DOWN, MOVING_DOWN | -- | `..._walk_down` |
+
+The four jump states are there because a sidescroller that routes its jump
+through `akgl_actor_cmhf_up_on` lands on 536 -- that handler clears `FACE_RIGHT`
+and sets `FACE_UP`, which in a side view is not a facing at all. A game that
+sets `ey` directly instead never reaches those states, and nothing breaks
+either way.
+
+State names in character JSON are the **prefixed** spellings from
+`src/actor_state_string_names.c`: `AKGL_ACTOR_STATE_ALIVE`, not
+`ACTOR_STATE_ALIVE`. `util/assets/littleguy.json` uses the old unprefixed names
+and `velocity_x`, and does not load against the current library. Do not copy it.
+
+## Maps
+
+Both maps are Tiled 1.8 TMJ with the tileset **embedded**.
+
+`akgl_tilemap_load` reads `columns`, `firstgid`, `tilecount`, `image`,
+`imagewidth`, `imageheight`, `margin`, `spacing`, `tilewidth`, `tileheight` and
+`name` straight out of each element of the map's `tilesets` array
+(`akgl_tilemap_load_tilesets_each`). The string `source` appears nowhere in
+`src/tilemap.c` and nothing in the library ever opens a `.tsj`, so an external
+tileset reference fails with `AKERR_KEY` on the missing `columns`. `README.md`
+in the repository root says the opposite; it is wrong.
+
+| Map | Cells | Layers | Objects |
+|---------------------------|-------|-------------------------------------------|---------|
+| `sidescroller/level1.tmj` | 40x15 | background (tile), terrain (tile), actors | 7 |
+| `jrpg/town.tmj` | 30x20 | ground (tile), decoration (tile), actors | 3 |
+
+Global tile ids are `(row * columns) + column + 1`; `0` means an empty cell and
+is skipped. `sidescroller/tiles.png` has ten columns and 60 tiles;
+`jrpg/tiles.png` has twenty-seven columns and 486 tiles, the last four columns
+of which are the character art the sheets above were cut from. Those cells are
+never referenced by `town.tmj`.
+
+Other things the loader insists on, all of them checked:
+
+- **Every object needs a `type` string**, including ones that are not actors. A
+ missing `type` fails the whole load, not just that object.
+- An actor object needs a non-empty `name`, a `character` **string** property
+ and a `state` **int** property. The string-array form of `state` works in
+ character JSON and is not accepted here.
+- The `character` named must already be in `AKGL_REGISTRY_CHARACTER`, which
+ means every sprite and character JSON has to be loaded before the map.
+- A map's `properties` are optional; if present, `physics.model` must name a
+ backend that exists (`null` or `arcade`) or the load fails. Gravity and drag
+ keys are `float`.
+- Tileset image paths resolve through `akgl_path_relative` against the map's own
+ directory. Image-layer paths do not -- those go through a plain `"%s/%s"`
+ join, so an absolute path there does not work. Keep every path relative.
+
+## Physics
+
+The sidescroller map asks for the `arcade` backend with `physics.gravity.y`
+900.0 and `physics.drag.y` 1.5. There is no terminal velocity in the backend;
+`ey` approaches `gravity_y / drag_y`, so those two numbers set it to 600 px/s.
+The JRPG map asks for `arcade` with both gravity components at 0.0.
+
+`ss_player` has `speed_y` and `acceleration_y` of 0.0 on purpose: a zero top
+speed on an axis means `akgl_physics_simulate` zeroes that axis's thrust
+outright, so holding a vertical direction cannot make the player fly. A jump
+belongs in `ey`.
diff --git a/docs/tutorials/assets/jrpg/character_jrpg_elder.json b/docs/tutorials/assets/jrpg/character_jrpg_elder.json
new file mode 100644
index 0000000..f0dbe9a
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/character_jrpg_elder.json
@@ -0,0 +1,70 @@
+{
+ "name": "jrpg_elder",
+ "speedtime": 150,
+ "speed_x": 60.0,
+ "speed_y": 60.0,
+ "acceleration_x": 400.0,
+ "acceleration_y": 400.0,
+ "sprite_mappings": [
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_DOWN"
+ ],
+ "sprite": "jrpg_elder_idle_down"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_DOWN",
+ "AKGL_ACTOR_STATE_MOVING_DOWN"
+ ],
+ "sprite": "jrpg_elder_walk_down"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT"
+ ],
+ "sprite": "jrpg_elder_idle_left"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT",
+ "AKGL_ACTOR_STATE_MOVING_LEFT"
+ ],
+ "sprite": "jrpg_elder_walk_left"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT"
+ ],
+ "sprite": "jrpg_elder_idle_right"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT",
+ "AKGL_ACTOR_STATE_MOVING_RIGHT"
+ ],
+ "sprite": "jrpg_elder_walk_right"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_UP"
+ ],
+ "sprite": "jrpg_elder_idle_up"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_UP",
+ "AKGL_ACTOR_STATE_MOVING_UP"
+ ],
+ "sprite": "jrpg_elder_walk_up"
+ }
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/character_jrpg_player.json b/docs/tutorials/assets/jrpg/character_jrpg_player.json
new file mode 100644
index 0000000..8c0e2c2
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/character_jrpg_player.json
@@ -0,0 +1,70 @@
+{
+ "name": "jrpg_player",
+ "speedtime": 150,
+ "speed_x": 60.0,
+ "speed_y": 60.0,
+ "acceleration_x": 400.0,
+ "acceleration_y": 400.0,
+ "sprite_mappings": [
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_DOWN"
+ ],
+ "sprite": "jrpg_player_idle_down"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_DOWN",
+ "AKGL_ACTOR_STATE_MOVING_DOWN"
+ ],
+ "sprite": "jrpg_player_walk_down"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT"
+ ],
+ "sprite": "jrpg_player_idle_left"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT",
+ "AKGL_ACTOR_STATE_MOVING_LEFT"
+ ],
+ "sprite": "jrpg_player_walk_left"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT"
+ ],
+ "sprite": "jrpg_player_idle_right"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT",
+ "AKGL_ACTOR_STATE_MOVING_RIGHT"
+ ],
+ "sprite": "jrpg_player_walk_right"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_UP"
+ ],
+ "sprite": "jrpg_player_idle_up"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_UP",
+ "AKGL_ACTOR_STATE_MOVING_UP"
+ ],
+ "sprite": "jrpg_player_walk_up"
+ }
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/character_jrpg_shopkeeper.json b/docs/tutorials/assets/jrpg/character_jrpg_shopkeeper.json
new file mode 100644
index 0000000..217aaa8
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/character_jrpg_shopkeeper.json
@@ -0,0 +1,70 @@
+{
+ "name": "jrpg_shopkeeper",
+ "speedtime": 150,
+ "speed_x": 60.0,
+ "speed_y": 60.0,
+ "acceleration_x": 400.0,
+ "acceleration_y": 400.0,
+ "sprite_mappings": [
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_DOWN"
+ ],
+ "sprite": "jrpg_shopkeeper_idle_down"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_DOWN",
+ "AKGL_ACTOR_STATE_MOVING_DOWN"
+ ],
+ "sprite": "jrpg_shopkeeper_walk_down"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT"
+ ],
+ "sprite": "jrpg_shopkeeper_idle_left"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT",
+ "AKGL_ACTOR_STATE_MOVING_LEFT"
+ ],
+ "sprite": "jrpg_shopkeeper_walk_left"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT"
+ ],
+ "sprite": "jrpg_shopkeeper_idle_right"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT",
+ "AKGL_ACTOR_STATE_MOVING_RIGHT"
+ ],
+ "sprite": "jrpg_shopkeeper_walk_right"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_UP"
+ ],
+ "sprite": "jrpg_shopkeeper_idle_up"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_UP",
+ "AKGL_ACTOR_STATE_MOVING_UP"
+ ],
+ "sprite": "jrpg_shopkeeper_walk_up"
+ }
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/jingle_start.ogg b/docs/tutorials/assets/jrpg/jingle_start.ogg
new file mode 100644
index 0000000..e3f2c49
Binary files /dev/null and b/docs/tutorials/assets/jrpg/jingle_start.ogg differ
diff --git a/docs/tutorials/assets/jrpg/npc_elder.png b/docs/tutorials/assets/jrpg/npc_elder.png
new file mode 100644
index 0000000..a549552
Binary files /dev/null and b/docs/tutorials/assets/jrpg/npc_elder.png differ
diff --git a/docs/tutorials/assets/jrpg/npc_shopkeeper.png b/docs/tutorials/assets/jrpg/npc_shopkeeper.png
new file mode 100644
index 0000000..5b0f929
Binary files /dev/null and b/docs/tutorials/assets/jrpg/npc_shopkeeper.png differ
diff --git a/docs/tutorials/assets/jrpg/player.png b/docs/tutorials/assets/jrpg/player.png
new file mode 100644
index 0000000..2dd160d
Binary files /dev/null and b/docs/tutorials/assets/jrpg/player.png differ
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_down.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_down.json
new file mode 100644
index 0000000..11cd354
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_down.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "npc_elder.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_elder_idle_down",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 0
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_left.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_left.json
new file mode 100644
index 0000000..ff403fd
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_left.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "npc_elder.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_elder_idle_left",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 3
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_right.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_right.json
new file mode 100644
index 0000000..385b6f2
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_right.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "npc_elder.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_elder_idle_right",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 6
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_up.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_up.json
new file mode 100644
index 0000000..0c1a548
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_idle_up.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "npc_elder.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_elder_idle_up",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 9
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_down.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_down.json
new file mode 100644
index 0000000..eec517d
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_down.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "npc_elder.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_elder_walk_down",
+ "width": 32,
+ "height": 32,
+ "speed": 150,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 1,
+ 0,
+ 2,
+ 0
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_left.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_left.json
new file mode 100644
index 0000000..6468eb4
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_left.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "npc_elder.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_elder_walk_left",
+ "width": 32,
+ "height": 32,
+ "speed": 150,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 4,
+ 3,
+ 5,
+ 3
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_right.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_right.json
new file mode 100644
index 0000000..5ebc555
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_right.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "npc_elder.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_elder_walk_right",
+ "width": 32,
+ "height": 32,
+ "speed": 150,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 7,
+ 6,
+ 8,
+ 6
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_up.json b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_up.json
new file mode 100644
index 0000000..46a283c
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_elder_walk_up.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "npc_elder.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_elder_walk_up",
+ "width": 32,
+ "height": 32,
+ "speed": 150,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 10,
+ 9,
+ 11,
+ 9
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_down.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_down.json
new file mode 100644
index 0000000..7bd4e50
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_down.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_player_idle_down",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 0
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_left.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_left.json
new file mode 100644
index 0000000..0c4b69e
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_left.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_player_idle_left",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 3
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_right.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_right.json
new file mode 100644
index 0000000..88e9542
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_right.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_player_idle_right",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 6
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_up.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_up.json
new file mode 100644
index 0000000..c6bd043
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_idle_up.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_player_idle_up",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 9
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_down.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_down.json
new file mode 100644
index 0000000..ffbd9db
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_down.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_player_walk_down",
+ "width": 32,
+ "height": 32,
+ "speed": 150,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 1,
+ 0,
+ 2,
+ 0
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_left.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_left.json
new file mode 100644
index 0000000..a315161
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_left.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_player_walk_left",
+ "width": 32,
+ "height": 32,
+ "speed": 150,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 4,
+ 3,
+ 5,
+ 3
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_right.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_right.json
new file mode 100644
index 0000000..b8b5301
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_right.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_player_walk_right",
+ "width": 32,
+ "height": 32,
+ "speed": 150,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 7,
+ 6,
+ 8,
+ 6
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_up.json b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_up.json
new file mode 100644
index 0000000..2b37d75
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_player_walk_up.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_player_walk_up",
+ "width": 32,
+ "height": 32,
+ "speed": 150,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 10,
+ 9,
+ 11,
+ 9
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_down.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_down.json
new file mode 100644
index 0000000..28d1615
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_down.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "npc_shopkeeper.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_shopkeeper_idle_down",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 0
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_left.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_left.json
new file mode 100644
index 0000000..f412be3
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_left.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "npc_shopkeeper.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_shopkeeper_idle_left",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 3
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_right.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_right.json
new file mode 100644
index 0000000..5cddafd
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_right.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "npc_shopkeeper.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_shopkeeper_idle_right",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 6
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_up.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_up.json
new file mode 100644
index 0000000..94eec53
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_idle_up.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "npc_shopkeeper.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_shopkeeper_idle_up",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 9
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_down.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_down.json
new file mode 100644
index 0000000..c57c626
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_down.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "npc_shopkeeper.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_shopkeeper_walk_down",
+ "width": 32,
+ "height": 32,
+ "speed": 150,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 1,
+ 0,
+ 2,
+ 0
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_left.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_left.json
new file mode 100644
index 0000000..7a29494
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_left.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "npc_shopkeeper.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_shopkeeper_walk_left",
+ "width": 32,
+ "height": 32,
+ "speed": 150,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 4,
+ 3,
+ 5,
+ 3
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_right.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_right.json
new file mode 100644
index 0000000..eb73dd0
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_right.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "npc_shopkeeper.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_shopkeeper_walk_right",
+ "width": 32,
+ "height": 32,
+ "speed": 150,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 7,
+ 6,
+ 8,
+ 6
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_up.json b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_up.json
new file mode 100644
index 0000000..ee17a00
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/sprite_jrpg_shopkeeper_walk_up.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "npc_shopkeeper.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "jrpg_shopkeeper_walk_up",
+ "width": 32,
+ "height": 32,
+ "speed": 150,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 10,
+ 9,
+ 11,
+ 9
+ ]
+}
diff --git a/docs/tutorials/assets/jrpg/tiles.png b/docs/tutorials/assets/jrpg/tiles.png
new file mode 100644
index 0000000..66bf115
Binary files /dev/null and b/docs/tutorials/assets/jrpg/tiles.png differ
diff --git a/docs/tutorials/assets/jrpg/town.tmj b/docs/tutorials/assets/jrpg/town.tmj
new file mode 100644
index 0000000..85e3901
--- /dev/null
+++ b/docs/tutorials/assets/jrpg/town.tmj
@@ -0,0 +1,1356 @@
+{
+ "compressionlevel": -1,
+ "height": 20,
+ "infinite": false,
+ "layers": [
+ {
+ "data": [
+ 1,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 3,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 90,
+ 91,
+ 91,
+ 91,
+ 91,
+ 91,
+ 91,
+ 91,
+ 91,
+ 92,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 171,
+ 172,
+ 172,
+ 172,
+ 173,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 198,
+ 199,
+ 199,
+ 199,
+ 200,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 198,
+ 199,
+ 199,
+ 199,
+ 200,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 198,
+ 199,
+ 199,
+ 199,
+ 200,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 225,
+ 226,
+ 226,
+ 226,
+ 227,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 117,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 118,
+ 119,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 144,
+ 145,
+ 145,
+ 145,
+ 145,
+ 145,
+ 145,
+ 145,
+ 145,
+ 146,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 28,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 29,
+ 30,
+ 55,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 56,
+ 57
+ ],
+ "height": 20,
+ "id": 1,
+ "name": "ground",
+ "opacity": 1,
+ "type": "tilelayer",
+ "visible": true,
+ "width": 30,
+ "x": 0,
+ "y": 0
+ },
+ {
+ "data": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 233,
+ 0,
+ 0,
+ 0,
+ 0,
+ 234,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 260,
+ 0,
+ 0,
+ 0,
+ 0,
+ 261,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 73,
+ 73,
+ 73,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 100,
+ 284,
+ 100,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 234,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 261,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 233,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 260,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 73,
+ 73,
+ 73,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 100,
+ 284,
+ 100,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 234,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 261,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 233,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 233,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 260,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 260,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "height": 20,
+ "id": 2,
+ "name": "decoration",
+ "opacity": 1,
+ "type": "tilelayer",
+ "visible": true,
+ "width": 30,
+ "x": 0,
+ "y": 0
+ },
+ {
+ "draworder": "topdown",
+ "id": 3,
+ "name": "actors",
+ "objects": [
+ {
+ "height": 32,
+ "id": 1,
+ "name": "player",
+ "properties": [
+ {
+ "name": "character",
+ "type": "string",
+ "value": "jrpg_player"
+ },
+ {
+ "name": "state",
+ "type": "int",
+ "value": 17
+ }
+ ],
+ "rotation": 0,
+ "type": "actor",
+ "visible": true,
+ "width": 32,
+ "x": 224,
+ "y": 256
+ },
+ {
+ "height": 32,
+ "id": 2,
+ "name": "shopkeeper",
+ "properties": [
+ {
+ "name": "character",
+ "type": "string",
+ "value": "jrpg_shopkeeper"
+ },
+ {
+ "name": "state",
+ "type": "int",
+ "value": 17
+ }
+ ],
+ "rotation": 0,
+ "type": "actor",
+ "visible": true,
+ "width": 32,
+ "x": 80,
+ "y": 128
+ },
+ {
+ "height": 32,
+ "id": 3,
+ "name": "elder",
+ "properties": [
+ {
+ "name": "character",
+ "type": "string",
+ "value": "jrpg_elder"
+ },
+ {
+ "name": "state",
+ "type": "int",
+ "value": 17
+ }
+ ],
+ "rotation": 0,
+ "type": "actor",
+ "visible": true,
+ "width": 32,
+ "x": 272,
+ "y": 112
+ }
+ ],
+ "opacity": 1,
+ "type": "objectgroup",
+ "visible": true,
+ "x": 0,
+ "y": 0
+ }
+ ],
+ "nextlayerid": 4,
+ "nextobjectid": 4,
+ "orientation": "orthogonal",
+ "properties": [
+ {
+ "name": "physics.model",
+ "type": "string",
+ "value": "arcade"
+ },
+ {
+ "name": "physics.gravity.x",
+ "type": "float",
+ "value": 0.0
+ },
+ {
+ "name": "physics.gravity.y",
+ "type": "float",
+ "value": 0.0
+ }
+ ],
+ "renderorder": "right-down",
+ "tiledversion": "1.8.2",
+ "tileheight": 16,
+ "tilesets": [
+ {
+ "columns": 27,
+ "firstgid": 1,
+ "image": "tiles.png",
+ "imageheight": 288,
+ "imagewidth": 432,
+ "margin": 0,
+ "name": "rpg_urban",
+ "spacing": 0,
+ "tilecount": 486,
+ "tileheight": 16,
+ "tilewidth": 16
+ }
+ ],
+ "tilewidth": 16,
+ "type": "map",
+ "version": "1.8",
+ "width": 30
+}
diff --git a/docs/tutorials/assets/sidescroller/character_ss_coin.json b/docs/tutorials/assets/sidescroller/character_ss_coin.json
new file mode 100644
index 0000000..cb5c598
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/character_ss_coin.json
@@ -0,0 +1,16 @@
+{
+ "name": "ss_coin",
+ "speedtime": 200,
+ "speed_x": 0.0,
+ "speed_y": 0.0,
+ "acceleration_x": 0.0,
+ "acceleration_y": 0.0,
+ "sprite_mappings": [
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE"
+ ],
+ "sprite": "ss_coin"
+ }
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/character_ss_hazard_blob.json b/docs/tutorials/assets/sidescroller/character_ss_hazard_blob.json
new file mode 100644
index 0000000..298adf3
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/character_ss_hazard_blob.json
@@ -0,0 +1,46 @@
+{
+ "name": "ss_hazard_blob",
+ "speedtime": 180,
+ "speed_x": 24.0,
+ "speed_y": 0.0,
+ "acceleration_x": 200.0,
+ "acceleration_y": 0.0,
+ "sprite_mappings": [
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE"
+ ],
+ "sprite": "ss_hazard_blob"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT"
+ ],
+ "sprite": "ss_hazard_blob"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT",
+ "AKGL_ACTOR_STATE_MOVING_LEFT"
+ ],
+ "sprite": "ss_hazard_blob"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT"
+ ],
+ "sprite": "ss_hazard_blob"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT",
+ "AKGL_ACTOR_STATE_MOVING_RIGHT"
+ ],
+ "sprite": "ss_hazard_blob"
+ }
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/character_ss_hazard_moth.json b/docs/tutorials/assets/sidescroller/character_ss_hazard_moth.json
new file mode 100644
index 0000000..1e7d151
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/character_ss_hazard_moth.json
@@ -0,0 +1,46 @@
+{
+ "name": "ss_hazard_moth",
+ "speedtime": 120,
+ "speed_x": 40.0,
+ "speed_y": 40.0,
+ "acceleration_x": 300.0,
+ "acceleration_y": 300.0,
+ "sprite_mappings": [
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE"
+ ],
+ "sprite": "ss_hazard_moth"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT"
+ ],
+ "sprite": "ss_hazard_moth"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT",
+ "AKGL_ACTOR_STATE_MOVING_LEFT"
+ ],
+ "sprite": "ss_hazard_moth"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT"
+ ],
+ "sprite": "ss_hazard_moth"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT",
+ "AKGL_ACTOR_STATE_MOVING_RIGHT"
+ ],
+ "sprite": "ss_hazard_moth"
+ }
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/character_ss_player.json b/docs/tutorials/assets/sidescroller/character_ss_player.json
new file mode 100644
index 0000000..81acf3f
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/character_ss_player.json
@@ -0,0 +1,89 @@
+{
+ "name": "ss_player",
+ "speedtime": 120,
+ "speed_x": 90.0,
+ "speed_y": 0.0,
+ "acceleration_x": 600.0,
+ "acceleration_y": 0.0,
+ "sprite_mappings": [
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT"
+ ],
+ "sprite": "ss_player_idle_right"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT",
+ "AKGL_ACTOR_STATE_MOVING_RIGHT"
+ ],
+ "sprite": "ss_player_run_right"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT"
+ ],
+ "sprite": "ss_player_idle_left"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT",
+ "AKGL_ACTOR_STATE_MOVING_LEFT"
+ ],
+ "sprite": "ss_player_run_left"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_DOWN"
+ ],
+ "sprite": "ss_player_idle_right"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_UP",
+ "AKGL_ACTOR_STATE_MOVING_UP"
+ ],
+ "sprite": "ss_player_jump_right"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT",
+ "AKGL_ACTOR_STATE_MOVING_UP"
+ ],
+ "sprite": "ss_player_jump_right"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT",
+ "AKGL_ACTOR_STATE_MOVING_UP"
+ ],
+ "sprite": "ss_player_jump_left"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_RIGHT",
+ "AKGL_ACTOR_STATE_MOVING_RIGHT",
+ "AKGL_ACTOR_STATE_MOVING_UP"
+ ],
+ "sprite": "ss_player_jump_right"
+ },
+ {
+ "state": [
+ "AKGL_ACTOR_STATE_ALIVE",
+ "AKGL_ACTOR_STATE_FACE_LEFT",
+ "AKGL_ACTOR_STATE_MOVING_LEFT",
+ "AKGL_ACTOR_STATE_MOVING_UP"
+ ],
+ "sprite": "ss_player_jump_left"
+ }
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/coin.png b/docs/tutorials/assets/sidescroller/coin.png
new file mode 100644
index 0000000..b1ab9aa
Binary files /dev/null and b/docs/tutorials/assets/sidescroller/coin.png differ
diff --git a/docs/tutorials/assets/sidescroller/hazard.png b/docs/tutorials/assets/sidescroller/hazard.png
new file mode 100644
index 0000000..72f6ebc
Binary files /dev/null and b/docs/tutorials/assets/sidescroller/hazard.png differ
diff --git a/docs/tutorials/assets/sidescroller/jingle_start.ogg b/docs/tutorials/assets/sidescroller/jingle_start.ogg
new file mode 100644
index 0000000..2db3b5b
Binary files /dev/null and b/docs/tutorials/assets/sidescroller/jingle_start.ogg differ
diff --git a/docs/tutorials/assets/sidescroller/level1.tmj b/docs/tutorials/assets/sidescroller/level1.tmj
new file mode 100644
index 0000000..6093bc1
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/level1.tmj
@@ -0,0 +1,1448 @@
+{
+ "compressionlevel": -1,
+ "height": 15,
+ "infinite": false,
+ "layers": [
+ {
+ "data": [
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 12,
+ 13,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 2,
+ 3,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 2,
+ 3,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ],
+ "height": 15,
+ "id": 1,
+ "name": "background",
+ "opacity": 1,
+ "type": "tilelayer",
+ "visible": true,
+ "width": 40,
+ "x": 0,
+ "y": 0
+ },
+ {
+ "data": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 28,
+ 29,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 28,
+ 29,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 28,
+ 29,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 28,
+ 29,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 31,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 33,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 35,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 37,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 21,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 23,
+ 0,
+ 0,
+ 0,
+ 21,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 22,
+ 23,
+ 15,
+ 15,
+ 15,
+ 25,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 25,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 25,
+ 0,
+ 0,
+ 0,
+ 15,
+ 15,
+ 15,
+ 25,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 25,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 25,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 0,
+ 0,
+ 0,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15,
+ 15
+ ],
+ "height": 15,
+ "id": 2,
+ "name": "terrain",
+ "opacity": 1,
+ "type": "tilelayer",
+ "visible": true,
+ "width": 40,
+ "x": 0,
+ "y": 0
+ },
+ {
+ "draworder": "topdown",
+ "id": 3,
+ "name": "actors",
+ "objects": [
+ {
+ "height": 32,
+ "id": 1,
+ "name": "player",
+ "properties": [
+ {
+ "name": "character",
+ "type": "string",
+ "value": "ss_player"
+ },
+ {
+ "name": "state",
+ "type": "int",
+ "value": 20
+ }
+ ],
+ "rotation": 0,
+ "type": "actor",
+ "visible": true,
+ "width": 32,
+ "x": 32,
+ "y": 160
+ },
+ {
+ "height": 32,
+ "id": 2,
+ "name": "coin1",
+ "properties": [
+ {
+ "name": "character",
+ "type": "string",
+ "value": "ss_coin"
+ },
+ {
+ "name": "state",
+ "type": "int",
+ "value": 16
+ }
+ ],
+ "rotation": 0,
+ "type": "actor",
+ "visible": true,
+ "width": 32,
+ "x": 128,
+ "y": 112
+ },
+ {
+ "height": 32,
+ "id": 3,
+ "name": "coin2",
+ "properties": [
+ {
+ "name": "character",
+ "type": "string",
+ "value": "ss_coin"
+ },
+ {
+ "name": "state",
+ "type": "int",
+ "value": 16
+ }
+ ],
+ "rotation": 0,
+ "type": "actor",
+ "visible": true,
+ "width": 32,
+ "x": 224,
+ "y": 80
+ },
+ {
+ "height": 32,
+ "id": 4,
+ "name": "coin3",
+ "properties": [
+ {
+ "name": "character",
+ "type": "string",
+ "value": "ss_coin"
+ },
+ {
+ "name": "state",
+ "type": "int",
+ "value": 16
+ }
+ ],
+ "rotation": 0,
+ "type": "actor",
+ "visible": true,
+ "width": 32,
+ "x": 352,
+ "y": 96
+ },
+ {
+ "height": 32,
+ "id": 5,
+ "name": "coin4",
+ "properties": [
+ {
+ "name": "character",
+ "type": "string",
+ "value": "ss_coin"
+ },
+ {
+ "name": "state",
+ "type": "int",
+ "value": 16
+ }
+ ],
+ "rotation": 0,
+ "type": "actor",
+ "visible": true,
+ "width": 32,
+ "x": 448,
+ "y": 64
+ },
+ {
+ "height": 32,
+ "id": 6,
+ "name": "blob1",
+ "properties": [
+ {
+ "name": "character",
+ "type": "string",
+ "value": "ss_hazard_blob"
+ },
+ {
+ "name": "state",
+ "type": "int",
+ "value": 18
+ }
+ ],
+ "rotation": 0,
+ "type": "actor",
+ "visible": true,
+ "width": 32,
+ "x": 400,
+ "y": 160
+ },
+ {
+ "height": 32,
+ "id": 7,
+ "name": "moth1",
+ "properties": [
+ {
+ "name": "character",
+ "type": "string",
+ "value": "ss_hazard_moth"
+ },
+ {
+ "name": "state",
+ "type": "int",
+ "value": 18
+ }
+ ],
+ "rotation": 0,
+ "type": "actor",
+ "visible": true,
+ "width": 32,
+ "x": 272,
+ "y": 80
+ }
+ ],
+ "opacity": 1,
+ "type": "objectgroup",
+ "visible": true,
+ "x": 0,
+ "y": 0
+ }
+ ],
+ "nextlayerid": 4,
+ "nextobjectid": 8,
+ "orientation": "orthogonal",
+ "properties": [
+ {
+ "name": "physics.model",
+ "type": "string",
+ "value": "arcade"
+ },
+ {
+ "name": "physics.gravity.y",
+ "type": "float",
+ "value": 900.0
+ },
+ {
+ "name": "physics.drag.y",
+ "type": "float",
+ "value": 1.5
+ }
+ ],
+ "renderorder": "right-down",
+ "tiledversion": "1.8.2",
+ "tileheight": 16,
+ "tilesets": [
+ {
+ "columns": 10,
+ "firstgid": 1,
+ "image": "tiles.png",
+ "imageheight": 96,
+ "imagewidth": 160,
+ "margin": 0,
+ "name": "pixel_line_platformer",
+ "spacing": 0,
+ "tilecount": 60,
+ "tileheight": 16,
+ "tilewidth": 16
+ }
+ ],
+ "tilewidth": 16,
+ "type": "map",
+ "version": "1.8",
+ "width": 40
+}
diff --git a/docs/tutorials/assets/sidescroller/player.png b/docs/tutorials/assets/sidescroller/player.png
new file mode 100644
index 0000000..2643635
Binary files /dev/null and b/docs/tutorials/assets/sidescroller/player.png differ
diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_coin.json b/docs/tutorials/assets/sidescroller/sprite_ss_coin.json
new file mode 100644
index 0000000..d692b7a
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/sprite_ss_coin.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "coin.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "ss_coin",
+ "width": 32,
+ "height": 32,
+ "speed": 200,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 0
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_hazard_blob.json b/docs/tutorials/assets/sidescroller/sprite_ss_hazard_blob.json
new file mode 100644
index 0000000..407a929
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/sprite_ss_hazard_blob.json
@@ -0,0 +1,17 @@
+{
+ "spritesheet": {
+ "filename": "hazard.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "ss_hazard_blob",
+ "width": 32,
+ "height": 32,
+ "speed": 180,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 0,
+ 1
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_hazard_moth.json b/docs/tutorials/assets/sidescroller/sprite_ss_hazard_moth.json
new file mode 100644
index 0000000..255ed53
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/sprite_ss_hazard_moth.json
@@ -0,0 +1,17 @@
+{
+ "spritesheet": {
+ "filename": "hazard.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "ss_hazard_moth",
+ "width": 32,
+ "height": 32,
+ "speed": 120,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 2,
+ 3
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_player_idle_left.json b/docs/tutorials/assets/sidescroller/sprite_ss_player_idle_left.json
new file mode 100644
index 0000000..4f5bb47
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/sprite_ss_player_idle_left.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "ss_player_idle_left",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 3
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_player_idle_right.json b/docs/tutorials/assets/sidescroller/sprite_ss_player_idle_right.json
new file mode 100644
index 0000000..be85013
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/sprite_ss_player_idle_right.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "ss_player_idle_right",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 0
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_player_jump_left.json b/docs/tutorials/assets/sidescroller/sprite_ss_player_jump_left.json
new file mode 100644
index 0000000..0e0fd5d
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/sprite_ss_player_jump_left.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "ss_player_jump_left",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 4
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_player_jump_right.json b/docs/tutorials/assets/sidescroller/sprite_ss_player_jump_right.json
new file mode 100644
index 0000000..8e8abd5
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/sprite_ss_player_jump_right.json
@@ -0,0 +1,16 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "ss_player_jump_right",
+ "width": 32,
+ "height": 32,
+ "speed": 250,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 1
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_player_run_left.json b/docs/tutorials/assets/sidescroller/sprite_ss_player_run_left.json
new file mode 100644
index 0000000..cc920a1
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/sprite_ss_player_run_left.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "ss_player_run_left",
+ "width": 32,
+ "height": 32,
+ "speed": 90,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 3,
+ 4,
+ 5,
+ 4
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/sprite_ss_player_run_right.json b/docs/tutorials/assets/sidescroller/sprite_ss_player_run_right.json
new file mode 100644
index 0000000..17f5c83
--- /dev/null
+++ b/docs/tutorials/assets/sidescroller/sprite_ss_player_run_right.json
@@ -0,0 +1,19 @@
+{
+ "spritesheet": {
+ "filename": "player.png",
+ "frame_width": 32,
+ "frame_height": 32
+ },
+ "name": "ss_player_run_right",
+ "width": 32,
+ "height": 32,
+ "speed": 90,
+ "loop": true,
+ "loopReverse": false,
+ "frames": [
+ 0,
+ 1,
+ 2,
+ 1
+ ]
+}
diff --git a/docs/tutorials/assets/sidescroller/tiles.png b/docs/tutorials/assets/sidescroller/tiles.png
new file mode 100644
index 0000000..8a180a6
Binary files /dev/null and b/docs/tutorials/assets/sidescroller/tiles.png differ
diff --git a/examples/jrpg/CMakeLists.txt b/examples/jrpg/CMakeLists.txt
new file mode 100644
index 0000000..d67abbb
--- /dev/null
+++ b/examples/jrpg/CMakeLists.txt
@@ -0,0 +1,72 @@
+# The top-down JRPG tutorial game, quoted by docs/20-tutorial-jrpg.md.
+#
+# It is a real target built by `all`, not a snippet: a tutorial whose program
+# does not compile is the failure this whole documentation effort exists to
+# stop, and the chapter's `c excerpt=` blocks fail the moment this source moves
+# under them.
+
+add_executable(jrpg
+ jrpg.c
+ world.c
+ textbox.c
+)
+
+target_link_libraries(jrpg
+ PRIVATE akstdlib::akstdlib akerror::akerror akgl
+ SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer
+ jansson::jansson -lm)
+target_include_directories(jrpg PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
+target_compile_options(jrpg PRIVATE ${AKGL_WARNING_FLAGS})
+
+# The game finds its data through two absolute paths baked in here. A game that
+# ships would install its assets and resolve them against SDL_GetBasePath(); a
+# tutorial has to run from the build tree, from the source tree, and from
+# whatever working directory CTest hands it, so the paths are compiled in.
+get_filename_component(JRPG_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
+target_compile_definitions(jrpg PRIVATE
+ JRPG_ASSET_DIR="${JRPG_REPO_ROOT}/docs/tutorials/assets/jrpg"
+ # tests/assets/akgl_test_mono.ttf, which ships beside its licence file.
+ JRPG_FONT_FILE="${JRPG_REPO_ROOT}/tests/assets/akgl_test_mono.ttf"
+)
+
+# The vendored SDL satellite libraries land in per-project subdirectories that
+# are not on the loader's default search path, exactly as they do for the test
+# suites. AKGL_VENDORED_RPATH is set by the top-level CMakeLists.txt and is only
+# defined when something was actually vendored.
+if(AKGL_VENDORED_DEPENDENCIES)
+ set_target_properties(jrpg PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
+endif()
+
+# The headless smoke run. `--demo` drives the arrow keys from a script and steps
+# the physics clock by a fixed 1/60 s, so the run is deterministic and finishes
+# in well under a second; `--frames` bounds it. Between them the run walks the
+# player into a wall, opens the text box, and exercises the follower's render
+# hook -- rather than just proving that main() returns.
+#
+# add_test() reaches the real command here: the top-level CMakeLists.txt shadows
+# it to suppress the vendored projects' registrations and lifts the suppression
+# again long before examples/ is added.
+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"
+)
+
+# ENVIRONMENT above replaces the environment wholesale, so LD_LIBRARY_PATH has
+# to go in the same property rather than a second one. Only needed when the
+# dependencies were vendored; an installed build resolves them normally.
+if(AKGL_VENDORED_DEPENDENCIES)
+ if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
+ set(JRPG_TEST_ENV_MOD "")
+ foreach(dir IN LISTS AKGL_TEST_LIBPATH)
+ list(APPEND JRPG_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
+ endforeach()
+ set_tests_properties(example_jrpg
+ PROPERTIES ENVIRONMENT_MODIFICATION "${JRPG_TEST_ENV_MOD}")
+ else()
+ string(REPLACE ";" ":" JRPG_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
+ set_tests_properties(example_jrpg PROPERTIES
+ ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
+ )
+ endif()
+endif()
diff --git a/examples/jrpg/jrpg.c b/examples/jrpg/jrpg.c
new file mode 100644
index 0000000..e6015a0
--- /dev/null
+++ b/examples/jrpg/jrpg.c
@@ -0,0 +1,369 @@
+/**
+ * @file jrpg.c
+ * @brief A small top-down JRPG: startup, the frame loop, and teardown.
+ *
+ * Chapter 20 of the manual quotes this program rather than restating it, so
+ * what is here is what the chapter teaches. Run it with no arguments for a
+ * window you can walk around in:
+ *
+ * ./examples/jrpg/jrpg
+ *
+ * Arrow keys walk, space talks to whoever is standing next to you and dismisses
+ * the box again. `--frames N` stops after N frames, which is what makes this
+ * runnable as a headless smoke test; `--demo` drives the arrow keys from a
+ * script and steps the physics clock by a fixed interval so the run is
+ * deterministic and takes no wall-clock time.
+ */
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "jrpg.h"
+
+/** @brief Nanoseconds in one 60 Hz step. What `--demo` advances the clock by. */
+#define JRPG_FIXED_STEP_NS (AKGL_TIME_ONESEC_NS / 60)
+/** @brief Milliseconds one 60 Hz frame is allowed to take, for the interactive loop. */
+#define JRPG_FRAME_BUDGET_MS 16
+
+/*
+ * The scripted walk `--demo` drives. The player starts at (224, 256) and the
+ * elder stands at (272, 112), so this is: right for a second, up for two and a
+ * bit, talk, try to walk while frozen, dismiss, walk away. Every one of those
+ * is a path the interactive game has and a headless run would otherwise never
+ * touch.
+ *
+ * Frame Key Down What it is for
+ */
+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 }
+};
+
+#define JRPG_DEMO_STEPS (sizeof(JRPG_DEMO_SCRIPT) / sizeof(JRPG_DEMO_SCRIPT[0]))
+
+static long frame_limit = 0;
+static bool demo = false;
+static bool running = true;
+static int exitstatus = 0;
+static bool lowfps_warned = false;
+
+/**
+ * @brief Replacement `akgl_game.lowfpsfunc`: say it once, not sixty times a second.
+ *
+ * akgl_game.fps is a completed-second average, so it reads 0 for the first
+ * second of every process -- which is under the threshold, so the default hook
+ * logs a line on every frame until the first second is up. The hook exists to
+ * be replaced; a real game sheds work here rather than talking about it.
+ */
+static void lowfps_quiet(void)
+{
+ if ( lowfps_warned == false ) {
+ lowfps_warned = true;
+ SDL_Log("Frame rate is under 30 and this game does nothing about it");
+ }
+}
+
+/**
+ * @brief Read `--frames N` and `--demo` off the command line.
+ *
+ * @param argc Argument count, from `main`.
+ * @param argv Argument vector, from `main`. Required.
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ * @throws AKERR_NULLPOINTER If @p argv is `NULL`.
+ * @throws AKERR_VALUE If `--frames` is last, or its argument is not a number.
+ */
+static akerr_ErrorContext *parse_args(int argc, char *argv[])
+{
+ PREPARE_ERROR(errctx);
+ int i = 0;
+ int frames = 0;
+
+ FAIL_ZERO_RETURN(errctx, argv, AKERR_NULLPOINTER, "argv");
+
+ for ( i = 1; i < argc; i++ ) {
+ if ( strcmp(argv[i], "--demo") == 0 ) {
+ demo = true;
+ } else if ( strcmp(argv[i], "--frames") == 0 ) {
+ if ( (i + 1) >= argc ) {
+ FAIL_RETURN(errctx, AKERR_VALUE, "--frames needs a frame count");
+ }
+ i += 1;
+ PASS(errctx, aksl_atoi(argv[i], &frames));
+ frame_limit = frames;
+ } else {
+ FAIL_RETURN(errctx, AKERR_VALUE, "usage: jrpg [--frames N] [--demo]");
+ }
+ }
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief Bring the library up, load the town, and bind the controls.
+ *
+ * The order below is the one game.h documents and the only one that works.
+ * Three things in it are not obvious and are what chapter 20 spends its time
+ * on: the properties have to be set between akgl_game_init and
+ * akgl_render_2d_init, because that is what reads them; the physics backend has
+ * to be initialized by hand, because akgl_game_init does not; and the assets
+ * have to be loaded sprites-then-characters-then-map.
+ *
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ */
+static akerr_ErrorContext *startup(void)
+{
+ PREPARE_ERROR(errctx);
+ akgl_Control talk;
+
+ // Required before akgl_game_init, which refuses to run without all three:
+ // the window title, SDL's application metadata and the savegame
+ // compatibility check are built from them.
+ PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name), "libakgl JRPG tutorial", sizeof(akgl_game.name) - 1));
+ PASS(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version), "1.0.0", sizeof(akgl_game.version) - 1));
+ PASS(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri), "net.aklabs.libakgl.examples.jrpg", sizeof(akgl_game.uri) - 1));
+
+ 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));
+
+ // akgl_game_init points akgl_physics at akgl_default_physics and stops
+ // there. 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 segmentation fault on frame one rather
+ // than an error context.
+ //
+ // town.tmj happens to declare its own physics, and jrpg_world_load switches
+ // to the backend that builds -- so removing this line alone leaves *this*
+ // program working, and removing it along with that switch is a SIGSEGV on
+ // frame one. Measured, by removing both. This line is what makes the
+ // program correct for a map that declares no physics, which is most of
+ // them. Nothing calls the factory for you either way. A top-down game wants
+ // the arcade backend with no gravity, which is what the property defaults
+ // already give.
+ PASS(errctx, akgl_physics_init_arcade(akgl_physics));
+
+ PASS(errctx, akgl_text_loadfont(JRPG_FONT_NAME, JRPG_FONT_FILE, JRPG_FONT_SIZE));
+
+ PASS(errctx, jrpg_world_load());
+ PASS(errctx, jrpg_world_populate());
+
+ // The arrow keys and the D-pad, wired to the akgl_actor_cmhf_* pairs, in
+ // one call. Keyboard id 0 and gamepad id 0 mean "the first of each".
+ PASS(errctx, akgl_controller_default(0, "player", 0, 0));
+
+ // And one binding of our own on the same map. A binding is a struct, copied
+ // in, so a stack local is fine. handler_off has to be non-NULL --
+ // akgl_controller_handle_event does not check it before calling it -- and
+ // the button is set to something no gamepad reports, because the gamepad
+ // and keyboard arms of the match are evaluated together.
+ PASS(errctx, aksl_memset((void *)&talk, 0x00, sizeof(talk)));
+ talk.event_on = SDL_EVENT_KEY_DOWN;
+ talk.event_off = SDL_EVENT_KEY_UP;
+ talk.key = SDLK_SPACE;
+ talk.button = (uint8_t)SDL_GAMEPAD_BUTTON_INVALID;
+ talk.handler_on = &jrpg_cmhf_talk;
+ talk.handler_off = &jrpg_cmhf_ignore;
+ PASS(errctx, akgl_controller_pushmap(0, &talk));
+
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief Feed the scripted keystrokes due on this frame into the control maps.
+ *
+ * Synthesized `SDL_Event`s rather than direct calls to the handlers, so the
+ * demo goes through akgl_controller_handle_event, the control map lookup and
+ * the binding -- the same path a real key takes. A test that skipped that would
+ * not be testing the thing that breaks.
+ *
+ * @param frameno The frame about to be drawn.
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ */
+static akerr_ErrorContext *demo_step(long frameno)
+{
+ PREPARE_ERROR(errctx);
+ SDL_Event event;
+ size_t i = 0;
+
+ for ( i = 0; i < JRPG_DEMO_STEPS; i++ ) {
+ if ( JRPG_DEMO_SCRIPT[i].frame != frameno ) {
+ continue;
+ }
+ PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event)));
+ if ( JRPG_DEMO_SCRIPT[i].down ) {
+ event.type = SDL_EVENT_KEY_DOWN;
+ } else {
+ event.type = SDL_EVENT_KEY_UP;
+ }
+ event.key.which = 0;
+ event.key.key = JRPG_DEMO_SCRIPT[i].key;
+ PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
+ }
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief One frame: events, camera, the library's own update, the box, present.
+ *
+ * akgl_game_update is update-every-actor, step the physics, draw the world. It
+ * does *not* clear or present the target, 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.
+ *
+ * @param frameno The frame number, for the demo script.
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ */
+static akerr_ErrorContext *frame(long frameno)
+{
+ PREPARE_ERROR(errctx);
+ SDL_Event event;
+ akgl_Iterator opflags = {
+ .flags = AKGL_ITERATOR_OP_UPDATE,
+ .layerid = 0
+ };
+
+ while ( SDL_PollEvent(&event) ) {
+ if ( event.type == SDL_EVENT_QUIT ) {
+ running = false;
+ }
+ PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
+ }
+ if ( demo ) {
+ PASS(errctx, demo_step(frameno));
+ // Drive the clock rather than sleeping on it. akgl_physics_simulate
+ // measures dt from gravity_time, which is a public field, so setting it
+ // one fixed step into the past makes every frame worth exactly 1/60 of
+ // a second of simulated time no matter how fast the loop actually runs.
+ // tests/physics_sim.c does the same, for the same reason: a simulated
+ // second should not cost a real one.
+ akgl_physics->gravity_time = SDL_GetTicksNS() - JRPG_FIXED_STEP_NS;
+ }
+
+ PASS(errctx, jrpg_camera_follow());
+
+ PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
+ // 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 -- but it would be running
+ // on top of a frame that did not finish. Treat a failed frame as terminal.
+ PASS(errctx, akgl_game_update(&opflags));
+ PASS(errctx, jrpg_textbox_draw());
+ PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
+
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief Give back what has to be given back, in the order that works.
+ *
+ * There is no `akgl_game_shutdown`; teardown is the caller's. Two of the three
+ * lines here are load-bearing and the third is a deliberate omission:
+ *
+ * - akgl_text_unloadallfonts must run **before** `SDL_Quit`. Fonts are kept 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.
+ * - akgl_tilemap_release is **not** called. 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. `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.
+ */
+static void teardown(void)
+{
+ IGNORE(akgl_text_unloadallfonts());
+ if ( akgl_window != NULL ) {
+ SDL_DestroyWindow(akgl_window);
+ akgl_window = NULL;
+ }
+ SDL_Quit();
+}
+
+int main(int argc, char *argv[])
+{
+ PREPARE_ERROR(errctx);
+ akgl_Actor *player = NULL;
+ long frameno = 0;
+ uint64_t started = 0;
+ uint64_t spent = 0;
+
+ ATTEMPT {
+ CATCH(errctx, parse_args(argc, argv));
+ CATCH(errctx, startup());
+ // Taken here rather than after FINISH: the registry is an SDL property
+ // set and `SDL_Quit` in teardown() destroys it, so a lookup afterwards
+ // finds nothing. The actor itself is in a static pool and outlives both.
+ player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
+
+ // The loop is the last thing in this ATTEMPT block on purpose. CATCH
+ // reports failure by `break`ing, and a `break` inside a loop binds to
+ // the loop rather than to the block -- so a failing frame leaves the
+ // loop and falls straight into CLEANUP, which is what is wanted. Put
+ // anything after this loop and it would run after a failure instead.
+ // src/tilemap.c carries the same note over the same shape.
+ while ( running ) {
+ started = SDL_GetTicks();
+ CATCH(errctx, frame(frameno));
+ frameno += 1;
+ if ( (frame_limit > 0) && (frameno >= frame_limit) ) {
+ running = false;
+ }
+ if ( demo == false ) {
+ spent = SDL_GetTicks() - started;
+ if ( spent < JRPG_FRAME_BUDGET_MS ) {
+ SDL_Delay((uint32_t)(JRPG_FRAME_BUDGET_MS - spent));
+ }
+ }
+ }
+ } CLEANUP {
+ teardown();
+ } PROCESS(errctx) {
+ } HANDLE_DEFAULT(errctx) {
+ LOG_ERROR_WITH_MESSAGE(errctx, "the JRPG example could not finish");
+ // Set a flag and act on it after FINISH. A bare `return` from inside a
+ // HANDLE block leaves before RELEASE_ERROR and leaks the context's pool
+ // slot; AGENTS.md spells that out, and an example is a bad place to
+ // teach it wrong.
+ exitstatus = 1;
+ } FINISH_NORETURN(errctx);
+
+ // Enough for the smoke run to be read rather than merely passed. A run that
+ // exits 0 having drawn nothing looks exactly like one that worked.
+ if ( player != NULL ) {
+ printf("jrpg: %ld frames, player at (%.0f, %.0f)\n", frameno, player->x, player->y);
+ } else {
+ printf("jrpg: %ld frames, no player\n", frameno);
+ }
+ return exitstatus;
+}
diff --git a/examples/jrpg/jrpg.h b/examples/jrpg/jrpg.h
new file mode 100644
index 0000000..1c708b0
--- /dev/null
+++ b/examples/jrpg/jrpg.h
@@ -0,0 +1,188 @@
+/**
+ * @file jrpg.h
+ * @brief Shared declarations for the JRPG tutorial game.
+ *
+ * Every function with external linkage in this program is declared here, the
+ * way AGENTS.md requires of the library itself. It is a three-translation-unit
+ * program and it would compile without the header; declaring them anyway is
+ * what keeps a signature from drifting between the definition and the call.
+ *
+ * Everything this program exports carries the `jrpg_` prefix. `static` helpers
+ * drop it, because the prefix exists only to avoid collisions with libakgl,
+ * SDL and libc -- the same rule, for the same reason.
+ */
+
+#ifndef _JRPG_JRPG_H_
+#define _JRPG_JRPG_H_
+
+#include
+#include
+#include
+#include
+#include
+
+/*
+ * Where the game's data lives. Both are absolute paths baked in by
+ * examples/jrpg/CMakeLists.txt, because the tutorial has to run from the build
+ * tree, from the source tree, and from wherever CTest happens to put its
+ * working directory. The fallbacks are what a reader compiling this by hand
+ * from the repository root would want.
+ */
+#ifndef JRPG_ASSET_DIR
+#define JRPG_ASSET_DIR "docs/tutorials/assets/jrpg"
+#endif
+#ifndef JRPG_FONT_FILE
+#define JRPG_FONT_FILE "tests/assets/akgl_test_mono.ttf"
+#endif
+
+/*
+ * The screen size is written as text because that is the only form
+ * akgl_set_property takes, and akgl_render_2d_init copies it onto `akgl_camera`
+ * on the way past. Everything below reads the camera rather than a second copy
+ * of the same two numbers.
+ */
+#define JRPG_SCREEN_WIDTH "320"
+#define JRPG_SCREEN_HEIGHT "240"
+
+/** @brief Registry name of the one font this game loads. */
+#define JRPG_FONT_NAME "jrpg"
+/** @brief Point size the font is rasterized at. A size is baked into the handle. */
+#define JRPG_FONT_SIZE 12
+
+/**
+ * @brief Index of the tile layer this game treats as solid.
+ *
+ * An index, not a name, because akgl_TilemapLayer has no `name` member: the
+ * loader reads a layer's `id`, `type`, `opacity`, `visible`, offset and data,
+ * and drops the name Tiled wrote. A map cannot say "the layer called
+ * collision", so the game and the map agree on a number. See chapter 20.
+ */
+#define JRPG_LAYER_SOLID 1
+
+/** @brief Longest line an NPC can say, terminator included. */
+#define JRPG_TEXTBOX_MAX_TEXT 256
+
+/** @brief Registry name of the party member this game attaches to the player. */
+#define JRPG_FOLLOWER_NAME "companion"
+
+/**
+ * @brief One entry in the scripted demo the headless smoke run drives.
+ *
+ * The keystrokes go in through akgl_controller_handle_event like any other
+ * event, so the smoke run exercises the same binding, state and physics path a
+ * player does rather than a separate one written to be testable.
+ */
+typedef struct {
+ long frame; /**< Frame number this step fires on. */
+ SDL_Keycode key; /**< Key to synthesize. */
+ bool down; /**< True for a press, false for a release. */
+} jrpg_ScriptStep;
+
+/**
+ * @brief Load every sprite, then every character, then the town map.
+ *
+ * In that order, and the order is not a preference: akgl_character_load_json
+ * resolves each sprite name through #AKGL_REGISTRY_SPRITE, and
+ * akgl_tilemap_load resolves each `character` property through
+ * #AKGL_REGISTRY_CHARACTER while it spawns the map's actor objects.
+ *
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ */
+akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_load(void);
+/**
+ * @brief Fix up the actors the map spawned, and attach the party member.
+ *
+ * Clears `movement_controls_face` on everything -- without which the default
+ * `facefunc` erases the facing bit the map set and every NPC stops being drawn
+ * on frame one -- installs the blocking movement logic on the player, and
+ * builds the follower as a child actor.
+ *
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ * @throws AKGL_ERR_REGISTRY If the map spawned no actor named "player".
+ */
+akerr_ErrorContext AKERR_NOIGNORE *jrpg_world_populate(void);
+/**
+ * @brief Centre the camera on the player, clamped to the edges of the map.
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ * @throws AKGL_ERR_REGISTRY If there is no actor named "player".
+ */
+akerr_ErrorContext AKERR_NOIGNORE *jrpg_camera_follow(void);
+/**
+ * @brief The player's `movementlogicfunc`: the library default, plus walls.
+ *
+ * libakgl implements no collision at all -- akgl_physics_arcade_collide raises
+ * AKERR_API, akgl_physics_simulate never calls `collide`, and
+ * akgl_physics_arcade_move consults no tilemap -- so a game that wants a wall
+ * writes one here. Raises AKGL_ERR_LOGICINTERRUPT while the text box is open,
+ * which is the documented way to tell the simulator to skip an actor's tick.
+ *
+ * @param obj The actor to compute acceleration for. Required, along with its
+ * `basechar`.
+ * @param dt Seconds this step covers.
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ * @throws AKERR_NULLPOINTER If @p obj or `obj->basechar` is `NULL`.
+ * @throws AKGL_ERR_LOGICINTERRUPT While a conversation is open.
+ */
+akerr_ErrorContext AKERR_NOIGNORE *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt);
+/**
+ * @brief The follower's `renderfunc`: akgl_actor_render with the parent detached.
+ *
+ * The guard for a defect. akgl_physics_simulate writes a child's `x` as
+ * `parent->x + vx` -- an absolute world position -- and akgl_actor_render then
+ * draws it at `parent->x + obj->x`, adding the parent's position a second time.
+ * Nulling `parent` for the duration of the draw takes the branch that does not
+ * add it. See chapter 20.
+ *
+ * @param obj The child actor to draw. Required.
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ * @throws AKERR_NULLPOINTER If @p obj is `NULL`.
+ */
+akerr_ErrorContext AKERR_NOIGNORE *jrpg_follower_render(akgl_Actor *obj);
+/**
+ * @brief Control handler: talk to whoever is standing nearby, or close the box.
+ * @param obj The actor the control map drives -- the player. Required.
+ * @param event The event that triggered this. Required, but not read.
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ * @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
+ */
+akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_talk(akgl_Actor *obj, SDL_Event *event);
+/**
+ * @brief Control handler that does nothing, for the release half of a binding.
+ *
+ * akgl_controller_handle_event does not check a matched binding's handler
+ * pointer before calling it, so a binding with a `NULL` `handler_off` is a
+ * crash rather than an error. Every binding gets both halves.
+ *
+ * @param obj The actor the control map drives. Required.
+ * @param event The event that triggered this. Required.
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ * @throws AKERR_NULLPOINTER If @p obj or @p event is `NULL`.
+ */
+akerr_ErrorContext AKERR_NOIGNORE *jrpg_cmhf_ignore(akgl_Actor *obj, SDL_Event *event);
+
+/**
+ * @brief Put a line of dialogue on screen and freeze the world.
+ * @param text The line to show. Required. Truncated at #JRPG_TEXTBOX_MAX_TEXT.
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ * @throws AKERR_NULLPOINTER If @p text is `NULL`.
+ */
+akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_open(char *text);
+/** @brief Dismiss the text box. */
+void jrpg_textbox_close(void);
+/** @brief True while a line of dialogue is on screen. */
+bool jrpg_textbox_showing(void);
+/**
+ * @brief Draw the panel and its text, if there is anything to draw.
+ *
+ * Called after akgl_game_update, so it lands on top of the world rather than
+ * under it, and in screen coordinates rather than world ones --
+ * akgl_text_rendertextat does not go through the camera, which is what a HUD
+ * wants.
+ *
+ * @return `NULL` on success -- including when the box is closed -- otherwise an
+ * error context owned by the caller.
+ * @throws AKGL_ERR_REGISTRY If #JRPG_FONT_NAME is not a loaded font.
+ */
+akerr_ErrorContext AKERR_NOIGNORE *jrpg_textbox_draw(void);
+
+#endif // _JRPG_JRPG_H_
diff --git a/examples/jrpg/textbox.c b/examples/jrpg/textbox.c
new file mode 100644
index 0000000..edac8de
--- /dev/null
+++ b/examples/jrpg/textbox.c
@@ -0,0 +1,125 @@
+/**
+ * @file textbox.c
+ * @brief The dialogue panel: two rectangles and a string, drawn over the world.
+ *
+ * Text in libakgl is immediate mode. Every akgl_text_rendertextat call
+ * rasterizes the string through SDL_ttf, uploads it as a texture, blits it and
+ * destroys the texture again -- there is no cached glyph atlas and no text
+ * object to hold on to. That is the wrong shape for a page of static prose
+ * redrawn sixty times a second and exactly the right shape for this: one short
+ * line, on screen only while somebody is talking.
+ */
+
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include "jrpg.h"
+
+/*
+ * Colour R G B A
+ */
+static const SDL_Color TEXTBOX_FILL = { 24, 20, 37, 235 };
+static const SDL_Color TEXTBOX_EDGE = { 240, 236, 214, 255 };
+static const SDL_Color TEXTBOX_INK = { 240, 236, 214, 255 };
+
+/** @brief Pixels between the panel and the edges of the screen. */
+#define TEXTBOX_MARGIN 8.0f
+/** @brief Panel height in pixels. Two lines of 12pt monospace, plus padding. */
+#define TEXTBOX_HEIGHT 56.0f
+/** @brief Pixels between the panel's edge and the text inside it. */
+#define TEXTBOX_PADDING 8.0f
+
+static char textbox_text[JRPG_TEXTBOX_MAX_TEXT];
+static bool textbox_visible = false;
+
+bool jrpg_textbox_showing(void)
+{
+ return textbox_visible;
+}
+
+void jrpg_textbox_close(void)
+{
+ textbox_visible = false;
+}
+
+akerr_ErrorContext *jrpg_textbox_open(char *text)
+{
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "text");
+ // aksl_strncpy, not strncpy: this is a fixed-width field, and strncpy at
+ // exactly the field width leaves it unterminated. Bounded at one less than
+ // the field so an over-long line truncates rather than being refused.
+ PASS(errctx,
+ aksl_strncpy(
+ textbox_text,
+ sizeof(textbox_text),
+ text,
+ sizeof(textbox_text) - 1
+ ));
+ textbox_visible = true;
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *jrpg_textbox_draw(void)
+{
+ PREPARE_ERROR(errctx);
+ TTF_Font *font = NULL;
+ SDL_FRect panel;
+
+ if ( textbox_visible == false ) {
+ SUCCEED_RETURN(errctx);
+ }
+ // akgl_text_rendertextat refuses the empty string -- SDL_ttf reports "Text
+ // has zero width" and the library passes that on as AKERR_NULLPOINTER,
+ // while akgl_text_measure accepts it. The two disagree, so anything drawing
+ // a line that might be empty checks for it. TODO.md, "Known and still
+ // open".
+ if ( textbox_text[0] == '\0' ) {
+ SUCCEED_RETURN(errctx);
+ }
+
+ // Fonts live in a registry under a name, not in an akgl type. There is no
+ // handle to keep, and no reference counting either: whoever calls
+ // akgl_text_unloadfont invalidates every pointer anybody else fetched.
+ // Fetching it per frame is the cheap way to stay honest about that.
+ font = SDL_GetPointerProperty(AKGL_REGISTRY_FONT, JRPG_FONT_NAME, NULL);
+ FAIL_ZERO_RETURN(
+ errctx,
+ font,
+ AKGL_ERR_REGISTRY,
+ "No font registered as \"%s\"",
+ JRPG_FONT_NAME
+ );
+
+ // Screen coordinates, from the camera's size rather than from a second copy
+ // of the window dimensions. akgl_render_2d_init put them there.
+ 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));
+ PASS(errctx,
+ akgl_text_rendertextat(
+ font,
+ textbox_text,
+ TEXTBOX_INK,
+ (int)(panel.w - (2.0f * TEXTBOX_PADDING)),
+ (int)(panel.x + TEXTBOX_PADDING),
+ (int)(panel.y + TEXTBOX_PADDING)
+ ));
+
+ SUCCEED_RETURN(errctx);
+}
diff --git a/examples/jrpg/world.c b/examples/jrpg/world.c
new file mode 100644
index 0000000..8f7edac
--- /dev/null
+++ b/examples/jrpg/world.c
@@ -0,0 +1,458 @@
+/**
+ * @file world.c
+ * @brief The content pipeline: sprites, characters, the town map, and what the
+ * map's actors do once they exist.
+ *
+ * Almost nothing here is game logic. It is the four steps between a directory
+ * of JSON and a world with people standing in it, in the one order that works,
+ * plus the two hooks -- a `movementlogicfunc` and a `renderfunc` -- that a game
+ * has to supply because the library does not.
+ */
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "jrpg.h"
+
+/*
+ * The three character templates and the twenty-four sprites that dress them.
+ * The file names are a product of these three tables rather than a list of
+ * twenty-four strings, because that is what the naming convention *is*: one
+ * sprite per character, per motion, per facing. A missing combination shows up
+ * as a load failure naming the file, not as art that silently never appears.
+ */
+
+/* Character template Sprite name prefix */
+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" };
+
+#define CAST_COUNT (sizeof(CAST) / sizeof(CAST[0]))
+#define MOTION_COUNT (sizeof(MOTIONS) / sizeof(MOTIONS[0]))
+#define FACING_COUNT (sizeof(FACINGS) / sizeof(FACINGS[0]))
+
+/** @brief One person in the town who has something to say. */
+typedef struct {
+ char *actor; /**< Registry key, which is the `name` of the map object that spawned them. */
+ char *line; /**< What they say. */
+} jrpg_Townsfolk;
+
+/*
+ * Actor (the map object's name) What they say
+ */
+static jrpg_Townsfolk TOWNSFOLK[] = {
+ { "elder", "ELDER: The old road north is closed. Nothing to be done about it,\nand nothing beyond it worth the walk." },
+ { "shopkeeper", "SHOPKEEPER: Nothing in stock but tiles and good intentions.\nCome back when I have inventory." }
+};
+
+#define TOWNSFOLK_COUNT (sizeof(TOWNSFOLK) / sizeof(TOWNSFOLK[0]))
+
+/** @brief How close the player has to stand, in pixels between sprite centres. */
+#define TALK_RANGE 64.0f
+
+/*
+ * The player's footprint, in pixels from the top-left of a 32x32 frame. A
+ * character stands on the bottom third of their sprite, so that is the part
+ * that has to fit through a doorway -- testing the whole frame would make every
+ * corridor two tiles wide.
+ *
+ * 0 32
+ * +------------------------------+ 0
+ * | |
+ * | (art) |
+ * | |
+ * | +----------------+ | 20 FEET_TOP
+ * | | footprint | |
+ * +------+----------------+------+ 30 FEET_TOP + FEET_H
+ * 6 26
+ * FEET_X FEET_X + FEET_W
+ */
+#define FEET_X 6.0f
+#define FEET_W 20.0f
+#define FEET_TOP 20.0f
+#define FEET_H 10.0f
+
+/** @brief Where the party member walks, in pixels relative to the player. */
+#define FOLLOWER_OFFSET_X (-14.0f)
+#define FOLLOWER_OFFSET_Y (10.0f)
+
+/**
+ * @brief Load one sprite definition by its three naming components.
+ *
+ * @param cast Character stem, e.g. `"player"`.
+ * @param motion `"idle"` or `"walk"`.
+ * @param facing `"up"`, `"down"`, `"left"` or `"right"`.
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ */
+static akerr_ErrorContext *sprite_load(char *cast, char *motion, char *facing)
+{
+ PREPARE_ERROR(errctx);
+ char path[PATH_MAX];
+ int written = 0;
+
+ FAIL_ZERO_RETURN(errctx, cast, AKERR_NULLPOINTER, "cast");
+ FAIL_ZERO_RETURN(errctx, motion, AKERR_NULLPOINTER, "motion");
+ FAIL_ZERO_RETURN(errctx, facing, AKERR_NULLPOINTER, "facing");
+
+ PASS(errctx,
+ aksl_snprintf(
+ &written,
+ path,
+ sizeof(path),
+ "%s/sprite_jrpg_%s_%s_%s.json",
+ JRPG_ASSET_DIR,
+ cast,
+ motion,
+ facing
+ ));
+ PASS(errctx, akgl_sprite_load_json(path));
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief Load one character definition by its stem.
+ * @param cast Character stem, e.g. `"player"`.
+ * @return `NULL` on success, otherwise an error context owned by the caller.
+ */
+static akerr_ErrorContext *character_load(char *cast)
+{
+ PREPARE_ERROR(errctx);
+ char path[PATH_MAX];
+ int written = 0;
+
+ FAIL_ZERO_RETURN(errctx, cast, AKERR_NULLPOINTER, "cast");
+ PASS(errctx,
+ aksl_snprintf(
+ &written,
+ path,
+ sizeof(path),
+ "%s/character_jrpg_%s.json",
+ JRPG_ASSET_DIR,
+ cast
+ ));
+ PASS(errctx, akgl_character_load_json(path));
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *jrpg_world_load(void)
+{
+ PREPARE_ERROR(errctx);
+ char path[PATH_MAX];
+ int written = 0;
+ size_t c = 0;
+ size_t m = 0;
+ size_t f = 0;
+
+ // Sprites first. A character JSON names its sprites by registry key and
+ // akgl_character_load_json refuses one it cannot find, so loading the two
+ // the other way round fails on every mapping in the file.
+ 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]));
+ }
+
+ // And the map last: loading it creates an actor for every `actor` object in
+ // its object layer, and each of those resolves a character by name.
+ PASS(errctx,
+ aksl_snprintf(&written, path, sizeof(path), "%s/town.tmj", JRPG_ASSET_DIR));
+ PASS(errctx, akgl_tilemap_load(path, akgl_gamemap));
+
+ // The map declares its own physics -- `physics.model` and zero gravity on
+ // both axes -- and akgl_tilemap_load builds a backend for it and sets
+ // use_own_physics. It does not *switch* to it: akgl_game_update simulates
+ // through the global akgl_physics, whatever that points at. Honouring the
+ // map is the caller's job, and this is it.
+ if ( akgl_gamemap->use_own_physics ) {
+ akgl_physics = &akgl_gamemap->physics;
+ }
+
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *jrpg_world_populate(void)
+{
+ PREPARE_ERROR(errctx);
+ akgl_Actor *player = NULL;
+ akgl_Actor *follower = NULL;
+ int i = 0;
+
+ // Every actor the map spawned, including the player.
+ //
+ // akgl_actor_initialize sets movement_controls_face, and the default
+ // facefunc it installs clears every facing bit and then sets the one
+ // matching a *movement* bit. An NPC has no movement bits, so on the first
+ // frame its state falls from ALIVE|FACE_DOWN (17) to ALIVE (16), which is
+ // a state combination no character JSON maps a sprite to -- and an actor
+ // with no sprite for its state is skipped rather than reported. Every NPC
+ // in the town disappears on frame one, silently, and so does the player as
+ // soon as they stop walking.
+ 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;
+ }
+
+ player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
+ FAIL_ZERO_RETURN(
+ errctx,
+ player,
+ AKGL_ERR_REGISTRY,
+ "town.tmj spawned no actor named \"player\""
+ );
+ player->movementlogicfunc = &jrpg_actor_logic_walk;
+
+ // The party member. Nothing in the map creates it, and nothing has to: an
+ // actor is a pool object with a name, and the character it instantiates is
+ // already registered. This one borrows the elder's template, which is the
+ // whole point of splitting instance from template.
+ 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));
+
+ // A child's velocity fields are read as a fixed offset from the parent
+ // rather than as a velocity: akgl_physics_simulate snaps a child to
+ // `parent->x + vx` and never simulates it. Set after addchild, because
+ // addchild is what makes them mean an offset.
+ follower->vx = FOLLOWER_OFFSET_X;
+ follower->vy = FOLLOWER_OFFSET_Y;
+
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief Is the map cell containing this pixel one the player cannot enter?
+ *
+ * Two rules, and both of them are the game's rather than the library's. The
+ * outer ring of the map is solid, because nothing else stops an actor walking
+ * off the edge of the world. Everything else is the decoration layer: a cell
+ * with a tile in it is a building, a tree or a lamp post, and a cell with 0 in
+ * it is open ground.
+ *
+ * @param px Position in map pixels along x.
+ * @param py Position in map pixels along y.
+ * @return True when the cell blocks movement.
+ */
+static bool cell_solid(float32_t px, float32_t py)
+{
+ int tx = 0;
+ int ty = 0;
+
+ tx = (int)(px / (float32_t)akgl_gamemap->tilewidth);
+ ty = (int)(py / (float32_t)akgl_gamemap->tileheight);
+
+ 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);
+}
+
+/**
+ * @brief Would an actor whose frame is at (x, y) have its feet in a wall?
+ *
+ * Tests the four corners of the footprint. Four corners is enough only because
+ * the footprint is smaller than a tile in both directions; a bigger one would
+ * step over a single-cell wall between two of its corners.
+ *
+ * @param x Candidate frame position along x, in map pixels.
+ * @param y Candidate frame position along y, in map pixels.
+ * @return True when any corner of the footprint lands in a solid cell.
+ */
+static bool feet_blocked(float32_t x, float32_t y)
+{
+ float32_t left = x + FEET_X;
+ float32_t right = x + FEET_X + FEET_W;
+ float32_t top = y + FEET_TOP;
+ float32_t bottom = y + FEET_TOP + FEET_H;
+
+ return (cell_solid(left, top) ||
+ cell_solid(right, top) ||
+ cell_solid(left, bottom) ||
+ cell_solid(right, bottom));
+}
+
+akerr_ErrorContext *jrpg_actor_logic_walk(akgl_Actor *obj, float32_t dt)
+{
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, obj->basechar, AKERR_NULLPOINTER, "obj->basechar");
+
+ // A conversation freezes the world. AKGL_ERR_LOGICINTERRUPT raised from a
+ // movementlogicfunc means "skip the rest of this tick for this actor", and
+ // akgl_physics_simulate swallows it in a HANDLE block -- so gravity, drag,
+ // the velocity recompute and the move are all skipped and the frame carries
+ // on. Thrust has already been integrated by the time this runs, so it is
+ // zeroed here; leaving it would let it accumulate while frozen and lurch on
+ // the first step after the box closes. The movement bits go too, so the
+ // walk animation stops rather than marching on the spot -- which does mean
+ // a direction held across the close has to be pressed again.
+ 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
+ );
+ }
+
+ // Everything the default hook does -- re-copy the character's speed limits,
+ // turn the movement bits into signed acceleration -- is still wanted. This
+ // hook adds to it rather than replacing it.
+ PASS(errctx, akgl_actor_logic_movement(obj, dt));
+
+ // Where this step would put us. akgl_physics_simulate has already
+ // integrated thrust for this step and capped it against the character's
+ // speed ellipse, and it computes velocity as `e + t` -- and with the town's
+ // zero gravity and zero drag, `e` stays zero, so `v` is `t`. That is what
+ // makes the prediction exact rather than approximate. A map with gravity
+ // would have to account for `ey` here as well.
+ //
+ // Axis at a time, so walking into a wall diagonally slides along it rather
+ // than stopping dead.
+ 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;
+ }
+
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *jrpg_follower_render(akgl_Actor *obj)
+{
+ PREPARE_ERROR(errctx);
+ akgl_Actor *parent = NULL;
+
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+
+ // The guard. akgl_physics_simulate has already written this actor's x and y
+ // as `parent->x + vx` and `parent->y + vy`, which is an absolute world
+ // position; akgl_actor_render adds the parent's position to it a second
+ // time. Detaching the parent for the duration of the draw takes the branch
+ // that does not. CLEANUP puts it back on every path, including the failing
+ // one -- an actor left with a NULL parent would be simulated as a free
+ // agent on the very next step.
+ parent = obj->parent;
+ obj->parent = NULL;
+ ATTEMPT {
+ CATCH(errctx, akgl_actor_render(obj));
+ } CLEANUP {
+ obj->parent = parent;
+ } PROCESS(errctx) {
+ } FINISH(errctx, true);
+
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *jrpg_camera_follow(void)
+{
+ PREPARE_ERROR(errctx);
+ akgl_Actor *player = NULL;
+ float32_t mapw = 0.0f;
+ float32_t maph = 0.0f;
+
+ player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
+ FAIL_ZERO_RETURN(errctx, player, AKGL_ERR_REGISTRY, "No actor named \"player\"");
+
+ mapw = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth);
+ maph = (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight);
+
+ akgl_camera->x = (player->x + 16.0f) - (akgl_camera->w / 2.0f);
+ akgl_camera->y = (player->y + 16.0f) - (akgl_camera->h / 2.0f);
+
+ if ( akgl_camera->x < 0.0f ) {
+ akgl_camera->x = 0.0f;
+ }
+ if ( akgl_camera->y < 0.0f ) {
+ akgl_camera->y = 0.0f;
+ }
+ if ( akgl_camera->x > (mapw - akgl_camera->w) ) {
+ akgl_camera->x = mapw - akgl_camera->w;
+ }
+ if ( akgl_camera->y > (maph - akgl_camera->h) ) {
+ akgl_camera->y = maph - akgl_camera->h;
+ }
+
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *jrpg_cmhf_talk(akgl_Actor *obj, SDL_Event *event)
+{
+ PREPARE_ERROR(errctx);
+ akgl_Actor *npc = NULL;
+ size_t i = 0;
+ float32_t dx = 0.0f;
+ float32_t dy = 0.0f;
+
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
+
+ if ( jrpg_textbox_showing() ) {
+ jrpg_textbox_close();
+ SUCCEED_RETURN(errctx);
+ }
+
+ for ( i = 0; i < TOWNSFOLK_COUNT; i++ ) {
+ npc = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, TOWNSFOLK[i].actor, NULL);
+ if ( npc == NULL ) {
+ continue;
+ }
+ dx = npc->x - obj->x;
+ dy = npc->y - obj->y;
+ if ( sqrtf((dx * dx) + (dy * dy)) > TALK_RANGE ) {
+ continue;
+ }
+ PASS(errctx, jrpg_textbox_open(TOWNSFOLK[i].line));
+ SUCCEED_RETURN(errctx);
+ }
+
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *jrpg_cmhf_ignore(akgl_Actor *obj, SDL_Event *event)
+{
+ PREPARE_ERROR(errctx);
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
+ SUCCEED_RETURN(errctx);
+}
diff --git a/examples/sidescroller/CMakeLists.txt b/examples/sidescroller/CMakeLists.txt
new file mode 100644
index 0000000..572ea6d
--- /dev/null
+++ b/examples/sidescroller/CMakeLists.txt
@@ -0,0 +1,48 @@
+# The sidescroller tutorial game.
+#
+# A real target, built by default, so docs/19-tutorial-sidescroller.md cannot
+# quote a program that does not compile -- and registered as a headless smoke
+# test, so it cannot quote one that does not run.
+
+get_filename_component(SS_ASSET_DIR
+ "${CMAKE_CURRENT_SOURCE_DIR}/../../docs/tutorials/assets/sidescroller" ABSOLUTE)
+
+add_executable(sidescroller
+ main.c
+ collision.c
+ player.c
+ actors.c
+)
+
+target_include_directories(sidescroller PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
+target_compile_options(sidescroller PRIVATE ${AKGL_WARNING_FLAGS})
+
+# Baked in rather than looked up at runtime, so the smoke test can be launched
+# from any working directory. `--assets DIR` overrides it for a reader who has
+# moved the art somewhere else.
+target_compile_definitions(sidescroller PRIVATE "SS_ASSET_DIR=\"${SS_ASSET_DIR}\"")
+
+target_link_libraries(sidescroller
+ PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf
+ SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
+
+# A vendored build leaves the SDL satellite libraries in per-project
+# subdirectories that are not on the loader's default search path, which is the
+# same problem AKGL_VENDORED_RPATH solves for the test executables.
+if(AKGL_VENDORED_DEPENDENCIES)
+ set_target_properties(sidescroller PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
+endif()
+
+# Four seconds of scripted play under the headless drivers: the level loads, the
+# player walks and jumps, the swept collision runs against real terrain, and the
+# program tears down and exits 0. A tutorial that stops working fails here rather
+# than in front of a reader.
+#
+# add_test() rather than _add_test(): the root CMakeLists.txt shadows it only
+# while this project is top-level, and by the time examples/ is added the
+# suppression has already been lifted. An embedded build gets the builtin.
+add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay)
+set_tests_properties(example_sidescroller PROPERTIES
+ TIMEOUT 120
+ ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
+)
diff --git a/examples/sidescroller/actors.c b/examples/sidescroller/actors.c
new file mode 100644
index 0000000..e0a19b1
--- /dev/null
+++ b/examples/sidescroller/actors.c
@@ -0,0 +1,183 @@
+/**
+ * @file actors.c
+ * @brief Everything level1.tmj places that is not the player.
+ *
+ * None of these are created here. The map's object layer creates all seven
+ * actors, registers them under the names Tiled gave them, and binds each one to
+ * the character its `character` property names -- so this file's whole job is to
+ * find them again by name and give them behaviour.
+ *
+ * Two of the three behaviours raise #AKGL_ERR_LOGICINTERRUPT, which is the one
+ * status in libakgl that is not a failure. Raised from a `movementlogicfunc` it
+ * means "skip the rest of this tick for me": no gravity, no drag, no move for
+ * that actor this step, and akgl_physics_simulate carries on to the next one.
+ * That is exactly what an actor that has just placed itself wants.
+ */
+
+#include
+
+#include
+
+#include
+#include
+
+#include "sidescroller.h"
+
+/** @brief The blob's collision box, offset into its 32x32 frame. */
+static SDL_FRect ss_blob_body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };
+
+/**
+ * @brief Per-actor data for everything in this file, one slot per actor.
+ *
+ * A fixed table rather than an allocation, for the same reason the library has
+ * pools rather than a `malloc`: the level places a known number of things, and a
+ * game that cannot run out of memory at runtime is one fewer failure mode.
+ */
+static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT];
+
+/**
+ * @brief Look an actor up by the name its Tiled object carried.
+ *
+ * A miss here means the map and the code disagree about what the level
+ * contains, which is worth failing on rather than working around.
+ */
+static akerr_ErrorContext *find_actor(char *name, akgl_Actor **dest)
+{
+ PREPARE_ERROR(errctx);
+ FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
+ FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
+
+ *dest = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, name, NULL);
+ FAIL_ZERO_RETURN(errctx, *dest, AKERR_KEY, "The map placed no actor called %s", name);
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief A `movementlogicfunc` for something that does not move at all.
+ *
+ * The coins need this. Their character declares no speed and no acceleration, so
+ * they cannot thrust -- but gravity is not thrust. `akgl_physics_arcade_gravity`
+ * accumulates into `ey` for every actor it is handed, so a coin left to the
+ * default logic falls out of the level along with everything else.
+ *
+ * Raising #AKGL_ERR_LOGICINTERRUPT is how an actor opts out of the rest of its
+ * step. It is not an error and nothing logs it.
+ */
+static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt)
+{
+ PREPARE_ERROR(errctx);
+ (void)dt;
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s does not simulate", (char *)obj->name);
+}
+
+/**
+ * @brief The blob: walk, and turn around at a wall or a ledge.
+ *
+ * This one stays inside the physics step -- it walks on the ground under the
+ * map's gravity like the player does, and uses the same swept resolution.
+ */
+static akerr_ErrorContext *ss_blob_movement(akgl_Actor *obj, float32_t dt)
+{
+ ss_ActorData *data = NULL;
+ ss_Contact contact;
+ float32_t probe_x = 0.0f;
+ float32_t probe_y = 0.0f;
+ bool floor_ahead = false;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
+ data = (ss_ActorData *)obj->actorData;
+
+ AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL));
+ if ( data->facing < 0.0f ) {
+ AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_MOVING_LEFT));
+ } else {
+ AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_MOVING_RIGHT));
+ }
+ /* Signs the acceleration from the movement bits just set. */
+ PASS(errctx, akgl_actor_logic_movement(obj, dt));
+
+ PASS(errctx, ss_collide_resolve(obj, &ss_blob_body, dt, &contact));
+
+ /* One pixel past the leading edge of the box and one pixel below its feet:
+ * no floor there means the next step walks off. */
+ probe_y = obj->y + ss_blob_body.y + ss_blob_body.h + 1.0f;
+ if ( data->facing < 0.0f ) {
+ probe_x = obj->x + ss_blob_body.x - 1.0f;
+ } else {
+ probe_x = obj->x + ss_blob_body.x + ss_blob_body.w + 1.0f;
+ }
+ PASS(errctx, ss_collide_solid_at(probe_x, probe_y, &floor_ahead));
+
+ if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
+ data->facing = -data->facing;
+ /* The resolution already zeroed `tx` on a blocked axis; zero it on the
+ * ledge path too, or the blob keeps its old momentum through the turn. */
+ obj->tx = 0.0f;
+ }
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief The moth: a figure-eight around where the map put it.
+ *
+ * It flies, so it wants no gravity -- and the map's physics has gravity, because
+ * the player needs it. Rather than fighting the backend the moth writes its own
+ * position and then opts out of the rest of the step, which is the second thing
+ * #AKGL_ERR_LOGICINTERRUPT is for.
+ */
+static akerr_ErrorContext *ss_moth_movement(akgl_Actor *obj, float32_t dt)
+{
+ ss_ActorData *data = NULL;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
+ data = (ss_ActorData *)obj->actorData;
+
+ data->phase += dt;
+ obj->x = data->home_x + (sinf(data->phase) * 64.0f);
+ obj->y = data->home_y + (sinf(data->phase * 2.0f) * 24.0f);
+
+ AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_FACE_ALL);
+ if ( cosf(data->phase) < 0.0f ) {
+ AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_LEFT);
+ } else {
+ AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_FACE_RIGHT);
+ }
+
+ FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s flies itself", (char *)obj->name);
+}
+
+akerr_ErrorContext *ss_actors_bind(void)
+{
+ char name[32];
+ int count = 0;
+ int i = 0;
+ PREPARE_ERROR(errctx);
+
+ for ( i = 0; i < SS_COIN_COUNT; i++ ) {
+ PASS(errctx, aksl_snprintf(&count, (char *)&name, sizeof(name), "coin%d", (i + 1)));
+ PASS(errctx, find_actor((char *)&name, &ss_game.coins[i]));
+ ss_game.coins[i]->movementlogicfunc = &ss_static_movement;
+ }
+
+ PASS(errctx, find_actor("blob1", &ss_game.hazards[0]));
+ PASS(errctx, ss_collide_settle(ss_game.hazards[0], &ss_blob_body));
+ ss_hazard_data[0].home_x = ss_game.hazards[0]->x;
+ ss_hazard_data[0].home_y = ss_game.hazards[0]->y;
+ ss_hazard_data[0].facing = -1.0f;
+ ss_game.hazards[0]->actorData = (void *)&ss_hazard_data[0];
+ ss_game.hazards[0]->movementlogicfunc = &ss_blob_movement;
+
+ PASS(errctx, find_actor("moth1", &ss_game.hazards[1]));
+ ss_hazard_data[1].home_x = ss_game.hazards[1]->x;
+ ss_hazard_data[1].home_y = ss_game.hazards[1]->y;
+ ss_hazard_data[1].facing = 1.0f;
+ ss_game.hazards[1]->actorData = (void *)&ss_hazard_data[1];
+ ss_game.hazards[1]->movementlogicfunc = &ss_moth_movement;
+
+ SUCCEED_RETURN(errctx);
+}
diff --git a/examples/sidescroller/collision.c b/examples/sidescroller/collision.c
new file mode 100644
index 0000000..08dafe1
--- /dev/null
+++ b/examples/sidescroller/collision.c
@@ -0,0 +1,383 @@
+/**
+ * @file collision.c
+ * @brief The collision libakgl does not have.
+ *
+ * This file exists because of a gap, and the gap is worth stating exactly:
+ *
+ * - `akgl_physics_arcade_collide` raises `AKERR_API` with the message "Not
+ * implemented".
+ * - `akgl_physics_simulate` never calls `collide` at all -- not for the arcade
+ * backend, not for the null one. The vtable slot exists and the simulation
+ * does not use it.
+ * - `akgl_physics_arcade_move` is `position += velocity * dt` and nothing else.
+ * It does not clamp to the map, consult the tilemap, or test anything.
+ *
+ * So an actor walks through a wall and off the edge of the world, and the only
+ * hook that runs inside the physics step is the actor's own `movementlogicfunc`.
+ * That is where this game's collision lives, and everything here is called from
+ * there.
+ *
+ * The awkward part is the ordering. `movementlogicfunc` runs *before* gravity,
+ * drag, the velocity recomputation and `move`, so it cannot look at where the
+ * actor ended up -- the step has not happened yet. ss_collide_predict therefore
+ * repeats the arithmetic akgl_physics_simulate is about to do, and the sweep
+ * resolves against that predicted position. Get the prediction wrong and the
+ * actor is resolved against a step it never takes.
+ */
+
+#include
+
+#include
+
+#include
+#include
+
+#include "sidescroller.h"
+
+/**
+ * @brief Half a pixel-thousandth, taken off the far edge of a box before it is
+ * turned into tile indices.
+ *
+ * A box whose right edge sits exactly on a tile boundary does not overlap the
+ * tile on the far side of it. Without this, an actor standing flush against a
+ * wall reads as inside it, and the sweep snaps it back a tile every frame.
+ */
+#define SS_COLLIDE_EPSILON 0.001f
+
+/** @brief Longest sub-step the sweep takes, in pixels. Half a tile cannot tunnel. */
+#define SS_COLLIDE_SUBSTEP (SS_TILE_SIZE / 2.0f)
+
+/** @brief Tiles ss_collide_settle will lift a spawn point before giving up. */
+#define SS_COLLIDE_SETTLE_TILES 4
+
+static akgl_TilemapLayer *ss_terrain = NULL;
+
+/**
+ * @brief Is this map cell solid?
+ *
+ * Off the left or right edge of the map is solid, so the level has walls at its
+ * ends. Off the top or the bottom is not: the sky is open and the pit in the
+ * middle of level1.tmj has to be fallable-into, which is the whole point of it.
+ */
+static akerr_ErrorContext *solid_tile(int tilex, int tiley, bool *dest)
+{
+ PREPARE_ERROR(errctx);
+ FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
+ FAIL_ZERO_RETURN(errctx, ss_terrain, AKERR_NULLPOINTER, "ss_collide_bind has not run");
+
+ if ( (tilex < 0) || (tilex >= ss_terrain->width) ) {
+ *dest = true;
+ } else if ( (tiley < 0) || (tiley >= ss_terrain->height) ) {
+ *dest = false;
+ } else {
+ /* A tile layer's data is global tile ids in row-major order, and 0 is
+ * the empty cell. Any tile at all on the terrain layer is solid: the
+ * level says what is solid by which layer the tile is drawn on. */
+ *dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
+ }
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *ss_collide_bind(akgl_Tilemap *map)
+{
+ int i = 0;
+ PREPARE_ERROR(errctx);
+ FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "map");
+
+ ss_terrain = NULL;
+ for ( i = 0; i < map->numlayers; i++ ) {
+ if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) &&
+ (map->layers[i].id == SS_TERRAIN_LAYER_ID) ) {
+ ss_terrain = &map->layers[i];
+ }
+ }
+ FAIL_ZERO_RETURN(
+ errctx,
+ ss_terrain,
+ AKERR_KEY,
+ "Map has no tile layer with id %d to collide against",
+ SS_TERRAIN_LAYER_ID
+ );
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *ss_collide_solid_at(float32_t x, float32_t y, bool *dest)
+{
+ PREPARE_ERROR(errctx);
+ FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
+ PASS(errctx, solid_tile((int)floorf(x / SS_TILE_SIZE), (int)floorf(y / SS_TILE_SIZE), dest));
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *ss_collide_box_blocked(SDL_FRect *box, bool *dest)
+{
+ int x0 = 0;
+ int x1 = 0;
+ int y0 = 0;
+ int y1 = 0;
+ int tilex = 0;
+ int tiley = 0;
+ bool solid = false;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
+ FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
+ FAIL_NONZERO_RETURN(
+ errctx,
+ ((box->w <= 0.0f) || (box->h <= 0.0f)),
+ AKERR_VALUE,
+ "Collision box is %fx%f; both sides must be positive",
+ box->w,
+ box->h
+ );
+
+ *dest = false;
+ x0 = (int)floorf(box->x / SS_TILE_SIZE);
+ x1 = (int)floorf((box->x + box->w - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
+ y0 = (int)floorf(box->y / SS_TILE_SIZE);
+ y1 = (int)floorf((box->y + box->h - SS_COLLIDE_EPSILON) / SS_TILE_SIZE);
+
+ for ( tiley = y0; tiley <= y1; tiley++ ) {
+ for ( tilex = x0; tilex <= x1; tilex++ ) {
+ PASS(errctx, solid_tile(tilex, tiley, &solid));
+ if ( solid == true ) {
+ *dest = true;
+ SUCCEED_RETURN(errctx);
+ }
+ }
+ }
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy)
+{
+ float32_t ex = 0.0f;
+ float32_t ey = 0.0f;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, dx, AKERR_NULLPOINTER, "dx");
+ FAIL_ZERO_RETURN(errctx, dy, AKERR_NULLPOINTER, "dy");
+ FAIL_ZERO_RETURN(errctx, akgl_physics, AKERR_NULLPOINTER, "akgl_physics");
+
+ /*
+ * Everything below is akgl_physics_simulate's own arithmetic, in its own
+ * order: gravity onto the environmental term, then drag off it, then
+ * velocity as environmental plus thrust, then position plus velocity times
+ * dt. The `!= 0` guards are the library's too -- it skips each axis whose
+ * constant is zero rather than multiplying by it -- and are repeated here
+ * because dropping them would make a zero-gravity axis pick up drag.
+ *
+ * This mirrors the *arcade* backend. Against the null backend gravity does
+ * nothing, so the prediction reduces to `(ex + tx) * dt`, which is still
+ * right.
+ */
+ ex = obj->ex;
+ ey = obj->ey;
+ if ( akgl_physics->gravity_x != 0 ) {
+ ex -= (float32_t)akgl_physics->gravity_x * dt;
+ }
+ if ( akgl_physics->gravity_y != 0 ) {
+ ey += (float32_t)akgl_physics->gravity_y * dt;
+ }
+ if ( akgl_physics->drag_x != 0 ) {
+ ex -= ex * (float32_t)akgl_physics->drag_x * dt;
+ }
+ if ( akgl_physics->drag_y != 0 ) {
+ ey -= ey * (float32_t)akgl_physics->drag_y * dt;
+ }
+ *dx = (ex + obj->tx) * dt;
+ *dy = (ey + obj->ty) * dt;
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief Walk @p box along (@p dx, @p dy) in sub-steps, stopping it at terrain.
+ *
+ * Each axis is tested on its own, which is what lets an actor slide along a wall
+ * instead of sticking to it, and the sub-step is capped at half a tile so a
+ * fast fall cannot pass through a floor between two samples. At 900 px/s^2 with
+ * the step bounded to `physics.max_timestep` (0.05 s) a fall covers 30 px in one
+ * step, which is nearly two tiles -- so this is not a theoretical concern.
+ *
+ * On a blocked axis the box is snapped to the tile boundary it was about to
+ * cross rather than simply not moved, so an actor lands flush on a floor at
+ * whatever speed it arrives.
+ */
+static akerr_ErrorContext *sweep(SDL_FRect *box, float32_t dx, float32_t dy, ss_Contact *dest)
+{
+ SDL_FRect trial;
+ float32_t span = 0.0f;
+ float32_t stepx = 0.0f;
+ float32_t stepy = 0.0f;
+ int steps = 0;
+ int i = 0;
+ bool solid = false;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, box, AKERR_NULLPOINTER, "box");
+ FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
+
+ span = fabsf(dx);
+ if ( fabsf(dy) > span ) {
+ span = fabsf(dy);
+ }
+ steps = (int)(span / SS_COLLIDE_SUBSTEP) + 1;
+ stepx = dx / (float32_t)steps;
+ stepy = dy / (float32_t)steps;
+
+ for ( i = 0; i < steps; i++ ) {
+ if ( stepx != 0.0f ) {
+ trial = *box;
+ trial.x += stepx;
+ PASS(errctx, ss_collide_box_blocked(&trial, &solid));
+ if ( solid == true ) {
+ if ( stepx > 0.0f ) {
+ box->x = (floorf((trial.x + trial.w) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.w;
+ } else {
+ box->x = (floorf(trial.x / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
+ }
+ stepx = 0.0f;
+ dest->blocked_x = true;
+ } else {
+ box->x = trial.x;
+ }
+ }
+ if ( stepy != 0.0f ) {
+ trial = *box;
+ trial.y += stepy;
+ PASS(errctx, ss_collide_box_blocked(&trial, &solid));
+ if ( solid == true ) {
+ if ( stepy > 0.0f ) {
+ box->y = (floorf((trial.y + trial.h) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.h;
+ } else {
+ box->y = (floorf(trial.y / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
+ }
+ stepy = 0.0f;
+ dest->blocked_y = true;
+ } else {
+ box->y = trial.y;
+ }
+ }
+ if ( (stepx == 0.0f) && (stepy == 0.0f) ) {
+ break;
+ }
+ }
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest)
+{
+ SDL_FRect box;
+ SDL_FRect probe;
+ float32_t dx = 0.0f;
+ float32_t dy = 0.0f;
+ bool solid = false;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
+ FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
+
+ dest->blocked_x = false;
+ dest->blocked_y = false;
+ dest->grounded = false;
+
+ PASS(errctx, ss_collide_predict(obj, dt, &dx, &dy));
+
+ box.x = obj->x + body->x;
+ box.y = obj->y + body->y;
+ box.w = body->w;
+ box.h = body->h;
+ PASS(errctx, sweep(&box, dx, dy, dest));
+
+ /*
+ * Only a blocked axis is written back. The free axis is left for
+ * akgl_physics_arcade_move to advance by exactly the amount this function
+ * predicted -- writing it here as well would move the actor twice.
+ *
+ * `ex`/`ey` are where gravity accumulates and `tx`/`ty` are the actor's own
+ * effort; a blocked axis has to give up both, or the actor keeps pressing
+ * into the wall and the next step's prediction is wrong by everything it
+ * accumulated while stuck.
+ *
+ * The environmental term is not set to zero, it is set to *minus one step of
+ * gravity*, and that is not a nicety. Zeroing it leaves the step about to add
+ * `gravity_y * dt` back, which commits `gravity_y * dt^2` of fall -- a
+ * quarter of a pixel at 60 Hz. A quarter of a pixel is invisible and it is
+ * still fatal: the box now overlaps the floor tile, so the *horizontal*
+ * sweep on the next step finds itself blocked wherever it is, and the actor
+ * is snapped back a whole tile every time it tries to walk. Pre-loading the
+ * cancellation leaves the actor resting exactly on the surface instead.
+ */
+ if ( dest->blocked_x == true ) {
+ obj->x = box.x - body->x;
+ obj->ex = (float32_t)akgl_physics->gravity_x * dt;
+ obj->tx = 0.0f;
+ }
+ if ( dest->blocked_y == true ) {
+ obj->y = box.y - body->y;
+ obj->ey = -(float32_t)akgl_physics->gravity_y * dt;
+ obj->ty = 0.0f;
+ }
+
+ /*
+ * Standing on a floor is not a state the library records, so it is measured:
+ * one pixel below where the box will be at the end of this step. Note that
+ * zeroing `ey` above does not stop gravity -- the step still adds
+ * `gravity_y * dt` to it and `move` still commits `gravity_y * dt^2` of
+ * fall, which is a quarter of a pixel at 60 Hz. The next step's sweep takes
+ * it straight back off, so a standing actor rests within a pixel of the
+ * surface forever rather than sinking through it.
+ */
+ probe = box;
+ probe.y += 1.0f;
+ PASS(errctx, ss_collide_box_blocked(&probe, &solid));
+ dest->grounded = solid;
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body)
+{
+ SDL_FRect box;
+ bool solid = false;
+ int i = 0;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, body, AKERR_NULLPOINTER, "body");
+
+ /*
+ * A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, so an
+ * actor can start out with its box partly inside a step -- level1.tmj puts
+ * the player at x=32 and a block at tiles (3,11), which is under the right
+ * half of the player's frame.
+ *
+ * The swept resolution cannot fix that. It stops an actor *entering*
+ * terrain and has nothing to say about one that began inside it; what it
+ * does instead is refuse every horizontal move, because the box is already
+ * blocked wherever it goes. So a spawn point gets lifted clear once, before
+ * the first step, a tile at a time.
+ */
+ for ( i = 0; i < SS_COLLIDE_SETTLE_TILES; i++ ) {
+ box.x = obj->x + body->x;
+ box.y = obj->y + body->y;
+ box.w = body->w;
+ box.h = body->h;
+ PASS(errctx, ss_collide_box_blocked(&box, &solid));
+ if ( solid == false ) {
+ SUCCEED_RETURN(errctx);
+ }
+ obj->y -= (float32_t)SS_TILE_SIZE;
+ SDL_Log("Lifted %s out of the terrain it spawned in, to y=%f", (char *)obj->name, obj->y);
+ }
+ FAIL_RETURN(
+ errctx,
+ AKERR_VALUE,
+ "%s spawned inside more than %d tiles of terrain at %f, %f",
+ (char *)obj->name,
+ SS_COLLIDE_SETTLE_TILES,
+ obj->x,
+ obj->y
+ );
+}
diff --git a/examples/sidescroller/main.c b/examples/sidescroller/main.c
new file mode 100644
index 0000000..13edd0b
--- /dev/null
+++ b/examples/sidescroller/main.c
@@ -0,0 +1,456 @@
+/**
+ * @file main.c
+ * @brief Startup, the frame loop, and teardown for the sidescroller tutorial.
+ *
+ * The order everything happens in is the point of this file. libakgl has one
+ * startup sequence that works, documented at the top of `include/akgl/game.h`,
+ * and three of its steps are ones a reader gets wrong the first time:
+ *
+ * - the configuration properties have to be set *before* the renderer and the
+ * physics backend are initialized, because both read them;
+ * - `akgl_game_init` does **not** choose a physics backend, so the application
+ * has to;
+ * - characters name sprites and maps name characters, so the assets load
+ * sprites, then characters, then the map -- and the map creates the actors.
+ */
+
+#include
+
+#include
+#include
+#include
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "sidescroller.h"
+
+/** @brief Where the tutorial assets live. CMake defines it; `--assets` overrides it. */
+#ifndef SS_ASSET_DIR
+#define SS_ASSET_DIR "."
+#endif
+
+/** @brief Longest asset path this program will build. */
+#define SS_PATH_MAX 1024
+
+ss_Game ss_game;
+
+/**
+ * @brief The sprites, loaded in this order.
+ *
+ * Sprites first and characters second is not a preference. A character's JSON
+ * names its sprites by registry name and `akgl_character_load_json` looks each
+ * one up as it reads the mapping, so a character loaded first fails with
+ * `AKERR_NULLPOINTER` on the first sprite it cannot find.
+ */
+static char *ss_sprite_files[] = {
+ "sprite_ss_player_idle_left.json", /* one frame, held */
+ "sprite_ss_player_idle_right.json",
+ "sprite_ss_player_run_left.json", /* four frames at 90 ms */
+ "sprite_ss_player_run_right.json",
+ "sprite_ss_player_jump_left.json", /* one frame, held for the whole arc */
+ "sprite_ss_player_jump_right.json",
+ "sprite_ss_coin.json",
+ "sprite_ss_hazard_blob.json",
+ "sprite_ss_hazard_moth.json",
+ NULL
+};
+
+/** @brief The characters. Each one binds state bitmasks to the sprites above. */
+static char *ss_character_files[] = {
+ "character_ss_player.json",
+ "character_ss_coin.json",
+ "character_ss_hazard_blob.json",
+ "character_ss_hazard_moth.json",
+ NULL
+};
+
+/** @brief Set in HANDLE_DEFAULT and read after FINISH; see the note in main. */
+static int ss_failed = 0;
+
+/**
+ * @brief Replacement for `akgl_game.lowfpsfunc`, which logs a line per frame.
+ *
+ * The default is `akgl_game_lowfps`, and it fires on **every frame** the frame
+ * rate is under 30 -- including every frame of the first second of the process,
+ * because `akgl_game.fps` is a completed-second average and reads 0 until the
+ * first second is up. The hook exists to be replaced; the point of it is that a
+ * game can shed work rather than log about it. This one has nothing to shed.
+ */
+static void ss_lowfps(void)
+{
+}
+
+/**
+ * @brief Join the asset directory and a file name into @p dest.
+ *
+ * `aksl_snprintf` rather than `snprintf`, because a path that does not fit has
+ * to arrive as `AKERR_OUTOFBOUNDS` naming both lengths. Truncated silently, it
+ * reports itself later as a missing file with a name nobody wrote.
+ */
+static akerr_ErrorContext *asset_path(char *dir, char *name, char *dest, size_t size)
+{
+ int count = 0;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "dir");
+ FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
+ FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
+ PASS(errctx, aksl_snprintf(&count, dest, size, "%s/%s", dir, name));
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief Bring the library up and open the window.
+ *
+ * `akgl_game.name`, `.version` and `.uri` are filled in first because
+ * `akgl_game_init` refuses to run without all three: the window title, SDL's
+ * application metadata and the savegame compatibility check are built from them.
+ */
+static akerr_ErrorContext *startup(void)
+{
+ PREPARE_ERROR(errctx);
+
+ PASS(errctx, aksl_strncpy(
+ (char *)&akgl_game.name,
+ sizeof(akgl_game.name),
+ "libakgl sidescroller 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.libakgl.sidescroller",
+ sizeof(akgl_game.uri) - 1));
+
+ PASS(errctx, akgl_game_init());
+ akgl_game.lowfpsfunc = &ss_lowfps;
+
+ /* Properties before the renderer: akgl_render_2d_init reads both of these
+ * out of the registry, and an unset one defaults to the string "0", which
+ * asks SDL for a zero-sized window. */
+ PASS(errctx, akgl_set_property("game.screenwidth", "960"));
+ PASS(errctx, akgl_set_property("game.screenheight", "480"));
+ PASS(errctx, akgl_render_2d_init(akgl_renderer));
+
+ /*
+ * libakgl draws in map pixels and has no scale factor of its own -- the only
+ * scaling it applies is the tilemap's perspective band, which this map does
+ * not use. So a 16-pixel tile is 16 screen pixels, and pixel art wants more
+ * than that. SDL's logical presentation is the answer: the game renders a
+ * 480x240 view and SDL scales it up by whole multiples to fill the window.
+ */
+ FAIL_ZERO_RETURN(
+ errctx,
+ SDL_SetRenderLogicalPresentation(
+ akgl_renderer->sdl_renderer,
+ SS_VIEW_WIDTH,
+ SS_VIEW_HEIGHT,
+ SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
+ AKGL_ERR_SDL,
+ "%s",
+ SDL_GetError()
+ );
+
+ /* akgl_render_2d_init sized the camera from the window. The view is what the
+ * camera looks through, so it has to say the same thing. */
+ akgl_camera->x = 0.0f;
+ akgl_camera->y = 0.0f;
+ akgl_camera->w = (float32_t)SS_VIEW_WIDTH;
+ akgl_camera->h = (float32_t)SS_VIEW_HEIGHT;
+
+ /*
+ * akgl_game_init does NOT choose a physics backend. It points akgl_physics
+ * at akgl_default_physics, which is zeroed storage -- all four of its method
+ * pointers are NULL -- and never initializes it. There is no
+ * `physics.engine` property either, whatever physics.h says. Skip this call
+ * and the first akgl_game_update calls through a NULL `simulate`.
+ *
+ * The map replaces this backend below with one of its own. It is still done
+ * here, because a game that loads a map without physics properties gets a
+ * working backend rather than a crash.
+ */
+ PASS(errctx, akgl_physics_init_arcade(akgl_physics));
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief Load the sprites, the characters and the level, in that order.
+ */
+static akerr_ErrorContext *load_level(char *assetdir)
+{
+ char path[SS_PATH_MAX];
+ akgl_Actor *player = NULL;
+ int i = 0;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
+
+ for ( i = 0; ss_sprite_files[i] != NULL; i++ ) {
+ PASS(errctx, asset_path(assetdir, ss_sprite_files[i], (char *)&path, sizeof(path)));
+ PASS(errctx, akgl_sprite_load_json((char *)&path));
+ }
+ for ( i = 0; ss_character_files[i] != NULL; i++ ) {
+ PASS(errctx, asset_path(assetdir, ss_character_files[i], (char *)&path, sizeof(path)));
+ PASS(errctx, akgl_character_load_json((char *)&path));
+ }
+
+ /*
+ * `akgl_gamemap` already points at `akgl_default_gamemap`, 25 MiB of static
+ * storage in the library. That is not an accident and it is not a
+ * micro-optimisation: sizeof(akgl_Tilemap) is three times a default 8 MiB
+ * thread stack, so a local one is a segfault before the loader writes a
+ * byte.
+ *
+ * Loading the map creates every `actor` object in its object layers, binds
+ * each to the character its properties name, and publishes it in
+ * AKGL_REGISTRY_ACTOR -- which is why the characters had to be loaded first.
+ */
+ PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path)));
+ PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap));
+
+ /*
+ * The map carried `physics.model`, `physics.gravity.y` and `physics.drag.y`,
+ * so the loader built a backend of its own and set `use_own_physics`.
+ * Nothing in the library acts on that flag: akgl_game_update steps the
+ * global akgl_physics and never looks at the map's. Honouring it is this
+ * line, and it is what lets a swimming level and a walking level differ by
+ * data rather than by code.
+ */
+ if ( akgl_gamemap->use_own_physics == true ) {
+ akgl_physics = &akgl_gamemap->physics;
+ SDL_Log(
+ "Using the map's own physics: gravity %.1f, drag %.1f, terminal fall %.1f px/s",
+ akgl_physics->gravity_y,
+ akgl_physics->drag_y,
+ (akgl_physics->gravity_y / akgl_physics->drag_y));
+ }
+
+ PASS(errctx, ss_collide_bind(akgl_gamemap));
+
+ player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
+ FAIL_ZERO_RETURN(errctx, player, AKERR_KEY, "The map placed no actor called player");
+ PASS(errctx, ss_player_bind(player));
+ PASS(errctx, ss_actors_bind());
+ PASS(errctx, ss_player_controls(0, "player"));
+
+ /*
+ * Re-stamp the clock the simulation measures against. Everything above --
+ * nine sprite files, four characters, a map and its tileset image -- happened
+ * between the backend being created and the first step, and dt is measured
+ * from `gravity_time`. The bound on `physics.max_timestep` would catch it,
+ * at the cost of one visibly slow-motion frame.
+ */
+ akgl_physics->gravity_time = SDL_GetTicksNS();
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief Centre the camera on the player, clamped to the level.
+ *
+ * The camera is a plain `SDL_FRect` in map pixels that the library reads; moving
+ * it is the whole of scrolling. It is floored to a whole pixel because
+ * `akgl_tilemap_draw` truncates it when it works out how much of the edge tiles
+ * to show, and a camera that is fractionally different every frame makes the
+ * tile grid shimmer.
+ */
+static akerr_ErrorContext *update_camera(void)
+{
+ float32_t limit = 0.0f;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player");
+ FAIL_ZERO_RETURN(errctx, akgl_camera, AKERR_NULLPOINTER, "akgl_camera");
+
+ limit = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) - akgl_camera->w;
+ akgl_camera->x = (ss_game.player->x + 16.0f) - (akgl_camera->w / 2.0f);
+ if ( akgl_camera->x > limit ) {
+ akgl_camera->x = limit;
+ }
+ if ( akgl_camera->x < 0.0f ) {
+ akgl_camera->x = 0.0f;
+ }
+ akgl_camera->x = (float32_t)((int)akgl_camera->x);
+ akgl_camera->y = 0.0f;
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief One frame: events, then the camera, then the library's own tick.
+ *
+ * `akgl_game_update` is update-every-actor, step-the-physics, draw-the-world. It
+ * does not clear or present, so the frame is bracketed by the backend's
+ * `frame_start` and `frame_end` here.
+ */
+static akerr_ErrorContext *frame(bool *running)
+{
+ SDL_Event event;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
+
+ while ( SDL_PollEvent(&event) == true ) {
+ if ( event.type == SDL_EVENT_QUIT ) {
+ *running = false;
+ }
+ /* Every event, unconditionally: one that no control map binds is not an
+ * error, it is a call that did nothing. */
+ PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
+ }
+
+ ss_game.frame += 1;
+ if ( ss_game.autoplay == true ) {
+ PASS(errctx, ss_player_autoplay(ss_game.frame));
+ }
+ PASS(errctx, update_camera());
+
+ PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
+ /*
+ * Note the failure contract: akgl_game_update takes the game state lock and
+ * every one of its failure paths returns with the lock still held. SDL
+ * mutexes are recursive so a single-threaded loop does not deadlock on the
+ * next frame, but a frame that failed has left the world half-stepped.
+ * Treat it as terminal, which is what PASS does here.
+ */
+ PASS(errctx, akgl_game_update(NULL));
+ PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
+ SUCCEED_RETURN(errctx);
+}
+
+static akerr_ErrorContext *run(int frames)
+{
+ bool running = true;
+ PREPARE_ERROR(errctx);
+
+ while ( running == true ) {
+ PASS(errctx, frame(&running));
+ if ( (frames > 0) && (ss_game.frame >= frames) ) {
+ running = false;
+ }
+ /* A crude frame limiter. A game with a window on a real display should
+ * ask SDL for vsync instead; this one has to work under the dummy video
+ * driver, where there is nothing to sync to. */
+ SDL_Delay(16);
+ }
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief Give back what the process is holding.
+ *
+ * **There is no `akgl_game_shutdown`.** Teardown is the application's, and the
+ * order matters in one place: `akgl_text_unloadallfonts` has to run before
+ * `TTF_Quit` or `SDL_Quit`, because those destroy the fonts underneath the
+ * registry that still points at them.
+ *
+ * The pools are not walked beyond the actors. They are static storage in a
+ * process that is exiting, and the sprites and characters are still referenced
+ * by each other; a game that loads a second level has to unwind that properly,
+ * and this one does not pretend to.
+ */
+static void shutdown_game(void)
+{
+ int i = 0;
+
+ IGNORE(akgl_text_unloadallfonts());
+ for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
+ if ( akgl_heap_actors[i].refcount > 0 ) {
+ IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i]));
+ }
+ }
+ /* Guarded: this runs from CLEANUP, which is reached even when startup failed
+ * before akgl_game_init pointed akgl_gamemap at anything. */
+ if ( akgl_gamemap != NULL ) {
+ IGNORE(akgl_tilemap_release(akgl_gamemap));
+ }
+ TTF_Quit();
+ MIX_Quit();
+ SDL_Quit();
+}
+
+static akerr_ErrorContext *parse_args(int argc, char *argv[], char **assetdir, int *frames)
+{
+ const char *env = NULL;
+ int i = 0;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, assetdir, AKERR_NULLPOINTER, "assetdir");
+ FAIL_ZERO_RETURN(errctx, frames, AKERR_NULLPOINTER, "frames");
+
+ env = SDL_getenv("AKGL_SIDESCROLLER_FRAMES");
+ if ( env != NULL ) {
+ PASS(errctx, aksl_atoi((char *)env, frames));
+ }
+ for ( i = 1; i < argc; i++ ) {
+ if ( strcmp(argv[i], "--autoplay") == 0 ) {
+ ss_game.autoplay = true;
+ } else if ( strcmp(argv[i], "--frames") == 0 ) {
+ i += 1;
+ FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--frames needs a count");
+ PASS(errctx, aksl_atoi(argv[i], frames));
+ } else if ( strcmp(argv[i], "--assets") == 0 ) {
+ i += 1;
+ FAIL_NONZERO_RETURN(errctx, (i >= argc), AKERR_VALUE, "--assets needs a directory");
+ *assetdir = argv[i];
+ } else {
+ FAIL_RETURN(
+ errctx,
+ AKERR_VALUE,
+ "usage: sidescroller [--assets DIR] [--frames N] [--autoplay]"
+ );
+ }
+ }
+ SUCCEED_RETURN(errctx);
+}
+
+int main(int argc, char *argv[])
+{
+ char *assetdir = SS_ASSET_DIR;
+ int frames = 0;
+ PREPARE_ERROR(errctx);
+
+ ATTEMPT {
+ CATCH(errctx, parse_args(argc, argv, &assetdir, &frames));
+ CATCH(errctx, startup());
+ CATCH(errctx, load_level(assetdir));
+ CATCH(errctx, run(frames));
+ } CLEANUP {
+ shutdown_game();
+ } PROCESS(errctx) {
+ } HANDLE_DEFAULT(errctx) {
+ LOG_ERROR_WITH_MESSAGE(errctx, "the sidescroller could not run");
+ /*
+ * Set a flag rather than returning: leaving a HANDLE block early skips
+ * FINISH's RELEASE_ERROR and leaks the context's pool slot, and the
+ * 129th such call aborts the process. AGENTS.md spells it out.
+ */
+ ss_failed = 1;
+ /*
+ * FINISH_NORETURN rather than FINISH: FINISH expands a
+ * `return __err_context` that an int-returning function cannot compile,
+ * even on a branch that cannot be reached.
+ */
+ } FINISH_NORETURN(errctx);
+
+ SDL_Log(
+ "sidescroller: %d frames, %d of %d coins, %d deaths",
+ ss_game.frame,
+ ss_game.coins_taken,
+ SS_COIN_COUNT,
+ ss_game.deaths);
+ return ss_failed;
+}
diff --git a/examples/sidescroller/player.c b/examples/sidescroller/player.c
new file mode 100644
index 0000000..b462619
--- /dev/null
+++ b/examples/sidescroller/player.c
@@ -0,0 +1,436 @@
+/**
+ * @file player.c
+ * @brief The player's two hooks and its control bindings.
+ *
+ * Two of the six behaviour hooks on `akgl_Actor` are replaced here, and the
+ * split between them is the frame's own order. `akgl_game_update` calls every
+ * actor's `updatefunc`, then steps the physics, then draws -- so per-frame game
+ * logic that is not movement (picking a coin up, falling in the pit) goes in
+ * `updatefunc`, and anything that has to happen inside the physics step goes in
+ * `movementlogicfunc`, which is the only hook the step calls.
+ *
+ * Two of the library's documented gaps are worked around here rather than
+ * papered over. Both are in `TODO.md` under "Arcade physics feel".
+ */
+
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include "sidescroller.h"
+
+/** @brief The player's collision box, as an offset into its 32x32 sprite frame. */
+static SDL_FRect ss_player_body = {
+ .x = SS_PLAYER_BOX_X,
+ .y = SS_PLAYER_BOX_Y,
+ .w = SS_PLAYER_BOX_W,
+ .h = SS_PLAYER_BOX_H
+};
+
+/** @brief Where the map put the player, so a death can put it back. */
+static ss_ActorData ss_player_data;
+
+/**
+ * @brief The rectangle an actor is tested against, given a 32x32 sprite frame.
+ *
+ * Smaller than the frame on purpose. Sprite art does not reach the edges, and a
+ * hazard box the full size of the frame kills a player who is visibly nowhere
+ * near it.
+ */
+static akerr_ErrorContext *hitbox(akgl_Actor *obj, float32_t inset, SDL_FRect *dest)
+{
+ PREPARE_ERROR(errctx);
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
+
+ dest->x = obj->x + inset;
+ dest->y = obj->y + inset;
+ dest->w = 32.0f - (inset * 2.0f);
+ dest->h = 32.0f - (inset * 2.0f);
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief Put the player back where the map placed it, at rest.
+ *
+ * Everything the simulation carries between steps has to be cleared, not just
+ * the position: `ey` is where gravity has been accumulating, and a player who
+ * respawns still holding a full-speed fall lands dead again immediately.
+ */
+static akerr_ErrorContext *respawn(akgl_Actor *obj)
+{
+ ss_ActorData *data = NULL;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
+
+ data = (ss_ActorData *)obj->actorData;
+ obj->x = data->home_x;
+ obj->y = data->home_y;
+ obj->ex = 0.0f;
+ obj->ey = 0.0f;
+ obj->tx = 0.0f;
+ obj->ty = 0.0f;
+ obj->vx = 0.0f;
+ obj->vy = 0.0f;
+ ss_game.deaths += 1;
+ SDL_Log("Player died (%d) and respawned at %f, %f", ss_game.deaths, obj->x, obj->y);
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief The player's `movementlogicfunc`: friction, the jump, and terrain.
+ *
+ * Called by `akgl_physics_simulate` once per actor per step, *before* gravity,
+ * drag, the velocity recomputation and `move`.
+ */
+static akerr_ErrorContext *ss_player_movement(akgl_Actor *obj, float32_t dt)
+{
+ ss_Contact contact;
+ float32_t friction = 0.0f;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+
+ /* The default logic still has to run: it is what copies the character's
+ * speeds onto the actor and signs its acceleration by the movement bits. */
+ PASS(errctx, akgl_actor_logic_movement(obj, dt));
+
+ /*
+ * Workaround 1: there is no friction anywhere in the arcade backend.
+ *
+ * akgl_actor_cmhf_left_off zeroes `tx` outright, so releasing a direction
+ * stops the actor dead inside one frame -- correct for a top-down Zelda,
+ * wrong for a sidescroller. This game binds its own `_off` handlers that
+ * leave `tx` alone (see below) and decays it here instead, faster on the
+ * ground than in the air. Below a pixel per second it is snapped to zero,
+ * because an exponential decay never actually arrives.
+ */
+ if ( AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT) &&
+ AKGL_BITMASK_HASNOT(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT) ) {
+ friction = SS_FRICTION_AIR;
+ if ( ss_game.grounded == true ) {
+ friction = SS_FRICTION_GROUND;
+ }
+ obj->tx -= obj->tx * friction * dt;
+ if ( fabsf(obj->tx) < 1.0f ) {
+ obj->tx = 0.0f;
+ }
+ }
+
+ /*
+ * A jump is an impulse straight into the environmental term, because `ey` is
+ * the axis gravity accumulates on and the two have to cancel for the arc to
+ * come back down. Thrust would not: `ty` is capped against the character's
+ * `speed_y`, which is 0 for this character, so a jump written as thrust is
+ * scaled to nothing by the ellipse cap in akgl_physics_simulate.
+ *
+ * `ss_game.grounded` is last step's verdict, one frame stale. That is the
+ * ordering again -- this hook runs before the step it is deciding about --
+ * and one frame of coyote time is not a thing a player can feel.
+ */
+ if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) {
+ obj->ey = -SS_JUMP_SPEED;
+ }
+ ss_game.jump_requested = false;
+
+ PASS(errctx, ss_collide_resolve(obj, &ss_player_body, dt, &contact));
+ ss_game.grounded = contact.grounded;
+
+ /*
+ * The character maps MOVING_UP to the jump sprites, so the state word says
+ * "in the air" whether the actor is rising or falling. Nothing else reads
+ * the bit here: `speed_y` and `acceleration_y` are both 0 in
+ * character_ss_player.json, so the thrust it would authorise is capped to
+ * nothing.
+ */
+ if ( contact.grounded == true ) {
+ AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
+ } else {
+ AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
+ }
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief The player's `updatefunc`: the animation, then what it is touching.
+ *
+ * Runs in `akgl_game_update`'s actor sweep, before the physics step and before
+ * anything is drawn.
+ */
+static akerr_ErrorContext *ss_player_update(akgl_Actor *obj)
+{
+ SDL_FRect player;
+ SDL_FRect other;
+ bool hit = false;
+ int i = 0;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+
+ /* Facing, then the animation frame. Replacing a hook does not mean
+ * reimplementing it. */
+ PASS(errctx, akgl_actor_update(obj));
+
+ /* Off the bottom of the world. Nothing in the library stops an actor
+ * leaving the map -- akgl_physics_arcade_move does not clamp -- so falling
+ * out of the level is a thing the game has to notice for itself. */
+ if ( obj->y > (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight) ) {
+ PASS(errctx, respawn(obj));
+ SUCCEED_RETURN(errctx);
+ }
+
+ PASS(errctx, hitbox(obj, 8.0f, &player));
+
+ for ( i = 0; i < SS_COIN_COUNT; i++ ) {
+ if ( ss_game.coins[i] == NULL ) {
+ continue;
+ }
+ PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other));
+ PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
+ if ( hit == true ) {
+ /*
+ * Giving the pool slot back is what unregisters the actor and stops
+ * it being drawn; there is no "despawn" call. Releasing another
+ * actor from inside this sweep is safe -- akgl_game_update re-reads
+ * `refcount` at the top of every iteration and skips a slot that has
+ * gone free.
+ */
+ PASS(errctx, akgl_heap_release_actor(ss_game.coins[i]));
+ ss_game.coins[i] = NULL;
+ ss_game.coins_taken += 1;
+ SDL_Log("Collected coin %d of %d", ss_game.coins_taken, SS_COIN_COUNT);
+ }
+ }
+
+ for ( i = 0; i < SS_HAZARD_COUNT; i++ ) {
+ if ( ss_game.hazards[i] == NULL ) {
+ continue;
+ }
+ PASS(errctx, hitbox(ss_game.hazards[i], 6.0f, &other));
+ PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
+ if ( hit == true ) {
+ PASS(errctx, respawn(obj));
+ SUCCEED_RETURN(errctx);
+ }
+ }
+ SUCCEED_RETURN(errctx);
+}
+
+/*
+ * Workaround 2, the release half.
+ *
+ * akgl_actor_cmhf_left_off and _right_off clear the movement bit, zero `ax`
+ * *and* zero `tx`. That last one is the "stops dead" behaviour; these two do
+ * everything else it does and leave `tx` for ss_player_movement to decay.
+ */
+static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event)
+{
+ PREPARE_ERROR(errctx);
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
+ obj->ax = 0.0f;
+ AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT);
+ SUCCEED_RETURN(errctx);
+}
+
+static akerr_ErrorContext *ss_control_right_off(akgl_Actor *obj, SDL_Event *event)
+{
+ PREPARE_ERROR(errctx);
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
+ obj->ax = 0.0f;
+ AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_RIGHT);
+ SUCCEED_RETURN(errctx);
+}
+
+/** @brief Ask for a jump. Whether one is allowed is ss_player_movement's call. */
+static akerr_ErrorContext *ss_control_jump_on(akgl_Actor *obj, SDL_Event *event)
+{
+ PREPARE_ERROR(errctx);
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
+ ss_game.jump_requested = true;
+ SUCCEED_RETURN(errctx);
+}
+
+/**
+ * @brief Cut a rising jump short when the button is let go.
+ *
+ * Variable jump height for four lines, and it works because `ey` is an ordinary
+ * field that nothing else owns between steps.
+ */
+static akerr_ErrorContext *ss_control_jump_off(akgl_Actor *obj, SDL_Event *event)
+{
+ PREPARE_ERROR(errctx);
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+ FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
+ if ( obj->ey < 0.0f ) {
+ obj->ey *= 0.4f;
+ }
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *ss_player_bind(akgl_Actor *obj)
+{
+ PREPARE_ERROR(errctx);
+ FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
+
+ ss_game.player = obj;
+
+ /* Lift the spawn point clear of the step it overlaps *before* recording it,
+ * so a respawn does not put the player back inside the geometry. */
+ PASS(errctx, ss_collide_settle(obj, &ss_player_body));
+ ss_player_data.home_x = obj->x;
+ ss_player_data.home_y = obj->y;
+ obj->actorData = (void *)&ss_player_data;
+
+ /*
+ * The default `facefunc` clears every facing bit and then sets one from the
+ * movement bits -- so an actor that stops moving is left facing nowhere, its
+ * state drops to bare ALIVE, and character_ss_player.json has no sprite for
+ * that. The actor becomes invisible while standing still.
+ *
+ * Clearing this is the documented way out: akgl_actor_automatic_face leaves
+ * an actor alone entirely when it is clear, and the facing bits stay
+ * wherever the control handlers last put them.
+ */
+ obj->movement_controls_face = false;
+
+ /* Replace the hooks after akgl_actor_initialize, never before -- it
+ * overwrites all six. The tilemap loader has already run it here. */
+ obj->movementlogicfunc = &ss_player_movement;
+ obj->updatefunc = &ss_player_update;
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *ss_player_controls(int controlmapid, char *actorname)
+{
+ akgl_ControlMap *controlmap = NULL;
+ akgl_Control control;
+ SDL_KeyboardID *keyboards = NULL;
+ SDL_JoystickID *gamepads = NULL;
+ int count = 0;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, actorname, AKERR_NULLPOINTER, "actorname");
+ /* akgl_controller_pushmap checks the upper bound and not the lower one, so
+ * a negative id indexes before the start of akgl_controlmaps. TODO.md,
+ * "Known and still open" item 11. */
+ FAIL_NONZERO_RETURN(
+ errctx,
+ ((controlmapid < 0) || (controlmapid >= AKGL_MAX_CONTROL_MAPS)),
+ AKERR_OUTOFBOUNDS,
+ "Control map id %d is outside 0..%d",
+ controlmapid,
+ (AKGL_MAX_CONTROL_MAPS - 1)
+ );
+ PASS(errctx, aksl_memset((void *)&control, 0x00, sizeof(akgl_Control)));
+
+ controlmap = &akgl_controlmaps[controlmapid];
+ controlmap->target = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, actorname, NULL);
+ FAIL_ZERO_RETURN(
+ errctx,
+ controlmap->target,
+ AKGL_ERR_REGISTRY,
+ "Actor %s is not in AKGL_REGISTRY_ACTOR; bind controls after the map is loaded",
+ actorname
+ );
+
+ /*
+ * A control map listens to exactly one keyboard and one gamepad, matched by
+ * id, and a binding whose id does not match the event's is not consulted.
+ * That is what keeps two local players on two keyboards apart -- and it is
+ * also why "the arrow keys do nothing" is usually the wrong id rather than
+ * the wrong key.
+ */
+ keyboards = SDL_GetKeyboards(&count);
+ if ( keyboards != NULL ) {
+ if ( count > 0 ) {
+ controlmap->kbid = keyboards[0];
+ }
+ SDL_free(keyboards);
+ }
+ gamepads = SDL_GetGamepads(&count);
+ if ( gamepads != NULL ) {
+ if ( count > 0 ) {
+ controlmap->jsid = gamepads[0];
+ }
+ SDL_free(gamepads);
+ }
+
+ /* ---- keyboard ---- */
+ control.event_on = SDL_EVENT_KEY_DOWN;
+ control.event_off = SDL_EVENT_KEY_UP;
+
+ control.key = SDLK_LEFT;
+ control.handler_on = &akgl_actor_cmhf_left_on;
+ control.handler_off = &ss_control_left_off;
+ PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
+
+ control.key = SDLK_RIGHT;
+ control.handler_on = &akgl_actor_cmhf_right_on;
+ control.handler_off = &ss_control_right_off;
+ PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
+
+ control.key = SDLK_SPACE;
+ control.handler_on = &ss_control_jump_on;
+ control.handler_off = &ss_control_jump_off;
+ PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
+
+ /* ---- gamepad ---- */
+ /* Clear the keycode first: a keyboard event is matched on `key` whatever
+ * else the binding carries, and 0 is a keycode like any other. */
+ control.key = 0;
+ control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
+ control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP;
+
+ control.button = SDL_GAMEPAD_BUTTON_DPAD_LEFT;
+ control.handler_on = &akgl_actor_cmhf_left_on;
+ control.handler_off = &ss_control_left_off;
+ PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
+
+ control.button = SDL_GAMEPAD_BUTTON_DPAD_RIGHT;
+ control.handler_on = &akgl_actor_cmhf_right_on;
+ control.handler_off = &ss_control_right_off;
+ PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
+
+ control.button = SDL_GAMEPAD_BUTTON_SOUTH;
+ control.handler_on = &ss_control_jump_on;
+ control.handler_off = &ss_control_jump_off;
+ PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
+
+ SDL_Log("Bound %s to keyboard %d and gamepad %d", actorname, controlmap->kbid, controlmap->jsid);
+ SUCCEED_RETURN(errctx);
+}
+
+akerr_ErrorContext *ss_player_autoplay(int frame)
+{
+ SDL_Event synthetic;
+ PREPARE_ERROR(errctx);
+
+ FAIL_ZERO_RETURN(errctx, ss_game.player, AKERR_NULLPOINTER, "ss_game.player");
+ PASS(errctx, aksl_memset((void *)&synthetic, 0x00, sizeof(SDL_Event)));
+ synthetic.type = SDL_EVENT_KEY_DOWN;
+
+ /*
+ * The headless smoke run has no keyboard, so it calls the handlers a
+ * keyboard would have called. They take the actor and an event, and the
+ * keyboard handlers do not read the event beyond requiring one -- which is
+ * what makes a scripted run possible at all.
+ */
+ if ( frame == 1 ) {
+ PASS(errctx, akgl_actor_cmhf_right_on(ss_game.player, &synthetic));
+ }
+ if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == 0 ) {
+ ss_game.jump_requested = true;
+ }
+ SUCCEED_RETURN(errctx);
+}
diff --git a/examples/sidescroller/sidescroller.h b/examples/sidescroller/sidescroller.h
new file mode 100644
index 0000000..4e2d048
--- /dev/null
+++ b/examples/sidescroller/sidescroller.h
@@ -0,0 +1,135 @@
+/**
+ * @file sidescroller.h
+ * @brief Shared declarations for the sidescroller tutorial game.
+ *
+ * The game is four translation units and this is the seam between them:
+ *
+ * main.c startup, asset loading, the frame loop, teardown
+ * collision.c the tile queries and the swept resolution libakgl does not have
+ * player.c the player's hooks and its control bindings
+ * actors.c the coins, the patrolling blob, the flying moth
+ *
+ * Nothing here is prefixed `akgl_`. That prefix belongs to the library; a game
+ * built on it takes its own, and this one is `ss_`.
+ */
+
+#ifndef _SIDESCROLLER_H_
+#define _SIDESCROLLER_H_
+
+#include
+
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+/*
+ * The level's geometry, in the units the map is authored in. level1.tmj is 40x15
+ * tiles of 16 pixels, so the world is 640x240 pixels and the view is a 480x240
+ * window onto it that scrolls horizontally.
+ */
+#define SS_TILE_SIZE 16 /* Pixels per map cell, from level1.tmj */
+#define SS_VIEW_WIDTH 480 /* Camera width in map pixels */
+#define SS_VIEW_HEIGHT 240 /* Camera height; the whole map is this tall */
+#define SS_WINDOW_SCALE 2 /* Integer upscale from the view to the window */
+
+/*
+ * Which layer of the map is solid.
+ *
+ * akgl_TilemapLayer records Tiled's numeric layer `id` and does *not* record the
+ * layer's name -- akgl_tilemap_load_layers reads `id`, `opacity`, `visible`, `x`,
+ * `y` and `type`, and nothing else. So a game cannot ask for "the layer called
+ * terrain"; it matches on the id Tiled assigned, which for level1.tmj is 2.
+ */
+#define SS_TERRAIN_LAYER_ID 2
+
+/* The player's collision box, as an offset into its 32x32 sprite frame. The art
+ * does not fill the frame edge to edge, and a box the full width of the frame
+ * catches on doorways the character visibly clears. */
+#define SS_PLAYER_BOX_X 8.0f
+#define SS_PLAYER_BOX_Y 0.0f
+#define SS_PLAYER_BOX_W 16.0f
+#define SS_PLAYER_BOX_H 32.0f
+
+/* Upward velocity a jump installs directly into the actor's environmental term,
+ * in pixels per second. Under the map's 900 px/s^2 gravity this peaks a little
+ * under 100 px -- six tiles -- which is what the platform heights are cut to. */
+#define SS_JUMP_SPEED 420.0f
+
+/* How fast thrust bleeds away when no direction is held, as a fraction per
+ * second. The library has no friction at all: see ss_player_friction. */
+#define SS_FRICTION_GROUND 12.0f
+#define SS_FRICTION_AIR 1.5f
+
+/* How many of each kind of thing level1.tmj places. */
+#define SS_COIN_COUNT 4
+#define SS_HAZARD_COUNT 2
+
+/* How far the autoplay script waits between jumps, in frames. */
+#define SS_AUTOPLAY_JUMP_PERIOD 45
+
+/**
+ * @brief What one call to ss_collide_resolve found.
+ *
+ * `blocked_x` and `blocked_y` say the sweep stopped the actor on that axis this
+ * step; `grounded` says there is solid terrain one pixel under where the actor
+ * will be when the step finishes, which is the test a jump is gated on.
+ */
+typedef struct {
+ bool blocked_x;
+ bool blocked_y;
+ bool grounded;
+} ss_Contact;
+
+/**
+ * @brief Per-actor game data, hung off akgl_Actor::actorData.
+ *
+ * The library never reads or frees `actorData`, so it is the place a game puts
+ * what the library has no field for. These live in a fixed table in actors.c
+ * rather than being allocated, which is the same discipline the library's own
+ * pools follow.
+ */
+typedef struct {
+ float32_t home_x; /**< Where the map placed this actor. The moth orbits it; the player respawns at it. */
+ float32_t home_y;
+ float32_t phase; /**< Seconds of flight, for the moth's orbit. */
+ float32_t facing; /**< -1.0 walking left, +1.0 walking right. The blob's patrol direction. */
+} ss_ActorData;
+
+/** @brief The whole game's state. One player, one level, no menus. */
+typedef struct {
+ akgl_Actor *player; /**< Borrowed from the actor pool; the map created it. */
+ akgl_Actor *coins[SS_COIN_COUNT]; /**< Cleared to NULL as each one is collected. */
+ akgl_Actor *hazards[SS_HAZARD_COUNT]; /**< The blob and the moth. Borrowed, never released. */
+ int coins_taken;
+ int deaths;
+ bool jump_requested; /**< Set by the jump binding, consumed by the movement logic. */
+ bool grounded; /**< Last step's verdict; what gates the next jump. */
+ bool autoplay; /**< Drive the player from a script instead of the keyboard. */
+ int frame; /**< Frames drawn so far. */
+} ss_Game;
+
+extern ss_Game ss_game;
+
+/* collision.c -- everything akgl_physics_arcade_collide would have done. */
+akerr_ErrorContext AKERR_NOIGNORE *ss_collide_bind(akgl_Tilemap *map);
+akerr_ErrorContext AKERR_NOIGNORE *ss_collide_solid_at(float32_t x, float32_t y, bool *dest);
+akerr_ErrorContext AKERR_NOIGNORE *ss_collide_box_blocked(SDL_FRect *box, bool *dest);
+akerr_ErrorContext AKERR_NOIGNORE *ss_collide_predict(akgl_Actor *obj, float32_t dt, float32_t *dx, float32_t *dy);
+akerr_ErrorContext AKERR_NOIGNORE *ss_collide_resolve(akgl_Actor *obj, SDL_FRect *body, float32_t dt, ss_Contact *dest);
+akerr_ErrorContext AKERR_NOIGNORE *ss_collide_settle(akgl_Actor *obj, SDL_FRect *body);
+
+/* player.c */
+akerr_ErrorContext AKERR_NOIGNORE *ss_player_bind(akgl_Actor *obj);
+akerr_ErrorContext AKERR_NOIGNORE *ss_player_controls(int controlmapid, char *actorname);
+akerr_ErrorContext AKERR_NOIGNORE *ss_player_autoplay(int frame);
+
+/* actors.c */
+akerr_ErrorContext AKERR_NOIGNORE *ss_actors_bind(void);
+
+#endif // _SIDESCROLLER_H_
diff --git a/scripts/fetch_tutorial_assets.sh b/scripts/fetch_tutorial_assets.sh
new file mode 100755
index 0000000..ac27ab9
--- /dev/null
+++ b/scripts/fetch_tutorial_assets.sh
@@ -0,0 +1,348 @@
+#!/bin/bash
+#
+# Refresh the tutorial art in docs/tutorials/assets/ from Kenney.nl.
+#
+# Those PNGs are tracked on purpose. They are what the two tutorial games in
+# examples/ draw, what docs/tutorials/PROVENANCE.md accounts for, and what a
+# reader copies into their own project -- so the tree has to build and the
+# tutorials have to run with no network access at all. This script therefore
+# has the same rule mkcontrollermappings.sh was fixed into for 0.5.0: **it must
+# never replace a good tracked asset with a worse one**. Everything is fetched
+# and repacked in a temporary directory, every step is checked, and the staged
+# files are moved into place only once all of them have come out right. Any
+# failure leaves the tracked copies exactly as they were and exits non-zero.
+#
+# Nothing in the build runs this. It is a deliberate, standalone refresh, and
+# its output should land in its own commit so the diff is reviewable.
+#
+# Two of the eight images are verbatim copies of a Kenney tileset. The other six
+# are repacked: 16x16 source art composited bottom-centre into 32x32 cells, one
+# row, left to right, which is the layout akgl_spritesheet_coords_for_frame
+# indexes. The repack lives here rather than in a second script so that the
+# tracked bytes are reproducible from one command.
+#
+# The JSON (sprites, characters) and the TMJ maps are hand-authored source, not
+# generated. This script does not touch them.
+#
+# Usage: scripts/fetch_tutorial_assets.sh [repository-root]
+
+set -euo pipefail
+
+AKGL_KENNEY_ASSET_URL="${AKGL_KENNEY_ASSET_URL:-https://kenney.nl/assets}"
+# A floor, not a target. Each pack page carries its own licence line; a page
+# that does not say CC0 is either the wrong page or a relicensed pack, and
+# either way its bytes must not land in this repository.
+AKGL_KENNEY_LICENSE_TEXT="${AKGL_KENNEY_LICENSE_TEXT:-Creative Commons CC0}"
+
+function die()
+{
+ echo "fetch_tutorial_assets: $*" >&2
+ exit 1
+}
+
+function note()
+{
+ echo "fetch_tutorial_assets: $*"
+}
+
+rootdir="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
+assetdir="${rootdir}/docs/tutorials/assets"
+
+if [[ ! -d "${assetdir}/sidescroller" ]] || [[ ! -d "${assetdir}/jrpg" ]]; then
+ die "no such directory: ${assetdir}/{sidescroller,jrpg}"
+fi
+
+command -v curl >/dev/null 2>&1 || die "curl is not available"
+command -v unzip >/dev/null 2>&1 || die "unzip is not available"
+command -v convert >/dev/null 2>&1 || die "ImageMagick 'convert' is not available"
+command -v identify >/dev/null 2>&1 || die "ImageMagick 'identify' is not available"
+
+workdir="$(mktemp -d)"
+
+function cleanup()
+{
+ rm -rf "${workdir}"
+}
+trap cleanup EXIT
+
+staging="${workdir}/staging"
+mkdir -p "${staging}/sidescroller" "${staging}/jrpg"
+
+##
+## Fetching
+##
+
+# Download one pack, check its page says CC0, and unpack it under ${workdir}/.
+function fetch_pack()
+{
+ local slug="$1"
+ local page="${workdir}/${slug}.html"
+ local zip="${workdir}/${slug}.zip"
+ local dest="${workdir}/${slug}"
+ local url=""
+
+ # --fail so an HTTP error status is an exit status rather than an error page
+ # in the body. Not a pipeline: the status being checked is curl's, and a
+ # pipeline reports the last command's.
+ if ! curl --fail --silent --show-error --location "${AKGL_KENNEY_ASSET_URL}/${slug}" --output "${page}"; then
+ die "could not fetch the pack page for ${slug}; tracked assets left as they were"
+ fi
+
+ if ! grep -q "${AKGL_KENNEY_LICENSE_TEXT}" "${page}"; then
+ die "the ${slug} page does not say '${AKGL_KENNEY_LICENSE_TEXT}'; refusing to vendor it"
+ fi
+
+ url="$(grep -o "https://[^'\"]*${slug}\.zip" "${page}" | head -1)"
+ if [[ -z "${url}" ]]; then
+ die "no download link found on the ${slug} page; tracked assets left as they were"
+ fi
+
+ if ! curl --fail --silent --show-error --location "${url}" --output "${zip}"; then
+ die "download failed from ${url}; tracked assets left as they were"
+ fi
+
+ if ! unzip -tq "${zip}" >/dev/null 2>&1; then
+ die "${slug}.zip did not survive an integrity check; tracked assets left as they were"
+ fi
+
+ mkdir -p "${dest}"
+ if ! unzip -qo "${zip}" -d "${dest}"; then
+ die "could not unpack ${slug}.zip; tracked assets left as they were"
+ fi
+
+ if ! grep -q "Creative Commons Zero" "${dest}/License.txt"; then
+ die "${slug} does not carry a CC0 License.txt; refusing to vendor it"
+ fi
+
+ note "fetched ${slug} from ${url}"
+}
+
+# Refuse to publish an audio file that is not actually Ogg. `file(1)` is not
+# guaranteed to be installed; the container magic is four bytes at offset zero.
+function require_ogg()
+{
+ local file="$1"
+ local minbytes="$2"
+
+ [[ -f "${file}" ]] || die "expected file is missing: ${file}"
+ if [[ "$(head -c 4 "${file}")" != "OggS" ]]; then
+ die "${file} is not an Ogg stream; tracked assets left as they were"
+ fi
+ if [[ "$(wc -c < "${file}")" -lt "${minbytes}" ]]; then
+ die "${file} is under ${minbytes} bytes; tracked assets left as they were"
+ fi
+}
+
+# Refuse to work from an image that is not the size the frame arithmetic assumes.
+function require_size()
+{
+ local file="$1"
+ local want="$2"
+ local got=""
+
+ [[ -f "${file}" ]] || die "expected file is missing: ${file}"
+ got="$(identify -format '%wx%h' "${file}")"
+ if [[ "${got}" != "${want}" ]]; then
+ die "${file} is ${got}, expected ${want}; the pack layout has changed and the frame offsets no longer hold"
+ fi
+}
+
+##
+## Repacking
+##
+## A sheet is one row of 32x32 cells. Each cell holds one 16x16 source tile
+## composited at (8, 16) -- horizontally centred, sitting on the cell's bottom
+## edge -- so an actor drawn into a 32x32 destination rectangle has its feet on
+## the bottom of that rectangle whichever sheet it came from.
+##
+
+AKGL_SHEET_CELL=32
+AKGL_SOURCE_TILE=16
+AKGL_CELL_OFFSET_X=8
+AKGL_CELL_OFFSET_Y=16
+
+# build_sheet ...
+#
+# Each spec is "X,Y" or "X,Y,flop": the source pixel offset of a 16x16 tile, and
+# whether to mirror it horizontally. Specs are laid into cells left to right, so
+# the position of a spec in the argument list *is* its frame id.
+function build_sheet()
+{
+ local src="$1"
+ local out="$2"
+ shift 2
+
+ local -a args=()
+ local count=$#
+ local idx=0
+ local spec=""
+ local sx=""
+ local sy=""
+ local flop=""
+
+ args+=( -size "$((count * AKGL_SHEET_CELL))x${AKGL_SHEET_CELL}" xc:none )
+ for spec in "$@"; do
+ IFS=',' read -r sx sy flop <<< "${spec}"
+ args+=( '(' "${src}" -crop "${AKGL_SOURCE_TILE}x${AKGL_SOURCE_TILE}+${sx}+${sy}" +repage )
+ if [[ "${flop:-}" == "flop" ]]; then
+ args+=( -flop )
+ fi
+ args+=( ')' -geometry "+$(( (idx * AKGL_SHEET_CELL) + AKGL_CELL_OFFSET_X ))+${AKGL_CELL_OFFSET_Y}" -composite )
+ idx=$((idx + 1))
+ done
+
+ # -strip, and the two date properties by name, because ImageMagick writes
+ # date:create/date:modify tEXt chunks from the wall clock. Without this a
+ # refresh that changed nothing at all still produced eight different files,
+ # and the diff would say the art had changed when it had not.
+ args+=( -strip +set date:create +set date:modify +set date:timestamp )
+ if ! convert "${args[@]}" "${out}"; then
+ die "could not repack ${out}; tracked assets left as they were"
+ fi
+}
+
+# rpg_urban_character
+#
+# The RPG Urban Pack lays each character out as a 4-column by 3-row block in the
+# last four columns of the tileset: columns are left, down, up, right; rows are
+# stand, step A, step B. This regroups one block into the twelve-frame order the
+# tutorial sprite JSON expects -- down, left, right, up, three frames each.
+function rpg_urban_character()
+{
+ local charidx="$1"
+ local out="$2"
+ local src="${workdir}/rpg-urban-pack/Tilemap/tilemap_packed.png"
+ local left=368
+ local down=384
+ local up=400
+ local right=416
+ local y0=$(( charidx * 3 * 16 ))
+ local y1=$(( y0 + 16 ))
+ local y2=$(( y0 + 32 ))
+
+ build_sheet "${src}" "${out}" \
+ "${down},${y0}" "${down},${y1}" "${down},${y2}" \
+ "${left},${y0}" "${left},${y1}" "${left},${y2}" \
+ "${right},${y0}" "${right},${y1}" "${right},${y2}" \
+ "${up},${y0}" "${up},${y1}" "${up},${y2}"
+}
+
+##
+## Do the work
+##
+
+fetch_pack "pixel-line-platformer"
+fetch_pack "rpg-urban-pack"
+fetch_pack "music-jingles"
+
+plp="${workdir}/pixel-line-platformer/Tilemap/tilemap_packed.png"
+rup="${workdir}/rpg-urban-pack/Tilemap/tilemap_packed.png"
+
+require_size "${plp}" "160x96"
+require_size "${rup}" "432x288"
+
+# Tilesets are copied verbatim. Both are already 16x16 with zero spacing and
+# zero margin, which is the only geometry akgl_tilemap_compute_tileset_offsets
+# gets right: it adds `spacing` to the tile pitch but sets the first row's y
+# offset to `spacing` rather than zero, and it ignores `margin` entirely.
+cp "${plp}" "${staging}/sidescroller/tiles.png"
+cp "${rup}" "${staging}/jrpg/tiles.png"
+
+# Pixel Line Platformer tile ids, ten columns: id -> ((id % 10) * 16, (id / 10) * 16)
+# 40, 41, 42 the armed rabbit: contact, passing (also the jump pose), contact
+# 44 the gold pickup
+# 51, 52 the moth, wings up and down
+# 55, 56 the red blob, two frames
+#
+# akgl_actor_render always draws with SDL_FLIP_NONE, so a left-facing run has to
+# exist in the sheet as its own frames rather than being mirrored at draw time.
+build_sheet "${plp}" "${staging}/sidescroller/player.png" \
+ "0,64" "16,64" "32,64" \
+ "0,64,flop" "16,64,flop" "32,64,flop"
+build_sheet "${plp}" "${staging}/sidescroller/coin.png" \
+ "64,64"
+build_sheet "${plp}" "${staging}/sidescroller/hazard.png" \
+ "80,80" "96,80" "16,80" "32,80"
+
+rpg_urban_character 0 "${staging}/jrpg/player.png"
+rpg_urban_character 3 "${staging}/jrpg/npc_shopkeeper.png"
+rpg_urban_character 2 "${staging}/jrpg/npc_elder.png"
+
+# One jingle per game, copied unchanged. akgl_load_start_bgm() is the library's
+# only file-audio entry point -- akgl_audio_* is a synthesiser, and there is no
+# sound-effect loader at all -- so a jingle is what a tutorial can actually
+# play. Both are stings of under two seconds, and the loop request in
+# akgl_load_start_bgm does not take effect, so each plays once.
+cp "${workdir}/music-jingles/Audio/8-Bit jingles/jingles_NES00.ogg" \
+ "${staging}/sidescroller/jingle_start.ogg"
+cp "${workdir}/music-jingles/Audio/Pizzicato jingles/jingles_PIZZI07.ogg" \
+ "${staging}/jrpg/jingle_start.ogg"
+
+cp "${workdir}/pixel-line-platformer/License.txt" "${staging}/LICENSE.kenney_pixel-line-platformer.txt"
+cp "${workdir}/rpg-urban-pack/License.txt" "${staging}/LICENSE.kenney_rpg-urban-pack.txt"
+cp "${workdir}/music-jingles/License.txt" "${staging}/LICENSE.kenney_music-jingles.txt"
+
+##
+## Check the staging area before anything is allowed near the tracked copies
+##
+
+# name expected size
+AKGL_TUTORIAL_ASSETS=(
+ "sidescroller/tiles.png 160x96"
+ "sidescroller/player.png 192x32"
+ "sidescroller/coin.png 32x32"
+ "sidescroller/hazard.png 128x32"
+ "jrpg/tiles.png 432x288"
+ "jrpg/player.png 384x32"
+ "jrpg/npc_shopkeeper.png 384x32"
+ "jrpg/npc_elder.png 384x32"
+)
+
+staged=0
+for entry in "${AKGL_TUTORIAL_ASSETS[@]}"; do
+ read -r name want <<< "${entry}"
+ require_size "${staging}/${name}" "${want}"
+ if [[ ! -s "${staging}/${name}" ]]; then
+ die "staged ${name} is empty; tracked assets left as they were"
+ fi
+ staged=$((staged + 1))
+done
+
+if [[ "${staged}" -ne "${#AKGL_TUTORIAL_ASSETS[@]}" ]]; then
+ die "staged ${staged} images, expected ${#AKGL_TUTORIAL_ASSETS[@]}; tracked assets left as they were"
+fi
+
+AKGL_TUTORIAL_AUDIO=(
+ "sidescroller/jingle_start.ogg"
+ "jrpg/jingle_start.ogg"
+)
+
+for name in "${AKGL_TUTORIAL_AUDIO[@]}"; do
+ require_ogg "${staging}/${name}" 4096
+done
+
+AKGL_TUTORIAL_LICENSES=(
+ "LICENSE.kenney_pixel-line-platformer.txt"
+ "LICENSE.kenney_rpg-urban-pack.txt"
+ "LICENSE.kenney_music-jingles.txt"
+)
+
+for name in "${AKGL_TUTORIAL_LICENSES[@]}"; do
+ if [[ ! -s "${staging}/${name}" ]]; then
+ die "staged ${name} is empty; tracked assets left as they were"
+ fi
+done
+
+##
+## Publish
+##
+
+for entry in "${AKGL_TUTORIAL_ASSETS[@]}"; do
+ read -r name want <<< "${entry}"
+ mv "${staging}/${name}" "${assetdir}/${name}"
+done
+for name in "${AKGL_TUTORIAL_AUDIO[@]}" "${AKGL_TUTORIAL_LICENSES[@]}"; do
+ mv "${staging}/${name}" "${assetdir}/${name}"
+done
+
+note "wrote ${staged} images, ${#AKGL_TUTORIAL_AUDIO[@]} jingles and ${#AKGL_TUTORIAL_LICENSES[@]} upstream licence files to ${assetdir}"