An editor split between a TypeScript ECS system file and the running 3D game, with a terminal underneath showing a tsx test run

The tech stack behind a browser RTS

Working with AI11 min readUpdated
ClaudeBuilt the thing
Adam SturrockDecided what mattered

The brief was one sentence long: it has to open in a browser tab, with nothing to install. Click a link and you are in a skirmish, no download, no launcher, no install step, and behind that link sits 9 factions, hundreds of units moving on one map at once, a campaign, a dungeon crawler and eight-player battles.

A dwarf host arrayed under its banner: crossbowmen, ironguard, a gyrocopter and a steam cannon

That sentence is the constraint every choice below answers to. The whole game runs on the player's machine, it arrives over the network before the first match starts, and it arrives again for anyone who clears their cache. Nothing can be pushed to a server later to make the download smaller or the frame budget bigger.

What it is built with is the question that arrives about this project more than any other, and the list on its own is not a useful answer. A stack is a set of trades, and the interesting half of each one is what it charged rather than what it bought. So: the versions first, then the bill.

Everything it runs on, with versions

LayerChoiceVersion
FrameworkNext.js16.1.6
UIReact and React DOM19.2.3
LanguageTypeScript5
3Dthree.js0.184
Physics@dimforge/rapier3d-compat0.12
DesktopTauri2.10
CSSTailwind4
Service workerSerwist9.5.7
Imagessharp0.34.5
NetworkingPeerJS1.5.5
Crash reportsTurso via @libsql/client0.17
Test runnertsx4.21

Half of those names mean nothing without a sentence, so: three.js is the standard library for 3D in a browser — it wraps WebGL, the browser's raw interface to the graphics card, and gives you scenes, cameras, meshes and materials instead of buffers. Rapier is a physics engine written in Rust and compiled to WebAssembly so it can run at native-ish speed in a tab; here it does ragdolls and nothing else. Tauri wraps a web app in a native desktop shell with a Rust backend, in the same territory as Electron but using the operating system's own browser engine rather than shipping Chromium. PeerJS is a friendly wrapper over WebRTC, the browser API that lets two browsers send data directly to each other without a server in the middle. Serwist builds the service worker, the script that sits between the page and the network and makes offline loading possible. Turso is hosted SQLite, used here as the crash-report database. sharp is the image processing library the whole 2D asset pipeline is built on, and tsx simply runs TypeScript files directly without a build step, which is what makes a test a script you can execute.

Assets are served from a Cloudflare R2 bucket behind assets.shardsofstone.com, and hosting is Vercel. There is no game engine underneath any of it. The entity-component-system, the fixed-timestep loop that runs 20 times a second, the pathfinder, the fog of war and both renderers are written in this repository, which is why it holds 1,266 tracked TypeScript files.

The five choices with a price that is not on the label

No game engine buys total control of the tick, the pathfinder and both renderers, and charges 1,266 files with no upstream maintainer. Every guarantee an engine would have handed over, deterministic stepping, entity serialisation, asset reloading, is one this project writes and then has to prove. That is not a complaint about the choice. It is the reason half of these posts exist.

three.js buys a renderer I did not have to write and charges you its failure modes. The 3D ground samples 16 textures, which is exactly the floor WebGL2 guarantees a graphics card will support. A seventeenth does not make the render worse. It makes the shader fail to build, and three.js reports that by printing to the console rather than raising anything, so the whole map renders flat white with nothing in the crash reporter. Adding a texture variant is free. Adding a texture role is not, and that ceiling now shapes what the terrain is allowed to do. The full account is the WebGL limit that turns your terrain white.

Rapier compiled to WebAssembly buys credible ragdolls and charges by never being allowed near the simulation. All 26 files that touch it are rendering code or lab pages, and not one of them is a simulation system. A WebAssembly physics step cannot sit on a lockstep path, so everything it computes is cosmetic by construction. A gameplay mechanic driven by physics is off the table for as long as the multiplayer guarantee stands, and that constraint arrived with the dependency rather than being decided later.

