7.3 KiB
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 inapp/.
Stack & tooling
- Bun runtime +
bun test. TypeScript 7 (nativetsc), 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
regimefrom asigitex:source.
Commands
bun start— Vite dev server; open the printed URL. Edits hot-reload.bun run build— production build todist/.bun run assets— regenerate the placeholder PNGs in/assets.bunx tsc --build tsconfig.app.json— typecheck the app+engine graph. Use this, notbun 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 byapp/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/*.png→Texture(zero-copy; ImageData bytes are already theColorlayout).level.ts— builds the playground: per-texture meshes,Aabbcolliders, NPC position, sky/cloud config. Geometry density knobDIVISIONS_PER_UNIT.player.ts— feet-cylinder player: gravity/jump + circle-vs-AABB/-circle collision.
index.html— Vite entry at repo root; holds the#screencanvas and the#fpsmeter div (styled inline).scripts/gen-assets.ts— procedurally draws the placeholder textures and writes PNGs (hand-rolled encoder vianode:zlib). Run viabun run assets.assets/— generatedfloor/wall/crate/npcPNGs. 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 Camera → Camera.viewProjection →
Sky.render (fills color + resets depth, replaces a clear) →
Rasterizer.draw floor, walls, crate (one call per texture) →
Sprite.billboard(npc) drawn via Rasterizer.draw →
Framebuffer.quantize → present (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 unboundps1(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.tsDIVISIONS_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 withperspectiveCorrect— 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).