24 KiB
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.7 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.
Cell-Tile material path (0.5.7)
A second, optional painting path: each layer can now also have a
cells_material array (W·H bool, lazy-allocated). Cells where
cells_material[x, y] is set use the classical 47-blob 8-neighbour
bitmask rule on effective material (vertex-derived OR cell-derived),
unlocking all 14 atlas slots. Cells whose material comes only from
the vertex grid continue to use the FIX-A 4-own-corner rule (5
reachable slots, dual-grid look).
Both paths coexist in the same layer; the bitmask rule is decided
per-cell based on whether cells_material[x, y] is true. A
vertex-only cell adjacent to a cell-tile cell remains structurally
blind to its neighbour because its 4-corner rule only reads its own
corner vertices — this asymmetric seam is documented behaviour. For
clean visuals use a single painting path per layer.
Public API: set_cell_material(layer, x, y, painted, map_id?) and
get_cell_material(layer, x, y, map_id?) -> bool.
The cells_material field is optional and absent on every existing
v3 map; save output is byte-identical to 0.5.6 when no cells_material
entries are non-zero.
Topology
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. Seeinit.luasource 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_DOWNconstants- Vertex-painted autotile renderer (any-corner + blob-14 SLOT_LOOKUP)
- Empty-layer + opaque-ceiling caches for render-opt
- 8 layer slots (
- 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.opaqueflag 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 paintget_vertex(layer_name, vx, vy, map_id?) -> bool
- v0.5.3 — bugfix:
load_texturesnow refreshes theatlas_by_aliasdict after replacingm.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 handleatlas_tile_size_px(atlas_idx, map_id?)— int, usually 64atlas_tile_uv(atlas_idx, slot, map_id?)—{x, y, w, h}in atlas pixel coords, ornil. Resolves slot via the sameslot_NN_name regex used internally by the renderer.
Original v0.2.0 entries
maps.load(path)
Syntax: maps.load(path: string) -> string
Example:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
-- 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/tilesindex in tile-units;tile_sizeis the conversion to pixels. - Tilemap-ids may be local (
<this-module-id>.<name>) or fully-qualified;load()resolves both. tile_atis bounds-checked: out-of-bounds returnsnil(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
{
"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
atlasesarray. - 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
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
textureatlas-id render via engine.render.draw_sprite_transform; tiles withouttexturefall 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)