PeerJS buys browser-to-browser matches with no game server to pay for or operate, and charges the entire determinism apparatus. With no authoritative server there is nothing to correct a client that has drifted, so every client simulates the whole match and a single differing bit anywhere is a divergence. That is why the project owns a state-hash check that plays the same game twice and a module of exactly-specified replacements for the maths functions the language declines to pin down. Peer-to-peer does not let you defer that line item.

TypeScript buys a codebase this size that stays legible as it grows, and checks none of the data. Roughly 13% of the source is unit, building, spell and tech definitions rather than logic, and a spell with the wrong radius is a perfectly valid number in a perfectly typed field. Every check on that share of the codebase is one I wrote by hand.

Two fleets trading broadsides in a storm, an ironclad closing on a goblin barge under low cloud

The simulation runs off the main thread, in a binary format

The game loop does not run on the main thread by design. A simulation worker runs the whole tick and publishes render state through a typed-array protocol, which is a schema describing the byte layout of every field the renderer reads. The main thread reads that on its 60 Hz animation frame and interpolates between snapshots, so 20 updates a second come out as smooth motion.

The shape of that schema is the part that transfers to other projects. Rather than one wide record per entity, it splits in two: a hot core that every renderer reads on every frame, carrying id, position, owner, type, flags, health and buff masks, and side tables addressed by a sparse id-to-index map for fields that only some kinds of entity have. Build progress and rally points belong to buildings. Gatherer state, projectile fuses and summon lifetimes belong to three other groups. One wide record would spend most of the buffer on entities that have no build progress and no mana, and worse, it would tie the renderer's hot loop to whether a component happens to exist.

What that cost is a second serialisation format with its own versioning discipline, now eight revisions in, where every renderer is a consumer that breaks quietly if a field moves. The phased migration it belongs to is running the simulation off the main thread.

Skaven-style ratmen pouring out of a stone tunnel mouth lit with green warp lightning, a rat ogre leading them, one human shieldman braced alone against the charge

248 tests, no test framework, and the measurement that drifted

There is no Jest, no Vitest, no runner, no config file and no describe block anywhere in the repository. A test is a standalone tsx script with its run line written in the file header, and it imports the real engine types directly, with no mocking layer between the test and the thing under test.

There are 248 of them, and the counting rule matters enough to write out, because the number moves with the rule. That figure is files tracked in git whose name ends in .test.ts, .test.tsx or .test.js, across the whole repository. Restrict it to the game engine and the answer is 226, because the React components, the lab pages and the asset tools carry tests too. Two defensible rules give two different numbers, so the count only means anything quoted alongside the rule that produced it.

Wired into package.json are four test commands, covering determinism, match-start spawns, registration drift and dungeon generation, plus the performance family. Everything else runs when I remember to run it.

That bill came due once, in the failure mode this approach actually has. The headless runner used for benchmarking registered a different set of systems than the real game did. It had fallen about twenty systems behind, and where it did not omit a system outright it quietly changed one: movement ran with no flying occupancy map, auras with no spatial grid. Every measurement taken through that runner was therefore a measurement of a slightly different game, and nothing reported it, because a script that nothing runs on a schedule cannot tell you it has drifted.

It was caught by an arithmetic contradiction rather than by a test. During the eight-player performance work, putting the missing systems back made the mean tick time fall, which is the wrong direction for adding work. A number moving the wrong way is the only alarm this setup has. The fix is now one of those four wired commands: it reads all three registration lists out of the source and fails loudly if the shipped game, the worker and the benchmark runner stop agreeing. The broader case for and against the whole approach is testing a game without a test framework.

Tailwind v4 has no config file, so the theme is not where you will look

Tailwind v4 moves configuration out of JavaScript and into CSS. This project has no tailwind.config.js, no tailwind.config.ts and no tailwind.config.mjs, because it does not need one. The PostCSS plugin is the entire build integration, and the theme is an @theme inline block at the top of the global stylesheet, declaring the panel colours, the dark background and the gold that the whole interface is built from.

