The Mediocres

00 A Hexagon

simple_hexagon_with_bevy
By
Jörn Krausen
hexagonalSRPG

Today, before we dive into the actual development talk, I’d like to take a moment to explore in more detail what we hope to achieve through this project—sprinkling in a fair share of personal thoughts along the way.

Why Hexagon?

I’m a huge fan of JRPGs, especially turn‐based SRPGs—or more broadly, turn‐based strategy games. Consider:

  • Farland Saga series, Fire Emblem series, Tactics Ogre, The recent Octopath Traveler
  • And strategic masterpieces like XCOM or Battle Brothers

Some of these use square‐grid maps, while many employ hex‐grids. (Of course, games like Baldur’s Gate 3 use continuous coordinates, but classic turn‐based SRPGs typically stick to grids.)

battle-brothers-example

battle-brothers-example

The Crucial Differences: Square vs. Hex

Forming a Front

  • Squares: When multiple units line up, each unit only contacts one neighbor edge.
  • Hexes: Units pack tighter—any gap lets enemies surround you, and each cell touches at least two adjacent cells.

Range & Diagonals

  • Squares: Diagonal distance ≠ straight‐line distance, making “fair” ranged combat tricky.
  • Hexes: All center-to-center distances are equal—no diagonal quirks.

Zone of Control (ZOC)

  • Hard to impose ZOC naturally on squares, since each unit only influences four directions.
  • On a hex grid, a unit controls six neighboring cells, easily constraining enemy movement.

Battle Brothers brilliantly leverages these hex characteristics to evoke that gritty, medieval, sweat-dripping melee combat. I loved it—and it inspired me to build my own take on “Battle Brothers on hexes.”

tile_difference_rect_vs_hexa

tile_difference_rect_vs_hexa

The Bigger Picture

Beyond just hex-grid combat, this project will rest on three pillars

Roguelike-Economy Systems
Beyond just hex-grid combat, we’ll build the kind of evolving, repeat-play economies you see in Darkest Dungeon, XCOM, Battle Brothers, Wartales, and similar titles—so every run feels fresh and strategically deep.

Smart AI
Rather than the usual “higher difficulty = massive resource bonuses for the AI,” I want a true test of wits: at higher difficulty levels, the AI should actually get smarter, managing its civilization more efficiently (without any unfair perks), so that I can experience being out-played by a genuinely superior strategist.

The Web
Steam is great, but with modern WebAssembly (wasm), pure-web games are finally practical. I’m excited to push the boundaries of what a browser-only strategy RPG can be.

So, now...

“A journey of a thousand miles begins with a single step.”

Let’s start by drawing a hexagon.

A Hexagon

Hex Geometry & Size

We’ll choose an edge length of 10 units for each hex cell.

  • Outer Radius (distance from center to a corner) = 10
  • Inner Radius (distance from center to a side)
Inner Radius=32×10=53  8.66025\text{Inner Radius} = \frac{\sqrt{3}}{2} \times 10 = 5\sqrt{3} \;\approx 8.66025

This matters because the distance between centers of adjacent hexes is 2 x Inner Radius

To keep this handy, let’s define a static metrics class:

#[derive(Resource)]
pub struct HexMetrics {
pub outer_radius: f32,
pub inner_radius: f32,
}
impl Default for HexMetrics {
fn default() -> Self {
let outer_radius = 10.0;
Self {
outer_radius,
inner_radius: outer_radius * 0.866025404, // sqrt(3)/2
}
}
}

File: hex_system.rs

Flat-Top vs. Pointy-Top

We’ll use flat-top hexes (horizontal faces) because:

  1. Vertical lines form naturally, making front-line layouts intuitive.
  2. From left/right perspectives, each hex exposes exactly 2 faces. Pointy-top hexes can expose up to 3 faces—often too many.
flat-top-and-pointy-top

flat-top-and-pointy-top

Computing Hex Vertices

For a flat-top hex centered at (0,0)(0,0)(0,0), the seven vertices(including a center point) are:

p0=(0,0,0),p1=(R,0,0),p2=(12R,0,r),p3=(12R,0,r),p4=(R,0,0),p5=(12R,0,r),p6=(12R,0,r),where R=outer_radius,r=inner_radius.\begin{aligned} \mathbf{p}_0 &= (0,\,0,\,0),\\ \mathbf{p}_1 &= (R,\,0,\,0),\\ \mathbf{p}_2 &= \bigl(\tfrac{1}{2}R,\,0,\,r\bigr),\\ \mathbf{p}_3 &= \bigl(-\tfrac{1}{2}R,\,0,\,r\bigr),\\ \mathbf{p}_4 &= (-R,\,0,\,0),\\ \mathbf{p}_5 &= \bigl(-\tfrac{1}{2}R,\,0,\,-r\bigr),\\ \mathbf{p}_6 &= \bigl(\tfrac{1}{2}R,\,0,\,-r\bigr), \end{aligned} \quad \\ \text{where } R = \text{outer\_radius},\quad r = \text{inner\_radius}.

In code:

