Files
mtgonline/backend/docs/WEB_SOCKET_ARCHITECTURE.md
T

12 KiB

WebSocket Game Server Architecture

Overview

The MTG Online WebSocket game server provides real-time game state synchronization and player action handling for multiplayer Magic: The Gathering sessions. This document defines the architecture, protocol, and message formats.

Architecture

┌─────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   Player 1  │────▶│   WebSocket     │────▶│   Game Server   │
│   Browser   │◀────│   Connection    │◀────│   (asyncio)     │
└─────────────┘     └─────────────────┘     └────────┬────────┘
                                                     │
┌─────────────┐     ┌─────────────────┐     ┌────────┴────────┐
│   Player 2  │────▶│   WebSocket     │────▶│   Game State    │
│   Browser   │◀────│   Connection    │◀────│   Manager       │
└─────────────┘     └─────────────────┘     └────────┬────────┘
                                                     │
┌─────────────┐     ┌─────────────────┐     ┌────────┴────────┐
│   Player 3  │────▶│   WebSocket     │────▶│   PostgreSQL    │
│   Browser   │◀────│   Connection    │◀────│   (Persistence) │
└─────────────┘     └─────────────────┘     └─────────────────┘

Components

  1. WebSocket Endpoint (/ws/game/{game_id})

    • Accepts connections
    • Authenticates players
    • Routes messages to/from game instances
  2. Game Server Manager

    • Manages active game instances
    • Handles game lifecycle (create, join, start, end)
    • Coordinates between players
  3. Game State Manager

    • Maintains authoritative game state
    • Validates player actions
    • Emits state changes to all players
  4. Persistence Layer

    • Saves game state to PostgreSQL
    • Enables game replay and recovery

Protocol Design

Message Format

All messages use JSON with the following envelope:

{
  "type": "message_type",
  "seq": 123,
  "ts": 1697750000.123,
  "data": {
    // Message-specific payload
  }
}
  • type: Message type identifier
  • seq: Sequence number for ordering and gap detection
  • ts: Unix timestamp (milliseconds)
  • data: Message-specific payload

Message Types

Connection Management

Type Direction Description
hello Client → Server Initial connection with auth token
welcome Server → Client Connection established, game info
bye Client → Server Player disconnecting
ping Either Keepalive
pong Either Keepalive response

Game Lifecycle

Type Direction Description
game_start Server → Client Game starting, initial state
game_state Server → Client Full game state snapshot
state_update Server → Client Incremental state change
game_over Server → Client Game ended with results

Player Actions

Type Direction Description
action Client → Server Player takes an action
action_ack Server → Client Action acknowledged
action_error Server → Client Action failed validation

Turn Management

Type Direction Description
turn_start Server → Client New turn beginning
phase_change Server → Client Phase transition
priority Server → Client Player has priority
stack_empty Server → Client Stack is empty

Chat

Type Direction Description
chat Either Chat message
chat_history Server → Client Chat history on join

Authentication

Players authenticate via JWT token in the hello message:

{
  "type": "hello",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "game_id": 42,
    "seat": 1
  }
}

Game State Model

Full State Snapshot

{
  "game_id": 42,
  "room_id": 5,
  "game_type": "Commander",
  "state": "IN_PROGRESS",
  "turn": 3,
  "phase": "main1",
  "players": [
    {
      "player_id": 1,
      "user_id": 101,
      "seat": 1,
      "life_total": 18,
      "hand_size": 5,
      "library_size": 95,
      "graveyard_size": 3,
      "is_active": true,
      "has_priority": true
    },
    {
      "player_id": 2,
      "user_id": 102,
      "seat": 2,
      "life_total": 20,
      "hand_size": 6,
      "library_size": 94,
      "graveyard_size": 2,
      "is_active": false,
      "has_priority": false
    }
  ],
  "battlefield": {
    "player_1": [
      {
        "card_id": "card_123",
        "name": "Lightning Bolt",
        "type": "Creature",
        "power": 2,
        "toughness": 2,
        "is_tapped": false,
        "is_attacking": false,
        "damage": 0
      }
    ],
    "player_2": []
  },
  "stack": [],
  "graveyard": {
    "player_1": [],
    "player_2": []
  }
}

Incremental State Update

{
  "type": "state_update",
  "seq": 124,
  "data": {
    "changes": [
      {
        "player_id": 1,
        "field": "life_total",
        "old_value": 20,
        "new_value": 18
      },
      {
        "player_id": 1,
        "field": "hand_size",
        "old_value": 6,
        "new_value": 5
      }
    ],
    "reason": "played_lightning_bolt"
  }
}

Player Action Protocol

Action Submission

{
  "type": "action",
  "seq": 125,
  "data": {
    "action_type": "play_card",
    "card_id": "card_456",
    "target": null,
    "mode": "attack"
  }
}

Action Types

