meat/AGENTS.md

21 KiB
Raw Blame History

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.
  • 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.
  • 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.
  • 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.
  • 2D assets only. Sprites/billboards (PS1-style), no 3D model loading.
  • Three layers, one-way deps: enginegameapp. 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

  • Bun runtime + bun test. TypeScript 7 (native tsc), strict, 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).
  • 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.
  • 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.jsontypecheck the engine+game+app graph. Use this, not bun run check (see Caveats).
  • bunx oxlint engine game app — lint.
  • bun test — tests (world compilation, render protocol, boundary guards).
  • 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.
    • 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 + 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), Material (texture + cull flag; a DrawGroup pairs a mesh with one, 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), 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 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.
    • 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). 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 engine instance prototype indexes + transforms. ?bench=st|mt A/Bs the paths. Renderer and worker import no game modules.
    • assets.ts — load /assets/*.pngTexture (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 writes PNGs (hand-rolled encoder via node:zlib). Run via bun run assets.
  • assets/ — generated floor/grass/bark/birch/leaf/needle/rock/flower/wall/crate/npc/frog/bee/robin 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, 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). Swap for real art anytime; filenames are the contract.
  • server/ — Bun server stub. shared/ — isomorphic slot.

Frame pipeline (app/main.ts tick)

Level.update (all actors) + Level.refreshActorCollidersCharacterController.update → build CameraCamera.viewProjectionRenderScene.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.

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 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 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) → 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 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.

Rasterizer specifics: near-plane clip (Sutherland-Hodgman), 1/w z-buffer, perspective-correct UVs, screen-space vertex snap, flat directional lighting, 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.

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, 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 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 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 off-screen or fogged each frame, so several things keep it cheap:

  • 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 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, 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 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.
  • Flat geometry + zero-alloc rasterMesh is a flat float array and the whole per-triangle path uses reused scratch, so a frame allocates ~0 bytes (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.

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 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/)

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 blobs (wider than tall, bushy).
  • spruce — tall thin trunk under stacked narrowing cone tiers pointing to a tip (taller than wide, conical).
  • 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.

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. 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 never cracks) into one shared rock mesh, sunk partway into the ground. scatterBoulders sizes them small→big (biased small) and drops colliders on the 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).

Controls

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.

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 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.

Roadmap / not yet built

In-browser RenderConfig slider panel; mipmaps; painter depth mode; gouraud 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).