---
title: "Eclipse: A Hexagonal Strategy Game"
description: "Minimax in Rust with WebAssembly frontend + Astro + Tailwind"
published: 2026-02-06
tags: ["rust", "math", "systems"]
---

import EclipseBoard from '../../../../components/blog/EclipseBoard.astro';

[Play online](https://eclipse.hermandaniel.com)

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](https://github.com/detrin/eclipse-python) 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](https://boardgamegeek.com/boardgame/11542/eclipse) released in 1999. The game seems to be a copy of a similar game [The Ball and Chain Game](https://thegamesarehere.com/products/the-ball-and-chain-game-the-great-american-trading-co-1999). 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

![](./board_real.png)

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.

<EclipseBoard caption="The Eclipse board in its initial configuration. Light player (tan/orange) on the right, dark player (brown) on the left." />

## Legal moves

<EclipseBoard
    pieces={[{ color: 'light', type: 'comet', nodes: [{ q: 0, r: 0 }] }]}
    caption="Comet can move to any of the 6 adjacent hexes (highlighted) as long as they are empty and not blocked by opponent chains."
    highlightHexes={[
        { q: 1, r: -1 },
        { q: 1, r: 0 },
        { q: 0, r: 1 },
        { q: -1, r: 1 },
        { q: -1, r: 0 },
        { q: 0, r: -1 },
  ]}
/>

<EclipseBoard
    pieces={[{ color: 'light', type: 'chain', nodes: [{ q: 0, r: 0 }, { q: 1, r: -1 }] }]}
    caption="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."
    highlightHexes={[
        { q: 1, r: -1 },
        { q: 1, r: 0 },
        { q: 0, r: 1 },
        { q: -1, r: 1 },
        { q: -1, r: 0 },
        { q: 0, r: -1 },
  ]}
/>

<EclipseBoard
    pieces={[{ color: 'light', type: 'chain', nodes: [{ q: 0, r: 0 }, { q: 2, r: -1 }] }]}
    caption="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."
    highlightHexes={[
        { q: 1, r: -1 },
        { q: 1, r: 0 },
        { q: 0, r: 1 },
        { q: -1, r: 1 },
        { q: -1, r: 0 },
        { q: 0, r: -1 },

        { q: -1, r: -1 },
        { q: -2, r: 1 },
        { q: -1, r: 2 },
        { q: 1, r: 1 },
        //{ q: 2, r: -1 },
        { q: 1, r: -2 },
  ]}
/>


<EclipseBoard
    pieces={[
        { color: 'light', type: 'comet', nodes: [{ q: -2, r: 0 }] },
        { color: 'dark', type: 'chain', nodes: [{ q: -1, r: -2 }, { q: -2, r: -1 }] },
        { color: 'dark', type: 'chain', nodes: [{ q: -3, r: 1 }, { q: -4, r: 2 }] },
        { color: 'light', type: 'chain', nodes: [{ q: -1, r: -1 }, { q: -2, r: 1 }] },
        { color: 'dark', type: 'comet', nodes: [{ q: 2, r: 0 }] },
        { color: 'light', type: 'chain', nodes: [{ q: 1, r: 1 }, { q: 2, r: -1 }] },
        { color: 'light', type: 'chain', nodes: [{ q: 2, r: 1 }, { q: 3, r: -1 }] },
    ]}
    caption="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."
    highlightHexes={[
        { q: -3, r: 0 },
        { q: -1, r: 0 },
  ]}
/>

---

## 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:
```rust
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.

```rust
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.

```rust
#[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.

```rust
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)

```rust
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
}
```

### Legal Move Generation

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

```rust
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**.

```rust
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:
```rust
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:

```rust
struct TranspositionEntry {
    depth: usize,
    score: f64,
    flag: EntryType,  // Exact, LowerBound, or UpperBound
}

transposition_table: RefCell<HashMap<u64, TranspositionEntry>>
```

Position hashing uses all game state:
```rust
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:

```rust
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.

```rust
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.

### 4. Quiescence Search

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

```rust
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:

```rust
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:

```rust
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:

```rust
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.

```rust
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](https://github.com/detrin/eclipse).

[Play online.](https://eclipse.hermandaniel.com)