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

26 KiB

Multiplayer System - Godot Client

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


Overview

The Godot Client provides a rich, native multiplayer experience with realtime synchronization, interactive combat UI, and smooth animations for multiplayer sessions.

Client Responsibilities:

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

Technical Stack:

  • Engine: Godot 4.5
  • Networking: HTTP requests to API + WebSocket for realtime
  • UI: Godot Control nodes (responsive layouts)
  • Animations: AnimationPlayer, Tweens

Scene Structure

Multiplayer Scenes Hierarchy

scenes/multiplayer/
├── MultiplayerMenu.tscn          # Entry point (create/join session)
├── SessionCreate.tscn            # Session creation form
├── Lobby.tscn                    # Lobby screen with party list
├── ActiveSession.tscn            # Main session scene
│   ├── Timer.tscn                # Session timer component
│   ├── PartyStatus.tscn          # Party member HP/status display
│   ├── NarrativePanel.tscn       # Narrative/conversation display
│   └── CombatUI.tscn             # Combat interface
│       ├── TurnOrder.tscn        # Turn order display
│       ├── ActionButtons.tscn    # Combat action buttons
│       └── TargetSelector.tscn   # Enemy target selection
└── SessionComplete.tscn          # Completion/rewards screen

Component Scenes

scenes/components/multiplayer/
├── PartyMemberCard.tscn          # Single party member display
├── InviteLinkCopy.tscn           # Invite link copy widget
├── ReadyToggle.tscn              # Ready status toggle button
├── CombatantCard.tscn            # Combat turn order entry
└── RewardDisplay.tscn            # Reward summary component

GDScript Implementation

Multiplayer Manager (Singleton)

Script: scripts/singletons/MultiplayerManager.gd

extends Node

# Signals for multiplayer events
signal session_created(session_id: String)
signal player_joined(member: Dictionary)
signal player_ready_changed(user_id: String, is_ready: bool)
signal session_started()
signal combat_started(encounter: Dictionary)
signal turn_changed(current_turn: int)
signal session_completed(rewards: Dictionary)
signal session_expired()

# Current session data
var current_session: Dictionary = {}
var is_host: bool = false
var my_character_id: String = ""

# API configuration
var api_base_url: String = "http://localhost:5000"

# WebSocket for realtime updates
var ws_client: WebSocketPeer
var is_connected: bool = false

func _ready():
    # Initialize WebSocket
    ws_client = WebSocketPeer.new()

func create_session(max_players: int, difficulty: String) -> void:
    """Create a new multiplayer session."""

    var body = {
        "max_players": max_players,
        "difficulty": difficulty,
        "tier_required": "premium"
    }

    var response = await APIClient.post("/api/v1/sessions/multiplayer/create", body)

    if response.status == 200:
        current_session = response.result
        is_host = true
        emit_signal("session_created", current_session.session_id)

        # Connect to realtime updates
        connect_realtime(current_session.session_id)
    else:
        push_error("Failed to create session: " + str(response.error))

func join_session(invite_code: String, character_id: String) -> void:
    """Join a multiplayer session via invite code."""

    # First, get session info
    var info_response = await APIClient.get("/api/v1/sessions/multiplayer/join/" + invite_code)

    if info_response.status != 200:
        push_error("Invalid invite code")
        return

    # Join with selected character
    var join_body = {"character_id": character_id}
    var join_response = await APIClient.post("/api/v1/sessions/multiplayer/join/" + invite_code, join_body)

    if join_response.status == 200:
        current_session = join_response.result
        my_character_id = character_id
        is_host = false

        # Connect to realtime updates
        connect_realtime(current_session.session_id)

        emit_signal("player_joined", {"character_id": character_id})
    else:
        push_error("Failed to join session: " + str(join_response.error))

