Martian Chess ai / atrophy

https://svonberg.org/wp-content/uploads/2025/11/martainchessstalemateai.html

I have updated the game to include the 27-Move Rule (The “Atrophy” Rule).


Changes Implemented
New Game Rule: Added a counter for movesSinceCapture. If it reaches 27, the game ends immediately.


AI Awareness:
The AI now receives the current movesSinceCapture count in its simulation.

Winning Strategy: If the AI is ahead in points and the counter is high (e.g., 25), it will prioritize non-capture moves to force the game to end and lock in its victory.

Losing Strategy: If the AI is behind, it will aggressively look for captures to reset the timer and extend the game.

UI Update: Added a “Stalemate Timer” bar below the score to show how close the game is to ending due to a lack of captures. Also changed graphics slightly to call back to Looney Pyramids.

Martian Chess experiment

https://svonberg.org/wp-content/uploads/2025/11/martianchess.html

How to Play


Objective: Score the most points by capturing pieces.


Movement:
Pawn (Small – 1 pt): Moves 1 step diagonally.


Drone (Medium – 2 pts): Moves 1 or 2 steps orthogonally (cannot jump).


Queen (Large – 3 pts): Moves any distance orthogonally or diagonally (cannot jump).


Ownership (Crucial Rule): Color is determined by location. Pieces on the left (Red zone) belong to Player 1. Pieces on the right (Blue zone) belong to Player 2/CPU.


The Canal: If you move a piece from your zone to the opponent’s zone, it immediately changes color and becomes theirs (unless you used it to capture, in which case you remove the captured piece and score points).

Winning: The game ends when a player has no pieces remaining in their territory. High score wins.

<!DOCTYPE html>
<html lang=”en”>
<head>
    <meta charset=”UTF-8″>
    <meta name=”viewport” content=”width=device-width, initial-scale=1.0″>
    <title>Martian Chess</title>
   
    <!– React & ReactDOM –>
    <script crossorigin src=”https://unpkg.com/react@18/umd/react.production.min.js”></script>
    <script crossorigin src=”https://unpkg.com/react-dom@18/umd/react-dom.production.min.js”></script>
   
    <!– Babel for JSX –>
    <script src=”https://unpkg.com/@babel/standalone/babel.min.js”></script>
   
    <!– Tailwind CSS –>
    <script src=”https://cdn.tailwindcss.com”></script>
   
    <!– Phosphor Icons –>
    <script src=”https://unpkg.com/@phosphor-icons/web”></script>

    <style>
        @import url(‘https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Rajdhani:wght@400;600&display=swap’);
       
        body {
            font-family: ‘Rajdhani’, sans-serif;
            background-color: #0f172a;
            color: #e2e8f0;
            touch-action: manipulation;
        }
       
        .font-display {
            font-family: ‘Orbitron’, sans-serif;
        }

        .perspective-board {
            transform: perspective(1000px) rotateX(5deg);
            transition: transform 0.3s ease;
        }
    </style>
