Files
the-sunken-reach/docs/GAME_RULES_REFERENCE.md
joshuaandClaude Opus 5.5 cbda10668b V1.31.0: armor holds Dexterity back
- All of a Dexterity modifier counts toward AC in light armor, at most +2
  in medium armor, none in heavy armor, going by the equipped item's kind
- The gear card says when the armor is holding something back
- Library and seed armor notes and docs brought in line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:24:28 +02:00

5.9 KiB
Raw Permalink Blame History

D&D 5e Rules Reference — Implementation Spec

This is the canonical math the backend must implement. Build it as a single shared calculation module (e.g. backend/src/game/calculations.ts) that is the only place these formulas live — every API route and the character builder call into it rather than re-deriving numbers. This mirrors the logic already built and verified in the "DnD Character Sheet Calculator.xlsx" workbook delivered earlier in this project; treat that workbook as the reference implementation if anything below is ambiguous.

All rules are 5e (2014 core rules).

Ability Modifier

modifier = floor((score - 10) / 2)

Use Math.floor, not integer truncation — they differ for negative results (e.g. score 7 → (7-10)/2 = -1.5 → floor = -2, not -1).

Proficiency Bonus

proficiencyBonus = 2 + floor((level - 1) / 4)

Level 1–4 → +2, 5–8 → +3, 9–12 → +4, 13–16 → +5, 17–20 → +6.

Final Ability Score

finalScore = rolledScore + speciesBonus(ability, species)

speciesBonus comes from the Species Ability Score Increase table below. See seed-data/species.json.

Saving Throws

saveBonus = abilityModifier + (proficient ? proficiencyBonus : 0)

Proficiency per ability comes from the Class Quick Facts table (seed-data/classes.json).

Skills

skillBonus = governingAbilityModifier + (proficient ? proficiencyBonus : 0)

Governing ability per skill is fixed — see seed-data/skills.json. Proficiency is a per-character, per-skill boolean the player/DM sets during character building (CharacterSkillProficiency in the Prisma schema).

Passive Perception:

passivePerception = 10 + perceptionSkillBonus

Armor Class

armorClass = 10 + dexModifier + armorBonus

dexModifier here is as much of it as the equipped armor lets through (armorDexterity): all of it with none or light armor, at most +2 in medium armor, none in heavy armor. Armor is an item marked with its kind (Item.armorType); its own AC is a gear effect.

Without armor, a class feature can replace the 10 + dexModifier part: Unarmored Defense (barbarian: 10 + DEX + CON; monk, also without a shield: 10 + DEX + WIS) and Draconic Resilience (13 + DEX). The best one is used, and only when it beats 10 + dexModifier. These, and the other class features that change a sheet number (Fast Movement, Unarmored Movement, Aura of Protection, Diamond Soul, Slippery Mind, Jack of All Trades), live in backend/src/game/classFeatures.js; CHARACTERS.md section 8 lists them.

armorBonus is a flat number the player enters representing whatever armor/shield they have equipped (this matches the workbook's "Armor Bonus (from armor worn)" input cell). Do not try to model armor types as a lookup table unless you want to extend scope later — a plain integer field on Character is enough for v1.

Initiative

initiative = dexModifier

Speed

Comes from the species table (30 ft standard; 25 ft for Gnome, Dwarf, Halfling). Store as speed on the species seed data; characters may override it (speedOverride on Character) for future feature support (e.g. barbarian speed boosts) without a schema change.

Hit Points

Hit Die size comes from class (seed-data/classes.json).

hpMax(level) =
  if level === 1:
    hitDieMax + conModifier
  else:
    hitDieMax + conModifier
    + (level - 1) * (floor(hitDieSize / 2) + 1 + conModifier)

Important: level 1 always uses the maximum possible roll of the Hit Die, not the average. Every level after 1 uses the fixed "average" value floor(hitDieSize / 2) + 1 (e.g. d10 → 6, d6 → 4, d12 → 7) plus the CON modifier again. This was a bug caught and fixed while building the Excel calculator — do not regress it.

hitDice available to spend on a short rest = character level (one die of the class's Hit Die size per level).

Spellcasting (only for classes with a Spellcasting Ability ≠ "None")

spellSaveDC = 8 + proficiencyBonus + spellcastingAbilityModifier
spellAttackBonus = proficiencyBonus + spellcastingAbilityModifier

Spellcasting ability per class is in seed-data/classes.json (Intelligence, Wisdom, or Charisma). Classes with "None" have no spellcasting stats — the UI should hide those fields entirely rather than show them as zero/blank.

Species Ability Score Increases & Speed

See seed-data/species.json for the full table (43 species). Note: Half-Elf gets +2 Charisma fixed, plus +1 to two other abilities of the player's choice. The 2024 species (Aasimar, Goliath) and those of Monsters of the Multiverse have no fixed bonuses: the player chooses +2 and +1, or +1 to three abilities. These choices are a species' flexible patterns; the builder must ask for them (don't default them in the UI), and they are stored on the character as speciesBonusChoices.

Class Quick Facts

See seed-data/classes.json for the full table (13 classes, the Artificer included): Hit Die, Primary Ability, the two Saving Throw proficiencies, and Spellcasting Ability.

Skill → Governing Ability

See seed-data/skills.json (18 skills).

"Quick Stats" View (combat-context panel)

When the frontend shows the contextual quick-stats panel during an attack action, surface exactly these derived values (all computed via the module above, never stored redundantly):

  • Attack bonus for the equipped/selected weapon: proficiencyBonus (if proficient) + relevantAbilityModifier (STR normally, DEX for finesse/ranged weapons — see seed-data/weapons.json's attackAbility field, which is "STR", "DEX", or "FINESSE" meaning "use whichever of STR/DEX is higher")
  • Damage: weapon's damage dice + relevant ability modifier
  • Current HP / Max HP / Temp HP
  • AC
  • Any special abilities/features flagged on the character (free-text list is fine for v1 — a specialAbilities text/JSON field on Character or a simple related table)

This is a read view assembled from existing data — it does not need its own schema table.