Building a Space Sim in Bevy - Part 8: Smart NPC Trading Intelligence
Implementing sophisticated AI trading with route scoring, exploration triggers, profit validation, and the strategy pattern.
Building a Space Sim in Bevy - Part 8: Smart NPC Trading Intelligence
Our NPC traders work. They fly to stations, buy stuff, sell stuff, repeat. But honestly? They’re not very bright.
Watch them for a while and you’ll notice problems. Sometimes they buy goods that don’t sell for profit anywhere. They never explore new stations - they just loop between the same ones forever. And every single NPC behaves exactly the same way.
Let’s fix that. In this part, we’re giving our NPCs actual trading intelligence.
The Vision
By the end of this part, our NPCs will:
- Only buy things they can sell profitably - no more buying high, selling low
- Explore new stations when current options aren’t great
- Have preferences - some NPCs love trading ore, others prefer electronics
- Consider stock levels - don’t fly to a station that’s out of what you need
This is where our simple AI becomes genuinely interesting to watch.
Architecture: The Strategy Pattern
Before diving into code, let’s think about architecture. We want different NPCs to make different decisions. A cautious trader might only take sure-profit routes. A risk-taker might explore more.
How do we make this flexible? Traits.
In Rust, traits let us define behavior that can have multiple implementations. We’ll create traits for:
- Route scoring: How attractive is this trade route?
- Exploration triggers: When should we look for new stations?
This is the Strategy Pattern - different algorithms, same interface.
Here’s our module structure:
// src/npc/trading/mod.rs
pub mod routes;
pub mod scoring;
pub mod exploration;
pub mod analysis;
pub use routes::*;
pub use scoring::*;
pub use exploration::*; Trade Routes as Value Objects
First, let’s give our trading logic a proper vocabulary. A “trade route” is a complete trade opportunity: buy commodity X at station A, sell at station B.
// src/npc/trading/routes.rs
use bevy::prelude::*;
#[derive(Debug, Clone)]
pub struct TradeRoute {
pub commodity: CommodityId,
pub buy_station: Entity,
pub sell_station: Entity,
pub buy_price: u32,
pub sell_price: u32,
pub available_stock: u32,
} This is a “value object” - it doesn’t have behavior, it just holds related data together. By packaging all this info into one struct, our code becomes much clearer. Instead of passing around six separate parameters, we pass one TradeRoute.
Now let’s add some useful calculations:
impl TradeRoute {
pub fn profit_margin(&self) -> f32 {
if self.buy_price == 0 { return 0.0; }
self.sell_price as f32 / self.buy_price as f32
}
pub fn profit_per_unit(&self) -> i32 {
self.sell_price as i32 - self.buy_price as i32
}
pub fn max_profit(&self, credits: u32, cargo_space: f32, mass_per_unit: f32) -> u32 {
// Three constraints on how much we can buy:
let by_credits = credits / self.buy_price; // What we can afford
let by_cargo = (cargo_space / mass_per_unit) as u32; // What fits
let by_stock = self.available_stock; // What's available
// We're limited by the smallest constraint
let quantity = by_credits.min(by_cargo).min(by_stock);
quantity * self.profit_per_unit().max(0) as u32
}
} The profit_margin tells us the ratio - a margin of 1.5 means we sell for 50% more than we buy. The max_profit method is clever: it calculates the best possible profit considering credits, cargo space, AND available stock. Real constraints, realistic behavior.
Discovering Routes
Given what an NPC knows about markets, how do we find all possible trade routes? We loop through every combination:
pub fn discover_routes(
knowledge: &MarketKnowledge,
current_station: Option<Entity>,
) -> Vec<TradeRoute> {
let mut routes = Vec::new();
// For each station we know buy prices at
for (buy_station, buy_prices) in &knowledge.buy_prices {
// Skip current station as buy source (we're already there!)
if Some(*buy_station) == current_station { continue; }
for (commodity, &buy_price) in buy_prices {
// Find stations where we could sell this commodity
for (sell_station, sell_prices) in &knowledge.sell_prices {
// Can't sell where we buy
if sell_station == buy_station { continue; }
if let Some(&sell_price) = sell_prices.get(commodity) {
// Only include profitable routes
if sell_price > buy_price {
let stock = knowledge.stock_levels
.get(buy_station)
.and_then(|s| s.get(commodity))
.copied()
.unwrap_or(0);
routes.push(TradeRoute {
commodity: *commodity,
buy_station: *buy_station,
sell_station: *sell_station,
buy_price,
sell_price,
available_stock: stock,
});
}
}
}
}
}
routes
} This is O(stations² × commodities), but with a handful of stations and commodities, it’s instant. The beauty is that NPCs will only “see” routes for stations they’ve actually visited. Fresh NPCs know nothing - they have to explore first!
Route Scoring: The Strategy Pattern in Action
Here’s where it gets interesting. Different NPCs should evaluate routes differently. We define a trait:
// src/npc/trading/scoring.rs
/// Trait for scoring trade routes
pub trait RouteScorer {
fn score(&self, route: &TradeRoute, personality: &TradingPersonality) -> f32;
} Simple interface: give me a route and a personality, I’ll give you a score. Higher is better.
Now we can implement different scoring strategies:
/// Scores routes based on profit margin and commodity preferences
pub struct PreferenceWeightedScorer;
impl RouteScorer for PreferenceWeightedScorer {
fn score(&self, route: &TradeRoute, personality: &TradingPersonality) -> f32 {
let margin = route.profit_margin();
// Filter by minimum profit margin
// Some NPCs won't bother with small profits
if margin < personality.min_profit_margin {
return 0.0;
}
// Base score is profit margin
let mut score = margin;
// Apply preference weight for preferred commodities
// An NPC who loves ore will weight ore routes higher
if personality.preferred_commodities.contains(&route.commodity) {
score *= personality.preference_weight;
}
// Bonus for high stock (more reliable)
if route.available_stock > 10 {
score *= 1.1;
}
score
}
} Let me walk through the logic:
- Minimum margin check: An NPC with
min_profit_margin = 1.2won’t take routes with less than 20% profit. Cautious! - Base score from margin: A 50% margin scores 1.5, a 100% margin scores 2.0
- Preference boost: If an NPC prefers this commodity, multiply the score
- Reliability bonus: High-stock routes get a small boost
Want a different strategy? Implement the trait differently. Maybe a RiskTakerScorer that ignores minimum margins and loves low-stock high-reward routes. The system is extensible.
Exploration Triggers
Here’s a problem: if NPCs only trade known routes, they’ll never discover better opportunities. They need to explore sometimes.
But when? This calls for another trait:
// src/npc/trading/exploration.rs
/// Trait for deciding when to explore
pub trait ExplorationTrigger {
fn should_explore(&self, knowledge: &MarketKnowledge, personality: &TradingPersonality) -> bool;
} Let’s implement a few triggers:
Low Stock Trigger
“All my favorite commodities are running low everywhere. Time to find new sources.”
pub struct LowStockTrigger {
pub threshold: u32,
}
impl ExplorationTrigger for LowStockTrigger {
fn should_explore(&self, knowledge: &MarketKnowledge, personality: &TradingPersonality) -> bool {
// Check if all known stations have low stock of preferred commodities
for commodity in &personality.preferred_commodities {
let has_good_stock = knowledge.stock_levels.values()
.any(|stocks| {
stocks.get(commodity).copied().unwrap_or(0) > self.threshold
});
if has_good_stock {
return false; // Found good stock somewhere, don't need to explore
}
}
true // All preferred commodities have low stock everywhere
}
} Bad Prices Trigger
“I can’t find ANY profitable routes. Better look for new markets.”
pub struct BadPricesTrigger;
impl ExplorationTrigger for BadPricesTrigger {
fn should_explore(&self, knowledge: &MarketKnowledge, personality: &TradingPersonality) -> bool {
let routes = discover_routes(knowledge, None);
let scorer = PreferenceWeightedScorer;
// Check if any route scores above zero
!routes.iter().any(|route| scorer.score(route, personality) > 0.0)
}
} If no route passes our scoring threshold, it’s time to explore.
Stale Data Trigger
“I haven’t visited Station X in a while. Prices might have changed.”
pub struct StaleDataTrigger {
pub max_age_seconds: f64,
}
impl ExplorationTrigger for StaleDataTrigger {
fn should_explore(&self, knowledge: &MarketKnowledge, personality: &TradingPersonality) -> bool {
// Conservative traders (low risk tolerance) care about fresh data
if personality.risk_tolerance < 0.3 {
// Check if any station data is too old
// (Would need current time from Bevy's Time resource)
false
} else {
false
}
}
} This one’s a stub - you’d need to pass in the current time and compare against knowledge.last_visited. But you get the idea.
Profit Validation
Here’s the key improvement that makes NPCs actually smart. Before buying anything, they validate that they can sell it profitably:
// src/npc/trading/analysis.rs
/// Calculate maximum profitable purchase quantity
pub fn calculate_purchase_quantity(
commodity: CommodityId,
buy_price: u32,
available_stock: u32,
wallet_credits: u32,
cargo_remaining: f32,
commodity_mass: f32,
knowledge: &MarketKnowledge,
personality: &TradingPersonality,
) -> u32 {
// Find best known sell price anywhere
let best_sell = knowledge.sell_prices.values()
.filter_map(|prices| prices.get(&commodity))
.max()
.copied()
.unwrap_or(0);
// Check profit margin against personality threshold
let margin = best_sell as f32 / buy_price as f32;
if margin < personality.min_profit_margin {
return 0; // Not profitable enough - don't buy ANY
}
// Calculate maximum by various constraints
let by_credits = wallet_credits / buy_price;
let by_cargo = (cargo_remaining / commodity_mass) as u32;
let by_stock = available_stock;
by_credits.min(by_cargo).min(by_stock)
} This is huge. Before, NPCs would buy whatever looked cheap. Now they think ahead: “Can I actually sell this for profit?” If not, they pass.
We can also estimate route risk:
pub fn estimate_route_risk(
route: &TradeRoute,
knowledge: &MarketKnowledge,
current_time: f64,
) -> f32 {
let mut risk = 0.0;
// Risk increases with data age
if let Some(&last_visit) = knowledge.last_visited.get(&route.sell_station) {
let age = current_time - last_visit;
risk += (age / 60.0).min(1.0) as f32 * 0.3; // Max 30% from staleness
}
// Risk increases with low stock
if route.available_stock < 5 {
risk += 0.2;
}
// Risk from small profit margins (less room for error)
if route.profit_margin() < 1.1 {
risk += 0.2;
}
risk.min(1.0)
} Risk-averse NPCs can use this to avoid uncertain trades. Risk-takers can ignore it and chase the big margins.
Putting It All Together
Now we combine everything into the destination selection system:
pub fn select_destination(
knowledge: &MarketKnowledge,
personality: &TradingPersonality,
cargo: &CargoHold,
current_pos: Vec2,
stations: &Query<(Entity, &Transform), With<Station>>,
time: f64,
) -> Option<Entity> {
// Step 1: Check if we should explore instead of trade
let triggers: Vec<Box<dyn ExplorationTrigger>> = vec![
Box::new(LowStockTrigger { threshold: 5 }),
Box::new(BadPricesTrigger),
];
for trigger in &triggers {
if trigger.should_explore(knowledge, personality) {
return find_exploration_target(knowledge, stations, current_pos);
}
}
// Step 2: Normal route selection
let routes = discover_routes(knowledge, None);
let scorer = PreferenceWeightedScorer;
// Score and sort routes
let mut scored: Vec<_> = routes.iter()
.map(|r| (r, scorer.score(r, personality)))
.filter(|(_, score)| *score > 0.0)
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
// Step 3: Pick destination based on cargo state
if cargo.contents.is_empty() {
// No cargo - go to best buy station
scored.first().map(|(route, _)| route.buy_station)
} else {
// Have cargo - find best place to sell what we have
let owned: Vec<_> = cargo.contents.keys().copied().collect();
scored.iter()
.find(|(route, _)| owned.contains(&route.commodity))
.map(|(route, _)| route.sell_station)
}
} The exploration target finder prefers unvisited stations, falling back to “oldest visited”:
fn find_exploration_target(
knowledge: &MarketKnowledge,
stations: &Query<(Entity, &Transform), With<Station>>,
current_pos: Vec2,
) -> Option<Entity> {
// Prefer unvisited stations
let unvisited: Vec<_> = stations.iter()
.filter(|(entity, _)| !knowledge.last_visited.contains_key(entity))
.collect();
if !unvisited.is_empty() {
// Pick closest unvisited
return unvisited.iter()
.min_by_key(|(_, transform)| {
transform.translation.truncate().distance(current_pos) as u32
})
.map(|(entity, _)| *entity);
}
// All visited - refresh oldest data
knowledge.last_visited.iter()
.min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(entity, _)| *entity)
} Runtime Tweaking with Reflect
One more thing: debugging AI is hard. Let’s make it easy to tweak NPC behavior at runtime using bevy-inspector-egui.
Add Reflect to our components:
#[derive(Component, Reflect)]
#[reflect(Component)]
pub struct TradingPersonality {
pub preferred_commodities: Vec<CommodityId>,
pub min_profit_margin: f32,
pub preference_weight: f32,
pub risk_tolerance: f32,
}
#[derive(Component, Default, Reflect)]
#[reflect(Component)]
pub struct MarketKnowledge {
// ... fields with Reflect
} Now run with cargo run --features dev and you can see every NPC’s personality in the inspector. Change their min_profit_margin live. Watch their behavior shift.
This is incredibly useful for balancing. Is the economy stagnant? Lower minimum margins. NPCs hoarding instead of trading? Adjust preferences. You can tune the whole simulation without recompiling.
The Emergent Result
Run the game now and watch your NPCs. They’re smarter:
- They pause before buying, checking if profit is possible
- When markets dry up, they strike out to unexplored stations
- NPCs with ore preferences cluster around mining stations
- The economy self-balances as NPCs respond to supply and demand
Watch a faction’s credits over time. If it’s growing, the AI is working! If it’s flat or declining, something’s wrong with your market setup - the economy is telling you.
What We Built
- TradeRoute value object: Clean encapsulation of trade opportunities
- Route discovery: Find all profitable routes from market knowledge
- Strategy pattern with traits: Flexible, extensible AI behaviors
- Multiple exploration triggers: NPCs know when to explore vs. exploit
- Profit validation: Never buy what you can’t sell
What We Learned
- Strategy pattern: Use traits for interchangeable algorithms
- Value objects: Package related data together
- Exploration vs. exploitation: Classic AI tradeoff
- Profit validation: Think ahead before acting
- Reflect derive: Runtime inspection for debugging AI
What’s Next
We’ve built a sophisticated trading simulation, but the player is stuck piloting one ship. Can’t even zoom out to see the whole sector!
In Part 9, we add RTS-style camera controls. Free camera movement, zoom, and the ability to follow any ship in the sector. Finally, we can watch our economy from a god’s-eye view.
Time to take command.