Skip to main content
BeeHex’s offline mode provides a complete local implementation of the Hex game, allowing you to play without any server connection. All game logic runs in the browser with full support for move validation, win detection, and game state management.

Overview

Offline mode simulates the multiplayer experience entirely client-side, making it perfect for practice, testing strategies, or playing when network connectivity is unavailable.

Zero Network

Complete game logic runs locally in the browser

Full Rules

All standard Hex rules and win conditions enforced

Hot Seat Play

Two players can alternate turns on the same device

Game Export

Export move sequences for later analysis

Starting an Offline Game

Offline games are initiated from the game mode selection page:
src/app/game_mode/page.tsx
if (gameType === "Hors-Ligne") {
  window.location.href = `/hex/l_${timeLimit.toString()}_${boardSize.toString()}`
}
The URL format is l_{time_limit}_{board_size}, where:
  • l_ prefix indicates local/offline mode
  • time_limit: Time control setting (currently 0 for unlimited)
  • board_size: Board dimension (5, 7, or 9)
Example: /hex/l_0_9 creates an offline 9x9 game with unlimited time.

OfflineHandler Architecture

The OfflineHandler class mimics the WebSocket handler interface while running entirely locally:
src/app/hex/[gameId]/OfflineHandler.ts
export class OfflineHandler {
  private game: GameInstance
  private callbacks: WebsocketCallbacks;

  constructor(callbacks: WebsocketCallbacks, gameParameters: packets.LocalGameParameters) {
    this.callbacks = callbacks;
    this.game = new GameInstance(this, "offline", gameParameters, "1", "1");
  }

  awaitConnection() {
    return new Promise<OfflineHandler>((resolve, reject) => {
      resolve(this);
    });
  }

  sendPacket(packet: packets.ServerBoundGenericPacket) {
    if (packet.type === packets.ServerBoundPacketType.PLAY_MOVE) {
      const playMovePacket = packet as packets.ServerBoundPlayMovePacket;
      this.game.playMove(playMovePacket.x, playMovePacket.y);
    }
    if (packet.type === packets.ServerBoundPacketType.FORFEIT_GAME) {
      if (this.game.turn % 2 === 0) {
        this.game.endGame(packets.GameStatus.SECOND_PLAYER_WIN);
      } else {
        this.game.endGame(packets.GameStatus.FIRST_PLAYER_WIN);
      }
    }
    if (packet.type === packets.ServerBoundPacketType.JOIN_GAME) {
      this.callbacks.joinGameCallback(this.game.exportGame());
    }
  }
}
The offline handler implements the same callback interface as the WebSocket handler:
src/app/hex/[gameId]/OfflineHandler.ts
interface WebsocketCallbacks {
  errorCallback: (message: string) => void;
  gameSearchCallback: (game_parameters: packets.LocalGameParameters, player_count: number, elo_range: [number, number]) => void;
  gameFoundCallback: (game_id: packets.GameId) => void;
  joinGameCallback: (game: packets.Game) => void;
  movePlayedCallback: (x: number, y: number, turn: number, grid_array: Array<Array<number>>) => void;
  gameEndCallback: (status: packets.GameStatus, moves: string, winningHexagons: Array<[number, number]>) => void;
  connectionEndedCallback: () => void;
}
This allows the same UI code to work for both online and offline games.

GameInstance Class

The core game logic is encapsulated in the GameInstance class:
src/app/hex/[gameId]/OfflineHandler.ts
class GameInstance implements packets.Game {
  private handler: OfflineHandler;
  public game_id: string;
  public game_parameters: packets.LocalGameParameters;
  public grid: Array<Array<number>>;
  public first_player_id: string;
  public second_player_id: string;
  private moves: Array<[number, number]> = [];
  public turn: number;

  constructor(handler: OfflineHandler, game_id: string, gameParameters: packets.LocalGameParameters, 
              firstPlayerId: string, secondPlayerId: string) {
    this.handler = handler;
    this.game_id = game_id;
    this.game_parameters = gameParameters;
    this.grid = Array.from(
      { length: gameParameters.board_size }, 
      () => Array(gameParameters.board_size).fill(0)
    );
    this.first_player_id = firstPlayerId;
    this.second_player_id = secondPlayerId;
    this.turn = 1;
  }
}

