Case study · Browser game
Hexfall
A turn-based hex conquest game with six modes, an economy, and a daily streak — all in one HTML file. No install, no backend. Live at vurctne.com/hexfall.
Problem
I wanted to ship a small turn-based strategy game with the constraint that anyone with a browser could play in under 2 seconds. That ruled out: any login, any download, any "loading…" screen, any framework that needs npm install on the player's side, any backend that could be down when someone clicks the link.
Hex conquest is a well-trodden genre, but the standard examples either need a server (matchmaking, persistence) or compromise on depth (one mode, no progression). The interesting question was: how much game can you fit in a single static file before you need a backend?
Solution
One HTML file, ~2,100 lines, ~120KB. React and Tailwind loaded via CDN UMD bundles. JSX compiled in the browser at runtime by @babel/standalone. Game state, AI, economy, six modes, tutorial, daily streak all in one <script type="text/babel"> block. Drop the file on any static host (Cloudflare Pages, GitHub Pages, S3) and it runs.
Six game modes baked in. Tutorial walks new players through 14 steps in ~5 minutes. Blitz is a ~3-minute single-opponent match with optional bonus goals (build 2 farms, build a tower, bank 50 gold). Campaign is a 10-stage progression. Skirmish vs AI is custom — pick map, difficulty, fog. Daily Challenge seeds the same map for every player on a given day so scores are comparable. Hot-seat is local pass-the-device multiplayer.
An economy layer runs across all modes: each tile earns 1 gold/turn; connected tiles form a "province" that shares its treasury; gold buys units (units have visible price tags ranging from 8g for the cheapest to 48g for the queen). Daily streak tracking and a Shop button hint at meta-progression layered on top.
Stack & why
- Single HTML file — the whole point. Maximum portability. The deploy unit is one file you can email or upload anywhere. Cloudflare Pages serves it from edge cache; first paint on a warm cache is <1 second.
- React 18 (UMD via CDN) — the game UI is dozens of small components driven by a central game-state object. React's reconciliation handles the redraw on every move; without it, the manual DOM updates would balloon the codebase. The CDN tradeoff: ~40KB of React loads from unpkg on first visit (cacheable across all CDN-hosted React apps), then never again.
- Tailwind via CDN — utility classes inline in JSX. The CDN script generates only the classes the page actually uses, keeping payload tiny. No PostCSS pipeline, no
tailwind.config.js. - @babel/standalone for JSX — without a bundler, the browser can't parse JSX directly. Babel compiles it on first paint. Adds ~1 second to initial load on cold cache; near-zero on revisits.
- No backend, ever — game state, daily streak, campaign progress all live in
localStorage. Daily Challenge derives its seed from the UTC date so everyone plays the same map without a server agreeing on it. Hot-seat multiplayer literally passes the device — no network involved. - SVG hexes, not canvas — each tile is a
<polygon>withpointer-eventsfor hit-testing. Easier to debug than canvas, and accessible to screen readers if you add ARIA labels later. - Zero analytics — privacy by default. The game does no network calls after the initial CDN fetches.
Architecture
Everything is one HTML file. Inside that file:
<head>
meta + OG tags + favicon (inline SVG)
Tailwind CDN script
React 18 UMD (production build)
ReactDOM 18 UMD
@babel/standalone
</head>
<body>
<div id="root"></div>
<script type="text/babel">
// ─── Constants & helpers ──────────────────────────
const HEX_DIRS = [...] // 6 axial neighbour offsets
const STARTING_GOLD, FARM_INCOME, TOWER_COST, ...
const UNIT_COSTS = { peasant: 8, spear: 18, knight: 32, queen: 48, ... }
// ─── Game logic (pure) ────────────────────────────
function captureTile(board, tile, player) { ... }
function recomputeProvinces(board) { ... } // connected-region pass
function applyEconomy(state) { ... } // per-province income
function legalMoves(board, player) { ... }
function evaluatePosition(state, player) { ... } // for AI
// ─── AI (one evaluator, three depths) ─────────────
function aiSkirmish(state, difficulty) { ... } // 1/2/3-ply minimax + heuristics
function aiCampaign(state, stage) { ... } // tuned per-stage parameters
// ─── Modes ────────────────────────────────────────
function ModeMenu() { ... } // 6-mode picker (screenshot 1)
function Tutorial() { ... } // 14-step guided walkthrough
function BlitzGame() { ... } // 3-min match + bonus goals
function CampaignStage() { ... } // 0..9 progression
function SkirmishGame() { ... } // map/difficulty/fog options
function DailyChallenge() { ... } // seeded by UTC date
function HotseatGame() { ... } // pass-the-device
// ─── Components ───────────────────────────────────
function Tile({ tile, onClick, ... }) { ... }
function Board({ state, ... }) { ... }
function HUD({ state, ... }) { ... } // unit shop + treasury
function StreakHeader({ days }) { ... } // daily streak (screenshot 1)
function App() {
const [route, setRoute] = useState('menu');
const [state, dispatch] = useReducer(gameReducer, loadFromLocalStorage());
// route between menu / tutorial / each game mode / end states
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
Hard parts
One AI evaluator, two product surfaces
Skirmish vs AI uses three difficulties from a single evaluatePosition() function — only the search depth (1/2/3-ply minimax) and bonus weights (board control, multi-tile capture threats, denying merges) differ. Campaign reuses the same evaluator but adds per-stage parameters: stage 0 is essentially Easy with a small map; stage 9 is Hard with a 4-bot free-for-all. Adding "Insane" Skirmish mode later is two lines of config; adding stage 10 is one line. The pure-function evaluator is the load-bearing abstraction.
Provinces (connected-region accounting)
Connected friendly tiles share a treasury. When you capture a tile that bridges two of your provinces, they merge — and the merged treasury is the sum. When you lose a tile that splits a province in two, both halves keep proportional shares. Implementing this correctly required a flood-fill pass after every move, with the right ordering for capture → merge → income calculation. Off-by-one bugs in the merge logic produced subtle "where did my gold go" complaints during playtesting; a unit test against snapshot board states caught the regressions.
Mobile-first hex math
Hex grids on mobile screens are unforgiving — finger taps need to hit the right hex when hexes are 40px wide. The board uses axial coordinates for game logic (q, r, with a derived s) and SVG <polygon> for rendering. A few Android browsers trigger ghost double-clicks on tap; -webkit-tap-highlight-color: transparent + touch-action: manipulation kills those. The viewport uses 100dvh (not 100vh) to handle the iOS Safari bottom-bar bounce.
Daily Challenge replay-resistance & streak tracking
Daily seed = UTC date. Same date generates the same map. Every player gets the same starting position, so scores are honestly comparable even without a server leaderboard. The seed feeds mulberry32 for procedural map generation. Streak tracking lives in localStorage: it advances if you've played on the previous UTC day, breaks otherwise. Edge case I had to debug: timezone changes (e.g., flying overseas) can produce ambiguous "did I play yesterday?" — resolved by using a stable UTC anchor and not the player's local date.
Result
- Live at vurctne.com/hexfall — embedded under the Vurctne Games studio site
- One HTML file, ~2,100 lines, ~120KB — six game modes, tutorial, economy, daily streak
- Zero ongoing cost — no backend, no analytics, no servers; localStorage for persistence
- Deploys anywhere static — moved between three hosts during testing without changing a line
- Tutorial completion → first competitive match in <10 minutes (designed cold-start path)
What I'd do differently
Babel-in-browser was the right call for "I want to write JSX without a build step", but it costs ~1 second of compile time on first paint. For a player visiting from cold cache, that's a long time to look at a blank screen. If I rebuilt today I'd either (a) write the JSX as plain React.createElement() calls (uglier source, instant first paint) or (b) precompile the JSX once with esbuild on commit and ship the compiled JS inline — still one file, no Babel runtime. Option (b) is ~30 minutes of tooling I'd happily pay now.
Second thing: I built six modes before I had any analytics or playtest data. If I were starting over, I'd ship Tutorial + Blitz + one AI difficulty as v1, watch what players actually replay, then add Campaign / Skirmish / Daily / Hot-seat in priority order. Two of the six modes might not be earning their keep.
Screenshots
Try it
Live demo: vurctne.com/hexfall — works in any modern browser, no install, ~3 second cold start. Try the Daily Challenge first; everyone gets the same map today, so you can compare scores with anyone who plays.