Files
Code_of_Conquest/public_web/docs/MULTIPLAYER.md
2025-11-24 23:10:55 -06:00

23 KiB

Multiplayer System - Web Frontend

Status: Planned Phase: 6 (Multiplayer Sessions) Last Updated: November 18, 2025


Overview

The Web Frontend handles the UI/UX for multiplayer sessions, including lobby screens, active session displays, combat interfaces, and realtime synchronization via JavaScript/HTMX patterns.

Frontend Responsibilities:

  • Render multiplayer session creation forms
  • Display lobby with player list and ready status
  • Show active session UI (timer, party status, combat)
  • Handle realtime updates via Appwrite Realtime WebSocket
  • Submit player actions to API backend
  • Display session completion and rewards

Technical Stack:

  • Templates: Jinja2
  • Interactivity: HTMX for AJAX interactions
  • Realtime: Appwrite JavaScript SDK for WebSocket subscriptions
  • Styling: Custom CSS (responsive design)

UI/UX Design

Session Creation Screen (Host)

Route: /multiplayer/create

Template: templates/multiplayer/create.html

{% extends "base.html" %}

{% block content %}
<div class="multiplayer-create">
    <h1>Create Multiplayer Session <span class="badge-premium">Premium</span></h1>
    <p>Invite your friends to a 2-hour co-op adventure!</p>

    <form hx-post="/api/v1/sessions/multiplayer/create"
          hx-target="#session-result"
          hx-swap="innerHTML">

        <div class="form-group">
            <label>Party Size:</label>
            <div class="radio-group">
                <input type="radio" name="max_players" value="2" id="size-2">
                <label for="size-2">2 Players</label>

                <input type="radio" name="max_players" value="3" id="size-3">
                <label for="size-3">3 Players</label>

                <input type="radio" name="max_players" value="4" id="size-4" checked>
                <label for="size-4">4 Players</label>
            </div>
        </div>

        <div class="form-group">
            <label>Difficulty:</label>
            <div class="radio-group">
                <input type="radio" name="difficulty" value="easy" id="diff-easy">
                <label for="diff-easy">Easy</label>

                <input type="radio" name="difficulty" value="medium" id="diff-medium" checked>
                <label for="diff-medium">Medium</label>

                <input type="radio" name="difficulty" value="hard" id="diff-hard">
                <label for="diff-hard">Hard</label>

                <input type="radio" name="difficulty" value="deadly" id="diff-deadly">
                <label for="diff-deadly">Deadly</label>
            </div>
        </div>

        <p class="info-text">
            AI will generate a custom campaign for your party based on your
            characters' levels and the selected difficulty.
        </p>

        <button type="submit" class="btn btn-primary">Create Session</button>
    </form>

    <div id="session-result"></div>
</div>
{% endblock %}

HTMX Pattern:

  • Form submission via hx-post to API endpoint
  • Response replaces #session-result div
  • API returns session details and redirects to lobby

Lobby Screen

Route: /multiplayer/lobby/{session_id}

Template: templates/multiplayer/lobby.html

{% extends "base.html" %}

