The sidescroller's controls did nothing. `akgl_controller_handle_event` matched `event->key.which == curmap->kbid` exactly, with no way to say "whatever keyboard the player is typing on", so the game did the obvious thing and bound `SDL_GetKeyboards()[0]`. That cannot work. The id a key event *carries* is chosen by the video backend and is not required to be an id `SDL_GetKeyboards()` reports. On X11 without XInput2 every key event carries `SDL_GLOBAL_KEYBOARD_ID`, which is 0, while `SDL_AddKeyboard` registers the attached keyboard as `SDL_DEFAULT_KEYBOARD_ID`, which is 1; with XInput2 the events carry the physical slave device's `sourceid` while `SDL_GetKeyboards()[0]` may be the master. Either way the map matched nothing and every key press was dropped. A `kbid` or `jsid` of 0 now matches any device of that kind. 0 is safe to spend: SDL documents `which` as 0 when the source is unknown or virtual, and joystick ids start at 1, so no real device is 0. A non-zero id still matches only that device, which is what keeps two local players on two keyboards apart, and there is a test for that half too. `util/charviewer.c` already passed 0 and depended on the old behaviour by accident; it works under XInput2 now as well. Two reasons this shipped, both closed: - Every test in tests/controller.c dispatched an event whose id equalled the id the map was bound with, so none of them could see it. - The sidescroller's smoke test called the control handlers directly instead of dispatching events, so it passed against a control map that matched nothing. It now pushes synthetic events through akgl_controller_handle_event from a deliberately non-zero device id and fails if the press does not arrive. Verified by breaking the binding and watching the run exit non-zero. `test_controller_wildcard_device_ids` was written first and failed against the unfixed library with "a map bound to keyboard 0 ignored a key press from device 11", which is the whole reason to trust it now that it passes. The controls were documented, in one sentence. Chapter 19 has a proper table of them, and chapter 15 explains why not to reach for SDL_GetKeyboards(). The header said the match was exact and now says what it does. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
36 KiB
19. 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 about nine hundred
lines of C — a header and four translation units, and rather more comment than that — and it
builds and runs as part of this repository's ordinary ctest run.
It is here for one reason above the others. libakgl has no collision detection at all, and a sidescroller is the shortest path to finding that out. So this chapter is mostly about what you write when the engine stops, and where exactly that code has to live for the frame to come out right.
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
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.
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 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.
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.
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 18 and AGENTS.md: these are
fixed-width fields that end up as registry keys.
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.
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:
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. 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.
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 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
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
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_jsonlooks each one up as it reads the mapping, so a character loaded first fails on the first sprite it cannot find (Chapter 11); - an
actorobject names its character the same way, and the map loader creates the actor and binds the character during the load (Chapter 13).
Nine sprite files and four character files, in a table rather than thirteen calls:
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:
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
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:
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:
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.
The collision libakgl does not have
Here is the whole problem, stated as three facts about src/physics.c:
akgl_physics_arcade_collideraisesAKERR_APIwith the message "Not implemented".akgl_physics_simulatenever callscollideat 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_moveisposition += velocity * dtand nothing else. It does not clamp to the map, consult the tilemap, or test anything.
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 says so plainly and this game is what the consequence looks like.
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:
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
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
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:
ex = obj->ex;
ey = obj->ey;
if ( akgl_physics->gravity_x != 0 ) {
ex -= (float32_t)akgl_physics->gravity_x * dt;
}
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.
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.
Solid is a layer, not a flag
There is no per-tile "solid" property. The level says what is solid by which layer a tile is drawn on:
*dest = (ss_terrain->data[(tiley * ss_terrain->width) + tilex] != 0);
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:
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:
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:
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:
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.
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.
So spawn points get lifted clear once, before the first step:
PASS(errctx, ss_collide_box_blocked(&box, &solid));
if ( solid == false ) {
SUCCEED_RETURN(errctx);
}
obj->y -= (float32_t)SS_TILE_SIZE;
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.
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:
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:
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 15 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
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.
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:
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).
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:
if ( contact.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:
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.
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.
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:
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:
- Only
movementlogicfuncgets this treatment. The backend'sgravityandmoveare called throughPASSin the same block, so a raise from either aborts the whole step — leaving every remaining actor unsimulated andgravity_timeunadvanced. - The
FAIL_RETURNis outside anyATTEMPTblock, asAGENTS.mdrequires. A*_RETURNinside one returns pastCLEANUP.
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:
if ( (contact.blocked_x == true) || ((contact.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:
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.
Picking things up
Collecting a coin is a rectangle test from Chapter 18 and a pool release:
PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other));
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
if ( hit == true ) {
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
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
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 and Chapter 8.
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
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
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 16). 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_charactercallsakgl_character_state_sprites_iterateon every release, and that function is anSDL_EnumeratePropertiescallback ending inFINISH_NORETURN— an error inside it exits the process. This is an ordinary path, not an unusual one. Chapter 11 and Chapter 4.akgl_tilemap_releasedoes 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.
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.
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.
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:
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:
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:
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), 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 — thrust, environmental velocity, the speed ellipse, and the full list of what is not implemented.
- Chapter 12 — the state mask, the six hooks, and the control handlers.
- Chapter 13 — what the map loader accepts, and the three extensions to Tiled this level uses.
- Chapter 15 — control maps, and why a binding that never fires is usually the wrong device id.
- Chapter 20 — the same library from the other end: a top-down game with no gravity, where the content pipeline is the interesting part.