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>

Leave a Reply