Building a Space Sim in Bevy - Part 14: Dynamic Pricing & Logging
Adding stock-based dynamic prices and configurable per-module logging to polish our space trading game.
Building a Space Sim in Bevy - Part 14: Dynamic Pricing & Logging
We’re in the home stretch. Our space sim has ships, stations, trading, combat, factions, territory, and a minimap. It’s playable. It’s interesting to watch.
But there are rough edges. Prices are static - buy ore for 50 credits everywhere, forever. Debugging is painful - too much noise from Bevy internals, not enough signal from game logic.
This final part addresses both: dynamic pricing that responds to supply and demand, and a logging system you can configure without recompiling.
Dynamic Pricing: Making the Economy Breathe
Static prices create static gameplay. If ore is always 50 credits at Mining Station and always 55 credits at Trading Station, the trade route never changes. Boring.
Real economies fluctuate. When stock runs low, prices rise. When warehouses overflow, prices drop. Let’s implement that.
The Pricing Configuration
// src/economy/pricing.rs
use bevy::prelude::*;
#[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% discount when full
full_stock: 100,
}
}
} These defaults create meaningful price swings. An empty station pays 150% of base price for goods it desperately needs. A station bursting with stock only pays 60% - it doesn’t want more.
The Magic Formula
How do we interpolate between empty and full? Linear interpolation:
impl PricingConfig {
pub fn stock_multiplier(&self, current_stock: u32) -> f32 {
if current_stock == 0 {
return self.empty_multiplier;
}
// How full is the warehouse? (0.0 to 1.0)
let fill_ratio = (current_stock as f32 / self.full_stock as f32).min(1.0);
// Lerp from empty to full multiplier
self.empty_multiplier + (self.full_multiplier - self.empty_multiplier) * fill_ratio
}
} At 0 stock, we get empty_multiplier (1.5). At 50 stock (half full), we get 1.05. At 100+ stock, we cap at full_multiplier (0.6).
Integrating with Station Markets
Now we modify the price calculations to use this multiplier:
impl StationMarket {
pub fn buy_price(
&self,
commodity: CommodityId,
registry: &CommodityRegistry,
pricing: &PricingConfig,
) -> u32 {
let base = registry.get(commodity)
.map(|c| c.base_price)
.unwrap_or(100);
let type_multiplier = self.buy_multipliers
.get(&commodity)
.unwrap_or(&1.0);
let stock = self.stock.get(&commodity).copied().unwrap_or(0);
let stock_multiplier = pricing.stock_multiplier(stock);
// MORE stock = LOWER price (station has plenty to sell)
(base as f32 * type_multiplier * (2.0 - stock_multiplier)) as u32
}
pub fn sell_price(
&self,
commodity: CommodityId,
registry: &CommodityRegistry,
pricing: &PricingConfig,
) -> u32 {
let base = registry.get(commodity)
.map(|c| c.base_price)
.unwrap_or(100);
let type_multiplier = self.sell_multipliers
.get(&commodity)
.unwrap_or(&1.0);
let stock = self.stock.get(&commodity).copied().unwrap_or(0);
let stock_multiplier = pricing.stock_multiplier(stock);
// LESS stock = HIGHER sell price (station pays premium for what it needs)
(base as f32 * type_multiplier * 0.9 * stock_multiplier) as u32
}
} The 2.0 - stock_multiplier inversion on buy price is important. When stock is low (stock_multiplier = 1.5), buy price becomes base * 0.5 - the station is selling cheap because it’s desperate to BUY (not sell) this commodity. The math can be confusing; think in terms of “what does the station want?”
The Economic Effect
| Stock Level | Station’s Buy Price | Station’s Sell Price |
|---|---|---|
| Empty (0) | Lower (please bring us stock!) | Higher (we’ll pay premium) |
| Half Full | Moderate | Moderate |
| Full (100+) | Higher (we’re full, go away) | Lower (we have plenty, won’t pay much) |
This creates emergent trade routes. NPCs naturally:
- Buy where stock is high (cheap acquisition)
- Sell where stock is low (premium payout)
- Compete with each other for the best deals
- Stabilize prices through their trading
Watch a busy station. When NPCs deplete its ore, prices spike. Other NPCs notice and start bringing ore there. Stock rises. Prices fall. The economy self-balances.
Configurable Logging
Now for a developer quality-of-life feature. Game debugging is hard. You’re trying to figure out why an NPC made a weird trade decision, but your console is flooded with:
[INFO bevy_render::renderer] Initializing wgpu...
[DEBUG wgpu_hal::gles] OpenGL version: 4.6.0...
[TRACE bevy_ecs::schedule] Running system: update_transforms... We need per-module log control.
Log Configuration Structure
// src/logging.rs
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Deserialize)]
pub struct LogConfig {
pub default_level: String,
pub module_filters: HashMap<String, String>,
}
impl Default for LogConfig {
fn default() -> Self {
Self {
default_level: "info".to_string(),
module_filters: HashMap::new(),
}
}
} The Configuration File
Create assets/log_config.ron:
(
default_level: "info",
module_filters: {
// Game modules - set these to "debug" when investigating
"space_sim::npc": "warn",
"space_sim::combat_ai": "warn",
"space_sim::economy": "info",
"space_sim::ship": "warn",
"space_sim::trading": "info",
// Reduce Bevy noise
"bevy_ecs": "warn",
"bevy_app": "warn",
"bevy_render": "warn",
"bevy_asset": "warn",
"wgpu": "error",
"wgpu_hal": "error",
"naga": "error",
},
) Now when something’s wrong with NPC trading, change "space_sim::npc": "debug" and restart. No recompilation. Instant focused debugging.
Loading at Startup
pub fn load_log_config() -> LogConfig {
let config_path = "assets/log_config.ron";
match std::fs::read_to_string(config_path) {
Ok(contents) => {
match ron::from_str(&contents) {
Ok(config) => config,
Err(e) => {
eprintln!("Failed to parse log config: {}", e);
LogConfig::default()
}
}
}
Err(_) => {
// File doesn't exist, use defaults
LogConfig::default()
}
}
}
fn parse_level(s: &str) -> log::LevelFilter {
match s.to_lowercase().as_str() {
"trace" => log::LevelFilter::Trace,
"debug" => log::LevelFilter::Debug,
"info" => log::LevelFilter::Info,
"warn" => log::LevelFilter::Warn,
"error" => log::LevelFilter::Error,
"off" => log::LevelFilter::Off,
_ => log::LevelFilter::Info,
}
} We load config before initializing Bevy, so it applies from the first frame.
Using Logs Effectively
Add logging throughout your code, but at appropriate levels:
// In NPC trading - important events as INFO
pub fn npc_execute_trades(/* ... */) {
info!(
"{} bought {} {} for {} credits (margin: {:.1}%)",
npc_name, quantity, commodity_name, cost,
(profit_margin - 1.0) * 100.0
);
}
// In combat - detailed info as DEBUG
pub fn check_projectile_hits(/* ... */) {
debug!(
"Projectile hit {} for {} damage (shields: {:.0}, hull: {:.0})",
target_name, damage, shield_remaining, hull_remaining
);
}
// In message processing - verbose data as TRACE
pub fn process_buy_messages(/* ... */) {
trace!("Processing buy message: {:?}", message);
if insufficient_credits {
warn!(
"Purchase failed: {} has insufficient credits ({} needs {})",
buyer_name, wallet.credits, total_cost
);
}
} Log Level Guide
| Level | Use For | Example |
|---|---|---|
error! | Things that shouldn’t happen | “Entity despawned while referenced” |
warn! | Unexpected but handled | “Purchase failed: insufficient credits” |
info! | Important game events | “Alpha bought 10 Ore for 500 credits” |
debug! | Debugging specific systems | “Projectile hit Player for 15 damage” |
trace! | Everything, very verbose | "Processing message: BuyMessage { ... }" |
Debugging Workflow
Normal gameplay:
default_level: "info" Debugging NPC decisions:
"space_sim::npc": "debug",
"space_sim::trading": "debug", Performance profiling (minimal logs):
default_level: "warn" Investigating specific crash:
"space_sim::suspect_module": "trace" Testing the Dynamic Economy
Let’s verify our pricing works:
- Start the game and note ore prices at a Mining Station
- Have NPCs (or yourself, if piloting) buy all the ore
- Watch prices change in real-time
- Wait - NPCs will notice the opportunity and bring ore
- Prices stabilize as stock refills
The economy lives. It breathes. It responds.
Series Retrospective
We did it. Over 14 parts, we built a complete space trading and combat simulation:
| Part | Feature |
|---|---|
| 1-2 | Project setup, ECS architecture, plugin system |
| 3 | Parallax starfield for atmosphere |
| 4-5 | Stations and basic HUD |
| 6 | Trading economy foundation |
| 7-8 | NPC traders with smart AI |
| 9 | RTS-style camera controls |
| 10 | Combat system with shields and weapons |
| 11 | Data-driven faction system |
| 12 | Hexagonal territory map |
| 13 | Render-to-texture minimap |
| 14 | Dynamic pricing and debugging tools |
The result is modular, extensible, and genuinely fun to watch. NPCs make decisions. Economies fluctuate. Factions fight. Territory changes hands.
What We Learned
Throughout this series:
- ECS architecture: Components for data, systems for behavior
- Plugin organization: Self-contained features with clear boundaries
- Data-driven design: RON files for configuration without recompilation
- State machines: Clear states with explicit transitions for AI
- Render layers: Multi-camera setups for HUD and minimap
- Economic simulation: Supply/demand creating emergent behavior
Where to Go Next
The architecture supports many extensions:
- Ship customization: Modular components, upgrade systems
- Missions: Quest system layered on faction relations
- Multiple sectors: Loading/unloading regions
- Save/load: Serialize world state to RON or binary
- Sound:
bevy_kira_audiofor music and effects - Multiplayer: Bevy’s networking capabilities
Each would be its own plugin, slotting cleanly into the existing structure.
Final Thoughts
Building games is hard. Building them well is harder. But Bevy’s ECS makes complexity manageable. Each feature stays isolated. Dependencies are explicit. The architecture scales.
I hope this series helped you understand not just the “how” but the “why.” Why we use components instead of inheritance. Why systems query instead of storing references. Why plugins instead of one giant main.rs.
Now go build something. Take this foundation and make it yours. Add pirates that negotiate ransoms. Add a black market. Add a faction that’s actually three ships in a trench coat pretending to be a government.
The void awaits.
Thank you for reading.