Skip to main content

Game State Management

The game engine is built around three core classes that manage game state at different levels of complexity.

GameInstance Class

Location: src/app/hex/[gameId]/GameInstance.ts The GameInstance class represents an active game session with full player information.
class GameInstance {
  private game_id: GameId;
  private game_parameters: GameParameters | LocalGameParameters;
  private grid_array: Array<Array<number>>;
  private first_player_id: UserId;
  private second_player_id: UserId;
  private turn: number;
  private own_id: UserId;
}

Grid Representation

The game board is stored as a 2D array where:
  • 0 = empty cell
  • 1 = red player (connects left to right)
  • 2 = blue player (connects top to bottom)
// Example 5x5 grid
[
  [0, 0, 1, 0, 0],
  [1, 0, 2, 0, 0],
  [0, 2, 1, 0, 0],
  [0, 0, 0, 2, 0],
  [0, 0, 0, 0, 1]
]

Key Methods

isValidMove(i: number, j: number): boolean {
  return this.grid_array[i][j] === 0;
}
Validates that a cell is empty before allowing a move.

AI Engine Architecture

BeeHex implements a sophisticated minimax-based AI with parallel exploration using Web Workers.

Class Hierarchy

SimpleGameInstance

Location: src/app/hex/[gameId]/Algorithm.ts:7 Lightweight representation of a game state without evaluation.
class SimpleGameInstance {
  public grid: number[][];
  public moves: Array<Coordinate>;
  public turn: number;
  protected gridHash: string;
  
  constructor(grid: Array<Array<number>>, turn: number, moves: Array<Coordinate> = []) {
    this.grid = grid;
    this.turn = turn;
    this.moves = moves;
    this.gridHash = hashGrid(grid);
  }
}

Move Generation

getValidMoves(): Array<Coordinate> {
  const validMoves: Array<Coordinate> = [];
  for (let i = 0; i < this.grid.length; i++) {
    for (let j = 0; j < this.grid[i].length; j++) {
      if (this.isValidMove(i, j)) {
        validMoves.push([i, j]);
      }
    }
  }
  return validMoves;
}

Immutable Move Application

The engine uses immutable state updates - each move creates a new game instance.
playMoveYX(y: number, x: number): SimpleGameInstance {
  if (this.isValidMove(y, x)) {
    let newGrid = this.grid.slice();
    let row = newGrid[y];
    newGrid[y] = array_cache.getFromOne(row, x, this.getCurrentPlayer());
    let newMoves = this.moves.slice();
    newMoves.push([y, x]);
    let newTurn = this.turn + 1;
    return new SimpleGameInstance(newGrid, newTurn, newMoves);
  } else {
    throw new Error("Invalid move");
  }
}

ScoredGameInstance

Location: src/app/hex/[gameId]/Algorithm.ts:95 Extends SimpleGameInstance with position evaluation and tree structure.
class ScoredGameInstance extends SimpleGameInstance {
  public score: Score;
  public previousInstances: Array<ScoredGameInstance> = [];
  public nextInstances: Map<number, ScoredGameInstance>;
  public leadingInstance: ScoredGameInstance | null = null;
  public completedBranch: boolean;
}

Score Propagation

Scores propagate up the tree using minimax logic:
updateScore() {
  if (this.nextInstances.size == 0) {
    console.warn("updateScore() called with no children")
  }
  
  let minMax;
  if (this.turn % 2 === 0) {
    // Blue player (minimize)
    minMax = (a, b) => a.getScore().isBiggerThan(b.getScore()) ? b : a
  } else {
    // Red player (maximize)
    minMax = (a, b) => a.getScore().isBiggerThan(b.getScore()) ? a : b
  }
  
  const nextInstances = this.nextInstances.values().toArray();
  const leadingInstance = nextInstances.reduce(minMax);
  let newScore = Score.fromRaw(leadingInstance.getScore().raw());
  
  if (newScore.isWinCountdown) {
    // Add 0.5 moves to countdown
    newScore.score += newScore.score > 0 ? 0.5 : -0.5;
  }
  
  this.grid = empty_array; // Clear grid to save memory
  this.completedBranch = leadingInstance.completedBranch;
  
  if (!currentScore.isEqualTo(newScore)) {
    this.score = newScore;
    this.leadingInstance = leadingInstance;
    this.updatePreviousInstances();
  }
}

