Files
echoes-of-the-ash/old/WEBSOCKET_MIGRATION_PLAN.md
2025-11-27 16:27:01 +01:00

17 KiB

WebSocket Migration Plan

Difficulty Assessment: Medium ⚙️

Time estimate: 3-5 days for full implementation Complexity: Moderate - FastAPI has excellent WebSocket support


Architecture Changes

Current (Polling)

Client → HTTP GET every 5s → Server → Query DB → Return JSON
       ← HTTP Response ←

Future (WebSocket)

Client ←→ WebSocket (persistent) ←→ Server
                                    ↓
                                  DB Query only on changes
                                    ↓
                                  Push to client immediately

Implementation Steps

Phase 1: Backend WebSocket Setup (1-2 days)

1. Add WebSocket endpoint

File: api/main.py

from fastapi import WebSocket, WebSocketDisconnect
from typing import Dict
import asyncio
import json

# Connection manager to track active WebSocket connections
class ConnectionManager:
    def __init__(self):
        self.active_connections: Dict[int, WebSocket] = {}  # player_id -> websocket
    
    async def connect(self, websocket: WebSocket, player_id: int):
        await websocket.accept()
        self.active_connections[player_id] = websocket
        print(f"Player {player_id} connected via WebSocket")
    
    def disconnect(self, player_id: int):
        if player_id in self.active_connections:
            del self.active_connections[player_id]
            print(f"Player {player_id} disconnected")
    
    async def send_personal_message(self, player_id: int, message: dict):
        """Send message to specific player"""
        if player_id in self.active_connections:
            try:
                await self.active_connections[player_id].send_json(message)
            except Exception as e:
                print(f"Error sending to player {player_id}: {e}")
                self.disconnect(player_id)
    
    async def broadcast(self, message: dict, exclude: int = None):
        """Send message to all connected players"""
        disconnected = []
        for player_id, connection in self.active_connections.items():
            if player_id == exclude:
                continue
            try:
                await connection.send_json(message)
            except Exception as e:
                print(f"Error broadcasting to player {player_id}: {e}")
                disconnected.append(player_id)
        
        # Clean up disconnected players
        for player_id in disconnected:
            self.disconnect(player_id)
    
    async def send_to_location(self, location_id: str, message: dict, exclude: int = None):
        """Send message to all players in a specific location"""
        # Get players in location from database
        players_in_location = await db.get_players_in_location(location_id)
        for player in players_in_location:
            if player['id'] != exclude and player['id'] in self.active_connections:
                await self.send_personal_message(player['id'], message)

manager = ConnectionManager()


@app.websocket("/ws/game/{token}")
async def websocket_endpoint(websocket: WebSocket, token: str):
    """
    Main WebSocket endpoint for real-time game updates
    Client connects with auth token, receives push updates
    """
    # Verify token and get player
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        player_id = payload.get("user_id")
        if not player_id:
            await websocket.close(code=1008, reason="Invalid token")
            return
    except JWTError:
        await websocket.close(code=1008, reason="Invalid token")
        return
    
    player = await db.get_player_by_id(player_id)
    if not player:
        await websocket.close(code=1008, reason="Player not found")
        return
    
    # Connect player
    await manager.connect(websocket, player_id)
    
    # Send initial state
    initial_state = await get_full_game_state(player_id)
    await websocket.send_json({
        "type": "initial_state",
        "data": initial_state
    })
    
    try:
        # Keep connection alive and listen for messages
        while True:
            # Receive messages from client (heartbeat, actions, etc.)
            data = await websocket.receive_json()
            
            # Handle different message types
            if data.get("type") == "heartbeat":
                await websocket.send_json({"type": "pong"})
            
            elif data.get("type") == "action":
                # Client performed action - process and broadcast updates
                await handle_websocket_action(player_id, data, websocket)
    
    except WebSocketDisconnect:
        manager.disconnect(player_id)
        print(f"Player {player_id} disconnected")


async def handle_websocket_action(player_id: int, data: dict, websocket: WebSocket):
    """Handle actions received via WebSocket"""
    action = data.get("action")
    
    if action == "move":
        # Process movement
        result = await process_move(player_id, data.get("direction"))
        
        # Send update to player
        await manager.send_personal_message(player_id, {
            "type": "state_update",
            "data": result
        })
        
        # Notify players in new location
        if result.get("success"):
            await manager.send_to_location(
                result["new_location_id"],
                {
                    "type": "player_entered",
                    "player": {
                        "id": player_id,
                        "username": result["username"]
                    }
                },
                exclude=player_id
            )
    
    # Add more actions as needed...

2. Push updates on state changes

Modify existing endpoints to push updates:

@app.post("/api/game/pickup")
async def pickup(req, current_user):
    # ... existing pickup logic ...
    
    # After successful pickup, push update via WebSocket
    await manager.send_personal_message(current_user['id'], {
        "type": "inventory_update",
        "data": {
            "inventory": new_inventory,
            "dropped_items": new_dropped_items
        }
    })
    
    # Also notify other players in location
    await manager.send_to_location(player['location_id'], {
        "type": "dropped_items_update",
        "data": {"dropped_items": new_dropped_items}
    }, exclude=current_user['id'])
    
    return result

