Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Data-Driven Architecture

Principle

Adding new game content should never require recompilation. Species, classes, items, skills, factions, status effects — all of it is defined in data files (TOML), loaded at server startup, and validated at load time. The database stores string IDs that reference data definitions, not Rust enums.

This means:

  • Adding a 10th species = add a TOML file, restart server
  • Adding a new status effect = add it to conditions.toml, restart server
  • Adding a new weapon type = add a TOML file, restart server
  • No code changes, no recompilation, no database migration

What Is Data (String IDs)

Everything that describes “what exists in the game world” is defined in TOML files under data/ and referenced by string ID throughout the codebase and database.

CategoryExample IDsDefined In
Species"ironborn", "verdani", "kharren"data/species/*.toml
Classes"vanguard", "shade", "arcanist"data/classes/*.toml
Subclasses"bulwark", "assassin", "evoker"data/classes/*.toml (nested)
Backgrounds"soldier", "scholar", "merchant"data/backgrounds/*.toml
Weapon Types"sword", "axe", "bow", "staff"data/weapon_types/*.toml
Equipment Slots"main_hand", "head", "ring_1"data/equipment_slots.toml
Rarity Tiers"common", "rare", "legendary"data/rarities.toml
Resource Types"stamina", "mana", "fury"data/resources.toml
Status Effects"poisoned", "stunned", "hasted"data/conditions/*.toml
Skills"power_strike", "fireball"data/skills/*.toml
Item Templates"iron_longsword", "leather_cap"data/items/**/*.toml
Creatures"goblin_scout", "cave_troll"data/creatures/*.toml
Quests"goblin_warren", "deep_mines"data/quests/**/*.toml
Factions"iron_compact", "shadow_court"data/factions/*.toml
Reputation Tiers"hostile", "friendly", "exalted"data/factions/reputation_tiers.toml
Crafting Professions"blacksmithing", "alchemy"data/crafting/professions.toml
Gathering Professions"mining", "herbalism"data/gathering/professions.toml
Recipes"iron_longsword_recipe"data/crafting/recipes/*.toml
Achievements"first_blood", "dungeon_crawler"data/achievements/*.toml
Loot Tables"goblin_bounty_t1"data/loot_tables/*.toml
Bonus Property Pools"offensive", "defensive"data/bonus_pools.toml

Database Columns

All of these are stored as TEXT in PostgreSQL. The column species on the characters table is TEXT NOT NULL, not a Postgres enum. This means adding a new species never requires a migration.

-- YES: plain text, validated by the application
CREATE TABLE characters (
    species     TEXT NOT NULL,  -- validated against data/species/ at runtime
    class       TEXT NOT NULL,  -- validated against data/classes/ at runtime
    background  TEXT NOT NULL,  -- validated against data/backgrounds/ at runtime
    ...
);

-- NO: Postgres enums require ALTER TYPE migrations to add values
-- CREATE TYPE species AS ENUM ('ironborn', 'verdani', ...);

Rust Types

Instead of Rust enums with hardcoded variants, game content IDs are wrapped in validated newtypes:

#![allow(unused)]
fn main() {
/// A validated reference to a species defined in data/species/*.toml
/// The inner String is guaranteed to be a valid species ID at construction time.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct SpeciesId(String);

impl SpeciesId {
    /// Create a SpeciesId, validating it exists in the game data registry.
    pub fn new(id: &str, registry: &GameData) -> Result<Self, GameDataError> {
        if registry.species.contains_key(id) {
            Ok(Self(id.to_string()))
        } else {
            Err(GameDataError::UnknownSpecies(id.to_string()))
        }
    }

    pub fn as_str(&self) -> &str { &self.0 }
}

// Same pattern for ClassId, BackgroundId, WeaponTypeId, SlotId,
// RarityId, ResourceTypeId, ConditionId, FactionId, SkillId, etc.
}

Data Registry

All game data is loaded into a GameData struct at startup and shared via Axum state:

#![allow(unused)]
fn main() {
pub struct GameData {
    pub species: HashMap<String, SpeciesDef>,
    pub classes: HashMap<String, ClassDef>,
    pub backgrounds: HashMap<String, BackgroundDef>,
    pub weapon_types: HashMap<String, WeaponTypeDef>,
    pub equipment_slots: HashMap<String, EquipmentSlotDef>,
    pub rarities: Vec<RarityDef>,  // ordered by tier (common first)
    pub resources: HashMap<String, ResourceDef>,
    pub conditions: HashMap<String, ConditionDef>,
    pub skills: HashMap<String, SkillDef>,
    pub item_templates: HashMap<String, ItemTemplateDef>,
    pub creatures: HashMap<String, CreatureDef>,
    pub quests: HashMap<String, QuestDef>,
    pub factions: HashMap<String, FactionDef>,
    pub reputation_tiers: Vec<ReputationTierDef>,  // ordered by threshold
    pub loot_tables: HashMap<String, LootTableDef>,
    pub recipes: HashMap<String, RecipeDef>,
    pub achievements: HashMap<String, AchievementDef>,
    pub bonus_pools: HashMap<String, BonusPoolDef>,
}

impl GameData {
    /// Load all data files from the data/ directory and validate cross-references.
    pub fn load(data_dir: &Path) -> Result<Self, GameDataError> {
        let data = Self { /* load each category */ };
        data.validate()?;  // check all cross-references
        Ok(data)
    }

    /// Validate that all cross-references between data files are valid.
    /// e.g., a class's `primary_resource` must reference a valid resource ID,
    /// a quest's `encounters.enemy_ids` must reference valid creature IDs, etc.
    fn validate(&self) -> Result<(), GameDataError> { /* ... */ }
}
}

