diff --git a/index.html b/index.html
index ffea47c..d85c0a1 100644
--- a/index.html
+++ b/index.html
@@ -4,7 +4,7 @@
Canopy
-
+
diff --git a/package-lock.json b/package-lock.json
index 41fa371..215cc5d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "canopy-game",
"version": "0.0.1",
"devDependencies": {
+ "typescript": "^6.0.2",
"vite": "^5.4.19"
}
},
@@ -923,6 +924,20 @@
"node": ">=0.10.0"
}
},
+ "node_modules/typescript": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
+ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
diff --git a/package.json b/package.json
index 22b2bfe..ee2888a 100644
--- a/package.json
+++ b/package.json
@@ -6,9 +6,11 @@
"scripts": {
"dev": "vite",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
},
"devDependencies": {
+ "typescript": "^6.0.2",
"vite": "^5.4.19"
}
}
diff --git a/src/constants.ts b/src/constants.ts
new file mode 100644
index 0000000..8abe002
--- /dev/null
+++ b/src/constants.ts
@@ -0,0 +1,16 @@
+import type { PlayerPalette } from "./types";
+
+export const STARTING_POINTS = 3;
+export const ROOT_SHIFT_COST = 1;
+export const ROUND_ANIMATION_SUN_MS = 900;
+export const ROUND_ANIMATION_BRANCH_MS = 1200;
+export const ROUND_ANIMATION_BONUS_MS = 900;
+
+export const PLAYER_PALETTE: PlayerPalette[] = [
+ { name: "Coral", primary: "#ff6b8a", glow: "rgba(255, 107, 138, 0.35)" },
+ { name: "Aqua", primary: "#4de0ff", glow: "rgba(77, 224, 255, 0.35)" },
+ { name: "Amber", primary: "#ffbf47", glow: "rgba(255, 191, 71, 0.35)" },
+ { name: "Mint", primary: "#6fffb0", glow: "rgba(111, 255, 176, 0.35)" },
+ { name: "Violet", primary: "#b28dff", glow: "rgba(178, 141, 255, 0.35)" },
+ { name: "Rose", primary: "#ff8dbf", glow: "rgba(255, 141, 191, 0.35)" },
+];
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..0cba93d
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,1008 @@
+import "./styles.css";
+
+import {
+ ROOT_SHIFT_COST,
+ ROUND_ANIMATION_BONUS_MS,
+ ROUND_ANIMATION_BRANCH_MS,
+ ROUND_ANIMATION_SUN_MS,
+} from "./constants";
+import {
+ buildChildrenMap as buildChildrenMapForState,
+ buildParentMap as buildParentMapForState,
+ getColumnLeaders as getColumnLeadersForState,
+ getLegalMovesForSource as getLegalMovesForSourceForState,
+ getNodeOwner as getNodeOwnerForState,
+ getRootShiftMove as getRootShiftMoveForState,
+ playerHasLegalMove as playerHasLegalMoveForState,
+} from "./rules-board";
+import {
+ buildEnergySimulation,
+ buildRoundAnimation as buildRoundAnimationForState,
+ maybeRollDisease as maybeRollDiseaseForState,
+ maybeRollSunbeam as maybeRollSunbeamForState,
+ scoreColumns as scoreColumnsForState,
+} from "./rules-scoring";
+import {
+ createInitialState,
+ createPlayers,
+ createRandomizedSeedInputs,
+ createSetupState,
+ getMaxStartingNodesPerPlayer,
+} from "./state";
+import type {
+ GameState,
+ GrowTarget,
+ NodeKey,
+ Player,
+ ScoreSnapshot,
+ SetupState,
+ ShiftMove,
+ TurnMove,
+} from "./types";
+import { keyFor, parseKey, tint, wait } from "./utils";
+
+const app = document.querySelector("#app");
+
+if (!(app instanceof HTMLElement)) {
+ throw new Error("#app container not found");
+}
+
+let roundAnimationToken = 0;
+let setup: SetupState = createSetupState();
+let state: GameState = createInitialState(setup);
+let isNewGameModalOpen = false;
+let previousScoreSnapshot: ScoreSnapshot[] | null = null;
+
+function getScoreSnapshot() {
+ return state.players.map((player) => ({
+ totalScore: player.totalScore,
+ roundScore: player.roundScore,
+ growthPoints: player.growthPoints,
+ bankedPoints: player.bankedPoints,
+ }));
+}
+
+function getCurrentPlayer() {
+ return state.players[state.activePlayerId];
+}
+
+function getNodeOwner(row: number, column: number) {
+ return getNodeOwnerForState(state, row, column);
+}
+
+function buildParentMap() {
+ return buildParentMapForState(state);
+}
+
+function buildChildrenMap(ownerId = null) {
+ return buildChildrenMapForState(state, ownerId);
+}
+
+function getRootShiftMove(sourceKey: NodeKey, delta: number, player: Player) {
+ return getRootShiftMoveForState(state, sourceKey, delta, player);
+}
+
+function getSelectedRootShiftMoves() {
+ const player = getCurrentPlayer();
+ if (!state.selectedSource) {
+ return [];
+ }
+
+ return [-1, 1]
+ .map((delta) => getRootShiftMove(state.selectedSource as NodeKey, delta, player))
+ .filter(Boolean) as ShiftMove[];
+}
+
+function getLegalMovesForSource(sourceKey: NodeKey, player: Player) {
+ return getLegalMovesForSourceForState(state, sourceKey, player);
+}
+
+function playerHasLegalMove(player: Player) {
+ return playerHasLegalMoveForState(state, player);
+}
+
+function scoreColumns() {
+ return scoreColumnsForState(state);
+}
+
+function maybeRollSunbeam(scores: number[]) {
+ return maybeRollSunbeamForState(state, scores);
+}
+
+function maybeRollDisease() {
+ return maybeRollDiseaseForState(state);
+}
+
+function buildRoundAnimation(energySimulation, sunbeamPlayerId, diseaseKeys) {
+ return buildRoundAnimationForState(state, energySimulation, sunbeamPlayerId, diseaseKeys);
+}
+
+function getColumnLeaders() {
+ return getColumnLeadersForState(state);
+}
+
+function getMoveUndoKeys(move: TurnMove) {
+ return move.undoKeys ?? ("targetKey" in move ? [move.targetKey] : []);
+}
+
+function findTurnMoveIndex(targetKey: NodeKey) {
+ return state.turnMoves.findIndex((move) => getMoveUndoKeys(move).includes(targetKey));
+}
+
+function applyShiftMove(move: ShiftMove, player: Player) {
+ move.movedNodes.forEach((node) => {
+ state.nodes.delete(node.fromKey);
+ });
+
+ move.movedNodes.forEach((node) => {
+ state.nodes.set(node.toKey, { ownerId: player.id });
+ });
+
+ move.movedEdges.forEach((edgeMove) => {
+ const edgeIndex = state.edges.findIndex((edge) => {
+ return edge.ownerId === edgeMove.before.ownerId
+ && edge.from.row === edgeMove.before.from.row
+ && edge.from.column === edgeMove.before.from.column
+ && edge.to.row === edgeMove.before.to.row
+ && edge.to.column === edgeMove.before.to.column;
+ });
+
+ if (edgeIndex !== -1) {
+ state.edges[edgeIndex] = {
+ from: { ...edgeMove.after.from },
+ to: { ...edgeMove.after.to },
+ ownerId: edgeMove.after.ownerId,
+ };
+ }
+ });
+
+ player.growthPoints -= move.cost;
+ state.turnMoves.push(move);
+ state.history.unshift(`${player.name} shifted a root ${move.direction} for ${move.cost} point.`);
+ updateSelection(move.selectKey);
+ render();
+}
+
+function undoTurnMove(move: TurnMove, player: Player) {
+ if (move.type === "shift") {
+ move.movedNodes.forEach((node) => {
+ state.nodes.delete(node.toKey);
+ });
+
+ move.movedNodes.forEach((node) => {
+ state.nodes.set(node.fromKey, { ownerId: player.id });
+ });
+
+ move.movedEdges.forEach((edgeMove) => {
+ const edgeIndex = state.edges.findIndex((edge) => {
+ return edge.ownerId === edgeMove.after.ownerId
+ && edge.from.row === edgeMove.after.from.row
+ && edge.from.column === edgeMove.after.from.column
+ && edge.to.row === edgeMove.after.to.row
+ && edge.to.column === edgeMove.after.to.column;
+ });
+
+ if (edgeIndex !== -1) {
+ state.edges[edgeIndex] = {
+ from: { ...edgeMove.before.from },
+ to: { ...edgeMove.before.to },
+ ownerId: edgeMove.before.ownerId,
+ };
+ }
+ });
+
+ player.growthPoints += move.cost;
+ return;
+ }
+
+ state.nodes.delete(move.targetKey);
+ const edgeIndex = state.edges.findIndex((edge) => {
+ return edge.ownerId === player.id
+ && edge.from.row === move.from.row
+ && edge.from.column === move.from.column
+ && edge.to.row === move.to.row
+ && edge.to.column === move.to.column;
+ });
+
+ if (edgeIndex !== -1) {
+ state.edges.splice(edgeIndex, 1);
+ }
+
+ player.growthPoints += move.cost;
+}
+
+function applyDisease(killedKeys: NodeKey[]) {
+ const killed = new Set(killedKeys);
+ if (killed.size === 0) {
+ return;
+ }
+
+ state.edges = state.edges.filter((edge) => {
+ return !killed.has(keyFor(edge.to.row, edge.to.column)) && !killed.has(keyFor(edge.from.row, edge.from.column));
+ });
+ killedKeys.forEach((key) => {
+ state.nodes.delete(key);
+ });
+}
+
+function updateSelection(sourceKey: NodeKey | null = null) {
+ const player = getCurrentPlayer();
+ state.selectedSource = sourceKey;
+ state.availableTargets = sourceKey ? getLegalMovesForSource(sourceKey, player) : [];
+}
+
+function isInteractionLocked() {
+ return state.gameOver || Boolean(state.animation);
+}
+
+function advanceTurn() {
+ if (state.players.every((player) => player.passed || !playerHasLegalMove(player))) {
+ endRound();
+ return;
+ }
+
+ let nextPlayerId = state.activePlayerId;
+ for (let step = 0; step < state.players.length; step += 1) {
+ nextPlayerId = (nextPlayerId + 1) % state.players.length;
+ const candidate = state.players[nextPlayerId];
+
+ if (!candidate.passed && playerHasLegalMove(candidate)) {
+ state.activePlayerId = nextPlayerId;
+ state.turnMoves = [];
+ updateSelection(null);
+ render();
+ return;
+ }
+ }
+
+ endRound();
+}
+
+function moveToFirstPlayableTurn() {
+ const nextPlayerId = state.players.findIndex((player) => playerHasLegalMove(player));
+
+ if (nextPlayerId === -1) {
+ state.gameOver = true;
+ state.history.unshift("No player can grow further. Final totals are locked.");
+ return false;
+ }
+
+ state.activePlayerId = nextPlayerId;
+ state.turnMoves = [];
+ updateSelection(null);
+ return true;
+}
+
+function undoMovesThrough(targetKey: NodeKey) {
+ const player = getCurrentPlayer();
+ const moveIndex = findTurnMoveIndex(targetKey);
+
+ if (moveIndex === -1) {
+ return false;
+ }
+
+ const undoneMoves = state.turnMoves.slice(moveIndex).reverse();
+ undoneMoves.forEach((move) => {
+ undoTurnMove(move, player);
+ });
+
+ state.turnMoves = state.turnMoves.slice(0, moveIndex);
+ state.history.unshift(`${player.name} rewound ${undoneMoves.length} move${undoneMoves.length === 1 ? "" : "s"} before ending the turn.`);
+
+ const lastMove = state.turnMoves.at(-1);
+ const newSelection = lastMove?.selectKey ?? (lastMove && "targetKey" in lastMove ? lastMove.targetKey : null);
+ updateSelection(newSelection);
+ render();
+ return true;
+}
+
+function growTo(target: GrowTarget) {
+ const player = getCurrentPlayer();
+ if (!state.selectedSource) {
+ return;
+ }
+
+ const source = parseKey(state.selectedSource);
+ const targetKey = keyFor(target.row, target.column);
+ if (state.nodes.has(targetKey) || target.cost > player.growthPoints) {
+ return;
+ }
+
+ state.nodes.set(targetKey, { ownerId: player.id });
+ state.edges.push({
+ from: { row: source.row, column: source.column },
+ to: { row: target.row, column: target.column },
+ ownerId: player.id,
+ });
+ state.turnMoves.push({
+ type: "grow",
+ from: source,
+ to: { row: target.row, column: target.column },
+ cost: target.cost,
+ targetKey,
+ undoKeys: [targetKey],
+ selectKey: targetKey,
+ });
+ player.growthPoints -= target.cost;
+ state.history.unshift(
+ `${player.name} grew ${target.direction === "vertical" ? "upward" : `diagonally ${target.direction}`} for ${target.cost} point${target.cost === 1 ? "" : "s"}.`
+ );
+
+ updateSelection(targetKey);
+ render();
+}
+
+function shiftSelectedRoot(delta: number) {
+ const player = getCurrentPlayer();
+ if (!state.selectedSource) {
+ return;
+ }
+
+ const move = getRootShiftMove(state.selectedSource, delta, player);
+ if (!move) {
+ return;
+ }
+
+ applyShiftMove(move, player);
+}
+
+function endTurn() {
+ const player = getCurrentPlayer();
+ if (player.growthPoints > 0) {
+ const confirmed = window.confirm(
+ `${player.name} still has ${player.growthPoints} growth point${player.growthPoints === 1 ? "" : "s"}. End the turn and lose them?`
+ );
+
+ if (!confirmed) {
+ return;
+ }
+ }
+
+ const lostGrowth = player.growthPoints;
+ state.turnMoves = [];
+ player.growthPoints = 0;
+ player.passed = true;
+ state.history.unshift(
+ `${player.name} ended their turn${lostGrowth > 0 ? ` and let ${lostGrowth} growth point${lostGrowth === 1 ? "" : "s"} wither` : ""}.`
+ );
+ advanceTurn();
+}
+
+function bankGrowthAndEndTurn() {
+ const player = getCurrentPlayer();
+ if (player.growthPoints <= 0) {
+ endTurn();
+ return;
+ }
+
+ player.bankedPoints += player.growthPoints;
+ state.history.unshift(
+ `${player.name} banked ${player.growthPoints} growth point${player.growthPoints === 1 ? "" : "s"} for next round.`
+ );
+ player.growthPoints = 0;
+ state.turnMoves = [];
+ player.passed = true;
+ advanceTurn();
+}
+
+async function endRound() {
+ const { scores, columnResults, energySimulation } = scoreColumns();
+ const { nextGrowth, event: sunbeamEvent, awardedPlayer } = maybeRollSunbeam(scores);
+ const { killedKeys, event: diseaseEvent } = maybeRollDisease();
+ const token = ++roundAnimationToken;
+
+ state.animation = buildRoundAnimation(energySimulation, awardedPlayer, killedKeys);
+ render();
+
+ await wait(ROUND_ANIMATION_SUN_MS);
+ if (token !== roundAnimationToken) {
+ return;
+ }
+
+ state.animation = { ...state.animation!, phase: "branches" };
+ render();
+
+ await wait(ROUND_ANIMATION_BRANCH_MS);
+ if (token !== roundAnimationToken) {
+ return;
+ }
+
+ if (awardedPlayer !== null) {
+ state.animation = { ...state.animation!, phase: "bonus" };
+ render();
+
+ await wait(ROUND_ANIMATION_BONUS_MS);
+ if (token !== roundAnimationToken) {
+ return;
+ }
+ }
+
+ state.animation = { ...state.animation!, phase: "events" };
+ render();
+
+ await wait(900);
+ if (token !== roundAnimationToken) {
+ return;
+ }
+
+ applyDisease(killedKeys);
+
+ state.players.forEach((player, index) => {
+ player.roundScore = scores[index];
+ player.totalScore += scores[index];
+ player.bonusPoints = nextGrowth[index] - scores[index];
+ player.growthPoints = nextGrowth[index] + player.bankedPoints;
+ player.bankedPoints = 0;
+ player.passed = false;
+ });
+
+ state.roundSummary = {
+ scores,
+ columnResults,
+ event: [sunbeamEvent, diseaseEvent].filter(Boolean).join(" "),
+ };
+
+ state.history.unshift(
+ `Round ${state.round} scored. ${state.players.map((player) => `${player.name}: ${player.roundScore}`).join(" | ")}`
+ );
+ [sunbeamEvent, diseaseEvent].filter(Boolean).forEach((eventText) => {
+ state.history.unshift(eventText as string);
+ });
+
+ state.animation = null;
+
+ const boardFull = state.nodes.size >= state.config.columns * state.config.rows || state.players.every((player) => !playerHasLegalMove(player));
+ if (boardFull) {
+ state.gameOver = true;
+ state.history.unshift("The canopy is complete. Final totals are locked.");
+ render();
+ return;
+ }
+
+ state.round += 1;
+ state.history.unshift(`Round ${state.round} begins.`);
+ moveToFirstPlayableTurn();
+ render();
+}
+
+function resetGame() {
+ roundAnimationToken += 1;
+ state = createInitialState(setup);
+ moveToFirstPlayableTurn();
+ render();
+}
+
+function openNewGameModal() {
+ isNewGameModalOpen = true;
+ render();
+}
+
+function closeNewGameModal() {
+ isNewGameModalOpen = false;
+ render();
+}
+
+function startNewGameFromModal() {
+ isNewGameModalOpen = false;
+ resetGame();
+}
+
+function finishGameNow() {
+ if (state.gameOver) {
+ return;
+ }
+
+ roundAnimationToken += 1;
+ state.animation = null;
+ const { scores, columnResults } = scoreColumns();
+ state.players.forEach((player, index) => {
+ player.roundScore = scores[index];
+ player.totalScore += scores[index];
+ });
+ state.roundSummary = { scores, columnResults, event: null };
+ state.gameOver = true;
+ state.history.unshift("Game ended manually. Final scores tallied from the current canopy.");
+ render();
+}
+
+function getTargetForCell(row: number, column: number) {
+ return state.availableTargets.find((target) => target.row === row && target.column === column) ?? null;
+}
+
+function isPendingTurnNode(row: number, column: number) {
+ const nodeKey = keyFor(row, column);
+ return state.turnMoves.some((move) => getMoveUndoKeys(move).includes(nodeKey));
+}
+
+function moveSetupPlayer(fromIndex: number, toIndex: number) {
+ if (toIndex < 0 || toIndex >= setup.playerCount) {
+ return;
+ }
+
+ [setup.paletteOrder[fromIndex], setup.paletteOrder[toIndex]] = [setup.paletteOrder[toIndex], setup.paletteOrder[fromIndex]];
+ [setup.seedInputs[fromIndex], setup.seedInputs[toIndex]] = [setup.seedInputs[toIndex], setup.seedInputs[fromIndex]];
+ render();
+}
+
+function randomizeStartingLocations() {
+ setup.seedInputs = createRandomizedSeedInputs(setup.playerCount, setup.columns, setup.startingNodesPerPlayer);
+ render();
+}
+
+function renderNewGameModal() {
+ if (!isNewGameModalOpen) {
+ return "";
+ }
+
+ const maxSeeds = getMaxStartingNodesPerPlayer(setup.playerCount, setup.columns);
+ const previewPlayers = createPlayers(setup.playerCount, setup.paletteOrder);
+
+ return `
+
+
+
+
+
New Game
+
Configure the next canopy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Turn order and colors
+
Move players up or down to set the manual color order. Leave shuffle on to randomize the order each new game.
+
+ ${previewPlayers.map((currentPlayer, index) => `
+
+
+
+ ${currentPlayer.name}
+
+
+
+
+
+
+ `).join("")}
+
+
+
+
Starting columns
+
Use 1-based column numbers, comma-separated. Duplicate or invalid picks are auto-corrected when you start a new game.
+
+
+ ${previewPlayers.map((currentPlayer, index) => `
+
+ `).join("")}
+
+
+
+
+
+
+
+
+ `;
+}
+
+function renderScoreboard() {
+ return state.players.map((player, index) => {
+ const isActive = index === state.activePlayerId && !state.gameOver;
+ const previous = previousScoreSnapshot?.[index];
+ const totalChanged = previous && previous.totalScore !== player.totalScore;
+ const sunlightChanged = previous && previous.roundScore !== player.roundScore;
+ const growthChanged = previous && previous.growthPoints !== player.growthPoints;
+ const bankChanged = previous && previous.bankedPoints !== player.bankedPoints;
+ return `
+
+
+
+
${player.name}
+
+
+
+ Total
+ ${player.totalScore}
+
+
+ Sunlight
+ ${player.roundScore}
+
+
+ Energy
+ ${player.growthPoints}
+
+
+ Bank
+ ${player.bankedPoints}
+
+
+
+ `;
+ }).join("");
+}
+
+function renderAnimationOverlay(columns: number, rows: number) {
+ if (!state.animation) {
+ return "";
+ }
+
+ const showSunlightWave = state.animation.phase === "sunlight";
+ const showBranchFlow = state.animation.phase !== "sunlight";
+
+ const sunlightCells = showSunlightWave ? state.animation.columns.flatMap((columnState) => {
+ const flashColor = columnState.ownerId === null ? "rgba(255, 241, 186, 0.9)" : state.players[columnState.ownerId].color;
+ const verticalCells = Array.from({ length: columnState.terminalRow + 1 }, (_, row) => ({ row, column: columnState.column }));
+
+ return verticalCells.map((node, index) => {
+ const left = (node.column / columns) * 100;
+ const top = (node.row / rows) * 100;
+ const width = (1 / columns) * 100;
+ const height = (1 / rows) * 100;
+ return ``;
+ });
+ }).join("") : "";
+
+ const flashes = showBranchFlow ? state.animation.traces.flatMap((trace) => {
+ const player = state.players[trace.playerId];
+ const nodes = trace.branchNodes;
+
+ return nodes.map((node, index) => {
+ const left = (node.column / columns) * 100;
+ const top = (node.row / rows) * 100;
+ const width = (1 / columns) * 100;
+ const height = (1 / rows) * 100;
+ return `
+
+ `;
+ });
+ }).join("") : "";
+
+ const roots = state.animation.rootBursts.map((burst) => {
+ const player = state.players[burst.playerId];
+ const root = parseKey(burst.key);
+ const x = ((root.column + 0.5) / columns) * 100;
+ const y = ((root.row + 0.5) / rows) * 100;
+ return `+${burst.count}`;
+ }).join("");
+
+ const disease = state.animation.diseaseKeys.map((key, index) => {
+ const node = parseKey(key);
+ const x = ((node.column + 0.5) / columns) * 100;
+ const y = ((node.row + 0.5) / rows) * 100;
+ return ``;
+ }).join("");
+
+ const bonusSunbeam = !state.animation.bonusTrace
+ ? ""
+ : `
+
+
+
+
+
+
+ `;
+
+ const bonusFlashes = !state.animation.bonusTrace
+ ? ""
+ : state.animation.bonusTrace.branchNodes.map((node, index) => {
+ const left = (node.column / columns) * 100;
+ const top = (node.row / rows) * 100;
+ const width = (1 / columns) * 100;
+ const height = (1 / rows) * 100;
+ return ``;
+ }).join("");
+
+ const sunbeam = state.animation.bonusBurst === null || state.animation.bonusBurst === undefined
+ ? ""
+ : (() => {
+ const root = parseKey(state.animation?.bonusBurst?.key as string);
+ const x = ((root.column + 0.5) / columns) * 100;
+ const y = ((root.row + 0.5) / rows) * 100;
+ return `+1`;
+ })();
+
+ return `
+
+ ${state.animation.phase === "bonus" ? bonusSunbeam : ""}
+
+
+ ${sunlightCells}
+ ${flashes}
+ ${state.animation.phase === "bonus" ? bonusFlashes : ""}
+
+
+ `;
+}
+
+function renderBoard() {
+ const columns = state.config.columns;
+ const rows = state.config.rows;
+ const columnLeaders = getColumnLeaders();
+ const parentMap = buildParentMap();
+ const lines = state.edges.map((edge) => {
+ const player = state.players[edge.ownerId];
+ const x1 = ((edge.from.column + 0.5) / columns) * 100;
+ const y1 = ((edge.from.row + 0.5) / rows) * 100;
+ const x2 = ((edge.to.column + 0.5) / columns) * 100;
+ const y2 = ((edge.to.row + 0.5) / rows) * 100;
+
+ return ``;
+ }).join("");
+
+ const cells = Array.from({ length: rows }, (_, row) => {
+ return Array.from({ length: columns }, (_, column) => {
+ const ownerId = getNodeOwner(row, column);
+ const player = ownerId === null ? null : state.players[ownerId];
+ const target = getTargetForCell(row, column);
+ const pending = isPendingTurnNode(row, column);
+ const nodeKey = keyFor(row, column);
+ const isRoot = ownerId !== null && row === rows - 1 && !parentMap.has(nodeKey);
+ const columnLeader = columnLeaders[column];
+ const background = columnLeader.ownerId === null || columnLeader.tied
+ ? "transparent"
+ : tint(state.players[columnLeader.ownerId].color);
+
+ return `
+
+ `;
+ }).join("");
+ }).join("");
+
+ return `
+
+
+
+ ${cells}
+ ${renderAnimationOverlay(columns, rows)}
+
+
+ `;
+}
+
+function renderSidebar() {
+ const player = getCurrentPlayer();
+ const rootShiftMoves = getSelectedRootShiftMoves();
+ const boardLocked = isInteractionLocked();
+ const nextGrowthText = state.roundSummary
+ ? state.players.map((current) => `${current.name}: ${current.growthPoints}`).join(" | ")
+ : "Next round growth = 1 + columns owned + any banked growth.";
+
+ return `
+
+ `;
+}
+
+function attachEvents() {
+ document.querySelectorAll(".cell").forEach((cell) => {
+ cell.addEventListener("click", () => {
+ if (isInteractionLocked()) {
+ return;
+ }
+
+ const row = Number(cell.dataset.row);
+ const column = Number(cell.dataset.column);
+ const currentPlayer = getCurrentPlayer();
+ const ownerId = getNodeOwner(row, column);
+ const target = getTargetForCell(row, column);
+ const nodeKey = keyFor(row, column);
+
+ if (target) {
+ growTo(target);
+ return;
+ }
+
+ if (ownerId === currentPlayer.id && undoMovesThrough(nodeKey)) {
+ return;
+ }
+
+ if (ownerId === currentPlayer.id) {
+ const sourceKey = nodeKey;
+ if (state.selectedSource === sourceKey) {
+ updateSelection(null);
+ } else {
+ updateSelection(sourceKey);
+ }
+ render();
+ }
+ });
+ });
+
+ document.querySelector("#new-game")?.addEventListener("click", openNewGameModal);
+ document.querySelector("#close-new-game")?.addEventListener("click", closeNewGameModal);
+ document.querySelector("#cancel-new-game")?.addEventListener("click", closeNewGameModal);
+ document.querySelector("#start-new-game")?.addEventListener("click", startNewGameFromModal);
+ document.querySelector("#new-game-modal-backdrop")?.addEventListener("click", (event) => {
+ if ((event.target as HTMLElement).id === "new-game-modal-backdrop") {
+ closeNewGameModal();
+ }
+ });
+ document.querySelector("#end-turn")?.addEventListener("click", endTurn);
+ document.querySelector("#bank-turn")?.addEventListener("click", bankGrowthAndEndTurn);
+ document.querySelector("#finish-game")?.addEventListener("click", finishGameNow);
+ document.querySelector("#player-count")?.addEventListener("input", (event) => {
+ const input = event.currentTarget as HTMLInputElement;
+ const output = input.parentElement?.querySelector("strong");
+ if (output) {
+ output.textContent = input.value;
+ }
+ setup = createSetupState(Number(input.value), setup.columns, setup.rows, setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder);
+ render();
+ });
+ document.querySelector("#column-count")?.addEventListener("change", (event) => {
+ const columns = Number((event.currentTarget as HTMLInputElement).value);
+ if (!Number.isInteger(columns)) {
+ return;
+ }
+
+ setup = createSetupState(setup.playerCount, Math.max(6, Math.min(24, columns)), setup.rows, setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder);
+ render();
+ });
+ document.querySelector("#row-count")?.addEventListener("change", (event) => {
+ const rows = Number((event.currentTarget as HTMLInputElement).value);
+ if (!Number.isInteger(rows)) {
+ return;
+ }
+
+ setup = createSetupState(setup.playerCount, setup.columns, Math.max(6, Math.min(24, rows)), setup.startingNodesPerPlayer, setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder);
+ render();
+ });
+ document.querySelector("#starting-nodes")?.addEventListener("change", (event) => {
+ const nextValue = Number((event.currentTarget as HTMLInputElement).value);
+ if (!Number.isInteger(nextValue)) {
+ return;
+ }
+
+ setup = createSetupState(setup.playerCount, setup.columns, setup.rows, Math.max(1, nextValue), setup.sunbeamChance, setup.diseaseChance, setup.seedInputs, setup.paletteOrder, setup.shuffleTurnOrder);
+ render();
+ });
+ document.querySelector("#sunbeam-chance")?.addEventListener("change", (event) => {
+ setup.sunbeamChance = Math.max(0, Math.min(100, Number((event.currentTarget as HTMLInputElement).value) || 0));
+ state.randomEffects.sunbeamChance = setup.sunbeamChance;
+ });
+ document.querySelector("#disease-chance")?.addEventListener("change", (event) => {
+ setup.diseaseChance = Math.max(0, Math.min(100, Number((event.currentTarget as HTMLInputElement).value) || 0));
+ state.randomEffects.diseaseChance = setup.diseaseChance;
+ });
+ document.querySelector("#shuffle-order-toggle")?.addEventListener("change", (event) => {
+ setup.shuffleTurnOrder = (event.currentTarget as HTMLInputElement).checked;
+ });
+ document.querySelectorAll(".seed-input").forEach((input) => {
+ input.addEventListener("input", (event) => {
+ const target = event.currentTarget as HTMLInputElement;
+ const playerId = Number(target.dataset.playerId);
+ setup.seedInputs[playerId] = target.value;
+ });
+ });
+ document.querySelector("#randomize-starting-locations")?.addEventListener("click", randomizeStartingLocations);
+ document.querySelectorAll("[data-move-player]").forEach((button) => {
+ button.addEventListener("click", () => {
+ const fromIndex = Number(button.dataset.movePlayer);
+ const direction = button.dataset.direction === "up" ? -1 : 1;
+ moveSetupPlayer(fromIndex, fromIndex + direction);
+ });
+ });
+ document.querySelectorAll("[data-root-shift]").forEach((button) => {
+ button.addEventListener("click", () => {
+ shiftSelectedRoot(Number(button.dataset.rootShift));
+ });
+ });
+}
+
+function render() {
+ app.innerHTML = `
+
+
+ ${renderBoard()}
+ ${renderSidebar()}
+
+
+
+ ${renderNewGameModal()}
+ `;
+
+ attachEvents();
+ previousScoreSnapshot = getScoreSnapshot();
+}
+
+render();
diff --git a/src/rules-board.ts b/src/rules-board.ts
new file mode 100644
index 0000000..1ec1de8
--- /dev/null
+++ b/src/rules-board.ts
@@ -0,0 +1,173 @@
+import { ROOT_SHIFT_COST } from "./constants";
+import type { ColumnLeader, GameState, GrowTarget, NodeKey, Player, PlayerId, ShiftMove } from "./types";
+import { keyFor, parseKey } from "./utils";
+
+export function getNodeOwner(state: GameState, row: number, column: number): PlayerId | null {
+ return state.nodes.get(keyFor(row, column))?.ownerId ?? null;
+}
+
+export function buildParentMap(state: GameState) {
+ return new Map(state.edges.map((edge) => [keyFor(edge.to.row, edge.to.column), keyFor(edge.from.row, edge.from.column)]));
+}
+
+export function buildChildrenMap(state: GameState, ownerId: PlayerId | null = null) {
+ const childrenMap = new Map();
+
+ state.edges.forEach((edge) => {
+ if (ownerId !== null && edge.ownerId !== ownerId) {
+ return;
+ }
+
+ const fromKey = keyFor(edge.from.row, edge.from.column);
+ const target = keyFor(edge.to.row, edge.to.column);
+ const entry = childrenMap.get(fromKey) ?? [];
+ entry.push(target);
+ childrenMap.set(fromKey, entry);
+ });
+
+ return childrenMap;
+}
+
+export function collectSubtreeKeys(state: GameState, rootKey: NodeKey, player: Player) {
+ const childrenMap = buildChildrenMap(state, player.id);
+ const subtree = new Set([rootKey]);
+ const queue: NodeKey[] = [rootKey];
+
+ while (queue.length > 0) {
+ const current = queue.shift();
+ if (!current) {
+ continue;
+ }
+
+ (childrenMap.get(current) ?? []).forEach((childKey) => {
+ if (!subtree.has(childKey)) {
+ subtree.add(childKey);
+ queue.push(childKey);
+ }
+ });
+ }
+
+ return subtree;
+}
+
+export function getRootShiftMove(state: GameState, sourceKey: NodeKey, delta: number, player: Player): ShiftMove | null {
+ if (state.round !== 1 || player.growthPoints < ROOT_SHIFT_COST) {
+ return null;
+ }
+
+ const parentMap = buildParentMap(state);
+ const source = parseKey(sourceKey);
+ if (source.row !== state.config.rows - 1 || state.nodes.get(sourceKey)?.ownerId !== player.id || parentMap.has(sourceKey)) {
+ return null;
+ }
+
+ const subtree = collectSubtreeKeys(state, sourceKey, player);
+ const movedNodes: ShiftMove["movedNodes"] = [];
+
+ for (const nodeKey of subtree) {
+ const node = parseKey(nodeKey);
+ const targetColumn = node.column + delta;
+ if (targetColumn < 0 || targetColumn >= state.config.columns) {
+ return null;
+ }
+
+ const targetKey = keyFor(node.row, targetColumn);
+ if (!subtree.has(targetKey) && state.nodes.has(targetKey)) {
+ return null;
+ }
+
+ movedNodes.push({ fromKey: nodeKey, toKey: targetKey, row: node.row, fromColumn: node.column, toColumn: targetColumn });
+ }
+
+ const movedEdges = state.edges
+ .filter((edge) => subtree.has(keyFor(edge.from.row, edge.from.column)) && subtree.has(keyFor(edge.to.row, edge.to.column)))
+ .map((edge) => ({
+ before: {
+ from: { ...edge.from },
+ to: { ...edge.to },
+ ownerId: edge.ownerId,
+ },
+ after: {
+ from: { row: edge.from.row, column: edge.from.column + delta },
+ to: { row: edge.to.row, column: edge.to.column + delta },
+ ownerId: edge.ownerId,
+ },
+ }));
+
+ return {
+ type: "shift",
+ cost: ROOT_SHIFT_COST,
+ direction: delta < 0 ? "left" : "right",
+ movedNodes,
+ movedEdges,
+ undoKeys: movedNodes.map((node) => node.toKey),
+ selectKey: keyFor(source.row, source.column + delta),
+ };
+}
+
+export function playerHasRootShiftMove(state: GameState, player: Player) {
+ if (state.round !== 1 || player.growthPoints < ROOT_SHIFT_COST) {
+ return false;
+ }
+
+ return Array.from(state.nodes.entries()).some(([nodeKey, node]) => {
+ if (node.ownerId !== player.id) {
+ return false;
+ }
+
+ return Boolean(getRootShiftMove(state, nodeKey, -1, player) || getRootShiftMove(state, nodeKey, 1, player));
+ });
+}
+
+export function getLegalMovesForSource(state: GameState, sourceKey: NodeKey, player: Player): GrowTarget[] {
+ const columns = state.config.columns;
+ const { row, column } = parseKey(sourceKey);
+ if (player.id !== getNodeOwner(state, row, column) || row === 0) {
+ return [];
+ }
+
+ const moves: GrowTarget[] = [
+ { row: row - 1, column, cost: 1, direction: "vertical" },
+ { row: row - 1, column: column - 1, cost: 2, direction: "left" },
+ { row: row - 1, column: column + 1, cost: 2, direction: "right" },
+ ];
+
+ return moves.filter((move) => {
+ if (move.column < 0 || move.column >= columns) {
+ return false;
+ }
+
+ if (state.nodes.has(keyFor(move.row, move.column))) {
+ return false;
+ }
+
+ return move.cost <= player.growthPoints;
+ });
+}
+
+export function playerHasLegalMove(state: GameState, player: Player) {
+ if (player.growthPoints <= 0) {
+ return false;
+ }
+
+ return Array.from(state.nodes.entries()).some(([nodeKey, node]) => {
+ if (node.ownerId !== player.id) {
+ return false;
+ }
+
+ return getLegalMovesForSource(state, nodeKey, player).length > 0;
+ }) || playerHasRootShiftMove(state, player);
+}
+
+export function getColumnLeaders(state: GameState): ColumnLeader[] {
+ return Array.from({ length: state.config.columns }, (_, column) => {
+ for (let row = 0; row < state.config.rows; row += 1) {
+ const owner = getNodeOwner(state, row, column);
+ if (owner !== null) {
+ return { ownerId: owner, row, tied: false };
+ }
+ }
+
+ return { ownerId: null, row: null, tied: false };
+ });
+}
diff --git a/src/rules-scoring.ts b/src/rules-scoring.ts
new file mode 100644
index 0000000..1d7b63d
--- /dev/null
+++ b/src/rules-scoring.ts
@@ -0,0 +1,179 @@
+import type { EnergySimulation, GameState, NodeKey, PlayerId, RootBurst, RoundAnimation } from "./types";
+import { keyFor, parseKey, shuffleArray } from "./utils";
+import { buildChildrenMap, buildParentMap } from "./rules-board";
+
+export function buildEnergySimulation(state: GameState): EnergySimulation {
+ const parentMap = buildParentMap(state);
+ const columns = [];
+ const scores = state.players.map(() => 0);
+
+ for (let column = 0; column < state.config.columns; column += 1) {
+ let hitNodeKey: NodeKey | null = null;
+
+ for (let row = 0; row < state.config.rows; row += 1) {
+ const nodeKey = keyFor(row, column);
+ if (state.nodes.has(nodeKey)) {
+ hitNodeKey = nodeKey;
+ break;
+ }
+ }
+
+ if (!hitNodeKey) {
+ columns.push({
+ column,
+ terminalRow: state.config.rows - 1,
+ intercepted: false,
+ ownerId: null,
+ hitNode: null,
+ rootKey: null,
+ branchNodes: [],
+ branchEdges: [],
+ });
+ continue;
+ }
+
+ const hitNode = parseKey(hitNodeKey);
+ const ownerId = state.nodes.get(hitNodeKey)?.ownerId as PlayerId;
+ const branchNodes = [hitNode];
+ const branchEdges = [];
+ let cursor = hitNodeKey;
+
+ while (parentMap.has(cursor)) {
+ const parentKey = parentMap.get(cursor) as NodeKey;
+ branchEdges.push({ from: parseKey(cursor), to: parseKey(parentKey) });
+ branchNodes.push(parseKey(parentKey));
+ cursor = parentKey;
+ }
+
+ scores[ownerId] += 1;
+ columns.push({
+ column,
+ terminalRow: hitNode.row,
+ intercepted: true,
+ ownerId,
+ hitNode,
+ rootKey: cursor,
+ branchNodes,
+ branchEdges,
+ });
+ }
+
+ const rootBurstMap = columns.reduce((map, column) => {
+ if (!column.intercepted || !column.rootKey) {
+ return map;
+ }
+
+ const entry = map.get(column.rootKey) ?? { key: column.rootKey, playerId: column.ownerId as PlayerId, count: 0 };
+ entry.count += 1;
+ map.set(column.rootKey, entry);
+ return map;
+ }, new Map());
+ const rootBursts: RootBurst[] = [...rootBurstMap.values()];
+
+ return {
+ scores,
+ columns,
+ rootBursts,
+ };
+}
+
+export function buildRoundAnimation(
+ state: GameState,
+ energySimulation: EnergySimulation,
+ sunbeamPlayerId: PlayerId | null,
+ diseaseKeys: NodeKey[],
+): RoundAnimation {
+ const traces = energySimulation.columns
+ .filter((column) => column.intercepted)
+ .map((column) => ({
+ playerId: column.ownerId as PlayerId,
+ verticalCells: Array.from({ length: column.terminalRow + 1 }, (_, row) => ({ row, column: column.column })),
+ ray: {
+ x: ((column.column + 0.5) / state.config.columns) * 100,
+ y: ((column.terminalRow + 0.5) / state.config.rows) * 100,
+ },
+ rootKey: column.rootKey,
+ branchNodes: column.branchNodes,
+ }));
+
+ const bonusTrace = sunbeamPlayerId === null ? null : traces.find((trace) => trace.playerId === sunbeamPlayerId) ?? null;
+ const bonusBurst = bonusTrace ? energySimulation.rootBursts.find((burst) => burst.key === bonusTrace.rootKey) ?? null : null;
+
+ return {
+ phase: "sunlight",
+ columns: energySimulation.columns,
+ traces,
+ rootBursts: energySimulation.rootBursts,
+ sunbeamPlayerId,
+ bonusTrace,
+ bonusBurst,
+ diseaseKeys,
+ };
+}
+
+export function scoreColumns(state: GameState) {
+ const energySimulation = buildEnergySimulation(state);
+ const columnResults = energySimulation.columns.map((column) => ({
+ column: column.column,
+ ownerId: column.ownerId,
+ topRow: column.intercepted ? column.terminalRow : null,
+ tied: false,
+ }));
+
+ return { scores: energySimulation.scores, columnResults, energySimulation };
+}
+
+export function maybeRollSunbeam(state: GameState, scores: number[]) {
+ const nextGrowth = scores.map((score) => score + 1);
+ const { sunbeamChance } = state.randomEffects;
+
+ if (sunbeamChance <= 0 || Math.random() * 100 >= sunbeamChance) {
+ return {
+ nextGrowth,
+ event: null,
+ awardedPlayer: null,
+ };
+ }
+
+ const awardedPlayer = Math.floor(Math.random() * state.players.length);
+ nextGrowth[awardedPlayer] += 1;
+
+ return {
+ nextGrowth,
+ awardedPlayer,
+ event: `${state.players[awardedPlayer].name} caught a stray sunbeam and gains +1 growth next round.`,
+ };
+}
+
+export function maybeRollDisease(state: GameState) {
+ const { diseaseChance } = state.randomEffects;
+ if (diseaseChance <= 0 || Math.random() * 100 >= diseaseChance) {
+ return {
+ killedKeys: [],
+ event: null,
+ };
+ }
+
+ const childrenMap = buildChildrenMap(state);
+ const parentMap = buildParentMap(state);
+ const twigKeys = Array.from(state.nodes.keys()).filter((nodeKey) => {
+ const { row } = parseKey(nodeKey);
+ return row !== state.config.rows - 1 && !childrenMap.has(nodeKey) && parentMap.has(nodeKey);
+ });
+
+ if (twigKeys.length === 0) {
+ return {
+ killedKeys: [],
+ event: null,
+ };
+ }
+
+ const shuffled = shuffleArray(twigKeys);
+ const killCount = Math.min(shuffled.length, 1 + Math.floor(Math.random() * Math.min(3, shuffled.length)));
+ const killedKeys = shuffled.slice(0, killCount);
+
+ return {
+ killedKeys,
+ event: `Disease spread through ${killCount} twig node${killCount === 1 ? "" : "s"} before the next round.`,
+ };
+}
diff --git a/src/state.ts b/src/state.ts
new file mode 100644
index 0000000..8fa075c
--- /dev/null
+++ b/src/state.ts
@@ -0,0 +1,194 @@
+import { PLAYER_PALETTE, STARTING_POINTS } from "./constants";
+import type { GameState, Player, SetupState } from "./types";
+import { keyFor, shuffleArray } from "./utils";
+
+export function createDefaultPaletteOrder(playerCount: number) {
+ return Array.from({ length: playerCount }, (_, index) => index % PLAYER_PALETTE.length);
+}
+
+export function createDefaultSeedInputs(playerCount: number, columns: number, startingNodesPerPlayer: number) {
+ const totalSeeds = playerCount * startingNodesPerPlayer;
+ const positions = Array.from({ length: totalSeeds }, (_, index) => Math.floor(((index + 0.5) * columns) / totalSeeds));
+
+ return Array.from({ length: playerCount }, (_, playerId) => {
+ const start = playerId * startingNodesPerPlayer;
+ return positions
+ .slice(start, start + startingNodesPerPlayer)
+ .map((column) => String(column + 1))
+ .join(", ");
+ });
+}
+
+function pickNearestOpenColumn(preferredColumn: number, columns: number, usedColumns: Set) {
+ if (!usedColumns.has(preferredColumn)) {
+ return preferredColumn;
+ }
+
+ for (let distance = 1; distance < columns; distance += 1) {
+ const left = preferredColumn - distance;
+ if (left >= 0 && !usedColumns.has(left)) {
+ return left;
+ }
+
+ const right = preferredColumn + distance;
+ if (right < columns && !usedColumns.has(right)) {
+ return right;
+ }
+ }
+
+ return preferredColumn;
+}
+
+export function createRandomizedSeedInputs(playerCount: number, columns: number, startingNodesPerPlayer: number) {
+ const zoneWidth = columns / playerCount;
+ const usedColumns = new Set();
+
+ return Array.from({ length: playerCount }, (_, playerId) => {
+ const picks: number[] = [];
+
+ for (let seedIndex = 0; seedIndex < startingNodesPerPlayer; seedIndex += 1) {
+ const localRatio = (seedIndex + 1) / (startingNodesPerPlayer + 1);
+ const center = (playerId + localRatio) * zoneWidth;
+ const subZoneWidth = zoneWidth / (startingNodesPerPlayer + 1);
+ const maxJitter = Math.max(0.35, Math.min(zoneWidth * 0.22, subZoneWidth * 0.42));
+ const jitter = (Math.random() * 2 - 1) * maxJitter;
+ const preferredColumn = Math.max(0, Math.min(columns - 1, Math.round(center + jitter - 0.5)));
+ const chosenColumn = pickNearestOpenColumn(preferredColumn, columns, usedColumns);
+
+ usedColumns.add(chosenColumn);
+ picks.push(chosenColumn + 1);
+ }
+
+ return picks.sort((left, right) => left - right).join(", ");
+ });
+}
+
+export function getMaxStartingNodesPerPlayer(playerCount: number, columns: number) {
+ return Math.max(1, Math.floor(columns / playerCount));
+}
+
+export function createSetupState(
+ playerCount = 3,
+ columns = 18,
+ rows = 16,
+ startingNodesPerPlayer = 1,
+ sunbeamChance = 0,
+ diseaseChance = 0,
+ seedInputs: string[] | null = null,
+ paletteOrder: number[] | null = null,
+ shuffleTurnOrder = true,
+): SetupState {
+ const clampedSeeds = Math.min(startingNodesPerPlayer, getMaxStartingNodesPerPlayer(playerCount, columns));
+ const defaults = createDefaultSeedInputs(playerCount, columns, clampedSeeds);
+ const paletteDefaults = createDefaultPaletteOrder(playerCount);
+
+ return {
+ playerCount,
+ columns,
+ rows,
+ startingNodesPerPlayer: clampedSeeds,
+ sunbeamChance,
+ diseaseChance,
+ seedInputs: Array.from({ length: playerCount }, (_, index) => seedInputs?.[index] ?? defaults[index]),
+ paletteOrder: Array.from({ length: playerCount }, (_, index) => paletteOrder?.[index] ?? paletteDefaults[index]),
+ shuffleTurnOrder,
+ };
+}
+
+export function createPlayers(playerCount: number, paletteOrder = createDefaultPaletteOrder(playerCount)): Player[] {
+ return Array.from({ length: playerCount }, (_, index) => {
+ const palette = PLAYER_PALETTE[paletteOrder[index] % PLAYER_PALETTE.length];
+ return {
+ id: index,
+ name: `Player ${index + 1}`,
+ color: palette.primary,
+ glow: palette.glow,
+ totalScore: 0,
+ roundScore: 0,
+ growthPoints: STARTING_POINTS,
+ bankedPoints: 0,
+ bonusPoints: 0,
+ passed: false,
+ };
+ });
+}
+
+export function normalizeSeedInputs(setup: SetupState) {
+ const assigned = new Set();
+ const fallback = createDefaultSeedInputs(setup.playerCount, setup.columns, setup.startingNodesPerPlayer)
+ .map((input) => input.split(",").map((part) => Number(part.trim()) - 1));
+
+ return Array.from({ length: setup.playerCount }, (_, playerId) => {
+ const requested = (setup.seedInputs[playerId] ?? "")
+ .split(",")
+ .map((part) => Number.parseInt(part.trim(), 10) - 1)
+ .filter((column) => Number.isInteger(column) && column >= 0 && column < setup.columns);
+
+ const uniqueColumns: number[] = [];
+ requested.forEach((column) => {
+ if (!assigned.has(column) && uniqueColumns.length < setup.startingNodesPerPlayer) {
+ assigned.add(column);
+ uniqueColumns.push(column);
+ }
+ });
+
+ fallback[playerId].forEach((column) => {
+ if (!assigned.has(column) && uniqueColumns.length < setup.startingNodesPerPlayer) {
+ assigned.add(column);
+ uniqueColumns.push(column);
+ }
+ });
+
+ for (let column = 0; column < setup.columns && uniqueColumns.length < setup.startingNodesPerPlayer; column += 1) {
+ if (!assigned.has(column)) {
+ assigned.add(column);
+ uniqueColumns.push(column);
+ }
+ }
+
+ return uniqueColumns;
+ });
+}
+
+export function createInitialState(setup: SetupState): GameState {
+ const playerPaletteOrder = setup.shuffleTurnOrder ? shuffleArray(setup.paletteOrder) : [...setup.paletteOrder];
+ const players = createPlayers(setup.playerCount, playerPaletteOrder);
+ const nodes = new Map();
+ const edges = [];
+ const seedColumnsByPlayer = normalizeSeedInputs(setup);
+
+ seedColumnsByPlayer.forEach((seedColumns, index) => {
+ seedColumns.forEach((column) => {
+ nodes.set(keyFor(setup.rows - 1, column), { ownerId: index });
+ });
+ });
+
+ return {
+ config: {
+ columns: setup.columns,
+ rows: setup.rows,
+ playerCount: setup.playerCount,
+ startingNodesPerPlayer: setup.startingNodesPerPlayer,
+ playerPaletteOrder,
+ },
+ players,
+ nodes,
+ edges,
+ round: 1,
+ activePlayerId: 0,
+ turnMoves: [],
+ selectedSource: null,
+ availableTargets: [],
+ animation: null,
+ randomEffects: {
+ sunbeamChance: setup.sunbeamChance,
+ diseaseChance: setup.diseaseChance,
+ },
+ gameOver: false,
+ history: [
+ `Round 1 begins on a ${setup.columns}x${setup.rows} board with ${setup.startingNodesPerPlayer} starting node${setup.startingNodesPerPlayer === 1 ? "" : "s"} each.`,
+ `${setup.shuffleTurnOrder ? "Turn order was randomized for this game." : "Turn order uses the setup order."}`,
+ ],
+ roundSummary: null,
+ };
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..8f93e42
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,184 @@
+export type PlayerId = number;
+export type NodeKey = `${number}:${number}`;
+
+export type PlayerPalette = {
+ name: string;
+ primary: string;
+ glow: string;
+};
+
+export type Position = {
+ row: number;
+ column: number;
+};
+
+export type SetupState = {
+ playerCount: number;
+ columns: number;
+ rows: number;
+ startingNodesPerPlayer: number;
+ sunbeamChance: number;
+ diseaseChance: number;
+ seedInputs: string[];
+ paletteOrder: number[];
+ shuffleTurnOrder: boolean;
+};
+
+export type Player = {
+ id: PlayerId;
+ name: string;
+ color: string;
+ glow: string;
+ totalScore: number;
+ roundScore: number;
+ growthPoints: number;
+ bankedPoints: number;
+ bonusPoints: number;
+ passed: boolean;
+};
+
+export type GameConfig = {
+ columns: number;
+ rows: number;
+ playerCount: number;
+ startingNodesPerPlayer: number;
+ playerPaletteOrder: number[];
+};
+
+export type NodeState = {
+ ownerId: PlayerId;
+};
+
+export type Edge = {
+ from: Position;
+ to: Position;
+ ownerId: PlayerId;
+};
+
+export type GrowDirection = "vertical" | "left" | "right";
+
+export type GrowTarget = Position & {
+ cost: number;
+ direction: GrowDirection;
+};
+
+export type ShiftMove = {
+ type: "shift";
+ cost: number;
+ direction: "left" | "right";
+ movedNodes: Array<{
+ fromKey: NodeKey;
+ toKey: NodeKey;
+ row: number;
+ fromColumn: number;
+ toColumn: number;
+ }>;
+ movedEdges: Array<{
+ before: Edge;
+ after: Edge;
+ }>;
+ undoKeys: NodeKey[];
+ selectKey: NodeKey;
+};
+
+export type GrowMove = {
+ type: "grow";
+ from: Position;
+ to: Position;
+ cost: number;
+ targetKey: NodeKey;
+ undoKeys: NodeKey[];
+ selectKey: NodeKey;
+};
+
+export type TurnMove = GrowMove | ShiftMove;
+
+export type ColumnEnergy = {
+ column: number;
+ terminalRow: number;
+ intercepted: boolean;
+ ownerId: PlayerId | null;
+ hitNode: Position | null;
+ rootKey: NodeKey | null;
+ branchNodes: Position[];
+ branchEdges: Array<{ from: Position; to: Position }>;
+};
+
+export type RootBurst = {
+ key: NodeKey;
+ playerId: PlayerId;
+ count: number;
+};
+
+export type EnergySimulation = {
+ scores: number[];
+ columns: ColumnEnergy[];
+ rootBursts: RootBurst[];
+};
+
+export type RoundAnimationTrace = {
+ playerId: PlayerId;
+ verticalCells: Position[];
+ ray: { x: number; y: number };
+ rootKey: NodeKey | null;
+ branchNodes: Position[];
+};
+
+export type RoundAnimation = {
+ phase: "sunlight" | "branches" | "bonus" | "events";
+ columns: ColumnEnergy[];
+ traces: RoundAnimationTrace[];
+ rootBursts: RootBurst[];
+ sunbeamPlayerId: PlayerId | null;
+ bonusTrace: RoundAnimationTrace | null;
+ bonusBurst: RootBurst | null;
+ diseaseKeys: NodeKey[];
+};
+
+export type ColumnResult = {
+ column: number;
+ ownerId: PlayerId | null;
+ topRow: number | null;
+ tied: boolean;
+};
+
+export type RoundSummary = {
+ scores: number[];
+ columnResults: ColumnResult[];
+ event: string | null;
+};
+
+export type ScoreSnapshot = {
+ totalScore: number;
+ roundScore: number;
+ growthPoints: number;
+ bankedPoints: number;
+};
+
+export type ColumnLeader = {
+ ownerId: PlayerId | null;
+ row: number | null;
+ tied: boolean;
+};
+
+export type RandomEffects = {
+ sunbeamChance: number;
+ diseaseChance: number;
+};
+
+export type GameState = {
+ config: GameConfig;
+ players: Player[];
+ nodes: Map;
+ edges: Edge[];
+ round: number;
+ activePlayerId: PlayerId;
+ turnMoves: TurnMove[];
+ selectedSource: NodeKey | null;
+ availableTargets: GrowTarget[];
+ animation: RoundAnimation | null;
+ randomEffects: RandomEffects;
+ gameOver: boolean;
+ history: string[];
+ roundSummary: RoundSummary | null;
+};
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..c53ce37
--- /dev/null
+++ b/src/utils.ts
@@ -0,0 +1,41 @@
+import type { NodeKey, Position } from "./types";
+
+export function keyFor(row: number, column: number): NodeKey {
+ return `${row}:${column}`;
+}
+
+export function parseKey(key: string): Position {
+ const [row, column] = key.split(":").map(Number);
+ return { row, column };
+}
+
+export function hexToRgb(hex: string) {
+ const value = hex.replace("#", "");
+ const normalized = value.length === 3 ? value.split("").map((part) => part + part).join("") : value;
+ const int = Number.parseInt(normalized, 16);
+ return {
+ r: (int >> 16) & 255,
+ g: (int >> 8) & 255,
+ b: int & 255,
+ };
+}
+
+export function tint(hex: string, alpha = 0.16) {
+ const { r, g, b } = hexToRgb(hex);
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+}
+
+export function wait(milliseconds: number) {
+ return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
+}
+
+export function shuffleArray(items: T[]) {
+ const next = [...items];
+
+ for (let index = next.length - 1; index > 0; index -= 1) {
+ const swapIndex = Math.floor(Math.random() * (index + 1));
+ [next[index], next[swapIndex]] = [next[swapIndex], next[index]];
+ }
+
+ return next;
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..cbe652d
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+declare module "*.css";
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..725b6eb
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": false,
+ "noEmit": true,
+ "isolatedModules": true,
+ "esModuleInterop": true
+ },
+ "include": ["src"]
+}