World Chunk Manual

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.

If you port this system, preserve the semantics first and the implementation shape second. The critical contracts are coordinate conversion, row encoding, patch trimming, chunk normalization, and dirty-chunk save behavior.

1. Core Model

Conceptual contract

World

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 structure
content/
  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.
Chunk filenames are part of the contract. The server and client both use {floor(chunkX)}_{floor(chunkY)}.json and chunk cache keys of {floor(chunkX)}:{floor(chunkY)}.

3. World Metadata

Index, world.json, bookmarks

worlds.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 math

The 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)
Use floor division semantics for negative coordinates. Truncation toward zero is wrong here and will break chunk ownership for tiles left or above the origin.

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.

Tile identity in chunk rows is based on a single tile symbol, not a tile id. The symbol-to-tile mapping comes from the tile catalog.

7. Height System

Sparse, order-derived Z stack

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

Do not model height layers as full-size parallel maps if you want parity with this repo. They are intentionally sparse.

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
  1. Load world metadata with GET /api/world/:worldId.
  2. Read bookmarks from the same payload and choose an initial view target.
  3. Convert that target into centerChunkX and centerChunkY.
  4. Load a chunk neighborhood with GET /api/world/:worldId/chunks?chunkX=...&chunkY=...&radius=1&createIfMissing=1.
  5. Compose those chunk payloads into one temporary working map.
  6. Set originChunkX, originChunkY, tileOffsetX, and tileOffsetY so 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
Missing chunks are completed in memory with empty payloads so every neighborhood is a full square, even when the filesystem only has sparse chunk files.

10. Editing And Mutation Flow

How user actions change chunk data

Tile paint

  1. Paint into the composed room layer rows.
  2. Translate local tile to world tile with tileOffsetX/Y.
  3. Translate world tile to chunk and local-in-chunk coordinates.
  4. Patch the cached chunk row cell directly.
  5. Mark that chunk dirty.

Height paint

  1. Mutate the sparse patch in the composed document.
  2. Expand or shrink patch bounds as needed.
  3. Trim empty borders.
  4. Rebuild every overlapped chunk from document state.
  5. 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
  1. Ensure the composed working document is current from any dirty chunk cache edits.
  2. Sync height layer metadata into cached chunks so names and Z order match the current editor stack.
  3. If visible dirty chunks exist, rebuild them from the current document before persistence.
  4. Collect dirty chunk payloads.
  5. POST a batch payload to /api/world/:worldId/chunks/batch-save.
  6. 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 passes

The 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.
Height patches accept dots in input, but dots are not a meaningful stored value. They are normalized to empty space and then trimmed.

13. Porting Checklist

What to preserve in another language

Must 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 reference

Resolve 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()