Cross-Reference Validation

At startup, the GameData::validate() method checks every cross-reference:

  • Every class’s primary_resource is a valid resource ID
  • Every class’s starting_skills are valid skill IDs
  • Every species’s attribute_bonuses reference valid attribute names
  • Every item template’s slot is a valid equipment slot ID
  • Every item template’s weapon_type (if weapon) is a valid weapon type ID
  • Every skill’s resource_cost.type is a valid resource ID
  • Every skill’s conditions_applied are valid condition IDs
  • Every quest encounter’s enemy_ids are valid creature IDs
  • Every quest’s loot_table is a valid loot table ID
  • Every recipe’s materials reference valid item template IDs
  • Every recipe’s result_item_template_id is a valid item template ID
  • Every faction’s conflict_pair (if set) is a valid faction ID
  • Every loot table entry’s template_id is a valid item template ID

If any validation fails, the server refuses to start with a clear error message naming the broken reference. This catches data errors at deploy time, not at player-action time.

What Is Code (Rust Enums)

Things that define how the engine works — not what content exists — remain as Rust enums. These change only when the engine’s behavior changes, which is a code change anyway.

CategoryEnumWhy It’s Code
Item storage locationsItemLocation (Equipped, Backpack, Bank, Mail, Listed)Each variant has different capacity rules, UI sections, transfer logic
Run state machineRunStatus (InProgress, Completed, Failed)Finite states with transitions enforced by the worker
Encounter dispatchEncounterType (Combat, Trap, Decision, Hazard, Rest, Puzzle)Each dispatches to a completely different resolver function
Queue condition evaluationQueueConditionKind (IfHpBelow, IfEnemyCountAbove, etc.)Each has different evaluation logic in the combat engine
Achievement criteria kindsCriteriaKind (KillCount, ReachLevel, CraftCount, etc.)Each has different progress tracking and check logic
Notification typesNotificationType (RunComplete, ItemSold, etc.)Each has different payload structure and routing

Important: Condition Definitions vs. Condition Evaluation

