Add the manual: nineteen chapters and a corrected README
docs/ is a narrative manual, not a second reference. Every header already
carries a substantial @file/@brief block and Doxyfile sets WARN_IF_UNDOCUMENTED
with WARN_AS_ERROR, so an undocumented symbol already fails CI. The gap was
navigation and worked examples. Chapters teach a task and link to the Doxygen
output; where a declaration or a constant table has to be in front of the
reader it arrives as a `c excerpt=` block, so the text *is* the header and
cannot diverge from it. That also preserves the hand-aligned bit-flag tables
scripts/reindent.el goes out of its way not to destroy.
The manual does not re-document its dependencies. libakerror owns the
ATTEMPT/CLEANUP/PROCESS/HANDLE/FINISH protocol, SDL3 owns renderers and events,
Tiled owns the map format, jansson owns json_t. A chapter that restated any of
them would be wrong the day upstream changed and nothing here would notice --
the same drift this work exists to fix, arriving from a different direction. So
each chapter says what libakgl adds or constrains and links out for the rest.
Chapter 4 is the exception and the reason for it: libakerror documents the
mechanism, but only libakgl can say which statuses its own functions raise and
what they mean here, and that was written down nowhere. It carries three tables
-- libakgl's five status codes, the libakerror statuses libakgl actually raises
with their meaning in this library, and the exit-status trap where
`exit(AKGL_ERR_SDL)` is a wait status of 0 because the band starts at 256.
Every chapter was written against src/ rather than against the header comments,
which is how 27 false claims in those comments came to light. Where a chapter
documents a known defect rather than a design decision it says so and points at
TODO.md.
README.md keeps the development process and hands the reader to docs/. Its
task-oriented FAQ is deleted rather than moved, because one source of truth per
topic is the whole point and that FAQ's examples did not compile.
Census: 39 compiled snippets, 89 verbatim header excerpts, 4 JSON documents run
through the real loaders, one linked-and-executed program with its output
compared byte for byte, one generated figure. 11 norun blocks, each justified.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:58:37 -04:00
# 07. The game and the frame
`game.h` is the top of the library. `akgl_game_init` brings up SDL, the pools, the
registries and the audio and font engines; `akgl_game_update` is one frame — update every
actor, step the physics, draw the world.
**There is exactly one of everything.** `akgl_renderer` , `akgl_physics` , `akgl_camera` and
`akgl_gamemap` are globals pointing at `akgl_default_*` storage, so a program can swap in
its own instance by reassigning the pointer without the rest of the library knowing. That
is the whole extent of the indirection: there is no notion of two worlds at once.
## The startup order
`game.h` documents a five-step sequence, and it holds up against `src/game.c` :
```c
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/staticstring.h>
/* The startup order, in the order it has to happen. */
akerr_ErrorContext AKERR_NOIGNORE *startup(void)
{
akgl_String *engine = NULL;
PREPARE_ERROR(errctx);
/* 1. The three required fields. akgl_game_init refuses to run without them. */
PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name),
"manual-demo", sizeof(akgl_game.name) - 1));
PASS(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version),
"1.0.0", sizeof(akgl_game.version) - 1));
PASS(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri),
"tech.starfort.manual-demo", sizeof(akgl_game.uri) - 1));
/* 2. Bring the library up: errors, pools, registries, SDL, audio, fonts. */
PASS(errctx, akgl_game_init());
/* 3. Configure, before anything reads the configuration. */
PASS(errctx, akgl_set_property("game.screenwidth", "800"));
PASS(errctx, akgl_set_property("game.screenheight", "600"));
PASS(errctx, akgl_set_property("physics.gravity.y", "400.0"));
/* 4. The two subsystems that read it. This is where the window appears. */
ATTEMPT {
CATCH(errctx, akgl_heap_next_string(&engine));
CATCH(errctx, akgl_string_initialize(engine, "arcade"));
CATCH(errctx, akgl_render_2d_init(akgl_renderer));
CATCH(errctx, akgl_physics_factory(akgl_physics, engine));
} CLEANUP {
IGNORE(akgl_heap_release_string(engine));
} PROCESS(errctx) {
} FINISH(errctx, true);
/* 5. Load assets, then loop on akgl_game_update. */
SUCCEED_RETURN(errctx);
}
```
**The three required fields have no defaults.** `akgl_game.name` , `.version` and `.uri` are
each checked for a non-zero length and each raises `AKERR_NULLPOINTER` if empty. All three
are load-bearing: `.uri` becomes the window title, all three become SDL's app metadata, and
`.name` , `.uri` and `.version` are compared on savegame load. `.version` must be a valid
semver string, or `akgl_game_load` fails later rather than here.
**Steps 3 and 4 cannot be swapped.** `akgl_render_2d_init` and `akgl_physics_init_arcade`
each read their configuration once, at init, and never look again. Setting
`physics.gravity.y` after the factory call changes nothing. See
[Chapter 6 ](06-the-registry.md ).
### What `akgl_game_init` does, in order
Verified against `src/game.c` . The order matters in three places, marked:
1. * * `akgl_error_init()` — first, before anything that can raise.** Everything below reports
through `AKGL_ERR_*` codes, and a code raised before its name is registered prints as
"Unknown Error". See [Chapter 4 ](04-errors.md ).
2. Stamp `akgl_game.libversion` from `AKGL_VERSION` .
3. Seed the frame clock: `gameStartTime` , `lastIterTime` and `lastFPSTime` all get
`SDL_GetTicksNS()` .
4. Install `akgl_game_lowfps` as `lowfpsfunc` .
5. Create the state mutex. Failure is `AKGL_ERR_SDL` .
6. **Take the state lock. ** Everything from here to the end runs holding it.
7. Check `name` , `version` and `uri` are non-empty.
Document collision, and correct what it made false elsewhere
The new chapter 15 covers the opt-in setup, where resolution runs in the step
and why it runs after the move, shapes and the z-extrusion trap, the asymmetric
masks and the defaults that matter more than the mechanism, tiles as a source
rather than proxies, the response hook and the three ways it is easy to write
wrongly, sensors, the four queries, both partitioners, and what is still not
implemented.
Collision falsified claims in eight other chapters. The physics chapter said
"There is no collision detection, at all" and that collide always raises
AKERR_API; actors had six behaviour hooks; the heap had five pools and four
non-claiming acquires; the status band held five codes; and the design
philosophy had exactly two pluggable subsystems. The appendix gains a
collision.h status section, the three new pool ceilings and a table of the
collision constants that are deliberately not in a public header.
Also fixes chapter labels the renumbering commit left pointing at their old
numbers -- "18. Utilities" linked to 19-utilities.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 07:25:52 -04:00
8. `akgl_heap_init()` — zero all eight pools.
Add the manual: nineteen chapters and a corrected README
docs/ is a narrative manual, not a second reference. Every header already
carries a substantial @file/@brief block and Doxyfile sets WARN_IF_UNDOCUMENTED
with WARN_AS_ERROR, so an undocumented symbol already fails CI. The gap was
navigation and worked examples. Chapters teach a task and link to the Doxygen
output; where a declaration or a constant table has to be in front of the
reader it arrives as a `c excerpt=` block, so the text *is* the header and
cannot diverge from it. That also preserves the hand-aligned bit-flag tables
scripts/reindent.el goes out of its way not to destroy.
The manual does not re-document its dependencies. libakerror owns the
ATTEMPT/CLEANUP/PROCESS/HANDLE/FINISH protocol, SDL3 owns renderers and events,
Tiled owns the map format, jansson owns json_t. A chapter that restated any of
them would be wrong the day upstream changed and nothing here would notice --
the same drift this work exists to fix, arriving from a different direction. So
each chapter says what libakgl adds or constrains and links out for the rest.
Chapter 4 is the exception and the reason for it: libakerror documents the
mechanism, but only libakgl can say which statuses its own functions raise and
what they mean here, and that was written down nowhere. It carries three tables
-- libakgl's five status codes, the libakerror statuses libakgl actually raises
with their meaning in this library, and the exit-status trap where
`exit(AKGL_ERR_SDL)` is a wait status of 0 because the band starts at 256.
Every chapter was written against src/ rather than against the header comments,
which is how 27 false claims in those comments came to light. Where a chapter
documents a known defect rather than a design decision it says so and points at
TODO.md.
README.md keeps the development process and hands the reader to docs/. Its
task-oriented FAQ is deleted rather than moved, because one source of truth per
topic is the whole point and that FAQ's examples did not compile.
Census: 39 compiled snippets, 89 verbatim header excerpts, 4 JSON documents run
through the real loaders, one linked-and-executed program with its output
compared byte for byte, one generated figure. 11 norun blocks, each justified.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:58:37 -04:00
9. The eight registry initializers: actor, sprite, spritesheet, character, font, music,
properties, actor-state-strings. **Note that `akgl_registry_init` is not called ** —
`akgl_game_init` calls the eight individually. See [Chapter 6 ](06-the-registry.md ).
10. `SDL_SetAppMetadata` from the three fields.
11. Zero all `AKGL_MAX_CONTROL_MAPS` control maps.
12. `SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD | SDL_INIT_AUDIO)` .
13. Feed the bundled controller database to `SDL_AddGamepadMapping` , one entry at a time.
A rejected entry is `AKGL_ERR_SDL` and aborts startup.
14. `akgl_controller_open_gamepads()` .
15. `MIX_Init()` and `MIX_CreateMixerDevice()` on the default playback device.
16. `TTF_Init()` .
17. Point `akgl_camera` , `akgl_renderer` , `akgl_physics` and `akgl_gamemap` at their default
storage.
18. Release the state lock.
**What it does not do: create the window, choose a physics backend, or load any
configuration.** Those three read properties, so they belong to the caller, after step 3
above.
**A failure anywhere from step 6 onward returns with the state mutex held.** `game.h` says
so. On a single-threaded program that is survivable — SDL mutexes are recursive, so the
same thread can take it again — but the mutex is never made available to another thread, so
anything else that calls `akgl_game_state_lock` will spend a second retrying and then fail.
A failed `akgl_game_init` is not something to retry; report it and exit.
## One frame
`akgl_game_update(opflags)` is the whole frame, and it is short enough to state exactly:
```text
akgl_game_state_lock() <- retries, does not block
akgl_game_update_fps() <- counts the frame, maybe fires lowfpsfunc
for each slot in akgl_heap_actors:
skip if refcount == 0
skip if OP_LAYERMASK and actor->layer != opflags->layerid
OP_TILEMAPSCALE ? akgl_tilemap_scale_actor(...) : actor->scale = 1.0
actor->updatefunc(actor) <- exactly once per actor
akgl_physics->simulate(akgl_physics, NULL)
akgl_renderer->draw_world(akgl_renderer, NULL)
akgl_game_state_unlock()
```
Five things follow from that, and four of them are not obvious.
**The sweep walks the pool, not the registry.** It reads `akgl_heap_actors` directly and
skips slots whose `refcount` is 0. That is why an actor released mid-frame simply stops
being visited, and why the actor registry's contents do not affect the update pass at all.
**Each actor's `updatefunc` runs exactly once.** Until 0.5.0 this loop sat inside a walk
over `AKGL_TILEMAP_MAX_LAYERS` that never compared an actor's `layer` to the layer being
walked, so every actor updated sixteen times a frame — 70 µs of work to do 4.4 µs of it.
Updating an actor is not a per-layer operation. **Drawing is ** , and
`akgl_render_2d_draw_world` walks the layers itself.
**`opflags` is not forwarded.** `simulate` and `draw_world` are both passed `NULL` , so they
use their own defaults regardless of what you asked for. Setting
`AKGL_ITERATOR_OP_LAYERMASK` restricts the * update * sweep and nothing else; the whole world
still draws.
**A `NULL` `opflags` means `AKGL_ITERATOR_OP_UPDATE` alone** — every live actor once, no
layer mask, and every actor's `scale` forced to 1.0 because `AKGL_ITERATOR_OP_TILEMAPSCALE`
is not set. If your sprites are rendering at the wrong size, that flag is the first thing to
check.
**A live actor's `updatefunc` is called without a `NULL` check.** A hand-built `akgl_Actor`
that never went through `akgl_actor_initialize` crashes here rather than raising.
### There is no frame pacing
`akgl_game_update` does not sleep, does not wait for vertical blank, and does not ask SDL
to. `akgl_render_2d_init` calls `SDL_CreateWindowAndRenderer` with no flags and never calls
`SDL_SetRenderVSync` , so **the loop runs as fast as the machine will let it ** and burns a
core doing it.
That is a deliberate omission rather than an oversight — pacing policy belongs to the game —
but it means the pacing is yours to add. Either call `SDL_SetRenderVSync(renderer, 1)` on
`akgl_renderer->sdl_renderer` after `akgl_render_2d_init` , or measure the frame and
`SDL_DelayNS` the remainder yourself. Physics is unaffected either way:
`akgl_physics_simulate` computes its own `dt` from `SDL_GetTicksNS` and bounds it at
`physics.max_timestep` .
### `akgl_game.fps` reads 0 for the first second
`akgl_game_update_fps` recomputes `fps` only when a **full second ** has elapsed since the
last recomputation, so it is a completed-second average and not an instantaneous figure. For
the first second of the process it is 0.
0 is below the low-FPS threshold of 30. **So `lowfpsfunc` fires on every single frame until
the first second is up** — several hundred `Low FPS! 0` lines from the default handler
before the game has drawn anything worth measuring. That is expected, not a symptom.
If you replace `lowfpsfunc` with something that sheds work, guard it on `fps > 0` or it will
shed work during startup, which is the worst moment for it. The hook is also what an
embedder has to know about: `akgl_game_update_fps` installs the default if it finds the
pointer `NULL` , because a host binding its own renderer with `akgl_render_2d_bind` never ran
`akgl_game_init` and used to crash here on frame one.
A caller running its own loop rather than `akgl_game_update` has to call
`akgl_game_update_fps` itself; nothing else does.
## The state lock
`akgl_game.state` is a single `int32_t` of application-defined flags. **The library never
reads it.** The mutex guarding it, `akgl_game.statelock` , is the only thread-safety
libakgl offers, and it protects the state flags — not the pools, not the registries, not
the renderer.
`akgl_game_state_lock` **polls; it does not block. ** It calls `SDL_TryLockMutex` every
`AKGL_GAME_STATE_LOCK_RETRY_MS` (100 ms) until `AKGL_GAME_STATE_LOCK_BUDGET_MS` (1000 ms) is
spent, then raises `AKGL_ERR_SDL` . The point is that a deadlock reports an error you can act
on instead of hanging the process. `SDL_GetError()` is appended for whatever it is worth,
but after a failed `SDL_TryLockMutex` it is usually stale or empty — contention is reported
by the return value, not by an error string. **The status is the signal, not the text. **
Two behaviours to plan around:
**Every failure path in `akgl_game_update` returns holding the lock.** Every step from
`akgl_game_state_lock` onward propagates with `PASS` , which returns immediately, so the
`akgl_game_state_unlock` at the bottom is never reached. Because SDL mutexes are recursive
the next frame on the same thread still gets the lock, so a single-threaded game does not
deadlock — it accumulates one unmatched lock per failed frame, and the mutex is never
released to any other thread again. **Treat a failed `akgl_game_update` as terminal. ** If
you genuinely must continue, unlock once yourself before the next frame — but only when you
know the failure came from * inside * the frame rather than from the lock acquisition itself,
because unlocking a mutex this thread does not hold is undefined behaviour in SDL.
**`akgl_game_state_lock` succeeds trivially before `akgl_game_init` .** `SDL_TryLockMutex`
returns true when passed `NULL` , and `akgl_game.statelock` is `NULL` until step 5 of
startup. It locks nothing and reports success.
`akgl_game_state_unlock` has no failure path and returns `NULL` . That includes the case
that matters — unlocking a mutex this thread does not hold — which is undefined behaviour in
SDL rather than an error here.
## Iterators are a work order
There is no iterator object, and **there is no `next()` ** . Traversal is SDL's —
`SDL_EnumerateProperties` over a registry — and `akgl_Iterator` is the `userdata` carried
into each callback, saying * which * entries to touch and * what * to do to each one.
```c excerpt=include/akgl/iterator.h
/** @brief Selects operations and an optional layer for actor traversal. */
typedef struct {
uint32_t flags; /**< Bitwise OR of the `AKGL_ITERATOR_OP_*` values below. */
uint8_t layerid; /**< Layer to restrict the sweep to. Read only when #AKGL_ITERATOR_OP_LAYERMASK is set. */
} akgl_Iterator;
#define AKGL_ITERATOR_OP_UPDATE (1 << 0) // 1 Call the actor's updatefunc
#define AKGL_ITERATOR_OP_RENDER (1 << 1) // 2 Call the actor's renderfunc
#define AKGL_ITERATOR_OP_RELEASE (1 << 2) // 4 Release the object back to its heap layer
#define AKGL_ITERATOR_OP_LAYERMASK (1 << 3) // 8 Skip anything whose layer != layerid
#define AKGL_ITERATOR_OP_TILEMAPSCALE (1 << 4) // 16 Scale actors to the tilemap; otherwise force scale 1.0
```
The operations are **independent bits, not an enum ** : one sweep can update, scale and
render, and they run in that fixed order regardless of the order the bits were set in. Bits
5 through 31 are declared `AKGL_ITERATOR_OP_UNDEFINED_*` and are unused.
Passing `NULL` where an `akgl_Iterator *` is expected is fine at the top-level entry points
— `akgl_game_update` , `akgl_physics_simulate` and `akgl_render_2d_draw_world` each
substitute their own defaults — but it is `AKERR_NULLPOINTER` once inside a callback.
**`akgl_game_update` reads only two of the five bits**: `AKGL_ITERATOR_OP_LAYERMASK` and
`AKGL_ITERATOR_OP_TILEMAPSCALE` . It calls `updatefunc` unconditionally rather than checking
`AKGL_ITERATOR_OP_UPDATE` , because updating every live actor once is the whole job. The
callback `akgl_registry_iterate_actor` does check all of them, and that is the one the
render sweep goes through.
## The main loop
```c
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/iterator.h>
/* A frame loop. A failed akgl_game_update is terminal: it returns holding the
* state lock, and FINISH_NORETURN is what turns that into a stack trace and a
* truthful exit status instead of a second frame on a poisoned mutex. */
int main(void)
{
akgl_Iterator work = {
.flags = (AKGL_ITERATOR_OP_UPDATE | AKGL_ITERATOR_OP_TILEMAPSCALE),
.layerid = 0
};
SDL_Event event;
bool running = true;
PREPARE_ERROR(errctx);
ATTEMPT {
while ( running == true ) {
while ( SDL_PollEvent(&event) == true ) {
if ( event.type == SDL_EVENT_QUIT ) {
running = false;
}
}
CATCH(errctx, akgl_game_update(&work));
}
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}
```
The `CATCH` inside the `while` is safe **only because the loop is the entire body of the
`ATTEMPT` **. `CATCH` reports failure by `break` ing, and a `break` binds to the innermost
enclosing loop, so it leaves the `while` and falls into `CLEANUP` /`PROCESS` /`FINISH` — which
is what you want. Put anything after the loop and you have to account for reaching it after
a failure. `AGENTS.md` spells the rule out, and `scripts/check_error_protocol.py` cannot see
this one.
## There is no `akgl_game_shutdown`
**Teardown is entirely the caller's.** No function in this library undoes `akgl_game_init` .
What there is, in the order it has to run:
| Step | Call | Why the order |
|---|---|---|
| 1 | `akgl_text_unloadfont` for each font you loaded | Must precede `TTF_Quit` . `text.h` documents the trap: `SDL_Quit` destroying the engine out from under a live `TTF_Font` is not a clean unload |
| 2 | `akgl_heap_release_*` for anything you still hold | `akgl_heap_release_spritesheet` destroys `SDL_Texture` s and must run on the renderer's thread, before the renderer goes |
| 3 | `akgl_audio_shutdown()` | Puts the synthesizer voices back |
| 4 | `akgl_renderer->shutdown(akgl_renderer)` | `akgl_render_2d_shutdown` |
| 5 | `TTF_Quit()` , `MIX_Quit()` , `SDL_Quit()` | SDL's own |
A process that is exiting anyway can skip most of it — the OS reclaims the pools, and SDL
reclaims what it owns — but a program that tears a * level * down and builds another one has
to get steps 1 and 2 right or it exhausts the pools. `akgl_heap_init_actor` plus
`akgl_registry_init_actor` is the cheap version of that for actors only; see
[Chapter 5 ](05-the-heap.md ).
## Savegames are partial
`akgl_game_save` and `akgl_game_load` exist and work, and **they do not yet save your game. **
Read this before building anything on them.
What `akgl_game_save` writes:
1. The `akgl_Game` struct verbatim — metadata, timing, FPS accounting and the state flags.
2. Four name tables — actors, sprites, spritesheets, characters — each mapping a registered
name to **the address the object had at save time ** , each terminated by a zeroed name
field and a zeroed pointer.
What it does not write: **the objects themselves. ** Not one actor, sprite, sheet or
character. The name tables are the mechanism that would let a loader reconnect pointers
between objects that will sit at different addresses next run; the objects those pointers
would point at are not in the file. `akgl_game_load` correspondingly rebuilds the four
old-address-to-current-object maps and stops there. `game.h` says so on both functions.
Three further things to know:
- **`akgl_game_load` refuses a save that does not match this build**, comparing the library
version, the game version, the game name and the game URI. Versions are compared by
**exact ** semver equality, not compatibility, because the file is a raw memory image and
semver has no way to say "the layout did not move". A mismatch is `AKERR_API` .
- **A save containing any registered spritesheet cannot be read back.** The writer uses
`AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH` (512) for that table and the reader used
`AKGL_ACTOR_MAX_NAME_LENGTH` (128) for all four. That is fixed as of 0.5.0 (`TODO.md` ,
"Known and still open" item 7) — and what catches it is the `require_at_eof` check, which
turns a field-width disagreement into `AKERR_IO` instead of a silent load with wrong maps.
A reader whose widths disagree finds a run of zeros mid-entry, stops early, and would
otherwise report success.
- **`akgl_game_load` leaks four `SDL_PropertiesID` per call.** The maps it builds are never
destroyed.
A write error part-way through the name tables **terminates the process ** rather than
returning. The entries go through `SDL_EnumerateProperties` , whose callbacks cannot report
failure upward and end in `FINISH_NORETURN` ; only the terminators are written through the
error-reporting path. See [Chapter 4 ](04-errors.md ).
## Where to go next
- [Chapter 4 ](04-errors.md ) — the statuses everything here raises.
- [Chapter 6 ](06-the-registry.md ) — the configuration step 3 above writes into.
- [Chapter 8 ](08-rendering.md ) — `akgl_render_2d_init` versus `akgl_render_2d_bind` , and
what `draw_world` does with the layers.
- [Chapter 14 ](14-physics.md ) — what `simulate` does with the `dt` this chapter does not
pace.
2026-08-02 07:15:25 -04:00
- [Chapter 22 ](22-appendix-limits.md ) — `AKGL_GAME_*` and the rest of the constants.