Files
libakgl/docs/14-physics.md
Tachikoma eabb9ad376
Some checks failed
libakgl CI Build / cmake_build (push) Successful in 9m7s
libakgl CI Build / performance (push) Successful in 9m44s
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / memory_check (push) Has been cancelled
Move outstanding work from TODO.md into the issue tracker
TODO.md carried two records in one file: what had been done, with the
measurements behind it, and what was left. The second half is what a tracker
is for, and keeping it here has already cost something -- AGENTS.md records a
round where eleven entries described code that had already changed, and this
file admitted to three more.

Every open item is now an issue on source.starfort.tech/andrew/libakgl,
labelled by kind and blast radius and milestoned by what it can land in: 0.9.x
for anything that breaks no ABI, 0.10.0 for new or changed public symbols,
1.0.0 for the design work. Four are epics: the performance plan (#60),
coverage (#61), actor rotation (#62), and the false header comments (#63).

Verified against the tree before filing rather than transcribed. Three entries
were already fixed and were not filed: the akgl_path_relative context leak, the
akgl_draw_background test extension, and the SDL enumeration audit -- keyboards,
gamepads and mappings are all freed in CLEANUP today. Two were reworded because
the code had moved: the fonts item is a missing teardown entry point rather than
a missing API, since akgl_text_unloadallfonts exists, and draw_world's tilemap
call is already bounded by numlayers, so only the per-layer actor rescan remains.

TODO.md keeps the part a tracker has no place for: why a decision went the way
it did, what the measurement was, and which arguments turned out to be wrong.

TODO.txt is deleted. Four of its eight entries had shipped -- actor-to-actor
collision, actor-to-world collision, automatic facing, image layers -- and the
four that had not are #74 through #77, with the GPU renderer's research links
kept because that is the part that took the time.

Every reference that named an item number or a moved section is repointed, in
the manual, the headers, the tests and the examples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:47:34 -04:00

18 KiB

14. Physics

The physics subsystem has the same shape as the renderer: a record of function pointers plus an initializer that fills it in. Two backends ship. null accepts every call and changes nothing, which is what a menu screen or a test harness wants. arcade applies gravity, drag, thrust and a speed cap, which is what a 2D game wants.

There is no "physics body" type. The body is the actor plus the character it instantiates. An actor carries the position and the three velocity terms; its character supplies the accelerations and the top speeds. Nothing else participates. If you have read Chapter 11 and Chapter 12 you have already met every field this chapter uses.

The model

   thrust  (tx, ty, tz)   the actor's own effort, while it is moving
      +
   environ (ex, ey, ez)   gravity, less drag -- accumulates across steps
      =
   velocity(vx, vy, vz)   recomputed every step; writing it directly is overwritten
                          (a CHILD actor is the exception -- see below)

   caps    (sx, sy, sz)   from the character. Caps the THRUST VECTOR, not velocity
   accel   (ax, ay, az)   from the character, signed by the direction of travel

Two properties of that arrangement carry all the consequences.

The cap is on thrust, not on velocity. sx/sy/sz are the character's own top speed, not a speed limit on the world, and akgl_physics_arcade_gravity touches only the environmental term. So a falling actor accelerates straight past its stated top speed — which is the point, for a falling one. It also means there is no terminal velocity; see below.

The cap is on the thrust vector, scaled to an ellipse, not on each axis separately.

                          ty
                           |         Capping each axis against its own limit
                     ......|......   admits the corner of the box: an actor
                  ..'      |      '..  holding two directions got both caps
                 .'    +---+---+    '.   at once and travelled their
                :      |   |   |      :  diagonal -- 41% faster than either
                :  ----+---+---+----  :  alone. Scaling the vector to the
            ----:------+---+---+------:---- tx   ellipse keeps a character
                :      |   |   |      :  whose horizontal and vertical
                :.     +---+---+     .:  speeds differ moving at the ratio
                  '..  |   sx     ..'    it asked for, and makes them equal
                     ''|.......'''       where the speeds are.
                       |
            dotted ellipse: the cap      solid box: the old per-axis clamp
	overshoot = 0.0f;
	if ( actor->sx != 0.0f ) {
	    overshoot += (actor->tx / actor->sx) * (actor->tx / actor->sx);
	} else {
	    actor->tx = 0.0f;
	}
	if ( actor->sy != 0.0f ) {
	    overshoot += (actor->ty / actor->sy) * (actor->ty / actor->sy);
	} else {
	    actor->ty = 0.0f;
	}
	if ( actor->sz != 0.0f ) {
	    overshoot += (actor->tz / actor->sz) * (actor->tz / actor->sz);
	} else {
	    actor->tz = 0.0f;
	}
	if ( overshoot > 1.0f ) {
	    thrustscale = 1.0f / sqrtf(overshoot);
	    actor->tx *= thrustscale;
	    actor->ty *= thrustscale;
	    actor->tz *= thrustscale;
	}

An axis whose top speed is zero cannot be thrust along at all — that is what the old per-axis clamp did with it — and it stays out of the magnitude entirely, because dividing by it would not end well.

Only x and y ever accumulate thrust. akgl_physics_simulate reads the AKGL_ACTOR_STATE_MOVING_LEFT/RIGHT bits into tx and MOVING_UP/DOWN into ty. AKGL_ACTOR_STATE_MOVING_IN and MOVING_OUT exist and nothing reads them, so tz is only ever zeroed or scaled — the z axis has gravity, drag and movement but no thrust an actor can apply. Set tz yourself from a movementlogicfunc if you need depth motion.

One step, in order

akgl_physics_simulate is shared by both backends; what differs is the gravity and move they point at. It walks the whole actor pool in index order, and per actor:

  refcount == 0 ?      ------------------------------> skip (free pool slot)
  parent != NULL ?     -> x,y,z = parent's + own v, -> skip (never simulates)
  basechar == NULL ?   ------------------------------> skip (no speeds)
  LAYERMASK set and actor->layer != layerid ? -------> skip

  tx += ax * dt   if MOVING_LEFT or MOVING_RIGHT
  ty += ay * dt   if MOVING_UP   or MOVING_DOWN
  scale (tx,ty,tz) to the (sx,sy,sz) ellipse if it is outside

  +- ATTEMPT ----------------------------------------------------------+
  |  actor->movementlogicfunc(actor, dt)                               |
  |        AKGL_ERR_LOGICINTERRUPT raised here is swallowed: the actor |
  |        is skipped for the rest of the tick and the loop continues  |
  |  self->gravity(self, actor, dt)                                    |
  |  ex -= ex * drag_x * dt   (and y, z, each only if drag is nonzero) |
  |  vx = ex + tx             (and y, z)                               |
  |  self->move(self, actor, dt)                                       |
  +--------------------------------------------------------------------+

  after the loop:  self->gravity_time = curtime

A child actor is snapped and skipped. An actor with a parent has its position set to the parent's plus its own vx/vy/vz, read as a fixed offset rather than as a velocity, and then it is skipped entirely. Children do not simulate. That is the mechanism a party member or a held item is built on — see Chapter 12.

The thrust accumulation happens before movementlogicfunc, which is what sets ax and sx. So the ax and sx a step uses are the ones the previous step's movement logic installed, and an actor's very first step sees sx == 0 and has its thrust zeroed. The header's summary — "movement logic, then gravity, then drag, then velocity, then move" — leaves that out. It is one frame of lag at the start of a movement and no player will see it, but it is the reason a unit test that calls simulate once and checks tx gets zero.

gravity_time is stamped after the loop, not before it. A gravity or move that raises returns straight out of akgl_physics_simulate without stamping, so the next step measures dt from the older epoch and takes a double-length step — bounded by max_timestep, but still a visible jump. Treat a raised status out of simulate as needing a re-stamp of gravity_time before you continue.

dt is not yours to supply

akgl_physics_simulate takes no timestep. It calls SDL_GetTicksNS() itself and measures against self->gravity_time, then bounds the result:

  dt = (SDL_GetTicksNS() - self->gravity_time) / 1e9
  if ( max_timestep > 0 and dt > max_timestep )  dt = max_timestep
  if ( dt < 0 )                                  dt = 0

max_timestep comes from the physics.max_timestep property and defaults to AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP — a twentieth of a second, three frames at 60 Hz, so an ordinary dropped frame passes through untouched and a level load does not.

That bound is not a nicety. Everything below it multiplies by dt, so one enormous dt moves every actor by however far its velocity carries it over the whole stall. A quarter-second load under platformer gravity is a hundred pixels of fall between the first frame and the second, straight through whatever was underneath. That was a live defect until 0.6.0: gravity_time was never seeded, so the first step measured the entire time since SDL started. Both initializers seed it now, and the bound catches the rest. Advancing the world in slow motion through a hitch is the trade every engine makes here.

A zero or negative max_timestep disables the bound, for a caller who would rather have the real elapsed time and handle it themselves.

Stepping deterministically in a test

Because dt is measured rather than passed, there is no way to step the engine by an exact interval — but there is a way to step it by an exact bound. tests/physics_sim.c documents the only technique that holds up under load:

  sim_physics.max_timestep = dt;   /* the step you want */
  sim_physics.gravity_time = 0;    /* measured interval = whole process uptime */
  sim_physics.simulate(&sim_physics, NULL);

Zeroing gravity_time makes the measured interval the process's whole uptime, which is always past the bound, so the step taken is max_timestep and nothing else. The scheduler cannot get in the way of that. The first version of that helper set gravity_time to now - dt and let the real clock supply the step; a machine busy enough to deschedule the process between that store and the SDL_GetTicksNS() inside simulate produced a longer step and a red suite, exactly once, under a parallel ctest. That the engine cannot be stepped exactly is itself a finding, recorded as issue #31.

Two more rules from that suite, both learned the hard way. main must call SDL_Init — without it SDL sets its clock epoch on first use, SDL_GetTicksNS() returns something around 130 ns, and every dt-dependent assertion passes for a reason unrelated to the code. And read the numbers each simulation prints even when it is green: a change that keeps every assertion passing while moving the apex of a jump by 30 px is a change worth noticing.

Choosing a backend

    if ( strncmp(type->data, "null", 4) == 0) {
	PASS(errctx, akgl_physics_init_null(self));
	SUCCEED_RETURN(errctx);
    }
    if ( strncmp(type->data, "arcade", 6) == 0) {
	PASS(errctx, akgl_physics_init_arcade(self));
	SUCCEED_RETURN(errctx);
    }
    FAIL_RETURN(errctx, AKERR_KEY, "Invalid physics engine %s", type->data);

The match is on leading characters. "nullify" selects null and "arcadia" selects arcade; anything else raises AKERR_KEY quoting what was asked for. Adding a backend means adding a name here, not a branch in the simulation.

akgl_game_init does not call the factory, and there is no physics.engine property. The @file block and the akgl_physics_factory doc comment in include/akgl/physics.h both say otherwise. They are wrong: akgl_game_init points akgl_physics at akgl_default_physics — zeroed storage whose four method pointers are all NULL — and never initializes it, and the string physics.engine appears nowhere in src/. The startup sequence in include/akgl/game.h has it right: the application calls an initializer or the factory itself, at step 4, after the configuration properties are set and before the first frame. Skip that and the first akgl_game_update calls through a NULL simulate.

The only caller of akgl_physics_factory inside the library is akgl_tilemap_load_physics, driven by a map's physics.model custom property — see Chapter 13. util/charviewer.c shows the direct form, akgl_physics_init_null(akgl_physics). Correcting the header comments is filed separately; until then, believe game.h and this chapter.

#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/physics.h>
#include <akgl/registry.h>

/*
 * Bring up the arcade backend. akgl_game_init does NOT do this: it points
 * akgl_physics at akgl_default_physics, which is zeroed storage whose method
 * pointers are all NULL. Call an initializer, or the first akgl_game_update
 * calls through a NULL simulate.
 *
 * Properties first, because akgl_physics_init_arcade reads them.
 */
akerr_ErrorContext AKERR_NOIGNORE *bring_up_physics(void)
{
    PREPARE_ERROR(errctx);

    PASS(errctx, akgl_set_property("physics.gravity.y", "900.0"));
    /* There is no terminal-velocity setting. Drag is the mechanism: ey
     * approaches gravity_y / drag_y, so 900 / 1.8 caps the fall at 500 px/s. */
    PASS(errctx, akgl_set_property("physics.drag.y", "1.8"));

    PASS(errctx, akgl_physics_init_arcade(akgl_physics));
    SUCCEED_RETURN(errctx);
}

akgl_physics_init_arcade reads seven properties — physics.gravity.x/.y/.z, physics.drag.x/.y/.z and physics.max_timestep — all with defaults, so an arcade backend with no configuration behaves like the null one until something is set. Set the properties first. With AKGL_REGISTRY_PROPERTIES uninitialized every read comes back as its default and this silently configures zero gravity and zero drag rather than reporting anything; see Chapter 6.

The gravity signs are chosen for screen space: x pulls toward screen left, y pulls down because y grows downward, z pulls away from the camera.

AKGL_ERR_LOGICINTERRUPT

The one status in libakgl that is not a failure. Raised from an actor's movementlogicfunc, it means "skip the rest of this tick for me" — no gravity, no drag, no move for that actor this step — and akgl_physics_simulate handles it and carries on to the next actor. It is a control signal wearing an error's clothes.

Only movementlogicfunc gets that treatment. The gravity and move calls return straight out of the loop on any status, AKGL_ERR_LOGICINTERRUPT included, so a backend must never use it to opt an actor out from there — there it aborts the whole step, leaving the actors already processed advanced and the rest not.

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

/*
 * A movement logic function that pins an actor to a floor at y = 400.
 *
 * With no collision world attached the backend consults nothing, so standing
 * on something is the caller's job. Doing it here rather than after the step is
 * what keeps the actor from being drawn inside the floor for one frame. A game
 * that attaches a world (Chapter 15) deletes this function instead of writing
 * it.
 */
akerr_ErrorContext AKERR_NOIGNORE *stand_on_floor(akgl_Actor *obj, float32_t dt)
{
    PREPARE_ERROR(errctx);
    FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");

    /* The default logic still has to run: it is what copies the character's
     * speeds and accelerations onto the actor. */
    PASS(errctx, akgl_actor_logic_movement(obj, dt));

    if ( obj->y >= 400.0f ) {
	obj->y = 400.0f;
	/* ey is where gravity accumulates. Zero it, or the actor keeps
	 * "falling" into the floor and cannot jump out of it. */
	obj->ey = 0.0f;
    }
    SUCCEED_RETURN(errctx);
}

/* Raising this from movement logic means "skip the rest of this tick for me".
 * akgl_physics_simulate handles it and moves on to the next actor; the actor
 * gets no gravity, no drag and no move this step. */
akerr_ErrorContext AKERR_NOIGNORE *frozen_in_cutscene(akgl_Actor *obj, float32_t dt)
{
    PREPARE_ERROR(errctx);
    (void)dt;
    FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
    FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "cutscene");
}

What is not implemented

Stated plainly, because all of it is reachable by an ordinary-looking call and none of it announces itself at compile time.

Collision is Chapter 15, and it is opt-in. A backend with no collision world attached does exactly what it did before collision existed: move is position += velocity * dt and consults nothing. Attach a world and the step resolves after every sub-move.

What remains missing here is the feel, below, and that is a different list from the one this section used to carry.

Four gaps in the feel

Found by tests/physics_sim.c, which runs the arcade backend the way a game does — a Mario-esque jump and fall, Zelda-style top-down walking, and a run reversed at full speed — and prints what the actor actually did. Three other defects that suite found are fixed in 0.6.0. These four are not. All four are issues #29 through #32.

Gap What you see The workaround today
No terminal velocity Gravity adds gravity_y * dt to ey every step and nothing bounds it. The jump simulation reaches 560 px/s in 0.7 s and would keep going physics.drag.y. It makes ey approach gravity_y / drag_y instead of diverging. That is the only brake, and it is not documented as one anywhere else
Releasing a direction stops the actor dead akgl_actor_cmhf_*_off sets tx (or ty) to zero, so a character at full speed stops within one frame — 0.0 px of drift measured in the second after release Correct for Zelda, wrong for Mario. There is no friction or deceleration anywhere in the backend; ax is the only rate and it applies only while a direction is held. Decay tx yourself in movementlogicfunc
Explicit Euler, so trajectories are frame-rate dependent The same jump traces a slightly different arc at 30 Hz than at 144 Hz. The reversal measures 0.500 s to turn around where the closed form predicts 0.489 s — about 2% at 60 Hz, growing with the step Nothing. 2% is not a bug a player can see. It is recorded because the fix (a fixed-step accumulator) is the same piece of work as removing the slow-motion trade max_timestep makes
A large drag coefficient inverts velocity ex -= ex * drag_x * dt is a first-order decay, so it only decays for drag * dt < 1. Past that it overshoots zero; past 2 it diverges. Nothing rejects it With dt bounded to the default 0.05 s that needs a drag above 20, which is not a plausible setting. But max_timestep is caller-settable, so keep drag * max_timestep below 1

The _off handlers used to be worse: they zeroed ay, ey, ty and vy together, and ey is where gravity accumulates, so tapping down mid-jump stopped the character in the air. They clear ax/tx (or ay/ty) and nothing else now. Velocity was never theirs to clear either — simulate recomputes v as e + t every step.

Statuses

akgl_physics_simulate propagates whatever an actor's movementlogicfunc, or the backend's gravity, move or collide, raises, and the first failure aborts the whole step — the actors already processed are advanced and the rest are not. The status meanings are in Chapter 4; the ones you will meet here are AKERR_NULLPOINTER (a NULL self or self->move), AKERR_VALUE and ERANGE (a physics.* property that is not a number, or does not fit a double), and AKGL_ERR_LOGICINTERRUPT, which is not a failure.