</head>
<body>
    <div id=”root”></div>

    <script type=”text/babel”>
        const { useState, useEffect, useMemo, useCallback, useRef } = React;

        // — Game Constants & Logic —

        const ROWS = 4;
        const COLS = 8;
        const PAWN = 1;   // Moves Diag 1
        const DRONE = 2;  // Moves Ortho 1-2
        const QUEEN = 3;  // Moves Any Slide

        const INITIAL_SETUP = [
            // Player 1 (Red/Left)
            { r: 0, c: 0, type: QUEEN }, { r: 0, c: 1, type: DRONE }, { r: 0, c: 2, type: PAWN },
            { r: 1, c: 0, type: DRONE }, { r: 1, c: 1, type: PAWN },  { r: 1, c: 2, type: PAWN },
            { r: 2, c: 0, type: DRONE }, { r: 2, c: 1, type: PAWN },  { r: 2, c: 2, type: PAWN },
            { r: 3, c: 0, type: QUEEN }, { r: 3, c: 1, type: DRONE }, { r: 3, c: 2, type: PAWN },
           
            // Player 2 (Blue/Right)
            { r: 0, c: 7, type: QUEEN }, { r: 0, c: 6, type: DRONE }, { r: 0, c: 5, type: PAWN },
            { r: 1, c: 7, type: DRONE }, { r: 1, c: 6, type: PAWN },  { r: 1, c: 5, type: PAWN },
            { r: 2, c: 7, type: DRONE }, { r: 2, c: 6, type: PAWN },  { r: 2, c: 5, type: PAWN },
            { r: 3, c: 7, type: QUEEN }, { r: 3, c: 6, type: DRONE }, { r: 3, c: 5, type: PAWN },
        ];

        const getOwner = (c) => c < 4 ? 0 : 1;
        const isValidPos = (r, c) => r >= 0 && r < ROWS && c >= 0 && c < COLS;

        const generateGrid = (pieces) => {
            const grid = Array(ROWS).fill(null).map(() => Array(COLS).fill(null));
            pieces.forEach(p => {
                if(isValidPos(p.r, p.c)) grid[p.r][p.c] = p;
            });
            return grid;
        };

        const getMoves = (grid, piece) => {
            const moves = [];
            const { r, c, type } = piece;
            const owner = getOwner(c);

            const addMove = (tr, tc) => {
                const targetPiece = grid[tr][tc];
                if (!targetPiece) {
                    moves.push({ r: tr, c: tc, isCapture: false });
                    return true;
                }
                const targetOwner = getOwner(tc);
                if (targetOwner !== owner) {
                    moves.push({ r: tr, c: tc, isCapture: true, value: targetPiece.type });
                    return false;
                }
                return false;
            };

            if (type === PAWN) {
                [[1, 1], [1, -1], [-1, 1], [-1, -1]].forEach(([dr, dc]) => {
                    const nr = r + dr, nc = c + dc;
                    if (isValidPos(nr, nc)) addMove(nr, nc);
                });
            } else if (type === DRONE) {
                [[0, 1], [0, -1], [1, 0], [-1, 0]].forEach(([dr, dc]) => {
                    let nr = r + dr, nc = c + dc;
                    if (isValidPos(nr, nc)) {
                        if (addMove(nr, nc)) {
                            nr += dr; nc += dc;
                            if (isValidPos(nr, nc)) addMove(nr, nc);
                        }
                    }
                });
            } else if (type === QUEEN) {
                [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [1, -1], [-1, 1], [-1, -1]].forEach(([dr, dc]) => {
                    let nr = r + dr, nc = c + dc;
                    while (isValidPos(nr, nc)) {
                        if (!addMove(nr, nc)) break;
                        nr += dr; nc += dc;
                    }
                });
            }
            return moves;
        };

        // — Components —

        const PieceIcon = ({ type, className }) => {
             const shapeClass = type === PAWN ? “rounded-full” : type === DRONE ? “rounded-sm” : “rounded-lg”;
            
             // Simple Geometric shapes for clarity
             if (type === PAWN) return (
                <svg viewBox=”0 0 24 24″ fill=”currentColor” className={className}>
                   <circle cx=”12″ cy=”12″ r=”8″ />
                </svg>
             );
             if (type === DRONE) return (
                <svg viewBox=”0 0 24 24″ fill=”currentColor” className={className}>
                   <rect x=”4″ y=”4″ width=”16″ height=”16″ rx=”2″ />
                </svg>
             );
             // Queen/Mother
             return (
                <svg viewBox=”0 0 24 24″ fill=”currentColor” className={className}>
                   <path d=”M12 2L2 22h20L12 2z” />
                </svg>
             );
        };

        const App = () => {
            const [pieces, setPieces] = useState(INITIAL_SETUP);
            const [turn, setTurn] = useState(0);
            const [scores, setScores] = useState([0, 0]);
            const [selected, setSelected] = useState(null);
            const [possibleMoves, setPossibleMoves] = useState([]);
            const [gameMode, setGameMode] = useState(‘PVC’); // ‘PVC’, ‘PVP’, ‘CVC’
            const [gameOver, setGameOver] = useState(false);
            const [lastMove, setLastMove] = useState(null);
            const [aiThinking, setAiThinking] = useState(false);

            const grid = useMemo(() => generateGrid(pieces), [pieces]);

            const checkWinCondition = useCallback((currentPieces, playerJustMoved) => {
                const nextPlayer = 1 – playerJustMoved;
                const piecesInNextZone = currentPieces.filter(p => getOwner(p.c) === nextPlayer);
                return piecesInNextZone.length === 0;
            }, []);

            const executeMove = useCallback((from, move) => {
                setPieces(prevPieces => {
                    const newPieces = […prevPieces];
                    const pieceIndex = newPieces.findIndex(p => p.r === from.r && p.c === from.c);
                    if (pieceIndex === -1) return prevPieces;

                    const movingPiece = { …newPieces[pieceIndex] };
                    let capturedValue = 0;

                    if (move.isCapture) {
                        const targetIndex = newPieces.findIndex(p => p.r === move.r && p.c === move.c);
                        if (targetIndex !== -1) {
                            capturedValue = newPieces[targetIndex].type;
                            newPieces.splice(targetIndex, 1);
                        }
                    }

                    // Re-find index in case splice shifted it
                    const finalMoverIndex = newPieces.findIndex(p => p.r === from.r && p.c === from.c);
                    newPieces[finalMoverIndex] = { …movingPiece, r: move.r, c: move.c };

                    if (capturedValue > 0) {
                        setScores(prev => {
                            const newScores = […prev];
                            newScores[turn] += capturedValue;
                            return newScores;
                        });
                    }
                   
                    // Check win condition immediately with new state
                    const isWin = checkWinCondition(newPieces, turn);
                    if (isWin) {
                        setGameOver(true);
                    } else {
                        setTurn(t => 1 – t);
                    }
                   
                    setLastMove({ from, to: { r: move.r, c: move.c } });
                    return newPieces;
                });

                setSelected(null);
                setPossibleMoves([]);
            }, [turn, checkWinCondition]);

            const handleSquareClick = (r, c) => {
                if (gameOver || aiThinking) return;
                if (gameMode === ‘CVC’) return;
                if (gameMode === ‘PVC’ && turn === 1) return;

                const clickedPiece = grid[r][c];
                const clickedOwner = getOwner(c);

                if (clickedPiece && clickedOwner === turn) {
                    if (selected && selected.r === r && selected.c === c) {
                        setSelected(null);
                        setPossibleMoves([]);
                    } else {
                        setSelected({ r, c });
                        setPossibleMoves(getMoves(grid, clickedPiece));
                    }
                } else if (selected) {
                    const move = possibleMoves.find(m => m.r === r && m.c === c);
                    if (move) {
                        executeMove(selected, move);
                    } else if (clickedPiece && clickedOwner === turn) {
                        // Switch selection
                        setSelected({ r, c });
                        setPossibleMoves(getMoves(grid, clickedPiece));
                    } else {
                        setSelected(null);
                        setPossibleMoves([]);
                    }
                }
            };

            // — AI Engine —
            const makeAiMove = useCallback((activeTurn) => {
                const aiPieces = pieces.filter(p => getOwner(p.c) === activeTurn);
                let allMoves = [];

                aiPieces.forEach(p => {
                    const moves = getMoves(grid, p);
                    moves.forEach(m => {
                        allMoves.push({ from: p, move: m });
                    });
                });

                if (allMoves.length === 0) {
                    setGameOver(true);
                    return;
                }

                const scoredMoves = allMoves.map(am => {
                    let score = 0;
                    const pieceValue = am.from.type;
                    const isCapture = am.move.isCapture;
                   
                    if (isCapture) {
                        score += am.move.value * 12; // High value on capture
                    }

                    const isEnemyZone = activeTurn === 0 ? am.move.c >= 4 : am.move.c < 4;
                   
                    if (isEnemyZone) {
                        // Cost of losing piece
                        score -= pieceValue * 5;
                        // Aggression Bonus: If we are high on score, trade aggressively
                        if (scores[activeTurn] > scores[1-activeTurn]) score += 2;
                    } else {
                        // Stay in zone
                        score += Math.random() * 2;
                       
                        // Prefer moves that don’t leave us stuck
                        // Center control (Cols 3,4)
                        const distToCanal = Math.abs(am.move.c – 3.5);
                        score += (4 – distToCanal) * 0.5;
                    }

                    return { …am, score };
                });

                scoredMoves.sort((a, b) => b.score – a.score);
               
                // Take top move or random of top 3 to avoid loops
                const topMoves = scoredMoves.slice(0, 3);
                const selectedMove = topMoves[Math.floor(Math.random() * topMoves.length)];
               
                executeMove(selectedMove.from, selectedMove.move);
            }, [grid, pieces, scores, executeMove]);

            // — Game Loop —
            useEffect(() => {
                if (gameOver) return;

                const isCpuTurn = (gameMode === ‘PVC’ && turn === 1) || (gameMode === ‘CVC’);

                if (isCpuTurn) {
                    setAiThinking(true);
                    const timer = setTimeout(() => {
                        makeAiMove(turn);
                        setAiThinking(false);
                    }, gameMode === ‘CVC’ ? 1000 : 800); // Faster for CVC

                    return () => clearTimeout(timer);
                } else {
                    setAiThinking(false);
                }
            }, [turn, gameMode, gameOver, makeAiMove]);

            const resetGame = () => {
                setPieces(INITIAL_SETUP);
                setScores([0, 0]);
                setTurn(0);
                setGameOver(false);
                setSelected(null);
                setPossibleMoves([]);
                setLastMove(null);
                setAiThinking(false);
            };

            // — Helpers —
            const getSquareColor = (r, c) => {
                const isLeft = c < 4;
                const base = isLeft ? ‘bg-red-900/20’ : ‘bg-blue-900/20’;
                const alt = isLeft ? ‘bg-red-900/10’ : ‘bg-blue-900/10’;
                const isDark = (r + c) % 2 === 1;
               
                if (lastMove && ((lastMove.from.r === r && lastMove.from.c === c) || (lastMove.to.r === r && lastMove.to.c === c))) {
                    return ‘bg-yellow-500/20 border-yellow-500/50’;
                }
                return isDark ? base : alt;
            };

            return (
                <div className=”min-h-screen flex flex-col items-center justify-center p-4 relative overflow-hidden bg-slate-950 select-none”>
                   
                    {/* Background */}
                    <div className=”absolute top-0 left-0 w-full h-full pointer-events-none z-0″>
                        <div className=”absolute top-[-20%] left-[-10%] w-[600px] h-[600px] bg-red-600/10 rounded-full blur-3xl”></div>
                        <div className=”absolute bottom-[-20%] right-[-10%] w-[600px] h-[600px] bg-blue-600/10 rounded-full blur-3xl”></div>
                    </div>

                    {/* Header */}
                    <div className=”z-10 text-center mb-4″>
                        <h1 className=”text-4xl md:text-5xl font-display font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-400 to-blue-400″>
                            MARTIAN CHESS
                        </h1>
                        <div className=”flex gap-2 mt-4 justify-center”>
                            {[‘PVC’, ‘PVP’, ‘CVC’].map(mode => (
                                <button
                                    key={mode}
                                    onClick={() => { setGameMode(mode); resetGame(); }}
                                    className={`px-4 py-1 rounded text-xs font-bold transition border border-slate-700 ${gameMode === mode ? ‘bg-slate-700 text-white’ : ‘bg-slate-900 text-slate-500 hover:bg-slate-800’}`}
                                >
                                    {mode === ‘PVC’ ? ‘1 Player’ : mode === ‘PVP’ ? ‘2 Players’ : ‘AI vs AI’}
                                </button>
                            ))}
                        </div>
                    </div>

                    {/* Score Board */}
                    <div className=”z-10 flex justify-between w-full max-w-3xl mb-2 px-4 items-end”>
                        <div className={`text-center transition-opacity ${turn === 0 ? ‘opacity-100’ : ‘opacity-50’}`}>
                            <div className=”text-red-500 font-bold text-sm”>RED</div>
                            <div className=”text-4xl font-mono text-white”>{scores[0]}</div>
                        </div>
                        <div className=”mb-2″>
                            {gameOver ?
                                <span className=”text-yellow-400 font-bold animate-pulse”>GAME OVER</span> :
                                <span className=”text-slate-600 text-xs uppercase tracking-widest”>{aiThinking ? ‘THINKING…’ : `${turn === 0 ? ‘RED’ : ‘BLUE’} TURN`}</span>
                            }
                        </div>
                        <div className={`text-center transition-opacity ${turn === 1 ? ‘opacity-100’ : ‘opacity-50’}`}>
                            <div className=”text-blue-500 font-bold text-sm”>BLUE</div>
                            <div className=”text-4xl font-mono text-white”>{scores[1]}</div>
                        </div>
                    </div>

                    {/* Board */}
                    <div className=”z-10 relative w-full max-w-[700px] aspect-[2/1] perspective-board”>
                        <div className=”w-full h-full grid grid-rows-4 grid-cols-8 shadow-2xl border border-slate-700 bg-slate-900/80 backdrop-blur-sm rounded-lg overflow-hidden”>
                            {Array(ROWS).fill(0).map((_, r) => (
                                Array(COLS).fill(0).map((_, c) => {
                                    const piece = grid[r][c];
                                    const isSelected = selected?.r === r && selected?.c === c;
                                    const isMove = possibleMoves.find(m => m.r === r && m.c === c);
                                    const isLeft = c < 4;

                                    return (
                                        <div
                                            key={`${r}-${c}`}
                                            onClick={() => handleSquareClick(r, c)}
                                            className={`
                                                relative flex items-center justify-center
                                                border-b border-r ${c === 3 ? ‘border-r-yellow-500/30 border-r-2’ : ‘border-slate-800’}
                                                ${getSquareColor(r, c)}
                                                ${isSelected ? ‘bg-yellow-500/20’ : ”}
                                                ${isMove ? ‘cursor-pointer’ : ”}
                                            `}
                                        >
                                            {isMove && !piece && <div className=”w-2 h-2 rounded-full bg-green-400/50 animate-pulse” />}
                                            {isMove && piece && <div className=”absolute inset-0 border-2 border-red-500/50 animate-pulse” />}

                                            {piece && (
                                                <div
                                                    className={`
                                                        w-3/4 h-3/4 transition-all duration-300
                                                        ${isLeft ? ‘text-red-500’ : ‘text-blue-500’}
                                                        ${isSelected ? ‘scale-110 text-yellow-400’ : ”}
                                                        drop-shadow-lg
                                                    `}
                                                >
                                                    <PieceIcon type={piece.type} className=”w-full h-full” />
                                                    <div className=”absolute -bottom-1 -right-1 text-[8px] font-bold opacity-70 bg-slate-900 px-1 rounded”>
                                                        {piece.type === 1 ? ‘1’ : piece.type === 2 ? ‘2’ : ‘3’}
                                                    </div>
                                                </div>
                                            )}
                                        </div>
                                    );
                                })
                            ))}
                        </div>
                    </div>

                    {/* Reset */}
                    <button
                        onClick={resetGame}
                        className=”z-10 mt-6 px-6 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-sm font-bold tracking-wider transition shadow-lg”
                    >
                        RESET
                    </button>

                </div>
            );
        };

        const root = ReactDOM.createRoot(document.getElementById(‘root’));
        root.render(<App />);
    </script>
