Skip to main content

ic-6jma: GH#9: ADR: Pluggable Rules Engine Interface

Snapshot: 2026-03-30T08:40:31Z

FieldValue
Statusopen
Assignee(unassigned)
Priority2
Labelsatlas
Created bygithub-bridge
Created2026-03-28T19:00:08Z
Updated2026-03-28T19:00:08Z

Description

GitHub issue: b4arena/spellkave#9 URL: https://github.com/b4arena/spellkave/issues/9

Context

The PRD uses "D&D-inspired framing" but explicitly states: "The goal is not a digital tabletop reproduction — it's a living world that borrows D&D's consequence logic." The architecture should support swapping rule systems (D&D 5e, Pathfinder 2e, custom simplified rules) without rewriting the world kernel.

FoundryVTT proves this is achievable: the same core runs D&D 5e, Pathfinder 2e, and 200+ other game systems through a plugin architecture. In Spellkave's SpacetimeDB world, the equivalent is compile-time trait implementations — different rule sets compiled into the module.

What Atlas Should Deliver

An ADR defining:

1. The Rules Engine Trait Interface

What contract a rules engine must satisfy:

  • Action resolution — given world state + actor + action, produce a result
  • Time progression — what happens on each world tick (decay, regeneration, environmental effects)
  • Action validation — is this action legal given current state?
  • Derived data — computed values from base stats (AC from DEX + armor, spell save DC from proficiency + ability mod)
  • Combat flow — initiative, turn order, attack/damage/save resolution

2. How It Maps to SpacetimeDB

  • Rules logic lives in Rust trait implementations
  • Reducers dispatch through the active rules engine
  • Swapping rule systems = swapping the impl + republishing the module
  • Not runtime-swappable (acceptable for Phase 0-2)
  • Static data (spell tables, monster stats) loaded from CSVs specific to each rule system

3. D&D 5e as First Implementation

Define what "D&D 5e rules" means for a persistent world simulation (not a tabletop VTT):

  • Which rules apply as-is (ability checks, saving throws, combat, HP/damage)
  • Which rules need adaptation (long rest ≠ "8 hours" in a continuous world, encounter balance for AI-vs-AI)
  • Which rules are irrelevant (initiative order in a real-time world? turn-based combat in an async world?)

4. Phase 0 Minimum Rules Surface

The smallest set of rules needed for the Phase 0 exit criteria ("≥3 AI agents, ≥2 emergent cross-agent events"):

  • Basic ability checks (d20 + modifier vs DC)
  • Simple combat (attack roll, damage, HP tracking)
  • Social interaction (persuasion, deception, insight checks)
  • Movement between locations

Key Reference Material

FoundryVTT (abstraction patterns)

BitCraft (implementation pattern)

Dependencies

  • Depends on #3 (SpacetimeDB architecture)
  • Depends on #5 (World State Data Model) — rules operate on the data model
  • Feeds into #7 (Phase 0 Minimal Module) — which rules to implement first

Acceptance Criteria

  • Rust trait interface defined (pseudocode is fine)
  • Mapping to SpacetimeDB reducers documented
  • D&D 5e adaptations for persistent-world context listed
  • Phase 0 minimum rules surface identified
  • Explains why not runtime-swappable (yet) and what would change that
  • Committed to spellkave repo

Conversation

