Bevy / Rust
21 min read

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.

Share:

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 LevelStation’s Buy PriceStation’s Sell Price
Empty (0)Lower (please bring us stock!)Higher (we’ll pay premium)
Half FullModerateModerate
Full (100+)Higher (we’re full, go away)Lower (we have plenty, won’t pay much)

This creates emergent trade routes. NPCs naturally:

  1. Buy where stock is high (cheap acquisition)
  2. Sell where stock is low (premium payout)
  3. Compete with each other for the best deals
  4. 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

LevelUse ForExample
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:

  1. Start the game and note ore prices at a Mining Station
  2. Have NPCs (or yourself, if piloting) buy all the ore
  3. Watch prices change in real-time
  4. Wait - NPCs will notice the opportunity and bring ore
  5. 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:

PartFeature
1-2Project setup, ECS architecture, plugin system
3Parallax starfield for atmosphere
4-5Stations and basic HUD
6Trading economy foundation
7-8NPC traders with smart AI
9RTS-style camera controls
10Combat system with shields and weapons
11Data-driven faction system
12Hexagonal territory map
13Render-to-texture minimap
14Dynamic 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_audio for 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.