{% block content %}
<div class="multiplayer-lobby">
    <h1>Multiplayer Lobby
        <button class="btn btn-secondary"
                hx-post="/api/v1/sessions/multiplayer/{{ session.session_id }}/leave"
                hx-confirm="Are you sure you want to leave?">
            Leave Lobby
        </button>
    </h1>

    <div class="invite-section">
        <p><strong>Session Code:</strong> {{ session.invite_code }}</p>
        <p>
            <strong>Invite Link:</strong>
            <input type="text"
                   id="invite-link"
                   value="https://codeofconquest.com/join/{{ session.invite_code }}"
                   readonly>
            <button onclick="copyInviteLink()" class="btn btn-sm">Copy Link</button>
        </p>
    </div>

    <div class="lobby-info">
        <p><strong>Difficulty:</strong> {{ session.campaign.difficulty|title }}</p>
        <p><strong>Duration:</strong> 2 hours</p>
    </div>

    <div id="party-list" class="party-list">
        <!-- Dynamically updated via realtime -->
        {% for member in session.party_members %}
        <div class="party-member" data-user-id="{{ member.user_id }}">
            {% if member.is_host %}
            <span class="crown">👑</span>
            {% endif %}
            <span class="username">{{ member.username }} {% if member.is_host %}(Host){% endif %}</span>
            <span class="character">{{ member.character_snapshot.name }} - {{ member.character_snapshot.player_class.name }} Lvl {{ member.character_snapshot.level }}</span>
            <span class="ready-status">
                {% if member.is_ready %}
                ✅ Ready
                {% else %}
                ⏳ Not Ready
                {% endif %}
            </span>
        </div>
        {% endfor %}

        <!-- Empty slots -->
        {% for i in range(session.max_players - session.party_members|length) %}
        <div class="party-member empty">
            <span>[Waiting for player...]</span>
        </div>
        {% endfor %}
    </div>

    <div class="lobby-actions">
        {% if current_user.user_id == session.host_user_id %}
        <!-- Host controls -->
        <button id="start-session-btn"
                class="btn btn-primary"
                hx-post="/api/v1/sessions/multiplayer/{{ session.session_id }}/start"
                hx-target="#lobby-status"
                {% if not all_ready %}disabled{% endif %}>
            Start Session
        </button>
        {% else %}
        <!-- Player ready toggle -->
        <button class="btn btn-primary"
                hx-post="/api/v1/sessions/multiplayer/{{ session.session_id }}/ready"
                hx-target="#lobby-status">
            {% if current_member.is_ready %}Not Ready{% else %}Ready{% endif %}
        </button>
        {% endif %}
    </div>

    <div id="lobby-status"></div>
</div>

<script>
// Realtime subscription for lobby updates
const { Client, Databases } = Appwrite;

const client = new Client()
    .setEndpoint('{{ appwrite_endpoint }}')
    .setProject('{{ appwrite_project_id }}');

const databases = new Databases(client);

// Subscribe to session updates
client.subscribe(`databases.{{ db_id }}.collections.multiplayer_sessions.documents.{{ session.session_id }}`, response => {
    console.log('Session updated:', response);

    // Update party list
    updatePartyList(response.payload);

    // Check if all ready, enable/disable start button
    checkAllReady(response.payload);

    // If session started, redirect to active session
    if (response.payload.status === 'active') {
        window.location.href = '/multiplayer/session/{{ session.session_id }}';
    }
});

function updatePartyList(sessionData) {
    // Update party member ready status dynamically
    sessionData.party_members.forEach(member => {
        const memberEl = document.querySelector(`.party-member[data-user-id="${member.user_id}"]`);
        if (memberEl) {
            const statusEl = memberEl.querySelector('.ready-status');
            statusEl.innerHTML = member.is_ready ? '✅ Ready' : '⏳ Not Ready';
        }
    });
}

function checkAllReady(sessionData) {
    const allReady = sessionData.party_members.every(m => m.is_ready);
    const startBtn = document.getElementById('start-session-btn');
    if (startBtn) {
        startBtn.disabled = !allReady;
    }
}

function copyInviteLink() {
    const linkInput = document.getElementById('invite-link');
    linkInput.select();
    document.execCommand('copy');
    alert('Invite link copied to clipboard!');
}
</script>
{% endblock %}

Realtime Pattern:

  • JavaScript subscribes to Appwrite Realtime for session updates
  • Updates player ready status dynamically
  • Redirects to active session when host starts
  • No page reload required

Active Session Screen

Route: /multiplayer/session/{session_id}

Template: templates/multiplayer/session.html

{% extends "base.html" %}

