Commit Graph

7 Commits

Author SHA1 Message Date
cf930f68bb Add a second partitioner, so the vtable is a seam and not a decoration
A binary space partition on libakstdlib's tree links and lists, registered in
the factory as "bsp". It runs the same assertions the grid does, because
tests/partition.c is a table over partitioners and adding a row is all it takes
to be held to the contract.

**Use the grid.** This is here to prove the vtable works and to have something
to measure the grid against, and the file says so at the top. It rebuilds
whenever the proxy set changes, which is the shape PERFORMANCE.md records Phaser
using and capping out around five thousand bodies. It would earn its place in a
world with wildly non-uniform object sizes, or one larger than the grid's fixed
cell array covers.

The difference is visible in the code rather than buried in a benchmark: the
grid's `move` compares four integers and returns when a proxy has not left its
cells, and this one marks the whole partition stale. The incremental-move
assertion in the suite is therefore grid-only, and says why.

aksl_tree_iterate is not used, and the file carries the three reasons so the
next reader does not rediscover them: it is a complete traversal whose only
control signal stops the entire walk, so there is no way to prune a subtree --
which is the only operation a spatial query is made of; it carries no per-node
context, and a pruning descent needs each node's bounds and plane; and its
breadth-first modes allocate, while only the depth-first ones are malloc-free
and those are the ones without pruning. aksl_tree_insert is unusable for a
different reason again: it is a comparator-ordered BST, and a spatial insert has
to compare a leaf against a plane, which aksl_TreeCompareFunc cannot express.

What is used is the link structure and the lists, neither of which allocates,
which is why they fit here at all. The descent is an explicit stack rather than
recursion, so the depth bound is an array bound the compiler can see -- this
library already has one documented way to blow the C stack and does not need a
second.

Split planes are the median of the item centres on the longer axis, not the
spatial midpoint: actors in a tile game cluster on the floor, and a midpoint
split gives one empty child and one full one for several levels running. A split
that separates nothing degrades the node to a leaf rather than recursing forever
on the same set.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:08:54 -04:00
5e213061af Call collide from the simulation, after the move rather than before it
The collide slot has existed unused since the backend was written. It is wired
in now, and its signature changes from (self, a1, a2) to (self, actor, dt): the
old shape had nowhere to put a normal, a depth, or a piece of map geometry, and
no answer to which of the two actors it was supposed to be resolving.

**Resolution runs after `move`.** That is the decision everything else follows
from. A resolver called from movementlogicfunc runs before gravity, drag and the
move, so it has to predict where the actor will end up -- which means
re-implementing the integrator's arithmetic in the game, and being wrong
whenever the integrator changes. examples/sidescroller/collision.c carries forty
lines of exactly that, and says so.

Only `move` is subdivided. Gravity, drag, the thrust integration and the speed
ellipse all still run once against the whole dt, and sum(subdt) is dt, so an
actor with nothing to collide with takes one sub-step of exactly dt -- `dt/1.0f`
is dt exactly in IEEE-754 -- and follows the arithmetic path it always did. That
is what lets the integrator stay untouched and every recorded physics number
stay valid, and tests/physics.c and tests/physics_sim.c pass unchanged with no
world attached.

Collision is opt-in. A backend with no collision world costs one comparison per
actor per step and does nothing else.

Also here:

- `self->gravity` was never null-checked and is dereferenced unconditionally, so
  a hand-built backend with only `move` filled in crashed rather than reporting.
- Children are re-snapped in a post-pass, gated on collision being on. The
  in-loop snap uses whatever position the parent had when the child's slot came
  up in pool order, which is already sometimes a frame stale; that was harmless
  while a parent only moved by v*dt and is not once a parent can be pushed out
  of a wall mid-step.
- tests/physics.c's deliberate tripwire has been visited. It asserted that
  arcade collide always raised AKERR_API, with a comment saying it existed so
  that implementing collision would have to come back here. What replaces it
  asserts the opt-in contract instead.
- physics_sim.c's hard-coded "%d of 7 simulations" is derived now. A literal
  goes stale the first time somebody adds a simulation, which is this commit.

