Files
libakgl/docs/07-the-game-and-the-frame.md
Tachikoma eabb9ad376
Some checks failed
libakgl CI Build / cmake_build (push) Successful in 9m7s
libakgl CI Build / performance (push) Successful in 9m44s
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / memory_check (push) Has been cancelled
Move outstanding work from TODO.md into the issue tracker
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>
2026-08-02 18:47:34 -04:00

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:

  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.
  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 eight pools.
  9. The eight registry initializers: actor, sprite, spritesheet, character, font, music, properties, actor-state-strings. Note that akgl_registry_init is not calledakgl_game_init calls the eight individually. See Chapter 6.
  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:

  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:

  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, "Defects") — 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.

Where to go next

  • Chapter 4 — the statuses everything here raises.
  • Chapter 6 — the configuration step 3 above writes into.
  • Chapter 8akgl_render_2d_init versus akgl_render_2d_bind, and what draw_world does with the layers.
  • Chapter 14 — what simulate does with the dt this chapter does not pace.
  • Chapter 23AKGL_GAME_* and the rest of the constants.