Eclipse: A Hexagonal Strategy Game

13 min read Original article ↗

Play online

I got this game when I was 11. It was a Christmas gift from an uncle. It is a logical board game for two that has very simple rules. The game is played on a hexagonal board with 49 hexes. Each player has a comet piece and 5 chains made of satellite pairs. The goal is to immobilize your opponent’s comet and satellites by strategically moving your pieces and creating chain crossings.

I made my first attempt at creating a bot that I could play with when I was 13. I tried to implement the minimax algorithm in C, but overall, I struggled with the abstraction and the data structures. My second attempt was at the age of 21; I managed to implement a working minmax bot in Python, but it was very slow, and I could only search 2-3 moves ahead. Now, at 28 years old, I decided to give it another try and build a fully optimized version in Rust, with the goal of making it fast enough to search 4-6 moves ahead in a reasonable time frame (both players combined).

The project is written in Rust. The frontend uses Astro+Tailwind and is deployed on Vercel. The Rust code is compiled to WebAssembly and runs directly in the browser for zero-latency gameplay. Before implementing the WebAssembly version, I built an HTTP API server using Actix-web, mainly for faster debugging.

What is Eclipse?

You can find the reference on BoardGameGeek released in 1999. The game seems to be a copy of a similar game The Ball and Chain Game. Nevertheless, I was unable to find any online implementation of the game, so that makes the whole problem more exciting.

Eclipse is a two-player abstract strategy game played on a custom 49-hex board. Each player controls:

  • 1 Comet the king-like piece you’re trying to trap
  • 5 Chains made of satellite pairs connected by rigid lines

The goal? Immobilize your opponent’s comet AND satellites so they have no legal moves.

Chains can cross each other, and when they do the bottom satellite becomes frozen in place.

The Eclipse board in its initial configuration. Light player (tan/orange) on the right, dark player (brown) on the left.
Comet can move to any of the 6 adjacent hexes (highlighted) as long as they are empty and not blocked by opponent chains.
Short satellite can move to any hex that is within the reach of its chain as long as the destination hex is empty and on-board.
Long satellite can move to any hex that is within the reach of its chain as long as the destination hex is empty and on-board.
On the left you can see that the ligh comet has still two valid moves, but the dark comet has no valid moves, meaning it is game over and light player wins.

The Board

Axial Coordinate System

The game uses axial coordinates (q, r) to represent hex positions:

( 0,-1) ( 1,-1)

(-1, 0) ( 0, 0) ( 1, 0)

(-1, 1) ( 0, 1)

Distance calculation uses the derived s = -(q + r) coordinate:

pub fn distance(&self, other: &Hex) -> i32 {

let dq = (self.q - other.q).abs();

let dr = (self.r - other.r).abs();

let ds = ((self.q + self.r) - (other.q + other.r)).abs();

(dq + dr + ds) / 2

}

This gives the minimum number of hex steps between any two positions.

The Hex Board

The Eclipse board has 180-degree rotational symmetry:

(-1,-3) ( 0,-3) ( 1,-3) ( 2,-3) ( 3,-3) ( 4,-3) [r=-3: 6 hexes]

(-2,-2) (-1,-2) ( 0,-2) ( 1,-2) ( 2,-2) ( 3,-2) ( 4,-2) [r=-2: 7 hexes]

(-3,-1) (-2,-1) (-1,-1) ( 0,-1) ( 1,-1) ( 2,-1) ( 3,-1) ( 4,-1) [r=-1: 8 hexes]

(-3, 0) (-2, 0) (-1, 0) ( 0, 0) ( 1, 0) ( 2, 0) ( 3, 0) [r= 0: 7 hexes]

(-4, 1) (-3, 1) (-2, 1) (-1, 1) ( 0, 1) ( 1, 1) ( 2, 1) ( 3, 1) [r= 1: 8 hexes]

(-4, 2) (-3, 2) (-2, 2) (-1, 2) ( 0, 2) ( 1, 2) ( 2, 2) [r= 2: 7 hexes]

(-4, 3) (-3, 3) (-2, 3) (-1, 3) ( 0, 3) ( 1, 3) [r= 3: 6 hexes]


Game Pieces and Mechanics

1. Comets (King Pieces)

Comets move one hex at a time, must land on an empty hex, and cannot cross an opponent chain.

