Files
libakgl/docs/20-tutorial-sidescroller.md
Andrew Kesterson cbf7c1b6c2 Delete the JRPG's collision too, and wall the map with proxies
cell_solid and feet_blocked go, and so does the prediction in the player's
movementlogicfunc. That prediction was only ever exact because the town has zero
gravity and zero drag -- v is t, so `x + tx * dt` is where the step lands. A map
with gravity would have had to fold ey in as well, which is the game
re-implementing the integrator. Resolution runs after the move now, so there is
nothing to predict.

The map's decoration layer carries `collidable`, which retires JRPG_LAYER_SOLID:
akgl_TilemapLayer has no name member, so the game and the map had to agree on an
index out of band and inserting a layer in Tiled broke it.

The edge of the world is not on any layer, so it is four static proxies covering
the outer ring plus a tile of overhang -- one pool slot per side instead of a
hundred solid tiles, and the first use of the static-proxy path in either
example. They carry LAYER_STATIC explicitly: shape_box defaults a shape to
LAYER_ACTOR, and a wall left on that layer is a wall everything walks through.

Only the player gets a shape. NPCs stand still and are spoken to; the follower
is a child snapped to its parent every step.

Verified against the old implementation with the demo script rewritten to hold
one direction: holding left into a building stops the player at x=122 with both,
and holding up into the map's edge stops them at y=-4 with both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 07:46:19 -04:00

760 lines
34 KiB
Markdown