func connect_realtime(session_id: String) -> void:
    """Connect to Appwrite Realtime for session updates."""

    # Use Appwrite SDK or custom WebSocket
    # For Appwrite Realtime, use their JavaScript SDK bridged via JavaScriptBridge
    # Or implement custom WebSocket to backend realtime endpoint

    var ws_url = "wss://cloud.appwrite.io/v1/realtime?project=" + Config.appwrite_project_id
    var channels = ["databases." + Config.appwrite_database_id + ".collections.multiplayer_sessions.documents." + session_id]

    # Connect WebSocket
    var err = ws_client.connect_to_url(ws_url)
    if err != OK:
        push_error("Failed to connect WebSocket: " + str(err))
        return

    is_connected = true

func _process(_delta):
    """Process WebSocket messages."""

    if not is_connected:
        return

    ws_client.poll()

    var state = ws_client.get_ready_state()
    if state == WebSocketPeer.STATE_OPEN:
        while ws_client.get_available_packet_count():
            var packet = ws_client.get_packet()
            var message = packet.get_string_from_utf8()
            handle_realtime_message(JSON.parse_string(message))

    elif state == WebSocketPeer.STATE_CLOSED:
        is_connected = false
        push_warning("WebSocket disconnected")

func handle_realtime_message(data: Dictionary) -> void:
    """Handle realtime event from backend."""

    var event_type = data.get("events", [])[0] if data.has("events") else ""
    var payload = data.get("payload", {})

    match event_type:
        "databases.*.collections.*.documents.*.update":
            # Session updated
            current_session = payload

            # Check for status changes
            if payload.status == "active":
                emit_signal("session_started")
            elif payload.status == "completed":
                emit_signal("session_completed", payload.campaign.rewards)
            elif payload.status == "expired":
                emit_signal("session_expired")

            # Check for combat updates
            if payload.has("combat_encounter") and payload.combat_encounter != null:
                emit_signal("combat_started", payload.combat_encounter)
                emit_signal("turn_changed", payload.combat_encounter.current_turn_index)

func toggle_ready() -> void:
    """Toggle ready status in lobby."""

    var response = await APIClient.post("/api/v1/sessions/multiplayer/" + current_session.session_id + "/ready", {})

    if response.status != 200:
        push_error("Failed to toggle ready status")

func start_session() -> void:
    """Start the session (host only)."""

    if not is_host:
        push_error("Only host can start session")
        return

    var response = await APIClient.post("/api/v1/sessions/multiplayer/" + current_session.session_id + "/start", {})

    if response.status != 200:
        push_error("Failed to start session: " + str(response.error))

func take_combat_action(action_type: String, ability_id: String = "", target_id: String = "") -> void:
    """Submit combat action to backend."""

    var body = {
        "action_type": action_type,
        "ability_id": ability_id,
        "target_id": target_id
    }

    var response = await APIClient.post("/api/v1/sessions/multiplayer/" + current_session.session_id + "/combat/action", body)

    if response.status != 200:
        push_error("Failed to take action: " + str(response.error))

func leave_session() -> void:
    """Leave the current session."""

    var response = await APIClient.post("/api/v1/sessions/multiplayer/" + current_session.session_id + "/leave", {})

    # Disconnect WebSocket
    if is_connected:
        ws_client.close()
        is_connected = false

    current_session = {}
    is_host = false

Usage:

# Autoload as singleton: Project Settings > Autoload > MultiplayerManager

Scene Implementation

Session Create Scene

Scene: scenes/multiplayer/SessionCreate.tscn

Script: scripts/multiplayer/session_create.gd

extends Control

@onready var party_size_group: ButtonGroup = $VBoxContainer/PartySize/ButtonGroup
@onready var difficulty_group: ButtonGroup = $VBoxContainer/Difficulty/ButtonGroup
@onready var create_button: Button = $VBoxContainer/CreateButton

func _ready():
    create_button.pressed.connect(_on_create_pressed)

func _on_create_pressed():
    var max_players = int(party_size_group.get_pressed_button().text.split(" ")[0])
    var difficulty = difficulty_group.get_pressed_button().text.to_lower()

    # Disable button to prevent double-click
    create_button.disabled = true

    # Create session via MultiplayerManager
    await MultiplayerManager.create_session(max_players, difficulty)

    # Navigate to lobby
    get_tree().change_scene_to_file("res://scenes/multiplayer/Lobby.tscn")

