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 ());
}
}
}
Interface Compatibility
Instant Connection
Local Packet Processing
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. Unlike WebSocket connections, offline handler connection is synchronous: src/app/hex/[gameId]/OfflineHandler.ts
awaitConnection () {
return new Promise < OfflineHandler >(( resolve , reject ) => {
resolve ( this ); // Immediately ready
});
}
No network handshake or authentication required. Packets are processed instantly without network latency: src/app/hex/[gameId]/OfflineHandler.ts
handlePacket ( packet : packets . ClientBoundGenericPacket ) {
switch ( packet . type ) {
case packets . ClientBoundPacketType . MOVE_PLAYED :
const movePlayedPacket = packet as packets . ClientBoundMovePlayedPacket ;
this . callbacks . movePlayedCallback (
movePlayedPacket . x ,
movePlayedPacket . y ,
movePlayedPacket . turn ,
movePlayedPacket . grid_array
);
break ;
case packets . ClientBoundPacketType . GAME_END :
const gameEndPacket = packet as packets . ClientBoundGameEndPacket ;
this . callbacks . gameEndCallback (
gameEndPacket . status ,
gameEndPacket . moves ,
gameEndPacket . winningHexagons
);
break ;
}
}
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 ++ ;
}
Validation
Check if the selected cell is empty. Invalid moves are silently rejected.
Update Grid
Place the current player’s stone (1 for odd turns, 2 for even turns).
Notify UI
Send a MOVE_PLAYED packet to update the visual board.
Record Move
Add the move to the history for later export.
Check Win
Run win detection from the newly placed stone.
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 (Red) Win Condition
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 (Blue) Win Condition
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" }
});
}
Create Handler
Instantiate OfflineHandler with game parameters and callbacks.
Request Game
Send JOIN_GAME packet (processed locally, no network request).
Initialize Board
The joinGameCallback receives the initial empty board and sets up the UI.
Set Player Names
Display generic “Joueur 1” and “Joueur 2” labels.
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:
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
Network
Game State
Features
Use Cases
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”
Online:
Server is source of truth
Client must stay synchronized
Server validates all moves
Game persisted to database
Offline:
Client is source of truth
No synchronization needed
Client validates all moves
Game lost when browser closes
Online:
MMR tracking
Ranked games
Game history
Spectator support
Private rooms
Offline:
Hot-seat multiplayer
Instant access
Move export
Full review mode
AI analysis
Online:
Competitive play
Playing with friends remotely
Ranked ladder climbing
Tournament games
Offline:
Practice and learning
Strategy testing
Local multiplayer
No internet scenarios
Quick casual games
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:
Interface Segregation : Same WebsocketCallbacks interface for online and offline
Dependency Injection : Callbacks passed to constructor
Single Responsibility : GameInstance handles logic, OfflineHandler handles communication
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