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>
