Chess assumes a square board. But there's a whole family of hexagonal chess variants — Gliński's, McCooey's, Shafran's, and others — played on a board of hexagons where every piece needs new movement rules and your spatial intuition is useless. I built a Rust implementation of eight of them that runs entirely in the browser via WASM, with multiplayer that needs almost no server at all. This post is about the three architecture decisions that made it tractable.
Decision 1: separate the game from the engine from the network
The repo is three crates, and the split is the most important design choice in the project:
hex-chess/
├── crates/
│ ├── core/ # pure game logic — no rendering, no networking
│ ├── game/ # Bevy WASM app — rendering, input, the actual game
│ └── signaling/ # minimal WebRTC signaling server
├── web/ # static web assets
└── deploy/nixos/ # production deployment
core has no dependencies on Bevy, on WASM, or on the network. It's pure rules: given a board state and a variant, what are the legal moves? That isolation buys two things. First, the rules are testable without a GPU or a browser — cargo test on core exercises move generation directly, which matters enormously when "is this move legal on a Shafran board?" has eight different correct answers depending on the variant. Second, the rules are reusable: the same core that the Bevy app calls could back a bot, a server-side validator, or a puzzle generator later, with zero rendering code dragged along.
game is the Bevy app — it owns rendering, input, and animation, and it asks core what's legal. signaling is its own tiny server. Keeping these three apart means a change to how pieces are drawn can't possibly break the rules, and a change to the netcode can't touch either.
Decision 2: hex coordinates, not (row, column)
The thing that breaks your brain about hex chess is that a square-grid mental model doesn't transfer. On a square board a piece has 8 neighbors and you index by (row, col). On a hex board a cell has 6 neighbors and (row, col) produces ugly special-casing everywhere — diagonals aren't diagonals, and "forward" depends on which of three axes you mean.
The standard fix (and the right one) is to stop thinking in two axes and use cube or axial coordinates — three coordinates (q, r, s) with the constraint q + r + s = 0. Movement directions become clean vector additions, distance is a single formula, and the six variants' wildly different movement rules all reduce to "which direction vectors does this piece get, and how far." Putting that coordinate system in core is what keeps the eight variants from turning into eight piles of special cases. If you take one idea from this project, it's that the coordinate system is the architecture for a hex game — pick it before you write a single movement rule.
Decision 3: peer-to-peer, so I barely run a server
Most multiplayer games need a server in the hot path relaying every move. I didn't want to run (or pay for) that, so hex-chess uses WebRTC for true peer-to-peer play. The only server is the signaling crate, and it does almost nothing:
- Two players want to connect. WebRTC needs them to exchange connection details (SDP offers/answers and ICE candidates) before they can talk directly.
- The signaling server is just the broker for that initial handshake. It introduces the peers.
- Once the peers connect, game traffic flows directly between the two browsers. The server is out of the loop entirely — it never sees a move.
So the "server infrastructure" for an arbitrary number of concurrent games is one tiny process whose only job is matchmaking introductions. It's idle the moment a game starts. That's a dramatically cheaper operational story than a stateful game server, and for a turn-based game like chess the direct peer connection is more than fast enough.
Shipping it: WASM, Nix, and CI
The game crate compiles to WebAssembly with Trunk, so the whole thing runs in the browser at near-native speed:
nix develop # reproducible toolchain
cd crates/game && trunk build --release
The entire dev environment and production deploy are defined in Nix — nix develop drops you into a shell with the exact Rust toolchain, wasm target, and Trunk version, and nix build .#game .#signaling produces both artifacts:
nix build .#game .#signaling # build both packages
Deployment lives in deploy/nixos/, and GitHub Actions handles CI. This is the same declarative approach I use across my whole fleet — the game and its signaling server are just two more Nix packages, built and deployed the same way as everything else I run. No "works on my machine" between my laptop and the box that serves the game.
What I'd tell someone starting a hex game
The hard parts of this project weren't the parts I expected. Rendering hexagons is easy; Bevy handles it. The hard parts were getting the coordinate system right before writing rules (retrofitting axial coordinates onto a square-grid assumption would have been a rewrite) and resisting the urge to put a real game server in the middle (the P2P handshake is more setup up front but far less to operate forever after).
Pure-logic core, hex-native coordinates, peer-to-peer networking with a near-stateless broker, WASM for reach, Nix for reproducibility. Five decisions, eight playable variants, and a multiplayer game I can run for the cost of a process that sits idle most of the time.
— Parker Jones, parkerjones.dev