meat/AGENTS.md
2026-08-04 03:11:46 +02:00

7.3 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 is rasterizer artifacts (affine texture swim, vertex snap, no mipmaps). Raytracing was considered and cut (it removes the very artifacts we want).
  • 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.
  • 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, Texture (nearest/bilinear, wrapping, no mipmaps), Sky (gradient + sun + procedural clouds).
    • scene/Camera (fps yaw/pitch), Mesh (indexed tris), Sprite (Y-axis billboard).
  • 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: per-texture meshes, Aabb colliders, NPC position, sky/cloud config. Geometry density knob DIVISIONS_PER_UNIT.
    • player.ts — feet-cylinder player: gravity/jump + circle-vs-AABB/-circle collision.
  • 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/wall/crate/npc PNGs. 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 (fills color + resets depth, replaces a clear) → Rasterizer.draw floor, walls, crate (one call per texture) → Sprite.billboard(npc) drawn via Rasterizer.drawFramebuffer.quantizepresent (integer-scale, letterboxed blit; imageSmoothingEnabled follows upscaleFilter).

Rasterizer specifics: near-plane clip (Sutherland-Hodgman), 1/w z-buffer, affine↔perspective-correct UV blend (perspectiveCorrect), screen-space vertex snap, flat directional lighting, distance fog, alpha cutout (discard texel alpha < 128, for sprites), double-sided (no backface culling).

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, perspectiveCorrect (0 = full affine swim, 1 = correct), textureFilter, lighting, fog.
  • app/level.ts DIVISIONS_PER_UNIT (1.3) — geometry triangle density per world unit. Higher = smaller triangles = less affine swim; below ~0.5 the floor degenerates into a black wedge (affine sampling collapses onto the texture's dark grout lines). Works with perspectiveCorrect — both fight the same affine error from different sides.

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

Controls

WASD move · mouse look (click canvas to pointer-lock) · Space jump · 1/2/3 switch look presets. FPS shown bottom-right.

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 diagnosed the affine black-wedge (affine sampled grout, perspective-correct sampled stone) and tuned the clouds. 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).