Files
libakgl/docs/11-characters.md
Andrew Kesterson b938460127 Add the manual: nineteen chapters and a corrected README
docs/ is a narrative manual, not a second reference. Every header already
carries a substantial @file/@brief block and Doxyfile sets WARN_IF_UNDOCUMENTED
with WARN_AS_ERROR, so an undocumented symbol already fails CI. The gap was
navigation and worked examples. Chapters teach a task and link to the Doxygen
output; where a declaration or a constant table has to be in front of the
reader it arrives as a `c excerpt=` block, so the text *is* the header and
cannot diverge from it. That also preserves the hand-aligned bit-flag tables
scripts/reindent.el goes out of its way not to destroy.

The manual does not re-document its dependencies. libakerror owns the
ATTEMPT/CLEANUP/PROCESS/HANDLE/FINISH protocol, SDL3 owns renderers and events,
Tiled owns the map format, jansson owns json_t. A chapter that restated any of
them would be wrong the day upstream changed and nothing here would notice --
the same drift this work exists to fix, arriving from a different direction. So
each chapter says what libakgl adds or constrains and links out for the rest.

Chapter 4 is the exception and the reason for it: libakerror documents the
mechanism, but only libakgl can say which statuses its own functions raise and
what they mean here, and that was written down nowhere. It carries three tables
-- libakgl's five status codes, the libakerror statuses libakgl actually raises
with their meaning in this library, and the exit-status trap where
`exit(AKGL_ERR_SDL)` is a wait status of 0 because the band starts at 256.

Every chapter was written against src/ rather than against the header comments,
which is how 27 false claims in those comments came to light. Where a chapter
documents a known defect rather than a design decision it says so and points at
TODO.md.

README.md keeps the development process and hands the reader to docs/. Its
task-oriented FAQ is deleted rather than moved, because one source of truth per
topic is the whole point and that FAQ's examples did not compile.

Census: 39 compiled snippets, 89 verbatim header excerpts, 4 JSON documents run
through the real loaders, one linked-and-executed program with its output
compared byte for byte, one generated figure. 11 norun blocks, each justified.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:58:37 -04:00

14 KiB

11. Characters

A character is the reusable template. An actor is the instance.

Everything that is the same for every goblin on the map — top speed, acceleration, how long an animation frame is held, and which sprite to draw for which combination of state bits — lives in one akgl_Character and is shared by pointer. A hundred goblins cost one character. Everything that is unique to a particular goblin — where it is, which way it is going, which frame it is on — lives in its akgl_Actor. See Chapter 12.

Characters are pool objects (akgl_heap_next_character, 256 slots) published in AKGL_REGISTRY_CHARACTER under their name, which is how akgl_actor_set_character finds them. akgl_registry_init has to have run first.

    akgl_Character "hero"                       akgl_Actor "player"
    +--------------------------------+          +---------------------+
    | sx, sy, sz    top speeds       |<---------| basechar            |
    | ax, ay, az    accelerations    |  borrowed| x, y, z             |
    | speedtime     frame dwell (ns) |          | state  (bitmask)    |
    | state_sprites -----------------+---+      | curSpriteFrameId    |
    +--------------------------------+   |      +---------------------+
                                         |
             SDL_PropertiesID keyed by the DECIMAL state bitmask
             "18"  -> akgl_Sprite *hero standing left
             "146" -> akgl_Sprite *hero walking left

The state-to-sprite map is an exact-match lookup

state_sprites is an SDL_PropertiesID. The key is the decimal spelling of the whole state valueSDL_itoa(state, buf, 10) — and the value is an akgl_Sprite *.

Two things follow, and both of them shape how you build a character:

1. The key is the entire state word, not a set of bits to match against. A sprite added for FACE_LEFT | MOVING_LEFT is not found by a lookup for FACE_LEFT. There is no subset fallback, no wildcard, no priority order, and no default sprite. You need a mapping for every state combination the character can actually be in.

2. A missing mapping is an error, not a quiet miss. akgl_character_sprite_get raises AKERR_KEY — this is the one lookup in the library that treats absence as a failure rather than as a successful answer of "nothing here", on the grounds that an actor with no sprite for its current state cannot be drawn. *dest is set to NULL alongside the error.

#include <akgl/actor.h>
#include <akgl/character.h>

/*
 * sprite_get treats "no mapping" as AKERR_KEY. Handle the status; do not test
 * the return value for NULL and hope.
 */
