609 lines
17 KiB
Markdown
609 lines
17 KiB
Markdown
# 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`
|
|
|
|
```python
|
|
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:**
|
|
|
|
```python
|
|
@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`
|
|
|
|
```python
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```python
|
|
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:**
|
|
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)
|
|
```typescript
|
|
// Client - 20 lines
|
|
useEffect(() => {
|
|
const interval = setInterval(fetchGameData, 5000)
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
```
|
|
|
|
```python
|
|
# 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)
|
|
```typescript
|
|
// Client - 80 lines (hook + handler)
|
|
const useGameWebSocket = (token, onMessage) => {
|
|
// Connection management
|
|
// Reconnection logic
|
|
// Message handling
|
|
// Heartbeat
|
|
}
|
|
```
|
|
|
|
```python
|
|
# 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!
|