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

739 lines
23 KiB
Markdown

# 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`
```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`
```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`
```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`
```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:**
```html
<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:**
```javascript
// 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:
```html
<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`
```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:**
```html
{% include 'multiplayer/partials/party_list.html' with party_members=session.party_members %}
```
---
## JavaScript/Appwrite Realtime
### Setup
**Base template** (`templates/base.html`):
```html
<head>
<!-- Appwrite SDK -->
<script src="https://cdn.jsdelivr.net/npm/appwrite@latest"></script>
</head>
```
### Subscription Patterns
**Session Updates:**
```javascript
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:**
```javascript
client.subscribe(
`databases.{{ db_id }}.collections.combat_encounters.documents.{{ encounter_id }}`,
response => {
updateCombatUI(response.payload);
}
);
```
### Error Handling
```javascript
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:**
```python
@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
---
## Related Documentation
- **[/api/docs/MULTIPLAYER.md](../../api/docs/MULTIPLAYER.md)** - Backend API endpoints and business logic
- **[TEMPLATES.md](TEMPLATES.md)** - Template structure and conventions
- **[HTMX_PATTERNS.md](HTMX_PATTERNS.md)** - HTMX integration patterns
- **[TESTING.md](TESTING.md)** - Manual testing guide
---
**Document Version:** 1.0 (Microservices Split)
**Created:** November 18, 2025
**Last Updated:** November 18, 2025