Turn-Based Games

Host controls game state. Players send move requests. Host validates and broadcasts.

When to Use

  • Chess, checkers, Go
  • Card games (Uno, poker, etc.)
  • Board games
  • Strategy games
  • Any game with discrete turns

The Pattern

import { connect } from '@watchtower-sdk/core'

const room = await connect('chess-game')

// Game state (host is source of truth)
let gameState = {
  board: createInitialBoard(),
  turn: null,
  winner: null
}

// Initialize game when both players join
room.on('join', (player) => {
  if (room.isHost && room.playerCount === 2) {
    // Assign colors and start
    gameState.turn = room.playerId // Host goes first
    broadcastState()
  }
})

// Host broadcasts authoritative state
function broadcastState() {
  if (room.isHost) {
    room.broadcast({ type: 'state', ...gameState })
  }
}

// Everyone receives state updates
room.on('message', (from, data) => {
  if (data.type === 'state') {
    gameState = data
    render()
  }
  
  // Host processes move requests
  if (data.type === 'move' && room.isHost) {
    handleMoveRequest(from, data)
  }
})

// Host validates and applies moves
function handleMoveRequest(playerId, move) {
  // Only process if it's their turn
  if (gameState.turn !== playerId) return
  
  // Validate the move
  if (!isValidMove(gameState.board, move.from, move.to)) return
  
  // Apply the move
  gameState.board = applyMove(gameState.board, move.from, move.to)
  
  // Check for winner
  if (checkWinner(gameState.board)) {
    gameState.winner = playerId
  } else {
    // Next player's turn
    gameState.turn = getOtherPlayer(playerId)
  }
  
  broadcastState()
}

// Players request moves (not apply directly)
function makeMove(from, to) {
  room.broadcast({ type: 'move', from, to })
}

Key Points

  • Host authority — only host modifies game state
  • Request/response — players send requests, host validates
  • Single source of truth — everyone syncs from host's state
  • Turn enforcement — host checks if it's your turn

Handling Host Disconnect

When the host leaves, a new host is assigned automatically. Transfer game state:

// Store state locally so new host can continue
let localGameState = null

room.on('message', (from, data) => {
  if (data.type === 'state') {
    localGameState = data
  }
})

// When you become host, broadcast current state
room.on('join', () => {
  if (room.isHost && localGameState) {
    gameState = localGameState
    broadcastState()
  }
})

Private Information (Card Games)

For games with hidden information, use direct messages:

// Host deals cards privately
function dealCards() {
  if (!room.isHost) return
  
  const deck = shuffleDeck()
  
  for (const player of room.players) {
    const hand = deck.splice(0, 5)
    room.send(player.id, { type: 'deal', hand })
  }
  
  // Broadcast public info only
  room.broadcast({ 
    type: 'state',
    playerCardCounts: getCardCounts(),
    deckRemaining: deck.length
  })
}

// Players receive their private hand
room.on('message', (from, data) => {
  if (data.type === 'deal') {
    myHand = data.hand
  }
})

Full Chess Example

import { connect } from '@watchtower-sdk/core'

const room = await connect()
let gameState = { board: initialBoard(), turn: null, winner: null }
let myColor = null

// Determine colors on join
room.on('join', (player) => {
  if (room.isHost) {
    if (room.playerCount === 1) {
      myColor = 'white'
    } else if (room.playerCount === 2) {
      gameState.turn = room.playerId
      room.broadcast({ type: 'state', ...gameState })
      room.send(player.id, { type: 'color', color: 'black' })
    }
  }
})

room.on('message', (from, data) => {
  switch (data.type) {
    case 'state':
      gameState = data
      renderBoard()
      break
    case 'color':
      myColor = data.color
      break
    case 'move':
      if (room.isHost) {
        if (gameState.turn === from && isValid(data.from, data.to)) {
          gameState.board = applyMove(gameState.board, data.from, data.to)
          gameState.turn = gameState.turn === room.playerId 
            ? room.players.find(p => p.id !== room.playerId).id 
            : room.playerId
          room.broadcast({ type: 'state', ...gameState })
        }
      }
      break
  }
})

function onSquareClick(square) {
  if (gameState.turn !== room.playerId) return // Not my turn
  if (selectedSquare) {
    room.broadcast({ type: 'move', from: selectedSquare, to: square })
    selectedSquare = null
  } else {
    selectedSquare = square
  }
}

Next Steps

  • Add move timers
  • Add undo/redo functionality
  • Add spectator support