let positions = vec![
[0.0, 0.0, 0.0]
[hex_metrics.outer_radius, 0.0, 0.0],
[0.5 * hex_metrics.outer_radius, 0.0, hex_metrics.inner_radius],
[-0.5 * hex_metrics.outer_radius, 0.0, hex_metrics.inner_radius],
[-hex_metrics.outer_radius, 0.0, 0.0],
[-0.5 * hex_metrics.outer_radius, 0.0, -hex_metrics.inner_radius],
[0.5 * hex_metrics.outer_radius, 0.0, -hex_metrics.inner_radius],
];

File: create_hex_mesh.rs

By translating and adding these vectors, we can place each cell and navigate to its neighbors.

Rendering with Bevy Custom Mesh

In Bevy, to draw a hex cell as a 3D mesh you need:

  1. Vertex Positions
  2. UV Coordinates
  3. Normals
  4. Index Buffer (which vertices form each triangle)
  5. Material (color or texture)

We can represent our hex as a triangle fan: one center vertex plus the six outer vertices, forming six triangles. Here is a full example of custom bevy mesh:

#[rustfmt::skip]
pub fn create_hex_mesh(hex_metrics: &HexMetrics) -> Mesh {
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
);
let positions = vec![
[0.0, 0.0, 0.0],
[hex_metrics.outer_radius, 0.0, 0.0],
[0.5 * hex_metrics.outer_radius, 0.0, hex_metrics.inner_radius],
[-0.5 * hex_metrics.outer_radius, 0.0, hex_metrics.inner_radius],
[-hex_metrics.outer_radius, 0.0, 0.0],
[-0.5 * hex_metrics.outer_radius, 0.0, -hex_metrics.inner_radius],
[0.5 * hex_metrics.outer_radius, 0.0, -hex_metrics.inner_radius],
];
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions.clone());
info!("positions: {:?}", positions);
// UV mapping: map positions from the xz-plane into 0-1 space.
let uvs: Vec<[f32; 2]> = positions
.iter()
.map(|[x, _y, z]| {
[
(x / (2.0 * hex_metrics.outer_radius)) + 0.5,
(z / (2.0 * hex_metrics.outer_radius)) + 0.5,
]
})
.collect();
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
// All normals point upward (y-axis up).
let normals = vec![[0.0, 1.0, 0.0]; positions.len()];
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
// Define triangles by connecting center (0) with pairs of adjacent vertices
let indices = vec![
0, 2, 1, // First triangle
0, 3, 2, // Second triangle
0, 4, 3, // Third triangle
0, 5, 4, // Fourth triangle
0, 6, 5, // Fifth triangle
0, 1, 6, // Sixth triangle
];
mesh.insert_indices(Indices::U32(indices));
mesh
}

File: create_hex_mesh.rs

Building a simple app

Let’s quickly build a simple application to render this mesh. For that, we’ll need to set up a basic camera, mesh, and material.

One useful configuration to keep in mind is the WindowPlugin, which is required to embed a WASM-compiled Bevy application into a target HTML element.

fn main() {
App::new()
.init_resource::<HexMetrics>()
.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
canvas: Some("#bevy-canvas".into()),
resolution: WindowResolution::new(300.0, 300.0),
fit_canvas_to_parent: true,
prevent_default_event_handling: false,
..default()
}),
..default()
}),
)
.add_systems(Startup, setup)
.run();
}
fn setup(
mut commands: Commands,
mut materials: ResMut<Assets<StandardMaterial>>,
mut meshes: ResMut<Assets<Mesh>>,
hex_metrics: Res<HexMetrics>,
asset_server: Res<AssetServer>,
) {
let hex_mesh_handle = meshes.add(create_hex_mesh(&hex_metrics));
let hex_material = materials.add(StandardMaterial {
base_color: Color::srgb(0.8, 0.0, 0.0),
cull_mode: None,
double_sided: true,
..default()
});
let entity = commands
.spawn((
Mesh3d(hex_mesh_handle),
MeshMaterial3d(hex_material.clone()),
Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)),
GlobalTransform::default(),
HexCell,
HexCoord::new(0, 0),
))
.id();
commands.spawn((
Camera3d::default(),
Transform::from_xyz(0.0, 150.0, 0.0).looking_at(Vec3::ZERO, -Vec3::Z),
Camera {
clear_color: Color::from(SLATE_950).into(),
hdr: true,
..default()
},
Msaa::Off,
));
commands.spawn((
DirectionalLight {
illuminance: light_consts::lux::OVERCAST_DAY,
shadows_enabled: true,
..default()
},
Transform {
translation: Vec3::new(0.0, 2.0, 0.0),
rotation: Quat::from_rotation_x(-PI / 4.),
..default()
},
));
commands.insert_resource(AmbientLight {
color: Color::WHITE,
brightness: 100.0,
..default()
});
}

File: main.rs

The result is the same as the cover image above. I originally planned to include the WASM binary in every post, but since the file size is large and there’s no meaningful interaction, it doesn’t seem very engaging, so I won’t include it each time. After a few more posts, I plan to add interactive demo pages in the project where you can explore the built WASM files directly.

simple_hexagon_from_bevy

simple_hexagon_from_bevy

Next Steps

We’ve laid the groundwork: hexagon geometry, coordinate calculations, and rendering a single cell in Bevy. Next up, we’ll explore Map‐wide coordinate systems (axial, cube coordinates)

With our first step complete, I’m excited for the journey ahead.