Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
50 KiB
Theriapolis — Phase 5 — Design & Implementation Plan
Rules, Character, Inventory, and Tactical Combat
Status: Proposed. Targets the codebase state as of 2026-04-24
(Phase 4 + tile-art polish complete; 256×256 world; ENABLE_RAIL=false;
SAVE_SCHEMA_VERSION=4; 114 tests green).
Governing docs:
theriapolis-rpg-implementation-plan.md§§ 8.1, 8.2, 8.3 (binding)theriapolis-rpg-clades.md(content authority for Clades + Species + size)theriapolis-rpg-classes.md(content authority for Classes + Subclasses + Backgrounds)theriapolis-rpg-equipment.md(content authority for Items)theriapolis-rpg-implementation-plan-phase4.md(everything Phase 5 plugs into)
1. Goals & non-goals
Goals
- A real character. The player's
Actorstops being a placeholder and gains ability scores, hit points, AC, an inventory, an equipped loadout, a Clade, a Species, a Class, and a Background — all chosen on a character-creation screen at "New Game" and saved with the world. - Combat that resolves. Hostile spawns generated by Phase 4's
TacticalChunkGen(already sitting inTacticalChunk.Spawnsand ignored) become live NPCs. When the player and a hostile end up within initiative range, control transfers to a turn-based combat loop that runs on the tactical grid. Attacks, damage, HP, and death all use a d20-adjacent resolver living inTheriapolis.Core/Rules/Combat/. - Inventory you can feel. Equip a weapon → attacks change. Wear armor →
AC changes. Wrong size → checks suffer. Carry too much → encumbrance
bites. The size-matching rule from
equipment.mdis a first-class concern, not a comment. - Content as data. Clades, species, classes, items, and NPC templates
are JSON in
Content/Data/, loaded byContentLoaderthe same waybiomes.jsonis today. New content is a JSON edit, not a code change. - Determinism preserved. A given (worldSeed, encounterId, turnSequence) produces identical dice outcomes. Save mid-combat, load, continue — the resolver picks up byte-identical to the live session.
- Phase 4 invariants intact. Polylines stay authoritative. Core stays
MonoGame-free. All RNG goes through
SeededRngwith new named sub-streams declared inConstants.cs.
Non-goals (explicit)
- Subclasses (level 3+). Phase 5 ships every class at level 1 only. The schema and content files carry full level tables for forward compatibility, but only level-1 features have runtime effect. Subclass selection is reserved for the level-up flow that Phase 5.5 / 6 builds.
- Levelling beyond character creation. XP is awarded and persisted, but
there is no level-up screen and
Character.Levelstays at 1. Alevel-upcommand in Tools is a stretch goal for end-of-phase polish. - Hybrid characters. Optional rules from
clades.md. Defer to Phase 6. - Pheromone Craft, Covenant Authority, Vocalization abilities. Each is a non-trivial subsystem that touches social/scent state Phase 6 owns. Their level-1 hooks (e.g. Scent-Broker's Scent Literacy, Covenant-Keeper's Mark of the Oath) ship as stubs — they exist, they capture the situation, but resolution is deferred. Combat-effect features (Fangsworn Fighting Style, Bulwark Sentinel Stance, Feral Rage, Shadow-Pelt Sneak Attack) ship for real.
- Faction logic, dialogue, quests, reputation events. Phase 6.
SaveBody.Factions / QuestState / Reputationstay as the empty containers Phase 4 reserved. - Friendly NPCs.
SpawnKind.Merchant/Patrolchunk records are loaded but not instantiated as live actors. OnlySpawnKind.WildAnimal,Brigand, andPoiGuardbecome combat-capable NPCs. Merchants / patrols remain as static map markers; Phase 6 makes them interactive. - Long-rest / short-rest mechanics. Phase 5 treats every combat as fully-rested (uses-per-rest features reset between encounters). The full rest model — campfires, time-of-day exhaustion, encumbered sleep — is Phase 8's problem when the world clock drives diurnal state.
- PoI interiors / dungeons. Phase 7. Combat in Phase 5 happens in the open world tactical view only.
2. Current-state inventory (what we plug into)
Audited 2026-04-24:
| Piece | Where | Phase 5 use |
|---|---|---|
Actor skeleton |
Actor.cs | Add Character? ref + IsHostileTo(Actor) helper |
PlayerActor |
PlayerActor.cs | Extend CaptureState/RestoreState to include Character |
ActorManager |
ActorManager.cs | Add SpawnNpc, RemoveActor, Tick(dt) |
TacticalChunk.Spawns |
TacticalChunk.cs:20 | Source for NpcSpawnerStage to instantiate live NPCs |
SpawnKind enum |
TacticalChunk.cs:69 | Maps to NpcTemplateDef.Id (e.g. Brigand → npc.brigand_footpad) |
| Reserved save fields | SaveBody.cs:23-30 | Phase 5 leaves these empty (still Phase 6) |
IPersistable<T> |
IPersistable.cs | New persistables: EncounterState, NpcRosterState |
ContentLoader |
ContentLoader.cs | Add LoadClades, LoadSpecies, LoadClasses, LoadItems, LoadNpcTemplates |
SeededRng |
SeededRng.cs | New sub-streams: RNG_CHARACTER, RNG_COMBAT, RNG_NPC_SPAWN, RNG_LOOT |
PlayScreen |
PlayScreen.cs | Push/pop CombatHUDScreen when an encounter starts; route input to CombatController |
| Tactical step counter | PlayerController.cs:103-104 | Repurpose: distance-walked drives both clock and encounter trigger checks |
WorldClock |
WorldClock.cs | Combat suspends world-clock advancement; resumes after |
| Constants | Constants.cs | All Phase 5 numbers (XP table, encounter trigger radius, AC ceiling, etc.) live here |
Three facts that materially shape Phase 5:
- Spawns already exist per chunk. No new generation pass; just an
instantiation pass that happens when
ChunkStreamerbrings a chunk into the active window. SaveBodyhas reserved containers that match the schema for Phase 5/6 needs — butPlayeronly carriesPlayerActorState, not character data. We extendPlayerActorStatewithCharacter(or add a parallelPlayerCharacterStatefield onSaveBody). The plan below takes the second path soPlayerActorStatestays a pure positional snapshot — clean separation of "where the body is" from "what the body is".- Tactical combat happens in the same coordinate space as movement.
The combat resolver doesn't need a new map — it uses the live
ChunkStreamerwindow. This is the payoff of Phase 4's coordinate model.
3. Phase 5 architecture
3.1 Module layout (new directories — all greenfield except where noted)
Theriapolis.Core/
Rules/
Stats/
AbilityScores.cs struct — STR/DEX/CON/INT/WIS/CHA + Mod() helper
ProficiencyBonus.cs static — level → +N table
SkillId.cs enum — 18 skills
SaveId.cs enum — 6 saves + which ability backs each
DamageType.cs enum — bludg/pierce/slash/fire/cold/lightning/poison/psychic/thunder
Condition.cs enum — prone/frightened/restrained/grappled/dazed/exhausted/blinded/stunned/unconscious
DerivedStats.cs static — HP/AC/Initiative/Speed/CarryCap from (clade, species, class, equipment)
Character/
Character.cs class — runtime aggregate; refs CladeDef + SpeciesDef + ClassDef + ability scores + level + xp + HP curr/max + Inventory
CharacterBuilder.cs class — fluent builder; used by both screen and tests
Background.cs record — id + name (mechanical effects: Phase 6)
Combat/
Encounter.cs class — owns participants, initiative order, current turn pointer, log, per-encounter SeededRng
Turn.cs struct — current actor's remaining action / bonus / reaction / movement
Resolver.cs static — pure functions: AttemptAttack, RollDamage, ApplyDamage, MakeSave, ApplyCondition
AttackProfile.cs record — derived from (weapon, attacker, target, situation); immutable per-resolution
DamageRoll.cs record — dice pool + flat mods + type
LineOfSight.cs static — tactical-tile Bresenham; blocked by IsBlockingDeco / wall
ReachAndCover.cs static — size-aware reach, cover from same-size terrain features
Entities/
Actor.cs EXTEND — add `Character? Character`, `bool IsAlive`, `Faction Allegiance` (placeholder enum)
NpcActor.cs class — Actor + AI state (current target, last-seen position, behavior tree id)
PlayerActor.cs EXTEND — CaptureState/RestoreState include character snapshot
ActorManager.cs EXTEND — `SpawnNpc(NpcTemplateDef, Vec2)`, `RemoveActor(int)`, `EncounterCheck()` per tick
Ai/
BehaviorBrigand.cs — move toward player, attack when in melee reach
BehaviorWildAnimal.cs — attack if engaged, flee at low HP
BehaviorPoiGuard.cs — patrol around POI center, engage intruders
AiContext.cs — read-only view of map + actors used by behaviors
Items/
ItemDef.cs record — JSON-loaded; id/kind/weight/cost/properties/damage/ac/sizes
ItemKind.cs enum — Weapon, Armor, Shield, Consumable, AdventuringGear, NaturalWeaponEnhancer
ItemInstance.cs class — runtime: ref to ItemDef + qty + condition + (optional) custom name
Inventory.cs class — list of instances + equip-slot map + encumbrance accessor
EquipSlot.cs enum — MainHand, OffHand, Body, Helm, Cloak, Boots, AdaptivePack, NaturalWeaponEnhancer×3
SizeMatch.cs static — small/medium/large weapon/armor compatibility check
Data/
ContentLoader.cs EXTEND — LoadClades/LoadSpecies/LoadClasses/LoadItems/LoadNpcTemplates
CladeDef.cs record — JSON: id, name, abilityMods, traits[], detriments[], languages[]
SpeciesDef.cs record — JSON: id, cladeId, name, size, abilityMods, traits[], detriments[]
ClassDef.cs record — JSON: id, name, hitDie, primaryAbility[], saves[], armorProf[], weaponProf[], skillsChoose, skillOptions[], levelTable[]
SubclassDef.cs record — JSON: id, classId, name, levelFeatures[] (loaded but unused in Phase 5)
BackgroundDef.cs record — JSON: id, name, skillProf[], toolProf[], featureText (mechanical effects deferred)
NpcTemplateDef.cs record — JSON: id, name, abilityScores, hp, ac, attacks[], speed, size, behaviorId, lootTableId
Persistence/
SaveBody.cs EXTEND — add `PlayerCharacterState PlayerCharacter`, `List<NpcDelta> ChunkNpcs`, `EncounterState? ActiveEncounter`
PlayerCharacterState.cs class — serializable: clade/species/class/background ids, ability scores, level, xp, hp curr, conditions[], inventory[], equipped slots
NpcDelta.cs struct — chunkCoord + localPos + templateId + isAlive + hpCurrent (for surviving NPCs)
EncounterState.cs class — only present mid-combat: encounterId, participants[], initiativeOrder, currentTurnIndex, perEncounterRngState
SaveCodec.cs EXTEND — add tag IDs ≥100: TAG_CHARACTER=100, TAG_NPC_ROSTER=101, TAG_ENCOUNTER=102
Theriapolis.Game/
Screens/
CharacterCreationScreen.cs — pre-game: clade → species → class → background → name → confirm
InventoryScreen.cs — modal during play (TAB key)
CombatHUDScreen.cs — overlay screen pushed when encounter starts; replaces PlayerController input routing
UI/
StatBlockPanel.cs — Myra panel; reusable in CharacterCreation + Inventory + CombatHUD
InitiativeStrip.cs — turn order + HP bars
CombatActionBar.cs — action buttons (Attack / Move / Use Item / End Turn / etc)
DamageNumberOverlay.cs — floating damage numbers in tactical view
Input/
CombatController.cs — replaces PlayerController while CombatHUDScreen is on top
Theriapolis.Tools/Commands/
CharacterRoll.cs — `dotnet run -- character-roll --seed N --clade canidae --species wolf --class fangsworn`
CombatDuel.cs — `dotnet run -- combat-duel --seed N --a npc.brigand --b npc.wolf --rounds 20`
ContentValidate.cs — sanity check: every species references a real clade, every npc uses real items, every class has level 1 features, etc.
Theriapolis.Tests/
Rules/
AbilityScoreTests.cs
DerivedStatsTests.cs
LevelTableTests.cs
CharacterBuilderTests.cs — every (clade × species × class) combo produces a valid level-1 character
InventoryEquipTests.cs
SizeMatchTests.cs
AttackResolutionTests.cs
DamageDeterminismTests.cs — same (encounterSeed, sequence) → identical rolls
ConditionTests.cs
LineOfSightTests.cs
ReachAndCoverTests.cs
Combat/
EncounterScenarioTests.cs — scripted: brigand vs wolf-folk fangsworn → expected outcome at seed X
InitiativeTests.cs
AiBehaviorTests.cs
Persistence/
CharacterSaveRoundTripTests.cs
NpcRosterRoundTripTests.cs
MidCombatSaveRoundTripTests.cs — save mid-encounter, load, finish, expected outcome unchanged
Architecture/
CoreNoDependencyTests.cs EXISTING — extended assertion: Rules/* also has no MonoGame ref
Content/Data/
clades.json — all 7 clades (Canidae, Felidae, Mustelidae, Ursidae, Cervidae, Bovidae, Leporidae)
species.json — all 19+ species per the quick-ref table in clades.md
classes.json — all 8 classes; full level table for forward compat; level-1 features only have runtime effect
subclasses.json — content present, mechanics deferred
backgrounds.json — all 12 backgrounds; mechanical effects deferred
items.json — Phase 5 subset: ~12 weapons, ~6 armors, ~3 shields, ~6 consumables, ~6 adventuring gear, ~4 natural weapon enhancers
npc_templates.json — Phase 5 set: brigand_footpad, brigand_marauder, brigand_archer, wolf, dire_wolf, bear_brown, wolverine, poi_guard_skeletal, poi_guard_corrupted
3.2 The actor / character split
Keep Actor lean (position, facing, speed — what the renderer cares about).
All gameplay state hangs off the optional Character ref:
public class Actor {
public int Id { get; init; }
public Vec2 Position { get; set; }
public float FacingAngleRad { get; set; }
public float SpeedWorldPxPerSec { get; set; } = C.PLAYER_TRAVEL_PX_PER_SEC;
// NEW in Phase 5:
public Character? Character { get; set; } // null on actors that aren't combat-capable yet
public Allegiance Allegiance { get; set; } // Player | Allied | Neutral | Hostile
public bool IsAlive => Character?.IsAlive ?? true;
}
PlayerActor and NpcActor both have a non-null Character. The
Character? nullability is for forward-compat: future actor types
(merchants, scenery NPCs) may not need stat blocks.
This avoids a single mega-class and keeps the rendering layer ignorant of
the stat layer. Camera2D doesn't need to know what HP is.
3.3 Coordinate systems for combat
Combat happens entirely in tactical-tile space, which is the same as world-pixel space (1 tactical tile = 1 world pixel — Phase 4 contract). Reach, range, and movement are measured in tactical tiles.
Size-to-occupancy mapping (from clades.md size appendix):
| Size | Tactical-tile footprint | Reach (melee) |
|---|---|---|
| Small | 1×1 | 1 tile |
| Medium | 1×1 | 1 tile |
| Medium-Large | 1×1 (counts as Large for grappling/carrying) | 1 tile |
| Large | 2×2 | 2 tiles |
(The plan reserves 3×3 for Huge but no Phase 5 NPC uses it.)
This is a deliberate simplification: in tabletop terms a "5 ft. reach"
weapon = 1 tile, and "10 ft. reach" / Large body = 2 tiles. Anything
finer (15 ft. reach polearms, etc.) becomes a weapon Reach: 2 tag in
its ItemDef.
3.4 Combat encounter lifecycle
Player walks tactical → ChunkStreamer hands ActorManager the chunk's
spawned NPCs (instantiated lazily on first stream-in).
Per tick (PlayScreen.Update):
ActorManager.EncounterCheck():
For each HOSTILE NPC within ENCOUNTER_TRIGGER_TILES (default 8) of
player AND with line of sight:
→ start an Encounter (auto-triggered)
→ push CombatHUDScreen
→ halt PlayerController, hand input to CombatController
→ halt WorldClock advance until encounter ends
ActorManager.NeutralProximityCheck():
For each FRIENDLY/NEUTRAL NPC within INTERACT_PROMPT_TILES (default 2)
of player AND with line of sight:
→ show "Press F to interact with <name>" prompt overlay
→ on F press → InteractionScreen (Phase 6 attaches dialogue)
While CombatHUDScreen is on top:
CombatController drives the active actor's Turn:
Player turn: input → AttackProfile → Resolver.AttemptAttack → log + damage numbers
NPC turn: behavior → AttackProfile → Resolver.AttemptAttack → log + damage numbers
When all hostiles are dead OR all hostiles are out of sight + 3 turns:
→ encounter ends
→ resolve XP award (player only) and loot drops
→ pop CombatHUDScreen
→ resume WorldClock + PlayerController
Two distinct interaction models, by allegiance:
- Hostile → auto-trigger combat on LOS-within-range. The player doesn't need to press anything — walking into a brigand's sight line is the engagement. This matches CRPG convention and removes a finicky "press F to attack" step in a continuous-movement view.
- Friendly / Neutral → walk-up prompt. A small overlay
(
"[F] Talk to Mara the Innkeeper") appears when the player gets within 2 tactical tiles. Pressing F opens anInteractionScreen. Phase 5 ships the prompt + screen shell; the screen body is empty (placeholder text) until Phase 6 attaches dialogue. This carves out the input path now so Phase 6 doesn't have to refactor encounter triggering.
Allegiance is set per-NPC at instantiation from
NpcTemplateDef.DefaultAllegiance. Brigands / Wild Animals / PoI
Guards default Hostile; Merchants / Patrols default Neutral. Future
faction logic can mutate this at runtime (Phase 6).
3.5 The dice contract
All combat dice go through a per-encounter SeededRng keyed by:
encounterSeed = worldSeed
^ C.RNG_COMBAT
^ EncounterId
EncounterId = (chunkCoord.X << 32) | chunkCoord.Y << 16 | encounterIndexInChunk.
This means:
- The same encounter triggered the same way in two playthroughs at the same seed produces identical first-roll outcomes.
- Player choices (which target, which weapon) immediately diverge the state, so realistic playthroughs see independent outcomes after move 1.
- Save mid-combat captures
currentTurnIndex+nextRollSequence→Encounter.RollD20()and friends pull fromSeededRng(encounterSeed).Skip(nextRollSequence).NextUInt64()→ byte-identical resume.
This is the same pattern Phase 4 uses for chunk gen. New constants:
public const ulong RNG_CHARACTER = 0xC4A2AC7EUL; // ability score rolls + starting inventory
public const ulong RNG_COMBAT = 0xC0B47UL; // per-encounter dice
public const ulong RNG_NPC_SPAWN = 0xA7C2AUL; // template variation when instantiating from chunk spawn list
public const ulong RNG_LOOT = 0x107EUL; // post-encounter drops
4. Subsystem detail
4.1 Stats
AbilityScores is a 6-byte struct (each score 1–30; level-1 cap 18 before
clade/species mods, hard cap 20 before level-20 features). Mod(int score)
returns (score - 10) / 2 rounded toward negative infinity per d20 rules.
DerivedStats is computed from the live Character and re-runs whenever
a relevant input changes (level up, equip change, condition applied):
| Stat | Formula |
|---|---|
| MaxHP | class.HitDie + Mod(CON) at level 1; later levels add RollHitDie() + Mod(CON) (rolled at level-up time, then frozen) |
| AC | armor.BaseAc + min(Mod(DEX), armor.MaxDexBonus) + shield.AcBonus + featureBonuses |
| Initiative | Mod(DEX) + featureBonuses (e.g. Feral L7 adds Mod(WIS)) |
| Speed | species.BaseSpeed + classMods + conditionMods (clamped ≥ 0) |
| CarryCap | STR * 15 lb (halved for Small, doubled for Large per equipment.md) |
Skill checks: 1d20 + Mod(stat) + (proficient ? ProfBonus : 0). Tools
can run an interactive character-roll to dump every roll.
4.2 Character creation
Screen flow (single Myra panel, multi-step):
- Clade. Picker of 7 cards: Canidae / Felidae / Mustelidae / Ursidae / Cervidae / Bovidae / Leporidae. Each shows ability mods + 1-line trait summary.
- Species. Filtered to the selected clade. Shows size, ability mods, defining trait.
- Class. All 8. Shows hit die, primary ability, level-1 feature names. Recommends species/class fits (informational only — no restrictions).
- Background. All 12. Skill proficiencies + tool proficiencies + feature text. Mechanical effects of features are stubbed for Phase 5.
- Stats. Two methods, player choice:
- Standard array: 15, 14, 13, 12, 10, 8 (assignable). Default.
- Roll 4d6 drop lowest ×6, assign. Each Reroll press derives a
fresh seed:
statRollSeed = worldSeed ^ RNG_STAT_ROLL ^ msSinceGameStartmsSinceGameStartis wall-clock ms since the process started (Stopwatch.GetTimestampsnapshot at game launch).CharacterBuilder.RollAbilityScoresaccepts anulong? msOverride = nullparameter; when non-null, that value is used in place of the live ms snapshot. Tests use the override; the game does not. This gives true non-reproducibility across plays (player gets fresh dice every press), reproducibility within tests (fixed override → fixed roll), and worldseed-anchored variation (the same ms snapshot in two different worlds yields different rolls becauseworldSeedis XORed in).
- Skills. Class lists
skillsChooseandskillOptions. Player picks N from the offered list (in addition to background's two free). - Name + confirm. Default "Wanderer". On confirm:
CharacterBuilderproduces aCharacter,ActorManager.SpawnPlayeris called with that character attached.
Validation: every step's Next button is disabled until the field is
valid. CharacterBuilder.TryBuild(out string error) is the single
canonical check.
4.3 Inventory + equipment
public sealed class Inventory {
public List<ItemInstance> Items { get; } = new();
public Dictionary<EquipSlot, ItemInstance?> Equipped { get; } = new();
public float TotalWeight => Items.Sum(i => i.Def.Weight * i.Qty);
public bool TryEquip(ItemInstance item, EquipSlot slot, out string err);
public bool TryUnequip(EquipSlot slot, out string err);
}
Equip slots:
| Slot | Notes |
|---|---|
| MainHand | Weapon or two-handed weapon |
| OffHand | Shield, light weapon (dual-wield), or empty |
| Body | Armor (Light/Medium/Heavy) or none |
| Helm | Optional |
| Cloak | Optional |
| Boots | Optional |
| AdaptivePack | Backpack (raises CarryCap) |
| NaturalWeapon×3 | Fang Caps / Claw Sheaths / Hoof Plates / Antler Tips / Horn Rings (slot N for which natural-weapon location they augment) |
TryEquip enforces:
- Weapon proficiency (class.WeaponProf must contain the item's proficiency tag, or the wielder takes disadvantage on attacks).
- Armor proficiency (no proficiency = disadvantage on STR/DEX checks + no spellcasting if applicable; for Phase 5, just disadvantage).
- Size compatibility:
SizeMatch.Check(item.Sizes, character.Size). If no match and item is not Adaptive, equip succeeds but sets aWrongSizeflag → disadvantage on attack rolls / -2 AC. - Two-handed weapons clear the OffHand slot.
- Heavy armor STR requirement (item.MinStr).
Encumbrance: at >100% capacity → speed -10 ft.; >150% → speed halved
- disadvantage on STR/DEX/CON checks.
4.4 Combat resolver
Resolver is a static class with pure functions. The Encounter is the
only stateful object; everything else is computed from (Encounter, attackerId, targetId, abilityKey, rngSequence).
AttemptAttack(encounter, attacker, target, weapon, situation):
- Compute
AttackProfile(attack bonus, damage dice, type, reach). - Roll d20 (advantage/disadvantage from
situationflags). - Compare to
target.AC. Critical hit on natural 20 (or 19-20 for crit-range weapons — e.g. Razored Claw Sheaths). - On hit: roll damage dice (doubled on crit per d20 rules — only the dice double, not the modifiers).
- Apply target HP reduction.
- Append a
CombatLogEntrywith dice values, modifiers, and outcome.
MakeSave(encounter, target, saveId, dc): d20 + ability mod + (prof
if proficient). Returns bool and logs.
ApplyCondition(encounter, target, condition, durationRounds): sets
the condition; duration ticks down at end of target's turn. Conditions
modify subsequent AttackProfile / MakeSave evaluations through
SituationFlags.
Death: Phase 5 ships permadeath as the only death model.
- NPC at HP ≤ 0 → removed from
ActorManagerimmediately; chunk'sNpcDeltarecords the kill so the body stays gone after a save/load cycle. - Player at HP ≤ 0 → start death saves (1d20 every turn; 3 cumulative successes ≥ 10 = stabilised at 0 HP, recoverable to 1 HP via ally's Field Repair / Healer's Kit; 3 cumulative failures < 10 = death). A natural 20 on a death save = revive at 1 HP. A natural 1 = two failures. (Standard d20 death-save loop.)
- On player death: an unrecoverable
DeathScreenappears with a single option,[Return to title]. The player's most recent save (theirs, or the encounter-start autosave) is the recovery mechanism — there is no in-world respawn or resurrection in Phase 5.
Save-anywhere is the player's hedge against permadeath. The pause
menu (ESC during play) gains a Save Game action that writes to the
current slot at any time except during cut scenes. Phase 5 has no
cut scenes, so save-anywhere is unconditional in this phase — the
exclusion is forward-compat scaffolding for whichever phase introduces
narrative interrupts. Combined with the existing autosave-on-screen-
transition (Phase 4), the player can save right before an ambush, lose,
and reload to retry. Permadeath here means "no in-world respawn", not
"no second chances".
Save-anywhere wiring:
- Pause menu adds
Save Gamebutton → callsSaveCodec.Write(currentSlot). - New autosave point: combat-start writes to slot
autosave_combat, separate from the rolling autosave slot, so the player can always retry the most recent fight even if their manual save is older. EncounterStatealready round-trips per §4.7 — saving mid-combat works as a side effect of save-anywhere.
4.5 NPC + AI
Templates are JSON. Behaviors are code (small switch statement, not a behavior-tree library — keep dependencies down):
public interface INpcBehavior {
void TakeTurn(NpcActor self, Encounter enc, AiContext ctx);
}
Three behaviors ship in Phase 5:
| Behavior | Logic |
|---|---|
Brigand |
If has ranged weapon and target > 4 tiles → ranged attack. Else move toward target along walkable tactical tiles, attack when in reach. |
WildAnimal |
Identical to Brigand but flees (move directly away from target) when at < 25% HP. |
PoiGuard |
Patrol around POI center while no target in sight. Engage like Brigand once target enters sight. |
AiContext provides the read-only view: tactical tiles in current chunk
window, other actors, line of sight via ReachAndCover.LineOfSight. AI
must not mutate the world directly; it returns intent and the
Encounter resolves it through Resolver.
4.6 Difficulty scaling — danger-zone template selection
Phase 3 already produces an EncounterDensity map (Stage 20). Phase 5
layers a DangerZone index on top, computed once per chunk at
instantiation time:
int DangerZone(WorldTile homeTile, ChunkCoord c) {
int distFromStartTiles = ChebyshevDist(c.WorldTileCenter, world.PlayerStartTile);
int distFromRoadTiles = NearestPolylineDistance(c.WorldTileCenter, world.Roads);
int distFromSettlementTiles = NearestSettlementDistance(c.WorldTileCenter);
int zone = 0;
zone += distFromStartTiles / C.DANGER_DIST_FROM_START_PER_ZONE; // +1 per 50 tiles
zone += (distFromRoadTiles > C.DANGER_DIST_FROM_ROAD_THRESHOLD) ? 1 : 0;
zone += (distFromSettlementTiles > C.DANGER_DIST_FROM_SETTLE_THRESHOLD) ? 1 : 0;
zone += BiomeDangerBonus(homeTile.Biome); // grassland 0, forest +0, mountain +1, marsh +1, badlands +2
return Math.Clamp(zone, C.DANGER_ZONE_MIN, C.DANGER_ZONE_MAX);
}
Five zones (0 = safest, 4 = deepest wilds). Each SpawnKind consults a
zone → template id lookup table living in npc_templates.json:
{
"spawn_kind_to_template_by_zone": {
"Brigand": [
"brigand_footpad", // zone 0: weakest
"brigand_footpad",
"brigand_marauder",
"brigand_marauder",
"brigand_captain" // zone 4: deepest wilds
],
"WildAnimal": [
"wolf_pup",
"wolf",
"wolf",
"dire_wolf",
"bear_brown" // zone 4: bears in the deep wilds
]
}
}
This means NPC stats themselves are fixed per template (no per-instance scaling) — what varies is which template spawns where. Easier to balance, easier to test, easier to communicate to the player ("the deep woods have bears" rather than "bears here have +20% HP").
DangerZone is computed once per chunk at instantiation and stored on
TacticalChunk.DangerZone (new byte field). The chunk's hash includes
it, so determinism tests catch any change to the zone formula.
combat-duel Tools command grows a --zone N flag for testing the
template lookup at a specific danger level.
4.7 Save schema
Two new fields on SaveBody:
public PlayerCharacterState PlayerCharacter { get; set; } = new();
public List<NpcDelta> ChunkNpcs { get; set; } = new();
public EncounterState? ActiveEncounter { get; set; } // null when not in combat
New SaveCodec tags (≥100 per the Phase 4 forward-compat rule):
TAG_CHARACTER = 100 // PlayerCharacterState
TAG_NPC_ROSTER = 101 // List<NpcDelta>
TAG_ENCOUNTER = 102 // EncounterState? (writes only if present)
Bump SAVE_SCHEMA_VERSION to 5. Add a no-op migration V4ToV5:
empty character → game refuses to load and prompts player to start a
new game from the same seed (so existing Phase-4 saves don't crash, but
they also don't auto-promote to Phase-5 saves with no character).
PlayerCharacterState is a flat record of primitives + arrays — same
discipline as PlayerActorState. Inventory serializes as a list of
(itemId, qty, equipSlot?) triples.
ChunkNpcs is per-chunk override list: any chunk whose NPCs have
diverged from the deterministic Pass5_Spawns baseline (one or more
killed; HP changed mid-chunk) shows up here. Empty list = baseline
roster intact. Flushed to save at chunk eviction or autosave.
ActiveEncounter is the rare case: player saved during combat. Loading
restores the encounter mid-state and pushes CombatHUDScreen directly
from the load path.
4.8 Content schemas
// clades.json
{
"id": "canidae",
"name": "Canidae",
"ability_mods": { "CON": 1, "WIS": 1 },
"size": null, // size lives on species, not clade
"traits": [
{ "id": "pack_instinct", "name": "Pack Instinct", "description": "..." },
{ "id": "superior_scent", "name": "Superior Scent", "description": "..." }
],
"detriments": [
{ "id": "pack_dependent", "name": "Pack-Dependent", "description": "..." }
],
"languages": ["common", "canid"]
}
// species.json
{
"id": "wolf",
"clade_id": "canidae",
"name": "Wolf-Folk",
"size": "medium_large",
"ability_mods": { "STR": 1 },
"base_speed_ft": 30,
"traits": [
{ "id": "jaws_of_the_alpha", "natural_weapon": { "name": "Bite", "damage": "1d8", "type": "piercing", "scaling_levels": [5, 11] } },
{ "id": "tireless_pursuit", "description": "..." },
{ "id": "howl", "description": "...", "uses_per_long_rest": 1 }
],
"detriments": [...]
}
// classes.json
{
"id": "fangsworn",
"name": "Fangsworn",
"hit_die": 10,
"primary_ability": ["STR", "DEX"],
"saves": ["STR", "CON"],
"armor_proficiencies": ["light", "medium", "heavy", "shields"],
"weapon_proficiencies": ["simple", "martial", "natural"],
"skills_choose": 2,
"skill_options": ["athletics", "intimidation", "perception", "survival", "animal_handling"],
"level_table": [
{ "level": 1, "prof": 2, "features": ["fighting_style", "claw_and_steel"] },
{ "level": 2, "prof": 2, "features": ["action_surge_1"] },
/* ...through level 20... */
],
"feature_definitions": {
"fighting_style": { "kind": "choice", "options": ["fang_and_blade", "shieldwall", "duelist", "great_weapon", "natural_predator"] },
"claw_and_steel": { "kind": "passive", "effect": "combine_natural_and_weapon_attacks" },
"action_surge_1": { "kind": "active", "uses_per_short_rest": 1, "effect": "extra_action" }
}
}
// items.json — Phase 5 v1 set (~30 items)
{
"id": "rend_sword",
"kind": "weapon",
"name": "Rend-sword",
"cost_fang": 25,
"weight_lb": 3,
"sizes": ["medium", "large"],
"proficiency": "martial",
"damage": "1d8",
"damage_versatile": "1d10",
"damage_type": "slashing",
"properties": ["versatile"]
}
// npc_templates.json
{
"id": "brigand_footpad",
"name": "Brigand Footpad",
"size": "medium",
"ability_scores": { "STR": 11, "DEX": 14, "CON": 12, "INT": 10, "WIS": 10, "CHA": 9 },
"hp": 11,
"ac": 12,
"speed_ft": 30,
"attacks": [
{ "name": "Scruff-knife", "to_hit": 4, "damage": "1d4+2", "type": "slashing", "reach_tiles": 1 }
],
"behavior": "brigand",
"loot_table": "loot_brigand_low",
"xp_award": 25
}
ContentValidate Tools command verifies referential integrity at build
time and fails CI on any broken reference.
5. Determinism & RNG
Every dice roll's source must be reproducible.
| RNG sub-stream | Used by |
|---|---|
RNG_CHARACTER |
Character-creation 4d6-drop-lowest rolls; starting equipment variation |
RNG_NPC_SPAWN |
Per-NPC variation when a chunk's spawn list is instantiated (e.g. brigand's stat-block rolls) |
RNG_COMBAT |
All in-combat dice (attack rolls, damage, saves, condition durations) |
RNG_LOOT |
Post-encounter drops (loot table evaluation) |
Per-encounter sub-seed:
encounterSeed = worldSeed ^ RNG_COMBAT ^ encounterId.
The encounter's SeededRng advances monotonically. EncounterState
serializes (encounterSeed, nextRollSequence); resume re-creates the
RNG and skips to that sequence. Verified by
MidCombatSaveRoundTripTests.
Tests required:
DamageDeterminismTests— 1000 rolls from(seedA, seq=0..999)produce the same outputs across 5 process runs.MidCombatSaveRoundTripTests— scripted encounter, save at turn 3, load, complete; final HP/log identical to running it through to completion in one go.
6. Constants going into Constants.cs
// ── Phase 5: RNG sub-streams ──────────────────────────────────────────
public const ulong RNG_CHARACTER = 0xC4A2AC7EUL;
public const ulong RNG_STAT_ROLL = 0x57A7507UL; // 4d6-drop-lowest re-rolls in char creation
public const ulong RNG_COMBAT = 0xC0B47UL;
public const ulong RNG_NPC_SPAWN = 0xA7C2AUL;
public const ulong RNG_LOOT = 0x107EUL;
// ── Phase 5: Encounter triggering ────────────────────────────────────
public const int ENCOUNTER_TRIGGER_TILES = 8; // tactical tiles; LoS still required (hostiles)
public const int INTERACT_PROMPT_TILES = 2; // tactical tiles; LoS still required (friendly/neutral)
public const int ENCOUNTER_DISENGAGE_TILES = 16; // hostiles out of sight + this far → encounter ends
public const int ENCOUNTER_DISENGAGE_TURNS = 3; // ...for this many consecutive turns
// ── Phase 5: Combat ──────────────────────────────────────────────────
public const int AC_FLOOR = 5; // sanity clamp
public const int AC_CEILING = 30;
public const int HP_MAX = 999; // Phase 5 won't exceed this
public const int DEATH_SAVES_TO_DIE = 3;
public const int DEATH_SAVES_TO_STABLE = 3;
public const int CRIT_NATURAL = 20;
// ── Phase 5: Encumbrance ─────────────────────────────────────────────
public const float ENCUMBRANCE_SOFT_MULT = 1.0f; // ≥1.0× capacity → speed -10ft
public const float ENCUMBRANCE_HARD_MULT = 1.5f; // ≥1.5× capacity → speed halved + disadvantage
// ── Phase 5: Difficulty scaling (danger zones) ───────────────────────
public const int DANGER_DIST_FROM_START_PER_ZONE = 50; // tiles per +1 zone
public const int DANGER_DIST_FROM_ROAD_THRESHOLD = 8; // tiles; further than this = +1 zone
public const int DANGER_DIST_FROM_SETTLE_THRESHOLD = 16; // tiles; further than this = +1 zone
public const int DANGER_ZONE_MIN = 0;
public const int DANGER_ZONE_MAX = 4;
// ── Phase 5: Save ────────────────────────────────────────────────────
// SAVE_SCHEMA_VERSION bumped to 5 (was 4 in Phase 4)
public const string SAVE_SLOT_AUTOSAVE_COMBAT = "autosave_combat"; // separate slot for retry-last-fight
7. Milestones
Each is a ship point: a branch with a self-contained set of changes, green tests, and a feature you can demonstrate.
M1 — Stats core + content load.
Rules/Stats/*complete with tests.ContentLoader.LoadClades / LoadSpecies / LoadClasses / LoadItems / LoadNpcTemplates.- Author all 7 clades.json, all 19 species.json, all 8 classes.json (level tables included; only level-1 feature defs present), all 12 backgrounds.json, the Phase 5 items.json subset, the Phase 5 npc_templates.json set.
ContentValidateTools command green.- Ship point:
dotnet testpasses new Rules/* suite.dotnet run -- content-validateexits 0.
M2 — Character creation + character-as-Actor-attachment + save-anywhere.
Character,CharacterBuilder,Inventory,ItemInstance,EquipSlot,SizeMatchin Core.Actor.Characterfield added;PlayerActor.CaptureState/RestoreStateextended.CharacterCreationScreenin Game (Myra-driven multi-step panel) including Standard Array + 4d6-drop-lowest withRNG_STAT_ROLL^msSinceGameStartseeding (override path wired for tests).character-rollTools command for headless verification, with--ms-override Nflag.- Save schema bumped to v5;
V4ToV5migration shipped (refuses Phase-4 saves with a clear message). - Pause-menu Save Game button wired (save-anywhere). Cut-scene exclusion is forward-compat (no cut scenes in Phase 5 → unconditional).
- Ship point: New Game → character creation → enter the world with a real stat block. ESC → Save Game → quit → load → same character at the same position.
M3 — Inventory UI + equip mechanics.
InventoryScreen(TAB key) with drag-to-equip slots.Equipped[*]flows through toDerivedStats.ACand the future attack profile.- Encumbrance speed effect visible in tactical movement (slower if overweight).
- Ship point: Equip a chain shirt → AC visibly changes on the character sheet panel. Pick up rocks until encumbered → walk slower.
M4 — Combat resolver + scripted-encounter Tools command.
Encounter,Turn,Resolver,AttackProfile,DamageRoll,LineOfSight,ReachAndCover.combat-duelTools command:--a player_character.json --b brigand_footpad --seed 42 --rounds 20runs a scripted melee and prints the log.- All
Rules/Combat/*tests green includingDamageDeterminismTests. - No game-side integration yet. Pure Core + Tools.
- Ship point: combat resolver verifiable in isolation. Headless scripted scenarios produce expected log lines.
M5 — Combat in the live game + danger zones + interact prompt.
NpcActor,INpcBehavior+ 3 behaviors (Brigand, WildAnimal, PoiGuard).ActorManager.SpawnNpccalled by a newNpcInstantiationStagethat runs whenChunkStreamerbrings a chunk into the active window. The stage consults the chunk'sDangerZoneto pick the actual template from thespawn_kind_to_template_by_zonetable — sameSpawnKind, different stat block per zone.EncounterCheck(hostiles auto-trigger) andNeutralProximityCheck(friendly/neutral show[F] Talk to ...prompt) perPlayScreen.Updatetick.CombatHUDScreen,CombatController,InitiativeStrip,CombatActionBar,DamageNumberOverlay.InteractionScreenshell — Phase 5 ships an empty placeholder body ("This NPC has nothing to say yet — Phase 6 attaches dialogue."), but the input wiring is real so Phase 6 doesn't refactor.- Mid-encounter
EncounterStatesave/restore via newIPersistable<EncounterState>onEncounter. - Combat-start autosave to
SAVE_SLOT_AUTOSAVE_COMBATso a fresh retry-this-fight slot is always available. MidCombatSaveRoundTripTestsgreen.- Ship point: walk into a brigand-spawn area in tactical mode →
encounter starts → fight to the death → world resumes. Walk up to a
merchant in town →
[F] Talkprompt appears → press F → empty dialogue placeholder. Save mid-fight, load, continue, identical outcome. Walk further from the start to verify deeper-zone NPCs (dire wolves, captain brigands) actually appear.
M6 — Polish + first-pass class features.
- Implement all level-1 combat-touching class features with real
effects:
- Fangsworn —
Fighting Style(5 options),Claw & Steel. - Bulwark —
Sentinel Stance(bonus action stance),Guardian's Mark. - Feral —
Feral Rage(2/encounter; uses-per-short-rest treated as per-encounter in Phase 5),Unarmored Defense. - Shadow-Pelt —
Sneak Attack(1d6),Scent Discipline(passive, no scent-system effect yet — flags only). - Scent-Broker —
Scent Literacy(UI surfaces NPC clade and current HP%),Nose for Lies(stub: logs "lie detected" with no dialogue system). - Covenant-Keeper —
Mark of the Oath(combat: marked targets give +2 attack to allies — hookable into faction logic later). - Muzzle-Speaker —
Voice of the Pack(ally within 30ft gains +1d4 to next attack; bonus action; per-encounter use). - Claw-Wright —
Field Repair(action: heal 1d8 + INT to ally or self),Adaptive Crafting(out-of-combat: removes WrongSize penalty for one item, 1-hour rest).
- Fangsworn —
- Death-save loop (player only — d20 per turn, 3 successes ≥ 10 to stabilise, 3 failures < 10 = permadeath).
DeathScreenon permadeath: single[Return to title]button. No in-world respawn. Recovery is via load (the autosave_combat slot always has a freshly-pre-encounter checkpoint, so retry-last-fight is one menu away).- Loot drop on NPC death (single weighted roll into chunk's local ground tiles; pickup via TAB inventory).
- Ship point: a playable Phase 5. Roll a Wolf-Folk Fangsworn, walk into Millhaven, fight a brigand, win, loot the body, save, level-1 features all visibly working.
8. Risks & mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Content-creation surface explodes (8 classes × 19 species × full level tables) | High | Med | Schema is forward-compatible from day one — author full level tables but only wire level-1 features. Phase 5.5 fills in higher levels without schema churn. |
| Combat resolver UX feels chunky after Phase 4's smooth tactical movement | Med | High | M4 + M5 split deliberately: prove the resolver in headless before integrating. Iterate UX in M6 with the live system in hand. |
| Mid-combat save/load determinism breaks subtly | Med | High | MidCombatSaveRoundTripTests is gating from M5 onward. Per-encounter SeededRng + monotonic sequence is the only way dice flow — no Random.Shared, no time-based seeds. |
| AI loops or stalls in chunk geometry the player rarely visits | Med | Med | Each NPC behavior has a per-turn budget (no recursion, no while-true). INpcBehavior.TakeTurn returns within bounded time; if no valid action, NPC ends turn. Tested via AiBehaviorTests. |
| Size + reach + cover interact in ways that break tactical clarity | Med | Med | Cover model is intentionally minimal in Phase 5: same-size deco between attacker and target = ½ cover (-2 attack); larger deco = ¾ cover (-5 attack). Anything fancier is Phase 6. |
| Scope of "level 1 class features" balloons because some level-1 features depend on subsystems Phase 5 doesn't have | Med | Med | M6's checklist explicitly lists which features are real-effect vs stubs. Anything that needs scent / faction / dialogue ships as a stub now and is upgraded by the phase that owns the missing system. |
SAVE_SCHEMA_VERSION=5 breaks all Phase-4 saves with no recovery path |
Low | Low | By design — Phase-4 saves have no character. Migration prompts player to start a new game from the same seed, preserving worldgen identity. The world is reproducible from the seed; only the placeholder actor is lost. |
9. Resolved decisions
The seven open questions from the v1 draft were closed on 2026-04-24:
- Stat-roll method. Standard Array default; 4d6-drop-lowest as an
opt-in
Rerollbutton. Each roll is seeded byworldSeed ^ RNG_STAT_ROLL ^ msSinceGameStart. Tests pass anulong? msOverrideparameter toCharacterBuilder.RollAbilityScoresto pin the roll. → Wired in §4.2. - Encounter trigger model. Hostile NPCs auto-trigger combat on
LOS-within-
ENCOUNTER_TRIGGER_TILES. Friendly / Neutral NPCs show a[F] Talk to ...prompt withinINTERACT_PROMPT_TILESand open anInteractionScreenwhen F is pressed. The screen body is a placeholder in Phase 5 (Phase 6 attaches dialogue). → Wired in §3.4. - Player death. Permadeath. The death-save loop runs (3 successes
≥ 10 to stabilise, 3 failures < 10 = dead). On death,
DeathScreenoffers only[Return to title]. Save-anywhere is the player's hedge: the pause menu'sSave Gameworks at any moment except cut scenes (forward-compat — no cut scenes exist in Phase 5). A dedicatedSAVE_SLOT_AUTOSAVE_COMBATslot writes at every encounter-start so retry-last-fight is one load away. → Wired in §4.4 and §6. - Difficulty scaling. Per-chunk
DangerZone(0–4) computed from biome danger + distance from player start + distance from roads/settlements. The chunk'sDangerZoneselects which template eachSpawnKinduses (table innpc_templates.json). NPC stats per template stay fixed — what varies is which template spawns. → Wired in new §4.6. - XP table. D&D 5e standard table verbatim. Level 2 at 300 XP, level 3 at 900 XP, etc. XP awarded and persisted in Phase 5; the level-up screen ships in Phase 5.5 / 6.
- Loot generosity. Each
NpcTemplateDefreferences aloot_tableid; Phase 5 ships ~5 tables that lean stingy. Tune by playtest in M6 using acombat-duel-style loot-distribution dump. - Natural-weapon enhancers + ability stat. Natural-weapon attacks default to STR. Fangsworn's Natural Predator fighting style upgrades all natural-weapon attacks to "use the higher of STR or DEX" (no new tag — handled inside the resolver when computing the attack profile).
10. What Phase 5 does not finish, and why that's OK
Phase 5's exit criterion is: a complete level-1 character can be created, walks the world, fights and survives (or dies and loads), and the system is ready to layer leveling, social/scent abilities, factions, and quests on top without re-architecting any of it.
Things deliberately deferred:
- Levelling beyond level 1. Phase 5.5: character sheet level-up flow, hit die selection, ability score improvement, subclass at level 3.
- Subclass features (level 3+). Same time as level-up.
- Pheromone craft / scent abilities / Vocalization / Covenant authority resolved. Phase 6 builds the social/scent layer on top of the level-1 stubs Phase 5 lays down.
- Faction & reputation tracks driving NPC behavior. Phase 6.
- Friendly NPC dialogue. Phase 6 — the merchant/patrol spawns Phase 5 ignores become Phase 6's first dialogue subjects.
- Quest engine + Act I content. Phase 6.
- PoI dungeon interiors. Phase 7.
- Long/short rest mechanics tied to the world clock. Phase 8.
- NPC schedules / day-night activity. Phase 8.
- AoE templates (cone, sphere, line). No level-1 feature in any of the 8 classes uses AoE; defer to whatever level introduces the first one (mostly subclass features, so naturally Phase 5.5+).
- Concentration / spell-like effect tracking. Same — no level-1 feature concentrates. Defer with subclass implementation.
- Non-melee/ranged combat: Phase 5 ships melee + simple ranged (single-target, range-band check, line-of-sight). Anything more exotic (multi-target rays, area suppression, etc.) waits.
The payoff: Phase 6 starts on a foundation where character + combat are real and tested, so the social/quest layer can focus on the social/quest problem instead of co-developing combat at the same time.
Theriapolis Phase 5 Implementation Plan — 2026-04-24 Author: Claude (Opus 4.7) for LO, in continuity with the Phase 0–4 plan series.