Commit
This commit is contained in:
608
old/WEBSOCKET_MIGRATION_PLAN.md
Normal file
608
old/WEBSOCKET_MIGRATION_PLAN.md
Normal file
@@ -0,0 +1,608 @@
|
||||
# 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!
|
||||
Reference in New Issue
Block a user