</body>
</html>

Pente experiment

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>

Pathfinder html app

Initiative tracker for pathfinder, html format for easy use on phone, laptop, tablet or whatnot.

I goofed on the d20s, will have to revise later

V 0.01 here –

https://svonberg.org/wp-content/uploads/2025/11/pf-init.html

Source

<!DOCTYPE html>
<html lang=”en”>
<head>
    <meta charset=”UTF-8″>
    <meta name=”viewport” content=”width=device-width, initial-scale=1.0″>
    <title>Pathfinder Initiative Tracker with Quick Add</title>
    <script src=”https://cdn.tailwindcss.com”></script>
    <script>
        tailwind.config = {
            theme: {
                extend: {
                    colors: {
                        ‘primary’: ‘#9f1239’, /* Deep Red for Pathfinder */
                        ‘secondary’: ‘#1f2937’,
                        ‘accent’: ‘#fcd34d’, /* Gold/Amber */
                        ‘background’: ‘#0f172a’, /* Dark Blue/Black */
                    },
                    fontFamily: {
                        sans: [‘Inter’, ‘sans-serif’],
                    },
                }
            }
        }
    </script>
    <style>
        @import url(‘https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap’);
        .scroll-container {
            max-height: 70vh;
            overflow-y: auto;
            scrollbar-width: thin;
            scrollbar-color: #9f1239 #1f2937;
        }
        .scroll-container::-webkit-scrollbar {
            width: 8px;
        }
        .scroll-container::-webkit-scrollbar-thumb {
            background-color: #9f1239;
            border-radius: 10px;
        }
        .scroll-container::-webkit-scrollbar-track {
            background: #1f2937;
        }
        /* Custom D20 styling */
        .d20-icon {
            filter: drop-shadow(0 0 5px rgba(252, 211, 77, 0.7));
            width: 1.5em;
            height: 1.5em;
            fill: currentColor;
        }
    </style>