fn is_valid_comet_move(&self, from: Hex, to: Hex, player: Player) -> bool {

// On board?

if !to.is_on_board() {

return false;

}

// Empty?

if self.occupied.contains_key(&to) {

return false;

}

// Cannot cross opponent chains

let opponent = player.opponent();

for chain in self.chains.values() {

if chain.owner == opponent {

let (c1, c2) = (from.to_pixel(), to.to_pixel());

let (ch1, ch2) = (chain.head.to_pixel(), chain.tail.to_pixel());

if segments_intersect(c1, c2, ch1, ch2) {

return false;

}

}

}

true

}


2. Chains (Fixed Maximum Length)

Each chain connects two satellites and has a maximum length:

  • Short chains: max length 1 (adjacent satellites only)
  • Long chains: max length 2 (adjacent or one hex between)

Chains are rigid and must never stretch beyond their max length.

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]

pub enum ChainType {

Short, // Max length 1

Long, // Max length 2

}

impl ChainType {

pub fn max_len(&self) -> i32 {

match self {

ChainType::Short => 1,

ChainType::Long => 2,

}

}

}

#[derive(Debug, Clone, Serialize, Deserialize)]

pub struct Chain {

pub id: ChainId,

pub owner: Player,

pub ctype: ChainType,

pub head: Hex,

pub tail: Hex,

pub head_last_moved: usize,

pub tail_last_moved: usize,

}


3. Chain Crossing and Immobilization

If a chain crosses an opponent chain, it becomes immobilized and cannot move. If two chains cross each other, both are immobilized.

pub fn is_chain_immobilized(&self, chain_id: ChainId) -> bool {

let chain = &self.chains[&chain_id];

let opponent = chain.owner.opponent();

for other_chain in self.chains.values() {

if other_chain.owner == opponent {

if self.chains_cross_internal(chain, other_chain) {

return true;

}

}

}

false

}


Move Generation and Validation

Satellite Move Validation

A satellite move is legal if:

  1. The destination is empty and on-board
  2. The new distance to the other end is between 1 and max_len
  3. Long chains at distance 2 must be diagonal (no shared axis)

fn is_valid_satellite_move(&self, chain: &Chain, _old_pos: Hex, new_pos: Hex, other_end: Hex) -> bool {

if self.occupied.contains_key(&new_pos) {

return false;

}

if !new_pos.is_on_board() {

return false;

}

let new_len = new_pos.distance(&other_end);

if new_len == 0 || new_len > chain.ctype.max_len() {

return false;

}

if chain.ctype == ChainType::Long && new_len == 2 {

if new_pos.shares_axis(&other_end) {

return false;

}

}

true

}

The engine generates all legal moves by combining comet moves and satellite moves for the current player.

pub fn get_legal_moves(&self) -> Vec<Move> {

let mut moves = Vec::new();

let player = self.current_turn;

// Comet moves

let comet_pos = if player == Player::Light { self.comet_light } else { self.comet_dark };

for neighbor in comet_pos.neighbors() {

if self.is_valid_comet_move(comet_pos, neighbor, player) {

moves.push(Move::MoveComet(neighbor));

}

}

// Satellite moves

for chain in self.chains.values() {

if chain.owner != player {

continue;

}

if self.is_chain_immobilized(chain.id) {

continue;

}

let head_targets = self.get_reachable_hexes(chain.tail, chain.ctype.max_len());

for hex in head_targets {

if self.is_valid_satellite_move(chain, chain.head, hex, chain.tail) {

moves.push(Move::MoveSatellite { chain_id: chain.id, old_pos: chain.head, new_pos: hex });

}

}

let tail_targets = self.get_reachable_hexes(chain.head, chain.ctype.max_len());

for hex in tail_targets {

if self.is_valid_satellite_move(chain, chain.tail, hex, chain.head) {

moves.push(Move::MoveSatellite { chain_id: chain.id, old_pos: chain.tail, new_pos: hex });

}

}

}

moves

}


Win Condition

A player wins when the opponent has no legal comet moves.

pub fn has_legal_comet_moves(&self, player: Player) -> bool {

let comet_pos = if player == Player::Light { self.comet_light } else { self.comet_dark };

comet_pos

.neighbors()

.into_iter()

.any(|neighbor| self.is_valid_comet_move(comet_pos, neighbor, player))

}

pub fn check_winner(&mut self) -> GameStatus {

if let GameStatus::Won(_) = self.status {

return self.status;

}

if !self.has_legal_comet_moves(self.current_turn) {

let winner = self.current_turn.opponent();

self.status = GameStatus::Won(winner);

}

self.status

}


The Minimax Bot

The bot uses minimax with alpha-beta pruning and several classic optimizations:

  • Transposition table for cached positions
  • Killer-move ordering
  • Null-move pruning (in deeper minimizing nodes)
  • Late move reductions
  • Quiescence search only on Hard difficulty (limited depth)