{% block content %}
<div class="multiplayer-session">
    <div class="session-header">
        <h1>{{ session.campaign.title }}</h1>
        <div class="timer" id="session-timer">
            ⏱️ <span id="time-remaining">{{ time_remaining }}</span> Remaining
        </div>
    </div>

    <div class="campaign-progress">
        <p>Campaign Progress:
            <span class="progress-bar">
                <span class="progress-fill" style="width: {{ (session.current_encounter_index / session.campaign.encounters|length) * 100 }}%"></span>
            </span>
            ({{ session.current_encounter_index }}/{{ session.campaign.encounters|length }} encounters)
        </p>
    </div>

    <div class="party-status">
        <h3>Party:</h3>
        <div class="party-members" id="party-status">
            {% for member in session.party_members %}
            <div class="party-member-status">
                <span class="name">{{ member.character_snapshot.name }}</span>
                <span class="hp">HP: <span id="hp-{{ member.character_id }}">{{ member.character_snapshot.current_hp }}</span>/{{ member.character_snapshot.max_hp }}</span>
                {% if not member.is_connected %}
                <span class="disconnected">⚠️ Disconnected</span>
                {% endif %}
            </div>
            {% endfor %}
        </div>
    </div>

    <div class="narrative-panel" id="narrative-panel">
        <h3>Narrative:</h3>
        <div class="conversation-history">
            {% for entry in session.conversation_history %}
            <div class="conversation-entry {{ entry.role }}">
                <strong>{{ entry.role|title }}:</strong> {{ entry.content }}
            </div>
            {% endfor %}
        </div>
    </div>

    {% if session.combat_encounter %}
    <!-- Combat UI -->
    <div class="combat-section" id="combat-section">
        <h3>Combat: {{ current_encounter.title }}</h3>

        <div class="turn-order" id="turn-order">
            <h4>Turn Order:</h4>
            <ol>
                {% for combatant_id in session.combat_encounter.turn_order %}
                <li class="{% if loop.index0 == session.combat_encounter.current_turn_index %}current-turn{% endif %}">
                    {{ get_combatant_name(combatant_id) }}
                    {% if is_current_user_turn(combatant_id) %}
                    <strong>(Your Turn!)</strong>
                    {% endif %}
                </li>
                {% endfor %}
            </ol>
        </div>

        {% if is_current_user_turn() %}
        <!-- Combat action form (only visible on player's turn) -->
        <div class="combat-actions">
            <h4>Your Action:</h4>
            <form hx-post="/api/v1/sessions/multiplayer/{{ session.session_id }}/combat/action"
                  hx-target="#combat-result"
                  hx-swap="innerHTML">

                <button type="submit" name="action_type" value="attack" class="btn btn-action">Attack</button>

                <select name="ability_id" class="ability-select">
                    <option value="">Use Ability ▼</option>
                    {% for ability in current_character.abilities %}
                    <option value="{{ ability.ability_id }}">{{ ability.name }}</option>
                    {% endfor %}
                </select>

                <select name="item_id" class="item-select">
                    <option value="">Use Item ▼</option>
                    {% for item in current_character.inventory %}
                    <option value="{{ item.item_id }}">{{ item.name }}</option>
                    {% endfor %}
                </select>

                <button type="submit" name="action_type" value="defend" class="btn btn-action">Defend</button>
            </form>
            <div id="combat-result"></div>
        </div>
        {% else %}
        <p class="waiting-turn">Waiting for other players...</p>
        {% endif %}
    </div>
    {% endif %}
</div>

<script>
// Realtime subscription for combat and session updates
const client = new Client()
    .setEndpoint('{{ appwrite_endpoint }}')
    .setProject('{{ appwrite_project_id }}');

// Subscribe to session updates
client.subscribe(`databases.{{ db_id }}.collections.multiplayer_sessions.documents.{{ session.session_id }}`, response => {
    console.log('Session updated:', response);

    // Update timer
    updateTimer(response.payload.time_remaining_seconds);

    // Update party status
    updatePartyStatus(response.payload.party_members);

    // Update combat state
    if (response.payload.combat_encounter) {
        updateCombatUI(response.payload.combat_encounter);
    }

    // Check for session expiration
    if (response.payload.status === 'expired' || response.payload.status === 'completed') {
        window.location.href = `/multiplayer/complete/{{ session.session_id }}`;
    }
});

// Timer countdown
let timeRemaining = {{ session.time_remaining_seconds }};
setInterval(() => {
    if (timeRemaining > 0) {
        timeRemaining--;
        updateTimerDisplay(timeRemaining);
    }
}, 1000);

function updateTimerDisplay(seconds) {
    const hours = Math.floor(seconds / 3600);
    const minutes = Math.floor((seconds % 3600) / 60);
    const secs = seconds % 60;

    const display = `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
    document.getElementById('time-remaining').innerText = display;

    // Warnings
    if (seconds === 600) alert('⚠️ 10 minutes remaining!');
    if (seconds === 300) alert('⚠️ 5 minutes remaining!');
    if (seconds === 60) alert('🚨 1 minute remaining!');
}

function updateTimer(seconds) {
    timeRemaining = seconds;
}

function updatePartyStatus(partyMembers) {
    partyMembers.forEach(member => {
        const hpEl = document.getElementById(`hp-${member.character_id}`);
        if (hpEl) {
            hpEl.innerText = member.character_snapshot.current_hp;
        }
    });
}

function updateCombatUI(combat) {
    // Update turn order highlighting
    const turnItems = document.querySelectorAll('.turn-order li');
    turnItems.forEach((item, index) => {
        item.classList.toggle('current-turn', index === combat.current_turn_index);
    });

    // Reload page section if turn changed (HTMX can handle partial updates)
    htmx.ajax('GET', `/multiplayer/session/{{ session.session_id }}/combat-ui`, {target: '#combat-section', swap: 'outerHTML'});
}
</script>
{% endblock %}

Realtime Pattern:

  • Subscribe to session document changes
  • Update timer countdown locally
  • Update party HP dynamically
  • Reload combat UI section when turn changes
  • Redirect to completion screen when session ends

Session Complete Screen

Route: /multiplayer/complete/{session_id}

Template: templates/multiplayer/complete.html

{% extends "base.html" %}

{% block content %}
<div class="session-complete">
    <h1>Campaign Complete!</h1>

    <div class="completion-banner">
        <h2>🎉 {{ session.campaign.title }} - VICTORY! 🎉</h2>
        <p>{{ session.campaign.description }}</p>
    </div>

    <div class="completion-stats">
        <p><strong>Time:</strong> {{ session_duration }} (Completion Bonus: +{{ completion_bonus_percent }}%)</p>
    </div>

    <div class="rewards-summary">
        <h3>Rewards:</h3>
        <ul>
            <li>💰 {{ rewards.gold_per_player }} gold ({{ base_gold }} + {{ bonus_gold }} bonus)</li>
            <li>⭐ {{ rewards.experience_per_player }} XP ({{ base_xp }} + {{ bonus_xp }} bonus)</li>
            <li>📦 Loot: {{ rewards.shared_items|join(', ') }}</li>
        </ul>

        {% if leveled_up %}
        <p class="level-up">🎊 Level Up! {{ current_character.name }} reached level {{ current_character.level }}!</p>
        {% endif %}
    </div>

    <div class="party-stats">
        <h3>Party Members:</h3>
        <ul>
            {% for member in session.party_members %}
            <li>
                <strong>{{ member.username }}</strong> ({{ member.character_snapshot.name }})
                {% if member.mvp_badge %}
                - {{ member.mvp_badge }}
                {% endif %}
            </li>
            {% endfor %}
        </ul>
    </div>

    <div class="actions">
        <a href="/sessions/history/{{ session.session_id }}" class="btn btn-secondary">View Full Session Log</a>
        <a href="/dashboard" class="btn btn-primary">Return to Main Menu</a>
    </div>
</div>
{% endblock %}

HTMX Integration Patterns

Form Submissions

All multiplayer actions use HTMX for seamless AJAX submissions:

Pattern:

<form hx-post="/api/v1/sessions/multiplayer/{session_id}/action"
      hx-target="#result-div"
      hx-swap="innerHTML">
    <!-- form fields -->
    <button type="submit">Submit</button>
</form>

Benefits:

  • No page reload
  • Partial page updates
  • Progressive enhancement (works without JS)

Realtime Updates

Combine HTMX with Appwrite Realtime for optimal UX:

Pattern:

// Listen for realtime events
client.subscribe(`channel`, response => {
    // Update via HTMX partial reload
    htmx.ajax('GET', '/partial-url', {
        target: '#target-div',
        swap: 'outerHTML'
    });
});

Polling Fallback

For browsers without WebSocket support, use HTMX polling:

<div hx-get="/api/v1/sessions/multiplayer/{session_id}/status"
     hx-trigger="every 5s"
     hx-target="#status-div">
    <!-- Status updates every 5 seconds -->
</div>

Template Organization

Directory Structure

templates/multiplayer/
├── create.html              # Session creation form
├── lobby.html               # Lobby screen
├── session.html             # Active session UI
├── complete.html            # Session complete screen
├── partials/
│   ├── party_list.html      # Reusable party member list
│   ├── combat_ui.html       # Combat interface partial
│   └── timer.html           # Timer component
└── components/
    ├── invite_link.html     # Invite link copy widget
    └── ready_toggle.html    # Ready status toggle button

Partial Template Pattern

Example: templates/multiplayer/partials/party_list.html

<!-- Reusable party list component -->
<div class="party-list">
    {% for member in party_members %}
    <div class="party-member" data-user-id="{{ member.user_id }}">
        {% if member.is_host %}
        <span class="crown">👑</span>
        {% endif %}
        <span class="username">{{ member.username }}</span>
        <span class="character">{{ member.character_snapshot.name }} - {{ member.character_snapshot.player_class.name }} Lvl {{ member.character_snapshot.level }}</span>
        <span class="ready-status">
            {% if member.is_ready %}✅ Ready{% else %}⏳ Not Ready{% endif %}
        </span>
    </div>
    {% endfor %}
</div>

Usage:

{% include 'multiplayer/partials/party_list.html' with party_members=session.party_members %}

JavaScript/Appwrite Realtime

Setup

Base template (templates/base.html):

<head>
    <!-- Appwrite SDK -->
    <script src="https://cdn.jsdelivr.net/npm/appwrite@latest"></script>
</head>

Subscription Patterns

Session Updates:

const client = new Appwrite.Client()
    .setEndpoint('{{ config.appwrite_endpoint }}')
    .setProject('{{ config.appwrite_project_id }}');

// Subscribe to specific session
client.subscribe(
    `databases.{{ db_id }}.collections.multiplayer_sessions.documents.{{ session_id }}`,
    response => {
        console.log('Session updated:', response.payload);

        // Handle different event types
        switch(response.events[0]) {
            case 'databases.*.collections.*.documents.*.update':
                handleSessionUpdate(response.payload);
                break;
        }
    }
);

Combat Updates:

client.subscribe(
    `databases.{{ db_id }}.collections.combat_encounters.documents.{{ encounter_id }}`,
    response => {
        updateCombatUI(response.payload);
    }
);

Error Handling

client.subscribe(channel, response => {
    // Handle updates
}, error => {
    console.error('Realtime error:', error);
    // Fallback to polling
    startPolling();
});

function startPolling() {
    setInterval(() => {
        htmx.ajax('GET', '/api/v1/sessions/multiplayer/{{ session_id }}', {
            target: '#session-container',
            swap: 'outerHTML'
        });
    }, 5000);
}

View Layer Implementation

Flask View Functions

Create Session View:

@multiplayer_bp.route('/create', methods=['GET', 'POST'])
@require_auth
def create_session():
    """Render session creation form."""

    if request.method == 'POST':
        # Forward to API backend
        response = requests.post(
            f"{API_BASE_URL}/api/v1/sessions/multiplayer/create",
            json=request.form.to_dict(),
            headers={"Authorization": f"Bearer {session['auth_token']}"}
        )

        if response.status_code == 200:
            session_data = response.json()['result']
            return redirect(url_for('multiplayer.lobby', session_id=session_data['session_id']))
        else:
            flash('Failed to create session', 'error')

    return render_template('multiplayer/create.html')

@multiplayer_bp.route('/lobby/<session_id>')
@require_auth
def lobby(session_id):
    """Display lobby screen."""

    # Fetch session from API
    response = requests.get(
        f"{API_BASE_URL}/api/v1/sessions/multiplayer/{session_id}",
        headers={"Authorization": f"Bearer {session['auth_token']}"}
    )

    session_data = response.json()['result']
    return render_template('multiplayer/lobby.html', session=session_data)

Testing Checklist

Manual Testing Tasks

  • Session creation form submits correctly
  • Invite link copies to clipboard
  • Lobby updates when players join
  • Ready status toggles work
  • Host can start session when all ready
  • Timer displays and counts down correctly
  • Party HP updates during combat
  • Combat actions submit correctly
  • Turn order highlights current player
  • Realtime updates work across multiple browsers
  • Session completion screen displays rewards
  • Disconnection handling shows warnings


Document Version: 1.0 (Microservices Split) Created: November 18, 2025 Last Updated: November 18, 2025