akerr_ErrorContext *sprite_or_none(akgl_Character *basechar, int state, akgl_Sprite **dest)
{
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, basechar, AKERR_NULLPOINTER, "basechar");
    FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
    *dest = NULL;

    ATTEMPT {
	CATCH(errctx, basechar->sprite_get(basechar, state, dest));
    } CLEANUP {
    } PROCESS(errctx) {
    } HANDLE(errctx, AKERR_KEY) {
	/* No sprite for this exact combination. *dest is already NULL. */
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

akgl_actor_update and akgl_actor_render both swallow that AKERR_KEY themselves — states change faster than art gets drawn, and a missing sprite should not stop the frame — so an under-mapped character shows up as an actor that silently does not draw. That is the symptom to recognize.

The combinations you will actually need

Four facings, moving and standing, alive: eight mappings for the simplest walking character. Written out, with the decimal key each one lands on:

Combination Decimal key What it draws
ALIVE 16 standing still, no facing — see below
ALIVE|FACE_DOWN 17 standing, facing the camera
ALIVE|FACE_LEFT 18 standing, facing left
ALIVE|FACE_RIGHT 20 standing, facing right
ALIVE|FACE_UP 24 standing, facing away
ALIVE|FACE_DOWN|MOVING_DOWN 1041 walking towards the camera
ALIVE|FACE_LEFT|MOVING_LEFT 146 walking left
ALIVE|FACE_RIGHT|MOVING_RIGHT 276 walking right
ALIVE|FACE_UP|MOVING_UP 536 walking away

Do not skip the first row. The default facefunc (akgl_actor_automatic_face) clears every facing bit and then sets one only if a movement bit is set — so an actor that stops moving is left with no facing bit at all, and its state drops to bare ALIVE. Without a mapping for 16 it vanishes the moment it stops. This is a known rough edge; the implementation carries a TODO saying as much, and Chapter 12 covers the workaround.

The decimals are shown to make the mechanism concrete. Write the constants, or the state-name strings in JSON — never the numbers.

Adding a mapping

akgl_character_sprite_add takes a reference on the sprite, so the character keeps it alive. The reference is taken before the property write, so a failed write cannot leave the sprite bound with no reference behind it.

Rebinding a state that is already mapped releases the sprite it displaced. The header says otherwisecharacter.h's @note on akgl_character_sprite_add claims the displaced sprite's reference "is never given back". That was true and is not; src/character.c reads the old binding out first and releases it, with a comment explaining that without it a character rebinding a state while alive leaks one sprite slot per rebind.

A defect the release path does have. The release is guarded by (displaced != NULL) && (displaced != ref). Re-adding the same sprite for the same state therefore increments its reference count and releases nothing — one leaked reference per redundant call. It is idempotent-looking and is not. Harmless in a loader that runs once; not harmless in a loop.

State 0 is accepted and is a usable key.

Building a character in code

#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/game.h>
#include <akgl/heap.h>

akerr_ErrorContext *build_hero(akgl_Sprite *idle_left, akgl_Sprite *walk_left)
{
    akgl_Character *hero = NULL;
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, idle_left, AKERR_NULLPOINTER, "idle_left");
    FAIL_ZERO_RETURN(errctx, walk_left, AKERR_NULLPOINTER, "walk_left");

    ATTEMPT {
	CATCH(errctx, akgl_heap_next_character(&hero));
	/* Zeroes the struct, creates the map, binds the two hooks, registers. */
	CATCH(errctx, akgl_character_initialize(hero, "hero"));

	/* Everything numeric is left at zero for us. */
	hero->speedtime = 100 * AKGL_TIME_ONEMS_NS;  /* 100 ms, in nanoseconds */
	hero->sx = 0.20f;
	hero->sy = 0.20f;
	hero->ax = 0.20f;
	hero->ay = 0.20f;

	/* One mapping per exact combination we intend to draw. */
	CATCH(errctx,
	      hero->sprite_add(
		  hero,
		  idle_left,
		  AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT)
	    );
	CATCH(errctx,
	      hero->sprite_add(
		  hero,
		  walk_left,
		  AKGL_ACTOR_STATE_ALIVE
		  | AKGL_ACTOR_STATE_FACE_LEFT
		  | AKGL_ACTOR_STATE_MOVING_LEFT)
	    );
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

sprite_add and sprite_get are function pointers on the struct, bound to akgl_character_sprite_add and akgl_character_sprite_get by akgl_character_initialize. Call through the pointers, as with the render backend in Chapter 08 — that is what lets a game substitute its own lookup, a state machine with fallbacks for instance, on one character.

az and sz are not read from JSON. They stay 0 unless you set them by hand.

The character JSON format

{
    "name": "hero",
    "speedtime": 100,
    "speed_x": 0.20,
    "speed_y": 0.20,
    "acceleration_x": 0.20,
    "acceleration_y": 0.20,
    "sprite_mappings": [
	{
	    "state": [
		"AKGL_ACTOR_STATE_ALIVE",
		"AKGL_ACTOR_STATE_FACE_LEFT"
	    ],
	    "sprite": "hero standing left"
	},
	{
	    "state": [
		"AKGL_ACTOR_STATE_ALIVE",
		"AKGL_ACTOR_STATE_FACE_LEFT",
		"AKGL_ACTOR_STATE_MOVING_LEFT"
	    ],
	    "sprite": "hero walking left"
	}
    ]
}
Key Type Meaning
name string Registry key in AKGL_REGISTRY_CHARACTER.
speedtime integer Milliseconds a frame is held, scaled to nanoseconds by AKGL_TIME_ONEMS_NS.
speed_x, speed_y number Top speed per axis. Copied onto an actor as sx/sy.
acceleration_x, acceleration_y number Acceleration per axis, signed by direction each step.
sprite_mappings array of objects One entry per state combination.
…[].sprite string A name already in AKGL_REGISTRY_SPRITE.
…[].state array of strings State names, OR-ed together into one key.

All of them are required. An absent key is AKERR_KEY naming it; a wrong type is AKERR_TYPE.

character.h's @brief describes speedtime as "in seconds". It is milliseconds — the struct field's own documentation says so and src/character.c multiplies by AKGL_TIME_ONEMS_NS.

Sprites must be loaded first. This loads characters, not sprites: every name in a sprite field has to already be in AKGL_REGISTRY_SPRITE, so akgl_sprite_load_json runs before akgl_character_load_json. A mapping naming an unregistered sprite fails with AKERR_NULLPOINTER (not AKERR_KEY — it is a lookup miss reported with a pointer status), and the message names the character, the state and the sprite.

Two keys you may see in existing files and should not copy: movementspeed and velocity_x. Neither is read by anything. They are ignored, silently.

State names are prefixed

The strings come from AKGL_ACTOR_STATE_STRING_NAMES in src/actor_state_string_names.c, which akgl_registry_init_actor_state_strings turns into a registry. Entry i names the bit 1 << i, and every name carries the AKGL_ACTOR_STATE_ prefix:

    AKGL_ACTOR_STATE_FACE_DOWN      AKGL_ACTOR_STATE_MOVING_LEFT
    AKGL_ACTOR_STATE_FACE_LEFT      AKGL_ACTOR_STATE_MOVING_RIGHT
    AKGL_ACTOR_STATE_FACE_RIGHT     AKGL_ACTOR_STATE_MOVING_UP
    AKGL_ACTOR_STATE_FACE_UP        AKGL_ACTOR_STATE_MOVING_DOWN
    AKGL_ACTOR_STATE_ALIVE          AKGL_ACTOR_STATE_MOVING_IN
    AKGL_ACTOR_STATE_DYING          AKGL_ACTOR_STATE_MOVING_OUT
    AKGL_ACTOR_STATE_DEAD           AKGL_ACTOR_STATE_UNDEFINED_13 .. _31

A name that is not in that list is AKERR_KEY, "Unknown actor state", quoting it — a typo is refused rather than skipped, because a skipped typo would bind a sprite to the wrong combination and show up as art that never appears. An empty state array is legal and gives you the key 0.

Adding a new state means adding a bit in include/akgl/actor.h and its name at the matching index in src/actor_state_string_names.c. A bit whose name is missing cannot be referred to from JSON at all — which is exactly what happened to MOVING_IN and MOVING_OUT until 0.5.0.

util/assets/littleguy.json is invalid against the current loader

The sample data for the one shipped demo does not load, and it is worth naming the three separate reasons because each is a different class of drift:

  1. The state names are unprefixed"ACTOR_STATE_ALIVE" rather than "AKGL_ACTOR_STATE_ALIVE". Every one fails with AKERR_KEY, "Unknown actor state".
  2. It has velocity_x and velocity_y, which nothing reads, and lacks speed_x and speed_y, which are required.
  3. It has no speedtime, acceleration_x or acceleration_y either — all three required, all three AKERR_KEY.

tests/assets/testcharacter.json is the file to copy from. Fixing the sample is out of scope here (plan.md, "Out of scope, deliberately"); the finding is recorded so nobody quotes it forward.

Teardown

akgl_heap_release_character decrements the reference count, and at zero it walks state_sprites with akgl_character_state_sprites_iterate, releasing every sprite reference sprite_add took, destroys the property set, clears the registry entry and zeroes the slot.

That walk is an SDL_EnumerateProperties callback, so it returns void and ends in FINISH_NORETURN. An error inside it exits the process via libakerror's default unhandled-error handler. The same hazard, in its more common form, is in Chapter 12 under akgl_registry_iterate_actor; install your own akerr_handler_unhandled_error if a game should survive it.

An actor borrows its character and takes no reference on it. Releasing a character out from under a live actor leaves a dangling basechar.

Known defects worth knowing here

  • Re-adding the same sprite for the same state leaks a reference. See above.
  • Long names register and unregister under different keys. akgl_character_initialize writes the registry entry under the caller's name pointer, while akgl_heap_release_character clears it under the character's own truncated 128-byte copy. For a name over 127 bytes those differ, and the registry entry survives the release — pointing at a zeroed slot. Latent for ordinary names; related to TODO.md, "Truncated registry keys can collide", which covers the collision half.
  • speedtime is written through an int * cast of a uint64_t field. src/character.c passes (int *)&obj->speedtime to akgl_get_json_integer_value. It works because the struct was zeroed and the platform is little-endian; it is the same class of cast-over-a-signature-mismatch that AGENTS.md argues against under "Never silence a warning with a cast".
  • akgl_character_initialize silently replaces a same-named character, and the one it displaced becomes unreachable rather than being released.

Where to look next

  • Chapter 10 — the sprites these map to.
  • Chapter 12 — the state bitmask itself, and what sets it.
  • Chapter 14 — what sx/sy and ax/ay mean once an actor is simulated.