A turn-based hex-grid strategy game for 2–4 players (human and/or AI). Players compete to conquer a map of hexagonal territories by expanding their armies and attacking neighbours. Last faction standing (or holder of all capitals in Capital Mode) wins.
Target platform: Android (via Capacitor). Runs in any modern browser during development. Tech stack: HTML Canvas + vanilla JavaScript + plain CSS.
The UI is split into two layers inside a shared position: relative container:
- Canvas (
z-index: 0) — hex grid only: tiles, unit counts, selection arrows, tile-type icons, highlights. - HTML/CSS layer (
z-index: 1,pointer-events: none) — everything else: top bar, the Units modal, and the setup screen. Individual interactive elements within this layer re-enablepointer-events: auto.
- A grid of hexagonal tiles, flat-top.
- Map sizes: Small, Normal, Big, Huge.
- Maps can be symmetric (mirrored layout for fairness).
- Some tiles may be inaccessible (impassable holes in the map shape).
- Each tile has an owner (a player, or neutral) and a unit count.
- Pan: drag anywhere on the canvas to scroll the map.
- Zoom: pinch (touchscreen) or scroll wheel (mouse/trackpad) to zoom in or out.
| Type | Peak population | Max gain/turn | Other effect |
|---|---|---|---|
| Plain | 50 | 5 | — |
| Bronze | 45 | 8 | — |
| Silver | 40 | 12 | — |
| Gold | 35 | 18 | — |
| Fortified | 60 | 3 | Units here get a combat defence bonus |
| Spyglass | 50 | 5 | Reveals additional tiles in Fog of War mode |
| Capital | 30 | 22 | See Capital tiles section; losing it = instant elimination |
Per-tile growth parameters are defined in js/config.js (TILE_GROWTH, CAPITAL_GROWTH).
When Capital Mode is enabled, each player starts with one designated capital tile. Capital tiles are not placed when Capital Mode is disabled.
- A capital tile has its own growth curve (
CAPITAL_GROWTHinjs/config.js), configured for faster early growth than Gold. - If a player's capital tile is captured, that player is immediately eliminated, regardless of how many other tiles they hold.
- 2–4 players; each player is a colour (Blue, Red, Orange, Green) — all colours must be unique.
- Each player can be Human or AI (at least one difficulty level: Normal).
Each turn, in order:
- Income phase — every owned tile grows automatically. Each tile briefly shows its gain.
- Queue phase — auto-send defaults are loaded into the move queue. The player reviews them, adds more moves, or cancels any queued move.
- End Turn — player clicks "End Turn". All queued moves execute in order, then the next player's turn begins.
Growth follows a bell-curve (tent function). Each tile type has two parameters:
peak (population with highest growth) and maxGain (units gained per turn at that peak).
# a = current units, p = peak, g = maxGain, b = GROWTH_SOFT_CAP
if a <= 1: gain = 0
elif a <= p: gain = max(1, floor(g * (a - 1) / (p - 1)))
elif a <= b: gain = floor(g * (b - a) / (b - p))
else (a > b): gain = -ceil(g * (a - b) / (b - p)) # shrinks
newUnits = max(1, units + gain)
Key properties:
- Populations of 0 or 1 do not grow ("need 2 to reproduce").
- Growth is always at least 1 for populations between 2 and
peak. - Growth tapers to 0 at
GROWTH_SOFT_CAP(default 120). - Populations above the soft-cap shrink symmetrically back toward it.
- There is no hard tile cap — the soft-cap is the natural limiter.
| Parameter | Default | Notes |
|---|---|---|
GROWTH_SOFT_CAP |
120 |
Soft-cap; above this, tiles shrink |
TILE_GROWTH[type].peak |
See tile table | Population at which gain is highest |
TILE_GROWTH[type].maxGain |
See tile table | Units gained per turn at the peak |
| (fixed income mode) | — | Overrides formula: every tile gains exactly 1/turn |
During the income phase each tile briefly displays its gain (e.g. +3) before the
number updates. Shrinking tiles show a red -N overlay.
A tile at 0 or 1 units gains nothing and shows no overlay.
- Player taps one of their own tiles.
- Directional arrows appear on all neighbouring tiles (except inaccessible tiles).
- Player taps a target tile — the Move dialog opens regardless of whether the target is friendly, neutral, or enemy.
- In the Move dialog the player picks how many units to send (slider, range 0 – N).
- Sending 0 cancels / removes an existing queued move for that direction.
- For neutral/enemy targets, the dialog shows the probability of winning, factoring in any units already queued from other tiles against the same target.
- For friendly targets, no win probability is shown — units simply transfer.
- An optional default value can be set: the source tile will auto-send that many units to that direction every turn (added to the queue, not executed immediately).
- On confirmation the move is queued, not executed immediately. The source tile's arrow remains visible with the queued unit count shown between the tile centre and the arrow tip.
- All queued moves (manual and auto-default) execute together when the player presses End Turn.
- Moves accumulate during the action phase and are visible as annotated arrows on the canvas.
- A tile can have multiple queued outgoing moves (one per direction).
- To undo a move: tap the same source → target pair and set the slider to 0.
- Auto-send defaults are loaded into the queue at the start of the action phase (after income), so the player can review or cancel them before End Turn.
- Committed units: queued outgoing units are treated as already spent. A tile's available units =
tile.units − sum of all queued outgoing from that tile. The canvas displays the available count (not the raw count), and the Move dialog's send slider is capped at the available units. This means a tile can never over-commit. - A tile may be reduced to 0 available units. It remains claimed by its owner and the display shows "0", but it gains no income that turn.
- Combined attacks: multiple tiles may queue moves against the same target. On execution they are grouped and resolved as a single battle — all attacking units are summed into one combined force, one pair of rolls is made, and survivors all go to the captured tile. Source tiles have their sent units deducted before the roll regardless of outcome.
- Attacker sends N units; tile retains the remainder.
- If attacker wins: tile is captured with surviving attacker units.
- If attacker loses: all sent units are lost; defender retains survivors.
roll() = 1 + luck * (2 * random() - 1) // uniform on [1-luck, 1+luck]
scoreA = N * roll()
scoreB = D * roll() * fortifiedMult // fortifiedMult = 1.5 if defender on Fortified tile, else 1.0
aWins = scoreA > scoreB
Survivors (winner retains a fraction proportional to how dominant the win was):
if aWins: survivors = max(1, round(N * (scoreA - scoreB) / scoreA))
else: survivors = max(1, round(D * (scoreB - scoreA) / scoreB))
All tunable balance constants live in js/config.js. See that file for comments and expected ranges.
| Parameter | Default | Effect |
|---|---|---|
luck |
0.5 |
0 = fully deterministic; 1 = maximum randomness |
fortifiedMult |
1.5 |
Defensive multiplier for Fortified tiles |
Computed via Monte Carlo (1,000 iterations) before the player commits.
| Setting | Description |
|---|---|
| Capital Mode | Each player gets a capital tile (Gold-rate growth). Capturing it eliminates that player immediately. No capitals without this mode. |
| Fog of War | Only tiles adjacent to your own are visible; Spyglass tiles expand vision |
| Remove luck factor | Deterministic combat |
| Gain fixed amount every turn | All tiles give exactly 1 unit/turn regardless of type |
| Set max to 99 | Unit counts are capped at 99 |
- Toggle inaccessible tiles (adds holes).
- Toggle symmetric map.
- Map size selector: Small / Normal / Big / Huge.
All screens are designed mobile-first for portrait orientation. Touch targets are at least 44×44 px.
┌─────────────────────────────┐
│ │
│ < hex grid > │ ← Canvas, fills remaining height
│ │
├─────────────────────────────┤
│ [✓ End Turn] turn label [≡] │ ← bottom bar, full width
└─────────────────────────────┘
- Bottom bar: "End turn" (left, disabled when it's not the human player's turn), current turn label (centre), Menu hamburger ≡ (right, toggles menu; clicking ≡ again or tapping the canvas closes it — no separate close button). Pinned to the bottom of the viewport. Game settings are setup-only — they cannot be changed once a game is in progress.
- Background: Sandy/parchment textured fill behind the grid.
- Tiles: Coloured by owner. Neutral tiles are gray. Number shows unit count. Special tile icons overlaid on the hex.
- Selected tile state: directional arrows on neighbours; outgoing unit counts shown.
- Attacked/targeted tiles: highlighted with a red border during selection.
- Queued moves: pending arrows remain on the canvas after the dialog closes, with the queued unit count displayed between the source tile centre and the arrow tip.
Full-width bottom sheet on mobile (slides up from bottom edge).
┌─────────────────────────────┐
│ Move │
│ How many to send? │
│ │
│ Win chance: 91.6% ████░ │ ← omitted for friendly targets
│ │
│ ──────────(42)────────── │ ← send slider (0 → N); thumb shows value
│ 0 N │
│ │
│ Default (auto every turn): │
│ ──(0)────────────────── │ ← default slider (0 → 99); thumb shows value
│ 0 99 │
│ │
│ [ Cancel ] [ Ok ] │
└─────────────────────────────┘
- Send range is 0 – N (all units). Sending 0 removes any existing queued move for this direction.
- Default of 0 = no auto-send; default of 99 = send all available units every turn.
- If the auto-send default exceeds the tile's available units at the start of a turn, it is clamped to the tile's actual unit count (never over-commit).
- Slider thumbs display their current value. Sliders are styled to be thick/tall enough for comfortable tap targets on mobile.
- Dismissing the dialog by tapping outside (deselecting) is equivalent to Cancel.
Scrollable single-column layout with always-visible sections. A sticky Start Game button sits at the bottom of the viewport.
┌─────────────────────────────┐
│ Players │ ← section header (plain, not interactive)
│ ┌─────────────────────┐ │
│ │ 🔵 Human [–] │ │ ← player row: colour swatch, type, remove
│ │ 🔴 CPU Normal [–] │ │
│ │ [+ Add player] │ │ ← hidden when 4 players present
│ └─────────────────────┘ │
├─────────────────────────────┤
│ Map │
│ [ Small ] [Normal] [ Big ] │
│ [ Huge ] │
│ ☐ Symmetric map │ ← default off
│ ☑ Inaccessible tiles │ ← default on
├─────────────────────────────┤
│ Rules │
│ ☑ Capital mode │ ← default on
│ …other toggles… │
├─────────────────────────────┤
│ │
│ [ Start Game ] │ ← sticky footer button
└─────────────────────────────┘
Players (expanded by default): colour swatch (tap to cycle) + Human/CPU toggle + remove button per player; "Add player" up to 4.
Map: size selector (Small/Normal/Big/Huge) with live mini hex preview; toggles for Symmetric map and Inaccessible tiles.
Rules: toggles for Capital Mode, Fog of War, Remove luck factor, Fixed income; number input for tile cap (default 99).
- At minimum one difficulty: Normal.
- AI takes its turn automatically after a short delay so the human can follow what happened.
- AI decision-making (to be designed in detail later):
- Prioritises attacking weak neighbours.
- Protects high-value tiles (Gold, capital).
- Uses auto-send defaults for stable fronts.
- Multiplayer over a network.
- Map editor.
- Saving/loading mid-game state.
- Sound / music.
- Animations beyond basic visual feedback.
- Cap emigration: tiles that have reached the cap continue to grow, but the surplus units automatically spill over into neighbouring friendly tiles instead of being lost.
- CSS framework (e.g. Bootstrap): swap plain CSS for a framework to get polished UI components and built-in features like light/dark mode switching with minimal custom CSS.
- How many AI difficulty levels for v1?
- Should Fog of War be in scope for v1?