The board, drawn

Coordinates placed by hand, roughly where each province falls in Europe, and
everything else drawn from those and from the adjacency rules. Nothing here
is a picture of anybody's board.

It draws the graph rather than regions, and that was not the first idea. The
first idea was Voronoi cells -- every point belonging to the nearest province
-- which makes a handsome cut-paper board and is 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 fixes it: provinces here interleave. The
Adriatic borders Venice with Trieste 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 otherwise is
worse than an ugly map -- it is a map that loses you the game.

The tests found three provinces drawn on top of each other and a distance
check of mine that was measuring Europe rather than checking my typing: it
failed fifteen honest placings because oceans are large and Moscow borders
five things. The exact check is that a line exists for every border in the
rules and for no others.

The viewBox is fitted to what is actually drawn. Europe is not rectangular,
and the box the coordinates were typed into left a third of the frame as
empty ocean -- which wastes the one thing this game was given more of than
the others.
This commit is contained in:
2026-09-09 07:46:22 -07:00
parent efbecf6425
commit ad6e384956
9 changed files with 690 additions and 1 deletions
+133
View File
@@ -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';
}
}
+72
View File
@@ -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<string | null>(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 (
<div className="app">
<header>
<h1>Great Powers</h1>
<p className="dim">Spring 1901 &mdash; the board before anybody has said anything.</p>
</header>
<div className="map-wrap">
<Board units={units} own={own} selected={picked} onPick={setPicked} />
</div>
<aside className="side">
<ul className="powers">
{POWERS.map((power) => (
<li key={power}>
<span className="swatch" style={{ background: COLOURS[power] }} />
{POWER_NAMES[power]}
<span className="count">{centreCount(own, power)}</span>
</li>
))}
</ul>
<div className="picked">
{picked ? (
<>
<h2>{PROVINCES[picked]!.name}</h2>
<p className="dim">
{PROVINCES[picked]!.terrain === 'sea'
? 'Open water. Fleets only.'
: PROVINCES[picked]!.terrain === 'coast'
? 'Coastal. Armies and fleets.'
: 'Inland. Armies only.'}
{PROVINCES[picked]!.sc ? ' A supply centre.' : ''}
</p>
</>
) : (
<p className="dim">Click a province.</p>
)}
</div>
</aside>
</div>
)
}
+147
View File
@@ -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<Power, string> = {
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 (
<svg
className="board"
viewBox={`${box.x} ${box.y} ${box.w} ${box.h}`}
role="img"
aria-label="The board: seventy-five provinces and the borders between them."
>
<rect className="sea-bed" x={box.x} y={box.y} width={box.w} height={box.h} />
{/* Borders first, so every province sits on top of its own edges. */}
<g className="edges">
{edges.map(([a, b]) => (
<line
key={`${a}-${b}`}
x1={CENTRES[a]!.x}
y1={CENTRES[a]!.y}
x2={CENTRES[b]!.x}
y2={CENTRES[b]!.y}
/>
))}
</g>
{Object.keys(PROVINCES).map((id) => {
const p = PROVINCES[id]!
const at = CENTRES[id]!
const r = radius(id)
const owner = own.get(id)
const fill =
p.terrain === 'sea' ? SEA : owner ? COLOURS[owner] : p.sc ? NEUTRAL_SC : LAND
return (
<g
className={`province ${p.terrain} ${p.sc ? 'centre' : ''} ${selected === id ? 'picked' : ''}`}
key={id}
onClick={() => onPick?.(id)}
>
{p.terrain === 'sea' ? (
<circle cx={at.x} cy={at.y} r={r} fill={fill} />
) : (
<rect
x={at.x - r}
y={at.y - r * 0.82}
width={r * 2}
height={r * 1.64}
rx={r * 0.45}
fill={fill}
/>
)}
{/* A supply centre is the only thing anybody is counting, so it
gets a mark of its own rather than a different shade. */}
{p.sc && <circle className="pip" cx={at.x} cy={at.y - r * 0.86} r={4} />}
<text
className="label"
x={at.x}
y={at.y + 5}
style={{ fill: labelInk(p.terrain === 'sea' ? SEA : owner ? COLOURS[owner] : LAND) }}
>
{id.toUpperCase()}
</text>
</g>
)
})}
{/* Units last: they are what you are actually looking at. */}
{[...units.entries()].map(([at, unit]) => {
const p = CENTRES[base(unit.at)] ?? CENTRES[at]!
return (
<g className="unit" key={at} transform={`translate(${p.x} ${p.y + 16})`}>
{unit.type === 'army' ? (
<path d="M-11 6 L-11 -3 L0 -9 L11 -3 L11 6 Z" fill={COLOURS[unit.power]} />
) : (
<path d="M-12 4 L12 4 L7 -2 L2 -2 L2 -9 L-3 -2 L-12 -2 Z" fill={COLOURS[unit.power]} />
)}
</g>
)
})}
</svg>
)
}
/** 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<Power, string> = {
austria: 'Austria',
england: 'England',
france: 'France',
germany: 'Germany',
italy: 'Italy',
russia: 'Russia',
turkey: 'Turkey',
}
export { POWERS }
+97
View File
@@ -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<string>()
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([])
})
})
+187
View File
@@ -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<string, Point> = {
// --- 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<string>()
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 }
}
+15
View File
@@ -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;
}
+10
View File
@@ -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(
<StrictMode>
<App />
</StrictMode>,
)