github-bridgeMar 28, 08:20 PMsystem
[GH @durandom] ## Addendum: Rules Engine Insights (from design exploration) ### How BitCraft Implements Game Rules BitCraft doesn't have an abstract "rules engine trait" — rules are embedded directly in reducers and handler functions. Combat logic lives in `handlers/attack.rs`: ```rust fn calculate_hit_outcome(ctx, attacker_id, defender_id, ...) -> (damage, armor, hit, crit) { const STRENGTH_PER_DAMAGE: f32 = 15.0; // +1 damage per 15 STR const EVASION_MULTIPLIER: f32 = 0.1; const ARMOR_50PCT_REDUCTION: f32 = 2000.0; // ... RPG damage formula with strength bonus, accuracy vs evasion, armor mitigation } ``` Key patterns: - **Constants at the top of handler files** — game balance numbers are literal constants - **Static data from CSV** — item stats, enemy stats, recipes loaded at build time - **`#[feature_gate("combat")]`** — custom proc macro to conditionally enable/disable game systems ### FoundryVTT D&D 5e → SpacetimeDB Mapping The most transferable files from FoundryVTT's dnd5e system: | FoundryVTT File | Maps To | Spellkave Equivalent | |---|---|---| | `config.mjs` (all D&D constants) | `static_data.rs` | Rust enums: abilities, skills, damage types, conditions | | `activity/attack.mjs` | `handlers/attack.rs` | Attack reducer: d20 + modifier vs AC | | `activity/save.mjs` | `handlers/save.rs` | Saving throw reducer: d20 + modifier vs DC | | `activity/check.mjs` | `handlers/check.rs` | Ability/skill check reducer | | `activity/damage.mjs` | `handlers/damage.rs` | Damage calculation with resistances | | `actor/character.mjs` | `components.rs` | `AbilityScoreState`, `SkillState`, `ClassState` tables | | `documents/combat.mjs` | `handlers/combat.rs` | Initiative, turn order (if needed in async world) | ### Adaptation Questions for Persistent World The ADR should specifically address which D&D 5e rules need adaptation: 1. **Long Rest = "8 hours"** — in a continuous world, what triggers a long rest? Time-based? Location-based (safe haven)? 2. **Initiative order** — does turn-based combat make sense in a real-time world? Or does combat become simultaneous with action timers (like BitCraft's `AttackTimer` pattern)? 3. **Encounter balance** — CR system assumes a DM balancing encounters. In an emergent world, who balances? Does the world self-balance through consequences? 4. **Spell slots** — long rest recovery in a world that never pauses. How fast do casters recharge? ### The `#[feature_gate]` Pattern BitCraft's feature gating is worth adopting: ```rust #[spacetimedb::reducer] #[feature_gate("combat")] pub fn attack_start(ctx: &ReducerContext, request: EntityAttackRequest) -> Result<(), String> { ... } ``` This allows enabling/disabling entire game systems at runtime via a `GatedFeature` table. For Spellkave Phase 0, this means you can ship the combat system but gate it while testing social interactions first. ### Pluggability Assessment For Phase 0-2, compile-time trait dispatch is sufficient: ```rust // The trait exists as a design boundary trait RulesEngine { fn resolve_action(ctx: &ReducerContext, actor: u64, action: &Action) -> ActionResult; } // But only one implementation exists struct Dnd5eRules; impl RulesEngine for Dnd5eRules { ... } ``` Runtime swappability (selecting rules engine per-world or per-region) would require either: - Dynamic dispatch (trait objects) — works but loses some type safety - Separate SpacetimeDB modules per rules system — cleaner but means separate deployments - Feature flags selecting between implementations at compile time — simplest Recommend: compile-time selection via feature flags for now. Revisit when a second rules system is actually needed.
github-bridgeMar 28, 08:30 PMsystem
[GH @durandom] ## Addendum: Adaptive Combat Model (tied to Relativistic Time) ### The Problem D&D's turn-based combat doesn't scale in a persistent world: - AI vs AI (90% of fights): turns are meaningless — both resolve instantly - Human vs AI: human needs think time, AI doesn't, world shouldn't freeze - Human vs Human: both need time, but can go AFK, blocking others - Large battles (5+ combatants): 30 turns of waiting = unplayable ### The Solution: Combat Mode Follows TimeFrame Combat mode is not a global setting — it's determined per-encounter by who's involved. This ties directly to the Relativistic Time Model (see issue #7). ```rust #[derive(SpacetimeType)] pub enum CombatMode { /// AI vs AI → entire combat resolves in one reducer call. /// Result written as narrative to WorldEventLog. InstantResolution, /// 1 human in combat. Generous turn timers (60s). /// AI acts instantly on its turn. Human gets full D&D tactical experience. RelaxedTurns, /// 2-3 humans. Classic D&D initiative + turns. /// 60s timeout per turn. Disconnect = AI takes over. ClassicTurns, /// 5+ combatants. Turns too slow. Cooldown-based real-time. /// D&D action economy preserved via cooldown budgets. ActionQueues, } fn determine_combat_mode(combatants: &[Combatant]) -> CombatMode { let humans = combatants.iter().filter(|c| !c.is_ai).count(); let total = combatants.len(); match (humans, total) { (0, _) => CombatMode::InstantResolution, (1, 2..=4) => CombatMode::RelaxedTurns, (2..=3, _) => CombatMode::ClassicTurns, (_, _) => CombatMode::ActionQueues, } } ``` ### SpacetimeDB Implementation ```rust #[spacetimedb::table(name = combat_instance, public)] pub struct CombatInstance { #[primary_key] pub combat_id: u64, pub location_entity_id: u64, pub mode: CombatMode, pub current_turn_entity_id: u64, // turn-based modes only pub turn_deadline: Timestamp, // turn-based modes only pub round: u32, pub status: CombatStatus, // Active, Resolved, Fled } #[spacetimedb::table(name = combatant, public, index(name = combat_id, btree(columns = [combat_id])))] pub struct Combatant { #[primary_key] pub id: u64, pub combat_id: u64, pub entity_id: u64, pub initiative: i32, pub turn_order: u32, pub is_ai: bool, } // Turn timeout enforcement (turn-based modes only) #[spacetimedb::table(name = turn_timer, scheduled(turn_timeout_handler, at = scheduled_at))] pub struct TurnTimer { #[primary_key] #[auto_inc] pub scheduled_id: u64, pub scheduled_at: spacetimedb::ScheduleAt, pub combat_id: u64, pub entity_id: u64, } // For ActionQueues mode: cooldown tracking (BitCraft's CombatState pattern) #[spacetimedb::table(name = action_cooldown_state, public)] pub struct ActionCooldownState { #[primary_key] pub entity_id: u64, pub action_available_at: Timestamp, pub bonus_action_available_at: Timestamp, pub reaction_available: bool, pub movement_remaining: f32, } ``` ### InstantResolution — AI vs AI Combat in Detail The most common case. Entire combat resolves in a single reducer call: ```rust fn resolve_instant_combat(ctx: &ReducerContext, combatants: &[u64]) { // 1. Roll initiative for all (determines narrative order) // 2. Simulate rounds until one side wins/flees // 3. Apply final state (HP, death, loot) to tables // 4. Write narrative summary to WorldEventLog: // "Goblin patrol attacked merchant caravan on Forest Road. // After 3 rounds, the goblins were driven off. // 2 goblins killed, 1 guard wounded." // Total: <100ms for a full combat } ``` ### Phase 0 Scope Only implement: - **InstantResolution** — AI vs AI (needed for 72h continuous world operation) - **RelaxedTurns** — single human player encounters (needed for Phase 1 human entry) Defer to Phase 2-3: - ClassicTurns (multi-human party combat) - ActionQueues (large-scale battles) ### D&D Rules Adaptations for Each Mode | D&D Concept | InstantResolution | RelaxedTurns | ClassicTurns | ActionQueues | |---|---|---|---|---| | Initiative | Rolled, determines narrative | Rolled, determines turn order | Standard D&D | N/A (cooldown-based) | | Actions per round | 1 Action + 1 Bonus + Move | Same (human gets 60s) | Same (60s timeout) | Cooldown budget per 6s | | Reactions | Simulated (% chance) | Full D&D reactions | Full D&D reactions | Event-triggered | | Concentration | Tracked, checked on damage | Full tracking | Full tracking | Full tracking | | Death saves | Simulated | Full (player rolls) | Full | Full | | Spell slots | Consumed normally | Full tracking | Full tracking | Full tracking |