meat/AGENTS.md

239 lines
14 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**.
- **Engine is headless.** `engine/` has no DOM types and could run server-side;
all browser glue (canvas, input, image decode) lives in `app/`.
## Stack & tooling
- **Bun** runtime + `bun test`. **TypeScript 7** (native `tsc`), strict,
`moduleResolution: bundler`. **Vite 8** serves/builds the client.
- **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`.
- `bunx tsc --build tsconfig.app.json` — **typecheck the app+engine graph. Use
this**, not `bun run check` (see Caveats).
- `bunx oxlint engine app` — lint.
- `bun test` — tests (none yet).
- `bun run serve` — Bun server (`server/server.ts`, a stub for now).
## Layout
- `engine/` — headless engine, consumed by `app/` via tsconfig project ref.
- `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
mipmaps), `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), `Terrain` (procedural
2026-08-04 12:51:07 +02:00
heightfield around the room: flat clearing in the center, rolling hills, tall
2026-08-04 15:05:02 +02:00
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;
2026-08-04 13:53:59 +02:00
`Terrain.height` is the shared ground-height sampler for the player), `Tree`
(procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob;
2026-08-04 14:02:12 +02:00
`Tree.build` appends into shared trunk + foliage meshes), `Boulder`
(procedural low-poly rock: a squashed, jittered, part-buried sphere;
2026-08-04 23:03:55 +02:00
`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).
2026-08-04 03:11:46 +02:00
- `app/` — browser glue.
- `main.ts` — game loop, input, preset switching, canvas blit, FPS meter.
- `assets.ts` — load `/assets/*.png``Texture` (zero-copy; ImageData bytes
are already the `Color` layout).
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;
`TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`), then `buildChunks` bakes
terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each =
per-texture meshes grass/bark/leaf/needle/rock/flowers + a tight AABB) that
`main` frustum-culls; bushes fold into the leaf mesh, flowers get their own
(drawn double-sided). `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
2026-08-04 15:05:02 +02:00
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.
2026-08-04 13:09:26 +02:00
- `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).
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-04 23:03:55 +02:00
- `assets/` — generated `floor/grass/bark/leaf/needle/rock/flower/wall/crate/npc`
PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` =
tree trunk/oak/spruce, `rock` = boulders, `flower` = 2x2 bloom-color atlas).
Swap for real art anytime;
2026-08-04 03:11:46 +02:00
filenames are the contract.
- `server/` — Bun server stub. `shared/` — isomorphic slot.
## Frame pipeline (`app/main.ts` `frame`)
`Player.update` → build `Camera``Camera.viewProjection`
2026-08-04 15:05:02 +02:00
`Sky.render` at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) →
`Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`,
then for each `Chunk` that `Frustum.intersectsAabb` passes: draw its grass, rock,
2026-08-04 23:03:55 +02:00
bark, leaf, needle (backface-culled) + flowers (double-sided) →
`Sprite.billboard(npc)` (double-sided) →
2026-08-04 03:11:46 +02:00
`Framebuffer.quantize``present` (integer-scale, letterboxed blit;
`imageSmoothingEnabled` follows `upscaleFilter`).
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-04 12:51:07 +02:00
`vertexSnap`, `textureFilter`, `lighting`, `fog`. (Texturing is always
perspective-correct — the affine-swim dial was removed.)
- **`app/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.
2026-08-04 15:05:02 +02:00
- **`app/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.
## 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-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
(measured). This buys frame *consistency* (no GC-pause spikes; worst/mean ~1.3x)
and makes geometry shareable for Web-Worker rasterization later. Note it did
**not** raise mean fps — allocation was never the bottleneck (JSC collects the
churn ~free); the mean is the transform+fill **compute**.
Frustum + backface + half-res sky give ~1.52x, growing with content since culled
chunks cost ~nothing. The remaining bottleneck is raw compute on visible tris, so
the mean-fps levers left are to **do less** (LOD / impostors for far trees —
`TREE_COUNT`/`BOULDER_COUNT` are the blunt content dials) or **use more cores**
(Web-Worker banded rasterization, now unblocked by the flat geometry).
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 `app/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).
2026-08-04 13:53:59 +02:00
## Trees (`engine/scene/Tree.ts`)
Procedural low-poly geometry, faceted flat-shaded like everything else. Two
`kind`s carry the species read purely by silhouette:
- **`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).
`growth` (0..1) runs **sapling → full grown**: it scales height/girth and adds
canopy blobs (oak) / tiers (spruce); `seed` gives each tree its own wobble.
`Tree.build` appends into caller meshes so a forest batches into 3 draw calls
(one bark trunk mesh, oak-leaf and spruce-needle foliage meshes). `app/level.ts`
`scatterTrees` seeds the forest; add a species by extending the union + a builder.
2026-08-04 14:02:12 +02:00
**Boulders** (`engine/scene/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
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-04 13:09:26 +02:00
WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `app/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.
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
lighting; more cloud types; more props / a weapon / moving enemies. `shared/`
is nearly empty. The FPS meter is static HTML + `textContent` writes only — no
DOM-built UI yet (deliberate).