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
371 lines
18 KiB
Markdown
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 eight 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 22](22-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants.
|