meat/AGENTS.md

356 lines
23 KiB
Markdown
Raw Normal View History

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**.
- **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).
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.
- `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).
- `bunx oxlint engine game app` — lint.
- `bun test` — tests (registry id-order + engine↛game layering guards).
2026-08-04 03:11:46 +02:00
- `bun run serve` — Bun server (`server/server.ts`, a stub for now).
## Layout
- `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,
so the renderer draws by list, not by named texture), `Sky` (gradient + sun +
procedural clouds; renders at 1/`step` res).
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`
(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.
2026-08-04 13:09:26 +02:00
- `level.ts` — builds the playground: a flat stone-floored room (three thick
2026-08-04 15:05:02 +02:00
walls via `slab`, north side open) always drawn, in the center of a big grassy
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
2026-08-04 23:03:55 +02:00
`placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders;
2026-08-07 12:49:13 +02:00
`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).
- `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.
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-07 12:49:13 +02:00
`Mob.update` (all mobs) + rebuild near-player mob colliders → `Player.update`
build `Camera``Camera.viewProjection``visibleChunks` + `visibleMobs`
2026-08-05 09:24:13 +02:00
(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.
**`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.
`renderBand` runs `renderScene.renderBand` for rows [y0,y1): `Sky.render` 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 —
`near` or `far` chosen by the pure `chunkFar` 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)`
2026-08-07 12:49:13 +02:00
the roaming mobs (each: shared local mesh × its `Mat4.compose` model matrix,
double-sided) → `Framebuffer.quantize`. `chunkFar` 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).
- **`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.
- **`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.
- **Half-res sky** (`SKY_STEP` in `main`, default 2) — the cloud fbm runs per
pixel and dominated the frame; sampling once per 2×2 block quarters it.
2026-08-05 09:24:13 +02:00
- **Distance LOD** (`RenderConfig.lodDistance`, `chunkFar` in `renderScene`) —
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.52x, 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).
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).
## Trees (`game/actors/Tree.ts` + `game/actors/trees/`)
2026-08-04 13:53:59 +02:00
2026-08-07 19:11:43 +02:00
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:
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-07 19:11:43 +02:00
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`
2026-08-07 19:11:43 +02:00
`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`.
2026-08-04 13:53:59 +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
WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` 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-07 12:49:13 +02:00
lighting; more cloud types; more props / a weapon; more mob kinds + smarter mob
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).