Jekyll site for dnd.rigo.nu - Custom Varlyn rules and campaign documentation
đł Fully containerized development environment
# Start local development server
make serve
Site will be available at: http://localhost:4000/dnd/
Run make help to see all available commands.
make serve builds Docker image, mounts current directory, and starts Jekyll server on port 4000 with auto-reload.
_Campaigns/ - Campaign documentation_Classes/ - Character classes_Folk/ - Races and peoples_Rules*/ - Game rules organized by categorydocs/_Classes/, docs/_Folk/, docs/_Campaigns/)_data/ if adding characters/scenerymake validate-profiles (classes) or make lint-md (formatting)Container wonât start:
make clean # Clean everything
make build # Rebuild Docker image
make serve # Try again
Common Issues:
make cleandocker ps and docker logsCtrl+C and make serveSite deploys automatically to https://dnd.rigo.nu when pushing to main branch via GitHub Pages.
See tools/README.md for complete tool documentation and usage.
Each answer affects multiple traits with positive/negative values. Player scores are calculated as percentages and matched against class profile requirements.
Data Structure:
# _data/question-bank.yml
- id: healing-magic
text: "Do you want to heal and protect your allies?"
answers:
"yes":
healing-magic: +4 # Strong FOR healing
damage-magic: -2 # Moderate AGAINST damage
utility-magic: +1 # Slight toward utility
"no":
healing-magic: -2 # Against healing
damage-magic: +4 # Strong for damage
Scoring Algorithm:
Folk Selection (Optional):
restriction.folk fields# Example: Folk-restricted archetype
path-of-the-battlerager:
restriction:
folk: ["dwarf"] # Can be single string or array
traits: ["reckless-value", "heavy-armor", "unstoppable-force"]
Accumulate scores as player answers:
traitScores = {
'healing-magic': { current: 0, min: 0, max: 0 },
'damage-magic': { current: 0, min: 0, max: 0 },
// ... etc
}
// For each question, update all affected traits
for (question in questions) {
for (trait in question.answer[playerChoice]) {
traitScores[trait].current += trait.value
traitScores[trait].min += (min possible value this Q)
traitScores[trait].max += (max possible value this Q)
}
}
Calculate alignment percentage:
percentage = (current - min) / (max - min) * 100
// Example: healing-magic
// current: +6, min: -4, max: +10
// percentage = (6 - (-4)) / (10 - (-4)) = 10/14 = 71%
Match to class profiles:
# docs/_Classes/cleric.md
profile:
traits: ["religious-value", "divine-magic", "healing-magic", "protective-value"]
archetypes:
life-domain:
traits: ["divine-healer"]
light-domain:
traits: ["holy-power", "divine-healer", "illuminating-light"]
Match score = Playerâs percentage in required traits

Filter by folk restrictions:
restriction.folk only appear if userâs folk matchesFilter by trait mismatch (default):
Character Profile Display:
After completing the questionnaire, users see their personalized character profile organized into trait categories:

Magic Affinity (*-magic traits) - Shows magical preferences and inclinations
healing-magic, damage-magic, divine-magic, arcane-magicBackground (*-background traits) - Represents character origins and experience
military-background, academic-background, tribal-backgroundPhilosophy & Values (*-value traits) - Core beliefs and worldview
lawful-value, chaotic-value, protective-value, cunning-valueKey Traits (all other traits) - Combat styles, skills, and characteristics
weapon-master, shield-specialist, stealth-masterImplementation Files:
_data/question-bank.yml - Question definitions with trait scoringassets/js/questionnaire.js - Scoring engine and recommendation algorithm_layouts/questionnaire.html - Template that loads data and questionnaire.jsdocs/_Classes/*.md - Class profiles with trait requirementsQuestions adapt to explore unexplored traits for top recommended classes. Starts random, then targets traits needed by lowest-ranked recommendations, ensuring all archetypes get fair evaluation.
The questionnaire system automatically includes new archetypes when theyâre properly structured in class files:
restriction.folk field if archetype is folk-specificmake validate-profiles to check structuremake test-class-scoring to verify recommendation logicmake extract to include in searchable contentFor trait naming and archetype patterns, reference existing class files in docs/_Classes/.
The Level Duration Matrix visualizes how many real-world days each campaign spent at each character level (1-20). Accounts for characters joining at different levels and fills gaps via interpolation.
1. Data Collection
For each campaign, filter characters by path (campaign number):
campaignChars = characterData.filter(c => c.path == campaign.nr)
2. Per-Character Duration Calculation
For each character with valid start, end, startlevel, and maxlvl:
totalDays = daysBetween(start, end)
startLvl = startlevel
endLvl = maxlvl - maxlvl2 // Account for multiclassing
levelsGained = endLvl - startLvl
if (levelsGained >= 0 && totalDays > 0) {
// Include both start and end level (character played at both)
levelsExperienced = levelsGained + 1
daysPerLevel = totalDays / levelsExperienced
// Distribute days-per-level to all levels played (inclusive)
for (lvl = startLvl; lvl <= endLvl; lvl++) {
levelDurations[lvl] += daysPerLevel
levelCounts[lvl] += 1
}
}
Example:
Characters often join campaigns at current level (e.g., new player joins level 11 campaign). This creates gaps where no characters played certain levels.
allLevels = sortedKeys(avgByLevel)
minLvl = allLevels[0]
maxLvl = allLevels[last]
for (lvl = minLvl; lvl <= maxLvl; lvl++) {
if (!avgByLevel[lvl]) {
// Find nearest levels with data
leftLvl = findNearestLeft(lvl, avgByLevel)
rightLvl = findNearestRight(lvl, avgByLevel)
if (leftLvl exists && rightLvl exists) {
// Interpolate between adjacent levels
avgByLevel[lvl] = round((avgByLevel[leftLvl] + avgByLevel[rightLvl]) / 2)
} else if (leftLvl exists) {
avgByLevel[lvl] = avgByLevel[leftLvl]
} else if (rightLvl exists) {
avgByLevel[lvl] = avgByLevel[rightLvl]
}
}
}
Example Gap Fill:
5. Per-Row Color Scaling
Each campaign row uses its own min/max for color intensity:
campaignDays = values(campaign.levels).filter(d => d > 0)
minDays = min(campaignDays)
maxDays = max(campaignDays)
getColorIntensity(days) {
if (!days) return lightGray
normalized = (days - minDays) / (maxDays - minDays)
lightness = 85% - (normalized * 30%) // Range: 85% to 55%
return hsl(210, 80%, lightness)
}
This ensures each campaignâs internal variation is visible, even if absolute day counts differ significantly between campaigns.
Implementation Files:
assets/js/campaign-stats.js - renderLevelDurationMatrix() functionassets/js/campaign-data.js - Data fetching and caching from Google Sheetstools/statistics.html - Matrix container and styling