21 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 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 inapp/.
Stack & tooling
- Bun runtime +
bun test. TypeScript 7 (nativetsc), strict,moduleResolution: bundler. Vite 8 serves/builds the client (ES-module workers; dev/preview serve COOP/COEP headers so the multi-threaded renderer'sSharedArrayBufferworks — 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
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.bun run bench:browser— Playwright: drive headless Chromium through the?bench=st/?bench=mtflythrough, 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.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(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/stepres).scene/—Camera(fps yaw/pitch; far plane reaches the outdoor peaks),Mesh(indexed tris; verts stored flat:STRIDEfloats x,y,z,u,v per vertex, no per-vertex objects — cache-friendly + alloc-free to draw),Sprite(Y-axis billboard),Terrain(procedural heightfield around the room: flat clearing in the center, rolling hills, tall edge peaks.Terrain.patchbuilds one ground patch over a rectangle -- called per chunk, aligned so patches weld crack-free, with a hole for the room;Terrain.heightis the shared ground-height sampler for the player),Tree(procedural low-poly oak/spruce/birch geometry, sapling..full via agrowthknob;Tree.buildappends into shared trunk + foliage meshes),Boulder(procedural low-poly rock: a squashed, jittered, part-buried sphere;Boulder.buildappends 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),Mob(a roaming creature —froghops the ground,beehovers/darts — the engine's only moving geometry. Unlike the baked props, a mob's low-poly mesh is built once per kind in local space;Mob.buildbakes the two canonical meshes,Mob.updatesteps the wander AI (leashed to a home anchor, deterministic via an evolving per-mob seed) each frame, and the liveposition/heading/scalebecome a per-frame model matrix at draw time).
app/— browser glue.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 + presentcritical-path ms, vsync interval, visible chunks / LOD-aware tri count — the real profiler in play). Also owns the mob sim: each frame it stepsMob.updatefor every mob, rebuilds the near-player mob colliders intolevel.colliders, and culls the mobs (visibleMobs) so only the visible transforms get dispatched.renderer.ts— the render driver. When the page is cross-origin-isolated it runs a pool ofrender-worker.tsthreads (MAX_WORKERS) over aSharedArrayBufferframebuffer, each owning a disjoint row band, synced by a lock-freeAtomicsbarrier; otherwise it renders inline.dispatch/doneare non-blocking so the caller paces on rAF. Per-frame inputs ride shared arrays: camera/matrix/visible-chunk list, plus the visible mob transforms (mobState, count in theMOBVIScontrol slot).?bench=st|mtA/Bs the paths.renderScene.ts—renderBand(fb, scene, …, mobDraws, …, y0, y1): the single source of render truth (sky + room + culled chunks + sprite + roaming mobs + quantize for a row band). Used full-height by the inline path, per-band by each worker.Scenebundles the static meshes/textures (incl. the two canonical mob meshes) so it clones to a worker whole; each mob is drawn double-sided through its ownviewProj × Mat4.compose(...)model matrix, andvisibleMobsfrustum-culls the moving mobs per frame.assets.ts— load/assets/*.png→Texture(zero-copy; ImageData bytes are already theColorlayout).level.ts— builds the playground: a flat stone-floored room (three thick walls viaslab, north side open) always drawn, in the center of a big grassyTerrainworld (~20x across). Props are placed first (placeTrees/placeBoulders/placeBushes/placeFlowers→ instance lists + colliders;TREE_/BOULDER_/BUSH_/FLOWER_COUNT/_SEED/_REACH) and the roaming mobs scattered (placeMobs;FROG_/BEE_COUNT,MOB_SEED,MOB_REACH— mobs move, so they carry no baked colliders), thenbuildChunksbakes terrain + props into aCHUNK_GRIDxCHUNK_GRIDgrid ofChunks (each = per-texture meshes grass/bark/birchBark/leaf/needle/rock/flowers + a tight AABB) thatmainfrustum-culls; bushes fold into the leaf mesh, flowers get their own (drawn double-sided). Trees + boulders are baked twice — full geometry and a low-poly impostor (barkFar/leafFar/needleFar/rockFar, via the builders'lodarg) — so a far chunk can swap to the cheap set with no per-frame work (seechunkFar/RenderConfig.lodDistance).Aabbcolliders (walls, crate, grown trunks, big boulders), NPC position,TERRAIN/TERRAIN_SUBDIV/GROUND_UV, sky/cloud config. The stone floor is lifted byFLOOR_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 fromTerrain.height(plus standable AABBs).
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/grass/bark/birch/leaf/needle/rock/flower/wall/crate/npc/frog/beePNGs (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= mob skin atlases: frog green + eye tone; bee stripe bands + head-dark + wing-pale regions). Swap for real art anytime; filenames are the contract.server/— Bun server stub.shared/— isomorphic slot.
Frame pipeline (app/main.ts tick)
Mob.update (all mobs) + rebuild near-player mob colliders → Player.update →
build Camera → Camera.viewProjection → visibleChunks + visibleMobs
(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 at
1/SKY_STEP res (fills color + resets depth, replaces a clear) → Rasterizer.draw
floor/walls/crate (room, always) → for each visible Chunk draw grass, then —
per chunk via the pure chunkFar test (dist² from camera to the chunk AABB vs
lodDistance²) — either the full rock/bark/leaf/needle + flowers, or the cheap
rockFar/barkFar/needleFar/leafFar impostor meshes (foliage detail dropped).
All backface-culled except double-sided flowers → Sprite.billboard(npc) →
the roaming mobs (each: shared local mesh × its Mat4.compose model matrix,
double-sided) → Framebuffer.quantize. chunkFar 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 unboundps1(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),Infinityoncleanto disable LOD. Lower it for more headroom (more pop), raise it for more far detail (more tris).
- boulders draw as cheap impostors (see Performance). ~60 for standard/soft/ps1
(well inside
app/level.tsGROUND_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.tsCHUNK_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-ChunkAABB test inmain) — 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_STEPinmain, default 2) — the cloud fbm runs per pixel and dominated the frame; sampling once per 2×2 block quarters it. - Distance LOD (
RenderConfig.lodDistance,chunkFarinrenderScene) — pastlodDistancea 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 raster —
Meshis 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 aSharedArrayBuffer. The barrier is lock-free: workersAtomics.waiton a frame counter (no per-frame messages), main writes camera/matrix/visible-list into shared arrays,dispatchis non-blocking, andmainpollsdone()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_WORKERScaps 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.5–2x, 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 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. Three
kinds carry the species read purely by silhouette:
oak— short tapered trunk, a couple of branches, a broad cluster of lumpy canopyblobs (wider than tall, bushy).spruce— tall thin trunk under stacked narrowingconetiers 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 whitebirchbark texture (brown bark can't stand in), and shares the oakleaffoliage.
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.
Tree.build appends into caller-chosen trunk + foliage meshes, so a forest still
batches into a few draw calls (brown-bark trunks, white-birch trunks, oak/birch
leaf foliage, spruce needles). app/level.ts placeTrees seeds the forest and
rolls the species; add one by extending the union + a builder (a new bark/leaf
look also needs its own texture + per-chunk mesh channel — see birchBark).
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. 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 ×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; more mob kinds + 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).