</head>
<body class=”bg-background text-gray-100 min-h-screen p-4 font-sans”>

    <div id=”app” class=”max-w-4xl mx-auto space-y-8″>
        <header class=”text-center py-4 bg-gray-800 rounded-xl shadow-2xl”>
            <h1 class=”text-3xl font-bold text-accent flex items-center justify-center”>
                <!– Left D20 SVG Icon –>
                <svg class=”d20-icon mr-3 text-accent” viewBox=”0 0 512 512″ xmlns=”http://www.w3.org/2000/svg”>
                    <path d=”M495.9 224.2L255.6 32.5 15.3 224.2l-1.3 268.3 241.6 42.4 240.2-42.4-1.9-268.3zM461 247.4l-64.8 112.5-121.3 210.3-2.6 4.5V230.1l-66.2-114.6 37.9-65.7 151.8 263.3 121.2-210.1 3.5 6.1zM344.2 360l-33.5 58.2-113.8-197.6 147.3-84.9 33.5 58.2 113.8 197.6-147.3 84.9z”/>
                </svg>
               
                Pathfinder Initiative Tracker
               
                <!– Right D20 SVG Icon –>
                <svg class=”d20-icon ml-3 text-accent” viewBox=”0 0 512 512″ xmlns=”http://www.w3.org/2000/svg”>
                    <path d=”M495.9 224.2L255.6 32.5 15.3 224.2l-1.3 268.3 241.6 42.4 240.2-42.4-1.9-268.3zM461 247.4l-64.8 112.5-121.3 210.3-2.6 4.5V230.1l-66.2-114.6 37.9-65.7 151.8 263.3 121.2-210.1 3.5 6.1zM344.2 360l-33.5 58.2-113.8-197.6 147.3-84.9 33.5 58.2 113.8 197.6-147.3 84.9z”/>
                </svg>
            </h1>
            <p class=”text-sm text-gray-400 mt-1″>Uses d20 + Initiative Mod + DEX tiebreaker rules.</p>
        </header>

        <!– Input and Character Management –>
        <div class=”bg-gray-800 p-6 rounded-xl shadow-xl flex flex-col lg:flex-row gap-6″>
           
            <!– Quick Add Section –>
            <div class=”lg:w-1/3 space-y-4″>
                <h2 class=”text-xl font-semibold text-primary”>Quick Add Monster/NPC</h2>
                <div>
                    <label for=”quick-add-select” class=”block text-sm font-medium text-gray-300 mb-1″>Select Preset</label>
                    <select id=”quick-add-select” class=”block w-full rounded-lg border-gray-700 bg-gray-700 text-gray-100 p-2 focus:ring-primary focus:border-primary”>
                        <option value=”” disabled selected>— Choose a Creature —</option>
                    </select>
                </div>
                <button onclick=”addSelectedPreset()” class=”w-full bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-lg transition duration-200 shadow-md”>
                    Quick Add to Roster
                </button>
                <div class=”border-t border-gray-700 pt-4″>
                    <h2 class=”text-xl font-semibold mb-3 text-primary”>Manual Add</h2>
                    <form id=”add-character-form” class=”space-y-4″>
                        <div>
                            <label for=”char-name” class=”block text-sm font-medium text-gray-300″>Name</label>
                            <input type=”text” id=”char-name” placeholder=”Name or Monster Type” required class=”mt-1 block w-full rounded-lg border-gray-700 bg-gray-700 text-gray-100 p-2 focus:ring-primary focus:border-primary”>
                        </div>
                        <div>
                            <label for=”init-mod” class=”block text-sm font-medium text-gray-300″>Initiative Modifier (e.g., +5)</label>
                            <input type=”number” id=”init-mod” placeholder=”+5″ required class=”mt-1 block w-full rounded-lg border-gray-700 bg-gray-700 text-gray-100 p-2 focus:ring-primary focus:border-primary”>
                        </div>
                        <div>
                            <label for=”dex-mod” class=”block text-sm font-medium text-gray-300″>DEX Modifier (Tiebreaker)</label>
                            <input type=”number” id=”dex-mod” placeholder=”+2″ required class=”mt-1 block w-full rounded-lg border-gray-700 bg-gray-700 text-gray-100 p-2 focus:ring-primary focus:border-primary”>
                        </div>
                        <button type=”submit” class=”w-full bg-primary hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg transition duration-200 shadow-md”>
                            Add to Roster Manually
                        </button>
                    </form>
                </div>
            </div>

            <!– Current Roster –>
            <div class=”lg:w-2/3″>
                <h2 class=”text-xl font-semibold mb-3 text-primary”>Current Roster</h2>
                <div id=”roster-list” class=”space-y-2 max-h-96 overflow-y-auto pr-2″>
                    <p class=”text-gray-400 text-sm” id=”empty-roster-msg”>Add characters and their modifiers to begin.</p>
                    <!– Character cards will be injected here –>
                </div>
            </div>
        </div>

        <!– Initiative Roll and Turn Order Display –>
        <div class=”bg-gray-800 p-6 rounded-xl shadow-xl”>
            <div class=”flex justify-between items-center mb-4 flex-wrap gap-3″>
                <button onclick=”rollInitiative()” id=”roll-button” class=”flex-grow bg-accent hover:bg-yellow-400 text-gray-900 font-extrabold py-3 px-6 rounded-lg transition duration-200 text-lg shadow-lg”>
                    Roll Initiative! (New Combat)
                </button>
                <button onclick=”resetRoster()” class=”bg-gray-600 hover:bg-gray-700 text-white text-sm font-bold py-3 px-3 rounded-lg transition duration-200″>
                    Reset Roster
                </button>
            </div>

            <h2 class=”text-2xl font-semibold text-accent mt-6 mb-3 border-b border-gray-700 pb-2″>Combat Turn Order</h2>

            <div id=”turn-order-list” class=”scroll-container space-y-2″>
                <p class=”text-center text-gray-400 p-4″>Press “Roll Initiative!” to generate the combat order.</p>
                <!– Turn entries will be injected here –>
            </div>
        </div>
    </div>

    <script>
        // Global State
        let characters = [];
        let initiativeRolled = false;
        const TURNS_TO_SHOW = 20;

        // Pathfinder Monster/NPC Presets (Expanded List)
        const presets = [
            // NEW ADDITION
            { name: “Housecat (CR —)”, initMod: 3, dexMod: 3, cr: ‘—’ },
           
            // CR 1/2 to 1
            { name: “Goblin Warrior (CR 1/3)”, initMod: 2, dexMod: 2, cr: ‘1/3’ },
            { name: “Orc Brute (CR 1/2)”, initMod: 0, dexMod: 0, cr: ‘1/2’ },
            { name: “Skeleton (CR 1/3)”, initMod: 0, dexMod: 0, cr: ‘1/3’ },
            { name: “Zombie (CR 1/2)”, initMod: -1, dexMod: -1, cr: ‘1/2’ },
            { name: “Wolf (CR 1)”, initMod: 2, dexMod: 2, cr: ‘1’ },
            { name: “Axe Beak (CR 1)”, initMod: 1, dexMod: 1, cr: ‘1’ },
           
            // CR 2 to 4
            { name: “Bandit Leader (CR 2 NPC)”, initMod: 3, dexMod: 3, cr: ‘2’ },
            { name: “Ghoul (CR 2)”, initMod: 6, dexMod: 3, cr: ‘2’ },
            { name: “Ogre (CR 3)”, initMod: -1, dexMod: -1, cr: ‘3’ },
            { name: “Giant Spider (CR 3)”, initMod: 7, dexMod: 3, cr: ‘3’ },
            { name: “Wight (CR 4)”, initMod: 4, dexMod: 4, cr: ‘4’ },
            { name: “Young Dragon (CR 4)”, initMod: 2, dexMod: 2, cr: ‘4’ },
           
            // CR 5 to 7
            { name: “Hill Giant (CR 5)”, initMod: -1, dexMod: -1, cr: ‘5’ },
            { name: “Troll (CR 5)”, initMod: 4, dexMod: 1, cr: ‘5’ },
            { name: “Shadow (CR 5)”, initMod: 4, dexMod: 4, cr: ‘5’ },
            { name: “Vampire Spawn (CR 5)”, initMod: 7, dexMod: 4, cr: ‘5’ },
            { name: “Manticore (CR 5)”, initMod: 1, dexMod: 1, cr: ‘5’ },
            { name: “Stone Golem (CR 7)”, initMod: -1, dexMod: -1, cr: ‘7’ },

            // CR 8+
            { name: “Black Pudding (CR 7)”, initMod: -5, dexMod: -5, cr: ‘7’ },
            { name: “Archmage (CR 8 NPC)”, initMod: 10, dexMod: 5, cr: ‘8’ },
            { name: “Drider (CR 8)”, initMod: 5, dexMod: 2, cr: ‘8’ },
            { name: “Frost Giant (CR 9)”, initMod: 0, dexMod: 0, cr: ‘9’ },
            { name: “Efreeti (CR 10)”, initMod: 6, dexMod: 3, cr: ’10’ },
            { name: “Balor (CR 13)”, initMod: 5, dexMod: 2, cr: ’13’ },
            { name: “Ancient Red Dragon (CR 20)”, initMod: 4, dexMod: 4, cr: ’20’ },
           
            // Generic NPCs
            { name: “Commoner (NPC)”, initMod: -1, dexMod: -1, cr: ‘0’ },
            { name: “Acolyte (NPC)”, initMod: 1, dexMod: 1, cr: ‘1/2’ },
            { name: “Guard Captain (NPC)”, initMod: 5, dexMod: 3, cr: ‘3’ },
            { name: “Master Thief (NPC)”, initMod: 9, dexMod: 5, cr: ‘5’ },
        ];

        /**
         * Represents a Pathfinder character’s combat stats.
         */
        class PathfinderCharacter {
            constructor(name, initMod, dexMod) {
                this.id = crypto.randomUUID();
                this.name = name;
                this.initModifier = parseInt(initMod);
                this.dexModifier = parseInt(dexMod);

                // Rolled/Transient values
                this.d20Roll = 0;
                this.d100Tiebreaker = 0;
                this.totalInitiative = 0;
            }

            /**
             * Performs the initiative roll and calculates the total.
             */
            roll() {
                this.d20Roll = Math.floor(Math.random() * 20) + 1; // 1 to 20
                this.d100Tiebreaker = Math.floor(Math.random() * 100) + 1; // 1 to 100 for final tiebreaker
                this.totalInitiative = this.d20Roll + this.initModifier;
            }
        }

        // — DOM Elements —
        const rosterList = document.getElementById(‘roster-list’);
        const turnOrderList = document.getElementById(‘turn-order-list’);
        const emptyRosterMsg = document.getElementById(’empty-roster-msg’);
        const rollButton = document.getElementById(‘roll-button’);
        const quickAddSelect = document.getElementById(‘quick-add-select’);
        const charNameInput = document.getElementById(‘char-name’);
        const initModInput = document.getElementById(‘init-mod’);
        const dexModInput = document.getElementById(‘dex-mod’);

        // — Utility Functions —

        /**
         * Initializes the Quick Add dropdown menu with preset data.
         */
        function initializePresets() {
            // Sort presets alphabetically for easy finding
            presets.sort((a, b) => a.name.localeCompare(b.name));

            presets.forEach(preset => {
                const option = document.createElement(‘option’);
                option.value = preset.name;
                const initSign = preset.initMod >= 0 ? ‘+’ : ”;
                const dexSign = preset.dexMod >= 0 ? ‘+’ : ”;
                option.textContent = `${preset.name} (Init: ${initSign}${preset.initMod}, DEX: ${dexSign}${preset.dexMod})`;
                quickAddSelect.appendChild(option);
            });
        }

        /**
         * Handles adding the selected preset monster to the roster.
         */
        function addSelectedPreset() {
            const selectedName = quickAddSelect.value;
            if (!selectedName) return;

            // Find the original preset data (ignoring the CR in the dropdown text)
            const preset = presets.find(p => p.name === selectedName);
            if (!preset) return;

            // Check if multiple instances of this monster already exist for automatic numbering
            const count = characters.filter(c => c.name.startsWith(preset.name)).length;
           
            // Remove the CR/NPC tag for cleaner numbering, then add it back if it’s the first one
            const baseName = preset.name.replace(/\s*\(CR.*?\)/, ”).replace(/\s*\(NPC\)/, ”).trim();
            const suffix = (preset.name.match(/\((CR.*?)\)/) || preset.name.match(/\((NPC)\)/))?.[0] || ”;
           
            let name;
            if (count === 0) {
                 name = preset.name; // Use the full name with CR/NPC tag if it’s the first one
            } else {
                 // Use the base name + number for subsequent entries
                 name = `${baseName} ${count + 1}`;
            }
           
            addCharacter(name, preset.initMod, preset.dexMod);
            // After adding, reset selection to prevent accidental double adds
            quickAddSelect.selectedIndex = 0;
        }

        /**
         * Sorts the character list based on Pathfinder initiative rules:
         */
        function sortInitiative() {
            return characters.sort((a, b) => {
                // Rule 1: Total Initiative (Highest first)
                if (b.totalInitiative !== a.totalInitiative) {
                    return b.totalInitiative – a.totalInitiative;
                }

                // Rule 2: DEX Modifier (Highest first)
                if (b.dexModifier !== a.dexModifier) {
                    return b.dexModifier – a.dexModifier;
                }

                // Rule 3: Hidden d100 Tiebreaker (Highest first)
                return b.d100Tiebreaker – a.d100Tiebreaker;
            });
        }

        /**
         * Renders the current list of characters in the Roster section.
         */
        function renderRoster() {
            rosterList.innerHTML = ”;
            if (characters.length === 0) {
                emptyRosterMsg.classList.remove(‘hidden’);
                return;
            }
            emptyRosterMsg.classList.add(‘hidden’);

            characters.forEach(char => {
                const initSign = char.initModifier >= 0 ? ‘+’ : ”;
                const dexSign = char.dexModifier >= 0 ? ‘+’ : ”;
                const charEl = document.createElement(‘div’);
                charEl.className = ‘flex justify-between items-center p-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition duration-150’;
                charEl.innerHTML = `
                    <div class=”flex flex-col”>
                        <span class=”font-bold text-lg text-white”>${char.name}</span>
                        <span class=”text-sm text-gray-400″>Init Mod: ${initSign}${char.initModifier} | DEX Mod: ${dexSign}${char.dexModifier}</span>
                    </div>
                    <button data-id=”${char.id}” class=”remove-char-btn text-red-400 hover:text-red-500 transition duration-200 p-1 rounded-full”>
                        <svg xmlns=”http://www.w3.org/2000/svg” class=”h-6 w-6″ fill=”none” viewBox=”0 0 24 24″ stroke=”currentColor” stroke-width=”2″>
                          <path stroke-linecap=”round” stroke-linejoin=”round” d=”M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16″ />
                        </svg>
                    </button>
                `;
                rosterList.appendChild(charEl);
            });

            // Attach removal event listeners
            document.querySelectorAll(‘.remove-char-btn’).forEach(button => {
                button.addEventListener(‘click’, (e) => removeCharacter(e.currentTarget.dataset.id));
            });
        }

        /**
         * Renders the generated turn order list (repeating the same order for multiple rounds).
         */
        function renderTurnOrder() {
            turnOrderList.innerHTML = ”;

            if (characters.length === 0 || !initiativeRolled) {
                turnOrderList.innerHTML = ‘<p class=”text-center text-gray-400 p-4″>Press “Roll Initiative!” to generate the combat order.</p>’;
                return;
            }

            const sortedRoster = sortInitiative();
           
            for (let i = 0; i < TURNS_TO_SHOW; i++) {
                const round = Math.floor(i / sortedRoster.length) + 1;
                const charIndex = i % sortedRoster.length;
                const char = sortedRoster[charIndex];

                // Add Round Header
                if (charIndex === 0) {
                    const roundHeader = document.createElement(‘div’);
                    roundHeader.className = ‘sticky top-0 z-10 bg-primary/90 text-white font-extrabold p-2.5 mt-3 rounded-lg text-center shadow-lg uppercase tracking-wider’;
                    roundHeader.textContent = `— ROUND ${round} —`;
                    turnOrderList.appendChild(roundHeader);
                }

                // Add Turn Entry
                const turnEl = document.createElement(‘div’);
                turnEl.className = `flex items-center p-3 rounded-lg transition duration-150 border-l-4 border-accent/70 ${i === 0 ? ‘bg-green-700/80 shadow-xl’ : ‘bg-gray-700 hover:bg-gray-600’}`;
               
                const initSign = char.initModifier >= 0 ? ‘+’ : ”;
               
                turnEl.innerHTML = `
                    <div class=”w-1/12 text-center font-extrabold text-xl ${i === 0 ? ‘text-white’ : ‘text-accent’}”>${i + 1}</div>
                    <div class=”w-8/12 flex-1 ml-4″>
                        <span class=”font-bold text-lg ${i === 0 ? ‘text-white’ : ‘text-white’}”>${char.name}</span>
                        <span class=”text-sm text-gray-400 block”>Round ${round} | Total Initiative: ${char.totalInitiative}</span>
                    </div>
                    <div class=”w-3/12 text-right”>
                        <span class=”text-xs text-gray-400 block”>d20: ${char.d20Roll} + ${initSign}${char.initModifier}</span>
                        <span class=”text-sm font-semibold block”>Total: ${char.totalInitiative}</span>
                    </div>
                `;
                turnOrderList.appendChild(turnEl);
            }
        }

        /**
         * Main function to update and re-render both lists.
         */
        function updateDisplay() {
            renderRoster();
            renderTurnOrder();
            // Update button text/color based on state
            if (initiativeRolled) {
                rollButton.textContent = “Reroll Initiative (New Combat)”;
                rollButton.classList.remove(‘bg-accent’);
                rollButton.classList.add(‘bg-primary’);
            } else {
                rollButton.textContent = “Roll Initiative! (New Combat)”;
                rollButton.classList.remove(‘bg-primary’);
                rollButton.classList.add(‘bg-accent’);
            }
        }

        // — Core Roster Management —

        /**
         * Adds a character to the roster and updates the display.
         */
        function addCharacter(name, initMod, dexMod) {
            const newChar = new PathfinderCharacter(name, initMod, dexMod);
            characters.push(newChar);
            initiativeRolled = false; // Must re-roll after adding a new character
            updateDisplay();
        }

        /**
         * Removes a character from the roster by ID.
         * @param {string} id – The ID of the character to remove.
         */
        function removeCharacter(id) {
            characters = characters.filter(char => char.id !== id);
            initiativeRolled = false; // Must re-roll after changing the roster
            updateDisplay();
        }

        /**
         * Clears all characters and resets the tracker.
         */
        function resetRoster() {
            characters = [];
            initiativeRolled = false;
            updateDisplay();
        }

        // — Event Handlers —

        /**
         * Rolls initiative for all characters and updates the order.
         */
        function rollInitiative() {
            if (characters.length === 0) {
                turnOrderList.innerHTML = ‘<p class=”text-center text-red-400 p-4 font-bold”>Error: Please add at least one character to the roster before rolling initiative.</p>’;
                initiativeRolled = false;
                updateDisplay();
                return;
            }

            characters.forEach(char => char.roll());
            initiativeRolled = true;
            updateDisplay();
        }

        /**
         * Handles the submission of the “Manual Add” form.
         */
        document.getElementById(‘add-character-form’).addEventListener(‘submit’, function(e) {
            e.preventDefault();

            const name = charNameInput.value.trim();
            const initMod = initModInput.value;
            const dexMod = dexModInput.value;

            if (name && initMod !== ” && dexMod !== ”) {
                addCharacter(name, initMod, dexMod);
               
                // Clear the form
                charNameInput.value = ”;
                initModInput.value = ”;
                dexModInput.value = ”;
                charNameInput.focus();
            } else {
                console.error(“Invalid input: All manual fields must be filled.”);
            }
        });

        // Initial setup on load
        initializePresets();
        updateDisplay();

    </script>
</body>
</html>

Critters

A few visitors dropped by the backyard today, each stopping in at their own hour like they were checking into a tiny woodland hotel with flexible scheduling.

Before dawn, the tailless mouse made the first appearance of the day. A small, round shadow skittering across the dim camera view, moving with that cautious little hop-hop-hop that only nighttime creatures seem to master. No tail, but plenty of determination. It lingered just long enough to be seen, then slipped back into the pre-dawn hush, leaving only a faint ripple in the stillness.

Hours later, once the sun had rubbed the sleep out of its eyes, a chipmunk arrived around 8:30. This one had attitude. It zipped up onto the concrete like it was clocking in for mischief, then made a very deliberate show of glancing toward the house. There’s a good chance it was taunting the cat, who watched from behind the glass with that slow-tail-swishing, offended dignity only a cat can manage. The chipmunk seemed pleased with itself before dashing off to whatever important acorn diplomacy awaited.

And then, a final visitor: a deer, calm and quiet, wandering into the frame just long enough to gather itself. One moment there, the next leaping up the hill in a single smooth arc, like gravity was only a suggestion. A brief cameo, elegant and gone.

Three tiny chapters, stitched into the morning. I love catching these fragments of the world going about its business while we rub the sleep from our eyes.

Tags: #backyardzoo #taillessmouse #chipmunkantics #catdrama #morningdeer #salemva #littlewildthings