TTRPG Pairwise Ranking Tool
🧩 Syntax:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pairwise Ranking Tool</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
}
.choice-btn {
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
}
.choice-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.choice-btn:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
#results-container {
max-height: 60vh;
overflow-y: auto;
}
</style>
</head>
<body class="bg-gray-50 text-gray-800 flex items-center justify-center min-h-screen p-4">
<div class="w-full max-w-2xl mx-auto bg-white rounded-xl shadow-lg p-6 sm:p-8">
<div id="setup-screen">
<h1 class="text-3xl font-bold text-center text-gray-900 mb-2">Pairwise Ranking Setup</h1>
<p class="text-center text-gray-600 mb-6">Enter categories and their options below.</p>
<textarea id="item-input" class="w-full h-48 p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" placeholder="Category One Option A Option B - Category Two Option C Option D -"></textarea>
<div class="mt-6 flex flex-col sm:flex-row sm:justify-center gap-3">
<button id="start-button" class="w-full sm:w-auto bg-indigo-600 text-white font-semibold py-3 px-6 rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all duration-200">Start Ranking</button>
<button id="load-ttrpg-button" class="w-full sm:w-auto bg-gray-200 text-gray-700 font-semibold py-3 px-6 rounded-lg hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400 transition-all duration-200">Load TTRPG Preferences</button>
</div>
</div>
<div id="ranking-screen" class="hidden">
<h1 class="text-3xl font-bold text-center text-gray-900 mb-2">Which is better?</h1>
<p id="progress-text" class="text-center text-gray-500 mb-8">Comparison 1 of X</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<button id="choice-a" class="choice-btn w-full h-48 sm:h-64 bg-white border-2 border-gray-200 rounded-lg p-4 flex flex-col items-center justify-center text-center hover:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500">
<span id="choice-a-option" class="block font-semibold"></span>
<span id="choice-a-category" class="block text-sm text-gray-500 mt-2"></span>
</button>
<button id="choice-b" class="choice-btn w-full h-48 sm:h-64 bg-white border-2 border-gray-200 rounded-lg p-4 flex flex-col items-center justify-center text-center hover:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500">
<span id="choice-b-option" class="block font-semibold"></span>
<span id="choice-b-category" class="block text-sm text-gray-500 mt-2"></span>
</button>
</div>
<div class="flex justify-between items-center mt-6">
<button id="undo-button" class="text-sm text-gray-500 hover:text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed">Undo Last Choice</button>
<button id="show-results-button" class="bg-green-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200">Finish & See Results</button>
</div>
</div>
<div id="results-screen" class="hidden">
<h1 class="text-3xl font-bold text-center text-gray-900 mb-4">Final Rankings</h1>
<div id="results-container" class="space-y-6">
<!-- Results will be injected here -->
</div>
<div class="mt-6 flex flex-col sm:flex-row sm:justify-center gap-3">
<button id="save-button" class="w-full sm:w-auto bg-indigo-600 text-white font-semibold py-3 px-6 rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">Save Results</button>
<button id="return-to-ranking-button" class="w-full sm:w-auto bg-gray-800 text-white font-semibold py-3 px-6 rounded-lg hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-600">Return to Ranking</button>
<button id="restart-button" class="w-full sm:w-auto bg-gray-200 text-gray-700 font-semibold py-3 px-6 rounded-lg hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400">Start Over</button>
</div>
</div>
</div>
<script>
// --- DOM Elements ---
const setupScreen = document.getElementById('setup-screen');
const rankingScreen = document.getElementById('ranking-screen');
const resultsScreen = document.getElementById('results-screen');
const itemInput = document.getElementById('item-input');
const startButton = document.getElementById('start-button');
const loadTtrpgButton = document.getElementById('load-ttrpg-button');
const progressText = document.getElementById('progress-text');
const choiceAOption = document.getElementById('choice-a-option');
const choiceACategory = document.getElementById('choice-a-category');
const choiceBOption = document.getElementById('choice-b-option');
const choiceBCategory = document.getElementById('choice-b-category');
const choiceAButton = document.getElementById('choice-a');
const choiceBButton = document.getElementById('choice-b');
const undoButton = document.getElementById('undo-button');
const showResultsButton = document.getElementById('show-results-button');
const resultsContainer = document.getElementById('results-container');
const saveButton = document.getElementById('save-button');
const restartButton = document.getElementById('restart-button');
const returnToRankingButton = document.getElementById('return-to-ranking-button');
// --- State ---
let categories = {};
let categoryNames = [];
let pairs = [];
let currentPairIndex = 0;
let rankings = {};
let history = [];
let minComparisons = 0;
let comparisonsMade = 0;
let currentDisplayed = { a: null, b: null };
// --- Default Data ---
const ttrpgPreferences = `Executing Cunning Plans
Executing a clever, multi-step plan that succeeds perfectly.
Setting up and executing a perfect ambush against a superior enemy force.
Infiltrating a secure location using stealth and cunning, completely undetected.
Carefully managing resources over a long journey, ending with exactly what you need at the critical moment.
Secretly orchestrating events so that two rival enemy factions weaken or destroy each other.
-
Creative Battlefield Solutions
Using the environment in a creative way to win a fight (e.g., causing a rockslide, flooding a room).
Exploiting a just-discovered weakness in the heat of battle to turn the tide of a desperate fight.
Making a series of split-second decisions during a chase scene to successfully escape or catch a target.
Using a seemingly mundane item or low-level spell in a genius way to solve a major combat problem.
Creating a perfect chokepoint or flanking opportunity through clever battlefield positioning.
-
High-Stakes Combat Triumphs
Winning a desperate, tactical battle against a single, powerful "boss" monster.
Holding the line against overwhelming waves of enemies in a desperate defense.
Defeating a recurring villain or rival who has challenged you throughout the story.
Winning a dramatic one-on-one duel against a worthy champion or antagonist.
Landing a critical hit at the most opportune moment to snatch victory from the jaws of defeat.
-
Manipulating Power Dynamics
Persuading a powerful, hostile authority figure to become an ally.
Navigating a tense, high-stakes social scene, like a royal court or a gang negotiation.
Pulling off a masterful bluff or deception to bypass a major obstacle.
Earning the genuine respect and loyalty of a skeptical community or faction.
Intimidating or blackmailing a powerful opponent to get what you want.
-
Unearthing Secrets & Clever Solutions
Uncovering a major world secret or piece of forbidden lore through research.
Figuring out an enemy's critical weakness through investigation or solving a riddle before the fight.
Exploring a completely alien, bizarre, or wondrous new location.
Solving a complex, non-combat puzzle or ancient riddle.
Finding a creative, non-violent solution to a problem that seemed to require a fight.
-
Character-Driven Victories & Comedic Moments
Having a key moment that resolves a personal goal or element from your character's backstory.
Finding a legendary magic item that dramatically changes how your character plays.
A moment of unexpected comedy or a running gag paying off spectacularly.
Seeing a long-term relationship with an NPC (friendly, romantic, or rival) reach a powerful conclusion.
Having a moment where your character fundamentally overcomes a personal flaw or fear, showing true growth.
-
Surviving Under Pressure
Making a major personal sacrifice to achieve a greater good, and then having to live with that choice.
Barely escaping from an unkillable monster or a collapsing dungeon where the only goal was to get out alive.
Using a forbidden or evil power for a good cause, and struggling with its corrupting influence.
Surviving a harsh environment with dwindling resources, where every decision about food and shelter is critical.
Being trapped inside a besieged location, knowing a terrifying threat is constantly trying to get in.
-
Overcoming Previous Limits
Unleashing a brand new, high-level ability for the first time that completely changes the scale of your power.
Effortlessly succeeding at a task that was once impossible for your character, showcasing your growth.
Displaying your power in a way that awes or terrifies a powerful NPC like a king or a dragon.
Single-handedly defeating a group of enemies that would have been a challenge for the whole party at lower levels.
Pulling off a purely cinematic, over-the-top finishing move that feels incredibly cool and stylish.
-
Cooperative Problem-Solving
Pulling off a spontaneous, multi-person combo move in combat that you've never tried before.
Having the party perfectly adopt different roles in a social encounter to manipulate the outcome.
One party member's strength perfectly covering for another's critical weakness to overcome an obstacle.
Pooling the party's collective knowledge to solve a puzzle that no single member could figure out alone.
A perfectly timed rescue or support action from another player that saves your character from certain doom.
-
Tangible Impact on the World
Founding a new guild, rebuilding a town, or establishing a stronghold that becomes a permanent fixture in the world.
Being directly responsible for changing a law or overthrowing a corrupt regime, altering the political landscape.
Having your party's deeds become a famous story, song, or legend known throughout the land.
Discovering a major new location (like a hidden valley or a lost city) and putting it on the map.
Creating a new trade route or destroying a corrupt monopoly, permanently changing a region's economy.
-`;
// --- Event Listeners ---
loadTtrpgButton.addEventListener('click', () => {
itemInput.value = ttrpgPreferences;
});
startButton.addEventListener('click', () => {
const parsedData = parseInput(itemInput.value);
if (!parsedData) {
itemInput.classList.add('border-red-500');
itemInput.value = "Invalid format. Please provide at least two categories, each with at least one option, separated by '-'";
setTimeout(() => {
itemInput.classList.remove('border-red-500');
itemInput.value = '';
itemInput.placeholder = "Category One\nOption A\nOption B\n-\nCategory Two\nOption C\nOption D\n-";
}, 3000);
return;
}
categories = parsedData;
categoryNames = Object.keys(categories);
initializeRanking();
});
choiceAButton.addEventListener('click', () => handleChoice(0));
choiceBButton.addEventListener('click', () => handleChoice(1));
undoButton.addEventListener('click', undoLastChoice);
showResultsButton.addEventListener('click', showResults);
returnToRankingButton.addEventListener('click', () => {
resultsScreen.classList.add('hidden');
rankingScreen.classList.remove('hidden');
});
saveButton.addEventListener('click', saveResults);
restartButton.addEventListener('click', () => {
// Reset all state
categories = {};
categoryNames = [];
pairs = [];
currentPairIndex = 0;
rankings = {};
history = [];
minComparisons = 0;
comparisonsMade = 0;
itemInput.value = '';
resultsScreen.classList.add('hidden');
setupScreen.classList.remove('hidden');
});
// --- Functions ---
function parseInput(text) {
const tempCategories = {};
const blocks = text.trim().split(/^-{1,}$/m);
for (const block of blocks) {
if (block.trim() === '') continue;
const lines = block.trim().split('\n').map(l => l.trim()).filter(Boolean);
if (lines.length < 2) continue;
const categoryName = lines[0];
const options = lines.slice(1).map(name => ({ name, shown: 0, wins: 0, losses: 0 }));
if(options.length > 0) {
tempCategories[categoryName] = options;
}
}
return Object.keys(tempCategories).length >= 2 ? tempCategories : null;
}
function initializeRanking() {
rankings = {};
categoryNames.forEach(name => {
rankings[name] = { score: 1200 };
});
pairs = [];
for (let i = 0; i < categoryNames.length; i++) {
for (let j = i + 1; j < categoryNames.length; j++) {
pairs.push([categoryNames[i], categoryNames[j]]);
}
}
minComparisons = Math.min(pairs.length, Math.ceil(categoryNames.length * 4));
shuffleArray(pairs);
currentPairIndex = 0;
comparisonsMade = 0;
history = [];
updateUndoButton();
setupScreen.classList.add('hidden');
rankingScreen.classList.remove('hidden');
resultsScreen.classList.add('hidden');
displayNextPair();
}
function getOptionToShow(categoryName) {
const options = categories[categoryName];
if (!options || options.length === 0) return { name: "N/A" };
let minShown = Infinity;
options.forEach(opt => {
if (opt.shown < minShown) {
minShown = opt.shown;
}
});
const leastShownOptions = options.filter(opt => opt.shown === minShown);
const chosenOption = leastShownOptions[Math.floor(Math.random() * leastShownOptions.length)];
chosenOption.shown++;
return chosenOption;
}
function displayNextPair() {
// If we've gone through all pairs, reshuffle and start over.
if (currentPairIndex >= pairs.length) {
shuffleArray(pairs);
currentPairIndex = 0;
}
const [categoryA, categoryB] = pairs[currentPairIndex];
currentDisplayed.a = getOptionToShow(categoryA);
currentDisplayed.b = getOptionToShow(categoryB);
setOptionText(choiceAOption, currentDisplayed.a.name);
setOptionText(choiceBOption, currentDisplayed.b.name);
choiceACategory.textContent = `(Category: ${categoryA})`;
choiceBCategory.textContent = `(Category: ${categoryB})`;
progressText.textContent = `Comparison #${comparisonsMade + 1} (Minimum Recommended: ${minComparisons})`;
showResultsButton.disabled = comparisonsMade < minComparisons;
}
function setOptionText(element, text) {
element.textContent = text;
element.classList.remove('text-2xl', 'text-xl', 'text-lg');
if (text.length > 85) {
element.classList.add('text-lg');
} else if (text.length > 60) {
element.classList.add('text-xl');
} else {
element.classList.add('text-2xl');
}
}
function handleChoice(winnerIndex) {
const [categoryA, categoryB] = pairs[currentPairIndex];
const winningCategory = winnerIndex === 0 ? categoryA : categoryB;
const losingCategory = winnerIndex === 0 ? categoryB : categoryA;
const winningOption = winnerIndex === 0 ? currentDisplayed.a : currentDisplayed.b;
const losingOption = winnerIndex === 0 ? currentDisplayed.b : currentDisplayed.a;
history.push({
winningCategory, losingCategory,
winningOption, losingOption,
oldWinnerScore: rankings[winningCategory].score,
oldLoserScore: rankings[losingCategory].score,
});
updateElo(winningCategory, losingCategory);
winningOption.wins++;
losingOption.losses++;
currentPairIndex++;
comparisonsMade++;
updateUndoButton();
displayNextPair();
}
function undoLastChoice() {
if (history.length === 0) return;
const last = history.pop();
// We can't simply decrement currentPairIndex as it might be 0 after a reshuffle.
// Instead, we force the next pair to be the one we just undid.
// This is a simplification; a more robust undo would require saving the shuffled list state.
// For now, we'll just go back one comparison and let the next pair be whatever it is.
comparisonsMade--;
rankings[last.winningCategory].score = last.oldWinnerScore;
rankings[last.losingCategory].score = last.oldLoserScore;
last.winningOption.wins--;
last.winningOption.shown--;
last.losingOption.losses--;
last.losingOption.shown--;
// Re-enable the results button if we go below the minimum
showResultsButton.disabled = comparisonsMade < minComparisons;
progressText.textContent = `Comparison #${comparisonsMade + 1} (Minimum Recommended: ${minComparisons})`;
updateUndoButton();
// Note: The displayed pair will not be the one just undone, but the next in the sequence.
// This is a trade-off for the indefinite ranking feature.
}
function updateUndoButton() {
undoButton.disabled = history.length === 0;
}
function updateElo(winner, loser) {
const K = 32;
const ratingWinner = rankings[winner].score;
const ratingLoser = rankings[loser].score;
const expectedWinner = 1 / (1 + Math.pow(10, (ratingLoser - ratingWinner) / 400));
rankings[winner].score = ratingWinner + K * (1 - expectedWinner);
rankings[loser].score = ratingLoser + K * (0 - (1 - expectedWinner));
}
function showResults() {
rankingScreen.classList.add('hidden');
resultsScreen.classList.remove('hidden');
const sortedCategories = Object.entries(rankings).sort(([, a], [, b]) => b.score - a.score);
resultsContainer.innerHTML = '';
const categorySection = document.createElement('div');
categorySection.innerHTML = `<h2 class="text-xl font-bold text-gray-800 mb-3">Category Rankings</h2>`;
const categoryList = document.createElement('div');
categoryList.className = 'bg-gray-50 border border-gray-200 rounded-lg p-2 space-y-2';
sortedCategories.forEach(([name, data], index) => {
const rankElement = document.createElement('div');
rankElement.className = 'flex items-center justify-between p-3 rounded-md';
rankElement.classList.add(index % 2 === 0 ? 'bg-white' : 'bg-gray-100');
rankElement.innerHTML = `
<div class="flex items-center">
<span class="text-lg font-bold text-gray-500 w-8 text-center">${index + 1}</span>
<span class="ml-4 text-lg font-medium text-gray-800">${name}</span>
</div>
<span class="text-sm font-mono text-gray-600">${Math.round(data.score)}</span>`;
categoryList.appendChild(rankElement);
});
categorySection.appendChild(categoryList);
resultsContainer.appendChild(categorySection);
const detailsSection = document.createElement('div');
detailsSection.innerHTML = `<h2 class="text-xl font-bold text-gray-800 mb-3 mt-6">Option Performance Details</h2>`;
const detailsList = document.createElement('div');
detailsList.className = 'space-y-4';
sortedCategories.forEach(([categoryName]) => {
const categoryDetail = document.createElement('div');
categoryDetail.innerHTML = `<h3 class="font-semibold text-gray-700">${categoryName}</h3>`;
const optionsList = document.createElement('ul');
optionsList.className = 'list-disc list-inside text-gray-600 text-sm pl-2';
categories[categoryName].forEach(opt => {
const optionItem = document.createElement('li');
optionItem.textContent = `${opt.name} (Won: ${opt.wins}, Lost: ${opt.losses}, Shown: ${opt.shown})`;
optionsList.appendChild(optionItem);
});
categoryDetail.appendChild(optionsList);
detailsList.appendChild(categoryDetail);
});
detailsSection.appendChild(detailsList);
resultsContainer.appendChild(detailsSection);
}
function saveResults() {
const sortedCategories = Object.entries(rankings).sort(([, a], [, b]) => b.score - a.score);
const dataToSave = {
categoryRankings: sortedCategories.map(([name, data], index) => ({
category: name,
rank: index + 1,
score: Math.round(data.score)
})),
optionDetails: categories,
comparisonsMade: comparisonsMade,
timestamp: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(dataToSave, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ranking-results-${new Date().getTime()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
</script>
</body>
</html>