Evaluation combines mobility, comet safety, chain control, and comet positioning.

Mobility evaluation compares legal move counts:

fn evaluate_mobility(&self, game: &GameState) -> f64 {

let our_moves = /* count our legal moves */ as f64;

let opponent_moves = /* count opponent legal moves */ as f64;

(our_moves - opponent_moves) / (our_moves + opponent_moves + 1.0)

}


Advanced Optimizations

1. Transposition Table (Hash Table)

Caches previously evaluated positions to avoid redundant work:

struct TranspositionEntry {

depth: usize,

score: f64,

flag: EntryType, // Exact, LowerBound, or UpperBound

}

transposition_table: RefCell<HashMap<u64, TranspositionEntry>>

Position hashing uses all game state:

pub fn hash_position(&self) -> u64 {

let mut hasher = DefaultHasher::new();

self.comet_light.hash(&mut hasher);

self.comet_dark.hash(&mut hasher);

for chain in self.chains.values() {

chain.head.hash(&mut hasher);

chain.tail.hash(&mut hasher);

chain.last_moved.hash(&mut hasher);

}

self.current_turn.hash(&mut hasher);

self.status.hash(&mut hasher);

hasher.finish()

}

2. Undo/Redo System

Instead of cloning game state (expensive), we efficiently reverse moves:

pub fn apply_move_for_search(&mut self, mv: Move) -> Result<UndoInfo> {

let undo_info = UndoInfo {

prev_move_number: self.move_number,

prev_turn: self.current_turn,

prev_status: self.status,

comet_undo: /* save old comet position if comet move */,

satellite_undo: /* save old satellite position if satellite move */,

// ... minimal state needed to reverse

};

// Apply move...

Ok(undo_info)

}

pub fn undo_move(&mut self, undo_info: UndoInfo) {

// Efficiently reverse all changes

self.move_number = undo_info.prev_move_number;

self.current_turn = undo_info.prev_turn;

// ... restore positions from undo_info

}

3. Move Ordering with Killer Moves

Better move ordering → more alpha-beta cutoffs → faster search.

killer_moves: RefCell<HashMap<usize, [Option<Move>; 2]>>

fn order_moves(&self, moves: &mut Vec<Move>, depth: usize) {

let killers = self.killer_moves.borrow();

moves.sort_by_key(|mv| {

// Killer moves first (caused cutoffs at this depth before)

if killers.get(&depth).map_or(false, |k| k[0].as_ref() == Some(mv)) {

return -2;

}

if killers.get(&depth).map_or(false, |k| k[1].as_ref() == Some(mv)) {

return -1;

}

// Then comet moves (game-changing)

match mv {

Move::MoveComet(_) => 0,

Move::MoveSatellite { .. } => 1,

}

});

}

When a move causes a beta cutoff, store it as a “killer” for that depth.

Avoids the “horizon effect” where the bot misses tactical threats just beyond search depth:

fn quiescence_search(&self, game: &mut GameState, alpha: f64, beta: f64,

qs_depth: usize, maximizing: bool) -> f64 {

let stand_pat = self.evaluate_position(game);

if qs_depth >= self.max_quiescence_depth {

return stand_pat;

}

let tactical_moves = self.get_tactical_moves(game);

if tactical_moves.is_empty() {

return stand_pat; // Position is quiet

}

// Search only tactical moves (chain crossings)

// ...

}

Tactical moves: Satellite moves that create new chain crossings with opponent.

5. Null Move Pruning

Gives opponent a “free move” at reduced depth. If they still can’t improve their position enough, prune the entire branch:

if null_move_allowed && !maximizing && depth >= 3 && beta < 9999.0 {

game.current_turn = game.current_turn.opponent();

let null_score = self.minimax(game, depth - 2, alpha, beta, true, false);

game.current_turn = game.current_turn.opponent();

if null_score >= beta {

return beta; // Null move pruning!

}

}

6. Late Move Reductions (LMR)

The big one. With good move ordering, later moves are unlikely to be best. Search them at reduced depth first:

if move_index >= 4 && depth >= 3 && !self.is_tactical_move(game, &mv) {

// Calculate reduction based on move index

let reduction = 1 + (move_index / 8).min(2);

let reduced_depth = depth.saturating_sub(reduction + 1);

// Quick search at reduced depth

let reduced_score = self.minimax(game, reduced_depth, alpha, beta, false, true);

// If promising, re-search at full depth

if reduced_score > alpha {

eval = self.minimax(game, depth - 1, alpha, beta, false, true);

} else {

eval = reduced_score; // Accept reduced search

}

} else {

// Full search for first few moves and tactical moves

eval = self.minimax(game, depth - 1, alpha, beta, false, true);

}