# 20. Tutorial: a 2D sidescroller
A complete game: a Tiled level, a player who runs and jumps, platforms that hold him up,
four coins to collect, a blob that patrols and a moth that flies. It is a header and three
translation units, rather more comment than code, and it builds and runs as part of this
repository's ordinary `ctest` run.
It used to be four translation units. The fourth was 383 lines of collision, and it is gone
— which makes this chapter as much about **where in the frame a thing has to happen** as
about what to write. That is the lesson that survived: the same test, run a few lines
earlier, is wrong in ways that take an afternoon to diagnose.
The program is `examples/sidescroller/`; the art, the map and the JSON are in
`docs/tutorials/assets/sidescroller/`. Every listing below is quoted straight out of those
files by the `docs_examples` test, so this chapter cannot describe a program that no longer
exists.
## Building it and running it
```sh norun
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build --parallel --target sidescroller
./build/examples/sidescroller/sidescroller
```
### Controls
| Key | Gamepad | Does |
|---|---|---|
| Left arrow | D-pad left | Run left |
| Right arrow | D-pad right | Run right |
| Space | South button (A) | Jump — hold it longer to jump higher |
Close the window to quit; there is no key bound to it.
Three flags exist for the smoke test and are useful by hand too: `--assets DIR` points
somewhere other than the compiled-in asset directory, `--frames N` (or
`AKGL_SIDESCROLLER_FRAMES`) exits after that many frames, and `--autoplay` drives the
player from a script instead of the keyboard.
## The level
`level1.tmj` is 40x15 tiles of 16 pixels — a world 640x240 pixels — with three layers:
a `background` tile layer, a `terrain` tile layer, and an `actors` object group. The camera
is a 480x240 window onto it that scrolls sideways.
```text
0 1 2 3
0123456789012345678901234567890123456789
0 ........................................
1 ........................................
2 ........................................
3 ........................................
4 ........................................
5 ........................................
6 ............................##..........
7 ..............##........................
8 ......................##................
9 ........##..............................
10 ........................................
11 ...#......#...............#......#......
12 ##################...###################
13 ##################...###################
14 ##################...###################
the terrain layer, every row of it. `#` is any non-zero tile; the three
empty columns at 18-20 are the pit, the two-tile runs on rows 6 to 9 are
the platforms, and the four single tiles on row 11 are steps.
```
The object group places seven actors: `player`, `coin1` through `coin4`, `blob1` and
`moth1`. Each is a Tiled object of type `actor` with a `character` string property and a
`state` **integer** property — the two custom properties
[Chapter 13](13-tilemaps.md) documents, and `state` is a number here even though character
JSON accepts the symbolic form. `20` is `AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_RIGHT`,
from the bit table in [Chapter 12](12-actors.md).
Nothing in the program creates an actor. **Loading the map does**, which is why the load
order below is what it is.
## Starting up
libakgl has exactly one startup order that works and it is written down at the top of
`include/akgl/game.h`. Three of its steps are the ones a first program gets wrong.
```c excerpt=examples/sidescroller/main.c
PASS(errctx, aksl_strncpy(
(char *)&akgl_game.name,
sizeof(akgl_game.name),
"libakgl sidescroller tutorial",
sizeof(akgl_game.name) - 1));
```
`akgl_game.name`, `.version` and `.uri` are required and have no defaults —
`akgl_game_init` refuses to run without all three, because the window title, SDL's
application metadata and the savegame compatibility check are built from them. `aksl_strncpy`
rather than `strncpy`, per [Chapter 19](19-utilities.md) and `AGENTS.md`: these are
fixed-width fields that end up as registry keys.
```c excerpt=examples/sidescroller/main.c
PASS(errctx, akgl_set_property("game.screenwidth", "960"));
PASS(errctx, akgl_set_property("game.screenheight", "480"));
PASS(errctx, akgl_render_2d_init(akgl_renderer));
```
**Properties before the renderer.** `akgl_render_2d_init` reads both dimensions out of
`AKGL_REGISTRY_PROPERTIES` and an unset one defaults to the string `"0"`, which asks SDL for
a zero-sized window rather than reporting anything. See [Chapter 6](06-the-registry.md).
The window is 960x480 and the view is 480x240, because libakgl draws in map pixels and has
no scale factor of its own — the only scaling it applies is the tilemap's perspective band,
and this map has none. A 16-pixel tile would otherwise be 16 screen pixels. SDL's logical
presentation supplies the missing factor:
```c excerpt=examples/sidescroller/main.c
FAIL_ZERO_RETURN(
errctx,
SDL_SetRenderLogicalPresentation(
akgl_renderer->sdl_renderer,
SS_VIEW_WIDTH,
SS_VIEW_HEIGHT,
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
AKGL_ERR_SDL,
"%s",
SDL_GetError()
);
```
That is an SDL call, not a libakgl one; it is documented in the
[SDL3 render API](https://wiki.libsdl.org/SDL3/SDL_SetRenderLogicalPresentation). The camera
then has to be told the same thing, because `akgl_render_2d_init` sized it from the window.
### `akgl_game_init` does not choose a physics backend
This is the one that produces a crash rather than a message, so it gets its own heading.
```c excerpt=examples/sidescroller/main.c
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
```
`akgl_game_init` points `akgl_physics` at `akgl_default_physics`, which is **zeroed storage
whose four method pointers are all `NULL`**, and never initializes it. There is no
`physics.engine` property either, whatever the `@file` block in `include/akgl/physics.h`
says — [Chapter 14](14-physics.md) has the details and the correction. Skip this call and
the first `akgl_game_update` calls through a `NULL` `simulate`.
### The low-FPS hook fires every frame for the first second
```c excerpt=examples/sidescroller/main.c
akgl_game.lowfpsfunc = &ss_lowfps;
```
`akgl_game.fps` is a completed-second average, so it reads 0 for the whole first second of
the process — which is under the threshold, so the default `akgl_game_lowfps` logs a line on
every frame until the second is up. The hook exists to be replaced; a real game sheds work
there. This one replaces it with an empty function so the log is readable.
## Loading in dependency order
```c excerpt=examples/sidescroller/main.c
for ( i = 0; ss_sprite_files[i] != NULL; i++ ) {
PASS(errctx, asset_path(assetdir, ss_sprite_files[i], (char *)&path, sizeof(path)));
PASS(errctx, akgl_sprite_load_json((char *)&path));
}
for ( i = 0; ss_character_files[i] != NULL; i++ ) {
PASS(errctx, asset_path(assetdir, ss_character_files[i], (char *)&path, sizeof(path)));
PASS(errctx, akgl_character_load_json((char *)&path));
}
```
Sprites, then characters, then the map. That is not a preference:
- a character's JSON names its sprites by **registry name**, and
`akgl_character_load_json` looks each one up as it reads the mapping, so a character
loaded first fails on the first sprite it cannot find ([Chapter 11](11-characters.md));
- an `actor` object names its character the same way, and the map loader creates the actor
and binds the character during the load ([Chapter 13](13-tilemaps.md)).
Nine sprite files and four character files, in a table rather than thirteen calls:
```c excerpt=examples/sidescroller/main.c
static char *ss_sprite_files[] = {
"sprite_ss_player_idle_left.json",
"sprite_ss_player_idle_right.json",
"sprite_ss_player_run_left.json",
"sprite_ss_player_run_right.json",
"sprite_ss_player_jump_left.json",
"sprite_ss_player_jump_right.json",
"sprite_ss_coin.json",
"sprite_ss_hazard_blob.json",
"sprite_ss_hazard_moth.json",
NULL
};
```
The map goes into `akgl_gamemap`, which already points at `akgl_default_gamemap`:
```c excerpt=examples/sidescroller/main.c
PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path)));
PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap));
```
That is 25 MiB of static storage in the library, and using it rather than declaring one is
not a micro-optimisation: `sizeof(akgl_Tilemap)` is three times a default 8 MiB thread
stack, so a local one is a segfault before the loader writes a byte. [Chapter 13](13-tilemaps.md)
has the size breakdown.
## The map brings its own physics
`level1.tmj` carries `physics.model`, `physics.gravity.y` and `physics.drag.y` as map-level
custom properties, so the loader built a backend for it and set `use_own_physics`. **Nothing
in the library acts on that flag** — `akgl_game_update` steps the global `akgl_physics` and
never looks at the map's — so honouring it is one line the game writes:
```c excerpt=examples/sidescroller/main.c
if ( akgl_gamemap->use_own_physics == true ) {
akgl_physics = &akgl_gamemap->physics;
```
The numbers are gravity 900 px/s² and drag 1.5. The drag is doing a job the engine has no
other word for. **There is no terminal velocity in libakgl**: `akgl_physics_arcade_gravity`
adds `gravity_y * dt` to the actor's `ey` every step and nothing bounds it — the simulation
in `tests/physics_sim.c` reaches 560 px/s in 0.7 s and keeps going. Drag is a first-order
decay applied to the same term, so `ey` converges on `gravity_y / drag_y` instead of
diverging: 900 / 1.5 = **600 px/s, and that is this level's terminal velocity**. It is the
only brake there is; `TODO.md`, "Arcade physics feel", records the gap.
Keep `drag * max_timestep` below 1. `ex -= ex * drag_x * dt` only decays while
`drag * dt < 1`; past that it overshoots zero and past 2 it diverges, and nothing rejects
it. With `physics.max_timestep` at its default 0.05 s that needs a drag above 20.
One more line before the loop starts:
```c excerpt=examples/sidescroller/main.c
akgl_physics->gravity_time = SDL_GetTicksNS();
```
`dt` is measured from `gravity_time`, not passed in, and everything above — nine sprite
files, four characters, a map and a tileset image — happened between the backend being
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).
## Collision, in three lines
The game does not implement collision. It attaches a world and the library resolves through
it:
```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;
```
Plus a shape on anything that should collide, which is two lines per actor:
```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) <- 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
```
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.
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
}
]
```
`akgl_collision_bind_tilemap` reads it off every layer and the game names no layer at all.
### Standing on something is still measured
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:
```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));
```
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.
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.
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/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. `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
Two of libakgl's documented gaps are about feel rather than correctness, and both are in
`TODO.md` under "Arcade physics feel". The tutorial works around both rather than pretending.
### Releasing a direction stops the actor dead
`akgl_actor_cmhf_left_off` clears the movement bit, zeroes `ax`, **and zeroes `tx`**. There
is no friction and no deceleration anywhere in the arcade backend, so a character at full
speed stops within one frame — 0.0 px of drift, measured. That is correct for a top-down
Zelda and wrong for a sidescroller.
The game binds its own release handlers, which do everything the library's do except the
last part:
```c excerpt=examples/sidescroller/player.c
static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
obj->ax = 0.0f;
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT);
SUCCEED_RETURN(errctx);
}
```
and decays `tx` in the movement logic instead, faster on the ground than in the air:
```c excerpt=examples/sidescroller/player.c
friction = SS_FRICTION_AIR;
if ( ss_game.grounded == true ) {
friction = SS_FRICTION_GROUND;
}
obj->tx -= obj->tx * friction * dt;
if ( fabsf(obj->tx) < 1.0f ) {
obj->tx = 0.0f;
}
```
The snap to zero below a pixel per second is there because an exponential decay never
actually arrives.
The `_on` handlers are the library's unchanged. `akgl_actor_cmhf_right_on` clears
`FACE_ALL | MOVING_ALL`, sets `MOVING_RIGHT | FACE_RIGHT` and signs `ax` from the character
— exactly right. Only the release half needed replacing. [Chapter 16](16-input.md) covers
building a control map; the whole of this game's is six bindings, three keyboard and three
gamepad.
### A jump is an impulse into `ey`, not thrust
```c excerpt=examples/sidescroller/player.c
if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) {
obj->ey = -SS_JUMP_SPEED;
}
ss_game.jump_requested = false;
```
It has to be the environmental term. `ey` is the axis gravity accumulates on, and the two
have to cancel for the arc to come back down. Written as thrust it would not work at all:
`ty` is capped against the character's `speed_y`, which is `0.0` for this character, so
`akgl_physics_simulate`'s ellipse cap scales it to nothing. See the thrust-versus-velocity
model in [Chapter 14](14-physics.md).
`ey` being an ordinary field that nothing else owns between steps also buys variable jump
height for four lines — releasing the button while still rising cuts the remaining
velocity:
```c excerpt=examples/sidescroller/player.c
if ( obj->ey < 0.0f ) {
obj->ey *= 0.4f;
}
```
## The state word chooses the sprite, and one bit is missing
An actor's whole 32-bit `state` is the key that selects a sprite ([Chapter 12](12-actors.md)).
The player's character binds ten combinations: idle and running each way, and jumping each
way.
The jump sprites are selected by setting `AKGL_ACTOR_STATE_MOVING_UP` whenever the actor is
not on the ground:
```c excerpt=examples/sidescroller/player.c
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);
}
```
Nothing else reads the bit here. `speed_y` and `acceleration_y` are both `0.0` in
`character_ss_player.json`, so the vertical thrust it would authorise is capped to nothing —
the bit is being used purely as an animation selector, which is a thing the state mask is
good for.
The missing bit is the one that makes the player disappear. The default `facefunc`,
`akgl_actor_automatic_face`, clears every facing bit and then sets one from the *movement*
bits — so **an actor that stops moving is left facing nowhere**, its state drops to bare
`ALIVE`, and there is no sprite for that. It is not an error: `akgl_actor_render` handles
the `AKERR_KEY`, treats the actor as invisible, and draws nothing. A player who stops
walking vanishes.
The documented way out is to take the facing bits off the library entirely:
```c excerpt=examples/sidescroller/player.c
obj->movement_controls_face = false;
```
`akgl_actor_automatic_face` leaves an actor alone when that is clear, so the facing bits stay
wherever the control handlers last put them — and `akgl_actor_cmhf_left_off` clears
`MOVING_LEFT` without touching `FACE_LEFT`, which is exactly the behaviour a standing sprite
needs. The alternative, mapping bare `ALIVE` to something, is in
[Chapter 12](12-actors.md).
## `AKGL_ERR_LOGICINTERRUPT`, in a game
The one status in libakgl that is not a failure. Raised from a `movementlogicfunc` it means
"skip the rest of this tick for me" — no gravity, no drag, no move — and
`akgl_physics_simulate` handles it and carries on to the next actor.
Two of this game's three non-player behaviours are built on it, for two different reasons.
The coins need it because **gravity is not thrust**. Their character declares no speed and
no acceleration, so they cannot thrust; `akgl_physics_arcade_gravity` accumulates into `ey`
for every actor it is handed regardless, and a coin left to the default logic falls out of
the level with everything else.
```c excerpt=examples/sidescroller/actors.c
static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt)
{
PREPARE_ERROR(errctx);
(void)dt;
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s does not simulate", (char *)obj->name);
}
```
The moth needs it because it flies and the level's physics has gravity, which the player
needs. Rather than fighting the backend it writes its own position and opts out:
```c excerpt=examples/sidescroller/actors.c
data->phase += dt;
obj->x = data->home_x + (sinf(data->phase) * 64.0f);
obj->y = data->home_y + (sinf(data->phase * 2.0f) * 24.0f);
```
Two rules go with it, both from [Chapter 4](04-errors.md):
- **Only `movementlogicfunc` gets this treatment.** The backend's `gravity` and `move` are
called through `PASS` in the same block, so a raise from either aborts the whole step —
leaving every remaining actor unsimulated and `gravity_time` unadvanced.
- 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 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 ( (wall_ahead == true) || ((grounded == true) && (floor_ahead == false)) ) {
data->facing = -data->facing;
```
### Per-actor data
`akgl_Actor::actorData` is a `void *` the library never reads or frees, and it is where the
blob's patrol direction and the moth's flight phase live. It points into a fixed table:
```c excerpt=examples/sidescroller/actors.c
static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT];
```
A table rather than an allocation, for the same reason libakgl has pools rather than a
`malloc`: the level places a known number of things, and a game that cannot run out of
memory at runtime is one fewer failure mode. See [Chapter 5](05-the-heap.md).
## Picking things up
Collecting a coin is a rectangle test from [Chapter 19](19-utilities.md) and a pool release:
```c excerpt=examples/sidescroller/player.c
PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other));
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
if ( hit == true ) {
```
```c excerpt=examples/sidescroller/player.c
PASS(errctx, akgl_heap_release_actor(ss_game.coins[i]));
ss_game.coins[i] = NULL;
ss_game.coins_taken += 1;
```
**There is no despawn call.** Giving the pool slot back is what clears the registry entry
and stops the actor being drawn, and it is safe to do from inside `akgl_game_update`'s actor
sweep: that loop re-reads `refcount` at the top of every iteration and skips a slot that has
gone free. Releasing an actor does not touch its character, so the other coins are
unaffected.
This runs in the player's `updatefunc` rather than its `movementlogicfunc`, and the split is
the frame's own order. `akgl_game_update` calls every actor's `updatefunc`, *then* steps the
physics, *then* draws. Game logic that is not movement belongs in the first pass; anything
that has to happen inside the physics step has only the one hook.
## The frame
```c excerpt=examples/sidescroller/main.c
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
```
```c excerpt=examples/sidescroller/main.c
PASS(errctx, akgl_game_update(NULL));
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
```
`akgl_game_update` is update-every-actor, step-the-physics, draw-the-world. It does **not**
clear or present, so the frame is bracketed by the backend's own calls — see
[Chapter 7](07-the-game-and-the-frame.md) and [Chapter 8](08-rendering.md).
Note the failure contract. **`akgl_game_update` returns holding the game state lock on every
one of its failure paths.** SDL mutexes are recursive so a single-threaded loop does not
deadlock on the next frame, but a frame that failed has left the world half-stepped: some
actors advanced and some not. Treat it as terminal, which is what `PASS` does here.
Events go through `akgl_controller_handle_event` unconditionally — an event that no control
map binds is not an error, it is a call that did nothing, which is what lets a host pump
everything through one path.
## The camera
```c excerpt=examples/sidescroller/main.c
limit = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) - akgl_camera->w;
akgl_camera->x = (ss_game.player->x + 16.0f) - (akgl_camera->w / 2.0f);
```
`akgl_camera` is a plain `SDL_FRect` in map pixels that `akgl_tilemap_draw` and
`akgl_actor_render` both read. Moving it is the whole of scrolling; there is no camera
object and no follow behaviour to configure. It is floored to a whole pixel afterwards
because `akgl_tilemap_draw` truncates it when working out how much of the edge tiles to
show, and a camera that is fractionally different every frame makes the tile grid shimmer.
The camera is updated before `akgl_game_update` and therefore uses the player's position
from the end of the *previous* step. Correcting that would mean splitting the update from
the draw, which `akgl_game_update` does not offer.
## Teardown
```c excerpt=examples/sidescroller/main.c
IGNORE(akgl_text_unloadallfonts());
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
if ( akgl_heap_actors[i].refcount > 0 ) {
IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i]));
}
}
if ( akgl_gamemap != NULL ) {
IGNORE(akgl_tilemap_release(akgl_gamemap));
}
TTF_Quit();
MIX_Quit();
SDL_Quit();
```
The `NULL` guard on the map is not defensive padding. This function is called from the
program's `CLEANUP` block, which is reached whether startup succeeded or not — and a bad
`--assets` path fails before `akgl_game_init` has pointed `akgl_gamemap` at anything.
**There is no `akgl_game_shutdown`.** Teardown belongs to the application, and the order
matters in one place: `akgl_text_unloadallfonts` has to run before `TTF_Quit` or `SDL_Quit`,
because those destroy the fonts underneath the registry that still points at them
([Chapter 17](17-text-and-fonts.md)). This game loads no fonts and calls it anyway, because
the ordering is the thing worth copying.
What this does **not** do is unwind the sprite, spritesheet and character pools. They are
static storage in a process that is exiting and the objects still reference each other; a
game that loads a second level has to do it properly, and this one does not pretend to. Two
things to know before you write that code:
- `akgl_heap_release_character` calls `akgl_character_state_sprites_iterate` on every
release, and that function is an `SDL_EnumerateProperties` callback ending in
`FINISH_NORETURN` — **an error inside it exits the process**. This is an ordinary path,
not an unusual one. [Chapter 11](11-characters.md) and [Chapter 4](04-errors.md).
- `akgl_tilemap_release` does not release the actors an object layer created. Those are
yours, which is what the loop above is for.
## Known defects you will see running this
Each is cross-referenced to `TODO.md`. None is worked around, because in each case the
workaround would be worse than the symptom.
**The leftmost column of tiles is drawn from the wrong part of the tileset when the camera
is within one tile of x=0.** `akgl_tilemap_draw` special-cases the first visible column to
show a partial tile, and writes `src.x += (int)viewport->x % map->tilewidth` — a `+=` onto
whatever `src.x` was left holding by the last tile of the previous row, rather than an
assignment onto that tile's own offset (`src/tilemap.c:766`). At `viewport->x = 0` the
added term is zero and the stale value is used unchanged. The visible result in this game is
a stray tile at the bottom-left of the screen, which disappears the moment the camera scrolls
away from the origin and comes back when the player walks home. `TODO.md`, "Performance"
item 5, records it alongside the tileset scan it lives in; the same shape applies to `src.y`
and the top row.
**An actor on layer 16 or higher is never drawn.** `akgl_Actor::layer` is a `uint32_t` and
nothing range-checks it, but `akgl_render_2d_draw_world` stops at `AKGL_TILEMAP_MAX_LAYERS`.
This level's object group is layer index 2, so it does not bite here — but a map with
seventeen layers loses its actors silently. [Chapter 12](12-actors.md).
**A wrong name in an asset file can kill the process rather than raise.** The six
`SDL_EnumerateProperties` callbacks in libakgl end in `FINISH_NORETURN`, and libakerror's
default unhandled-error handler calls `akerr_exit()`. `akgl_game_update` does not go through
one — it sweeps the pool directly and propagates — so this game reaches it only through
`akgl_heap_release_character`. Install your own `akerr_handler_unhandled_error` if a game
should survive it, and call `akerr_exit()` from it. [Chapter 4](04-errors.md).
**`akgl_tilemap_load` releases nothing on a failed load.** Textures already uploaded and
actors already created stay where they are and the destination holds a half-built map. This
game treats a failed load as fatal, which is the only honest thing to do with one map.
[Chapter 13](13-tilemaps.md).
## The smoke test
The game is registered as a CTest case, so a change that breaks it turns the suite red
rather than waiting for a reader to notice:
```cmake
add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay)
set_tests_properties(example_sidescroller PROPERTIES
TIMEOUT 120
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
)
```
Four seconds of scripted play under the headless drivers. `--autoplay` holds *right* and
taps jump every forty-five frames, and it does it by pushing synthetic events through
`akgl_controller_handle_event` rather than by calling the handlers:
```c excerpt=examples/sidescroller/player.c
if ( frame == 1 ) {
synthetic.type = SDL_EVENT_KEY_DOWN;
synthetic.key.which = SS_AUTOPLAY_KBID;
synthetic.key.key = SDLK_RIGHT;
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &synthetic));
FAIL_ZERO_RETURN(
errctx,
AKGL_BITMASK_HAS(ss_game.player->state, AKGL_ACTOR_STATE_MOVING_RIGHT),
AKGL_ERR_BEHAVIOR,
"the right arrow did not reach the player; the control map matched nothing"
);
}
```
**That distinction is the whole value of the smoke test, and the first version got it
wrong.** Calling the handlers directly is easier and it works — but it skips the binding
table, which is the part with something to get wrong. This game shipped with a control map
that matched no keyboard on earth, and a smoke test that called the handlers passed anyway.
A test that cannot fail is not a test, so the script drives the real dispatch and then
asserts the press actually arrived. Break the binding and the run exits non-zero naming the
reason.
`SS_AUTOPLAY_KBID` is deliberately not `0`: the map binds keyboard `0` meaning *any*
keyboard, and driving it from a non-zero device id is what proves that. In four seconds the
script walks the level, jumps, collides with terrain, collects a coin, falls in the pit and
respawns — so the smoke test exercises the input path, the collision and the pickup, not
just the startup.
A run ends with a line naming what happened, which is worth reading when a change moves the
feel:
```text
sidescroller: 240 frames, 1 of 4 coins, 1 deaths
```
The two counts are not fixed. `dt` is measured from the wall clock rather than passed in
([Chapter 14](14-physics.md)), so a busier machine takes slightly different steps and the
script lands in slightly different places. That is why the smoke test asserts a clean exit
rather than a score — an assertion on the numbers would be a test of the scheduler.
## Where to look next
- [Chapter 14](14-physics.md) — thrust, environmental velocity, the speed ellipse, and the
full list of what is not implemented.
- [Chapter 12](12-actors.md) — the state mask, the six hooks, and the control handlers.
- [Chapter 13](13-tilemaps.md) — what the map loader accepts, and the three extensions to
Tiled this level uses.
- [Chapter 16](16-input.md) — control maps, and why a binding that never fires is usually
the wrong device id.
- [Chapter 21](21-tutorial-jrpg.md) — the same library from the other end: a top-down game
with no gravity, where the content pipeline is the interesting part.