Files
libakgl/docs/21-tutorial-jrpg.md
Andrew Kesterson cbf7c1b6c2 Delete the JRPG's collision too, and wall the map with proxies
cell_solid and feet_blocked go, and so does the prediction in the player's
movementlogicfunc. That prediction was only ever exact because the town has zero
gravity and zero drag -- v is t, so `x + tx * dt` is where the step lands. A map
with gravity would have had to fold ey in as well, which is the game
re-implementing the integrator. Resolution runs after the move now, so there is
nothing to predict.

The map's decoration layer carries `collidable`, which retires JRPG_LAYER_SOLID:
akgl_TilemapLayer has no name member, so the game and the map had to agree on an
index out of band and inserting a layer in Tiled broke it.

The edge of the world is not on any layer, so it is four static proxies covering
the outer ring plus a tile of overhang -- one pool slot per side instead of a
hundred solid tiles, and the first use of the static-proxy path in either
example. They carry LAYER_STATIC explicitly: shape_box defaults a shape to
LAYER_ACTOR, and a wall left on that layer is a wall everything walks through.

Only the player gets a shape. NPCs stand still and are spoken to; the follower
is a child snapped to its parent every step.

Verified against the old implementation with the demo script rewritten to hold
one direction: holding left into a building stops the player at x=122 with both,
and holding up into the map's edge stops them at y=-4 with both.

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

33 KiB
Raw Blame History

21. Tutorial: a top-down JRPG

A town, three people standing in it, a party member who follows you around, and a box that tells you what the elder thinks of the road north. About six hundred lines of C, in examples/jrpg/, built by all and run headless by ctest.

This is the content-pipeline tutorial. Its subject is the road from a directory of JSON and PNG to a world with people in it: twenty-four sprites, three characters, one Tiled map that spawns its own actors, four-way per-facing animation, and text on top. Chapter 20 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.

Build it and run it

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:

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.
  • akgl_tilemap_load resolves each actor object's character property through AKGL_REGISTRY_CHARACTER while it spawns the actor. See Chapter 13.
    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:

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 covers the consequences; the one that binds here is that four facings times {standing, walking} is eight distinct combinations, and every one of them needs its own mapping or the actor vanishes when it reaches that state:

State Value Sprite
ALIVEFACE_DOWN 17 jrpg_player_idle_down
ALIVEFACE_DOWNMOVING_DOWN 1041 jrpg_player_walk_down
ALIVEFACE_LEFT 18 jrpg_player_idle_left
ALIVEFACE_LEFTMOVING_LEFT 146 jrpg_player_walk_left
ALIVEFACE_RIGHT 20 jrpg_player_idle_right
ALIVEFACE_RIGHTMOVING_RIGHT 276 jrpg_player_walk_right
ALIVEFACE_UP 24 jrpg_player_idle_up
ALIVEFACE_UPMOVING_UP 536 jrpg_player_walk_up

There is no diagonal row and there does not need to be one. The default arrow bindings clear every facing and movement bit before setting their own (akgl_actor_cmhf_left_on and its five siblings all do), so holding two directions moves in whichever was pressed last rather than diagonally. That is documented in Chapter 16, 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'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:

    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:

    for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
	if ( akgl_heap_actors[i].refcount == 0 ) {
	    continue;
	}
	akgl_heap_actors[i].movement_controls_face = false;
    }

Turning the automatic facing off is not a compromise here. The arrow-key handlers already set the facing bit alongside the movement bit on the way down, and akgl_actor_cmhf_*_off clears only the movement bit on the way up — so the facing an actor is left with is exactly the one it was walking in. The automatic facefunc is for an actor whose facing is not otherwise decided.

Walls

The library resolves them (Chapter 15); this game says which things are solid and gets out of the way.

Two rules, and they arrive by two different mechanisms because they are two different kinds of geometry. Every non-empty cell of the decoration layer — a building, a tree, a lamp post — is solid, and the layer says so itself:

   "properties": [
    {
     "name": "collidable",
     "type": "bool",
     "value": true
    }
   ]

