Delete the sidescroller's collision, and let the library do it

collision.c was 383 lines that re-implemented akgl_physics_simulate's own
arithmetic, because movementlogicfunc runs before gravity, drag and the move
and so had to predict where the actor would end up. The whole file goes. What
replaces it is three lines in main.c, a shape on the player and the blob, and a
`collidable` property on the map's terrain layer -- which also retires
SS_TERRAIN_LAYER_ID, a compiled-in layer id that broke whenever somebody
reordered layers in Tiled.

grounded stays a probe rather than becoming a contact test. A contact says
something pushed back this step; grounded asks whether there is a floor to push
off, and that stays true through the frame after landing. The blob keeps its own
wall and ledge probes for the same reason: the resolver does not say which side
of the blob it was pushed from, and says nothing at all about a floor that is
missing.

The summary line now carries the player's resting position, because exiting 0
was not evidence that collision ran. It reports 136.0,160.0 grounded after 240
autoplay frames -- feet at y=192, flush on the surface of terrain row 12.

The tutorial's collision section is rewritten around the library's, keeping the
quarter-of-a-pixel story as what a predicting resolver costs. The docs harness
learns `json excerpt=`, so the map property the chapter quotes is checked
against the map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
2026-08-02 07:35:08 -04:00
parent 35a58670d0
commit 490e62dbbf
9 changed files with 232 additions and 596 deletions

View File

