2026-08-08 14:15:32 +02:00
|
|
|
|
# Levels & the in-game editor — direction note
|
|
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
|
Status: **historical proposal, partially superseded.** Captures a brainstorm so the next
|
2026-08-08 14:15:32 +02:00
|
|
|
|
session starts from the conclusion, not a cold read. Nothing here is committed
|
|
|
|
|
|
code; it's the shape we want and *why*.
|
|
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
|
> **Architecture note (2026-08-20):** Naming and ownership sections below are
|
|
|
|
|
|
> superseded by `docs/adr/0001-engine-owns-world-concepts.md` and `CONTEXT.md`.
|
|
|
|
|
|
> Canonical terms are now engine `LevelDefinition` (live-level creation input),
|
|
|
|
|
|
> engine `Level` (live runtime), and clone-safe engine `RenderScene`; game owns
|
|
|
|
|
|
> concrete definitions and values. A future serializable editor document needs its
|
|
|
|
|
|
> own name rather than overloading these runtime terms. References below to game-owned world contracts, content
|
|
|
|
|
|
> `kind` fields/registries, `game/Terrain.ts`, or `game/renderScene.ts` describe the
|
|
|
|
|
|
> old code only. Editor goals remain valid but must use the canonical boundary.
|
|
|
|
|
|
|
2026-08-08 14:15:32 +02:00
|
|
|
|
## The pivot
|
|
|
|
|
|
|
|
|
|
|
|
The game is heading toward a **multiplayer twitch shooter**. Each match loads a
|
|
|
|
|
|
**fresh, isolated level**. Consequences that shape everything below:
|
|
|
|
|
|
|
|
|
|
|
|
- **Maps are hand-made, not procedural.** Procedural scatter is demoted to a
|
|
|
|
|
|
*tool* (a starting-block generator), never the runtime map path. This is the
|
|
|
|
|
|
whole reason the editor matters — the editor *is* the map pipeline.
|
|
|
|
|
|
- **Real authoritative server.** The server bakes the same map the clients do and
|
|
|
|
|
|
runs the authoritative sim.
|
|
|
|
|
|
- **No in-world level switching.** You don't walk through a portal to another
|
|
|
|
|
|
world; the match lifecycle loads a level at start. → **portals / transition
|
|
|
|
|
|
triggers are explicitly not needed.**
|
|
|
|
|
|
- **Persistence and player carry-over are YAGNI** for now — but must not be walled
|
|
|
|
|
|
off (see hedges).
|
|
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
|
## Historical naming proposal: `Level` (data) vs `Scene` (runtime)
|
2026-08-08 14:15:32 +02:00
|
|
|
|
|
|
|
|
|
|
Agreed vocabulary — reads as **"bake a `Level` into a `Scene`."** Fits the
|
|
|
|
|
|
type + namespace convention.
|
|
|
|
|
|
|
|
|
|
|
|
- **`Level`** = the authored, serializable **data document** (what an earlier draft
|
|
|
|
|
|
called `LevelSpec`). Pure plain data. The editor's output; the network's payload;
|
|
|
|
|
|
the server's and client's input.
|
|
|
|
|
|
- **`Scene`** = the baked **runtime instance** — the assembled world you render and
|
|
|
|
|
|
simulate.
|
|
|
|
|
|
|
|
|
|
|
|
**Repurposing note (so nobody trips):** today the names mean something narrower —
|
|
|
|
|
|
`Level` (`game/level.ts:69`) is a *bake output* and `Scene` (`renderScene.ts:19`)
|
|
|
|
|
|
is a *render-only, worker-cloneable subset*. Under the new vocabulary:
|
|
|
|
|
|
|
|
|
|
|
|
- The name **`Level` is freed** for the authored data document.
|
|
|
|
|
|
- **`Scene` grows** to be the whole runtime instance — it **absorbs** the colliders
|
|
|
|
|
|
+ live mob array + terrain sampler that currently hang off `Level`. The thing
|
|
|
|
|
|
**cloned to workers stays a render-subset projection** of the Scene (no colliders,
|
|
|
|
|
|
no live mobs — those are main-thread only), exactly as the current `Scene` already
|
|
|
|
|
|
is. This collapses today's incidental `Level`/`Scene` split (main.ts copies
|
|
|
|
|
|
fields across) into two clear things.
|
|
|
|
|
|
|
|
|
|
|
|
So the pipeline is:
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
generator(params) → Level (data) → bake(level, textures) → Scene (runtime)
|
|
|
|
|
|
(a TOOL: script / editor / never runtime) (the runtime path)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
(`bake` is today's `buildLevel`, renamed and re-typed to take a `Level` document.
|
|
|
|
|
|
Namespace home — `Scene.from(level)` vs `Level.bake()` — is an open detail.)
|
|
|
|
|
|
|
|
|
|
|
|
## The spine: the `Level` document
|
|
|
|
|
|
|
|
|
|
|
|
One serializable, explicit map document is the center of gravity. Four consumers,
|
|
|
|
|
|
one artifact:
|
|
|
|
|
|
|
|
|
|
|
|
- **Editor** *writes* it.
|
|
|
|
|
|
- **Server** *reads* it — bakes headless, runs authoritative collision/sim.
|
|
|
|
|
|
- **Client** *reads* it — bakes the identical `Scene` to render + predict.
|
|
|
|
|
|
- **Network** ships it (or its id) at match start.
|
|
|
|
|
|
|
|
|
|
|
|
Because the bake is deterministic, server and every client bake **byte-identical
|
|
|
|
|
|
geometry and collision** from the same `Level` — so only the document (or its id)
|
|
|
|
|
|
crosses the wire, never geometry.
|
|
|
|
|
|
|
|
|
|
|
|
### The one real refactor
|
|
|
|
|
|
|
|
|
|
|
|
Today the map *definition* is module-level constants welded **inside**
|
|
|
|
|
|
`buildLevel()`. Split generation from baking: the generator *produces* an explicit
|
|
|
|
|
|
`Level`; `bake` consumes it. `bake` should treat the **explicit** document (a list
|
|
|
|
|
|
of placements) as canonical — the generator emits that form. This one cut serves
|
|
|
|
|
|
three futures at once: multiplayer sync, the editor, and (later) persistence.
|
|
|
|
|
|
|
|
|
|
|
|
## Build order
|
|
|
|
|
|
|
|
|
|
|
|
1. **`Level` document + `bake(level)` + the gen/bake split.** Pure `game/`,
|
|
|
|
|
|
headless, unit-testable, **no UI, no netcode**. The whole foundation — do it
|
|
|
|
|
|
first, on its own.
|
|
|
|
|
|
2. **Then, in parallel — both ride the document, neither blocks the other:**
|
|
|
|
|
|
- **Editor** — in-engine, browser, `app/`. Emits a `Level` file.
|
|
|
|
|
|
- **Server** — real authoritative, imports `game/`, bakes the `Level` headless.
|
|
|
|
|
|
|
|
|
|
|
|
## The editor shape
|
|
|
|
|
|
|
|
|
|
|
|
- **In-game, single-player, edit↔play toggle.** Same running client: an edit mode
|
|
|
|
|
|
to author the `Level` live, and a button that flips to **play mode** and drops you
|
|
|
|
|
|
into the environment you're building. "Everything works except multiplayer" —
|
|
|
|
|
|
full local sim (player, mobs, collision, later weapons) runs in play mode; no
|
|
|
|
|
|
server, no netcode in the editor.
|
|
|
|
|
|
- The play toggle is a **re-bake + mode switch** — a loading beat, not a hot path.
|
|
|
|
|
|
So it may freely re-bake and even reuse the existing worker respawn/`reconfigure`
|
|
|
|
|
|
path; there is **no match, so no live-worker-resync problem** (the one genuinely
|
|
|
|
|
|
hard sub-problem, thereby dodged).
|
|
|
|
|
|
- The engine already provides the hard parts: live first-person view, deterministic
|
|
|
|
|
|
bake, and `camera` + `Terrain.height` for "where's the ground under the cursor"
|
|
|
|
|
|
(raycast/picking). The remaining weight is **UI/interaction**. AGENTS.md notes
|
|
|
|
|
|
"no DOM-built UI yet (deliberate)" — the editor is where that ends.
|
|
|
|
|
|
|
|
|
|
|
|
## Editor features (the brainstorm)
|
|
|
|
|
|
|
|
|
|
|
|
Three feature buckets. For each: what's already there, the real new work, the catch.
|
|
|
|
|
|
|
|
|
|
|
|
### 1. Sidebar tree — level config + contents
|
|
|
|
|
|
|
|
|
|
|
|
A **property inspector over the `Level` document.** If the document is clean data,
|
|
|
|
|
|
the tree is literally a view of it: each node = a field or an instance; edit a
|
|
|
|
|
|
field = mutate the document + re-bake.
|
|
|
|
|
|
|
|
|
|
|
|
- **World config already exists as data**, just scattered: `SkyConfig`, the
|
|
|
|
|
|
`CloudLayer` union (basicCumulus/fancyCumulus + params), `TERRAIN`
|
|
|
|
|
|
(amplitude/frequency/peaks/inner/blend), `GROUND_UV`, room dims. Parameterizing =
|
|
|
|
|
|
moving these constants into the `Level`.
|
|
|
|
|
|
- **The tree forces one categorization: world property vs client look-preset.**
|
|
|
|
|
|
`RenderConfig` (internalWidth, colorDepth, dither, lodDistance, filters) is a
|
|
|
|
|
|
*per-viewer PS1 dial* — stays client-side. Sky/clouds/terrain = per-map, in the
|
|
|
|
|
|
document. But **fog + lighting live in `RenderConfig` today**, and in a shooter
|
|
|
|
|
|
fog density = sightlines = a *gameplay/map* property → fog probably moves into the
|
|
|
|
|
|
`Level`. Decide the world-vs-viewer line once, up front.
|
|
|
|
|
|
- **Re-bake blast radius varies wildly:** sky color = re-run sky, no chunk touch;
|
|
|
|
|
|
one tree = re-bake 1–2 chunks; terrain amplitude = re-bake *every* chunk. For a
|
|
|
|
|
|
tool, full re-bake per edit is fine to start; scope to dirty cells only if it janks.
|
|
|
|
|
|
- **Anti-overengineering line:** "everything parameterized" = *document completeness*
|
|
|
|
|
|
(every knob is data — yes). It is **not** a mandate for a generic reflection-UI
|
|
|
|
|
|
framework. Hand-wire panels for the handful of config sections; generalize to
|
|
|
|
|
|
type-driven widgets only if it hurts.
|
|
|
|
|
|
|
|
|
|
|
|
### 2. Object tools — add / select / move / rotate
|
|
|
|
|
|
|
|
|
|
|
|
- **Add** — palette pick + raycast ground (`camera` + `Terrain.height`) → drop an
|
|
|
|
|
|
instance record → re-bake. Easy.
|
|
|
|
|
|
- **Select / pick** — **no GPU picking needed.** Props bake to anonymous triangles,
|
|
|
|
|
|
but the document retains the instance list with positions → **ray-vs-instance-bounds
|
|
|
|
|
|
test in JS**, nearest hit wins. (Another reason instances must live on the document.)
|
|
|
|
|
|
- **Move** — drag → raycast ground → update `position` → re-bake touched cell(s).
|
|
|
|
|
|
Crossing a chunk boundary re-bakes 2 (cell membership is by base position).
|
|
|
|
|
|
- **Rotate — the one real engine gap.** Props have **no orientation today** (Tree/
|
|
|
|
|
|
Boulder/Bush/Flower carry position + seed + size, no yaw; only *mobs* have
|
|
|
|
|
|
`heading`, applied live via `Mat4.compose`). Fix by **separating the per-instance
|
|
|
|
|
|
transform from geometry-gen**: `build` emits the instance's **unique local-space**
|
|
|
|
|
|
geometry (keep seed/growth = shape), and the **baker applies position + rotation +
|
|
|
|
|
|
scale** when appending to the chunk mesh. This mirrors what mobs already do (TRS
|
|
|
|
|
|
via `Mat4.compose`) — props become "unique local mesh × transform," baked-in
|
|
|
|
|
|
(static) where mobs are live (dynamic). This is the non-trivial refactor the object
|
|
|
|
|
|
tools demand; do it early since it touches every prop builder.
|
|
|
|
|
|
- **Gizmos** — the rasterizer draws textured tris only, **no line primitive.** A
|
|
|
|
|
|
move/rotate gizmo (and selection highlight / wireframe AABB) needs either thin-box
|
|
|
|
|
|
tris or a new line-draw path. **Start gizmo-less** (drag on ground = XZ move,
|
|
|
|
|
|
scroll/key = yaw; tinted re-draw for selection) and add handles later.
|
|
|
|
|
|
|
|
|
|
|
|
### 3. Terrain brushes — elevation + texture (the biggest new capability)
|
|
|
|
|
|
|
|
|
|
|
|
- **Elevation needs a stored, editable heightfield** — the thing that doesn't exist
|
|
|
|
|
|
yet. `Terrain.height` is a **pure analytic function** of noise params; a brush has
|
|
|
|
|
|
nowhere to write. So: **the `Level` owns a height grid**; procedural gen
|
|
|
|
|
|
*initializes* it; brushes edit it directly; `Terrain.height` bilinear-samples the
|
|
|
|
|
|
grid. ("Seeding is a tool," made concrete — noise seeds the grid, then you sculpt.)
|
|
|
|
|
|
- **Collision comes free** — player + mobs already sample `Terrain.height`, so once
|
|
|
|
|
|
it samples the grid, gameplay collision follows. No separate collision bake.
|
|
|
|
|
|
- **Welds still hold** — patches weld by sampling shared height at shared world
|
|
|
|
|
|
positions; grid-as-source keeps that. Re-bake only the brushed cells.
|
|
|
|
|
|
- Side effect: the room "hole" + flat-inner-clearing special cases **dissolve into
|
|
|
|
|
|
authored terrain**. An arena may drop the room concept entirely — the map *is* the
|
|
|
|
|
|
sculpted terrain.
|
|
|
|
|
|
- **Texture brush fits the engine shockingly well** — the chunk baker **already
|
|
|
|
|
|
accumulates one mesh per material key** and the renderer draws by DrawGroup list.
|
|
|
|
|
|
Ground is one `grass` material today; painting = ground goes to **N materials, each
|
|
|
|
|
|
painted tri routed to its material's mesh by a per-vertex/per-cell material id** —
|
|
|
|
|
|
the *exact* routing the baker already does for props. **No rasterizer change.** Hard
|
|
|
|
|
|
edges between materials (no blend) suit the PS1 look; soft splat-blending would need
|
|
|
|
|
|
per-pixel multi-texture rasterizer work — skip it, add later only if missed.
|
|
|
|
|
|
|
|
|
|
|
|
### Threads that cut across
|
|
|
|
|
|
|
|
|
|
|
|
1. **The `Level` document gains three responsibilities:** retain instance lists
|
|
|
|
|
|
(pick/move), own an editable **height grid** (elevation), own a **ground-material
|
|
|
|
|
|
map** (texture paint). All still pure data — the document grew, the architecture
|
|
|
|
|
|
didn't strain.
|
|
|
|
|
|
2. **One engine refactor unlocks the object tools:** pull per-instance transform
|
|
|
|
|
|
(pos/rot/scale) out of prop `build` into the baker — props become "unique local
|
|
|
|
|
|
mesh × transform," matching mobs.
|
|
|
|
|
|
3. **Terrain flips from function to data** — analytic → stored grid. Biggest single
|
|
|
|
|
|
change, but it's what "hand-made maps" *means*, and collision + welds fall out free.
|
|
|
|
|
|
4. **Almost none of this needs new *rendering*** — picking is JS, texture paint
|
|
|
|
|
|
reuses per-material DrawGroups, terrain is more patch bake. The only genuinely new
|
|
|
|
|
|
render bit is small + optional: **line-draw for gizmos/selection**.
|
|
|
|
|
|
|
|
|
|
|
|
## Why the architecture already fits (the two bets that pre-paid for this)
|
|
|
|
|
|
|
|
|
|
|
|
- **Deterministic bake** — the world is byte-identical from a `Level` every run →
|
|
|
|
|
|
multiplayer world-sync is nearly free (ship the document, everyone bakes the same).
|
|
|
|
|
|
- **DOM-free `engine`/`game`, layering test-enforced** — the authoritative server
|
|
|
|
|
|
runs `bake(level)` + sim with no renderer; `app/` is client-only glue. The layering
|
|
|
|
|
|
seam already enforced (`tests/layering.test.ts`) *is* the client/server seam. Keep
|
|
|
|
|
|
the `Level` document + `bake` in `game/`; editor UI in `app/`; server imports `game/`.
|
|
|
|
|
|
|
|
|
|
|
|
## Cheap hedges (do now, save pain later)
|
|
|
|
|
|
|
|
|
|
|
|
- The `Level` document is **pure serializable data** — no closures, no behavior baked
|
|
|
|
|
|
in (behavior stays code, imported per-side, as `Entity` already does it).
|
|
|
|
|
|
- A `Level` has an **id/name** — "load level X" is a reference; later persistence keys
|
|
|
|
|
|
off it for free.
|
|
|
|
|
|
- **Player state stays out of the `Level`/`Scene`** (already true) — carry-over later
|
|
|
|
|
|
touches the player, never the map.
|
|
|
|
|
|
- `bake` eats the **explicit** document as canonical — keeps the recipe-vs-explicit
|
|
|
|
|
|
network choice open.
|
|
|
|
|
|
- **No hardcoded buffer sizes** tied to today's world — mob/framebuffer sizing already
|
|
|
|
|
|
re-runs on `setup`; keep it so odd-shaped match maps just work.
|
|
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
|
## Historical current-state facts (before 2026-08-20)
|
2026-08-08 14:15:32 +02:00
|
|
|
|
|
|
|
|
|
|
- `buildLevel(textures): Level` (`game/level.ts:175`) is the single entry, called once
|
|
|
|
|
|
in `app/main.ts`. Today's `Level` (`game/level.ts:69-81`) is a **bake result**
|
|
|
|
|
|
(chunks/meshes/colliders/mobs) — under the new naming it becomes part of **`Scene`**;
|
|
|
|
|
|
the authored **`Level` document** is the missing *input* half. `buildLevel` becomes
|
|
|
|
|
|
`bake(level, textures) → Scene`.
|
|
|
|
|
|
- Placement (`placeTrees/placeBoulders/placeBushes/placeFlowers/placeMobs`,
|
|
|
|
|
|
`game/level.ts:359+`) is procedural from fixed seeds + counts, fully deterministic.
|
|
|
|
|
|
These become the **generator** (emit a `Level`) rather than running inside the bake.
|
|
|
|
|
|
- **Prop instances are discarded after bake** — only anonymous triangles survive in
|
|
|
|
|
|
per-chunk per-material meshes (`buildChunks`, `game/level.ts:251`). No `level.trees`
|
|
|
|
|
|
to iterate. The editor edits the **document**, then re-bakes — never mutates baked
|
|
|
|
|
|
meshes in place.
|
|
|
|
|
|
- **Mobs are the exception** — retained live, simulated each frame, crossing to workers
|
|
|
|
|
|
as packed floats. Add/move/delete is cheap, no worker resync (caveat: mob shared
|
|
|
|
|
|
buffer sized once to `scene.mobCount` in `app/renderer.ts` — growing past it needs a
|
|
|
|
|
|
re-`setup`).
|
|
|
|
|
|
- Instance shapes are already small plain records (position + seed + kind/size/color/
|
|
|
|
|
|
growth) — editor-friendly, directly serializable. See
|
|
|
|
|
|
`game/actors/{Tree,Boulder,Bush,Flower,Mob}.ts`.
|
|
|
|
|
|
- Prop geometry currently bakes **position into world-space verts inside `build`**, with
|
|
|
|
|
|
seed/growth driving unique shape — hence the "separate transform from geometry-gen"
|
|
|
|
|
|
refactor needed for rotate/scale (see Object tools).
|
|
|
|
|
|
- `Terrain.height` (`game/Terrain.ts`) is a **pure analytic function**, no stored
|
|
|
|
|
|
heightfield — hence the elevation-brush needs a stored grid (see Terrain brushes).
|
|
|
|
|
|
- The render `Scene` (`renderScene.ts:19`) is the **render-only subset** already; the
|
|
|
|
|
|
chunk baker already routes geometry to **one mesh per material key** — the mechanism
|
|
|
|
|
|
the texture brush reuses.
|
|
|
|
|
|
- Workers hold a **one-time structured clone** of the render subset from init
|
|
|
|
|
|
(`app/renderer.ts` `setup`); per-frame only camera/matrix/visible/mob buffers cross.
|
|
|
|
|
|
Chunk-geometry edits reach workers only via a re-send (`reconfigure`→`setup`, respawns
|
|
|
|
|
|
them — the same path keys 1/2/3 use). Fine for a mode-switch re-bake; would be the wall
|
|
|
|
|
|
for live in-match editing, which we are **not** doing.
|
|
|
|
|
|
|
|
|
|
|
|
## Explicitly out of scope / dropped
|
|
|
|
|
|
|
|
|
|
|
|
Portals & in-world transitions · persistence · player carry-over · procedural as the
|
|
|
|
|
|
primary map path · any live-edit-during-a-multiplayer-match capability · GPU picking ·
|
|
|
|
|
|
soft terrain-texture blending (hard-edged materials first).
|