# lib-core.maps Multi-layer tile-grid map implementation with vertex-painted autotile (blob-14), sparse per-cell overrides, packed-u32 GID legacy path, atlas loading + UV resolution, walkability + sight-blocking queries, and a v3 multi-layer render pipeline with opaque-ceiling cache. **Version:** 0.5.6 **Lib-ID:** lib-core.maps **Requires:** (none) **Tags:** maps, tile-grid, multi-layer, vertex-painting, autotile, blob-14, override, walkability, tilemap ## Schema versions | Version | Highlights | |---|---| | v1 | Single-layer `tiles[]` + tilemap path. Loaded but auto-upgraded to v2 on `maps.load`. | | v2 | Multi-atlas + multi-layer with packed-u32 GIDs (atlas_idx + tile_id + rotation). `set_cell_gid`, `save_to_disk`. | | v3 | Per-layer `material` (atlas-alias), optional `vertices` ((W+1)·(H+1) grid), optional `overrides` ({"x:y": int OR {slot, rot, flip}}). Renderer derives slot/rot/flip from neighbour-bitmask blob-gating; overrides force-place specific orientations. | ## Painting model (v3 autotile, dual-grid) Two grids offset by half a tile. The PAINT grid (called `vertices` in storage, "map-tiles" in design docs) is what users paint. The RENDER grid (called `cells`) is offset by (+0.5, +0.5) tile and is where sprites sit. Each render-cell spans 4 surrounding paint-tiles which act as its 4 corners (TL, TR, BL, BR). - **Paint grid**: (W+1)·(H+1) cells. Any-corner rule: render-cell (x,y) is material iff any of its 4 corner paint-tiles is painted. - **Bitmask + SLOT_LOOKUP** (0.5.6 dual-grid native): each material render-cell's 8-bit neighbour-bitmask (clockwise from N) is derived from its own 4 corner paint-tiles — cardinal bit set iff ≥1 of the edge's 2 paint-tiles painted, diagonal bit set iff the corner paint-tile is painted. Blob-gating then zeroes diagonals whose 2 adjacent cardinals are not both set. The gated bitmask maps to one of 14 canonical slots × {0,1,2,3} rotation × {0,1} flip via a D4-orbit table. - **Override sublayer**: sparse `{"x:y": slot}` or `{"x:y": {slot, rot, flip}}`. Takes precedence over the bitmask-derived slot AND forces material-presence on the cell. **0.5.6 fix:** pre-0.5.6 derived the bitmask from the material status of the 8 neighbour render-cells, which violated dual-grid semantics — two cells whose shared edge had no painted paint-tiles still saw each other as material whenever any unrelated corner of either was painted, producing connected blobs across visually empty paint-tile gaps. See plan `2026-05-29-painting-model-rethink`. ## Topology ```mermaid graph LR this["lib-core.maps"] engine["engine.*"] this --> engine ``` ## API > **Note:** the API section below was authored against v0.2.0 and is > being progressively updated. Entries marked `[v0.x added]` are the > additions since 0.2.0. See `init.lua` source for the full surface. ### APIs added v0.3.0 — v0.5.4 (summary) - **v0.3.0** — atlas-baker integration: `load_textures(asset_aliases)` rewrite for M.2 atlas format; height-field API; atlas-bootstrap stub when tilemap JSON missing. - **v0.4.0** — write-APIs for editors / procedural-gen: - `set_cell_gid(layer_name, x, y, gid, map_id?)` - `set_roof(x, y, value, map_id?)` - `save_to_disk(map_id, path)` - **v0.5.0a-e** — schema-v3 multi-layer terrain stack: - 8 layer slots (`foundation`, `subsurface`, `surface`, `topsurface`, `lower_wall`, `wall`, `upper_wall`, `canopy`) - `LAYER_Z` + `LAYER_ORDER_TOP_DOWN` constants - Vertex-painted autotile renderer (any-corner + blob-14 SLOT_LOOKUP) - Empty-layer + opaque-ceiling caches for render-opt - **v0.5.0d** — sparse override sublayer: - `set_override(layer_name, x, y, slot_or_entry, map_id?)` - `clear_override(layer_name, x, y, map_id?)` - `get_override(layer_name, x, y, map_id?)` - **v0.5.1** — override-entry format extension to `{slot, rot, flip}` (object form, backwards-compat with bare integer). Auto-compacts to bare int when canonical orientation (rot=0+flip=0). `tile.opaque` flag consumed from atlas-baker v0.2.0 alpha-analysis. - **v0.5.2** — public vertex-grid write APIs: - `set_vertex(layer_name, vx, vy, painted, map_id?)` — any-corner rule fills up to 4 cells; lazy-allocates grid on first paint - `get_vertex(layer_name, vx, vy, map_id?) -> bool` - **v0.5.3** — bugfix: `load_textures` now refreshes the `atlas_by_alias` dict after replacing `m.atlases[i]`, fixing the v3 vertex/material render path which was resolving through the pre-load stub (no texture handle → MISSING_ASSET_COLOR fallback). - **v0.5.4** — public atlas accessors for palette consumers: - `atlas_diffuse_handle(atlas_idx, map_id?)` — raylib texture handle - `atlas_tile_size_px(atlas_idx, map_id?)` — int, usually 64 - `atlas_tile_uv(atlas_idx, slot, map_id?)` — `{x, y, w, h}` in atlas pixel coords, or `nil`. Resolves slot via the same `slot_NN_` name regex used internally by the renderer. ### Original v0.2.0 entries ### `maps.load(path)` **Syntax:** `maps.load(path: string) -> string` **Example:** ```lua local id = maps.load("maps/forest.json") maps.set_current(id) ``` **Description:** Loads a JSON map-file from disk, resolves + loads its referenced tilemap (local module-tilemap-id or fully-qualified), validates the map-table, and registers it. Returns the map-id. Errors if the id is already registered. ### `maps.create(t)` **Syntax:** `maps.create(t: {id: string, tilemap_table: table, ...}) -> string` **Example:** ```lua local id = maps.create({ id = "test-tiny", size = { w = 4, h = 4 }, tile_size = { w = 16, h = 16 }, tilemap_table = my_tilemap, tiles = { 0,0,0,0, 0,1,1,0, 0,1,1,0, 0,0,0,0 }, }) ``` **Description:** Programmatic creation for tests + procedural generators. Caller must supply a fully-built `tilemap_table` (not a path/id reference). Otherwise identical to `load`. ### `maps.size(map_id)` **Syntax:** `maps.size(map_id: string | nil) -> {w: integer, h: integer}` **Description:** Returns map-size in tiles. Falls back to current-map when `map_id` is nil. Errors if no current map. ### `maps.tile_size(map_id)` **Syntax:** `maps.tile_size(map_id: string | nil) -> {w: integer, h: integer}` **Description:** Returns tile-size in pixels. Falls back to current-map when nil. ### `maps.tile_at(a, b, c)` **Syntax:** `maps.tile_at(map_id: string, tx: integer, ty: integer) -> table | nil` *(also: `maps.tile_at(tx, ty)` uses current-map)* **Example:** ```lua local tile = maps.tile_at(5, 3) if tile then engine.print(tile.id) end ``` **Description:** Arity-flex sugar: `tile_at(tx, ty)` uses current-map; `tile_at(map_id, tx, ty)` is explicit. Returns the resolved tile-record from the tilemap, or `nil` if out-of-bounds. ### `maps.is_walkable(a, b, c)` **Syntax:** `maps.is_walkable(map_id: string, tx: integer, ty: integer) -> bool` *(also: `is_walkable(tx, ty)` uses current-map)* **Description:** Convenience: returns `true` iff the tile exists and has `walkable == true`. Out-of-bounds is `false`. ### `maps.tilemap_id(map_id)` **Syntax:** `maps.tilemap_id(map_id: string | nil) -> string` **Description:** Returns the fully-qualified tilemap-id referenced by a map. ### `maps.current()` **Syntax:** `maps.current() -> string | nil` **Description:** Returns the currently-active map-id, or `nil` if none. ### `maps.set_current(map_id)` **Syntax:** `maps.set_current(map_id: string) -> void` **Description:** Switches the current-map pointer. Errors if `map_id` is not registered. ### `maps.list()` **Syntax:** `maps.list() -> string[]` **Description:** Returns an array of all registered map-ids. ### `maps.load_textures(asset_aliases)` **Syntax:** `maps.load_textures(asset_aliases: {[string]: string}) -> void` **Example:** ```lua maps.set_current(map_id) maps.load_textures({ terrain = "lib-core.terrain-assets" }) ``` **Description:** Resolves the current tilemap's tile `texture` atlas-ids to texture-handles via the asset-lib indirection. `asset_aliases` maps the tilemap's `asset_pack` alias-key to a lib-id; the lib's `assets/atlas.json` is read to locate each texture file. Populates `tile.texture_handle` on each tile in-place. No-op if the tilemap has no `asset_pack`. Call once at module init after `set_current`; do not call repeatedly (texture handles are not auto-cached). ### `maps.draw_map()` **Syntax:** `maps.draw_map() -> void` **Description:** Draws the current map grid. Tiles with a resolved `texture_handle` render via `engine.render.draw_sprite_transform` with rotation about the tile center (using the map's optional `tile_rotations` parallel array). Tiles without a texture handle fall back to `engine.render.draw_rect` with `tile.color` (Phase 1 mode). No-op if no current map. ### `maps.state(map_id)` **Syntax:** `maps.state(map_id: string) -> string` **Description:** DEPRECATED-MVP stub. Always returns `"Active"` in v0.1.x. Full lifecycle (`Virgin`/`Inert`/`Passive`/`Active`/`Pinned`) lands in the multi-map slice. ### `maps.pin(map_id, reason)` **Syntax:** `maps.pin(map_id: string, reason: string) -> void` **Description:** DEPRECATED-MVP stub. Emits a warn in v0.1.x. Pinning prevents lifecycle-eviction in the multi-map slice. ### `maps.encode_gid(atlas_index, tile_id, rotation?)` **Syntax:** `maps.encode_gid(atlas_index: integer, tile_id: integer, rotation?: integer) -> integer` **Example:** ```lua local gid = maps.encode_gid(0, 42, 1) -- atlas 0, tile 42, rotation 90° ``` **Description:** Packs atlas_index (8 bits), tile_id (20 bits), and rotation (2 bits, 0–3 = 0°/90°/180°/270°) into a single u32 GID. `rotation` defaults to 0 when omitted. Bit layout: `[31..24 atlas_index][23..4 tile_id][3..2 rotation][1..0 reserved]`. ### `maps.decode_gid(gid)` **Syntax:** `maps.decode_gid(gid: integer) -> atlas_index: integer, tile_id: integer, rotation: integer` **Example:** ```lua local ai, tid, rot = maps.decode_gid(gid) ``` **Description:** Unpacks a packed-u32 GID into its three components: atlas_index, tile_id, and rotation (0–3). Inverse of `encode_gid`. ### `maps.upgrade_v1_to_v2(v1_table)` **Syntax:** `maps.upgrade_v1_to_v2(v1_table: table) -> table` **Example:** ```lua local v2 = maps.upgrade_v1_to_v2(old_map) ``` **Description:** Converts a schema-v1 map table to schema-v2 format. The v1 flat tile array is placed into the `surface` layer with GIDs encoded against `atlases[1]`. Called automatically by `maps.load` and `maps.create` when the schema_version field is absent or equals 1; consumers normally do not need to call this directly. ### `maps.validate_map_table_v2(table, source)` **Syntax:** `maps.validate_map_table_v2(table: table, source: string) -> void` **Description:** Validates a v2 map table for required fields (schema_version, id, size, atlases, layers). Errors with the `source` string as context when the table is malformed. Called internally by `load` and `create`; available for use in tests and generators. ### `maps.atlas_count(map_id?)` **Syntax:** `maps.atlas_count(map_id: string | nil) -> integer` **Example:** ```lua local n = maps.atlas_count() -- count atlases on current map ``` **Description:** Returns the number of atlas entries in the map's `atlases` array. Falls back to current-map when `map_id` is nil. ### `maps.atlas_id_at(idx, map_id?)` **Syntax:** `maps.atlas_id_at(idx: integer, map_id: string | nil) -> string` **Example:** ```lua local id = maps.atlas_id_at(1) -- fully-qualified atlas id at index 1 ``` **Description:** Returns the fully-qualified atlas id at 1-based index `idx` in the map's atlases array. Falls back to current-map when `map_id` is nil. Errors if index is out of range. ### `maps.has_layer(layer_name, map_id?)` **Syntax:** `maps.has_layer(layer_name: string, map_id: string | nil) -> boolean` **Example:** ```lua if maps.has_layer("canopy") then -- render canopy layer end ``` **Description:** Returns `true` if the map has a layer with the given name. Falls back to current-map when `map_id` is nil. Valid layer names: `foundation`, `subsurface`, `surface`, `topsurface`, `lower_wall`, `wall`, `upper_wall`, `canopy`, `roof`. ### `maps.is_indoor(x, y, map_id?)` **Syntax:** `maps.is_indoor(x: integer, y: integer, map_id: string | nil) -> boolean` **Example:** ```lua if maps.is_indoor(tx, ty) then -- apply indoor lighting end ``` **Description:** Returns `true` if the roof metadata layer marks the cell at `(x, y)` as indoor. Falls back to current-map when `map_id` is nil. Returns `false` if the map has no roof layer or the cell is out of bounds. ### `maps.cell_gid(layer_name, x, y, map_id?)` **Syntax:** `maps.cell_gid(layer_name: string, x: integer, y: integer, map_id: string | nil) -> integer` **Example:** ```lua local gid = maps.cell_gid("surface", 5, 3) local ai, tid, rot = maps.decode_gid(gid) ``` **Description:** Returns the packed-u32 GID stored at cell `(x, y)` in the named layer. Returns 0 for empty cells and out-of-bounds positions. Falls back to current-map when `map_id` is nil. ### `maps.tile_at_layer(layer_name, x, y, map_id?)` **Syntax:** `maps.tile_at_layer(layer_name: string, x: integer, y: integer, map_id: string | nil) -> table | nil` **Example:** ```lua local tile = maps.tile_at_layer("surface", 5, 3) if tile then engine.print(tile.id) end ``` **Description:** Returns the resolved tile record from the atlas referenced by the cell's GID in the named layer, or `nil` if the cell is empty or out of bounds. Falls back to current-map when `map_id` is nil. Equivalent to `maps.tile_at` but with an explicit layer argument. ### `maps.blocks_walk(x, y, map_id?)` **Syntax:** `maps.blocks_walk(x: integer, y: integer, map_id: string | nil) -> boolean` **Example:** ```lua if maps.blocks_walk(tx, ty) then -- cell is impassable end ``` **Description:** Returns `true` if any tile in the walk-relevant layers (`surface`, `lower_wall`, `wall`) at `(x, y)` has `walkable == false`. Out-of-bounds returns `true` (blocked). Falls back to current-map when `map_id` is nil. Supersedes `is_walkable` for v2 maps. ### `maps.blocks_sight(x, y, map_id?)` **Syntax:** `maps.blocks_sight(x: integer, y: integer, map_id: string | nil) -> boolean` **Example:** ```lua if maps.blocks_sight(tx, ty) then -- cell blocks line of sight end ``` **Description:** Returns `true` if any tile at `(x, y)` across all relevant layers has `blocks_sight == true`. Falls back to current-map when `map_id` is nil. Returns `true` for out-of-bounds positions. ### `maps.iterate_layers_pre_entities(fn, map_id?)` **Syntax:** `maps.iterate_layers_pre_entities(fn: function(layer_name: string), map_id: string | nil) -> void` **Example:** ```lua maps.iterate_layers_pre_entities(function(layer_name) -- draw the layer end) ``` **Description:** Calls `fn` once for each visual layer that renders before the entity slot, in draw order: `foundation`, `subsurface`, `surface`, `topsurface`. Falls back to current-map when `map_id` is nil. Skips layers not present on the map. ### `maps.iterate_layers_post_entities(fn, map_id?)` **Syntax:** `maps.iterate_layers_post_entities(fn: function(layer_name: string), map_id: string | nil) -> void` **Example:** ```lua maps.iterate_layers_post_entities(function(layer_name) -- draw the layer above entities end) ``` **Description:** Calls `fn` once for each visual layer that renders after the entity slot, in draw order: `lower_wall`, `wall`, `upper_wall`, `canopy`. Falls back to current-map when `map_id` is nil. Skips layers not present on the map. ### `maps.draw_map_pre_entities()` **Syntax:** `maps.draw_map_pre_entities() -> void` **Example:** ```lua -- in on_draw: maps.draw_map_pre_entities() -- draw entities here maps.draw_map_post_entities() ``` **Description:** Draws all pre-entity layers of the current map (`foundation` through `topsurface`) using `engine.render.draw_sprite_transform` for textured tiles and `engine.render.draw_rect` as fallback. No-op if no current map. Replaces the single-pass `draw_map` for consumers that need to interleave entity rendering. ### `maps.draw_map_post_entities()` **Syntax:** `maps.draw_map_post_entities() -> void` **Description:** Draws all post-entity layers of the current map (`lower_wall` through `canopy`). Must be called after entity rendering when using the split draw model. No-op if no current map. ### Write APIs (v0.4.0+) #### `maps.set_cell_gid(layer_name, x, y, gid, map_id?)` Writes a single cell into the named layer of the current (or named) map. The layer must be one of `VALID_LAYER_NAMES`; bounds are checked against `map.size`. If the layer does not yet exist on the map it is allocated and initialised to all-zero before the write. Sets an internal `_dirty` flag so callers (e.g. the map-editor) can track unsaved changes. #### `maps.set_roof(x, y, value, map_id?)` Writes a single roof flag (`0` or `1`) at the named cell. Allocates the roof array on demand if the map did not previously have one. Same bounds-check as `set_cell_gid`. Throws on values other than 0 or 1. #### `maps.save_to_disk(map_id, path)` Serialises the in-memory map to v2 JSON and writes it to `path`. Output is pretty-printed with 2-space indent and is byte-deterministic for the same map state (sorted object keys, fixed array order). Reverses the internal atlas-resolution back to atlas-ID strings on disk. Resets the `_dirty` flag on success. ## Conventions - Pixel-coords + tile-coords kept distinct: `size`/`tiles` index in tile-units; `tile_size` is the conversion to pixels. - Tilemap-ids may be local (`.`) or fully-qualified; `load()` resolves both. - `tile_at` is bounds-checked: out-of-bounds returns `nil` (not error). - Y-down-positive per ADR-0031. ## Schema (v2) Schema-v2 is the canonical map format as of v0.2.0. Schema-v1 maps are auto-migrated to v2 transparently in `maps.load` and `maps.create`. ### Top-level structure ```json { "schema_version": 2, "id": "my-map", "size": { "w": 16, "h": 16 }, "tile_size": { "w": 16, "h": 16 }, "atlases": [ { "id": "lib-core.terrain-assets", "alias": "terrain" } ], "layers": { "foundation": [/* w*h packed u32 GIDs */], "subsurface": [], "surface": [/* … */], "topsurface": [], "lower_wall": [], "wall": [], "upper_wall": [], "canopy": [] }, "roof": [/* w*h booleans: true = indoor */] } ``` All layer arrays are `w * h` elements, row-major (Y-down-positive). Empty arrays or omitted keys mean the layer is absent. The `roof` array is a flat boolean array (not a layer slot); `is_indoor` reads from it. ### Layer-name whitelist and gameplay semantics | Layer | Z-order | Semantics | |---|:---:|---| | `foundation` | 1 | Bottom-most ground fill (deep floor, pit bottom, water bed) | | `subsurface` | 2 | Sub-floor details (rubble, cables, sub-water objects) | | `surface` | 3 | Primary floor / ground layer — walkability is keyed here | | `topsurface` | 4 | Floor overlays (rugs, puddles, decals on the ground) | | *(entities)* | — | Entity slot — rendered between topsurface and lower_wall | | `lower_wall` | 5 | Wall bases, furniture bases, low obstacles | | `wall` | 6 | Main wall bodies, furniture, doors | | `upper_wall` | 7 | Wall tops, window frames, upper furniture details | | `canopy` | 8 | Roof fringe, tree canopy, overhead overlays | | `roof` | — | Metadata only (not rendered); indoor mask for lighting | ### Packed-u32 GID bit layout Each cell in a layer array is a single 32-bit unsigned integer: ``` Bit: 31 24 23 4 3 2 1 0 [atlas:8 ] [tile_id:20 ] [rot:2] [res:2] ``` - **atlas** (bits 31..24): 0-based index into the map's `atlases` array. - **tile_id** (bits 23..4): tile index within the atlas. - **rot** (bits 3..2): rotation in 90° steps — 0=0°, 1=90°, 2=180°, 3=270°. - **res** (bits 1..0): reserved, must be 0. Encode: `gid = (atlas << 24) | (tile_id << 4) | (rot << 2)` Decode: `atlas = gid >> 24`, `tile_id = (gid >> 4) & 0xFFFFF`, `rot = (gid >> 2) & 0x3` A GID of 0 means "empty cell" (no tile). ### Auto-migration v1 → v2 When `maps.load` or `maps.create` receives a map without `schema_version` or with `schema_version == 1`, `maps.upgrade_v1_to_v2` is called automatically. The v1 flat `tiles` array is placed into the `surface` layer with GIDs encoded against `atlases[1]`. Existing consumers see v2 data transparently; no code changes required. ## Consumer pattern ```lua local maps = require("lib-core.maps") local id = maps.load("maps/forest.json") maps.set_current(id) local sz = maps.size() for ty = 0, sz.h - 1 do for tx = 0, sz.w - 1 do if not maps.is_walkable(tx, ty) then -- mark blocked cell end end end ``` ## CHANGELOG ### v0.2.0 - Schema-v2 multi-layer maps with named layer slots (foundation, subsurface, surface, topsurface, lower_wall, wall, upper_wall, canopy) plus a roof metadata layer. - Packed-u32 GID encoding per cell: bits 31..24 atlas_index, 23..4 tile_id, 3..2 rotation, 1..0 reserved. - Multi-atlas-per-map: atlases[] array, GIDs reference into it. - Auto-migration of v1 maps to v2 on load — existing consumers see v2 data transparently. - New per-layer query APIs: tile_at_layer, cell_gid, has_layer. - New gameplay queries: blocks_walk, blocks_sight, is_indoor. - New layer-iteration APIs: iterate_layers_pre_entities, iterate_layers_post_entities, draw_map_pre_entities, draw_map_post_entities. Entity slot is between topsurface and lower_wall. - Backward-compat: draw_map and tile_at delegate to the v2 API using the surface layer for legacy consumers. ### v0.1.2 - Sprite-mode in draw_map: tilemap-tiles with `texture` atlas-id render via engine.render.draw_sprite_transform; tiles without `texture` fall back to color-rect render (Phase 1 mode). - Map-data tile_rotations parallel array support: per-cell 90-degree rotation in {0, 90, 180, 270}, applied at draw via rotation about the tile center. Optional; absent means all-zero. - New API: maps.load_textures(asset_aliases) resolves the current tilemap's tile atlas-ids to texture-handles via the asset-lib indirection. Mirrors the puppet.load_textures pattern. - Tilemap schema additions: tilemap.asset_pack (alias-key), tile.texture (atlas-id). - Backward-compat: existing Phase 1 color-only tilemaps render unchanged. ### v0.1.1 - Tilemap-id resolution handles module-ids with dots (prefix-match instead of first-dot split). ### v0.1.0 (P.0) - Initial release: load/create + tile_at + is_walkable + current-map management. ## References - Spec v0.2.0 (M.1): `meta/docs/superpowers/specs/2026-05-21-map-multi-layer-design.md` - Spec v0.1.0 (P.0): `meta/docs/superpowers/specs/2026-05-09-p0-lib-maps-design.md` - Architecture: `meta/docs/architecture/map-topology.md` - ADR-0001 (engine knows verbs, libs bring nouns) - ADR-0031 (pixel-convention: Y-down-positive) - ADR-0038 (API-Doc-Convention)