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
/**
* @ file collision . h
* @ brief Collision shapes : what an actor occupies , and what it will collide with .
*
* A shape is a convex volume positioned relative to an actor ' s origin . It lives
* on the akgl_Character , so every goblin sharing a character shares one shape
* definition , exactly as they share speeds and the state - to - sprite map - - and an
* individual actor may override it when it needs to .
*
* @ section collision_leaf This header includes nothing of ours but types . h
*
* ` actor . h ` and ` character . h ` both need akgl_CollisionShape by value , so this
* header cannot include either of them without a cycle . It names actors and
* tilemaps as incomplete struct pointers instead , which is why they need struct
* tags and why ` akgl_Tilemap ` grew one . The ` headers ` suite compiles this file
* as the first include of a translation unit and would fail immediately if that
* ever stopped being true .
*
* @ section collision_extrusion Why a 2 D shape has a depth
*
* The narrowphase behind this is three - dimensional , which is what lets the same
* shapes carry into a 3 D game later . It answers with the * * minimum * * translation
* that separates two volumes - - and if a 2 D shape were extruded into a thin
* slab , the cheapest way to separate two of them would be along z . The
* narrowphase would report a contact , the resolver would push the actor into the
* screen , and on screen nothing would happen at all while the actor sank through
* the floor .
*
* So the setters give every shape a depth of # AKGL_COLLISION_DEPTH_RATIO times
* its largest planar half - extent unless told otherwise . That is not a large
* number chosen for comfort ; it is the smallest ratio for which the z overlap of
* any two shapes built this way is provably larger than any planar penetration
* they can reach , so z can never be the minimum axis . Pass a depth by hand only
* if you know what that costs .
*/
# ifndef _AKGL_COLLISION_H_
# define _AKGL_COLLISION_H_
# include <stdbool.h>
# include <stdint.h>
# include <SDL3/SDL_rect.h>
# include <akerror.h>
# include <akgl/types.h>
struct akgl_Actor ;
struct akgl_Tilemap ;
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
struct akgl_CollisionWorld ;
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
/** @brief No shape. The actor takes no part in collision. */
# define AKGL_COLLISION_SHAPE_NONE 0
/** @brief An axis-aligned box. The common case, and the one with a closed-form answer. */
# define AKGL_COLLISION_SHAPE_BOX 1
/** @brief A circle in the xy plane, extruded along z. `hx` is the radius and `hy` is ignored. */
# define AKGL_COLLISION_SHAPE_CIRCLE 2
/** @brief A capsule whose long axis is x: a box with semicircular caps left and right. */
# define AKGL_COLLISION_SHAPE_CAPSULE_X 3
/** @brief A capsule whose long axis is y: a box with semicircular caps top and bottom. */
# define AKGL_COLLISION_SHAPE_CAPSULE_Y 4
/** @brief No flags. */
# define AKGL_COLLISION_FLAG_NONE 0x00000000u
/** @brief Never moved by the resolver and never re-inserted into the broad phase. */
# define AKGL_COLLISION_FLAG_STATIC 0x00000001u
/** @brief Reports a contact and never pushes. Pickups, trigger volumes, damage zones. */
# define AKGL_COLLISION_FLAG_SENSOR 0x00000002u
/** @brief Present but ignored this step. Cheaper than dropping the shape and putting it back. */
# define AKGL_COLLISION_FLAG_DISABLED 0x00000004u
/**
* @ brief Reserved for a swept narrowphase . * * Not implemented ; setting it does nothing . * *
*
* Sub - stepping bounds how far an actor moves between collision tests , which
* stops anything moving at ordinary speeds from passing through a wall . A
* projectile is not moving at ordinary speeds . See ` TODO . md ` .
*/
# define AKGL_COLLISION_FLAG_BULLET 0x00000008u
/** @brief On no layer. A shape with this `layermask` collides with nothing. */
# define AKGL_COLLISION_LAYER_NONE 0x00000000u
/** @brief Map geometry: solid tiles and static proxies. The default `collidemask`. */
# define AKGL_COLLISION_LAYER_STATIC (1u << 0)
/** @brief Ordinary actors. The default `layermask`. */
# define AKGL_COLLISION_LAYER_ACTOR (1u << 1)
/** @brief Suggested layer for the player. Nothing in the library treats it specially. */
# define AKGL_COLLISION_LAYER_PLAYER (1u << 2)
/** @brief Suggested layer for hostiles. */
# define AKGL_COLLISION_LAYER_ENEMY (1u << 3)
/** @brief Suggested layer for things picked up rather than bumped into. */
# define AKGL_COLLISION_LAYER_PICKUP (1u << 4)
/** @brief Every layer, including the ones a game defines for itself in bits 5 and above. */
# define AKGL_COLLISION_LAYER_ALL 0xFFFFFFFFu
/**
* @ brief Depth given to a 2 D shape , as a multiple of its largest planar half - extent .
*
* 2 and not 1. At 1 , two identical squares fully overlapped have the same
* overlap on z as in the plane , and which axis the narrowphase calls " minimum "
* comes down to floating - point luck . At 2 the z overlap of any pair is at least
* twice the largest planar half - extent either can reach , and the planar
* penetration is at most that - - so z loses every time , by construction rather
* than by margin .
*/
# define AKGL_COLLISION_DEPTH_RATIO 2.0f
/**
* @ brief A convex volume , positioned relative to an actor ' s origin .
*
* Stored as a centre and half - extents rather than as an ` SDL_FRect ` , because
* that is the form both the overlap test and the narrowphase want and this way
* the conversion happens once , in a setter , instead of on every one of the tens
* of thousands of tests a frame . The setters take the rectangle form a game
* already has .
*/
typedef struct akgl_CollisionShape {
uint8_t kind ; /**< One of the `AKGL_COLLISION_SHAPE_*` values. #AKGL_COLLISION_SHAPE_NONE means this actor does not collide, and is what a zeroed shape is. */
uint32_t flags ; /**< Bitwise OR of the `AKGL_COLLISION_FLAG_*` values. */
uint32_t layermask ; /**< Which layers this shape is *on*. What other shapes test against. */
uint32_t collidemask ; /**< Which layers this shape *responds to*. Asymmetric on purpose: a player can block against an NPC while the NPC ignores the player. */
float32_t ox ; /**< Centre offset from the actor's `x`, in map pixels. */
float32_t oy ; /**< Centre offset from the actor's `y`. Positive is down, matching screen space. */
float32_t oz ; /**< Centre offset from the actor's `z`. */
float32_t hx ; /**< Half-extent along x. The radius, for a circle or a capsule. */
float32_t hy ; /**< Half-extent along y. Ignored for a circle. */
float32_t hz ; /**< Half-extent along z. Never 0 after a setter has run; see the extrusion note on this file. */
} akgl_CollisionShape ;
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
/**
* @ brief One shape ' s registration in the broad phase .
*
* A proxy is what the partitioner indexes . It carries a * * copy * * of the shape
* rather than a pointer to it , which is deliberate : an actor ' s own logic may
* legitimately rewrite its shape mid - step - - a character crouching , a projectile
* arming - - and a broad phase whose bounds were computed from a shape that has
* since changed is a source of bugs that only appear when two things happen in
* the same frame . The copy is refreshed once per step , from one place .
*
* Proxies are pool objects . The library creates and destroys them ; a game does
* not , and a game that releases an actor gets its proxy released with it .
*/
typedef struct akgl_CollisionProxy {
uint8_t refcount ; /**< Pool bookkeeping; 0 means the slot is free. First member, as in every pooled type here. */
struct akgl_Actor * owner ; /**< The actor this proxy tracks, or `NULL` for static geometry with no actor behind it. Borrowed; no reference is taken. */
akgl_CollisionShape shape ; /**< A copy of the owner's shape as of the last refresh. */
SDL_FRect bounds ; /**< Planar world bounds in map pixels, computed from #shape at (#x, #y). */
float32_t x ; /**< World position #bounds was computed from. */
float32_t y ; /**< World position #bounds was computed from. */
float32_t z ; /**< World position #bounds was computed from. Not represented in #bounds. */
uint32_t stamp ; /**< Sweep serial this proxy was last visited on, so a proxy spanning several cells is reported once. Written by the partitioner. */
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
int32_t cx0 ; /**< Grid cell rectangle currently occupied, inclusive. Written by the uniform grid only, and the thing its `move` compares against. */
int32_t cy0 ; /**< Grid cell rectangle currently occupied. */
int32_t cx1 ; /**< Grid cell rectangle currently occupied. */
int32_t cy1 ; /**< Grid cell rectangle currently occupied. */
int16_t first ; /**< Head of this proxy's chain of cell entries, or -1 when it is not in the grid. Written by the uniform grid only. */
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
} akgl_CollisionProxy ;
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
/**
* @ brief One proxy ' s membership of one grid cell .
*
* Two intrusive chains : one through the cell , so a query can walk what is in it ,
* and one through the proxy , so removing a proxy unlinks its whole span without
* searching . Everything is an index rather than a pointer , which keeps the
* structure relocatable and makes clearing it a ` memset ` .
*/
typedef struct akgl_CollisionCell {
uint8_t refcount ; /**< Pool bookkeeping; 0 means the slot is free. */
int16_t proxy ; /**< Index into the proxy pool. */
int32_t cell ; /**< Which cell, as `(row * columns) + column`. */
int16_t next ; /**< Next entry in this cell's chain, or -1. */
int16_t prev ; /**< Previous entry in this cell's chain, or -1, so removal is O(1). */
int16_t ownernext ; /**< Next entry belonging to the same proxy, or -1. */
} akgl_CollisionCell ;
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
/**
* @ brief Force every contact normal into the xy plane .
*
* A safety net rather than a mode . The extrusion the setters apply already makes
* z the most expensive axis to separate on , so a normal along z should be
* unreachable - - but a caller who set a depth by hand has opted out of that
* guarantee , and the failure it produces is silent : the resolver pushes the
* actor into the screen , which moves it nowhere a player can see while leaving
* it inside whatever it hit .
*
* Pass it for a 2 D game , which is every game today . When there is a real third
* axis to resolve on , leave it off .
*/
# define AKGL_COLLISION_TEST_PLANAR 0x00000001u
/**
* @ brief One overlap , described well enough to undo it .
*
* The narrowphase fills in the geometry . Which actor , which tile , and whether
* either side was a sensor are filled in by the layer above , which is the only
* one that knows .
*/
typedef struct akgl_Contact {
struct akgl_Actor * self ; /**< The actor being told about this contact. Filled in by the resolver, not the narrowphase. */
struct akgl_Actor * other ; /**< The other actor, or `NULL` when the hit was map geometry with no actor behind it. */
int32_t tilex ; /**< Tile column that was hit, or -1 when the other side was not a tile. */
int32_t tiley ; /**< Tile row, or -1. */
int32_t tilelayer ; /**< Index into the tilemap's layers, or -1. */
int32_t tilegid ; /**< Global tile id that was hit, or 0. This is what tells a spike from a floor without a second lookup. */
float32_t nx ; /**< Contact normal, unit length. **Points out of the other shape and toward this one**, so moving along it by #depth separates them. */
float32_t ny ; /**< Normal along y. Negative means the surface is *below*, since y grows downward. */
float32_t nz ; /**< Normal along z. Always 0 when #AKGL_COLLISION_TEST_PLANAR was used. */
float32_t depth ; /**< How far along the normal to move to stop overlapping, in map pixels. Never negative. */
float32_t px ; /**< A point on the overlap, in map pixels. Approximate for a non-box pair; see below. */
float32_t py ; /**< Contact point along y. */
float32_t pz ; /**< Contact point along z. */
float32_t dt ; /**< Length of the sub-step this contact was found in, in seconds. Filled in by the resolver. */
bool sensor ; /**< Either side carries #AKGL_COLLISION_FLAG_SENSOR: report, do not push. Filled in by the resolver. */
bool statichit ; /**< The other side does not move -- a tile, or a proxy flagged #AKGL_COLLISION_FLAG_STATIC. */
} akgl_Contact ;
/**
* @ brief Test two positioned shapes , and describe the overlap if there is one .
*
* Three paths , cheapest first . The proxies ' bounds reject most pairs outright .
* A box against a box is answered in closed form - - exact depth , exactly
* axis - aligned normal , no iteration - - which matters because a tile game is
* almost entirely boxes , and because a resting actor wants a normal that is
* precisely ` ( 0 , - 1 , 0 ) ` rather than one converged to within a tolerance , or it
* creeps . Everything else goes to the iterative narrowphase .
*
* @ note * * The contact point is approximate for anything but a box pair . * * The
* iterative solver returns a point on the portal it converged to , which
* for a deep off - centre overlap can sit noticeably away from the deepest
* point . The depth and the normal are not approximate , and the blocking
* resolver uses only those . Do not build a damage falloff on the point .
*
* @ param a First proxy . Required .
* @ param b Second proxy . Required .
* @ param flags Bitwise OR of the ` AKGL_COLLISION_TEST_ * ` values .
* @ param dest Receives the contact geometry when @ p hit comes back ` true ` .
* Required . The normal points out of @ p b and toward @ p a .
* @ param hit Receives whether the two overlap . Required .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If any pointer argument is ` NULL ` .
* @ throws AKGL_ERR_COLLISION If the solver could not characterise an
* intersection it found - - a degenerate shape , or the arena running out .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_test ( akgl_CollisionProxy * a , akgl_CollisionProxy * b , uint32_t flags , akgl_Contact * dest , bool * hit ) ;
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
/** @brief Longest partitioner name, including the terminator. */
# define AKGL_PARTITIONER_MAX_NAME_LENGTH 32
/**
* @ brief Visits one proxy a query found . Raise ` AKERR_ITERATOR_BREAK ` to stop early .
*
* A query is allowed to over - report and forbidden to under - report , so a visitor
* will see proxies that turn out not to overlap . Rejecting those is the
* narrowphase ' s job and it is cheap ; missing one is a wall an actor walks
* through .
*/
typedef akerr_ErrorContext AKERR_NOIGNORE * ( * akgl_CollisionVisitFunc ) ( akgl_CollisionProxy * proxy , void * data ) ;
/** @brief Visits one candidate pair. Raise `AKERR_ITERATOR_BREAK` to stop early. */
typedef akerr_ErrorContext AKERR_NOIGNORE * ( * akgl_CollisionPairFunc ) ( akgl_CollisionProxy * a , akgl_CollisionProxy * b , void * data ) ;
/**
* @ brief A pluggable broad phase : a record of function pointers and an initializer .
*
* The same shape as akgl_RenderBackend and akgl_PhysicsBackend , for the same
* reason - - behaviour that varies is a slot to fill , not a branch to add .
*
* @ section partitioner_move Why ` move ` is its own slot
*
* It could be spelled remove - then - insert , and that would throw away the entire
* argument for the structure that is installed by default . 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 forever for a static
* one . Collapsing it into remove - and - insert turns the incremental grid into the
* rebuild - every - frame tree that was measured and rejected .
*/
typedef struct akgl_Partitioner {
char name [ AKGL_PARTITIONER_MAX_NAME_LENGTH ] ; /**< Which implementation is installed. Diagnostic, and what the tests use to report which row failed. */
akerr_ErrorContext AKERR_NOIGNORE * ( * reset ) ( struct akgl_Partitioner * self , struct akgl_CollisionWorld * world ) ; /**< Drop every proxy and re-derive the structure's geometry from the world. */
akerr_ErrorContext AKERR_NOIGNORE * ( * insert ) ( struct akgl_Partitioner * self , akgl_CollisionProxy * proxy ) ; /**< Add a proxy at its current bounds. */
akerr_ErrorContext AKERR_NOIGNORE * ( * remove ) ( struct akgl_Partitioner * self , akgl_CollisionProxy * proxy ) ; /**< Take a proxy out. Removing one that is not in is success, not an error. */
akerr_ErrorContext AKERR_NOIGNORE * ( * move ) ( struct akgl_Partitioner * self , akgl_CollisionProxy * proxy ) ; /**< The proxy's bounds changed. See the note above on why this is not remove-then-insert. */
akerr_ErrorContext AKERR_NOIGNORE * ( * query ) ( struct akgl_Partitioner * self , SDL_FRect * area , uint32_t mask , akgl_CollisionVisitFunc visit , void * data ) ; /**< Visit every proxy that may overlap @p area and whose `layermask` intersects @p mask. Each proxy is visited at most once. */
akerr_ErrorContext AKERR_NOIGNORE * ( * each_pair ) ( struct akgl_Partitioner * self , akgl_CollisionPairFunc visit , void * data ) ; /**< Visit every candidate pair exactly once. */
void * state ; /**< The implementation's private storage. Never dereferenced outside the file that set it. */
} akgl_Partitioner ;
/**
* @ brief Broad - phase geometry and the partitioner that indexes it .
*
* One of these exists per world . It is reached by pointer and never embedded by
* value in anything , because the structures behind it are measured in tens of
* kilobytes and ` akgl_Tilemap ` is already large enough to matter .
*/
typedef struct akgl_CollisionWorld {
akgl_Partitioner partitioner ; /**< The broad phase. By value: it is a vtable and a `void *`, not storage. */
float32_t cellwidth ; /**< Broad-phase cell width in map pixels. */
float32_t cellheight ; /**< Broad-phase cell height. */
float32_t originx ; /**< World x the cell grid is measured from. 0 for a Tiled map. */
float32_t originy ; /**< World y the cell grid is measured from. */
uint32_t flags ; /**< `AKGL_COLLISION_TEST_*` bits applied to every test this world runs. */
uint32_t sweep ; /**< Serial of the current pass. Stamped onto proxies so one spanning several cells is reported once. */
uint32_t tests ; /**< Narrowphase calls during the last pass. Diagnostic. */
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
struct akgl_Tilemap * tilesource ; /**< The map whose solid tile layers are static geometry, or `NULL` for none. Borrowed. */
uint32_t tilelayers ; /**< Which of that map's layers are solid, copied from its `collidablelayers`. */
uint32_t tilelayermask ; /**< The collision layer solid tiles are treated as being on. #AKGL_COLLISION_LAYER_STATIC by default. */
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
} akgl_CollisionWorld ;
/**
* @ brief Bring up a collision world with a named partitioner .
*
* @ param self The world . Required . Zeroed first .
* @ param type ` " grid " ` for the incremental uniform grid , which is the default
* and what a game should use , or ` NULL ` for the same . Matched on
* leading characters , like akgl_physics_factory .
* @ param cellwidth Cell width in map pixels . Pass the map ' s tile width . Must be positive .
* @ param cellheight Cell height in map pixels . Must be positive .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p self is ` NULL ` .
* @ throws AKERR_VALUE If either cell dimension is not positive .
* @ throws AKERR_KEY If @ p type names no partitioner . The message quotes it .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_world_init ( akgl_CollisionWorld * self , char * type , float32_t cellwidth , float32_t cellheight ) ;
/**
* @ brief Install a partitioner by name .
*
* Adding an implementation means adding a name here , not a branch anywhere else .
*
* @ param self The partitioner to fill in . Required .
* @ param type ` " grid " ` , or ` NULL ` for the default . Matched on leading characters .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p self is ` NULL ` .
* @ throws AKERR_KEY If @ p type matches nothing .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_partitioner_factory ( akgl_Partitioner * self , char * type ) ;
/**
* @ brief Install the incremental uniform grid .
*
* The default , and the structure the performance record argues for : cells keyed
* on tile size , static storage , and insert and remove that happen only when a
* proxy crosses a cell boundary . A proxy that has not left its cells costs
* nothing to update , and a static one costs nothing ever .
*
* @ param self The partitioner to fill in . Required .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p self is ` NULL ` .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_partitioner_init_grid ( akgl_Partitioner * self ) ;
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
/**
* @ brief Make a map ' s solid layers count as collision geometry .
*
* @ section collision_tiles Tiles are read , not registered
*
* A map ' s solid tiles are * * not * * given proxies . At the maximum map size that
* would be a quarter of a million of them per layer - - tens of megabytes of
* index , to describe data that is already a dense grid sitting in the tilemap .
* Instead the world keeps a borrowed pointer to the map and reads
* ` layers [ i ] . data [ ] ` directly over whatever cell range a query covers , which is
* nine array reads for a 32 - pixel actor on 16 - pixel tiles and costs nothing to
* maintain when a level loads .
*
* Static geometry that is * not * tile - aligned - - a slope , a Tiled object
* rectangle , a platform that only moves between levels - - is still an ordinary
* proxy carrying # AKGL_COLLISION_FLAG_STATIC . Both mechanisms exist ; tiles use
* the free one because there are a hundred thousand of them .
*
* @ param self The world . Required .
* @ param map The map , or ` NULL ` to detach the one already bound .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p self is ` NULL ` .
* @ throws AKERR_VALUE If the map ' s tile dimensions are not positive .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_bind_tilemap ( akgl_CollisionWorld * self , struct akgl_Tilemap * map ) ;
/**
* @ brief Is the tile under this point solid ?
*
* A point query against the bound map ' s collidable layers , and nothing else - -
* it does not consult proxies . Off the map is * * not * * solid : a game that wants
* an edge of the world puts one there , and a pit that kills the player has to be
* a pit rather than a wall .
*
* @ param self The world . Required .
* @ param x World x in map pixels .
* @ param y World y in map pixels .
* @ param dest Receives whether a solid tile covers that point . Required .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p self or @ p dest is ` NULL ` .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_solid_at ( akgl_CollisionWorld * self , float32_t x , float32_t y , bool * dest ) ;
/**
* @ brief Would this rectangle overlap anything solid ?
*
* Tiles and proxies both . This is the query a game reaches for when it wants an
* answer without a response : a ledge probe ahead of a walking enemy , a check
* that a spawn point is clear , a door that only opens when nothing is standing
* in it .
*
* @ param self The world . Required .
* @ param box The rectangle , in map pixels . Required .
* @ param mask Which collision layers count . # AKGL_COLLISION_LAYER_ALL for
* everything , # AKGL_COLLISION_LAYER_STATIC for map geometry only .
* @ param dest Receives the answer . Required .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If any pointer argument is ` NULL ` .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_box_blocked ( akgl_CollisionWorld * self , SDL_FRect * box , uint32_t mask , bool * dest ) ;
/**
* @ brief Visit every proxy that may overlap a rectangle .
*
* The broad phase , exposed . Tiles are not proxies and so are not visited ; use
* akgl_collision_box_blocked when the question includes map geometry .
*
* @ param self The world . Required .
* @ param box The rectangle , in map pixels . Required .
* @ param mask Which collision layers to report .
* @ param visit Called once per candidate . Required .
* @ param data Passed through to @ p visit .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If any pointer argument is ` NULL ` .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_query_box ( akgl_CollisionWorld * self , SDL_FRect * box , uint32_t mask , akgl_CollisionVisitFunc visit , void * data ) ;
/**
* @ brief Lift a shape out of the geometry it was placed inside .
*
* Resolution stops a shape * entering * geometry and has nothing to say about one
* that started inside it - - what it does instead is refuse every move , so an
* actor spawned in a wall is simply stuck . That is not a hypothetical : level
* authors place a character on a step and Tiled rounds the object to a position
* that overlaps the tile below it .
*
* This walks the shape up a tile at a time until it is clear , and refuses rather
* than searching forever if it cannot be . Call it once , after a map load , for
* anything the map placed .
*
* @ param self The world . Required .
* @ param shape The shape being placed . Required .
* @ param x Position in map pixels . Read and written .
* @ param y Position in map pixels . Read and written ; this is what moves .
* @ param maxsteps How many tiles to try before giving up . 0 uses a sensible default .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If any pointer argument is ` NULL ` .
* @ throws AKERR_VALUE If the shape is still inside geometry after @ p maxsteps .
* The message carries the position , because the answer is almost always
* to move the object in the level rather than to raise the limit .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_settle ( akgl_CollisionWorld * self , akgl_CollisionShape * shape , float32_t * x , float32_t * y , int maxsteps ) ;
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
/**
* @ brief Resolve one actor against everything it currently overlaps .
*
* Called by the physics step after each sub - move , so it looks at where the actor
* * is * rather than predicting where it will be . That is what lets a game delete
* the arithmetic it would otherwise have to duplicate from the integrator .
*
* Tiles first , then proxies . Each contact goes to the actor ' s ` collidefunc ` , and
* the index entry is re - synced after every response , because a response that
* moves the actor invalidates everything computed before it .
*
* @ param self The world . Required .
* @ param actor The actor . Required . Returns success unchanged if it has no
* shape , no proxy or no response hook .
* @ param dt Length of the sub - step , in seconds . Carried on each contact .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p self or @ p actor is ` NULL ` .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_resolve ( akgl_CollisionWorld * self , struct akgl_Actor * actor , float32_t dt ) ;
/**
* @ brief How many pieces this actor ' s move has to be taken in .
*
* Bounds how far an actor travels between collision tests to less than the
* thinnest thing it could pass through - - a cell , or its own extent , whichever
* is smaller , because a small fast actor is the one that tunnels .
*
* Answers 1 when there is no world or the actor has no shape , and the caller
* then takes exactly the step it always did : ` dt / 1.0f ` is ` dt ` exactly , so a
* non - colliding actor follows a bit - identical arithmetic path .
*
* @ param self The world , or ` NULL ` for no collision .
* @ param actor The actor . Required .
* @ param dt Length of the whole step , in seconds .
* @ param dest Receives the count , never below 1. Required .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p actor or @ p dest is ` NULL ` .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_substeps ( akgl_CollisionWorld * self , struct akgl_Actor * actor , float32_t dt , int * dest ) ;
/**
* @ brief Bring every live actor ' s proxy into agreement with the actor .
*
* One pass over the actor pool at the top of a step : create a proxy for anything
* that has gained a shape , release one from anything that has lost its shape ,
* and refresh the rest . The only place a proxy is created or destroyed during a
* step , so nothing downstream has to reason about lifetimes .
*
* @ param self The world . Required .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p self is ` NULL ` .
* @ throws AKGL_ERR_HEAP If the proxy pool is exhausted .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_sync_actors ( akgl_CollisionWorld * self ) ;
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
/*
* The following is part of the internal API . Proxies are created and destroyed
* by the library , not by a game .
*/
/**
* @ brief Claim a pooled proxy for an actor ' s shape and take the reference on it .
*
* Follows the convention every pool here uses : akgl_heap_next_collision_proxy
* finds a free slot and does * * not * * claim it , and this takes the reference .
* Write the two adjacent , because until the reference is taken the slot is still
* free and the next acquire hands out the same pointer . That is ` TODO . md ` " Known
* and still open " item 8, it applies to four of the five existing pools, and
* this one does not depart from it - - a sixth convention would be worse than the
* defect .
*
* @ param obj The proxy to initialize . Required .
* @ param owner The actor it tracks , or ` NULL ` for static geometry .
* @ param shape The shape to copy into it . Required .
* @ param x World position of the owner .
* @ param y World position of the owner .
* @ param z World position of the owner .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p obj or @ p shape is ` NULL ` .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_proxy_initialize ( akgl_CollisionProxy * obj , struct akgl_Actor * owner , akgl_CollisionShape * shape , float32_t x , float32_t y , float32_t z ) ;
/**
* @ brief Recompute a proxy ' s bounds from its owner ' s current shape and position .
*
* Called once per step per live proxy , before the broad phase is asked anything .
* This is the one place the shape copy is refreshed .
*
* @ param obj The proxy . Required .
* @ param shape The current shape to copy . Required .
* @ param x Current world position of the owner .
* @ param y Current world position of the owner .
* @ param z Current world position of the owner .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p obj or @ p shape is ` NULL ` .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_proxy_sync ( akgl_CollisionProxy * obj , akgl_CollisionShape * shape , float32_t x , float32_t y , float32_t z ) ;
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
/**
* @ brief Claim a pooled grid cell entry , pointing it at a proxy and a cell .
*
* The other half of the acquire - then - initialize pair , as for proxies .
*
* @ param obj The entry . Required .
* @ param proxyidx Index of the proxy in the proxy pool .
* @ param cell Cell number , or - 1 for the oversized chain .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p obj is ` NULL ` .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_cell_initialize ( akgl_CollisionCell * obj , int16_t proxyidx , int32_t cell ) ;
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
/**
* @ brief Build an axis - aligned box from the frame - relative rectangle a game has .
*
* @ p body is an offset and a size measured from the actor ' s position - - the same
* form a game already writes a hitbox in , inset into its sprite frame because
* sprite art does not reach the edges of its cell . It is converted here to the
* centre - and - half - extent form everything downstream uses .
*
* The shape is zeroed first , so every field not named by the arguments ends at a
* known value : no flags , and the default masks of # AKGL_COLLISION_LAYER_ACTOR on
* and # AKGL_COLLISION_LAYER_STATIC responded to . That default is chosen so an
* actor given a shape and nothing else collides with the map and with nothing
* else - - a town full of NPCs does not start shoving itself around because
* somebody gave the townsfolk hitboxes .
*
* @ param dest Receives the shape . Required .
* @ param body Offset and size in map pixels , relative to the actor ' s position .
* Required . Both ` w ` and ` h ` must be positive .
* @ param depth Half - extent along z . Pass 0 for a 2 D game and the extrusion is
* chosen for you ; see the note on this file for why that matters .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p dest or @ p body is ` NULL ` .
* @ throws AKERR_VALUE If ` body - > w ` or ` body - > h ` is not positive , or @ p depth is
* negative .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_shape_box ( akgl_CollisionShape * dest , SDL_FRect * body , float32_t depth ) ;
/**
* @ brief Build a circle , extruded along z into a cylinder .
*
* A cylinder and not a sphere : a sphere ' s caps would round away from the plane
* and let a shape slip past a corner in a way a 2 D game never expects .
*
* @ param dest Receives the shape . Required .
* @ param ox Centre offset from the actor ' s ` x ` , in map pixels .
* @ param oy Centre offset from the actor ' s ` y ` .
* @ param radius Radius in map pixels . Must be positive .
* @ param depth Half - extent along z , or 0 to have it chosen .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p dest is ` NULL ` .
* @ throws AKERR_VALUE If @ p radius is not positive , or @ p depth is negative .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_shape_circle ( akgl_CollisionShape * dest , float32_t ox , float32_t oy , float32_t radius , float32_t depth ) ;
/**
* @ brief Build a capsule : a box with semicircular caps on one axis .
*
* Useful for a character that should slide off a corner rather than catch on it ,
* which a box does and a capsule does not .
*
* @ param dest Receives the shape . Required .
* @ param body Offset and size in map pixels , as for akgl_collision_shape_box .
* Required .
* @ param axis # AKGL_COLLISION_SHAPE_CAPSULE_X or # AKGL_COLLISION_SHAPE_CAPSULE_Y .
* @ param depth Half - extent along z , or 0 to have it chosen .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p dest or @ p body is ` NULL ` .
* @ throws AKERR_VALUE If either side is not positive , @ p depth is negative , @ p
* axis is neither capsule kind , or the capped axis is not the longer one
* - - a capsule whose caps are wider than it is long is a circle written
* confusingly , and is refused rather than silently reinterpreted .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_shape_capsule ( akgl_CollisionShape * dest , SDL_FRect * body , uint8_t axis , float32_t depth ) ;
/**
* @ brief The planar bounds a shape occupies with its owner at a given position .
*
* This is what the broad phase indexes on and what a cheap overlap test uses
* before anything more expensive runs . ` z ` is not represented : the extrusion
* exists to keep the narrowphase honest , not to be searched .
*
* @ param shape The shape . Required .
* @ param x The owner ' s ` x ` .
* @ param y The owner ' s ` y ` .
* @ param dest Receives the bounds in map pixels . Required .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If @ p shape or @ p dest is ` NULL ` .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_shape_bounds ( akgl_CollisionShape * shape , float32_t x , float32_t y , SDL_FRect * dest ) ;
/**
* @ brief Whether two shapes are allowed to interact , in the given direction .
*
* Asymmetric , and that is the point : @ p self responds to @ p other when @ p
* other ' s ` layermask ` intersects @ p self ' s ` collidemask ` . The reverse is a
* separate question with its own answer , which is how a player blocks against a
* pushable crate while the crate ignores everything .
*
* A shape of kind # AKGL_COLLISION_SHAPE_NONE , or one carrying
* # AKGL_COLLISION_FLAG_DISABLED , interacts with nothing in either direction .
*
* @ param self The shape doing the responding . Required .
* @ param other The shape being responded to . Required .
* @ param dest Receives whether the pair is worth testing . Required .
* @ return ` NULL ` on success , otherwise an error context owned by the caller .
* @ throws AKERR_NULLPOINTER If any argument is ` NULL ` .
*/
akerr_ErrorContext AKERR_NOIGNORE * akgl_collision_shape_interacts ( akgl_CollisionShape * self , akgl_CollisionShape * other , bool * dest ) ;
# endif