Building a Space Sim in Bevy - Part 4: Space Stations
Adding faction-owned space stations with different types, market behaviors, and data-driven spawning from RON files.
Building a Space Sim in Bevy - Part 4: Space Stations
A space game without stations is just… flying around in a void. Stations are the economic heartbeat of our universe. They’re where NPC traders will buy and sell cargo, where different factions stake their claim, and where our living economy comes to life.
Today we’re adding faction-owned stations with distinct types that affect market behavior. Later, NPC traders will dock at these stations to trade - but let’s build the infrastructure first.
Designing Our Stations
I want stations to feel different from each other. Not just visually, but economically. A mining station shouldn’t sell the same things at the same prices as a military outpost.
So let’s define station types:
// src/station/components.rs
use bevy::prelude::*;
/// Marker for station entities
#[derive(Component)]
pub struct Station;
/// Station identity and type
#[derive(Component)]
pub struct StationInfo {
pub name: String,
pub station_type: StationType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StationType {
Trading, // Balanced prices, jack of all trades
Mining, // Sells raw materials cheap
Military, // Sells weapons cheap
} Each type will have different price multipliers later (Part 6), but for now let’s at least give them different colors so we can tell them apart:
impl StationType {
pub fn color(&self) -> Color {
match self {
StationType::Trading => Color::srgb(0.2, 0.8, 0.3), // Green
StationType::Mining => Color::srgb(0.8, 0.6, 0.2), // Orange
StationType::Military => Color::srgb(0.8, 0.2, 0.2), // Red
}
}
} Docking Zones
NPC traders need to know when they’ve “arrived” at a station. We’ll use a docking zone - a radius around the station where ships can interact with it:
/// Defines the docking radius around a station
#[derive(Component)]
pub struct DockingZone {
pub radius: f32,
}
impl Default for DockingZone {
fn default() -> Self {
Self { radius: 150.0 }
}
} When we build NPC traders (Part 7), they’ll navigate to the station and stop when they enter this zone. Simple and effective.
Faction Ownership
Here’s where things get interesting. Every station belongs to a faction, and that faction ownership affects everything: which NPCs will trade there, the prices they get, even whether they might get attacked.
For now, we’ll use a simple faction identifier. We’ll build the full faction system in Part 11, but let’s prepare for it:
// We'll reference this from the faction module later
use crate::faction::FactionId;
/// Defines how to spawn a station
pub struct StationSpawn {
pub position: Vec2,
pub name: String,
pub station_type: StationType,
pub faction: FactionId,
} Configuration Resource
Where should stations be placed? Hardcoding positions in Rust would be annoying to tweak. Instead, let’s use a configuration resource that we can later populate from data files:
#[derive(Resource, Default)]
pub struct StationSpawnConfig {
pub stations: Vec<StationSpawn>,
} Later in the series (Part 11), we’ll load this from RON files. For now, we can set up defaults in code.
Spawning Stations
Now the fun part - actually creating stations in the world. This system runs when we enter the Playing state:
pub fn spawn_stations(
mut commands: Commands,
config: Res<StationSpawnConfig>,
mut faction_registry: ResMut<FactionRegistry>,
existing: Query<Entity, With<Station>>,
) {
// Idempotent - don't spawn duplicates
if !existing.is_empty() {
return;
}
for station_def in &config.stations {
// Blend station type color with faction color
// This gives each station a unique look while still showing faction
let base_color = station_def.station_type.color();
let faction_color = faction_registry.color(&station_def.faction);
let blended_color = blend_colors(base_color, faction_color, 0.3);
let entity = commands.spawn((
Station,
FactionMember::new(station_def.faction.clone()),
Name::new(format!("Station: {}", station_def.name)),
StationInfo {
name: station_def.name.clone(),
station_type: station_def.station_type,
},
DockingZone::default(),
Sprite {
color: blended_color,
custom_size: Some(Vec2::new(80.0, 80.0)), // Bigger than ships
..default()
},
Transform::from_xyz(
station_def.position.x,
station_def.position.y,
-10.0, // Behind ships but above stars
),
)).id();
// Register with faction so they know they own it
faction_registry.register_station(station_def.faction.clone(), entity);
info!("Spawned station: {}", station_def.name);
}
} Let’s talk about that color blending:
fn blend_colors(color1: Color, color2: Color, factor: f32) -> Color {
let c1 = color1.to_srgba();
let c2 = color2.to_srgba();
Color::srgba(
c1.red * (1.0 - factor) + c2.red * factor,
c1.green * (1.0 - factor) + c2.green * factor,
c1.blue * (1.0 - factor) + c2.blue * factor,
1.0,
)
} With factor = 0.3, stations are 70% their type color and 30% their faction color. A green Trading station owned by a red faction will have a slight red tint. This helps players identify both the station type AND who owns it at a glance.
Visual Hierarchy
Notice the Z-coordinate is -10.0. Here’s my current Z-ordering scheme:
| Z Level | Contents |
|---|---|
| -100 | Stars (background) |
| -10 | Stations |
| 0 | Ships |
| 10+ | UI, effects |
Stations are behind ships but in front of stars. When an NPC “docks”, it visually overlaps the station, which looks correct.
The Plugin
Let’s wire it all up:
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum StationSet {
Visual,
}
pub struct StationPlugin;
impl Plugin for StationPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<StationSpawnConfig>()
.configure_sets(
Update,
StationSet::Visual
.after(ShipSet::Movement)
.run_if(in_state(GameState::Playing)),
)
.add_systems(OnEnter(GameState::Playing), spawn_stations);
}
} The station set runs after ship movement. This matters later when we check for NPC arrival at stations.
Testing It Out
To see our stations, we need to add some to the config. In your plugin or a setup system:
fn setup_default_stations(mut config: ResMut<StationSpawnConfig>) {
config.stations = vec![
StationSpawn {
position: Vec2::new(0.0, 500.0),
name: "Central Hub".to_string(),
station_type: StationType::Trading,
faction: FactionId::new("Player"),
},
StationSpawn {
position: Vec2::new(-800.0, -300.0),
name: "Mining Outpost".to_string(),
station_type: StationType::Mining,
faction: FactionId::new("Alpha"),
},
StationSpawn {
position: Vec2::new(700.0, -400.0),
name: "Military Base".to_string(),
station_type: StationType::Military,
faction: FactionId::new("Gamma"),
},
];
} Run the game, and you should see colored squares at those positions. Green for trading, orange for mining, red for military - with subtle faction tints.
What’s Coming
Right now, stations just… exist. They’re scenery. But we’re building toward something bigger:
- Part 6: Stations get markets with buy/sell prices based on their type
- Part 7: NPC traders navigate to stations and dock
- Part 8: NPCs buy low at one station, sell high at another
- Part 11: Full faction system with diplomatic relations
Each station type will have different price multipliers:
| Station Type | Sells Cheap | Buys Well |
|---|---|---|
| Mining | Ore, Fuel | Food, Electronics |
| Trading | Balanced | Balanced |
| Military | Weapons | Fuel |
This creates natural trade routes. Buy ore cheap at a mining station, sell it at a trading station. The NPC traders will figure this out automatically!
What We Learned
- Station types: Different purposes affect economy
- Faction ownership: Every entity belongs to someone
- Color blending: Visual identification of type AND owner
- Configuration resources: Data-driven spawning
- Docking zones: Simple radius-based interaction areas
- Z-ordering: Layering 2D elements correctly
What’s Next
We have stations, but no way to see game information! In Part 5, we’ll add a simple HUD showing coordinates and credits. Nothing fancy - just enough to know what’s happening.
Then in Part 6, we’ll give stations actual markets with prices, setting the stage for our NPC traders.
The economy is coming to life!