Files
libakgl/docs/07-the-game-and-the-frame.md
Andrew Kesterson b938460127 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

371 lines
18 KiB
Markdown

# 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.
8. `akgl_heap_init()` — zero all five pools.
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.
- [Chapter 21](21-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants.