All posts by scottobear

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

Deer visit

Just one deer on the backyard camera last night, stepping into frame like it wasn’t sure if it was allowed to be there. It came up to the concrete slab out back, gave everything a long, thoughtful once-over, ears perked and tail flicking like Morse code to the darkness.

It lingered just long enough to think about taking another step… then changed its mind and drifted away, soft as breath, back into the shadows. A tiny moment, but it feels like a small blessing every time the wild edges of the world brush up against the house.

Nice to have that little heartbeat of nature checking in before dawn.

#deer #backyardzoo  #roanokeva

How many times has he been rightfully called a pig?

Bubba’s eye view?

Answer: every time

‘Quiet, piggy’: Trump responds to reporter after Epstein question

When speaking to reporters on board Air Force One on 14 November, President Donald Trump was asked about recently released emails from Jeffrey Epstein which mentioned him.

Trump said he knew nothing about that and said the focus should be on other people named in those emails, including former President Bill Clinton.

After a journalist from Bloomberg News tried to ask a follow-up question on Epstein, the president turned to her and said: “Quiet. Quiet, piggy.”

The exchange occurred as calls grew for the US Department of Justice to release files related to its investigation into Epstein, the late financier and convicted sex offender who died in prison in 2019.

On Sunday, President Trump reversed his position and called for Republicans to vote for a bill that forces the Justice Department to release all of its files related to the Epstein case.

#doodle #politics #piggy #ochrejelly

Testing die roll html

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

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

Code

