<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nick</title>
    <description>The latest articles on DEV Community by Nick (@webgamerush).</description>
    <link>https://dev.to/webgamerush</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4147342%2F0ebdbc79-4999-4336-a78f-eae979a53af6.png</url>
      <title>DEV Community: Nick</title>
      <link>https://dev.to/webgamerush</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/webgamerush"/>
    <language>en</language>
    <item>
      <title>What I learned building a 3D browser RTS with three.js and no build step</title>
      <dc:creator>Nick</dc:creator>
      <pubDate>Mon, 28 Sep 2026 14:35:25 +0000</pubDate>
      <link>https://dev.to/webgamerush/what-i-learned-building-a-3d-browser-rts-with-threejs-and-no-build-step-1a31</link>
      <guid>https://dev.to/webgamerush/what-i-learned-building-a-3d-browser-rts-with-threejs-and-no-build-step-1a31</guid>
      <description>&lt;p&gt;I recently shipped &lt;a href="https://webgamerush.com/game/singularity-rush" rel="noopener noreferrer"&gt;Singularity Rush&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;It'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.&lt;/p&gt;

&lt;h2&gt;
  
  
  One folder, no build step
&lt;/h2&gt;

&lt;p&gt;The game is a folder of ES modules. &lt;code&gt;index.html&lt;/code&gt; has an import map, and that's the entire toolchain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"importmap"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;imports&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;three&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./vendor/three.module.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;three/addons/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./vendor/addons/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"src/main.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;three.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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the simulation away from the renderer
&lt;/h2&gt;

&lt;p&gt;The 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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The simulation advances in fixed ticks at 10 Hz. The renderer runs at display rate and interpolates between ticks.&lt;/li&gt;
&lt;li&gt;All randomness comes from a seeded PRNG, a small mulberry32-style function stored on the world. The simulation never calls &lt;code&gt;Math.random()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Player input becomes commands that are queued for the next tick. The AI sends commands through the same path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A build check keeps that boundary honest. It fails if anything under &lt;code&gt;src/sim/&lt;/code&gt; imports three.js or mentions &lt;code&gt;document&lt;/code&gt;, &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;requestAnimationFrame&lt;/code&gt;, or &lt;code&gt;Math.random&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;simDir&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;simDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doesNotMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/from &lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;three/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; imports three.js`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doesNotMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;document|window|requestAnimationFrame|Math&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;random&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; touches the DOM or unseeded randomness`&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 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.&lt;/p&gt;

&lt;p&gt;It's useful outside CI as well. Adding &lt;code&gt;?skip=600&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The caching trap
&lt;/h2&gt;

&lt;p&gt;The same simple setup was less pleasant once caching got involved. I'd set &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; 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 &lt;code&gt;main.js&lt;/code&gt; with &lt;code&gt;max-age=14400&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After a deploy, a returning player could get a fresh &lt;code&gt;main.js&lt;/code&gt; and a four-hour-old &lt;code&gt;hud.js&lt;/code&gt;. When ES modules disagree about what exists, the resulting errors aren't especially helpful.&lt;/p&gt;

&lt;p&gt;A 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 &lt;code&gt;b/&amp;lt;hash&amp;gt;/&lt;/code&gt;, then rewrites the paths in &lt;code&gt;index.html&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;I don't trust the config file on this one anymore. I check the production headers with &lt;code&gt;curl -I&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing your units behind buildings
&lt;/h2&gt;

&lt;p&gt;Visibility 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.&lt;/p&gt;

&lt;p&gt;I 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 &lt;code&gt;1&lt;/code&gt; 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 (&lt;code&gt;depthFunc: THREE.GreaterDepth&lt;/code&gt;). It skips pixels where the stencil is already &lt;code&gt;1&lt;/code&gt;, which stops the silhouette from painting over a visible unit or its own arm.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MeshBasicMaterial&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FACTIONS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;faction&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;depthWrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;depthFunc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GreaterDepth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;stencilWrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;stencilRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;stencilFunc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NotEqualStencilFunc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;polygonOffset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// pulled a hair toward the camera so&lt;/span&gt;
  &lt;span class="na"&gt;polygonOffsetFactor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// feet on the ground don't flicker&lt;/span&gt;
  &lt;span class="na"&gt;polygonOffsetUnits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One easy thing to miss: the renderer has to be created with &lt;code&gt;stencil: true&lt;/code&gt;, or none of this does anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't be clever with left-drag
&lt;/h2&gt;

&lt;p&gt;I 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.&lt;/p&gt;

&lt;p&gt;So I looked at how more than 20 RTS games handle it. Every desktop RTS I checked, including StarCraft II, Age of Empires IV, C&amp;amp;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.&lt;/p&gt;

&lt;p&gt;So 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mouse or trackpad?
&lt;/h2&gt;

&lt;p&gt;The 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 &lt;code&gt;ctrlKey&lt;/code&gt;, which usually means a pinch, points toward a trackpad. Firefox's &lt;code&gt;deltaMode === 1&lt;/code&gt; points toward a mouse. A &lt;code&gt;wheelDelta&lt;/code&gt; divisible by 120 usually means a mouse notch.&lt;/p&gt;

&lt;p&gt;Usually. Chrome's per-site zoom broke that rule: at 80% zoom, one mouse notch reports a &lt;code&gt;wheelDelta&lt;/code&gt; of 149 rather than 120. On macOS I found a better test, since a trackpad's &lt;code&gt;wheelDelta&lt;/code&gt; is three times &lt;code&gt;deltaY&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Life inside an iframe, and on iPhones
&lt;/h2&gt;

&lt;p&gt;The game runs inside an iframe on the site, and that exposed bugs I never saw when it ran by itself.&lt;/p&gt;

&lt;p&gt;First, wheel events leaked to the parent page. The game document can't scroll, so any wheel event that escaped &lt;code&gt;preventDefault()&lt;/code&gt; 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 &lt;code&gt;{ passive: false }&lt;/code&gt; fixed it: it swallows wheel input during a match, except over panels that can actually scroll.&lt;/p&gt;

&lt;p&gt;Then there was fullscreen on iPhone, which doesn't exist for elements. In Chrome on an iPhone, &lt;code&gt;requestFullscreen&lt;/code&gt;, &lt;code&gt;webkitRequestFullscreen&lt;/code&gt;, and &lt;code&gt;fullscreenEnabled&lt;/code&gt; were all &lt;code&gt;undefined&lt;/code&gt;, 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 &lt;code&gt;standalone&lt;/code&gt; manifest is the only way to hide the browser bar.&lt;/p&gt;

&lt;p&gt;iOS also enlarged paragraph text by about 1.6 times in landscape until I added this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;-webkit-text-size-adjust&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;text-size-adjust&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chrome's device emulation showed neither of those iOS problems. A real phone on the LAN did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the theme matter
&lt;/h2&gt;

&lt;p&gt;I 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  On building it with AI tools
&lt;/h2&gt;

&lt;p&gt;I 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.&lt;/p&gt;

&lt;p&gt;If 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: &lt;a href="https://webgamerush.com/game/singularity-rush" rel="noopener noreferrer"&gt;play Singularity Rush&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>threejs</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
