Files
libakgl/docs/02-design-philosophy.md

299 lines
13 KiB
Markdown
Raw Normal View History

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
# 02. Design philosophy
Six decisions shape every API in this library. They are not preferences stated up front and
then quietly abandoned in the implementation; each one is visible in the code, and this
chapter shows the code that proves it.
None of this is an argument that you should build a game this way. It is an explanation of
what libakgl will and will not do for you, so you can decide whether the tradeoffs are ones
you want.
## Bounded, pre-declared resources — not dynamic ones
**libakgl does not call `malloc` at runtime.** Every runtime object comes out of a fixed,
statically allocated array declared in `heap.h`. There are five such arrays — actors,
sprites, spritesheets, characters and strings — and the ceilings are compile-time constants:
```c excerpt=include/akgl/heap.h
#ifndef AKGL_MAX_HEAP_ACTOR
#define AKGL_MAX_HEAP_ACTOR 64
#endif
#ifndef AKGL_MAX_HEAP_SPRITE
#define AKGL_MAX_HEAP_SPRITE (AKGL_MAX_HEAP_ACTOR * 16)
#endif
#ifndef AKGL_MAX_HEAP_SPRITESHEET
#define AKGL_MAX_HEAP_SPRITESHEET AKGL_MAX_HEAP_SPRITE
#endif
#ifndef AKGL_MAX_HEAP_CHARACTER
#define AKGL_MAX_HEAP_CHARACTER 256
#endif
#ifndef AKGL_MAX_HEAP_STRING
#define AKGL_MAX_HEAP_STRING 256
#endif
```
Allocation is a linear scan for a slot whose reference count is zero. That is the whole
algorithm:
```c excerpt=src/heap.c
akerr_ErrorContext *akgl_heap_next_actor(akgl_Actor **dest)
{
PREPARE_ERROR(errctx);
for (int i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
if ( akgl_heap_actors[i].refcount != 0 ) {
continue;
}
*dest = &akgl_heap_actors[i];
SUCCEED_RETURN(errctx);
}
FAIL_RETURN(errctx, AKGL_ERR_HEAP, "Unable to find unused actor on the heap");
}
```
Two consequences follow, and both matter more than the absence of `malloc`.
**Exhaustion is a normal error, not a catastrophe.** `AKGL_ERR_HEAP` means "this pool is
full". It is not `ENOMEM`, the process is not in trouble, and the fix is almost never a
bigger pool — **it is usually a missing release.** Raising `AKGL_MAX_HEAP_*` before you have
checked for a leak converts a bug that fails on frame 300 into one that fails on frame 3000.
**The pools are compile-time sized, so the ceiling is an ABI constraint.** The arrays live
in the library, so libakgl and everything linking it must agree on the numbers. Overriding
one means rebuilding the whole tree, not just your game.
The idiom for a pool object is claim, use, release in `CLEANUP`, which runs on every path
out of the `ATTEMPT` block:
```c
#include <akerror.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/staticstring.h>
#include <akgl/util.h>
/* Claim, use, release. The CLEANUP block runs on every path out of ATTEMPT. */
akerr_ErrorContext *scratch_path(char *root, char *name)
{
akgl_String *buf = NULL;
PREPARE_ERROR(errctx);
PASS(errctx, akgl_heap_next_string(&buf));
ATTEMPT {
CATCH(errctx, akgl_string_initialize(buf, name));
CATCH(errctx, akgl_path_relative(root, buf->data, buf));
SDL_Log("resolved to %s", buf->data);
} CLEANUP {
IGNORE(akgl_heap_release_string(buf));
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
```
**If the pool has no layer for your type, add one.** A new kind of runtime object gets a new
array, a `next`, and a `release`, in `heap.h` and `src/heap.c`. It does not get an
allocator. [Chapter 5](05-the-heap.md) covers the layers, the reference-count asymmetry
between `akgl_heap_next_string` and the other seven, and what the ceilings cost you.
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
## Variation lives in a backend, not in a branch
There are exactly three pluggable subsystems, and all have the same shape: **a struct of
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
function pointers, plus an initializer that populates it.**
```text
akgl_RenderBackend akgl_PhysicsBackend akgl_Partitioner
+--------------------+ +--------------------+ +-----------------+
| sdl_renderer | | simulate | | reset |
| shutdown | | gravity | | insert |
| frame_start | | collide | | remove |
| frame_end | | move | | move |
| draw_texture | | drag_x/y/z | | query |
| draw_mesh | | gravity_x/y/z | | each_pair |
| draw_world | | max_timestep | | state |
+--------------------+ | collision | +-----------------+
^ +--------------------+ ^
| populated by ^ | populated by
akgl_render_2d_bind | populated by akgl_partitioner_init_grid
akgl_render_2d_init akgl_physics_init_null akgl_partitioner_init_bsp
akgl_physics_init_arcade (akgl_partitioner_factory)
(akgl_physics_factory)
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
```
The shipped physics initializers are five assignments and nothing else:
```c excerpt=src/physics.c
akerr_ErrorContext *akgl_physics_init_null(akgl_PhysicsBackend *self)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
self->gravity = akgl_physics_null_gravity;
self->collide = akgl_physics_null_collide;
self->move = akgl_physics_null_move;
self->simulate = akgl_physics_simulate;
```
**To vary the behaviour, write an initializer and register it — do not add a conditional.**
Nothing in `akgl_physics_simulate` knows which backend it is stepping, and nothing in the
render path knows whether `draw_texture` came from the 2D backend or from yours. You can
also mix: borrow the entry points that already do what you want and replace only the one
that does not.
```c
#include <akerror.h>
#include <akgl/game.h>
#include <akgl/physics.h>
#include <akgl/actor.h>
/* One tick of "everything floats up at a constant rate", as a backend. */
static akerr_ErrorContext *floaty_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
actor->ey = -40.0f * dt;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *floaty_init(akgl_PhysicsBackend *self)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
/* Borrow the parts that already do the right thing. */
self->simulate = akgl_physics_simulate;
self->move = akgl_physics_arcade_move;
self->collide = akgl_physics_null_collide;
self->gravity = floaty_gravity;
self->gravity_time = SDL_GetTicksNS();
self->max_timestep = AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP;
SUCCEED_RETURN(errctx);
}
```
There is no inheritance, no RTTI and no dispatch on a type tag anywhere in the library.
Behaviour attaches as a function pointer on a struct — which is also how an actor's own six
behaviour hooks work, in [Chapter 12](12-actors.md).
## Bit flags are the default representation for state
Where another library would give you an enum and a setter per property, libakgl gives you a
32-bit mask and four macros. An actor's state is the clearest case: it is a *set*, not a
value, because an actor is alive and facing left and moving left all at once.
```c excerpt=include/akgl/actor.h
#define AKGL_ACTOR_STATE_FACE_DOWN (1 << 0) // 1 0000 0000 0000 0001
#define AKGL_ACTOR_STATE_FACE_LEFT (1 << 1) // 2 0000 0000 0000 0010
#define AKGL_ACTOR_STATE_FACE_RIGHT (1 << 2) // 4 0000 0000 0000 0100
#define AKGL_ACTOR_STATE_FACE_UP (1 << 3) // 8 0000 0000 0000 1000
#define AKGL_ACTOR_STATE_ALIVE (1 << 4) // 16 0000 0000 0001 0000
```
The iterator flags in `iterator.h` are the same idea for "what should this sweep do":
```c excerpt=include/akgl/iterator.h
#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
```
Both tables are hand-aligned, with the decimal value and the bit pattern in a comment per
row, and both are protected from the reindent script's `tabify` step precisely so that
alignment survives. The tables in the headers are the reference; these excerpts are checked
against them.
**Use the macros, not the operators.** `AKGL_BITMASK_HAS(x, y)` is "every bit of `y` is set
in `x`", not "any of them". It is fully parenthesized so that `!AKGL_BITMASK_HAS(a, b)`
means what it reads as — until 0.5.0 it did not, and the negation bound to the `&`. Nothing
in the tree negated it, which is the only reason that was latent rather than live.
## Errors carry context, and callers cannot ignore them
**Every one of libakgl's 156 functions returns `akerr_ErrorContext AKERR_NOIGNORE *`.**
Results come back through pointer parameters; the return value is always the error. The
`AKERR_NOIGNORE` attribute makes discarding it a compiler diagnostic.
That is a libakerror design and it is documented by libakerror.
[Chapter 4](04-errors.md) covers the part that is genuinely libakgl's: which statuses these
functions raise, what each one means *here*, and the traps libakgl's own structure creates.
Read it before any subsystem chapter.
The reasoning behind the choice is operational rather than aesthetic. A game that fails on
somebody else's machine leaves you a log file and nothing else, so an error has to say what
failed, where, and why, at the point it happened — not three frames later at a `NULL`
dereference with no history attached.
## Everything is referenced by name
Nothing in this library is passed around by pointer where a name will do. An actor names the
character it instantiates; a character names the sprites it draws for each state; a sprite
names the sheet it cuts frames from. All of it is resolved at load time through eight
registries:
```c excerpt=include/akgl/registry.h
extern SDL_PropertiesID AKGL_REGISTRY_ACTOR;
extern SDL_PropertiesID AKGL_REGISTRY_SPRITE;
extern SDL_PropertiesID AKGL_REGISTRY_SPRITESHEET;
extern SDL_PropertiesID AKGL_REGISTRY_CHARACTER;
```
The lookup itself is unremarkable, and that is the point:
```c excerpt=src/actor.c
obj->basechar = SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, basecharname, NULL);
```
**This is what lets the whole asset graph be described in JSON files that reference each
other by name**, with no build step and no code generation. It also means a typo in an asset
file is a runtime failure rather than a compile error — which is the tradeoff, stated
plainly. The registries are SDL property sets rather than an akgl type, so you can enumerate
one with `SDL_EnumerateProperties`; that is exactly what `akgl_registry_iterate_actor` does.
Two sharp edges, both in [Chapter 6](06-the-registry.md): an uninitialized registry id is 0,
which SDL treats as "no such property set" and silently drops writes to; and
`AKGL_REGISTRY_SPRITESHEET` is keyed on the *resolved* image path, which is what makes two
sprites naming the same PNG share one texture.
## One world at a time
The library keeps exactly one of everything, reachable through four globals:
```c excerpt=include/akgl/game.h
extern akgl_Tilemap *akgl_gamemap;
extern akgl_RenderBackend *akgl_renderer;
extern akgl_PhysicsBackend *akgl_physics;
extern SDL_FRect *akgl_camera;
```
`akgl_game_init` points each of them at default storage — `akgl_default_renderer`,
`akgl_default_physics`, `akgl_default_camera`, `akgl_default_gamemap`. **They are pointers
so that you can substitute your own instance by reassigning one**, and the rest of the
library goes on drawing and simulating through it without knowing.
That is the entire extent of the indirection. **There is no notion of two worlds at once.**
No context handle is threaded through the API, no `akgl_World *` is passed to every call,
and two simultaneous games in one process is not a thing this library supports. If you want
a paused world behind a menu, you save the state you care about and swap the pointers back.
The globals all carry the `akgl_` prefix, and that is not cosmetic. Until 0.5.0 the library
exported a bare `renderer`, `tests/character.c` defined an `SDL_Renderer *renderer` of its
own, both had external linkage with the same spelling, and the executable's definition
preempted the library's. Every texture load in that suite failed and the suite reported
success anyway. A consuming game with a variable called `renderer` would have hit exactly
the same thing, with no test to notice.
## What the six add up to
You get a library whose worst-case memory is knowable at compile time, whose failure modes
arrive as messages instead of crashes, whose behaviour you extend by writing a function
rather than by editing a switch, and whose assets are text files you can edit by hand.
You give up dynamic sizing, more than one world, and any ability to describe a game without
writing C. Those are the tradeoffs. [Chapter 3](03-getting-started.md) puts a window on the
screen.