TODO.md carried two records in one file: what had been done, with the measurements behind it, and what was left. The second half is what a tracker is for, and keeping it here has already cost something -- AGENTS.md records a round where eleven entries described code that had already changed, and this file admitted to three more. Every open item is now an issue on source.starfort.tech/andrew/libakgl, labelled by kind and blast radius and milestoned by what it can land in: 0.9.x for anything that breaks no ABI, 0.10.0 for new or changed public symbols, 1.0.0 for the design work. Four are epics: the performance plan (#60), coverage (#61), actor rotation (#62), and the false header comments (#63). Verified against the tree before filing rather than transcribed. Three entries were already fixed and were not filed: the akgl_path_relative context leak, the akgl_draw_background test extension, and the SDL enumeration audit -- keyboards, gamepads and mappings are all freed in CLEANUP today. Two were reworded because the code had moved: the fonts item is a missing teardown entry point rather than a missing API, since akgl_text_unloadallfonts exists, and draw_world's tilemap call is already bounded by numlayers, so only the per-layer actor rescan remains. TODO.md keeps the part a tracker has no place for: why a decision went the way it did, what the measurement was, and which arguments turned out to be wrong. TODO.txt is deleted. Four of its eight entries had shipped -- actor-to-actor collision, actor-to-world collision, automatic facing, image layers -- and the four that had not are #74 through #77, with the GPU renderer's research links kept because that is the part that took the time. Every reference that named an item number or a moved section is repointed, in the manual, the headers, the tests and the examples. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
18 KiB
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:
#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.
What akgl_game_init does, in order
Verified against src/game.c. The order matters in three places, marked:
akgl_error_init()— first, before anything that can raise. Everything below reports throughAKGL_ERR_*codes, and a code raised before its name is registered prints as "Unknown Error". See Chapter 4.- Stamp
akgl_game.libversionfromAKGL_VERSION. - Seed the frame clock:
gameStartTime,lastIterTimeandlastFPSTimeall getSDL_GetTicksNS(). - Install
akgl_game_lowfpsaslowfpsfunc. - Create the state mutex. Failure is
AKGL_ERR_SDL. - Take the state lock. Everything from here to the end runs holding it.
- Check
name,versionanduriare non-empty. akgl_heap_init()— zero all eight pools.- The eight registry initializers: actor, sprite, spritesheet, character, font, music,
properties, actor-state-strings. Note that
akgl_registry_initis not called —akgl_game_initcalls the eight individually. See Chapter 6. SDL_SetAppMetadatafrom the three fields.- Zero all
AKGL_MAX_CONTROL_MAPScontrol maps. SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD | SDL_INIT_AUDIO).- Feed the bundled controller database to
SDL_AddGamepadMapping, one entry at a time. A rejected entry isAKGL_ERR_SDLand aborts startup. akgl_controller_open_gamepads().MIX_Init()andMIX_CreateMixerDevice()on the default playback device.TTF_Init().- Point
akgl_camera,akgl_renderer,akgl_physicsandakgl_gamemapat their default storage. - 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:
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.
/** @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
#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 breaking, 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_Textures 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.
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:
- The
akgl_Gamestruct verbatim — metadata, timing, FPS accounting and the state flags. - 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_loadrefuses 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 isAKERR_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 usedAKGL_ACTOR_MAX_NAME_LENGTH(128) for all four. That is fixed as of 0.5.0 (TODO.md, "Defects") — and what catches it is therequire_at_eofcheck, which turns a field-width disagreement intoAKERR_IOinstead 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_loadleaks fourSDL_PropertiesIDper 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.
Where to go next
- Chapter 4 — the statuses everything here raises.
- Chapter 6 — the configuration step 3 above writes into.
- Chapter 8 —
akgl_render_2d_initversusakgl_render_2d_bind, and whatdraw_worlddoes with the layers. - Chapter 14 — what
simulatedoes with thedtthis chapter does not pace. - Chapter 23 —
AKGL_GAME_*and the rest of the constants.