A pure rename plus link rewrite and nothing else. Collision is a pluggable subsystem with two implementations, shapes, a response hook and a tile binding; that is a chapter, and burying it inside the physics chapter is the shape this manual otherwise avoids -- rendering and drawing are 8 and 9, spritesheets and characters and actors are 10, 11 and 12. Kept separate from writing the chapter because **nothing validates a cross-reference target.** The example harness reads fenced blocks; it does not follow links. A rename mixed into five hundred lines of new prose is not reviewable, and a broken link would land silently. As its own commit it is reviewable as a rename, and a grep for dangling `](NN-` targets is clean. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
szis not copied andazis 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 six 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 |
Replace them after akgl_actor_initialize, never before — it overwrites all six.
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
ALIVEand no longer matches a "standing still facing left" sprite. The implementation carries aTODOsaying as much. Two workarounds: map bareALIVEto something, or clearmovement_controls_faceand 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_actorrecurse 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: zeroingtxon release stops the actor dead. Correct for Zelda, wrong for Mario. Bind your own_offhandler that decaystxover 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
updatefuncfails; akgl_tilemap_scale_actorfailing;- an actor whose
renderfuncfails; - a
NULLuserdata— the iterator is required despite thevoid *.
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_actorrecurses with no cycle check.- Long names register and unregister under different keys.
akgl_actor_initializewrites the registry entry under the caller'snamepointer whileakgl_heap_release_actorclears 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 toTODO.md, "Truncated registry keys can collide". akgl_actor_initializesilently 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,vandaeach step. - Chapter 16 — control maps, and binding the handlers above.
- Chapter 08 — where
renderfuncgets called from. - Chapter 04 —
AKGL_ERR_LOGICINTERRUPTand the rest of the status tables.