refactor: move world concepts into engine

This commit is contained in:
Toad 2026-08-24 15:17:53 +02:00
parent eeedcb8e48
commit 2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions

143
AGENTS.md
View file

@ -26,14 +26,19 @@ rules live in `.agents/rules/*.md`.
frame; on-screen solids also **backface-cull**. This is what keeps a dense world
(thousands of trees/rocks) affordable — off-screen content costs ~nothing.
- **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**.
- **Three layers, one-way deps: `engine` ← `game` ← `app`.** `engine/` is the
reusable, content-agnostic **mechanism** (rasterizer, framebuffer, culling, the
`Actor`/`Material` interfaces) — no DOM, no game content, could run server-side.
`game/` is **this game's content** (the creatures/props, terrain, level, scene
assembly + render orchestration) built on the engine interfaces. `app/` is
**browser glue** (canvas, input, image decode, the worker render driver + loop).
- **Three layers, one-way deps: `engine` ← `game` ← `app`.** `engine/` owns
reusable, content-agnostic **concepts and mechanisms**: levels, terrain, actors,
prefabs, collision, render scenes, chunking/LOD, rasterization, and worker render
transport. `game/` owns **this game's concrete content**: actual level values,
placements, materials, and frog/bee/robin or oak/spruce/birch definitions.
`app/` is browser glue (canvas, input mapping, image decode, workers, frame loop).
Enforced: `engine/` imports nothing from `game`/`app`, `game/` nothing from `app`
(`tests/layering.test.ts`). Both `engine` and `game` are DOM-free (tsconfig).
- **No closed game-content kinds or registries.** Instances reference concrete
`ActorDefinition`/`Prefab` objects directly. Do not add `MobKind`, `TreeKind`,
`*_KINDS`, content dispatch switches, or stable content-order protocols. Numeric
prototype indexes are scene-local engine transport details only. Closed unions
remain valid for finite engine capabilities such as collider or cloud shape.
## Stack & tooling
@ -57,7 +62,7 @@ rules live in `.agents/rules/*.md`.
- `bunx tsc --build tsconfig.app.json` — **typecheck the engine+game+app graph. Use
this**, not `bun run check` (see Caveats).
- `bunx oxlint engine game app` — lint.
- `bun test` — tests (registry id-order + engine↛game layering guards).
- `bun test` — tests (world compilation, render protocol, boundary guards).
- `bun run serve` — Bun server (`server/server.ts`, a stub for now).
## Layout
@ -71,73 +76,51 @@ rules live in `.agents/rules/*.md`.
(optional backface cull per draw), `Frustum` (6 planes from the viewProj +
AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no
mipmaps), `Material` (texture + cull flag; a `DrawGroup` pairs a mesh with one,
so the renderer draws by list, not by named texture), `Sky` (gradient + sun +
procedural clouds; renders at 1/`step` res).
so the renderer draws by list, not by named texture), `Chunk`/`ChunkBuilder`
(spatial batches + two-level LOD), `RenderScene` (clone-safe scene projection,
culling + band rendering), `RenderProtocol` (shared worker frame layout), and
`Sky` (full-resolution gradient + sun, procedural clouds sampled at 1/`step`).
- `scene/``Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
`Mesh` (indexed tris; verts stored flat: `STRIDE` floats x,y,z,u,v per vertex,
no per-vertex objects — cache-friendly + alloc-free to draw), `Sprite`
(Y-axis billboard), `Actor` (the generic `Entity<State, World>` interface —
build + update + bounds — that a content kind implements; the engine dispatches
through it, never a `kind` switch).
- `game/` — this game's content + world assembly, on the engine interfaces
(Y-axis billboard), `MeshBuilder` (generic quad/slab/box construction), `Actor`
(open behavior + render/collider contract), and `Prefab` (open static-content
contract + type-erased placed value).
- `world/``Terrain` (height contract, built-in rolling generator, generic
patch meshing), `Collider`/`CollisionWorld`, `CharacterController`, and `Level`
(live actor/collision state + clone-safe `RenderScene` projection).
- `game/` — this game's definitions and level data, on the engine interfaces
(headless: no DOM, imports nothing from `app`).
- `actors/` — the placeable things. `Mob` (a **roaming** creature — `frog` hops
the ground, `bee` hovers/darts, `robin` mostly hops but now and then takes a
short powered flight — the only moving geometry; each kind an `Entity` in
`mobs/<Kind>.ts` + shared `mobs/mobkit.ts`, assembled by the thin `Mob` registry.
Its local-space mesh is built once per kind; `Mob.update` steps the wander AI
(leashed to a home anchor, deterministic per evolving `seed`) each frame and the
live `position`/`heading`/`scale` become a per-frame model matrix at draw.
`MOB_KINDS` is the SAB id order). `Tree` (oak/spruce/birch, each a `TreeSpecies`
in `trees/<Kind>.ts` + `trees/treekit.ts` — see the Trees section), `Boulder`
short powered flight — the only moving geometry; each module exports an
`ActorDefinition` factory and instances hold the resulting object directly.
`Tree` is shared placement state; Oak/Spruce/Birch modules export material-bound
`Prefab<Tree>` factories with no species registry. `Boulder`
(squashed jittered part-buried sphere), `Bush` (leaf-blob cluster, shares the
leaf mesh), `Flower` (stem + colored bloom, 2x2 atlas, double-sided). Baked props
append into shared per-material meshes; mobs draw live.
- `Terrain.ts` — procedural heightfield around the room (flat clearing, rolling
hills, tall edge peaks). `Terrain.patch` builds one ground patch over a rectangle
(per chunk, welds crack-free, hole for the room); `Terrain.height` is the shared
ground sampler for the player + mobs.
- `level.ts` — builds the playground: a flat stone-floored room (three thick
walls via `slab`, north side open) always drawn, in the center of a big grassy
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
`placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders;
`TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`) and the roaming mobs
scattered (`placeMobs`; `FROG_/BEE_/ROBIN_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs
move, so no baked colliders), then `buildChunks` bakes terrain + props into a
`CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = a tight AABB + two
`DrawGroup[]` lists `near`/`far`; the baker accumulates one mesh per **material
key** (`MAT_ORDER`) and routes each prop by its declared material, so it names no
texture) that `main` frustum-culls. Trees + boulders bake **twice** — full into
`near`, a low-poly impostor into `far` — so a far chunk swaps to the cheap set
with no per-frame work (`chunkFar` / `RenderConfig.lodDistance`).
`buildLevel(textures)` binds the ground/prop `Material`s once + shares them.
Also: `Aabb` colliders, NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`,
sky/cloud config, `FLOOR_LIFT` (a z-bias lifting the stone floor over the terrain
skirt). Room surfaces are single flat quads (texturing is perspective-correct).
- `renderScene.ts``renderBand(fb, scene, …, mobDraws, …, y0, y1)`: the single
source of render truth (sky + room + culled chunk draw-groups + sprite + roaming
mobs + quantize for a row band). Used full-height by the inline path, per-band by
each worker. `Scene` bundles the static meshes/textures (incl. the canonical mob
meshes) so it clones to a worker whole; each mob draws double-sided through its
own `viewProj × Mat4.compose(...)` model matrix, and `visibleChunks`/`visibleMobs`
frustum-cull per frame. `textures.ts` holds the `Textures` palette type.
- `player.ts` — feet-cylinder player: gravity/jump + Shift-run (`RUN_MULTIPLIER`)
+ circle-vs-AABB/-circle collision, substepped so fast running can't tunnel
walls; ground height from `Terrain.height` (plus standable AABBs).
- `level.ts` — concrete playground values and placement policy: room dimensions,
rolling-terrain parameters, counts/seeds/reach, material bindings, sky, and
spawn definition lists. It places direct `Prefab` objects, submits the resulting
values to engine `ChunkBuilder`, then creates engine `Level`; no game renderer or material-key
registry exists. Trees + boulders bake full and far-impostor geometry; bushes
and flowers provide near geometry only.
- `player.ts` — concrete player tuning only; movement and collision live in
engine `CharacterController`.
- `app/` — browser glue only (top layer; depends on `game` + `engine`).
- `main.ts` — game loop: input, sim, preset switching, per-frame culling, then
the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS upscale)
+ a multi-line frame HUD (`work + present` critical-path ms, vsync, visible
chunks / LOD-aware tris). Owns the **mob sim**: steps `Mob.update` for every mob,
rebuilds near-player mob colliders into `level.colliders`, culls mobs
(`visibleMobs`) so only visible transforms dispatch.
chunks / LOD-aware tris). Calls engine `Level` simulation/collider extraction,
maps browser keys into `CharacterInput`, and dispatches visible render instances.
- `renderer.ts` — the render driver. When the page is cross-origin-isolated it runs
a pool of `render-worker.ts` threads (`MAX_WORKERS`) over a `SharedArrayBuffer`
framebuffer, each owning a disjoint row band, synced by a lock-free `Atomics`
barrier; otherwise inline. `dispatch`/`done` are non-blocking so the caller paces
on rAF. Per-frame inputs ride shared arrays: camera/matrix/visible-chunk list +
visible **mob transforms** (`mobState`, count in `MOBVIS`). `?bench=st|mt` A/Bs
the paths.
visible engine instance prototype indexes + transforms. `?bench=st|mt` A/Bs
the paths. Renderer and worker import no game modules.
- `assets.ts` — load `/assets/*.png``Texture` (zero-copy; ImageData bytes are
already the `Color` layout); returns the `game` `Textures` palette.
- `index.html` — Vite entry at repo root; holds the `#screen` canvas and the
@ -155,9 +138,10 @@ rules live in `.agents/rules/*.md`.
## Frame pipeline (`app/main.ts` `tick`)
`Mob.update` (all mobs) + rebuild near-player mob colliders → `Player.update`
build `Camera``Camera.viewProjection``visibleChunks` + `visibleMobs`
(frustum-cull, once on the main thread) → `renderer.dispatch` (non-blocking) →
`Level.update` (all actors) + `Level.refreshActorColliders`
`CharacterController.update` → build `Camera``Camera.viewProjection`
`RenderScene.visibleChunks` + `Level.visibleInstances` (frustum-cull, once on the
main thread) → `renderer.dispatch` (non-blocking) →
next rAF: `renderer.done()` ? `present` : skip this vsync. Frame N is presented
while N+1 is dispatched; the pump never blocks or async-awaits, so it can't
desync from rAF.
@ -171,16 +155,15 @@ an integer multiple (crisp letterbox, centered) once per resize/config, and
`linear`). This replaced a per-frame main-thread `drawImage` that scaled to the
whole window (cost grew with window size); present is now ~0.2ms.
`renderBand` runs `renderScene.renderBand` for rows [y0,y1): `Sky.render` at
1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Rasterizer.draw`
`renderBand` runs `RenderScene.renderBand` for rows [y0,y1): `Sky.render` with
clouds sampled at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Rasterizer.draw`
floor/walls/crate (room, always) → for each visible `Chunk`, loop its draw-groups —
`near` or `far` chosen by the pure `chunkFar` test (dist² from camera to the chunk
`near` or `far` chosen by the pure `Chunk.isFar` test (dist² from camera to the chunk
AABB vs `lodDistance²`): `near` is grass + full trees/rocks + flowers, `far` is grass
+ the cheap impostors (foliage/flowers dropped). Each group draws with its own
`Material` (cull per-material, so solids backface-cull and flowers stay double-sided)
`Sprite.billboard(npc)`
the roaming mobs (each: shared local mesh × its `Mat4.compose` model matrix,
double-sided) → `Framebuffer.quantize`. `chunkFar` is pure (camera + baked bounds + config
→ each billboard → each visible actor prototype instance (shared local draw groups ×
its `Mat4.compose` model matrix) → `Framebuffer.quantize`. `Chunk.isFar` is pure (camera + baked bounds + config
only), so every worker band picks the same LOD for a chunk → no horizontal seam.
Multi-threaded: N workers each run `renderBand` over
their band of the shared framebuffer in parallel; single-threaded: one call over
@ -225,9 +208,9 @@ off-screen or fogged each frame, so several things keep it cheap:
chunks that fall outside the view. Behind you + off to the sides = free.
- **Backface culling** (`draw(..., true)`) — ~halves fill on solid geometry
(terrain, foliage, rock). See the Rasterizer note re winding.
- **Half-res sky** (`SKY_STEP` in `main`, default 2) — the cloud fbm runs per
- **Half-res clouds** (`SKY_STEP` in `app/renderer.ts`, default 2) — cloud fbm runs per
pixel and dominated the frame; sampling once per 2×2 block quarters it.
- **Distance LOD** (`RenderConfig.lodDistance`, `chunkFar` in `renderScene`) —
- **Distance LOD** (`RenderConfig.lodDistance`, `Chunk.isFar`) —
past `lodDistance` a chunk's trees + boulders swap to pre-baked low-poly
impostors and its bushes/flowers drop; both meshes are baked once at load, and
the near/far pick is a pure function of camera + chunk bounds, so it costs
@ -282,11 +265,10 @@ branching in the cloud shader. Cost scales with sky resolution — fine at
## Trees (`game/actors/Tree.ts` + `game/actors/trees/`)
Procedural low-poly geometry, faceted flat-shaded like everything else. Each species
is a `TreeSpecies` definition in its own `trees/<Kind>.ts` module (geometry +
which chunk materials its trunk/foliage bake into); `Tree.ts` just assembles them
into a registry (`Tree.species(kind)`, `TREE_KINDS`) and shared primitives live in
`trees/treekit.ts`. Three `kind`s carry the species read purely by silhouette:
Procedural low-poly geometry, faceted flat-shaded like everything else. `Tree.ts`
contains only shared placement state. Oak, Spruce, and Birch each expose a concrete
`Prefab<Tree>` factory from their own module; level data references those definition
objects directly, and shared geometry primitives live in `trees/treekit.ts`.
- **`oak`** — short tapered trunk, a couple of branches, a broad cluster of
lumpy canopy `blob`s (wider than tall, bushy).
- **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing
@ -298,14 +280,11 @@ into a registry (`Tree.species(kind)`, `TREE_KINDS`) and shared primitives live
`growth` (0..1) runs **sapling → full grown**: it scales height/girth and adds
canopy blobs (oak/birch) / tiers (spruce); `seed` gives each tree its own wobble.
A species declares its `trunk`/`foliage` **material keys** (e.g. birch → white
`birch` trunk, oak `leaf` foliage); the chunk baker (`level.ts`) accumulates one
mesh per material key and routes each tree via `Tree.species(kind)` — so a forest
still batches into a few draw calls and the baker names no texture. `game/level.ts`
`placeTrees` seeds the forest and rolls the species. **Add a species** = add a
`trees/<Kind>.ts` module (its geometry + material keys) + one entry in the `Tree`
registry; only a genuinely new material also needs a `Material` in `buildLevel` +
its key in `MAT_ORDER`.
Each factory receives concrete trunk/foliage `Material` objects and closes over
them. `ChunkBuilder` accumulates meshes by material object, so a forest still
batches into a few draw groups without string keys or a registry. `game/level.ts`
`placeTrees` selects from a weighted list of prefab objects. **Add a species** =
add one concrete prefab module and include its object in level data.
**Boulders** (`game/actors/Boulder.ts`) work the same way: `Boulder.build`
appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it
@ -317,7 +296,7 @@ colliders; add a new prop type by cloning the pattern (generator + scatter).
## Controls
WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `game/player.ts`) · mouse
WASD move · **Shift** run (speed from `Player.config` in `game/player.ts`) · mouse
look (click canvas to pointer-lock) · **Space** jump · **1/2/3** switch look
presets. FPS shown bottom-right. The room's north wall is open — walk out onto
the terrain and toward the peaks.
@ -349,7 +328,7 @@ job tmp dir, not the repo.
## Roadmap / not yet built
In-browser RenderConfig slider panel; mipmaps; `painter` depth mode; gouraud
lighting; more cloud types; more props / a weapon; more mob kinds + smarter mob
lighting; more cloud types; more props / a weapon; more mob definitions + smarter mob
behavior (they wander + block/stand-on today, but don't yet react to the player). `shared/`
is nearly empty. The FPS meter is static HTML + `textContent` writes only — no
DOM-built UI yet (deliberate).