# 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](11-characters.md) and [Chapter 12](12-actors.md) you have already met every field this chapter uses. ## The model ```text 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.** ```text 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 ``` ```c excerpt=src/physics.c 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: ```text 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](12-actors.md). **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: ```text 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: ```text 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 in `TODO.md` under "Arcade physics feel". 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 ```c excerpt=src/physics.c 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](13-tilemaps.md). `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. ```c #include #include #include #include /* * 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](06-the-registry.md). 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. ```c #include #include #include /* * A movement logic function that pins an actor to a floor at y = 400. * * The arcade backend never calls collide and never consults the tilemap, 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. */ 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. **There is no collision detection, at all.** - `akgl_physics_arcade_collide` always raises `AKERR_API` with the message "Not implemented". It is deliberately loud rather than silently permissive. - **`akgl_physics_simulate` never calls `collide`.** Not for the arcade backend, not for the null one, not ever. The vtable slot exists and the simulation does not use it. - `akgl_physics_arcade_move` does `position += velocity * dt` and nothing else. It does not clamp to the map, consult the tilemap, or test anything. **An actor walks straight through a wall and off the edge of the world.** `akgl_physics_null_collide` *succeeds*, and that is not an oversight: the null backend's contract is "nothing collides", which is an answer. The arcade one means "nobody has written this yet", and a caller who reaches it should find out. Until it exists, collision belongs in your `movementlogicfunc`, as in the example above. The sidescroller tutorial in [Chapter 19](19-tutorial-sidescroller.md) does exactly that and says why. `akgl_collide_*` in [Chapter 18](18-utilities.md) gives you the rectangle tests. ### 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 are in `TODO.md`, "Arcade physics feel". | 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` or `move`, 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](04-errors.md); the ones you will meet here are `AKERR_NULLPOINTER` (a `NULL` `self` or `self->move`), `AKERR_API` (`collide`, always), `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.