<!DOCTYPE html>
<html lang=”en”>
<head>
    <meta charset=”UTF-8″>
    <meta name=”viewport” content=”width=device-width, initial-scale=1.0″>
    <title>Appalachian Dice Roller</title>
    <script src=”https://cdn.tailwindcss.com”></script>
    <link href=”https://fonts.googleapis.com/css2?family=Merriweather:wght@700&family=Open+Sans:wght@400;600&display=swap” rel=”stylesheet”>
    <style>
        body {
            margin: 0;
            overflow: hidden;
            font-family: ‘Open Sans’, sans-serif;
            color: #d8dee9;
            background: linear-gradient(to bottom, #4a6c8a, #78909c, #a7b7be);
            background-image: url(‘https://image.pollinations.ai/prompt/Blue%20Ridge%20Mountains%20at%20dawn,%20misty%20valleys,%20river,%20pine%20trees,%20photorealistic,%20cinematic%20lighting’);
            background-size: cover;
            background-position: center;
        }
        #canvas-container {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            z-index: 1;
            background-color: rgba(0, 0, 0, 0.3);
        }
        #ui-container {
            position: relative;
            z-index: 10;
            pointer-events: none;
            text-shadow: 1px 1px 3px rgba(0,0,0,0.5);
        }
        .interactive { pointer-events: auto; }
       
        h1, h2 { font-family: ‘Merriweather’, serif; }

        /* Custom Styles for Appalachian feel */
        .wood-panel {
            background-color: rgba(30, 40, 50, 0.85);
            border: 1px solid #4a6c8a;
            box-shadow: 0 4px 6px rgba(0,0,0,0.3), inset 0 0 8px rgba(74, 108, 138, 0.4);
        }
        .stone-input {
            background-color: rgba(20, 30, 40, 0.9);
            border: 1px solid #5a7d9b;
            color: #e0f2f1;
            text-shadow: 1px 1px 2px rgba(0,0,0,0.7);
        }
        .stone-button {
            background-color: #3b5a6d;
            border: 1px solid #6f8fa3;
            color: #ffffff;
            font-family: ‘Merriweather’, serif;
            text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
            box-shadow: 0 2px 4px rgba(0,0,0,0.3), inset 0 1px 2px rgba(255,255,255,0.1);
            transition: all 0.2s ease-in-out;
            display: flex;
            align-items: center;
            justify-content: center;
            text-align: center;
            line-height: 1.1;
        }
        .stone-button:hover {
            background-color: #4a6c8a;
            border-color: #8bb1c7;
            box-shadow: 0 3px 5px rgba(0,0,0,0.4), inset 0 1px 3px rgba(255,255,255,0.15);
            transform: translateY(-1px);
        }
        .stone-button:active {
            transform: translateY(0);
            box-shadow: 0 1px 2px rgba(0,0,0,0.2), inset 0 0 5px rgba(0,0,0,0.3);
        }

        /* Animations */
        @keyframes pulse-crit {
            0% { text-shadow: 0 0 8px #ffd700; transform: scale(1); color: #ffd700; }
            50% { text-shadow: 0 0 15px #ffd700, 0 0 30px #e74c3c; transform: scale(1.1); color: #ffd700; }
            100% { text-shadow: 0 0 8px #ffd700; transform: scale(1); color: #ffd700; }
        }
        .crit-anim { animation: pulse-crit 0.5s ease-in-out infinite; }
       
        @keyframes pulse-fail {
            0% { text-shadow: 0 0 5px #c0392b; color: #c0392b; }
            50% { text-shadow: 0 0 15px #c0392b; color: #c0392b; }
            100% { text-shadow: 0 0 5px #c0392b; color: #c0392b; }
        }
        .fail-anim { animation: pulse-fail 1s ease-in-out infinite; }

        .result-text {
            color: #d8dee9;
            text-shadow: 1px 1px 5px rgba(0,0,0,0.6);
        }
    </style>
</head>
<body class=”h-screen w-screen flex flex-col”>

    <div id=”canvas-container”></div>

    <div id=”ui-container” class=”h-full w-full flex flex-col justify-between p-4 sm:p-6″>
       
        <!– Header –>
        <div class=”w-full flex justify-between items-start”>
            <div>
                <h1 class=”text-2xl sm:text-3xl font-bold text-teal-200 tracking-wide”>MOUNTAIN <span class=”text-gray-400″>ROLL</span></h1>
                <p class=”text-xs sm:text-sm text-blue-300 mt-1″>ANCIENT STONES // ACTIVE</p>
            </div>
        </div>

        <!– Result Display –>
        <div class=”absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-center w-full pointer-events-none”>
            <div id=”result-label” class=”text-sm sm:text-lg text-blue-200 tracking-wider mb-2 sm:mb-3 opacity-0 transition-opacity duration-300″>TOTAL SUM</div>
            <div id=”result-display” class=”text-6xl sm:text-8xl font-bold result-text opacity-0 transition-all duration-200″>–</div>
            <div id=”result-breakdown” class=”text-base sm:text-xl text-green-300 mt-2 sm:mt-3 opacity-0 tracking-wider font-semibold”></div>
        </div>

        <!– Controls –>
        <div class=”w-full max-w-lg mx-auto p-3 sm:p-4 rounded-lg wood-panel interactive”>
            <div class=”flex flex-col gap-3 sm:gap-4″>
               
                <!– Input Row –>
                <div class=”flex items-end justify-between gap-2 sm:gap-4″>
                    <div class=”w-1/5 sm:w-1/4″>
                        <label class=”block text-[10px] sm:text-xs text-blue-300 mb-1 uppercase tracking-wider text-center”>Count</label>
                        <input type=”number” id=”count-input” value=”3″ min=”1″ max=”25″
                            class=”w-full p-2 rounded text-center text-lg sm:text-xl focus:outline-none stone-input”>
                    </div>
                    <div class=”text-blue-300 pb-2 sm:pb-3 font-bold text-xl sm:text-2xl”>X</div>
                    <div class=”w-1/5 sm:w-1/4″>
                        <label class=”block text-[10px] sm:text-xs text-blue-300 mb-1 uppercase tracking-wider text-center”>Faces</label>
                        <input type=”number” id=”faces-input” value=”6″ min=”2″ max=”100″
                            class=”w-full p-2 rounded text-center text-lg sm:text-xl focus:outline-none stone-input”>
                    </div>
                    <div class=”flex-1 min-w-0″> <!– min-w-0 prevents flex item from overflowing –>
                        <label class=”block text-[10px] sm:text-xs text-blue-300 mb-1 uppercase tracking-wider text-center”>Action</label>
                        <button id=”roll-btn”
                            class=”w-full py-2 px-1 sm:px-4 rounded stone-button uppercase h-[44px] sm:h-[46px] text-xs sm:text-sm font-bold tracking-wide whitespace-nowrap overflow-hidden text-ellipsis”>
                            ROLL STONES
                        </button>
                    </div>
                </div>

                <!– Quick Presets –>
                <div class=”grid grid-cols-5 gap-2 mt-1″>
                    <button class=”preset-btn py-2 stone-button text-xs sm:text-sm w-full” data-count=”1″ data-faces=”20″>1d20</button>
                    <button class=”preset-btn py-2 stone-button text-xs sm:text-sm w-full” data-count=”2″ data-faces=”20″>2d20</button>
                    <button class=”preset-btn py-2 stone-button text-xs sm:text-sm w-full” data-count=”3″ data-faces=”6″>3d6</button>
                    <button class=”preset-btn py-2 stone-button text-xs sm:text-sm w-full” data-count=”4″ data-faces=”6″>4d6</button>
                    <button class=”preset-btn py-2 stone-button text-xs sm:text-sm w-full” data-count=”2″ data-faces=”8″>2d8</button>
                </div>
            </div>
        </div>
    </div>

    <!– Three.js –>
    <script src=”https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js”></script>

    <script>
        // — SCENE SETUP —
        const container = document.getElementById(‘canvas-container’);
        const scene = new THREE.Scene();
        scene.fog = new THREE.FogExp2(0x4a6c8a, 0.02);

        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        camera.position.z = 10;

        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        container.appendChild(renderer.domElement);
        renderer.setClearColor(0x000000, 0);

        // — LIGHTING —
        const ambientLight = new THREE.AmbientLight(0x708090);
        scene.add(ambientLight);
       
        const lights = [];
        const lightColors = [0x8fbc8f, 0xb0e0e6, 0xd8bfd8];
       
        lightColors.forEach((col, i) => {
            const l = new THREE.PointLight(col, 0.6, 50);
            l.position.set(Math.sin(i*2) * 10, Math.cos(i*2) * 10, 10);
            scene.add(l);
            lights.push(l);
        });

        // — MATERIALS —
        const fillMaterial = new THREE.MeshLambertMaterial({
            color: 0x5e6e7b,
            reflectivity: 0.1,
            shininess: 10,
            polygonOffset: true,
            polygonOffsetFactor: 1,
            polygonOffsetUnits: 1
        });
       
        const wireMaterial = new THREE.LineBasicMaterial({
            color: 0x8fbc8f,
            transparent: true,
            opacity: 0.7
        });

        // — STATE VARIABLES —
        let diceObjects = [];
        const mainGroup = new THREE.Group();
        scene.add(mainGroup);

        const countInput = document.getElementById(‘count-input’);
        const facesInput = document.getElementById(‘faces-input’);
        const rollBtn = document.getElementById(‘roll-btn’);
        const resultDisplay = document.getElementById(‘result-display’);
        const resultLabel = document.getElementById(‘result-label’);
        const resultBreakdown = document.getElementById(‘result-breakdown’);
        const presetBtns = document.querySelectorAll(‘.preset-btn’);

        let isRolling = false;

        // — GEOMETRY GENERATOR —
        function getGeometry(faces) {
            faces = parseInt(faces);
            switch(faces) {
                case 4: return new THREE.TetrahedronGeometry(0.9);
                case 6: return new THREE.BoxGeometry(1.2, 1.2, 1.2);
                case 8: return new THREE.OctahedronGeometry(0.9);
                case 12: return new THREE.DodecahedronGeometry(0.9);
                case 20: return new THREE.IcosahedronGeometry(0.9);
                default: return new THREE.IcosahedronGeometry(0.9, 1);
            }
        }

        // — LAYOUT ENGINE —
        function updateScene() {
            while(mainGroup.children.length > 0){
                const child = mainGroup.children[0];
                if(child.geometry) child.geometry.dispose();
                mainGroup.remove(child);
            }
            diceObjects = [];

            const count = parseInt(countInput.value) || 1;
            const faces = parseInt(facesInput.value) || 6;
           
            const cols = Math.ceil(Math.sqrt(count));
            const rows = Math.ceil(count / cols);
            const spacing = 3.0;
           
            const startX = -((cols – 1) * spacing) / 2;
            const startY = ((rows – 1) * spacing) / 2;

            const maxDim = Math.max(cols, rows);
            const targetZ = 5 + (maxDim * 2.0);
            camera.position.z = targetZ;

            const geometry = getGeometry(faces);
            const edges = new THREE.EdgesGeometry(geometry);

            for(let i = 0; i < count; i++) {
                const col = i % cols;
                const row = Math.floor(i / cols);

                const mesh = new THREE.Mesh(geometry, fillMaterial);
                const wireframe = new THREE.LineSegments(edges, wireMaterial.clone());
               
                mesh.add(wireframe);

                mesh.position.x = startX + (col * spacing);
                mesh.position.y = startY – (row * spacing);
               
                diceObjects.push({
                    mesh: mesh,
                    wire: wireframe,
                    speed: { x: 0.002, y: 0.002 },
                    baseY: mesh.position.y
                });

                mainGroup.add(mesh);
            }
        }

        // — INPUT LISTENERS —
        function handleUpdate() {
            if(isRolling) return;
            let c = parseInt(countInput.value);
            if(c > 25) { c = 25; countInput.value = 25; }
            if(c < 1) { c = 1; countInput.value = 1; }
            let f = parseInt(facesInput.value);
            if(f < 2) { f = 2; facesInput.value = 2; }
            updateScene();
        }

        countInput.addEventListener(‘change’, handleUpdate);
        facesInput.addEventListener(‘change’, handleUpdate);

        presetBtns.forEach(btn => {
            btn.addEventListener(‘click’, () => {
                if(isRolling) return;
                countInput.value = btn.getAttribute(‘data-count’);
                facesInput.value = btn.getAttribute(‘data-faces’);
                updateScene();
            });
        });

        // — ROLL LOGIC —
        rollBtn.addEventListener(‘click’, () => {
            if(isRolling) return;
            isRolling = true;
           
            const count = parseInt(countInput.value);
            const faces = parseInt(facesInput.value);

            resultDisplay.style.opacity = 0;
            resultLabel.style.opacity = 0;
            resultBreakdown.style.opacity = 0;
            resultDisplay.className = “text-6xl sm:text-8xl font-bold result-text opacity-0 transition-all duration-200”;
           
            // Store original text but show calculating text
            const originalText = rollBtn.innerText;
            rollBtn.innerText = “DIVINING…”;
            rollBtn.classList.add(‘opacity-50’, ‘cursor-not-allowed’);

            diceObjects.forEach(obj => {
                obj.speed = {
                    x: (Math.random() – 0.5) * 1.5,
                    y: (Math.random() – 0.5) * 1.5
                };
            });

            const results = [];
            let total = 0;
            for(let i=0; i<count; i++) {
                const r = Math.floor(Math.random() * faces) + 1;
                results.push(r);
                total += r;
            }

            setTimeout(() => {
                showResult(total, results, faces);
            }, 1000);
        });

        function showResult(total, results, faces) {
            const decayInt = setInterval(() => {
                let allStopped = true;
                diceObjects.forEach(obj => {
                    obj.speed.x *= 0.8;
                    obj.speed.y *= 0.8;
                    if(Math.abs(obj.speed.x) > 0.005) allStopped = false;
                });

                if(allStopped) {
                    clearInterval(decayInt);
                    diceObjects.forEach(obj => obj.speed = { x: 0.002, y: 0.002 });
                    isRolling = false;
                    rollBtn.innerText = “ROLL STONES”;
                    rollBtn.classList.remove(‘opacity-50’, ‘cursor-not-allowed’);
                }
            }, 50);

            resultLabel.style.opacity = 1;
            resultDisplay.textContent = total;
            resultDisplay.style.opacity = 1;
            resultBreakdown.textContent = `[ ${results.join(‘, ‘)} ]`;
            resultBreakdown.style.opacity = 1;

            const maxPossible = results.length * faces;
            const minPossible = results.length;

            diceObjects.forEach(obj => obj.wire.material.color.setHex(0x8fbc8f));

            if(total === maxPossible) {
                resultDisplay.classList.add(‘crit-anim’);
                diceObjects.forEach(obj => obj.wire.material.color.setHex(0xffd700));
            } else if (total === minPossible) {
                resultDisplay.classList.add(‘fail-anim’);
                diceObjects.forEach(obj => obj.wire.material.color.setHex(0xc0392b));
            } else {
                resultDisplay.classList.add(‘result-text’);
            }
        }

        // — ANIMATION LOOP —
        const clock = new THREE.Clock();
       
        function animate() {
            requestAnimationFrame(animate);
            const time = clock.getElapsedTime();

            diceObjects.forEach((obj, index) => {
                obj.mesh.rotation.x += obj.speed.x;
                obj.mesh.rotation.y += obj.speed.y;

                if(!isRolling) {
                    obj.mesh.position.y = obj.baseY + Math.sin(time + index) * 0.08;
                }
               
                obj.wire.material.opacity = 0.6 + Math.sin(time * 1.5) * 0.2;
            });

            renderer.render(scene, camera);
        }

        updateScene();
        animate();

        window.addEventListener(‘resize’, () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });

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

Close calls

Upcoming close approaches (November 2025)


2025VC4: Approaches on November 18, 2025, at 00:07 UTC


2025VP1: Approaches on November 18, 2025, at 00:24 UTC.


3361 Orpheus: Approaches on November 19, 2025, at 01:26 UTC.


2025VO2: Approaches on November 19, 2025, at 06:20 UTC.


Upcoming major close approach


Asteroid Apophis: This large asteroid will pass about 20,000 miles (32,000 km) from Earth’s surface on April 13, 2029, which is closer than the orbit of many geostationary satellites. While this will be the closest known approach by a large asteroid, it is not expected to impact Earth.

Via https://neo.ssa.esa.int/close-approacheshttps://neo.ssa.esa.int/close-approaches

Day 20,743

Pearl “Helpin”

Started the morning with a gentle shuffle out the door to the Salem YMCA. The pool was quiet, the kind of soft blue that invites you in without a word. Drifted through a few laps, letting the water unkink the knots and smooth out the leftover sleep. Afterward, did a little time on the machines, enough to get the muscles humming and the blood warmed up. The sort of modest workout that feels like a small high five from the universe.

Came home to the comforting rhythm of chores. Laundry tumbling in the background like a polite storm. In the kitchen, I set up a mini burrito workshop, this one powered by farmers market bounty and a little kitchen alchemy. Spring mix for a bit of crunch and green life, cheese for comfort, and “meat” made with Impossible burger cooked up with black beans and corn. A dollop of sour cream, a drizzle of taco sauce, then rolled each one up into tidy parcels. A whole tray of them, each smelling like a promise.

The cat arrived right on cue, her expression a blend of entitlement and curiosity. To ease her very real fear of missing out, she received a single dignified fingerprint of sour cream. Peace restored.

Plenty of burrito fixings left for tomorrow’s dinner, which feels like a small gift sent forward in time. The house is quiet now, laundry done, kitchen warm, day stitched together with simple goodness.

Day 20,742

Woke up this morning to a dippy doodle that crawled out of the subconscious when  still half-caffeinated. A little forest spirit stepped onto the page, bold as you please. A walking log with one big watchful eye, twig antlers like radio towers picking up secret frequencies. He carries a staff topped with another eye, which seems a bit redundant, unless you’re the sort of being who requires multiple viewpoints at once. Understandable. Mondays are like that.

There’s something friendly about him though. A guardian more than a threat. The sort of creature who wanders the twilight edge between trail and dream, tapping the earth with his knotted staff, checking for… well, whatever treefolk check for. Maybe he’s listening for root gossip. Maybe he’s making sure no one’s stealing the forest’s socks.

I like the way the lines shimmer around the eyes, like little bursts of silent magic. You can almost hear the gaze. He feels like the patron saint of getting slightly lost on purpose. Of wandering down the wrong path just to see where it goes. A fellow wanderer of green corridors and mossy shortcuts.

If I see him next time on the Greenway or tucked beside a foggy ridge, I’ll nod politely. You never ignore someone with that many eyes, disembodiedor not. He’s doing important work. Plus, he looks like he gives good trail advice.

#sketchbook #forestguardian #wyrdwoodwalker #doodle #oddfriends

Day 20,741


Cold morning light filtering in through the blinds today, the kind that makes the house feel like a quiet aquarium. I stayed in for most of it, body reminding me in its own grumbly language to take things slow. Sciatica humming along the back of my leg like an old electric wire, a collection of assorted aches chiming in just to form a chorus. Not the worst day, but not one for wandering far, either. The universe sometimes taps you on the shoulder and says stay close to home, friend, and listen.

So I did. Put the kettle on, shuffled from room to room like a hermit crab deciding which shell felt right. Every tiny movement a negotiation with muscles that have strong opinions. The cat was pleased with this, taking my enforced stillness as an invitation to supervise from nearby cushions. A slow day is a lap day, in her view.

Outside, the wind rattled the last stubborn leaves still hanging on, the trees looking a little more winter-boned with each passing hour. Clouds moved like old ships across the sky, and I watched them through the window instead of being under them. There’s a comfort in that. Some days the world can be observed from behind glass.

I read a little. I napped a little. I stretched the sciatic nerve in careful truce-seeking motions. It didn’t stop complaining, but it calmed down enough to let me breathe more easily. Pain has a way of shrinking the map of your day, but it also sharpens your attention to the small good things. Warm blankets. Hot tea. A favorite song drifting through the room like slow fog.

Not every day needs to be an adventure. Some are quieter, gentler, lived in short steps and soft corners. Today was one of those, and somehow that feels okay.

Tags: #dailyobservations #sciatica #cozyhermitday #stayingin #smallcomforts

Day 20,740

A little colder now, and the trees know it

The air nipped at me this morning. Not a bite, just that small, curious tap on the shoulder that says, Here it comes. I stepped outside with my mug and watched a thin veil of breath drift ahead of me like a shy ghost. The neighborhood birds felt it too; their calls were brisk, no-nonsense, as if they had errands.

The trees have started their slow retreat into winter colors. Not the fireburst of peak autumn anymore, but the quieter palette. Russet edges. Cinnamon browns. Golds sinking into umber. Each leaf looks like it is storing some last secret of warmth before letting go. Even the evergreens seem to be standing straighter, bracing for the season shift.

Walking the greenway, I noticed how the sunlight comes in at a different angle now, brushing everything with a steeper, paler glow. The mountains around Roanoke seemed half-dreaming under it, wrapped in those soft grays and dusty blues that show up when the year is winding down. Any day now, the morning fog will start clinging to the ridges like a blanket that refuses to be folded.

There’s a comfort in this little chill. A reminder that the world is always turning the page, even when I’m moving slow. I like that feeling of putting hands in pockets, tugging the hoodie a little tighter, listening to the leaves skitter across the pavement like tiny travelers on their own migration.

Winter is close enough to smell on the wind. Not here yet, but leaning in. Whispering. And I find myself ready for it, or as ready as anyone ever is. Let the cold come gently, like this. Let it arrive with soft footsteps and silver mornings. I’ll meet it on the porch with a warm cup and a grateful sigh.

It is now the temperature outside that makes me irresistible to chilly women and snuggly animals, as i throw off a wave of warmth that than put the most cranky baby into nap-mode.  I have used this power only for good since obtaining it in my youth.

Day 20,738

Seeing if I can crib the new design for the Salem RidgeYaks in my own style, just for the fun of it. Maybe I’ll get a tshirt made up to wear to a game?

Images are, 1. my doodle,2. actual design, and then 3. a more direct replica of the logo to make into stickers or something for laughs.

#SalemRidge Yaks #digitalmarkers #doodle #SalemVA #Roanokeva #stickerideas @ridgeyaksbaseball