UI Layout:

VBoxContainer
├── Label "Create Multiplayer Session"
├── HBoxContainer (Party Size)
│   ├── RadioButton "2 Players"
│   ├── RadioButton "3 Players"
│   └── RadioButton "4 Players"
├── HBoxContainer (Difficulty)
│   ├── RadioButton "Easy"
│   ├── RadioButton "Medium"
│   ├── RadioButton "Hard"
│   └── RadioButton "Deadly"
└── Button "Create Session"

Lobby Scene

Scene: scenes/multiplayer/Lobby.tscn

Script: scripts/multiplayer/lobby.gd

extends Control

@onready var invite_code_label: Label = $VBoxContainer/InviteSection/CodeLabel
@onready var invite_link_input: LineEdit = $VBoxContainer/InviteSection/LinkInput
@onready var copy_button: Button = $VBoxContainer/InviteSection/CopyButton
@onready var party_list: VBoxContainer = $VBoxContainer/PartyList
@onready var ready_button: Button = $VBoxContainer/Actions/ReadyButton
@onready var start_button: Button = $VBoxContainer/Actions/StartButton
@onready var leave_button: Button = $VBoxContainer/Actions/LeaveButton

# Party member card scene
var party_member_card_scene = preload("res://scenes/components/multiplayer/PartyMemberCard.tscn")

func _ready():
    # Connect signals
    MultiplayerManager.player_joined.connect(_on_player_joined)
    MultiplayerManager.player_ready_changed.connect(_on_player_ready_changed)
    MultiplayerManager.session_started.connect(_on_session_started)

    # Setup UI
    var session = MultiplayerManager.current_session
    invite_code_label.text = "Session Code: " + session.invite_code
    invite_link_input.text = "https://codeofconquest.com/join/" + session.invite_code

    copy_button.pressed.connect(_on_copy_pressed)
    ready_button.pressed.connect(_on_ready_pressed)
    start_button.pressed.connect(_on_start_pressed)
    leave_button.pressed.connect(_on_leave_pressed)

    # Show/hide start button based on host status
    start_button.visible = MultiplayerManager.is_host
    ready_button.visible = !MultiplayerManager.is_host

    # Populate party list
    refresh_party_list()

func refresh_party_list():
    """Refresh the party member list."""

    # Clear existing cards
    for child in party_list.get_children():
        child.queue_free()

    var session = MultiplayerManager.current_session
    var party_members = session.get("party_members", [])

    # Add party member cards
    for member in party_members:
        var card = party_member_card_scene.instantiate()
        card.set_member_data(member)
        party_list.add_child(card)

    # Add empty slots
    var max_players = session.get("max_players", 4)
    for i in range(max_players - party_members.size()):
        var card = party_member_card_scene.instantiate()
        card.set_empty_slot()
        party_list.add_child(card)

    # Check if all ready
    check_all_ready()

func check_all_ready():
    """Check if all players are ready and enable/disable start button."""

    if not MultiplayerManager.is_host:
        return

    var session = MultiplayerManager.current_session
    var party_members = session.get("party_members", [])

    var all_ready = true
    for member in party_members:
        if not member.is_ready:
            all_ready = false
            break

    start_button.disabled = !all_ready

func _on_player_joined(member: Dictionary):
    refresh_party_list()

func _on_player_ready_changed(user_id: String, is_ready: bool):
    refresh_party_list()

func _on_session_started():
    # Navigate to active session
    get_tree().change_scene_to_file("res://scenes/multiplayer/ActiveSession.tscn")

func _on_copy_pressed():
    DisplayServer.clipboard_set(invite_link_input.text)
    # Show toast notification
    show_toast("Invite link copied!")

func _on_ready_pressed():
    await MultiplayerManager.toggle_ready()

func _on_start_pressed():
    start_button.disabled = true
    await MultiplayerManager.start_session()