Three new whole-motion simulations, and the third took two attempts to make
honest:

- Landing and resting: 0.0000 px of drift over 120 steps after settling.
- Walking after landing: 107 px in a second, which is the symptom a player sees
  when a resting actor is caught on the ground it is standing on.
- A fast fall not tunnelling. The first version used gravity, so the step size
  grew every frame and the actor happened to land *inside* the floor rather than
  stepping over it -- it passed with sub-stepping disabled and proved nothing.
  It uses a constant 1200 px/s now, so every step is exactly 60 pixels against a
  32-pixel window in which an overlap exists, and the samples miss it cleanly.
  With sub-stepping the actor stops at y=288; without it, y=1300.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:05:09 -04:00
e632abf720 Let a map say which layers are solid, and answer questions about them
A tile layer with a `collidable` boolean custom property is collision geometry.
Before this a game had to hard-code a layer index -- both examples do, because
akgl_TilemapLayer does not retain the name Tiled wrote -- and that index changes
the moment somebody reorders layers in the editor.

Solid tiles are **not** given proxies. At the maximum map size that is a quarter
of a million per layer, tens of megabytes of index to describe data that is
already a dense grid sitting in the tilemap. The world keeps a borrowed pointer
and reads layers[i].data[] over whatever cell range a query covers: nine array
reads for a 32-pixel actor on 16-pixel tiles, nothing to build at level load,
and nothing to maintain per frame. Static geometry that is not tile-aligned is
still an ordinary proxy with AKGL_COLLISION_FLAG_STATIC; both mechanisms exist
and tiles use the free one because there are a hundred thousand of them.

Four public queries come with it, all of which answer without resolving:
solid_at, box_blocked, query_box and settle. They are what a game reaches for
when it wants to know rather than to be pushed -- a ledge probe ahead of a
walking enemy, a check that a doorway is clear -- and the sidescroller cannot
drop its hand-rolled collision without them.

akgl_collision_settle is the one worth naming. Resolution stops a shape entering
geometry and has nothing to say about one that began inside it: what it does
instead is refuse every move, so an actor spawned in a wall is simply stuck.
Level authors produce that constantly, so settling walks a shape up a tile at a
time and refuses loudly rather than searching forever.

Two defects found by writing the tests:

- The fixture put an akgl_Tilemap on the stack and segfaulted before the first
  assertion. It is about 26 MB -- the layer and tileset arrays are sized for the
  worst case the format allows -- and tilemap.h says so. It is static now, with
  a comment saying why, because the next person to write a map fixture will
  reach for a local first as well.

- The far-edge nudge used AKGL_COLLISION_EPSILON, which is 1e-6. That is a
  sensible tolerance on a unit vector and a meaningless one on a map coordinate:
  `float` carries about seven significant digits, so at a coordinate of 144 the
  smallest representable step is around 1.5e-5 and `144.0f - 1e-6f` is exactly
  144.0f. The nudge did nothing, a box resting flush on the floor read as inside
  it, and every move it tried looked blocked -- an actor standing on the ground
  unable to walk. Two different quantities were sharing one constant; the tile
  one is now its own, at a thousandth of a pixel, which is what the sidescroller
  example independently arrived at.

Both breaks verified: removing the nudge and ignoring the `collidable` bit each
turn the suite red with the symptom named.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:38:12 -04:00
68e042bd47 Add a pluggable broad phase, and the incremental uniform grid behind it
akgl_Partitioner is a record of function pointers and an initializer, the same
shape as the render and physics backends. `move` is its own slot rather than
remove-then-insert, and that is the whole design: a uniform grid's move is a
comparison and a return when the proxy has not left the cells it was in, which
is the steady state for a walking actor and the permanent state for a static
one. Spelling it as remove-and-insert would turn the incremental grid into the
rebuild-every-frame tree that PERFORMANCE.md already measured and rejected.