The definitions of status effects (Poisoned does 5 damage/round, lasts 3 rounds) are data-driven — they live in data/conditions/*.toml. But the evaluation logic for queue conditions (“if my HP is below X”) is code, because each condition type requires different logic to evaluate:

#![allow(unused)]
fn main() {
// CODE: how to evaluate a queue condition (engine logic)
pub enum QueueConditionKind {
    Always,
    IfHpBelow,
    IfHpAbove,
    IfTargetHpBelow,
    IfEnemyCountAbove,
    IfEnemyCountBelow,
    IfAllyHpBelow,
    IfResourceAbove,
    IfResourceBelow,
    IfHasCondition,      // the condition ID is a data reference
    IfTargetHasCondition, // the condition ID is a data reference
}

// DATA: what "poisoned" actually does (loaded from TOML)
// data/conditions/poisoned.toml
// [poisoned]
// name = "Poisoned"
// damage_per_round = 5
// duration_rounds = 3
// damage_type = "poison"
// stackable = false
}

Data File Format Examples

Species Definition

# data/species/ironborn.toml
[ironborn]
name = "Ironborn"
description = "Stout mountain-dwelling folk..."
attribute_bonuses = { fortitude = 2, might = 1 }

[ironborn.species_skill]
id = "ironborn_resilience"
name = "Ironborn Resilience"
description = "Reduce incoming physical damage by 5%"
effect = { type = "damage_reduction", subtype = "physical", value = 0.05 }

Condition Definition

# data/conditions/poisoned.toml
[poisoned]
name = "Poisoned"
description = "Taking poison damage each round"
category = "debuff"
damage_per_round = 5
damage_type = "poison"
default_duration = 3
stackable = false
icon = "poison"

[stunned]
name = "Stunned"
description = "Cannot act this round"
category = "debuff"
skip_turn = true
default_duration = 1
stackable = false
icon = "stun"

Equipment Slot Definition

# data/equipment_slots.toml
[[slots]]
id = "main_hand"
name = "Main Hand"
accepts = ["weapon"]
required = false

[[slots]]
id = "off_hand"
name = "Off Hand"
accepts = ["weapon", "shield"]
required = false
blocked_by = "two_handed"  # if main_hand has a two-handed weapon

[[slots]]
id = "head"
name = "Head"
accepts = ["armor_head"]
required = false

# ... etc for all 11 slots

Rarity Definition

# data/rarities.toml
[[rarities]]
id = "common"
name = "Common"
color = "#ffffff"
tier = 0
bonus_count = 0
has_signature = false
salvage_material = "common_fragment"
salvage_quantity = 2

[[rarities]]
id = "rare"
name = "Rare"
color = "#0070dd"
tier = 2
bonus_count = [1, 2]  # 1-2 random bonuses
has_signature = true
salvage_material = "rare_core"
salvage_quantity = 1

[[rarities]]
id = "legendary"
name = "Legendary"
color = "#ff8000"
tier = 4
bonus_count = 3
has_signature = true
has_gear_skill = true
salvage_material = "legendary_spark"
salvage_quantity = 1

Resource Definition

# data/resources.toml
[[resources]]
id = "stamina"
name = "Stamina"
max_base = 100
regen_per_round = 10
regen_on_rest = 50
color = "#4CAF50"
used_by_classes = ["vanguard", "pathfinder", "berserker"]

[[resources]]
id = "mana"
name = "Mana"
max_base = 80
regen_per_round = 5
regen_on_rest = 30
color = "#2196F3"
used_by_classes = ["arcanist", "hexbinder"]

[[resources]]
id = "fury"
name = "Fury"
max_base = 0  # starts at 0, builds during combat
regen_per_round = 0
gain_on_hit = 15
gain_on_take_damage = 10
color = "#F44336"
used_by_classes = ["berserker"]

Migration Strategy for Existing Feature Docs

The feature docs (00-18) reference hardcoded enums in several places. The principle going forward:

  1. Never define a Rust enum for game content. Use String / newtype wrapper validated against the GameData registry.
  2. Never use sqlx::Type on game content. Use TEXT columns in Postgres. The #[sqlx(transparent)] derive on the newtype wrapper handles serialization.
  3. Define all game content in data/ TOML files. Validate cross-references at startup.
  4. Feature 00 now includes the GameData registry as part of the foundation — it’s loaded before the API server starts accepting requests.
  5. Tests use a test GameData fixture loaded from a test_data/ directory with minimal content (1-2 of each type) rather than the full game data set.