7. Iterative Deepening

For depths ≥4, search incrementally from depth 2 to target depth, using previous results for move ordering:

fn find_best_move(&self, game: &GameState) -> Option<Move> {

let max_depth = self.difficulty.depth();

self.killer_moves.borrow_mut().clear();

if max_depth < 4 {

return self.find_best_move_at_depth(game, max_depth, None)

.map(|(mv, _)| mv);

}

let mut best_move: Option<Move> = None;

for depth in 2..=max_depth {

if let Some((mv, _score)) = self.find_best_move_at_depth(game, depth, best_move) {

best_move = Some(mv); // Use as hint for next iteration

}

}

best_move

}


Architecture: Rust + Web Stack

Backend (Rust)

eclipse/

├── src/

│ ├── main.rs # CLI entry point

│ ├── lib.rs # Library root

│ ├── board.rs # Hexagonal grid system

│ ├── moves.rs # Move generation and types

│ ├── states.rs # Game state management

│ ├── display.rs # Terminal rendering

│ ├── input.rs # User input parsing

│ ├── api.rs # Core API functions (HTTP + WASM)

│ ├── wasm.rs # WebAssembly bindings

│ ├── bot.rs # Bot trait definition

│ ├── randombot.rs # Random move bot

│ ├── simplebot.rs # Simple evaluation bot

│ ├── minimaxbot.rs # Minimax AI with alpha-beta pruning

│ ├── cli.rs # CLI argument parsing

│ ├── serde_helpers.rs # JSON serialization helpers

│ └── bin/

│ └── eclipse-api.rs # HTTP API server (Actix-web)

├── examples/

│ ├── bot_demo.rs # Bot demonstration

│ ├── chain_crossing_demo.rs

│ ├── show_initial_board.rs

│ ├── generate_state.rs

│ └── test_json_state.rs

├── web/ # Astro web interface

│ ├── src/

│ │ ├── pages/

│ │ │ └── index.astro # Entry point

│ │ ├── components/

│ │ │ └── EclipseGame.astro # Main game component

│ │ ├── lib/

│ │ │ ├── wasmApi.ts # WASM API wrapper (active)

│ │ │ ├── api.ts # HTTP API client (legacy)

│ │ │ ├── gameLogic.ts # Client-side utilities

│ │ │ └── hexUtils.ts # Hex grid math

│ │ ├── types/

│ │ │ └── game.ts # TypeScript definitions

│ │ ├── styles/

│ │ │ └── global.css # Global styles

│ │ └── pkg/ # WASM module (committed for deployment)

│ │ ├── eclipse.js # JS bindings

│ │ ├── eclipse_bg.wasm # WebAssembly binary

│ │ └── eclipse.d.ts # TypeScript definitions

│ ├── public/ # Static assets

│ ├── package.json

│ ├── build.sh # Build script for deployment

│ ├── vercel.json # Vercel configuration

│ ├── DEPLOYMENT.md # Deployment guide

│ └── astro.config.mjs # Astro configuration

├── pkg/ # WASM build output (gitignored at root)

│ # Copy to web/src/pkg/ for deployment

├── target/ # Rust build artifacts (gitignored)

├── Cargo.toml # Rust dependencies

├── Cargo.lock

└── README.md # This file

WASM Bindings

This part is pretty uneventful, here are examples of two bindings.

use wasm_bindgen::prelude::*;

#[wasm_bindgen]

pub fn get_initial_state() -> String {

match crate::api::handle_initial_state_request() {

Ok(json) => json,

Err(e) => format!(r#"{{"success": false, "error": "{}"}}"#, e),

}

}

#[wasm_bindgen]

pub fn get_best_move(state_json: &str, player: &str, depth: u8, weight: f64) -> String {

let player_enum = match player.to_lowercase().as_str() {

"light" => Player::Light,

"dark" => Player::Dark,

_ => {

return format!(

r#"{{"success": false, "error": "Invalid player: '{}'. Must be 'light' or 'dark'"}}"#,

player

);

}

};

match handle_api_request(state_json, player_enum, depth, weight) {

Ok(json) => json,

Err(e) => format!(r#"{{"success": false, "error": "{}"}}"#, e),

}

}

All functions return JSON strings.

Frontend (Web)

Astro web app (web/) is beyond the scope of this blog.


If you are interesed you can find the code here.

Play online.