That replaces a #define JRPG_LAYER_SOLID 1, and the replacement matters more than it looks. akgl_TilemapLayer has no name member — the loader reads a layer's id, type, opacity, visible and offset and drops the name Tiled wrote, so a game could not say "the layer called decoration" and had to agree with the map on an index. Insert a layer in Tiled and the game starts colliding with the scenery. Now the map carries the answer and the game names no layer at all.

The second rule is the edge of the world, which is not on any layer. That is four static proxies:

	PASS(errctx, akgl_heap_next_collision_proxy(&jrpg_edges[i]));
	PASS(errctx, akgl_collision_shape_box(&jrpg_edge_shapes[i], &sides[i], 0.0f));
	jrpg_edge_shapes[i].layermask = AKGL_COLLISION_LAYER_STATIC;
	jrpg_edge_shapes[i].collidemask = AKGL_COLLISION_LAYER_NONE;
	jrpg_edge_shapes[i].flags |= AKGL_COLLISION_FLAG_STATIC;

Four proxies rather than a hundred solid tiles, because a long thin box costs one pool slot whatever its length — this is what proxies are for, and tiles are the other mechanism precisely because there are a hundred thousand of them.

Three details in five lines:

  • layermask is set by hand. akgl_collision_shape_box defaults a shape to AKGL_COLLISION_LAYER_ACTOR, which is right for an actor and wrong for scenery. An actor responds to AKGL_COLLISION_LAYER_STATIC and nothing else, so a wall left on the default layer is a wall everything walks through.
  • collidemask is cleared. The wall is not asking to be pushed out of anything.
  • The acquire and the initialize are adjacent, with nothing between them. akgl_heap_next_collision_proxy finds a free slot and does not claim it; the initialize takes the reference. Between the two lines a second acquire returns the same pointer. Chapter 5 covers why the pools are built that way.

The walls cover the map's outermost ring of cells and extend a tile beyond it. The ring is what the art expects; the overhang is what stops an actor arriving fast enough from being resolved out the far side of a wall exactly one tile thick.

Then the player gets a footprint:

    body = (SDL_FRect){ FEET_X, FEET_TOP, FEET_W, FEET_H };
    PASS(errctx, akgl_collision_shape_box(&player->shape, &body, 0.0f));
    player->shape_override = true;

A 20×10 box at the bottom of the 32×32 frame rather than the whole frame, because a character stands on their feet and every doorway in the town is one tile wide. Testing the whole frame would make every corridor two tiles wide.

Only the player gets one. The NPCs stand still and are spoken to, not walked into; shapes on them would make the town a pinball table. The follower is a child, snapped to the player's position every step, so a shape on it would fight the snap rather than stop anything. That is what the default masks are for — an actor with a shape collides with map geometry and with no other actor until you add a bit.

What went away

This chapter used to carry a movementlogicfunc that predicted the step:

    if ( feet_blocked(obj->x + (obj->tx * dt), obj->y) ) { obj->tx = 0.0f; ... }

It worked, and it came with a qualifier: velocity is recomputed as e + t every step, and it was only exact because this map has zero gravity and zero drag, so e stays zero and v is t. A map with gravity would have had to fold ey into the prediction as well — which is the game re-implementing the library's integrator, in a file that would not be recompiled when the integrator changed.

Resolution runs after the move now, on a position that already happened, so there is nothing to predict and the qualifier goes away with it. Both this game and the sidescroller land in exactly the same pixel they did before: holding left into a building stops the player at x=122, and holding up into the map's edge stops them at y=-4, with either implementation.

Freezing the world with AKGL_ERR_LOGICINTERRUPT

AKGL_ERR_LOGICINTERRUPT is the one status in libakgl that is a control-flow signal rather than a failure. Raised from a movementlogicfunc it means skip the rest of this tick for this actor, and akgl_physics_simulate swallows it in a HANDLE block: gravity, drag, the velocity recompute and the move are all skipped, and the frame carries on. Raised from gravity or move instead it aborts the whole step for every actor, which is not the same thing at all — see Chapter 14.

A conversation is exactly the case it was made for:

    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 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 continues. 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.

    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) — 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:

    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:

	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:

    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.

    parent = obj->parent;
    obj->parent = NULL;
    ATTEMPT {
	CATCH(errctx, akgl_actor_render(obj));
    } CLEANUP {
	obj->parent = parent;
    } PROCESS(errctx) {
    } FINISH(errctx, true);

