Purpose
One world, fixed-size chunks, sparse height patches, and chunk-local saves.
The editor does not treat the world as one giant saved grid. It treats the world as a fixed-size chunk lattice, composes a visible neighborhood into a temporary working document, and writes changes back out as chunk-local JSON files.
World space is global
Every meaningful tile position ultimately lives in world coordinates.
Chunk space is storage
Each chunk owns its local rows, local height patches, and local instance origins.
Height is sparse
Higher-Z content is not a full extra map. It is a set of trimmed patch rectangles.
1. Core Model
Conceptual contractWorld
A world is the top-level container. It owns chunk dimensions, tile size, default background tile, bookmarks, spawn point, background color, editor UI state, and every chunk file beneath its directory.
Chunk
A chunk is a fixed-size rectangular storage cell addressed by (chunkX, chunkY). It stores
room layers, height patches, and instances that belong to that cell.
Room layer
A room layer is a dense character grid exactly width x height for the chunk. Each character
is a tile symbol or a fill symbol.
Height layer
A height layer is a sparse patch. It is stored as a trimmed rectangle with x, y,
rows, and z. Empty borders are removed.
Instance
An instance is chunk-owned and stores its local tile origin. Its full record.position world
coordinate is reconstructed during normalization.
Neighborhood
The editor loads a chunk neighborhood, composes it into one temporary document, lets the user edit that document, then syncs changes back into the cached per-chunk payloads.
2. File Layout
On-disk structurecontent/
worlds.json
worlds/
overworld/
world.json
bookmarks.json
chunks/
0_0.json
0_1.json
1_0.json
-1_0.json
| File | Role |
|---|---|
content/worlds.json |
Index of worlds. Each entry provides id, name, and worldDir. |
world.json |
World metadata and editor metadata. No chunk payload lives here. |
bookmarks.json |
Saved navigation targets for the world editor. |
chunks/<chunkX>_<chunkY>.json |
Chunk-local room layers, sparse height patches, and instances. |
{floor(chunkX)}_{floor(chunkY)}.json and chunk cache keys of
{floor(chunkX)}:{floor(chunkY)}.
3. World Metadata
Index, world.json, bookmarksworlds.json
{
"schemaVersion": 1,
"worlds": [
{
"id": "overworld",
"name": "Overworld Mock",
"worldDir": "worlds/overworld"
}
]
}
world.json
{
"schemaVersion": 1,
"id": "overworld",
"name": "Overworld Mock",
"chunkWidth": 32,
"chunkHeight": 32,
"tileSize": 32,
"backgroundColor": "#060A14",
"defaultBackgroundTileId": "tile_5b6206b849",
"heightBlurStep": 0.1,
"editorUi": { "...": "editor panel layout state" },
"spawn": { "x": 80, "y": 80 },
"editor": {
"defaultZoom": 1,
"gridVisible": true
}
}
bookmarks.json
{
"schemaVersion": 1,
"worldId": "overworld",
"bookmarks": [
{ "id": "poi_1", "label": "Town Center", "x": 120, "y": 84 }
]
}
| Field | Meaning |
|---|---|
chunkWidth, chunkHeight |
Tile dimensions of every chunk in the world. The current repo uses 32x32. |
tileSize |
Pixel size for editor/runtime rendering. |
defaultBackgroundTileId |
World-level background tile inherited by chunk base cells that store .. |
backgroundColor |
Backdrop color for the editor and viewers. |
heightBlurStep |
Viewer/editor visual parameter for height blur strength. It is metadata, not map geometry. |
editorUi |
Saved panel/folder organization for the editor UI. |
spawn |
World tile coordinate used as a starting navigation target when no bookmark is chosen. |
4. Chunk Schema
Persistent unit of map content{
"schemaVersion": 1,
"worldId": "overworld",
"chunkX": 3,
"chunkY": 2,
"width": 32,
"height": 32,
"backgroundTileId": "",
"roomLayers": [
{
"layer": 0,
"rows": ["................................", "..."],
"instanceIds": []
},
{
"layer": 1,
"rows": [" ", "..."],
"instanceIds": []
}
],
"heightLayers": [],
"instances": []
}
| Field | Meaning | Important rule |
|---|---|---|
chunkX, chunkY |
Chunk address in world chunk space. | File name and payload must agree after normalization. |
width, height |
Chunk dimensions in tiles. | Rows are normalized to exactly these dimensions. |
backgroundTileId |
Chunk-level override of the world background tile. | Empty string means inherit the world background tile. |
roomLayers |
Dense per-layer character rows. | Layer 0 always exists. At least one editable layer above it also exists. |
heightLayers |
Sparse chunk-local height patches. | Each patch is trimmed and local to the chunk, not world-global on disk. |
instances |
Chunk-owned entity records. | x and y are local to the chunk payload. |
5. Coordinate System
Global to local mathThe conversion rules are the foundation of the entire system. Everything else depends on them being stable and identical in every language port.
chunkX = floor(worldX / chunkWidth) chunkY = floor(worldY / chunkHeight) localX = worldX - (chunkX * chunkWidth) localY = worldY - (chunkY * chunkHeight) worldX = (chunkX * chunkWidth) + localX worldY = (chunkY * chunkHeight) + localY
Chunk key
chunkKey = "{chunkX}:{chunkY}"
Chunk filename
fileName = "{chunkX}_{chunkY}.json"
Address helper
One helper returns chunkX, chunkY, localX, localY, chunkKey, and fileName.
| World tile | Chunk size | Chunk coord | Local coord |
|---|---|---|---|
(73, 18) |
32x32 |
(2, 0) |
(9, 18) |
(-1, -1) |
32x32 |
(-1, -1) |
(31, 31) |
(32, 32) |
32x32 |
(1, 1) |
(0, 0) |
6. Row Encoding Rules
How characters are interpreted| Context | Stored char | Meaning |
|---|---|---|
| Room layer 0 | . |
Inherit the chunk background tile if set, otherwise inherit the world default background tile. |
| Room layer 0 | space |
Explicit hole / transparent empty cell on the base layer. |
| Room layer 0 | Any other single char | Explicit tile symbol for that cell. |
| Room layer > 0 | space |
Empty cell. |
| Room layer > 0 | Any other single char | Explicit tile symbol for that overlay layer. |
| Height patch rows | or . |
Empty cell. Dots are normalized away to spaces. |
| Height patch rows | Any other single char | Tile symbol to draw at that patch cell. |
Dense room rows
Room rows are always normalized to the full chunk width and full chunk height. Short rows are padded. Long rows are truncated.
Sparse height rows
Height rows are stored as tightly trimmed rectangles. Empty outer rows and columns are removed. Trailing whitespace is stripped.
7. Height System
Sparse, order-derived Z stackAuthoring model
The editor exposes a list of height layers. The list order is authoritative. The first entry is Z1,
the second is Z2, and so on. In the editor code, height layer cloning rewrites
z = index + 1.
Storage model
Each chunk stores only the part of a patch that overlaps that chunk. On load, those local patches are re-expanded into neighborhood-local coordinates for the working document.
| Field | Meaning | Rule |
|---|---|---|
id |
Stable patch identity across saves. | Must be unique per chunk payload after normalization. |
name |
Human-facing layer name. | Optional. |
z |
Height level. | In the current editor, this is effectively derived from list order. |
x, y |
Patch origin. | Chunk-local on disk, neighborhood-local in the composed working document. |
rows |
Trimmed patch characters. | Dots are treated as empty; trailing whitespace is trimmed. |
Example patch on disk
{
"id": "height_2",
"name": "Tower Top",
"z": 2,
"x": 10,
"y": 6,
"rows": [
" AAA ",
"A A",
" AAA "
]
}
How patch painting works
Painting on a height layer does not mutate chunk rows directly. It mutates the sparse patch in the working document, expands the patch bounds if needed, trims the result, then rebuilds any overlapped chunks.
How erasing works
Erasing writes a space, then the patch is retrimmed. If the patch becomes empty, it collapses to
rows: [] at its trimmed origin.
8. Instance System
Chunk-owned entities
Instances are stored inside the chunk that owns their origin tile. The persisted x and
y are chunk-local. During normalization, the editor/runtime populates
record.position with the derived world tile position.
{
"id": "inst_gatekeeper_001",
"templateId": "npc_gatekeeper_bubbles",
"layer": 1,
"x": 12,
"y": 9,
"record": {
"name": "Bubbles",
"spriteId": "npc_human_style_13"
}
}
| Behavior | Current implementation |
|---|---|
| ID generation | Normalization guarantees an id. Duplicate mode can force new ids. |
| Layer ownership | The instance carries layer, and each room layer caches matching instanceIds. |
| Position clamping | x and y are clamped into chunk bounds during normalization. |
| Denormalized metadata | instanceIds in room layers are rebuilt from the actual instances array. |
9. Loading Pipeline
From JSON to working document- Load world metadata with
GET /api/world/:worldId. - Read bookmarks from the same payload and choose an initial view target.
- Convert that target into
centerChunkXandcenterChunkY. - Load a chunk neighborhood with
GET /api/world/:worldId/chunks?chunkX=...&chunkY=...&radius=1&createIfMissing=1. - Compose those chunk payloads into one temporary working map.
- Set
originChunkX,originChunkY,tileOffsetX, andtileOffsetYso local document coordinates can be translated back to world coordinates.
Neighborhood radius
Initial bootstrap uses radius 1, which means a 3x3 chunk neighborhood.
The runtime can grow this dynamically up to radius 4 depending on viewport size.
Composition result
The editor does not directly edit chunk payloads on screen. It edits the composed document made from the visible neighborhood, then syncs back into chunk cache entries.
Neighborhood composition result originChunkX = centerChunkX - radius originChunkY = centerChunkY - radius composedWidth = ((radius * 2) + 1) * chunkWidth composedHeight = ((radius * 2) + 1) * chunkHeight tileOffsetX = originChunkX * chunkWidth tileOffsetY = originChunkY * chunkHeight
10. Editing And Mutation Flow
How user actions change chunk dataTile paint
- Paint into the composed room layer rows.
- Translate local tile to world tile with
tileOffsetX/Y. - Translate world tile to chunk and local-in-chunk coordinates.
- Patch the cached chunk row cell directly.
- Mark that chunk dirty.
Height paint
- Mutate the sparse patch in the composed document.
- Expand or shrink patch bounds as needed.
- Trim empty borders.
- Rebuild every overlapped chunk from document state.
- Mark those chunks dirty.
Layer metadata edits
Renaming or reordering room layers does not only change the visible document. The runtime also syncs cached chunk metadata and adjusts per-instance layer ownership.
Height layer metadata edits
Renaming, duplicating, deleting, or reordering height layers triggers metadata sync into cached chunks and usually rebuilds visible chunks from the document.
| Operation | Implementation behavior |
|---|---|
| Chunk move | Source chunk payload is reassigned to a new chunk address, then the source is replaced with an empty chunk. |
| Chunk duplicate | Content is copied into the destination chunk, but placed instances are intentionally not copied. |
| Chunk transform | Rows, height patches, and instance local coordinates are rotated or flipped together. |
| Chunk clear | The destination payload becomes a fresh empty chunk payload. |
| Chunk background fill | The chunk stores a chunk-local background tile override and clears explicit base cells back to dots. |
11. Save Pipeline
Dirty chunks plus world metadata- Ensure the composed working document is current from any dirty chunk cache edits.
- Sync height layer metadata into cached chunks so names and Z order match the current editor stack.
- If visible dirty chunks exist, rebuild them from the current document before persistence.
- Collect dirty chunk payloads.
- POST a batch payload to
/api/world/:worldId/chunks/batch-save. - On success, clear the saved dirty chunk keys.
POST /api/world/:worldId/chunks/batch-save
{
"world": { "...": "world.json fields" },
"bookmarks": { "...": "bookmarks.json fields" },
"chunks": [
{ "...": "normalized chunk payload" }
]
}
What gets saved every time
World metadata and bookmarks are always included in the current batch-save payload, even if no chunk rows changed.
What gets saved selectively
Chunk files are saved only for chunk keys in the dirty set. That is the core scaling behavior.
12. Normalization Rules
Server and runtime repair passesThe raw JSON format is not consumed directly. Both the server and editor runtime normalize aggressively. A port needs to decide whether to keep that behavior, but if you want compatibility with saved data from this repo, you should.
| Object | Normalization behavior |
|---|---|
| World definition | Chunk dimensions are clamped to at least 1, tile size to at least 8, colors are normalized, spawn is floored, and editor UI is normalized. |
| Room rows | Rows are resized to exact chunk dimensions using . for layer 0 and for other layers. |
| Room layers | Layer 0 is always guaranteed. At least one editable layer above zero is also guaranteed. |
| Height layers | Ids are deduped, negative coordinates are clipped, rows are clamped to chunk bounds, dots become spaces, and empty borders are trimmed. |
| Instances | Ids are guaranteed, coordinates are clamped into chunk bounds, and record.position is rewritten to world coordinates. |
instanceIds |
The per-layer id lists are deduped and rebuilt from the instances array. |
13. Porting Checklist
What to preserve in another languageMust preserve exactly
- Floor-based world/chunk/local coordinate conversion.
- Base-layer
.inheritance semantics. - Sparse height patch trimming rules.
- Chunk-local instance storage with world position reconstruction.
- Dirty-chunk-only save behavior.
- Neighborhood composition and decomposition logic.
Can be implemented differently
- UI technology.
- Rendering backend.
- In-memory cache container types.
- HTTP framework.
- History storage mechanism.
Recommended port modules
world_chunking: pure coordinate and naming helpers.world_schema: normalization and validation for world/chunk/bookmark payloads.world_compose: chunk neighborhood to working-document composition.world_decompose: working-document back to chunk payload slicing.world_persistence: batch-save endpoint and file IO.world_editor_runtime: cache, dirty keys, neighborhood loads, and edit sync logic.
14. Minimal Pseudocode
Language-agnostic referenceResolve a world tile to a chunk cell
function resolveWorldChunkAddress(worldX, worldY, chunkWidth, chunkHeight):
chunkX = floor(worldX / chunkWidth)
chunkY = floor(worldY / chunkHeight)
localX = worldX - (chunkX * chunkWidth)
localY = worldY - (chunkY * chunkHeight)
return {
chunkX,
chunkY,
localX,
localY,
chunkKey: chunkX + ":" + chunkY,
fileName: chunkX + "_" + chunkY + ".json"
}
Compose a neighborhood into a working document
function composeNeighborhood(chunks, centerChunkX, centerChunkY, radius, chunkWidth, chunkHeight):
originChunkX = centerChunkX - radius
originChunkY = centerChunkY - radius
composedWidth = ((radius * 2) + 1) * chunkWidth
composedHeight = ((radius * 2) + 1) * chunkHeight
roomLayers = denseComposeRoomLayers(chunks, originChunkX, originChunkY, composedWidth, composedHeight)
heightLayers = sparseComposeHeightLayers(chunks, originChunkX, originChunkY)
instances = projectInstancesToNeighborhood(chunks, originChunkX, originChunkY)
return {
width: composedWidth,
height: composedHeight,
originChunkX,
originChunkY,
tileOffsetX: originChunkX * chunkWidth,
tileOffsetY: originChunkY * chunkHeight,
roomLayers,
heightLayers,
instances
}
Sync a painted tile into the owning chunk
function syncTileEdit(localTileX, localTileY, layerNumber, storedChar):
worldTileX = tileOffsetX + localTileX
worldTileY = tileOffsetY + localTileY
chunkX = floor(worldTileX / chunkWidth)
chunkY = floor(worldTileY / chunkHeight)
localX = worldTileX - (chunkX * chunkWidth)
localY = worldTileY - (chunkY * chunkHeight)
chunk = chunkCache[chunkKey(chunkX, chunkY)]
layer = ensureRoomLayer(chunk, layerNumber)
layer.rows[localY][localX] = storedChar
dirtyChunkKeys.add(chunkKey(chunkX, chunkY))
Rebuild chunk-local height patches from the working document
function buildChunkHeightLayersFromDocument(documentHeightLayers, baseTileX, baseTileY, chunkWidth, chunkHeight):
result = []
chunkRight = baseTileX + chunkWidth
chunkBottom = baseTileY + chunkHeight
for each patch in documentHeightLayers:
overlap = intersect(patch.bounds, [baseTileX, baseTileY, chunkRight, chunkBottom])
if overlap is empty:
continue
localRows = slicePatchRowsIntoOverlap(patch, overlap)
result.push({
id: patch.id,
name: patch.name,
z: patch.z,
x: overlap.left - baseTileX,
y: overlap.top - baseTileY,
rows: trimTrailingWhitespace(localRows)
})
return result
Batch save
function saveWorld():
ensureWorldDocumentCurrent()
syncCachedWorldHeightLayerMetadata()
rebuildVisibleDirtyChunksFromDocument()
payload = {
world: worldMetadata,
bookmarks: bookmarkPayload,
chunks: dirtyChunkPayloads()
}
POST /api/world/{worldId}/chunks/batch-save payload
clearDirtyChunkKeysThatWereSaved()