Files
libakgl/docs/13-tilemaps.md
Andrew Kesterson 8f848d80be Document the UI subsystem: chapter 22, with the appendix moving to 23
docs/22-ui.md covers the clay-backed UI: what clay owns against what libakgl
owns, bring-up and the arena refusal contract, the frame bracket and where it
sits in a real frame, the consumed-event rules (with the one-frame-stale hit
test stated plainly), the three widgets, menus across keyboard, gamepad and
mouse, writing raw CLAY() with an application-owned press edge, images and
styles, and how layout errors surface from frame_end with a captured trace.
Every listing is a checked block: one linked-and-run example with pinned
output, eight excerpts quoting examples/uidemo and examples/jrpg/textbox.c,
and the chapter figure is a frame out of uidemo via docs_game_figures.

The chapter owes and pays the comparison the tutorials earn: the dialog
widget's default style reproduces the JRPG textbox palette, the two listings
sit side by side, and the trade is stated -- 125 lines you own completely
against one call that costs you the subsystem. Neither is deprecated;
chapter 21 keeps teaching the hand-rolled panel on purpose.

The appendix moves to 23 (links in chapters 4, 5, 6, 7, 13 and the TOC
updated) and gains the ui.h status cross-reference, the AKGL_UI_* limits
table, and the new draw primitives' rows. Its status-band figures were stale
at COUNT 5 / LIMIT 261 while the band already held six codes; now correct at
7 / 263. Chapter 4 gains AKGL_ERR_UI (262) in the excerpt, the table and the
name list.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:32:20 -04:00

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 23.

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 character name comes back as AKERR_KEY from akgl_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.