Worldshaper/docs/chunk_manual.htm
2026-06-26 18:18:14 -04:00

1335 lines
45 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>World Chunk Manual</title>
<style>
:root {
color-scheme: dark;
--bg: #08111f;
--bg-alt: #0d1a30;
--panel: #10203b;
--panel-2: #13294c;
--panel-3: #17345f;
--text: #deebff;
--muted: #a8bfdc;
--border: #33507c;
--accent: #78c1ff;
--accent-2: #abdfff;
--warn: #ffd47a;
--good: #7fe0af;
--shadow: rgba(0, 8, 20, 0.45);
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
background:
radial-gradient(circle at top right, rgba(120, 193, 255, 0.12), transparent 28%),
linear-gradient(180deg, #08111f 0%, #060d18 100%);
color: var(--text);
font-family: "Segoe UI", Arial, sans-serif;
}
a {
color: var(--accent-2);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code, pre {
font-family: Consolas, "Courier New", monospace;
}
.shell {
min-height: 100vh;
display: grid;
grid-template-rows: 52px 1fr;
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, #163057 0%, #10203c 100%);
box-shadow: 0 8px 20px var(--shadow);
}
.topbar-title {
margin-left: auto;
color: var(--accent-2);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.btn {
height: 34px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--panel-2);
color: var(--text);
font-size: 12px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.btn:hover {
background: var(--panel-3);
}
.layout {
display: grid;
grid-template-columns: 290px minmax(0, 1fr);
min-height: 0;
}
.sidebar {
position: sticky;
top: 52px;
align-self: start;
height: calc(100vh - 52px);
overflow: auto;
padding: 14px;
border-right: 1px solid var(--border);
background: rgba(10, 24, 46, 0.82);
backdrop-filter: blur(14px);
}
.sidebar-card,
.panel,
.mini-card,
.callout {
border: 1px solid var(--border);
border-radius: 12px;
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
box-shadow: 0 10px 24px var(--shadow);
}
.sidebar-card {
padding: 14px;
display: grid;
gap: 10px;
margin-bottom: 12px;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sidebar h1 {
margin: 0;
font-size: 22px;
line-height: 1.1;
}
.muted {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.03);
color: var(--accent-2);
font-size: 11px;
white-space: nowrap;
}
.nav {
display: grid;
gap: 6px;
}
.nav a {
display: block;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 9px;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.nav a:hover {
border-color: var(--border);
background: rgba(255, 255, 255, 0.03);
color: var(--text);
text-decoration: none;
}
.content {
padding: 18px;
display: grid;
gap: 14px;
}
.hero {
padding: 18px;
display: grid;
gap: 12px;
background:
linear-gradient(140deg, rgba(120, 193, 255, 0.12), transparent 50%),
linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
}
.hero h2,
.panel h2,
.panel h3 {
margin: 0;
line-height: 1.2;
}
.hero h2 {
font-size: 30px;
}
.hero p,
.panel p,
.panel li,
.panel td,
.panel th {
font-size: 14px;
line-height: 1.62;
}
.panel {
padding: 16px;
display: grid;
gap: 12px;
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.panel-tag {
color: var(--accent-2);
font-size: 12px;
white-space: nowrap;
}
.grid {
display: grid;
gap: 12px;
}
.grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.mini-card {
padding: 12px;
display: grid;
gap: 8px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.01));
}
.mini-card h3 {
font-size: 14px;
}
.mini-card p,
.mini-card ul,
.mini-card ol {
margin: 0;
}
.callout {
padding: 12px 13px;
background: rgba(255, 255, 255, 0.035);
}
.callout.warn {
border-color: var(--warn);
background: rgba(255, 212, 122, 0.08);
}
.callout.good {
border-color: var(--good);
background: rgba(127, 224, 175, 0.08);
}
.table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.12);
}
.table th,
.table td {
border: 1px solid var(--border);
padding: 9px 10px;
text-align: left;
vertical-align: top;
}
.table th {
color: var(--accent-2);
background: rgba(255, 255, 255, 0.03);
font-weight: 700;
}
.code {
margin: 0;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #0b162a;
color: var(--text);
overflow: auto;
font-size: 12px;
line-height: 1.58;
white-space: pre;
}
.footer-note {
color: var(--muted);
font-size: 12px;
}
@media (max-width: 1080px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
border-right: none;
border-bottom: 1px solid var(--border);
}
.grid.two,
.grid.three {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.topbar-title {
display: none;
}
.hero h2 {
font-size: 24px;
}
}
</style>
</head>
<body>
<div class="shell">
<div class="topbar">
<button class="btn" type="button" onclick="window.location.href='/'">Main Editor</button>
<button class="btn" type="button" onclick="window.location.href='/wiki'">Wiki</button>
<button class="btn" type="button" onclick="window.location.href='/wiki-assets/dialogue-builder.html'">Dialogue Builder</button>
<div class="topbar-title">World Chunk Manual</div>
</div>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-card">
<p class="eyebrow">Manual</p>
<h1>Chunk, Layer, and Height System</h1>
<p class="muted">
This page documents the open-world storage and editor runtime as it exists in this repo today,
with the goal of making the system portable to another language without guessing at the contracts.
</p>
<div class="chip-row">
<span class="chip">Chunked world</span>
<span class="chip">Sparse height patches</span>
<span class="chip">Batch save</span>
<span class="chip">Porting reference</span>
</div>
</div>
<div class="sidebar-card">
<p class="eyebrow">Sections</p>
<nav class="nav">
<a href="#core-model">1. Core Model</a>
<a href="#file-layout">2. File Layout</a>
<a href="#world-schema">3. World Metadata</a>
<a href="#chunk-schema">4. Chunk Schema</a>
<a href="#coordinates">5. Coordinates</a>
<a href="#row-encoding">6. Row Encoding</a>
<a href="#height-system">7. Height System</a>
<a href="#instances">8. Instances</a>
<a href="#loading">9. Loading Pipeline</a>
<a href="#editing">10. Editing Pipeline</a>
<a href="#saving">11. Save Pipeline</a>
<a href="#normalization">12. Normalization Rules</a>
<a href="#porting">13. Porting Checklist</a>
<a href="#pseudocode">14. Pseudocode</a>
</nav>
</div>
<div class="sidebar-card">
<p class="eyebrow">Source Of Truth</p>
<p class="muted">
The behavior described here comes from these implementation files:
<code>src/worldChunking.ts</code>,
<code>server.js</code>,
<code>src/mapEditorPopup/bootstrap.ts</code>,
<code>src/mapEditorPopup/runtime.ts</code>,
<code>src/mapEditorPopup/interactionController.ts</code>,
and <code>src/mapEditorPopup/persistenceController.ts</code>.
</p>
</div>
</aside>
<main class="content">
<section class="hero panel">
<p class="eyebrow">Purpose</p>
<h2>One world, fixed-size chunks, sparse height patches, and chunk-local saves.</h2>
<p>
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.
</p>
<div class="grid three">
<div class="mini-card">
<h3>World space is global</h3>
<p>Every meaningful tile position ultimately lives in world coordinates.</p>
</div>
<div class="mini-card">
<h3>Chunk space is storage</h3>
<p>Each chunk owns its local rows, local height patches, and local instance origins.</p>
</div>
<div class="mini-card">
<h3>Height is sparse</h3>
<p>Higher-Z content is not a full extra map. It is a set of trimmed patch rectangles.</p>
</div>
</div>
<div class="callout good">
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.
</div>
</section>
<section class="panel" id="core-model">
<div class="panel-head">
<h2>1. Core Model</h2>
<span class="panel-tag">Conceptual contract</span>
</div>
<div class="grid two">
<div class="mini-card">
<h3>World</h3>
<p>
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.
</p>
</div>
<div class="mini-card">
<h3>Chunk</h3>
<p>
A chunk is a fixed-size rectangular storage cell addressed by <code>(chunkX, chunkY)</code>. It stores
room layers, height patches, and instances that belong to that cell.
</p>
</div>
<div class="mini-card">
<h3>Room layer</h3>
<p>
A room layer is a dense character grid exactly <code>width x height</code> for the chunk. Each character
is a tile symbol or a fill symbol.
</p>
</div>
<div class="mini-card">
<h3>Height layer</h3>
<p>
A height layer is a sparse patch. It is stored as a trimmed rectangle with <code>x</code>, <code>y</code>,
<code>rows</code>, and <code>z</code>. Empty borders are removed.
</p>
</div>
<div class="mini-card">
<h3>Instance</h3>
<p>
An instance is chunk-owned and stores its local tile origin. Its full <code>record.position</code> world
coordinate is reconstructed during normalization.
</p>
</div>
<div class="mini-card">
<h3>Neighborhood</h3>
<p>
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.
</p>
</div>
</div>
</section>
<section class="panel" id="file-layout">
<div class="panel-head">
<h2>2. File Layout</h2>
<span class="panel-tag">On-disk structure</span>
</div>
<pre class="code">content/
worlds.json
worlds/
overworld/
world.json
bookmarks.json
chunks/
0_0.json
0_1.json
1_0.json
-1_0.json</pre>
<table class="table">
<thead>
<tr>
<th>File</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>content/worlds.json</code></td>
<td>Index of worlds. Each entry provides <code>id</code>, <code>name</code>, and <code>worldDir</code>.</td>
</tr>
<tr>
<td><code>world.json</code></td>
<td>World metadata and editor metadata. No chunk payload lives here.</td>
</tr>
<tr>
<td><code>bookmarks.json</code></td>
<td>Saved navigation targets for the world editor.</td>
</tr>
<tr>
<td><code>chunks/&lt;chunkX&gt;_&lt;chunkY&gt;.json</code></td>
<td>Chunk-local room layers, sparse height patches, and instances.</td>
</tr>
</tbody>
</table>
<div class="callout warn">
Chunk filenames are part of the contract. The server and client both use
<code>{floor(chunkX)}_{floor(chunkY)}.json</code> and chunk cache keys of
<code>{floor(chunkX)}:{floor(chunkY)}</code>.
</div>
</section>
<section class="panel" id="world-schema">
<div class="panel-head">
<h2>3. World Metadata</h2>
<span class="panel-tag">Index, world.json, bookmarks</span>
</div>
<div class="grid two">
<div class="mini-card">
<h3><code>worlds.json</code></h3>
<pre class="code">{
"schemaVersion": 1,
"worlds": [
{
"id": "overworld",
"name": "Overworld Mock",
"worldDir": "worlds/overworld"
}
]
}</pre>
</div>
<div class="mini-card">
<h3><code>world.json</code></h3>
<pre class="code">{
"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
}
}</pre>
</div>
</div>
<div class="mini-card">
<h3><code>bookmarks.json</code></h3>
<pre class="code">{
"schemaVersion": 1,
"worldId": "overworld",
"bookmarks": [
{ "id": "poi_1", "label": "Town Center", "x": 120, "y": 84 }
]
}</pre>
</div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>chunkWidth</code>, <code>chunkHeight</code></td>
<td>Tile dimensions of every chunk in the world. The current repo uses <code>32x32</code>.</td>
</tr>
<tr>
<td><code>tileSize</code></td>
<td>Pixel size for editor/runtime rendering.</td>
</tr>
<tr>
<td><code>defaultBackgroundTileId</code></td>
<td>World-level background tile inherited by chunk base cells that store <code>.</code>.</td>
</tr>
<tr>
<td><code>backgroundColor</code></td>
<td>Backdrop color for the editor and viewers.</td>
</tr>
<tr>
<td><code>heightBlurStep</code></td>
<td>Viewer/editor visual parameter for height blur strength. It is metadata, not map geometry.</td>
</tr>
<tr>
<td><code>editorUi</code></td>
<td>Saved panel/folder organization for the editor UI.</td>
</tr>
<tr>
<td><code>spawn</code></td>
<td>World tile coordinate used as a starting navigation target when no bookmark is chosen.</td>
</tr>
</tbody>
</table>
</section>
<section class="panel" id="chunk-schema">
<div class="panel-head">
<h2>4. Chunk Schema</h2>
<span class="panel-tag">Persistent unit of map content</span>
</div>
<pre class="code">{
"schemaVersion": 1,
"worldId": "overworld",
"chunkX": 3,
"chunkY": 2,
"width": 32,
"height": 32,
"backgroundTileId": "",
"roomLayers": [
{
"layer": 0,
"rows": ["................................", "..."],
"instanceIds": []
},
{
"layer": 1,
"rows": [" ", "..."],
"instanceIds": []
}
],
"heightLayers": [],
"instances": []
}</pre>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Meaning</th>
<th>Important rule</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>chunkX</code>, <code>chunkY</code></td>
<td>Chunk address in world chunk space.</td>
<td>File name and payload must agree after normalization.</td>
</tr>
<tr>
<td><code>width</code>, <code>height</code></td>
<td>Chunk dimensions in tiles.</td>
<td>Rows are normalized to exactly these dimensions.</td>
</tr>
<tr>
<td><code>backgroundTileId</code></td>
<td>Chunk-level override of the world background tile.</td>
<td>Empty string means inherit the world background tile.</td>
</tr>
<tr>
<td><code>roomLayers</code></td>
<td>Dense per-layer character rows.</td>
<td>Layer <code>0</code> always exists. At least one editable layer above it also exists.</td>
</tr>
<tr>
<td><code>heightLayers</code></td>
<td>Sparse chunk-local height patches.</td>
<td>Each patch is trimmed and local to the chunk, not world-global on disk.</td>
</tr>
<tr>
<td><code>instances</code></td>
<td>Chunk-owned entity records.</td>
<td><code>x</code> and <code>y</code> are local to the chunk payload.</td>
</tr>
</tbody>
</table>
</section>
<section class="panel" id="coordinates">
<div class="panel-head">
<h2>5. Coordinate System</h2>
<span class="panel-tag">Global to local math</span>
</div>
<p>
The conversion rules are the foundation of the entire system. Everything else depends on them being stable and identical in every language port.
</p>
<pre class="code">chunkX = floor(worldX / chunkWidth)
chunkY = floor(worldY / chunkHeight)
localX = worldX - (chunkX * chunkWidth)
localY = worldY - (chunkY * chunkHeight)
worldX = (chunkX * chunkWidth) + localX
worldY = (chunkY * chunkHeight) + localY</pre>
<div class="grid three">
<div class="mini-card">
<h3>Chunk key</h3>
<p><code>chunkKey = "{chunkX}:{chunkY}"</code></p>
</div>
<div class="mini-card">
<h3>Chunk filename</h3>
<p><code>fileName = "{chunkX}_{chunkY}.json"</code></p>
</div>
<div class="mini-card">
<h3>Address helper</h3>
<p>One helper returns <code>chunkX</code>, <code>chunkY</code>, <code>localX</code>, <code>localY</code>, <code>chunkKey</code>, and <code>fileName</code>.</p>
</div>
</div>
<table class="table">
<thead>
<tr>
<th>World tile</th>
<th>Chunk size</th>
<th>Chunk coord</th>
<th>Local coord</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>(73, 18)</code></td>
<td><code>32x32</code></td>
<td><code>(2, 0)</code></td>
<td><code>(9, 18)</code></td>
</tr>
<tr>
<td><code>(-1, -1)</code></td>
<td><code>32x32</code></td>
<td><code>(-1, -1)</code></td>
<td><code>(31, 31)</code></td>
</tr>
<tr>
<td><code>(32, 32)</code></td>
<td><code>32x32</code></td>
<td><code>(1, 1)</code></td>
<td><code>(0, 0)</code></td>
</tr>
</tbody>
</table>
<div class="callout warn">
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.
</div>
</section>
<section class="panel" id="row-encoding">
<div class="panel-head">
<h2>6. Row Encoding Rules</h2>
<span class="panel-tag">How characters are interpreted</span>
</div>
<table class="table">
<thead>
<tr>
<th>Context</th>
<th>Stored char</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td>Room layer 0</td>
<td><code>.</code></td>
<td>Inherit the chunk background tile if set, otherwise inherit the world default background tile.</td>
</tr>
<tr>
<td>Room layer 0</td>
<td><code> </code> space</td>
<td>Explicit hole / transparent empty cell on the base layer.</td>
</tr>
<tr>
<td>Room layer 0</td>
<td>Any other single char</td>
<td>Explicit tile symbol for that cell.</td>
</tr>
<tr>
<td>Room layer &gt; 0</td>
<td><code> </code> space</td>
<td>Empty cell.</td>
</tr>
<tr>
<td>Room layer &gt; 0</td>
<td>Any other single char</td>
<td>Explicit tile symbol for that overlay layer.</td>
</tr>
<tr>
<td>Height patch rows</td>
<td><code> </code> or <code>.</code></td>
<td>Empty cell. Dots are normalized away to spaces.</td>
</tr>
<tr>
<td>Height patch rows</td>
<td>Any other single char</td>
<td>Tile symbol to draw at that patch cell.</td>
</tr>
</tbody>
</table>
<div class="grid two">
<div class="mini-card">
<h3>Dense room rows</h3>
<p>
Room rows are always normalized to the full chunk width and full chunk height. Short rows are padded.
Long rows are truncated.
</p>
</div>
<div class="mini-card">
<h3>Sparse height rows</h3>
<p>
Height rows are stored as tightly trimmed rectangles. Empty outer rows and columns are removed. Trailing whitespace is stripped.
</p>
</div>
</div>
<div class="callout">
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.
</div>
</section>
<section class="panel" id="height-system">
<div class="panel-head">
<h2>7. Height System</h2>
<span class="panel-tag">Sparse, order-derived Z stack</span>
</div>
<div class="grid two">
<div class="mini-card">
<h3>Authoring model</h3>
<p>
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
<code>z = index + 1</code>.
</p>
</div>
<div class="mini-card">
<h3>Storage model</h3>
<p>
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.
</p>
</div>
</div>
<table class="table">
<thead>
<tr>
<th>Field</th>
<th>Meaning</th>
<th>Rule</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>id</code></td>
<td>Stable patch identity across saves.</td>
<td>Must be unique per chunk payload after normalization.</td>
</tr>
<tr>
<td><code>name</code></td>
<td>Human-facing layer name.</td>
<td>Optional.</td>
</tr>
<tr>
<td><code>z</code></td>
<td>Height level.</td>
<td>In the current editor, this is effectively derived from list order.</td>
</tr>
<tr>
<td><code>x</code>, <code>y</code></td>
<td>Patch origin.</td>
<td>Chunk-local on disk, neighborhood-local in the composed working document.</td>
</tr>
<tr>
<td><code>rows</code></td>
<td>Trimmed patch characters.</td>
<td>Dots are treated as empty; trailing whitespace is trimmed.</td>
</tr>
</tbody>
</table>
<pre class="code">Example patch on disk
{
"id": "height_2",
"name": "Tower Top",
"z": 2,
"x": 10,
"y": 6,
"rows": [
" AAA ",
"A A",
" AAA "
]
}</pre>
<div class="grid two">
<div class="mini-card">
<h3>How patch painting works</h3>
<p>
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.
</p>
</div>
<div class="mini-card">
<h3>How erasing works</h3>
<p>
Erasing writes a space, then the patch is retrimmed. If the patch becomes empty, it collapses to
<code>rows: []</code> at its trimmed origin.
</p>
</div>
</div>
<div class="callout warn">
Do not model height layers as full-size parallel maps if you want parity with this repo. They are intentionally sparse.
</div>
</section>
<section class="panel" id="instances">
<div class="panel-head">
<h2>8. Instance System</h2>
<span class="panel-tag">Chunk-owned entities</span>
</div>
<p>
Instances are stored inside the chunk that owns their origin tile. The persisted <code>x</code> and
<code>y</code> are chunk-local. During normalization, the editor/runtime populates
<code>record.position</code> with the derived world tile position.
</p>
<pre class="code">{
"id": "inst_gatekeeper_001",
"templateId": "npc_gatekeeper_bubbles",
"layer": 1,
"x": 12,
"y": 9,
"record": {
"name": "Bubbles",
"spriteId": "npc_human_style_13"
}
}</pre>
<table class="table">
<thead>
<tr>
<th>Behavior</th>
<th>Current implementation</th>
</tr>
</thead>
<tbody>
<tr>
<td>ID generation</td>
<td>Normalization guarantees an id. Duplicate mode can force new ids.</td>
</tr>
<tr>
<td>Layer ownership</td>
<td>The instance carries <code>layer</code>, and each room layer caches matching <code>instanceIds</code>.</td>
</tr>
<tr>
<td>Position clamping</td>
<td><code>x</code> and <code>y</code> are clamped into chunk bounds during normalization.</td>
</tr>
<tr>
<td>Denormalized metadata</td>
<td><code>instanceIds</code> in room layers are rebuilt from the actual instances array.</td>
</tr>
</tbody>
</table>
</section>
<section class="panel" id="loading">
<div class="panel-head">
<h2>9. Loading Pipeline</h2>
<span class="panel-tag">From JSON to working document</span>
</div>
<ol>
<li>Load world metadata with <code>GET /api/world/:worldId</code>.</li>
<li>Read bookmarks from the same payload and choose an initial view target.</li>
<li>Convert that target into <code>centerChunkX</code> and <code>centerChunkY</code>.</li>
<li>Load a chunk neighborhood with <code>GET /api/world/:worldId/chunks?chunkX=...&amp;chunkY=...&amp;radius=1&amp;createIfMissing=1</code>.</li>
<li>Compose those chunk payloads into one temporary working map.</li>
<li>Set <code>originChunkX</code>, <code>originChunkY</code>, <code>tileOffsetX</code>, and <code>tileOffsetY</code> so local document coordinates can be translated back to world coordinates.</li>
</ol>
<div class="grid two">
<div class="mini-card">
<h3>Neighborhood radius</h3>
<p>
Initial bootstrap uses radius <code>1</code>, which means a <code>3x3</code> chunk neighborhood.
The runtime can grow this dynamically up to radius <code>4</code> depending on viewport size.
</p>
</div>
<div class="mini-card">
<h3>Composition result</h3>
<p>
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.
</p>
</div>
</div>
<pre class="code">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</pre>
<div class="callout">
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.
</div>
</section>
<section class="panel" id="editing">
<div class="panel-head">
<h2>10. Editing And Mutation Flow</h2>
<span class="panel-tag">How user actions change chunk data</span>
</div>
<div class="grid two">
<div class="mini-card">
<h3>Tile paint</h3>
<ol>
<li>Paint into the composed room layer rows.</li>
<li>Translate local tile to world tile with <code>tileOffsetX/Y</code>.</li>
<li>Translate world tile to chunk and local-in-chunk coordinates.</li>
<li>Patch the cached chunk row cell directly.</li>
<li>Mark that chunk dirty.</li>
</ol>
</div>
<div class="mini-card">
<h3>Height paint</h3>
<ol>
<li>Mutate the sparse patch in the composed document.</li>
<li>Expand or shrink patch bounds as needed.</li>
<li>Trim empty borders.</li>
<li>Rebuild every overlapped chunk from document state.</li>
<li>Mark those chunks dirty.</li>
</ol>
</div>
<div class="mini-card">
<h3>Layer metadata edits</h3>
<p>
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.
</p>
</div>
<div class="mini-card">
<h3>Height layer metadata edits</h3>
<p>
Renaming, duplicating, deleting, or reordering height layers triggers metadata sync into cached chunks and usually rebuilds visible chunks from the document.
</p>
</div>
</div>
<table class="table">
<thead>
<tr>
<th>Operation</th>
<th>Implementation behavior</th>
</tr>
</thead>
<tbody>
<tr>
<td>Chunk move</td>
<td>Source chunk payload is reassigned to a new chunk address, then the source is replaced with an empty chunk.</td>
</tr>
<tr>
<td>Chunk duplicate</td>
<td>Content is copied into the destination chunk, but placed instances are intentionally not copied.</td>
</tr>
<tr>
<td>Chunk transform</td>
<td>Rows, height patches, and instance local coordinates are rotated or flipped together.</td>
</tr>
<tr>
<td>Chunk clear</td>
<td>The destination payload becomes a fresh empty chunk payload.</td>
</tr>
<tr>
<td>Chunk background fill</td>
<td>The chunk stores a chunk-local background tile override and clears explicit base cells back to dots.</td>
</tr>
</tbody>
</table>
</section>
<section class="panel" id="saving">
<div class="panel-head">
<h2>11. Save Pipeline</h2>
<span class="panel-tag">Dirty chunks plus world metadata</span>
</div>
<ol>
<li>Ensure the composed working document is current from any dirty chunk cache edits.</li>
<li>Sync height layer metadata into cached chunks so names and Z order match the current editor stack.</li>
<li>If visible dirty chunks exist, rebuild them from the current document before persistence.</li>
<li>Collect dirty chunk payloads.</li>
<li>POST a batch payload to <code>/api/world/:worldId/chunks/batch-save</code>.</li>
<li>On success, clear the saved dirty chunk keys.</li>
</ol>
<pre class="code">POST /api/world/:worldId/chunks/batch-save
{
"world": { "...": "world.json fields" },
"bookmarks": { "...": "bookmarks.json fields" },
"chunks": [
{ "...": "normalized chunk payload" }
]
}</pre>
<div class="grid two">
<div class="mini-card">
<h3>What gets saved every time</h3>
<p>
World metadata and bookmarks are always included in the current batch-save payload, even if no chunk rows changed.
</p>
</div>
<div class="mini-card">
<h3>What gets saved selectively</h3>
<p>
Chunk files are saved only for chunk keys in the dirty set. That is the core scaling behavior.
</p>
</div>
</div>
</section>
<section class="panel" id="normalization">
<div class="panel-head">
<h2>12. Normalization Rules</h2>
<span class="panel-tag">Server and runtime repair passes</span>
</div>
<p>
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.
</p>
<table class="table">
<thead>
<tr>
<th>Object</th>
<th>Normalization behavior</th>
</tr>
</thead>
<tbody>
<tr>
<td>World definition</td>
<td>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.</td>
</tr>
<tr>
<td>Room rows</td>
<td>Rows are resized to exact chunk dimensions using <code>.</code> for layer 0 and <code> </code> for other layers.</td>
</tr>
<tr>
<td>Room layers</td>
<td>Layer 0 is always guaranteed. At least one editable layer above zero is also guaranteed.</td>
</tr>
<tr>
<td>Height layers</td>
<td>Ids are deduped, negative coordinates are clipped, rows are clamped to chunk bounds, dots become spaces, and empty borders are trimmed.</td>
</tr>
<tr>
<td>Instances</td>
<td>Ids are guaranteed, coordinates are clamped into chunk bounds, and <code>record.position</code> is rewritten to world coordinates.</td>
</tr>
<tr>
<td><code>instanceIds</code></td>
<td>The per-layer id lists are deduped and rebuilt from the instances array.</td>
</tr>
</tbody>
</table>
<div class="callout warn">
Height patches accept dots in input, but dots are not a meaningful stored value. They are normalized to empty space and then trimmed.
</div>
</section>
<section class="panel" id="porting">
<div class="panel-head">
<h2>13. Porting Checklist</h2>
<span class="panel-tag">What to preserve in another language</span>
</div>
<div class="grid two">
<div class="mini-card">
<h3>Must preserve exactly</h3>
<ul>
<li>Floor-based world/chunk/local coordinate conversion.</li>
<li>Base-layer <code>.</code> inheritance semantics.</li>
<li>Sparse height patch trimming rules.</li>
<li>Chunk-local instance storage with world position reconstruction.</li>
<li>Dirty-chunk-only save behavior.</li>
<li>Neighborhood composition and decomposition logic.</li>
</ul>
</div>
<div class="mini-card">
<h3>Can be implemented differently</h3>
<ul>
<li>UI technology.</li>
<li>Rendering backend.</li>
<li>In-memory cache container types.</li>
<li>HTTP framework.</li>
<li>History storage mechanism.</li>
</ul>
</div>
</div>
<div class="mini-card">
<h3>Recommended port modules</h3>
<ul>
<li><code>world_chunking</code>: pure coordinate and naming helpers.</li>
<li><code>world_schema</code>: normalization and validation for world/chunk/bookmark payloads.</li>
<li><code>world_compose</code>: chunk neighborhood to working-document composition.</li>
<li><code>world_decompose</code>: working-document back to chunk payload slicing.</li>
<li><code>world_persistence</code>: batch-save endpoint and file IO.</li>
<li><code>world_editor_runtime</code>: cache, dirty keys, neighborhood loads, and edit sync logic.</li>
</ul>
</div>
</section>
<section class="panel" id="pseudocode">
<div class="panel-head">
<h2>14. Minimal Pseudocode</h2>
<span class="panel-tag">Language-agnostic reference</span>
</div>
<h3>Resolve a world tile to a chunk cell</h3>
<pre class="code">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"
}</pre>
<h3>Compose a neighborhood into a working document</h3>
<pre class="code">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
}</pre>
<h3>Sync a painted tile into the owning chunk</h3>
<pre class="code">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))</pre>
<h3>Rebuild chunk-local height patches from the working document</h3>
<pre class="code">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</pre>
<h3>Batch save</h3>
<pre class="code">function saveWorld():
ensureWorldDocumentCurrent()
syncCachedWorldHeightLayerMetadata()
rebuildVisibleDirtyChunksFromDocument()
payload = {
world: worldMetadata,
bookmarks: bookmarkPayload,
chunks: dirtyChunkPayloads()
}
POST /api/world/{worldId}/chunks/batch-save payload
clearDirtyChunkKeysThatWereSaved()</pre>
<p class="footer-note">
This manual matches the current repository behavior, including editor-specific conventions like height Z being derived from layer order.
</p>
</section>
</main>
</div>
</div>
</body>
</html>