The grid is a dense 128x128 array of cell heads over the world, cells keyed on
tile size, with two intrusive chains per entry -- one through the cell so a query
can walk it, one through the proxy so removal unlinks a whole span without
searching. Everything is an int16_t index rather than a pointer, so the
structure is relocatable and clearing it is a memset. A proxy covering more
cells than AKGL_COLLISION_GRID_MAX_SPAN spills onto one chain every query walks,
which is what makes the cell pool's ceiling provable rather than hopeful.

tests/partition.c compares every query against a linear scan over the same
proxies and asserts containment in one direction, not equality. That asymmetry
is the contract: over-reporting costs a narrowphase call, under-reporting is a
wall an actor walks through. The suite is a table over partitioners so the same
assertions run against every implementation, which is what the second one
landing later will be held to.

Three deliberate breaks, and two of them were not caught the first time:

- Removing the min-cell rule so a shared-cell pair reports repeatedly: caught.
- Dropping the unlink in `move`: **not caught**, initially. Stale entries do not
  produce wrong query answers, because the bounds re-check filters them out --
  they leak the cell pool until the grid stops working, on a timescale no unit
  test reaches by accident. Counting live pool entries across 200 moves is the
  only thing that sees it, and now does: 505 entries against an expected 5.
- Swapping floorf for a truncating cast: **not caught, and correctly so.**
  Everything below zero is clamped to cell 0 either way, so today the two are
  indistinguishable. The comment claiming truncation is a defect was overstating
  it and now says what is actually true: the clamp is the only thing hiding it,
  and a negative world origin or a relaxed clamp would make it real with no test
  standing in front of it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:29:02 -04:00
a3cb6ca79a Answer whether two shapes overlap, and how to undo it
akgl_collision_test takes two proxies and fills in a contact: a unit normal, a
depth, and a point. Three paths, cheapest first.

The proxies' bounds reject most pairs in four comparisons and no arithmetic,
which is free because the bounds were computed when the proxy was synced.

A box against a box is answered in closed form. That is not only cheaper than
the general solver, it is *exact*, and the difference shows up where it matters
most: an actor resting on a floor wants a normal of precisely (0, -1, 0), and an
iterative solver converges to something like (0.0001, -0.99999, 0), which
accumulates into a slow sideways creep. A tile game is almost entirely boxes, so
this is the path that runs.

Everything else goes to ccdMPRPenetration. MPR rather than GJK+EPA because MPR
allocates nothing at all, converges in fewer iterations, and its one weakness --
a coarser contact *point* -- is on a field the blocking resolver never reads.
EPA stays compiled and available behind the arena for a caller who one day wants
an accurate manifold. The header says the point is approximate so nobody builds
a damage falloff on it.

The normal points out of the second shape and toward the first, so a caller
moving `a` along it by `depth` separates the pair. libccd hands back the
opposite convention; converting once here saves every resolver from remembering
the sign, and getting it backwards would compile, would pass any "do these
collide" test, and would drag actors into walls. There is a test that asserts
the direction, and inverting the conversion turns it red.

AKGL_COLLISION_TEST_PLANAR flattens the normal into the xy plane. The extrusion
the setters apply already makes z the most expensive axis, so this is a net for
a caller who set a depth by hand -- and the failure it catches is silent: the
narrowphase reports a contact, the resolver pushes the actor into the screen,
and the actor does not move on screen while remaining inside the floor. It also
rescues the degenerate case, two shapes at exactly the same centre, where there
is no planar direction at all and a zero-length normal would be a wall that
stops nothing. Level authors stack things constantly.

Three things the tests found rather than assumed:

- The guard has two copies, one per path, and the box one is where it earns its
  place. Removing both is caught; removing only the iterative one is not,
  because the iterative solver is seeded from the line between the two centres
  and so converges to a planar answer on its own for a planar offset. That is
  measured and written down in the test rather than papered over with a fixture
  contrived to force it.
- A concentric pair has no preferred sign on the degenerate axis, so the test
  asserts the magnitude of the normal rather than its direction. The first
  version asserted -1 and failed against an equally correct +1.
- The box fast path and the iterative solver are two implementations of one
  answer, so they are driven over the same arrangements and required to agree on
  the decision and the axis. A shortcut nothing cross-checks is a shortcut
  waiting to diverge.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:18:09 -04:00