func _on_leave_pressed():
    await MultiplayerManager.leave_session()
    get_tree().change_scene_to_file("res://scenes/MainMenu.tscn")

func show_toast(message: String):
    # Implement toast notification (simple Label with animation)
    var toast = Label.new()
    toast.text = message
    toast.position = Vector2(400, 50)
    add_child(toast)

    # Fade out animation
    var tween = create_tween()
    tween.tween_property(toast, "modulate:a", 0.0, 2.0)
    tween.tween_callback(toast.queue_free)

Party Member Card Component

Scene: scenes/components/multiplayer/PartyMemberCard.tscn

Script: scripts/components/party_member_card.gd

extends PanelContainer

@onready var crown_icon: TextureRect = $HBoxContainer/CrownIcon
@onready var username_label: Label = $HBoxContainer/UsernameLabel
@onready var character_label: Label = $HBoxContainer/CharacterLabel
@onready var ready_status: Label = $HBoxContainer/ReadyStatus

func set_member_data(member: Dictionary):
    """Populate card with member data."""

    crown_icon.visible = member.is_host
    username_label.text = member.username + (" (Host)" if member.is_host else "")
    character_label.text = member.character_snapshot.name + " - " + member.character_snapshot.player_class.name + " Lvl " + str(member.character_snapshot.level)

    if member.is_ready:
        ready_status.text = "✅ Ready"
        ready_status.modulate = Color.GREEN
    else:
        ready_status.text = "⏳ Not Ready"
        ready_status.modulate = Color.YELLOW

func set_empty_slot():
    """Display as empty slot."""

    crown_icon.visible = false
    username_label.text = "[Waiting for player...]"
    character_label.text = ""
    ready_status.text = ""

Active Session Scene

Scene: scenes/multiplayer/ActiveSession.tscn

Script: scripts/multiplayer/active_session.gd

extends Control

@onready var campaign_title: Label = $Header/TitleLabel
@onready var timer_label: Label = $Header/TimerLabel
@onready var progress_bar: ProgressBar = $ProgressSection/ProgressBar
@onready var party_status: HBoxContainer = $PartyStatus
@onready var narrative_panel: RichTextLabel = $NarrativePanel/TextLabel
@onready var combat_ui: Control = $CombatUI

var time_remaining: int = 7200  # 2 hours in seconds

func _ready():
    # Connect signals
    MultiplayerManager.combat_started.connect(_on_combat_started)
    MultiplayerManager.turn_changed.connect(_on_turn_changed)
    MultiplayerManager.session_completed.connect(_on_session_completed)
    MultiplayerManager.session_expired.connect(_on_session_expired)

    # Setup UI
    var session = MultiplayerManager.current_session
    campaign_title.text = session.campaign.title
    time_remaining = session.time_remaining_seconds

    update_progress_bar()
    update_party_status()
    update_narrative()

    # Start timer countdown
    var timer = Timer.new()
    timer.wait_time = 1.0
    timer.timeout.connect(_on_timer_tick)
    add_child(timer)
    timer.start()

func _on_timer_tick():
    """Update timer every second."""

    time_remaining -= 1

    if time_remaining <= 0:
        timer_label.text = "⏱️ Time's Up!"
        return

    var hours = time_remaining / 3600
    var minutes = (time_remaining % 3600) / 60
    var seconds = time_remaining % 60

    timer_label.text = "⏱️ %d:%02d:%02d Remaining" % [hours, minutes, seconds]

    # Warnings
    if time_remaining == 600:
        show_warning("⚠️ 10 minutes remaining!")
    elif time_remaining == 300:
        show_warning("⚠️ 5 minutes remaining!")
    elif time_remaining == 60:
        show_warning("🚨 1 minute remaining!")

func update_progress_bar():
    var session = MultiplayerManager.current_session
    var total_encounters = session.campaign.encounters.size()
    var current = session.current_encounter_index

    progress_bar.max_value = total_encounters
    progress_bar.value = current

