← All projects

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.

Hexfall
Stack
HTML · React 18 (UMD via CDN) · Tailwind (CDN) · @babel/standalone (runtime JSX)
Status
Live · single ~2,100-line HTML file
Live
vurctne.com/hexfall · github.com/Vurctne/hexfall
Role
Solo. Game design, AI, art (SVG), code, ship.
Modes
Tutorial · Blitz · Campaign · Skirmish vs AI · Daily Challenge · Hot-seat
Platform
Mobile-first (viewport-locked, dvh units, no overscroll); works on any modern browser

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

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

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

Hexfall mode-select screen — Hexfall logo with crossed swords, gold counter, Shop button, Daily streak progress, six game-mode buttons (Play Tutorial, Blitz, Campaign, Skirmish vs AI, Daily Challenge, Hot-seat), and stats footer
Mode select. Six entry points + daily streak tracking + shop + stats footer (Plays · Wins · Current streak). Mobile-portrait layout.
Hexfall tutorial step 1 of 14 — Welcome card with goal text, behind a hex board showing red player territory expanding into the upper-right and blue player territory in the lower-left, with capital tiles holding 50 gold each
Tutorial step 1 of 14. Welcome card overlays the live board so players see what they're about to control. Total tutorial walkthrough is ~5 minutes.
Hexfall tutorial step 2 of 14 — Economy explanation card describing how connected tiles form a province that shares gold, with the active board below and a HUD bar at the bottom showing unit price tags (8, 18, 32, 48, 18, 38) plus a Farm +4 income tile
Tutorial step 2. Province + economy explanation, with the unit-purchase HUD live at the bottom — gold prices visible, farm income preview shown.
Hexfall Blitz mode mid-match — three bonus goal cards at top (Build 2 farms 0/2 +20, Build a tower 0/1 +10, Bank 50 gold 0/50 +15), with red and blue territories spreading on a larger procedural hex map dotted with tree tiles, and a hint at the bottom 'Tap a tile. Tap your unit then a highlighted tile to move'
Blitz mode in progress. Three bonus-goal cards at top with progress trackers and bonus values. Larger procedural map with tree tiles. Bottom hint guides first-time Blitz players.

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.