d0b057f47a Pool collision proxies, and tie one to the life of its actor
A proxy is what the broad phase will index: an actor's shape, where it is, and
the bounds that follow from those. It is a sixth heap layer, two per actor --
one for the actor itself and headroom for static geometry a game registers that
is not tile-aligned. Solid *tiles* deliberately get none: the broad phase will
read those out of the tilemap's own array, because one proxy per tile is tens of
megabytes to index data that is already a grid.

The pool follows the existing convention rather than improving on it.
akgl_heap_next_collision_proxy finds a free slot and does not claim it;
akgl_collision_proxy_initialize claims it. That is TODO.md item 8's asymmetry,
and it is kept on purpose: an acquire abandoned before initialization leaks
nothing, because the slot still reads as free, and a sixth pool with its own
rule would be worse than one defect with five instances. Fixing item 8 means
fixing all five together with every call site audited, and that is its own
change.

The proxy holds a *copy* of the shape rather than a pointer. An actor's own
logic may rewrite its shape mid-step -- a character crouching, a projectile
arming -- and a broad phase whose bounds came from a shape that has since
changed is a bug that only appears when two things happen in the same frame.

akgl_heap_release_actor now releases the actor's proxy. Without it the proxy
holds a borrowed `owner` into a slot that has just been zeroed, so the broad
phase keeps a registration whose owner reads as a free actor, and the next
contact against it is a collision with nothing. Verified by removing the release
and watching test_proxy_dies_with_its_actor go red.

The tests pin the convention as well as the behaviour: that two acquires with no
initialize between them return the same slot is asserted, not merely tolerated,
so that changing the convention has to change a test that says why it exists.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:56:21 -04:00
9056ff06d6 Add collision shapes, on the character and overridable per actor
A shape is a convex volume positioned relative to an actor's origin. It lives on
akgl_Character, so every goblin sharing a character shares one shape definition
the way they already share speeds and the state-to-sprite map, and an actor may
override it with akgl_Actor::shape_override.

The flag is not redundant with an empty shape. "This actor deliberately has no
collider" and "this actor has not been given one yet" are different states, and
without somewhere to record the difference akgl_actor_set_character cannot tell
whether it is allowed to overwrite what it finds.

include/akgl/collision.h is a leaf: it names actors and tilemaps as incomplete
struct pointers rather than including their headers, because both of those need
akgl_CollisionShape by value and a cycle forms otherwise. The `headers` suite
compiles it as the first include of a translation unit, so that property is
enforced rather than merely intended -- which is why the tilemap types got struct
tags two commits ago.

The masks are asymmetric and the defaults are the load-bearing part: layermask
ACTOR, collidemask STATIC. An actor given a shape and nothing else collides with
map geometry and with no other actor. "Everything with a hitbox shoves everything
else" would be a surprising default for a town full of scenery and a painful one
to discover after the fact; opting in to actor-versus-actor is one bit, opting
out of it would have been a hunt through every NPC a game spawns.

Every shape gets a z depth, because the narrowphase behind this is three
dimensional and answers with the *minimum* separating translation. A thin
extrusion makes z the cheapest axis, and an actor pushed along z goes nowhere a
player can see while remaining inside the floor -- a collision system reporting
success while doing nothing. The ratio of 2 is the smallest for which the z
overlap of any two shapes the setters build provably exceeds any planar
penetration they can reach.

The test for that asserts the inequality across a spread of sizes rather than
asserting the constant is 2, so a change to the constant fails here rather than
in a game.

Two things about that test are worth recording, because the first version had
both:

- It could not fail. TEST_ASSERT expands to FAIL_BREAK, which reports by
  `break`ing, and the assertion was inside a nested `for` -- so the break left
  the loop rather than the ATTEMPT block and the failure evaporated. This is the
  hazard AGENTS.md documents for CATCH; it applies to the whole family. Verified
  by setting the ratio to 0.5 and watching the suite still pass, then fixing it
  and watching it report the 512x512 case by name.
- tests/collision_arena.c had the same bug in a loop added in the previous
  commit, and it is fixed here too.

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