collision.c was 383 lines that re-implemented akgl_physics_simulate's own arithmetic, because movementlogicfunc runs before gravity, drag and the move and so had to predict where the actor would end up. The whole file goes. What replaces it is three lines in main.c, a shape on the player and the blob, and a `collidable` property on the map's terrain layer -- which also retires SS_TERRAIN_LAYER_ID, a compiled-in layer id that broke whenever somebody reordered layers in Tiled. grounded stays a probe rather than becoming a contact test. A contact says something pushed back this step; grounded asks whether there is a floor to push off, and that stays true through the frame after landing. The blob keeps its own wall and ledge probes for the same reason: the resolver does not say which side of the blob it was pushed from, and says nothing at all about a floor that is missing. The summary line now carries the player's resting position, because exiting 0 was not evidence that collision ran. It reports 136.0,160.0 grounded after 240 autoplay frames -- feet at y=192, flush on the surface of terrain row 12. The tutorial's collision section is rewritten around the library's, keeping the quarter-of-a-pixel story as what a predicting resolver costs. The docs harness learns `json excerpt=`, so the map property the chapter quotes is checked against the map. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
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.