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

29 KiB

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

-- 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

# 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

# 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

# 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

// 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

<!-- 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

/* 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

// 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

# 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}
    })
// 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

# 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