meat/.agents/plans/levels-and-editor.md

252 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Levels & the in-game editor — direction note
Status: **direction agreed, not yet built.** Captures a brainstorm so the next
session starts from the conclusion, not a cold read. Nothing here is committed
code; it's the shape we want and *why*.
## 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).
## Naming: `Level` (data) vs `Scene` (runtime)
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 12 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.
## Current-state facts the next session will need
- `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).