Building a Space Sim in Bevy - Part 12: Map System & Hex Grid
Implementing a hexagonal sector map with territory ownership, RON-based configuration, and soft world boundaries.
Building a Space Sim in Bevy - Part 12: Map System & Hex Grid
Our universe has factions, but where does one faction’s territory end and another’s begin? Ships fly around an infinite void with no sense of place. There’s no “Alpha space” or “Gamma territory” - just colored dots moving through darkness.
Let’s fix that. We’re adding a hexagonal sector map with visible territories and soft world boundaries.
Why Hexagons?
I could use squares. They’re simpler. But hexagons have compelling advantages for strategy games:
Equal distance to all neighbors: In a square grid, diagonal neighbors are ~1.4x further than cardinal neighbors. In hex grids, all six neighbors are equidistant.
More organic shapes: Hex territories look more like natural regions than blocky squares.
Six directions: More strategic options than four.
Beautiful tessellation: They just look good.
The math is slightly harder, but once you understand it, hexes aren’t complicated.
The Hex Coordinate System
There are many ways to address hexes. I’m using axial coordinates (q, r) because they’re intuitive and the math works out cleanly:
// src/map/hex.rs
use bevy::prelude::*;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct HexCoord {
pub q: i32,
pub r: i32,
}
impl HexCoord {
pub fn new(q: i32, r: i32) -> Self {
Self { q, r }
}
/// The implicit third coordinate (q + r + s = 0 always)
pub fn s(&self) -> i32 {
-self.q - self.r
}
} Axial coordinates have a hidden third axis s that’s derived from the other two. This constraint (q + r + s = 0) is what makes the math elegant.
Distance Between Hexes
How far apart are two hexes? With the cubic representation, distance is beautifully simple:
impl HexCoord {
pub fn distance(&self, other: &HexCoord) -> i32 {
((self.q - other.q).abs()
+ (self.r - other.r).abs()
+ (self.s() - other.s()).abs()) / 2
}
} Sum the absolute differences in all three axes, divide by two. That’s it.
Finding Neighbors
Every hex has exactly six neighbors:
impl HexCoord {
pub fn neighbors(&self) -> [HexCoord; 6] {
[
HexCoord::new(self.q + 1, self.r),
HexCoord::new(self.q + 1, self.r - 1),
HexCoord::new(self.q, self.r - 1),
HexCoord::new(self.q - 1, self.r),
HexCoord::new(self.q - 1, self.r + 1),
HexCoord::new(self.q, self.r + 1),
]
}
} These six offsets never change. Memorize them or keep this reference handy.
The Hex Grid
Now let’s build a grid that can convert between hex coordinates and world positions:
#[derive(Clone, Debug)]
pub struct HexGrid {
pub radius: i32, // Number of rings from center
pub hex_size: f32, // Size of each hex in world units
}
impl HexGrid {
pub fn new(radius: i32, hex_size: f32) -> Self {
Self { radius, hex_size }
}
} A grid with radius = 3 has 7 hexes across the widest point (center + 3 on each side). The hex_size controls how big each hex appears in world space.
Converting Hex to World Position
This is where the hex math gets real. For “pointy-top” hexagons (which I prefer), the conversion is:
impl HexGrid {
pub fn hex_to_world(&self, hex: &HexCoord) -> Vec2 {
let x = self.hex_size * (3.0_f32.sqrt() * hex.q as f32
+ 3.0_f32.sqrt() / 2.0 * hex.r as f32);
let y = self.hex_size * (3.0 / 2.0 * hex.r as f32);
Vec2::new(x, y)
}
} The sqrt(3) appears because that’s the ratio of hex width to height. Don’t worry about deriving it - just trust the formula or look up “pointy hex to pixel” if you’re curious.
Converting World to Hex
Going backwards is trickier. We calculate fractional coordinates then round to the nearest hex:
impl HexGrid {
pub fn world_to_hex(&self, pos: Vec2) -> HexCoord {
let q = (3.0_f32.sqrt() / 3.0 * pos.x - 1.0 / 3.0 * pos.y) / self.hex_size;
let r = (2.0 / 3.0 * pos.y) / self.hex_size;
Self::axial_round(q, r)
}
fn axial_round(q: f32, r: f32) -> HexCoord {
let s = -q - r;
let mut rq = q.round();
let mut rr = r.round();
let rs = s.round();
let q_diff = (rq - q).abs();
let r_diff = (rr - r).abs();
let s_diff = (rs - s).abs();
// Fix the axis with the largest rounding error
if q_diff > r_diff && q_diff > s_diff {
rq = -rr - rs;
} else if r_diff > s_diff {
rr = -rq - rs;
}
HexCoord::new(rq as i32, rr as i32)
}
} The rounding logic ensures we always satisfy the q + r + s = 0 constraint. If simple rounding violates it, we fix the coordinate with the largest error.
Iterating All Hexes
To spawn visuals or calculate ownership, we need to visit every hex in the grid:
impl HexGrid {
pub fn all_hexes(&self) -> impl Iterator<Item = HexCoord> + '_ {
(-self.radius..=self.radius).flat_map(move |q| {
let r_min = (-self.radius).max(-q - self.radius);
let r_max = self.radius.min(-q + self.radius);
(r_min..=r_max).map(move |r| HexCoord::new(q, r))
})
}
} This generates a hexagonal pattern centered at (0,0). The bounds on r ensure we stay within a hexagonal shape rather than a rectangle.
Map Configuration
Like factions, maps are data-driven:
// assets/maps/default/map.ron
(
name: "Default Sector",
hex_radius: 3,
factions_file: "factions.ron",
stations_file: "stations.ron",
combat_file: Some("combat.ron"),
) The map file references other configuration files. This keeps things organized - factions in one file, stations in another, all tied together by the map.
#[derive(Asset, TypePath, Deserialize)]
pub struct MapAsset {
pub name: String,
pub hex_radius: i32,
pub factions_file: String,
pub stations_file: String,
pub combat_file: Option<String>,
}
#[derive(Resource)]
pub struct LoadedMap {
pub name: String,
pub hex_radius: i32,
pub hex_grid: HexGrid,
} When the map loads, we create the HexGrid and store it for other systems to use.
World Bounds
The hex grid defines the playable area. Ships shouldn’t just fly off into infinity:
#[derive(Resource, Default)]
pub struct WorldBounds {
pub radius: f32,
}
impl WorldBounds {
pub fn from_hex_grid(grid: &HexGrid) -> Self {
// Find the world position of an edge hex
let outermost = HexCoord::new(grid.radius, 0);
let world_pos = grid.hex_to_world(&outermost);
// Add some padding
Self {
radius: world_pos.length() + grid.hex_size * 2.0,
}
}
} This gives us a circular boundary around the hex grid. We’ll use it for the soft boundary system.
Territory Ownership
Here’s the interesting part: which faction controls each sector? We calculate this dynamically based on entity positions:
#[derive(Resource, Default)]
pub struct SectorOwnership {
ownership: HashMap<HexCoord, FactionId>,
}
impl SectorOwnership {
pub fn get(&self, hex: &HexCoord) -> Option<&FactionId> {
self.ownership.get(hex)
}
pub fn set(&mut self, hex: HexCoord, faction: FactionId) {
self.ownership.insert(hex, faction);
}
} The ownership calculation weights different entity types:
pub fn calculate_sector_ownership(
loaded_map: Option<Res<LoadedMap>>,
mut ownership: ResMut<SectorOwnership>,
stations: Query<(&Transform, &FactionMember), With<Station>>,
ships: Query<(&Transform, &FactionMember), With<Ship>>,
) {
let Some(map) = loaded_map else { return };
ownership.ownership.clear();
// Track faction presence per hex
let mut presence: HashMap<HexCoord, HashMap<FactionId, u32>> = HashMap::new();
// Stations have strong presence (weight 10)
for (transform, faction) in stations.iter() {
let hex = map.hex_grid.world_to_hex(transform.translation.truncate());
*presence.entry(hex)
.or_default()
.entry(faction.faction.clone())
.or_default() += 10;
}
// Ships have weaker presence (weight 1)
for (transform, faction) in ships.iter() {
let hex = map.hex_grid.world_to_hex(transform.translation.truncate());
*presence.entry(hex)
.or_default()
.entry(faction.faction.clone())
.or_default() += 1;
}
// Whoever has the most presence controls the sector
for (hex, factions) in presence {
if let Some((faction, _)) = factions.iter().max_by_key(|(_, count)| *count) {
ownership.set(hex, faction.clone());
}
}
} A single station claims a sector. Ships can contest it, but you’d need 10+ ships to outweigh one station. This creates stable territories around stations while allowing temporary occupation by fleets.
Visualizing Sectors
Let’s make the hexes visible:
#[derive(Component)]
pub struct Sector {
pub coord: HexCoord,
}
#[derive(Component)]
pub struct SectorVisual;
pub fn spawn_hex_visuals(
mut commands: Commands,
loaded_map: Res<LoadedMap>,
) {
for hex in loaded_map.hex_grid.all_hexes() {
let world_pos = loaded_map.hex_grid.hex_to_world(&hex);
commands.spawn((
Sector { coord: hex },
Transform::from_translation(world_pos.extend(-50.0)), // Behind everything
)).with_children(|parent| {
parent.spawn((
SectorVisual,
Sprite {
color: Color::srgba(0.2, 0.2, 0.2, 0.1),
custom_size: Some(Vec2::splat(loaded_map.hex_grid.hex_size * 1.7)),
..default()
},
Transform::default(),
));
});
}
} I’m using square sprites as placeholders - for proper hex shapes you’d use a mesh or hex sprite.
Now update colors based on ownership:
pub fn update_sector_colors(
ownership: Res<SectorOwnership>,
registry: Res<FactionRegistry>,
sectors: Query<(&Sector, &Children)>,
mut visuals: Query<&mut Sprite, With<SectorVisual>>,
) {
for (sector, children) in sectors.iter() {
let color = if let Some(faction) = ownership.get(§or.coord) {
if let Some(info) = registry.get_info(faction) {
// Faction color, very transparent
let rgba = info.color.to_srgba();
Color::srgba(rgba.red, rgba.green, rgba.blue, 0.15)
} else {
Color::srgba(0.2, 0.2, 0.2, 0.1)
}
} else {
Color::srgba(0.2, 0.2, 0.2, 0.1) // Unclaimed
};
for &child in children.iter() {
if let Ok(mut sprite) = visuals.get_mut(child) {
sprite.color = color;
}
}
}
} Now sectors glow with faction colors. Alpha territory is red-tinted. Beta is green. You can see at a glance who controls what.
Soft World Boundaries
Hard walls feel wrong in space. Instead, ships experience increasing drag as they leave the map:
pub fn soft_boundary_drag(
bounds: Option<Res<WorldBounds>>,
mut ships: Query<(&Transform, &mut Velocity), With<Ship>>,
) {
let Some(bounds) = bounds else { return };
for (transform, mut velocity) in ships.iter_mut() {
let pos = transform.translation.truncate();
let distance = pos.length();
if distance > bounds.radius {
// How far outside bounds
let overflow = distance - bounds.radius;
// Exponential drag (capped at 95%)
let drag_strength = (overflow / 100.0).min(0.95);
velocity.linear *= 1.0 - drag_strength;
// Pull toward center
let pull_dir = -pos.normalize_or_zero();
let pull_strength = overflow * 0.5;
velocity.linear += pull_dir * pull_strength;
}
}
} Ships that stray outside the map slow down and get gently pushed back. It feels like “space is getting thick out here” rather than “you hit an invisible wall.”
The further out you go, the stronger the effect. Ships can venture out briefly but can’t escape forever.
Wiring It Up
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum MapSet {
Ownership,
Visual,
}
impl Plugin for MapPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SectorOwnership>()
.init_resource::<WorldBounds>()
.configure_sets(Update, (
MapSet::Ownership,
MapSet::Visual,
).chain())
.add_systems(Update, (
calculate_sector_ownership.in_set(MapSet::Ownership),
update_sector_colors.in_set(MapSet::Visual),
soft_boundary_drag,
).run_if(in_state(GameState::Playing)));
}
} The Emergent Result
Fire up the game and look at your sector. Hexes glow with faction colors. Move a fleet into enemy territory and watch the color shift as you contest it. Fly toward the edge and feel your ship slow down.
This transforms abstract faction ownership into something visible and tangible. You can see the front lines of a war. You can watch territory change hands.
What We Built
- HexCoord: Axial coordinate system for hexes
- HexGrid: Conversion between hex and world coordinates
- SectorOwnership: Dynamic territory calculation
- Visual sectors: Faction-colored hex display
- Soft boundaries: Natural-feeling edge of the world
What We Learned
- Hex math: Axial coordinates with q, r, and derived s
- Rounding to hex: Fix the axis with largest error
- Weighted presence: Stations count more than ships
- Soft vs hard boundaries: Drag feels better than walls
- Nested entity pattern: Sectors with visual children
What’s Next
We can see the hex grid… if we zoom way out. But we can’t watch the whole map while also viewing combat details.
In Part 13, we add a minimap using render-to-texture. A small window showing the entire sector, with icons for ships and stations. Finally, strategic awareness at a glance.
Time to see the big picture.