Score Class

Location: src/app/hex/[gameId]/Algorithm.ts:783 Represents position evaluation with special handling for forced wins.
class Score {
  public score: number;
  public isWinCountdown: boolean;
  
  constructor(score: number, isWinCountdown: boolean) {
    this.score = score;
    this.isWinCountdown = isWinCountdown;
  }
}

Score Semantics

When isWinCountdown = false, the score represents a heuristic evaluation:
  • Positive values: Red (player 1) is winning
  • Negative values: Blue (player 2) is winning
  • Magnitude: Distance from victory (lower = better for that player)
return new Score(player2Score[0] - player1Score[0], false);

Position Evaluation Heuristic

Location: src/app/hex/[gameId]/Algorithm.ts:526 The basicHeuristic function evaluates positions using a sophisticated pathfinding algorithm.

Algorithm Overview

1

Calculate path costs

For each player, compute minimum cost to connect their edges using Dijkstra-style search.
2

Detect forced wins

If cost = 0, the player has already won. Return win countdown score.
3

Compute differential

Return player2_cost - player1_cost as heuristic evaluation.

Path Cost Calculation

function attributeScore(grid: number[][], player: number): [number, number] {
  let size = grid.length;
  let minCost = grid.length ** 2;
  let minBridges = grid.length ** 2;
  let hexagonsToCheck: Array<CheckableHexagon> = [];
  let checkedHexagons: Array<Coordinate> = [];
  
  // Initialize starting edge
  if (player === 1) { // Red: left edge
    for (let i = 0; i < grid.length; i++) {
      if (grid[i][0] === 1) {
        hexagonsToCheck.push([[i, 0], 0, 0]);
      } else if (grid[i][0] === 0) {
        // Empty cell costs 1, or 0 if bridge exists
        hexagonsToCheck.push([[i, 0], detectBridgeCost(i, 0), bridges]);
      }
    }
  }
  // ... (similar for player 2: top edge)
  
  // Dijkstra-style search
  while (hexagonsToCheck.length > 0) {
    hexagonsToCheck.sort((a, b) => b[1] - a[1]);
    let hexagon = hexagonsToCheck.pop()!;
    checkedHexagons.push(hexagon[0]);
    
    let surroundingHexagons = getSurroundingHexagons(size, hexagon[0][0], hexagon[0][1]);
    let freeHexagons = filterAntiHexagons(grid, surroundingHexagons, 3 - player);
    
    for (let surroundingHexagon of freeHexagons) {
      let cost = calculateMoveCost(hexagon, surroundingHexagon, grid, player);
      let bridges = isBridge ? hexagon[2] + 1 : hexagon[2];
      
      if (boundCheck === size - 1) { // Reached target edge
        if (cost < minCost) {
          minCost = cost;
          minBridges = bridges;
        }
      } else {
        hexagonsToCheck.push([surroundingHexagon, cost, bridges]);
      }
    }
  }
  
  return [minCost, minBridges];
}

Bridge Detection

A “bridge” is a connection pattern that allows jumping over opponent pieces without additional cost.
function getInnerBridgeHexagons(hex1: Coordinate, hex2: Coordinate): Array<Coordinate> {
  let diffY = hex2[0] - hex1[0];
  let diffX = hex2[1] - hex1[1];
  
  // Example: jump from (y,x) to (y+1,x+1) requires both (y,x+1) and (y+1,x) empty
  if (diffX === 1 && diffY === 1) {
    return [[y, x - 1], [y - 1, x]];
  }
  // ... (6 possible bridge patterns)
}

Explorer and Web Workers

Location: src/app/hex/[gameId]/Algorithm.ts:281 The Explorer class orchestrates parallel game tree search using Web Workers.

Explorer Architecture

