Creating Custom Games with Shoehive
This guide will walk you through the process of building a custom game using the Shoehive framework.
Overview
Creating a game with Shoehive involves:
- Defining your game state and rules
- Registering your game with the GameManager
- Handling player actions via command handlers
- Creating game-specific event constants
- Handling game events
- Resetting and restarting games
Step 1: Define Your Game
First, you need to define your game by creating a GameDefinition
:
import { GameDefinition, Table, EventBus } from 'shoehive';
// Create game definition
const ticTacToeGame: GameDefinition = {
id: "tic-tac-toe",
name: "Tic-Tac-Toe",
description: "Classic two-player game of X and O",
minPlayers: 2,
maxPlayers: 2,
defaultSeats: 2,
maxSeatsPerPlayer: 1,
// Setup function should be included in the options object
options: {
// This function will be called when a new table is created
setupTable: (table: Table) => {
// Initialize game-specific state
table.setAttribute('board', [
[null, null, null],
[null, null, null],
[null, null, null]
]);
table.setAttribute('currentPlayer', null);
table.setAttribute('winner', null);
table.setAttribute('gameOver', false);
}
}
};
Step 2: Register Your Game
Register your game with the GameManager:
import { createGameServer } from 'shoehive';
import * as http from 'http';
// Create server
const server = http.createServer();
const gameServer = createGameServer(server);
// Register your game
gameServer.gameManager.registerGame(ticTacToeGame);
// Start the server
server.listen(3000, () => {
console.log('Game server running on port 3000');
});
Step 3: Handle Player Actions
Set up command handlers for game-specific actions:
// Handle the "makeMove" command
gameServer.messageRouter.registerCommandHandler('makeMove', (player, data) => {
// Validate input
if (!data.row || !data.col ||
typeof data.row !== 'number' ||
typeof data.col !== 'number') {
return;
}
const table = player.getTable();
if (!table) return;
// Make sure it's a valid move and the player's turn
if (!isValidMove(table, player, data.row, data.col)) {
player.sendMessage({
type: 'error',
message: 'Invalid move'
});
return;
}
// Update the game state
makeMove(table, player, data.row, data.col);
// Check for game over conditions
checkGameState(table);
});
// Helper functions
function isValidMove(table: Table, player: Player, row: number, col: number): boolean {
const board = table.getAttribute('board');
const currentPlayerId = table.getAttribute('currentPlayer');
const gameOver = table.getAttribute('gameOver');
// Check if the game is already over
if (gameOver) return false;
// Check if it's the player's turn
if (currentPlayerId !== player.id) return false;
// Check if the move is within bounds
if (row < 0 || row > 2 || col < 0 || col > 2) return false;
// Check if the cell is empty
if (board[row][col] !== null) return false;
return true;
}
function makeMove(table: Table, player: Player, row: number, col: number): void {
const board = table.getAttribute('board');
const players = table.getSeatMap();
// Determine player symbol (X for player 0, O for player 1)
const playerIndex = players.findIndex(p => p && p.id === player.id);
const symbol = playerIndex === 0 ? 'X' : 'O';
// Update the board
board[row][col] = symbol;
table.setAttribute('board', board);
// Update the current player (switch turns)
const nextPlayerIndex = (playerIndex + 1) % 2;
const nextPlayer = players[nextPlayerIndex];
table.setAttribute('currentPlayer', nextPlayer ? nextPlayer.id : null);
// Broadcast the updated board to all players
table.broadcastToAll({
type: 'boardUpdate',
board: board,
lastMove: { row, col, symbol, playerId: player.id },
currentPlayer: nextPlayer ? nextPlayer.id : null
});
}
function checkGameState(table: Table): void {
const board = table.getAttribute('board');
// Check rows, columns, and diagonals for a win
const winner = checkWinner(board);
if (winner) {
// We have a winner
table.setAttribute('winner', winner);
table.setAttribute('gameOver', true);
table.setState('ENDED');
// Broadcast game over
table.broadcastToAll({
type: 'gameOver',
winner: winner,
board: board
});
// Emit event for game end
gameServer.eventBus.emit(GAME_EVENTS.ENDED, table, winner);
} else if (isBoardFull(board)) {
// It's a draw
table.setAttribute('gameOver', true);
table.setState('ENDED');
// Broadcast game over
table.broadcastToAll({
type: 'gameOver',
winner: null,
isDraw: true,
board: board
});
// Emit event for game end
gameServer.eventBus.emit(GAME_EVENTS.ENDED, table, null);
}
}
function checkWinner(board): string | null {
// Check rows
for (let i = 0; i < 3; i++) {
if (board[i][0] && board[i][0] === board[i][1] && board[i][0] === board[i][2]) {
return board[i][0];
}
}
// Check columns
for (let i = 0; i < 3; i++) {
if (board[0][i] && board[0][i] === board[1][i] && board[0][i] === board[2][i]) {
return board[0][i];
}
}
// Check diagonals
if (board[0][0] && board[0][0] === board[1][1] && board[0][0] === board[2][2]) {
return board[0][0];
}
if (board[0][2] && board[0][2] === board[1][1] && board[0][2] === board[2][0]) {
return board[0][2];
}
return null;
}
function isBoardFull(board): boolean {
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (board[i][j] === null) {
return false;
}
}
}
return true;
}
Step 4: Create Game-Specific Event Constants
For a cleaner, more maintainable codebase, create game-specific event constants:
import { EventType } from 'shoehive';
// Define your game-specific events
export const TIC_TAC_TOE_EVENTS = {
MOVE_MADE: "tictactoe:move:made",
GAME_STARTED: "tictactoe:game:started",
GAME_ENDED: "tictactoe:game:ended",
PLAYER_FORFEITED: "tictactoe:player:forfeited",
GAME_RESET: "tictactoe:game:reset"
} as const;
// Create a type for your events
export type TicTacToeEventType = typeof TIC_TAC_TOE_EVENTS[keyof typeof TIC_TAC_TOE_EVENTS];
// Now you can use them with the EventBus
function makeMove(table: Table, player: Player, row: number, col: number): void {
// ... existing code ...
// Emit a custom event when a move is made
gameServer.eventBus.emit(TIC_TAC_TOE_EVENTS.MOVE_MADE, table, player, {
row,
col,
symbol
});
}
Step 5: Handle Game Events
Set up event listeners for game-specific events:
import { PLAYER_EVENTS, TABLE_EVENTS, GAME_EVENTS } from 'shoehive';
import { TIC_TAC_TOE_EVENTS } from './tic-tac-toe-events';
// Handle when players join the table
gameServer.eventBus.on(TABLE_EVENTS.PLAYER_JOINED, (player, table) => {
// Only interested in Tic-Tac-Toe tables
if (table.getAttribute('gameId') !== 'tic-tac-toe') return;
// Get the current seat map
const players = table.getSeatMap().filter(p => p !== null);
// If we have exactly 2 players and the game hasn't started yet
if (players.length === 2 && table.getState() === 'WAITING') {
// Set the first player as the current player
table.setAttribute('currentPlayer', players[0].id);
// Set the table state to active
table.setState('ACTIVE');
// Broadcast game start
table.broadcastToAll({
type: 'gameStart',
board: table.getAttribute('board'),
currentPlayer: players[0].id,
players: players.map(p => ({ id: p.id, symbol: players.indexOf(p) === 0 ? 'X' : 'O' }))
});
// Emit event for game start
gameServer.eventBus.emit(TIC_TAC_TOE_EVENTS.GAME_STARTED, table, {
startTime: Date.now(),
players: players.map(p => ({ id: p.id }))
});
}
});
// Handle when players leave the table
gameServer.eventBus.on(TABLE_EVENTS.PLAYER_LEFT, (player, table) => {
// Only interested in Tic-Tac-Toe tables
if (table.getAttribute('gameId') !== 'tic-tac-toe') return;
// If the game is active, consider it a forfeit
if (table.getState() === 'ACTIVE') {
// Get the other player
const players = table.getPlayers();
const otherPlayer = players.find(p => p.id !== player.id);
if (otherPlayer) {
// Set the other player as the winner
table.setAttribute('winner', otherPlayer.id);
table.setAttribute('gameOver', true);
table.setState('ENDED');
// Broadcast game over
table.broadcastToAll({
type: 'gameOver',
winner: otherPlayer.id,
reason: 'forfeit',
board: table.getAttribute('board')
});
// Emit event for player forfeit and game end
gameServer.eventBus.emit(TIC_TAC_TOE_EVENTS.PLAYER_FORFEITED, table, player, otherPlayer);
gameServer.eventBus.emit(TIC_TAC_TOE_EVENTS.GAME_ENDED, table, otherPlayer.id);
}
}
});
// Listen for move made events
gameServer.eventBus.on(TIC_TAC_TOE_EVENTS.MOVE_MADE, (table, player, moveData) => {
// This is a good place to log moves or update statistics
console.log(`Player ${player.id} made a move: ${moveData.symbol} at [${moveData.row}, ${moveData.col}]`);
});
// Listen for table empty event to clean up resources
gameServer.eventBus.on(TABLE_EVENTS.EMPTY, (table) => {
// Only interested in Tic-Tac-Toe tables
if (table.getAttribute('gameId') !== 'tic-tac-toe') return;
// Clean up any timers or other resources
console.log(`Tic-Tac-Toe table ${table.id} was removed`);
});
Step 6: Use Debug Monitoring During Development
Shoehive’s EventBus includes a debug monitor to help you track and analyze events during development:
// Enable debug monitoring at the start of your game server
if (process.env.NODE_ENV === 'development') {
// Monitor all events
gameServer.eventBus.debugMonitor(true);
// Or monitor only TicTacToe events
gameServer.eventBus.debugMonitor(
true,
(eventName) => eventName.startsWith('tictactoe:'),
(event, ...args) => {
console.log(`[TicTacToe Event] ${event}`, JSON.stringify(args, null, 2));
}
);
}
Step 7: Reset and Restart Games
Add functionality to reset or restart games:
// Handle the "resetGame" command
gameServer.messageRouter.registerCommandHandler('resetGame', (player, data) => {
const table = player.getTable();
if (!table) return;
// Only allow reset if the game is over
if (!table.getAttribute('gameOver')) {
player.sendMessage({
type: 'error',
message: 'Cannot reset an active game'
});
return;
}
// Reset the game state
resetGame(table);
// Broadcast the reset
table.broadcastToAll({
type: 'gameReset',
board: table.getAttribute('board')
});
// Emit game reset event
gameServer.eventBus.emit(TIC_TAC_TOE_EVENTS.GAME_RESET, table);
});
function resetGame(table: Table): void {
// Reset the board
table.setAttribute('board', [
[null, null, null],
[null, null, null],
[null, null, null]
]);
// Reset game state
table.setAttribute('winner', null);
table.setAttribute('gameOver', false);
// Get the players and set the starting player
const players = table.getSeatMap().filter(p => p !== null);
if (players.length === 2) {
// Alternate who goes first
const lastStarterId = table.getAttribute('lastStarter');
const newStarterIndex = lastStarterId === players[0].id ? 1 : 0;
table.setAttribute('currentPlayer', players[newStarterIndex].id);
table.setAttribute('lastStarter', players[newStarterIndex].id);
// Set table state to active
table.setState('ACTIVE');
} else {
// Not enough players, wait
table.setAttribute('currentPlayer', null);
table.setState('WAITING');
}
}
Best Practices
When creating games with Shoehive, keep these best practices in mind:
- Use event constants: Always use event constants from
EventTypes.ts
for built-in events and define your own constants for game-specific events - Follow naming conventions: Use the
domain:action
pattern for your custom events (e.g.,tictactoe:move:made
) - Use debug monitoring: Enable
eventBus.debugMonitor()
during development to track and debug events - Separate concerns: Keep game logic, state management, and UI separate
- Validate inputs: Always validate player inputs on the server side
- Prevent cheating: Don’t trust client data for game state
- Handle disconnections: Have a strategy for when players disconnect
- Implement reconnection: Allow players to reconnect and continue playing
- Document events: Create clear documentation of your custom events and their payloads
- Error handling: Provide meaningful error messages to players
Next Steps
- Learn about using Transport Modules for authentication and transactions
- Explore Advanced Event Patterns for complex games