@@ -240,189 +240,135 @@ files, four characters, a map and a tileset image — happened between the backe
created and the first step. The `max_timestep` bound would have caught it, at the cost of
one visibly slow-motion frame. Re-stamping is cheaper. See [Chapter 14](14-physics.md).
## The collision libakgl does not have
## Collision, in three lines
Here is the whole problem, stated as three facts about `src/physics.c`:
The game does not implement collision. It attaches a world and the library resolves through
it:
- **`akgl_physics_arcade_collide` raises `AKERR_API`** with the message "Not implemented".
- **`akgl_physics_simulate` never calls `collide` at all** — not for the arcade backend, not
for the null one. The vtable slot exists and the simulation does not use it.
- **`akgl_physics_arcade_move` is `position += velocity * dt` and nothing else.** It does
not clamp to the map, consult the tilemap, or test anything.
```c excerpt=examples/sidescroller/main.c
PASS(errctx, akgl_collision_world_init(&ss_collision, NULL, (float32_t)SS_TILE_SIZE, (float32_t)SS_TILE_SIZE));
PASS(errctx, akgl_collision_bind_tilemap(&ss_collision, akgl_gamemap));
akgl_physics->collision = &ss_collision;
```
So an actor walks through a wall and off the edge of the world, and none of that announces
itself at compile time. [Chapter 14](14-physics.md) says so plainly and this game is what
the consequence looks like.
Plus a shape on anything that should collide, which is two lines per actor:
The only hook the physics step calls is the actor's own `movementlogicfunc`, so that is
where the collision goes. And that placement is awkward in exactly one way, which shapes
everything in `collision.c`:
```c excerpt=examples/sidescroller/player.c
PASS(errctx, akgl_collision_shape_box(&obj->shape, &ss_player_body, 0.0f));
obj->shape_override = true;
```
That is the whole of it. [Chapter 15](15-collision.md) is the reference; what follows is
what this game had to know.
### Why it resolves after the move and not before
The only per-actor hook the physics step used to offer was `movementlogicfunc`, and it runs
in the wrong place:
```text
akgl_physics_simulate, per actor:
tx += ax * dt <- thrust, from the previous step's ax
cap (tx,ty,tz) to the speed ellipse
movementlogicfunc(actor, dt) <- YOU ARE HERE
movementlogicfunc(actor, dt) <- the only hook there used to be
gravity(self, actor, dt) ey += gravity_y * dt
ex -= ex * drag_x * dt (and y, z)
vx = ex + tx (and y, z)
move(self, actor, dt) x += vx * dt
collide(self, actor, dt) <- where resolution actually happens
```
**Your hook runs before the step it has to resolve.** It cannot look at where the actor
ended up, because the actor has not moved yet. So the game predicts the step instead:
A resolver in `movementlogicfunc` runs before gravity, drag and the move, so it cannot look
at where the actor ended up — the actor has not moved yet. It has to *predict* the step,
which means copying the six lines above into the game, `!= 0` guards included, in a file
that will not be recompiled when they change.
```c excerpt=examples/sidescroller/collision.c
ex = obj->ex;
ey = obj->ey;
if ( akgl_physics->gravity_x != 0 ) {
ex -= (float32_t)akgl_physics->gravity_x * dt;
This tutorial used to carry that copy, and 383 lines around it. All of it is gone. `collide`
runs after `move` on a position that already happened, so there is nothing to predict.
**One symptom is worth keeping, because it is what a predicting resolver costs.** The
obvious thing for such a resolver to do on a blocked vertical axis is zero `ey`. It is
wrong: the step is about to add `gravity_y * dt` back and `move` commits `gravity_y * dt²`
of fall — a quarter of a pixel at 900 px/s² and 60 Hz. Invisible, and fatal. That quarter
pixel of overlap means the box intersects the floor tile, so on the *next* step the
horizontal sweep finds itself blocked wherever it goes and snaps back to a tile boundary.
The symptom is a character who cannot walk, jerking backwards by up to a tile every time it
tries, and nothing about it looks like a vertical problem. The fix was to pre-load the
cancellation — `ey = -gravity_y * dt` — which is arithmetic no game should ever have had to
write. Resolving after the move deletes the whole class.
### Solid is data now, not a layer id compiled into the game
The old version matched Tiled's numeric layer id, because **`akgl_TilemapLayer` does not
record the layer's name** — `akgl_tilemap_load_layers` reads `id`, `opacity`, `visible`,
`x`, `y` and `type` and nothing else, so there is no way to ask for "the layer called
terrain". A `#define SS_TERRAIN_LAYER_ID 2` broke the moment somebody reordered layers in
the editor.
The map says it instead. `level1.tmj`'s terrain layer carries a custom property:
```json excerpt=docs/tutorials/assets/sidescroller/level1.tmj
"properties": [
{
"name": "collidable",
"type": "bool",
"value": true
}
if ( akgl_physics->gravity_y != 0 ) {
ey += (float32_t)akgl_physics->gravity_y * dt;
}
if ( akgl_physics->drag_x != 0 ) {
ex -= ex * (float32_t)akgl_physics->drag_x * dt;
}
if ( akgl_physics->drag_y != 0 ) {
ey -= ey * (float32_t)akgl_physics->drag_y * dt;
}
*dx = (ex + obj->tx) * dt;
*dy = (ey + obj->ty) * dt;
]
```
That is `akgl_physics_simulate`'s own arithmetic in its own order, `!= 0` guards included —
dropping them would let a zero-gravity axis pick up drag, which the library does not do. Get
it wrong and the actor is resolved against a step it never takes.
`akgl_collision_bind_tilemap` reads it off every layer and the game names no layer at all.
Be clear about what this is: a copy of somebody else's implementation, in a file that will
not be recompiled when that implementation changes. It is a liability, and it is not a
design — it is what the missing `collide` call costs. When `akgl_physics_simulate` grows a
collision hook, this function is the first thing to delete.
### Standing on something is still measured
### Solid is a layer, not a flag
Nothing in libakgl records whether an actor is on the ground, and a contact does not answer
it either — a contact says something pushed back this step, which is a different question
from "is there a floor to push off". So it stays a probe:
There is no per-tile "solid" property. The level says what is solid by which layer a tile is
drawn on:
```c excerpt=examples/sidescroller/collision.c
*dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
```c excerpt=examples/sidescroller/main.c
PASS(errctx, akgl_collision_shape_bounds(shape, x, y + 1.0f, &feet));
PASS(errctx, akgl_collision_box_blocked(&ss_collision, &feet, AKGL_COLLISION_LAYER_STATIC, dest));
```
Finding that layer takes a small piece of knowledge that is easy to lose an afternoon to:
**`akgl_TilemapLayer` does not record the layer's name.** `akgl_tilemap_load_layers` reads
`id`, `opacity`, `visible`, `x`, `y` and `type` and nothing else, so there is no way to ask
for "the layer called terrain". The game matches on Tiled's numeric layer id instead:
One pixel, and only one: a taller probe reports a floor the actor is still falling towards,
and a jump that fires off it looks like the player jumped out of thin air.
```c excerpt=examples/sidescroller/collision.c
if ( (map->layers[i].type == AKGL_TILEMAP_LAYER_TYPE_TILES) &&
(map->layers[i].id == SS_TERRAIN_LAYER_ID) ) {
ss_terrain = &map->layers[i];
}
```
Off the left or right edge of the map counts as solid, so the level has walls at its ends.
Off the top or the bottom does not: the sky is open, and falling into the pit is the whole
point of the pit.
### Sweeping, not testing
A fall at 600 px/s with the step bounded to 0.05 s covers 30 pixels, which is nearly two
tiles. A single test at the destination walks straight through a floor. So the motion is
walked in sub-steps of at most half a tile, each axis separately — testing the axes
separately is what lets an actor slide along a wall instead of sticking to it — and a
blocked axis is snapped to the boundary it was about to cross rather than simply not moved,
so an actor lands flush at whatever speed it arrives:
```c excerpt=examples/sidescroller/collision.c
if ( stepy != 0.0f ) {
trial = *box;
trial.y += stepy;
PASS(errctx, ss_collide_box_blocked(&trial, &solid));
if ( solid == true ) {
if ( stepy > 0.0f ) {
box->y = (floorf((trial.y + trial.h) / SS_TILE_SIZE) * SS_TILE_SIZE) - trial.h;
} else {
box->y = (floorf(trial.y / SS_TILE_SIZE) + 1.0f) * SS_TILE_SIZE;
}
stepy = 0.0f;
dest->blocked_y = true;
} else {
box->y = trial.y;
}
}
```
Only a blocked axis is written back to the actor. The free axis is left for
`akgl_physics_arcade_move` to advance by exactly the amount that was predicted — writing it
here as well would move the actor twice.
### The quarter of a pixel that breaks everything
This one cost an afternoon and is the most useful thing in the chapter.
The obvious thing to do on a blocked axis is to zero the environmental term. It is wrong,
and the reason is the ordering again: the step is about to add `gravity_y * dt` back, and
`move` commits `gravity_y * dt²` of fall. At 900 px/s² and 60 Hz that is a quarter of a
pixel. Invisible — and fatal.
A quarter of a pixel of overlap means the actor's box intersects the floor tile. On the next
step the **horizontal** sweep therefore finds itself blocked wherever it tries to go, and
snaps the actor back to a tile boundary. The symptom is a character who cannot walk, jerking
backwards by up to a tile every time it tries. Nothing about it looks like a vertical
problem.
The fix is to pre-load the cancellation instead of zeroing:
```c excerpt=examples/sidescroller/collision.c
if ( dest->blocked_y == true ) {
obj->y = box.y - body->y;
obj->ey = -(float32_t)akgl_physics->gravity_y * dt;
obj->ty = 0.0f;
}
```
After the step's gravity, `ey` is exactly zero; after drag, still zero; `move` commits
nothing. A standing actor rests exactly on the surface, forever, at a `y` that is a whole
number of pixels.
### Standing on something is measured, not recorded
Nothing in libakgl knows whether an actor is on the ground. It is one probe, a pixel below
where the box will be when the step finishes:
```c excerpt=examples/sidescroller/collision.c
probe = box;
probe.y += 1.0f;
PASS(errctx, ss_collide_box_blocked(&probe, &solid));
dest->grounded = solid;
```
The verdict is one frame stale by the time the jump reads it, because this hook runs before
the step it is deciding about. One frame of coyote time is not something a player can feel.
The verdict is one frame stale by the time the jump reads it, because `movementlogicfunc`
runs before the step it is deciding about. One frame of coyote time is not something a
player can feel.
### Spawning inside the scenery
A hand-drawn level places a 32-pixel sprite on a 16-pixel grid, and `level1.tmj` puts the
player at x=32 with a step at tile (3,11) — under the right half of the player's frame. The
swept resolution cannot help: it stops an actor *entering* terrain and has nothing to say
about one that began inside it. What it does instead is refuse every horizontal move,
because the box is blocked wherever it goes.
player at x=32 with a step at tile (3,11) — under the right half of the player's frame.
Resolution cannot help: it stops an actor *entering* geometry and has nothing to say about
one that began inside it. What it does instead is refuse every move, so the actor is simply
stuck.
So spawn points get lifted clear once, before the first step:
```c excerpt=examples/sidescroller/collision.c
PASS(errctx, ss_collide_box_blocked(&box, &solid));
if ( solid == false ) {
SUCCEED_RETURN(errctx);
}
obj->y -= (float32_t)SS_TILE_SIZE;
```c excerpt=examples/sidescroller/player.c
PASS(errctx, akgl_collision_settle(&ss_collision, &obj->shape, &obj->x, &obj->y, 0));
```
The player and the blob both need it, and both end up standing on the block they were
overlapping. Failing after four tiles is deliberate: a spawn point buried that deep is a
level bug, and a silent nudge would hide it.
overlapping. `akgl_collision_settle` refuses loudly after four tiles rather than searching
forever: a spawn point buried that deep is a level bug, and a silent nudge would hide it.
### What the blob still asks for itself
The blob turns around at a wall or a ledge, and the resolver answers neither question — it
says something pushed back, not which side of the blob it was on, and it says nothing at all
about a floor that is *missing*. Both are queries:
```c excerpt=examples/sidescroller/actors.c
PASS(errctx, akgl_collision_shape_bounds(&obj->shape, (obj->x + step), obj->y, &ahead));
PASS(errctx, akgl_collision_box_blocked(&ss_collision, &ahead, AKGL_COLLISION_LAYER_STATIC, &wall_ahead));
PASS(errctx, ss_grounded(&obj->shape, obj->x, obj->y, &grounded));
```
This is what the query API is for: asking without being pushed.
## Making it feel like a platformer
@@ -508,7 +454,7 @@ The jump sprites are selected by setting `AKGL_ACTOR_STATE_MOVING_UP` whenever t
not on the ground:
```c excerpt=examples/sidescroller/player.c
if ( contact.grounded == true ) {
if ( ss_game.grounded == true ) {
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
} else {
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
@@ -579,12 +525,13 @@ Two rules go with it, both from [Chapter 4](04-errors.md):
- The `FAIL_RETURN` is outside any `ATTEMPT` block, as `AGENTS.md` requires. A `*_RETURN`
inside one returns past `CLEANUP`.
The blob is the counter-example: it stays inside the physics step, walks under the level's
gravity like the player does, and uses the same swept resolution. It turns around when the
sweep blocks it or when there is no floor one pixel past its leading edge:
The blob is the counter-example: it stays inside the physics step and walks under the
level's gravity like the player does, and the library resolves it the same way. All its hook
decides is which way to face next — a wall ahead, or no floor one pixel past its leading
edge:
```c excerpt=examples/sidescroller/actors.c
if ( (contact.blocked_x == true) || ((contact.grounded == true) && (floor_ahead == false)) ) {
if ( (wall_ahead == true) || ((grounded == true) && (floor_ahead == false)) ) {
data->facing = -data->facing;
```

View File

@@ -1227,7 +1227,14 @@
"visible": true,
"width": 40,
"x": 0,
"y": 0
"y": 0,
"properties": [
{
"name": "collidable",
"type": "bool",
"value": true
}
]
},
{
"draworder": "topdown",