examples/sidescroller and examples/jrpg are complete programs, built with the library and exercised headless by ctest. The chapters quote them with `c excerpt=examples/...` blocks rather than restating the code, so a chapter cannot drift from a program that compiles -- the excerpt check fails the moment the source moves. 34 excerpts in one chapter, 21 in the other. The two are complementary. The sidescroller is the physics tutorial: gravity, a jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map, NPCs spawned from map objects, four-way per-facing animation, a text box, a follower. Both smoke tests drive real SDL_Events through akgl_controller_handle_event and step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run is deterministic and finishes in under five seconds. Writing them is what turned up most of the defects recorded in the next commit, because a game exercises paths a unit test does not. Each workaround says in the chapter which library gap forced it: - collision is written in a custom movementlogicfunc, because akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never calls collide at all; - the sidescroller cancels the step's own gravity when it blocks downward, because otherwise a quarter-pixel of penetration makes the *horizontal* sweep report blocked and the character walks backwards a tile at a time; - both clear movement_controls_face on every map-spawned actor, because the default facefunc leaves a stopped actor with no facing bit, no sprite, and no draw; - the JRPG's follower gets a renderfunc that nulls obj->parent for the duration of the draw, because a child's offset is counted twice. Assets are CC0 from three Kenney packs, vendored with per-pack licence text, per-file provenance, and the geometry contract in docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a reader who copies a tutorial into their own game inherits no obligation. scripts/fetch_tutorial_assets.sh refreshes them in the shape mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status, refuses a pack page that does not say CC0, verifies the archive and the staged dimensions, and leaves the tracked bytes untouched on any failure. Both failure paths were tested, and a no-op refresh is byte-identical. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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_<dir>_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
typestring, including ones that are not actors. A missingtypefails the whole load, not just that object. - An actor object needs a non-empty
name, acharacterstring property and astateint property. The string-array form ofstateworks in character JSON and is not accepted here. - The
characternamed must already be inAKGL_REGISTRY_CHARACTER, which means every sprite and character JSON has to be loaded before the map. - A map's
propertiesare optional; if present,physics.modelmust name a backend that exists (nullorarcade) or the load fails. Gravity and drag keys arefloat. - Tileset image paths resolve through
akgl_path_relativeagainst 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.