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>
20 KiB
13. Tilemaps
libakgl does not have a map format. It has a loader for somebody else's, and the
somebody else is the Tiled map editor. Levels are authored in
Tiled, saved as JSON (.tmj), and read by akgl_tilemap_load.
That division is deliberate, and it means this chapter owes you almost nothing about the
format itself. What a layer is, what a tileset is, how firstgid works, how custom
properties are stored — all of that is
the Tiled JSON map format reference,
maintained by the people who write the editor. Read it there.
What this chapter owes you is the part Tiled cannot tell you: which subset of the format libakgl actually reads, what it refuses, the three things it adds on top, and how big the resulting object is. All four have bitten somebody.
The size of an akgl_Tilemap
Start here, because it is the one that produces a crash rather than an error message.
sizeof(akgl_Tilemap) = 26,388,008 bytes = 25.2 MiB
layers[16] 16 x 1,120,296 = 17.1 MiB 512x512 ints of tile data, plus
128 x 560-byte objects, per layer
tilesets[16] 16 x 528,944 = 8.1 MiB 65,536 x 2 ints of tile offsets,
plus a PATH_MAX image path, each
everything else = ~0.0 MiB geometry, perspective, physics
An akgl_Tilemap belongs in static storage and nowhere else. A default thread stack is
8 MiB on Linux; this object is three times that, so a local one blows the stack before the
loader writes its first byte, and it does so as a segfault with no error context. That is
why the library's own map, akgl_default_gamemap, is a file-scope object in
src/game.c, and why akgl_gamemap is a pointer to it rather than a copy.
The bounds are fixed at compile time, so the size is fixed too. Every field is present whether the map uses it or not: a 20x15 map with one tileset and two layers costs the same 25.2 MiB as a 511x511 one. Raising any of the constants below raises this number and changes the ABI — see Chapter 21.
Limits
/** @brief Widest map, in tiles. Width times height is what is actually bounded. */
#define AKGL_TILEMAP_MAX_WIDTH 512
/** @brief Tallest map, in tiles. */
#define AKGL_TILEMAP_MAX_HEIGHT 512
/** @brief Layers per map. Also the number of draw passes akgl_render_2d_draw_world makes. */
#define AKGL_TILEMAP_MAX_LAYERS 16
/** @brief Tilesets per map. */
#define AKGL_TILEMAP_MAX_TILESETS 16
/** @brief Entries in a tileset's offset table. Indexed by *local* tile id, so a tileset with a high `firstgid` still starts at 0. */
#define AKGL_TILEMAP_MAX_TILES_PER_IMAGE 65536
/** @brief Longest tileset name. */
#define AKGL_TILEMAP_MAX_TILESET_NAME_SIZE 512
/** @brief Longest resolved tileset image path. */
#define AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE PATH_MAX
/** @brief Longest object name. Note that an object naming an actor is truncated at #AKGL_ACTOR_MAX_NAME_LENGTH (128) instead. */
#define AKGL_TILEMAP_MAX_OBJECT_NAME_SIZE 512
/** @brief Objects in one object layer. Not enforced by the loader -- a longer group writes past the array. */
#define AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER 128
Two of these carry a wrinkle worth knowing before you lay out a level.
The width and height bound is on the product, and it is exclusive. akgl_tilemap_load
refuses when width * height >= AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT, so
262,144 cells is one too many: a literal 512x512 map is rejected with
AKERR_OUTOFBOUNDS, and 512x511 is the largest rectangle that loads. The same
off-by-one applies per tile layer in akgl_tilemap_load_layer_tile. Nothing is wrong with
either check other than the >=; it is recorded here because "the maximum is 512x512" is
what the constant names say and is not what the code does.
The doc comment on AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER is stale. It says the loader
does not enforce it. The loader does — akgl_tilemap_load_layer_objects checks j at the
top of the loop body and raises AKERR_OUTOFBOUNDS naming the layer, and
akgl_tilemap_load_tilesets does the same against AKGL_TILEMAP_MAX_TILESETS. Both were
genuinely unbounded and both were fixed in 0.5.0 (TODO.md, "Found while rewriting the
Doxygen comments" item 17), with fixtures in tests/tilemap.c at exactly 128, 129 and 17.
Trust the code.
What the loader accepts
The loader reads a strict subset of the TMJ format. Everything in this section is a
requirement, not a suggestion: a key that is absent raises AKERR_KEY and a key with the
wrong JSON type raises AKERR_TYPE, both naming what was wanted. See
Chapter 4 for what those statuses mean in libakgl.
| Level | Members akgl_tilemap_load requires |
Notes |
|---|---|---|
| Map root | tilewidth, tileheight, width, height, layers, tilesets |
orientation is read from nowhere and forced to 0; isometric is not implemented |
| Every layer | id, opacity, visible, x, y, type |
A type other than the three below keeps these fields and is otherwise skipped |
tilelayer |
width, height, data |
data must be a plain array of integers |
imagelayer |
image |
Layer width/height are taken from the loaded texture, not the JSON |
objectgroup |
objects |
See below — every object has requirements of its own |
| Every object | name, x, y, visible, type |
Including plain rectangles. An object with no type member fails the whole load |
| Every tileset | columns, firstgid, imagewidth, imageheight, margin, spacing, tilecount, tilewidth, tileheight, name, image |
The embedded form; see below |
Three of these deserve their own paragraph.
Tilesets must be embedded, not external. Tiled writes a tileset into the map's
tilesets array in one of two shapes: the whole tileset inline, or a two-member stub
{"firstgid": N, "source": "tiles.tsj"} pointing at a separate file.
akgl_tilemap_load_tilesets_each reads columns, image and the rest directly out of the
array element and there is no code anywhere in src/ that opens a source file, so an
external tileset reference fails with AKERR_KEY on the missing columns. Turn
Embed tileset on when you save, or use Tiled's Map → Embed Tilesets command. The
shipped fixture tests/assets/testmap.tmj is embedded, which is what makes it load.
Every object needs a type string, and type is where the extensions live. The loader
reads it on every object in an object group and compares it against "actor" and
"perspective"; anything else is recorded in the layer's object array and otherwise
ignored. An object with the member missing entirely does not reach that comparison — it
fails the load. If your editor writes the object's class under a different member name,
that is the drift to check first; Tiled's own field naming has changed across releases, and
the fixture in this repository is a Tiled 1.8.2 export.
The map file's directory is the root every path resolves against, so a map and its art move together. The two resolvers are not the same, though, and the difference matters:
| Path | Resolved by | Consequence |
|---|---|---|
A tileset's image |
akgl_path_relative |
Canonicalized against the map's directory; an absolute path works |
An image layer's image |
aksl_snprintf("%s/%s", ...) |
A plain join. Not canonicalized, so an absolute path becomes /maps//art/sky.png and does not open |
Both are bounded at AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE (PATH_MAX). The image-layer
join raises AKERR_OUTOFBOUNDS naming both lengths when it does not fit; it used to
truncate silently, and an over-long path reported itself as a missing file with a name the
caller never wrote.
libakgl's three extensions to Tiled
None of these are part of the TMJ format. They are conventions the loader imposes on custom properties and object types, and Tiled will happily author all three without knowing what they mean.
1. Object type actor spawns a real actor
An object whose type is the string "actor" does not stay a rectangle. The loader
claims an actor from the pool, initializes it, binds a character, publishes it in
AKGL_REGISTRY_ACTOR, and copies the object's position, visibility and layer index onto
it. The object's name is the registry key. See Chapter 12 for what an
actor is and Chapter 11 for what a character supplies.
Two custom properties are required on the object:
| Property | Tiled type | Meaning |
|---|---|---|
character |
string |
Name of a character already in AKGL_REGISTRY_CHARACTER |
state |
int |
The actor's initial AKGL_ACTOR_STATE_* bitmask, as a number |
state is an integer here, and only here. Character JSON accepts the string-array form
— ["AKGL_ACTOR_STATE_ALIVE", "AKGL_ACTOR_STATE_FACE_DOWN"] — because
AKGL_ACTOR_STATE_STRING_NAMES exists to decode it. akgl_tilemap_load_layer_object_actor
calls akgl_get_json_properties_integer, which demands a property Tiled declared as
"int", so a map that spells the state as names raises AKERR_TYPE. Add up the bit values
from Chapter 12 and write the sum.
Two consequences of the registry being the identity:
- The characters have to be loaded before the map. An unregistered
charactername comes back asAKERR_KEYfromakgl_actor_set_character, in the middle of a partly loaded map. - Two objects naming one actor do not make two actors. The second placement takes another reference on the first and overwrites its position, so the actor ends up wherever the last object put it. That is the mechanism for placing one actor from two layers; it is not a way to spawn a crowd.
An actor object with an empty name is refused with AKERR_KEY — an actor has to be
addressable.
2. A physics.model property gives the map its own physics
A custom property named physics.model on the map root hands the map's name to
akgl_physics_factory and stores the resulting backend in map->physics, then sets
map->use_own_physics. Six more properties configure it, each defaulting to 0.0 when
absent:
| Property | Tiled type | Sets |
|---|---|---|
physics.model |
string |
Which backend — null or arcade. See Chapter 14 |
physics.gravity.x / .y / .z |
float |
The backend's gravity constants |
physics.drag.x / .y / .z |
float |
The backend's drag coefficients |
A swimming level and a walking level then differ by data rather than by code.
Nothing in the library acts on use_own_physics. akgl_game_update steps the global
akgl_physics and never looks at the map's. The flag is a signal to you: read it after
loading and point akgl_physics at map->physics if you want it honoured. The example
below does exactly that.
A map with no properties array at all, or with properties but no physics.model, is the
normal case: akgl_tilemap_load_physics returns success and the map uses the game's global
backend. The AKERR_KEY handlers in that function are the "map did not ask" path, not
error paths. A physics.model naming a backend that does not exist is an error.
3. p_foreground and p_vanishing define the pseudo-3D band
Two objects in an object layer, each with "type": "perspective" and a scale custom
property declared float, define a band down the screen over which actors are scaled. That
is what makes a character look further away as they walk up a top-down map.
y = 0 ----------------------------------------------------- top of the map
.
. actors above p_vanishing_y stay at
. p_vanishing_scale
p_vanishing_y ----------- [tiny actor] scale = p_vanishing_scale
/ \
/ \ linear interpolation between the
/ \ two rows, at p_rate per pixel of y
/ \
p_foreground_y --------- [ big actor ] scale = p_foreground_scale
actors below here stay at
p_foreground_scale
The names are matched literally: p_foreground sets the row where actors are at full size,
p_vanishing the row where they are smallest. Both the type and the name have to be
right. The loader checks type == "perspective" first and only then compares the name,
so an object named p_vanishing with type "object" is silently just an object — no error,
no perspective, and a very confusing afternoon.
Each marker contributes its y and its scale; p_rate is derived at load as the scale
change per pixel between the two. A marker at y = 0 disables perspective, because the
rate is only computed when both p_foreground_y and p_vanishing_y are non-zero — and a
map with no markers has both scales at 1.0 and a rate of 0, so the whole feature is a no-op
rather than a special case. Perspective markers are forced invisible: they are geometry,
not scenery.
akgl_tilemap_scale_actor applies the band, writing only the actor's scale. It is called
per actor by akgl_game_update against the global akgl_gamemap when the frame's
iterator carries AKGL_ITERATOR_OP_TILEMAPSCALE; see Chapter 7.
The marker heights (p_foreground_h, p_vanishing_h) are recorded and not used by the
current rate calculation.
A minimal map
Two 16-pixel tiles square, one tile layer, one embedded tileset, and one actor object. The
state value 17 is AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN — 16 plus 1, from
the bit table in Chapter 12. Add the values you want; there is no symbolic
form on this side.
{
"compressionlevel": -1,
"height": 2,
"infinite": false,
"nextlayerid": 3,
"nextobjectid": 2,
"orientation": "orthogonal",
"renderorder": "right-down",
"tiledversion": "1.8.2",
"tileheight": 16,
"tilewidth": 16,
"type": "map",
"version": "1.8",
"width": 2,
"layers": [
{
"data": [1, 2, 3, 4],
"height": 2,
"id": 1,
"name": "Tile Layer 1",
"opacity": 1,
"type": "tilelayer",
"visible": true,
"width": 2,
"x": 0,
"y": 0
},
{
"draworder": "topdown",
"id": 2,
"name": "Object Layer 1",
"opacity": 1,
"type": "objectgroup",
"visible": true,
"x": 0,
"y": 0,
"objects": [
{
"gid": 195,
"height": 16,
"id": 1,
"name": "testactor",
"rotation": 0,
"type": "actor",
"visible": true,
"width": 16,
"x": 16,
"y": 16,
"properties": [
{ "name": "character", "type": "string", "value": "testcharacter" },
{ "name": "state", "type": "int", "value": 17 }
]
}
]
}
],
"tilesets": [
{
"columns": 48,
"firstgid": 1,
"image": "assets/tileset.png",
"imageheight": 576,
"imagewidth": 768,
"margin": 0,
"name": "World_A1",
"spacing": 0,
"tilecount": 1728,
"tileheight": 16,
"tilewidth": 16
}
]
}
Loading one
#include <akgl/tilemap.h>
#include <akgl/error.h>
#include <akgl/game.h>
/*
* 25 MiB of tilemap. Static storage, never a local: a stack frame this size
* overruns the default 8 MiB thread stack before the loader writes a byte.
*/
static akgl_Tilemap townmap;
akerr_ErrorContext AKERR_NOIGNORE *load_town(void)
{
PREPARE_ERROR(errctx);
/* Reusing a map struct leaks the previous map's textures unless this runs
* first -- akgl_tilemap_load zeroes rather than releases. */
PASS(errctx, akgl_tilemap_release(&townmap));
PASS(errctx, akgl_tilemap_load("assets/town.tmj", &townmap));
/* The map may have brought its own physics with it. */
if ( townmap.use_own_physics == true ) {
akgl_physics = &townmap.physics;
}
akgl_gamemap = &townmap;
SUCCEED_RETURN(errctx);
}
Loading a map has side effects well beyond the destination struct: textures are uploaded, actors are created in the pool, and names are published in the registry. So the renderer, the pools, the registries and every character the map names have to be in place first. The startup order that produces that is in Chapter 7.
Drawing is per layer. akgl_tilemap_draw(map, viewport, layeridx) draws one layer clipped
to a viewport in map pixels — usually akgl_camera — and draws the tiles at the viewport's
edges partially, so scrolling is smooth rather than snapping to the grid. An image layer
ignores the viewport and is drawn once at the origin. akgl_render_2d_draw_world makes one
pass per layer; see Chapter 8.
Known defects
Documented here because this is where a reader hits them. Each is cross-referenced to
TODO.md.
A failed load releases nothing. akgl_tilemap_load zeroes the destination up front and
then loads physics, geometry, layers and tilesets in order. If any of that fails, the
textures already uploaded and the actors already created stay exactly where they are, and
the destination holds a half-built map. CLEANUP gives back the JSON document and one pool
string and nothing else. Treat a failed load as needing akgl_tilemap_release and a
fresh actor registry before you try again — otherwise the retry inherits the corpse, and
akgl_tilemap_load will zero the struct over the previous attempt's texture pointers and
leak them.
akgl_tilemap_release does not release the actors an object layer created. It destroys
tileset and image-layer textures and clears each pointer as it goes — that half is correct
as of 0.5.0, and a second release is safe rather than a use-after-free (TODO.md, "Known
and still open" item 2). The actors, and their references on characters and sprites, are
yours to release. The header's @warning describing a tileset double-free is stale; the
loop it describes was fixed and tests/tilemap.c releases the fixture three times and
asserts every texture pointer is NULL.
akgl_tilemap_draw does not bounds-check layeridx. An index at or past numlayers
reads a neighbouring layer, and one past 16 reads past the array. akgl_tilemap_draw_tileset
checks its upper bound but not for a negative index.
A tileset's margin is recorded and never applied. akgl_tilemap_compute_tileset_offsets
steps by tile size plus spacing and ignores margin entirely, so a tileset image with a
border produces offsets shifted by it — every tile drawn from slightly the wrong place. The
FIXME is at src/tilemap.c:187. Author tilesets with margin: 0 until it is fixed.
A second tileset wastes its whole offset table. The offset table is indexed by local
tile id from 0, so tileset B's entries sit at the front of B's own 65,536-entry table while
akgl_tilemap_draw indexes it by tilenum - firstgid — correct, but it means every
tileset pays 512 KiB regardless of how many tiles it has. The FIXME on
akgl_Tileset::tile_offsets explains it. Consequence for you: prefer one large tileset per
map to five small ones.
Tileset images do not go through the spritesheet registry, so an image shared between two maps is loaded twice, into two textures.
numlayers is set before the layer bound is checked. akgl_tilemap_load_layers assigns
the JSON array's length and then refuses a seventeenth layer, so on that failure path the
struct briefly claims more layers than it has. It is only reachable on a path that has
already failed, but do not read numlayers off a map whose load raised.