https://svonberg.org/wp-content/uploads/2025/11/pente13.html
<!DOCTYPE html>
<html lang=”en”>
<head>
<meta charset=”UTF-8″>
<meta name=”viewport” content=”width=device-width, initial-scale=1.0″>
<title>Pente Master</title>
<style>
:root {
–bg-color: #f0f4f8;
–board-color: #e3c076;
–board-line: #4b3621;
–p1-color: #1a1a1a; /* Black */
–p2-color: #f2f2f2; /* White */
–accent: #3b82f6;
–text-main: #1f2937;
}
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; -webkit-user-select: none; }
body {
font-family: -apple-system, BlinkMacSystemFont, “Segoe UI”, Roboto, Helvetica, Arial, sans-serif;
background-color: var(–bg-color);
color: var(–text-main);
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
overflow: hidden;
}
header {
width: 100%;
padding: 1rem;
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
text-align: center;
z-index: 10;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
max-width: 900px;
}
h1 { font-size: 1.5rem; font-weight: 700; color: var(–accent); }
.controls {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
select, button {
padding: 0.5rem 0.8rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background: white;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
button:hover, select:hover { border-color: var(–accent); color: var(–accent); }
select:disabled { background: #f3f4f6; color: #9ca3af; cursor: not-allowed; border-color: #e5e7eb; }
button.primary {
background: var(–accent);
color: white;
border: none;
}
button.primary:hover { background: #2563eb; }
/* Game Info Panel */
.game-info {
display: flex;
width: 100%;
max-width: 600px;
justify-content: space-around;
padding: 1rem;
font-weight: 600;
}
.player-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: white;
min-width: 120px;
border: 2px solid transparent;
transition: all 0.3s;
}
.player-card.active {
border-color: var(–accent);
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.1);
transform: translateY(-2px);
}
.stone-indicator {
width: 24px;
height: 24px;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
margin-bottom: 4px;
border: 1px solid rgba(0,0,0,0.1);
}
.p1-stone { background: var(–p1-color); }
.p2-stone { background: var(–p2-color); }
.captures { font-size: 0.8rem; color: #6b7280; margin-top: 4px; }
.captures span { font-weight: bold; color: var(–text-main); font-size: 1rem; }
/* Game Area */
#game-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 10px;
position: relative;
}
canvas {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border-radius: 4px;
cursor: crosshair;
background-color: var(–board-color);
}
/* Modal & Toast */
#modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); display: none;
justify-content: center; align-items: center; z-index: 50;
backdrop-filter: blur(2px);
}
.modal {
background: white; padding: 2rem; border-radius: 1rem;
text-align: center; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
max-width: 90%; width: 400px;
}
.modal h2 { font-size: 2rem; margin-bottom: 1rem; color: var(–text-main); }
.modal p { margin-bottom: 1.5rem; color: #4b5563; }
#toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
background: #374151; color: white; padding: 10px 20px;
border-radius: 20px; font-size: 0.9rem; opacity: 0;
transition: opacity 0.3s; pointer-events: none; z-index: 100;
}
@media (min-width: 650px) {
header { flex-direction: row; justify-content: space-between; padding: 1rem 2rem; }
.controls { justify-content: flex-end; }
}
</style>
</head>
<body>
<header>
<h1>Pente</h1>
<div class=”controls”>
<select id=”grid-size” title=”Board Size”>
<option value=”9″>9×9 (Small)</option>
<option value=”13″>13×13 (Medium)</option>
<option value=”19″ selected>19×19 (Standard)</option>
</select>
<select id=”game-mode” title=”Game Mode”>
<option value=”hvh”>Human vs Human</option>
<option value=”hvai” selected>Human vs AI</option>
<option value=”aiva”>AI vs AI</option>
</select>
<select id=”play-as” title=”Play As (For Human vs AI)”>
<option value=”1″>P1 (Black)</option>
<option value=”2″>P2 (White)</option>
<option value=”random”>Random</option>
</select>
<button class=”primary” id=”reset-btn”>New Game</button>
</div>
</header>
<div class=”game-info”>
<div class=”player-card active” id=”p1-card”>
<div class=”stone-indicator p1-stone”></div>
<div id=”p1-name”>Player 1</div>
<div class=”captures”>Pairs: <span id=”p1-captures”>0</span>/5</div>
</div>
<div class=”player-card” id=”p2-card”>
<div class=”stone-indicator p2-stone”></div>
<div id=”p2-name”>CPU 1</div>
<div class=”captures”>Pairs: <span id=”p2-captures”>0</span>/5</div>
</div>
</div>
<div id=”game-container”>
<canvas id=”board”></canvas>
</div>
<div id=”toast”>Notification</div>
<div id=”modal-overlay”>
<div class=”modal”>
<h2 id=”winner-title”>Wins!</h2>
<p id=”win-reason”>Reason</p>
<button class=”primary” id=”modal-reset”>Play Again</button>
</div>
</div>
<script>
/**
* PENTE GAME LOGIC
*/
let BOARD_SIZE = 19;
const EMPTY = 0;
const P1 = 1; // Black
const P2 = 2; // White
// State
let board = [];
let currentPlayer = P1;
let gameActive = false;
let p1Captures = 0;
let p2Captures = 0;
let winningStones = [];
let lastMove = null;
// Configuration
let p1IsHuman = true;
let p2IsHuman = false;
// Canvas
const canvas = document.getElementById(‘board’);
const ctx = canvas.getContext(‘2d’);
let cellSize = 30;
let boardPadding = 30;
// DOM
const p1Card = document.getElementById(‘p1-card’);
const p2Card = document.getElementById(‘p2-card’);
const p1NameEl = document.getElementById(‘p1-name’);
const p2NameEl = document.getElementById(‘p2-name’);
const p1ScoreEl = document.getElementById(‘p1-captures’);
const p2ScoreEl = document.getElementById(‘p2-captures’);
const modeSelect = document.getElementById(‘game-mode’);
const gridSizeSelect = document.getElementById(‘grid-size’);
const playAsSelect = document.getElementById(‘play-as’);
const resetBtn = document.getElementById(‘reset-btn’);
const modalOverlay = document.getElementById(‘modal-overlay’);
const modalReset = document.getElementById(‘modal-reset’);
const toastEl = document.getElementById(‘toast’);
// AI Config
const AI_DELAY = 600;
let aiTimeout = null;
/* — INITIALIZATION — */
function initGame() {
// 1. Setup Grid
BOARD_SIZE = parseInt(gridSizeSelect.value);
board = Array(BOARD_SIZE).fill().map(() => Array(BOARD_SIZE).fill(EMPTY));
// 2. Setup Roles & Names
const mode = modeSelect.value;
let userPref = playAsSelect.value;
// Handle Random preference
if (mode === ‘hvai’ && userPref === ‘random’) {
userPref = Math.random() < 0.5 ? ‘1’ : ‘2’;
showToast(userPref === ‘1’ ? “Random: You are Player 1” : “Random: You are Player 2”);
}
if (mode === ‘hvh’) {
p1IsHuman = true;
p2IsHuman = true;
p1NameEl.innerText = “Player 1”;
p2NameEl.innerText = “Player 2”;
} else if (mode === ‘aiva’) {
p1IsHuman = false;
p2IsHuman = false;
p1NameEl.innerText = “CPU 1”;
p2NameEl.innerText = “CPU 2”;
} else { // hvai
if (userPref === ‘1’) {
p1IsHuman = true;
p2IsHuman = false;
p1NameEl.innerText = “Player 1”;
p2NameEl.innerText = “CPU 2”;
} else {
p1IsHuman = false;
p2IsHuman = true;
p1NameEl.innerText = “CPU 1”;
p2NameEl.innerText = “Player 2”;
}
}
// 3. Reset State
currentPlayer = P1;
p1Captures = 0;
p2Captures = 0;
lastMove = null;
winningStones = [];
gameActive = true;
updateScoreUI();
toggleActiveCard();
modalOverlay.style.display = ‘none’;
clearTimeout(aiTimeout);
resizeCanvas();
// 4. Check if first player is AI
checkAiTurn();
}
function checkAiTurn() {
if (!gameActive) return;
const isHumanTurn = (currentPlayer === P1 && p1IsHuman) || (currentPlayer === P2 && p2IsHuman);
if (!isHumanTurn) {
clearTimeout(aiTimeout);
aiTimeout = setTimeout(aiTurn, AI_DELAY);
}
}
function resizeCanvas() {
const container = document.getElementById(‘game-container’);
const size = Math.min(container.clientWidth, container.clientHeight);
const dpr = window.devicePixelRatio || 1;
const availableSize = size – 20;
cellSize = Math.floor(availableSize / (BOARD_SIZE + 1));
if (cellSize < 14) cellSize = 14;
const canvasSize = cellSize * (BOARD_SIZE + 1);
canvas.width = canvasSize * dpr;
canvas.height = canvasSize * dpr;
canvas.style.width = `${canvasSize}px`;
canvas.style.height = `${canvasSize}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
boardPadding = cellSize;
drawBoard();
}
/* — DRAWING — */
function drawBoard() {
ctx.fillStyle = ‘#e3c076’;
ctx.fillRect(0, 0, canvas.width, canvas.height); // Clear whole canvas
// Grid
ctx.strokeStyle = ‘#4b3621’;
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < BOARD_SIZE; i++) {
const pos = boardPadding + i * cellSize;
ctx.moveTo(pos, boardPadding);
ctx.lineTo(pos, boardPadding + (BOARD_SIZE – 1) * cellSize);
ctx.moveTo(boardPadding, pos);
ctx.lineTo(boardPadding + (BOARD_SIZE – 1) * cellSize, pos);
}
ctx.stroke();
// Stars
const points = getStarPoints(BOARD_SIZE);
ctx.fillStyle = ‘#4b3621’;
for (let r of points) {
for (let c of points) {
const x = boardPadding + c * cellSize;
const y = boardPadding + r * cellSize;
ctx.beginPath();
ctx.arc(x, y, cellSize * 0.12, 0, Math.PI * 2);
ctx.fill();
}
}
// Stones
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
if (board[r][c] !== EMPTY) {
drawStone(r, c, board[r][c]);
}
}
}
// Last Move
if (lastMove) {
const x = boardPadding + lastMove.c * cellSize;
const y = boardPadding + lastMove.r * cellSize;
ctx.fillStyle = ‘rgba(255, 0, 0, 0.5)’;
ctx.beginPath();
ctx.arc(x, y, cellSize * 0.15, 0, Math.PI * 2);
ctx.fill();
}
// Win Line
if (winningStones.length > 0) {
ctx.strokeStyle = ‘rgba(50, 255, 50, 0.8)’;
ctx.lineWidth = 4;
ctx.lineCap = ’round’;
ctx.beginPath();
const start = winningStones[0];
const end = winningStones[winningStones.length – 1];
ctx.moveTo(boardPadding + start.c * cellSize, boardPadding + start.r * cellSize);
ctx.lineTo(boardPadding + end.c * cellSize, boardPadding + end.r * cellSize);
ctx.stroke();
}
}
function getStarPoints(size) {
if (size === 19) return [3, 9, 15];
if (size === 13) return [3, 6, 9];
if (size === 9) return [2, 4, 6];
return [];
}
function drawStone(r, c, type) {
const x = boardPadding + c * cellSize;
const y = boardPadding + r * cellSize;
const radius = cellSize * 0.42;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
const grad = ctx.createRadialGradient(x – radius/3, y – radius/3, radius/10, x, y, radius);
if (type === P1) {
grad.addColorStop(0, ‘#555’);
grad.addColorStop(1, ‘#000’);
} else {
grad.addColorStop(0, ‘#fff’);
grad.addColorStop(1, ‘#d1d5db’);
}
ctx.fillStyle = grad;
ctx.fill();
ctx.shadowColor = ‘rgba(0,0,0,0.2)’;
ctx.shadowBlur = 2;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
ctx.stroke();
ctx.shadowColor = ‘transparent’;
}
/* — INPUT & LOGIC — */
canvas.addEventListener(‘mousedown’, handleInput);
canvas.addEventListener(‘touchstart’, (e) => {
e.preventDefault();
handleInput(e.touches[0]);
}, {passive: false});
function handleInput(e) {
if (!gameActive) return;
// Check if it is human turn
const isHumanTurn = (currentPlayer === P1 && p1IsHuman) || (currentPlayer === P2 && p2IsHuman);
if (!isHumanTurn) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssX = e.clientX – rect.left;
const cssY = e.clientY – rect.top;
// Convert to logical coordinates within canvas space
const x = cssX * (canvas.width / rect.width) / dpr;
const y = cssY * (canvas.height / rect.height) / dpr;
const c = Math.round((x – boardPadding) / cellSize);
const r = Math.round((y – boardPadding) / cellSize);
if (isValidMove(r, c)) {
makeMove(r, c);
}
}
function isValidMove(r, c) {
return r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && board[r][c] === EMPTY;
}
function makeMove(r, c) {
board[r][c] = currentPlayer;
lastMove = {r, c};
// 1. Check Captures
const captured = checkCaptures(r, c, currentPlayer);
if (captured > 0) {
if (currentPlayer === P1) p1Captures += captured;
else p2Captures += captured;
updateScoreUI();
showToast(`${getName(currentPlayer)} captured ${captured} pair(s)!`);
if ((currentPlayer === P1 && p1Captures >= 5) || (currentPlayer === P2 && p2Captures >= 5)) {
endGame(currentPlayer, “5 Pairs Captured”);
drawBoard();
return;
}
}
// 2. Check 5-in-a-row
const winningLine = checkWin(r, c, currentPlayer);
if (winningLine) {
winningStones = winningLine;
endGame(currentPlayer, “5 Stones in a Row”);
drawBoard();
return;
}
drawBoard();
// Switch Turn
currentPlayer = currentPlayer === P1 ? P2 : P1;
toggleActiveCard();
// Trigger Next
checkAiTurn();
}
function getName(player) {
return player === P1 ? p1NameEl.innerText : p2NameEl.innerText;
}
function toggleActiveCard() {
if (currentPlayer === P1) {
p1Card.classList.add(‘active’);
p2Card.classList.remove(‘active’);
} else {
p1Card.classList.remove(‘active’);
p2Card.classList.add(‘active’);
}
}
function updateScoreUI() {
p1ScoreEl.innerText = p1Captures;
p2ScoreEl.innerText = p2Captures;
}
function showToast(msg) {
toastEl.innerText = msg;
toastEl.style.opacity = 1;
setTimeout(() => { toastEl.style.opacity = 0; }, 2000);
}
function endGame(winner, reason) {
gameActive = false;
const winnerName = getName(winner);
document.getElementById(‘winner-title’).innerText = `${winnerName} Wins!`;
document.getElementById(‘win-reason’).innerText = reason;
modalOverlay.style.display = ‘flex’;
}
/* — MECHANICS — */
const DIRECTIONS = [
{dr: 0, dc: 1}, {dr: 1, dc: 0}, {dr: 1, dc: 1}, {dr: 1, dc: -1}
];
function checkCaptures(r, c, player) {
const opponent = player === P1 ? P2 : P1;
let totalCaptured = 0;
for (let d of DIRECTIONS) {
// Forward
if (getCell(r + d.dr, c + d.dc) === opponent &&
getCell(r + 2 * d.dr, c + 2 * d.dc) === opponent &&
getCell(r + 3 * d.dr, c + 3 * d.dc) === player) {
board[r + d.dr][c + d.dc] = EMPTY;
board[r + 2 * d.dr][c + 2 * d.dc] = EMPTY;
totalCaptured++;
}
// Backward
if (getCell(r – d.dr, c – d.dc) === opponent &&
getCell(r – 2 * d.dr, c – 2 * d.dc) === opponent &&
getCell(r – 3 * d.dr, c – 3 * d.dc) === player) {
board[r – d.dr][c – d.dc] = EMPTY;
board[r – 2 * d.dr][c – 2 * d.dc] = EMPTY;
totalCaptured++;
}
}
return totalCaptured;
}
function checkWin(r, c, player) {
for (let d of DIRECTIONS) {
let stones = [{r,c}];
let i = 1;
while (getCell(r + i * d.dr, c + i * d.dc) === player) {
stones.push({r: r + i * d.dr, c: c + i * d.dc}); i++;
}
let j = 1;
while (getCell(r – j * d.dr, c – j * d.dc) === player) {
stones.push({r: r – j * d.dr, c: c – j * d.dc}); j++;
}
if (stones.length >= 5) return stones;
}
return null;
}
function getCell(r, c) {
if (r < 0 || r >= BOARD_SIZE || c < 0 || c >= BOARD_SIZE) return -1;
return board[r][c];
}
/* — AI — */
// Scores
const S_WIN = 1000000;
const S_BLK_WIN = 500000;
const S_WIN_CAP = 200000;
const S_BLK_WIN_CAP = 150000;
const S_CAP = 10000;
const S_BLK_CAP = 8000;
const S_OPEN4 = 5000;
const S_OPEN3 = 1000;
function aiTurn() {
if (!gameActive) return;
const center = Math.floor(BOARD_SIZE / 2);
// First move optimization
let stones = 0;
for(let r=0;r<BOARD_SIZE;r++) for(let c=0;c<BOARD_SIZE;c++) if(board[r][c]) stones++;
if (stones === 0) { makeMove(center, center); return; }
const moves = filterRelevantMoves();
let bestMove = null;
let maxScore = -Infinity;
// If no relevant moves (rare), pick random empty
if (moves.length === 0) {
const all = getPossibleMoves();
if (all.length > 0) makeMove(all[0].r, all[0].c);
return;
}
for (let move of moves) {
const score = evaluateMove(move.r, move.c, currentPlayer, center);
const finalScore = score + Math.random() * 10; // Variation
if (finalScore > maxScore) {
maxScore = finalScore;
bestMove = move;
}
}
if (bestMove) makeMove(bestMove.r, bestMove.c);
}
function filterRelevantMoves() {
const occupied = [];
for(let r=0; r<BOARD_SIZE; r++) {
for(let c=0; c<BOARD_SIZE; c++) {
if(board[r][c] !== EMPTY) occupied.push({r,c});
}
}
const relevantSet = new Set();
const range = 2;
for (let stone of occupied) {
for (let dr = -range; dr <= range; dr++) {
for (let dc = -range; dc <= range; dc++) {
const nr = stone.r + dr;
const nc = stone.c + dc;
if (nr >= 0 && nr < BOARD_SIZE && nc >= 0 && nc < BOARD_SIZE && board[nr][nc] === EMPTY) {
relevantSet.add(`${nr},${nc}`);
}
}
}
}
return Array.from(relevantSet).map(s => {
const [r, c] = s.split(‘,’).map(Number);
return {r, c};
});
}
function getPossibleMoves() {
let m = [];
for (let r=0;r<BOARD_SIZE;r++) for (let c=0;c<BOARD_SIZE;c++) if (board[r][c]===EMPTY) m.push({r,c});
return m;
}
function evaluateMove(r, c, me, center) {
const opp = me === P1 ? P2 : P1;
let score = 0;
const dist = Math.abs(r – center) + Math.abs(c – center);
score -= dist * 2;
// Offensive
board[r][c] = me;
if (checkWin(r, c, me)) score += S_WIN;
const caps = countCaptures(r, c, me);
if (caps > 0) {
score += S_CAP * caps;
const myCaps = me === P1 ? p1Captures : p2Captures;
if (myCaps + caps >= 5) score += S_WIN_CAP;
}
score += analyzePatterns(r, c, me);
board[r][c] = EMPTY;
// Defensive
board[r][c] = opp;
if (checkWin(r, c, opp)) score += S_BLK_WIN;
const oppCaps = countCaptures(r, c, opp);
if (oppCaps > 0) {
score += S_BLK_CAP * oppCaps;
const enCaps = opp === P1 ? p1Captures : p2Captures;
if (enCaps + oppCaps >= 5) score += S_BLK_WIN_CAP;
}
score += analyzePatterns(r, c, opp) * 0.9;
board[r][c] = EMPTY;
return score;
}
function countCaptures(r, c, player) {
const opponent = player === P1 ? P2 : P1;
let count = 0;
for (let d of DIRECTIONS) {
if (getCell(r + d.dr, c + d.dc) === opponent &&
getCell(r + 2 * d.dr, c + 2 * d.dc) === opponent &&
getCell(r + 3 * d.dr, c + 3 * d.dc) === player) count++;
if (getCell(r – d.dr, c – d.dc) === opponent &&
getCell(r – 2 * d.dr, c – 2 * d.dc) === opponent &&
getCell(r – 3 * d.dr, c – 3 * d.dc) === player) count++;
}
return count;
}
function analyzePatterns(r, c, player) {
let score = 0;
for (let d of DIRECTIONS) {
let line = [];
for(let k=-4; k<=4; k++) line.push(getCell(r + k*d.dr, c + k*d.dc));
const s = line.map(x => x===player?’X’:(x===EMPTY?’.’:’O’)).join(”);
if (s.includes(‘.XXXX.’)) score += S_OPEN4;
else if (s.includes(‘OXXXX.’) || s.includes(‘.XXXXO’)) score += S_OPEN4 * 0.5;
if (s.includes(‘.XXX.’)) score += S_OPEN3;
if (s.includes(‘.X.XX.’) || s.includes(‘.XX.X.’)) score += S_OPEN3 * 0.9;
}
return score;
}
/* — EVENTS — */
// Toggle Play As select based on mode
modeSelect.addEventListener(‘change’, () => {
if (modeSelect.value === ‘hvai’) {
playAsSelect.disabled = false;
} else {
playAsSelect.disabled = true;
}
initGame();
});
gridSizeSelect.addEventListener(‘change’, initGame);
playAsSelect.addEventListener(‘change’, initGame);
resetBtn.addEventListener(‘click’, initGame);
modalReset.addEventListener(‘click’, initGame);
window.addEventListener(‘resize’, resizeCanvas);
window.onload = () => {
resizeCanvas();
initGame();
};
</script>
</body>
</html>
