Building a Space Sim in Bevy - Part 6: Economy System
Building the economic foundation with commodities, station markets, dynamic pricing, and faction wallets for NPC trading.
Building a Space Sim in Bevy - Part 6: Economy System
We have stations. We have a HUD showing credits. But credits don’t mean anything yet. There’s nothing to buy, nothing to sell, no reason for trade to exist.
That changes today. We’re building the economic foundation that makes our NPC traders possible. By the end of this part, stations will have markets with dynamic prices, and we’ll have all the infrastructure needed for Part 7’s AI traders.
What Makes an Economy?
For a space trading game, we need:
- Commodities: Things to buy and sell (Ore, Food, Electronics, etc.)
- Station Markets: Each station buys and sells at different prices
- Dynamic Pricing: Prices change based on supply
- Faction Wallets: Shared economy per faction
Let’s build each piece.
The Commodity System
First, what can be traded? I’m keeping it simple - five commodities that cover the basics:
// src/economy/commodities.rs
use bevy::prelude::*;
use std::collections::HashMap;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Reflect)]
pub enum CommodityId {
Ore,
Food,
Electronics,
Weapons,
Fuel,
} Each commodity needs some metadata - name, description, base price, and mass (for cargo capacity):
pub struct CommodityInfo {
pub id: CommodityId,
pub name: &'static str,
pub description: &'static str,
pub base_price: u32,
pub mass: f32,
} I’m using a registry pattern to store all commodity definitions centrally:
#[derive(Resource)]
pub struct CommodityRegistry {
commodities: HashMap<CommodityId, CommodityInfo>,
}
impl Default for CommodityRegistry {
fn default() -> Self {
let mut commodities = HashMap::new();
commodities.insert(CommodityId::Ore, CommodityInfo {
id: CommodityId::Ore,
name: "Ore",
description: "Raw mining materials",
base_price: 50,
mass: 2.0, // Heavy!
});
commodities.insert(CommodityId::Food, CommodityInfo {
id: CommodityId::Food,
name: "Food",
description: "Essential supplies",
base_price: 80,
mass: 1.0,
});
commodities.insert(CommodityId::Electronics, CommodityInfo {
id: CommodityId::Electronics,
name: "Electronics",
description: "High-tech components",
base_price: 200,
mass: 0.5, // Light but valuable
});
commodities.insert(CommodityId::Weapons, CommodityInfo {
id: CommodityId::Weapons,
name: "Weapons",
description: "Military hardware",
base_price: 300,
mass: 1.5,
});
commodities.insert(CommodityId::Fuel, CommodityInfo {
id: CommodityId::Fuel,
name: "Fuel",
description: "Ship propellant",
base_price: 30,
mass: 1.0,
});
Self { commodities }
}
}
impl CommodityRegistry {
pub fn get(&self, id: CommodityId) -> Option<&CommodityInfo> {
self.commodities.get(&id)
}
pub fn iter(&self) -> impl Iterator<Item = &CommodityInfo> {
self.commodities.values()
}
} Why a registry? Because multiple systems need commodity info - trading, cargo display, price calculations. Having one source of truth keeps things consistent.
Cargo Holds
NPC traders need somewhere to put the goods they buy. Enter the cargo hold:
// src/economy/components.rs
#[derive(Component, Default, Reflect)]
#[reflect(Component)]
pub struct CargoHold {
pub contents: HashMap<CommodityId, u32>,
pub capacity: f32, // Mass-based capacity
} I’m using mass-based capacity instead of slot-based. This creates interesting trade-offs: you can carry lots of heavy ore OR lots of light electronics, but not both. It makes cargo decisions meaningful.
Here are the key methods:
impl CargoHold {
pub fn new(capacity: f32) -> Self {
Self {
contents: HashMap::new(),
capacity,
}
}
pub fn quantity(&self, commodity: CommodityId) -> u32 {
*self.contents.get(&commodity).unwrap_or(&0)
}
pub fn current_mass(&self, registry: &CommodityRegistry) -> f32 {
self.contents.iter()
.filter_map(|(id, qty)| {
registry.get(*id).map(|info| info.mass * *qty as f32)
})
.sum()
}
pub fn remaining_capacity(&self, registry: &CommodityRegistry) -> f32 {
self.capacity - self.current_mass(registry)
}
pub fn add(&mut self, commodity: CommodityId, quantity: u32, registry: &CommodityRegistry) -> bool {
let info = registry.get(commodity);
let mass = info.map(|i| i.mass).unwrap_or(1.0);
// Check if there's room
if self.remaining_capacity(registry) >= mass * quantity as f32 {
*self.contents.entry(commodity).or_insert(0) += quantity;
true
} else {
false
}
}
pub fn remove(&mut self, commodity: CommodityId, quantity: u32) -> bool {
let current = self.contents.get(&commodity).copied().unwrap_or(0);
if current >= quantity {
*self.contents.entry(commodity).or_insert(0) -= quantity;
if self.contents[&commodity] == 0 {
self.contents.remove(&commodity);
}
true
} else {
false
}
}
} The add method returns false if there’s not enough capacity. The remove method cleans up empty entries to keep the HashMap tidy.
Station Markets
Now the heart of the economy: markets. Each station has different prices for buying and selling:
#[derive(Component, Default, Reflect)]
#[reflect(Component)]
pub struct StationMarket {
pub buy_multipliers: HashMap<CommodityId, f32>, // What the station pays
pub sell_multipliers: HashMap<CommodityId, f32>, // What the station charges
pub stock: HashMap<CommodityId, StockInfo>,
}
#[derive(Clone, Default, Reflect)]
pub struct StockInfo {
pub quantity: u32,
} Why separate buy and sell multipliers? Because stations want to make profit! They buy low and sell high, just like real traders.
Let’s create different market configurations for each station type:
impl StationMarket {
pub fn trading_station() -> Self {
Self::with_balanced_prices() // 1.0x multipliers
}
pub fn mining_station() -> Self {
// Mining stations have LOTS of ore and fuel
// They sell raw materials cheap, buy finished goods at premium
Self {
buy_multipliers: [
(CommodityId::Ore, 0.6), // Cheap to buy here
(CommodityId::Fuel, 0.7), // Cheap to buy here
(CommodityId::Food, 1.2), // They need food
(CommodityId::Electronics, 1.3), // They need electronics
(CommodityId::Weapons, 1.0),
].into(),
sell_multipliers: [
(CommodityId::Ore, 0.5),
(CommodityId::Fuel, 0.6),
(CommodityId::Food, 1.1),
(CommodityId::Electronics, 1.2),
(CommodityId::Weapons, 0.9),
].into(),
stock: Self::default_stock(),
}
}
pub fn military_station() -> Self {
// Military stations have weapons, need fuel
Self {
buy_multipliers: [
(CommodityId::Weapons, 0.7), // Cheap to buy here
(CommodityId::Fuel, 1.2), // They need fuel badly
(CommodityId::Ore, 1.0),
(CommodityId::Food, 1.0),
(CommodityId::Electronics, 1.1),
].into(),
sell_multipliers: [
(CommodityId::Weapons, 0.6),
(CommodityId::Fuel, 1.1),
(CommodityId::Ore, 0.9),
(CommodityId::Food, 0.9),
(CommodityId::Electronics, 1.0),
].into(),
stock: Self::default_stock(),
}
}
fn default_stock() -> HashMap<CommodityId, StockInfo> {
[
(CommodityId::Ore, StockInfo { quantity: 50 }),
(CommodityId::Food, StockInfo { quantity: 50 }),
(CommodityId::Electronics, StockInfo { quantity: 30 }),
(CommodityId::Weapons, StockInfo { quantity: 20 }),
(CommodityId::Fuel, StockInfo { quantity: 100 }),
].into()
}
} Look at those mining station prices! Ore is 0.6x base price to buy. A trader can pick up ore at 30 credits (50 × 0.6) and sell it at a trading station for nearly 50 credits. That’s a 67% profit margin!
These asymmetries create natural trade routes that NPC traders will discover and exploit.
Dynamic Pricing
Static prices are boring. Real economies respond to supply and demand. Let’s make prices fluctuate based on stock levels:
// src/economy/pricing.rs
#[derive(Resource, Reflect)]
#[reflect(Resource)]
pub struct PricingConfig {
/// Price multiplier when stock is empty (scarcity premium)
pub empty_multiplier: f32,
/// Price multiplier when stock is full (surplus discount)
pub full_multiplier: f32,
/// Stock level considered "full"
pub full_stock: u32,
}
impl Default for PricingConfig {
fn default() -> Self {
Self {
empty_multiplier: 1.5, // 50% more when empty
full_multiplier: 0.6, // 40% less when full
full_stock: 100,
}
}
} The stock_multiplier function calculates how stock affects price:
impl PricingConfig {
pub fn stock_multiplier(&self, current_stock: u32) -> f32 {
if current_stock == 0 {
return self.empty_multiplier;
}
let fill_ratio = (current_stock as f32 / self.full_stock as f32).min(1.0);
// Lerp from empty_multiplier to full_multiplier based on stock
self.empty_multiplier + (self.full_multiplier - self.empty_multiplier) * fill_ratio
}
} At zero stock, prices are 150% of normal (scarcity). At full stock, prices are 60% of normal (surplus). This creates feedback loops:
- NPCs deplete stock → prices rise
- High prices attract more NPCs bringing goods
- Stock increases → prices fall
- NPCs stop bringing goods, stock depletes
- Cycle repeats
It’s emergent supply/demand without complex economics code!
Price Calculations
Now let’s put it together. Station markets calculate actual prices using both type multipliers AND stock levels:
impl StationMarket {
pub fn buy_price(
&self,
commodity: CommodityId,
base_price: u32,
pricing: &PricingConfig,
) -> u32 {
let type_multiplier = self.buy_multipliers
.get(&commodity)
.unwrap_or(&1.0);
let stock = self.available_stock(commodity);
let stock_multiplier = pricing.stock_multiplier(stock);
// More stock = lower buy price (station has plenty to sell)
(base_price as f32 * type_multiplier * (2.0 - stock_multiplier)) as u32
}
pub fn sell_price(
&self,
commodity: CommodityId,
base_price: u32,
pricing: &PricingConfig,
) -> u32 {
let type_multiplier = self.sell_multipliers
.get(&commodity)
.unwrap_or(&1.0);
let stock = self.available_stock(commodity);
let stock_multiplier = pricing.stock_multiplier(stock);
// Less stock = higher sell price (station pays premium for goods it needs)
(base_price as f32 * type_multiplier * 0.9 * stock_multiplier) as u32
}
pub fn available_stock(&self, commodity: CommodityId) -> u32 {
self.stock.get(&commodity).map(|s| s.quantity).unwrap_or(0)
}
} Notice the 0.9 in sell price? That’s the station’s cut. They always pay slightly less than they charge, making a small profit on each transaction.
Automatic Market Setup
Markets should be added to stations automatically:
pub fn setup_station_markets(
mut commands: Commands,
station_query: Query<(Entity, &StationInfo), (With<Station>, Without<StationMarket>)>,
) {
for (entity, info) in &station_query {
let market = match info.station_type {
StationType::Trading => StationMarket::trading_station(),
StationType::Mining => StationMarket::mining_station(),
StationType::Military => StationMarket::military_station(),
};
commands.entity(entity).insert(market);
info!("Added market to station: {}", info.name);
}
} This runs in Update with a filter for stations without markets. Lazy initialization means we can spawn stations anywhere and they automatically get appropriate markets.
The Plugin
pub struct EconomyPlugin;
impl Plugin for EconomyPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<CommodityRegistry>()
.init_resource::<PricingConfig>()
.register_type::<CommodityId>()
.register_type::<CargoHold>()
.register_type::<StationMarket>()
.register_type::<PricingConfig>()
.add_systems(
Update,
setup_station_markets.run_if(in_state(GameState::Playing)),
);
}
} The register_type calls enable runtime inspection with bevy-inspector-egui. Super helpful for debugging!
The Trade Opportunity Map
Let me show you what we’ve built. Here’s how prices differ by station:
| Station Type | Sells Cheap | Pays Well For |
|---|---|---|
| Mining (orange) | Ore, Fuel | Food, Electronics |
| Trading (green) | Balanced | Balanced |
| Military (red) | Weapons | Fuel |
Example profitable route:
- Buy Ore at Mining Station: ~30 credits (50 × 0.6 multiplier)
- Fly to Trading Station
- Sell Ore: ~45 credits
- Profit: 50%!
With dynamic pricing on top:
- If the Mining station has lots of ore (full stock), price drops even further
- If the Trading station is low on ore (empty stock), they pay even more
- Profits can exceed 100% on well-timed trades
What We Built
- Commodity registry: Central definitions for all trade goods
- Mass-based cargo: Interesting capacity trade-offs
- Station markets: Different prices per station type
- Dynamic pricing: Stock levels affect prices
- Automatic setup: Markets initialize themselves
What We Learned
- Registry pattern: Centralized data accessible everywhere
- Multiplier stacking: Type multipliers + stock multipliers = final price
- Emergent economics: Simple rules create complex market behavior
- Reflect derive: Runtime inspection with bevy-inspector-egui
- Lazy initialization: Components added on first frame needing them
What’s Next
We have a complete economy… but nobody’s using it! The prices fluctuate, the opportunities exist, but no one’s trading.
In Part 7, we add the traders. AI-controlled NPC ships that navigate between stations, dock, and automatically buy low / sell high. The economy comes alive!
Get ready for emergent behavior.