Grid Representation

The game board is represented as a 2D array:
  • 0: Empty cell
  • 1: Player 1 (Red) stone
  • 2: Player 2 (Blue) stone
src/app/hex/[gameId]/OfflineHandler.ts
this.grid = Array.from(
  { length: gameParameters.board_size }, 
  () => Array(gameParameters.board_size).fill(0)
);

Move Processing

When a player clicks a hexagon, the move is processed locally:
src/app/hex/[gameId]/OfflineHandler.ts
playMove(x: number, y: number) {
  if (this.grid[x][y] !== 0) return; // Cell already occupied
  
  this.grid[x][y] = this.turn % 2 === 0 ? 2 : 1;
  
  this.handler.handlePacket({
    type: packets.ClientBoundPacketType.MOVE_PLAYED, 
    x: x, 
    y: y, 
    turn: this.turn + 1,
    grid_array: [...this.grid]
  } as packets.ClientBoundMovePlayedPacket);
  
  this.moves.push([x, y]);
  
  let { winner, winningHexagons } = this.checkForWinnerXY(x, y);
  if (winner) {
    this.endGame(
      this.turn % 2 === 0 ? packets.GameStatus.SECOND_PLAYER_WIN : packets.GameStatus.FIRST_PLAYER_WIN, 
      winningHexagons
    );
  }
  
  this.turn++;
}
1

Validation

Check if the selected cell is empty. Invalid moves are silently rejected.
2

Update Grid

Place the current player’s stone (1 for odd turns, 2 for even turns).
3

Notify UI

Send a MOVE_PLAYED packet to update the visual board.
4

Record Move

Add the move to the history for later export.
5

Check Win

Run win detection from the newly placed stone.
6

Advance Turn

Increment turn counter for next player.

Win Detection

The offline mode implements a flood-fill algorithm to detect winning connections:
src/app/hex/[gameId]/OfflineHandler.ts
checkForWinnerXY(x: number, y: number) {
  let hexagonState = this.grid[x][y];
  if (hexagonState != 1 && hexagonState != 2) {
    console.warn("Check for winner called on hexagon of type " + hexagonState + ".");
    return { winner: false, winningHexagons: [] };
  }

  let hexagonsToCheck: Array<[number, number]> = [[x, y]];
  let checkedHexagons: Array<number> = [];
  let winningHexagons: Array<[number, number]> = [];
  let lowerBound = false;
  let higherBound = false;

  while (hexagonsToCheck.length > 0) {
    let hexagon = hexagonsToCheck.pop()!!;
    checkedHexagons.push(this.hashHexagon(hexagon));
    winningHexagons.push(hexagon);

    let ownSurroundingHexagons = this.filterHexagons(
      this.getSurroundingHexagons(hexagon[0], hexagon[1]), 
      hexagonState
    );

    for (let ownHexagon of ownSurroundingHexagons) {
      if (!checkedHexagons.includes(this.hashHexagon(ownHexagon))) {
        hexagonsToCheck.push(ownHexagon);
      }
    }

    if (hexagonState === 1) {
      if (hexagon[1] === 0) lowerBound = true;
      if (hexagon[1] === this.game_parameters.board_size - 1) higherBound = true;
    } else {
      if (hexagon[0] === 0) lowerBound = true;
      if (hexagon[0] === this.game_parameters.board_size - 1) higherBound = true;
    }

    if (lowerBound && higherBound) {
      return { winner: true, winningHexagons: winningHexagons };
    }
  }

  return { winner: false, winningHexagons: [] };
}

Win Detection Logic

Player 1 wins by connecting the left and right edges:
  • Left edge: hexagon[1] === 0
  • Right edge: hexagon[1] === board_size - 1
