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.
| Category | Example IDs | Defined 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_resourceis a valid resource ID - Every class’s
starting_skillsare valid skill IDs - Every species’s
attribute_bonusesreference valid attribute names - Every item template’s
slotis a valid equipment slot ID - Every item template’s
weapon_type(if weapon) is a valid weapon type ID - Every skill’s
resource_cost.typeis a valid resource ID - Every skill’s
conditions_appliedare valid condition IDs - Every quest encounter’s
enemy_idsare valid creature IDs - Every quest’s
loot_tableis a valid loot table ID - Every recipe’s
materialsreference valid item template IDs - Every recipe’s
result_item_template_idis a valid item template ID - Every faction’s
conflict_pair(if set) is a valid faction ID - Every loot table entry’s
template_idis 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.
| Category | Enum | Why It’s Code |
|---|---|---|
| Item storage locations | ItemLocation (Equipped, Backpack, Bank, Mail, Listed) | Each variant has different capacity rules, UI sections, transfer logic |
| Run state machine | RunStatus (InProgress, Completed, Failed) | Finite states with transitions enforced by the worker |
| Encounter dispatch | EncounterType (Combat, Trap, Decision, Hazard, Rest, Puzzle) | Each dispatches to a completely different resolver function |
| Queue condition evaluation | QueueConditionKind (IfHpBelow, IfEnemyCountAbove, etc.) | Each has different evaluation logic in the combat engine |
| Achievement criteria kinds | CriteriaKind (KillCount, ReachLevel, CraftCount, etc.) | Each has different progress tracking and check logic |
| Notification types | NotificationType (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:
- Never define a Rust enum for game content. Use
String/ newtype wrapper validated against theGameDataregistry. - Never use
sqlx::Typeon game content. UseTEXTcolumns in Postgres. The#[sqlx(transparent)]derive on the newtype wrapper handles serialization. - Define all game content in
data/TOML files. Validate cross-references at startup. - Feature 00 now includes the
GameDataregistry as part of the foundation — it’s loaded before the API server starts accepting requests. - Tests use a test
GameDatafixture loaded from atest_data/directory with minimal content (1-2 of each type) rather than the full game data set.