2026-08-04 03:11:46 +02:00
|
|
|
|
# meat
|
|
|
|
|
|
|
|
|
|
|
|
A from-scratch browser **first-person-shooter game engine** with a configurable
|
|
|
|
|
|
**PS1 aesthetic**. Everything is hand-written: a **software rasterizer** draws
|
|
|
|
|
|
textured triangles into a low-res CPU framebuffer, then Canvas2D blits it
|
|
|
|
|
|
upscaled. No WebGL/WebGPU, no game-engine dependencies.
|
|
|
|
|
|
|
|
|
|
|
|
This file is the durable project brief (checked in, auto-loaded). The per-topic
|
|
|
|
|
|
rules live in `.agents/rules/*.md`.
|
|
|
|
|
|
|
|
|
|
|
|
## Guiding decisions (the "why", not derivable from code)
|
|
|
|
|
|
|
|
|
|
|
|
- **PS1 look is a set of live knobs**, dial-able from full-PS1 to clean. See
|
|
|
|
|
|
`RenderConfig`. Nothing about the era is hardcoded into the renderer.
|
2026-08-04 12:51:07 +02:00
|
|
|
|
- **Software rasterizer on purpose** — the PS1 look leans on rasterizer traits
|
|
|
|
|
|
(vertex snap, low-res + dither/banding, no mipmaps). Raytracing was considered
|
|
|
|
|
|
and cut (it removes the very artifacts we want). Note: **affine texture "swim"
|
|
|
|
|
|
was dropped** — texturing is always perspective-correct now (it warped badly on
|
|
|
|
|
|
the big outdoor terrain); the other era knobs stay.
|
2026-08-04 03:11:46 +02:00
|
|
|
|
- **Minimal architecture.** Scene-graph-lite / plain data + functions.
|
|
|
|
|
|
Deliberately **not** ECS or any "Big Game Architecture." Prefer the smallest
|
|
|
|
|
|
clear structure; add knobs to experiment rather than abstractions.
|
2026-08-04 15:05:02 +02:00
|
|
|
|
- **Culling beats batching here.** A "draw call" is just a JS loop (no GPU state),
|
|
|
|
|
|
so merging the world into big meshes only defeats visibility skipping. Instead
|
|
|
|
|
|
the outdoor world is stored as spatial **chunks** that are frustum-culled per
|
|
|
|
|
|
frame; on-screen solids also **backface-cull**. This is what keeps a dense world
|
|
|
|
|
|
(thousands of trees/rocks) affordable — off-screen content costs ~nothing.
|
2026-08-04 03:11:46 +02:00
|
|
|
|
- **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**.
|
2026-08-24 15:17:53 +02:00
|
|
|
|
- **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).
|
2026-08-08 14:15:32 +02:00
|
|
|
|
Enforced: `engine/` imports nothing from `game`/`app`, `game/` nothing from `app`
|
|
|
|
|
|
(`tests/layering.test.ts`). Both `engine` and `game` are DOM-free (tsconfig).
|
2026-08-24 15:17:53 +02:00
|
|
|
|
- **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.
|
2026-08-04 03:11:46 +02:00
|
|
|
|
|
|
|
|
|
|
## Stack & tooling
|
|
|
|
|
|
|
|
|
|
|
|
- **Bun** runtime + `bun test`. **TypeScript 7** (native `tsc`), strict,
|
2026-08-05 09:24:13 +02:00
|
|
|
|
`moduleResolution: bundler`. **Vite 8** serves/builds the client (ES-module
|
|
|
|
|
|
workers; dev/preview serve COOP/COEP headers so the multi-threaded renderer's
|
|
|
|
|
|
`SharedArrayBuffer` works — a static prod host must send them too, or the app
|
|
|
|
|
|
falls back to single-threaded).
|
2026-08-04 03:11:46 +02:00
|
|
|
|
- **oxc** toolchain: `oxlint` + `oxfmt` (no eslint/prettier).
|
|
|
|
|
|
- commitlint + husky (Conventional Commits), OpenSpec change workflow,
|
|
|
|
|
|
forge-sync (Forgejo). Templates generated by `regime` from a `sigitex:` source.
|
|
|
|
|
|
|
|
|
|
|
|
## Commands
|
|
|
|
|
|
|
|
|
|
|
|
- `bun start` — Vite dev server; open the printed URL. Edits hot-reload.
|
|
|
|
|
|
- `bun run build` — production build to `dist/`.
|
|
|
|
|
|
- `bun run assets` — regenerate the placeholder PNGs in `/assets`.
|
2026-08-05 09:24:13 +02:00
|
|
|
|
- `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.
|
2026-08-08 14:15:32 +02:00
|
|
|
|
- `bunx tsc --build tsconfig.app.json` — **typecheck the engine+game+app graph. Use
|
2026-08-04 03:11:46 +02:00
|
|
|
|
this**, not `bun run check` (see Caveats).
|
2026-08-08 14:15:32 +02:00
|
|
|
|
- `bunx oxlint engine game app` — lint.
|
2026-08-24 15:17:53 +02:00
|
|
|
|
- `bun test` — tests (world compilation, render protocol, boundary guards).
|
2026-08-04 03:11:46 +02:00
|
|
|
|
- `bun run serve` — Bun server (`server/server.ts`, a stub for now).
|
|
|
|
|
|
|
|
|
|
|
|
## Layout
|
|
|
|
|
|
|
2026-08-08 14:15:32 +02:00
|
|
|
|
- `engine/` — content-agnostic mechanism (no DOM, no game content); consumed by
|
|
|
|
|
|
`game/` then `app/` via tsconfig project refs.
|
2026-08-04 03:11:46 +02:00
|
|
|
|
- `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 +
|
2026-08-04 15:05:02 +02:00
|
|
|
|
Bayer dither), `RenderConfig` (the look dials + presets), `Rasterizer`
|
|
|
|
|
|
(optional backface cull per draw), `Frustum` (6 planes from the viewProj +
|
|
|
|
|
|
AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no
|
2026-08-07 19:11:43 +02:00
|
|
|
|
mipmaps), `Material` (texture + cull flag; a `DrawGroup` pairs a mesh with one,
|
2026-08-24 15:17:53 +02:00
|
|
|
|
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`).
|
2026-08-04 12:51:07 +02:00
|
|
|
|
- `scene/` — `Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
|
2026-08-04 19:12:09 +02:00
|
|
|
|
`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`
|
2026-08-24 15:17:53 +02:00
|
|
|
|
(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
|
2026-08-08 14:15:32 +02:00
|
|
|
|
(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
|
2026-08-24 15:17:53 +02:00
|
|
|
|
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`
|
2026-08-08 14:15:32 +02:00
|
|
|
|
(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.
|
2026-08-24 15:17:53 +02:00
|
|
|
|
- `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`.
|
2026-08-08 14:15:32 +02:00
|
|
|
|
- `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
|
2026-08-24 15:17:53 +02:00
|
|
|
|
chunks / LOD-aware tris). Calls engine `Level` simulation/collider extraction,
|
|
|
|
|
|
maps browser keys into `CharacterInput`, and dispatches visible render instances.
|
2026-08-08 14:15:32 +02:00
|
|
|
|
- `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 +
|
2026-08-24 15:17:53 +02:00
|
|
|
|
visible engine instance prototype indexes + transforms. `?bench=st|mt` A/Bs
|
|
|
|
|
|
the paths. Renderer and worker import no game modules.
|
2026-08-08 14:15:32 +02:00
|
|
|
|
- `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes are
|
|
|
|
|
|
already the `Color` layout); returns the `game` `Textures` palette.
|
2026-08-04 03:11:46 +02:00
|
|
|
|
- `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
|
|
|
|
|
|
writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`.
|
2026-08-07 13:37:30 +02:00
|
|
|
|
- `assets/` — generated `floor/grass/bark/birch/leaf/needle/rock/flower/wall/crate/npc/frog/bee/robin`
|
2026-08-07 13:01:32 +02:00
|
|
|
|
PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`birch`/`leaf`/`needle` =
|
|
|
|
|
|
brown trunk / white birch trunk / oak leaf / spruce needle, `rock` = boulders, `flower` = 2x2 bloom-color atlas,
|
2026-08-07 13:37:30 +02:00
|
|
|
|
`frog`/`bee`/`robin` = mob skin atlases: frog green + eye tone; bee stripe bands +
|
|
|
|
|
|
head-dark + wing-pale; robin brown back + orange breast + dark eye/beak).
|
2026-08-04 23:03:55 +02:00
|
|
|
|
Swap for real art anytime;
|
2026-08-04 03:11:46 +02:00
|
|
|
|
filenames are the contract.
|
|
|
|
|
|
- `server/` — Bun server stub. `shared/` — isomorphic slot.
|
|
|
|
|
|
|
2026-08-05 09:24:13 +02:00
|
|
|
|
## Frame pipeline (`app/main.ts` `tick`)
|
|
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
|
`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) →
|
2026-08-05 09:24:13 +02:00
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
**`present` is a GPU/CSS upscale, not a CPU blit.** The `#screen` canvas backing
|
|
|
|
|
|
store *is* the internal render resolution, so `present` is one internal-res
|
|
|
|
|
|
`putImageData` (viewport-independent, ~fixed cost). The browser compositor scales
|
|
|
|
|
|
the element to the display via CSS — `layout()` sets the element's pixel size to
|
|
|
|
|
|
an integer multiple (crisp letterbox, centered) once per resize/config, and
|
|
|
|
|
|
`image-rendering` follows `upscaleFilter` (`pixelated` for `nearest`, `auto` for
|
|
|
|
|
|
`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.
|
|
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
|
`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`
|
2026-08-07 16:58:24 +02:00
|
|
|
|
floor/walls/crate (room, always) → for each visible `Chunk`, loop its draw-groups —
|
2026-08-24 15:17:53 +02:00
|
|
|
|
`near` or `far` chosen by the pure `Chunk.isFar` test (dist² from camera to the chunk
|
2026-08-07 16:58:24 +02:00
|
|
|
|
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)
|
2026-08-24 15:17:53 +02:00
|
|
|
|
→ 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
|
2026-08-05 09:24:13 +02:00
|
|
|
|
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
|
|
|
|
|
|
the full height. Bands are disjoint (no two workers touch a pixel) and their
|
|
|
|
|
|
interior edges snap to `SKY_STEP` so the sky's block grid stays seamless.
|
2026-08-04 03:11:46 +02:00
|
|
|
|
|
|
|
|
|
|
Rasterizer specifics: near-plane clip (Sutherland-Hodgman), **1/w z-buffer**,
|
2026-08-04 12:51:07 +02:00
|
|
|
|
perspective-correct UVs, screen-space vertex snap, flat directional lighting,
|
2026-08-04 15:05:02 +02:00
|
|
|
|
distance fog, **alpha cutout** (discard texel alpha < 128, for sprites).
|
|
|
|
|
|
**Backface culling is opt-in** (`draw(..., cull)`, default off = double-sided):
|
|
|
|
|
|
on for solid world chunks, off for sprites and the room. It relies on winding, so
|
|
|
|
|
|
generators feeding culled draws (terrain patch, tree/boulder builders) must wind
|
|
|
|
|
|
front-out — a culled mesh that renders inside-out has its index order flipped
|
|
|
|
|
|
(see `Terrain.patch`). The cull sign: back-facing == positive screen area here.
|
2026-08-04 03:11:46 +02:00
|
|
|
|
|
|
|
|
|
|
## The look — where to tune
|
|
|
|
|
|
|
|
|
|
|
|
- **`engine/render/RenderConfig.ts`** — per-frame render dials + presets:
|
|
|
|
|
|
`standard` (384×216, the startup default), `soft`, `clean`, plus an unbound
|
|
|
|
|
|
`ps1` (320×240). In-app keys **1/2/3** switch standard/soft/clean live.
|
|
|
|
|
|
Knobs: `internalWidth/Height`, `upscaleFilter`, `colorDepth`, `dither`,
|
2026-08-05 09:24:13 +02:00
|
|
|
|
`vertexSnap`, `textureFilter`, `lighting`, `fog`, `lodDistance`. (Texturing is
|
|
|
|
|
|
always perspective-correct — the affine-swim dial was removed.)
|
|
|
|
|
|
- **`RenderConfig.lodDistance`** — beyond this many world units, a chunk's trees
|
|
|
|
|
|
+ boulders draw as cheap impostors (see Performance). ~60 for standard/soft/ps1
|
|
|
|
|
|
(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).
|
2026-08-08 14:15:32 +02:00
|
|
|
|
- **`game/level.ts` `GROUND_UV`** (0.25) — outdoor ground texture tiles per world
|
2026-08-04 12:51:07 +02:00
|
|
|
|
unit. Lower = the stone tiles bigger and less busy = less far-distance moire
|
|
|
|
|
|
(there are no mipmaps); higher = finer but shimmerier.
|
2026-08-08 14:15:32 +02:00
|
|
|
|
- **`game/level.ts` `CHUNK_GRID`** (12) / `TERRAIN_SUBDIV` (5) — spatial-cull
|
2026-08-04 15:05:02 +02:00
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
## Performance / where the frame goes
|
|
|
|
|
|
|
|
|
|
|
|
The world is dense (hundreds of trees + boulders, ~50k tris) but most of it is
|
2026-08-04 19:12:09 +02:00
|
|
|
|
off-screen or fogged each frame, so several things keep it cheap:
|
2026-08-04 15:05:02 +02:00
|
|
|
|
- **Frustum culling** (`Frustum` + per-`Chunk` AABB test in `main`) — skips whole
|
|
|
|
|
|
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.
|
2026-08-24 15:17:53 +02:00
|
|
|
|
- **Half-res clouds** (`SKY_STEP` in `app/renderer.ts`, default 2) — cloud fbm runs per
|
2026-08-04 15:05:02 +02:00
|
|
|
|
pixel and dominated the frame; sampling once per 2×2 block quarters it.
|
2026-08-24 15:17:53 +02:00
|
|
|
|
- **Distance LOD** (`RenderConfig.lodDistance`, `Chunk.isFar`) —
|
2026-08-05 09:24:13 +02:00
|
|
|
|
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
|
|
|
|
|
|
nothing per frame and stays worker-safe (no seam). In a dense forest view this
|
|
|
|
|
|
is what tips the per-frame work under the 16.67ms (60fps) vsync budget — it cut
|
|
|
|
|
|
~1.4x of the triangles in-forest and drops far-tree/rock detail that fog is
|
|
|
|
|
|
already dimming anyway.
|
2026-08-04 19:12:09 +02:00
|
|
|
|
- **Flat geometry + zero-alloc raster** — `Mesh` is a flat float array and the
|
|
|
|
|
|
whole per-triangle path uses reused scratch, so a frame allocates ~0 bytes
|
2026-08-05 09:24:13 +02:00
|
|
|
|
(measured). Buys frame *consistency* (no GC-pause spikes; worst/mean ~1.3x) and
|
|
|
|
|
|
makes geometry shareable across worker threads. It did **not** raise mean fps —
|
|
|
|
|
|
allocation was never the bottleneck; the mean is the transform+fill **compute**.
|
|
|
|
|
|
- **Multi-threaded rasterization** (`renderer.ts` + `render-worker.ts`) — split the
|
|
|
|
|
|
framebuffer into row bands, one worker each, over a `SharedArrayBuffer`. The
|
|
|
|
|
|
barrier is **lock-free**: workers `Atomics.wait` on a frame counter (no per-frame
|
|
|
|
|
|
messages), main writes camera/matrix/visible-list into shared arrays, `dispatch`
|
|
|
|
|
|
is non-blocking, and `main` polls `done()` on its rAF and presents the finished
|
|
|
|
|
|
frame (present frame N, dispatch N+1). Measured real-browser (`bun run
|
|
|
|
|
|
bench:browser`): **~1.5x median AND ~1.2x p95** vs single-thread, frame time
|
|
|
|
|
|
pinned near vsync. The gotcha is **oversubscription** — too many workers (main +
|
|
|
|
|
|
browser + OS competing) wrecks the p95 tail even as the median improves (6
|
|
|
|
|
|
workers were far worse than 3); `MAX_WORKERS` caps it, retune per machine.
|
|
|
|
|
|
Requires a cross-origin-isolated page (COOP/COEP; Vite serves them) — else it
|
|
|
|
|
|
falls back to single-thread, so the app never breaks.
|
|
|
|
|
|
|
|
|
|
|
|
Frustum + backface + half-res sky give ~1.5–2x, workers ~1.5x more, distance LOD
|
|
|
|
|
|
another ~1.4x of the tris in dense views. Together they get the heavy in-forest
|
|
|
|
|
|
view (the worst case) under the 60fps vsync budget on the worker path; the
|
|
|
|
|
|
single-thread fallback still lands ~30fps there. The blunt content dials if it
|
|
|
|
|
|
still lags are `TREE_COUNT`/`BOULDER_COUNT` (less world) and `lodDistance` (more
|
|
|
|
|
|
aggressive impostor swap).
|
|
|
|
|
|
|
|
|
|
|
|
**Profiling**: `bun run bench:browser` (Playwright) starts Vite, drives headless
|
|
|
|
|
|
Chromium through `?bench=st` and `?bench=mt` (a scripted flythrough with a fixed
|
|
|
|
|
|
camera path), and prints median/p95/max **work time** (critical-path band time)
|
|
|
|
|
|
and **frame time** for both. Headless absolute fps ≠ a real display, but the
|
|
|
|
|
|
single-vs-workers *relative* result and the *tail* (p95/max = jitter) are real —
|
|
|
|
|
|
that's how the worker path was actually validated instead of guessed.
|
2026-08-04 03:11:46 +02:00
|
|
|
|
|
|
|
|
|
|
## Clouds
|
|
|
|
|
|
|
|
|
|
|
|
Procedural, moving, in the sky pass (`engine/render/Sky.ts`). Two styles picked
|
|
|
|
|
|
by a `CloudLayer` discriminated union `kind`:
|
|
|
|
|
|
- **`basicCumulus`** — flat hard-thresholded white puffs, 1 noise lookup/pixel.
|
|
|
|
|
|
- **`fancyCumulus`** — domain-warped + heightfield-shaded fake volume,
|
|
|
|
|
|
~5 lookups/pixel (pricier; watch the FPS meter).
|
|
|
|
|
|
|
2026-08-08 14:15:32 +02:00
|
|
|
|
Both are exported presets in `game/level.ts`; the active one is set in
|
2026-08-04 03:11:46 +02:00
|
|
|
|
`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).
|
|
|
|
|
|
|
2026-08-08 14:15:32 +02:00
|
|
|
|
## Trees (`game/actors/Tree.ts` + `game/actors/trees/`)
|
2026-08-04 13:53:59 +02:00
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
|
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`.
|
2026-08-04 13:53:59 +02:00
|
|
|
|
- **`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
|
|
|
|
|
|
to a tip (taller than wide, conical).
|
2026-08-07 13:01:32 +02:00
|
|
|
|
- **`birch`** — silver birch: tall, slender, near-straight trunk with an airy,
|
|
|
|
|
|
high, slightly drooping canopy (lean silhouette). Uses a separate **white**
|
|
|
|
|
|
`birch` bark texture (brown bark can't stand in), and shares the oak `leaf`
|
|
|
|
|
|
foliage.
|
2026-08-04 13:53:59 +02:00
|
|
|
|
|
|
|
|
|
|
`growth` (0..1) runs **sapling → full grown**: it scales height/girth and adds
|
2026-08-07 13:01:32 +02:00
|
|
|
|
canopy blobs (oak/birch) / tiers (spruce); `seed` gives each tree its own wobble.
|
2026-08-24 15:17:53 +02:00
|
|
|
|
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.
|
2026-08-04 13:53:59 +02:00
|
|
|
|
|
2026-08-08 14:15:32 +02:00
|
|
|
|
**Boulders** (`game/actors/Boulder.ts`) work the same way: `Boulder.build`
|
2026-08-04 14:02:12 +02:00
|
|
|
|
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
|
2026-08-04 23:03:55 +02:00
|
|
|
|
big ones. `Bush` (leaf-blob clusters) and `Flower` (stem + colored bloom, atlas
|
|
|
|
|
|
UVs) are the same again — ground detail scattered near the play area, no
|
|
|
|
|
|
colliders; add a new prop type by cloning the pattern (generator + scatter).
|
2026-08-04 14:02:12 +02:00
|
|
|
|
|
2026-08-04 03:11:46 +02:00
|
|
|
|
## Controls
|
|
|
|
|
|
|
2026-08-24 15:17:53 +02:00
|
|
|
|
WASD move · **Shift** run (speed from `Player.config` in `game/player.ts`) · mouse
|
2026-08-04 13:09:26 +02:00
|
|
|
|
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.
|
2026-08-04 03:11:46 +02:00
|
|
|
|
|
|
|
|
|
|
## Code style (oxlint-enforced — match it)
|
|
|
|
|
|
|
|
|
|
|
|
No semicolons, double quotes, 2-space indent, trailing commas. **`type`, never
|
|
|
|
|
|
`interface`.** Function declarations (arrows allowed). Uppercase hex (`0xFF`).
|
|
|
|
|
|
Curly braces required on all `if`. No `void` operator. `T[]` not `Array<T>`.
|
|
|
|
|
|
Prefer `globalThis` over `window`. Domain behavior lives in a `type` +
|
|
|
|
|
|
matching `namespace` (see `Vec3`, `Color`, `Rasterizer`).
|
|
|
|
|
|
|
|
|
|
|
|
## Caveats
|
|
|
|
|
|
|
|
|
|
|
|
- No mipmaps, so distant textures shimmer (period-correct); far cloud/geometry
|
|
|
|
|
|
is hidden by fog.
|
|
|
|
|
|
|
|
|
|
|
|
## Debugging the renderer without a browser (key workflow)
|
|
|
|
|
|
|
|
|
|
|
|
The engine is pure, so you can **render headless to a PNG and inspect it**. A
|
|
|
|
|
|
throwaway `bun` script can: `buildLevel()`, decode `assets/*.png` (they're
|
|
|
|
|
|
filter-0 RGBA — trivial to inflate), set a `Camera` pose, run
|
|
|
|
|
|
`Sky.render` + `Rasterizer.draw` into a `Framebuffer`, encode the color buffer
|
|
|
|
|
|
to PNG (same hand-rolled encoder as `scripts/gen-assets.ts`), write it, and read
|
2026-08-04 12:51:07 +02:00
|
|
|
|
it back. Sampling individual pixels this way tuned the clouds, framed the terrain
|
|
|
|
|
|
peaks, and confirmed the ground textures flat (no swim). Put temp scripts in the
|
|
|
|
|
|
job tmp dir, not the repo.
|
2026-08-04 03:11:46 +02:00
|
|
|
|
|
|
|
|
|
|
## Roadmap / not yet built
|
|
|
|
|
|
|
|
|
|
|
|
In-browser RenderConfig slider panel; mipmaps; `painter` depth mode; gouraud
|
2026-08-24 15:17:53 +02:00
|
|
|
|
lighting; more cloud types; more props / a weapon; more mob definitions + smarter mob
|
2026-08-07 12:49:13 +02:00
|
|
|
|
behavior (they wander + block/stand-on today, but don't yet react to the player). `shared/`
|
2026-08-04 03:11:46 +02:00
|
|
|
|
is nearly empty. The FPS meter is static HTML + `textContent` writes only — no
|
|
|
|
|
|
DOM-built UI yet (deliberate).
|