Action Type Description Required Fields
play_card Play a card from hand card_id, target (optional)
tap_card Tap a card card_id
untap_card Untap a card card_id
attack Declare attacker card_id, target_player
block Declare blocker card_id, target_card_id
assign_damage Assign combat damage card_id, target_card_id, damage
pass_priority Pass priority -
cast_spell Cast a spell card_id, target (optional)
activate_ability Activate ability card_id, ability_id, target (optional)
end_turn End current turn -
concede Concede the game -

Action Validation

The server validates all actions against the current game state:

  1. Turn/Phase Check: Is this action legal in the current phase?
  2. Priority Check: Does the player have priority?
  3. Card State Check: Is the card in the correct zone?
  4. Target Check: Is the target valid?
  5. Cost Check: Can the player pay the cost?

Turn and Phase Management

Turn Structure

Turn Start
  └─ Untap Phase
       └─ Untap all permanents
  └─ Upkeep Phase
       └─ Upkeep costs
  └─ Draw Phase
       └─ Draw a card
  └─ Main Phase 1
       └─ Play lands, creatures, instants
  └─ Combat Phase
       └─ Beginning of Combat
       └─ Declare Attackers
       └─ Declare Blockers
       └─ Combat Damage
       └─ End of Combat
  └─ Main Phase 2
       └─ Play lands, creatures, instants
  └─ End Phase
       └─ Cleanup
Turn End

Priority System

The server tracks priority and only accepts actions from the player with priority:

{
  "type": "priority",
  "data": {
    "player_id": 1,
    "phase": "main1",
    "stack_size": 0
  }
}

Error Handling

Action Errors

{
  "type": "action_error",
  "seq": 126,
  "data": {
    "action_seq": 125,
    "error_code": "INVALID_TARGET",
    "error_message": "Target player is not in the game",
    "action_type": "play_card"
  }
}

Error Codes

Code Description
INVALID_ACTION Action not legal in current phase
NO_PRIORITY Player does not have priority
INVALID_TARGET Target is not valid
CARD_NOT_IN_HAND Card not in player's hand
CARD_NOT_ON_BATTLEFIELD Card not on battlefield
CANT_PAY_COST Player cannot pay the cost
STACK_NOT_EMPTY Stack is not empty
GAME_OVER Game is already over

Reconnection and Recovery

Reconnection Protocol

When a player reconnects:

  1. Client sends hello with same game_id and seat
  2. Server validates player is still in the game
  3. Server sends welcome with current game state
  4. Server sends chat_history for the session
  5. Player resumes play

State Recovery

If the server crashes:

  1. Game state is loaded from PostgreSQL
  2. Players are notified to reconnect
  3. Game resumes from last saved state

Concurrency Model

asyncio-based Architecture

class GameServer:
    def __init__(self):
        self.games: Dict[int, GameInstance] = {}
        self.player_connections: Dict[int, WebSocket] = {}

    async def handle_connection(self, websocket, game_id):
        """Handle a single WebSocket connection."""
        pass

    async def game_loop(self, game_id):
        """Main game loop for a game instance."""
        pass

class GameInstance:
    def __init__(self, game_id, players):
        self.game_id = game_id
        self.players = players
        self.state = GameState()
        self.lock = asyncio.Lock()

    async def process_action(self, player_id, action):
        """Process a player action with locking."""
        async with self.lock:
            return self.state.apply_action(player_id, action)

Locking Strategy

  • Game-level lock: Protects game state mutations
  • Action queue: Orders actions within a turn
  • Phase transitions: Synchronized across all players

Performance Considerations

Message Size

  • Full state snapshots: ~5-10 KB
  • Incremental updates: ~100-500 bytes
  • Chat messages: ~50-200 bytes

Latency Targets

  • Action acknowledgment: < 100ms
  • State update delivery: < 200ms
  • Turn transition: < 500ms

Scaling

  • Single server: ~50 concurrent games
  • Multiple servers: Use Redis pub/sub for cross-server communication
  • Database: PostgreSQL for persistence, Redis for caching

Security

Message Validation

  • All messages validated against schema
  • Sequence numbers prevent replay attacks
  • JWT tokens expire after 1 hour
  • Rate limiting on actions per second

Data Integrity

  • Server is authoritative
  • Client cannot modify game state directly
  • All actions validated before application

Implementation Plan

Phase 1: Basic Infrastructure

  • WebSocket endpoint setup
  • Connection management
  • Authentication
  • Basic message routing

Phase 2: Game State Management

  • Game state model
  • State serialization
  • Full state snapshots
  • Incremental updates

Phase 3: Turn and Phase Management

  • Turn structure implementation
  • Phase transitions
  • Priority system
  • Action validation

Phase 4: Player Actions

  • Card play actions
  • Combat actions
  • Spell casting
  • Ability activation

Phase 5: Persistence and Recovery

  • Game state persistence
  • Reconnection handling
  • Game recovery
  • Chat history

Phase 6: Testing and Optimization

  • Unit tests
  • Integration tests
  • Load testing
  • Performance optimization

References