CLEANUP restores it on every path, including the failing one. An actor left holding a NULL parent would stop being snapped and start being simulated as a free agent on the very next step, which is a stranger bug than the one being worked around.

The alternative was to drive the follower by hand — no addchild, a position written from the game's own logic every frame — and it is a perfectly reasonable choice. This game does not take it, because the mechanism is worth showing and because a fifteen-line hook is cheaper than reimplementing the parent/child lifecycle. The real fix is one line in either src/physics.c or src/actor.c and a decision about which reading is canonical.

The text box

Text in libakgl is immediate mode. Every akgl_text_rendertextat call rasterizes the string through SDL_ttf, uploads it to a texture, blits it, and destroys the texture and the surface again — there is no glyph atlas and no text object to keep. Chapter 17 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.

    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's subject. Three things in it are easy to get wrong, and this block is where two of them land:

    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:

    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

    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:

	while ( running ) {
	    started = SDL_GetTicks();
	    CATCH(errctx, frame(frameno));
	    frameno += 1;
	    if ( (frame_limit > 0) && (frameno >= frame_limit) ) {
		running = false;
	    }

CATCH reports failure by breaking, 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:

    IGNORE(akgl_text_unloadallfonts());
    if ( akgl_window != NULL ) {
	SDL_DestroyWindow(akgl_window);
	akgl_window = NULL;
    }
    SDL_Quit();
  • akgl_text_unloadallfonts must run before SDL_Quit. Fonts live in an SDL property registry, and SDL_Quit destroys the registry — taking the last reference to every font still in it, with no way left to close them. Chapter 17 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 and Chapter 4 both cover it.

The headless smoke run

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_Events 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:

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.

	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 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:

jrpg: 320 frames, player at (280, 146)

What this costs

Four things in this program exist because the library does not do them. None of them is hidden, and none of them is a criticism of a design — they are the current state of a library that is honest about being unfinished. Two more were here in 0.7.0 and are gone: the hard-coded collision layer index, and the tile-based blocking in the player's movementlogicfunc. Collision closed both.

# Gap What this game does Recorded in
1 movement_controls_face erases the facing bit of any actor that is not moving, so every map-spawned actor stops being drawn on frame one Clears the field on every live actor after loading the map actor.h @note; a TODO in src/actor.c. Not in TODO.md
2 akgl_default_physics is zeroed BSS and akgl_game_init never calls the factory; akgl_game_update calls simulate through a NULL pointer Calls akgl_physics_init_arcade explicitly, and switches to the map's backend when it declares one The false physics.engine claim in physics.h is in the plan's out-of-scope list
3 A child actor's position is written as absolute by the physics step and read as relative by the renderer, so the parent's position is added twice A renderfunc that detaches the parent for the duration of the draw Not in TODO.md. actor.h documents both readings
4 akgl_tilemap_release double-frees tileset textures and never frees image-layer ones Does not call it; lets SDL_Quit reclaim them TODO.md, "Known and still open" item 2

Two more that are not workarounds but will bite anyone extending this: akgl_Actor::layer is a uint32_t with no bound, while akgl_render_2d_draw_world stops at AKGL_TILEMAP_MAX_LAYERS (16) — an actor on layer 20 is simulated and never drawn. And the SDL enumeration callbacks (akgl_registry_iterate_actor, akgl_character_state_sprites_iterate) return void and end in FINISH_NORETURN, so an error inside one exits the process rather than failing a frame. Chapter 4 covers installing your own akerr_handler_unhandled_error if that is not what you want.

Where to look next

  • Chapter 20 — the same shape of program with gravity, a jump and platforms, which is where the physics model earns its keep.
  • Chapter 13 — the map format, the three libakgl extensions to Tiled, and the four constraints the loader really enforces.
  • Chapter 11 and Chapter 12 — the template and the instance, and why the state word is the whole key.
  • Chapter 16 — control maps, the default bindings, and the keystroke ring this game does not use.
  • Chapter 22 — every compile-time ceiling in one place, including the 64-actor pool this town uses four of.