class Explorer {
  private game: ScoredGameInstance;
  private exploredBoardCount: number = 0;
  private heuristic: Function;
  private updateCallback: Function;
  private mainInstances: Array<MainScoredGameInstance>;
  private worker: Worker;
  private instances: Map<GridHash, ScoredGameInstance>;
  private bestMoves: Array<MainScoredGameInstance>;
  
  constructor(grid: Array<Array<number>>, turn: number, heuristic: Function, updateCallback: Function) {
    let simpleGame = new SimpleGameInstance(grid, turn, []);
    this.worker = new Worker(new URL("AlgorithmWorker.ts", import.meta.url));
    this.worker.addEventListener("message", this._workerCallback.bind(this));
    
    this.game = this.rateInstance(simpleGame);
    this.instances = new Map<GridHash, ScoredGameInstance>();
    this.instances.set(this.game.getGridHash(), this.game);
    
    this.mainInstances = this.populateMainInstances();
    this.sendNextInstanceToWorker();
  }
}

Exploration Strategy

1

Initialize root position

Create ScoredGameInstance from current board state and evaluate with heuristic.
2

Populate main instances

Generate all first-level moves as MainScoredGameInstance objects (special class that reports back to UI).
3

Explore second level

For each main instance, explore opponent replies to depth 2.
4

Send to worker

Identify best incomplete branch and send to Web Worker for deep exploration (2 more levels).
5

Process worker results

Receive explored game tree, reconstruct instance graph, propagate scores upward.
6

Update UI

Return top 10 moves with their scores and optimal continuations.
7

Repeat

Continue sending next best incomplete branch until tree is fully explored.

Worker Communication

Worker Code: src/app/hex/[gameId]/AlgorithmWorker.ts
// AlgorithmWorker.ts
self.addEventListener("message", (event) => {
  let packet = event.data as AlgorithmWorkerBoundGenericPacket;
  
  if (packet.type === AlgorithmWorkerBoundPacketType.EXPLORE_INSTANCE) {
    const game = ScoredGameInstance.fromRaw(packet.game);
    const resultMap = new Map<GridHash, RawScoredGameInstance>();
    const leaves: Array<GridHash> = [];
    
    // Explore 2 levels deep
    let nextInstances = explore(game);
    for (let nextInstance of nextInstances.values()) {
      resultMap.set(nextInstance.getGridHash(), nextInstance.raw());
      
      let furtherInstances = explore(nextInstance);
      for (let furtherInstance of furtherInstances.values()) {
        resultMap.set(furtherInstance.getGridHash(), furtherInstance.raw());
        leaves.push(furtherInstance.getGridHash());
      }
    }
    
    postMessage({
      type: AlgorithmExplorerBoundPacketType.RESULT,
      id: packet.id,
      result: { gameInstances: resultMap, leaves: leaves }
    });
  }
});

Performance Monitoring

// From Algorithm.ts:451
private _workerCallback(event: MessageEvent) {
  const currentTime = Date.now();
  this.exploredBoardCount += 1;
  
  if (currentTime - this.startTime > 5000) {
    let boardsPerSecond = Math.round(
      (this.exploredBoardCount * 1000) / (currentTime - this.startTime)
    );
    console.log(`Explored ${this.exploredBoardCount} boards in ${currentTime - this.startTime} ms (${boardsPerSecond} boards/s)`);
    this.startTime = currentTime;
    this.exploredBoardCount = 0;
  }
}
On modern hardware, BeeHex can explore 10,000+ positions per second for 9x9 boards.
interface RecommendedMove {
  coordinate: Coordinate;          // [y, x] of suggested move
  score: Score;                     // Evaluation of this move
  optimalRoute: Array<Coordinate>;  // Best continuation from this position
}
Example output from Explorer:
[
  {
    "coordinate": [4, 4],
    "score": { "score": -2, "isWinCountdown": false },
    "optimalRoute": [[4, 4], [3, 5], [5, 3], [4, 6]]
  },
  {
    "coordinate": [2, 3],
    "score": { "score": -1, "isWinCountdown": false },
    "optimalRoute": [[2, 3], [3, 2], [1, 4]]
  }
]

Next Steps

WebSocket Protocol

Learn about the multiplayer communication protocol and packet specifications