3. Add database helper for location players

File: api/database.py

async def get_players_in_location(location_id: str) -> List[Dict]:
    """Get all players currently in a location"""
    async with DatabaseSession() as session:
        stmt = select(players).where(players.c.location_id == location_id)
        result = await session.execute(stmt)
        return [dict(row) for row in result.fetchall()]

Phase 2: Frontend WebSocket Setup (1-2 days)

1. Create WebSocket hook

File: pwa/src/hooks/useGameWebSocket.ts

import { useEffect, useRef, useState } from 'react'

interface WebSocketMessage {
  type: string
  data: any
}

export const useGameWebSocket = (token: string, onMessage: (msg: WebSocketMessage) => void) => {
  const ws = useRef<WebSocket | null>(null)
  const [isConnected, setIsConnected] = useState(false)
  const reconnectTimeout = useRef<number | null>(null)

  const connect = () => {
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
    const host = window.location.host
    const wsUrl = `${protocol}//${host}/ws/game/${token}`

    ws.current = new WebSocket(wsUrl)

    ws.current.onopen = () => {
      console.log('WebSocket connected')
      setIsConnected(true)
      
      // Start heartbeat
      const heartbeat = setInterval(() => {
        if (ws.current?.readyState === WebSocket.OPEN) {
          ws.current.send(JSON.stringify({ type: 'heartbeat' }))
        }
      }, 30000) // Every 30 seconds

      // Store interval ID for cleanup
      ws.current.addEventListener('close', () => clearInterval(heartbeat))
    }

    ws.current.onmessage = (event) => {
      try {
        const message = JSON.parse(event.data)
        onMessage(message)
      } catch (error) {
        console.error('Error parsing WebSocket message:', error)
      }
    }

    ws.current.onerror = (error) => {
      console.error('WebSocket error:', error)
    }

    ws.current.onclose = () => {
      console.log('WebSocket disconnected')
      setIsConnected(false)
      
      // Attempt to reconnect after 3 seconds
      reconnectTimeout.current = window.setTimeout(() => {
        console.log('Attempting to reconnect...')
        connect()
      }, 3000)
    }
  }

  useEffect(() => {
    connect()

    return () => {
      // Cleanup on unmount
      if (reconnectTimeout.current) {
        clearTimeout(reconnectTimeout.current)
      }
      if (ws.current) {
        ws.current.close()
      }
    }
  }, [token])

  const sendMessage = (message: any) => {
    if (ws.current?.readyState === WebSocket.OPEN) {
      ws.current.send(JSON.stringify(message))
    }
  }

  return { isConnected, sendMessage }
}

2. Update Game component

File: pwa/src/components/Game.tsx

import { useGameWebSocket } from '../hooks/useGameWebSocket'

const Game = () => {
  // ... existing state ...

  const handleWebSocketMessage = (message: WebSocketMessage) => {
    switch (message.type) {
      case 'initial_state':
        // Set full game state on connect
        setPlayerState(message.data.player)
        setLocation(message.data.location)
        setInventory(message.data.inventory)
        setEquipment(message.data.equipment)
        break

      case 'state_update':
        // Partial update to player state
        setPlayerState(prev => ({ ...prev, ...message.data.player }))
        break

      case 'inventory_update':
        // Update inventory
        setInventory(message.data.inventory)
        if (message.data.dropped_items) {
          setDroppedItems(message.data.dropped_items)
        }
        break

      case 'pvp_combat_start':
        // PvP combat initiated
        setCombatState(message.data.combat)
        break

      case 'pvp_combat_action':
        // Opponent performed action in PvP
        setCombatLog(prev => [...prev, message.data.action])
        setCombatState(prev => ({ ...prev, ...message.data.combat }))
        break

      case 'player_entered':
        // Another player entered your location
        setMessage(`${message.data.player.username} entered the area`)
        break

      // Add more message types...
    }
  }

  const { isConnected, sendMessage } = useGameWebSocket(
    localStorage.getItem('token') || '',
    handleWebSocketMessage
  )

  // Remove old polling useEffect, replace with WebSocket
  // Keep fallback polling for when WebSocket disconnects
  useEffect(() => {
    if (!isConnected) {
      // Fallback to polling if WebSocket disconnected
      const interval = setInterval(() => {
        fetchGameData(true)
      }, 10000) // Poll every 10s as fallback

      return () => clearInterval(interval)
    }
  }, [isConnected])

  // ... rest of component
}

Use WebSockets for real-time updates + Polling as fallback