The algorithm uses flood-fill starting from the newly placed stone to find all connected stones of the same color. If any connected component touches both edges, Player 1 wins.
Player 2 wins by connecting the top and bottom edges:
  • Top edge: hexagon[0] === 0
  • Bottom edge: hexagon[0] === board_size - 1
The same flood-fill approach is used, checking for connections between opposite edges.
The algorithm explores all hexagons connected to the most recent move:
src/app/hex/[gameId]/OfflineHandler.ts
getSurroundingHexagons(x: number, y: number) {
  let surroundingHexagons: Array<[number, number]> = [];
  if (x > 0) surroundingHexagons.push([x - 1, y]);
  if (x < this.game_parameters.board_size - 1) surroundingHexagons.push([x + 1, y]);
  if (y > 0) surroundingHexagons.push([x, y - 1]);
  if (y < this.game_parameters.board_size - 1) surroundingHexagons.push([x, y + 1]);
  if (x > 0 && y < this.game_parameters.board_size - 1) surroundingHexagons.push([x - 1, y + 1]);
  if (x < this.game_parameters.board_size - 1 && y > 0) surroundingHexagons.push([x + 1, y - 1]);
  return surroundingHexagons;
}
Each hexagon has up to 6 neighbors in the hex grid.
The algorithm tracks all hexagons in the winning path:
src/app/hex/[gameId]/OfflineHandler.ts
let winningHexagons: Array<[number, number]> = [];
// ...
winningHexagons.push(hexagon);
This data is used to highlight the winning path in the UI (feature currently commented out in the codebase).

Game End Handling

When a win is detected or a player forfeits, the game ends:
src/app/hex/[gameId]/OfflineHandler.ts
endGame(status: packets.GameStatus, winningHexagons: Array<[number, number]> = []) {
  this.handler.handlePacket({
    type: packets.ClientBoundPacketType.GAME_END, 
    status: status, 
    moves: this.exportMoves(),
    winningHexagons: winningHexagons 
  } as packets.ClientBoundGameEndPacket);
}

Move Export

Games can be exported as a move sequence:
src/app/hex/[gameId]/OfflineHandler.ts
exportMoves(): string {
  let movesString = '';
  for (let move of this.moves) {
    movesString += this.hashXY(move[0], move[1]) + ' ';
  }
  return movesString.trim();
}

hashXY(x: number, y: number): number {
  const size = this.grid.length;
  return x + y * size;
}
Moves are encoded as single numbers: x + y * board_size
Example: On a 9x9 board, move at (3, 5) is encoded as 3 + 5 * 9 = 48

Initialization Flow

The offline game initialization follows these steps:
src/app/hex/[gameId]/page.tsx
async function localInitialize() {
  const ownId = "1";
  
  function joinGameCallback(game_details: Game) {
    setGameState(GameState.PLAYING);
    workingGameState = GameState.PLAYING;
    game = GameInstance.fromGame(game_details, ownId);
    setGameParametersState(game_details.game_parameters);
    setGrid(game_details.grid);
    showGrid();
  }

  function movePlayedCallback(x: number, y: number, turn: number, grid_array: Array<Array<number>>) {
    if (game) {
      game.updateGameState(grid_array, turn);
      setGrid(grid_array);
    }
  }

  function gameEndCallback(status: GameStatus, moves: string, winningHexagons: Array<[number, number]>) {
    console.log('Game ended', status, moves);
    setStoredMoves(moves.split(' ').map((move) => {
      const move_int = parseInt(move);
      const y = Math.floor(move_int / game!!.getGridArray().length);
      const x = move_int % game!!.getGridArray().length;
      return [x, y];
    }));
    setClickCallback(() => reviewClickCallback);
    setGameState(GameState.REVIEWING);
    workingGameState = GameState.REVIEWING;
  }

  function localClickCallback(i: number, j: number) {
    if (workingGameState === GameState.PLAYING && game && game.isValidMove(i, j)) {
      offlineHandler.sendPacket({
        type: ServerBoundPacketType.PLAY_MOVE,
        x: i,
        y: j
      } as ServerBoundPlayMovePacket);
    }
  }

  let offlineHandler = await new OfflineHandler({
    errorCallback,
    gameSearchCallback,
    gameFoundCallback,
    joinGameCallback,
    movePlayedCallback,
    gameEndCallback,
    connectionEndedCallback
  }, gameParameters!!).awaitConnection();

  offlineHandler.sendPacket({
    type: ServerBoundPacketType.JOIN_GAME,
    game_id: "offline"
  } as ServerBoundJoinGamePacket);
  
  setClickCallback(() => localClickCallback);
  setHoverCallback(() => localHoverCallback);
  setPlayers({
    player1: { name: "Joueur 1", timer: "X:XX" },
    player2: { name: "Joueur 2", timer: "X:XX" }
  });
}
1

