diff --git a/src/components/pente/GameOverDrawer.jsx b/src/components/pente/GameOverDrawer.jsx
new file mode 100644
index 0000000..c7eea1b
--- /dev/null
+++ b/src/components/pente/GameOverDrawer.jsx
@@ -0,0 +1,160 @@
+import React from 'react'
+import Link from 'next/link'
+import { track } from 'src/lib/analytics'
+import { PLAYER_COLORS } from 'src/lib/pente/constants'
+
+/**
+ * The bottom drawer shown after a local (non-multiplayer) game ends —
+ * winner banner, play-again/analyze actions, the move-by-move analysis
+ * list, and the consulting CTA. Pure presentation over already-computed
+ * game state; every mutation goes back up through the callback props.
+ */
+export default function GameOverDrawer({
+ gameOver,
+ isOnline,
+ winner,
+ gameMode,
+ resetLocalBoard,
+ moveHistory,
+ gameAnalysis,
+ handleAnalyze,
+ analysisViewTurn,
+ setAnalysisViewTurn,
+ consultingCtaDismissed,
+ setConsultingCtaDismissed,
+ botEnabled,
+ humanColor,
+}) {
+ if (!gameOver || isOnline) return null
+
+ return (
+
+
+
+
+ {PLAYER_COLORS[winner]?.name || 'Unknown'} Wins!
+ {gameMode?.teams && (
+
+ (Team {gameMode.teams.findIndex(t => t.includes(winner)) + 1})
+
+ )}
+
+
+
+ Play Again
+
+ {moveHistory.length > 0 && !gameAnalysis && (!gameMode || gameMode.key === 'classic') && (
+
+ Analyze
+
+ )}
+
+
+
+ track('cta_click', {
+ page: '/posts/pente',
+ metadata: { location: 'pente_ingame_tip' },
+ beacon: true,
+ })
+ }
+ className="block mb-2.5 text-sm text-candy-500 hover:text-candy-400 transition-colors"
+ >
+ Enjoying Pente? Support development →
+
+
+ {gameAnalysis && (
+
+ {gameAnalysis.map((entry, idx) => {
+ const isBlunder = entry.annotation.includes('Blunder');
+ const isMistake = entry.annotation.includes('Mistake');
+ const isViewing = analysisViewTurn === idx;
+ const mover = moveHistory[idx]?.moveMadeBy;
+ return (
+
setAnalysisViewTurn(isViewing ? null : idx)}
+ className={`w-full text-left text-xs px-3 py-1.5 rounded-lg transition-colors ${
+ isViewing
+ ? 'bg-forest-700/60 border border-forest-500'
+ : isBlunder
+ ? 'bg-red-900/30 border border-red-700/30 hover:bg-red-900/50'
+ : isMistake
+ ? 'bg-yellow-900/20 border border-yellow-700/30 hover:bg-yellow-900/40'
+ : 'bg-forest-900/40 border border-forest-700/20 hover:bg-forest-800/40'
+ }`}
+ >
+ #{idx + 1}
+
+ {PLAYER_COLORS[mover]?.name || '?'}
+
+
+ {entry.annotation}
+
+
+ {(entry.evaluation / 1000).toFixed(1)}k
+
+
+ );
+ })}
+ {analysisViewTurn !== null && (
+
+ Viewing move #{analysisViewTurn + 1}.{' '}
+ setAnalysisViewTurn(null)}
+ className="text-cyan-400 hover:text-cyan-300"
+ >
+ Back to final
+
+
+ )}
+
+ )}
+
+ {!consultingCtaDismissed && (
+
+
+ Enjoying the game? I build stuff like this professionally.{' '}
+ {
+ const result = botEnabled
+ ? (winner === humanColor ? 'win' : 'loss')
+ : 'win';
+ track('consulting_from_game', {
+ page: '/posts/pente',
+ metadata: { game: 'pente', result },
+ beacon: true,
+ });
+ }}
+ className="font-semibold text-candy-300 hover:text-candy-200 transition-colors whitespace-nowrap"
+ >
+ Work with me →
+
+
+
setConsultingCtaDismissed(true)}
+ aria-label="Dismiss"
+ className="flex-shrink-0 text-forest-500 hover:text-forest-300 transition-colors text-sm leading-none"
+ >
+ ✕
+
+
+ )}
+
+ )
+}
diff --git a/src/components/pente/RulesPanel.jsx b/src/components/pente/RulesPanel.jsx
new file mode 100644
index 0000000..91222fe
--- /dev/null
+++ b/src/components/pente/RulesPanel.jsx
@@ -0,0 +1,54 @@
+import React from 'react'
+import { PLAYER_COLORS } from 'src/lib/pente/constants'
+import { MODE_RULES } from 'src/lib/pente/boardDisplay'
+
+/**
+ * The collapsible rules blurb under the header. Pure presentation over the
+ * active game mode — no state of its own, no callbacks. Rendered only while
+ * the parent's `showRules` toggle is on.
+ */
+export default function RulesPanel({ gameMode }) {
+ const rules = gameMode && MODE_RULES[gameMode.key]
+
+ return (
+
+
+ {rules
+ ? {rules.title}
+ : <>19×19 board. First to five-in-a-row or{' '}
+ five captured pairs wins.>
+ }
+
+
+
+ Capture: {' '}
+ {rules
+ ? rules.captures
+ : 'Bracket exactly two opponent stones with yours in a straight line.'
+ }
+
+
+ Five in a row: {' '}
+ Any direction — horizontal, vertical, or diagonal.
+ {gameMode?.teams && (
+ (Your stones only — teammate stones don’t count.)
+ )}
+
+ {(!gameMode || gameMode.key === 'classic') && (
+
+ Pro rule: {' '}
+ First player’s second stone must be ≥3 intersections from center.
+
+ )}
+ {gameMode?.teams && (
+
+ Teams: {' '}
+ {PLAYER_COLORS[gameMode.teams[0][0]]?.name} + {PLAYER_COLORS[gameMode.teams[0][1]]?.name} vs{' '}
+ {PLAYER_COLORS[gameMode.teams[1][0]]?.name} + {PLAYER_COLORS[gameMode.teams[1][1]]?.name}.
+ Captures are shared within your team.
+
+ )}
+
+
+ )
+}
diff --git a/src/components/pente/TurnStatusBar.jsx b/src/components/pente/TurnStatusBar.jsx
new file mode 100644
index 0000000..df29837
--- /dev/null
+++ b/src/components/pente/TurnStatusBar.jsx
@@ -0,0 +1,70 @@
+import React from 'react'
+import { WHITE, PLAYER_COLORS } from 'src/lib/pente/constants'
+
+/**
+ * Turn indicator + per-player score/capture row under the header. Pure
+ * presentation over already-derived state — no game logic, no callbacks.
+ */
+export default function TurnStatusBar({
+ showLobby,
+ gameStatus,
+ currentPlayer,
+ playerName,
+ botEnabled,
+ humanColor,
+ botThinking,
+ moveCount,
+ lastBotStats,
+ activePlayers,
+ gameMode,
+ captures,
+}) {
+ if (showLobby || gameStatus === 'error') return null
+
+ return (
+
+ {/* Turn dot */}
+
+
+ {playerName}
+ {botEnabled && currentPlayer !== humanColor ? ' (Bot)' : ''}
+ {botThinking ? '…' : '’s turn'}
+
+ {moveCount > 0 && (
+
#{moveCount}
+ )}
+ {lastBotStats && botEnabled && !botThinking && (
+
+ d{lastBotStats.depth} {lastBotStats.nodes > 1000 ? `${(lastBotStats.nodes / 1000).toFixed(1)}k` : lastBotStats.nodes}n
+
+ )}
+
+ {/* Score + captures — right-aligned */}
+
+ {/* Compact score display for all active players */}
+ {activePlayers.map((p, i) => (
+
+ {i > 0 && {i === 1 ? '–' : ':'} }
+
+
+ {gameMode?.teams
+ ? (captures[`team${gameMode.teams.findIndex(t => t.includes(p))}`] || 0)
+ : (captures[p] || 0)
+ }
+
+ /{gameMode?.captureThreshold || 5}
+
+ ))}
+
+
+ )
+}
diff --git a/src/lib/pente/boardDisplay.js b/src/lib/pente/boardDisplay.js
new file mode 100644
index 0000000..304354e
--- /dev/null
+++ b/src/lib/pente/boardDisplay.js
@@ -0,0 +1,76 @@
+import { BLACK, WHITE, RED, BLUE, PLAYER_COLORS } from 'src/lib/pente/constants'
+
+/**
+ * Pure presentation helpers for the Pente board UI — cell/style class
+ * lookups plus the static mode-preset and rules-copy tables. No React, no
+ * game state. Split out of src/pages/posts/pente.js so the page component
+ * stops growing with content that never changes at runtime.
+ */
+
+// Map cell value to CSS class
+export function cellClass(cell) {
+ switch (cell) {
+ case BLACK: return 'black'
+ case WHITE: return 'white'
+ case RED: return 'red'
+ case BLUE: return 'blue'
+ default: return ''
+ }
+}
+
+// Map cell value to capture-eject CSS class
+export function captureClass(color) {
+ switch (color) {
+ case BLACK: return 'capture-black'
+ case WHITE: return 'capture-white'
+ case RED: return 'capture-red'
+ case BLUE: return 'capture-blue'
+ default: return 'capture-black'
+ }
+}
+
+// Get hover class for current player
+export function hoverClass(player) {
+ return `board-hover-${PLAYER_COLORS[player]?.css || 'black'}`
+}
+
+// Game mode presets for the mode selector
+export const MODE_PRESETS = [
+ { key: 'local', label: 'Local', modeKey: null, bots: false },
+ { key: 'bot1v1', label: 'vs Bot', modeKey: 'classic', bots: true },
+ { key: 'bot4ffa', label: 'vs 3 Bots', modeKey: 'ffa4', bots: true },
+ { key: 'bot2v2', label: '2v2 Bots', modeKey: 'team2v2', bots: true },
+ { key: 'online', label: 'Online', modeKey: null, bots: false },
+]
+
+// Rules text per game mode
+export const MODE_RULES = {
+ classic: {
+ title: 'Classic Pente',
+ captures: 'Bracket exactly two opponent stones with yours in a straight line to capture them.',
+ },
+ ffa4: {
+ title: 'Free-for-All (4 Players)',
+ captures: 'You can capture any opponent\'s pair. All three other players are opponents. Pairs must be the same color — you can\'t capture a mixed pair.',
+ },
+ team2v2: {
+ title: '2v2 Team Pente',
+ captures: 'You and your teammate share a capture count. Your teammate\'s stones count as brackets for captures — their stone at one end and yours at the other can capture an opponent pair between you. Five-in-a-row must be your stones only.',
+ },
+}
+
+export function modeBtnClass(active) {
+ return `px-3 py-2 text-xs transition-colors ${
+ active
+ ? 'bg-forest-700/70 text-white'
+ : 'bg-forest-900/60 text-forest-400 hover:text-forest-200'
+ }`
+}
+
+export function actionBtnClass(active = false) {
+ return `text-xs px-3 py-2 rounded-lg border transition-colors min-h-[36px] ${
+ active
+ ? 'bg-cyan-900/50 text-cyan-300 border-cyan-600/50'
+ : 'bg-forest-900/60 text-forest-400 hover:text-forest-200 border-forest-700/40 hover:border-forest-500'
+ }`
+}
diff --git a/src/pages/posts/pente.js b/src/pages/posts/pente.js
index dfca854..6e8bc3a 100644
--- a/src/pages/posts/pente.js
+++ b/src/pages/posts/pente.js
@@ -30,58 +30,17 @@ import QueueBanner from 'src/components/pente/QueueBanner';
import MatchConfirmModal from 'src/components/pente/MatchConfirmModal';
import useBoardTheme from 'src/hooks/useBoardTheme';
import BoardCustomizer from 'src/components/pente/BoardCustomizer';
-
-// Map cell value to CSS class
-function cellClass(cell) {
- switch (cell) {
- case BLACK: return 'black';
- case WHITE: return 'white';
- case RED: return 'red';
- case BLUE: return 'blue';
- default: return '';
- }
-}
-
-// Map cell value to capture-eject CSS class
-function captureClass(color) {
- switch (color) {
- case BLACK: return 'capture-black';
- case WHITE: return 'capture-white';
- case RED: return 'capture-red';
- case BLUE: return 'capture-blue';
- default: return 'capture-black';
- }
-}
-
-// Get hover class for current player
-function hoverClass(player) {
- return `board-hover-${PLAYER_COLORS[player]?.css || 'black'}`;
-}
-
-// Game mode presets for the mode selector
-const MODE_PRESETS = [
- { key: 'local', label: 'Local', modeKey: null, bots: false },
- { key: 'bot1v1', label: 'vs Bot', modeKey: 'classic', bots: true },
- { key: 'bot4ffa', label: 'vs 3 Bots', modeKey: 'ffa4', bots: true },
- { key: 'bot2v2', label: '2v2 Bots', modeKey: 'team2v2', bots: true },
- { key: 'online', label: 'Online', modeKey: null, bots: false },
-];
-
-// Rules text per game mode
-const MODE_RULES = {
- classic: {
- title: 'Classic Pente',
- captures: 'Bracket exactly two opponent stones with yours in a straight line to capture them.',
- },
- ffa4: {
- title: 'Free-for-All (4 Players)',
- captures: 'You can capture any opponent\'s pair. All three other players are opponents. Pairs must be the same color \u2014 you can\'t capture a mixed pair.',
- },
- team2v2: {
- title: '2v2 Team Pente',
- captures: 'You and your teammate share a capture count. Your teammate\'s stones count as brackets for captures \u2014 their stone at one end and yours at the other can capture an opponent pair between you. Five-in-a-row must be your stones only.',
- },
-};
+import TurnStatusBar from 'src/components/pente/TurnStatusBar';
+import RulesPanel from 'src/components/pente/RulesPanel';
+import GameOverDrawer from 'src/components/pente/GameOverDrawer';
+import {
+ cellClass,
+ captureClass,
+ hoverClass,
+ MODE_PRESETS,
+ modeBtnClass,
+ actionBtnClass,
+} from 'src/lib/pente/boardDisplay';
const GameBoard = () => {
const router = useRouter();
@@ -635,22 +594,6 @@ const GameBoard = () => {
const playerName_ = PLAYER_COLORS[currentPlayer]?.name || 'Unknown';
const showEval = !isOnline && !showLobby && (!gameMode || gameMode.key === 'classic');
- // ─────────────────────────────────────────────────────────────────────────
- // Style helpers
- const modeBtn = (active) =>
- `px-3 py-2 text-xs transition-colors ${
- active
- ? 'bg-forest-700/70 text-white'
- : 'bg-forest-900/60 text-forest-400 hover:text-forest-200'
- }`;
-
- const actionBtn = (active = false) =>
- `text-xs px-3 py-2 rounded-lg border transition-colors min-h-[36px] ${
- active
- ? 'bg-cyan-900/50 text-cyan-300 border-cyan-600/50'
- : 'bg-forest-900/60 text-forest-400 hover:text-forest-200 border-forest-700/40 hover:border-forest-500'
- }`;
-
// ─────────────────────────────────────────────────────────────────────────
const penteZone = getZone(gameElo);
@@ -694,7 +637,7 @@ const GameBoard = () => {
{MODE_PRESETS.map((preset, i) => (
0 ? 'border-l border-forest-700/40' : ''}`}
+ className={`${modeBtnClass(modePreset === preset.key)} ${i > 0 ? 'border-l border-forest-700/40' : ''}`}
onClick={() => switchPreset(preset.key)}
>
{preset.label}
@@ -706,7 +649,7 @@ const GameBoard = () => {
{!isOnline && !gameOver && (!gameMode || gameMode.key === 'classic') && (
{
)}
{!isOnline && (
-
+
New
)}
setShowCustomizer(s => !s)}
title="Board style"
aria-label="Customize board style"
@@ -728,7 +671,7 @@ const GameBoard = () => {
🎨
setShowRules(r => !r)}
title="Rules"
>
@@ -758,52 +701,20 @@ const GameBoard = () => {
)}
{/* Row 2 — Turn indicator + score + captures */}
- {!showLobby && mp.gameStatus !== 'error' && (
-
- {/* Turn dot */}
-
-
- {playerName_}
- {botEnabled && currentPlayer !== humanColor ? ' (Bot)' : ''}
- {botThinking ? '\u2026' : '\u2019s turn'}
-
- {moveCount > 0 && (
-
#{moveCount}
- )}
- {lastBotStats && botEnabled && !botThinking && (
-
- d{lastBotStats.depth} {lastBotStats.nodes > 1000 ? `${(lastBotStats.nodes / 1000).toFixed(1)}k` : lastBotStats.nodes}n
-
- )}
-
- {/* Score + captures — right-aligned */}
-
- {/* Compact score display for all active players */}
- {activePlayers.map((p, i) => (
-
- {i > 0 && {i === 1 ? '\u2013' : ':'} }
-
-
- {gameMode?.teams
- ? (captures[`team${gameMode.teams.findIndex(t => t.includes(p))}`] || 0)
- : (captures[p] || 0)
- }
-
- /{gameMode?.captureThreshold || 5}
-
- ))}
-
-
- )}
+
{/* Multiplayer status */}
{isOnline && mp.gameStatus !== 'loading' && mp.gameStatus !== 'error' && (
@@ -867,47 +778,7 @@ const GameBoard = () => {
)}
{/* Collapsible rules */}
- {showRules && (
-
-
- {gameMode && MODE_RULES[gameMode.key]
- ? {MODE_RULES[gameMode.key].title}
- : <>19\u00d719 board. First to five-in-a-row or{' '}
- five captured pairs wins.>
- }
-
-
-
- Capture: {' '}
- {gameMode && MODE_RULES[gameMode.key]
- ? MODE_RULES[gameMode.key].captures
- : 'Bracket exactly two opponent stones with yours in a straight line.'
- }
-
-
- Five in a row: {' '}
- Any direction — horizontal, vertical, or diagonal.
- {gameMode?.teams && (
- (Your stones only — teammate stones don’t count.)
- )}
-
- {(!gameMode || gameMode.key === 'classic') && (
-
- Pro rule: {' '}
- First player’s second stone must be \u22653 intersections from center.
-
- )}
- {gameMode?.teams && (
-
- Teams: {' '}
- {PLAYER_COLORS[gameMode.teams[0][0]]?.name} + {PLAYER_COLORS[gameMode.teams[0][1]]?.name} vs{' '}
- {PLAYER_COLORS[gameMode.teams[1][0]]?.name} + {PLAYER_COLORS[gameMode.teams[1][1]]?.name}.
- Captures are shared within your team.
-
- )}
-
-
- )}
+ {showRules && }
{/* ══════════════════════════════════════════════════════════════
@@ -1049,136 +920,22 @@ const GameBoard = () => {
{/* ══════════════════════════════════════════════════════════════
GAME-OVER DRAWER
══════════════════════════════════════════════════════════════ */}
- {gameOver && !isOnline && (
-
-
-
-
- {PLAYER_COLORS[winner]?.name || 'Unknown'} Wins!
- {gameMode?.teams && (
-
- (Team {gameMode.teams.findIndex(t => t.includes(winner)) + 1})
-
- )}
-
-
-
- Play Again
-
- {moveHistory.length > 0 && !gameAnalysis && (!gameMode || gameMode.key === 'classic') && (
-
- Analyze
-
- )}
-
-
-
- track('cta_click', {
- page: '/posts/pente',
- metadata: { location: 'pente_ingame_tip' },
- beacon: true,
- })
- }
- className="block mb-2.5 text-sm text-candy-500 hover:text-candy-400 transition-colors"
- >
- Enjoying Pente? Support development →
-
-
- {gameAnalysis && (
-
- {gameAnalysis.map((entry, idx) => {
- const isBlunder = entry.annotation.includes('Blunder');
- const isMistake = entry.annotation.includes('Mistake');
- const isViewing = analysisViewTurn === idx;
- const mover = moveHistory[idx]?.moveMadeBy;
- return (
-
setAnalysisViewTurn(isViewing ? null : idx)}
- className={`w-full text-left text-xs px-3 py-1.5 rounded-lg transition-colors ${
- isViewing
- ? 'bg-forest-700/60 border border-forest-500'
- : isBlunder
- ? 'bg-red-900/30 border border-red-700/30 hover:bg-red-900/50'
- : isMistake
- ? 'bg-yellow-900/20 border border-yellow-700/30 hover:bg-yellow-900/40'
- : 'bg-forest-900/40 border border-forest-700/20 hover:bg-forest-800/40'
- }`}
- >
- #{idx + 1}
-
- {PLAYER_COLORS[mover]?.name || '?'}
-
-
- {entry.annotation}
-
-
- {(entry.evaluation / 1000).toFixed(1)}k
-
-
- );
- })}
- {analysisViewTurn !== null && (
-
- Viewing move #{analysisViewTurn + 1}.{' '}
- setAnalysisViewTurn(null)}
- className="text-cyan-400 hover:text-cyan-300"
- >
- Back to final
-
-
- )}
-
- )}
-
- {!consultingCtaDismissed && (
-
-
- Enjoying the game? I build stuff like this professionally.{' '}
- {
- const result = botEnabled
- ? (winner === humanColor ? 'win' : 'loss')
- : 'win';
- track('consulting_from_game', {
- page: '/posts/pente',
- metadata: { game: 'pente', result },
- beacon: true,
- });
- }}
- className="font-semibold text-candy-300 hover:text-candy-200 transition-colors whitespace-nowrap"
- >
- Work with me →
-
-
-
setConsultingCtaDismissed(true)}
- aria-label="Dismiss"
- className="flex-shrink-0 text-forest-500 hover:text-forest-300 transition-colors text-sm leading-none"
- >
- ✕
-
-
- )}
-
- )}
+
{/* Post-multiplayer game result */}
{isOnline && mp.gameStatus === 'finished' && (