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.

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
| Layer | Choice | Version |
|---|---|---|
| Framework | Next.js | 16.1.6 |
| UI | React and React DOM | 19.2.3 |
| Language | TypeScript | 5 |
| 3D | three.js | 0.184 |
| Physics | @dimforge/rapier3d-compat | 0.12 |
| Desktop | Tauri | 2.10 |
| CSS | Tailwind | 4 |
| Service worker | Serwist | 9.5.7 |
| Images | sharp | 0.34.5 |
| Networking | PeerJS | 1.5.5 |
| Crash reports | Turso via @libsql/client | 0.17 |
| Test runner | tsx | 4.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.

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.

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.





