Files
libakgl/docs/12-actors.md
Andrew Kesterson 35a58670d0 Document collision, and correct what it made false elsewhere
The new chapter 15 covers the opt-in setup, where resolution runs in the step
and why it runs after the move, shapes and the z-extrusion trap, the asymmetric
masks and the defaults that matter more than the mechanism, tiles as a source
rather than proxies, the response hook and the three ways it is easy to write
wrongly, sensors, the four queries, both partitioners, and what is still not
implemented.

Collision falsified claims in eight other chapters. The physics chapter said
"There is no collision detection, at all" and that collide always raises
AKERR_API; actors had six behaviour hooks; the heap had five pools and four
non-claiming acquires; the status band held five codes; and the design
philosophy had exactly two pluggable subsystems. The appendix gains a
collision.h status section, the three new pool ceilings and a table of the
collision constants that are deliberately not in a public header.

Also fixes chapter labels the renumbering commit left pointing at their old
numbers -- "18. Utilities" linked to 19-utilities.md.

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

21 KiB

12. Actors

An actor is a live thing in the world. It carries only what is unique to it — where it is, which way it is moving, which animation frame it is on — and borrows speed, acceleration and the whole state-to-sprite map from the akgl_Character it points at. That is what makes a hundred of one kind of thing cheap. See Chapter 11 for the other half.

Actors are pool objects (akgl_heap_next_actor, 64 slots by default) published in AKGL_REGISTRY_ACTOR under their name.

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

