# 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` ```gdscript 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:** ```gdscript # Autoload as singleton: Project Settings > Autoload > MultiplayerManager ``` --- ## Scene Implementation ### Session Create Scene **Scene:** `scenes/multiplayer/SessionCreate.tscn` **Script:** `scripts/multiplayer/session_create.gd` ```gdscript 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` ```gdscript 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` ```gdscript 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` ```gdscript 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` ```gdscript 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:** ```gdscript # 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:** ```gdscript 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:** ```gdscript 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) ``` --- ## Related Documentation - **[/api/docs/MULTIPLAYER.md](../../api/docs/MULTIPLAYER.md)** - Backend API endpoints and business logic - **[ARCHITECTURE.md](ARCHITECTURE.md)** - Godot client architecture - **[GETTING_STARTED.md](GETTING_STARTED.md)** - Setup guide --- **Document Version:** 1.0 (Microservices Split) **Created:** November 18, 2025 **Last Updated:** November 18, 2025