Worldshaper/Release/docs/OPEN_WORLD_CHUNK_V1.md

551 lines
13 KiB
Markdown
Raw Normal View History

2026-06-26 18:18:14 -04:00
# Open World Chunking V1
This document proposes a concrete v1 data model and editor/runtime architecture for replacing the current finite `map` model with a streamed open world.
The goal is not "fake endless maps glued together." The goal is:
- one continuous world coordinate space
- chunked storage and loading
- seamless editing across chunk boundaries
- sparse height overrides
- chunk-local save/write operations
- renderer and editor surfaces that only load and draw what is nearby
## Plain-English Model
Think of the world as graph paper that goes on forever.
- The player and editor work in one global coordinate space: `(worldX, worldY)`.
- That graph paper is cut into fixed-size square sheets called `chunks`.
- Each chunk stores only the tiles, height patches, and instances that belong to that square.
- The game and editor only load the nearby chunks.
The user should never feel like they are "switching maps." They should feel like they are scrolling around one world.
## V1 Recommendation
V1 should introduce a new top-level concept: `world`.
Current:
- `maps.json`
- one map entry owns rows, layers, height layers, instances
Proposed:
- `worlds.json`
- one world entry defines chunk rules and metadata
- chunk files hold actual terrain/layer/instance data
Keep support for multiple worlds. Even in an "open world" game, multiple worlds are still useful for:
- overworld
- interiors or special dimensions
- test worlds
- developer sandboxes
## Goals
- Seamless editing across arbitrary world coordinates
- Automatic chunk creation when painting or placing far away
- No giant monolithic world file for tile data
- Chunk-local save/load for performance
- Reuse current room-layer and height-patch ideas where possible
- Replace minimap with world navigation, coordinates, and bookmarks
## Non-Goals For V1
- Infinite procedural terrain generation
- Networked world streaming
- Region compression or advanced binary storage
- Fully general LOD terrain synthesis
- Rewriting the whole engine to 3D or voxel storage
## Chosen Chunk Size
The chosen v1 chunk size is `32x32`.
Reason:
- simpler mental math in tooling and code
- slightly better fit for common viewport batching
- cleaner chunk index math
- easier future optimization
`25x25` would also work, but v1 should standardize on `32x32` and avoid making chunk size configurable until the new architecture is stable.
## Coordinate Model
The world uses two coordinate spaces:
1. World coordinates
- Absolute tile coordinates in the world
- Example: `(73, -12)`
2. Chunk-local coordinates
- Tile coordinates within a chunk
- Example: chunk `(2, -1)` local tile `(9, 20)`
Conversion:
```txt
chunkX = floor(worldX / chunkWidth)
chunkY = floor(worldY / chunkHeight)
localX = worldX - (chunkX * chunkWidth)
localY = worldY - (chunkY * chunkHeight)
```
This is the core of the whole system.
## Proposed File Structure
```txt
content/
worlds.json
worlds/
overworld/
world.json
bookmarks.json
chunks/
0_0.json
1_0.json
0_1.json
-1_0.json
```
### `content/worlds.json`
Index of available worlds.
```json
{
"schemaVersion": 1,
"worlds": [
{
"id": "overworld",
"name": "Overworld",
"worldDir": "worlds/overworld"
}
]
}
```
### `content/worlds/overworld/world.json`
World-level metadata only.
```json
{
"schemaVersion": 1,
"id": "overworld",
"name": "Overworld",
"chunkWidth": 32,
"chunkHeight": 32,
"tileSize": 32,
"defaultBackgroundTileId": "tile_grass_01",
"spawn": { "x": 0, "y": 0 },
"editor": {
"defaultZoom": 1,
"gridVisible": true
}
}
```
### `content/worlds/overworld/bookmarks.json`
Replaces the current minimap's "jump around the finite map" role with saved navigation targets.
```json
{
"schemaVersion": 1,
"worldId": "overworld",
"bookmarks": [
{ "id": "town_center", "label": "Town Center", "x": 120, "y": 84 },
{ "id": "north_tower", "label": "North Tower", "x": 145, "y": 32 }
]
}
```
### Chunk file
```json
{
"schemaVersion": 1,
"worldId": "overworld",
"chunkX": 2,
"chunkY": -1,
"width": 32,
"height": 32,
"backgroundTileId": "tile_grass_01",
"roomLayers": [
{
"layer": 0,
"name": "Ground",
"rows": ["................................", "..."],
"instanceIds": []
},
{
"layer": 1,
"name": "Walls",
"rows": [" ", "..."],
"instanceIds": ["inst_gatekeeper_001"]
}
],
"heightLayers": [
{
"id": "height_001",
"name": "Tower Level 1",
"z": 1,
"x": 10,
"y": 8,
"rows": [
" 4444 ",
" 4 4 ",
" 4444 "
]
}
],
"instances": [
{
"id": "inst_gatekeeper_001",
"templateId": "npc_gatekeeper_bubbles",
"layer": 1,
"x": 12,
"y": 9,
"record": {
"name": "Bubbles",
"spriteId": "npc_human_style_13",
"faction": "dangerous_gatekeeper"
}
}
]
}
```
## Why This Structure
This splits data by ownership:
- `world.json`: rules and metadata
- `bookmarks.json`: editor navigation helpers
- chunk files: actual editable world content
That keeps save operations local and avoids turning one file into a bottleneck.
## Editor Data Model
The editor runtime should stop treating one "map" as the active document.
Instead it should have:
```ts
type OpenWorldSession = {
worldId: string;
chunkWidth: number;
chunkHeight: number;
tileSize: number;
cameraWorldX: number;
cameraWorldY: number;
loadedChunks: Record<string, WorldChunk>;
dirtyChunks: Record<string, boolean>;
activeLayer: number;
activeHeightLayerId: string;
editingTargetKind: "room" | "height";
bookmarks: WorldBookmark[];
};
```
The main change is that the editor should think in `world coordinates` first and `chunk ownership` second.
## Editing Rules
### Painting tiles
When the user paints:
1. Convert pointer position to `(worldX, worldY)`
2. Resolve chunk and local coordinate
3. Create chunk if it does not exist
4. Edit the right chunk layer row
5. Mark that chunk dirty
6. Patch only the visible surfaces touched by the stroke
If a rectangle, circle, or fill crosses chunk boundaries, the brush simply touches multiple chunks in one action.
### Placing instances
When the user drops an instance:
1. Resolve world tile
2. Resolve chunk
3. Create chunk if missing
4. Add instance to that chunk
5. Mark chunk dirty
### Painting far away
Yes, the editor should create all needed empty chunks automatically if the user paints or places content in a region that has never been created before.
It should not create a giant range of intermediate chunks just because the camera moved there. Chunks should be created lazily:
- create on first edit
- optionally create on explicit "stamp empty region" tooling
## Runtime Load Model
The game should not load the whole world.
Instead:
1. Determine the player's current chunk
2. Load a chunk radius around the player
3. Keep nearby chunks hot
4. Unload distant chunks
Example:
- player is in chunk `(10, 4)`
- active chunk radius is `1`
- load chunks from `(9..11, 3..5)`
That gives a `3x3` neighborhood.
This is the same core idea the editor should use for panning and visible drawing.
## Renderer Model
The renderer should operate in visible chunk slices, not full-world arrays.
Suggested v1 pipeline:
1. Determine visible world tile bounds from camera
2. Determine which chunks intersect that area
3. Ensure those chunks are loaded
4. Draw visible base layers from those chunks
5. Apply active height patches for current Z
6. Draw instances
7. Draw overlays and cursors
## Surface Caching
The current tile-surface work already points in the right direction.
V1 should move to:
- one cached surface per visible chunk layer
- one cached patch surface per height patch
- redraw only invalidated chunks or patches
That is much better than treating the whole world as one giant canvas.
## Height Layer Model In Open World
The current direction of sparse patch-based height overrides is still the right one.
V1 should keep height layers separate from ordinary draw layers.
Normal chunk data:
- base room layers define the world at Z0
Height patch data:
- sparse overrides define what appears at higher Z values
When the player goes up a ladder:
- current Z changes
- renderer continues drawing visible Z0 world
- renderer applies any visible patch data matching that Z
This is exactly the kind of thing chunking helps with, because only nearby patches need to be considered.
## Instance Model
Instances should remain data-driven and chunk-owned.
Recommended v1 storage:
- store instances inside the chunk that owns their tile origin
- keep instance `x` and `y` local to the chunk file
- derive world position in memory
Alternative:
- store world `x` and `y` directly in the chunk file
Either can work. For consistency with chunk-local tile rows, chunk-local instance positions are cleaner on disk.
Example:
```json
{
"id": "inst_blacksmith_001",
"templateId": "npc_blacksmith",
"layer": 1,
"x": 18,
"y": 7,
"record": {
"name": "Mera"
}
}
```
## World Navigation UI
The current minimap should be replaced.
Recommended v1 navigation tools:
- current world coordinate display
- jump to X/Y
- bookmarks / waypoints
- "center on player"
- chunk grid overlay toggle
- optional chunk overview panel showing loaded and dirty chunks
This is much more useful for an open world than a single minimap squeezed into a corner.
## Save Model
The editor should save only dirty chunks and world metadata.
Recommended split:
- save `world.json` only when world-level settings change
- save `bookmarks.json` only when bookmarks change
- save only touched chunk files when content changes
This is the core performance and safety advantage.
## Undo / History Model
History can no longer assume one finite map snapshot as the main unit.
Recommended v1 history operation format:
```ts
type ChunkEditOperation = {
type: "chunk_edit";
chunkKey: string;
before: Partial<WorldChunk>;
after: Partial<WorldChunk>;
};
```
For multi-chunk brushes:
```ts
type MultiChunkEditOperation = {
type: "multi_chunk_edit";
chunks: ChunkEditOperation[];
};
```
This is important because world editing will regularly cross chunk boundaries.
## API Shape
Suggested API direction:
- `GET /api/worlds`
- `GET /api/world/:worldId`
- `GET /api/world/:worldId/bookmarks`
- `GET /api/world/:worldId/chunks?x=10&y=4&radius=1`
- `POST /api/world/:worldId/chunk/:chunkX/:chunkY`
- `POST /api/world/:worldId/chunks/batch-save`
- `POST /api/world/:worldId/bookmarks`
The important change is that APIs become chunk-aware instead of map-aware.
## Migration From Current Map Model
The easiest migration path is not "rewrite everything at once."
Recommended migration steps:
1. Introduce `world` and `chunk` data structures alongside current maps
2. Build an importer that converts one existing map into one or more chunks
3. Keep existing tile/layer/height patch formats as similar as possible
4. Build a separate open-world editor mode first
5. Retire the old map model only after the new one is stable
For migration:
- one `100x100` map can become `4x4` chunks at `25x25`
- or `4x4` chunks at `32x32` with padded edges
## What Must Be Rewritten
This is a major architecture change. The following systems would need meaningful refactors:
- popup bootstrap and document loading
- editor runtime state
- renderer surface caching
- minimap/navigation UI
- history model
- persistence API
- map switcher UX
The following ideas can be reused:
- tile symbol rendering
- tile/instance palettes
- sparse height patch logic
- layer editing concepts
- chunk/surface invalidation patterns
## V1 Risks
- Trying to preserve too much of the finite-map abstraction
- Storing too much redundant empty chunk data
- Keeping full-world snapshots in history
- Letting chunk creation happen implicitly on camera movement
- Rebuilding all visible chunk surfaces too often
## Recommended V1 Boundaries
To keep this realistic, v1 should do only this:
- one open world
- fixed chunk size
- chunk-local room layers
- sparse chunk-local height patches
- chunk-owned instances
- bookmarks instead of minimap
- batch save of dirty chunks
- visible-chunk-only rendering
That is already a large and meaningful overhaul.
## Implementation Order
The safest order is:
1. Add new world/chunk schemas and API routes without removing current map routes
2. Build a converter that turns one current map into one chunked world
3. Add a chunk-aware loader in parallel with the current popup bootstrap
4. Replace the popup minimap with coordinates, bookmarks, and jump controls
5. Convert rendering from one-map surfaces to per-chunk surfaces
6. Convert tile painting, height painting, and instance placement to world-coordinate edits
7. Convert save/history to batch dirty chunks
8. Only then remove the old finite-map assumptions
## Final Recommendation
This direction makes sense.
The strongest version of it is:
- stop thinking in "many maps stitched together"
- start thinking in "one world partitioned into chunks"
That keeps the user experience seamless while keeping storage and rendering manageable.
If we choose to proceed, the next best step is:
1. finalize the exact chunk JSON schema
2. build the map-to-world importer
3. add a parallel open-world editor path before deleting the current map editor