diff --git a/README.md b/README.md
index ebe2e6b..ee51a2f 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,22 @@ would not be.
**Cartoony.** Flat cel fills and one heavy outline, the same house style the
other three games use, on a map generated from the topology in
-`src/game/map.ts` rather than traced from anybody's board.
+`src/game/map.ts` rather than traced from anybody's board. The coordinates in
+`src/game/layout.ts` are placed by hand -- roughly where each province falls
+in Europe -- and everything else is drawn from those and from the adjacency
+rules.
+
+**It draws the graph, not regions, and that was not the first idea.** The
+first idea was Voronoi cells: every point on the board belonging to the
+nearest province, which gives a handsome cut-paper board. It is also wrong.
+Sixty-five pairs that border each other in the rules came out with regions
+that did not touch, and no amount of moving coordinates would fix it, because
+the fault is structural -- provinces here interleave. The Adriatic borders
+Venice with Trieste sitting between their centres; the Atlantic borders North
+Africa around the outside of Spain. Convex cells cannot say that, and a map
+that shows two regions meeting when the rules say they do not is worse than
+an ugly map. It is a map that loses you the game. So adjacency is drawn as
+lines, which say exactly what the rules say and cannot be misread.
**It gets the whole screen.** The other three games live in a four-by-three
cabinet because the machines they are rebuilding did. This one is not
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..dcc2887
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Great Powers — the classic negotiation game, in your browser
+
+
+
+
+
+
+
diff --git a/src/App.css b/src/App.css
new file mode 100644
index 0000000..5ea919b
--- /dev/null
+++ b/src/App.css
@@ -0,0 +1,133 @@
+/* =====================================================================
+ Great Powers.
+
+ Cartoony: flat fills, one heavy outline on everything, no gradients
+ anywhere near the board. And it takes the whole window -- the other games
+ on this site live in a four-by-three cabinet because the machines they
+ rebuild did, and this one is not rebuilding a machine.
+ ===================================================================== */
+
+.app {
+ height: 100%;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 260px;
+ grid-template-rows: auto minmax(0, 1fr);
+ grid-template-areas: 'head head' 'map side';
+ gap: 10px 18px;
+ padding: clamp(10px, 2vw, 22px);
+}
+
+header { grid-area: head; }
+
+h1 {
+ margin: 0;
+ font-size: clamp(1.4rem, 3.2vw, 2.2rem);
+ font-weight: 900;
+ letter-spacing: -0.02em;
+ color: #ffd479;
+ -webkit-text-stroke: 3px #241a10;
+ paint-order: stroke fill;
+}
+
+header p { margin: 2px 0 0; font-size: 0.9rem; }
+
+.dim { color: #a3b3c9; }
+
+.map-wrap {
+ grid-area: map;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.board {
+ width: 100%;
+ height: 100%;
+ border: 3px solid #241a10;
+ border-radius: 16px;
+ background: #6ea3c9;
+}
+
+.sea-bed { fill: #6ea3c9; }
+
+.edges line {
+ stroke: #241a10;
+ stroke-width: 2;
+ opacity: 0.28;
+}
+
+.province rect,
+.province circle {
+ stroke: #241a10;
+ stroke-width: 3;
+ cursor: pointer;
+}
+
+.province.sea circle { stroke-opacity: 0.55; }
+
+.province .pip {
+ fill: #fff6e0;
+ stroke: #241a10;
+ stroke-width: 2;
+}
+
+.province .label {
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ text-anchor: middle;
+ pointer-events: none;
+}
+
+.province.picked rect,
+.province.picked circle {
+ stroke: #ffd479;
+ stroke-width: 5;
+}
+
+.unit path {
+ stroke: #241a10;
+ stroke-width: 2.5;
+ stroke-linejoin: round;
+}
+
+/* --- the side ------------------------------------------------------- */
+
+.side {
+ grid-area: side;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.powers { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 5px; }
+
+.powers li {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ font-weight: 700;
+ font-size: 0.92rem;
+}
+
+.swatch {
+ width: 15px;
+ height: 15px;
+ border: 2px solid #241a10;
+ border-radius: 4px;
+}
+
+.count { margin-left: auto; font-variant-numeric: tabular-nums; color: #a3b3c9; }
+
+.picked h2 { margin: 0 0 2px; font-size: 1rem; }
+.picked p { margin: 0; font-size: 0.86rem; line-height: 1.45; }
+
+@media (max-width: 780px) {
+ .app {
+ grid-template-columns: 1fr;
+ grid-template-areas: 'head' 'map' 'side';
+ }
+}
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..6029bb9
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,72 @@
+import { useMemo, useState } from 'react'
+import { Board, COLOURS, POWER_NAMES } from './components/Board'
+import { OPENING, POWERS, PROVINCES } from './game/map'
+import { boardFrom, type Unit } from './game/orders'
+import { centreCount, openingOwnership } from './game/turn'
+import './App.css'
+
+/**
+ * The opening position, on the board, so the map can be looked at.
+ *
+ * This is scaffolding: order entry, the press and the turn loop are not
+ * wired to it yet. What it is for is seeing whether seventy-five provinces
+ * and twenty-two units are legible at a glance, which is a question no test
+ * can answer.
+ */
+export default function App() {
+ const [picked, setPicked] = useState(null)
+
+ const units = useMemo(() => {
+ const all: Unit[] = []
+ for (const power of POWERS) {
+ for (const at of OPENING[power].armies) all.push({ power, type: 'army', at })
+ for (const at of OPENING[power].fleets) all.push({ power, type: 'fleet', at })
+ }
+ return boardFrom(all)
+ }, [])
+
+ const own = useMemo(openingOwnership, [])
+
+ return (
+
+
+
Great Powers
+
Spring 1901 — the board before anybody has said anything.
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/Board.tsx b/src/components/Board.tsx
new file mode 100644
index 0000000..d7ca376
--- /dev/null
+++ b/src/components/Board.tsx
@@ -0,0 +1,147 @@
+import { PROVINCES, POWERS, base, type Power } from '../game/map'
+import { CENTRES, borders, bounds, radius } from '../game/layout'
+import type { Board as Units } from '../game/orders'
+import type { Ownership } from '../game/turn'
+
+/**
+ * The board.
+ *
+ * Everything is drawn from `layout.ts` and the rules, so nothing here is a
+ * picture of anybody's map. Flat fills, one heavy outline, no gradients: the
+ * same cel style the other three games use, on a board that has to stay
+ * readable at a glance while somebody is arguing with you about Galicia.
+ *
+ * Borders are lines because in this game the adjacency *is* the rules. A
+ * region map has to decide whether two shapes touch, and when it gets that
+ * wrong it costs somebody the game. A line cannot be misread.
+ */
+
+export const COLOURS: Record = {
+ austria: '#e4572e',
+ england: '#3b5bdb',
+ france: '#4dabf7',
+ germany: '#495057',
+ italy: '#37b24d',
+ russia: '#9775fa',
+ turkey: '#f59f00',
+}
+
+const SEA = '#4a7fa8'
+const LAND = '#e8d9b5'
+const NEUTRAL_SC = '#fff6e0'
+const OUTLINE = '#241a10'
+
+export function Board({
+ units,
+ own,
+ selected,
+ onPick,
+}: {
+ units: Units
+ own: Ownership
+ selected?: string | null
+ onPick?: (province: string) => void
+}) {
+ const edges = borders()
+ const box = bounds()
+
+ return (
+
+ )
+}
+
+/** Dark text on a light province, light on a dark one. */
+function labelInk(background: string): string {
+ const n = parseInt(background.slice(1), 16)
+ const lum = (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000
+ return lum > 140 ? OUTLINE : '#fff6e0'
+}
+
+export const POWER_NAMES: Record = {
+ austria: 'Austria',
+ england: 'England',
+ france: 'France',
+ germany: 'Germany',
+ italy: 'Italy',
+ russia: 'Russia',
+ turkey: 'Turkey',
+}
+
+export { POWERS }
diff --git a/src/game/layout.test.ts b/src/game/layout.test.ts
new file mode 100644
index 0000000..cff71a9
--- /dev/null
+++ b/src/game/layout.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest'
+import { ARMY, FLEET, PROVINCES, base } from './map'
+import { CENTRES, HEIGHT, WIDTH, borders, radius } from './layout'
+
+/**
+ * Coordinates typed by hand are wrong somewhere, and a map is the one thing
+ * where wrong looks like a style choice. These are the checks that turn
+ * "Bulgaria seems to be in the North Sea" into a failing test.
+ */
+
+const ids = Object.keys(PROVINCES)
+const dist = (a: string, b: string) =>
+ Math.hypot(CENTRES[a]!.x - CENTRES[b]!.x, CENTRES[a]!.y - CENTRES[b]!.y)
+
+describe('the placings', () => {
+ it('has a spot for every province and nothing else', () => {
+ expect(Object.keys(CENTRES).sort()).toEqual(ids.sort())
+ })
+
+ it('keeps them all on the board', () => {
+ for (const [id, p] of Object.entries(CENTRES)) {
+ expect(p.x, id).toBeGreaterThan(0)
+ expect(p.x, id).toBeLessThan(WIDTH)
+ expect(p.y, id).toBeGreaterThan(0)
+ expect(p.y, id).toBeLessThan(HEIGHT)
+ }
+ })
+
+ it('keeps them far enough apart to be separate places', () => {
+ const close: string[] = []
+ for (const a of ids) {
+ for (const b of ids) {
+ if (a >= b) continue
+ if (dist(a, b) < 24) close.push(`${a}/${b} at ${dist(a, b).toFixed(0)}`)
+ }
+ }
+ expect(close).toEqual([])
+ })
+})
+
+describe('the geography agrees with the rules', () => {
+ const edges = borders()
+
+ it('draws a line for every border in the rules, and no others', () => {
+ const expected = new Set()
+ for (const id of ids) {
+ for (const to of ARMY[id] ?? []) if (base(to) !== id) expected.add([id, base(to)].sort().join(':'))
+ const keys = PROVINCES[id]!.coasts ? PROVINCES[id]!.coasts!.map((c) => `${id}/${c}`) : [id]
+ for (const key of keys) {
+ for (const to of FLEET[key] ?? []) if (base(to) !== id) expected.add([id, base(to)].sort().join(':'))
+ }
+ }
+ expect(new Set(edges.map((e) => e.join(':')))).toEqual(expected)
+ })
+
+ it('never draws the same border twice', () => {
+ expect(new Set(edges.map((e) => e.join(':'))).size).toBe(edges.length)
+ })
+
+ /**
+ * A smoke test for a province placed in the wrong country, and nothing
+ * more ambitious than that. The threshold is loose because provinces are
+ * not the same size: Moscow borders five things and is enormous, Norway
+ * reaches St Petersburg across the top of Finland, and North Africa runs
+ * the length of the Mediterranean. Tightening this until those failed
+ * would be measuring Europe rather than checking my typing.
+ */
+ it('does not place a province in the wrong part of the continent', () => {
+ const far: string[] = []
+ for (const [a, b] of edges) {
+ if (PROVINCES[a]!.terrain === 'sea' || PROVINCES[b]!.terrain === 'sea') continue
+ if (dist(a, b) > 250) far.push(`${a}-${b} at ${dist(a, b).toFixed(0)}`)
+ }
+ expect(far).toEqual([])
+ })
+
+ it('puts the seas where the water is', () => {
+ // The Atlantic west of Portugal, the Black Sea east of Bulgaria, and
+ // Norway north of Spain. Three facts that would survive any redrawing.
+ expect(CENTRES.mao!.x).toBeLessThan(CENTRES.por!.x)
+ expect(CENTRES.bla!.x).toBeGreaterThan(CENTRES.bul!.x)
+ expect(CENTRES.nwy!.y).toBeLessThan(CENTRES.spa!.y)
+ })
+})
+
+describe('the drawing', () => {
+ it('never lets two provinces overlap on the board', () => {
+ const overlapping: string[] = []
+ for (const a of ids) {
+ for (const b of ids) {
+ if (a >= b) continue
+ if (dist(a, b) < radius(a) + radius(b) - 12) overlapping.push(`${a}/${b}`)
+ }
+ }
+ expect(overlapping).toEqual([])
+ })
+})
diff --git a/src/game/layout.ts b/src/game/layout.ts
new file mode 100644
index 0000000..c7e5994
--- /dev/null
+++ b/src/game/layout.ts
@@ -0,0 +1,187 @@
+import { ARMY, FLEET, PROVINCES, base } from './map'
+
+/**
+ * Where everything sits, and how the regions are drawn.
+ *
+ * The board this game is played on is somebody's artwork and is not here.
+ * What is here is a set of coordinates I placed by hand -- roughly where each
+ * province falls in Europe, in a thousand by eight hundred box -- and the map
+ * is drawn from those and from the adjacency rules. It is a drawing of the
+ * data rather than a tracing of a board, the same rule the cave game's
+ * dodecahedron is drawn under.
+ *
+ * **It draws the graph, not regions, and that was not the first idea.**
+ *
+ * The first idea was Voronoi cells -- every point on the board belonging to
+ * the nearest province -- which gives a handsome cut-paper board and is
+ * wrong. Sixty-five pairs that border each other in the rules had regions
+ * that did not touch, and no amount of nudging coordinates would fix it,
+ * because the fault is structural: provinces here interleave. The Adriatic
+ * borders Venice with Trieste sitting between their centres; the Atlantic
+ * borders North Africa around the outside of Spain. Convex cells cannot say
+ * that.
+ *
+ * A map that shows two regions meeting when the rules say they do not is
+ * worse than an ugly map. It is a map that loses you the game. So the
+ * adjacency is drawn as lines, which can say exactly what the rules say, and
+ * the province is a shape sitting on top of it. Nobody can misread a line.
+ */
+
+export const WIDTH = 1000
+export const HEIGHT = 800
+
+export interface Point {
+ x: number
+ y: number
+}
+
+/**
+ * The centres, west to east and north to south. Placed by eye against an
+ * atlas: near enough that a player recognises Europe, and not a copy of
+ * anybody's board.
+ */
+export const CENTRES: Record = {
+ // --- the ocean and the northern seas ---
+ nao: { x: 60, y: 120 },
+ nwg: { x: 250, y: 80 },
+ bar: { x: 520, y: 40 },
+ iri: { x: 160, y: 300 },
+ nth: { x: 315, y: 235 },
+ ska: { x: 395, y: 195 },
+ hel: { x: 352, y: 265 },
+ bal: { x: 470, y: 235 },
+ bot: { x: 505, y: 155 },
+ eng: { x: 215, y: 375 },
+ mao: { x: 75, y: 400 },
+
+ // --- the British Isles ---
+ cly: { x: 215, y: 215 },
+ edi: { x: 250, y: 240 },
+ lvp: { x: 210, y: 275 },
+ yor: { x: 258, y: 288 },
+ wal: { x: 200, y: 318 },
+ lon: { x: 258, y: 332 },
+
+ // --- Scandinavia and the north ---
+ nwy: { x: 390, y: 130 },
+ swe: { x: 445, y: 140 },
+ fin: { x: 520, y: 100 },
+ stp: { x: 625, y: 90 },
+ den: { x: 398, y: 248 },
+
+ // --- Russia ---
+ lvn: { x: 560, y: 190 },
+ mos: { x: 690, y: 200 },
+ war: { x: 545, y: 278 },
+ ukr: { x: 620, y: 320 },
+ sev: { x: 700, y: 355 },
+
+ // --- Germany and the Low Countries ---
+ pru: { x: 483, y: 268 },
+ ber: { x: 432, y: 292 },
+ kie: { x: 390, y: 286 },
+ ruh: { x: 363, y: 332 },
+ mun: { x: 392, y: 368 },
+ sil: { x: 468, y: 322 },
+ hol: { x: 330, y: 300 },
+ bel: { x: 300, y: 345 },
+
+ // --- France and Iberia ---
+ pic: { x: 280, y: 376 },
+ par: { x: 275, y: 418 },
+ bre: { x: 208, y: 418 },
+ bur: { x: 328, y: 404 },
+ gas: { x: 248, y: 462 },
+ mar: { x: 312, y: 482 },
+ spa: { x: 182, y: 512 },
+ por: { x: 115, y: 522 },
+
+ // --- Italy ---
+ pie: { x: 372, y: 456 },
+ ven: { x: 408, y: 448 },
+ tus: { x: 396, y: 492 },
+ rom: { x: 420, y: 524 },
+ nap: { x: 458, y: 562 },
+ apu: { x: 468, y: 522 },
+
+ // --- Austria and the Balkans ---
+ tyr: { x: 412, y: 402 },
+ boh: { x: 442, y: 358 },
+ vie: { x: 472, y: 392 },
+ bud: { x: 518, y: 402 },
+ gal: { x: 542, y: 342 },
+ tri: { x: 452, y: 442 },
+ ser: { x: 508, y: 446 },
+ alb: { x: 500, y: 482 },
+ gre: { x: 518, y: 532 },
+ bul: { x: 566, y: 462 },
+ rum: { x: 598, y: 406 },
+
+ // --- Turkey and the Levant ---
+ con: { x: 628, y: 506 },
+ ank: { x: 702, y: 500 },
+ smy: { x: 668, y: 556 },
+ arm: { x: 765, y: 500 },
+ syr: { x: 742, y: 572 },
+
+ // --- the Mediterranean and Africa ---
+ adr: { x: 458, y: 482 },
+ ion: { x: 478, y: 610 },
+ aeg: { x: 572, y: 548 },
+ eas: { x: 655, y: 622 },
+ bla: { x: 665, y: 432 },
+ tys: { x: 412, y: 568 },
+ lyo: { x: 330, y: 532 },
+ wes: { x: 268, y: 572 },
+ naf: { x: 230, y: 640 },
+ tun: { x: 398, y: 640 },
+}
+
+/**
+ * Every pair of provinces that border each other, once each.
+ *
+ * The union of both graphs at province level: an army's border and a fleet's
+ * are different questions, but a line on the map means "these two touch", and
+ * which unit can use it is what the panel beside the map is for.
+ */
+export function borders(): [string, string][] {
+ const seen = new Set()
+ const out: [string, string][] = []
+
+ const add = (a: string, b: string) => {
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`
+ if (seen.has(key) || a === b) return
+ seen.add(key)
+ out.push(a < b ? [a, b] : [b, a])
+ }
+
+ for (const [id, tos] of Object.entries(ARMY)) for (const to of tos) add(id, base(to))
+ for (const [key, tos] of Object.entries(FLEET)) {
+ for (const to of tos) add(base(key), base(to))
+ }
+ return out
+}
+
+/** How big a province is drawn. Seas are wide and vague, land is compact. */
+export const radius = (id: string): number =>
+ PROVINCES[id]!.terrain === 'sea' ? 26 : PROVINCES[id]!.sc ? 21 : 17
+
+/**
+ * The box the board actually occupies, rather than the box the coordinates
+ * were typed into. Europe is not rectangular and the placings do not fill a
+ * thousand by eight hundred; drawing that box leaves a third of the frame as
+ * empty ocean, which wastes the one thing this game was given more of than
+ * the others -- room.
+ */
+export function bounds(pad = 34): { x: number; y: number; w: number; h: number } {
+ const ids = Object.keys(CENTRES)
+ const lo = (f: (id: string) => number) => Math.min(...ids.map(f))
+ const hi = (f: (id: string) => number) => Math.max(...ids.map(f))
+
+ const minX = lo((id) => CENTRES[id]!.x - radius(id))
+ const maxX = hi((id) => CENTRES[id]!.x + radius(id))
+ const minY = lo((id) => CENTRES[id]!.y - radius(id))
+ const maxY = hi((id) => CENTRES[id]!.y + radius(id))
+
+ return { x: minX - pad, y: minY - pad, w: maxX - minX + pad * 2, h: maxY - minY + pad * 2 }
+}
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..bf6321c
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,15 @@
+:root {
+ font-synthesis: none;
+ -webkit-font-smoothing: antialiased;
+ color-scheme: dark;
+}
+
+* { box-sizing: border-box; }
+
+html, body, #root { height: 100%; margin: 0; }
+
+body {
+ background: radial-gradient(120% 90% at 50% 0%, #23324a 0%, #16202f 55%, #0d141d 100%);
+ color: #f2e9d8;
+ font-family: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, sans-serif;
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..bef5202
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)