const Game = () => {
  const [connectionMode, setConnectionMode] = useState<'websocket' | 'polling'>('websocket')

  const { isConnected, sendMessage } = useGameWebSocket(token, handleWebSocketMessage)

  // Monitor connection and switch to polling if WebSocket fails
  useEffect(() => {
    if (!isConnected) {
      console.warn('WebSocket unavailable, using polling fallback')
      setConnectionMode('polling')
    } else {
      setConnectionMode('websocket')
    }
  }, [isConnected])

  // Fallback polling when WebSocket not available
  useEffect(() => {
    if (connectionMode === 'polling') {
      const interval = setInterval(fetchGameData, 5000)
      return () => clearInterval(interval)
    }
  }, [connectionMode])
}

Benefits:

  • Best UX when WebSocket works (99% of time)
  • Graceful fallback for problematic networks
  • Works behind corporate firewalls
  • No downtime during deployments

Scaling Considerations

Single Server (Current - Simple)

Client ←→ WebSocket ←→ FastAPI Server ←→ Database

Works for: Up to 1,000 concurrent connections

Multi-Server (Future - When growing)

Client ←→ WebSocket ←→ FastAPI Server 1 ←→ Redis Pub/Sub
                     ↓                      ↓
                  Database ←→ FastAPI Server 2

Use Redis for message broadcasting between servers:

import redis.asyncio as redis

redis_client = redis.from_url("redis://localhost")

class ConnectionManager:
    async def broadcast(self, message: dict):
        # Publish to Redis channel
        await redis_client.publish('game_events', json.dumps(message))
    
    async def listen_to_broadcasts(self):
        # Subscribe to Redis channel
        pubsub = redis_client.pubsub()
        await pubsub.subscribe('game_events')
        
        async for message in pubsub.listen():
            if message['type'] == 'message':
                data = json.loads(message['data'])
                # Forward to connected clients
                await self._send_to_local_connections(data)

Works for: Unlimited connections (horizontal scaling)


Migration Strategy: Gradual Rollout

Option 1: Big Bang (3-5 days downtime)

  • Implement everything at once
  • Test thoroughly
  • Deploy and switch

Week 1:

  • Implement WebSocket endpoint
  • Keep polling working
  • Add feature flag: USE_WEBSOCKET=false

Week 2:

  • Test WebSocket with beta users
  • Fix any issues
  • Enable for 10% of users

Week 3:

  • Enable for 50% of users
  • Monitor performance
  • Fix edge cases

Week 4:

  • Enable for 100% of users
  • Keep polling as fallback
  • Remove old polling code (optional)

Expected Benefits After Migration

Performance

  • Latency: 5000ms → <100ms (50x faster)
  • Bandwidth: 18KB/min → ~1KB/min (95% reduction)
  • Server Load: 12 queries/poll → 1 query/change (90% reduction)

User Experience

  • Instant combat updates (no 5s delay)
  • 🗺️ Live player locations on map
  • 💬 Real-time chat capability
  • 🎮 Better PvP feel (see actions immediately)
  • 📢 Server announcements (events, maintenance)

New Features Enabled

  • 👥 Party/group system
  • 💬 In-game chat
  • 🗺️ Live world map with player positions
  • 📊 Real-time leaderboards
  • 🎪 Live events/raids
  • 🎁 Random spawns/drops broadcast

My Recommendation

For Your Game: Implement WebSockets Now 🚀

Why:

  1. You're already planning Steam release - WebSocket quality expected
  2. PvP combat exists - Real-time feel makes huge difference
  3. FastAPI has excellent WebSocket support - Not that hard
  4. Your codebase is clean - Easy to refactor
  5. Growing player base - Better to do it now than later

Timeline:

  • Day 1-2: Backend WebSocket setup
  • Day 3-4: Frontend integration
  • Day 5: Testing & polish
  • Week 2: Gradual rollout

Risk: Low - Keep polling as fallback


Code Complexity Comparison

Polling (Current)

// Client - 20 lines
useEffect(() => {
  const interval = setInterval(fetchGameData, 5000)
  return () => clearInterval(interval)
}, [])
# Server - 5 lines
@app.get("/api/game/state")
async def get_state(user):
    return await db.get_player_state(user.id)

Total: ~25 lines, very simple

WebSocket (After migration)

// Client - 80 lines (hook + handler)
const useGameWebSocket = (token, onMessage) => {
  // Connection management
  // Reconnection logic
  // Message handling
  // Heartbeat
}
# Server - 150 lines
class ConnectionManager:
    # Connection tracking
    # Broadcasting
    # Message routing

@app.websocket("/ws/game/{token}")
async def websocket_endpoint():
    # Auth
    # Connection handling
    # Message processing

Total: ~230 lines, moderate complexity

Verdict: About 10x more code, but FastAPI does heavy lifting. Complexity is manageable.


My Assessment

Difficulty: 6/10 for me to implement

  • I know the codebase well
  • FastAPI WebSocket support is great
  • Your architecture is clean

Would take me: 3-4 days to implement fully with testing

Worth it? Absolutely YES 💯

  • Long-term better performance
  • Better UX
  • Industry standard for real-time games
  • Enables future features
  • Required for serious Steam release

Want me to start implementing it? I can do it in phases with zero downtime!