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>
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>