{"slug": "what-i-learned-building-a-3d-browser-rts-with-three-js-and-no-build-step", "title": "What I learned building a 3D browser RTS with three.js and no build step", "summary": "A developer shipped Singularity Rush, a free browser-based real-time strategy game built from roughly 6,700 lines of plain JavaScript with three.js r186 and no bundler or framework, using an import map as the entire toolchain. The project keeps the simulation free of rendering and DOM dependencies via a build check that enforces seeded randomness, enabling headless AI-vs-AI matches in CI, and adds a post-build content-hashing step to work around CDN cache TTLs that could serve mismatched ES modules.", "body_md": "I recently shipped [Singularity Rush](https://webgamerush.com/game/singularity-rush), a free real-time strategy game that runs in the browser. Four fictional AI labs are racing to train a Gen 5 model. Engineers mine data, datacenters on power sites produce compute, researchers speed up training, and rival red teams try to wreck the whole operation.\n\nIt's about 6,700 lines of plain JavaScript. No framework, no bundler, just three.js and CC0 models from Kenney. I'd make most of the same technical choices again, but a few of them came with catches I didn't see until the game was in production.\n\nThe game is a folder of ES modules. `index.html` has an import map, and that's the entire toolchain:\n\n```\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"three\": \"./vendor/three.module.js\",\n    \"three/addons/\": \"./vendor/addons/\"\n  }\n}\n</script>\n<script type=\"module\" src=\"src/main.js\"></script>\n```\n\nthree.js r186 is vendored next to the code. I edit a file, reload, and the browser runs the same code that's sitting in the repo. For a solo project, that's hard to beat.\n\nThe decision that paid off most was keeping the simulation separate from the view. The simulation knows nothing about rendering, and the view only reads simulation state.\n\n`Math.random()`.\nA build check keeps that boundary honest. It fails if anything under `src/sim/` imports three.js or mentions `document`, `window`, `requestAnimationFrame`, or `Math.random`.\n\n``` js\nfor (const file of readdirSync(simDir)) {\n  const source = readFileSync(path.join(simDir, file), 'utf8');\n  assert.doesNotMatch(source, /from ['\"]three/, `${file} imports three.js`);\n  assert.doesNotMatch(\n    source,\n    /\\b(document|window|requestAnimationFrame|Math\\.random)\\b/,\n    `${file} touches the DOM or unseeded randomness`\n  );\n}\n```\n\nThat lets Node play complete AI-vs-AI matches in CI. Each seeded match has to end with a winner, take a plausible amount of time, keep every stockpile at or above zero, and produce the same world hash when it runs again. The test prints the winning lab too, so if a balance change suddenly hands every game to one lab, I find out before players do.\n\nIt's useful outside CI as well. Adding `?skip=600` fast-forwards through 10 minutes of AI play in a blink, which is how I get mid-game screenshots without sitting around waiting for a match to develop.\n\nThe same simple setup was less pleasant once caching got involved. I'd set `Cache-Control: no-cache` for the code folder in my host's headers file and assumed I was done. I wasn't: the CDN zone had a Browser Cache TTL setting that overrode the origin headers for JavaScript and CSS, and it sent `main.js` with `max-age=14400`.\n\nAfter a deploy, a returning player could get a fresh `main.js` and a four-hour-old `hud.js`. When ES modules disagree about what exists, the resulting errors aren't especially helpful.\n\nA bundler normally avoids this by putting hashes in filenames, so I added a small post-build step to do the same job. It hashes the source, vendored three.js, and CSS, moves everything under `b/<hash>/`, then rewrites the paths in `index.html`, including the import map. A deploy check fails if any unhashed code path survives. I still get a build-free development loop, and production gets immutable URLs.\n\nI don't trust the config file on this one anymore. I check the production headers with `curl -I`.\n\nVisibility was another surprise. From the default camera angle, buildings and trees hid the heads of 11% to 23% of the units on screen, and rotating the camera just moved the problem somewhere else.\n\nI borrowed the StarCraft II solution: show your own units through cover as flat silhouettes in the team color. In three.js, it takes two passes. The normal material writes `1` to the stencil buffer wherever the unit is visible. The silhouette pass reuses the same geometry and skeleton, so it still animates, but it only draws where the unit is behind something (`depthFunc: THREE.GreaterDepth`). It skips pixels where the stencil is already `1`, which stops the silhouette from painting over a visible unit or its own arm.\n\n```\nnew THREE.MeshBasicMaterial({\n  color: FACTIONS[faction].color,\n  transparent: true,\n  opacity: 0.6,\n  depthWrite: false,\n  depthFunc: THREE.GreaterDepth,\n  stencilWrite: true,\n  stencilRef: 1,\n  stencilFunc: THREE.NotEqualStencilFunc,\n  polygonOffset: true,       // pulled a hair toward the camera so\n  polygonOffsetFactor: -2,   // feet on the ground don't flicker\n  polygonOffsetUnits: -4,\n});\n```\n\nOne easy thing to miss: the renderer has to be created with `stencil: true`, or none of this does anything.\n\nI changed the left-drag behavior more than once. In one version, a quick drag panned the map. Later, a hold followed by a drag did it. Both sounded clever and both caused mistakes: pause for a moment before dragging and the game would pan when you meant to select, with nothing on screen to explain why.\n\nSo I looked at how more than 20 RTS games handle it. Every desktop RTS I checked, including StarCraft II, Age of Empires IV, C&C Remastered, Supreme Commander, Warcraft III, Northgard, and They Are Billions, uses a plain left-drag for box selection. Panning goes somewhere else: the screen edge, the keyboard, middle-drag, or right-drag. I only found left-drag panning in games that had nothing to box-select.\n\nSo I went with the convention. Left-drag selects, and right-drag or middle-drag pans. On touch, drag pans and a long press followed by a drag selects, which matches how mobile strategy games do it.\n\nThe harder input problem was telling a mouse wheel from a trackpad. A wheel should zoom, but two-finger scrolling on a trackpad should pan. Browsers don't identify the device, so the game keeps a small capped score based on the wheel events it sees. Sideways motion or `ctrlKey`, which usually means a pinch, points toward a trackpad. Firefox's `deltaMode === 1` points toward a mouse. A `wheelDelta` divisible by 120 usually means a mouse notch.\n\nUsually. Chrome's per-site zoom broke that rule: at 80% zoom, one mouse notch reports a `wheelDelta` of 149 rather than 120. On macOS I found a better test, since a trackpad's `wheelDelta` is three times `deltaY` and a notched mouse's isn't. There's still a manual mouse/trackpad switch in the HUD, because the game is guessing, however carefully.\n\nThe game runs inside an iframe on the site, and that exposed bugs I never saw when it ran by itself.\n\nFirst, wheel events leaked to the parent page. The game document can't scroll, so any wheel event that escaped `preventDefault()` scrolled the whole site instead. The map handled its own wheel events, but the HUD covers much of the frame. One window-level listener with `{ passive: false }` fixed it: it swallows wheel input during a match, except over panels that can actually scroll.\n\nThen there was fullscreen on iPhone, which doesn't exist for elements. In Chrome on an iPhone, `requestFullscreen`, `webkitRequestFullscreen`, and `fullscreenEnabled` were all `undefined`, and since every iOS browser uses WebKit, switching browsers won't rescue you. The site falls back to an \"immersive\" layout, and Add to Home Screen with a `standalone` manifest is the only way to hide the browser bar.\n\niOS also enlarged paragraph text by about 1.6 times in landscape until I added this:\n\n```\nhtml { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }\n```\n\nChrome's device emulation showed neither of those iOS problems. A real phone on the LAN did.\n\nI also wanted the AI-race theme to change how the game plays, rather than sit on top as decoration. Each lab chooses a training pace. Reckless training is 35% faster, but three incidents close together freeze the run for 45 seconds. A Safety Audit freezes a rival that's been racing recklessly and backfires against one with a clean record. Each generation costs data up front and then burns compute, so the game is an economy race first, which is why losing datacenters to a red team hurts so much.\n\nI built the whole thing solo, with plenty of help from AI coding tools. The pure simulation and its checks are what made that manageable. AI is very good at producing a change that looks convincing and runs just long enough to fool you, and making those changes survive full matches was a much better filter than staring at the diff.\n\nIf you try the game, I'd love to know whether the first five minutes make sense without the guide, and whether the AI feels fair on Standard. It's free, with no download or signup: [play Singularity Rush](https://webgamerush.com/game/singularity-rush).", "url": "https://wpnews.pro/news/what-i-learned-building-a-3d-browser-rts-with-three-js-and-no-build-step", "canonical_source": "https://dev.to/webgamerush/what-i-learned-building-a-3d-browser-rts-with-threejs-and-no-build-step-1a31", "published_at": "2026-09-28 14:35:25+00:00", "updated_at": "2026-09-28 14:51:11.012800+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Singularity Rush", "three.js", "Kenney"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/what-i-learned-building-a-3d-browser-rts-with-three-js-and-no-build-step", "markdown": "https://wpnews.pro/news/what-i-learned-building-a-3d-browser-rts-with-three-js-and-no-build-step.md", "text": "https://wpnews.pro/news/what-i-learned-building-a-3d-browser-rts-with-three-js-and-no-build-step.txt", "jsonld": "https://wpnews.pro/news/what-i-learned-building-a-3d-browser-rts-with-three-js-and-no-build-step.jsonld"}}