# V2_06: Game Replay & Export System > **Scope**: Replay viewer, game export/import, share links, spectator mode > **Dependencies**: V2_01 (Event Sourcing), V2_02 (Persistence), V2_03 (User Accounts) > **Complexity**: Medium --- ## Overview The replay system leverages our event-sourced architecture to provide: - **Replay Viewer**: Step through any completed game move-by-move - **Export/Import**: Download games as JSON, share with others - **Share Links**: Generate public links to specific games - **Spectator Mode**: Watch live games in progress --- ## 1. Database Schema ### Shared Games Table ```sql -- Public share links for completed games CREATE TABLE shared_games ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), game_id UUID NOT NULL REFERENCES games(id), share_code VARCHAR(12) UNIQUE NOT NULL, -- Short shareable code created_by UUID REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ, -- NULL = never expires view_count INTEGER DEFAULT 0, is_public BOOLEAN DEFAULT true, title VARCHAR(100), -- Optional custom title description TEXT -- Optional description ); CREATE INDEX idx_shared_games_code ON shared_games(share_code); CREATE INDEX idx_shared_games_game ON shared_games(game_id); -- Track replay views for analytics CREATE TABLE replay_views ( id SERIAL PRIMARY KEY, shared_game_id UUID REFERENCES shared_games(id), viewer_id UUID REFERENCES users(id), -- NULL for anonymous viewed_at TIMESTAMPTZ DEFAULT NOW(), ip_hash VARCHAR(64), -- Hashed IP for rate limiting watch_duration_seconds INTEGER ); ``` --- ## 2. Replay Service ### Core Implementation ```python # server/replay.py from dataclasses import dataclass from typing import Optional import secrets import json from server.events import EventStore, GameEvent from server.game import Game, GameOptions @dataclass class ReplayFrame: """Single frame in a replay.""" event_index: int event: GameEvent game_state: dict # Serialized game state after event timestamp: float @dataclass class GameReplay: """Complete replay of a game.""" game_id: str frames: list[ReplayFrame] total_duration_seconds: float player_names: list[str] final_scores: dict[str, int] winner: Optional[str] options: GameOptions class ReplayService: def __init__(self, event_store: EventStore, db_pool): self.event_store = event_store self.db = db_pool async def build_replay(self, game_id: str) -> GameReplay: """Build complete replay from event store.""" events = await self.event_store.get_events(game_id) if not events: raise ValueError(f"No events found for game {game_id}") frames = [] game = None start_time = None for i, event in enumerate(events): if start_time is None: start_time = event.timestamp # Apply event to get state if event.event_type == "game_started": game = Game.from_event(event) else: game.apply_event(event) frames.append(ReplayFrame( event_index=i, event=event, game_state=game.to_dict(reveal_all=True), timestamp=(event.timestamp - start_time).total_seconds() )) return GameReplay( game_id=game_id, frames=frames, total_duration_seconds=frames[-1].timestamp if frames else 0, player_names=[p.name for p in game.players], final_scores={p.name: p.score for p in game.players}, winner=game.winner.name if game.winner else None, options=game.options ) async def create_share_link( self, game_id: str, user_id: Optional[str] = None, title: Optional[str] = None, expires_days: Optional[int] = None ) -> str: """Generate shareable link for a game.""" share_code = secrets.token_urlsafe(8)[:12] # 12-char code expires_at = None if expires_days: expires_at = f"NOW() + INTERVAL '{expires_days} days'" async with self.db.acquire() as conn: await conn.execute(""" INSERT INTO shared_games (game_id, share_code, created_by, title, expires_at) VALUES ($1, $2, $3, $4, $5) """, game_id, share_code, user_id, title, expires_at) return share_code async def get_shared_game(self, share_code: str) -> Optional[dict]: """Retrieve shared game by code.""" async with self.db.acquire() as conn: row = await conn.fetchrow(""" SELECT sg.*, g.room_code, g.completed_at FROM shared_games sg JOIN games g ON sg.game_id = g.id WHERE sg.share_code = $1 AND sg.is_public = true AND (sg.expires_at IS NULL OR sg.expires_at > NOW()) """, share_code) if row: # Increment view count await conn.execute(""" UPDATE shared_games SET view_count = view_count + 1 WHERE share_code = $1 """, share_code) return dict(row) return None async def export_game(self, game_id: str) -> dict: """Export game as portable JSON format.""" replay = await self.build_replay(game_id) return { "version": "1.0", "exported_at": datetime.utcnow().isoformat(), "game": { "id": replay.game_id, "players": replay.player_names, "winner": replay.winner, "final_scores": replay.final_scores, "duration_seconds": replay.total_duration_seconds, "options": asdict(replay.options) }, "events": [ { "type": f.event.event_type, "data": f.event.data, "timestamp": f.timestamp } for f in replay.frames ] } async def import_game(self, export_data: dict, user_id: str) -> str: """Import a game from exported JSON.""" if export_data.get("version") != "1.0": raise ValueError("Unsupported export version") # Generate new game ID for import new_game_id = str(uuid.uuid4()) # Store events with new game ID for event_data in export_data["events"]: event = GameEvent( game_id=new_game_id, event_type=event_data["type"], data=event_data["data"], timestamp=datetime.fromisoformat(event_data["timestamp"]) ) await self.event_store.append(event) # Mark as imported game async with self.db.acquire() as conn: await conn.execute(""" INSERT INTO games (id, imported_by, imported_at, is_imported) VALUES ($1, $2, NOW(), true) """, new_game_id, user_id) return new_game_id ``` --- ## 3. Spectator Mode ### Live Game Watching ```python # server/spectator.py from typing import Set from fastapi import WebSocket class SpectatorManager: """Manage spectators watching live games.""" def __init__(self): # game_id -> set of spectator websockets self.spectators: dict[str, Set[WebSocket]] = {} async def add_spectator(self, game_id: str, ws: WebSocket): """Add spectator to game.""" if game_id not in self.spectators: self.spectators[game_id] = set() self.spectators[game_id].add(ws) # Send current game state game = await self.get_game_state(game_id) await ws.send_json({ "type": "spectator_joined", "game": game.to_dict(reveal_all=False), "spectator_count": len(self.spectators[game_id]) }) async def remove_spectator(self, game_id: str, ws: WebSocket): """Remove spectator from game.""" if game_id in self.spectators: self.spectators[game_id].discard(ws) if not self.spectators[game_id]: del self.spectators[game_id] async def broadcast_to_spectators(self, game_id: str, message: dict): """Send update to all spectators of a game.""" if game_id not in self.spectators: return dead_connections = set() for ws in self.spectators[game_id]: try: await ws.send_json(message) except: dead_connections.add(ws) # Clean up dead connections self.spectators[game_id] -= dead_connections def get_spectator_count(self, game_id: str) -> int: return len(self.spectators.get(game_id, set())) # Integration with main game loop async def handle_game_event(game_id: str, event: GameEvent): """Called after each game event to notify spectators.""" await spectator_manager.broadcast_to_spectators(game_id, { "type": "game_update", "event": event.to_dict(), "timestamp": event.timestamp.isoformat() }) ``` --- ## 4. API Endpoints ```python # server/routes/replay.py from fastapi import APIRouter, HTTPException, Query from fastapi.responses import JSONResponse router = APIRouter(prefix="/api/replay", tags=["replay"]) @router.get("/game/{game_id}") async def get_replay(game_id: str, user: Optional[User] = Depends(get_current_user)): """Get full replay for a game.""" # Check if user has permission (played in game or game is public) if not await can_view_game(user, game_id): raise HTTPException(403, "Cannot view this game") replay = await replay_service.build_replay(game_id) return { "game_id": replay.game_id, "frames": [ { "index": f.event_index, "event_type": f.event.event_type, "timestamp": f.timestamp, "state": f.game_state } for f in replay.frames ], "metadata": { "players": replay.player_names, "winner": replay.winner, "final_scores": replay.final_scores, "duration": replay.total_duration_seconds } } @router.post("/game/{game_id}/share") async def create_share_link( game_id: str, title: Optional[str] = None, expires_days: Optional[int] = Query(None, ge=1, le=365), user: User = Depends(require_auth) ): """Create shareable link for a game.""" if not await user_played_in_game(user.id, game_id): raise HTTPException(403, "Can only share games you played in") share_code = await replay_service.create_share_link( game_id, user.id, title, expires_days ) return { "share_code": share_code, "share_url": f"/replay/{share_code}", "expires_days": expires_days } @router.get("/shared/{share_code}") async def get_shared_replay(share_code: str): """Get replay via share code (public endpoint).""" shared = await replay_service.get_shared_game(share_code) if not shared: raise HTTPException(404, "Shared game not found or expired") replay = await replay_service.build_replay(shared["game_id"]) return { "title": shared.get("title"), "view_count": shared["view_count"], "replay": replay } @router.get("/game/{game_id}/export") async def export_game(game_id: str, user: User = Depends(require_auth)): """Export game as downloadable JSON.""" if not await can_view_game(user, game_id): raise HTTPException(403, "Cannot export this game") export_data = await replay_service.export_game(game_id) return JSONResponse( content=export_data, headers={ "Content-Disposition": f'attachment; filename="golf-game-{game_id[:8]}.json"' } ) @router.post("/import") async def import_game( export_data: dict, user: User = Depends(require_auth) ): """Import a game from JSON export.""" try: new_game_id = await replay_service.import_game(export_data, user.id) return {"game_id": new_game_id, "message": "Game imported successfully"} except ValueError as e: raise HTTPException(400, str(e)) # Spectator endpoints @router.websocket("/spectate/{room_code}") async def spectate_game(websocket: WebSocket, room_code: str): """WebSocket endpoint for spectating live games.""" await websocket.accept() game_id = await get_game_id_by_room(room_code) if not game_id: await websocket.close(code=4004, reason="Game not found") return try: await spectator_manager.add_spectator(game_id, websocket) while True: # Keep connection alive, handle pings data = await websocket.receive_text() if data == "ping": await websocket.send_text("pong") except WebSocketDisconnect: pass finally: await spectator_manager.remove_spectator(game_id, websocket) ``` --- ## 5. Frontend: Replay Viewer ### Replay Component ```javascript // client/replay.js class ReplayViewer { constructor(container) { this.container = container; this.frames = []; this.currentFrame = 0; this.isPlaying = false; this.playbackSpeed = 1.0; this.playInterval = null; } async loadReplay(gameId) { const response = await fetch(`/api/replay/game/${gameId}`); const data = await response.json(); this.frames = data.frames; this.metadata = data.metadata; this.currentFrame = 0; this.render(); this.renderControls(); } async loadSharedReplay(shareCode) { const response = await fetch(`/api/replay/shared/${shareCode}`); if (!response.ok) { this.showError("Replay not found or expired"); return; } const data = await response.json(); this.frames = data.replay.frames; this.metadata = data.replay; this.title = data.title; this.currentFrame = 0; this.render(); } render() { if (!this.frames.length) return; const frame = this.frames[this.currentFrame]; const state = frame.state; // Render game board at this state this.renderBoard(state); // Show event description this.renderEventInfo(frame); // Update timeline this.updateTimeline(); } renderBoard(state) { // Similar to main game rendering but read-only const boardHtml = `
${state.players.map(p => this.renderPlayerHand(p)).join('')}
${state.discard_top ? this.renderCard(state.discard_top) : ''}
`; this.container.querySelector('.replay-board-container').innerHTML = boardHtml; } renderEventInfo(frame) { const descriptions = { 'game_started': 'Game started', 'card_drawn': `${frame.event.data.player} drew a card`, 'card_discarded': `${frame.event.data.player} discarded`, 'card_swapped': `${frame.event.data.player} swapped a card`, 'turn_ended': `${frame.event.data.player}'s turn ended`, 'round_ended': 'Round ended', 'game_ended': `Game over! ${this.metadata.winner} wins!` }; const desc = descriptions[frame.event_type] || frame.event_type; this.container.querySelector('.event-description').textContent = desc; } renderControls() { const controls = `
1 / ${this.frames.length}
`; this.container.querySelector('.controls-container').innerHTML = controls; this.bindControlEvents(); } bindControlEvents() { this.container.querySelector('.btn-start').onclick = () => this.goToFrame(0); this.container.querySelector('.btn-end').onclick = () => this.goToFrame(this.frames.length - 1); this.container.querySelector('.btn-prev').onclick = () => this.prevFrame(); this.container.querySelector('.btn-next').onclick = () => this.nextFrame(); this.container.querySelector('.btn-play').onclick = () => this.togglePlay(); this.container.querySelector('.timeline-slider').oninput = (e) => { this.goToFrame(parseInt(e.target.value)); }; this.container.querySelector('.speed-select').onchange = (e) => { this.playbackSpeed = parseFloat(e.target.value); if (this.isPlaying) { this.stopPlayback(); this.startPlayback(); } }; } goToFrame(index) { this.currentFrame = Math.max(0, Math.min(index, this.frames.length - 1)); this.render(); } nextFrame() { if (this.currentFrame < this.frames.length - 1) { this.currentFrame++; this.render(); } else if (this.isPlaying) { this.togglePlay(); // Stop at end } } prevFrame() { if (this.currentFrame > 0) { this.currentFrame--; this.render(); } } togglePlay() { this.isPlaying = !this.isPlaying; const btn = this.container.querySelector('.btn-play'); if (this.isPlaying) { btn.textContent = '⏸'; this.startPlayback(); } else { btn.textContent = '▶'; this.stopPlayback(); } } startPlayback() { const baseInterval = 1000; // 1 second between frames this.playInterval = setInterval(() => { this.nextFrame(); }, baseInterval / this.playbackSpeed); } stopPlayback() { if (this.playInterval) { clearInterval(this.playInterval); this.playInterval = null; } } updateTimeline() { const slider = this.container.querySelector('.timeline-slider'); const counter = this.container.querySelector('.frame-counter'); if (slider) slider.value = this.currentFrame; if (counter) counter.textContent = `${this.currentFrame + 1} / ${this.frames.length}`; } } ``` ### Replay Page HTML ```html ``` ### Replay Styles ```css /* client/style.css additions */ .replay-controls { display: flex; align-items: center; gap: 1rem; padding: 1rem; background: var(--surface-color); border-radius: 8px; flex-wrap: wrap; justify-content: center; } .replay-controls button { width: 40px; height: 40px; border-radius: 50%; border: none; background: var(--primary-color); color: white; cursor: pointer; font-size: 1.2rem; } .replay-controls button:hover { background: var(--primary-dark); } .timeline { flex: 1; min-width: 200px; display: flex; align-items: center; gap: 0.5rem; } .timeline-slider { flex: 1; height: 8px; -webkit-appearance: none; background: var(--border-color); border-radius: 4px; } .timeline-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; background: var(--primary-color); border-radius: 50%; cursor: pointer; } .frame-counter { font-family: monospace; min-width: 80px; text-align: right; } .event-description { text-align: center; padding: 1rem; font-size: 1.1rem; color: var(--text-secondary); min-height: 3rem; } .speed-control { display: flex; align-items: center; gap: 0.5rem; } .speed-select { padding: 0.25rem 0.5rem; border-radius: 4px; } /* Spectator badge */ .spectator-count { position: absolute; top: 10px; right: 10px; background: rgba(0,0,0,0.7); color: white; padding: 0.5rem 1rem; border-radius: 20px; display: flex; align-items: center; gap: 0.5rem; } .spectator-count::before { content: '👁'; } ``` --- ## 6. Share Dialog ```javascript // Share modal component class ShareDialog { constructor(gameId) { this.gameId = gameId; } async show() { const modal = document.createElement('div'); modal.className = 'modal share-modal'; modal.innerHTML = ` `; document.body.appendChild(modal); this.bindEvents(modal); } async generateLink(modal) { const title = modal.querySelector('#share-title').value || null; const expiry = modal.querySelector('#share-expiry').value || null; const response = await fetch(`/api/replay/game/${this.gameId}/share`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, expires_days: expiry ? parseInt(expiry) : null }) }); const data = await response.json(); const fullUrl = `${window.location.origin}${data.share_url}`; modal.querySelector('#share-link').value = fullUrl; modal.querySelector('.share-result').classList.remove('hidden'); modal.querySelector('.btn-generate').classList.add('hidden'); } } ``` --- ## 7. Integration Points ### Game End Integration ```python # In main.py after game ends async def on_game_end(game: Game): # Store final game state await event_store.append(GameEvent( game_id=game.id, event_type="game_ended", data={ "winner": game.winner.id, "final_scores": {p.id: p.score for p in game.players}, "duration": game.duration_seconds } )) # Notify spectators await spectator_manager.broadcast_to_spectators(game.id, { "type": "game_ended", "winner": game.winner.name, "final_scores": {p.name: p.score for p in game.players} }) ``` ### Navigation Links ```javascript // Add to game history/profile function renderGameHistory(games) { return games.map(game => `
${formatDate(game.played_at)} ${game.won ? 'Won' : 'Lost'} ${game.score} pts Watch Replay
`).join(''); } ``` --- ## 8. Validation Tests ```python # tests/test_replay.py async def test_build_replay(): """Verify replay correctly reconstructs game states.""" # Create game with known moves game_id = await create_test_game() replay = await replay_service.build_replay(game_id) assert len(replay.frames) > 0 assert replay.game_id == game_id assert replay.winner is not None # Verify each frame has valid state for frame in replay.frames: assert frame.game_state is not None assert 'players' in frame.game_state async def test_share_link_creation(): """Test creating and accessing share links.""" game_id = await create_completed_game() user_id = "test-user" share_code = await replay_service.create_share_link(game_id, user_id) assert len(share_code) == 12 # Retrieve via share code shared = await replay_service.get_shared_game(share_code) assert shared is not None assert shared["game_id"] == game_id async def test_share_link_expiry(): """Verify expired links return None.""" game_id = await create_completed_game() # Create link that expires in -1 days (already expired) share_code = await create_expired_share(game_id) shared = await replay_service.get_shared_game(share_code) assert shared is None async def test_export_import_roundtrip(): """Test game can be exported and reimported.""" original_game_id = await create_completed_game() export_data = await replay_service.export_game(original_game_id) assert export_data["version"] == "1.0" assert len(export_data["events"]) > 0 # Import as new game new_game_id = await replay_service.import_game(export_data, "importer-user") # Verify imported game matches original_replay = await replay_service.build_replay(original_game_id) imported_replay = await replay_service.build_replay(new_game_id) assert len(original_replay.frames) == len(imported_replay.frames) assert original_replay.final_scores == imported_replay.final_scores async def test_spectator_connection(): """Test spectator can join and receive updates.""" game_id = await create_active_game() async with websocket_client(f"/api/replay/spectate/{game_id}") as ws: # Should receive initial state msg = await ws.receive_json() assert msg["type"] == "spectator_joined" assert "game" in msg # Simulate game event await trigger_game_event(game_id) # Should receive update update = await ws.receive_json() assert update["type"] == "game_update" ``` --- ## 9. Security Considerations 1. **Access Control**: Users can only view replays of games they played in, unless shared 2. **Rate Limiting**: Limit share link creation to prevent abuse 3. **Expired Links**: Clean up expired share links via background job 4. **Import Validation**: Validate imported JSON structure to prevent injection 5. **Spectator Limits**: Cap spectators per game to prevent resource exhaustion --- ## Summary This document provides a complete replay and export system that: - Leverages event sourcing for perfect game reconstruction - Supports shareable links with optional expiry - Enables live spectating of games in progress - Allows game export/import for portability - Includes frontend replay viewer with playback controls