Forkspawn is a single-page Three.js application. There's no game engine download, no Unity WebGL megabuild, no asset pipeline. The entire game — renderer setup, scene graph, input handling, AI, combat — lives in one React component that dynamically imports Three.js from a CDN at runtime and constructs the world from procedural geometry and canvas-generated textures. Here's the stack:
The result: the game JS ships at ~4MB (gzips under 1MB), Three.js loads from jsDelivr's CDN and is cached by the browser across visits, and there are zero texture or model downloads. First paint to playable is under 2 seconds on a warm cache.
The first decision: don't bundle Three.js. The full library is ~600KB minified; bundling it into the page chunk bloats the initial download and couples the game to the build. Instead we load it as an ES module via native dynamic import() at runtime, then cache the module on window so a second mount (demo → full version) reuses it without a second network request.
// Three.js r169 module build from jsDelivr — CORS-enabled, cached // across visits. The UMD build (window.THREE) was removed after r160. const THREE_CDN = "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js"; function loadThree(): Promise<AnyTHREE> { if (window.THREE) return Promise.resolve(window.THREE); // Use Function() to hide import() from webpack/turbopack static // analysis — ensures it stays a native browser dynamic import // at runtime, not a build-time code-split chunk. const dynamicImport = new Function("url", "return import(url)"); return dynamicImport(THREE_CDN).then(mod => { window.THREE = mod; // cache for next mount return mod; }); }
The new Function("url", "return import(url)") trick is the key: bundlers statically analyze import() calls and try to resolve them at build time. Wrapping it in a runtime-constructed function hides it from the analyzer, so the browser performs a genuine native dynamic import at runtime — pulling Three.js from the CDN, not from a local chunk.
Forkspawn's visual quality comes not from expensive assets but from a correctly-configured rendering pipeline. Every setting below is a deliberate trade-off between fidelity and the 60fps budget on a mid-range laptop GPU.
Every surface uses MeshStandardMaterial — Three.js's physically-based shading model. The floor is rough (0.9) and barely metallic (0.1) to read as worn concrete; walls are semi-metallic (0.3) with moderate roughness (0.8) to catch the directional light as specular streaks. Enemy armor is low-metalness (0.2) with camo-textured albedo. The metalness/roughness workflow is what gives the scene its "real materials" feel without any normal maps or baked AO.
const floorMat = new THREE.MeshStandardMaterial({ map: floorTex, // procedural canvas texture roughness: 0.9, metalness: 0.1, }); const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance", }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
A single DirectionalLight simulates moonlight — the primary light source, casting 2048×2048 PCF soft shadows across the entire arena. A warm PointLight(the brand orange, 0xff6b1a) adds a fill glow from the far end of the arena, giving the scene color contrast between cool moonlight and warm ambient. An AmbientLight lifts the shadowed surfaces so they don't crush to pure black.
const moonLight = new THREE.DirectionalLight(0x8090c0, 1.2); moonLight.castShadow = true; moonLight.shadow.mapSize.set(2048, 2048); moonLight.shadow.camera.set(-50, 50, 50, -50); moonLight.shadow.bias = -0.0005; // kill shadow acne const warmLight = new THREE.PointLight(0xff6b1a, 0.8, 80);
ACES Filmic tone mapping (exposure 1.1) compresses the high-dynamic-range lighting into a display-ready range with filmic rolloff in the highlights — this is the single setting that makes a WebGL scene stop looking "web-game" and start looking "cinematic." FogExp2 (density 0.012) adds exponential atmospheric depth, so distant enemies fade into the haze and the arena feels larger than its 120×120 unit footprint.
renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.1; renderer.shadowMap.type = THREE.PCFSoftShadowMap; scene.fog = new THREE.FogExp2(0x0a0a12, 0.012);
The single biggest contributor to the 4MB budget: not a single texture file is downloaded. Every surface texture — concrete floor, metal panel walls, enemy camo fatigues, the night sky — is drawn programmatically onto a <canvas> 2D context at load time, then uploaded to the GPU as a THREE.CanvasTexture. This replaces a traditional texture atlas (a packed PNG sheet that must be downloaded, decoded, and UV-mapped) with a few hundred lines of Canvas 2D draw calls that execute in milliseconds and cost zero network bytes.
The floor texture is a 512×512 canvas: a dark base fill, 8,000 random noise pixels for grain, a faint 8×8 grid for tile seams, then wrapped and repeated 16× across the arena plane. The wall texture draws an 8×8 metal-panel grid with rivets and grime. The enemy texture layers a vertical gradient, 40 camo blotches, and a red visor strip. Each is a self-contained function:
function makeFloorTexture(THREE): AnyObj { const c = document.createElement("canvas"); c.width = c.height = 512; const ctx = c.getContext("2d")!; ctx.fillStyle = "#1a1a1e"; ctx.fillRect(0, 0, 512, 512); // 8000 noise pixels for concrete grain for (let i = 0; i < 8000; i++) { /* random dark/light specks */ } // faint grid for tile seams for (let i = 0; i <= 8; i++) { /* stroke grid lines */ } const tex = new THREE.CanvasTexture(c); tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(16, 16); // tile across the floor return tex; }
Why this matters for a browser FPS: texture downloads are the dominant payload in any web game. A single 4K PBR texture set (albedo + normal + roughness) is 15–25MB. Forkspawn's entire visual asset budget iszero bytes — the "textures" are code that runs once at startup. The trade-off is less micro-detail than a hand-authored 4K texture, but with tone mapping and fog the result reads as convincingly atmospheric at a fraction of the cost.
Draw calls are the dominant GPU overhead in a browser game — each is a CPU→GPU dispatch with fixed cost. Forkspawn keeps them low through three strategies:
MeshStandardMaterialinstance (same wall texture, same roughness/metalness). All 12 cover crates share one crate material. Three.js batches meshes that share a material into fewer state changes, so 16 walls+crates cost ~2 material binds instead of 16.BoxGeometry, CapsuleGeometry, SphereGeometry,PlaneGeometry. No GLTF parsing, no mesh decompression, no draw-call-heavy submeshes. The entire arena is under 20 meshes.// One wall material, reused across all 4 walls — one texture, one bind const wallMat = new THREE.MeshStandardMaterial({ map: wallTex, roughness: 0.8, metalness: 0.3, }); for (const def of wallDefs) { const wall = new THREE.Mesh(new THREE.BoxGeometry(def.w, 12, def.d), wallMat); wall.castShadow = wall.receiveShadow = true; scene.add(wall); }
The enemy model is a Group of 5 primitive meshes (capsule body, sphere head, two shoulder pads) sharing two materials. A wave of 10 enemies is 50 meshes — well within budget, and frustum culling drops any enemy behind the player instantly.
The combat system is where browser FPS performance is won or lost. The naive approach — spawn a bullet mesh per shot, simulate its velocity each frame, test collision against every enemy — creates two problems: a growing array of per-frame physics objects (at 600 RPM that's 10 live bullets to track), and an O(bullets × enemies) collision loop every frame. Forkspawn eliminates both with instant hitscan raycasting.
On every shot, a THREE.Raycaster is cast from the camera position along the view direction (with per-weapon spread applied as random jitter to the direction vector). The ray is intersected against all colliders and enemy hitboxes in a single pass — walls, crates, enemy bodies, and enemy heads. The nearest intersect wins. If it's an enemy mesh, damage is applied (headshots = 2×); if it's a wall, a spark spawns at the impact point. There are zero persistent bullet objects and zero per-frame collision loops. The cost is O(visible enemies + colliders) per shot, not per frame.
const raycaster = new THREE.Raycaster(); raycaster.far = 200; // arena diagonal ~170; skip far misses function shoot() { // ... fire-rate / ammo checks ... const origin = camera.position.clone(); const baseDir = new THREE.Vector3(); camera.getWorldDirection(baseDir); // Build target list: living enemy bodies + heads (+ static colliders) const targets = [...colliderMeshes]; for (const enemy of enemies) if (enemy.alive) targets.push(enemy.mesh.children[0], enemy.mesh.children[1]); const pelletCount = w.name.includes("Breacher") ? 8 : 1; // shotgun for (let p = 0; p < pelletCount; p++) { const dir = baseDir.clone(); dir.x += (Math.random() - 0.5) * w.spread; // hip-fire cone dir.y += (Math.random() - 0.5) * w.spread; dir.normalize(); raycaster.set(origin, dir); const hits = raycaster.intersectObjects(targets, false); if (hits.length > 0) { const hit = hits[0]; // nearest intersect wins const ud = hit.object.userData; if (ud.enemyIdx !== undefined) { const isHead = ud.isHead; enemy.hp -= isHead ? w.damage * 2 : w.damage; // headshot 2× spawnTracer(origin, hit.point); } else { spawnParticles(hit.point, 0x886633, 4); // wall spark } } } }
Each enemy is a Group: children[0] is the capsule body, children[1] is the sphere head. At spawn time both are tagged with userData.enemyIdx (their index in the enemies array) and userData.isHead (boolean). When the raycaster returns an intersect, we read hit.object.userData to resolve which enemy was hit and whether it was a headshot — no separate bounding-volume pass, no manual ray-sphere math. The raycaster's built-in sphere/AABB intersection does the geometry; userData does the bookkeeping.
Pure hitscan has a UX problem: the damage tick is instant and invisible, so the shot doesn't "feel" like a gun. Forkspawn spawns a tracer line — a THREE.Line from the muzzle to the impact point — that fades out over 60 ms. This is purely cosmetic; the damage is already applied. The tracers are recycled (geometry disposed on expiry, not GC'd), so even the shotgun's 8 pellets per shot produce no sustained allocation pressure.
The loop is a single requestAnimationFrame callback that runs update → render each frame. Movement is delta-time normalized (multiply velocity by dt in seconds), so the game behaves identically at 60fps, 120fps, or during a frame hiccup — no frame-rate-dependent speed bugs. Player collision is resolved against a precomputed array of THREE.Box3 AABBs (walls + crates), tested per-axis so the player slides along walls instead of sticking.
Input splits cleanly by platform: desktop uses the Pointer Lock API for mouse-look (the cursor disappears and mouse deltas drive yaw/pitch), with WASD + click-to-fire. Mobile gets a virtual joystick (left thumb moves, right thumb drags to look) and on-screen fire/reload/jump/sprint buttons. The touch input state is a plain object the loop reads each frame — the DOM UI and the Three.js engine are fully decoupled.
The "no download" promise is literal: there is no installer, no launcher, no .exe, no app store. The game is a web page. When you click Play, here's what crosses the network:
Three.js is served from jsDelivr's CDN with proper Cache-Control headers, so after the first visit it's in the browser's HTTP cache and costs zero bytes on every return. The game JS chunk is the only first-party payload, and it gzips to under 1MB. On a typical broadband connection that's a sub-second download; on a warm cache it's instant.
Compare that to a Unity WebGL export: the engine runtime alone is 8–15MB, and a minimal scene with textures pushes 30–80MB before the first frame. Forkspawn's entire payload is smaller than a single Unity loading screen.
| Technique | What It Saves | Impact |
|---|---|---|
| Procedural canvas textures | Network: ~20MB of PNGs | Zero texture downloads |
| Hitscan raycasting | CPU: per-frame projectile loop | O(n) per shot, 0 per frame |
| Shared materials | GPU: material state changes | 16 meshes → ~2 binds |
| Frustum culling (built-in) | GPU: off-screen draws | ~50% of scene skipped |
| Capped pixel ratio (2×) | GPU: fragment fill rate | No 4K overdraw on Retina |
| CDN-loaded Three.js | Network: bundle size | 600KB cached across visits |
| Delta-time movement | CPU: frame-rate bugs | Identical at 60/120fps |
| Tracer recycling | CPU/GC: per-shot alloc | Geometry disposed, not GC'd |
Yes — if you configure the pipeline correctly. The settings that matter: MeshStandardMaterialfor PBR shading, PCFSoftShadowMap for soft shadows, ACESFilmicToneMappingfor filmic color, and FogExp2 for atmospheric depth. The visual gap between a default-configured Three.js scene and a "AAA" one is almost entirely in these four settings, not in asset quality. See it in action →
For a fast-firing FPS (carbine at 600 RPM), simulating 10+ live projectile meshes per frame — each with velocity integration and collision tests against every enemy — is the single biggest CPU sink. Hitscan collapses that to one raycast per shot with no persistent objects. The trade-off is no bullet travel time, but at carbine engagement distances (<50m) that's physically negligible and what every AAA shooter (CoD, CS, Valorant) does for hitscan weapons. Tracers provide the visual feedback.
Forkspawn's arena is small enough (120×120 units) and the enemy count low enough (≤15 simultaneous) that explicit geometric LOD — swapping high/low-poly meshes by distance — isn't needed; every enemy is already only 5 low-poly primitives. The "LOD" that matters here is frustum culling(skip off-screen meshes entirely) and shadow LOD (the 2048² shadow map covers the whole arena in one pass rather than per-light cascades). For a larger open-world browser game, Three.js's built-in THREE.LOD object would swap mesh detail by distance — the same technique, just automated.
Yes. The game detects touch devices and swaps Pointer Lock for a virtual joystick + drag-look + on-screen buttons. The pixel ratio cap (2×) prevents Retina/4K mobile screens from crushing the fill rate. WebGL 2 is supported on all modern mobile browsers (Safari 15+, Chrome on Android). Performance is gated by the GPU, not the techniques — the same optimizations that hold 60fps on a laptop keep it smooth on a flagship phone.
The game engine lives in a single Game.tsx component (~1,700 lines) — renderer setup, procedural textures, hitscan combat, AI, input, the full loop. The best way to understand it is to play the demo and inspect the canvas in your browser's devtools. Try the demo →
The architecture is one thing. Feeling the recoil, the headshots, and the 60fps is another. Play the Forkspawn demo free in your browser — no sign-up, no download, ~4MB.
Built with Three.js · WebGL 2 · No game engine · No downloads