meat/AGENTS.md
2026-08-04 15:05:02 +02:00

12 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.
  • 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.jsontypecheck 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 + 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).
    • scene/Camera (fps yaw/pitch; far plane reaches the outdoor peaks), Mesh (indexed tris), 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), Tree (procedural low-poly oak/spruce geometry, sapling..full via a growth knob; Tree.build appends into shared trunk + foliage meshes), Boulder (procedural low-poly rock: a squashed, jittered, part-buried sphere; Boulder.build appends into a shared mesh).
  • app/ — browser glue.
    • main.ts — game loop, input, preset switching, canvas blit, FPS meter.
    • assets.ts — load /assets/*.pngTexture (zero-copy; ImageData bytes are already the Color layout).
    • 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 → instance lists + colliders; TREE_/BOULDER_COUNT/_SEED/ _REACH), then buildChunks bakes terrain + props into a CHUNK_GRID x CHUNK_GRID grid of Chunks (each = per-texture meshes grass/bark/leaf/needle/ rock + a tight AABB) that main frustum-culls. 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).
  • 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/leaf/needle/rock/wall/crate/npc PNGs (floor = room stone, grass = outdoor ground, bark/leaf/needle = tree trunk/oak/spruce, rock = boulders). Swap for real art anytime; filenames are the contract.
  • server/ — Bun server stub. shared/ — isomorphic slot.

Frame pipeline (app/main.ts frame)

Player.update → build CameraCamera.viewProjectionSky.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, bark, leaf, needle (backface-culled) → Sprite.billboard(npc) (double-sided) → Framebuffer.quantizepresent (integer-scale, letterboxed blit; imageSmoothingEnabled follows upscaleFilter).

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. (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.
  • 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 off-screen or fogged each frame, so three 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 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.

Together ~1.52x over drawing everything full-res every frame, and the win grows with content since culled chunks cost ~nothing. Next levers if needed: LOD / impostors for far trees, flat typed-array geometry (kill per-tri allocation), Web-Worker banded rasterization. TREE_COUNT/BOULDER_COUNT are the blunt content dials.

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

Trees (engine/scene/Tree.ts)

Procedural low-poly geometry, faceted flat-shaded like everything else. Two kinds carry the species read purely by silhouette:

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

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.

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

Controls

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.

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 / moving enemies. shared/ is nearly empty. The FPS meter is static HTML + textContent writes only — no DOM-built UI yet (deliberate).