Building a Space Sim in Bevy - Part 11: Data-Driven Factions
Creating a faction system with diplomatic relations, shared economies, and data-driven configuration via RON files.
Building a Space Sim in Bevy - Part 11: Data-Driven Factions
We have a problem. Combat works beautifully - ships shoot projectiles, shields absorb damage, explosions bloom. But everyone attacks everyone. There’s no “us versus them.” No politics. No reason for conflict beyond proximity.
Real space operas have factions. The Federation versus the Romulans. The Alliance versus the Independents. Let’s give our universe that structure.
What We’re Building
By the end of this part:
- Factions defined in data files - no hardcoding
- Diplomatic relations - Ally, Neutral, Enemy
- Shared faction wallets - NPCs trade for their faction, not themselves
- Combat AI that respects relations - won’t shoot allies
- Trading affected by standing - allies trade free, enemies won’t trade at all
This touches almost every system we’ve built. But the beauty of good architecture is that we can add factions by creating new components and checking them in existing systems.
The Faction Identity
First, how do we identify factions? I’m using a simple string wrapper:
// src/faction/components.rs
use bevy::prelude::*;
/// Unique faction identifier
#[derive(Clone, PartialEq, Eq, Hash, Debug, Reflect)]
pub struct FactionId(pub String);
impl FactionId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn player() -> Self {
Self::new("Player")
}
} Why a String and not an enum? Because I want factions to be data-driven. If factions were an enum, adding a new faction would require recompiling. With strings, I can define new factions in configuration files.
The player() method is a convenience - there’s always a player faction, and we reference it often.
Faction Membership
Entities belong to factions via a component:
/// Marks an entity as belonging to a faction
#[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component)]
pub struct FactionMember {
pub faction: FactionId,
}
impl FactionMember {
pub fn new(faction: impl Into<String>) -> Self {
Self {
faction: FactionId::new(faction),
}
}
} Ships, stations, projectiles - anything with a FactionMember component belongs to that faction. Simple.
Faction Information
Each faction has static info (name, color) and runtime data (credits, counts):
/// Static faction info (from config)
pub struct FactionInfo {
pub name: String,
pub color: Color,
pub is_player: bool,
}
/// Runtime faction data (changes during gameplay)
pub struct FactionData {
pub credits: u32,
pub ship_count: u32,
pub station_count: u32,
} I separated these because they serve different purposes. FactionInfo is loaded once and never changes. FactionData changes constantly as NPCs trade and ships spawn/die.
The Faction Registry
We need a central place to manage all faction data:
#[derive(Resource, Default)]
pub struct FactionRegistry {
factions: HashMap<FactionId, FactionInfo>,
data: HashMap<FactionId, FactionData>,
} This resource holds everything. Let’s add the essential methods:
impl FactionRegistry {
pub fn register(&mut self, id: FactionId, info: FactionInfo, starting_credits: u32) {
self.data.insert(id.clone(), FactionData {
credits: starting_credits,
ship_count: 0,
station_count: 0,
});
self.factions.insert(id, info);
}
pub fn get_info(&self, id: &FactionId) -> Option<&FactionInfo> {
self.factions.get(id)
}
pub fn credits(&self, faction: &FactionId) -> u32 {
self.data.get(faction).map(|d| d.credits).unwrap_or(0)
}
pub fn color(&self, faction: &FactionId) -> Color {
self.factions.get(faction)
.map(|info| info.color)
.unwrap_or(Color::WHITE)
}
} For the wallet operations that NPCs use:
impl FactionRegistry {
pub fn spend_credits(&mut self, faction: &FactionId, amount: u32) -> bool {
if let Some(data) = self.data.get_mut(faction) {
if data.credits >= amount {
data.credits -= amount;
return true;
}
}
false // Not enough credits
}
pub fn add_credits(&mut self, faction: &FactionId, amount: u32) {
if let Some(data) = self.data.get_mut(faction) {
data.credits += amount;
}
}
} These methods enforce the “can’t spend what you don’t have” rule. If a faction is broke, spend_credits returns false and the trade fails.
Diplomatic Relations
Here’s where it gets political. Factions can be allied, neutral, or hostile:
// src/faction/relations.rs
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum FactionRelation {
Ally,
#[default]
Neutral,
Enemy,
} We store relations between faction pairs:
#[derive(Resource, Default)]
pub struct FactionRelations {
relations: HashMap<(FactionId, FactionId), FactionRelation>,
} The key insight: relations are symmetric. If A is hostile to B, then B is hostile to A. We enforce this in the API:
impl FactionRelations {
pub fn set(&mut self, a: &FactionId, b: &FactionId, relation: FactionRelation) {
// Insert both directions
let key1 = (a.clone(), b.clone());
let key2 = (b.clone(), a.clone());
self.relations.insert(key1, relation);
self.relations.insert(key2, relation);
}
pub fn get(&self, a: &FactionId, b: &FactionId) -> FactionRelation {
// Same faction = always allies
if a == b {
return FactionRelation::Ally;
}
let key = (a.clone(), b.clone());
self.relations.get(&key).copied().unwrap_or(FactionRelation::Neutral)
}
} Notice that same-faction is always Ally, and undefined relations default to Neutral. This means you only need to specify exceptional relations (alliances and hostilities).
Helper methods make the code cleaner:
impl FactionRelations {
pub fn is_hostile(&self, a: &FactionId, b: &FactionId) -> bool {
self.get(a, b) == FactionRelation::Enemy
}
pub fn is_friendly(&self, a: &FactionId, b: &FactionId) -> bool {
matches!(self.get(a, b), FactionRelation::Ally)
}
} Data-Driven Configuration
Now the magic: defining factions in data files, not code. I use RON (Rusty Object Notation), which is like JSON but nicer for Rust types.
Create assets/maps/default/factions.ron:
(
factions: [
(
id: "Player",
name: "Player",
color: (0.2, 0.8, 0.9, 1.0),
starting_credits: 1000,
is_player: true,
),
(
id: "Alpha",
name: "Faction Alpha",
color: (1.0, 0.4, 0.4, 1.0),
starting_credits: 10000,
),
(
id: "Beta",
name: "Faction Beta",
color: (0.4, 1.0, 0.4, 1.0),
starting_credits: 10000,
),
(
id: "Gamma",
name: "Faction Gamma",
color: (1.0, 0.8, 0.3, 1.0),
starting_credits: 10000,
),
],
relations: [
("Player", "Gamma", "Enemy"),
("Alpha", "Gamma", "Enemy"),
],
) This defines four factions with colors and starting credits. The relations section says: Player and Gamma are enemies. Alpha and Gamma are enemies. Everyone else is neutral.
Already you can see the story emerging: Gamma is the antagonist faction, hostile to both the player and Alpha. Beta is the neutral party, trading with everyone.
Loading the Configuration
To load RON files as assets, we need a custom asset type:
use bevy::asset::Asset;
use serde::Deserialize;
#[derive(Asset, TypePath, Deserialize)]
pub struct FactionsAsset {
pub factions: Vec<FactionDef>,
pub relations: Vec<(String, String, String)>,
}
#[derive(Deserialize)]
pub struct FactionDef {
pub id: String,
pub name: String,
pub color: (f32, f32, f32, f32),
pub starting_credits: u32,
#[serde(default)]
pub is_player: bool,
} The #[serde(default)] means is_player defaults to false if not specified.
Now a system to process this asset:
pub fn load_factions(
asset: Res<Assets<FactionsAsset>>,
handle: Res<FactionsAssetHandle>,
mut registry: ResMut<FactionRegistry>,
mut relations: ResMut<FactionRelations>,
mut loaded: Local<bool>,
) {
if *loaded { return; }
let Some(factions_asset) = asset.get(&handle.0) else { return };
// Register each faction
for def in &factions_asset.factions {
let id = FactionId::new(&def.id);
let info = FactionInfo {
name: def.name.clone(),
color: Color::srgba(def.color.0, def.color.1, def.color.2, def.color.3),
is_player: def.is_player,
};
registry.register(id, info, def.starting_credits);
}
// Set up relations
for (a, b, relation_str) in &factions_asset.relations {
let relation = match relation_str.as_str() {
"Ally" => FactionRelation::Ally,
"Enemy" => FactionRelation::Enemy,
_ => FactionRelation::Neutral,
};
relations.set(&FactionId::new(a), &FactionId::new(b), relation);
}
*loaded = true;
info!("Loaded {} factions", factions_asset.factions.len());
} The Local<bool> is a clever Bevy pattern - it’s state local to this system, ensuring we only process the file once.
Combat AI Integration
Remember our enemy detection system from Part 10? It needs one change:
pub fn detect_enemies(
mut commands: Commands,
seekers: Query<(Entity, &Transform, &FactionMember, &CombatPersonality), Without<CombatTarget>>,
potential_targets: Query<(Entity, &Transform, &FactionMember), With<Combatant>>,
relations: Res<FactionRelations>, // NEW!
) {
for (seeker_entity, seeker_transform, seeker_faction, personality) in seekers.iter() {
let seeker_pos = seeker_transform.translation.truncate();
let mut closest_enemy: Option<(Entity, f32)> = None;
for (target_entity, target_transform, target_faction) in potential_targets.iter() {
if target_entity == seeker_entity { continue; }
// KEY CHANGE: Only target actual enemies
if !relations.is_hostile(&seeker_faction.faction, &target_faction.faction) {
continue;
}
let distance = seeker_pos.distance(target_transform.translation.truncate());
if distance <= personality.detection_range {
if closest_enemy.is_none() || distance < closest_enemy.unwrap().1 {
closest_enemy = Some((target_entity, distance));
}
}
}
if let Some((target, _)) = closest_enemy {
commands.entity(seeker_entity).insert(CombatTarget {
entity: target,
last_known_position: potential_targets.get(target)
.map(|(_, t, _)| t.translation.truncate())
.unwrap_or(seeker_pos),
});
}
}
} One line changed: we check relations.is_hostile() instead of “is it a different entity?” Now Alpha ships will patrol peacefully past Player ships, but engage Gamma on sight.
Faction-Aware Trading
Trading also respects relations:
pub fn calculate_trade_cost(
buyer_faction: &FactionId,
seller_faction: &FactionId,
base_price: u32,
relations: &FactionRelations,
) -> Option<u32> {
match relations.get(buyer_faction, seller_faction) {
FactionRelation::Ally => {
// Same faction or allies: free internal transfer
Some(0)
}
FactionRelation::Neutral => {
// Standard market price
Some(base_price)
}
FactionRelation::Enemy => {
// Enemies don't trade at all
None
}
}
} Returning None for enemies means the trade is impossible - the station won’t serve hostile ships. This forces hostile factions to capture stations or go without.
The “free for allies” rule is powerful. Ships within the same faction don’t pay each other - it’s an internal resource transfer. This makes faction economies work as unified wholes.
Visual Faction Colors
Ships and stations should display their faction’s colors:
pub fn update_faction_colors(
mut entities: Query<(&FactionMember, &mut Sprite), Changed<FactionMember>>,
registry: Res<FactionRegistry>,
) {
for (member, mut sprite) in entities.iter_mut() {
if let Some(info) = registry.get_info(&member.faction) {
sprite.color = info.color;
}
}
} The Changed<FactionMember> filter means this only runs when faction membership changes - efficient! Most entities keep their faction forever, so this system stays idle.
Now you can tell at a glance who owns what. Cyan ships are yours. Red ships are Alpha. Green are Beta. Yellow are Gamma (and probably shooting at you).
Running the Simulation
Fire up the game and watch the politics unfold:
- Player (Cyan) - Your faction. Start with 1,000 credits.
- Alpha (Red) - Hostile to Gamma. Will fight yellow ships.
- Beta (Green) - Neutral with everyone. Peaceful traders.
- Gamma (Yellow) - Hostile to you AND Alpha. The common enemy.
Watch Alpha and Gamma ships fight while Beta traders peacefully buy and sell. The economy and combat are now driven by political structure, not random violence.
Why Data-Driven?
This approach has huge benefits:
- Moddable: Players can edit
factions.ronto create their own scenarios - Testable: Load different faction configs for different test cases
- Flexible: Add new factions without touching Rust code
- Clear: All political relationships visible in one file
- Scenarios: Create “peaceful” maps with no enemies, or “war” maps where everyone fights
The game becomes a platform, not just a single experience.
What We Built
- FactionId: String-based, data-driven identity
- FactionRegistry: Central management of all factions
- FactionRelations: Symmetric diplomatic relationships
- RON configuration: Factions defined in data files
- System integration: Combat and trading respect relations
What We Learned
- Data-driven design: Configuration files over hardcoded enums
- Symmetric relations: Store both (A,B) and (B,A) for fast lookup
- Resource registries: Centralized management patterns
- Local state:
Local<bool>for one-time initialization - Minimal integration: Existing systems gain faction awareness with small changes
What’s Next
Our universe has factions… but where do they live? It’s hard to tell “faction territory” from just looking at ship colors.
In Part 12, we add a hex-based map system with territory ownership. Factions will control regions, and the map will show the balance of power at a glance.
The galaxy gets geography.