akerr_ErrorContext *spawn_player(void)
{
    akgl_Actor *player = NULL;
    PREPARE_ERROR(errctx);

    ATTEMPT {
	CATCH(errctx, akgl_heap_next_actor(&player));
	/* Zeroes it, sets scale 1.0 and movement_controls_face, installs the
	   six default hooks, registers it, takes the first reference. */
	CATCH(errctx, akgl_actor_initialize(player, "player"));
	/* Until this runs the actor cannot update, render or simulate. */
	CATCH(errctx, akgl_actor_set_character(player, "hero"));

	player->state = AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN;
	player->visible = true;
	player->layer = 1;
	player->x = 64.0f;
	player->y = 64.0f;
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

akgl_actor_initialize does not set a character. akgl_actor_set_character looks one up by name, copies its sx/sy onto the actor and zeroes ax/ay so it starts from rest. The character is borrowed, not referenced. A name that is not registered comes back as AKERR_NULLPOINTER — a lookup miss reported with a pointer status rather than AKERR_KEY.

sz is not copied and az is not zeroed, so an actor's depth speed keeps whatever it had. The default movement logic re-copies all three every step, which papers over it for anything that simulates.

State is a 32-bit bitmask

An actor is facing left and moving left and alive at the same time, and the whole combination is the key that selects a sprite. It is not an enum and there is no current-state field.

Thirteen bits are defined:

#define AKGL_ACTOR_STATE_FACE_DOWN             (1 << 0)             // 1           0000 0000 0000 0001
#define AKGL_ACTOR_STATE_FACE_LEFT             (1 << 1)             // 2           0000 0000 0000 0010
#define AKGL_ACTOR_STATE_FACE_RIGHT            (1 << 2)             // 4           0000 0000 0000 0100
#define AKGL_ACTOR_STATE_FACE_UP               (1 << 3)             // 8           0000 0000 0000 1000
#define AKGL_ACTOR_STATE_ALIVE                 (1 << 4)             // 16          0000 0000 0001 0000
#define AKGL_ACTOR_STATE_DYING                 (1 << 5)             // 32          0000 0000 0010 0000
#define AKGL_ACTOR_STATE_DEAD                  (1 << 6)             // 64          0000 0000 0100 0000
#define AKGL_ACTOR_STATE_MOVING_LEFT           (1 << 7)             // 128         0000 0000 1000 0000
#define AKGL_ACTOR_STATE_MOVING_RIGHT          (1 << 8)             // 256         0000 0001 0000 0000
#define AKGL_ACTOR_STATE_MOVING_UP             (1 << 9)             // 512         0000 0010 0000 0000
#define AKGL_ACTOR_STATE_MOVING_DOWN           (1 << 10)            // 1024        0000 0100 0000 0000
#define AKGL_ACTOR_STATE_MOVING_IN             (1 << 11)            // 2048        0000 1000 0000 0000
#define AKGL_ACTOR_STATE_MOVING_OUT            (1 << 12)            // 4096        0001 0000 0000 0000

The trailing bit pattern in that table is the position within its own 16-bit half, not the full 32-bit value. actor.h says so at the top of the block; read as a whole word it looks like bit 16 restarts at bit 0, which it does not.

Bits 13 through 31 are undefined and are yours. They are declared as AKGL_ACTOR_STATE_UNDEFINED_13 .. _31 and — this is the useful part — they are already named at the matching indices in src/actor_state_string_names.c, so a character JSON can bind a sprite to one without any code change. Renaming a bit means editing both files at the same index; see Chapter 11.

state is an int32_t, so bit 31 makes it negative and its decimal property key is -2147483648. It works. Prefer the low undefined bits.

Two composite masks are provided:

#define AKGL_ACTOR_STATE_FACE_ALL              (AKGL_ACTOR_STATE_FACE_DOWN | AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_FACE_UP)
#define AKGL_ACTOR_STATE_MOVING_ALL            (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_MOVING_UP | AKGL_ACTOR_STATE_MOVING_DOWN)

MOVING_ALL does not include MOVING_IN or MOVING_OUT. It is the four planar directions only. Clearing it leaves the two depth bits set.

AKGL_BITMASK_HAS means all of them, not any of them

The four manipulation macros live in game.h:

Macro Means
AKGL_BITMASK_HAS(x, y) every bit of y is set in x((x) & (y)) == (y)
AKGL_BITMASK_HASNOT(x, y) at least one bit of y is missing from x
AKGL_BITMASK_ADD(x, y) set every bit of y in x; modifies x
AKGL_BITMASK_DEL(x, y) clear every bit of y in x; modifies x

The "all, not any" reading is the one that catches people. AKGL_BITMASK_HAS(state, AKGL_ACTOR_STATE_FACE_ALL) asks whether the actor is facing all four ways at once, which is never. To ask "is it facing at all", test (state & AKGL_ACTOR_STATE_FACE_ALL) directly, or AKGL_BITMASK_HASNOT against a single bit.

All five macros are fully parenthesized, so !AKGL_BITMASK_HAS(a, b) means what it reads as. Until 0.5.0 it expanded to a bare (x & y) == y and a negation bound to the &, parsing as !(a & b) == b. Nothing in the tree negated it, which is the only reason it was latent rather than live — and the test that distinguishes the two parses is a bit that is not set whose value is not 1, not the obvious one.

The seven behaviour hooks

Behaviour attaches as function pointers, not by inheritance. There is no actor subclass. akgl_actor_initialize installs the library's defaults on every actor, and a game that wants a different flavour of anything replaces the pointer on that one actor.

Hook Default Called by Job
updatefunc akgl_actor_update akgl_game_update's sweep per-frame logic: facefunc, then advance the animation if due
renderfunc akgl_actor_render akgl_render_2d_draw_world per-frame draw
facefunc akgl_actor_automatic_face akgl_actor_update choose the facing bits
movementlogicfunc akgl_actor_logic_movement akgl_physics_simulate, before gravity turn movement bits into signed acceleration
changeframefunc akgl_actor_logic_changeframe akgl_actor_update, when the frame is due step to the next animation frame
addchild akgl_actor_add_child you attach a child actor
collidefunc akgl_actor_collide_block akgl_collision_resolve, after the move answer a contact — see Chapter 15

Replace them after akgl_actor_initialize, never before — it overwrites all seven.

The movementlogicfunc slot is the interesting one, because it is where AKGL_ERR_LOGICINTERRUPT stops being a table row in Chapter 04 and becomes something you write. It is not a failure; it is a control signal meaning "skip the rest of this tick for this actor", and akgl_physics_simulate swallows it:

#include <akgl/actor.h>
#include <akgl/error.h>
#include <akgl/game.h>

/*
 * A movementlogicfunc that freezes a dying actor. AKGL_ERR_LOGICINTERRUPT is a
 * control signal, not a failure -- akgl_physics_simulate swallows it and skips
 * the rest of this actor's step. Only a movementlogicfunc may raise it; from a
 * backend's gravity or move it aborts the whole step.
 */
static akerr_ErrorContext *frozen_movement(akgl_Actor *obj, float32_t dt)
{
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
    if ( AKGL_BITMASK_HAS(obj->state, AKGL_ACTOR_STATE_DYING) ) {
	FAIL_RETURN(
	    errctx,
	    AKGL_ERR_LOGICINTERRUPT,
	    "%s is dying; skip its step",
	    (char *)obj->name);
    }
    PASS(errctx, akgl_actor_logic_movement(obj, dt));
    SUCCEED_RETURN(errctx);
}

akerr_ErrorContext *freeze_the_dying(akgl_Actor *obj)
{
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
    obj->movementlogicfunc = &frozen_movement;
    SUCCEED_RETURN(errctx);
}

The default movementlogicfunc re-copies the character's speed limits onto the actor each step — so editing a character takes effect next step — and sets ax/ay to plus or minus the character's acceleration according to which movement bits are set. It integrates nothing; the physics backend does that (Chapter 14). Opposing bits do not cancel: MOVING_LEFT wins over MOVING_RIGHT because it is tested first. An actor with no movement bits keeps its previous acceleration rather than being zeroed, which is why the input handlers clear it themselves on release.

The default facefunc clears every facing bit and sets the one matching the first movement bit it finds, in the order left, right, up, down — so a diagonal faces its horizontal component. An actor with movement_controls_face clear is left alone entirely, which is the hook for something that aims independently of the way it walks.

An actor that stops moving is left with no facing bit at all, rather than keeping the way it was last facing. Its state drops to bare ALIVE and no longer matches a "standing still facing left" sprite. The implementation carries a TODO saying as much. Two workarounds: map bare ALIVE to something, or clear movement_controls_face and set the facing bits yourself.

layer

layer is which tilemap layer the actor is drawn on, and it is set from the object layer it was placed in. akgl_render_2d_draw_world interleaves the map's layers with the actors standing on them, in ascending order — see Chapter 08.

Only layers 0 through 15 are drawn. The field is a uint32_t and nothing range-checks it, but draw_world's loop stops at AKGL_TILEMAP_MAX_LAYERS (16). An actor on layer 16 is never drawn and never reports why.

Parents and children

addchild attaches one actor to another so it moves with it — a carried lantern, a turret on a tank, a party member walking behind the hero. Up to AKGL_ACTOR_MAX_CHILDREN (8) per parent.

A child is not simulated. The physics step reaches it, sees a parent, snaps it to the parent's position plus its own offset, and moves on: no thrust, no gravity, no drag, no acceleration. That is the whole behaviour, and games that use it depend on exactly that.

The parent takes a reference on the child, and releasing a parent releases every child with it, recursively. There is no detach function; AKERR_RELATIONSHIP is what you get for attaching an actor that already has a parent, and the only way out is to release and rebuild. Running out of slots is AKERR_OUTOFBOUNDS.

Nothing checks for a cycle. Making an actor its own ancestor makes akgl_heap_release_actor recurse until the stack runs out. This builds a tree, not a graph, and the tree-ness is your responsibility.

The offset lives in two places, and they disagree

This is a real defect and it will bite anyone using children, so it gets stated plainly rather than buried:

    akgl_physics_simulate:   actor->x = parent->x + actor->vx     <- offset is vx
    akgl_actor_render:       dest.x  = parent->x + actor->x       <- offset is x

src/physics.c treats vx/vy/vz as the offset and writes the result into x/y/z, turning the child's x into an absolute world position. src/actor.c then treats x/y as an offset and adds the parent's position a second time. In akgl_game_update the physics step runs before the draw, so a child is drawn one full parent-offset away from where it should be.

actor.h documents both readings, in two different places, without noticing they contradict — akgl_Actor::x says "For a child, an offset from the parent" and akgl_Actor::vy says "On a child actor this is read as an offset from the parent instead."

It is invisible when the parent sits at the origin, which is why it survived. Until it is fixed, the guard is: do not call akgl_actor_render for a child through draw_world if you also run the physics step. Bind a renderfunc on the child that draws at obj->x/obj->y without re-adding the parent, or keep children purely decorative and position them yourself.

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

/* A lantern carried 8 px right of and 8 px above the player. */
akerr_ErrorContext *attach_lantern(akgl_Actor *player)
{
    akgl_Actor *lantern = NULL;
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player");

    ATTEMPT {
	CATCH(errctx, akgl_heap_next_actor(&lantern));
	CATCH(errctx, akgl_actor_initialize(lantern, "player lantern"));
	CATCH(errctx, akgl_actor_set_character(lantern, "lantern"));
	lantern->state = AKGL_ACTOR_STATE_ALIVE;
	lantern->visible = true;
	lantern->layer = player->layer;
	/* The physics step reads the offset out of vx/vy/vz. */
	lantern->vx = 8.0f;
	lantern->vy = -8.0f;
	CATCH(errctx, player->addchild(player, lantern));
    } CLEANUP {
    } PROCESS(errctx) {
    } FINISH(errctx, true);
    SUCCEED_RETURN(errctx);
}

The eight control handlers

The akgl_actor_cmhf_* functions ("control map handler function") are what akgl_controller_default binds the arrow keys and the D-pad to, and what a game's own akgl_Control bindings can point at. They take the control map's target actor, not a global "player", so they work for any number of locally controlled actors. See Chapter 16.

Handler Sets Clears first Writes
akgl_actor_cmhf_left_on MOVING_LEFT | FACE_LEFT FACE_ALL | MOVING_ALL ax = -basechar->ax
akgl_actor_cmhf_left_off MOVING_LEFT ax = 0, tx = 0
akgl_actor_cmhf_right_on MOVING_RIGHT | FACE_RIGHT FACE_ALL | MOVING_ALL ax = basechar->ax
akgl_actor_cmhf_right_off MOVING_RIGHT ax = 0, tx = 0
akgl_actor_cmhf_up_on MOVING_UP | FACE_UP FACE_ALL | MOVING_ALL ay = -basechar->ay
akgl_actor_cmhf_up_off MOVING_UP ay = 0, ty = 0
akgl_actor_cmhf_down_on MOVING_DOWN | FACE_DOWN FACE_ALL | MOVING_ALL ay = basechar->ay
akgl_actor_cmhf_down_off MOVING_DOWN ay = 0, ty = 0

ay is negated for up because y grows downward.

Two directions is not a diagonal. Every _on handler clears the whole of FACE_ALL | MOVING_ALL before setting its own two bits, so holding left and up at once moves in whichever was pressed last. That is deliberate — it keeps the state word to one facing and one direction, which keeps the sprite mapping in Chapter 11 from needing a mapping per diagonal. A game that wants diagonals binds its own handlers that AKGL_BITMASK_ADD without clearing, and maps the extra combinations.

The _off handlers clear exactly two fields. ax and tx for the horizontal pair, ay and ty for the vertical. They do not touch ex/ey and they do not touch vx/vy.

actor.h still says otherwise — the block comment above them describes the _off handlers as "zeroing acceleration, thrust, environmental velocity and velocity on that axis", and akgl_actor_cmhf_up_off and _down_off each carry a @note saying they zero ey. They did, and that was a defect: ey is where the arcade backend accumulates gravity, so tapping down mid-jump stopped the character in the air. Velocity was never theirs to clear either — simulate recomputes v as e + t every step. Those notes predate the fix.

Known defect (TODO.md, "Arcade physics feel"). There is no friction and no deceleration: zeroing tx on release stops the actor dead. Correct for Zelda, wrong for Mario. Bind your own _off handler that decays tx over time if you want momentum.

Releasing left while holding right also stops the rightward movement, since left_off clears the whole x axis whichever way the actor was going.

Warning: an error inside akgl_registry_iterate_actor exits the process

akgl_registry_iterate_actor is an SDL_EnumerateProperties callback. SDL callbacks return void, so it has nowhere to propagate an error to — it ends in FINISH_NORETURN, which logs the stack trace and then calls libakerror's unhandled-error handler, whose default implementation calls akerr_exit() and terminates the process.

Everything it can go wrong on is ordinary content:

  • a registry key with no pointer behind it — AKERR_KEY;
  • an actor whose updatefunc fails;
  • akgl_tilemap_scale_actor failing;
  • an actor whose renderfunc fails;
  • a NULL userdata — the iterator is required despite the void *.

A wrong name in an asset file therefore kills the game rather than skipping an actor. This is not theoretical: it is the first thing a reader hits when a sprite or character name is misspelled.

akgl_game_update does not use this callback — it sweeps the actor pool directly — so you reach it by driving a registry sweep yourself, which is what util/charviewer.c does:

SDL_EnumerateProperties(AKGL_REGISTRY_ACTOR, &akgl_registry_iterate_actor, (void *)&opflags);

(norun because it needs a live registry and a populated opflags; it is quoted to show the shape, and util/charviewer.c is the compiled instance of it.)

The same hazard is in akgl_character_state_sprites_iterate, which akgl_heap_release_character calls on every character release — so that one is on an ordinary path, not an unusual one. See Chapter 11.

If a game should survive these, install your own handler:

#include <akerror.h>
#include <akgl/actor.h>
#include <akgl/error.h>

static akerr_ErrorContext *last_callback_error = NULL;

/*
 * The default akerr_handler_unhandled_error calls akerr_exit(). Replacing it
 * is the only way an error raised inside an SDL callback does not take the
 * process with it. Take a reference: the context is released after this
 * returns.
 */
static void survive_unhandled_error(akerr_ErrorContext *errctx)
{
    if ( errctx == NULL ) {
	return;
    }
    LOG_ERROR(errctx);
    errctx->refcount += 1;
    last_callback_error = errctx;
}

void install_survivable_handler(void)
{
    akerr_handler_unhandled_error = &survive_unhandled_error;
}

Note what surviving costs you: the sweep carries on with the next entry, and the actor that failed simply did not update or draw this frame. Check last_callback_error somewhere, or you have traded a loud crash for a silent one.

The rest of the struct

A few fields worth calling out; the full annotated list is in the generated Doxygen for akgl_Actor.

Field Note
visible Deliberate hiding. Off-camera actors are skipped separately.
scale Draw scale. Written by akgl_tilemap_scale_actor, or forced to 1.0 when tilemap scaling is off — it is overwritten every frame, so setting it by hand does not stick.
actorData Your per-actor data. The library never reads or frees it.
curSpriteFrameId Index into the sprite's frameids playlist, not a frame number on the sheet. See Chapter 10.
movetimer Unused. Nothing in the library reads or writes it.
vx/vy/vz Recomputed each step as e + t. Writing them directly is overwritten — except on a child, where they are the offset.

Known defects worth knowing here

  • The parent/child offset is double-counted at draw time. See above.
  • akgl_heap_release_actor recurses with no cycle check.
  • Long names register and unregister under different keys. akgl_actor_initialize writes the registry entry under the caller's name pointer while akgl_heap_release_actor clears it under the actor's truncated 128-byte copy. Identical for ordinary names; divergent past 127 bytes, and then the registry keeps an entry pointing at a zeroed slot. Related to TODO.md, "Truncated registry keys can collide".
  • akgl_actor_initialize silently replaces a same-named actor, and the one it displaced becomes unreachable rather than being released.
  • No friction on release, and no terminal velocity under gravity — TODO.md, "Arcade physics feel". Chapter 14 covers both.

Where to look next

  • Chapter 11 — the template, and the state-to-sprite map.
  • Chapter 14 — what happens to t, e, v and a each step.
  • Chapter 16 — control maps, and binding the handlers above.
  • Chapter 08 — where renderfunc gets called from.
  • Chapter 04AKGL_ERR_LOGICINTERRUPT and the rest of the status tables.