feat: game/engine split refactor + skybox

This commit is contained in:
Dan Finch 2026-08-08 14:15:32 +02:00
parent 581e5892b0
commit eeedcb8e48
34 changed files with 610 additions and 218 deletions

155
AGENTS.md
View file

@ -26,8 +26,14 @@ 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**.
- **Engine is headless.** `engine/` has no DOM types and could run server-side;
all browser glue (canvas, input, image decode) lives in `app/`.
- **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).
Enforced: `engine/` imports nothing from `game`/`app`, `game/` nothing from `app`
(`tests/layering.test.ts`). Both `engine` and `game` are DOM-free (tsconfig).
## Stack & tooling
@ -48,15 +54,16 @@ rules live in `.agents/rules/*.md`.
- `bun run bench:browser` — Playwright: drive headless Chromium through the
`?bench=st`/`?bench=mt` flythrough, print single-thread vs worker frame timings
(median/p95/max). The real-browser profiler; run it on the target machine.
- `bunx tsc --build tsconfig.app.json` — **typecheck the app+engine graph. Use
- `bunx tsc --build tsconfig.app.json` — **typecheck the engine+game+app graph. Use
this**, not `bun run check` (see Caveats).
- `bunx oxlint engine app` — lint.
- `bun test` — tests (none yet).
- `bunx oxlint engine game app` — lint.
- `bun test` — tests (registry id-order + engine↛game layering guards).
- `bun run serve` — Bun server (`server/server.ts`, a stub for now).
## Layout
- `engine/` — headless engine, consumed by `app/` via tsconfig project ref.
- `engine/` — content-agnostic mechanism (no DOM, no game content); consumed by
`game/` then `app/` via tsconfig project refs.
- `math/``Vec2`, `Vec3`, `Mat4` (column-major, OpenGL-style; verified).
- `render/``Color` (packed RGBA, little-endian = canvas ImageData order),
`Framebuffer` (Uint32 color + Float32 1/w depth; `quantize` = color-depth +
@ -69,74 +76,70 @@ rules live in `.agents/rules/*.md`.
- `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), `Terrain` (procedural
heightfield around the room: flat clearing in the center, rolling hills, tall
edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called
per chunk, aligned so patches weld crack-free, with a hole for the room;
`Terrain.height` is the shared ground-height sampler for the player), `Actor`
(the `Entity` interface — geometry + behavior + bounds — that each mob kind
implements), `Tree` (procedural low-poly oak/spruce/birch, sapling..full via a
`growth` knob; each species a `TreeSpecies` in `trees/<Kind>.ts`, assembled by a
registry — see the Trees section), `Boulder`
(procedural low-poly rock: a squashed, jittered, part-buried sphere;
`Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf
blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom;
samples a 2x2 color-atlas texture, drawn double-sided), `Mob` (a **roaming**
creature — `frog` hops the ground, `bee` hovers/darts, `robin` mostly hops but
now and then takes a short powered flight — the engine's only moving geometry.
Unlike the baked props, a mob's low-poly mesh is built once per kind in **local
space**; `Mob.build` bakes the canonical meshes, `Mob.update` steps the wander
AI (leashed to a home anchor, deterministic via an evolving per-mob seed) each
frame, and the live `position`/`heading`/`scale` become a per-frame model matrix
at draw time. New kinds extend the `MobKind` union + `MOB_KINDS` order).
- `app/` — browser glue.
- `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
interval, visible chunks / LOD-aware tri count — the real profiler in play).
Also owns the **mob sim**: each frame it steps `Mob.update` for every mob,
rebuilds the near-player mob colliders into `level.colliders`, and culls the
mobs (`visibleMobs`) so only the visible transforms get dispatched.
- `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 it renders inline. `dispatch`/`done`
are non-blocking so the caller paces on rAF. Per-frame inputs ride shared
arrays: camera/matrix/visible-chunk list, plus the visible **mob transforms**
(`mobState`, count in the `MOBVIS` control slot). `?bench=st|mt` A/Bs the paths.
- `renderScene.ts``renderBand(fb, scene, …, mobDraws, …, y0, y1)`: the single
source of render truth (sky + room + culled chunks + 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 two canonical mob
meshes) so it clones to a worker whole; each mob is drawn double-sided through
its own `viewProj × Mat4.compose(...)` model matrix, and `visibleMobs`
frustum-culls the moving mobs per frame.
- `assets.ts` — load `/assets/*.png``Texture` (zero-copy; ImageData bytes
are already the `Color` layout).
(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
(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`
(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_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs move,
so they carry 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`, where a `DrawGroup` is a baked
mesh + its `Material` = texture + cull; the renderer just loops them and knows no
content by name) that `main` frustum-culls; bushes fold into the leaf mesh,
flowers get their own (double-sided) group. Trees + boulders are baked **twice**
full geometry into `near` and a low-poly impostor into `far` (via the builders'
`lod` arg) — so a far chunk swaps to the cheap group set with no per-frame work
(see `chunkFar` / `RenderConfig.lodDistance`). `buildLevel(textures)` binds the
ground/prop materials once and shares them across chunks. `Aabb` colliders (walls, crate, grown trunks, big
boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`, sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it
stays clean over the terrain skirt that laps under the room edges. Room surfaces
are single flat quads -- no subdivision needed since texturing is
perspective-correct.
- `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).
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).
- `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.
- `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.
- `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
`#fps` meter div (styled inline).
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
@ -206,10 +209,10 @@ front-out — a culled mesh that renders inside-out has its index order flipped
(well inside `fog.far`, so far detail is already fog-dimmed at the switch),
`Infinity` on `clean` to disable LOD. Lower it for more headroom (more pop),
raise it for more far detail (more tris).
- **`app/level.ts` `GROUND_UV`** (0.25) — outdoor ground texture tiles per world
- **`game/level.ts` `GROUND_UV`** (0.25) — outdoor ground texture tiles per world
unit. Lower = the stone tiles bigger and less busy = less far-distance moire
(there are no mipmaps); higher = finer but shimmerier.
- **`app/level.ts` `CHUNK_GRID`** (12) / `TERRAIN_SUBDIV` (5) — spatial-cull
- **`game/level.ts` `CHUNK_GRID`** (12) / `TERRAIN_SUBDIV` (5) — spatial-cull
granularity and terrain resolution. World terrain divisions = `CHUNK_GRID *
TERRAIN_SUBDIV`. Smaller cells cull tighter (draw less off-screen) but cost more
per-cell tests/bounds. This is the lever if a dense world still lags.
@ -272,12 +275,12 @@ by a `CloudLayer` discriminated union `kind`:
- **`fancyCumulus`** — domain-warped + heightfield-shaded fake volume,
~5 lookups/pixel (pricier; watch the FPS meter).
Both are exported presets in `app/level.ts`; the active one is set in
Both are exported presets in `game/level.ts`; the active one is set in
`buildLevel`'s `sky.clouds`. Add new cloud types by extending the union and
branching in the cloud shader. Cost scales with sky resolution — fine at
`standard`, heavy at `clean` (mitigate: fewer fbm octaves or half-res sky).
## Trees (`engine/scene/Tree.ts` + `engine/scene/trees/`)
## 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 +
@ -298,13 +301,13 @@ 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. `app/level.ts`
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`.
**Boulders** (`engine/scene/Boulder.ts`) work the same way: `Boulder.build`
**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
never cracks) into one shared rock mesh, sunk partway into the ground.
`scatterBoulders` sizes them small→big (biased small) and drops colliders on the
@ -314,7 +317,7 @@ colliders; add a new prop type by cloning the pattern (generator + scatter).
## Controls
WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `app/player.ts`) · mouse
WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` 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.