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.
traits arrayrestriction.folk field if archetype is folk-specificmake validate-profiles to check structuremake 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