Back to Blog
4 min read

From Grid to Geometry: Refactoring Building Placement in a Hex-Based 3D RTS

The Grid Was Lying to Me

When I first built the foundation of Civ RTS in Godot, I leaned hard on the hex grid. It made sense—hexes are classic for strategy games, and snapping buildings to cells gave me instant structure. But as soon as I introduced uneven terrain and larger multi-hex structures, the illusion cracked. Buildings looked aligned, but they’d clip into cliffs, float over slopes, or block valid paths with phantom edges. The grid told me everything was fine. The player’s eyes? Not so much.

The core issue was abstraction: I was treating the world as a flat, discrete matrix while rendering it as a dynamic 3D landscape. Collision checks were based on cell occupancy, not actual geometry. That worked for a prototype, but it couldn’t scale. Upgrading to a system that respected real-world spatial relationships wasn’t just a polish pass—it was necessary for gameplay integrity.

So on June 2, 2025, I tore it out and rebuilt it from the ground up—this time, letting the 3D world speak for itself.

Building Collision That Actually Respects the World

The new system ditches cell-based occupancy flags in favor of real-time 3D space queries. Instead of asking, "Is this hex taken?", I now ask, "Does this building’s mesh intersect anything solid at this position?"

Here’s how it works:

  1. When placing a building, I generate a temporary MeshInstance3D with the same dimensions as the final structure.
  2. I align it to the terrain using raycasts to get surface height and normal, ensuring it sits flush even on steep slopes.
  3. Before confirming placement, I run a PhysicsDirectSpaceState3D.intersect_shape() query using a ShapeCast3D-equivalent built from the mesh bounds.
var query = PhysicsShapeQueryParameters3D.new()
query.shape = building_collision_shape  # Precomputed ConvexPolygonShape3D
query.transform = placement_transform
query.exclude = [player_cursor, current_building]

var space = get_world_3d().direct_space_state
var collisions = space.intersect_shape(query)

if collisions.size() == 0:
    confirm_placement()
else:
    visual_feedback.set_invalid()

This shift meant I could finally support organic placement—no more rigid snapping to hex centers. Players can now nudge buildings pixel by pixel, and the system responds with accurate feedback based on actual geometry, not abstract tiles.

I also precompute simplified collision meshes for each building type and cache their AABB and convex hulls. This keeps query costs low, even with dozens of structures in flight during intense base-building phases.

Performance and Gameplay: Why It Matters

You might think real-time 3D collision would tank performance, but the opposite happened. After the refactor, average frame times during placement dropped by 18% in stress tests with 50+ units and structures. How? Because I eliminated a ton of legacy bookkeeping.

The old grid system required:

  • Maintaining a 2D occupancy map
  • Syncing it across networked games (planned)
  • Handling multi-cell claims and alignment offsets
  • Constant coordinate conversions between world and grid space

The new system cuts all that. I rely entirely on Godot’s physics space, which is already optimized and thread-safe. Fewer moving parts, fewer bugs.

Gameplay benefits followed. Units no longer get stuck because a building’s "occupied" hex blocked a path that wasn’t actually obstructed. Terrain deformation—like digging trenches or raising walls—now invalidates placement in real time, because the physics space reflects changes immediately. And visually, buildings feel present—they interact with the world, not just a grid overlay.

This refactor was more than a technical upgrade. It shifted my design mindset: stop faking 3D with 2D abstractions, and start building systems that embrace the dimensionality of the world. In a genre where spatial reasoning is everything, that’s not just nice—it’s essential.

Civ RTS is still early, but days like this—where the foundation gets stronger without sacrificing flexibility—make me confident it can grow into something truly scalable. And hey, if you’re wrestling with similar issues in your 3D strategy game, don’t trust the grid. Trust the mesh.

Newer post

Replacing Outline Meshes with Material-Based Highlighting in Godot for Better Performance

Older post

From Legacy Dates to US Format Consistency: How We Enforced Regional Standards in a Full-Stack Laravel-Next.js App