Building a Space Sim in Bevy - Part 7: NPC Trading Ships
Adding AI-controlled trader ships with behavior state machines, navigation, and automatic trading using faction wallets.
Building a Space Sim in Bevy - Part 7: NPC Trading Ships
This is the moment everything comes together. We’ve built stations, an economy, dynamic pricing… but it’s all been infrastructure. Today we add the actors who use that infrastructure: NPC traders.
By the end of this part, you’ll have AI ships flying around your sector, docking at stations, and making trades. Watch them long enough, and you’ll see them discover profitable routes on their own. It’s genuinely satisfying to watch.
The Big Picture
Our NPCs need to:
- Decide where to go (based on cargo and market knowledge)
- Navigate to the destination
- Dock at the station
- Trade - sell cargo, buy new goods
- Undock and repeat
This is a classic state machine pattern. Each NPC is in one state at a time, with clear transitions between states.
NPC Components
Let’s start with the data structures. First, the basics:
// src/npc/components.rs
use bevy::prelude::*;
/// Marker for AI-controlled ships
#[derive(Component)]
pub struct Npc;
/// What the NPC is currently doing
#[derive(Component, Default, PartialEq)]
pub enum NpcBehaviorState {
#[default]
Planning, // Deciding where to go next
Traveling, // Flying to destination
Docking, // Approaching station carefully
Trading, // At station, buying/selling
Undocking, // Leaving station
}
/// Where we're headed
#[derive(Component)]
pub struct NpcDestination {
pub target_station: Entity,
}
/// We're currently at this station
#[derive(Component)]
pub struct NpcDockedAt {
pub station: Entity,
} The state machine is just an enum. Simple, but it works beautifully. Each state has one job, and transitions are explicit.
Trading Personality
Here’s where it gets interesting. Not all traders are the same! Some haul bulk goods, others specialize in high-tech equipment. This creates variety:
#[derive(Component, Clone)]
pub struct TradingPersonality {
pub preferred_commodities: Vec<CommodityId>,
pub min_profit_margin: f32, // Won't buy unless this profit is possible
pub preference_weight: f32, // How much to favor preferred goods
pub risk_tolerance: f32, // Willingness to visit unknown stations
} Let’s create a few personality types:
impl TradingPersonality {
/// High volume, low margins - hauls ore, fuel, food
pub fn bulk_trader() -> Self {
Self {
preferred_commodities: vec![CommodityId::Ore, CommodityId::Fuel, CommodityId::Food],
min_profit_margin: 1.05, // Only needs 5% profit
preference_weight: 1.5,
risk_tolerance: 0.3,
}
}
/// High value goods - electronics and weapons
pub fn tech_trader() -> Self {
Self {
preferred_commodities: vec![CommodityId::Electronics, CommodityId::Weapons],
min_profit_margin: 1.15, // Wants 15% profit minimum
preference_weight: 2.0,
risk_tolerance: 0.5,
}
}
/// Trades anything if the price is right
pub fn opportunist() -> Self {
Self {
preferred_commodities: vec![], // No preferences
min_profit_margin: 1.20, // But demands good margins
preference_weight: 1.0,
risk_tolerance: 0.8,
}
}
} A bulk trader will happily haul ore for 5% profit, making lots of small trades. An opportunist waits for 20% margins but will trade anything. These different strategies create a more dynamic economy.
Market Knowledge
NPCs need to remember prices. Without memory, they’d have no way to find profitable routes:
#[derive(Component, Default)]
pub struct MarketKnowledge {
pub buy_prices: HashMap<Entity, HashMap<CommodityId, u32>>,
pub sell_prices: HashMap<Entity, HashMap<CommodityId, u32>>,
pub stock_levels: HashMap<Entity, HashMap<CommodityId, u32>>,
pub last_visited: HashMap<Entity, f32>, // Game time of last visit
} Every time an NPC docks, they update their knowledge with current prices. This information gradually becomes stale as the market changes, encouraging NPCs to revisit stations.
impl MarketKnowledge {
pub fn record_market_data(
&mut self,
station: Entity,
market: &StationMarket,
registry: &CommodityRegistry,
pricing: &PricingConfig,
time: f32,
) {
let buy = self.buy_prices.entry(station).or_default();
let sell = self.sell_prices.entry(station).or_default();
let stock = self.stock_levels.entry(station).or_default();
for commodity in registry.iter() {
let base_price = commodity.base_price;
buy.insert(commodity.id, market.buy_price(commodity.id, base_price, pricing));
sell.insert(commodity.id, market.sell_price(commodity.id, base_price, pricing));
stock.insert(commodity.id, market.available_stock(commodity.id));
}
self.last_visited.insert(station, time);
}
} Spawning NPCs
NPCs share the physics system with everything else - they have ThrustInput, Velocity, and Engine just like player-controllable ships. This is the beauty of ECS: the same movement code handles all ships.
pub fn spawn_npc_traders(
mut commands: Commands,
config: Res<NpcSpawnConfig>,
mut faction_registry: ResMut<FactionRegistry>,
existing: Query<Entity, With<Npc>>,
) {
if !existing.is_empty() { return; } // Idempotent
for def in &config.npcs {
let faction_color = faction_registry.color(&def.faction);
let entity = commands.spawn((
Ship,
Npc,
FactionMember::new(def.faction.clone()),
Name::new(format!("NPC: {}", def.name)),
NpcBehaviorState::Planning,
def.personality.clone(),
MarketKnowledge::default(),
// Same physics components as any ship!
Velocity::default(),
Engine {
thrust: 150.0, // Slightly slower than player ships
rotation_speed: 4.0,
},
ThrustInput::default(),
Sprite {
color: faction_color,
custom_size: Some(Vec2::new(15.0, 20.0)),
..default()
},
Transform::from_translation(def.spawn_position.extend(0.0)),
)).id();
faction_registry.register_ship(def.faction.clone(), entity);
}
} Notice there’s no Wallet component! This is important. NPCs use their faction’s shared wallet, not individual wallets. When an NPC makes profit, it goes to the faction treasury. This creates emergent faction economics.
The State Machine: Planning
When an NPC is in Planning state, they need to decide where to go. The logic depends on whether they’re carrying cargo:
pub fn npc_plan_route(
mut commands: Commands,
mode: Res<ControlMode>,
mut npcs: Query<(
Entity,
&mut NpcBehaviorState,
&TradingPersonality,
&MarketKnowledge,
&CargoHold,
&Transform,
), With<Npc>>,
stations: Query<(Entity, &Transform), With<Station>>,
) {
for (entity, mut state, personality, knowledge, cargo, transform) in &mut npcs {
// Skip if player is piloting this NPC
if mode.controlled_entity() == Some(entity) {
continue;
}
if *state != NpcBehaviorState::Planning { continue; }
let npc_pos = transform.translation.truncate();
// Decision: where should we go?
let destination = if !cargo.contents.is_empty() {
// We have cargo - find best place to sell
find_best_sell_station(cargo, knowledge, personality, &stations, npc_pos)
} else {
// No cargo - find best place to buy
find_best_buy_station(personality, knowledge, &stations, npc_pos)
};
if let Some(target) = destination {
commands.entity(entity).insert(NpcDestination { target_station: target });
*state = NpcBehaviorState::Traveling;
}
}
} See that mode.controlled_entity() check? That’s a nice touch - if you’re piloting an NPC (via the control system from Part 9), their AI pauses. You take over completely.
The State Machine: Navigation
Now the fun part - making ships fly! NPCs use the same physics system as everything else. We just need to set their ThrustInput appropriately:
pub fn npc_steering(
mode: Res<ControlMode>,
mut npcs: Query<(
Entity,
&NpcDestination,
&NpcBehaviorState,
&Transform,
&mut ThrustInput,
), With<Npc>>,
stations: Query<&Transform, With<Station>>,
) {
for (entity, destination, state, transform, mut input) in &mut npcs {
// Skip if player is piloting
if mode.controlled_entity() == Some(entity) { continue; }
// Only steer when traveling or docking
if !matches!(*state, NpcBehaviorState::Traveling | NpcBehaviorState::Docking) {
input.forward = 0.0;
input.rotation = 0.0;
continue;
}
let Ok(target_transform) = stations.get(destination.target_station) else {
continue;
};
let to_target = target_transform.translation.truncate()
- transform.translation.truncate();
let distance = to_target.length();
if distance < 1.0 { continue; }
// Here's the magic: steering with cross product
let target_dir = to_target.normalize();
let forward = (transform.rotation * Vec3::Y).truncate();
let cross = forward.x * target_dir.y - forward.y * target_dir.x;
input.rotation = cross.clamp(-1.0, 1.0);
// Slow down when approaching
let is_docking = matches!(*state, NpcBehaviorState::Docking);
input.forward = if is_docking { 0.1 }
else if distance > 300.0 { 1.0 }
else if distance > 150.0 { 0.5 }
else { 0.3 };
}
} Let me explain that cross product trick - it’s elegant:
cross = forward.x × target.y - forward.y × target.x - If we need to turn left, cross is positive
- If we need to turn right, cross is negative
- If we’re aligned, cross is zero
No trigonometry needed! The sign and magnitude tell us exactly how to rotate. I use this pattern constantly in 2D games.
Detecting Arrival
When an NPC gets close to a station, they transition to docking:
pub fn npc_detect_arrival(
mode: Res<ControlMode>,
mut npcs: Query<(
Entity,
&NpcDestination,
&mut NpcBehaviorState,
&Transform,
), With<Npc>>,
stations: Query<(&Transform, &DockingZone), With<Station>>,
) {
for (entity, destination, mut state, transform) in &mut npcs {
if mode.controlled_entity() == Some(entity) { continue; }
if *state != NpcBehaviorState::Traveling { continue; }
let Ok((station_transform, docking_zone)) = stations.get(destination.target_station) else {
continue;
};
let distance = transform.translation.truncate()
.distance(station_transform.translation.truncate());
// Enter docking mode when close to the zone
if distance <= docking_zone.radius + 30.0 {
*state = NpcBehaviorState::Docking;
}
}
} Trading with Faction Wallets
Here’s the core of the economy in action. When docked, NPCs trade:
pub fn npc_execute_trades(
mode: Res<ControlMode>,
pricing: Res<PricingConfig>,
mut faction_registry: ResMut<FactionRegistry>,
mut npcs: Query<(
Entity,
&mut NpcBehaviorState,
&TradingPersonality,
&FactionMember,
&NpcDockedAt,
&mut CargoHold,
&mut MarketKnowledge,
), With<Npc>>,
mut stations: Query<(&mut StationMarket, &FactionMember), With<Station>>,
registry: Res<CommodityRegistry>,
time: Res<Time>,
) {
for (entity, mut state, personality, npc_faction, docked_at, mut cargo, mut knowledge)
in &mut npcs
{
if mode.controlled_entity() == Some(entity) { continue; }
if *state != NpcBehaviorState::Trading { continue; }
let Ok((mut market, station_faction)) = stations.get_mut(docked_at.station) else {
continue;
};
// Are we trading with our own faction?
let is_same_faction = npc_faction.faction == station_faction.faction;
// Update our market knowledge
knowledge.record_market_data(
docked_at.station, &market, ®istry, &pricing,
time.elapsed_secs(),
);
// SELL everything first
for (commodity, quantity) in cargo.contents.clone() {
if let Some(info) = registry.get(commodity) {
let sell_price = market.sell_price(commodity, info.base_price, &pricing);
let total = sell_price * quantity;
cargo.remove(commodity, quantity);
faction_registry.add_credits(&npc_faction.faction, total);
market.add_stock(commodity, quantity);
}
}
// BUY preferred commodities
for &commodity in &personality.preferred_commodities {
let Some(info) = registry.get(commodity) else { continue };
let buy_price = market.buy_price(commodity, info.base_price, &pricing);
// Intra-faction trades are FREE!
let actual_cost = if is_same_faction { 0 } else { buy_price };
// Check if we know a profitable destination
let best_sell = knowledge.sell_prices.iter()
.filter(|(s, _)| **s != docked_at.station)
.filter_map(|(_, prices)| prices.get(&commodity))
.max()
.copied()
.unwrap_or(0);
// Skip if not profitable enough
let required = (actual_cost as f32 * personality.min_profit_margin) as u32;
if best_sell < required && actual_cost > 0 { continue; }
// How much can we buy?
let available = market.available_stock(commodity);
let credits = faction_registry.credits(&npc_faction.faction);
let can_afford = if actual_cost > 0 { credits / actual_cost } else { u32::MAX };
let can_carry = (cargo.remaining_capacity(®istry) / info.mass) as u32;
let to_buy = can_afford.min(can_carry).min(available).min(10);
if to_buy > 0 && cargo.add(commodity, to_buy, ®istry) {
if actual_cost > 0 {
faction_registry.remove_credits(&npc_faction.faction, actual_cost * to_buy);
}
market.remove_stock(commodity, to_buy);
}
}
*state = NpcBehaviorState::Undocking;
}
} There’s a lot here, so let me highlight the key insight: intra-faction trades are free!
When an NPC trades at their own faction’s station, they don’t pay credits. This creates emergent behavior:
- Factions with more stations have internal trade networks
- Cross-faction trade generates wealth (you pay the other faction)
- Factions compete economically, not just militarily
The Plugin
Let’s wire everything together:
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum NpcSet {
Decision,
Interaction,
Trading,
}
impl Plugin for NpcPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<NpcSpawnConfig>()
.configure_sets(Update, (
NpcSet::Decision,
NpcSet::Interaction,
NpcSet::Trading,
).chain().after(ShipSet::Movement).run_if(in_state(GameState::Playing)))
.add_systems(OnEnter(GameState::Playing), spawn_npc_traders)
.add_systems(Update, (
npc_plan_route.in_set(NpcSet::Decision),
npc_steering.in_set(ShipSet::Input),
npc_detect_arrival.in_set(NpcSet::Interaction),
npc_initiate_dock.in_set(NpcSet::Interaction),
npc_execute_trades.in_set(NpcSet::Trading),
npc_undock.after(NpcSet::Trading),
));
}
} Notice npc_steering is in ShipSet::Input, not NpcSet. That’s because it writes to ThrustInput, which the ship physics system reads. By putting it in the input set, it integrates cleanly with the existing physics pipeline.
Watch It Come Alive
Run the game with a few NPCs configured. You’ll see:
- Ships spawn at their starting positions
- They pick a station and start flying
- They slow down as they approach
- They “dock” (stop at the station)
- A moment later, they undock with different cargo
- They head to another station
- Repeat forever!
Watch the credits display. You’ll see your faction’s wealth change as NPCs make trades. If they’re good traders, it goes up. If not… well, that’s emergent behavior too.
Debugging Tips
When things don’t work (and they won’t, at first), here’s what to check:
- NPCs not moving? Check if they’re in
Travelingstate. Check ifThrustInputis being set. - NPCs spinning in circles? The cross product might have the wrong sign. Try negating it.
- Not trading? Check if they have a
CargoHold. Check if the station has aStationMarket. - No profit? Check the pricing multipliers. Maybe the route isn’t actually profitable.
Adding info!() logs to state transitions helps enormously.
What We Learned
- Behavior state machines: Clean AI organization with explicit states
- Shared physics: NPCs use the same movement system as everything else
- Cross product steering: Elegant 2D rotation without trig
- Faction wallets: Shared economy creates emergent faction dynamics
- Free intra-faction trade: Simple rule with complex consequences
- Player override: AI pauses when you take control
What’s Next
Our NPCs trade, but they’re not very smart. They don’t explore unknown stations, they don’t adapt to changing prices, and they can’t handle complex multi-hop routes.
In Part 8, we’ll add sophisticated route scoring, exploration behavior, and profit validation. The traders will get much smarter.
But honestly? Even this basic version is fun to watch. Sometimes the simplest AI creates the most satisfying emergent behavior.