Skip to content
2023Archived, no longer updated

Hexbound

A turn-based tactics game on a hex grid, built to learn how much game logic you can fit into a Web Worker without dropping a frame.

typescriptgame-devwebglcanvas

A small tactics game I wrote to learn two things: hex grid pathfinding, and how to keep a game loop honest.

Hex grids are not that hard

The only genuinely fiddly part is converting between the two coordinate systems — axial coordinates for storage, cube coordinates for distance maths — and then remembering that each cube coordinate sums to zero.

ts
// Cube distance: the only hex distance formula you actually need
function cubeDistance(a: Cube, b: Cube): number {
  return (
    Math.abs(a.q - b.q) +
    Math.abs(a.r - b.r) +
    Math.abs(a.s - b.s)
  ) / 2
}
 
// Neighbours are six fixed offsets — no lookup table needed
const HEX_DIRECTIONS: Cube[] = [
  { q: +1, r: 0, s: -1 },
  { q: +1, r: -1, s: 0 },
  { q: 0, r: -1, s: +1 },
  { q: -1, r: 0, s: +1 },
  { q: -1, r: +1, s: 0 },
  { q: 0, r: +1, s: -1 },
]

divmod is the other function worth memorising, because flat-to-axial conversion appears in every hex library you'll ever write:

ts
function divMod(x: number, y: number) {
  const q = Math.floor(x / y)
  return [q, x - q * y] as const
}

Pathfinding is A* with cubeDistance as the heuristic. Hex grids are unweighted enough that this converges almost immediately at the scale a tactics game needs.

The Worker was the point

Turn resolution — AI decisions, damage, status effects, win conditions — runs entirely in a Web Worker. The main thread receives a resolved turn and only ever does rendering.

This is the right split for a turn-based game specifically. Nothing about the simulation is frame-rate-sensitive, so it has no business occupying the thread that also owns paint and input. The main thread never runs game logic; it draws a state.

ts
// main thread
worker.postMessage({ type: "resolveTurn", state, order: unitId })
 
// worker — no DOM access at all, no imports from the renderer
self.onmessage = ({ data }) => {
  self.postMessage({ type: "turnResolved", state: resolve(data.state) })
}

It also meant the entire simulation was unit-testable without a DOM, which is not a small nicety when you're writing AI that decides who dies.

Status

Archived, and I'm at peace with that. It taught me hex maths, worker architecture, and that a game's AI is mostly just a priority queue. I'd like to return to it with actual pathfinding through terrain, but the lessons were worth the time spent.

HAVE A QUESTION?

Open an issue on the repository, or just email me. Both work.

Email Razi

MORE PROJECTS

Live

This website — a statically exported Next.js app with a hand-built 8-bit design system, MDX content, and a CRT phosphor you can switch.

nextjstypescripttailwinddesign
LiveSource
Live

A zero-dependency WebAudio library that plays NES-style music from a compact note syntax. Ships about 4kb and no assets.

typescriptaudioweb-audioopen-source
LiveSource
Live

A browser tool that converts any image into 1-bit, 4-colour and 8-colour pixel art with real dithering algorithms. Fully client-side.

typescriptcanvastoolingwebgl
LiveSource

Command palette

Search for a command to run