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
}
Phase 3: Hybrid Approach (Recommended - Best of Both Worlds)
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
Option 2: Gradual (Recommended - Zero downtime)
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:
- You're already planning Steam release - WebSocket quality expected
- PvP combat exists - Real-time feel makes huge difference
- FastAPI has excellent WebSocket support - Not that hard
- Your codebase is clean - Easy to refactor
- 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!