That is genuinely less machinery. The cost is discoverability, and it is not theoretical. Every piece of Tailwind knowledge in circulation says the theme is in a config file at the repository root. Here there is no such file, so the honest answer to "where do I change the gold" is a place that searching for tailwind.config will never find. The only mitigation is to write it down, which is why it sits in the answers at the top of this post.

Tauri adds a second build target that constrains the first

The desktop build is Tauri 2, and it is not a separate application. It is the same Next.js app, statically exported, chosen by an environment variable the build reads. Static export removes server-rendered routes, API routes and custom headers, so the config has to guard its headers block, the one that sets cache control on the asset path, because that mechanism does not exist in an exported build. Serwist is switched off under Tauri as well, since a service worker inside a native shell solves nothing.

The real price sits upstream of all of that. Every feature added to the web app has to be checked against a target that has no server, which quietly rules out a category of designs before they are ever proposed. On the shipping side, the Apple-silicon Mac build is published, and the download copy on the site is derived from a flag listing which binaries actually exist, so the page can only offer a build that is really there. Each remaining platform is built on a machine of its own kind, because release artefacts exceed GitHub's 2 GB per-file limit and CI uploads fail on them.

Two dependencies that are here because something broke

sharp runs the whole 2D asset pipeline: atlas packing, an AVIF-first smallest-wins optimiser, and content-hash manifests for cache busting. Those manifests are committed to the repository rather than generated at deploy time, because the build cannot see assets that live only in the CDN bucket, and a build that cannot see them serves the wrong format. Getting that wrong once took a browser tab out of memory, which is the subject of keeping a browser game inside its memory budget.

Turso is here because a client-side game with a hand-written renderer fails in ways that never reproduce on the machine it was built on. The crash reporter posts to an endpoint backed by a Turso database, which is the only reason a shader that fails to build on a player's graphics card is ever knowable. Almost everything unusual about this stack removes a layer that would otherwise have reported something: no engine, no game server, no test runner. The crash database is the one layer added back.

What is next

The simulation worker is built, the protocol is live, and a parity mode runs its world alongside the main thread's so the two can be compared directly. The work left is flipping the switch that makes the worker authoritative and re-proving every renderer against it. On the desktop side, the remaining platforms need a machine each rather than a decision each. Both jobs have the same shape: not blocked on knowing what to do, blocked on proving it did not break anything, which in a stack with no test runner is the more expensive half.

Questions

What is Shards of Stone built with?

TypeScript throughout, on Next.js 16.1.6 and React 19.2.3 for the shell and the marketing site, three.js 0.184 for the 3D renderer, and a hand-written entity-component-system engine with no game framework under it. Physics is Rapier 0.12 compiled to WebAssembly, the desktop build is Tauri 2, assets are served from Cloudflare R2, hosting is Vercel, and crash reports go to Turso.

Can you build a real-time strategy game in TypeScript?

Yes, and the language is not usually what limits you. This one runs a fixed 20-tick-per-second simulation over 1,266 TypeScript files, and the performance work that mattered was algorithmic rather than about the language: flood fills that should have been a cached component labelling, draw calls that should have been instanced. TypeScript's type system is what keeps a codebase this size legible to work in.

Does the game run in a browser?

Yes. The browser is the primary target and the whole game runs client-side, with the simulation in a Web Worker and a binary snapshot protocol carrying render state back to the main thread at 20 Hz. There is also a Tauri desktop build from the same source, which uses a static export of the Next.js app rather than a server.

How do you turn a Next.js app into a desktop app?

Tauri wraps the built frontend in a native shell with a Rust backend. The catch is that Tauri needs a static site, so the Next.js config switches to output export when the build is for Tauri, and everything that only exists in server mode is disabled in that branch: custom headers, API routes, image optimisation. That constraint shapes what the web app is allowed to depend on.

Where is the Tailwind config file in Tailwind v4?

There usually is not one. Tailwind v4 is configured in CSS rather than in JavaScript, so this project has no tailwind.config file at all and the whole theme lives in an @theme inline block at the top of the global stylesheet. It is a real simplification and a real discoverability cost, because the first place most people look no longer exists.

← All posts