From f5e0642b399ab683fbfbc0ad3b5d3be7cf14f395 Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 1 Jul 2026 23:47:03 +0100 Subject: [PATCH] Arena polish: in-run HUD, no free loot, retire-with-winnings - HUD arena status (top center): live wave number and run gold, driven by GameState signals, torn down cleanly on HUD.reset() - Free floor weapons removed: the initial sword/shield spawn is gone and manually placed level.tscn weapons are deleted server-side at init. Gear now only enters play via the armory or enemy drops. - Retire to Camp: new escape-menu button (shown only while alive in an active run) banks 100% of run gold and keeps all gear - the counterweight to death's 10% settlement. Exit Game and window close also bank winnings before quitting. - CLAUDE.md rewritten for the gladiator arena era: core loop, autoloads, economy API, RPC patterns, verification workflow. --- CLAUDE.md | 188 +++++++++++++++++--------------- level/scripts/game_state.gd | 30 +++++ level/scripts/level.gd | 86 +++------------ level/ui/scripts/escape_menu.gd | 42 +++++++ level/ui/scripts/hud_manager.gd | 65 +++++++++++ 5 files changed, 253 insertions(+), 158 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3123032..ba8e994 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,111 +4,127 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is a 3D multiplayer fighting game built in Godot 4.5 using an inheritance-based architecture. The game uses ENet for client-server networking with the Network singleton (autoload) managing all multiplayer connections. +**Survival of the Snippest** — a 3D multiplayer gladiator arena survival roguelike built in Godot 4.5. +Outfit a gladiator with banked gold, enter the colosseum, survive escalating enemy waves, earn gold +from kills and wave clears, and either retire alive (keep everything) or die (keep 10% of your total +value, floored at the 500-gold fresh start). Uses ENet client-server networking. ## Key Commands ### Running the Game -- Open the project in Godot Editor and press F5 to run -- Or use: `godot --path . res://level/scenes/level.tscn` +- Open the project in Godot Editor and press F5, or: `godot --path . res://level/scenes/level.tscn` +- Headless script-error check: `godot --headless --path . --quit` ### Testing Multiplayer Locally -1. Run the main scene (level.tscn) -2. Click "Host" on one instance to create a server (default port 8080) -3. Run another instance and click "Join" to connect as a client +1. Run one instance, click ENTER ARENA to host (port 8080) +2. Run a second instance, click JOIN FRIEND to connect (IP 127.0.0.1) + +### In-Game Debug Keys +- `N` — start next enemy wave (server only) +- `H` — toggle hitbox/hurtbox debug visualization (off by default) + +## The Core Loop + +1. **Outfitting screen** (main menu): armory shop on the left/middle, Character Sheet in preview + mode on the right showing the pending loadout live. Buy weapons with banked gold; hand rules + enforced (two-handers and off-hands displace each other). +2. **Arena run**: chosen loadout auto-equips on spawn. Waves spawn on a timer; kills pay gold + (server-credited to the killer's peer), wave clears pay a bonus to all players. +3. **Run end**: + - **Death** (permadeath, no respawn): results screen; keep `max(500, 10% of total value)`; + gear and loadout liquidated. + - **Retire** (escape menu, or quitting alive): bank 100% of run gold, keep all gear. ## Architecture -### Inheritance Structure -The codebase follows an inheritance-based design pattern: -- **BaseUnit** (base class) - Common functionality for all game entities that can take damage/have health -- **Character** extends **CharacterBody3D** - Player character class (class_name: Character) - - Currently implements movement, jumping, sprinting, respawning, and skin customization - - Uses multiplayer authority for input handling (only the owner processes input) +### Autoloads (singletons) +- **Network** (`level/scripts/network.gd`) — ENet setup, host/join, `players` dict keyed by peer_id + (`{"nick": String, "skin": Character.SkinColor}`). Server = peer 1, MAX_PLAYERS = 10. +- **GameState** (`level/scripts/game_state.gd`) — the economy. Persists `banked_gold`, + `owned_items`, `selected_loadout` to `user://save.cfg`. Per-run: `run_gold`, `current_wave`, + `kills_this_run`, `run_active`. Key API: `buy_item`, `equip_loadout_item`, `start_run`, + `end_run` (death settlement, double-call guarded), `retire_run` (bank everything), + `server_credit_kill/gold` (server → owning peer RPCs), `server_sync_wave`. + No compile-time dependency on Network — keep it that way (testable standalone via `--script`). +- **HUD** (`level/ui/scripts/hud_manager.gd`) — builds all in-run UI for the local player + (action bar, unit frame, character sheet, arena status wave/gold readout, escape menu, + run results on death). `reset()` tears everything down when returning to the menu — + autoload children survive scene reloads, so always reset before `reload_current_scene()`. -### Networking (Network.gd) -- **Type**: Autoload singleton (accessible via `Network` globally) -- **Protocol**: ENet (UDP-based) -- **Architecture**: Client-Server (one host, multiple clients up to MAX_PLAYERS=10) -- **Player Data**: Stored in `Network.players` dictionary keyed by peer_id - - Contains: `{"nick": String, "skin": Character.SkinColor}` +### Class hierarchy +- **BaseUnit** (`base_unit.gd`) — health, damage, death, respawn, passive regen. Server validates + all damage. NOTE: `died` can fire twice on the host (direct emit + call_local sync) — guard + anything expensive hooked to it. +- **Character** (`player.gd`, class_name Character) — player: movement, dash, attacks, blocking, + weapon equip/drop/pickup. `can_respawn = false` (death ends the run). Attacks snap the body to + camera facing and lock direction for startup+active (`_lock_facing_to_camera`); dash locks + direction for its duration. +- **BaseEnemy** (`base_enemy.gd`) — targeting, `gold_reward` (basic 10, armed 25). Enemies are + server-authoritative (authority = 1). + - **BasicEnemy** — melee chaser; `can_respawn = false`, despawns after death, 40% health orb drop. + - **ArmedEnemy** — player-model enemy that seeks/equips weapons and drops them on death. + `can_respawn = false` (respawning would be a gold farm). Practice dummies DO respawn and + deliberately pay no gold. + +### Weapons +- **WeaponData** (`weapon_data.gd`) — Resource in `level/resources/*.tres`: combat stats, hand type + (MAIN_HAND / OFF_HAND / TWO_HAND), block stats, `cost`/`tier` for the shop, `icon` (optional — + weapon slots fall back to name text when missing). +- **BaseWeapon** — equipped instance on a hand bone; **WorldWeapon** — pickable RigidBody3D. +- No free floor weapons: manually placed WorldWeapons in level.tscn are removed at init on the + server (`_remove_manual_weapons_on_server`). Gear enters play via the shop or enemy drops only. +- The shop catalog is auto-discovered: any WeaponData `.tres` in `level/resources/` appears in + the armory (`outfitting_screen.gd`). + +### UI +- `character_sheet.gd` has two modes: live player (Tab in-game) and `preview_mode` (embedded in + the outfitting screen, rendering from a loadout dictionary). Keep both paths working. +- UI is largely built in code; match that style for new components. ### RPC Patterns -The project uses Godot 4's RPC system for multiplayer synchronization: -- Use `@rpc("any_peer", "reliable")` for critical data (health, damage) -- Use `@rpc("any_peer", "unreliable")` for frequent position updates -- The server (multiplayer.is_server()) is authoritative for game state -- Use `multiplayer.get_remote_sender_id()` to identify RPC sender -- Use `rpc_id(peer_id, "method_name")` for targeted RPCs +- `@rpc("any_peer", "reliable")` — server-validated actions (damage, heal); server checks + `multiplayer.is_server()` inside. +- `@rpc("any_peer", "call_local", "reliable")` — state sync to all peers (spawns, equips, health). +- `@rpc("authority", ...)` — server-initiated (GameState credits, wave sync). +- Use `rpc_id(peer_id, ...)` for targeted sync (late-join). When the server targets itself + (peer 1), call the method directly instead of `rpc_id(1, ...)`. +- Late-join sync lives in `level.gd _on_player_connected` — new networked object types must be + added there (players, weapons, enemies, orbs, equipped gear all have precedents). -### Scene Structure -- **level/scenes/level.tscn** - Main scene with menu UI, chat, and PlayersContainer - - level.gd spawns players dynamically when they connect -- **level/scenes/player.tscn** - Player character scene - - Instantiated by server when players connect - - Named with peer_id for easy lookup +### Scene Structure (level.tscn) +- Containers the code expects by name: `PlayersContainer`, `WeaponsContainer`, `EnemiesContainer`, + `OrbsContainer`, `PlayerSpawnPoints`, `EnemySpawner` (spawn points are its children named + `EnemySpawnPoint*`). +- Networked nodes are named deterministically for lookup: players by peer_id, `WorldWeapon_`, + `Enemy__`, `ArmedEnemy_`, `HealthOrb_`. ### Physics Layers -- Layer 1: "player" - Player collision -- Layer 2: "world" - Environment collision +1 player · 2 world · 3 weapon · 4 hitbox · 5 hurtbox -### Input Actions -Defined in project.godot: -- `move_left`, `move_right`, `move_forward`, `move_backward` (WASD) -- `jump` (Space) -- `shift` (Left Shift for sprinting) -- `quit` (Escape) -- `toggle_chat` (Enter) +### Input Actions (project.godot) +`move_left/right/forward/backward` (WASD), `jump` (Space), `shift` (sprint), `dash` (F), +`attack` (LMB), `block` (RMB), `pickup` (E), `toggle_character_sheet` (Tab), +`toggle_chat` (F12), `quit` (Esc). ## Development Guidelines ### Git Commits -When creating git commits, do NOT include "🤖 Generated with [Claude Code]" or "Co-Authored-By: Claude" in commit messages. Keep commit messages clean and professional. +When creating git commits, do NOT include "🤖 Generated with [Claude Code]" or +"Co-Authored-By: Claude" in commit messages. Keep commit messages clean and professional. -### Adding New Components -1. Create scripts in `level/scripts/` -2. For multiplayer-synchronized components: - - Always check `is_multiplayer_authority()` before processing input - - Use RPC for state changes that need to replicate - - Server should validate all important state changes +### Verification +- Always run `godot --headless --path . --quit` after script changes and check for SCRIPT ERROR. + (Pre-existing "invalid UID" warnings on mesh/theme assets are known noise.) +- Economy logic can be tested standalone: a SceneTree script that instantiates + `game_state.gd` and exercises the API (`--script` mode; autoload `_ready` doesn't fire there, + call `load_save()` manually). Restore the default save afterwards. +- Test multiplayer with 2 instances (host + client); check late joins and death/retire flows. -### Adding New Unit Types (Enemies, NPCs) -1. Create a new class that extends BaseUnit -2. Implement required virtual methods -3. Add any unique behavior in _physics_process/_process -4. Ensure multiplayer authority is set correctly - -### Testing Multiplayer -- Always test with at least 2 instances (1 host, 1 client) -- Test edge cases: late joins, disconnections, packet loss -- Verify state is synchronized correctly across all clients - -## Common Patterns - -### Spawning Networked Entities -```gdscript -# Server-side only -if multiplayer.is_server(): - var entity = entity_scene.instantiate() - entity.name = str(unique_id) # Name with ID for easy lookup - entity.set_multiplayer_authority(owner_peer_id) - container.add_child(entity, true) # true = force readable name -``` - -### Multiplayer Authority Check -```gdscript -func _physics_process(delta): - if not is_multiplayer_authority(): - return - # Only the authority processes input/logic -``` - -### RPC for State Changes -```gdscript -@rpc("any_peer", "reliable") -func take_damage(amount: int, attacker_id: int): - if not multiplayer.is_server(): - return # Server validates - # Apply damage logic - rpc("sync_health", current_health) # Broadcast to all -``` +### Adding Content +- **New weapon**: create a WeaponData `.tres` in `level/resources/` with `cost`/`tier` — the shop + picks it up automatically. +- **New enemy**: extend BaseEnemy, set `gold_reward`, decide `can_respawn` (respawning enemies + must not pay gold), add scene to the EnemySpawner pool or spawn via level.gd (connect its + `died` signal for gold there). +- **Multiplayer state changes**: server validates, then syncs via RPC; check + `is_multiplayer_authority()` before processing input. diff --git a/level/scripts/game_state.gd b/level/scripts/game_state.gd index 09131c4..c420a21 100644 --- a/level/scripts/game_state.gd +++ b/level/scripts/game_state.gd @@ -39,6 +39,11 @@ signal loadout_changed(loadout: Dictionary) func _ready() -> void: load_save() +func _notification(what: int) -> void: + # Closing the window mid-run counts as walking out alive - bank the winnings + if what == NOTIFICATION_WM_CLOSE_REQUEST and run_active: + retire_run() + # --- Persistence --------------------------------------------------------- func load_save() -> void: @@ -186,6 +191,31 @@ func end_run() -> Dictionary: run_ended.emit(last_run_stats) return last_run_stats +## Walk out of the arena alive: bank 100% of run gold, keep all gear. +## The reward for retiring instead of dying (death only keeps 10%). +func retire_run() -> Dictionary: + if not run_active: + return last_run_stats + run_active = false + + last_run_stats = { + "waves": current_wave, + "kills": kills_this_run, + "time": get_run_time(), + "gold_earned": run_gold, + "total_value": get_total_value(), + "kept": banked_gold + run_gold, + "retired": true, + } + + banked_gold += run_gold + run_gold = 0 + gold_changed.emit(banked_gold) + run_gold_changed.emit(run_gold) + save_game() + run_ended.emit(last_run_stats) + return last_run_stats + # --- Multiplayer crediting (server -> owning peer) ------------------------ # GameState is an autoload, so it exists at the same path on every peer with # the server (peer 1) as its multiplayer authority. The server decides who diff --git a/level/scripts/level.gd b/level/scripts/level.gd index d30186a..1675240 100644 --- a/level/scripts/level.gd +++ b/level/scripts/level.gd @@ -100,11 +100,9 @@ func initialize_multiplayer(): Network.connect("player_connected", Callable(self, "_on_player_connected")) multiplayer.peer_disconnected.connect(_remove_player) - # Initialize any manually placed weapons in the scene - _initialize_manual_weapons() - - # Spawn initial weapons when server starts - _spawn_initial_weapons() + # Remove any manually placed weapons - free floor loot undermines the shop. + # Weapons only enter the arena via the armory or dropped by slain enemies. + _remove_manual_weapons_on_server() # Spawn practice dummies _spawn_practice_dummies() @@ -154,81 +152,25 @@ func _cleanup_manual_weapons_on_client(): weapons_container.remove_child(weapon) weapon.free() -func _initialize_manual_weapons(): - """Initialize any WorldWeapon nodes manually placed in the level scene""" +func _remove_manual_weapons_on_server(): + """Remove WorldWeapon nodes manually placed in level.tscn (server side). + Clients already remove their local copies in _cleanup_manual_weapons_on_client. + The arena economy requires gear to be bought or looted from slain enemies.""" if not multiplayer.is_server(): return if not weapons_container: return - # Find all WorldWeapon nodes in the weapons container - var manual_weapons = [] + var removed = 0 for child in weapons_container.get_children(): - if child is WorldWeapon: - manual_weapons.append(child) + if child is WorldWeapon and child.weapon_id == -1: + weapons_container.remove_child(child) + child.free() + removed += 1 - if manual_weapons.is_empty(): - print("[Server] No manually placed weapons found") - return - - print("[Server] Found ", manual_weapons.size(), " manually placed weapon(s)") - - # Initialize each manually placed weapon - for weapon in manual_weapons: - # Skip if already initialized (weapon_id != -1) - if weapon.weapon_id != -1: - continue - - # Assign unique ID - _weapon_spawn_counter += 1 - weapon.weapon_id = _weapon_spawn_counter - - # Set deterministic name for networking - var old_name = weapon.name - weapon.name = "WorldWeapon_" + str(weapon.weapon_id) - - # Track in active weapons - _active_weapons[weapon.weapon_id] = weapon - - # Connect cleanup signal - weapon.tree_exiting.connect(_on_weapon_removed.bind(weapon.weapon_id)) - - print("[Server] Initialized manual weapon '", old_name, "' with ID: ", weapon.weapon_id, " at position: ", weapon.global_position) - - # Verify weapon_data is set - if not weapon.weapon_data: - push_error("Manual weapon '", old_name, "' has no WeaponData assigned!") - continue - -func _spawn_initial_weapons(): - if not multiplayer.is_server(): - return - - # Wait a frame for everything to be ready - await get_tree().process_frame - - print("[Server] _spawn_initial_weapons - Connected peers: ", multiplayer.get_peers()) - - # Spawn a sword - _weapon_spawn_counter += 1 - print("[Server] Calling RPC to spawn sword with ID: ", _weapon_spawn_counter) - rpc("spawn_world_weapon", - "res://level/resources/weapon_sword.tres", - Vector3(5, 1, 0), - Vector3.ZERO, - _weapon_spawn_counter - ) - - # Spawn a shield - _weapon_spawn_counter += 1 - print("[Server] Calling RPC to spawn shield with ID: ", _weapon_spawn_counter) - rpc("spawn_world_weapon", - "res://level/resources/weapon_shield.tres", - Vector3(-5, 1, 0), - Vector3.ZERO, - _weapon_spawn_counter - ) + if removed > 0: + print("[Server] Removed ", removed, " manually placed floor weapon(s)") func _on_player_connected(peer_id, player_info): print("[Server] _on_player_connected called for peer ", peer_id, " with info: ", player_info) diff --git a/level/ui/scripts/escape_menu.gd b/level/ui/scripts/escape_menu.gd index 11ef55b..e924772 100644 --- a/level/ui/scripts/escape_menu.gd +++ b/level/ui/scripts/escape_menu.gd @@ -9,11 +9,27 @@ var is_visible: bool = false # Player reference (to control mouse mode) var player: Character = null +var _retire_button: Button = null + func _ready(): # Start hidden hide() is_visible = false + # Insert "Retire to Camp" between Resume and Exit - leave alive, bank everything + var vbox = get_node_or_null("Panel/MarginContainer/VBoxContainer") + var exit_button = get_node_or_null("Panel/MarginContainer/VBoxContainer/ExitButton") + if vbox and exit_button: + _retire_button = Button.new() + _retire_button.name = "RetireButton" + _retire_button.custom_minimum_size = Vector2(0, 50) + _retire_button.add_theme_color_override("font_color", Color.WHITE) + _retire_button.add_theme_color_override("font_hover_color", Color(1, 0.8, 0)) + _retire_button.add_theme_font_size_override("font_size", 20) + _retire_button.pressed.connect(_on_retire_pressed) + vbox.add_child(_retire_button) + vbox.move_child(_retire_button, exit_button.get_index()) + func _input(event): # Toggle on Escape press if event.is_action_pressed("quit"): @@ -29,6 +45,7 @@ func toggle_menu(): is_visible = !is_visible if is_visible: show() + _update_retire_button() # Release mouse when opening menu Input.mouse_mode = Input.MOUSE_MODE_VISIBLE else: @@ -37,10 +54,35 @@ func toggle_menu(): if player and player.is_multiplayer_authority(): Input.mouse_mode = Input.MOUSE_MODE_CAPTURED +## Retiring is only possible while alive with an active run +func _update_retire_button(): + if not _retire_button: + return + var can_retire = GameState.run_active and player and not player.is_dead + _retire_button.visible = can_retire + if can_retire: + _retire_button.text = "Retire to Camp (+%dg)" % GameState.run_gold + ## Called when Resume button is clicked func _on_resume_pressed(): toggle_menu() +## Walk out alive: bank all run gold, keep gear, return to the menu +func _on_retire_pressed(): + GameState.retire_run() + + multiplayer.multiplayer_peer = null + Network.players.clear() + + if has_node("/root/HUD"): + get_node("/root/HUD").reset() + + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + get_tree().reload_current_scene() + ## Called when Exit button is clicked func _on_exit_pressed(): + # Leaving alive still banks your winnings (GameState also catches window close) + if GameState.run_active and player and not player.is_dead: + GameState.retire_run() get_tree().quit() diff --git a/level/ui/scripts/hud_manager.gd b/level/ui/scripts/hud_manager.gd index cff03b1..e5ca554 100644 --- a/level/ui/scripts/hud_manager.gd +++ b/level/ui/scripts/hud_manager.gd @@ -11,6 +11,9 @@ var tab_hint: Control = null var keybind_hint: Control = null var escape_menu: Control = null var run_results: Control = null +var arena_status: Control = null +var _wave_label: Label = null +var _gold_label: Label = null # Player reference var local_player: Character = null @@ -81,6 +84,7 @@ func _create_ui_components(): _create_tab_hint() _create_keybind_hint() _create_escape_menu() + _create_arena_status() ## Create action bar at bottom of screen func _create_action_bar(): @@ -150,6 +154,57 @@ func _create_escape_menu(): else: push_error("[HUD] Failed to load escape_menu.tscn") +## Create the arena status readout (wave + run gold, top center) +func _create_arena_status(): + arena_status = PanelContainer.new() + arena_status.name = "ArenaStatus" + arena_status.set_anchors_preset(Control.PRESET_CENTER_TOP) + arena_status.offset_top = 10 + arena_status.grow_horizontal = Control.GROW_DIRECTION_BOTH + add_child(arena_status) + + var margin = MarginContainer.new() + margin.add_theme_constant_override("margin_left", 18) + margin.add_theme_constant_override("margin_right", 18) + margin.add_theme_constant_override("margin_top", 6) + margin.add_theme_constant_override("margin_bottom", 6) + arena_status.add_child(margin) + + var hbox = HBoxContainer.new() + hbox.add_theme_constant_override("separation", 24) + margin.add_child(hbox) + + _wave_label = Label.new() + _wave_label.add_theme_font_size_override("font_size", 22) + _wave_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.0)) + _wave_label.add_theme_color_override("font_outline_color", Color.BLACK) + _wave_label.add_theme_constant_override("outline_size", 2) + hbox.add_child(_wave_label) + + _gold_label = Label.new() + _gold_label.add_theme_font_size_override("font_size", 22) + _gold_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.2)) + _gold_label.add_theme_color_override("font_outline_color", Color.BLACK) + _gold_label.add_theme_constant_override("outline_size", 2) + hbox.add_child(_gold_label) + + # Live updates from the economy + GameState.wave_changed.connect(_on_wave_changed) + GameState.run_gold_changed.connect(_on_run_gold_changed) + + # Seed with current values (run may already be underway) + _on_wave_changed(GameState.current_wave) + _on_run_gold_changed(GameState.run_gold) + print("[HUD] Arena status created") + +func _on_wave_changed(wave: int): + if _wave_label: + _wave_label.text = "Wave %d" % wave if wave > 0 else "Prepare..." + +func _on_run_gold_changed(gold: int): + if _gold_label: + _gold_label.text = "Gold +%d" % gold + ## Show all UI components func show_hud(): show() @@ -165,6 +220,13 @@ func reset(): _disconnect_player_signals() local_player = null + # Drop GameState connections so they don't fire on freed labels + # (and don't double-connect when the next run creates a new status bar) + if GameState.wave_changed.is_connected(_on_wave_changed): + GameState.wave_changed.disconnect(_on_wave_changed) + if GameState.run_gold_changed.is_connected(_on_run_gold_changed): + GameState.run_gold_changed.disconnect(_on_run_gold_changed) + for child in get_children(): child.queue_free() @@ -176,6 +238,9 @@ func reset(): keybind_hint = null escape_menu = null run_results = null + arena_status = null + _wave_label = null + _gold_label = null print("[HUD] Reset - all UI components removed") ## Player signal callbacks