func update_party_status():
    # Clear existing party status
    for child in party_status.get_children():
        child.queue_free()

    var session = MultiplayerManager.current_session
    for member in session.party_members:
        var status_label = Label.new()
        var char = member.character_snapshot
        status_label.text = "%s (HP: %d/%d)" % [char.name, char.current_hp, char.max_hp]

        if not member.is_connected:
            status_label.text += " ⚠️ Disconnected"

        party_status.add_child(status_label)

func update_narrative():
    var session = MultiplayerManager.current_session
    narrative_panel.clear()

    for entry in session.conversation_history:
        narrative_panel.append_text("[b]%s:[/b] %s\n\n" % [entry.role.capitalize(), entry.content])

func _on_combat_started(encounter: Dictionary):
    combat_ui.visible = true
    combat_ui.setup_combat(encounter)

func _on_turn_changed(current_turn: int):
    combat_ui.update_turn_order(current_turn)

func _on_session_completed(rewards: Dictionary):
    get_tree().change_scene_to_file("res://scenes/multiplayer/SessionComplete.tscn")

func _on_session_expired():
    show_warning("Session time limit reached. Redirecting...")
    await get_tree().create_timer(3.0).timeout
    get_tree().change_scene_to_file("res://scenes/multiplayer/SessionComplete.tscn")

func show_warning(message: String):
    # Display warning notification (AcceptDialog or custom popup)
    var dialog = AcceptDialog.new()
    dialog.dialog_text = message
    add_child(dialog)
    dialog.popup_centered()

Combat UI Component

Scene: scenes/multiplayer/CombatUI.tscn

Script: scripts/multiplayer/combat_ui.gd

extends Control

@onready var turn_order_list: VBoxContainer = $TurnOrder/List
@onready var action_buttons: HBoxContainer = $Actions/Buttons
@onready var attack_button: Button = $Actions/Buttons/AttackButton
@onready var ability_dropdown: OptionButton = $Actions/Buttons/AbilityDropdown
@onready var defend_button: Button = $Actions/Buttons/DefendButton

var is_my_turn: bool = false
var current_encounter: Dictionary = {}

func setup_combat(encounter: Dictionary):
    """Setup combat UI for new encounter."""

    current_encounter = encounter
    populate_turn_order(encounter)
    setup_action_buttons()

func populate_turn_order(encounter: Dictionary):
    """Display turn order list."""

    # Clear existing
    for child in turn_order_list.get_children():
        child.queue_free()

    var turn_order = encounter.turn_order
    var current_turn = encounter.current_turn_index

    for i in range(turn_order.size()):
        var combatant_id = turn_order[i]
        var label = Label.new()
        label.text = get_combatant_name(combatant_id)

        # Highlight current turn
        if i == current_turn:
            label.modulate = Color.YELLOW
            label.text = "▶ " + label.text

        turn_order_list.add_child(label)

    # Check if it's my turn
    check_if_my_turn(current_turn, turn_order)

func update_turn_order(current_turn: int):
    """Update turn order highlighting."""

    var labels = turn_order_list.get_children()
    for i in range(labels.size()):
        var label = labels[i]
        if i == current_turn:
            label.modulate = Color.YELLOW
            label.text = "▶ " + get_combatant_name(current_encounter.turn_order[i])
        else:
            label.modulate = Color.WHITE
            label.text = get_combatant_name(current_encounter.turn_order[i])

    # Check if it's my turn
    check_if_my_turn(current_turn, current_encounter.turn_order)

func check_if_my_turn(current_turn: int, turn_order: Array):
    """Check if current combatant is controlled by this player."""

    var current_combatant_id = turn_order[current_turn]

    # Check if this combatant belongs to me
    is_my_turn = is_my_combatant(current_combatant_id)

    # Enable/disable action buttons
    action_buttons.visible = is_my_turn

func is_my_combatant(combatant_id: String) -> bool:
    """Check if combatant belongs to current player."""

    # Logic to determine if this combatant_id matches my character
    return combatant_id == MultiplayerManager.my_character_id

