golfgame/docs/v2/V2_06_REPLAY_EXPORT.md
Aaron D. Lee bea85e6b28 Huge v2 uplift, now deployable with real user management and tooling!
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:32:15 -05:00

977 lines
29 KiB
Markdown

# 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 = `
<div class="replay-board">
${state.players.map(p => this.renderPlayerHand(p)).join('')}
<div class="replay-center">
<div class="deck-area">
<div class="card deck-card">
<span class="card-back"></span>
</div>
${state.discard_top ? this.renderCard(state.discard_top) : ''}
</div>
</div>
</div>
`;
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 = `
<div class="replay-controls">
<button class="btn-start" title="Go to start">⏮</button>
<button class="btn-prev" title="Previous">⏪</button>
<button class="btn-play" title="Play/Pause">▶</button>
<button class="btn-next" title="Next">⏩</button>
<button class="btn-end" title="Go to end">⏭</button>
<div class="timeline">
<input type="range" min="0" max="${this.frames.length - 1}"
value="0" class="timeline-slider">
<span class="frame-counter">1 / ${this.frames.length}</span>
</div>
<div class="speed-control">
<label>Speed:</label>
<select class="speed-select">
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
<option value="4">4x</option>
</select>
</div>
</div>
`;
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
<!-- client/replay.html or section in index.html -->
<div id="replay-view" class="view hidden">
<header class="replay-header">
<h2 class="replay-title">Game Replay</h2>
<div class="replay-meta">
<span class="player-names"></span>
<span class="game-duration"></span>
</div>
</header>
<div class="replay-board-container">
<!-- Board renders here -->
</div>
<div class="event-description"></div>
<div class="controls-container">
<!-- Controls render here -->
</div>
<div class="replay-actions">
<button class="btn-share">Share Replay</button>
<button class="btn-export">Export JSON</button>
<button class="btn-back">Back to Menu</button>
</div>
</div>
```
### 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 = `
<div class="modal-content">
<h3>Share This Game</h3>
<div class="share-options">
<label>
<span>Title (optional):</span>
<input type="text" id="share-title" placeholder="Epic comeback win!">
</label>
<label>
<span>Expires in:</span>
<select id="share-expiry">
<option value="">Never</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</label>
</div>
<div class="share-result hidden">
<p>Share this link:</p>
<div class="share-link-container">
<input type="text" id="share-link" readonly>
<button class="btn-copy">Copy</button>
</div>
</div>
<div class="modal-actions">
<button class="btn-generate">Generate Link</button>
<button class="btn-cancel">Cancel</button>
</div>
</div>
`;
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 => `
<div class="history-item">
<span class="game-date">${formatDate(game.played_at)}</span>
<span class="game-result">${game.won ? 'Won' : 'Lost'}</span>
<span class="game-score">${game.score} pts</span>
<a href="/replay/${game.id}" class="btn-replay">Watch Replay</a>
</div>
`).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