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

15 KiB
Raw Blame History

Levels & the in-game editor — direction note

Status: historical proposal, partially superseded. 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.

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.

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

Historical naming proposal: 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 / pickno 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.

Historical current-state facts (before 2026-08-20)

  • 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 (reconfiguresetup, 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).