Create Handler

Instantiate OfflineHandler with game parameters and callbacks.
2

Request Game

Send JOIN_GAME packet (processed locally, no network request).
3

Initialize Board

The joinGameCallback receives the initial empty board and sets up the UI.
4

Set Player Names

Display generic “Joueur 1” and “Joueur 2” labels.
5

Enable Interaction

Set click callback to allow both players to make moves.

Game Review Mode

After an offline game ends, it automatically enters review mode:
src/app/hex/[gameId]/page.tsx
function gameEndCallback(status: GameStatus, moves: string, winningHexagons: Array<[number, number]>) {
  setStoredMoves(moves.split(' ').map((move) => {
    const move_int = parseInt(move);
    const y = Math.floor(move_int / game!!.getGridArray().length);
    const x = move_int % game!!.getGridArray().length;
    return [x, y];
  }));
  setClickCallback(() => reviewClickCallback);
  setGameState(GameState.REVIEWING);
  workingGameState = GameState.REVIEWING;
}
In review mode, players can:
  • Use a slider to navigate through moves
  • Click cells to play alternate variations
  • View AI analysis for each position
  • See top move recommendations
Review mode works identically for both online and offline games, leveraging the same analysis engine.

Local Parameters

Offline games use simplified parameters:
src/app/definitions.ts
export interface LocalGameParameters {
  time_limit: number,
  board_size: number
}
Notably absent:
  • ranked: Offline games are never ranked
  • Player IDs: Both players use dummy ID “1”
  • Server-side state: No database persistence

Comparison: Online vs Offline

Online:
  • WebSocket connection to server
  • Network latency affects responsiveness
  • Requires authentication
  • Can reconnect after disconnect
Offline:
  • No network communication
  • Instant response to moves
  • No authentication needed
  • Connection always “ready”

Limitations

Offline mode has several limitations:
  • No Persistence: Games are lost when the page is closed
  • No Undo: Once a move is played, it cannot be taken back
  • No Server Validation: Potential for client-side manipulation
  • No Statistics: Games don’t count toward your record
  • Limited Timer: Time controls are not fully implemented

Future Enhancements

Potential improvements for offline mode:

Local Storage

Save games to browser localStorage for persistence

AI Opponent

Computer opponent using the analysis engine

Position Setup

Load custom positions for analysis

PGN Export

Export games in a standard notation format

Code Architecture Benefits

The offline handler demonstrates excellent software design:
  1. Interface Segregation: Same WebsocketCallbacks interface for online and offline
  2. Dependency Injection: Callbacks passed to constructor
  3. Single Responsibility: GameInstance handles logic, OfflineHandler handles communication
  4. Open/Closed Principle: Easy to extend with new game modes
src/app/hex/[gameId]/OfflineHandler.ts
// Both handlers implement the same interface
interface WebsocketCallbacks {
  // ... callback definitions ...
}

class WebsocketHandler {
  constructor(callbacks: WebsocketCallbacks) { /* ... */ }
}

class OfflineHandler {
  constructor(callbacks: WebsocketCallbacks, gameParameters: packets.LocalGameParameters) { /* ... */ }
}
This architecture makes it trivial to switch between online and offline modes without changing the UI code.

Next Steps

Multiplayer System

Compare with the online WebSocket implementation

AI Analysis

Use the analysis engine to improve your offline games