func setup_action_buttons():
    """Setup combat action button handlers."""

    attack_button.pressed.connect(_on_attack_pressed)
    defend_button.pressed.connect(_on_defend_pressed)

    # Populate ability dropdown
    # (Load character abilities from MultiplayerManager.current_session)

func _on_attack_pressed():
    # Show target selection
    show_target_selection("attack")

func _on_defend_pressed():
    await MultiplayerManager.take_combat_action("defend")

func show_target_selection(action_type: String):
    """Show enemy target selection UI."""

    # Display list of enemies, allow player to select target
    # For simplicity, just take first enemy
    var enemies = get_enemies_from_encounter()
    if enemies.size() > 0:
        await MultiplayerManager.take_combat_action(action_type, "", enemies[0].combatant_id)

func get_combatant_name(combatant_id: String) -> String:
    # Lookup combatant name from encounter data
    return combatant_id  # Placeholder

func get_enemies_from_encounter() -> Array:
    # Extract enemy combatants from encounter
    return []  # Placeholder

WebSocket Integration

Appwrite Realtime via GDScript

Godot doesn't natively support Appwrite SDK, so we'll use WebSocketPeer:

Custom WebSocket Client:

# In MultiplayerManager.gd

func connect_realtime(session_id: String) -> void:
    var project_id = Config.appwrite_project_id
    var db_id = Config.appwrite_database_id

    # Construct Appwrite Realtime WebSocket URL
    var ws_url = "wss://cloud.appwrite.io/v1/realtime?project=" + project_id

    ws_client = WebSocketPeer.new()
    var err = ws_client.connect_to_url(ws_url)

    if err != OK:
        push_error("WebSocket connection failed: " + str(err))
        return

    is_connected = true

    # After connection, subscribe to channels
    await get_tree().create_timer(1.0).timeout  # Wait for connection
    subscribe_to_channels(session_id)

func subscribe_to_channels(session_id: String):
    """Send subscribe message to Appwrite Realtime."""

    var subscribe_message = {
        "type": "subscribe",
        "channels": [
            "databases." + Config.appwrite_database_id + ".collections.multiplayer_sessions.documents." + session_id
        ]
    }

    ws_client.send_text(JSON.stringify(subscribe_message))

Alternative: Use HTTP polling as fallback if WebSocket fails.


Testing Checklist

Manual Testing Tasks (Godot Client)

  • Session creation UI works correctly
  • Invite link copies to clipboard
  • Lobby updates when players join
  • Ready status toggle works
  • Host can start session when all ready
  • Timer displays and counts down correctly
  • Party HP updates during combat
  • Combat action buttons submit correctly
  • Turn order highlights current player
  • Realtime updates work (test with web client simultaneously)
  • Session completion screen displays rewards
  • Disconnection handling shows warnings
  • UI is responsive on different screen sizes
  • Animations play smoothly

Animation Guidelines

Combat Animations

Attack Animation:

func play_attack_animation(attacker_id: String, target_id: String):
    var attacker_sprite = get_combatant_sprite(attacker_id)
    var target_sprite = get_combatant_sprite(target_id)

    var tween = create_tween()
    # Move attacker forward
    tween.tween_property(attacker_sprite, "position:x", target_sprite.position.x - 50, 0.3)
    # Flash target red (damage)
    tween.parallel().tween_property(target_sprite, "modulate", Color.RED, 0.1)
    tween.tween_property(target_sprite, "modulate", Color.WHITE, 0.1)
    # Move attacker back
    tween.tween_property(attacker_sprite, "position:x", attacker_sprite.position.x, 0.3)

Damage Number Popup:

func show_damage_number(damage: int, position: Vector2):
    var label = Label.new()
    label.text = "-" + str(damage)
    label.position = position
    label.modulate = Color.RED
    add_child(label)

    var tween = create_tween()
    tween.tween_property(label, "position:y", position.y - 50, 1.0)
    tween.parallel().tween_property(label, "modulate:a", 0.0, 1.0)
    tween.tween_callback(label.queue_free)


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