extends Node2D

# Game World - Main game scene that manages the gameplay

@onready var player_manager = $PlayerManager
@onready var camera = $Camera2D
@onready var network_manager = $"/root/NetworkManager"
@onready var bg_music: AudioStreamPlayer = $BgMusic
@onready var canvas_modulate: CanvasModulate = $CanvasModulate
@onready var environment: Node2D = $Environment

# Screenshake system
var screenshake_offset: Vector2 = Vector2.ZERO
var screenshake_timer: float = 0.0
var screenshake_duration: float = 0.0
var screenshake_strength: float = 0.0

var local_players = []
const BASE_CAMERA_ZOOM: float = 4.0
const BASE_CAMERA_ZOOM_MOBILE: float = 5.5 # More zoomed in for mobile devices
const REFERENCE_ASPECT: float = 16.0 / 9.0

# Mouse cursor system
var cursor_sprite: Sprite2D = null # Free movement cursor (frame 0)
var grid_cursor_sprite: Sprite2D = null # Grid-locked cursor (frame 1)
var spell_cursor_sprite: Sprite2D = null # Spell targeting cursor (frame 1, tinted by element)
var cursor_layer: CanvasLayer = null
const CURSOR_LAYER_Z: int = 2000 # Very high Z index for cursor
var use_mouse_control: bool = true # Enable/disable mouse control
var camera_lerp_to_cursor: bool = false # Optional: lerp camera slightly toward cursor
const CURSOR_CAMERA_LERP_AMOUNT: float = 0.15 # How much camera lerps toward cursor (0.0 = none, 1.0 = full)
var cursor_pulse_time: float = 0.0 # Time accumulator for pulsing animation
const CURSOR_PULSE_SPEED: float = 3.0 # Speed of color pulse animation

# Fog of war
const FOG_TILE_SIZE: int = 16
const FOG_VIEW_RANGE_TILES: float = 10.0
const FOG_BACK_RANGE_TILES: float = 3.0
const FOG_RAY_STEP: float = 0.5
const FOG_RAY_ANGLE_STEP: int = 10
const FOG_UPDATE_INTERVAL: float = 0.1
const FOG_UPDATE_INTERVAL_CORRIDOR: float = 0.25 # Slower update in corridors to reduce lag
const FOG_DEBUG_DRAW: bool = false
var fog_update_timer: float = 0.0
var peer_cleanup_timer: float = 0.0
const PEER_CLEANUP_INTERVAL: float = 0.5 # Check every 0.5 seconds

# Tab visibility and buffer overflow protection
var was_tab_visible: bool = true
var tab_inactive_time: float = 0.0
var last_tab_state_change: int = 0 # Time of last tab state change (for debouncing)
const TAB_STATE_DEBOUNCE_MS: int = 500 # Debounce tab state changes (500ms)

# Entrance walk-out system
var players_walking_out: Array = [] # Players currently walking out of entrance
var entrance_walk_out_complete: bool = false # True when all players have exited entrance
var last_sound_play_time: Dictionary = {} # sound_name -> time
const SOUND_RATE_LIMIT: float = 0.05 # Only play same sound every 50ms (20 sounds/second max)
const MAX_BUFFER_SIZE: int = 2 * 1024 * 1024 # 2MB buffer threshold
var last_buffer_check_time: float = 0.0
const BUFFER_CHECK_INTERVAL: float = 0.1 # Check buffer every 100ms
var client_buffer_states: Dictionary = {} # peer_id -> {buffered: int, last_check: float, skip_until: float}
const CLIENT_BUFFER_CHECK_INTERVAL: float = 0.1 # Check client buffers every 100ms (more frequent)
const CLIENT_BUFFER_SKIP_THRESHOLD: int = 1024 * 256 # Skip sending if buffer > 256KB (lower threshold to catch earlier)
const CLIENT_BUFFER_SKIP_DURATION: float = 3.0 # Skip sending for 3 seconds if buffer is full (longer duration)
var fog_node: Node2D = null
var cached_corridor_mask: PackedInt32Array = PackedInt32Array()
var cached_corridor_rooms: Array = []
var cached_corridor_player_tile: Vector2i = Vector2i(-1, -1)
var cached_corridor_allowed_room_ids: Dictionary = {}
var was_in_corridor: bool = false # Track previous corridor state to detect transitions
var last_corridor_fog_update: float = 0.0 # Time of last fog update in corridor

# Torch-based CanvasModulate: only recalc on room/corridor transition, lerp over time
var _torch_darken_initialized: bool = false
var _torch_darken_in_room_last: bool = false
var _torch_darken_last_room_id: String = ""
var _torch_darken_target_scale: float = 1.0
var _torch_darken_current_scale: float = 1.0
const _TORCH_DARKEN_LERP_SPEED: float = 4.0
const _TORCH_DARKEN_MIN_SCALE: float = 0.15 # Never go below this; allows player light to punch through
var seen_by_player: Dictionary = {} # player_name -> PackedInt32Array (0 unseen, 1 seen)
var combined_seen: PackedInt32Array = PackedInt32Array()
var explored_map: PackedInt32Array = PackedInt32Array() # 0 unseen, 1 explored
var fog_debug_lines: Array = []

# Dungeon generation
var dungeon_data: Dictionary = {}
var dungeon_tilemap_layer: TileMapLayer = null
var dungeon_tilemap_layer_above: TileMapLayer = null
var current_level: int = 1
var dungeon_seed: int = 0

# Chunked dungeon sync state (client only)
var dungeon_sync_metadata: Dictionary = {} # map_size, seed, level, host_room
var dungeon_sync_chunks: Dictionary = {} # chunk_idx -> {tile_grid_chunk, grid_chunk}
var dungeon_sync_rooms: Dictionary = {} # start_room, rooms
var dungeon_sync_total_chunks: int = 0
var dungeon_sync_received_chunks: int = 0
var dungeon_sync_complete: bool = false

# Level stats tracking
var level_enemies_defeated: int = 0
var level_times_downed: int = 0
var level_exp_collected: float = 0.0
var level_coins_collected: int = 0

# Client ready tracking (server only)
var clients_ready: Dictionary = {} # peer_id -> bool

# Track dungeon syncs in progress (server only) - prevent multiple simultaneous syncs
var dungeon_sync_in_progress: Dictionary = {} # peer_id -> bool

# Track chunk acknowledgments (server only) - for flow control
var dungeon_chunk_acks: Dictionary = {} # peer_id -> {chunk_idx -> bool, next_chunk_to_send -> int, chunks_data -> Array}

# Pre-packed dungeon blob (server only) - packed once, sent many times
var dungeon_blob_chunks: Array = [] # Array of PackedByteArray chunks (<16KB each)
var dungeon_blob_metadata: Dictionary = {} # Static metadata: seed, level, map_size (host_room collected dynamically)

# Level complete tracking
var level_complete_triggered: bool = false # Prevent multiple level complete triggers
var game_over_triggered: bool = false # Prevent multiple game over triggers

# Server-authoritative "all dead" respawn: avoid host/joiner desync (only joiner or only host respawning)
signal respawn_all_ready
var dead_players: Dictionary = {} # player_name -> true; server only
var respawn_all_check_running: bool = false

# Track broken interactable objects (object_index -> true) for syncing to new clients
var broken_objects: Dictionary = {} # object_index -> true

# Track defeated enemies (enemy_index -> true) for syncing to new clients
var defeated_enemies: Dictionary = {} # enemy_index -> true

# Track opened chests (object_index -> true) for syncing to new clients
var opened_chests: Dictionary = {} # object_index -> true

# Track activated floor switches (switch_name -> true) for syncing to new clients
var activated_switches: Dictionary = {} # switch_name -> true

# Track door states (door_name -> state_dict) for syncing to new clients
var door_states: Dictionary = {} # door_name -> {"is_closed": bool, "puzzle_solved": bool, "key_used": bool}

# Track which clients have GameWorld ready (server only)
var client_gameworld_ready: Dictionary = {} # peer_id -> bool
var client_gameworld_sync_pending: Dictionary = {} # peer_id -> local_count

# Pending interactable state syncs (client only)
var pending_interactable_states: Dictionary = {} # obj_name -> {pos, vel, z_pos, z_vel, airborne}

# Pending door state syncs (client only)
var pending_door_states: Dictionary = {} # door_name -> {open: bool, close: bool, puzzle_solved: bool}

# Pending chest open syncs (client only)
var pending_chest_opens: Dictionary = {} # chest_name -> {loot_type: String, player_peer_id: int}

func _ready():
	# Add to group for easy access
	add_to_group("game_world")
	
	# Connect network signals
	if network_manager:
		network_manager.player_connected.connect(_on_player_connected)
		network_manager.player_disconnected.connect(_on_player_disconnected)
	
	# Create chat UI
	_create_chat_ui()
	
	# Create inventory UI
	_create_inventory_ui()
	
	# Layer for damage numbers / floating text (not affected by CanvasModulate)
	_load_floating_text_layer()
	
	# Initialize mouse cursor system
	_init_mouse_cursor()
	
	# Generate dungeon on host only
	# Only generate if we're the server (not just "no multiplayer peer")
	# This prevents clients from generating their own dungeon before connecting
	if multiplayer.is_server():
		LogManager.log("GameWorld: _ready() - Will generate dungeon (is_server: true)", LogManager.CATEGORY_DUNGEON)
		call_deferred("_generate_dungeon")
	else:
		# Client or single-player mode
		if multiplayer.has_multiplayer_peer():
			LogManager.log("GameWorld: _ready() - Client connected, will wait for dungeon sync", LogManager.CATEGORY_DUNGEON)
		else:
			# Single-player mode (no multiplayer peer) - generate dungeon
			LogManager.log("GameWorld: _ready() - Single-player mode, will generate dungeon", LogManager.CATEGORY_DUNGEON)
			call_deferred("_generate_dungeon")
		# Clients spawn players immediately (they'll be moved when dungeon syncs)
		if multiplayer.has_multiplayer_peer():
			call_deferred("_spawn_all_players")
			# Initialize camera after a short delay to ensure players are spawned
			# This prevents black screen before dungeon sync arrives
			get_tree().create_timer(0.1).timeout.connect(func():
				if is_inside_tree():
					_update_camera()
			)
	
	# Notify server that GameWorld is ready (client only)
	if multiplayer.has_multiplayer_peer() and not multiplayer.is_server():
		call_deferred("_send_gameworld_ready")
	
	# Peer cleanup is handled in _process()

func _send_gameworld_ready():
	# Client notifies server that GameWorld is ready to receive RPCs
	if not is_inside_tree():
		LogManager.log("GameWorld: Cannot send gameworld_ready - not in tree", LogManager.CATEGORY_NETWORK)
		return
	if multiplayer.has_multiplayer_peer() and not multiplayer.is_server():
		var peers = multiplayer.get_peers()
		
		# On web, multiplayer.get_peers() might never include peer 1 even though Matchbox connection is ready
		# Check Matchbox connection state instead if available
		var matchbox_connected = false
		if network_manager and network_manager.matchbox_client:
			var matchbox = network_manager.matchbox_client
			var pc = matchbox.peer_connections.get(1) if "peer_connections" in matchbox else null
			if pc:
				var connection_state = pc.get_connection_state()
				var signaling_state = pc.get_signaling_state()
				# Matchbox connection is ready if signaling is STABLE (0) or connection is CONNECTING/CONNECTED
				matchbox_connected = (signaling_state == 0 or connection_state >= 1)
				LogManager.log("GameWorld: Matchbox connection state for peer 1: connection=" + str(connection_state) + ", signaling=" + str(signaling_state) + ", matchbox_connected=" + str(matchbox_connected), LogManager.CATEGORY_NETWORK)
		
		var is_peer_in_list = (1 in peers)
		LogManager.log("GameWorld: Client sending gameworld_ready, peers: " + str(peers) + ", matchbox_connected: " + str(matchbox_connected), LogManager.CATEGORY_NETWORK)
		
		# If peer is in multiplayer list, send RPC immediately
		# If not, but Matchbox connection is ready, try sending anyway (might work on web)
		# Otherwise, retry
		if not is_peer_in_list and not matchbox_connected:
			# Retry - on web, this can take several seconds
			var retry_count = get_meta("gameworld_ready_retry_count", 0)
			if retry_count < 50: # Try up to 50 times (10 seconds total)
				set_meta("gameworld_ready_retry_count", retry_count + 1)
				LogManager.log("GameWorld: Host peer (1) not in peers and Matchbox not connected yet, retrying (" + str(retry_count + 1) + "/50)...", LogManager.CATEGORY_NETWORK)
				get_tree().create_timer(0.2).timeout.connect(func():
					if is_instance_valid(self) and is_inside_tree():
						_send_gameworld_ready()
				)
				return
			else:
				# After max retries, log warning and continue retrying (but less frequently)
				LogManager.log("GameWorld: Max retries reached for gameworld_ready, continuing with slower retries (every 1 second)...", LogManager.CATEGORY_NETWORK)
				get_tree().create_timer(1.0).timeout.connect(func():
					if is_instance_valid(self) and is_inside_tree():
						_send_gameworld_ready()
				)
				return
		
		# Peer is in list OR Matchbox connection is ready - try to send RPC
		var peer_id = multiplayer.get_unique_id()
		
		# On web, if peer isn't in get_peers(), the RPC will always fail
		# So we only try to send if peer is in list, otherwise rely on server's proactive sync
		if is_peer_in_list:
			LogManager.log("GameWorld: Sending _notify_gameworld_ready to server (peer 1) from client " + str(peer_id), LogManager.CATEGORY_NETWORK)
			_notify_gameworld_ready.rpc_id(1, peer_id)
			# Reset retry count on success
			remove_meta("gameworld_ready_retry_count")
		else:
			# Peer not in list - RPC will fail on web, so don't try
			# Server will proactively send dungeon sync after 3 seconds via fallback
			LogManager.log("GameWorld: Peer not in multiplayer list (web issue) - skipping RPC, server will sync proactively", LogManager.CATEGORY_NETWORK)
			# Still reset retry count since we're giving up on RPC
			remove_meta("gameworld_ready_retry_count")

@rpc("any_peer", "reliable")
func _notify_gameworld_ready(peer_id: int):
	# Server receives notification that client's GameWorld is ready
	if not multiplayer.is_server():
		return
	client_gameworld_ready[peer_id] = true
	# If we were waiting to sync this client, do it now
	if client_gameworld_sync_pending.has(peer_id):
		var local_count = client_gameworld_sync_pending[peer_id]
		client_gameworld_sync_pending.erase(peer_id)
		_send_initial_client_sync(peer_id, local_count)

func _spawn_all_players():
	LogManager.log("GameWorld: Spawning all players. Server: " + str(multiplayer.is_server()), LogManager.CATEGORY_GAMEPLAY)
	LogManager.log("GameWorld: Players info: " + str(network_manager.players_info), LogManager.CATEGORY_GAMEPLAY)
	
	# Only spawn on server initially - clients will spawn via RPC
	if multiplayer.is_server():
		for peer_id in network_manager.players_info.keys():
			var info = network_manager.players_info[peer_id]
			LogManager.log("GameWorld: Server spawning " + str(info.local_player_count) + " players for peer " + str(peer_id), LogManager.CATEGORY_GAMEPLAY)
			player_manager.spawn_players_for_peer(peer_id, info.local_player_count)

func _on_player_connected(peer_id: int, player_info: Dictionary):
	LogManager.log("GameWorld: Player connected signal received for peer " + str(peer_id) + " with info: " + str(player_info), LogManager.CATEGORY_GAMEPLAY)
	
	# Send join message to chat (only on server to avoid duplicates)
	if multiplayer.is_server():
		_send_player_join_message(peer_id, player_info)
	
	# Reset ready status for this peer (they need to notify again after spawning)
	if multiplayer.is_server():
		clients_ready[peer_id] = false
		# Reset all_clients_ready flag for all server players when a new client connects
		_reset_server_players_ready_flag()
	
	if multiplayer.is_server():
		# Always spawn new joiners in the original start room
		var start_room = dungeon_data.start_room if not dungeon_data.is_empty() and dungeon_data.has("start_room") else {}
		if start_room and not start_room.is_empty():
			LogManager.log("GameWorld: Using start room for new joiner spawn (" + str(start_room.x) + ", " + str(start_room.y) + ")", LogManager.CATEGORY_GAMEPLAY)
			_update_spawn_points(start_room)
		else:
			LogManager.log("GameWorld: Could not find start room, using default spawn points", LogManager.CATEGORY_DUNGEON)
			_update_spawn_points() # Use start room as fallback
		
		# Server spawns locally
		LogManager.log("GameWorld: Server spawning players for peer " + str(peer_id), LogManager.CATEGORY_GAMEPLAY)
		player_manager.spawn_players_for_peer(peer_id, player_info.local_player_count)
		
		# Wait for client GameWorld to be ready before sending RPCs
		# On web, _notify_gameworld_ready RPC might fail, so we proactively send sync after a delay
		if client_gameworld_ready.get(peer_id, false):
			LogManager.log("GameWorld: Client " + str(peer_id) + " already marked as ready, sending sync immediately", LogManager.CATEGORY_NETWORK)
			_send_initial_client_sync(peer_id, player_info.local_player_count)
		else:
			client_gameworld_sync_pending[peer_id] = player_info.local_player_count
			_schedule_gameworld_ready_check(peer_id, player_info.local_player_count)
			# On web, RPC might fail, so also schedule a proactive sync after delay
			# This ensures clients get dungeon sync even if _notify_gameworld_ready fails
			# For WebAssembly, we use shorter delays and multiple attempts
			LogManager.log("GameWorld: Scheduling proactive sync for peer " + str(peer_id) + " (web workaround - multiple attempts)", LogManager.CATEGORY_NETWORK)
			
			# Try sync after 1 second (quick attempt)
			get_tree().create_timer(1.0).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					if not client_gameworld_ready.get(peer_id, false):
						LogManager.log("GameWorld: Proactive sync attempt 1 for peer " + str(peer_id), LogManager.CATEGORY_NETWORK)
						client_gameworld_ready[peer_id] = true
						_send_initial_client_sync_with_retry(peer_id, player_info.local_player_count)
			)
			
			# Try sync after 3 seconds (backup attempt)
			get_tree().create_timer(3.0).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					if not client_gameworld_ready.get(peer_id, false):
						LogManager.log("GameWorld: Proactive sync attempt 2 for peer " + str(peer_id) + " (RPC notification may have failed on web)", LogManager.CATEGORY_NETWORK)
						client_gameworld_ready[peer_id] = true
						_send_initial_client_sync_with_retry(peer_id, player_info.local_player_count)
					else:
						LogManager.log("GameWorld: Proactive sync skipped for peer " + str(peer_id) + " (already synced)", LogManager.CATEGORY_NETWORK)
			)
			
			# Try sync after 5 seconds (last resort for web)
			get_tree().create_timer(5.0).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					if not client_gameworld_ready.get(peer_id, false):
						LogManager.log("GameWorld: Proactive sync attempt 3 (last resort) for peer " + str(peer_id), LogManager.CATEGORY_NETWORK)
						client_gameworld_ready[peer_id] = true
						_send_initial_client_sync_with_retry(peer_id, player_info.local_player_count)
			)
	else:
		# Clients spawn directly when they receive this signal
		LogManager.log("GameWorld: Client spawning players for peer " + str(peer_id), LogManager.CATEGORY_GAMEPLAY)
		player_manager.spawn_players_for_peer(peer_id, player_info.local_player_count)

func _schedule_gameworld_ready_check(peer_id: int, local_count: int, attempts: int = 0):
	# Server waits for client GameWorld to be ready before sending RPCs
	if not multiplayer.is_server():
		return
	
	if client_gameworld_ready.get(peer_id, false):
		_send_initial_client_sync(peer_id, local_count)
		return
	
	if attempts >= 50:
		LogManager.log("GameWorld: Timed out waiting for client GameWorld ready for peer " + str(peer_id), LogManager.CATEGORY_NETWORK)
		return
	
	get_tree().create_timer(0.2).timeout.connect(func():
		if is_instance_valid(self) and is_inside_tree():
			_schedule_gameworld_ready_check(peer_id, local_count, attempts + 1)
	)

func _send_initial_client_sync(peer_id: int, local_count: int):
	# Send initial dungeon and world state to a client (server only)
	if not multiplayer.is_server():
		return
	
	if not client_gameworld_ready.get(peer_id, false):
		return
	
	# Use retry mechanism for web compatibility
	_send_initial_client_sync_with_retry(peer_id, local_count)

func _send_initial_client_sync_with_retry(peer_id: int, local_count: int, retry_count: int = 0):
	# Send initial dungeon and world state to a client with retry logic for web
	if not multiplayer.is_server():
		return
	
	# Check if peer is recognized in multiplayer system
	var peers = multiplayer.get_peers()
	var is_peer_recognized = (peer_id in peers)
	
	# On web, peer might not be recognized even though Matchbox connection is ready
	# Check Matchbox connection state as fallback
	var matchbox_connected = false
	if network_manager and network_manager.matchbox_client:
		var matchbox = network_manager.matchbox_client
		var pc = matchbox.peer_connections.get(peer_id) if "peer_connections" in matchbox else null
		if pc:
			var connection_state = pc.get_connection_state()
			var signaling_state = pc.get_signaling_state()
			matchbox_connected = (signaling_state == 0 or connection_state >= 1)
	
	# If peer not recognized and Matchbox not connected, retry
	if not is_peer_recognized and not matchbox_connected:
		if retry_count < 15: # Try up to 15 times (3 seconds total) - reduced from 30
			LogManager.log("GameWorld: Peer " + str(peer_id) + " not recognized yet, retrying sync (" + str(retry_count + 1) + "/15)...", LogManager.CATEGORY_NETWORK)
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_send_initial_client_sync_with_retry(peer_id, local_count, retry_count + 1)
			)
			return
		else:
			# After max retries, try anyway if Matchbox connection exists
			LogManager.log("GameWorld: Max retries reached for peer " + str(peer_id) + ", attempting sync anyway (Matchbox connection may be ready)", LogManager.CATEGORY_NETWORK)
	
	# Sync existing dungeon to the new client (if dungeon has been generated)
	if not dungeon_data.is_empty():
		LogManager.log("GameWorld: Syncing existing dungeon to client " + str(peer_id) + " (is_peer_recognized: " + str(is_peer_recognized) + ", matchbox_connected: " + str(matchbox_connected) + ")", LogManager.CATEGORY_GAMEPLAY)
		var host_room = _get_host_room()
		# Defer the RPC call to ensure the peer is registered in the multiplayer system
		# On web, this might still fail, but we'll retry if needed
		call_deferred("_sync_dungeon_to_client", peer_id, dungeon_data, dungeon_seed, current_level, host_room)
	
	# Sync spawn to all clients - wait a bit for data channel to be ready
	# Use call_deferred with a small delay to ensure WebRTC data channel is open
	get_tree().create_timer(0.1).timeout.connect(func():
		if is_inside_tree():
			_rpc_to_ready_peers("_sync_spawn_player", [peer_id, local_count])
	)
	# Push existing players' state (equipment, inventory, race, appearance) to the new joiner
	# so they see the host's Wizard inventory (tomes, etc.). Delay so joiner has spawned player nodes first.
	get_tree().create_timer(0.4).timeout.connect(func():
		if is_inside_tree():
			_push_existing_players_state_to_client(peer_id)
	)
	
	# Sync broken interactable objects to the new client
	# Wait a bit after dungeon sync to ensure objects are spawned first
	call_deferred("_sync_broken_objects_to_client", peer_id)

	# Sync existing enemies (from spawners) to the new client
	# Wait a bit after dungeon sync to ensure spawners are spawned first
	call_deferred("_sync_existing_enemies_to_client", peer_id)
	
	# Sync existing chest open states to the new client
	# Wait a bit after dungeon sync to ensure objects are spawned first
	call_deferred("_sync_existing_chest_states_to_client", peer_id)
	
	# Sync existing trap states (detected/disarmed) to the new client
	# Wait a bit after dungeon sync to ensure traps are spawned first
	call_deferred("_sync_existing_trap_states_to_client", peer_id)
	
	# Note: Dungeon-spawned enemies are already synced via _sync_dungeon RPC
	# which includes dungeon_data.enemies and calls _spawn_enemies() on the client.
	# So we don't need to sync them again with individual RPCs.
	
	# Note: Interactable objects are also synced via _sync_dungeon RPC
	# which includes dungeon_data.interactable_objects and calls _spawn_interactable_objects() on the client.
	# However, chest open states and broken objects need to be synced separately since they change during gameplay.
	# NOTE: Torches are already in dungeon_data and are spawned from the blob, so we don't need to sync them separately.
		
	# Sync door states to the new client (wait until after dungeon chunks)
	get_tree().create_timer(2.0).timeout.connect(func():
		if is_inside_tree():
			_sync_existing_door_states_to_client(peer_id)
	)
	
	# Sync defeated enemies to the new client (so they don't spawn)
	# Wait until after dungeon chunks
	get_tree().create_timer(2.0).timeout.connect(func():
		if is_inside_tree():
			_sync_defeated_enemies_to_client(peer_id)
	)
	
	# Sync existing loot on the ground to the new client
	get_tree().create_timer(2.0).timeout.connect(func():
		if is_inside_tree():
			_sync_existing_loot_to_client(peer_id)
	)
	
	# Sync activated floor switches to the new client
	get_tree().create_timer(2.0).timeout.connect(func():
		if is_inside_tree():
			_sync_activated_switches_to_client(peer_id)
	)
	
	# Sync interactable object positions (boxes that were moved/thrown)
	get_tree().create_timer(2.0).timeout.connect(func():
		if is_inside_tree():
			_sync_interactable_object_positions_to_client(peer_id)
	)

func _push_existing_players_state_to_client(peer_id: int) -> void:
	# Server: push each server-authority player's full state (equipment, inventory, race, appearance) to the new joiner.
	# Joiner otherwise never receives host's inventory because _sync_inventory was sent before joiner was "ready".
	if not multiplayer.is_server():
		return
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	var server_id = multiplayer.get_unique_id()
	for child in entities_node.get_children():
		if child.is_in_group("player") and child.get_multiplayer_authority() == server_id:
			if child.has_method("_push_full_state_to_peer"):
				child._push_full_state_to_peer(peer_id)
	LogManager.log("GameWorld: Pushed existing players state to client " + str(peer_id), LogManager.CATEGORY_NETWORK)

func _rpc_to_ready_peers(method: String, args: Array = []):
	# Send RPC to all clients whose GameWorld is ready
	if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server():
		return
	
	# Get peers list once to avoid multiple calls
	var peers = multiplayer.get_peers()
	
	for peer_id in peers:
		if client_gameworld_ready.get(peer_id, false):
			# Final check: verify peer is still in get_peers() right before sending
			# This minimizes the race condition window
			var current_peers = multiplayer.get_peers()
			if peer_id not in current_peers:
				client_gameworld_ready.erase(peer_id)
				continue
			
			# For WebRTC, verify data channels are actually open right before sending
			# This check happens immediately before the RPC to minimize race condition window
			if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
				var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer
				if not webrtc_peer.has_peer(peer_id):
					client_gameworld_ready.erase(peer_id)
					continue
				
				var peer_info = webrtc_peer.get_peer(peer_id)
				if not peer_info:
					client_gameworld_ready.erase(peer_id)
					continue
				
				# Check if data channels are connected (this is the critical check)
				var is_net_connected = peer_info.get("connected", false)
				if not is_net_connected:
					client_gameworld_ready.erase(peer_id)
					continue
				
				# Also check connection state to be extra safe
				var connection_obj = peer_info.get("connection")
				if connection_obj != null and typeof(connection_obj) == TYPE_INT:
					var connection_val = int(connection_obj)
					# Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED
					if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED
						client_gameworld_ready.erase(peer_id)
						continue
				
				# Also verify channels array to ensure channels are actually open
				# The "connected" flag might not update immediately when channels close
				var channels = peer_info.get("channels", [])
				if channels is Array and channels.size() > 0:
					var all_channels_open = true
					for channel in channels:
						if channel != null:
							# Check if channel has ready_state method
							if channel.has_method("get_ready_state"):
								var ready_state = channel.get_ready_state()
								# WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
								if ready_state != 1: # Not OPEN
									all_channels_open = false
									break
							elif "ready_state" in channel:
								var ready_state = channel.get("ready_state")
								if ready_state != 1: # Not OPEN
									all_channels_open = false
									break
					if not all_channels_open:
						client_gameworld_ready.erase(peer_id)
						continue
				
				# Also check matchbox_client connection state for additional verification
				if network_manager and network_manager.matchbox_client:
					var matchbox = network_manager.matchbox_client
					if "peer_connections" in matchbox:
						var pc = matchbox.peer_connections.get(peer_id)
						if pc and pc.has_method("get_connection_state"):
							var matchbox_conn_state = pc.get_connection_state()
							# Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED
							if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED
								client_gameworld_ready.erase(peer_id)
								continue
				
				# Check if client's buffer is full - skip sending if so
				if _should_skip_client_due_to_buffer(peer_id):
					continue # Skip sending to this client - their buffer is full
			
			# All checks passed, send RPC
			# Note: Even with all checks, there's still a tiny race condition window,
			# but this minimizes it significantly. If channel closes between check and send,
			# Godot will log the error but it won't crash.
			# Additional last-moment check: verify peer is still in current peers
			var final_peers = multiplayer.get_peers()
			if peer_id not in final_peers:
				client_gameworld_ready.erase(peer_id)
				continue
			
			# Try to send - if it fails, we'll detect it via periodic cleanup
			var _result = callv("rpc_id", [peer_id, method] + args)
			# Note: rpc_id doesn't return an error code in GDScript, so we rely on
			# periodic cleanup and the extensive pre-checks above to prevent most errors

func _rpc_node_to_ready_peers(node: Node, method: String, args: Array = []):
	# Send RPC from a node to all clients whose GameWorld is ready
	if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server():
		return
	if not node:
		return
	
	# Get peers list once to avoid multiple calls
	var peers = multiplayer.get_peers()
	
	for peer_id in peers:
		if client_gameworld_ready.get(peer_id, false):
			# Final check: verify peer is still in get_peers() right before sending
			# This minimizes the race condition window
			var current_peers = multiplayer.get_peers()
			if peer_id not in current_peers:
				client_gameworld_ready.erase(peer_id)
				continue
			
			# For WebRTC, verify data channels are actually open right before sending
			# This check happens immediately before the RPC to minimize race condition window
			if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
				var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer
				if not webrtc_peer.has_peer(peer_id):
					client_gameworld_ready.erase(peer_id)
					continue
				
				var peer_info = webrtc_peer.get_peer(peer_id)
				if not peer_info:
					client_gameworld_ready.erase(peer_id)
					continue
				
				# Check if data channels are connected (this is the critical check)
				var is_net_connected = peer_info.get("connected", false)
				if not is_net_connected:
					client_gameworld_ready.erase(peer_id)
					continue
				
				# Also check connection state to be extra safe
				var connection_obj = peer_info.get("connection")
				if connection_obj != null and typeof(connection_obj) == TYPE_INT:
					var connection_val = int(connection_obj)
					# Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED
					if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED
						client_gameworld_ready.erase(peer_id)
						continue
				
				# Also verify channels array to ensure channels are actually open
				# The "connected" flag might not update immediately when channels close
				var channels = peer_info.get("channels", [])
				if channels is Array and channels.size() > 0:
					var all_channels_open = true
					for channel in channels:
						if channel != null:
							# Check if channel has ready_state method
							if channel.has_method("get_ready_state"):
								var ready_state = channel.get_ready_state()
								# WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
								if ready_state != 1: # Not OPEN
									all_channels_open = false
									break
							elif "ready_state" in channel:
								var ready_state = channel.get("ready_state")
								if ready_state != 1: # Not OPEN
									all_channels_open = false
									break
					if not all_channels_open:
						client_gameworld_ready.erase(peer_id)
						continue
				
				# Also check matchbox_client connection state for additional verification
				if network_manager and network_manager.matchbox_client:
					var matchbox = network_manager.matchbox_client
					if "peer_connections" in matchbox:
						var pc = matchbox.peer_connections.get(peer_id)
						if pc and pc.has_method("get_connection_state"):
							var matchbox_conn_state = pc.get_connection_state()
							# Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED
							if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED
								client_gameworld_ready.erase(peer_id)
								continue
				
				# Check if client's buffer is full - skip sending if so (CRITICAL to prevent buffer overflow errors)
				if _should_skip_client_due_to_buffer(peer_id):
					continue # Skip sending to this client - their buffer is full
			
			# All checks passed, send RPC
			# Note: Even with all checks, there's still a tiny race condition window,
			# but this minimizes it significantly. If channel closes between check and send,
			# Godot will log the error but it won't crash.
			# Additional last-moment check: verify peer is still in current peers
			var final_peers = multiplayer.get_peers()
			if peer_id not in final_peers:
				client_gameworld_ready.erase(peer_id)
				continue
			
			# Try to send - if it fails, we'll detect it via periodic cleanup
			var _result = node.callv("rpc_id", [peer_id, method] + args)
			# Note: rpc_id doesn't return an error code in GDScript, so we rely on
			# periodic cleanup and the extensive pre-checks above to prevent most errors

func _is_peer_connected(peer_id: int) -> bool:
	"""Check if a peer is still connected and has open data channels"""
	if not multiplayer.has_multiplayer_peer():
		return false
	
	# Check if peer is in get_peers()
	var peers = multiplayer.get_peers()
	if peer_id not in peers:
		return false
	
	# For WebRTC, check if data channels are actually open
	if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
		var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer
		if webrtc_peer.has_peer(peer_id):
			var peer_info = webrtc_peer.get_peer(peer_id)
			if peer_info:
				# Check if data channels are connected
				var is_net_connected = peer_info.get("connected", false)
				if not is_net_connected:
					return false
				# Also check connection state
				var connection_obj = peer_info.get("connection")
				if connection_obj != null:
					var connection_val: int = -1
					if typeof(connection_obj) == TYPE_INT:
						connection_val = int(connection_obj)
					# Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED
					if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED
						return false
		else:
			# Peer not found in WebRTC peer list
			return false
	
	return true

func _sync_existing_enemies_to_client(client_peer_id: int):
	# Find all enemy spawners and sync their spawned enemies to the new client
	# Spawners are children of the Entities node, not GameWorld directly
	# Check if node is still valid and in tree
	if not is_inside_tree():
		LogManager.log("GameWorld: Node not in tree, ignoring enemy sync RPC", LogManager.CATEGORY_NETWORK)
		return
	
	if not multiplayer.is_server():
		return
	
	var spawners = []
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		# Find spawners in Entities node
		for child in entities_node.get_children():
			if child.is_in_group("enemy_spawner") or (child.has_method("get_spawned_enemy_positions") and child.has_method("spawn_enemy_at_position")):
				spawners.append(child)
	else:
		# Fallback: search all children (shouldn't happen, but just in case)
		for child in get_children():
			if child.is_in_group("enemy_spawner") or (child.has_method("get_spawned_enemy_positions") and child.has_method("spawn_enemy_at_position")):
				spawners.append(child)
	
	LogManager.log("GameWorld: Syncing existing enemies to client " + str(client_peer_id) + " from " + str(spawners.size()) + " spawners", LogManager.CATEGORY_DUNGEON)
	
	for spawner in spawners:
		var enemy_data = spawner.get_spawned_enemy_positions()
		for data in enemy_data:
			# Use the stored scene_index and humanoid_type for each enemy
			var pos = data.position
			var scene_index = data.scene_index if "scene_index" in data else -1
			var humanoid_type = data.humanoid_type if "humanoid_type" in data else -1
			_sync_enemy_spawn.rpc_id(client_peer_id, spawner.name, pos, scene_index, humanoid_type)
			print("GameWorld: Sent enemy spawn sync to client ", client_peer_id, ": spawner=", spawner.name, " pos=", pos, " scene_index=", scene_index, " humanoid_type=", humanoid_type)

func _cleanup_disconnected_peers():
	"""Periodically check and remove disconnected peers from client_gameworld_ready"""
	if not multiplayer.has_multiplayer_peer() or not multiplayer.is_server():
		return
	
	# Get current peers list
	var current_peers = multiplayer.get_peers()
	
	# Check all peers in client_gameworld_ready
	var peers_to_remove: Array[int] = []
	
	for peer_id in client_gameworld_ready.keys():
		# First check: is peer still in get_peers()?
		if peer_id not in current_peers:
			peers_to_remove.append(peer_id)
			continue
		
		# Second check: for WebRTC, verify connection state
		if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
			var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer
			if not webrtc_peer.has_peer(peer_id):
				peers_to_remove.append(peer_id)
				continue
			
			var peer_info = webrtc_peer.get_peer(peer_id)
			if not peer_info:
				peers_to_remove.append(peer_id)
				continue
			
			# Check connection state
			var is_net_connected = peer_info.get("connected", false)
			if not is_net_connected:
				peers_to_remove.append(peer_id)
				continue
			
			var connection_obj = peer_info.get("connection")
			if connection_obj != null and typeof(connection_obj) == TYPE_INT:
				var connection_val = int(connection_obj)
				# Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED
				if connection_val == 3 or connection_val == 4: # DISCONNECTED or FAILED
					peers_to_remove.append(peer_id)
					continue
			
			# Check data channels
			var channels = peer_info.get("channels", [])
			if channels is Array and channels.size() > 0:
				var all_channels_open = true
				for channel in channels:
					if channel != null:
						var ready_state = -1
						if channel.has_method("get_ready_state"):
							ready_state = channel.get_ready_state()
						elif "ready_state" in channel:
							ready_state = channel.get("ready_state")
						# WebRTCDataChannel.State: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED
						if ready_state != 1: # Not OPEN
							all_channels_open = false
							break
				if not all_channels_open:
					peers_to_remove.append(peer_id)
					continue
			
			# Also check matchbox_client connection state
			if network_manager and network_manager.matchbox_client:
				var matchbox = network_manager.matchbox_client
				if "peer_connections" in matchbox:
					var pc = matchbox.peer_connections.get(peer_id)
					if pc and pc.has_method("get_connection_state"):
						var matchbox_conn_state = pc.get_connection_state()
						# Connection state: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED
						if matchbox_conn_state == 3 or matchbox_conn_state == 4: # DISCONNECTED or FAILED
							peers_to_remove.append(peer_id)
							continue
	
	# Remove disconnected peers
	for peer_id in peers_to_remove:
		if client_gameworld_ready.has(peer_id):
			client_gameworld_ready.erase(peer_id)
			print("GameWorld: Cleanup removed disconnected peer ", peer_id, " from client_gameworld_ready")
		if clients_ready.has(peer_id):
			clients_ready.erase(peer_id)
		if dungeon_sync_in_progress.has(peer_id):
			dungeon_sync_in_progress.erase(peer_id)
		if dungeon_chunk_acks.has(peer_id):
			dungeon_chunk_acks.erase(peer_id)

func _on_player_disconnected(peer_id: int, player_info: Dictionary):
	print("GameWorld: Player disconnected - ", peer_id)
	
	# Immediately clean up ready status for disconnected peer
	# This is critical to prevent RPC errors after disconnection
	if client_gameworld_ready.has(peer_id):
		client_gameworld_ready.erase(peer_id)
		print("GameWorld: Removed peer ", peer_id, " from client_gameworld_ready")
	if clients_ready.has(peer_id):
		clients_ready.erase(peer_id)
	
	# Clear dungeon sync in progress flag
	if dungeon_sync_in_progress.has(peer_id):
		dungeon_sync_in_progress.erase(peer_id)
	
	# Clear any chunk acknowledgments for this peer
	if dungeon_chunk_acks.has(peer_id):
		dungeon_chunk_acks.erase(peer_id)
	
	# Send disconnect message to chat (only on server to avoid duplicates)
	if multiplayer.is_server():
		_send_player_disconnect_message(peer_id, player_info)
	
	player_manager.despawn_players_for_peer(peer_id)

@rpc("authority", "reliable")
func _sync_spawn_player(peer_id: int, local_count: int):
	# Only clients process this RPC (server already spawned)
	if not multiplayer.is_server():
		print("GameWorld: Client received RPC to spawn peer ", peer_id)
		player_manager.spawn_players_for_peer(peer_id, local_count)
		# Client will notify server when ready via _notify_client_ready

@rpc("any_peer", "reliable")
func _notify_client_ready(peer_id: int):
	# Client notifies server that it's ready (all players spawned)
	if multiplayer.is_server():
		LogManager.log("GameWorld: Client " + str(peer_id) + " is ready", LogManager.CATEGORY_GAMEPLAY)
		clients_ready[peer_id] = true
		# Store the time when this client became ready
		var current_time = Time.get_ticks_msec() / 1000.0
		clients_ready[str(peer_id) + "_ready_time"] = current_time
		
		# When a new player finishes loading:
		# 1. Position only the new joiner in start room
		# 2. Send all current player positions to the new joiner (without moving them)
		# 3. Notify all existing players about the new joiner's position
		# Wait a bit longer to ensure the client has fully loaded the game scene and spawned all players
		if not dungeon_data.is_empty():
			# Delay positioning to ensure client scene is fully loaded
			get_tree().create_timer(0.5).timeout.connect(func():
				if is_instance_valid(self) and is_inside_tree():
					_position_new_joiner_and_sync_positions(peer_id)
			)
		
		# Notify all players that a client is ready (so server players can check if all are ready)
		_rpc_to_ready_peers("_client_ready_status_changed", [clients_ready.duplicate()])
		# Note: We don't reset the flag here - we want server players to check if all are ready now

@rpc("authority", "reliable")
func _client_ready_status_changed(_ready_status: Dictionary):
	# Server broadcasts ready status to all clients
	# This allows server players to know when all clients are ready
	if multiplayer.is_server():
		return
	# Store ready status on clients so they can avoid sending RPCs to peers not ready
	clients_ready = _ready_status

func _reset_server_players_ready_flag():
	# Reset all_clients_ready flag for all server players
	# This happens when a new client connects, so server players re-check readiness
	# Called directly on server (not via RPC)
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		for child in entities_node.get_children():
			if child.is_in_group("player"):
				# Only reset for server-controlled players (authority = server peer ID)
				if child.get_multiplayer_authority() == multiplayer.get_unique_id():
					child.all_clients_ready = false
					print("GameWorld: Reset all_clients_ready for server player ", child.name)

@rpc("authority", "reliable")
func _sync_smoke_puffs(_spawner_name: String, puff_positions: Array):
	# Clients spawn smoke puffs when server tells them to
	if not multiplayer.is_server():
		var smoke_puff_scene = preload("res://scenes/smoke_puff.tscn")
		if not smoke_puff_scene:
			return
		
		var entities_node = get_node_or_null("Entities")
		if not entities_node:
			return
		
		for pos in puff_positions:
			if pos is Vector2:
				var puff = smoke_puff_scene.instantiate()
				if puff:
					puff.global_position = pos
					puff.z_index = 10
					if puff.has_node("Sprite2D"):
						puff.get_node("Sprite2D").z_index = 10
					entities_node.add_child(puff)

@rpc("authority", "reliable")
func _sync_enemy_spawn(spawner_name: String, spawn_position: Vector2, scene_index: int = -1, humanoid_type: int = -1):
	# Clients spawn enemy when server tells them to
	if not multiplayer.is_server():
		print("GameWorld: Client received RPC to spawn enemy at spawner: ", spawner_name, " position: ", spawn_position, " scene_index: ", scene_index, " humanoid_type: ", humanoid_type)
		
		# Find the spawner node by name (spawners are children of Entities node, not GameWorld)
		var spawner = null
		var entities_node = get_node_or_null("Entities")
		if entities_node:
			spawner = entities_node.get_node_or_null(spawner_name)
		
		# Fallback: try as direct child of GameWorld (for backwards compatibility)
		if not spawner:
			spawner = get_node_or_null(spawner_name)
		
		if not spawner:
			push_error("ERROR: Could not find spawner with name: ", spawner_name, " in Entities node or GameWorld")
			return
		
		if not spawner.has_method("spawn_enemy_at_position"):
			push_error("ERROR: Spawner does not have spawn_enemy_at_position method!")
			return
		
		# Call spawn method on the spawner with scene index and humanoid_type
		spawner.spawn_enemy_at_position(spawn_position, scene_index, humanoid_type)

# Loot ID counter (server only)
var loot_id_counter: int = 0

@rpc("authority", "reliable")
func _sync_loot_spawn(spawn_position: Vector2, loot_type: int, initial_velocity: Vector2, initial_velocity_z: float, loot_id: int = -1):
	# Clients spawn loot when server tells them to
	if not multiplayer.is_server():
		var loot_scene = preload("res://scenes/loot.tscn")
		if not loot_scene:
			return
		
		var loot = loot_scene.instantiate()
		var entities_node = get_node_or_null("Entities")
		if entities_node:
			# Set multiplayer authority to server (peer 1) so RPCs work
			if multiplayer.has_multiplayer_peer():
				loot.set_multiplayer_authority(1)
			# Store unique loot ID for identification
			if loot_id >= 0:
				loot.set_meta("loot_id", loot_id)
			entities_node.add_child(loot)
			loot.global_position = spawn_position
			loot.loot_type = loot_type
			# Ensure key loot has a deterministic name for any legacy RPCs
			if loot_type == 4: # LootType.KEY
				loot.name = "KeyLoot_%d_%d" % [int(spawn_position.x), int(spawn_position.y)]
			# Set initial velocity before _ready() processes
			# Use synced velocity to ensure bounce matches server
			loot.velocity = initial_velocity
			loot.velocity_z = initial_velocity_z
			loot.velocity_set_by_spawner = true
			# Only set airborne if there's actual velocity (loot that's still bouncing)
			loot.is_airborne = (initial_velocity_z > 0.0 or initial_velocity.length() > 0.0)
			print("Client spawned loot: ", loot_type, " at ", spawn_position, " velocity: ", initial_velocity, " velocity_z: ", initial_velocity_z, " airborne: ", loot.is_airborne)

@rpc("authority", "reliable")
func _sync_item_loot_spawn(spawn_position: Vector2, item_data: Dictionary, initial_velocity: Vector2, initial_velocity_z: float, loot_id: int = -1):
	# Clients spawn item loot when server tells them to
	if not multiplayer.is_server():
		var loot_scene = preload("res://scenes/loot.tscn")
		if not loot_scene:
			return
		
		var loot = loot_scene.instantiate()
		var entities_node = get_node_or_null("Entities")
		if entities_node:
			# Create Item instance from data
			var item = Item.new(item_data)
			
			# Set multiplayer authority to server (peer 1) so RPCs work
			if multiplayer.has_multiplayer_peer():
				loot.set_multiplayer_authority(1)
			# Store unique loot ID for identification
			if loot_id >= 0:
				loot.set_meta("loot_id", loot_id)
			entities_node.add_child(loot)
			loot.global_position = spawn_position
			loot.loot_type = loot.LootType.ITEM
			loot.item = item # Set the item instance
			# Set initial velocity before _ready() processes
			# Use synced velocity to ensure bounce matches server
			loot.velocity = initial_velocity
			loot.velocity_z = initial_velocity_z
			loot.velocity_set_by_spawner = true
			# Only set airborne if there's actual velocity (loot that's still bouncing)
			loot.is_airborne = (initial_velocity_z > 0.0 or initial_velocity.length() > 0.0)
			print("Client spawned item loot: ", item.item_name, " at ", spawn_position, " velocity: ", initial_velocity, " velocity_z: ", initial_velocity_z, " airborne: ", loot.is_airborne)

@rpc("authority", "unreliable")
func _sync_loot_physics(loot_id: int, pos: Vector2, velocity: Vector2, position_z: float, velocity_z: float):
	# Clients receive periodic physics sync from server for reconciliation
	if multiplayer.is_server():
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	# Find loot by ID
	var loot = null
	for child in entities_node.get_children():
		if child.is_in_group("loot") and child.has_meta("loot_id"):
			if child.get_meta("loot_id") == loot_id:
				loot = child
				break
	
	if loot and is_instance_valid(loot):
		# Store server-authoritative state for reconciliation
		loot.server_position = pos
		loot.server_velocity = velocity
		loot.server_position_z = position_z
		loot.server_velocity_z = velocity_z
		loot.last_sync_time = Time.get_ticks_msec() / 1000.0
		
		# Reconcile: smooth correction if prediction error is significant
		_reconcile_loot_state(loot)

func _reconcile_loot_state(loot):
	"""Reconcile client prediction with server state - smooth correction"""
	if not loot or not is_instance_valid(loot):
		return
	
	# Calculate prediction error
	var pos_error = loot.global_position.distance_to(loot.server_position)
	var z_error = abs(loot.position_z - loot.server_position_z)
	
	# If error is significant, apply smooth correction
	if pos_error > loot.prediction_error_threshold or z_error > loot.prediction_error_threshold * 0.5:
		# Smooth correction using lerp (prevents jerky snaps)
		loot.global_position = loot.global_position.lerp(loot.server_position, loot.correction_smoothing)
		loot.velocity = loot.velocity.lerp(loot.server_velocity, loot.correction_smoothing)
		loot.position_z = lerp(loot.position_z, loot.server_position_z, loot.correction_smoothing)
		loot.velocity_z = lerp(loot.velocity_z, loot.server_velocity_z, loot.correction_smoothing)
		# Update airborne state from server
		loot.is_airborne = (loot.server_velocity_z > 0.0 or loot.server_velocity.length() > 0.0)
	else:
		# Error is small - still update velocity to match server for better prediction
		# This helps keep prediction accurate over time
		loot.velocity = loot.velocity.lerp(loot.server_velocity, loot.correction_smoothing * 0.5)
		loot.velocity_z = lerp(loot.velocity_z, loot.server_velocity_z, loot.correction_smoothing * 0.5)
		loot.is_airborne = (loot.server_velocity_z > 0.0 or loot.server_velocity.length() > 0.0)

@rpc("authority", "reliable")
func _sync_loot_floating_text(loot_type_value: int, text: String, color_value: Color, sprite_frame_value: int, player_peer_id: int):
	# Client receives floating text sync from server (routed via GameWorld)
	if multiplayer.is_server():
		return
	
	# Find player by peer ID
	var player = null
	var players = get_tree().get_nodes_in_group("player")
	for p in players:
		if p.get_multiplayer_authority() == player_peer_id:
			player = p
			break
	
	if not player or not is_instance_valid(player):
		return
	
	# Determine texture and frames based on loot type (0 = COIN, others use items sheet)
	var item_texture: Texture2D = null
	var sprite_hframes: int = 1
	var sprite_vframes: int = 1
	
	if loot_type_value == 0:
		item_texture = load("res://assets/gfx/pickups/gold_coin.png")
		sprite_hframes = 6
		sprite_vframes = 1
	else:
		item_texture = load("res://assets/gfx/pickups/items_n_shit.png")
		sprite_hframes = 20
		sprite_vframes = 14
	
	_show_loot_floating_text(player, text, color_value, item_texture, sprite_hframes, sprite_vframes, sprite_frame_value)

func _show_loot_floating_text(player: Node, text: String, color: Color, item_texture: Texture2D, sprite_hframes: int, sprite_vframes: int, sprite_frame: int):
	# Create floating text and item graphic above player's head
	var floating_text_scene = preload("res://scenes/floating_text.tscn")
	if floating_text_scene and player and is_instance_valid(player):
		var floating_text = floating_text_scene.instantiate()
		var parent = player.get_parent()
		if parent:
			parent.add_child(floating_text)
			floating_text.global_position = Vector2(player.global_position.x, player.global_position.y - 20)
			floating_text.setup(text, color, 0.5, 0.5, item_texture, sprite_hframes, sprite_vframes, sprite_frame)

func _apply_heal_spell_sync(target_name: String, amount_to_apply: float, display_amount: int, is_crit: bool, is_overheal: bool, allow_overheal: bool, is_revive: bool = false, is_damage_to_enemy: bool = false):
	var target: Node = null
	
	# Find target - check both players and enemies
	for p in get_tree().get_nodes_in_group("player"):
		if p.name == target_name and is_instance_valid(p):
			target = p
			break
	
	if not target:
		for e in get_tree().get_nodes_in_group("enemy"):
			if e.name == target_name and is_instance_valid(e):
				target = e
				break
	
	if not target:
		return
	
	if is_damage_to_enemy:
		# Damage already applied by caster; just spawn effect on target (enemy)
		var parent = target.get_parent() if target.get_parent() else get_node_or_null("Entities")
		if parent:
			var eff_scene = load("res://scenes/healing_effect.tscn") as PackedScene
			if eff_scene:
				var eff = eff_scene.instantiate()
				parent.add_child(eff)
				var pos = target.global_position
				eff.global_position = pos
				if eff.has_method("setup"):
					eff.setup(target)
				eff.global_position = pos
		return
	
	var me = multiplayer.get_unique_id()
	var tid = target.get_multiplayer_authority()
	
	if is_revive:
		# Revive only works for players
		if target.is_in_group("player"):
			if me == tid and target.has_method("_revive_from_heal"):
				target._revive_from_heal(display_amount)
			elif tid != 0 and target.has_method("_revive_from_heal"):
				target._revive_from_heal.rpc_id(tid, display_amount)
	else:
		# Heal players or enemies
		if amount_to_apply > 0:
			if target.is_in_group("player") and target.has_method("heal"):
				if me == tid:
					target.heal(amount_to_apply, allow_overheal)
			elif target.is_in_group("enemy") and target.character_stats:
				# Enemy healing - use character_stats.heal() directly
				if me == tid:
					target.character_stats.heal(amount_to_apply, allow_overheal)
					# Sync current_health for backwards compatibility
					target.current_health = target.character_stats.hp
				elif tid != 0:
					# Sync enemy healing via RPC
					if target.has_method("rpc_heal_enemy"):
						target.rpc_heal_enemy.rpc_id(tid, amount_to_apply, allow_overheal)
	# When revive, target's authority already spawned effect+text in _revive_from_heal; skip to avoid double spawn
	var skip_spawn = is_revive and me == tid
	if not skip_spawn:
		var parent = target.get_parent() if target.get_parent() else get_node_or_null("Entities")
		if parent:
			var eff_scene = load("res://scenes/healing_effect.tscn") as PackedScene
			if eff_scene:
				var eff = eff_scene.instantiate()
				parent.add_child(eff)
				var pos = target.global_position
				eff.global_position = pos
				if eff.has_method("setup"):
					eff.setup(target)
				eff.global_position = pos
			var prefix = ""
			if is_crit and is_overheal:
				prefix = "CRIT OVERHEAL! "
			elif is_crit:
				prefix = "CRIT! "
			elif is_overheal:
				prefix = "OVERHEAL! "
			var heal_text = prefix + "+" + str(display_amount) + " HP"
			_show_loot_floating_text(target, heal_text, Color.GREEN, null, 1, 1, 0)

@rpc("authority", "unreliable")
func _sync_enemy_position(enemy_name: String, enemy_index: int, pos: Vector2, vel: Vector2, z_pos: float, dir: int, frame: int, anim: String, frame_num: int, state_value: int):
	# Clients receive enemy position updates from server
	# Find the enemy by name or index
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	# Try to find enemy by name first, then by index
	var enemy = null
	for child in entities_node.get_children():
		if child.is_in_group("enemy"):
			if child.name == enemy_name:
				enemy = child
				break
			elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
				enemy = child
				break
	
	if enemy and enemy.has_method("_sync_position"):
		# Call the enemy's _sync_position method directly (not via RPC)
		enemy._sync_position(pos, vel, z_pos, dir, frame, anim, frame_num, state_value)

@rpc("authority", "reliable")
func _sync_enemy_death(enemy_name: String, enemy_index: int):
	# Clients receive enemy death sync from server
	# Find the enemy by name or index
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	# Skip processing if tab just became active (may be stale messages from when tab was inactive)
	if not was_tab_visible:
		return # Tab was inactive, skip old/stale RPCs to prevent sound spam
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	# Try to find enemy by name first, then by index
	var enemy = null
	for child in entities_node.get_children():
		if child.is_in_group("enemy"):
			if child.name == enemy_name:
				enemy = child
				break
			elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
				enemy = child
				break
	
	if enemy and enemy.has_method("_sync_death"):
		# Call the enemy's _sync_death method directly (not via RPC)
		enemy._sync_death()
	else:
		# Enemy not found - might already be freed or never spawned
		# This is okay, just log it
		print("GameWorld: Could not find enemy for death sync: name=", enemy_name, " index=", enemy_index)

@rpc("authority", "reliable")
func _sync_enemy_damage_visual(enemy_name: String, enemy_index: int, damage_amount: float = 0.0, attacker_position: Vector2 = Vector2.ZERO, is_critical: bool = false, is_dodged: bool = false):
	# Clients receive enemy damage visual sync from server
	# Find the enemy by name or index
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	# Skip processing if tab just became active (may be stale messages)
	if not was_tab_visible:
		return # Tab was inactive, skip old/stale RPCs
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	# Try to find enemy by name first, then by index
	var enemy = null
	for child in entities_node.get_children():
		if child.is_in_group("enemy"):
			if child.name == enemy_name:
				enemy = child
				break
			elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
				enemy = child
				break
	
	if enemy and enemy.has_method("_sync_damage_visual"):
		# Call the enemy's _sync_damage_visual method directly (not via RPC)
		enemy._sync_damage_visual(damage_amount, attacker_position, is_critical, is_dodged)
	else:
		# Enemy not found - might already be freed or never spawned
		# This is okay, just log it
		print("GameWorld: Could not find enemy for damage visual sync: name=", enemy_name, " index=", enemy_index)

@rpc("authority", "reliable")
func _sync_enemy_burn_debuff(enemy_name: String, enemy_index: int, apply: bool):
	# Clients receive enemy burn debuff sync from server
	# Find the enemy by name or index
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	# Try to find enemy by name first, then by index
	var enemy = null
	for child in entities_node.get_children():
		if child.is_in_group("enemy"):
			if child.name == enemy_name:
				enemy = child
				break
			elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
				enemy = child
				break
	
	if enemy and enemy.has_method("_sync_burn_debuff"):
		# Call the enemy's _sync_burn_debuff method directly (not via RPC)
		enemy._sync_burn_debuff(apply)
	else:
		# Enemy not found - might already be freed or never spawned
		print("GameWorld: Could not find enemy for burn debuff sync: name=", enemy_name, " index=", enemy_index)

@rpc("authority", "reliable")
func _sync_enemy_attack(enemy_name: String, enemy_index: int, direction: int, attack_dir: Vector2):
	# Clients receive enemy attack sync from server
	# Find the enemy by name or index
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	# Try to find enemy by name first, then by index
	var enemy = null
	for child in entities_node.get_children():
		if child.is_in_group("enemy"):
			if child.name == enemy_name:
				enemy = child
				break
			elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
				enemy = child
				break
	
	if enemy and enemy.has_method("_sync_attack"):
		# Call the enemy's _sync_attack method directly (not via RPC)
		enemy._sync_attack(direction, attack_dir)
	else:
		# Enemy not found - might already be freed or never spawned
		# This is okay, just log it
		print("GameWorld: Could not find enemy for attack sync: name=", enemy_name, " index=", enemy_index)

@rpc("authority", "reliable")
func _sync_enemy_throw_bomb(bomb_name: String, enemy_name: String, enemy_index: int, bomb_pos: Vector2, throw_force: Vector2):
	# Clients spawn bomb when server tells them an enemy threw one
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	var enemy = null
	for child in entities_node.get_children():
		if child.is_in_group("enemy"):
			if child.name == enemy_name:
				enemy = child
				break
			elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
				enemy = child
				break
	if not enemy:
		return
	var attack_bomb_scene = load("res://scenes/attack_bomb.tscn")
	if not attack_bomb_scene:
		push_error("GameWorld: Could not load attack_bomb scene for enemy throw sync!")
		return
	var bomb = attack_bomb_scene.instantiate()
	bomb.name = bomb_name
	entities_node.add_child(bomb)
	bomb.global_position = bomb_pos
	bomb.setup(bomb_pos, enemy, throw_force, true) # true = is_thrown
	if bomb.has_node("Sprite2D"):
		bomb.get_node("Sprite2D").visible = true
	print("GameWorld: (client) synced enemy bomb: ", bomb_name, " at ", bomb_pos)

@rpc("any_peer", "reliable")
func _request_item_drop(item_data: Dictionary, drop_position: Vector2, player_peer_id: int):
	# Server receives item drop request from client
	# Remove item from player's inventory and spawn as loot on server (syncs to all clients)
	if not multiplayer.is_server():
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	# Find the player by peer_id
	var player = null
	var players = get_tree().get_nodes_in_group("player")
	for p in players:
		if p.has_method("get_multiplayer_authority") and p.get_multiplayer_authority() == player_peer_id:
			player = p
			break
	
	# Create item from data
	var item = Item.new(item_data)
	if not item:
		LogManager.log("GameWorld: Could not create item from data for drop request", LogManager.CATEGORY_DUNGEON)
		return
	
	# Remove item from player's inventory on server (if player found)
	if player and player.character_stats:
		var item_index = -1
		for i in range(player.character_stats.inventory.size()):
			var inv_item = player.character_stats.inventory[i]
			if inv_item and inv_item.item_name == item.item_name and inv_item.spriteFrame == item.spriteFrame:
				# Found matching item - remove it
				item_index = i
				break
		
		if item_index >= 0:
			player.character_stats.inventory.remove_at(item_index)
			# Emit character_changed to sync inventory update
			player.character_stats.character_changed.emit(player.character_stats)
			print("GameWorld: Removed item from player inventory on server: ", item.item_name)
		else:
			print("GameWorld: WARNING: Item not found in player inventory on server: ", item.item_name)
	
	# Spawn loot on server (this will sync to all clients)
	var loot = ItemLootHelper.spawn_item_loot(item, drop_position, entities_node, self)
	if loot:
		# Set metadata for pickup cooldown
		loot.set_meta("dropped_by_peer_id", player_peer_id)
		loot.set_meta("drop_time", Time.get_ticks_msec())
		print("GameWorld: Server spawned item loot from client drop request: ", item.item_name)

@rpc("any_peer", "reliable")
func _request_enemy_damage(enemy_name: String, enemy_index: int, damage: float, attacker_position: Vector2, is_critical: bool):
	# Server receives enemy damage request from client
	# Route to the correct enemy
	if not multiplayer.is_server():
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	# Try to find enemy by name first, then by index
	var enemy = null
	for child in entities_node.get_children():
		if child.is_in_group("enemy"):
			if child.name == enemy_name:
				enemy = child
				break
			elif child.has_meta("enemy_index") and child.get_meta("enemy_index") == enemy_index:
				enemy = child
				break
	
	if enemy and enemy.has_method("rpc_take_damage"):
		# Call the enemy's rpc_take_damage method directly (it will handle authority check)
		enemy.rpc_take_damage(damage, attacker_position, is_critical, false, false) # is_burn_damage=false, apply_burn_debuff=false
	else:
		# Enemy not found - might already be freed
		print("GameWorld: Could not find enemy for damage request: name=", enemy_name, " index=", enemy_index)

@rpc("any_peer", "reliable")
func _request_punch_hit_effect(pos_x: float, pos_y: float):
	# Server receives request from client (punch hit); broadcast effect to all peers
	if not multiplayer.is_server():
		return
	_sync_punch_hit_effect(pos_x, pos_y)
	if has_method("_rpc_to_ready_peers"):
		_rpc_to_ready_peers("_sync_punch_hit_effect", [pos_x, pos_y])

@rpc("any_peer", "reliable")
func _sync_punch_hit_effect(pos_x: float, pos_y: float):
	# Spawn punch hit effect on this peer (called by server or when server broadcasts)
	var scene = load("res://scenes/damage_effect_punch.tscn") as PackedScene
	if not scene:
		return
	var effect = scene.instantiate()
	add_child(effect)
	effect.global_position = Vector2(pos_x, pos_y)
	if effect.has_method("setup"):
		effect.setup(Vector2(pos_x, pos_y))

@rpc("any_peer", "reliable")
func _request_slash_hit_effect(pos_x: float, pos_y: float):
	if not multiplayer.is_server():
		return
	_sync_slash_hit_effect(pos_x, pos_y)
	if has_method("_rpc_to_ready_peers"):
		_rpc_to_ready_peers("_sync_slash_hit_effect", [pos_x, pos_y])

@rpc("any_peer", "reliable")
func _sync_slash_hit_effect(pos_x: float, pos_y: float):
	var scene = load("res://scenes/damage_effect_slash.tscn") as PackedScene
	if not scene:
		return
	var effect = scene.instantiate()
	add_child(effect)
	effect.global_position = Vector2(pos_x, pos_y)
	if effect.has_method("setup"):
		effect.setup(Vector2(pos_x, pos_y))

@rpc("any_peer", "reliable")
func _request_arrow_hit_effect(pos_x: float, pos_y: float):
	if not multiplayer.is_server():
		return
	_sync_arrow_hit_effect(pos_x, pos_y)
	if has_method("_rpc_to_ready_peers"):
		_rpc_to_ready_peers("_sync_arrow_hit_effect", [pos_x, pos_y])

@rpc("any_peer", "reliable")
func _sync_arrow_hit_effect(pos_x: float, pos_y: float):
	var scene = load("res://scenes/damage_effect_arrow.tscn") as PackedScene
	if not scene:
		return
	var effect = scene.instantiate()
	add_child(effect)
	effect.global_position = Vector2(pos_x, pos_y)
	if effect.has_method("setup"):
		effect.setup(Vector2(pos_x, pos_y))

@rpc("any_peer", "reliable")
func _request_axe_hit_effect(pos_x: float, pos_y: float):
	if not multiplayer.is_server():
		return
	_sync_axe_hit_effect(pos_x, pos_y)
	if has_method("_rpc_to_ready_peers"):
		_rpc_to_ready_peers("_sync_axe_hit_effect", [pos_x, pos_y])

@rpc("any_peer", "reliable")
func _sync_axe_hit_effect(pos_x: float, pos_y: float):
	var scene = load("res://scenes/damage_effect_axe.tscn") as PackedScene
	if not scene:
		return
	var effect = scene.instantiate()
	add_child(effect)
	effect.global_position = Vector2(pos_x, pos_y)
	if effect.has_method("setup"):
		effect.setup(Vector2(pos_x, pos_y))

@rpc("any_peer", "reliable")
func _request_bite_effect(player_name: String):
	if not multiplayer.is_server():
		return
	_sync_bite_effect(player_name)
	if has_method("_rpc_to_ready_peers"):
		_rpc_to_ready_peers("_sync_bite_effect", [player_name])

@rpc("any_peer", "reliable")
func _sync_bite_effect(player_name: String):
	var player = player_manager.get_node_or_null(player_name) if player_manager else null
	if not player and is_instance_valid(self):
		for p in get_tree().get_nodes_in_group("player"):
			if p.name == player_name:
				player = p
				break
	if not player or not is_instance_valid(player):
		return
	var scene = load("res://scenes/damage_effect_bite.tscn") as PackedScene
	if not scene:
		return
	var effect = scene.instantiate()
	player.add_child(effect)
	effect.position = Vector2.ZERO
	if effect.has_method("setup"):
		effect.setup(Vector2.ZERO)

@rpc("any_peer", "reliable")
func _request_loot_pickup(loot_id: int, loot_position: Vector2, player_peer_id: int):
	# Server receives loot pickup request from client
	# Route to the correct loot item
	if not multiplayer.is_server():
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	# Find loot by ID or position
	var loot = null
	for child in entities_node.get_children():
		if child.is_in_group("loot") or child.has_method("_request_pickup"):
			# Check by ID first
			if child.has_meta("loot_id") and child.get_meta("loot_id") == loot_id:
				loot = child
				break
			# Fallback: check by position (within 16 pixels tolerance)
			elif child.global_position.distance_to(loot_position) < 16.0:
				loot = child
				break
	
	if loot and loot.has_method("_request_pickup"):
		# Call the loot's _request_pickup method directly (it will handle the rest)
		loot._request_pickup(player_peer_id)
	else:
		print("GameWorld: Could not find loot for pickup request: id=", loot_id, " pos=", loot_position)

@rpc("any_peer", "reliable")
func _request_chest_open_by_name(chest_name: String, player_peer_id: int):
	# Server receives chest open request by name (avoids node path RPC errors)
	if not multiplayer.is_server():
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	var chest = entities_node.get_node_or_null(chest_name)
	if not chest:
		# Fallback: try to find by object_index if name is InteractableObject_X
		if chest_name.begins_with("InteractableObject_"):
			var index_str = chest_name.substr(20)
			if index_str.is_valid_int():
				var obj_index = index_str.to_int()
				for child in entities_node.get_children():
					if child.is_in_group("interactable_object") and child.has_meta("object_index"):
						if child.get_meta("object_index") == obj_index:
							chest = child
							break
	
	if chest and chest.has_method("_request_chest_open"):
		chest._request_chest_open(player_peer_id)
	else:
		print("GameWorld: Could not find chest for open request: name=", chest_name, " peer_id=", player_peer_id)

@rpc("authority", "reliable")
func _sync_player_exit_stairs(player_peer_id: int):
	# Client receives notification that a player reached exit stairs
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	# Find the player by peer ID
	var players = get_tree().get_nodes_in_group("player")
	var target_player = null
	for p in players:
		if p.has_method("get_multiplayer_authority") and p.get_multiplayer_authority() == player_peer_id:
			target_player = p
			break
	
	# Only disable controls/collision if this is our local player
	if target_player:
		var my_peer_id = multiplayer.get_unique_id()
		if player_peer_id == my_peer_id:
			# This is our local player - disable controls and collision
			target_player.controls_disabled = true
			target_player.set_collision_layer_value(1, false)
			print("GameWorld: Client disabled controls and collision for local player ", target_player.name)
			# Show black fade overlay for this player
			_show_black_fade_overlay()

@rpc("authority", "reliable")
func _sync_show_level_complete(level_time: float):
	# Clients receive level complete UI sync from server
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	# Stop HUD timer when level completes (on clients too)
	var hud = get_node_or_null("IngameHUD")
	if hud and hud.has_method("stop_timer"):
		hud.stop_timer()
	
	# Show level complete UI (each client will show their own local player's stats)
	_show_level_complete_ui(level_time)

@rpc("authority", "reliable")
func _sync_hide_level_complete():
	# Clients receive hide level complete UI sync from server
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	var level_complete_ui = get_node_or_null("LevelCompleteUI")
	if level_complete_ui:
		level_complete_ui.visible = false

@rpc("authority", "reliable")
func _sync_disable_stairs():
	# Clients receive stairs disable sync from server
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	# Disable stairs Area2D on clients so no player can activate it
	var stairs_area = get_node_or_null("StairsArea")
	if stairs_area:
		stairs_area.set_deferred("monitoring", false)
		stairs_area.set_deferred("monitorable", false)
		LogManager.log("GameWorld: Client disabled stairs Area2D", LogManager.CATEGORY_DUNGEON)

@rpc("authority", "reliable")
func _sync_restore_player_controls():
	# Clients receive restore controls/collision sync from server
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	# Restore controls and collision for local player
	var my_peer_id = multiplayer.get_unique_id()
	var players = get_tree().get_nodes_in_group("player")
	for player in players:
		if player.has_method("get_multiplayer_authority") and player.get_multiplayer_authority() == my_peer_id:
			player.controls_disabled = false
			player.set_collision_layer_value(1, true)
			print("GameWorld: Client restored controls and collision for local player ", player.name)
			break

@rpc("authority", "reliable")
func _sync_remove_black_fade():
	# Clients receive remove black fade sync from server
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	_remove_black_fade_overlay()

@rpc("authority", "reliable")
func _sync_show_level_number(level: int):
	# Clients receive level number UI sync from server
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	current_level = level
	_show_level_number()

@rpc("authority", "reliable")
func _sync_loot_remove(loot_id: int, loot_position: Vector2):
	# Clients receive loot removal sync from server
	if multiplayer.is_server():
		return # Server ignores this (it's the sender)
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	# Try to find and remove all matching loot (by ID, else by position)
	var matched_loot: Array = []
	for child in entities_node.get_children():
		if child.is_in_group("loot") or child.has_method("_sync_remove"):
			if loot_id >= 0 and child.has_meta("loot_id") and child.get_meta("loot_id") == loot_id:
				matched_loot.append(child)
			elif loot_id < 0 and child.global_position.distance_to(loot_position) < 16.0:
				matched_loot.append(child)
	
	if matched_loot.is_empty():
		# Fallback: try position match even when id was provided
		for child in entities_node.get_children():
			if child.is_in_group("loot") or child.has_method("_sync_remove"):
				if child.global_position.distance_to(loot_position) < 16.0:
					matched_loot.append(child)
	
	if not matched_loot.is_empty():
		for loot in matched_loot:
			if loot and loot.has_method("_sync_remove"):
				loot._sync_remove()
		print("GameWorld: Client removed ", matched_loot.size(), " loot item(s) for id=", loot_id, " pos=", loot_position)
	else:
		# Loot not found - might already be freed or never spawned
		print("GameWorld: Could not find loot for removal sync: id=", loot_id, " pos=", loot_position)

func _check_tab_visibility():
	# Check if browser tab is visible (web only)
	if OS.get_name() == "Web":
		# Use DisplayServer to check if window is focused/visible
		# This is a simple check - on web, when tab is inactive, window loses focus
		var window_visible = DisplayServer.window_get_mode() != DisplayServer.WINDOW_MODE_MINIMIZED
		# Also check if we can poll (if process is running, tab is likely active)
		# On web, when tab is inactive, _process may still run but less frequently
		# We'll track time between _process calls as a proxy for tab activity
		var current_time = Time.get_ticks_msec()
		if not has_meta("last_process_time"):
			set_meta("last_process_time", current_time)
			return true
		
		var time_since_last_process = current_time - get_meta("last_process_time", current_time)
		set_meta("last_process_time", current_time)
		
		# If more than 1 second passed, tab was likely inactive
		if time_since_last_process > 1000:
			return false
		
		return window_visible
	return true # Always visible on non-web platforms

func _process(delta):
	# Update mouse cursor
	if use_mouse_control:
		_update_mouse_cursor(delta)
	
	# Check tab visibility for buffer overflow protection (clients only)
	if not multiplayer.is_server():
		var is_tab_visible = _check_tab_visibility()
		var current_time = Time.get_ticks_msec()
		
		# Debounce tab state changes to avoid rapid toggling
		if (is_tab_visible != was_tab_visible) and (current_time - last_tab_state_change > TAB_STATE_DEBOUNCE_MS):
			last_tab_state_change = current_time
			
			if not is_tab_visible and was_tab_visible:
				# Tab just became inactive
				tab_inactive_time = current_time
				print("GameWorld: Tab became inactive - will flush old messages when active again")
				was_tab_visible = false
			elif is_tab_visible and not was_tab_visible:
				# Tab just became active again - skip stale RPCs for a short time
				var inactive_duration = current_time - tab_inactive_time
				print("GameWorld: Tab became active again after ", inactive_duration, "ms - will skip stale RPCs for 1 second to prevent buffer overflow")
				
				# Clear sound rate limiting
				last_sound_play_time.clear()
				
				# Keep was_tab_visible as false temporarily to skip stale RPCs
				# This prevents processing hundreds of queued RPCs from when tab was inactive
				get_tree().create_timer(1.0).timeout.connect(func():
					if is_inside_tree():
						was_tab_visible = true
						print("GameWorld: Tab reactivation complete - now processing fresh RPCs")
				)
		elif is_tab_visible == was_tab_visible:
			# State is stable, update was_tab_visible if needed
			was_tab_visible = is_tab_visible
	
	# Check client buffers on server and skip sending to clients with full buffers
	if multiplayer.is_server() and multiplayer.has_multiplayer_peer():
		var current_time_sec = Time.get_ticks_msec() / 1000.0
		_check_client_buffers(current_time_sec)
	
	# Check WebRTC buffer size periodically (clients only)
	if not multiplayer.is_server() and multiplayer.has_multiplayer_peer():
		var current_time = Time.get_ticks_msec()
		if current_time - last_buffer_check_time > BUFFER_CHECK_INTERVAL * 1000:
			last_buffer_check_time = current_time
			_check_and_handle_buffer_overflow()
	
	# Update camera to follow local players
	_update_camera()
	_update_fog_of_war(delta)
	_update_canvas_modulate_by_torches()
	
	# Periodic cleanup of disconnected peers (server only)
	if multiplayer.is_server() and multiplayer.has_multiplayer_peer():
		peer_cleanup_timer += delta
		if peer_cleanup_timer >= PEER_CLEANUP_INTERVAL:
			peer_cleanup_timer = 0.0
			_cleanup_disconnected_peers()

func _check_client_buffers(current_time: float):
	# Check all client buffers and mark which ones should be skipped
	if not multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
		return
	
	var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer
	var peers = multiplayer.get_peers()
	
	for peer_id in peers:
		if not webrtc_peer.has_peer(peer_id):
			continue
		
		var peer_info = webrtc_peer.get_peer(peer_id)
		if not peer_info:
			continue
		
		# Only check buffers periodically to avoid performance impact
		if not client_buffer_states.has(peer_id):
			client_buffer_states[peer_id] = {"buffered": 0, "last_check": 0.0, "skip_until": 0.0}
		
		var buffer_state = client_buffer_states[peer_id]
		if current_time - buffer_state.last_check < CLIENT_BUFFER_CHECK_INTERVAL:
			continue # Skip this check - too soon
		
		buffer_state.last_check = current_time
		
		# Check buffer size
		var channels = peer_info.get("channels", [])
		var total_buffered = 0
		if channels is Array:
			for channel in channels:
				if channel != null and channel.has_method("get_buffered_amount"):
					total_buffered += channel.get_buffered_amount()
		
		buffer_state.buffered = total_buffered
		
		# If buffer is too full, mark client to skip for a duration
		if total_buffered > CLIENT_BUFFER_SKIP_THRESHOLD:
			if buffer_state.skip_until < current_time:
				buffer_state.skip_until = current_time + CLIENT_BUFFER_SKIP_DURATION
				print("GameWorld: HOST - Client ", peer_id, " buffer full (", total_buffered, " bytes) - skipping RPCs for ", CLIENT_BUFFER_SKIP_DURATION, " seconds")

func _should_skip_client_due_to_buffer(peer_id: int) -> bool:
	# Check if we should skip sending to this client due to full buffer
	if not client_buffer_states.has(peer_id):
		return false
	
	var buffer_state = client_buffer_states[peer_id]
	var current_time = Time.get_ticks_msec() / 1000.0
	
	# Skip if we're in the skip period
	if buffer_state.skip_until > current_time:
		return true
	
	# If skip period expired, check if buffer is still full
	if buffer_state.buffered > CLIENT_BUFFER_SKIP_THRESHOLD:
		# Buffer still full, extend skip period
		buffer_state.skip_until = current_time + CLIENT_BUFFER_SKIP_DURATION
		return true
	
	return false

func _check_and_handle_buffer_overflow():
	# Check WebRTC data channel buffer size and handle overflow
	if not multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
		return
	
	var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer
	var server_peer_id = 1 # Server is always peer 1
	
	if not webrtc_peer.has_peer(server_peer_id):
		return
	
	var peer_info = webrtc_peer.get_peer(server_peer_id)
	if not peer_info:
		return
	
	var channels = peer_info.get("channels", {})
	if not channels is Array:
		return
	
	var total_buffered = 0
	for channel in channels:
		if channel != null and channel.has_method("get_buffered_amount"):
			var buffered = channel.get_buffered_amount()
			total_buffered += buffered
	
	if total_buffered > MAX_BUFFER_SIZE:
		print("GameWorld: WARNING - Buffer overflow detected! Buffered: ", total_buffered, " bytes (max: ", MAX_BUFFER_SIZE, ")")
		print("GameWorld: Tab may have been inactive - old messages will be processed but may cause issues")
		# Note: We can't actually clear the buffer from Godot, but we've logged it
		# The browser will eventually process messages, but they may be old/stale

func _update_camera():
	local_players = player_manager.get_local_players()
	
	if local_players.is_empty():
		return
	
	# Update screenshake
	if screenshake_timer > 0.0:
		screenshake_timer -= get_process_delta_time()
		var shake_amount = screenshake_strength * (screenshake_timer / screenshake_duration)
		screenshake_offset = Vector2(
			randf_range(-shake_amount, shake_amount),
			randf_range(-shake_amount, shake_amount)
		)
	else:
		screenshake_offset = Vector2.ZERO
	
	# Calculate center of all local players
	var center = Vector2.ZERO
	for player in local_players:
		center += player.position
	center /= local_players.size()
	
	# Smooth camera movement (with screenshake)
	camera.position = camera.position.lerp(center + screenshake_offset, 0.1)
	
	# Base zoom with aspect ratio adjustment (show more on wider screens)
	var viewport_size = get_viewport().get_visible_rect().size
	var aspect = viewport_size.x / max(1.0, viewport_size.y)
	var aspect_factor = 1.0
	if aspect > REFERENCE_ASPECT:
		# Wider than 16:9 -> zoom out to show more
		aspect_factor = REFERENCE_ASPECT / aspect
	
	# Detect mobile/touchscreen and use higher zoom for better visibility
	var is_mobile = false
	var os_name = OS.get_name().to_lower()
	if os_name in ["android", "ios"] or DisplayServer.is_touchscreen_available():
		is_mobile = true
	
	var base_zoom = BASE_CAMERA_ZOOM_MOBILE if is_mobile else BASE_CAMERA_ZOOM
	var target_zoom = base_zoom * aspect_factor
	
	# Adjust zoom based on player spread (for split-screen effect)
	if local_players.size() > 1:
		var max_distance = 0.0
		for player in local_players:
			var distance = center.distance_to(player.position)
			max_distance = max(max_distance, distance)
		
		# Adjust zoom to fit all players
		var spread_zoom = clamp(800.0 / (max_distance + 400.0), 0.5, 1.5)
		target_zoom *= spread_zoom
	
	# Always update zoom (for both single and multi-player)
	camera.zoom = camera.zoom.lerp(Vector2.ONE * target_zoom, 0.05)
	
	# Optional: Lerp camera slightly toward cursor direction
	if camera_lerp_to_cursor and use_mouse_control:
		var cursor_world_pos = camera.get_global_mouse_position()
		var cursor_offset = cursor_world_pos - center
		var lerped_center = center + cursor_offset * CURSOR_CAMERA_LERP_AMOUNT
		camera.position = camera.position.lerp(lerped_center, 0.1)

func add_screenshake(strength: float, duration: float):
	# Add screenshake effect
	screenshake_strength = max(screenshake_strength, strength) # Use max if already shaking
	screenshake_duration = max(screenshake_duration, duration) # Use max duration
	screenshake_timer = max(screenshake_timer, duration) # Reset timer if new shake is stronger

func _init_mouse_cursor():
	# Create cursor layer with high Z index
	cursor_layer = CanvasLayer.new()
	cursor_layer.name = "MouseCursorLayer"
	cursor_layer.layer = CURSOR_LAYER_Z
	add_child(cursor_layer)
	
	# Load cursor texture
	var cursor_texture = load("res://assets/gfx/cursor.png")
	if not cursor_texture:
		push_error("GameWorld: Could not load cursor.png!")
		return
	
	# Create free movement cursor sprite (frame 0)
	cursor_sprite = Sprite2D.new()
	cursor_sprite.name = "MouseCursorFree"
	cursor_sprite.texture = cursor_texture
	# Set up sprite sheet (32x16 with 2 frames of 16x16 each)
	cursor_sprite.hframes = 2
	cursor_sprite.vframes = 1
	cursor_sprite.frame = 0 # Frame 0 = free movement
	cursor_sprite.modulate.a = 0.75 # 75% opacity
	cursor_layer.add_child(cursor_sprite)
	
	# Create grid-locked cursor sprite (frame 1)
	grid_cursor_sprite = Sprite2D.new()
	grid_cursor_sprite.name = "MouseCursorGrid"
	grid_cursor_sprite.texture = cursor_texture
	grid_cursor_sprite.hframes = 2
	grid_cursor_sprite.vframes = 1
	grid_cursor_sprite.frame = 1 # Frame 1 = grid-locked
	grid_cursor_sprite.modulate.a = 0.3 # 30% opacity
	cursor_layer.add_child(grid_cursor_sprite)
	
	# Create spell targeting cursor sprite (frame 1, will be tinted by element)
	spell_cursor_sprite = Sprite2D.new()
	spell_cursor_sprite.name = "MouseCursorSpell"
	spell_cursor_sprite.texture = cursor_texture
	spell_cursor_sprite.hframes = 2
	spell_cursor_sprite.vframes = 1
	spell_cursor_sprite.frame = 1 # Frame 1 = grid-locked
	spell_cursor_sprite.modulate.a = 0.5 # 50% opacity
	spell_cursor_sprite.visible = false # Hidden by default
	cursor_layer.add_child(spell_cursor_sprite)
	
	# Hide system cursor
	Input.mouse_mode = Input.MOUSE_MODE_HIDDEN

func _update_mouse_cursor(delta: float):
	if not use_mouse_control:
		return
	
	if not cursor_sprite or not is_instance_valid(cursor_sprite):
		return
	
	if not grid_cursor_sprite or not is_instance_valid(grid_cursor_sprite):
		return
	
	if not spell_cursor_sprite or not is_instance_valid(spell_cursor_sprite):
		return
	
	var is_charging_spell = false
	var spell_element = "fire"
	if local_players.size() > 0:
		var player = local_players[0]
		if player and is_instance_valid(player) and player.is_local_player:
			if "is_charging_spell" in player:
				is_charging_spell = player.is_charging_spell
			if "current_spell_element" in player:
				spell_element = player.current_spell_element
	
	# Update pulse time for grid cursor color animation
	cursor_pulse_time += delta * CURSOR_PULSE_SPEED
	
	# Get mouse position in viewport (screen space)
	var mouse_pos = get_viewport().get_mouse_position()
	
	# Convert to world position for game logic
	var world_pos = camera.get_global_mouse_position()
	
	# Scale cursors to match camera zoom level (so grid-locked cursor aligns with tiles)
	# Each cursor frame is 16x16 pixels, and tiles are 16x16 pixels
	# Scale by camera zoom to maintain 1:1 pixel ratio
	var cursor_scale = camera.zoom.x # Use x zoom (should be same as y)
	cursor_sprite.scale = Vector2.ONE * cursor_scale
	grid_cursor_sprite.scale = Vector2.ONE * cursor_scale
	spell_cursor_sprite.scale = Vector2.ONE * cursor_scale
	
	# Check if we should show grid-locked cursor (when mouse is over game world tiles)
	var show_grid_cursor = false
	var grid_locked_world_pos = world_pos
	if dungeon_tilemap_layer:
		var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position)
		var tile_data = dungeon_tilemap_layer.get_cell_source_id(tile_pos)
		if tile_data >= 0: # Valid tile
			show_grid_cursor = true
			# Snap to tile center for world position calculation
			grid_locked_world_pos = dungeon_tilemap_layer.map_to_local(tile_pos) + dungeon_tilemap_layer.global_position
			# Convert grid-locked world position to screen position for cursor display
			# Formula: (world_pos - camera.position) * camera.zoom + viewport_center
			var viewport_size = get_viewport().get_visible_rect().size
			var viewport_center = viewport_size / 2.0
			var grid_locked_screen_pos = (grid_locked_world_pos - camera.position) * camera.zoom.x + viewport_center
			grid_cursor_sprite.position = grid_locked_screen_pos
		else:
			grid_cursor_sprite.position = Vector2(-1000, -1000) # Hide off-screen
	
	# Update free cursor position (always follows mouse)
	cursor_sprite.position = mouse_pos
	
	# Update spell cursor if charging spell
	if is_charging_spell:
		grid_cursor_sprite.visible = false
		var spell_target_pos = Vector2.ZERO
		if spell_element == "healing" and local_players.size() > 0:
			var cp = local_players[0]
			if cp and is_instance_valid(cp) and cp.has_method("_get_heal_target"):
				var ht = cp._get_heal_target()
				if ht and is_instance_valid(ht):
					spell_target_pos = ht.global_position
		else:
			spell_target_pos = _get_valid_spell_target_position(world_pos)
		if spell_target_pos != Vector2.ZERO:
			spell_cursor_sprite.visible = true
			# Convert world position to screen position
			var viewport_size = get_viewport().get_visible_rect().size
			var viewport_center = viewport_size / 2.0
			var spell_screen_pos = (spell_target_pos - camera.position) * camera.zoom.x + viewport_center
			spell_cursor_sprite.position = spell_screen_pos
			
			# Tint by element
			match spell_element:
				"fire":
					spell_cursor_sprite.modulate = Color(1.0, 0.3, 0.3, 0.5) # Red
				"healing":
					spell_cursor_sprite.modulate = Color(0.3, 1.0, 0.35, 0.5) # Green
				"frost":
					spell_cursor_sprite.modulate = Color(0.3, 0.6, 1.0, 0.5) # Blue
				"water", "ice":
					spell_cursor_sprite.modulate = Color(0.3, 0.5, 1.0, 0.5) # Blue
				"electric":
					spell_cursor_sprite.modulate = Color(1.0, 1.0, 0.3, 0.5) # Yellow
				"earth":
					spell_cursor_sprite.modulate = Color(0.3, 1.0, 0.3, 0.5) # Green
				"wind":
					spell_cursor_sprite.modulate = Color(1.0, 1.0, 1.0, 0.5) # White
				_:
					spell_cursor_sprite.modulate = Color(1.0, 0.3, 0.3, 0.5) # Default red
		else:
			spell_cursor_sprite.visible = false
	else:
		# Show normal grid cursor when not charging spell
		spell_cursor_sprite.visible = false
		grid_cursor_sprite.visible = show_grid_cursor
		if show_grid_cursor:
			# Pulse color: oscillate between normal and brighter color
			var pulse_value = (sin(cursor_pulse_time) + 1.0) / 2.0 # 0.0 to 1.0
			# Interpolate between normal (1,1,1) and brighter (1.5, 1.2, 1.0) for a warm pulse
			var base_color = Color(1.0, 1.0, 1.0)
			var pulse_color = Color(1.5, 1.2, 1.0)
			grid_cursor_sprite.modulate = base_color.lerp(pulse_color, pulse_value * 0.5) # 50% of the way to pulse color
			grid_cursor_sprite.modulate.a = 0.3 # Keep opacity at 30%
	
	# Update player facing direction based on mouse position (use world position)
	# Only update if mouse is inside the window viewport
	var viewport_rect = get_viewport().get_visible_rect()
	var mouse_in_window = viewport_rect.has_point(mouse_pos)
	
	if local_players.size() > 0:
		var player = local_players[0] # Use first local player
		if player and is_instance_valid(player) and player.is_local_player:
			if mouse_in_window:
				# Mouse is in window - use mouse for direction control
				var player_pos = player.global_position
				# Use spell cursor position if charging spell, otherwise use grid-locked or free mouse position
				var target_world_pos = world_pos
				if is_charging_spell and spell_cursor_sprite.visible:
					# Use spell cursor position for facing direction
					var viewport_size = get_viewport().get_visible_rect().size
					var viewport_center = viewport_size / 2.0
					# Convert spell cursor screen position back to world position
					var spell_screen_pos = spell_cursor_sprite.position
					target_world_pos = (spell_screen_pos - viewport_center) / camera.zoom.x + camera.position
				elif show_grid_cursor:
					target_world_pos = grid_locked_world_pos
				
				var mouse_direction = (target_world_pos - player_pos).normalized()
				
				# Only update facing if mouse is far enough from player
				if mouse_direction.length() > 0.1:
					player._update_facing_from_mouse(mouse_direction)
			else:
				# Mouse is outside window - disable mouse control (use WASD/movement for direction)
				if "mouse_control_active" in player:
					player.mouse_control_active = false

func get_grid_locked_cursor_position() -> Vector2:
	# Get the grid-locked cursor world position for spell casting
	if not dungeon_tilemap_layer:
		return Vector2.ZERO
	
	var _mouse_pos = get_viewport().get_mouse_position()
	var world_pos = camera.get_global_mouse_position()
	
	var tile_pos = dungeon_tilemap_layer.local_to_map(world_pos - dungeon_tilemap_layer.global_position)
	var tile_data = dungeon_tilemap_layer.get_cell_source_id(tile_pos)
	if tile_data >= 0: # Valid tile
		# Return tile center world position
		return dungeon_tilemap_layer.map_to_local(tile_pos) + dungeon_tilemap_layer.global_position
	
	return Vector2.ZERO # No valid grid position

func _get_valid_spell_target_position(target_world_pos: Vector2) -> Vector2:
	# Get valid spell target position (closest valid floor tile, or in front of wall if blocked)
	# Returns Vector2.ZERO if no valid position found
	if not dungeon_tilemap_layer:
		return Vector2.ZERO
	
	# Get player position for raycast
	var player_pos = Vector2.ZERO
	if local_players.size() > 0:
		var player = local_players[0]
		if player and is_instance_valid(player) and player.is_local_player:
			player_pos = player.global_position
	
	# Get tile position from world position
	var tile_pos = dungeon_tilemap_layer.local_to_map(target_world_pos - dungeon_tilemap_layer.global_position)
	var tile_center = dungeon_tilemap_layer.map_to_local(tile_pos) + dungeon_tilemap_layer.global_position
	
	# Check if target is on a floor tile and not blocked by wall
	if _is_valid_spell_target(tile_center, player_pos):
		return tile_center
	
	# If target is invalid, find closest valid position along the line from player to target
	var direction = (target_world_pos - player_pos).normalized()
	var max_distance = player_pos.distance_to(target_world_pos)
	var step_size = 16.0 # One tile
	var steps = int(max_distance / step_size) + 1
	
	# Search backwards from target towards player to find first valid position
	for i in range(steps + 1):
		var check_distance = max_distance - (i * step_size)
		if check_distance < 0:
			break
		
		var check_pos = player_pos + (direction * check_distance)
		var check_tile_pos = dungeon_tilemap_layer.local_to_map(check_pos - dungeon_tilemap_layer.global_position)
		var check_tile_center = dungeon_tilemap_layer.map_to_local(check_tile_pos) + dungeon_tilemap_layer.global_position
		
		if _is_valid_spell_target(check_tile_center, player_pos):
			return check_tile_center
	
	return Vector2.ZERO # No valid position found

func _is_valid_spell_target(target_pos: Vector2, player_pos: Vector2) -> bool:
	# Check if target position is valid for spell casting (floor tile, not blocked by wall)
	if dungeon_data.is_empty() or not dungeon_data.has("grid"):
		return false
	
	# Check if target is on a floor tile
	var tile_size = 16
	var tile_x = int(target_pos.x / tile_size)
	var tile_y = int(target_pos.y / tile_size)
	var grid = dungeon_data.grid
	var map_size = dungeon_data.map_size
	
	# Check bounds
	if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y:
		return false
	
	# Check if it's a floor (1), door (2), or corridor (3) tile - same as walkable
	if grid[tile_x][tile_y] != 1 and grid[tile_x][tile_y] != 2 and grid[tile_x][tile_y] != 3:
		return false
	
	# Check if there's a wall between player and target using raycast
	var space_state = get_world_2d().direct_space_state
	var query = PhysicsRayQueryParameters2D.new()
	query.from = player_pos
	query.to = target_pos
	query.collision_mask = 64 # Layer 7 = walls (bit 6 = 64)
	
	# Exclude player if we have a reference
	if local_players.size() > 0:
		var player = local_players[0]
		if player and is_instance_valid(player):
			query.exclude = [player.get_rid()]
	
	var result = space_state.intersect_ray(query)
	if result:
		return false
	
	return true

func _is_valid_spell_target_exclude(target_pos: Vector2, caster_pos: Vector2, exclude_rid: RID) -> bool:
	if dungeon_data.is_empty() or not dungeon_data.has("grid"):
		return false
	var tile_size = 16
	var tile_x: int = int(target_pos.x / tile_size)
	var tile_y: int = int(target_pos.y / tile_size)
	var grid = dungeon_data.grid
	var map_size = dungeon_data.map_size
	if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y:
		return false
	if grid[tile_x][tile_y] != 1 and grid[tile_x][tile_y] != 2 and grid[tile_x][tile_y] != 3:
		return false
	var space_state = get_world_2d().direct_space_state
	var query = PhysicsRayQueryParameters2D.new()
	query.from = caster_pos
	query.to = target_pos
	query.collision_mask = 64
	if exclude_rid != RID():
		query.exclude = [exclude_rid]
	var result = space_state.intersect_ray(query)
	return not result

func _get_valid_spell_target_for_enemy(enemy_pos: Vector2, target_pos: Vector2, enemy_rid: RID) -> Vector2:
	if not dungeon_tilemap_layer or dungeon_data.is_empty() or not dungeon_data.has("grid"):
		return Vector2.ZERO
	var dir = (target_pos - enemy_pos).normalized()
	var step = 16.0
	var max_dist = enemy_pos.distance_to(target_pos) + step * 4
	var steps = int(max_dist / step) + 1
	for i in range(steps + 1):
		var check_dist = min(float(i) * step, max_dist)
		var check_pos = enemy_pos + dir * check_dist
		var tile_pos = dungeon_tilemap_layer.local_to_map(check_pos - dungeon_tilemap_layer.global_position)
		var tile_center = dungeon_tilemap_layer.map_to_local(tile_pos) + dungeon_tilemap_layer.global_position
		if _is_valid_spell_target_exclude(tile_center, enemy_pos, enemy_rid):
			return tile_center
	return Vector2.ZERO

func _is_walkable_tile(tile_center: Vector2) -> bool:
	"""True if tile is floor/door/corridor (not a wall). No raycast - use for adjacent spikes only."""
	if dungeon_data.is_empty() or not dungeon_data.has("grid"):
		return false
	var tile_size = 16
	var tile_x = int(tile_center.x / tile_size)
	var tile_y = int(tile_center.y / tile_size)
	var grid = dungeon_data.grid
	var map_size = dungeon_data.map_size
	if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y:
		return false
	var v = grid[tile_x][tile_y]
	return v == 1 or v == 2 or v == 3

func _get_adjacent_valid_spell_tile_centers(center_world_pos: Vector2, _player_pos: Vector2) -> Array:
	var out: Array = []
	if not dungeon_tilemap_layer or dungeon_data.is_empty() or not dungeon_data.has("grid"):
		return out
	var center_tile = dungeon_tilemap_layer.local_to_map(center_world_pos - dungeon_tilemap_layer.global_position)
	var offsets = [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]
	for off in offsets:
		var t = center_tile + off
		var tile_center = dungeon_tilemap_layer.map_to_local(t) + dungeon_tilemap_layer.global_position
		if _is_walkable_tile(tile_center):
			out.append(tile_center)
	return out

func _init_fog_of_war():
	if dungeon_data.is_empty() or not dungeon_data.has("map_size"):
		return
	
	# Create or reset fog node
	if fog_node and is_instance_valid(fog_node):
		fog_node.queue_free()
		fog_node = null
	
	var fog_script = load("res://scripts/fog_of_war.gd")
	fog_node = Node2D.new()
	fog_node.name = "FogOfWar"
	fog_node.z_index = 100
	fog_node.set_script(fog_script)
	add_child(fog_node)
	if fog_node.has_method("setup"):
		fog_node.setup(dungeon_data.map_size, FOG_TILE_SIZE)
	
	# Initialize seen arrays for current players
	seen_by_player.clear()
	var players = get_tree().get_nodes_in_group("player")
	for player in players:
		if player and player.name != "":
			seen_by_player[player.name] = _create_seen_array(dungeon_data.map_size)
	
	combined_seen = _create_seen_array(dungeon_data.map_size)
	explored_map = _create_seen_array(dungeon_data.map_size)
	
	# Clear corridor cache when initializing new fog
	cached_corridor_mask.clear()
	cached_corridor_rooms.clear()
	cached_corridor_player_tile = Vector2i(-1, -1)
	cached_corridor_allowed_room_ids.clear()
	was_in_corridor = false
	last_corridor_fog_update = 0.0
	# Reset torch-based darkening state for new level
	_torch_darken_initialized = false
	_torch_darken_in_room_last = false
	_torch_darken_last_room_id = ""
	_torch_darken_target_scale = 1.0
	_torch_darken_current_scale = 1.0

func _update_fog_of_war(delta: float) -> void:
	if not fog_node or dungeon_data.is_empty() or not dungeon_data.has("map_size"):
		return
	
	# Determine if we're in a corridor and use appropriate update interval
	var in_corridor = false
	if player_manager.get_local_players().size() > 0 and player_manager.get_local_players()[0]:
		var p_tile = Vector2i(int(player_manager.get_local_players()[0].global_position.x / FOG_TILE_SIZE), int(player_manager.get_local_players()[0].global_position.y / FOG_TILE_SIZE))
		in_corridor = _find_room_at_tile(p_tile).is_empty()
	
	# Detect corridor entry/exit transitions
	var corridor_state_changed = (in_corridor != was_in_corridor)
	if corridor_state_changed:
		# State changed - clear cache and force update
		cached_corridor_mask.clear()
		cached_corridor_rooms.clear()
		cached_corridor_player_tile = Vector2i(-1, -1)
		cached_corridor_allowed_room_ids.clear()
		was_in_corridor = in_corridor
		print("GameWorld: Corridor state changed - was_in_corridor: ", !in_corridor, " -> in_corridor: ", in_corridor)
	
	# In corridors: only update when entering/exiting or when player moves significantly
	# Skip expensive updates if we're stationary in a corridor
	if in_corridor and not corridor_state_changed:
		# Check if player moved significantly (more than 1 tile)
		var current_player_tile = Vector2i(int(player_manager.get_local_players()[0].global_position.x / FOG_TILE_SIZE), int(player_manager.get_local_players()[0].global_position.y / FOG_TILE_SIZE))
		var player_moved = cached_corridor_player_tile.distance_to(current_player_tile) > 1
		
		# Only update if player moved significantly OR enough time has passed (much longer interval)
		var time_since_last_update = Time.get_ticks_msec() / 1000.0 - last_corridor_fog_update
		if not player_moved and time_since_last_update < 1.0: # Skip if stationary and updated recently
			return # Skip expensive fog update - player is stationary in corridor
	
	var update_interval = FOG_UPDATE_INTERVAL_CORRIDOR if in_corridor else FOG_UPDATE_INTERVAL
	fog_update_timer += delta
	if fog_update_timer < update_interval:
		return
	fog_update_timer = 0.0
	
	var map_size = dungeon_data.map_size
	combined_seen = _create_seen_array(map_size)
	if explored_map.is_empty():
		explored_map = _create_seen_array(map_size)
	
	var local_player_list = player_manager.get_local_players()
	fog_debug_lines.clear()
	for player in local_player_list:
		if not player or not is_instance_valid(player):
			continue
		if not seen_by_player.has(player.name):
			seen_by_player[player.name] = _create_seen_array(map_size)
		var seen_map = seen_by_player[player.name]
		_update_seen_for_player(player, seen_map)
		_combine_seen_maps(combined_seen, seen_map)
		_combine_seen_maps(explored_map, seen_map)
	
	# Mask visibility to current room only (hide other rooms even if previously seen)
	var current_room = {}
	if local_player_list.size() > 0 and local_player_list[0]:
		var p_tile = Vector2i(int(local_player_list[0].global_position.x / FOG_TILE_SIZE), int(local_player_list[0].global_position.y / FOG_TILE_SIZE))
		current_room = _find_room_at_tile(p_tile)
		if not current_room.is_empty():
			_mark_room_explored(current_room)
			_mark_room_visible(current_room)
			# Only hide other rooms; keep corridor visible when we can see into it (no closed door in way)
			for y in range(map_size.y):
				for x in range(map_size.x):
					var idx = x + y * map_size.x
					if idx < 0 or idx >= combined_seen.size():
						continue
					var tile_room = _find_room_at_tile(Vector2i(x, y))
					if tile_room.is_empty():
						continue # Corridor: keep (already revealed by raycast if visible)
					if tile_room.x != current_room.x or tile_room.y != current_room.y or tile_room.w != current_room.w or tile_room.h != current_room.h:
						combined_seen[idx] = 0 # Other room: hide
		else:
			# In corridors (no room), only show tiles connected to the corridor component
			# AND explicitly clear combined_seen for all tiles in rooms that aren't connected
			var corridor_player_tile = Vector2i(int(local_player_list[0].global_position.x / FOG_TILE_SIZE), int(local_player_list[0].global_position.y / FOG_TILE_SIZE))
			
			# Cache corridor data - only rebuild if player moved more than 1 tile
			var should_rebuild_corridor = cached_corridor_mask.is_empty() or cached_corridor_player_tile.distance_to(corridor_player_tile) > 1
			
			var corridor_mask: PackedInt32Array
			var corridor_rooms: Array
			var allowed_room_ids: Dictionary
			
			if should_rebuild_corridor:
				# Rebuild corridor mask and rooms (expensive operation)
				cached_corridor_mask = _build_corridor_mask(corridor_player_tile)
				cached_corridor_rooms = _get_rooms_connected_to_corridor(cached_corridor_mask, corridor_player_tile)
				cached_corridor_player_tile = corridor_player_tile
				# Build a set of allowed room IDs for fast lookup
				cached_corridor_allowed_room_ids = {}
				for room in cached_corridor_rooms:
					var room_id = str(room.x) + "," + str(room.y) + "," + str(room.w) + "," + str(room.h)
					cached_corridor_allowed_room_ids[room_id] = true
				# Use the rebuilt data
				corridor_mask = cached_corridor_mask
				corridor_rooms = cached_corridor_rooms
				allowed_room_ids = cached_corridor_allowed_room_ids
			else:
				# Use cached data (much faster!)
				corridor_mask = cached_corridor_mask
				corridor_rooms = cached_corridor_rooms
				allowed_room_ids = cached_corridor_allowed_room_ids
			
			# Check explored rooms and mark them visible
			for room in corridor_rooms:
				# If this room was previously explored, mark the entire room (including outer walls) as visible
				var was_explored = false
				for x in range(room.x - 2, room.x + room.w + 2):
					for y in range(room.y - 2, room.y + room.h + 2):
						if x < 0 or y < 0 or x >= map_size.x or y >= map_size.y:
							continue
						var idx = x + y * map_size.x
						if idx >= 0 and idx < explored_map.size() and explored_map[idx] == 1:
							was_explored = true
							break
					if was_explored:
						break
				if was_explored:
					_mark_room_visible(room)
			# Explicitly clear combined_seen for ANY tile not in corridor mask or allowed rooms
			# OPTIMIZATION: Only do expensive per-tile checks when corridor state changed or player moved significantly
			var needs_tile_clear = corridor_state_changed or should_rebuild_corridor
			if needs_tile_clear:
				for y in range(map_size.y):
					for x in range(map_size.x):
						var idx = x + y * map_size.x
						if idx < 0 or idx >= combined_seen.size():
							continue
						var tile_in_corridor = idx < corridor_mask.size() and corridor_mask[idx] == 1
						# Check if this tile is in a room, and if so, is it an allowed room?
						var tile_room = _find_room_at_tile(Vector2i(x, y))
						var in_allowed_room = false
						if not tile_room.is_empty():
							var room_id = str(tile_room.x) + "," + str(tile_room.y) + "," + str(tile_room.w) + "," + str(tile_room.h)
							in_allowed_room = allowed_room_ids.has(room_id)
						# Clear combined_seen for any tile not in corridor or allowed rooms
						if not tile_in_corridor and not in_allowed_room:
							combined_seen[idx] = 0
			
			# Update last corridor fog update time
			last_corridor_fog_update = Time.get_ticks_msec() / 1000.0
	
	if fog_node.has_method("set_maps"):
		fog_node.set_maps(explored_map, combined_seen)
	if fog_node.has_method("set_debug_lines"):
		fog_node.set_debug_lines(fog_debug_lines, FOG_DEBUG_DRAW)
	var player_tile := Vector2i(-1, -1)
	if local_player_list.size() > 0 and local_player_list[0]:
		player_tile = Vector2i(int(local_player_list[0].global_position.x / FOG_TILE_SIZE), int(local_player_list[0].global_position.y / FOG_TILE_SIZE))
	var minimap_draw = get_node_or_null("Minimap/MarginContainer/MinimapView/MinimapDraw")
	if minimap_draw and minimap_draw.has_method("set_maps") and dungeon_data.has("grid"):
		var exit_tile := Vector2i(-1, -1)
		var exit_discovered := false
		if dungeon_data.has("stairs") and not dungeon_data.stairs.is_empty():
			var s = dungeon_data.stairs
			if s.has("x") and s.has("y") and s.has("w") and s.has("h"):
				exit_tile = Vector2i(s.x + s.w / 2, s.y + s.h / 2)
				for dx in range(int(s.w)):
					for dy in range(int(s.h)):
						var tx: int = int(s.x) + dx
						var ty: int = int(s.y) + dy
						if tx >= 0 and ty >= 0 and tx < map_size.x and ty < map_size.y:
							var idx: int = tx + ty * map_size.x
							if idx >= 0 and idx < explored_map.size() and explored_map[idx] != 0:
								exit_discovered = true
								break
					if exit_discovered:
						break
		minimap_draw.set_maps(explored_map, map_size, dungeon_data.grid, player_tile, exit_tile, exit_discovered)

func _create_seen_array(map_size: Vector2i) -> PackedInt32Array:
	var size = map_size.x * map_size.y
	var arr = PackedInt32Array()
	arr.resize(size)
	for i in range(size):
		arr[i] = 0
	return arr

func _combine_seen_maps(target: PackedInt32Array, source: PackedInt32Array) -> void:
	var size = min(target.size(), source.size())
	for i in range(size):
		if source[i] != 0:
			target[i] = 1

func _update_seen_for_player(player: Node, seen_map: PackedInt32Array) -> void:
	var map_size = dungeon_data.map_size
	var p_tile = Vector2(player.global_position.x / FOG_TILE_SIZE, player.global_position.y / FOG_TILE_SIZE)
	var current_room = _find_room_at_tile(Vector2i(int(p_tile.x), int(p_tile.y)))
	var view_dir = _get_player_view_dir(player)
	_mark_seen(seen_map, Vector2i(int(p_tile.x), int(p_tile.y)))
	
	# When in corridor, restrict raycasts to corridor + connected rooms only
	var allowed_rooms_for_corridor = null
	if current_room.is_empty():
		var player_tile = Vector2i(int(p_tile.x), int(p_tile.y))
		var corridor_mask = _build_corridor_mask(player_tile)
		allowed_rooms_for_corridor = _get_rooms_connected_to_corridor(corridor_mask, player_tile)
	
	for angle in range(0, 360, FOG_RAY_ANGLE_STEP):
		var rad = deg_to_rad(float(angle))
		var dir = Vector2(cos(rad), sin(rad))
		var angle_weight = _get_view_angle_weight(view_dir, dir)
		var ray_range = lerp(FOG_BACK_RANGE_TILES, FOG_VIEW_RANGE_TILES, angle_weight)
		var last_tile = Vector2i(-999, -999)
		var steps = int(ray_range / FOG_RAY_STEP)
		var after_block_tiles = 0
		var last_world = player.global_position
		for s in range(steps + 1):
			var dist = float(s) * FOG_RAY_STEP
			var sample = p_tile + dir * dist
			var tile = Vector2i(int(floor(sample.x)), int(floor(sample.y)))
			if tile == last_tile:
				continue
			last_tile = tile
			if tile.x < 0 or tile.y < 0 or tile.x >= map_size.x or tile.y >= map_size.y:
				break
			# If player is in a room: allow seeing into corridor through open doorways (ray stops at wall/closed door only)
			# If player is in corridor, only allow seeing tiles in corridor or connected rooms
			if current_room.is_empty() and allowed_rooms_for_corridor != null:
				var tile_room = _find_room_at_tile(tile)
				# If tile is in a room, check if it's one of the allowed (connected) rooms
				if not tile_room.is_empty():
					var in_allowed_room = false
					for room in allowed_rooms_for_corridor:
						if _is_tile_in_room_or_walls(tile, room):
							in_allowed_room = true
							break
					# If tile is in a room that's not allowed, stop the ray
					if not in_allowed_room:
						break
			last_world = Vector2(tile.x * FOG_TILE_SIZE + FOG_TILE_SIZE * 0.5, tile.y * FOG_TILE_SIZE + FOG_TILE_SIZE * 0.5)
			_mark_seen(seen_map, tile)
			if _is_visibility_blocking_tile(tile):
				# Stop ray on any wall/closed door
				break
			elif after_block_tiles > 0:
				after_block_tiles += 1
				if after_block_tiles > 3:
					break
		if FOG_DEBUG_DRAW:
			fog_debug_lines.append([player.global_position, last_world])

func _mark_seen(seen_map: PackedInt32Array, tile: Vector2i) -> void:
	var map_size = dungeon_data.map_size
	if tile.x < 0 or tile.y < 0 or tile.x >= map_size.x or tile.y >= map_size.y:
		return
	var idx = tile.x + tile.y * map_size.x
	if idx >= 0 and idx < seen_map.size():
		seen_map[idx] = 1

func _is_tile_in_room_or_walls(tile: Vector2i, room: Dictionary) -> bool:
	if room.is_empty():
		return false
	# Room bounds including 2-tile walls: room.x..room.x+room.w, room.y..room.y+room.h
	return tile.x >= room.x and tile.x < room.x + room.w and tile.y >= room.y and tile.y < room.y + room.h

func _mark_room_explored(room: Dictionary) -> void:
	if room.is_empty():
		return
	var map_size = dungeon_data.map_size
	# Include 2-tile wall thickness around the room
	for x in range(room.x - 2, room.x + room.w + 2):
		for y in range(room.y - 2, room.y + room.h + 2):
			if x < 0 or y < 0 or x >= map_size.x or y >= map_size.y:
				continue
			var idx = x + y * map_size.x
			if idx >= 0 and idx < explored_map.size():
				explored_map[idx] = 1

func _mark_room_visible(room: Dictionary) -> void:
	if room.is_empty():
		return
	var map_size = dungeon_data.map_size
	# Include 2-tile wall thickness around the room
	for x in range(room.x - 2, room.x + room.w + 2):
		for y in range(room.y - 2, room.y + room.h + 2):
			if x < 0 or y < 0 or x >= map_size.x or y >= map_size.y:
				continue
			var idx = x + y * map_size.x
			if idx >= 0 and idx < combined_seen.size():
				combined_seen[idx] = 1

func _find_room_at_tile(tile: Vector2i) -> Dictionary:
	if dungeon_data.is_empty() or not dungeon_data.has("rooms"):
		return {}
	for room in dungeon_data.rooms:
		if tile.x >= room.x and tile.x < room.x + room.w and tile.y >= room.y and tile.y < room.y + room.h:
			return room
	return {}

func _get_room_index_for_tile(tile: Vector2i) -> int:
	if dungeon_data.is_empty() or not dungeon_data.has("rooms"):
		return -1
	for i in range(dungeon_data.rooms.size()):
		var room = dungeon_data.rooms[i]
		if tile.x >= room.x and tile.x < room.x + room.w and tile.y >= room.y and tile.y < room.y + room.h:
			return i
	return -1

func _count_torches_in_room(room: Dictionary) -> int:
	if dungeon_data.is_empty() or not dungeon_data.has("torches"):
		return 0
	var tile_size := 16
	var min_x: float = room.x * tile_size
	var min_y: float = room.y * tile_size
	var max_x: float = (room.x + room.w) * tile_size
	var max_y: float = (room.y + room.h) * tile_size
	var count := 0
	for t in dungeon_data.torches:
		var pos: Vector2 = t.get("position", Vector2.ZERO)
		if pos.x >= min_x and pos.x <= max_x and pos.y >= min_y and pos.y <= max_y:
			count += 1
	return count

func _median_torch_scale_from_rooms(rooms: Array) -> float:
	if rooms.is_empty():
		return 0.0
	var counts: Array = []
	for r in rooms:
		counts.append(_count_torches_in_room(r))
	counts.sort()
	var n := counts.size()
	var mid := int(n / 2.0)
	var median: float
	if n % 2 == 1:
		median = float(counts[mid])
	else:
		median = (float(counts[mid - 1]) + float(counts[mid])) * 0.5
	median = clampf(median, 0.0, 4.0)
	return median / 4.0

func _update_canvas_modulate_by_torches() -> void:
	if dungeon_data.is_empty() or not dungeon_data.has("torches"):
		return
	var cm = get_node_or_null("CanvasModulate")
	if not cm or not is_instance_valid(cm):
		return
	var local_list = player_manager.get_local_players() if player_manager else []
	if local_list.is_empty() or not local_list[0]:
		return
	var p = local_list[0]
	var p_tile = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE))
	var current_room = _find_room_at_tile(p_tile)
	var in_room := not current_room.is_empty()
	var room_id := ""
	if in_room:
		room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h)
	var transition := not _torch_darken_initialized
	if _torch_darken_initialized:
		if in_room != _torch_darken_in_room_last:
			transition = true
		elif in_room and room_id != _torch_darken_last_room_id:
			transition = true
	if transition:
		_torch_darken_initialized = true
		_torch_darken_in_room_last = in_room
		_torch_darken_last_room_id = room_id
		if in_room:
			var tc = clampi(_count_torches_in_room(current_room), 0, 4)
			_torch_darken_target_scale = tc / 4.0
		else:
			_torch_darken_target_scale = _median_torch_scale_from_rooms(cached_corridor_rooms)
	var delta := get_process_delta_time()
	_torch_darken_current_scale = lerpf(_torch_darken_current_scale, _torch_darken_target_scale, clampf(delta * _TORCH_DARKEN_LERP_SPEED, 0.0, 1.0))
	var s := maxf(_torch_darken_current_scale, _TORCH_DARKEN_MIN_SCALE)
	cm.color = Color(s, s, s)

func _reapply_torch_darkening() -> void:
	if dungeon_data.is_empty() or not dungeon_data.has("torches"):
		return
	var cm = get_node_or_null("CanvasModulate")
	if not cm or not is_instance_valid(cm):
		return
	var local_list = player_manager.get_local_players() if player_manager else []
	if local_list.is_empty() or not local_list[0]:
		return
	var p = local_list[0]
	var p_tile = Vector2i(int(p.global_position.x / FOG_TILE_SIZE), int(p.global_position.y / FOG_TILE_SIZE))
	var current_room = _find_room_at_tile(p_tile)
	var in_room := not current_room.is_empty()
	var target: float
	if in_room:
		var tc = clampi(_count_torches_in_room(current_room), 0, 4)
		target = tc / 4.0
	else:
		target = _median_torch_scale_from_rooms(cached_corridor_rooms)
	var t := maxf(target, _TORCH_DARKEN_MIN_SCALE)
	_torch_darken_target_scale = target
	_torch_darken_current_scale = target
	var room_id := ""
	if in_room:
		room_id = str(current_room.x) + "," + str(current_room.y) + "," + str(current_room.w) + "," + str(current_room.h)
	_torch_darken_initialized = true
	_torch_darken_in_room_last = in_room
	_torch_darken_last_room_id = room_id
	cm.color = Color(t, t, t)

func _get_view_angle_weight(view_dir: Vector2, ray_dir: Vector2) -> float:
	if view_dir.length() < 0.1 or ray_dir.length() < 0.1:
		return 1.0
	var dot = clamp(view_dir.normalized().dot(ray_dir.normalized()), -1.0, 1.0)
	# Map dot (-1..1) -> weight (0..1)
	return (dot + 1.0) * 0.5

func _get_player_view_dir(player: Node) -> Vector2:
	if player and "current_direction" in player:
		match player.current_direction:
			0:
				return Vector2.RIGHT
			1:
				return Vector2.DOWN
			2:
				return Vector2.LEFT
			3:
				return Vector2.UP
			4:
				return Vector2(1, 1)
			5:
				return Vector2(-1, 1)
			6:
				return Vector2(-1, -1)
			7:
				return Vector2(1, -1)
	return Vector2.RIGHT

func _is_visibility_blocking_tile(tile: Vector2i) -> bool:
	# Walls block vision
	if dungeon_data.grid[tile.x][tile.y] == 0:
		return true
	
	# Closed doors block vision
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		for door in entities_node.get_children():
			if door.is_in_group("blocking_door"):
				if "is_closed" in door and door.is_closed:
					var door_tile = Vector2i(int(door.global_position.x / FOG_TILE_SIZE), int(door.global_position.y / FOG_TILE_SIZE))
					if door_tile == tile:
						return true
	return false

func _build_corridor_mask(start_tile: Vector2i) -> PackedInt32Array:
	var map_size = dungeon_data.map_size
	var mask = _create_seen_array(map_size)
	if start_tile.x < 0 or start_tile.y < 0 or start_tile.x >= map_size.x or start_tile.y >= map_size.y:
		return mask
	
	var queue: Array = []
	queue.append(start_tile)
	mask[start_tile.x + start_tile.y * map_size.x] = 1
	
	var max_steps = 10 # Reduced from 24 to prevent corridor branches from reaching far rooms
	while queue.size() > 0:
		var tile = queue.pop_front()
		var dist = abs(tile.x - start_tile.x) + abs(tile.y - start_tile.y)
		if dist > max_steps:
			continue
		var neighbors = [
			Vector2i(tile.x + 1, tile.y),
			Vector2i(tile.x - 1, tile.y),
			Vector2i(tile.x, tile.y + 1),
			Vector2i(tile.x, tile.y - 1)
		]
		for n in neighbors:
			if n.x < 0 or n.y < 0 or n.x >= map_size.x or n.y >= map_size.y:
				continue
			var idx = n.x + n.y * map_size.x
			if mask[idx] == 1:
				continue
			# Only traverse walkable tiles (non-wall), and stop at closed doors
			if dungeon_data.grid[n.x][n.y] == 0:
				continue
			if _is_door_tile(n) and _is_visibility_blocking_tile(n):
				continue
			mask[idx] = 1
			queue.append(n)
	
	return mask

func _get_rooms_connected_to_corridor(corridor_mask: PackedInt32Array, player_tile: Vector2i) -> Array:
	var rooms = []
	if dungeon_data.is_empty() or not dungeon_data.has("rooms") or player_tile.x < 0:
		return rooms
	var map_size = dungeon_data.map_size
	# Only check rooms within a small distance of the player (8 tiles)
	var max_room_distance = 8
	var room_distances = []
	for room in dungeon_data.rooms:
		# Calculate room center distance from player - skip if too far
		var room_center = Vector2i(room.x + room.w / 2, room.y + room.h / 2)
		var room_dist = abs(room_center.x - player_tile.x) + abs(room_center.y - player_tile.y)
		if room_dist > max_room_distance * 2:
			continue
		
		var touches_corridor = false
		var closest_corridor_dist = 999999
		# Only check room perimeter (walls and door areas) within close range of player
		for x in range(room.x - 2, room.x + room.w + 2):
			for y in range(room.y - 2, room.y + room.h + 2):
				if x < 0 or y < 0 or x >= map_size.x or y >= map_size.y:
					continue
				# Only check tiles near the player (within max_room_distance)
				var tile_dist = abs(x - player_tile.x) + abs(y - player_tile.y)
				if tile_dist > max_room_distance:
					continue
				# Check if this tile is adjacent to a corridor tile in the mask
				var neighbors = [
					Vector2i(x + 1, y),
					Vector2i(x - 1, y),
					Vector2i(x, y + 1),
					Vector2i(x, y - 1)
				]
				for n in neighbors:
					if n.x < 0 or n.y < 0 or n.x >= map_size.x or n.y >= map_size.y:
						continue
					var n_dist = abs(n.x - player_tile.x) + abs(n.y - player_tile.y)
					if n_dist > max_room_distance:
						continue
					var n_idx = n.x + n.y * map_size.x
					if n_idx >= 0 and n_idx < corridor_mask.size() and corridor_mask[n_idx] == 1:
						# Check if n is actually a corridor tile (grid value 3) or a door (grid value 2)
						if dungeon_data.grid[n.x][n.y] == 3 or dungeon_data.grid[n.x][n.y] == 2:
							touches_corridor = true
							if n_dist < closest_corridor_dist:
								closest_corridor_dist = n_dist
							break
				if touches_corridor:
					break
			if touches_corridor:
				break
		if touches_corridor:
			room_distances.append({"room": room, "dist": closest_corridor_dist})
	
	# Sort by distance and return only the 2 closest rooms
	room_distances.sort_custom(func(a, b): return a.dist < b.dist)
	for i in range(min(2, room_distances.size())):
		rooms.append(room_distances[i].room)
	
	return rooms

func _is_door_tile(tile: Vector2i) -> bool:
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return false
	for door in entities_node.get_children():
		if door.is_in_group("blocking_door"):
			var door_tile = Vector2i(int(door.global_position.x / FOG_TILE_SIZE), int(door.global_position.y / FOG_TILE_SIZE))
			if door_tile == tile:
				return true
	return false

func _generate_dungeon():
	print("GameWorld: _generate_dungeon() called - is_server: ", multiplayer.is_server(), ", has_peer: ", multiplayer.has_multiplayer_peer())
	
	if not multiplayer.is_server() and multiplayer.has_multiplayer_peer():
		LogManager.log("GameWorld: Not server, skipping dungeon generation", LogManager.CATEGORY_DUNGEON)
		return
	
	# Reset level complete flag for new level
	level_complete_triggered = false
	game_over_triggered = false # Reset game over flag for new level
	dead_players.clear()
	respawn_all_check_running = false
	
	# CRITICAL: Reset all players' death state flags when transitioning to a new level
	# This prevents GAME OVER from showing incorrectly when players are alive in the new level
	var players = get_tree().get_nodes_in_group("player")
	for player in players:
		if is_instance_valid(player):
			if "is_dead" in player:
				player.is_dead = false
			if "is_processing_death" in player:
				player.is_processing_death = false
			# Reset exit notification flag for new level
			if "has_seen_exit_this_level" in player:
				player.has_seen_exit_this_level = false
			LogManager.log("GameWorld: Reset death state for player " + str(player.name) + " on level transition", LogManager.CATEGORY_DUNGEON)
	
	# Hide game over UI if it exists
	var game_over_ui = get_node_or_null("GameOverUI")
	if game_over_ui:
		game_over_ui.visible = false
	
	print("GameWorld: Generating dungeon level ", current_level)
	
	# Generate seed (random for all levels)
	if dungeon_seed == 0:
		dungeon_seed = randi()
	
	# Create dungeon generator
	var generator = load("res://scripts/dungeon_generator.gd").new()
	var map_size = Vector2i(72, 72) # 72x72 tiles
	
	# Hide all players and remove collision before generating new level
	_hide_all_players()
	
	# Generate dungeon (pass current level for scaling)
	dungeon_data = generator.generate_dungeon(map_size, dungeon_seed, current_level)
	
	if dungeon_data.is_empty():
		push_error("ERROR: Dungeon generation returned empty data!")
		return
	
	LogManager.log("GameWorld: Dungeon generated with " + str(dungeon_data.rooms.size()) + " rooms", LogManager.CATEGORY_DUNGEON)
	print("GameWorld: Start room at ", dungeon_data.start_room.x, ", ", dungeon_data.start_room.y)
	print("GameWorld: Map size: ", dungeon_data.map_size)
	
	# Render dungeon
	_render_dungeon()
	
	# Spawn torches
	_spawn_torches()
	
	# Spawn enemies
	_spawn_enemies()
	
	# Spawn interactable objects
	_spawn_interactable_objects()
	
	# Spawn traps
	_spawn_traps()
	
	# Spawn blocking doors
	_spawn_blocking_doors()
	
	# Spawn room triggers
	_spawn_room_triggers()
	
	# Wait a frame to ensure enemies and objects are properly in scene tree before syncing
	await get_tree().process_frame
	
	# Update player spawn points based on start room
	_update_spawn_points()
	
	# Spawn players for all connected peers (after dungeon is generated and spawn points are set)
	# This ensures players spawn at the correct location
	_spawn_all_players()
	
	# Move any already-spawned players to the correct spawn points
	_move_all_players_to_start_room()
	
	# Restore players (make visible and restore collision) after a short delay
	_schedule_restore_all_players(0.6)
	
	# Update camera immediately to ensure it's looking at the players
	await get_tree().process_frame # Wait a frame for players to be fully in scene tree
	_update_camera()
	
	# Show level number (for initial level generation only - not when called from level completion)
	# Check if this is initial generation by checking if we're in _ready or if level is 1
	# For level completion, the level number is shown after _generate_dungeon() completes
	if current_level == 1:
		_show_level_number()
		# Sync to all clients
		if multiplayer.has_multiplayer_peer():
			_rpc_to_ready_peers("_sync_show_level_number", [current_level])
	
	# Load HUD after dungeon generation completes (non-blocking)
	call_deferred("_load_hud")
	
	# Pack entire dungeon into blob and chunk it (server only)
	if multiplayer.is_server():
		_pack_dungeon_blob()
	
	# Sync dungeon to all clients using blob sync
	if multiplayer.has_multiplayer_peer():
		# Get host's current room for spawning new players near host
		var host_room = _get_host_room()
		
		# Debug: Check if enemies are in dungeon_data before syncing
		if dungeon_data.has("enemies"):
			LogManager.log("GameWorld: Server syncing dungeon with " + str(dungeon_data.enemies.size()) + " enemies", LogManager.CATEGORY_GAMEPLAY)
		else:
			LogManager.log("GameWorld: WARNING: Server dungeon_data has NO 'enemies' key before sync!", LogManager.CATEGORY_DUNGEON)
		
		# Use blob sync for all connected peers
		var peers = multiplayer.get_peers()
		for peer_id in peers:
			if peer_id != 1: # Don't sync to self (server is peer 1)
				print("GameWorld: HOST - Syncing new level to peer ", peer_id, " using blob sync")
				_sync_dungeon_to_client(peer_id, dungeon_data, dungeon_seed, current_level, host_room)
	
	LogManager.log("GameWorld: Dungeon generation completed successfully", LogManager.CATEGORY_DUNGEON)

# Dungeon shader color replacement: 13 original colors (wall x6, ground x5, fallout x2)
const _DUNGEON_ORIGINALS: Array = [
	Color(24 / 255.0, 59 / 255.0, 255 / 255.0), # 0 wall
	Color(33 / 255.0, 50 / 255.0, 195 / 255.0), # 1 wall
	Color(98 / 255.0, 29 / 255.0, 93 / 255.0), # 2 wall
	Color(66 / 255.0, 13 / 255.0, 52 / 255.0), # 3 wall
	Color(74 / 255.0, 33 / 255.0, 134 / 255.0), # 4 wall
	Color(50 / 255.0, 12 / 255.0, 23 / 255.0), # 5 wall
	Color(149 / 255.0, 79 / 255.0, 111 / 255.0), # 6 ground
	Color(192 / 255.0, 95 / 255.0, 193 / 255.0), # 7 ground
	Color(48 / 255.0, 38 / 255.0, 20 / 255.0), # 8 ground
	Color(143 / 255.0, 71 / 255.0, 112 / 255.0), # 9 ground
	Color(106 / 255.0, 62 / 255.0, 57 / 255.0), # 10 ground
	Color(69 / 255.0, 42 / 255.0, 31 / 255.0), # 11 fallout
	Color(53 / 255.0, 46 / 255.0, 26 / 255.0), # 12 fallout
]

# Original wall slots ordered light→dark (by luminance). Tile art uses these for highlight/mid/shadow.
# We must assign scheme colors so light→dark is preserved, or shading inverts and looks wrong.
const _WALL_LIGHT_TO_DARK_ORDER: Array = [0, 1, 2, 4, 3, 5]

func _luminance(c: Color) -> float:
	return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b

func _reorder_wall_colors_by_luminance(six_colors: Array) -> Array:
	assert(six_colors.size() == 6)
	var with_lum: Array = []
	for i in range(6):
		var c = six_colors[i] as Color
		with_lum.append({"color": c, "lum": _luminance(c)})
	with_lum.sort_custom(func(a, b): return (a["lum"] as float) > (b["lum"] as float))
	var sorted_colors: Array = []
	for e in with_lum:
		sorted_colors.append(e["color"])
	var out: Array = []
	for i in range(6):
		out.append(Color.BLACK)
	out[0] = sorted_colors[0]
	out[1] = sorted_colors[1]
	out[2] = sorted_colors[2]
	out[3] = sorted_colors[4]
	out[4] = sorted_colors[3]
	out[5] = sorted_colors[5]
	return out

func _get_dungeon_color_scheme(scheme_index: int) -> Array:
	var o = _DUNGEON_ORIGINALS
	var walls: Array
	var ground_fallout: Array
	match scheme_index:
		0: # 1️⃣ Arcane Blue (magic / night / mana)
			walls = [
				Color(24 / 255.0, 59 / 255.0, 255 / 255.0), Color(80 / 255.0, 120 / 255.0, 255 / 255.0), Color(140 / 255.0, 180 / 255.0, 255 / 255.0),
				Color(10 / 255.0, 30 / 255.0, 120 / 255.0), Color(180 / 255.0, 200 / 255.0, 255 / 255.0), Color(220 / 255.0, 230 / 255.0, 255 / 255.0),
			]
			ground_fallout = [
				Color(0.78, 0.48, 0.24), Color(0.84, 0.54, 0.30), Color(0.58, 0.36, 0.16),
				Color(0.72, 0.44, 0.22), Color(0.66, 0.40, 0.20), Color(0.38, 0.30, 0.22),
				Color(0.32, 0.28, 0.20),
			]
		1: # 2️⃣ Crimson Void (blood / corruption / danger)
			walls = [
				Color(120 / 255.0, 20 / 255.0, 40 / 255.0), Color(180 / 255.0, 40 / 255.0, 60 / 255.0), Color(220 / 255.0, 90 / 255.0, 110 / 255.0),
				Color(60 / 255.0, 5 / 255.0, 20 / 255.0), Color(255 / 255.0, 140 / 255.0, 160 / 255.0), Color(90 / 255.0, 10 / 255.0, 30 / 255.0),
			]
			ground_fallout = [
				Color(0.22, 0.58, 0.62), Color(0.28, 0.64, 0.66), Color(0.18, 0.48, 0.52),
				Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34),
				Color(0.22, 0.34, 0.30),
			]
		2: # 3️⃣ Toxic Green (poison / nature / alchemy)
			walls = [
				Color(20 / 255.0, 120 / 255.0, 40 / 255.0), Color(60 / 255.0, 180 / 255.0, 90 / 255.0), Color(120 / 255.0, 220 / 255.0, 160 / 255.0),
				Color(10 / 255.0, 60 / 255.0, 25 / 255.0), Color(180 / 255.0, 255 / 255.0, 210 / 255.0), Color(40 / 255.0, 90 / 255.0, 55 / 255.0),
			]
			ground_fallout = [
				Color(0.64, 0.36, 0.72), Color(0.70, 0.42, 0.78), Color(0.48, 0.26, 0.56),
				Color(0.58, 0.32, 0.66), Color(0.54, 0.30, 0.62), Color(0.34, 0.26, 0.38),
				Color(0.28, 0.22, 0.32),
			]
		3: # 4️⃣ Stone Grey (industrial / ruins / UI neutral) — brightened for visibility
			walls = [
				Color(95 / 255.0, 95 / 255.0, 100 / 255.0), Color(125 / 255.0, 125 / 255.0, 130 / 255.0), Color(170 / 255.0, 170 / 255.0, 175 / 255.0),
				Color(58 / 255.0, 58 / 255.0, 62 / 255.0), Color(200 / 255.0, 200 / 255.0, 205 / 255.0), Color(238 / 255.0, 238 / 255.0, 242 / 255.0),
			]
			ground_fallout = [
				Color(0.58, 0.58, 0.60), Color(0.62, 0.62, 0.64), Color(0.46, 0.46, 0.48),
				Color(0.55, 0.55, 0.57), Color(0.50, 0.50, 0.52), Color(0.40, 0.40, 0.42),
				Color(0.36, 0.36, 0.38),
			]
		4: # 5️⃣ Royal Purple (arcane royalty / bosses)
			walls = [
				Color(80 / 255.0, 30 / 255.0, 130 / 255.0), Color(130 / 255.0, 70 / 255.0, 180 / 255.0), Color(180 / 255.0, 130 / 255.0, 220 / 255.0),
				Color(40 / 255.0, 10 / 255.0, 80 / 255.0), Color(220 / 255.0, 180 / 255.0, 255 / 255.0), Color(100 / 255.0, 60 / 255.0, 150 / 255.0),
			]
			ground_fallout = [
				Color(0.90, 0.68, 0.22), Color(0.94, 0.74, 0.28), Color(0.72, 0.52, 0.14),
				Color(0.84, 0.60, 0.18), Color(0.78, 0.56, 0.16), Color(0.46, 0.36, 0.20),
				Color(0.38, 0.30, 0.18),
			]
		5: # 6️⃣ Desert Gold (sand / temples / sunlight)
			walls = [
				Color(150 / 255.0, 110 / 255.0, 40 / 255.0), Color(200 / 255.0, 160 / 255.0, 80 / 255.0), Color(240 / 255.0, 210 / 255.0, 140 / 255.0),
				Color(90 / 255.0, 60 / 255.0, 15 / 255.0), Color(255 / 255.0, 230 / 255.0, 170 / 255.0), Color(170 / 255.0, 130 / 255.0, 60 / 255.0),
			]
			ground_fallout = [
				Color(0.22, 0.58, 0.62), Color(0.28, 0.64, 0.66), Color(0.18, 0.48, 0.52),
				Color(0.20, 0.54, 0.58), Color(0.20, 0.50, 0.54), Color(0.26, 0.38, 0.34),
				Color(0.22, 0.34, 0.30),
			]
		6: # 7️⃣ Ancient Stone (medieval / castles / ruins)
			walls = [
				Color(120 / 255.0, 110 / 255.0, 100 / 255.0), Color(160 / 255.0, 150 / 255.0, 140 / 255.0), Color(200 / 255.0, 190 / 255.0, 180 / 255.0),
				Color(60 / 255.0, 55 / 255.0, 50 / 255.0), Color(220 / 255.0, 210 / 255.0, 200 / 255.0), Color(90 / 255.0, 85 / 255.0, 75 / 255.0),
			]
			ground_fallout = [
				Color(0.35, 0.28, 0.22), Color(0.40, 0.32, 0.26), Color(0.28, 0.22, 0.18),
				Color(0.38, 0.30, 0.24), Color(0.32, 0.26, 0.20), Color(0.24, 0.20, 0.16),
				Color(0.20, 0.16, 0.14),
			]
		7: # 8️⃣ Infernal Lava (hell / bosses / damage)
			walls = [
				Color(180 / 255.0, 40 / 255.0, 20 / 255.0), Color(240 / 255.0, 90 / 255.0, 30 / 255.0), Color(255 / 255.0, 160 / 255.0, 80 / 255.0),
				Color(90 / 255.0, 10 / 255.0, 5 / 255.0), Color(255 / 255.0, 210 / 255.0, 160 / 255.0), Color(140 / 255.0, 30 / 255.0, 15 / 255.0),
			]
			ground_fallout = [
				Color(0.32, 0.68, 0.48), Color(0.38, 0.74, 0.54), Color(0.22, 0.52, 0.36),
				Color(0.28, 0.62, 0.44), Color(0.26, 0.58, 0.40), Color(0.26, 0.34, 0.28),
				Color(0.22, 0.30, 0.24),
			]
		_:
			return o.duplicate()
	if walls.size() == 6 and ground_fallout.size() == 7:
		walls = _reorder_wall_colors_by_luminance(walls)
		var out: Array = []
		out.append_array(walls)
		out.append_array(ground_fallout)
		return out
	return o.duplicate()

func _apply_dungeon_color_scheme() -> void:
	var scheme_idx = (abs(dungeon_seed) + current_level) % 8
	var replace_colors = _get_dungeon_color_scheme(scheme_idx)
	var shader_res = load("res://shaders/game_world.gdshader") as Shader
	if not shader_res:
		return
	for layer in [dungeon_tilemap_layer, dungeon_tilemap_layer_above]:
		if not layer or not is_instance_valid(layer):
			continue
		var mat = layer.material
		if not mat or not (mat is ShaderMaterial):
			mat = ShaderMaterial.new()
			mat.shader = shader_res
			layer.material = mat
		var sm = mat as ShaderMaterial
		for i in range(13):
			var orig = _DUNGEON_ORIGINALS[i] as Color
			var rpl = replace_colors[i] as Color
			sm.set_shader_parameter("original_" + str(i), orig)
			sm.set_shader_parameter("replace_" + str(i), rpl)
		# Index 13 unused; set to no-op (original same as replace, distinct from tile colors)
		var neutral = Color(0.0, 0.0, 0.0, 1.0)
		sm.set_shader_parameter("original_13", neutral)
		sm.set_shader_parameter("replace_13", neutral)
		# TileMapLayerAbove: tint ffffff77 for slight transparency
		if layer == dungeon_tilemap_layer_above:
			sm.set_shader_parameter("tint", Color(1.0, 1.0, 1.0, 0x77 / 255.0))
		else:
			sm.set_shader_parameter("tint", Color(1.0, 1.0, 1.0, 1.0))
		# Apply CanvasModulate-style darkening via ambient (unshaded shader bypasses CanvasModulate)
		var cm = get_node_or_null("CanvasModulate")
		var ambient_color = Color(1.0, 1.0, 1.0, 1.0)
		if cm and is_instance_valid(cm):
			ambient_color = cm.color
		sm.set_shader_parameter("ambient", ambient_color)
	LogManager.log("GameWorld: Applied dungeon color scheme " + str(scheme_idx) + " (seed " + str(dungeon_seed) + ", level " + str(current_level) + ")", LogManager.CATEGORY_DUNGEON)

func _render_dungeon():
	if dungeon_data.is_empty():
		push_error("ERROR: Cannot render dungeon - no dungeon data!")
		return
	
	# Try to use existing TileMapLayer from scene, or create new one
	var env_node = get_node_or_null("Environment")
	
	if env_node:
		dungeon_tilemap_layer = env_node.get_node_or_null("DungeonLayer0")
		dungeon_tilemap_layer_above = env_node.get_node_or_null("TileMapLayerAbove")
	
	if not dungeon_tilemap_layer:
		# Create new TileMapLayer
		LogManager.log("GameWorld: Creating new TileMapLayer", LogManager.CATEGORY_DUNGEON)
		dungeon_tilemap_layer = TileMapLayer.new()
		dungeon_tilemap_layer.name = "DungeonLayer0"
		
		# Add to scene
		if environment:
			environment.add_child(dungeon_tilemap_layer)
		else:
			add_child(dungeon_tilemap_layer)
			move_child(dungeon_tilemap_layer, 0)
		
		dungeon_tilemap_layer.position = Vector2.ZERO
		LogManager.log("GameWorld: Created new TileMapLayer and added to scene", LogManager.CATEGORY_DUNGEON)
	else:
		LogManager.log("GameWorld: Using existing TileMapLayer from scene", LogManager.CATEGORY_DUNGEON)
	
	if not dungeon_tilemap_layer_above:
		# Create new TileMapLayerAbove
		LogManager.log("GameWorld: Creating new TileMapLayerAbove", LogManager.CATEGORY_DUNGEON)
		dungeon_tilemap_layer_above = TileMapLayer.new()
		dungeon_tilemap_layer_above.name = "TileMapLayerAbove"
		
		# Add to scene
		if environment:
			environment.add_child(dungeon_tilemap_layer_above)
		else:
			add_child(dungeon_tilemap_layer_above)
			move_child(dungeon_tilemap_layer_above, 0)
		
		dungeon_tilemap_layer_above.position = Vector2.ZERO
		LogManager.log("GameWorld: Created new TileMapLayerAbove and added to scene", LogManager.CATEGORY_DUNGEON)
	else:
		LogManager.log("GameWorld: Using existing TileMapLayerAbove from scene", LogManager.CATEGORY_DUNGEON)
	
	# TileMapLayer should work standalone - no TileMap needed
	LogManager.log("GameWorld: TileMapLayer ready for rendering", LogManager.CATEGORY_DUNGEON)
	
	# Render tiles from dungeon_data
	var tile_grid = dungeon_data.tile_grid
	var grid = dungeon_data.grid
	var map_size = dungeon_data.map_size
	
	LogManager.log("GameWorld: Rendering " + str(map_size.x) + "x" + str(map_size.y) + " tiles", LogManager.CATEGORY_DUNGEON)
	var tiles_placed = 0
	var above_tiles_placed = 0
	
	const BLACK_TILE = Vector2i(2, 2) # Black tile for non-floor/wall/door
	
	for x in range(map_size.x):
		for y in range(map_size.y):
			var tile_coords = tile_grid[x][y]
			var grid_value = grid[x][y]
			
			# Render main layer - set a tile for EVERY position
			var main_tile: Vector2i
			
			# Determine what tile to use based on grid value
			# Only use the tile_coords if it's a valid tile (not 0,0) or if it's a wall
			if grid_value == 0:
				# Wall - use the tile_coords (which should be a wall tile, can be 0,0 for top-left corner)
				main_tile = tile_coords
			elif grid_value == 1:
				# Floor - use the tile_coords (which should be a floor tile)
				main_tile = tile_coords
			elif grid_value == 2:
				# Door - use the tile_coords (which should be a door tile)
				main_tile = tile_coords
			elif grid_value == 3:
				# Corridor - use the tile_coords (which should be a floor tile)
				main_tile = tile_coords
			elif grid_value == 4:
				# Stairs - use the tile_coords (which should be a stairs tile)
				main_tile = tile_coords
			else:
				# Anything else (empty/void) - use black tile (2,2)
				main_tile = BLACK_TILE
			
			# If tile_coords is (0,0) and it's not a wall, use black tile instead
			if tile_coords == Vector2i(0, 0) and grid_value != 0:
				main_tile = BLACK_TILE
			
			# Always explicitly set a tile for every position (prevents default 0,0)
			dungeon_tilemap_layer.set_cell(Vector2i(x, y), 0, main_tile)
			if main_tile != Vector2i(0, 0): # Count non-default tiles
				tiles_placed += 1
			
			# Render above layer
			# Render tile (2,2) for every tile that is NOT floor, room-walls, or doors
			# This includes corridors and any other tiles
			if grid_value == 0:
				# Wall - check if it's actually a wall tile or just empty space
				# Empty spaces have tile_coords == (0,0), actual walls have other tile_coords
				if tile_coords == Vector2i(0, 0):
					# Empty space - render black tile (2,2)
					dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, BLACK_TILE)
					above_tiles_placed += 1
				# else: actual wall - don't render anything in above layer
			elif grid_value == 1:
				# Floor - don't render anything in above layer
				pass
			elif grid_value == 2:
				# Door - render specific door parts
				var current_tile = tile_coords
				
				# Check which door type this is based on tile coordinates
				# Door UP: first row (y=0) - tiles (7,0), (8,0), (9,0)
				if current_tile.y == 0 and current_tile.x >= 7 and current_tile.x <= 9:
					dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, current_tile)
					above_tiles_placed += 1
				
				# Door RIGHT: second column (x=11) - tiles (11,2), (11,3), (11,4)
				elif current_tile.x == 11 and current_tile.y >= 2 and current_tile.y <= 4:
					dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, current_tile)
					above_tiles_placed += 1
				
				# Door LEFT: first column (x=5) - tiles (5,2), (5,3), (5,4)
				elif current_tile.x == 5 and current_tile.y >= 2 and current_tile.y <= 4:
					dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, current_tile)
					above_tiles_placed += 1
				
				# Door DOWN: second row (y=6) - tiles (7,6), (8,6), (9,6)
				elif current_tile.y == 6 and current_tile.x >= 7 and current_tile.x <= 9:
					dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, current_tile)
					above_tiles_placed += 1
			elif grid_value == 4: # Stairs - render similar to doors
				var current_tile = tile_coords
				# Render stairs parts similar to doors
				# Stairs use the same structure as doors but with special middle frame tiles
				# Stairs UP: first row (y=0) - tiles (7,0), (10,0), (9,0) - middle tile is (10,0) instead of (8,0)
				if current_tile.y == 0 and current_tile.x >= 7 and current_tile.x <= 9:
					dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, current_tile)
					above_tiles_placed += 1
				# Special case: UP stairs middle tile (10,0)
				elif current_tile == Vector2i(10, 0):
					dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, current_tile)
					above_tiles_placed += 1
				# Stairs RIGHT: columns (x=10 or x=11) - tiles (10,2), (11,1), (10,3), (11,3), (10,4), (11,4)
				# Middle tile is (11,1) instead of (11,3)
				elif (current_tile.x == 10 or current_tile.x == 11) and current_tile.y >= 2 and current_tile.y <= 4:
					dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, current_tile)
					above_tiles_placed += 1
				# Special case: RIGHT stairs middle tile (11,1)
				elif current_tile == Vector2i(11, 1):
					dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, current_tile)
					above_tiles_placed += 1
				# Stairs LEFT: first column (x=5) - tiles (5,2), (5,1), (5,3), (5,4)
				# Middle tile is (5,1) instead of (5,3)
				elif current_tile.x == 5 and current_tile.y >= 1 and current_tile.y <= 4:
					dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, current_tile)
					above_tiles_placed += 1
				# Stairs DOWN: second row (y=5) - tiles (7,5), (6,6), (9,5)
				# Middle tile is (6,6) instead of (8,6)
				elif current_tile.y == 5 and current_tile.x >= 7 and current_tile.x <= 9:
					dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, current_tile)
					above_tiles_placed += 1
				# Special case: DOWN stairs middle tile (6,6)
				elif current_tile == Vector2i(6, 6):
					dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, current_tile)
					above_tiles_placed += 1
			else:
				# Everything else (corridors, empty tiles, etc.) - render black tile (2,2)
				dungeon_tilemap_layer_above.set_cell(Vector2i(x, y), 0, BLACK_TILE)
				above_tiles_placed += 1
	
	LogManager.log("GameWorld: Placed " + str(tiles_placed) + " tiles on main layer", LogManager.CATEGORY_DUNGEON)
	LogManager.log("GameWorld: Placed " + str(above_tiles_placed) + " tiles on above layer", LogManager.CATEGORY_DUNGEON)
	LogManager.log("GameWorld: Dungeon rendered on TileMapLayer", LogManager.CATEGORY_DUNGEON)
	_init_fog_of_war()
	
	# Create stairs Area2D if stairs data exists
	_create_stairs_area()
	
	# Create entrance Area2D if entrance data exists (level > 1)
	_create_entrance_area()

	# Randomize dungeon color scheme (seed-based)
	_apply_dungeon_color_scheme()

func _update_spawn_points(target_room: Dictionary = {}, clear_existing: bool = true):
	# Update player manager spawn points based on a room
	# If target_room is empty, use start room (for initial spawn)
	# Otherwise use the provided room (for spawning new players near host)
	# clear_existing: If true, clear existing spawn points first (for respawn/new room)
	if not dungeon_data.has("start_room"):
		return
	
	var room = target_room if not target_room.is_empty() else dungeon_data.start_room
	var tile_size = 16 # 16 pixels per tile
	
	# Get already assigned spawn positions to exclude them (if not clearing)
	var exclude_positions = []
	if not clear_existing:
		for spawn_point in player_manager.spawn_points:
			exclude_positions.append(spawn_point)
	
	# Clear existing spawn points if requested
	if clear_existing:
		player_manager.spawn_points.clear()
	
	# Find free floor tiles in the room (excluding already assigned positions)
	var free_tiles = _find_free_floor_tiles_in_room(room, exclude_positions)
	
	# Update player manager spawn points
	if free_tiles.size() > 0:
		# Use free floor tiles as spawn points
		for tile_pos in free_tiles:
			var world_x = tile_pos.x * tile_size + tile_size / 2.0 # Center of tile
			var world_y = tile_pos.y * tile_size + tile_size / 2.0 # Center of tile
			player_manager.spawn_points.append(Vector2(world_x, world_y))
		LogManager.log("GameWorld: Updated spawn points with " + str(free_tiles.size()) + " free floor tiles in room", LogManager.CATEGORY_DUNGEON)
	else:
		# Fallback: Create spawn points in a circle around the room center
		var room_center_x = (room.x + room.w / 2.0) * tile_size
		var room_center_y = (room.y + room.h / 2.0) * tile_size
		var num_spawn_points = 8
		for i in range(num_spawn_points):
			var angle = i * PI * 2 / num_spawn_points
			var offset = Vector2(cos(angle), sin(angle)) * 30 # 30 pixel radius
			player_manager.spawn_points.append(Vector2(room_center_x, room_center_y) + offset)
		LogManager.log("GameWorld: Updated spawn points in circle around room center (no free tiles found)", LogManager.CATEGORY_DUNGEON)

func _find_room_at_position(world_pos: Vector2) -> Dictionary:
	# Find which room contains the given world position
	if dungeon_data.is_empty() or not dungeon_data.has("rooms"):
		return {}
	
	var tile_size = 16 # 16 pixels per tile
	var tile_x = int(world_pos.x / tile_size)
	var tile_y = int(world_pos.y / tile_size)
	
	# Check each room to see if the position is inside it
	for room in dungeon_data.rooms:
		# Room interior is from room.x + 2 to room.x + room.w - 2 (excluding 2-tile walls)
		if tile_x >= room.x + 2 and tile_x < room.x + room.w - 2 and \
		   tile_y >= room.y + 2 and tile_y < room.y + room.h - 2:
			return room
	
	return {}

func _find_free_floor_tiles_in_room(room: Dictionary, exclude_positions: Array = []) -> Array:
	# Find all free floor tiles in a room (tiles that are floor and not occupied)
	# exclude_positions: Array of Vector2 world positions to exclude (for players already assigned spawn points)
	if dungeon_data.is_empty() or not dungeon_data.has("grid"):
		return []
	
	var free_tiles = []
	var grid = dungeon_data.grid
	var map_size = dungeon_data.map_size
	
	# Room interior is from room.x + 2 to room.x + room.w - 2 (excluding 2-tile walls)
	for x in range(room.x + 2, room.x + room.w - 2):
		for y in range(room.y + 2, room.y + room.h - 2):
			if x >= 0 and x < map_size.x and y >= 0 and y < map_size.y:
				# Check if it's a floor tile
				if grid[x][y] == 1: # Floor
					var tile_world_x = x * 16 + 8 # Center of tile
					var tile_world_y = y * 16 + 8 # Center of tile
					var tile_world_pos = Vector2(tile_world_x, tile_world_y)
					
					# Check if this position is in the exclude list
					var is_excluded = false
					for excluded_pos in exclude_positions:
						if tile_world_pos.distance_to(excluded_pos) < 32:
							is_excluded = true
							break
					
					if is_excluded:
						continue
					
					# Check if there's already a player at this position
					var is_free = true
					for player in player_manager.get_all_players():
						if tile_world_pos.distance_to(player.position) < 32:
							is_free = false
							break
					
					if is_free:
						free_tiles.append(Vector2i(x, y))
	
	# Shuffle free tiles to randomize spawn positions
	free_tiles.shuffle()
	
	return free_tiles

func _is_safe_spawn_position(world_pos: Vector2) -> bool:
	# Check if a world position is safe for spawning (on a floor tile)
	if dungeon_data.is_empty() or not dungeon_data.has("grid"):
		return false
	
	var tile_size = 16
	var tile_x = int(world_pos.x / tile_size)
	var tile_y = int(world_pos.y / tile_size)
	var grid = dungeon_data.grid
	var map_size = dungeon_data.map_size
	
	# Check bounds
	if tile_x < 0 or tile_y < 0 or tile_x >= map_size.x or tile_y >= map_size.y:
		return false
	
	# Check if it's a floor tile
	if grid[tile_x][tile_y] == 1: # Floor
		return true
	
	return false

func _find_nearby_safe_spawn_position(world_pos: Vector2, max_distance: float = 64.0) -> Vector2:
	# Find a nearby safe spawn position (on a floor tile)
	# Returns the original position if it's safe, otherwise finds the nearest safe position
	# max_distance: Maximum distance to search for a safe position
	# First check if the original position is safe
	if _is_safe_spawn_position(world_pos):
		return world_pos
	
	# Search in expanding circles around the position
	var tile_size = 16
	var search_radius = 1 # Start with 1 tile radius
	var max_radius = int(max_distance / tile_size) + 1
	
	while search_radius <= max_radius:
		# Check all tiles in a square around the position
		var center_tile_x = int(world_pos.x / tile_size)
		var center_tile_y = int(world_pos.y / tile_size)
		
		# Check tiles in a square pattern
		for dx in range(-search_radius, search_radius + 1):
			for dy in range(-search_radius, search_radius + 1):
				# Skip if outside the search radius (only check the perimeter)
				if abs(dx) != search_radius and abs(dy) != search_radius:
					continue
				
				var check_tile_x = center_tile_x + dx
				var check_tile_y = center_tile_y + dy
				var check_world_pos = Vector2(check_tile_x * tile_size + tile_size / 2.0, check_tile_y * tile_size + tile_size / 2.0)
				
				# Check if this position is safe
				if _is_safe_spawn_position(check_world_pos):
					LogManager.log("GameWorld: Found safe spawn position at " + str(check_world_pos) + " (original was " + str(world_pos) + ")", LogManager.CATEGORY_DUNGEON)
					return check_world_pos
		
		search_radius += 1
	
	# If no safe position found, return original (fallback)
	LogManager.log("GameWorld: WARNING: Could not find safe spawn position near " + str(world_pos) + ", using original position", LogManager.CATEGORY_DUNGEON)
	return world_pos

func _get_host_room() -> Dictionary:
	# Get the room where the host (server peer) is currently located
	if not multiplayer.is_server():
		return {}
	
	# Find the host's players (server peer is usually peer_id 1)
	var host_peer_id = 1
	var host_players = []
	for player in player_manager.get_all_players():
		if player.peer_id == host_peer_id:
			host_players.append(player)
	
	# If no host players found, try to find any server player
	if host_players.is_empty():
		host_players = player_manager.get_all_players()
	
	if host_players.is_empty():
		return {}
	
	# Use the first host player's position to find the room
	var host_pos = host_players[0].position
	return _find_room_at_position(host_pos)

func _check_peer_recognized(peer_id: int) -> bool:
	# Check if peer is recognized in multiplayer system and data channels are open
	if not multiplayer.is_server():
		return false
	
	var peers = multiplayer.get_peers()
	var is_in_peers = (peer_id in peers)
	
	# First check if peer is in get_peers() and has "connected" flag set to true
	# The "connected" flag is true when all three data channels (reliable, unreliable, ordered) are open
	if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
		var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer
		var has_peer = webrtc_peer.has_peer(peer_id)
		
		if has_peer:
			var peer_info = webrtc_peer.get_peer(peer_id)
			if peer_info:
				# Log peer info keys for debugging (only once per peer)
				var log_key = "peer_" + str(peer_id) + "_keys_logged"
				if not has_meta(log_key):
					set_meta(log_key, true)
					LogManager.log("GameWorld: Peer " + str(peer_id) + " peer_info keys: " + str(peer_info.keys()), LogManager.CATEGORY_NETWORK)
				
				var is_net_connected = peer_info.get("connected", false)
				# On web, peer_info has "connection" key (not "connection_state")
				# WebRTCPeerConnection.ConnectionState: 0=NEW, 1=CONNECTING, 2=CONNECTED, 3=DISCONNECTED, 4=FAILED
				# Note: "connection" might be an enum object (WebRTCPeerConnection.ConnectionState), not an int
				var connection_obj = peer_info.get("connection")
				var connection_val: int = -1
				if connection_obj != null:
					# Try to get int value from enum (it might be an enum or already an int)
					var connection_obj_type = typeof(connection_obj)
					if connection_obj_type == TYPE_INT:
						connection_val = int(connection_obj)
					else:
						# If it's an enum object, we can't easily get the int value on web
						# Just use -1 so we don't try to compare it
						connection_val = -1
				var channels = peer_info.get("channels", {})
				
				# Check individual data channel states (hypothesis B - channels might not all be open)
				var channel_states = []
				if channels is Array:
					for i in range(channels.size()):
						var channel = channels[i]
						if channel != null:
							# Try to get ready_state from channel (WebRTCDataChannel has ready_state property)
							var channel_ready_state = -1
							if channel.has_method("get_ready_state"):
								channel_ready_state = channel.get_ready_state()
							else:
								# Try accessing as property
								channel_ready_state = channel.get("ready_state") if "ready_state" in channel else -1
							channel_states.append(channel_ready_state)
						else:
							channel_states.append(-1)
				
				# Try to get connection state from matchbox_client peer_connections (hypothesis D - alternative access)
				var matchbox_connection_state = -1
				var matchbox_signaling_state = -1
				if network_manager and network_manager.matchbox_client:
					var matchbox = network_manager.matchbox_client
					# peer_connections is a Dictionary: peer_id (int) -> WebRTCPeerConnection
					if "peer_connections" in matchbox:
						var peer_conns = matchbox.peer_connections
						if peer_conns and peer_conns.has(peer_id):
							var pc = peer_conns[peer_id]
							if pc:
								if pc.has_method("get_connection_state"):
									matchbox_connection_state = pc.get_connection_state()
								if pc.has_method("get_signaling_state"):
									matchbox_signaling_state = pc.get_signaling_state()
				
				# Log peer info for debugging (only once per peer to reduce spam)
				var log_key2 = "peer_" + str(peer_id) + "_recognized_logged"
				if not has_meta(log_key2):
					set_meta(log_key2, true)
					LogManager.log("GameWorld: Peer " + str(peer_id) + " info - connected: " + str(is_net_connected) + ", connection: " + str(connection_val) + ", matchbox_conn: " + str(matchbox_connection_state) + ", matchbox_sig: " + str(matchbox_signaling_state) + ", on_web: " + str(OS.get_name() == "Web"), LogManager.CATEGORY_NETWORK)
				
				if is_net_connected and is_in_peers:
					LogManager.log("GameWorld: Peer " + str(peer_id) + " is connected (WebRTC - data channels open) and in get_peers()", LogManager.CATEGORY_NETWORK)
					return true
				
				# Log Matchbox connection state for debugging, but don't use it for RPC readiness
				# RPCs will fail if peer isn't in get_peers(), regardless of Matchbox connection state
				if OS.get_name() == "Web" and matchbox_signaling_state == 0 and matchbox_connection_state >= 1:
					LogManager.log("GameWorld: Peer " + str(peer_id) + " Matchbox connection is ready (signaling: STABLE, connection: " + str(matchbox_connection_state) + ") but NOT in get_peers() yet - will retry", LogManager.CATEGORY_NETWORK)
				
				# On web, even if "connected" is false, if "connection" is CONNECTED (2), log for debugging
				# but still require is_in_peers to be true for RPC readiness
				if OS.get_name() == "Web" and connection_val == 2 and not is_in_peers:
					LogManager.log("GameWorld: Peer " + str(peer_id) + " connection value is CONNECTED (2) on web but NOT in get_peers() yet - will retry", LogManager.CATEGORY_NETWORK)
				# Peer exists but not ready - return false without logging (too spammy)
			else:
				# Log once if peer_info is null
				var log_key = "peer_" + str(peer_id) + "_null_info_logged"
				if not has_meta(log_key):
					set_meta(log_key, true)
					LogManager.log("GameWorld: Peer " + str(peer_id) + " exists in WebRTC but peer_info is null", LogManager.CATEGORY_NETWORK)
		else:
			# Log once if peer doesn't exist in WebRTC
			var log_key = "peer_" + str(peer_id) + "_not_in_webrtc_logged"
			if not has_meta(log_key):
				set_meta(log_key, true)
				LogManager.log("GameWorld: Peer " + str(peer_id) + " not found in WebRTCMultiplayerPeer", LogManager.CATEGORY_NETWORK)
	
	# CRITICAL: Only return true if peer is in get_peers() - this is required for RPCs to work
	# Godot's rpc_id() will fail with "unknown peer ID" if peer isn't in get_peers(), regardless of Matchbox connection state
	if is_in_peers:
		LogManager.log("GameWorld: Peer " + str(peer_id) + " found in get_peers() - RPC ready", LogManager.CATEGORY_NETWORK)
		return true
	
	# Peer not ready - don't spam logs, just return false
	
	return false

func _sync_dungeon_to_client(client_peer_id: int, dungeon_data_sync: Dictionary, seed_value: int, level: int, host_room: Dictionary, retry_count: int = 0):
	# Helper function to send dungeon sync RPC to a client with retry logic
	# Called via call_deferred to ensure the peer is registered in the multiplayer system
	if multiplayer.is_server():
		# Prevent multiple simultaneous syncs to the same peer
		if dungeon_sync_in_progress.get(client_peer_id, false):
			print("GameWorld: HOST - Dungeon sync already in progress for peer ", client_peer_id, ", skipping duplicate sync")
			LogManager.log("GameWorld: Dungeon sync already in progress for peer " + str(client_peer_id) + ", skipping", LogManager.CATEGORY_NETWORK)
			return
		
		# Mark sync as in progress
		dungeon_sync_in_progress[client_peer_id] = true
		
		# Check if peer is recognized before sending RPC
		var is_recognized = _check_peer_recognized(client_peer_id)
		var peers = multiplayer.get_peers()
		var is_in_peers = (client_peer_id in peers)
		
		# On web, RPCs will fail with "unknown peer ID" if peer isn't in get_peers()
		# So we MUST wait until the peer is in get_peers() before sending RPCs
		# Check if peer is in get_peers() - this is the only reliable check for RPC readiness
		if not is_in_peers:
			# Reduced retries - don't retry so many times for host
			var max_retries = 30 if OS.get_name() == "Web" else 20 # 6 seconds on web, 4 seconds otherwise
			if retry_count < max_retries:
				if retry_count % 10 == 0: # Log every 10th retry (every 2 seconds) to reduce spam
					# Check data channel states for debugging
					var channel_info = ""
					if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
						var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer
						if webrtc_peer.has_peer(client_peer_id):
							var peer_info = webrtc_peer.get_peer(client_peer_id)
							if peer_info:
								var is_net_connected = peer_info.get("connected", false)
								var channels = peer_info.get("channels", {})
								channel_info = " (connected: " + str(is_net_connected) + ", channels: " + str(channels.size()) + ")"
					LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() yet, retrying dungeon sync (" + str(retry_count + 1) + "/" + str(max_retries) + ")" + channel_info, LogManager.CATEGORY_NETWORK)
				get_tree().create_timer(0.2).timeout.connect(func():
					if is_inside_tree() and multiplayer.is_server():
						_sync_dungeon_to_client(client_peer_id, dungeon_data_sync, seed_value, level, host_room, retry_count + 1)
				)
				return
			else:
				# After max retries, give up and log error
				LogManager.log_error("GameWorld: Max retries (" + str(max_retries) + ") reached for peer " + str(client_peer_id) + ", giving up on dungeon sync", LogManager.CATEGORY_NETWORK)
				dungeon_sync_in_progress.erase(client_peer_id) # Clear sync flag
				return
		
		LogManager.log("GameWorld: Sending dungeon sync RPC to peer " + str(client_peer_id) + " (is_in_peers: " + str(is_in_peers) + ", is_recognized: " + str(is_recognized) + ")", LogManager.CATEGORY_NETWORK)
		print("=== GameWorld: HOST - Sending dungeon blob sync to peer ", client_peer_id, " ===")
		# Peer is in get_peers(), safe to send RPC
		# Use the pre-packed blob chunks for efficient sending
		if dungeon_blob_chunks.is_empty():
			print("GameWorld: HOST - WARNING: No blob chunks available, packing now...")
			_pack_dungeon_blob()
		call_deferred("_send_dungeon_blob_sync", client_peer_id)

func _collect_current_world_metadata() -> Dictionary:
	# Collect current world state metadata on-demand
	# This is called whenever syncing to a new client to ensure they get the latest state
	if not multiplayer.is_server():
		return {}
	
	# Get CURRENT defeated enemies
	var defeated_list = []
	for enemy_index in defeated_enemies.keys():
		defeated_list.append(enemy_index)
	print("GameWorld: _collect_current_world_metadata() - Collecting ", defeated_list.size(), " defeated enemies: ", defeated_list)
	
	# Get CURRENT broken objects
	var broken_list = []
	for obj_index in broken_objects.keys():
		broken_list.append(obj_index)
	
	# Get CURRENT door states (re-collect from entities)
	var door_states_list = []
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		for child in entities_node.get_children():
			if child.is_in_group("blocking_door") or child.has_method("_sync_door_open"):
				var door_state = {
					"door_name": child.name,
					"is_closed": child.is_closed if "is_closed" in child else true,
					"puzzle_solved": child.puzzle_solved if "puzzle_solved" in child else false,
					"key_used": child.key_used if "key_used" in child else false,
					"position": child.position if "position" in child else Vector2.ZERO,
					"closed_position": child.closed_position if "closed_position" in child else Vector2.ZERO,
					"open_offset": child.open_offset if "open_offset" in child else Vector2.ZERO
				}
				door_states_list.append(door_state)
	
	# Get CURRENT opened chests
	var opened_chests_list = []
	if entities_node:
		for child in entities_node.get_children():
			if child.is_in_group("interactable_object"):
				if "object_type" in child and child.object_type == "Chest":
					if "is_chest_opened" in child and child.is_chest_opened:
						opened_chests_list.append(child.name)
						print("GameWorld: _collect_current_world_metadata() - Found opened chest: ", child.name)
	print("GameWorld: _collect_current_world_metadata() - Collecting ", opened_chests_list.size(), " opened chests: ", opened_chests_list)
	
	# Get CURRENT existing loot
	var loot_list = []
	if entities_node:
		for child in entities_node.get_children():
			if child.is_in_group("loot"):
				var current_velocity = child.velocity if "velocity" in child else Vector2.ZERO
				var current_velocity_z = child.velocity_z if "velocity_z" in child else 0.0
				var loot_data = {
					"position": child.global_position,
					"loot_type": child.loot_type if "loot_type" in child else 0,
					"loot_id": child.get_meta("loot_id") if child.has_meta("loot_id") else -1,
					"velocity": current_velocity,
					"velocity_z": current_velocity_z
				}
				# For item loot, include item data
				if child.loot_type == child.LootType.ITEM and "item" in child and child.item:
					var item = child.item
					if item.has_method("save"):
						loot_data["item_data"] = item.save()
				loot_list.append(loot_data)
	
	return {
		"defeated_enemies": defeated_list,
		"broken_objects": broken_list,
		"door_states": door_states_list,
		"opened_chests": opened_chests_list,
		"existing_loot": loot_list,
		"host_room": _get_host_room()
	}

func _send_dungeon_blob_sync(client_peer_id: int):
	# Send pre-packed dungeon blob chunks with acknowledgment-based flow control
	if not is_inside_tree() or not multiplayer.is_server():
		return
	
	if dungeon_blob_chunks.is_empty():
		print("GameWorld: HOST - ERROR: No dungeon blob chunks available!")
		return
	
	var total_chunks = dungeon_blob_chunks.size()
	print("GameWorld: HOST - Starting dungeon blob sync to peer ", client_peer_id, " (", total_chunks, " chunks)")
	
	# Get static metadata (from packed blob)
	var static_metadata = dungeon_blob_metadata
	
	# Collect CURRENT dynamic world state metadata (always fresh, not from when blob was packed)
	var dynamic_metadata = _collect_current_world_metadata()
	
	print("GameWorld: HOST - [CHUNK 0] Sending metadata with current world state...")
	print("GameWorld: HOST - Current state: ", dynamic_metadata.defeated_enemies.size(), " defeated enemies, ", dynamic_metadata.broken_objects.size(), " broken objects, ", dynamic_metadata.door_states.size(), " door states, ", dynamic_metadata.opened_chests.size(), " opened chests, ", dynamic_metadata.existing_loot.size(), " existing loot")
	
	# Send metadata with current state
	_sync_dungeon_blob_metadata.rpc_id(
		client_peer_id,
		static_metadata.seed,
		static_metadata.level,
		static_metadata.map_size,
		dynamic_metadata.host_room,
		total_chunks,
		dynamic_metadata.defeated_enemies,
		dynamic_metadata.broken_objects,
		dynamic_metadata.door_states,
		dynamic_metadata.opened_chests,
		dynamic_metadata.existing_loot
	)
	
	# Initialize acknowledgment tracking
	var chunk_acks = {}
	for i in range(total_chunks):
		chunk_acks[i] = false
	
	dungeon_chunk_acks[client_peer_id] = {
		"chunks": dungeon_blob_chunks.duplicate(), # Copy of chunks
		"acks": chunk_acks,
		"next_chunk": 0,
		"total_chunks": total_chunks
	}
	
	# Retry metadata if joiner never acks (lost ack = black map, no collision)
	var pid = client_peer_id
	var retry_timer = get_tree().create_timer(5.0)
	retry_timer.timeout.connect(func():
		if not is_inside_tree() or not multiplayer.is_server():
			return
		if not dungeon_chunk_acks.has(pid):
			return # Sync completed or already retried
		var sd = dungeon_chunk_acks[pid]
		if sd.next_chunk > 0:
			return # Already receiving chunk acks, no retry needed
		LogManager.log("GameWorld: HOST - No metadata ack from peer " + str(pid) + " after 5s, retrying dungeon sync", LogManager.CATEGORY_NETWORK)
		dungeon_chunk_acks.erase(pid)
		dungeon_sync_in_progress.erase(pid)
		_sync_dungeon_to_client(pid, dungeon_data, dungeon_seed, current_level, _get_host_room())
	)

func _send_sync_dungeon_rpc(client_peer_id: int, dungeon_data_sync: Dictionary, seed_value: int, level: int, host_room: Dictionary, retry_count: int = 0):
	# OLD METHOD - kept for backwards compatibility, but should use _send_dungeon_blob_sync instead
	# Send the actual RPC - called deferred to avoid buffer issues
	# Check buffer before sending to prevent "Buffer full! Dropping data" errors
	if not is_inside_tree() or not multiplayer.is_server():
		return
	
	# Check WebRTC data channel buffer before sending large RPC
	# According to Godot docs, get_buffered_amount() returns bytes queued
	# Typical buffer size is 16MB, but we should keep it under 1MB to be safe
	var max_buffer_size = 1024 * 1024 # 1MB threshold
	var buffer_ok = true
	var buffered_amount = 0
	
	if multiplayer.multiplayer_peer is WebRTCMultiplayerPeer:
		var webrtc_peer = multiplayer.multiplayer_peer as WebRTCMultiplayerPeer
		if webrtc_peer.has_peer(client_peer_id):
			var peer_info = webrtc_peer.get_peer(client_peer_id)
			if peer_info:
				var channels = peer_info.get("channels", [])
				if channels is Array and channels.size() > 0:
					# Check the reliable channel (usually first one)
					for channel in channels:
						if channel != null and channel.has_method("get_buffered_amount"):
							buffered_amount = channel.get_buffered_amount()
							if buffered_amount > max_buffer_size:
								buffer_ok = false
								break
	
	if not buffer_ok:
		# Buffer is too full, wait and retry
		var max_retries = 50 # Wait up to 10 seconds (50 * 0.2s)
		if retry_count < max_retries:
			if retry_count % 10 == 0: # Log every 2 seconds
				print("GameWorld: HOST - Buffer too full (", buffered_amount, " bytes), waiting before sending dungeon sync to peer ", client_peer_id, " (retry ", retry_count + 1, "/", max_retries, ")")
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_send_sync_dungeon_rpc(client_peer_id, dungeon_data_sync, seed_value, level, host_room, retry_count + 1)
			)
			return
		else:
			print("GameWorld: HOST - ERROR: Max retries reached for dungeon sync to peer ", client_peer_id, " (buffer still full)")
			return
	
	# Chunk dungeon data into <16KB pieces to avoid WebRTC buffer overflow
	# WebRTC has a 64KB message limit, but we'll use 16KB chunks for safety
	var tile_grid = dungeon_data_sync.get("tile_grid", [])
	var grid = dungeon_data_sync.get("grid", [])
	var map_size = dungeon_data_sync.get("map_size", Vector2i(72, 72))
	var start_room = dungeon_data_sync.get("start_room", {})
	var rooms = dungeon_data_sync.get("rooms", [])
	
	var non_essential_data = {
		"enemies": dungeon_data_sync.get("enemies", []),
		"torches": dungeon_data_sync.get("torches", []),
		"interactable_objects": dungeon_data_sync.get("interactable_objects", [])
	}
	
	# Calculate chunk size: send 10 rows at a time (~10KB per chunk, well under 16KB limit)
	# Each row: map_size.x * 8 bytes (tile_grid Vector2i) + map_size.x * 4 bytes (grid int) = map_size.x * 12 bytes
	# 10 rows = 10 * map_size.x * 12 bytes + overhead = ~10KB per chunk (for 72x72, scales with larger maps)
	# We use 10 rows to keep chunks well under 16KB WebRTC limit
	const ROWS_PER_CHUNK = 10
	var total_rows = map_size.y
	var total_chunks = ceil(float(total_rows) / float(ROWS_PER_CHUNK))
	
	# Calculate actual chunk size for logging
	var bytes_per_row = map_size.x * 12 # 8 bytes Vector2i + 4 bytes int
	var chunk_size_bytes = ROWS_PER_CHUNK * bytes_per_row
	print("GameWorld: HOST - Chunk calculation: ", map_size.x, " cols * 12 bytes/row = ", bytes_per_row, " bytes/row")
	print("GameWorld: HOST - 10 rows per chunk = ", chunk_size_bytes, " bytes (~", chunk_size_bytes / 1024.0, "KB) per chunk")
	
	print("GameWorld: HOST - Starting chunked dungeon sync to peer ", client_peer_id)
	print("GameWorld: HOST - Map size: ", map_size, ", Total rows: ", total_rows, ", Chunks: ", total_chunks)
	print("GameWorld: HOST - Buffer status: ", buffered_amount, " bytes")
	
	# Step 1: Send metadata first (map_size, seed, level, host_room) - this is tiny
	print("GameWorld: HOST - [CHUNK 0/", total_chunks + 2, "] Sending metadata...")
	_sync_dungeon_metadata.rpc_id(client_peer_id, map_size, seed_value, level, host_room)
	
	# Step 2: Prepare all chunks and store them for acknowledgment-based sending
	# Initialize acknowledgment tracking for this peer
	var chunks_data = []
	var chunk_acks = {}
	for chunk_idx in range(total_chunks):
		chunk_acks[chunk_idx] = false
		var start_row = chunk_idx * ROWS_PER_CHUNK
		var end_row = min(start_row + ROWS_PER_CHUNK, total_rows)
		
		# Extract chunk: for each column x, extract rows start_row to end_row
		var tile_grid_chunk = []
		var grid_chunk = []
		for x in range(map_size.x):
			var tile_col_chunk = []
			var grid_col_chunk = []
			for y in range(start_row, end_row):
				if x < tile_grid.size() and y < tile_grid[x].size():
					tile_col_chunk.append(tile_grid[x][y])
					grid_col_chunk.append(grid[x][y])
			tile_grid_chunk.append(tile_col_chunk)
			grid_chunk.append(grid_col_chunk)
		
		chunks_data.append({
			"chunk_idx": chunk_idx,
			"start_row": start_row,
			"end_row": end_row,
			"tile_grid": tile_grid_chunk,
			"grid": grid_chunk,
			"size": _estimate_chunk_size(tile_grid_chunk, grid_chunk)
		})
	
	# Store chunk data and tracking for this peer
	dungeon_chunk_acks[client_peer_id] = {
		"chunks": chunks_data,
		"acks": chunk_acks,
		"next_chunk": 0, # Start with chunk 0 (metadata is sent immediately)
		"total_chunks": total_chunks,
		"start_room": start_room,
		"rooms": rooms,
		"entities": non_essential_data
	}
	
	# Wait for metadata acknowledgment before sending first chunk
	# Client will send ack for metadata (chunk -1), then we send chunk 0
	
	# Step 3 & 4: Rooms and entities will be sent after all tile chunks are acknowledged

@rpc("any_peer", "reliable")
func _ack_dungeon_chunk(chunk_idx: int):
	# Server receives acknowledgment from client
	if not multiplayer.is_server():
		return
	
	var sender_id = multiplayer.get_remote_sender_id()
	if not dungeon_chunk_acks.has(sender_id):
		print("GameWorld: HOST - Received ack for chunk ", chunk_idx, " from peer ", sender_id, " but no sync in progress")
		return
	
	var sync_data = dungeon_chunk_acks[sender_id]
	var acks = sync_data.acks
	var total_chunks = sync_data.total_chunks
	
	# Mark chunk as acknowledged
	if chunk_idx == -1:
		# Metadata acknowledgment - send first blob chunk
		print("GameWorld: HOST - [CHUNK 0] Metadata acknowledged by peer ", sender_id, ", sending first blob chunk...")
		_send_next_chunk(sender_id)
	elif chunk_idx >= 0 and chunk_idx < total_chunks:
		# Blob chunk acknowledgment
		acks[chunk_idx] = true
		print("GameWorld: HOST - [CHUNK ", chunk_idx + 1, "/", total_chunks + 1, "] Acknowledged by peer ", sender_id)
		_send_next_chunk(sender_id)

func _send_next_chunk(peer_id: int):
	# Send the next unacknowledged chunk to the peer
	if not dungeon_chunk_acks.has(peer_id):
		return
	
	var sync_data = dungeon_chunk_acks[peer_id]
	var chunks = sync_data.chunks
	var acks = sync_data.acks
	var next_chunk = sync_data.next_chunk
	var total_chunks = sync_data.total_chunks
	
	# Find next unacknowledged chunk
	while next_chunk < total_chunks:
		if not acks.get(next_chunk, false):
			# Check if this is blob chunks (PackedByteArray) or old tile chunks (Dictionary)
			var chunk_data = chunks[next_chunk]
			if chunk_data is PackedByteArray:
				# New blob chunk system
				print("GameWorld: HOST - [CHUNK ", next_chunk + 1, "/", total_chunks + 1, "] Sending blob chunk (", chunk_data.size(), " bytes)")
				_sync_dungeon_blob_chunk.rpc_id(peer_id, next_chunk, chunk_data)
			else:
				# Old tile chunk system (backwards compatibility)
				print("GameWorld: HOST - [CHUNK ", next_chunk + 1, "/", total_chunks + 2, "] Sending rows ", chunk_data.start_row, "-", chunk_data.end_row - 1, " (", chunk_data.size, " bytes)")
				_sync_dungeon_chunk.rpc_id(peer_id, chunk_data.chunk_idx, chunk_data.start_row, chunk_data.end_row, chunk_data.tile_grid, chunk_data.grid)
			sync_data.next_chunk = next_chunk + 1
			return
		next_chunk += 1
	
	# All chunks acknowledged
	if next_chunk >= total_chunks:
		# Check if this is blob system (no rooms/entities to send separately)
		if chunks.size() > 0 and chunks[0] is PackedByteArray:
			# Blob system - all data is in the blob, sync complete
			print("GameWorld: HOST - All dungeon blob chunks acknowledged by peer ", peer_id, ", sync complete!")
			dungeon_chunk_acks.erase(peer_id)
			dungeon_sync_in_progress.erase(peer_id)
		else:
			# Old system - send rooms
			print("GameWorld: HOST - [CHUNK ", total_chunks + 1, "/", total_chunks + 2, "] All tile chunks acknowledged, sending rooms data...")
			_sync_dungeon_rooms.rpc_id(peer_id, sync_data.start_room, sync_data.rooms)

func _pack_dungeon_blob():
	# Pack entire dungeon data into a blob and chunk it for efficient sending
	# This is done once when dungeon is generated, then chunks are reused for all joiners
	if not multiplayer.is_server():
		return
	
	if dungeon_data.is_empty():
		print("GameWorld: HOST - Cannot pack dungeon blob: dungeon_data is empty")
		return
	
	# Clear old chunks before packing new ones (important for level transitions)
	dungeon_blob_chunks.clear()
	dungeon_blob_metadata.clear()
	
	# Collect ALL dungeon data into a single dictionary
	var full_dungeon_data = {
		"tile_grid": dungeon_data.get("tile_grid", []),
		"grid": dungeon_data.get("grid", []),
		"map_size": dungeon_data.get("map_size", Vector2i(72, 72)),
		"rooms": dungeon_data.get("rooms", []),
		"start_room": dungeon_data.get("start_room", {}),
		"exit_room": dungeon_data.get("exit_room", {}),
		"doors": dungeon_data.get("doors", []),
		"torches": dungeon_data.get("torches", []),
		"enemies": dungeon_data.get("enemies", []),
		"interactable_objects": dungeon_data.get("interactable_objects", []),
		"traps": dungeon_data.get("traps", []),
		"stairs": dungeon_data.get("stairs", {}),
		"entrance": dungeon_data.get("entrance", {}),
		"blocking_doors": dungeon_data.get("blocking_doors", [])
	}
	
	# Log what's being packed
	var enemy_count = full_dungeon_data.enemies.size() if full_dungeon_data.enemies is Array else 0
	var torch_count = full_dungeon_data.torches.size() if full_dungeon_data.torches is Array else 0
	var object_count = full_dungeon_data.interactable_objects.size() if full_dungeon_data.interactable_objects is Array else 0
	var trap_count = full_dungeon_data.traps.size() if full_dungeon_data.traps is Array else 0
	print("GameWorld: HOST - Packing dungeon blob with: ", enemy_count, " enemies, ", torch_count, " torches, ", object_count, " interactable objects, ", trap_count, " traps")
	LogManager.log("GameWorld: Packing dungeon blob - enemies: " + str(enemy_count) + ", torches: " + str(torch_count) + ", objects: " + str(object_count) + ", traps: " + str(trap_count), LogManager.CATEGORY_DUNGEON)
	
	# Store only STATIC metadata (dynamic state is collected on-demand when syncing to clients)
	# This ensures joiners always get the current world state, not what it was when blob was packed
	dungeon_blob_metadata = {
		"seed": dungeon_seed,
		"level": current_level,
		"map_size": full_dungeon_data.map_size
		# host_room, defeated_enemies, broken_objects, door_states, opened_chests, existing_loot
		# are collected dynamically in _collect_current_world_metadata() when syncing
	}
	
	# Serialize to bytes
	var blob_bytes = var_to_bytes(full_dungeon_data)
	var blob_size = blob_bytes.size()
	print("GameWorld: HOST - Packed dungeon blob: ", blob_size, " bytes (", blob_size / 1024.0, "KB)")
	
	# Chunk the bytes into <16KB pieces
	const MAX_CHUNK_SIZE = 14 * 1024 # 14KB to leave room for overhead
	dungeon_blob_chunks.clear()
	
	var offset = 0
	var chunk_idx = 0
	while offset < blob_size:
		var chunk_size = min(MAX_CHUNK_SIZE, blob_size - offset)
		var chunk = blob_bytes.slice(offset, offset + chunk_size)
		dungeon_blob_chunks.append(chunk)
		print("GameWorld: HOST - Created chunk ", chunk_idx, ": ", chunk.size(), " bytes")
		offset += chunk_size
		chunk_idx += 1
	
	print("GameWorld: HOST - Dungeon blob chunked into ", dungeon_blob_chunks.size(), " chunks")

func _estimate_chunk_size(tile_grid_chunk: Array, grid_chunk: Array) -> int:
	# Estimate size of a chunk in bytes
	# tile_grid_chunk: Array of columns (x), each column is Array of Vector2i (8 bytes each)
	# grid_chunk: Array of columns (x), each column is Array of int (4 bytes each)
	var size = 0
	for col in tile_grid_chunk:
		if col is Array:
			size += col.size() * 8 # Vector2i = 8 bytes
	for col in grid_chunk:
		if col is Array:
			size += col.size() * 4 # int = 4 bytes
	size += 1024 # Dictionary/Array overhead
	return size

@rpc("authority", "reliable", "call_local")
func _sync_dungeon(dungeon_data_sync: Dictionary, seed_value: int, level: int, host_room: Dictionary = {}):
	# Clients receive dungeon data from host
	# Use direct print FIRST to ensure we see this even if buffer is full
	print("=== _sync_dungeon RPC RECEIVED on client ===")
	print("=== Level: ", level, " ===")
	if not multiplayer.is_server():
		# Use direct print to avoid buffer issues
		print("=== GameWorld: Client received dungeon sync for level ", level, " ===")
		LogManager.log("GameWorld: Client received dungeon sync for level " + str(level), LogManager.CATEGORY_DUNGEON)
		print("GameWorld: dungeon_data_sync keys: ", dungeon_data_sync.keys())
		LogManager.log("GameWorld: dungeon_data_sync keys: " + str(dungeon_data_sync.keys()), LogManager.CATEGORY_DUNGEON)
		if dungeon_data_sync.has("enemies"):
			var enemy_count = dungeon_data_sync.enemies.size() if dungeon_data_sync.enemies is Array else 0
			print("GameWorld: dungeon_data_sync has ", enemy_count, " enemies")
			LogManager.log("GameWorld: dungeon_data_sync has " + str(enemy_count) + " enemies", LogManager.CATEGORY_DUNGEON)
		else:
			print("GameWorld: WARNING: dungeon_data_sync has NO 'enemies' key!")
			LogManager.log("GameWorld: WARNING: dungeon_data_sync has NO 'enemies' key!", LogManager.CATEGORY_DUNGEON)
		
		# Check if we're reconnecting to the same level - skip dungeon reload if so
		var skip_reload = false
		if network_manager and network_manager.reconnection_level > 0:
			if network_manager.reconnection_level == level:
				skip_reload = true
				LogManager.log("GameWorld: Reconnecting to same level (" + str(level) + "), skipping dungeon reload", LogManager.CATEGORY_DUNGEON)
		
		dungeon_data = dungeon_data_sync
		dungeon_seed = seed_value
		current_level = level # Update current_level FIRST before showing level number
		LogManager.log("GameWorld: Client updated current_level to " + str(current_level) + " from sync", LogManager.CATEGORY_DUNGEON)
		
		# CRITICAL: Re-randomize appearance for all existing players now that we have dungeon_seed
		# Players spawned before dungeon_seed was received used Time.get_ticks_msec() which causes different appearance
		_fix_player_appearance_after_dungeon_sync()
		
		# Skip dungeon reload if reconnecting to same level
		if skip_reload:
			# Just update the level number, don't reload the dungeon
			LogManager.log("GameWorld: Skipping dungeon reload - same level on reconnection", LogManager.CATEGORY_DUNGEON)
			# Clear reconnection state
			if network_manager:
				network_manager.reconnection_level = 0
			return
		
		# Clear previous level on client
		_clear_level()
		
		# Reset all player grab/lift/push states to prevent being stuck in lifting animation
		var all_players = get_tree().get_nodes_in_group("player")
		for player in all_players:
			if player and is_instance_valid(player) and player.has_method("reset_grab_state"):
				player.reset_grab_state()
		
		# Wait for old entities to be fully freed before spawning new ones
		await get_tree().process_frame
		await get_tree().process_frame # Wait an extra frame to ensure cleanup is complete
		
		# Render dungeon on client
		print("GameWorld: Client - Rendering dungeon...")
		_render_dungeon()
		print("GameWorld: Client - Dungeon rendered")
		
		# Spawn torches on client
		print("GameWorld: Client - Spawning torches...")
		_spawn_torches()
		print("GameWorld: Client - Torches spawned")
		
		# Spawn enemies on client
		print("GameWorld: Client - Spawning enemies...")
		_spawn_enemies()
		print("GameWorld: Client - Enemies spawned")
		
		# Spawn interactable objects on client
		print("GameWorld: Client - Spawning interactable objects...")
		_spawn_interactable_objects()
		print("GameWorld: Client - Interactable objects spawned")
		
		# Spawn blocking doors on client
		print("GameWorld: Client - Spawning blocking doors...")
		_spawn_blocking_doors()
		print("GameWorld: Client - Blocking doors spawned")
		
		# Spawn traps on client
		print("GameWorld: Client - Spawning traps...")
		_spawn_traps()
		print("GameWorld: Client - Traps spawned")
		
		# Spawn room triggers on client
		print("GameWorld: Client - Spawning room triggers...")
		_spawn_room_triggers()
		print("GameWorld: Client - Room triggers spawned")
		
		# Wait a frame to ensure all enemies and objects are properly added to scene tree and initialized
		await get_tree().process_frame
		await get_tree().process_frame # Wait an extra frame to ensure enemies are fully ready
		
		# Update spawn points - use host's room if available, otherwise use start room
		print("GameWorld: Client - Updating spawn points...")
		if not host_room.is_empty():
			print("GameWorld: Client - Using host's room for spawn points: ", host_room)
			LogManager.log("GameWorld: Using host's room for spawn points", LogManager.CATEGORY_DUNGEON)
			_update_spawn_points(host_room)
			# Move any existing players to spawn near host
			_move_players_to_host_room(host_room)
		else:
			print("GameWorld: Client - Host room not available, using start room")
			LogManager.log("GameWorld: Host room not available, using start room", LogManager.CATEGORY_DUNGEON)
			_update_spawn_points()
		# Move all players to start room
		print("GameWorld: Client - Moving all players to start room...")
		_move_all_players_to_start_room()
		print("GameWorld: Client - Players moved to start room")
		
		# CRITICAL: Update camera immediately after moving players to ensure joiner can see the game
		# Wait a frame to ensure players are fully positioned
		await get_tree().process_frame
		print("GameWorld: Client - Updating camera...")
		_update_camera()
		print("GameWorld: Client - Camera updated")
		
		# Note: Level number is shown via _sync_show_level_number RPC, not here
		# This prevents duplicate displays and ensures consistent timing
		
		# Load HUD on client (same as server does)
		# Use call_deferred to ensure it runs after all dungeon spawning is complete
		print("=== GameWorld: Client - About to load HUD (call_deferred) ===")
		LogManager.log("GameWorld: Client - About to load HUD (call_deferred)", LogManager.CATEGORY_DUNGEON)
		call_deferred("_load_hud")
		# Also ensure HUD loads after a short delay as backup
		get_tree().create_timer(0.2).timeout.connect(func():
			var hud_check = get_node_or_null("IngameHUD")
			if not hud_check or not is_instance_valid(hud_check):
				print("=== GameWorld: Client - HUD not found after deferred call, loading directly as backup... ===")
				LogManager.log("GameWorld: Client - HUD not found after deferred call, loading directly as backup", LogManager.CATEGORY_DUNGEON)
				_load_hud()
			else:
				print("GameWorld: Client - HUD already loaded (found in scene tree)")
		)
		print("=== GameWorld: Client - _sync_dungeon() completed ===")
		
		# CRITICAL: Fix appearance for players spawned before dungeon_seed was available
		# This ensures all players match the host's appearance
		call_deferred("_fix_player_appearance_after_dungeon_sync")

@rpc("authority", "reliable", "call_local")
func _sync_dungeon_blob_metadata(seed_value: int, level: int, map_size_sync: Vector2i, host_room: Dictionary, total_chunks: int, defeated_enemies_list: Array = [], broken_objects_list: Array = [], door_states_list: Array = [], opened_chests_list: Array = [], existing_loot_list: Array = []):
	# Client receives metadata for blob sync
	print("=== _sync_dungeon_blob_metadata RPC RECEIVED on client ===")
	print("=== [CHUNK 0] Client received blob metadata - Level: ", level, ", Map size: ", map_size_sync, ", Total chunks: ", total_chunks)
	print("=== Defeated enemies: ", defeated_enemies_list.size(), ", Broken objects: ", broken_objects_list.size(), ", Door states: ", door_states_list.size(), ", Opened chests: ", opened_chests_list.size(), ", Existing loot: ", existing_loot_list.size(), " ===")
	print("=== [CLIENT] Defeated enemies list: ", defeated_enemies_list)
	print("=== [CLIENT] Opened chests list: ", opened_chests_list)
	if not multiplayer.is_server():
		# Store defeated enemies BEFORE dungeon is unpacked and enemies are spawned
		defeated_enemies.clear()
		for enemy_index in defeated_enemies_list:
			defeated_enemies[enemy_index] = true
		if defeated_enemies_list.size() > 0:
			print("GameWorld: Client - Received ", defeated_enemies_list.size(), " defeated enemy indices before dungeon spawn")
			print("GameWorld: Client - Defeated enemy indices: ", defeated_enemies_list)
			print("GameWorld: Client - defeated_enemies dictionary now has ", defeated_enemies.size(), " entries")
			LogManager.log("GameWorld: Client received " + str(defeated_enemies_list.size()) + " defeated enemy indices", LogManager.CATEGORY_NETWORK)
		
		# Store broken objects BEFORE objects are spawned
		broken_objects.clear()
		for obj_index in broken_objects_list:
			broken_objects[obj_index] = true
		if broken_objects_list.size() > 0:
			print("GameWorld: Client - Received ", broken_objects_list.size(), " broken object indices before dungeon spawn")
			print("GameWorld: Client - Broken object indices: ", broken_objects_list)
			print("GameWorld: Client - broken_objects dictionary now has ", broken_objects.size(), " entries")
			LogManager.log("GameWorld: Client received " + str(broken_objects_list.size()) + " broken object indices", LogManager.CATEGORY_NETWORK)
		
		# Store door states (will be applied after doors spawn)
		# Convert format: server sends is_closed, but _apply_pending_door_state expects open/close
		pending_door_states.clear()
		for door_state in door_states_list:
			var door_name = door_state.get("door_name", "")
			if door_name != "":
				# Convert is_closed to open/close format expected by _apply_pending_door_state
				var converted_state = {}
				var is_closed = door_state.get("is_closed", true)
				if is_closed:
					converted_state["close"] = true
				else:
					converted_state["open"] = true
				
				# Add puzzle_solved if present
				if door_state.has("puzzle_solved"):
					converted_state["puzzle_solved"] = door_state.get("puzzle_solved", false)
				
				# Store key_used for KeyDoors (needed for proper state restoration)
				if door_state.has("key_used"):
					converted_state["key_used"] = door_state.get("key_used", false)
				
				# Store position info for fallback matching
				if door_state.has("position"):
					converted_state["position"] = door_state.get("position")
				if door_state.has("closed_position"):
					converted_state["closed_position"] = door_state.get("closed_position")
				if door_state.has("open_offset"):
					converted_state["open_offset"] = door_state.get("open_offset")
				
				pending_door_states[door_name] = converted_state
				print("GameWorld: Client - Stored door state for ", door_name, ": is_closed=", is_closed, ", puzzle_solved=", converted_state.get("puzzle_solved", false))
		if door_states_list.size() > 0:
			print("GameWorld: Client - Received ", door_states_list.size(), " door states before dungeon spawn")
			LogManager.log("GameWorld: Client received " + str(door_states_list.size()) + " door states", LogManager.CATEGORY_NETWORK)
		
		# Store opened chest states (will be applied after chests spawn)
		pending_chest_opens.clear()
		for chest_name in opened_chests_list:
			pending_chest_opens[chest_name] = {
				"loot_type": "coin", # Default, actual loot type should be stored if available
				"player_peer_id": 0,
				"item_data": {}
			}
		if opened_chests_list.size() > 0:
			print("GameWorld: Client - Received ", opened_chests_list.size(), " opened chest names before dungeon spawn")
			print("GameWorld: Client - Opened chest names: ", opened_chests_list)
			print("GameWorld: Client - pending_chest_opens dictionary now has ", pending_chest_opens.size(), " entries")
			LogManager.log("GameWorld: Client received " + str(opened_chests_list.size()) + " opened chest names", LogManager.CATEGORY_NETWORK)
		
		# Store existing loot (will be spawned after dungeon is loaded)
		# We'll need to store this temporarily and spawn it after dungeon is ready
		# For now, we'll spawn it immediately after dungeon blob is reassembled
		if existing_loot_list.size() > 0:
			print("GameWorld: Client - Received ", existing_loot_list.size(), " existing loot items before dungeon spawn")
			LogManager.log("GameWorld: Client received " + str(existing_loot_list.size()) + " existing loot items", LogManager.CATEGORY_NETWORK)
		# Check if this is a new level (different from current)
		var is_new_level = (current_level != level)
		if is_new_level:
			print("GameWorld: Client - New level detected (", current_level, " -> ", level, "), clearing old dungeon data")
			# Clear old dungeon data for level transition
			dungeon_data.clear()
			# Clear any ongoing syncs
			dungeon_sync_in_progress.clear()
			
			# CRITICAL: Reset all players' death state flags when transitioning to a new level
			# This prevents GAME OVER from showing incorrectly when players are alive in the new level
			var players = get_tree().get_nodes_in_group("player")
			for player in players:
				if is_instance_valid(player):
					if "is_dead" in player:
						player.is_dead = false
					if "is_processing_death" in player:
						player.is_processing_death = false
					# Reset exit notification flag for new level
					if "has_seen_exit_this_level" in player:
						player.has_seen_exit_this_level = false
			# Also reset game over flag on client
			game_over_triggered = false
			LogManager.log("GameWorld: Client - Reset death state for all players on level transition", LogManager.CATEGORY_DUNGEON)
		
		# CRITICAL: Store defeated enemies, broken objects, opened chests, and door states in temporary variables
		# because _clear_level() will clear them, but we need them for the new level
		var temp_defeated_enemies = defeated_enemies.duplicate()
		var temp_broken_objects = broken_objects.duplicate()
		var temp_pending_chest_opens = pending_chest_opens.duplicate()
		var temp_pending_door_states = pending_door_states.duplicate()
		
		# Reset blob sync state
		# Store metadata including state data for later use
		dungeon_sync_metadata = {
			"map_size": map_size_sync,
			"seed": seed_value,
			"level": level,
			"host_room": host_room,
			"existing_loot": existing_loot_list # Store for spawning after dungeon is ready
		}
		dungeon_sync_chunks.clear()
		dungeon_sync_received_chunks = 0
		dungeon_sync_complete = false
		dungeon_sync_total_chunks = total_chunks
		
		# Initialize blob data storage
		if not dungeon_sync_chunks.has("blob_data"):
			dungeon_sync_chunks["blob_data"] = {}
		for i in range(total_chunks):
			dungeon_sync_chunks["blob_data"][i] = null
		
		dungeon_seed = seed_value
		current_level = level
		
		print("GameWorld: Client - [CHUNK 0] Blob metadata received - expecting ", total_chunks, " blob chunks")
		
		# Check if reconnecting to same level
		if network_manager and network_manager.reconnection_level > 0:
			if network_manager.reconnection_level == level:
				LogManager.log("GameWorld: Reconnecting to same level (" + str(level) + "), skipping dungeon reload", LogManager.CATEGORY_DUNGEON)
				if network_manager:
					network_manager.reconnection_level = 0
				dungeon_sync_complete = true
				return
		
		# Clear previous level (this will clear defeated_enemies and pending_chest_opens)
		_clear_level()
		
		# CRITICAL: Restore defeated enemies, broken objects, opened chests, and door states AFTER _clear_level()
		# These were received in the metadata and need to be preserved for the new level
		defeated_enemies = temp_defeated_enemies
		broken_objects = temp_broken_objects
		pending_chest_opens = temp_pending_chest_opens
		pending_door_states = temp_pending_door_states
		print("GameWorld: Client - Restored ", defeated_enemies.size(), " defeated enemies, ", broken_objects.size(), " broken objects, ", pending_chest_opens.size(), " opened chests, and ", pending_door_states.size(), " door states after _clear_level()")
		
		# CRITICAL: Send ack via call_deferred - avoid await in RPC handler (can prevent ack from ever firing on some platforms)
		# Host waits for this ack before sending blob chunks; lost ack = joiner gets black map, no collision
		call_deferred("_deferred_ack_dungeon_metadata")

func _deferred_ack_dungeon_metadata():
	# Client-only: send metadata ack after _clear_level has settled (called via call_deferred from _sync_dungeon_blob_metadata)
	if multiplayer.is_server() or not is_inside_tree():
		return
	_ack_dungeon_chunk.rpc_id(1, -1)
	print("GameWorld: Client - [CHUNK 0] Sent deferred metadata ack to host")

@rpc("authority", "reliable", "call_local")
func _sync_dungeon_metadata(map_size_sync: Vector2i, seed_value: int, level: int, host_room: Dictionary = {}):
	# OLD METHOD - kept for backwards compatibility
	# Client receives metadata first - initializes chunked sync
	print("=== _sync_dungeon_metadata RPC RECEIVED on client ===")
	print("=== [CHUNK 0] Client received metadata - Level: ", level, ", Map size: ", map_size_sync, " ===")
	if not multiplayer.is_server():
		# Send acknowledgment for metadata (chunk -1)
		_ack_dungeon_chunk.rpc_id(1, -1) # Send to server (peer 1)
		# If we're already syncing and this is for the same level, ignore duplicate metadata
		if not dungeon_sync_complete and dungeon_sync_metadata.has("level"):
			var existing_level = dungeon_sync_metadata.get("level", 0)
			if existing_level == level:
				print("GameWorld: Client - [CHUNK 0] WARNING: Received duplicate metadata for level ", level, ", ignoring")
				return
		
		# Reset chunk sync state
		dungeon_sync_metadata = {
			"map_size": map_size_sync,
			"seed": seed_value,
			"level": level,
			"host_room": host_room
		}
		dungeon_sync_chunks.clear()
		dungeon_sync_rooms.clear()
		dungeon_sync_received_chunks = 0
		dungeon_sync_complete = false
		
		# Calculate expected chunk count (10 rows per chunk)
		var total_rows = map_size_sync.y
		dungeon_sync_total_chunks = ceil(float(total_rows) / 10.0)
		
		dungeon_seed = seed_value
		current_level = level
		
		print("GameWorld: Client - [CHUNK 0] Metadata received - Map size: ", map_size_sync, ", Level: ", level)
		print("GameWorld: Client - [CHUNK 0] Expecting ", dungeon_sync_total_chunks, " tile chunks (", total_rows, " rows total)")
		var bytes_per_row = map_size_sync.x * 12 # 8 bytes Vector2i + 4 bytes int
		var chunk_size_bytes = 10 * bytes_per_row
		print("GameWorld: Client - [CHUNK 0] Chunk size: ~", chunk_size_bytes / 1024.0, "KB per chunk (", map_size_sync.x, " cols * 12 bytes/row * 10 rows)")
		LogManager.log("GameWorld: Client received dungeon metadata for level " + str(level) + " (map size: " + str(map_size_sync) + ")", LogManager.CATEGORY_DUNGEON)
		
		# Check if reconnecting to same level
		if network_manager and network_manager.reconnection_level > 0:
			if network_manager.reconnection_level == level:
				LogManager.log("GameWorld: Reconnecting to same level (" + str(level) + "), skipping dungeon reload", LogManager.CATEGORY_DUNGEON)
				if network_manager:
					network_manager.reconnection_level = 0
				dungeon_sync_complete = true
				return
		
		# Clear previous level
		_clear_level()
		await get_tree().process_frame
		await get_tree().process_frame

@rpc("authority", "reliable", "call_local")
func _sync_dungeon_blob_chunk(chunk_idx: int, chunk_bytes: PackedByteArray):
	# Client receives a blob chunk
	print("=== _sync_dungeon_blob_chunk RPC RECEIVED on client ===")
	print("=== [CHUNK ", chunk_idx + 1, "] Client received blob chunk (", chunk_bytes.size(), " bytes) ===")
	if not multiplayer.is_server():
		# Store blob chunk
		if not dungeon_sync_chunks.has("blob_data"):
			dungeon_sync_chunks["blob_data"] = {}
		var blob_data = dungeon_sync_chunks["blob_data"]
		var is_new = not blob_data.has(chunk_idx) or blob_data[chunk_idx] == null
		blob_data[chunk_idx] = chunk_bytes
		if is_new:
			dungeon_sync_received_chunks += 1
		
		print("GameWorld: Client - [CHUNK ", chunk_idx + 1, "] Received (", dungeon_sync_received_chunks, "/", dungeon_sync_total_chunks, " chunks)")
		
		# Send acknowledgment to server
		_ack_dungeon_chunk.rpc_id(1, chunk_idx) # Send to server (peer 1)
		
		# Check if all chunks received
		if dungeon_sync_received_chunks >= dungeon_sync_total_chunks:
			print("GameWorld: Client - All blob chunks received! Reassembling and unpacking...")
			_reassemble_dungeon_blob()

@rpc("authority", "reliable", "call_local")
func _sync_dungeon_chunk(chunk_idx: int, start_row: int, end_row: int, tile_grid_chunk: Array, grid_chunk: Array):
	# OLD METHOD - kept for backwards compatibility
	# Client receives a chunk of tile_grid and grid rows
	print("=== _sync_dungeon_chunk RPC RECEIVED on client ===")
	print("=== [CHUNK ", chunk_idx + 1, "] Client received rows ", start_row, "-", end_row - 1, " ===")
	if not multiplayer.is_server():
		# Store chunk
		dungeon_sync_chunks[chunk_idx] = {
			"start_row": start_row,
			"end_row": end_row,
			"tile_grid": tile_grid_chunk,
			"grid": grid_chunk
		}
		dungeon_sync_received_chunks += 1
		
		print("GameWorld: Client - [CHUNK ", chunk_idx + 1, "] Received (", dungeon_sync_received_chunks, "/", dungeon_sync_total_chunks, " chunks)")
		
		# Send acknowledgment to server
		_ack_dungeon_chunk.rpc_id(1, chunk_idx) # Send to server (peer 1)
		
		# Check if all chunks received (will be triggered by _sync_dungeon_rooms)

@rpc("authority", "reliable", "call_local")
func _sync_dungeon_rooms(start_room_sync: Dictionary, rooms_sync: Array):
	# Client receives rooms data - triggers dungeon assembly and rendering
	print("=== _sync_dungeon_rooms RPC RECEIVED on client ===")
	print("=== [CHUNK ", dungeon_sync_total_chunks + 1, "] Client received rooms data ===")
	if not multiplayer.is_server():
		dungeon_sync_rooms = {
			"start_room": start_room_sync,
			"rooms": rooms_sync
		}
		
		print("GameWorld: Client - [CHUNK ", dungeon_sync_total_chunks + 1, "] Rooms received - checking if all chunks complete...")
		
		# Check if we have all chunks
		if dungeon_sync_received_chunks < dungeon_sync_total_chunks:
			print("GameWorld: Client - WARNING: Received rooms but missing chunks! (", dungeon_sync_received_chunks, "/", dungeon_sync_total_chunks, ")")
			# Wait a bit for missing chunks
			get_tree().create_timer(0.5).timeout.connect(func():
				if is_inside_tree() and not multiplayer.is_server():
					_check_and_render_dungeon()
			)
		else:
			# All chunks received, render dungeon
			_check_and_render_dungeon()

func _reassemble_dungeon_blob():
	# Reassemble blob chunks and unpack dungeon data
	if dungeon_sync_complete:
		return
	
	if not dungeon_sync_chunks.has("blob_data"):
		print("GameWorld: Client - Cannot reassemble: no blob data")
		return
	
	var blob_data = dungeon_sync_chunks["blob_data"]
	var total_chunks = dungeon_sync_total_chunks
	
	# Check if we have all chunks
	for i in range(total_chunks):
		if not blob_data.has(i) or blob_data[i] == null:
			print("GameWorld: Client - Missing blob chunk ", i)
			return
	
	# Reassemble chunks into single byte array
	var blob_bytes = PackedByteArray()
	for i in range(total_chunks):
		blob_bytes.append_array(blob_data[i])
	
	print("GameWorld: Client - Reassembled blob: ", blob_bytes.size(), " bytes")
	
	# Deserialize blob
	var full_dungeon_data = bytes_to_var(blob_bytes)
	if not full_dungeon_data is Dictionary:
		print("GameWorld: Client - ERROR: Failed to deserialize dungeon blob (not a Dictionary)!")
		LogManager.log_error("GameWorld: Client - Dungeon blob deserialize failed", LogManager.CATEGORY_NETWORK)
		return
	
	# Validate required map data (missing = black map, no collision)
	if not full_dungeon_data.has("tile_grid") or not full_dungeon_data.tile_grid is Array:
		print("GameWorld: Client - ERROR: Dungeon blob missing or invalid tile_grid!")
		LogManager.log_error("GameWorld: Client - Dungeon blob missing tile_grid", LogManager.CATEGORY_NETWORK)
		return
	if not full_dungeon_data.has("grid") or not full_dungeon_data.grid is Array:
		print("GameWorld: Client - ERROR: Dungeon blob missing or invalid grid!")
		LogManager.log_error("GameWorld: Client - Dungeon blob missing grid", LogManager.CATEGORY_NETWORK)
		return
	if not full_dungeon_data.has("map_size"):
		print("GameWorld: Client - ERROR: Dungeon blob missing map_size!")
		LogManager.log_error("GameWorld: Client - Dungeon blob missing map_size", LogManager.CATEGORY_NETWORK)
		return
	var tile_grid_val = full_dungeon_data.tile_grid
	var grid_val = full_dungeon_data.grid
	if tile_grid_val.is_empty() or grid_val.is_empty():
		print("GameWorld: Client - ERROR: Dungeon blob has empty tile_grid or grid!")
		LogManager.log_error("GameWorld: Client - Dungeon blob empty tile_grid/grid", LogManager.CATEGORY_NETWORK)
		return
	
	# Extract dungeon data
	dungeon_data = full_dungeon_data
	dungeon_seed = dungeon_sync_metadata.seed
	current_level = dungeon_sync_metadata.level
	
	# Log what was unpacked
	var enemy_count = dungeon_data.enemies.size() if dungeon_data.has("enemies") and dungeon_data.enemies is Array else 0
	var torch_count = dungeon_data.torches.size() if dungeon_data.has("torches") and dungeon_data.torches is Array else 0
	var object_count = dungeon_data.interactable_objects.size() if dungeon_data.has("interactable_objects") and dungeon_data.interactable_objects is Array else 0
	print("GameWorld: Client - Dungeon blob unpacked! Rendering...")
	print("GameWorld: Client - Unpacked data: ", enemy_count, " enemies, ", torch_count, " torches, ", object_count, " interactable objects")
	print("GameWorld: Client - dungeon_data keys: ", dungeon_data.keys())
	LogManager.log("GameWorld: Client unpacked dungeon blob for level " + str(current_level) + " - enemies: " + str(enemy_count) + ", torches: " + str(torch_count) + ", objects: " + str(object_count), LogManager.CATEGORY_DUNGEON)
	
	# Fix player appearance
	_fix_player_appearance_after_dungeon_sync()
	
	# Render dungeon
	print("GameWorld: Client - Rendering dungeon from blob...")
	_render_dungeon()
	print("GameWorld: Client - Dungeon rendered")
	
	# Initialize fog of war after dungeon is rendered (CRITICAL for joiner to see the map)
	print("GameWorld: Client - Initializing fog of war...")
	_init_fog_of_war()
	print("GameWorld: Client - Fog of war initialized")
	
	# Spawn all entities
	print("GameWorld: Client - Spawning torches from blob...")
	_spawn_torches()
	print("GameWorld: Client - Torches spawned")
	
	print("GameWorld: Client - Spawning enemies from blob...")
	if dungeon_data.has("enemies"):
		print("GameWorld: Client - dungeon_data has enemies: ", dungeon_data.enemies.size(), " enemies")
	else:
		print("GameWorld: Client - WARNING: dungeon_data has NO 'enemies' key!")
	print("GameWorld: Client - Before spawning enemies, defeated_enemies dictionary has ", defeated_enemies.size(), " entries: ", defeated_enemies.keys())
	_spawn_enemies()
	print("GameWorld: Client - Enemies spawned")
	
	print("GameWorld: Client - Spawning interactable objects from blob...")
	if dungeon_data.has("interactable_objects"):
		print("GameWorld: Client - dungeon_data has interactable_objects: ", dungeon_data.interactable_objects.size(), " objects")
	else:
		print("GameWorld: Client - WARNING: dungeon_data has NO 'interactable_objects' key!")
	print("GameWorld: Client - Before spawning objects, pending_chest_opens dictionary has ", pending_chest_opens.size(), " entries: ", pending_chest_opens.keys())
	_spawn_interactable_objects()
	print("GameWorld: Client - Interactable objects spawned")
	
	print("GameWorld: Client - Spawning blocking doors from blob...")
	_spawn_blocking_doors()
	print("GameWorld: Client - Blocking doors spawned")
	
	# Spawn traps on client
	print("GameWorld: Client - Spawning traps from blob...")
	_spawn_traps()
	print("GameWorld: Client - Traps spawned")
	
	print("GameWorld: Client - Spawning room triggers from blob...")
	_spawn_room_triggers()
	print("GameWorld: Client - Room triggers spawned")
	
	# Apply door states (from metadata) - after doors are spawned
	if pending_door_states.size() > 0:
		print("GameWorld: Client - Applying ", pending_door_states.size(), " pending door states...")
		var entities_node = get_node_or_null("Entities")
		if entities_node:
			for child in entities_node.get_children():
				if child.is_in_group("blocking_door") or child.has_method("_sync_door_open"):
					if pending_door_states.has(child.name):
						_apply_pending_door_state(child)
		print("GameWorld: Client - Door states applied")
	
	# Spawn existing loot (from metadata) - call the RPC handler directly since we're the client
	if dungeon_sync_metadata.has("existing_loot"):
		var loot_list = dungeon_sync_metadata.existing_loot
		if loot_list.size() > 0:
			print("GameWorld: Client - Spawning ", loot_list.size(), " existing loot items from metadata...")
			for loot_data in loot_list:
				if loot_data.has("item_data"):
					# Call the RPC handler directly (it's marked as call_local)
					_sync_item_loot_spawn(loot_data.position, loot_data.item_data, loot_data.get("velocity", Vector2.ZERO), loot_data.get("velocity_z", 0.0), loot_data.get("loot_id", -1))
				else:
					# Call the RPC handler directly (it's marked as call_local)
					_sync_loot_spawn(loot_data.position, loot_data.get("loot_type", 0), loot_data.get("velocity", Vector2.ZERO), loot_data.get("velocity_z", 0.0), loot_data.get("loot_id", -1))
			print("GameWorld: Client - Existing loot spawned")
	
	# Update spawn points
	print("GameWorld: Client - Updating spawn points...")
	var host_room = dungeon_sync_metadata.host_room
	if not host_room.is_empty():
		print("GameWorld: Client - Using host's room for spawn points: ", host_room)
		_update_spawn_points(host_room)
		_move_players_to_host_room(host_room)
	else:
		print("GameWorld: Client - Host room not available, using start room")
		_update_spawn_points()
	
	# Move all players to start room
	print("GameWorld: Client - Moving all players to start room...")
	_move_all_players_to_start_room()
	print("GameWorld: Client - Players moved to start room")
	
	# Update camera
	await get_tree().process_frame
	print("GameWorld: Client - Updating camera...")
	_update_camera()
	print("GameWorld: Client - Camera updated")
	
	# Update fog of war to reveal area around player (CRITICAL for joiner to see the map)
	await get_tree().process_frame
	print("GameWorld: Client - Updating fog of war after player positioning...")
	_update_fog_of_war(0.0) # Pass 0.0 for delta since we're in async context
	print("GameWorld: Client - Fog of war updated")
	
	# Load HUD
	print("=== GameWorld: Client - About to load HUD (call_deferred) ===")
	call_deferred("_load_hud")
	get_tree().create_timer(0.2).timeout.connect(func():
		var hud_check = get_node_or_null("IngameHUD")
		if not hud_check or not is_instance_valid(hud_check):
			print("=== GameWorld: Client - HUD not found after deferred call, loading directly as backup... ===")
			_load_hud()
		else:
			print("GameWorld: Client - HUD already loaded (found in scene tree)")
	)
	
	dungeon_sync_complete = true
	print("=== GameWorld: Client - Dungeon blob sync completed ===")
	
	call_deferred("_fix_player_appearance_after_dungeon_sync")

func _check_and_render_dungeon():
	# Assemble dungeon_data from chunks and render
	if dungeon_sync_complete:
		return # Already rendered
	
	if dungeon_sync_metadata.is_empty():
		print("GameWorld: Client - Cannot render: metadata not received")
		return
	
	if dungeon_sync_chunks.size() < dungeon_sync_total_chunks:
		print("GameWorld: Client - Cannot render: missing chunks (", dungeon_sync_chunks.size(), "/", dungeon_sync_total_chunks, ")")
		return
	
	if dungeon_sync_rooms.is_empty():
		print("GameWorld: Client - Cannot render: rooms not received")
		return
	
	print("GameWorld: Client - All chunks received! Assembling dungeon data...")
	
	# Assemble tile_grid and grid from chunks
	var map_size = dungeon_sync_metadata.map_size
	var tile_grid = []
	var grid = []
	
	# Initialize arrays
	for x in range(map_size.x):
		tile_grid.append([])
		grid.append([])
		for y in range(map_size.y):
			tile_grid[x].append(Vector2i(0, 0))
			grid[x].append(0)
	
	# Fill from chunks
	# Each chunk contains columns (x) with rows (y) from start_row to end_row
	# tile_grid_chunk[x] contains tile_grid[x][start_row..end_row]
	for chunk_idx in range(dungeon_sync_total_chunks):
		if not dungeon_sync_chunks.has(chunk_idx):
			print("GameWorld: Client - ERROR: Missing chunk ", chunk_idx)
			return
		
		var chunk = dungeon_sync_chunks[chunk_idx]
		var start_row = chunk.start_row
		var _end_row = chunk.end_row
		var tile_grid_chunk = chunk.tile_grid
		var grid_chunk = chunk.grid
		
		# For each column x, copy the row range from the chunk
		for col_x in range(min(tile_grid_chunk.size(), map_size.x)):
			if col_x >= tile_grid.size():
				continue
			var tile_col_chunk = tile_grid_chunk[col_x]
			var grid_col_chunk = grid_chunk[col_x]
			for row_idx in range(tile_col_chunk.size()):
				var actual_y = start_row + row_idx
				if actual_y >= map_size.y:
					break
				if row_idx < tile_col_chunk.size():
					tile_grid[col_x][actual_y] = tile_col_chunk[row_idx]
					grid[col_x][actual_y] = grid_col_chunk[row_idx]
	
	# Reconstruct dungeon_data
	dungeon_data = {
		"tile_grid": tile_grid,
		"grid": grid,
		"map_size": map_size,
		"start_room": dungeon_sync_rooms.start_room,
		"rooms": dungeon_sync_rooms.rooms,
		"enemies": [],
		"torches": [],
		"interactable_objects": []
	}
	
	dungeon_seed = dungeon_sync_metadata.seed
	current_level = dungeon_sync_metadata.level
	
	print("GameWorld: Client - Dungeon data assembled! Rendering...")
	LogManager.log("GameWorld: Client assembled dungeon from chunks for level " + str(current_level), LogManager.CATEGORY_DUNGEON)
	
	# Reset all player grab/lift/push states to prevent being stuck in lifting animation
	var all_players = get_tree().get_nodes_in_group("player")
	for player in all_players:
		if player and is_instance_valid(player) and player.has_method("reset_grab_state"):
			player.reset_grab_state()
	
	# Fix player appearance
	_fix_player_appearance_after_dungeon_sync()
	
	# Render dungeon
	print("GameWorld: Client - Rendering dungeon from chunks...")
	_render_dungeon()
	print("GameWorld: Client - Dungeon rendered")
	
	# Update spawn points
	print("GameWorld: Client - Updating spawn points...")
	var host_room = dungeon_sync_metadata.host_room
	if not host_room.is_empty():
		print("GameWorld: Client - Using host's room for spawn points: ", host_room)
		_update_spawn_points(host_room)
		_move_players_to_host_room(host_room)
	else:
		print("GameWorld: Client - Host room not available, using start room")
		_update_spawn_points()
	
	# Move all players to start room
	print("GameWorld: Client - Moving all players to start room...")
	_move_all_players_to_start_room()
	print("GameWorld: Client - Players moved to start room")
	
	# Update camera
	await get_tree().process_frame
	print("GameWorld: Client - Updating camera...")
	_update_camera()
	print("GameWorld: Client - Camera updated")
	
	# Load HUD
	print("=== GameWorld: Client - About to load HUD (call_deferred) ===")
	call_deferred("_load_hud")
	get_tree().create_timer(0.2).timeout.connect(func():
		var hud_check = get_node_or_null("IngameHUD")
		if not hud_check or not is_instance_valid(hud_check):
			print("=== GameWorld: Client - HUD not found after deferred call, loading directly as backup... ===")
			_load_hud()
		else:
			print("GameWorld: Client - HUD already loaded (found in scene tree)")
	)
	
	dungeon_sync_complete = true
	print("=== GameWorld: Client - Chunked dungeon sync completed ===")
	
	call_deferred("_fix_player_appearance_after_dungeon_sync")

@rpc("authority", "reliable", "call_local")
func _sync_dungeon_entities(non_essential_data: Dictionary):
	# Clients receive non-essential dungeon data (enemies, torches, interactable_objects)
	# This is sent separately to avoid exceeding WebRTC message size limits
	print("=== _sync_dungeon_entities RPC RECEIVED on client ===")
	print("=== [CHUNK FINAL] Client received entities data ===")
	if not multiplayer.is_server():
		# Send acknowledgment for entities (chunk total_chunks + 1)
		_ack_dungeon_chunk.rpc_id(1, dungeon_sync_total_chunks + 1)
		# Merge non-essential data into dungeon_data
		if dungeon_data.is_empty():
			print("GameWorld: Client - WARNING: Received entities sync but dungeon_data is empty!")
			return
		
		dungeon_data["enemies"] = non_essential_data.get("enemies", [])
		dungeon_data["torches"] = non_essential_data.get("torches", [])
		dungeon_data["interactable_objects"] = non_essential_data.get("interactable_objects", [])
		
		print("GameWorld: Client - Received entities sync - enemies: ", dungeon_data.enemies.size(), ", torches: ", dungeon_data.torches.size(), ", objects: ", dungeon_data.interactable_objects.size())
		
		# Spawn entities (torches and enemies are already synced separately, but ensure interactable objects are spawned)
		if not dungeon_data.interactable_objects.is_empty():
			print("GameWorld: Client - Spawning interactable objects from entities sync...")
			_spawn_interactable_objects()
			print("GameWorld: Client - Interactable objects spawned")
		
		# Spawn blocking doors and room triggers if not already spawned
		_spawn_blocking_doors()
		_spawn_room_triggers()

func _fix_player_appearance_after_dungeon_sync():
	# Re-randomize appearance for all players that were spawned before dungeon_seed was received
	# This ensures all players (including joiners) have the same appearance across all clients
	# IMPORTANT: Only run on clients, not on server (server players already have correct appearance)
	if multiplayer.is_server():
		return # Server players already have correct appearance, skip
	
	if dungeon_seed == 0:
		return # No seed yet, skip
	
	var players = get_tree().get_nodes_in_group("player")
	for player in players:
		if player and is_instance_valid(player) and player.has_method("_reinitialize_appearance_with_seed"):
			# Only re-initialize players that were marked as needing appearance reset
			# This prevents re-randomizing players that already have correct appearance
			if player.has_meta("needs_appearance_reset"):
				player._reinitialize_appearance_with_seed(dungeon_seed)
				LogManager.log("GameWorld: Re-initialized appearance for player " + str(player.name) + " with dungeon_seed " + str(dungeon_seed), LogManager.CATEGORY_GAMEPLAY)

func _spawn_torches():
	# Spawn torches from dungeon data
	if dungeon_data.is_empty() or not dungeon_data.has("torches"):
		return
	
	var torch_scene = preload("res://scenes/torch_wall.tscn")
	if not torch_scene:
		push_error("ERROR: Could not load torch_wall.tscn!")
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		push_error("ERROR: Could not find Entities node!")
		return
	
	# Remove existing blocking doors first to prevent duplicates/renames
	var doors_to_remove = []
	for child in entities_node.get_children():
		if child.is_in_group("blocking_door"):
			doors_to_remove.append(child)
	
	for door_to_remove in doors_to_remove:
		if is_instance_valid(door_to_remove):
			door_to_remove.queue_free()
	
	if doors_to_remove.size() > 0:
		await get_tree().process_frame
		await get_tree().process_frame
	
	# Remove existing torches first
	for child in entities_node.get_children():
		if child.name.begins_with("Torch_"):
			child.queue_free()
	
	# Spawn torches
	var torches = dungeon_data.torches
	LogManager.log("GameWorld: Spawning " + str(torches.size()) + " torches", LogManager.CATEGORY_DUNGEON)
	
	for i in range(torches.size()):
		var torch_data = torches[i]
		var torch = torch_scene.instantiate()
		torch.name = "Torch_%d" % i
		entities_node.add_child(torch)
		torch.global_position = torch_data.position
		torch.rotation_degrees = torch_data.rotation
	
	LogManager.log("GameWorld: Spawned " + str(torches.size()) + " torches", LogManager.CATEGORY_DUNGEON)

func _spawn_enemies():
	# Spawn enemies from dungeon data
	if dungeon_data.is_empty() or not dungeon_data.has("enemies"):
		return
	
	# On server: spawn enemies with full authority
	# On clients: spawn enemies but they'll be authority of server
	var is_server = multiplayer.is_server() or not multiplayer.has_multiplayer_peer()
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		push_error("ERROR: Could not find Entities node!")
		return
	
	# Remove existing enemies first (except ones spawned by spawners)
	# Also remove dead enemies that might still be in the scene
	# Collect all enemies to remove first, then remove them
	var enemies_to_remove = []
	for child in entities_node.get_children():
		if child.is_in_group("enemy") and child.has_meta("dungeon_spawned"):
			enemies_to_remove.append(child)
	
	# Remove all old dungeon enemies
	for enemy in enemies_to_remove:
		print("GameWorld: Removing old dungeon enemy: ", enemy.name)
		if is_instance_valid(enemy):
			enemy.queue_free()
	
	# Wait a frame for enemies to be removed before spawning new ones
	await get_tree().process_frame
	
	# Spawn enemies
	if not dungeon_data.has("enemies"):
		LogManager.log("GameWorld: WARNING: dungeon_data has no 'enemies' key!", LogManager.CATEGORY_DUNGEON)
		return
	
	var enemies = dungeon_data.enemies
	if enemies == null or not enemies is Array:
		print("GameWorld: WARNING: dungeon_data.enemies is not an Array! Type: ", typeof(enemies))
		return
	
	LogManager.log("GameWorld: Spawning " + str(enemies.size()) + " enemies (is_server: " + str(is_server) + ")", LogManager.CATEGORY_DUNGEON)
	print("GameWorld: _spawn_enemies() - Starting spawn, defeated_enemies dictionary has ", defeated_enemies.size(), " entries")
	if defeated_enemies.size() > 0:
		print("GameWorld: _spawn_enemies() - Defeated enemy indices: ", defeated_enemies.keys())
	
	for i in range(enemies.size()):
		# Check if this enemy was already defeated (for clients joining mid-game)
		# CRITICAL: Check BEFORE creating any enemy instance
		if defeated_enemies.has(i):
			print("GameWorld: [SKIP] Skipping spawn of defeated enemy with index " + str(i) + " (defeated_enemies has this index)")
			print("GameWorld: [SKIP] Enemy at array index ", i, " will NOT be spawned")
			LogManager.log("GameWorld: Skipping spawn of defeated enemy with index " + str(i), LogManager.CATEGORY_NETWORK)
			continue # Don't spawn defeated enemies
		
		print("GameWorld: [SPAWN] Spawning enemy at index ", i, " (not in defeated_enemies)")
		
		var enemy_data = enemies[i]
		if not enemy_data is Dictionary:
			push_error("ERROR: Enemy data at index ", i, " is not a Dictionary! Type: ", typeof(enemy_data))
			continue
		
		if not enemy_data.has("type"):
			push_error("ERROR: Enemy data missing 'type' field: ", enemy_data)
			continue
		
		if not enemy_data.has("position"):
			push_error("ERROR: Enemy data missing 'position' field: ", enemy_data)
			continue
		
		var enemy_type = enemy_data.type
		if not enemy_type is String:
			push_error("ERROR: Enemy type is not a String! Type: ", typeof(enemy_type), " Value: ", enemy_type)
			continue
		
		if not enemy_type.begins_with("res://"):
			# If type is just "enemy_rat", convert to full path
			enemy_type = "res://scenes/" + enemy_type + ".tscn"
		
		var enemy_scene = load(enemy_type)
		if not enemy_scene:
			push_error("ERROR: Could not load enemy scene: ", enemy_type)
			continue
		
		var enemy = enemy_scene.instantiate()
		# Use consistent naming: Enemy_<index> based on position in enemies array
		# This ensures server and client enemies have the same names
		enemy.name = "Enemy_%d" % i
		enemy.set_meta("dungeon_spawned", true)
		# Store enemy index for identification
		enemy.set_meta("enemy_index", i)
		
		# Store room reference for AI (if available)
		if enemy_data.has("room") and enemy_data.room is Dictionary:
			enemy.set_meta("room", enemy_data.room)
		
		# Set multiplayer authority BEFORE adding to scene tree (CRITICAL for RPC to work!)
		if multiplayer.has_multiplayer_peer():
			enemy.set_multiplayer_authority(1)
			print("GameWorld: Set enemy authority to 1 BEFORE add_child: ", enemy.name, " authority: ", enemy.get_multiplayer_authority())
		
		# Set enemy stats BEFORE adding to scene (so _ready() can use them)
		if "max_health" in enemy_data:
			enemy.max_health = enemy_data.max_health
			enemy.current_health = enemy_data.max_health
		if "move_speed" in enemy_data:
			enemy.move_speed = enemy_data.move_speed
		if "damage" in enemy_data:
			enemy.damage = enemy_data.damage
		
		# If it's a humanoid enemy, set the humanoid_type
		if enemy_type.ends_with("enemy_humanoid.tscn") and "humanoid_type" in enemy_data:
			enemy.humanoid_type = enemy_data.humanoid_type
		
		# CRITICAL: Set collision mask BEFORE adding to scene to ensure enemies collide with walls (layer 7 = bit 6 = 64)
		# This overrides any collision_mask set in the scene file
		enemy.collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64)
		
		# Add to scene tree AFTER setting authority and stats
		entities_node.add_child(enemy)
		enemy.global_position = enemy_data.position
		
		# CRITICAL: Re-verify collision_mask AFTER adding to scene (in case scene file overrides it)
		# This ensures enemies always collide with walls (layer 7 = bit 6 = 64)
		if enemy.collision_mask != (1 | 2 | 64):
			LogManager.log("GameWorld: WARNING - Enemy " + str(enemy.name) + " collision_mask was " + str(enemy.collision_mask) + ", expected " + str(1 | 2 | 64) + "! Correcting...", LogManager.CATEGORY_DUNGEON)
			enemy.collision_mask = 1 | 2 | 64
		
		# Verify authority is still set after adding to tree
		if multiplayer.has_multiplayer_peer():
			var auth_after = enemy.get_multiplayer_authority()
			print("GameWorld: Enemy authority AFTER add_child: ", enemy.name, " authority: ", auth_after, " is_authority: ", enemy.is_multiplayer_authority())
			if auth_after != 1 and is_server:
				push_error("GameWorld: ERROR - Enemy authority lost after add_child! Expected 1, got ", auth_after)
		
		# Ensure enemy is fully initialized
		if not enemy.is_inside_tree():
			push_error("GameWorld: ERROR - Enemy not in tree after add_child!")
		
		if is_server:
			LogManager.log("GameWorld: Server spawned enemy: " + str(enemy.name) + " at " + str(enemy_data.position) + " (type: " + str(enemy_type) + ", authority: " + str(enemy.get_multiplayer_authority()) + " in_tree: " + str(enemy.is_inside_tree()) + " is_authority: " + str(enemy.is_multiplayer_authority()) + " index: " + str(i), LogManager.CATEGORY_DUNGEON)
		else:
			LogManager.log("GameWorld: Client spawned enemy: " + str(enemy.name) + " at " + str(enemy_data.position) + " (type: " + str(enemy_type) + ", authority: " + str(enemy.get_multiplayer_authority()) + " is_authority: " + str(enemy.is_multiplayer_authority()) + " in_tree: " + str(enemy.is_inside_tree()) + " index: " + str(i), LogManager.CATEGORY_DUNGEON)
	
	if is_server:
		LogManager.log("GameWorld: Server spawned " + str(enemies.size()) + " enemies", LogManager.CATEGORY_DUNGEON)
	else:
		LogManager.log("GameWorld: Client spawned " + str(enemies.size()) + " enemies", LogManager.CATEGORY_DUNGEON)
	
	# After spawning all enemies, explicitly remove any defeated enemies that might still exist
	# This handles edge cases where defeated enemies weren't properly removed (e.g., joining mid-game)
	if defeated_enemies.size() > 0:
		print("GameWorld: [CLEANUP] Checking for defeated enemies to remove (defeated_enemies has ", defeated_enemies.size(), " entries)")
		print("GameWorld: [CLEANUP] Defeated enemy indices in dictionary: ", defeated_enemies.keys())
		var defeated_enemies_found = []
		var all_enemies = []
		for child in entities_node.get_children():
			if child.is_in_group("enemy"):
				all_enemies.append(child)
				var enemy_index = -1
				if child.has_meta("enemy_index"):
					enemy_index = child.get_meta("enemy_index")
				else:
					# Try to extract index from name (e.g., "Enemy_5" -> 5)
					var name_parts = child.name.split("_")
					if name_parts.size() >= 2:
						var index_str = name_parts[name_parts.size() - 1]
						if index_str.is_valid_int():
							enemy_index = int(index_str)
				
				print("GameWorld: [CLEANUP] Checking enemy: ", child.name, " (index: ", enemy_index, ", in_defeated: ", (enemy_index >= 0 and defeated_enemies.has(enemy_index)), ")")
				
				if enemy_index >= 0 and defeated_enemies.has(enemy_index):
					defeated_enemies_found.append(child)
					print("GameWorld: [CLEANUP] Found defeated enemy to remove: ", child.name, " (index: ", enemy_index, ")")
		
		print("GameWorld: [CLEANUP] Total enemies in scene: ", all_enemies.size(), ", Defeated enemies found: ", defeated_enemies_found.size())
		
		# Remove all defeated enemies that are still in the scene
		for defeated_enemy in defeated_enemies_found:
			if is_instance_valid(defeated_enemy):
				print("GameWorld: [CLEANUP] Removing defeated enemy that was still in scene: ", defeated_enemy.name)
				defeated_enemy.queue_free()
		
		if defeated_enemies_found.size() > 0:
			print("GameWorld: [CLEANUP] Removed ", defeated_enemies_found.size(), " defeated enemies that were still in scene")
		else:
			print("GameWorld: [CLEANUP] No defeated enemies found in scene to remove (all were properly skipped during spawn)")

func _spawn_traps():
	# Spawn traps from dungeon data
	if dungeon_data.is_empty() or not dungeon_data.has("traps"):
		LogManager.log("GameWorld: No traps to spawn", LogManager.CATEGORY_DUNGEON)
		return
	
	var is_server = multiplayer.is_server() or not multiplayer.has_multiplayer_peer()
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		push_error("ERROR: Could not find Entities node!")
		return
	
	# Remove existing traps first (avoid name conflicts / desync)
	var traps_to_remove = []
	for child in entities_node.get_children():
		if child.is_in_group("trap"):
			traps_to_remove.append(child)
	
	for trap in traps_to_remove:
		print("GameWorld: Removing old trap: ", trap.name)
		if is_instance_valid(trap):
			trap.queue_free()
	
	# Wait a frame to ensure removals are processed
	if traps_to_remove.size() > 0:
		await get_tree().process_frame
	
	var traps = dungeon_data.traps
	if traps == null or not traps is Array:
		print("GameWorld: WARNING: dungeon_data.traps is not an Array!")
		return
	
	LogManager.log("GameWorld: Spawning " + str(traps.size()) + " trap(s)", LogManager.CATEGORY_DUNGEON)
	
	var trap_scene = load("res://scenes/trap.tscn")
	if not trap_scene:
		push_error("ERROR: Could not load trap scene!")
		return
	
	for i in range(traps.size()):
		var trap_data = traps[i]
		if not trap_data is Dictionary:
			continue
		
		var trap = trap_scene.instantiate()
		trap.name = "Trap_" + str(i)
		trap.global_position = trap_data.position
		
		# Set multiplayer authority to server
		if is_server:
			trap.set_multiplayer_authority(1) # Server is authority
		
		entities_node.add_child(trap, true)
		LogManager.log("GameWorld: Spawned trap at " + str(trap_data.position), LogManager.CATEGORY_DUNGEON)

func _spawn_interactable_objects():
	# Spawn interactable objects from dungeon data
	if dungeon_data.is_empty() or not dungeon_data.has("interactable_objects"):
		return
	
	# On server: spawn objects with full authority
	# On clients: spawn objects but they'll be authority of server
	var is_server = multiplayer.is_server() or not multiplayer.has_multiplayer_peer()
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		push_error("ERROR: Could not find Entities node!")
		return
	
	# Remove existing interactable objects first (avoid name conflicts / desync)
	var objects_to_remove = []
	for child in entities_node.get_children():
		if child.is_in_group("interactable_object"):
			objects_to_remove.append(child)
	
	# Remove all old dungeon objects
	for obj in objects_to_remove:
		print("GameWorld: Removing old dungeon interactable object: ", obj.name)
		if is_instance_valid(obj):
			obj.queue_free()
	
	# Wait a frame to ensure removals are processed before re-spawning
	if objects_to_remove.size() > 0:
		await get_tree().process_frame
		await get_tree().process_frame
	
	# Spawn objects
	if not dungeon_data.has("interactable_objects"):
		LogManager.log("GameWorld: WARNING: dungeon_data has no 'interactable_objects' key!", LogManager.CATEGORY_DUNGEON)
		return
	
	var objects = dungeon_data.interactable_objects
	if objects == null or not objects is Array:
		print("GameWorld: WARNING: dungeon_data.interactable_objects is not an Array! Type: ", typeof(objects))
		return
	
	LogManager.log("GameWorld: Spawning " + str(objects.size()) + " interactable objects (is_server: " + str(is_server) + ")", LogManager.CATEGORY_DUNGEON)
	
	var interactable_object_scene = load("res://scenes/interactable_object.tscn")
	if not interactable_object_scene:
		push_error("ERROR: Could not load interactable_object scene!")
		return
	
	for i in range(objects.size()):
		var object_data = objects[i]
		if not object_data is Dictionary:
			push_error("ERROR: Object data at index ", i, " is not a Dictionary! Type: ", typeof(object_data))
			continue
		
		if not object_data.has("type"):
			push_error("ERROR: Object data missing 'type' field: ", object_data)
			continue
		
		if not object_data.has("position"):
			push_error("ERROR: Object data missing 'position' field: ", object_data)
			continue
		
		if not object_data.has("setup_function"):
			push_error("ERROR: Object data missing 'setup_function' field: ", object_data)
			continue
		
		# Check if object with this index already exists (prevent duplicates)
		var existing_obj = entities_node.get_node_or_null("InteractableObject_%d" % i)
		if existing_obj and is_instance_valid(existing_obj):
			LogManager.log("GameWorld: Object InteractableObject_%d already exists, skipping spawn" % i, LogManager.CATEGORY_DUNGEON)
			continue
		
		var obj = interactable_object_scene.instantiate()
		# Use consistent naming: InteractableObject_<index>
		obj.name = "InteractableObject_%d" % i
		obj.set_meta("dungeon_spawned", true)
		obj.set_meta("object_index", i)
		
		# Store room reference (if available)
		if object_data.has("room") and object_data.room is Dictionary:
			obj.set_meta("room", object_data.room)
		
		# Set multiplayer authority BEFORE adding to scene tree
		if multiplayer.has_multiplayer_peer():
			obj.set_multiplayer_authority(1)
		
		# Add to scene tree
		entities_node.add_child(obj)
		# Re-apply name and index after add_child to avoid auto-renaming
		obj.name = "InteractableObject_%d" % i
		obj.set_meta("object_index", i)
		obj.global_position = object_data.position
		
		# Call the setup function to configure the object
		if obj.has_method(object_data.setup_function):
			obj.call(object_data.setup_function)
		else:
			push_error("ERROR: Object does not have method: ", object_data.setup_function)
		
		# Add to group for easy access
		obj.add_to_group("interactable_object")
	
		# Apply any pending chest open sync that arrived before this chest spawned
		# IMPORTANT: Check after setup_function is called so object_type is set
		# Check if it's a chest by checking object_type (setup_chest sets object_type to "Chest")
		var is_chest = false
		if "object_type" in obj and obj.object_type == "Chest":
			is_chest = true
		
		if is_chest and pending_chest_opens.has(obj.name):
			print("GameWorld: Found pending chest open for ", obj.name, " (object_type: ", obj.object_type, "), applying now")
			var chest_state = pending_chest_opens[obj.name]
			var obj_ref = obj # Capture reference for deferred call
			var chest_name = obj.name # Capture name for logging
			# Use call_deferred to ensure chest is fully initialized (sprite, chest_opened_frame, etc.)
			# This happens after setup_chest is called, so sprite and frames should be ready
			call_deferred("_open_pending_chest", obj_ref, chest_state, chest_name)
			pending_chest_opens.erase(obj.name)
		
		# Apply pending state sync if it arrived before this object spawned
		if pending_interactable_states.has(obj.name):
			var state = pending_interactable_states[obj.name]
			_apply_interactable_state(obj, state)
			pending_interactable_states.erase(obj.name)
		
		# If this object is already broken (for clients joining mid-game), break it immediately
		# Check broken_objects after object is fully spawned
		if broken_objects.has(i):
			print("GameWorld: Object at index ", i, " (name: ", obj.name, ") is marked as broken, breaking it now")
			print("GameWorld: Object state - is_broken: ", obj.is_broken if "is_broken" in obj else "N/A", ", has _sync_break: ", obj.has_method("_sync_break"), ", is_destroyable: ", obj.is_destroyable if "is_destroyable" in obj else "N/A")
			# Use both call_deferred and timer to ensure object is fully initialized
			var obj_ref = obj # Capture reference
			var obj_index = i # Capture index for logging
			# First try with call_deferred (runs at end of frame)
			call_deferred("_break_spawned_object", obj_ref, obj_index)
			# Also use timer as backup (wait longer to ensure sprite and all components are ready)
			get_tree().create_timer(0.1).timeout.connect(func():
				if is_instance_valid(obj_ref) and not obj_ref.is_queued_for_deletion():
					if "is_broken" in obj_ref and not obj_ref.is_broken:
						print("GameWorld: Timer backup - breaking object at index ", obj_index, " (name: ", obj_ref.name, ")")
						_break_spawned_object(obj_ref, obj_index)
			)
	
	LogManager.log("GameWorld: Spawned " + str(objects.size()) + " interactable objects", LogManager.CATEGORY_DUNGEON)

func _break_spawned_object(obj: Node, obj_index: int):
	# Helper to break an object that was already broken when client joined mid-game
	if not is_instance_valid(obj):
		print("GameWorld: Cannot break object at index ", obj_index, " - object is invalid")
		return
	if obj.is_queued_for_deletion():
		print("GameWorld: Object at index ", obj_index, " (name: ", obj.name, ") is already queued for deletion")
		return
	if "is_broken" in obj and obj.is_broken:
		print("GameWorld: Object at index ", obj_index, " (name: ", obj.name, ") is already broken")
		return
	# Check if object is destroyable (chests and pillars are not destroyable)
	if "is_destroyable" in obj and not obj.is_destroyable:
		print("GameWorld: Object at index ", obj_index, " (name: ", obj.name, ") is not destroyable, skipping break")
		return
	if obj.has_method("_sync_break"):
		print("GameWorld: Breaking object at index ", obj_index, " (name: ", obj.name, ")")
		print("GameWorld: Object state before break - is_broken: ", obj.is_broken if "is_broken" in obj else "N/A", ", is_queued_for_deletion: ", obj.is_queued_for_deletion(), ", has sprite: ", "sprite" in obj and obj.sprite != null)
		obj._sync_break(true) # silent=true to avoid duplicate sounds
		# Verify it worked
		if "is_broken" in obj:
			print("GameWorld: Object state after _sync_break - is_broken: ", obj.is_broken)
		if obj.is_queued_for_deletion():
			print("GameWorld: Successfully broke object ", obj.name, " at index ", obj_index, " (object is queued for deletion)")
		else:
			print("GameWorld: WARNING - Object ", obj.name, " was not queued for deletion after _sync_break!")
	else:
		print("GameWorld: Object at index ", obj_index, " (name: ", obj.name, ") does not have _sync_break method")

func _open_pending_chest(obj: Node, chest_state: Dictionary, chest_name: String):
	# Helper to open a chest that was already opened when client joined mid-game
	if not is_instance_valid(obj):
		print("GameWorld: Cannot open chest ", chest_name, " - chest is invalid")
		return
	if obj.is_chest_opened:
		print("GameWorld: Chest ", chest_name, " is already opened")
		return
	if obj.has_method("_sync_chest_open"):
		var item_data = chest_state.get("item_data", {})
		print("GameWorld: Opening chest ", chest_name, " with loot_type: ", chest_state.get("loot_type", "coin"))
		obj._sync_chest_open(chest_state.get("loot_type", "coin"), chest_state.get("player_peer_id", 0), item_data)
		print("GameWorld: Successfully opened chest ", chest_name)
	else:
		print("GameWorld: WARNING - Chest ", chest_name, " does not have _sync_chest_open method")
		# Fallback: try to set the sprite frame directly if available
		if "sprite" in obj and "chest_opened_frame" in obj and obj.chest_opened_frame >= 0:
			obj.is_chest_opened = true
			if obj.sprite:
				obj.sprite.frame = obj.chest_opened_frame
				print("GameWorld: Opened chest ", chest_name, " using fallback method (sprite frame)")

func _sync_existing_dungeon_enemies_to_client(client_peer_id: int):
	# Sync existing dungeon-spawned enemies to newly connected client
	# Use dungeon_data.enemies array (like torches) instead of searching scene tree
	if dungeon_data.is_empty() or not dungeon_data.has("enemies"):
		return
	
	var enemies = dungeon_data.enemies
	print("GameWorld: Syncing ", enemies.size(), " dungeon-spawned enemies to client ", client_peer_id)
	
	# Sync each enemy from dungeon data
	for enemy_data in enemies:
		_sync_dungeon_enemy_spawn.rpc_id(client_peer_id, enemy_data)

@rpc("authority", "reliable")
func _sync_dungeon_enemy_spawn(enemy_data: Dictionary):
	# Client receives dungeon enemy spawn data and spawns it
	print("GameWorld: Client received RPC to spawn dungeon enemy: type=", enemy_data.type, " pos=", enemy_data.position)
	
	# Find the enemy index from the position in the enemies array
	# We need to match the server's enemy index to ensure consistent naming
	var enemy_index = -1
	if dungeon_data.has("enemies") and dungeon_data.enemies is Array:
		for idx in range(dungeon_data.enemies.size()):
			var e_data = dungeon_data.enemies[idx]
			if e_data.has("position") and e_data.position.distance_to(enemy_data.position) < 1.0:
				enemy_index = idx
				break
	
	# If enemy_index is in enemy_data, use it (more reliable)
	if "enemy_index" in enemy_data:
		enemy_index = enemy_data.enemy_index
	
	# Check if this enemy was already defeated (for clients joining mid-game)
	if enemy_index >= 0 and defeated_enemies.has(enemy_index):
		LogManager.log("GameWorld: Skipping spawn of defeated enemy with index " + str(enemy_index), LogManager.CATEGORY_NETWORK)
		return # Don't spawn defeated enemies
	
	if not multiplayer.is_server():
		# Convert enemy type to full path if needed (same as _spawn_enemies)
		var enemy_type = enemy_data.type
		if not enemy_type.begins_with("res://"):
			# If type is just "enemy_rat", convert to full path
			enemy_type = "res://scenes/" + enemy_type + ".tscn"
		
		var enemy_scene = load(enemy_type)
		if not enemy_scene:
			push_error("ERROR: Could not load enemy scene: ", enemy_type)
			return
		
		var entities_node = get_node_or_null("Entities")
		if not entities_node:
			push_error("ERROR: Could not find Entities node!")
			return
		
		# Check if enemy already exists (avoid duplicates from _spawn_enemies() + RPC)
		# Also check if enemy is dead or queued for deletion
		for child in entities_node.get_children():
			if child.is_in_group("enemy") and child.has_meta("dungeon_spawned"):
				# Check if it's a duplicate by position
				var child_pos = child.global_position
				if child_pos.distance_to(enemy_data.position) < 1.0: # Same position
					# Also check if it's dead - if so, remove it first
					if "is_dead" in child and child.is_dead:
						print("GameWorld: Removing dead duplicate enemy at ", enemy_data.position)
						child.queue_free()
						# Continue to spawn new one
					else:
						LogManager.log("GameWorld: Enemy already exists at " + str(enemy_data.position) + ", skipping duplicate spawn", LogManager.CATEGORY_DUNGEON)
						return
		
		# If we couldn't find the index, use a fallback
		if enemy_index == -1:
			enemy_index = entities_node.get_child_count()
		
		var enemy = enemy_scene.instantiate()
		enemy.name = "Enemy_%d" % enemy_index
		enemy.set_meta("dungeon_spawned", true)
		enemy.set_meta("enemy_index", enemy_index)
		
		# Store room reference for AI (if available and valid)
		if enemy_data.has("room") and enemy_data.room is Dictionary:
			enemy.set_meta("room", enemy_data.room)
		
		# Set multiplayer authority BEFORE adding to scene tree (CRITICAL!)
		if multiplayer.has_multiplayer_peer():
			enemy.set_multiplayer_authority(1)
			print("GameWorld: Set enemy authority to 1 BEFORE add_child (RPC): ", enemy.name)
		
		# Set enemy stats BEFORE adding to scene
		if "max_health" in enemy_data:
			enemy.max_health = enemy_data.max_health
		
		# If it's a humanoid enemy, set the humanoid_type
		if enemy_type.ends_with("enemy_humanoid.tscn") and "humanoid_type" in enemy_data:
			enemy.humanoid_type = enemy_data.humanoid_type
		if "move_speed" in enemy_data:
			enemy.move_speed = enemy_data.move_speed
		if "damage" in enemy_data:
			enemy.damage = enemy_data.damage
		
		# CRITICAL: Set collision mask BEFORE adding to scene to ensure enemies collide with walls (layer 7 = bit 6 = 64)
		# This overrides any collision_mask set in the scene file
		enemy.collision_mask = 1 | 2 | 64 # Collide with players (layer 1), objects (layer 2), and walls (layer 7 = bit 6 = 64)
		
		# Add to scene tree AFTER setting authority and stats
		entities_node.add_child(enemy)
		enemy.global_position = enemy_data.position
		
		# CRITICAL: Re-verify collision_mask AFTER adding to scene (in case scene file overrides it)
		# This ensures enemies always collide with walls (layer 7 = bit 6 = 64)
		if enemy.collision_mask != (1 | 2 | 64):
			LogManager.log("GameWorld: WARNING - Enemy " + str(enemy.name) + " collision_mask was " + str(enemy.collision_mask) + ", expected " + str(1 | 2 | 64) + "! Correcting...", LogManager.CATEGORY_DUNGEON)
			enemy.collision_mask = 1 | 2 | 64
		
		# Verify authority is still set
		if multiplayer.has_multiplayer_peer():
			var auth_after = enemy.get_multiplayer_authority()
			print("GameWorld: Enemy authority AFTER add_child (RPC): ", enemy.name, " authority: ", auth_after, " is_authority: ", enemy.is_multiplayer_authority())
			if auth_after != 1:
				push_error("GameWorld: ERROR - Enemy authority lost after add_child in RPC! Expected 1, got ", auth_after)
		
		LogManager.log("GameWorld: Client spawned dungeon enemy via RPC: " + str(enemy.name) + " at " + str(enemy_data.position) + " (type: " + str(enemy_type) + ", authority: " + str(enemy.get_multiplayer_authority()) + ")", LogManager.CATEGORY_DUNGEON)

func _sync_existing_chest_states_to_client(client_peer_id: int, retry_count: int = 0):
	# Sync chest open states to new client with retry logic
	if not is_inside_tree():
		return
	
	if not multiplayer.is_server():
		return
	
	# Check if peer is recognized before sending RPC
	if not _check_peer_recognized(client_peer_id):
		if retry_count < 15: # Reduced from 30
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_sync_existing_chest_states_to_client(client_peer_id, retry_count + 1)
			)
			return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	var opened_chest_count = 0
	for child in entities_node.get_children():
		if child.is_in_group("interactable_object"):
			if "object_type" in child and child.object_type == "Chest":
				if "is_chest_opened" in child and child.is_chest_opened:
					# Found an opened chest - sync it to the client
					var obj_name = child.name
					_sync_chest_state.rpc_id(client_peer_id, obj_name, true)
					opened_chest_count += 1
					print("GameWorld: Syncing opened chest ", obj_name, " to client ", client_peer_id)
	
	print("GameWorld: Synced ", opened_chest_count, " opened chests to client ", client_peer_id)

func _sync_existing_trap_states_to_client(client_peer_id: int, retry_count: int = 0):
	# Sync trap states (detected/disarmed) to new client with retry logic
	if not is_inside_tree():
		return
	
	if not multiplayer.is_server():
		return
	
	# Check if peer is recognized before sending RPC
	if not _check_peer_recognized(client_peer_id):
		if retry_count < 15: # Reduced from 30
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_sync_existing_trap_states_to_client(client_peer_id, retry_count + 1)
			)
			return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	var synced_trap_count = 0
	for child in entities_node.get_children():
		if child.is_in_group("trap"):
			# Found a trap - sync its state (detected/disarmed) to the client
			var trap_name = child.name
			var is_detected = child.is_detected if "is_detected" in child else false
			var is_disarmed = child.is_disarmed if "is_disarmed" in child else false
			_sync_trap_state_by_name.rpc_id(client_peer_id, trap_name, is_detected, is_disarmed)
			synced_trap_count += 1
			print("GameWorld: Syncing trap ", trap_name, " state (detected: ", is_detected, ", disarmed: ", is_disarmed, ") to client ", client_peer_id)
	
	print("GameWorld: Synced ", synced_trap_count, " trap states to client ", client_peer_id)

@rpc("any_peer", "reliable")
func _request_trap_disarm(trap_name: String):
	# Joiner requests server to sync trap disarm (host never receives _sync_trap_state_by_name)
	if not multiplayer.is_server():
		return
	if not is_inside_tree():
		return
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	var trap = entities_node.get_node_or_null(trap_name)
	if not trap or not trap.is_in_group("trap"):
		return
	# Apply disarm locally on host, then broadcast to all clients
	_apply_trap_state_by_name(trap_name, true, true)
	_sync_trap_state_by_name.rpc(trap_name, true, true)
	LogManager.log("GameWorld: Host applied trap disarm for " + trap_name + " (requested by joiner)", LogManager.CATEGORY_GAMEPLAY)

func _apply_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed: bool) -> void:
	# Apply trap state locally (used by host when relaying joiner disarm)
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	var trap = entities_node.get_node_or_null(trap_name)
	if not trap or not trap.is_in_group("trap"):
		return
	if "is_detected" in trap:
		trap.is_detected = is_detected
	if "is_disarmed" in trap:
		trap.is_disarmed = is_disarmed
	if is_detected and "sprite" in trap and trap.sprite:
		trap.sprite.modulate.a = 1.0
	if is_disarmed:
		if "sprite" in trap and trap.sprite:
			trap.sprite.modulate = Color(0.5, 0.5, 0.5, 0.5)
		if "activation_area" in trap and trap.activation_area:
			trap.activation_area.monitoring = false
		
		# Grant EXP to all players when trap is disarmed (only on server)
		if multiplayer.is_server():
			var trap_exp_reward = 8.0 # EXP reward for disarming a trap
			var all_players = get_tree().get_nodes_in_group("player")
			var valid_players = []
			for player in all_players:
				if is_instance_valid(player) and player.character_stats:
					valid_players.append(player)
			
			if valid_players.size() > 0:
				# Split EXP evenly among all players
				var exp_per_player = trap_exp_reward / valid_players.size()
				for player in valid_players:
					player.character_stats.add_xp(exp_per_player)
					LogManager.log("Trap disarmed: granted " + str(exp_per_player) + " EXP to " + str(player.name) + " (shared from " + str(trap_exp_reward) + " total)", LogManager.CATEGORY_DUNGEON)
					
					# Sync EXP to client if this player belongs to a client
					var player_peer_id = player.get_multiplayer_authority()
					if player_peer_id != 0 and player_peer_id != multiplayer.get_unique_id() and player.has_method("_sync_stats_update"):
						var coins = player.character_stats.coin if "coin" in player.character_stats else 0
						var xp = player.character_stats.xp if "xp" in player.character_stats else 0.0
						player._sync_stats_update.rpc_id(player_peer_id, player.character_stats.kills, coins, xp)
				
				# Show floating EXP text at trap position and sync to all clients
				if is_instance_valid(trap) and "global_position" in trap:
					# Show locally first
					var game_world = get_tree().get_first_node_in_group("game_world")
					if game_world and game_world.has_method("_show_exp_number_at_position"):
						game_world._show_exp_number_at_position(exp_per_player, trap.global_position)
					# Sync to all clients via game_world
					if game_world and game_world.has_method("_sync_exp_text_at_position") and multiplayer.has_multiplayer_peer():
						game_world._sync_exp_text_at_position.rpc(exp_per_player, trap.global_position)

@rpc("authority", "reliable")
func _sync_trap_state_by_name(trap_name: String, is_detected: bool, is_disarmed: bool):
	# Client receives trap state sync by name (avoids node path RPC errors)
	if not is_inside_tree():
		return
	
	if not multiplayer.is_server():
		_apply_trap_state_by_name(trap_name, is_detected, is_disarmed)
		print("GameWorld: Client received trap state sync for ", trap_name, " - detected: ", is_detected, ", disarmed: ", is_disarmed)

func _sync_broken_objects_to_client(client_peer_id: int, retry_count: int = 0):
	# Sync broken interactable objects to new client with retry logic
	# Check if node is still valid and in tree
	if not is_inside_tree():
		LogManager.log("GameWorld: Node not in tree, ignoring broken objects sync RPC", LogManager.CATEGORY_NETWORK)
		return
	
	if not multiplayer.is_server():
		return
	
	# Check if peer is recognized before sending RPC
	if not _check_peer_recognized(client_peer_id):
		if retry_count < 15: # Reduced from 30
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_sync_broken_objects_to_client(client_peer_id, retry_count + 1)
			)
			return
	
	if broken_objects.is_empty():
		LogManager.log("GameWorld: No broken objects to sync to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK)
		return
	
	# Convert dictionary to array of indices
	var broken_indices = []
	for obj_index in broken_objects.keys():
		broken_indices.append(obj_index)
	
	_sync_broken_objects.rpc_id(client_peer_id, broken_indices, true)
	LogManager.log("GameWorld: Syncing " + str(broken_indices.size()) + " broken objects to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK)

@rpc("authority", "reliable")
func _sync_broken_objects(broken_indices: Array, silent: bool = false):
	# Client receives list of broken object indices
	# Check if this node is still valid and in tree
	if not is_inside_tree():
		LogManager.log("GameWorld: Node not in tree, ignoring broken objects sync RPC", LogManager.CATEGORY_NETWORK)
		return
	
	if not multiplayer.is_server():
		# Store broken objects locally
		for obj_index in broken_indices:
			broken_objects[obj_index] = true
		
		# Break the objects that are already spawned
		var entities_node = get_node_or_null("Entities")
		if entities_node:
			for obj_index in broken_indices:
				var obj_name = "InteractableObject_%d" % obj_index
				var obj = entities_node.get_node_or_null(obj_name)
				if obj and obj.has_method("_sync_break"):
					# Use call_deferred to ensure object is fully initialized
					obj.call_deferred("_sync_break", silent)
					LogManager.log("GameWorld: Client breaking object " + obj_name + " (was already broken before joining)", LogManager.CATEGORY_NETWORK)
		
		LogManager.log("GameWorld: Client received " + str(broken_indices.size()) + " broken object indices", LogManager.CATEGORY_NETWORK)

@rpc("authority", "reliable")
func _sync_chest_state(obj_name: String, is_opened: bool):
	# Client receives chest state sync
	if not multiplayer.is_server():
		var entities_node = get_node_or_null("Entities")
		if entities_node:
			var chest = entities_node.get_node_or_null(obj_name)
			if chest and "is_chest_opened" in chest:
				chest.is_chest_opened = is_opened
				if chest.has_method("_sync_chest_open"):
					# Call the sync function to update visuals (loot type doesn't matter for visual sync)
					chest._sync_chest_open("coin")
				elif "sprite" in chest and "chest_opened_frame" in chest:
					if chest.sprite and chest.chest_opened_frame >= 0:
						chest.sprite.frame = chest.chest_opened_frame
				print("GameWorld: Client received chest state sync for ", obj_name, " - opened: ", is_opened)

func _sync_existing_torches_to_client(client_peer_id: int, retry_count: int = 0):
	# Sync existing torches to newly connected client with retry logic
	# Check if node is still valid and in tree
	if not is_inside_tree():
		LogManager.log("GameWorld: Node not in tree, ignoring torch sync RPC", LogManager.CATEGORY_NETWORK)
		return
	
	if not multiplayer.is_server():
		return
	
	# Check if peer is recognized before sending RPC
	if not _check_peer_recognized(client_peer_id):
		if retry_count < 15: # Reduced from 30
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_sync_existing_torches_to_client(client_peer_id, retry_count + 1)
			)
			return
		else:
			# Max retries reached - give up, don't try RPC (will fail anyway)
			LogManager.log("GameWorld: Max retries reached for torch sync to peer " + str(client_peer_id) + ", giving up", LogManager.CATEGORY_NETWORK)
			return
	
	# CRITICAL: Double-check that peer is in get_peers() before calling RPC
	# This is required because Godot's rpc_id() will fail if peer isn't in get_peers()
	var peers = multiplayer.get_peers()
	if client_peer_id not in peers:
		LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() before torch sync RPC, retrying...", LogManager.CATEGORY_NETWORK)
		if retry_count < 15: # Reduced from 30
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_sync_existing_torches_to_client(client_peer_id, retry_count + 1)
			)
		return
	
	if dungeon_data.is_empty() or not dungeon_data.has("torches"):
		return
	
	var torches = dungeon_data.torches
	print("GameWorld: Syncing ", torches.size(), " torches to client ", client_peer_id)
	
	for torch_data in torches:
		_sync_torch_spawn.rpc_id(client_peer_id, torch_data.position, torch_data.rotation)

@rpc("authority", "reliable")
func _sync_torch_spawn(torch_position: Vector2, torch_rotation: float):
	# Clients spawn torch when server tells them to
	if not multiplayer.is_server():
		# CRITICAL: Check if torch already exists at this position to avoid duplicates
		var entities_node = get_node_or_null("Entities")
		if not entities_node:
			return
		
		# Check if a torch already exists at this position (within 1 pixel tolerance)
		for child in entities_node.get_children():
			if child.is_in_group("torch"):
				if child.global_position.distance_to(torch_position) < 1.0:
					# Torch already exists at this position - skip spawning duplicate
					return
		
		var torch_scene = preload("res://scenes/torch_wall.tscn")
		if not torch_scene:
			return
		
		var torch = torch_scene.instantiate()
		torch.name = "Torch_%d" % entities_node.get_child_count()
		entities_node.add_child(torch)
		torch.global_position = torch_position
		torch.rotation_degrees = torch_rotation
		print("Client spawned torch at ", torch_position, " with rotation ", torch_rotation)

func _sync_existing_door_states_to_client(client_peer_id: int, retry_count: int = 0):
	# Sync door states to new client with retry logic
	if not is_inside_tree():
		return
	
	if not multiplayer.is_server():
		return
	
	# Check if peer is recognized before sending RPC
	if not _check_peer_recognized(client_peer_id):
		if retry_count < 20: # Reduced from 60
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_sync_existing_door_states_to_client(client_peer_id, retry_count + 1)
			)
			return
		else:
			# Max retries reached - give up, don't try RPC (will fail anyway)
			LogManager.log("GameWorld: Max retries reached for door state sync to peer " + str(client_peer_id) + ", giving up", LogManager.CATEGORY_NETWORK)
			return
	
	# CRITICAL: Double-check that peer is in get_peers() before calling RPC
	var peers = multiplayer.get_peers()
	if client_peer_id not in peers:
		LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() before door sync RPC, retrying...", LogManager.CATEGORY_NETWORK)
		if retry_count < 20: # Reduced from 60
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_sync_existing_door_states_to_client(client_peer_id, retry_count + 1)
			)
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	var door_count = 0
	for child in entities_node.get_children():
		if child.is_in_group("blocking_door") or child.has_method("_sync_door_open"):
			# Get door state
			var door_state = {
				"is_closed": child.is_closed if "is_closed" in child else true,
				"puzzle_solved": child.puzzle_solved if "puzzle_solved" in child else false,
				"key_used": child.key_used if "key_used" in child else false,
				"position": child.position if "position" in child else Vector2.ZERO,
				"closed_position": child.closed_position if "closed_position" in child else Vector2.ZERO,
				"open_offset": child.open_offset if "open_offset" in child else Vector2.ZERO
			}
			_sync_door_state.rpc_id(client_peer_id, child.name, door_state)
			door_count += 1
			LogManager.log("GameWorld: Syncing door state for " + child.name + " to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK)
	
	LogManager.log("GameWorld: Synced " + str(door_count) + " door states to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK)

@rpc("authority", "reliable")
func _sync_door_state(door_name: String, door_state: Dictionary):
	# Client receives door state sync
	# Check if node is still valid and in tree
	if not is_inside_tree():
		LogManager.log("GameWorld: Node not in tree, ignoring door state sync RPC for " + door_name, LogManager.CATEGORY_NETWORK)
		return
	
	if not multiplayer.is_server():
		var entities_node = get_node_or_null("Entities")
		if entities_node:
			var door = entities_node.get_node_or_null(door_name)
			if door:
				# Apply door state
				if "is_closed" in door_state:
					door.is_closed = door_state.is_closed
				if "puzzle_solved" in door_state:
					door.puzzle_solved = door_state.puzzle_solved
				if "key_used" in door_state:
					door.key_used = door_state.key_used
				
				# Set position based on state
				if "closed_position" in door_state and "open_offset" in door_state:
					if door_state.is_closed:
						door.position = door_state.closed_position
						door.global_position = door_state.closed_position
						door.set_collision_layer_value(7, true)
					else:
						door.position = door_state.closed_position + door_state.open_offset
						door.global_position = door_state.closed_position + door_state.open_offset
						door.set_collision_layer_value(7, false)
				
				LogManager.log("GameWorld: Client received door state sync for " + door_name, LogManager.CATEGORY_NETWORK)

func _sync_defeated_enemies_to_client(client_peer_id: int):
	# Sync defeated enemies to new client (so they don't spawn)
	# Check if node is still valid and in tree
	if not is_inside_tree():
		LogManager.log("GameWorld: Node not in tree, ignoring defeated enemies sync RPC", LogManager.CATEGORY_NETWORK)
		return
	
	if not multiplayer.is_server():
		return
	
	if defeated_enemies.is_empty():
		LogManager.log("GameWorld: No defeated enemies to sync to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK)
		return
	
	# Convert dictionary to array of indices
	var defeated_indices = []
	for enemy_index in defeated_enemies.keys():
		defeated_indices.append(enemy_index)
	
	_sync_defeated_enemies.rpc_id(client_peer_id, defeated_indices)
	LogManager.log("GameWorld: Syncing " + str(defeated_indices.size()) + " defeated enemies to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK)

@rpc("authority", "reliable")
func _sync_defeated_enemies(defeated_indices: Array):
	# Client receives list of defeated enemy indices
	if not is_inside_tree():
		return
	
	if not multiplayer.is_server():
		# Store defeated enemies locally
		for enemy_index in defeated_indices:
			defeated_enemies[enemy_index] = true
		
		LogManager.log("GameWorld: Client received " + str(defeated_indices.size()) + " defeated enemy indices", LogManager.CATEGORY_NETWORK)

func _sync_existing_loot_to_client(client_peer_id: int):
	# Sync existing loot on the ground to new client
	# Check if node is still valid and in tree
	if not is_inside_tree():
		LogManager.log("GameWorld: Node not in tree, ignoring loot sync RPC", LogManager.CATEGORY_NETWORK)
		return
	
	if not multiplayer.is_server():
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	var loot_count = 0
	for child in entities_node.get_children():
		if child.is_in_group("loot"):
			# Get loot data including current velocity and position_z for accurate sync
			var current_velocity = child.velocity if "velocity" in child else Vector2.ZERO
			var current_velocity_z = child.velocity_z if "velocity_z" in child else 0.0
			var _current_position_z = child.position_z if "position_z" in child else 0.0
			var _is_airborne = child.is_airborne if "is_airborne" in child else false
			
			var loot_data = {
				"position": child.global_position,
				"loot_type": child.loot_type if "loot_type" in child else 0,
				"loot_id": child.get_meta("loot_id") if child.has_meta("loot_id") else -1
			}
			
			# For item loot, include item data
			if child.loot_type == child.LootType.ITEM and "item" in child and child.item:
				var item = child.item
				# Use Item.save() method to get all item data as dictionary
				var item_data = item.save()
				# Sync with current velocity/position_z if still bouncing, otherwise use zero
				_sync_item_loot_spawn.rpc_id(client_peer_id, loot_data.position, item_data, current_velocity, current_velocity_z, loot_data.loot_id)
			else:
				# Sync with current velocity/position_z if still bouncing, otherwise use zero
				_sync_loot_spawn.rpc_id(client_peer_id, loot_data.position, loot_data.loot_type, current_velocity, current_velocity_z, loot_data.loot_id)
			
			loot_count += 1
	
	LogManager.log("GameWorld: Synced " + str(loot_count) + " loot items to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK)

func _sync_activated_switches_to_client(client_peer_id: int, retry_count: int = 0):
	# Sync activated floor switches to new client with retry logic
	# Check if node is still valid and in tree
	if not is_inside_tree():
		LogManager.log("GameWorld: Node not in tree, ignoring switch sync RPC", LogManager.CATEGORY_NETWORK)
		return
	
	if not multiplayer.is_server():
		return
	
	# Check if peer is recognized before sending RPC
	if not _check_peer_recognized(client_peer_id):
		var max_retries = 20 # Reduced from 30/60
		if retry_count < max_retries:
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_sync_activated_switches_to_client(client_peer_id, retry_count + 1)
			)
			return
		else:
			LogManager.log("GameWorld: Max retries reached for switch sync to peer " + str(client_peer_id) + ", giving up", LogManager.CATEGORY_NETWORK)
			return
	
	# CRITICAL: Double-check that peer is in get_peers() before calling RPC
	var peers = multiplayer.get_peers()
	if client_peer_id not in peers:
		LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() before switch sync RPC, retrying...", LogManager.CATEGORY_NETWORK)
		var max_retries = 20 # Reduced from 30/60
		if retry_count < max_retries:
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_sync_activated_switches_to_client(client_peer_id, retry_count + 1)
			)
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	var switch_count = 0
	for child in entities_node.get_children():
		if child.is_in_group("floor_switch"):
			if "is_activated" in child and child.is_activated:
				_sync_switch_state.rpc_id(client_peer_id, child.name, true)
				switch_count += 1
				LogManager.log("GameWorld: Syncing activated switch " + child.name + " to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK)
	
	LogManager.log("GameWorld: Synced " + str(switch_count) + " activated switches to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK)

@rpc("authority", "reliable")
func _sync_switch_state(switch_name: String, is_activated: bool):
	# Client receives switch state sync
	if not multiplayer.is_server():
		var entities_node = get_node_or_null("Entities")
		if entities_node:
			var switch_node = entities_node.get_node_or_null(switch_name)
			if switch_node and "is_activated" in switch_node:
				switch_node.is_activated = is_activated
				if switch_node.has_method("_update_visual"):
					switch_node._update_visual()
				LogManager.log("GameWorld: Client received switch state sync for " + switch_name + " - activated: " + str(is_activated), LogManager.CATEGORY_NETWORK)

func _sync_interactable_object_positions_to_client(client_peer_id: int, retry_count: int = 0):
	# Sync current positions of interactable objects (boxes that were moved/thrown) with retry logic
	# Check if node is still valid and in tree
	if not is_inside_tree():
		LogManager.log("GameWorld: Node not in tree, ignoring interactable object position sync RPC", LogManager.CATEGORY_NETWORK)
		return
	
	if not multiplayer.is_server():
		return
	
	# Check if peer is recognized before sending RPC
	if not _check_peer_recognized(client_peer_id):
		# Reduced retries - don't retry so many times for host
		var max_retries = 20 # Reduced from 30/60
		if retry_count < max_retries:
			if retry_count % 10 == 0: # Log every 10th retry to reduce spam
				LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not ready for interactable object sync, retrying (" + str(retry_count + 1) + "/" + str(max_retries) + ")...", LogManager.CATEGORY_NETWORK)
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_sync_interactable_object_positions_to_client(client_peer_id, retry_count + 1)
			)
			return
		else:
			# After max retries, give up - don't try to send RPC (will fail anyway on web)
			LogManager.log("GameWorld: Max retries reached for interactable object position sync for peer " + str(client_peer_id) + ", giving up", LogManager.CATEGORY_NETWORK)
			return
	
	# CRITICAL: Double-check that peer is in get_peers() before calling RPC
	var peers = multiplayer.get_peers()
	if client_peer_id not in peers:
		LogManager.log("GameWorld: Peer " + str(client_peer_id) + " not in get_peers() before interactable object sync RPC, retrying...", LogManager.CATEGORY_NETWORK)
		var max_retries = 20 # Reduced from 30/60
		if retry_count < max_retries:
			get_tree().create_timer(0.2).timeout.connect(func():
				if is_inside_tree() and multiplayer.is_server():
					_sync_interactable_object_positions_to_client(client_peer_id, retry_count + 1)
			)
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	# Wait for dungeon_data to be available
	if dungeon_data.is_empty() or not dungeon_data.has("interactable_objects"):
		LogManager.log("GameWorld: Dungeon data not ready for interactable object position sync", LogManager.CATEGORY_NETWORK)
		return
	
	var object_count = 0
	for child in entities_node.get_children():
		if child.is_in_group("interactable_object") and child.has_meta("object_index"):
			# Skip broken objects (they're synced separately)
			var obj_index = child.get_meta("object_index")
			if broken_objects.has(obj_index):
				continue
			
			# Sync all interactable objects (including chests and pillars)
			var throw_vel = child.throw_velocity if "throw_velocity" in child else Vector2.ZERO
			var z_pos = child.position_z if "position_z" in child else 0.0
			var z_vel = child.velocity_z if "velocity_z" in child else 0.0
			var airborne = child.is_airborne if "is_airborne" in child else false
			var obj_name = child.name
			_sync_interactable_object_state.rpc_id(client_peer_id, obj_name, child.global_position, throw_vel, z_pos, z_vel, airborne)
			object_count += 1
	
	LogManager.log("GameWorld: Synced " + str(object_count) + " interactable object positions to client " + str(client_peer_id), LogManager.CATEGORY_NETWORK)

@rpc("authority", "reliable")
func _sync_interactable_object_state(obj_name: String, pos: Vector2, vel: Vector2, z_pos: float, z_vel: float, airborne: bool):
	# Client receives interactable object state sync (uses GameWorld to avoid node path issues)
	if not is_inside_tree():
		return
	
	if not multiplayer.is_server():
		var entities_node = get_node_or_null("Entities")
		if not entities_node:
			return
		
		var obj = entities_node.get_node_or_null(obj_name)
		var state = {
			"pos": pos,
			"vel": vel,
			"z_pos": z_pos,
			"z_vel": z_vel,
			"airborne": airborne
		}
		if obj:
			_apply_interactable_state(obj, state)
		else:
			# Store for later if object isn't spawned yet
			pending_interactable_states[obj_name] = state

@rpc("authority", "reliable")
func _sync_player_position_by_name(player_name: String, pos: Vector2):
	# Client receives player position sync by name (avoids node path RPC errors)
	if not is_inside_tree():
		return
	
	if not multiplayer.is_server():
		var entities_node = get_node_or_null("Entities")
		if not entities_node:
			return
		
		var player = entities_node.get_node_or_null(NodePath(str(player_name)))
		if player and player.has_method("_sync_teleport_position"):
			# Joiner's own player during fall: skip teleport so fall-from-sky plays; we're already visible
			var self_falling = player.get("is_local_player") and player.is_local_player and player.get("spawn_landing") and player.spawn_landing
			if not self_falling:
				player._sync_teleport_position(pos)
				player.visible = true

@rpc("authority", "reliable")
func _ensure_player_visible_by_name(player_name: String):
	# Visibility-only sync (no position update). Used for delayed joiner visibility retry.
	if not is_inside_tree():
		return
	if multiplayer.is_server():
		return
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	var player = entities_node.get_node_or_null(NodePath(str(player_name)))
	if player:
		player.visible = true
		# Joiner's own player: also show lights (they were hidden in _ready)
		if player.get("is_local_player") and player.is_local_player:
			var cl = player.get("cone_light")
			if cl:
				cl.visible = true
			var pl = player.get("point_light")
			if pl:
				pl.visible = true

@rpc("authority", "reliable")
func _sync_door_open_by_name(door_name: String):
	# Client receives door open sync by name (avoids node path RPC errors)
	if not is_inside_tree():
		return
	
	if not multiplayer.is_server():
		var entities_node = get_node_or_null("Entities")
		if not entities_node:
			return
		
		var door = entities_node.get_node_or_null(door_name)
		if not door:
			door = _find_door_fallback(entities_node, door_name)
		if door and door.has_method("_sync_door_open"):
			door._sync_door_open()
		else:
			# Store for later if door isn't spawned yet
			if not pending_door_states.has(door_name):
				pending_door_states[door_name] = {}
			pending_door_states[door_name]["open"] = true

@rpc("authority", "reliable")
func _sync_door_close_by_name(door_name: String):
	# Client receives door close sync by name (avoids node path RPC errors)
	if not is_inside_tree():
		return
	
	if not multiplayer.is_server():
		var entities_node = get_node_or_null("Entities")
		if not entities_node:
			return
		
		var door = entities_node.get_node_or_null(door_name)
		if not door:
			door = _find_door_fallback(entities_node, door_name)
		if door and door.has_method("_sync_door_close"):
			door._sync_door_close()
		else:
			# Store for later if door isn't spawned yet
			if not pending_door_states.has(door_name):
				pending_door_states[door_name] = {}
			pending_door_states[door_name]["close"] = true

@rpc("authority", "reliable")
func _sync_door_puzzle_solved_by_name(door_name: String, is_solved: bool):
	# Client receives door puzzle solved sync by name
	if not is_inside_tree():
		return
	
	if not multiplayer.is_server():
		var entities_node = get_node_or_null("Entities")
		if not entities_node:
			return
		
		var door = entities_node.get_node_or_null(door_name)
		if not door:
			door = _find_door_fallback(entities_node, door_name)
		if door and door.has_method("_sync_puzzle_solved"):
			door._sync_puzzle_solved(is_solved)
		else:
			# Store for later if door isn't spawned yet
			if not pending_door_states.has(door_name):
				pending_door_states[door_name] = {}
			pending_door_states[door_name]["puzzle_solved"] = is_solved

func _apply_pending_door_state(door: Node):
	if not door:
		return
	
	var state = null
	var state_key = door.name
	
	# Try to find state by door name first
	if pending_door_states.has(door.name):
		state = pending_door_states[door.name]
		state_key = door.name
	else:
		# Try fallback key if name was auto-renamed
		if door.has_meta("door_index"):
			var fallback_key = "BlockingDoor_%d" % door.get_meta("door_index")
			if pending_door_states.has(fallback_key):
				state = pending_door_states[fallback_key]
				state_key = fallback_key
		# Try position-based matching as last resort
		# position is a built-in property of Node2D, so we can access it directly
		if not state and door is Node2D:
			var door_pos = door.position
			for key in pending_door_states.keys():
				var test_state = pending_door_states[key]
				if test_state.has("position"):
					var state_pos = test_state.position
					if door_pos.distance_to(state_pos) < 1.0: # Same position
						state = test_state
						state_key = key
						print("GameWorld: Matched door state by position for ", door.name, " (matched key: ", key, ")")
						break
	
	if not state:
		print("GameWorld: No pending door state found for ", door.name)
		return
	
	print("GameWorld: Applying door state to ", door.name, ": open=", state.get("open", false), ", close=", state.get("close", false), ", puzzle_solved=", state.get("puzzle_solved", false))
	
	# Apply door state
	if state.has("open") and state.open and door.has_method("_sync_door_open"):
		door._sync_door_open()
		print("GameWorld: Applied open state to door ", door.name)
	if state.has("close") and state.close and door.has_method("_sync_door_close"):
		door._sync_door_close()
		print("GameWorld: Applied close state to door ", door.name)
	if state.has("puzzle_solved") and door.has_method("_sync_puzzle_solved"):
		door._sync_puzzle_solved(state.puzzle_solved)
		print("GameWorld: Applied puzzle_solved=", state.puzzle_solved, " to door ", door.name)
	
	# For KeyDoors, also restore key_used state if present
	# Check if door has key_used property using "key_used" in door (works for script properties)
	if state.has("key_used") and "key_used" in door:
		door.key_used = state.key_used
		print("GameWorld: Applied key_used=", state.key_used, " to door ", door.name)
	
	# Remove state after applying
	pending_door_states.erase(state_key)

@rpc("authority", "reliable")
func _sync_chest_open_by_name(chest_name: String, loot_type_str: String, player_peer_id: int, item_data: Dictionary = {}):
	# Client receives chest open sync by name (avoids node path RPC errors)
	if not is_inside_tree():
		return
	
	if not multiplayer.is_server():
		var entities_node = get_node_or_null("Entities")
		if not entities_node:
			return
		
		var chest = entities_node.get_node_or_null(chest_name)
		if not chest and chest_name.begins_with("InteractableObject_"):
			# Fallback: try to find by object_index if name doesn't match
			var index_str = chest_name.substr(20)
			if index_str.is_valid_int():
				var obj_index = index_str.to_int()
				for child in entities_node.get_children():
					if child.is_in_group("interactable_object") and child.has_meta("object_index"):
						if child.get_meta("object_index") == obj_index:
							chest = child
							break
		
		if chest and chest.has_method("_sync_chest_open"):
			chest._sync_chest_open(loot_type_str, player_peer_id, item_data)
		else:
			# Store for later if chest isn't spawned yet
			pending_chest_opens[chest_name] = {
				"loot_type": loot_type_str,
				"player_peer_id": player_peer_id,
				"item_data": item_data
			}

func _apply_interactable_state(obj: Node, state: Dictionary):
	# Apply interactable state to an object (client only)
	if not obj:
		return
	if obj.has_method("_sync_box_state"):
		obj._sync_box_state(state.pos, state.vel, state.z_pos, state.z_vel, state.airborne)

func _find_door_fallback(entities_node: Node, door_name: String) -> Node:
	if not entities_node:
		return null
	if door_name.begins_with("BlockingDoor_"):
		var index_str = door_name.substr(13)
		if index_str.is_valid_int():
			var door_index = index_str.to_int()
			for child in entities_node.get_children():
				if child.is_in_group("blocking_door"):
					if child.has_meta("door_index") and child.get_meta("door_index") == door_index:
						return child
					if child.name.begins_with("BlockingDoor_%d" % door_index):
						return child
	# Fallback: try any door that starts with the same prefix
	for child in entities_node.get_children():
		if child.is_in_group("blocking_door") and child.name.begins_with(door_name):
			return child
	return null

func _clear_level():
	# Clear previous level data
	LogManager.log("GameWorld: Clearing previous level...", LogManager.CATEGORY_DUNGEON)
	
	# Reset level-scoped sync state (server + clients)
	broken_objects.clear()
	defeated_enemies.clear()
	opened_chests.clear()
	activated_switches.clear()
	door_states.clear()
	pending_interactable_states.clear()
	pending_door_states.clear()
	pending_chest_opens.clear()
	
	# Clear tilemap layers - ensure we get references from scene if they exist
	var env_node = get_node_or_null("Environment")
	if env_node:
		var layer0 = env_node.get_node_or_null("DungeonLayer0")
		if layer0:
			layer0.clear()
		var layer_above = env_node.get_node_or_null("TileMapLayerAbove")
		if layer_above:
			layer_above.clear()
	
	# Also clear via stored references if they exist
	if dungeon_tilemap_layer:
		dungeon_tilemap_layer.clear()
	if dungeon_tilemap_layer_above:
		dungeon_tilemap_layer_above.clear()
	
	# Remove all entities EXCEPT players (enemies, torches, loot, etc.)
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		var entities_to_remove = []
		for child in entities_node.get_children():
			# Don't free players - they persist across levels
			if not child.is_in_group("player"):
				entities_to_remove.append(child)
		
		# Free all entities immediately (not queue_free) to ensure they're gone before spawning new ones
		for entity in entities_to_remove:
			if is_instance_valid(entity):
				entity.free() # Use free() instead of queue_free() for immediate removal
	
	# Remove stairs area
	var stairs_area = get_node_or_null("StairsArea")
	if stairs_area:
		stairs_area.free() # Use free() for immediate removal
	
	# Remove entrance area
	var entrance_area = get_node_or_null("EntranceArea")
	if entrance_area:
		entrance_area.free() # Use free() for immediate removal
	
	# Remove entrance gate door (under Entities; reuse entities_node from above)
	var entrance_gate = entities_node.get_node_or_null("EntranceGateDoor") if entities_node else null
	if entrance_gate:
		entrance_gate.free() # Use free() for immediate removal
	
	# Clear dungeon data (but keep it for now until new one is generated)
	# dungeon_data = {}  # Don't clear yet, wait for new generation
	
	# Clear fog of war
	seen_by_player.clear()
	combined_seen = PackedInt32Array()
	if fog_node and is_instance_valid(fog_node):
		fog_node.queue_free()
		fog_node = null
	
	LogManager.log("GameWorld: Previous level cleared", LogManager.CATEGORY_DUNGEON)

func _hide_all_players():
	# Hide all players and remove collision before generating new level
	var players = get_tree().get_nodes_in_group("player")
	for player in players:
		if player:
			# Store original collision layer
			if not player.has_meta("original_collision_layer"):
				player.set_meta("original_collision_layer", player.collision_layer)
			# Remove collision layer
			player.collision_layer = 0
			# Hide player
			player.visible = false
			LogManager.log("GameWorld: Hid player " + str(player.name) + " and removed collision", LogManager.CATEGORY_DUNGEON)

func _restore_all_players():
	# Restore all players (make visible and restore collision) after placement
	var players = get_tree().get_nodes_in_group("player")
	for player in players:
		if player:
			# Restore collision layer
			if player.has_meta("original_collision_layer"):
				player.collision_layer = player.get_meta("original_collision_layer")
				player.remove_meta("original_collision_layer")
			else:
				# Default to layer 1 (players)
				player.collision_layer = 1
			# Make player visible
			player.visible = true
			LogManager.log("GameWorld: Restored player " + str(player.name) + " (visible and collision restored)", LogManager.CATEGORY_DUNGEON)

func _schedule_restore_all_players(delay: float = 0.6):
	# Delay restoring player visibility/collision to allow positioning to finish
	if not is_inside_tree():
		return
	get_tree().create_timer(delay).timeout.connect(func():
		if is_inside_tree():
			_restore_all_players()
	)

func _position_new_joiner_and_sync_positions(new_peer_id: int):
	# Position a new joiner and sync all player positions correctly
	# This should ONLY be called when a new client joins
	# DO NOT move existing players - only position the new joiner
	if not multiplayer.is_server():
		return
	
	if dungeon_data.is_empty() or not dungeon_data.has("start_room"):
		return
	
	var start_room = dungeon_data.start_room
	_update_spawn_points(start_room)
	
	# Find all players, sorted by peer_id and local_index for consistent spawn point assignment
	var players = get_tree().get_nodes_in_group("player")
	var sorted_players = []
	for player in players:
		if player.get("peer_id") != null:
			sorted_players.append(player)
	sorted_players.sort_custom(func(a, b):
		if a.peer_id != b.peer_id:
			return a.peer_id < b.peer_id
		var a_index = a.get("local_player_index") if a.get("local_player_index") != null else 0
		var b_index = b.get("local_player_index") if b.get("local_player_index") != null else 0
		return a_index < b_index
	)
	
	# Find players belonging to the new joiner
	var new_joiner_players = []
	for player in sorted_players:
		if player.peer_id == new_peer_id:
			new_joiner_players.append(player)
	
	# Count how many spawn points are already used by existing players (with lower peer_id)
	var used_spawn_index = 0
	for player in sorted_players:
		if player.peer_id < new_peer_id:
			used_spawn_index += 1
	
	# Check if entrance exists (level > 1) - joiners should use entrance if available
	var has_entrance = not dungeon_data.is_empty() and dungeon_data.has("entrance") and not dungeon_data.entrance.is_empty()
	
	if has_entrance and current_level > 1:
		# Position new joiner in entrance and start walk-out sequence (same as level transition)
		# CRITICAL: Disable falling cutscene for joiners in entrance (they should walk out, not fall)
		for player in new_joiner_players:
			if is_instance_valid(player):
				player.spawn_landing = false
				player.position_z = 0.0
				player.velocity_z = 0.0
				player.is_airborne = false
		_position_players_in_entrance(new_joiner_players)
	else:
		# Normal spawn positioning (level 1 or no entrance)
		# Assign spawn points to new joiner's players (starting from the next available spawn point)
		var spawn_index = used_spawn_index
		for player in new_joiner_players:
			if spawn_index < player_manager.spawn_points.size():
				var new_pos = player_manager.spawn_points[spawn_index]
				
				# CRITICAL: Verify spawn position is safe (on floor, not in wall)
				if not _is_safe_spawn_position(new_pos):
					# Spawn position is not safe, find a nearby safe position
					var safe_pos = _find_nearby_safe_spawn_position(new_pos, 128.0)
					LogManager.log("GameWorld: WARNING - Spawn position " + str(new_pos) + " for new joiner " + player.name + " was unsafe, using safe position: " + str(safe_pos), LogManager.CATEGORY_GAMEPLAY)
					new_pos = safe_pos
				
				player.global_position = new_pos
				LogManager.log("GameWorld: Positioned new joiner player " + player.name + " (peer_id: " + str(player.peer_id) + ") at spawn index " + str(spawn_index) + " position " + str(new_pos), LogManager.CATEGORY_GAMEPLAY)
				spawn_index += 1
			else:
				# Fallback: place in center of start room
				var room_center_x = (start_room.x + start_room.w / 2.0) * 16
				var room_center_y = (start_room.y + start_room.h / 2.0) * 16
				var fallback_pos = Vector2(room_center_x, room_center_y)
				
				# CRITICAL: Verify fallback position is safe
				if not _is_safe_spawn_position(fallback_pos):
					fallback_pos = _find_nearby_safe_spawn_position(fallback_pos, 128.0)
					LogManager.log("GameWorld: WARNING - Fallback spawn position for new joiner " + player.name + " was unsafe, using safe position: " + str(fallback_pos), LogManager.CATEGORY_GAMEPLAY)
				
				player.global_position = fallback_pos
				LogManager.log("GameWorld: Positioned new joiner player " + player.name + " (peer_id: " + str(player.peer_id) + ") at start room center " + str(fallback_pos), LogManager.CATEGORY_GAMEPLAY)
			
			# Host never receives _sync_player_position_by_name; ensure joiner is visible on host
			player.visible = true
	
	# CRITICAL: Remove black fade overlay for new joiner (in case it was left from previous level)
	# This prevents joiners from seeing a black screen
	# Remove on server (for host's view) and sync to joiner's client
	_remove_black_fade_overlay()
	if multiplayer.has_multiplayer_peer():
		_sync_remove_black_fade.rpc_id(new_peer_id)
	
	# Send ALL current player positions to the new joiner (so they see everyone correctly)
	# Wait longer to ensure the client has fully loaded the game scene and all player nodes are spawned
	await get_tree().create_timer(0.3).timeout # Wait 0.3 seconds after positioning
	
	for player in sorted_players:
		if player.is_inside_tree() and is_instance_valid(player):
			# Use GameWorld RPC to avoid node path resolution issues
			_sync_player_position_by_name.rpc_id(new_peer_id, player.name, player.global_position)
			LogManager.log("GameWorld: Sent player " + player.name + " position (" + str(player.global_position) + ") to new joiner " + str(new_peer_id), LogManager.CATEGORY_GAMEPLAY)
			
			# Sync equipment for existing players to new joiner
			if "character_stats" in player and player.character_stats:
				var equipment_data = {}
				for slot_name in player.character_stats.equipment.keys():
					var item = player.character_stats.equipment[slot_name]
					if item:
						equipment_data[slot_name] = item.save() # Serialize item data
					else:
						equipment_data[slot_name] = null
				# Send equipment sync via player's RPC (the player node will route it correctly)
				if player.has_method("_sync_equipment"):
					player._sync_equipment.rpc_id(new_peer_id, equipment_data)
					LogManager.log("GameWorld: Sent player " + player.name + " equipment to new joiner " + str(new_peer_id), LogManager.CATEGORY_GAMEPLAY)
	
	# Notify all existing players about the new joiner's positions (without moving them)
	# _sync_player_position_by_name also sets visible = true on clients
	var existing_peers = multiplayer.get_peers()
	for existing_peer_id in existing_peers:
		if existing_peer_id != new_peer_id:
			for player in new_joiner_players:
				if player.is_inside_tree() and is_instance_valid(player):
					_sync_player_position_by_name.rpc_id(existing_peer_id, player.name, player.global_position)
					LogManager.log("GameWorld: Notified existing peer " + str(existing_peer_id) + " about new joiner player " + player.name + " position (" + str(player.global_position) + ")", LogManager.CATEGORY_GAMEPLAY)
	# Delayed retry: ensure joiner becomes visible on clients if spawn arrived after first position sync
	var joiner_names: Array = []
	for player in new_joiner_players:
		if player.is_inside_tree() and is_instance_valid(player):
			joiner_names.append(player.name)
	if joiner_names.size() > 0:
		get_tree().create_timer(1.0).timeout.connect(func():
			if not is_inside_tree() or not multiplayer.is_server():
				return
			var entities_node = get_node_or_null("Entities")
			# Host: ensure joiner's players are visible (host never receives visibility RPCs)
			if entities_node:
				for pname in joiner_names:
					var p = entities_node.get_node_or_null(NodePath(str(pname)))
					if p:
						p.visible = true
			# Clients (existing peers): RPC ensure_visible
			for existing_peer_id in multiplayer.get_peers():
				if existing_peer_id != new_peer_id:
					for pname in joiner_names:
						_ensure_player_visible_by_name.rpc_id(existing_peer_id, pname)
			# Joiner: also send ensure_visible for their own player(s) as second chance
			for pname in joiner_names:
				_ensure_player_visible_by_name.rpc_id(new_peer_id, pname)
			LogManager.log("GameWorld: Sent delayed joiner visibility retry (host + peers + joiner)", LogManager.CATEGORY_GAMEPLAY)
		)

func _move_all_players_to_start_room():
	# Move all players to the start room of the new level
	# This should ONLY be called during level generation or level transitions, NOT when a new player joins
	if dungeon_data.is_empty() or not dungeon_data.has("start_room"):
		return
	
	# Reset all player grab/lift/push states to prevent being stuck in lifting animation
	var all_players = get_tree().get_nodes_in_group("player")
	for player in all_players:
		if player and is_instance_valid(player) and player.has_method("reset_grab_state"):
			player.reset_grab_state()
	
	var start_room = dungeon_data.start_room
	_update_spawn_points(start_room)
	
	# Move all players to spawn points based on peer_id (deterministic across clients)
	var players = get_tree().get_nodes_in_group("player")
	
	# Sort players by peer_id, then by local_player_index to ensure consistent ordering
	var sorted_players = []
	for player in players:
		if player.get("peer_id") != null:
			sorted_players.append(player)
	sorted_players.sort_custom(func(a, b):
		if a.peer_id != b.peer_id:
			return a.peer_id < b.peer_id
		var a_index = a.get("local_player_index") if a.get("local_player_index") != null else 0
		var b_index = b.get("local_player_index") if b.get("local_player_index") != null else 0
		return a_index < b_index
	)
	
	# Assign spawn points sequentially based on sorted order
	# IMPORTANT: Only server should move players and sync positions
	# Clients should wait for server to sync positions via RPC
	if not multiplayer.is_server():
		# Client: Don't move players here - wait for server to sync positions
		# The server will call _sync_teleport_position for each player
		return
	
	# Check if entrance exists (level > 1)
	var has_entrance = not dungeon_data.is_empty() and dungeon_data.has("entrance") and not dungeon_data.entrance.is_empty()
	
	if has_entrance and current_level > 1:
		# Position players inside entrance and start walk-out sequence
		_position_players_in_entrance(sorted_players)
	else:
		# Normal spawn positioning (level 1 or no entrance)
		var spawn_index = 0
		for player in sorted_players:
			if spawn_index < player_manager.spawn_points.size():
				var new_pos = player_manager.spawn_points[spawn_index]
				
				# CRITICAL: Verify spawn position is safe (on floor, not in wall)
				if not _is_safe_spawn_position(new_pos):
					# Spawn position is not safe, find a nearby safe position
					var safe_pos = _find_nearby_safe_spawn_position(new_pos, 128.0)
					LogManager.log("GameWorld: WARNING - Spawn position " + str(new_pos) + " for player " + player.name + " was unsafe, using safe position: " + str(safe_pos), LogManager.CATEGORY_GAMEPLAY)
					new_pos = safe_pos
				
				player.global_position = new_pos
				LogManager.log("GameWorld: Moved player " + player.name + " (peer_id: " + str(player.peer_id) + ") to start room at " + str(new_pos), LogManager.CATEGORY_GAMEPLAY)
				
				# Server: Sync position to all clients for ALL players (including server's own)
				# Use GameWorld RPC to avoid node path resolution issues
				_rpc_to_ready_peers("_sync_player_position_by_name", [player.name, new_pos])
				
				spawn_index += 1
			else:
				# Fallback: place in center of start room
				var room_center_x = (start_room.x + start_room.w / 2.0) * 16
				var room_center_y = (start_room.y + start_room.h / 2.0) * 16
				var fallback_pos = Vector2(room_center_x, room_center_y)
				
				# CRITICAL: Verify fallback position is safe
				if not _is_safe_spawn_position(fallback_pos):
					fallback_pos = _find_nearby_safe_spawn_position(fallback_pos, 128.0)
					LogManager.log("GameWorld: WARNING - Fallback spawn position was unsafe, using safe position: " + str(fallback_pos), LogManager.CATEGORY_GAMEPLAY)
				
				player.global_position = fallback_pos
				LogManager.log("GameWorld: Moved player " + player.name + " (peer_id: " + str(player.peer_id) + ") to start room center at " + str(player.global_position), LogManager.CATEGORY_GAMEPLAY)
				
				# Server: Sync position to all clients for ALL players (including server's own)
				_rpc_to_ready_peers("_sync_player_position_by_name", [player.name, fallback_pos])

func _position_players_in_entrance(players: Array):
	# Position all players inside the entrance and start walk-out sequence
	if dungeon_data.is_empty() or not dungeon_data.has("entrance") or dungeon_data.entrance.is_empty():
		return
	
	var entrance_data = dungeon_data.entrance
	if not entrance_data.has("world_pos") or not entrance_data.has("world_size"):
		return
	
	# Reset walk-out state
	players_walking_out.clear()
	entrance_walk_out_complete = false
	
	# Position each player inside the entrance (stacked slightly)
	var entrance_center = entrance_data.world_pos
	var player_spacing = 8.0 # Small spacing between players
	
	for i in range(players.size()):
		var player = players[i]
		if not is_instance_valid(player):
			continue
		
		# Position player inside entrance (slightly offset based on index)
		var offset_x = (i % 3) * player_spacing - player_spacing # Spread horizontally
		var offset_y = int(float(i) / 3.0) * player_spacing # Stack vertically
		var player_pos = entrance_center + Vector2(offset_x, offset_y)
		
		player.global_position = player_pos
		
		# Lock controls and add to walk-out list
		player.controls_disabled = true
		players_walking_out.append(player)
		
		# Play "go_down_stairs" when player appears in entrance (reversed if go_down_stairs_reversed.mp3 exists)
		_play_entrance_appear_sound(player_pos)
		
		# Sync position and control state
		_rpc_to_ready_peers("_sync_player_position_by_name", [player.name, player_pos])
		if multiplayer.has_multiplayer_peer():
			_rpc_to_ready_peers("_sync_player_entrance_walk_out", [player.name, true])
		
		LogManager.log("GameWorld: Positioned player " + player.name + " in entrance at " + str(player_pos), LogManager.CATEGORY_GAMEPLAY)
	
	# Start walk-out sequence after a brief delay
	await get_tree().create_timer(0.5).timeout
	_start_entrance_walk_out()

func _start_entrance_walk_out():
	# Make all players walk out of the entrance
	if players_walking_out.is_empty():
		entrance_walk_out_complete = true
		_close_entrance()
		return
	
	# Get entrance data
	var entrance_data = dungeon_data.entrance
	var entrance_center = entrance_data.world_pos
	var start_room = dungeon_data.start_room
	var room_center = Vector2((start_room.x + float(start_room.w) / 2.0) * 16, (start_room.y + float(start_room.h) / 2.0) * 16)
	
	# Walk straight into room: use entrance wall dir -> "into room" (perpendicular)
	var walk_direction := Vector2.DOWN
	if entrance_data.has("dir"):
		match entrance_data.dir:
			"UP": walk_direction = Vector2.DOWN # entrance on top wall -> walk down into room
			"DOWN": walk_direction = Vector2.UP
			"LEFT": walk_direction = Vector2.RIGHT
			"RIGHT": walk_direction = Vector2.LEFT
			_: pass
	
	# Target positions: free tiles straight ahead (sort by distance along walk_direction)
	var target_positions: Array = []
	var free_tiles = _get_free_floor_tiles_in_room(start_room)
	if free_tiles.size() > 0:
		var tile_size = 16
		var scored: Array = []
		for ft in free_tiles:
			var p = Vector2(ft.x * tile_size + tile_size / 2.0, ft.y * tile_size + tile_size / 2.0)
			var along = (p - entrance_center).dot(walk_direction)
			scored.append({"pos": p, "along": along})
		scored.sort_custom(func(a, b): return a.along > b.along)
		for i in range(players_walking_out.size()):
			if i < scored.size():
				target_positions.append(scored[i].pos)
			elif i < player_manager.spawn_points.size():
				target_positions.append(player_manager.spawn_points[i])
			else:
				target_positions.append(room_center)
	else:
		for i in range(players_walking_out.size()):
			if i < player_manager.spawn_points.size():
				target_positions.append(player_manager.spawn_points[i])
			else:
				target_positions.append(room_center)
	
	# Make each player walk straight to their target (same direction for all: into room)
	for i in range(players_walking_out.size()):
		var player = players_walking_out[i]
		if not is_instance_valid(player):
			continue
		
		var target_pos = target_positions[i] if i < target_positions.size() else room_center
		var direction = (target_pos - player.global_position).normalized()
		if direction.length() < 0.1:
			direction = walk_direction
		
		player.velocity = direction * 120.0 # Walk speed for cut-scene
		
		# Store target position for checking completion
		player.set_meta("entrance_walk_target", target_pos)
		
		# Sync walk target via RPC
		if multiplayer.has_multiplayer_peer():
			_rpc_to_ready_peers("_sync_player_walk_target", [player.name, target_pos])
	
	# Check when all players have reached their targets
	_check_entrance_walk_out_complete()

func _check_entrance_walk_out_complete():
	# Check if all players have exited the entrance
	if players_walking_out.is_empty():
		entrance_walk_out_complete = true
		_close_entrance()
		return
	
	# Remove players that have reached their targets
	var players_to_remove = []
	for player in players_walking_out:
		if not is_instance_valid(player):
			players_to_remove.append(player)
			continue
		
		# Consider player "out" only when they've reached their walk target (inside the room).
		# Do NOT use "far from entrance" — that closed the gate when exiting the room / entering another.
		var has_reached_target = false
		if player.has_meta("entrance_walk_target"):
			var target_pos = player.get_meta("entrance_walk_target")
			var distance_to_target = player.global_position.distance_to(target_pos)
			if distance_to_target < 16.0: # Close enough to target (inside room)
				has_reached_target = true
				player.remove_meta("entrance_walk_target")
		
		if has_reached_target:
			# Player has exited - unlock controls and stop velocity
			player.controls_disabled = false
			player.velocity = Vector2.ZERO
			if multiplayer.has_multiplayer_peer():
				_rpc_to_ready_peers("_sync_player_entrance_walk_out", [player.name, false])
			players_to_remove.append(player)
	
	# Remove exited players
	for player in players_to_remove:
		players_walking_out.erase(player)
	
	# If all players are out, close entrance
	if players_walking_out.is_empty():
		entrance_walk_out_complete = true
		_close_entrance()
	else:
		# Check again after a short delay
		await get_tree().create_timer(0.1).timeout
		_check_entrance_walk_out_complete()

func _play_entrance_appear_sound(at_pos: Vector2):
	# Play go_down_stairs when player appears in entrance; use reversed if available
	var path_reversed = "res://assets/audio/sfx/walk/go_down_stairs_reversed.mp3"
	var path_normal = "res://assets/audio/sfx/walk/go_down_stairs.mp3"
	var path_use = path_normal
	if ResourceLoader.exists(path_reversed):
		path_use = path_reversed
	var stream = load(path_use) as AudioStream
	if not stream:
		return
	var snd = AudioStreamPlayer2D.new()
	snd.stream = stream
	snd.global_position = at_pos
	add_child(snd)
	snd.play()
	snd.finished.connect(func(): snd.queue_free())

func _entrance_dir_to_door_dir(d: String) -> String:
	# Dungeon uses "UP"/"DOWN"/"LEFT"/"RIGHT"; door expects "Up"/"Down"/"Left"/"Right"
	match d:
		"UP": return "Up"
		"DOWN": return "Down"
		"LEFT": return "Left"
		"RIGHT": return "Right"
		_: return d

func _entrance_gate_position(entrance_data: Dictionary) -> Vector2:
	# Position gate like blocking doors: center of door on wall, per direction.
	# entrance_data has world_pos (center of stairs), world_size, dir.
	const TILE_SIZE: float = 16.0
	var c = entrance_data.world_pos
	var sz = entrance_data.world_size
	var d = entrance_data.get("dir", "UP")
	match d:
		"UP":
			return Vector2(c.x, c.y - sz.y / 2.0 + TILE_SIZE / 2.0)
		"DOWN":
			return Vector2(c.x, c.y + sz.y / 2.0 - TILE_SIZE / 2.0)
		"LEFT":
			return Vector2(c.x - sz.x / 2.0 + TILE_SIZE / 2.0, c.y)
		"RIGHT":
			return Vector2(c.x + sz.x / 2.0 - TILE_SIZE / 2.0, c.y)
		_:
			return c

func _close_entrance():
	# Close entrance by adding a non-openable gate_door
	if dungeon_data.is_empty() or not dungeon_data.has("entrance") or dungeon_data.entrance.is_empty():
		return
	
	var entrance_data = dungeon_data.entrance
	if not entrance_data.has("world_pos") or not entrance_data.has("dir"):
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		push_error("GameWorld: Could not find Entities node for entrance gate!")
		return
	
	# Check if gate already exists (gate is under Entities)
	var existing_gate = entities_node.get_node_or_null("EntranceGateDoor")
	if existing_gate:
		return # Already closed
	
	# Load door scene
	var door_scene = load("res://scenes/door.tscn")
	if not door_scene:
		push_error("GameWorld: Could not load door scene for entrance gate!")
		return
	
	var gate_dir = _entrance_dir_to_door_dir(entrance_data.dir)
	var gate_pos = _entrance_gate_position(entrance_data)
	
	# Create gate door — spawn OPEN like other GateDoors, then animate closed (plays close sound)
	# Position like blocking doors: center of door on wall per direction
	var gate_door = door_scene.instantiate()
	gate_door.name = "EntranceGateDoor"
	gate_door.type = "GateDoor"
	gate_door.direction = gate_dir
	gate_door.is_closed = false # Start OPEN so we can animate close + play SfxCloseGateDoor
	gate_door.requires_enemies = false
	gate_door.requires_switch = false
	
	gate_door.global_position = gate_pos
	
	if multiplayer.has_multiplayer_peer():
		gate_door.set_multiplayer_authority(get_multiplayer_authority())
	
	entities_node.add_child(gate_door)
	
	if multiplayer.has_multiplayer_peer():
		_rpc_to_ready_peers("_sync_entrance_gate_door", [gate_pos, gate_dir])
	
	# Hide "ENTRANCE" label
	var entrance_area = get_node_or_null("EntranceArea")
	if entrance_area:
		var entrance_label = entrance_area.get_node_or_null("EntranceLabel")
		if entrance_label:
			entrance_label.visible = false
	
	# Animate gate closed after _ready_after_setup runs (plays SfxCloseGateDoor, syncs to clients)
	var gd = gate_door
	get_tree().create_timer(0.12).timeout.connect(func():
		if is_instance_valid(gd) and gd.has_method("_close"):
			gd._close()
	)
	
	LogManager.log("GameWorld: Closed entrance with gate door at " + str(gate_pos), LogManager.CATEGORY_DUNGEON)

@rpc("any_peer", "reliable")
func _sync_player_entrance_walk_out(player_name: String, is_walking_out: bool):
	# Sync entrance walk-out state to clients
	var players = get_tree().get_nodes_in_group("player")
	for p in players:
		if p.name == player_name:
			p.controls_disabled = is_walking_out
			if not is_walking_out:
				p.velocity = Vector2.ZERO
				p.remove_meta("entrance_walk_target")
			break

@rpc("any_peer", "reliable")
func _sync_player_walk_target(player_name: String, target_pos: Vector2):
	# Sync walk target to clients (cut-scene walk into room)
	var players = get_tree().get_nodes_in_group("player")
	for p in players:
		if p.name == player_name:
			var direction = (target_pos - p.global_position).normalized()
			if direction.length() < 0.1:
				direction = Vector2.DOWN
			p.velocity = direction * 120.0
			p.set_meta("entrance_walk_target", target_pos)
			break

@rpc("any_peer", "reliable")
func _sync_entrance_gate_door(gate_pos: Vector2, gate_dir: String):
	# Sync entrance gate door creation to clients
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	var existing_gate = entities_node.get_node_or_null("EntranceGateDoor")
	if existing_gate:
		return # Already exists
	
	var door_scene = load("res://scenes/door.tscn")
	if not door_scene:
		return
	
	var gate_door = door_scene.instantiate()
	gate_door.name = "EntranceGateDoor"
	gate_door.type = "GateDoor"
	gate_door.direction = gate_dir
	gate_door.is_closed = false # Start OPEN; host will sync close + sound
	gate_door.requires_enemies = false
	gate_door.requires_switch = false
	gate_door.global_position = gate_pos
	
	if multiplayer.has_multiplayer_peer():
		gate_door.set_multiplayer_authority(1)
	
	entities_node.add_child(gate_door)
	
	# Hide "ENTRANCE" label on clients too
	var entrance_area = get_node_or_null("EntranceArea")
	if entrance_area:
		var entrance_label = entrance_area.get_node_or_null("EntranceLabel")
		if entrance_label:
			entrance_label.visible = false

func _get_free_floor_tiles_in_room(room: Dictionary) -> Array:
	# Get free floor tiles in a room (not walls, doors, or stairs)
	var free_tiles = []
	
	# Check each tile in the room (excluding walls)
	for x in range(room.x + 2, room.x + room.w - 2):
		for y in range(room.y + 2, room.y + room.h - 2):
			# Check if tile is floor (grid value 1)
			if x >= 0 and x < dungeon_data.map_size.x and y >= 0 and y < dungeon_data.map_size.y:
				if dungeon_data.grid[x][y] == 1: # Floor tile
					free_tiles.append({"x": x, "y": y})
	
	return free_tiles

func _create_stairs_area():
	# Remove existing stairs area if any
	var existing_stairs = get_node_or_null("StairsArea")
	if existing_stairs:
		existing_stairs.queue_free()
	
	# Check if stairs data exists
	if dungeon_data.is_empty() or not dungeon_data.has("stairs") or dungeon_data.stairs.is_empty():
		return
	
	var stairs_data = dungeon_data.stairs
	if not stairs_data.has("world_pos") or not stairs_data.has("world_size"):
		return
	
	# Create stairs Area2D programmatically
	var stairs_area = Area2D.new()
	stairs_area.name = "StairsArea"
	
	# Set collision layer/mask BEFORE adding to scene
	stairs_area.collision_layer = 0
	stairs_area.collision_mask = 1 # Detect players (layer 1)
	
	# Add script BEFORE adding to scene (so _ready() is called properly)
	var stairs_script = load("res://scripts/stairs.gd")
	if stairs_script:
		stairs_area.set_script(stairs_script)
	
	# Add collision shape
	var collision_shape = CollisionShape2D.new()
	var rect_shape = RectangleShape2D.new()
	rect_shape.size = stairs_data.world_size
	collision_shape.shape = rect_shape
	stairs_area.add_child(collision_shape)
	
	# Set position
	stairs_area.global_position = stairs_data.world_pos
	
	# Add to scene AFTER everything is set up
	add_child(stairs_area)
	
	print("GameWorld: Created stairs Area2D at ", stairs_data.world_pos, " with size ", stairs_data.world_size)

func _create_entrance_area():
	# Remove existing entrance area if any
	var existing_entrance = get_node_or_null("EntranceArea")
	if existing_entrance:
		existing_entrance.queue_free()
	
	# Check if entrance data exists (only for level > 1)
	if dungeon_data.is_empty() or not dungeon_data.has("entrance") or dungeon_data.entrance.is_empty():
		return
	
	var entrance_data = dungeon_data.entrance
	if not entrance_data.has("world_pos") or not entrance_data.has("world_size"):
		return
	
	# Create entrance Area2D programmatically (similar to stairs)
	var entrance_area = Area2D.new()
	entrance_area.name = "EntranceArea"
	
	# Set collision layer/mask BEFORE adding to scene
	entrance_area.collision_layer = 0
	entrance_area.collision_mask = 1 # Detect players (layer 1)
	
	# Add collision shape
	var collision_shape = CollisionShape2D.new()
	var rect_shape = RectangleShape2D.new()
	rect_shape.size = entrance_data.world_size
	collision_shape.shape = rect_shape
	entrance_area.add_child(collision_shape)
	
	# Set position
	entrance_area.global_position = entrance_data.world_pos
	
	# Add "ENTRANCE" label above (like EXIT / DISARM)
	var label = Label.new()
	label.name = "EntranceLabel"
	label.text = "ENTRANCE"
	label.add_theme_font_size_override("font_size", 16)
	label.add_theme_color_override("font_color", Color.YELLOW)
	label.add_theme_color_override("font_outline_color", Color.BLACK)
	label.add_theme_constant_override("outline_size", 2)
	label.position = Vector2(-35, -30)
	label.z_index = 100
	label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	entrance_area.add_child(label)
	
	# Add to scene AFTER everything is set up
	add_child(entrance_area)
	
	print("GameWorld: Created entrance Area2D at ", entrance_data.world_pos, " with size ", entrance_data.world_size)

@rpc("any_peer", "reliable")
func _request_player_reached_stairs(player_peer_id: int):
	# Client notifies server that they reached exit stairs (LEVEL COMPLETE)
	if not multiplayer.is_server():
		return
	var players = get_tree().get_nodes_in_group("player")
	var target = null
	for p in players:
		if p.has_method("get_multiplayer_authority") and p.get_multiplayer_authority() == player_peer_id:
			target = p
			break
	if target and is_instance_valid(target):
		LogManager.log("GameWorld: Server received stairs reach from client (peer " + str(player_peer_id) + "), triggering level complete", LogManager.CATEGORY_DUNGEON)
		_on_player_reached_stairs(target)

func _on_player_reached_stairs(player: Node):
	# Player reached stairs - trigger level complete
	if not multiplayer.is_server() and multiplayer.has_multiplayer_peer():
		return # Only server handles this
	
	# Prevent multiple triggers - if already triggered, ignore
	if level_complete_triggered:
		LogManager.log("GameWorld: Level complete already triggered, ignoring duplicate trigger", LogManager.CATEGORY_DUNGEON)
		return
	
	LogManager.log("GameWorld: Player " + str(player.name) + " reached stairs!", LogManager.CATEGORY_DUNGEON)
	
	# Mark as triggered to prevent re-triggering
	level_complete_triggered = true
	
	# CRITICAL: Disable stairs so no other player can activate it
	# Use set_deferred() because we're in a signal callback (body_entered)
	var stairs_area = get_node_or_null("StairsArea")
	if stairs_area:
		stairs_area.set_deferred("monitoring", false)
		stairs_area.set_deferred("monitorable", false)
		LogManager.log("GameWorld: Disabled stairs Area2D to prevent other players from activating", LogManager.CATEGORY_DUNGEON)
		# Sync stairs disable to all clients
		if multiplayer.has_multiplayer_peer():
			_rpc_to_ready_peers("_sync_disable_stairs", [])
	
	# Disable controls and collision for the player who reached stairs
	var player_peer_id = player.get_multiplayer_authority() if player.has_method("get_multiplayer_authority") else 0
	player.controls_disabled = true
	# Remove collision layer (layer 1 = players)
	player.set_collision_layer_value(1, false)
	print("GameWorld: Disabled controls and collision for player ", player.name)
	
	# Show black fade overlay for server's local player if this is the server's player
	var my_peer_id = multiplayer.get_unique_id() if multiplayer.has_multiplayer_peer() else 1
	if player_peer_id == my_peer_id:
		_show_black_fade_overlay()
	
	# Sync controls disabled and collision removal to clients
	if multiplayer.has_multiplayer_peer() and player_peer_id > 0:
		_rpc_to_ready_peers("_sync_player_exit_stairs", [player_peer_id])
	
	# Drop any held objects for all players before level completion
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		for child in entities_node.get_children():
			if child.is_in_group("player") and child.has_method("_force_drop_held_object"):
				child._force_drop_held_object()
	
	# Stop HUD timer when level completes
	var hud = get_node_or_null("IngameHUD")
	var level_time: float = 0.0
	if hud and hud.has_method("stop_timer"):
		hud.stop_timer()
		# Get the level time before stopping
		if hud.has_method("get_level_time"):
			level_time = hud.get_level_time()
	
	# Fade out player
	_fade_out_player(player)
	
	# Stop background music when level completes
	_stop_bg_music()
	
	# Grant EXP to all players for completing the level
	var level_completion_exp = 15.0 # EXP reward for completing a level
	var all_players = get_tree().get_nodes_in_group("player")
	var valid_players = []
	for p in all_players:
		if is_instance_valid(p) and p.character_stats:
			valid_players.append(p)
	
	if valid_players.size() > 0:
		# Split EXP evenly among all players
		var exp_per_player = level_completion_exp / valid_players.size()
		for p in valid_players:
			p.character_stats.add_xp(exp_per_player)
			LogManager.log("Level complete: granted " + str(exp_per_player) + " EXP to " + str(p.name) + " (shared from " + str(level_completion_exp) + " total)", LogManager.CATEGORY_DUNGEON)
			
			# Sync EXP to client if this player belongs to a client
			if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
				var p_peer_id = p.get_multiplayer_authority()
				if p_peer_id != 0 and p_peer_id != multiplayer.get_unique_id() and p.has_method("_sync_stats_update"):
					var coins = p.character_stats.coin if "coin" in p.character_stats else 0
					var xp = p.character_stats.xp if "xp" in p.character_stats else 0.0
					p._sync_stats_update.rpc_id(p_peer_id, p.character_stats.kills, coins, xp)
			
			# Show floating EXP text at player position and sync to all clients
			if multiplayer.is_server() and is_instance_valid(p):
				# Show locally first
				_show_exp_number_at_player(exp_per_player, p)
				# Sync to all clients via RPC
				if multiplayer.has_multiplayer_peer():
					var p_peer_id = p.get_multiplayer_authority()
					_sync_exp_text_at_player.rpc(exp_per_player, p_peer_id)
	
	# Show level complete UI (server and clients) with per-player stats
	_show_level_complete_ui(level_time)
	# Sync to all clients (each client will show their own local player's stats)
	if multiplayer.has_multiplayer_peer():
		_rpc_to_ready_peers("_sync_show_level_complete", [level_time])
	
	# After delay, hide UI and generate new level
	await get_tree().create_timer(5.0).timeout # Show stats for 5 seconds
	
	# Hide level complete UI (server and clients)
	var level_complete_ui = get_node_or_null("LevelCompleteUI")
	if level_complete_ui:
		level_complete_ui.visible = false
	# Sync hide to all clients
	if multiplayer.has_multiplayer_peer():
		_rpc_to_ready_peers("_sync_hide_level_complete", [])
	
	# Clear previous level
	_clear_level()
	
	# Wait for old entities to be fully freed before generating new level
	await get_tree().process_frame
	await get_tree().process_frame # Wait an extra frame to ensure cleanup is complete
	
	# Generate next level
	current_level += 1
	LogManager.log("GameWorld: Incremented to level " + str(current_level) + " (was level " + str(current_level - 1) + ")", LogManager.CATEGORY_DUNGEON)
	
	# Update room registry with new level
	if network_manager and network_manager.is_hosting and network_manager.room_registry:
		var player_count = network_manager.get_all_player_ids().size()
		network_manager.room_registry.send_room_update(player_count, current_level)
	
	# Generate the new dungeon (this is async but we don't await it - it will complete in background)
	_generate_dungeon()
	
	# Wait for dungeon generation to complete (it has await statements inside)
	# We need to wait for all the async operations in _generate_dungeon() to finish
	await get_tree().process_frame
	await get_tree().process_frame
	await get_tree().process_frame # Extra frame to ensure everything is done
	
	# Verify current_level is still correct
	print("GameWorld: After dungeon generation, current_level = ", current_level)
	
	# Show level number (server and clients) - use the incremented level
	print("GameWorld: Showing level number for level ", current_level)
	_show_level_number()
	# Sync to all clients
	if multiplayer.has_multiplayer_peer():
		LogManager.log("GameWorld: Syncing level number " + str(current_level) + " to all clients", LogManager.CATEGORY_DUNGEON)
		_rpc_to_ready_peers("_sync_show_level_number", [current_level])
	
	# Restart HUD timer for new level
	hud = get_node_or_null("IngameHUD")
	if hud and hud.has_method("start_timer"):
		hud.start_timer()
	
	# Restore controls and collision for all players after a short delay
	_schedule_restore_player_controls(0.6)
	
	# Remove black fade overlay (server and clients)
	_remove_black_fade_overlay()
	if multiplayer.has_multiplayer_peer():
		_rpc_to_ready_peers("_sync_remove_black_fade", [])
	
	# Move all players to start room (server side)
	_move_all_players_to_start_room()
	
	# Fade players back in
	_fade_in_all_players()
	
	# Sync new level to all clients - use start room since all players should be there
	# IMPORTANT: Wait multiple frames to ensure dungeon generation and enemy spawning is complete before syncing
	await get_tree().process_frame
	await get_tree().process_frame # Wait extra frames to ensure enemies are fully initialized
	
	if multiplayer.has_multiplayer_peer():
		var start_room = dungeon_data.start_room if not dungeon_data.is_empty() and dungeon_data.has("start_room") else {}
		
		# Debug: Verify enemies are in dungeon_data before syncing
		if dungeon_data.has("enemies"):
			LogManager.log("GameWorld: Server about to sync new level with " + str(dungeon_data.enemies.size()) + " enemies to all clients", LogManager.CATEGORY_DUNGEON)
		else:
			LogManager.log("GameWorld: ERROR: Server dungeon_data has NO 'enemies' key when syncing new level!", LogManager.CATEGORY_DUNGEON)
		
		# Use blob sync for all connected peers
		var peers = multiplayer.get_peers()
		for peer_id in peers:
			if peer_id != 1: # Don't sync to self (server is peer 1)
				print("GameWorld: HOST - Syncing new level (from stairs) to peer ", peer_id, " using blob sync")
				_sync_dungeon_to_client(peer_id, dungeon_data, dungeon_seed, current_level, start_room)

func _get_local_player_stats() -> Dictionary:
	# Get stats for the local player (for level complete screen)
	# Returns a dictionary with: enemies_defeated, coins_collected
	var stats = {
		"enemies_defeated": 0,
		"coins_collected": 0
	}
	
	# Find local player(s) - in multiplayer, find player with authority matching this client
	# In single-player, just use the first player
	var players = get_tree().get_nodes_in_group("player")
	var local_player = null
	
	if multiplayer.has_multiplayer_peer():
		# Multiplayer: find player with matching authority (client's own player)
		var my_peer_id = multiplayer.get_unique_id()
		for player in players:
			if player.has_method("get_multiplayer_authority") and player.get_multiplayer_authority() == my_peer_id:
				local_player = player
				break
	else:
		# Single-player: use first player
		if players.size() > 0:
			local_player = players[0]
	
	if local_player and local_player.character_stats:
		# Get enemies defeated (kills)
		if "kills" in local_player.character_stats:
			stats.enemies_defeated = local_player.character_stats.kills
		
		# Get coins collected
		if "coin" in local_player.character_stats:
			stats.coins_collected = local_player.character_stats.coin
	
	return stats

func _fade_out_player(player: Node):
	# Fade out all sprite layers
	var fade_tween = create_tween()
	fade_tween.set_parallel(true)
	
	var sprite_layers = []
	if "sprite_body" in player and player.sprite_body:
		sprite_layers.append(player.sprite_body)
	if "sprite_boots" in player and player.sprite_boots:
		sprite_layers.append(player.sprite_boots)
	if "sprite_armour" in player and player.sprite_armour:
		sprite_layers.append(player.sprite_armour)
	if "sprite_facial_hair" in player and player.sprite_facial_hair:
		sprite_layers.append(player.sprite_facial_hair)
	if "sprite_hair" in player and player.sprite_hair:
		sprite_layers.append(player.sprite_hair)
	if "sprite_eyes" in player and player.sprite_eyes:
		sprite_layers.append(player.sprite_eyes)
	if "sprite_eyelashes" in player and player.sprite_eyelashes:
		sprite_layers.append(player.sprite_eyelashes)
	if "sprite_addons" in player and player.sprite_addons:
		sprite_layers.append(player.sprite_addons)
	if "sprite_headgear" in player and player.sprite_headgear:
		sprite_layers.append(player.sprite_headgear)
	if "sprite_weapon" in player and player.sprite_weapon:
		sprite_layers.append(player.sprite_weapon)
	if "shadow" in player and player.shadow:
		sprite_layers.append(player.shadow)
	
	for sprite_layer in sprite_layers:
		if sprite_layer:
			fade_tween.tween_property(sprite_layer, "modulate:a", 0.0, 1.0)

func _fade_in_all_players():
	# Fade in all players after level transition
	var players = get_tree().get_nodes_in_group("player")
	var fade_tweens = []
	for player in players:
		var tween = _fade_in_player(player)
		if tween:
			fade_tweens.append(tween)
	
	# Start background music after all players fade in (when new level is ready)
	# Wait for all fade tweens to complete
	if fade_tweens.size() > 0:
		await fade_tweens[0].finished # Wait for first player's fade (they should all finish around the same time)
	_start_bg_music()

func _fade_in_player(player: Node) -> Tween:
	# Fade in all sprite layers
	var fade_tween = create_tween()
	fade_tween.set_parallel(true)
	
	var sprite_layers = []
	if "sprite_body" in player and player.sprite_body:
		sprite_layers.append(player.sprite_body)
	if "sprite_boots" in player and player.sprite_boots:
		sprite_layers.append(player.sprite_boots)
	if "sprite_armour" in player and player.sprite_armour:
		sprite_layers.append(player.sprite_armour)
	if "sprite_facial_hair" in player and player.sprite_facial_hair:
		sprite_layers.append(player.sprite_facial_hair)
	if "sprite_hair" in player and player.sprite_hair:
		sprite_layers.append(player.sprite_hair)
	if "sprite_eyes" in player and player.sprite_eyes:
		sprite_layers.append(player.sprite_eyes)
	if "sprite_eyelashes" in player and player.sprite_eyelashes:
		sprite_layers.append(player.sprite_eyelashes)
	if "sprite_addons" in player and player.sprite_addons:
		sprite_layers.append(player.sprite_addons)
	if "sprite_headgear" in player and player.sprite_headgear:
		sprite_layers.append(player.sprite_headgear)
	if "sprite_weapon" in player and player.sprite_weapon:
		sprite_layers.append(player.sprite_weapon)
	if "shadow" in player and player.shadow:
		sprite_layers.append(player.shadow)
	
	for sprite_layer in sprite_layers:
		if sprite_layer:
			sprite_layer.modulate.a = 0.0 # Start invisible
			fade_tween.tween_property(sprite_layer, "modulate:a", 1.0, 1.0)
	
	return fade_tween

func _show_black_fade_overlay():
	# Create black fade overlay for player who reached exit
	# Remove existing fade if any
	var existing_fade = get_node_or_null("BlackFadeOverlay")
	if existing_fade:
		existing_fade.queue_free()
	
	# Create CanvasLayer with z_index 999 (below level complete UI which is 1000)
	var fade_layer = CanvasLayer.new()
	fade_layer.name = "BlackFadeOverlay"
	fade_layer.layer = 999 # Below level complete UI (1000) but above gameplay
	add_child(fade_layer)
	
	# Create ColorRect that fills the screen
	var fade_rect = ColorRect.new()
	fade_rect.name = "FadeRect"
	fade_rect.color = Color(0, 0, 0, 1) # Black, fully opaque
	fade_rect.set_anchors_preset(Control.PRESET_FULL_RECT) # Fill entire screen
	fade_layer.add_child(fade_rect)
	
	# Fade in from transparent to black
	fade_rect.modulate.a = 0.0 # Start transparent
	var fade_tween = create_tween()
	fade_tween.tween_property(fade_rect, "modulate:a", 1.0, 0.5) # Fade in over 0.5 seconds
	LogManager.log("GameWorld: Created black fade overlay for player who reached exit", LogManager.CATEGORY_DUNGEON)

func _remove_black_fade_overlay():
	# Remove black fade overlay when new level starts
	var existing_fade = get_node_or_null("BlackFadeOverlay")
	if existing_fade:
		# Fade out quickly before removing
		var fade_rect = existing_fade.get_node_or_null("FadeRect")
		if fade_rect:
			var fade_tween = create_tween()
			fade_tween.tween_property(fade_rect, "modulate:a", 0.0, 0.2) # Fade out over 0.2 seconds
			await fade_tween.finished
		existing_fade.queue_free()
		LogManager.log("GameWorld: Removed black fade overlay", LogManager.CATEGORY_DUNGEON)

func _restore_player_controls_and_collision():
	# Restore controls and collision for all players when new level starts
	var players = get_tree().get_nodes_in_group("player")
	for player in players:
		player.controls_disabled = false
		# Restore collision layer (layer 1 = players)
		player.set_collision_layer_value(1, true)
		print("GameWorld: Restored controls and collision for player ", player.name)

func _schedule_restore_player_controls(delay: float = 0.6):
	# Delay restoring player controls/collision to allow positioning to finish
	if not multiplayer.is_server():
		return
	get_tree().create_timer(delay).timeout.connect(func():
		if is_inside_tree():
			_restore_player_controls_and_collision()
			if multiplayer.has_multiplayer_peer():
				_rpc_to_ready_peers("_sync_restore_player_controls", [])
	)

func _show_level_complete_ui(level_time: float = 0.0):
	# Create or show level complete UI
	var level_complete_ui = get_node_or_null("LevelCompleteUI")
	if not level_complete_ui:
		# Try to load scene if it exists, but fall back to programmatic creation if it doesn't
		var scene_path = "res://scenes/level_complete_ui.tscn"
		if ResourceLoader.exists(scene_path):
			var level_complete_scene = load(scene_path)
			if level_complete_scene:
				level_complete_ui = level_complete_scene.instantiate()
				level_complete_ui.name = "LevelCompleteUI"
				add_child(level_complete_ui)
			else:
				# Scene file exists but failed to load - fall back to programmatic creation
				LogManager.log("GameWorld: Warning - level_complete_ui.tscn exists but failed to load, creating programmatically", LogManager.CATEGORY_DUNGEON)
				level_complete_ui = _create_level_complete_ui_programmatically()
		else:
			# Scene file doesn't exist - create UI programmatically (expected behavior)
			level_complete_ui = _create_level_complete_ui_programmatically()
	
	if level_complete_ui:
		if level_complete_ui.has_method("show_stats"):
			# Get stats for local player
			var local_stats = _get_local_player_stats()
			# Use current_level directly (matches what HUD shows)
			# current_level hasn't been incremented yet when this is called
			level_complete_ui.show_stats(
				local_stats.enemies_defeated,
				0, # times_downed - not shown per user request
				0.0, # exp_collected - not shown per user request
				local_stats.coins_collected,
				level_time,
				current_level
			)
		
		# Play level complete sound at 60% volume
		_play_level_complete_sound()

func _play_level_complete_sound():
	# Play level complete sound at 60% volume
	var sound_path = "res://assets/audio/sfx/level_complete_03.wav"
	if ResourceLoader.exists(sound_path):
		var sound_stream = load(sound_path) as AudioStream
		if sound_stream:
			# Create AudioStreamPlayer (not 2D, so it plays globally)
			var audio_player = AudioStreamPlayer.new()
			audio_player.stream = sound_stream
			audio_player.volume_db = linear_to_db(0.6) # 60% volume
			add_child(audio_player)
			audio_player.play()
			# Clean up after sound finishes
			audio_player.finished.connect(func(): audio_player.queue_free())

func _start_bg_music():
	# Start background music (only if not already playing)
	if bg_music and not bg_music.playing:
		bg_music.play()
		print("GameWorld: Started background music")

func _stop_bg_music():
	# Stop background music
	if bg_music and bg_music.playing:
		bg_music.stop()
		print("GameWorld: Stopped background music")

func _show_level_number():
	# Show level number text
	print("GameWorld: _show_level_number() called with current_level = ", current_level)
	var level_text_ui = get_node_or_null("LevelTextUI")
	if not level_text_ui:
		# Try to load scene if it exists, but fall back to programmatic creation if it doesn't
		var scene_path = "res://scenes/level_text_ui.tscn"
		if ResourceLoader.exists(scene_path):
			var level_text_scene = load(scene_path)
			if level_text_scene:
				level_text_ui = level_text_scene.instantiate()
				level_text_ui.name = "LevelTextUI"
				add_child(level_text_ui)
			else:
				# Scene file exists but failed to load - fall back to programmatic creation
				LogManager.log("GameWorld: Warning - level_text_ui.tscn exists but failed to load, creating programmatically", LogManager.CATEGORY_DUNGEON)
				level_text_ui = _create_level_text_ui_programmatically()
		else:
			# Scene file doesn't exist - create UI programmatically (expected behavior)
			level_text_ui = _create_level_text_ui_programmatically()
	
	if level_text_ui:
		if level_text_ui.has_method("show_level"):
			# Store the level number in a local variable to ensure we use the correct value
			var level_to_show = current_level
			LogManager.log("GameWorld: Calling show_level(" + str(level_to_show) + ") on LevelTextUI (current_level = " + str(current_level) + ")", LogManager.CATEGORY_DUNGEON)
			# Make sure we pass the current level value explicitly
			level_text_ui.show_level(level_to_show)
		else:
			LogManager.log("GameWorld: ERROR - LevelTextUI does not have show_level method!", LogManager.CATEGORY_DUNGEON)
	else:
		LogManager.log("GameWorld: ERROR - Could not create or find LevelTextUI!", LogManager.CATEGORY_DUNGEON)

func _load_hud():
	# Check if HUD already exists - only load it once
	var existing_hud = get_node_or_null("IngameHUD")
	if existing_hud and is_instance_valid(existing_hud):
		LogManager.log("GameWorld: HUD already exists, skipping load (will just reset timer)", LogManager.CATEGORY_DUNGEON)
		print("GameWorld: HUD already exists, skipping load")
		# Reset timer for new level if method exists
		if existing_hud.has_method("reset_level_timer"):
			existing_hud.reset_level_timer()
		return
	
	# Load HUD dynamically to avoid scene loading errors
	# This is optional - don't crash if HUD scene doesn't exist or fails to load
	# Use a try-catch-like approach by checking for errors
	var hud_scene_path = "res://scenes/ingame_hud.tscn"
	
	print("GameWorld: Loading HUD from ", hud_scene_path)
	
	# Check if scene exists
	if not ResourceLoader.exists(hud_scene_path):
		LogManager.log("GameWorld: HUD scene not found at " + str(hud_scene_path) + " - HUD disabled", LogManager.CATEGORY_DUNGEON)
		print("GameWorld: ERROR - HUD scene not found at ", hud_scene_path)
		return
	
	# Try to load the scene
	var hud_scene = load(hud_scene_path)
	if not hud_scene:
		print("GameWorld: ERROR - Failed to load HUD scene from ", hud_scene_path)
		LogManager.log("GameWorld: Warning - Failed to load HUD scene from " + str(hud_scene_path), LogManager.CATEGORY_DUNGEON)
		return
	
	# Try to instantiate
	var hud = null
	if hud_scene.has_method("instantiate"):
		hud = hud_scene.instantiate()
		print("GameWorld: HUD scene instantiated")
	else:
		LogManager.log("GameWorld: Warning - HUD scene is not a PackedScene", LogManager.CATEGORY_DUNGEON)
		print("GameWorld: ERROR - HUD scene is not a PackedScene")
		return
	
	if not hud:
		LogManager.log("GameWorld: Warning - Failed to instantiate HUD scene", LogManager.CATEGORY_DUNGEON)
		print("GameWorld: ERROR - Failed to instantiate HUD scene")
		return
	
	# Add to scene tree
	hud.name = "IngameHUD"
	
	# Ensure HUD is visible and on top layer
	hud.visible = true
	hud.layer = 100 # High layer to ensure HUD is on top of everything
	
	print("GameWorld: Adding HUD to scene tree (visible: ", hud.visible, ", layer: ", hud.layer, ")")
	add_child(hud)
	
	# Reset timer if method exists
	if hud.has_method("reset_level_timer"):
		hud.reset_level_timer()
	
	LogManager.log("GameWorld: HUD loaded successfully and added to scene tree", LogManager.CATEGORY_DUNGEON)
	print("GameWorld: HUD loaded successfully and added to scene tree")
	print("GameWorld: HUD visible: ", hud.visible, ", layer: ", hud.layer)
	print("GameWorld: HUD is_inside_tree: ", hud.is_inside_tree())
	
	_load_off_screen_indicators()
	_load_minimap()
	_load_floating_text_layer()

func _load_floating_text_layer() -> void:
	var existing = get_node_or_null("FloatingTextLayer")
	if existing and is_instance_valid(existing):
		return
	var layer = CanvasLayer.new()
	layer.name = "FloatingTextLayer"
	layer.layer = 210
	layer.follow_viewport_enabled = true
	add_child(layer)

@rpc("any_peer", "call_local", "reliable")
func _sync_exp_text_at_position(amount: float, exp_pos: Vector2):
	# Sync EXP text display to all clients at a specific position
	_show_exp_number_at_position(amount, exp_pos)

@rpc("any_peer", "call_local", "reliable")
func _sync_exp_text_at_player(amount: float, player_peer_id: int):
	# Sync EXP text display to all clients at a player's position
	var player = null
	var players = get_tree().get_nodes_in_group("player")
	for p in players:
		if p.get_multiplayer_authority() == player_peer_id:
			player = p
			break
	
	if player and is_instance_valid(player):
		_show_exp_number_at_player(amount, player)

func _show_exp_number_at_position(amount: float, exp_pos: Vector2):
	# Show EXP number (green, using dmg_numbers.png font) at a specific position
	var damage_number_scene = preload("res://scenes/damage_number.tscn")
	if not damage_number_scene:
		return
	
	var exp_label = damage_number_scene.instantiate()
	if not exp_label:
		return
	
	# Set text and color for EXP (green)
	exp_label.label = "+" + str(int(amount)) + " EXP"
	exp_label.color = Color(0.4, 1.0, 0.4) # Bright green
	exp_label.z_index = 5
	
	# Direction is straight up
	exp_label.direction = Vector2(0, -1)
	
	# Position at specified location
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		entities_node.add_child(exp_label)
		exp_label.global_position = exp_pos + Vector2(0, -20) # Slightly above
	else:
		get_tree().current_scene.add_child(exp_label)
		exp_label.global_position = exp_pos + Vector2(0, -20)

func _show_exp_number_at_player(amount: float, player: Node):
	# Show EXP number (green, using dmg_numbers.png font) at player position
	var damage_number_scene = preload("res://scenes/damage_number.tscn")
	if not damage_number_scene or not is_instance_valid(player):
		return
	
	var exp_label = damage_number_scene.instantiate()
	if not exp_label:
		return
	
	# Set text and color for EXP (green)
	exp_label.label = "+" + str(int(amount)) + " EXP"
	exp_label.color = Color(0.4, 1.0, 0.4) # Bright green
	exp_label.z_index = 5
	
	# Direction is straight up
	exp_label.direction = Vector2(0, -1)
	
	# Position at player location
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		entities_node.add_child(exp_label)
		exp_label.global_position = player.global_position + Vector2(0, -20) # Above player
	else:
		get_tree().current_scene.add_child(exp_label)
		exp_label.global_position = player.global_position + Vector2(0, -20)

func world_to_screen(world_pos: Vector2) -> Vector2:
	var vp = get_viewport()
	if not vp:
		return world_pos
	return vp.get_canvas_transform() * world_pos

func _load_minimap() -> void:
	var existing = get_node_or_null("Minimap")
	if existing and is_instance_valid(existing):
		return
	var scene = load("res://scenes/minimap.tscn") as PackedScene
	if not scene:
		return
	var layer = scene.instantiate()
	layer.name = "Minimap"
	add_child(layer)

func _load_off_screen_indicators():
	var existing = get_node_or_null("OffScreenIndicators")
	if existing and is_instance_valid(existing):
		return
	var scene = load("res://scenes/off_screen_indicators.tscn") as PackedScene
	if not scene:
		return
	var layer = scene.instantiate()
	layer.name = "OffScreenIndicators"
	add_child(layer)

func _initialize_hud():
	# Find or get the HUD and reset its level timer
	# This is optional - don't crash if HUD doesn't exist
	var hud = get_node_or_null("IngameHUD")
	if hud and is_instance_valid(hud) and hud.has_method("reset_level_timer"):
		hud.reset_level_timer()
	else:
		LogManager.log("GameWorld: HUD not found or not ready - this is OK if HUD scene is missing", LogManager.CATEGORY_DUNGEON)

func _create_chat_ui():
	# Load chat UI scene
	var chat_ui_scene = load("res://scenes/chat_ui.tscn")
	if not chat_ui_scene:
		push_error("GameWorld: Could not load chat_ui.tscn scene!")
		return
	
	var chat_ui = chat_ui_scene.instantiate()
	if chat_ui:
		add_child(chat_ui)
		LogManager.log("GameWorld: Chat UI scene instantiated and added to scene tree", LogManager.CATEGORY_DUNGEON)
	else:
		push_error("GameWorld: Failed to instantiate chat_ui.tscn!")

func _create_inventory_ui():
	# Load inventory UI scene
	var inventory_ui_scene = load("res://scenes/inventory_ui.tscn")
	if not inventory_ui_scene:
		push_error("GameWorld: Could not load inventory_ui.tscn scene!")
		return
	
	var inventory_ui = inventory_ui_scene.instantiate()
	if inventory_ui:
		inventory_ui.name = "InventoryUI"
		add_child(inventory_ui)
		LogManager.log("GameWorld: Inventory UI scene instantiated and added to scene tree", LogManager.CATEGORY_DUNGEON)
	else:
		push_error("GameWorld: Failed to instantiate inventory_ui.tscn!")

func _send_player_join_message(peer_id: int, player_info: Dictionary):
	# Send a chat message when a player joins
	# Only send from server to avoid duplicate messages
	if not multiplayer.is_server():
		return
	
	# Get player name (use first player name from the player_info)
	var player_names = player_info.get("player_names", [])
	var player_name = ""
	if player_names.size() > 0:
		player_name = player_names[0]
	else:
		player_name = "Player%d" % peer_id
	
	# Get chat UI
	var chat_ui = get_node_or_null("ChatUI")
	if chat_ui and chat_ui.has_method("send_system_message"):
		var message = "%s joined the game" % player_name
		chat_ui.send_system_message(message)

func _send_player_disconnect_message(peer_id: int, player_info: Dictionary):
	# Send a chat message when a player disconnects
	# Only send from server to avoid duplicate messages
	if not multiplayer.is_server():
		return
	
	# Get player name from player_info
	var player_name = ""
	var player_names = player_info.get("player_names", [])
	if player_names.size() > 0:
		player_name = player_names[0]
	else:
		player_name = "Player%d" % peer_id
	
	# Get chat UI
	var chat_ui = get_node_or_null("ChatUI")
	if chat_ui and chat_ui.has_method("send_system_message"):
		var message = "%s disconnected" % player_name
		chat_ui.send_system_message(message)

@rpc("authority", "reliable")
func _receive_chat_message(player_name: String, message: String):
	# Route chat messages through game_world to avoid node path issues
	# Find ChatUI and call _receive_message on it
	var chat_ui = get_node_or_null("ChatUI")
	if chat_ui and chat_ui.has_method("_receive_message"):
		chat_ui._receive_message(player_name, message)
	else:
		# ChatUI not found yet, log and skip (message will be lost, but that's okay)
		LogManager.log("GameWorld: ChatUI not found when trying to receive chat message from " + player_name, LogManager.CATEGORY_NETWORK)

@rpc("any_peer", "reliable")
func _sync_object_break(obj_name: String):
	# Route object break RPC through game_world to avoid node path issues
	# Find object by name and call _sync_break on it
	# Check if this node is still valid and in tree
	if not is_inside_tree():
		LogManager.log("GameWorld: Node not in tree, ignoring box break RPC for " + obj_name, LogManager.CATEGORY_NETWORK)
		return
	
	# Skip processing if tab was just inactive (may be stale messages from buffer overflow)
	if not multiplayer.is_server() and not was_tab_visible:
		return # Tab was inactive, skip old/stale RPCs to prevent sound spam
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		LogManager.log("GameWorld: Entities node not found, ignoring box break RPC for " + obj_name, LogManager.CATEGORY_NETWORK)
		return
	
	# Try to find object by name first
	var obj = entities_node.get_node_or_null(obj_name)
	
	# If not found and name looks like "InteractableObject_X", try extracting index and searching by meta
	if not obj and obj_name.begins_with("InteractableObject_"):
		var index_str = obj_name.substr(20) # Skip "InteractableObject_"
		if index_str.is_valid_int():
			var obj_index = index_str.to_int()
			# Search all children for object with matching object_index meta
			for child in entities_node.get_children():
				if child.has_meta("object_index") and child.get_meta("object_index") == obj_index:
					obj = child
					break
	
	# If still not found, try to find any interactable object that matches (fallback)
	if not obj:
		# Last resort: try to find by checking if any interactable has the name or position
		# This shouldn't be necessary if naming is consistent, but helps with edge cases
		for child in entities_node.get_children():
			if "health" in child and child.has_method("_sync_break"):
				# If this object has object_index meta, check if name matches pattern
				if child.has_meta("object_index"):
					var child_index = child.get_meta("object_index")
					var expected_name = "InteractableObject_%d" % child_index
					if expected_name == obj_name:
						obj = child
						break
	
	if obj and obj.has_method("_sync_break"):
		# Break locally (on server or client)
		obj._sync_break()
		
		# On server: track broken objects for syncing to new clients
		if multiplayer.is_server():
			var obj_index = -1
			if obj.has_meta("object_index"):
				obj_index = obj.get_meta("object_index")
			elif obj_name.begins_with("InteractableObject_"):
				var index_str = obj_name.substr(20) # Skip "InteractableObject_"
				if index_str.is_valid_int():
					obj_index = index_str.to_int()
			
			if obj_index >= 0:
				broken_objects[obj_index] = true
				LogManager.log("GameWorld: Tracking broken object " + obj_name + " (index: " + str(obj_index) + ")", LogManager.CATEGORY_NETWORK)
		
		# If we're the server and this came from a client, broadcast to all other clients
		if multiplayer.is_server() and multiplayer.get_remote_sender_id() != 0:
			# This is a request from a client - broadcast to all clients
			# The client who sent the request already broke it locally, so they'll skip breaking again
			# Use call_deferred to ensure we're in a safe state for RPC
			call_deferred("_broadcast_object_break", obj_name)
			LogManager.log("GameWorld: Server broadcasting box break for " + obj_name + " (requested by client " + str(multiplayer.get_remote_sender_id()) + ")", LogManager.CATEGORY_NETWORK)
	else:
		# Object not found - might have already been destroyed, which is okay
		LogManager.log("GameWorld: Object " + obj_name + " not found when trying to sync break", LogManager.CATEGORY_NETWORK)

func _broadcast_object_break(obj_name: String):
	# Helper to broadcast object break - called deferred to ensure safe RPC state
	if is_inside_tree() and multiplayer.has_multiplayer_peer() and multiplayer.is_server():
		_rpc_to_ready_peers("_sync_object_break", [obj_name])

func _register_player_died(player: Node):
	# Track dead players for server-authoritative "all dead" respawn.
	# Authority runs this; if client, we RPC server so server has full picture.
	if not is_inside_tree():
		return
	var peer_id: int = 0
	if player and player.has_method("get_multiplayer_authority"):
		peer_id = player.get_multiplayer_authority()
	var n = str(player.name) if player else ""
	if n.is_empty():
		return
	if multiplayer.is_server() and multiplayer.has_multiplayer_peer():
		if dead_players.has(n):
			return
		dead_players[n] = true
		if not respawn_all_check_running:
			respawn_all_check_running = true
		_run_respawn_all_check()
	elif multiplayer.has_multiplayer_peer():
		_request_register_player_died.rpc_id(1, peer_id)
	else:
		# Single-player: we are "server"
		if dead_players.has(n):
			return
		dead_players[n] = true
		if not respawn_all_check_running:
			respawn_all_check_running = true
		_run_respawn_all_check()

@rpc("any_peer", "reliable")
func _request_register_player_died(player_peer_id: int):
	if not multiplayer.is_server():
		return
	var pm = get_node_or_null("PlayerManager")
	if not pm or not pm.has_method("get_all_players"):
		return
	for p in pm.get_all_players():
		if not is_instance_valid(p):
			continue
		if p.has_method("get_multiplayer_authority") and p.get_multiplayer_authority() == player_peer_id:
			var n = str(p.name)
			if n.is_empty() or dead_players.has(n):
				return
			dead_players[n] = true
			if not respawn_all_check_running:
				respawn_all_check_running = true
			_run_respawn_all_check()
			return

func _unregister_player_died(player: Node):
	# Remove player from dead_players dictionary when they're revived
	# Authority runs this; if client, we RPC server so server has full picture.
	if not is_inside_tree():
		return
	var peer_id: int = 0
	if player and player.has_method("get_multiplayer_authority"):
		peer_id = player.get_multiplayer_authority()
	var n = str(player.name) if player else ""
	if n.is_empty():
		return
	if multiplayer.is_server() and multiplayer.has_multiplayer_peer():
		if dead_players.has(n):
			dead_players.erase(n)
			LogManager.log("GameWorld: Unregistered player " + n + " from dead_players (revived)", LogManager.CATEGORY_DUNGEON)
	elif multiplayer.has_multiplayer_peer():
		_request_unregister_player_died.rpc_id(1, peer_id)
	else:
		# Single-player: we are "server"
		if dead_players.has(n):
			dead_players.erase(n)
			LogManager.log("GameWorld: Unregistered player " + n + " from dead_players (revived)", LogManager.CATEGORY_DUNGEON)

@rpc("any_peer", "reliable")
func _request_unregister_player_died(player_peer_id: int):
	if not multiplayer.is_server():
		return
	var pm = get_node_or_null("PlayerManager")
	if not pm or not pm.has_method("get_all_players"):
		return
	for p in pm.get_all_players():
		if not is_instance_valid(p):
			continue
		if p.has_method("get_multiplayer_authority") and p.get_multiplayer_authority() == player_peer_id:
			var n = str(p.name)
			if not n.is_empty() and dead_players.has(n):
				dead_players.erase(n)
				LogManager.log("GameWorld: Unregistered player " + n + " from dead_players (revived)", LogManager.CATEGORY_DUNGEON)
			return

func _are_all_players_dead_server() -> bool:
	var pm = get_node_or_null("PlayerManager")
	if not pm or not pm.has_method("get_all_players"):
		return false
	var all_p = pm.get_all_players()
	if all_p.is_empty():
		return false
	
	# Get list of connected peer IDs (exclude disconnected players)
	var connected_peer_ids: Array[int] = []
	if multiplayer.has_multiplayer_peer():
		# Server's own peer ID (1) is always connected
		connected_peer_ids.append(1)
		# Add all other connected peers
		for peer_id in multiplayer.get_peers():
			connected_peer_ids.append(peer_id)
	else:
		# Single-player: only check local players
		connected_peer_ids.append(1)
	
	# Only check players that are connected (not disconnected)
	var has_connected_players = false
	for p in all_p:
		if not is_instance_valid(p):
			continue
		
		# Get player's peer ID
		var player_peer_id = 0
		if p.has_method("get_multiplayer_authority"):
			player_peer_id = p.get_multiplayer_authority()
		elif "peer_id" in p:
			player_peer_id = p.peer_id
		
		# Skip disconnected players (not in connected_peer_ids)
		if player_peer_id > 0 and player_peer_id not in connected_peer_ids:
			continue
		
		# This is a connected player - check if they're dead
		has_connected_players = true
		var in_dead = dead_players.has(str(p.name))
		var node_dead = "is_dead" in p and p.is_dead
		if not in_dead and not node_dead:
			# Found a connected player who is not dead
			return false
	
	# If no connected players found, return false (shouldn't happen, but safety check)
	if not has_connected_players:
		return false
	
	# All connected players are dead
	return true

func _run_respawn_all_check():
	# Server-only coroutine: wait until all players dead, then game over + respawn_all.
	while is_inside_tree() and respawn_all_check_running:
		await get_tree().create_timer(0.2).timeout
		if not _are_all_players_dead_server():
			continue
		break
	respawn_all_check_running = false
	if not is_inside_tree():
		return
	
	# CRITICAL: Double-check that all players are still dead before showing game over
	# This prevents showing game over if level transition happened during the wait
	if not _are_all_players_dead_server():
		# Players are no longer dead (likely level transition happened), abort
		dead_players.clear()
		return
	
	if game_over_triggered:
		pass # Already shown (e.g. by earlier logic)
	else:
		game_over_triggered = true
		_show_game_over_local()
		if multiplayer.has_multiplayer_peer() and is_inside_tree():
			_sync_show_game_over.rpc()
	await get_tree().create_timer(0.5).timeout
	if not is_inside_tree():
		return
	
	# CRITICAL: Check again after the wait - level transition might have happened
	if not _are_all_players_dead_server():
		# Players are no longer dead (level transition happened), abort and reset
		game_over_triggered = false
		dead_players.clear()
		# Hide game over UI if it was shown
		var game_over_ui = get_node_or_null("GameOverUI")
		if game_over_ui:
			game_over_ui.visible = false
		return
	
	dead_players.clear()
	respawn_all_ready.emit()
	if multiplayer.has_multiplayer_peer() and is_inside_tree():
		_sync_respawn_all.rpc()

@rpc("any_peer", "reliable")
func _sync_respawn_all():
	if multiplayer.is_server():
		return # Server already ran respawn_all_ready locally
	respawn_all_ready.emit()

func _show_game_over():
	# Show game over UI when all players die (legacy / fallback; server uses _run_respawn_all_check)
	if game_over_triggered:
		return # Already shown
	
	game_over_triggered = true
	
	# Show game over UI locally
	_show_game_over_local()
	
	# Sync to all clients
	if multiplayer.has_multiplayer_peer() and is_inside_tree():
		_sync_show_game_over.rpc()

func _show_game_over_local():
	# Create or show game over UI
	var game_over_ui = get_node_or_null("GameOverUI")
	if not game_over_ui:
		# Create game over UI programmatically (similar to level complete)
		var canvas_layer = CanvasLayer.new()
		canvas_layer.name = "GameOverUI"
		canvas_layer.layer = 1001 # Above level complete UI (1000)
		add_child(canvas_layer)
		
		# Load standard font
		var standard_font = load("res://assets/fonts/standard_font.png") as FontFile
		var theme = Theme.new()
		if standard_font:
			theme.default_font = standard_font
			theme.default_font_size = 10
		
		var vbox = VBoxContainer.new()
		vbox.theme = theme
		canvas_layer.add_child(vbox)
		
		# Center the VBoxContainer
		var screen_size = get_viewport().get_visible_rect().size
		vbox.set_anchors_preset(Control.PRESET_CENTER)
		vbox.offset_left = - screen_size.x / 2
		vbox.offset_right = screen_size.x / 2
		vbox.offset_top = -100
		vbox.offset_bottom = screen_size.y / 2 - 100
		vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		
		# Title - "GAME OVER" in large size
		var title = Label.new()
		title.name = "TitleLabel"
		title.text = "GAME OVER"
		title.theme = theme
		title.add_theme_font_size_override("font_size", 72)
		title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		vbox.add_child(title)
		
		game_over_ui = canvas_layer
	
	if game_over_ui:
		game_over_ui.visible = true
		
		# Fade in
		var vbox = game_over_ui.get_child(0) if game_over_ui.get_child_count() > 0 else null
		if vbox:
			vbox.modulate.a = 0.0
			var fade_in = create_tween()
			fade_in.tween_property(vbox, "modulate:a", 1.0, 0.5)
	
	# Play game over sound at 60% volume
	_play_game_over_sound()
	
	# Stop background music when all players die
	_stop_bg_music()
	
	# Fade out game graphics when GAME OVER is shown
	_fade_out_game_graphics()

@rpc("any_peer", "reliable")
func _sync_show_game_over():
	# Sync game over screen to other peer(s). Either host or joiner can trigger when both dead.
	# Sender already showed locally in _show_game_over; we only run when receiving from the other peer.
	if game_over_triggered:
		return # Already shown (e.g. we triggered, or we already processed this RPC)
	game_over_triggered = true
	_show_game_over_local()

func _play_game_over_sound():
	# Play game over sound at 60% volume
	var sound_path = "res://assets/audio/sfx/level_fail_02.wav"
	if ResourceLoader.exists(sound_path):
		var sound_stream = load(sound_path) as AudioStream
		if sound_stream:
			# Create AudioStreamPlayer (not 2D, so it plays globally)
			var audio_player = AudioStreamPlayer.new()
			audio_player.stream = sound_stream
			audio_player.volume_db = linear_to_db(0.6) # 60% volume
			add_child(audio_player)
			audio_player.play()
			# Clean up after sound finishes
			audio_player.finished.connect(func(): audio_player.queue_free())

func _fade_out_game_graphics():
	# Fade out game graphics when GAME OVER is shown
	if not canvas_modulate:
		return
	
	# Fade out CanvasModulate (darken the game)
	var fade_tween = create_tween()
	fade_tween.tween_property(canvas_modulate, "color", Color(0.0, 0.0, 0.0, 1.0), 0.5)
	
	# Also fade out Environment (tilemaps)
	if environment:
		environment.modulate.a = 1.0
		fade_tween.parallel().tween_property(environment, "modulate:a", 0.0, 0.5)

func _fade_in_game_graphics():
	# Fade in game graphics quickly when GAME OVER is removed
	if not canvas_modulate:
		return
	
	# Get original CanvasModulate color (restore original brightness)
	var original_color = Color(0.69140625, 0.69140625, 0.69140625, 1)
	
	# Fade in CanvasModulate quickly
	var fade_tween = create_tween()
	fade_tween.tween_property(canvas_modulate, "color", original_color, 0.2) # Quick fade in (0.2s)
	
	# Also fade in Environment (tilemaps)
	if environment:
		environment.modulate.a = 0.0
		fade_tween.parallel().tween_property(environment, "modulate:a", 1.0, 0.2)

func _hide_game_over():
	# Hide GAME OVER screen when player respawns
	if not game_over_triggered:
		return # Not shown, nothing to hide
	
	game_over_triggered = false
	
	var game_over_ui = get_node_or_null("GameOverUI")
	if game_over_ui:
		game_over_ui.visible = false
	
	# Fade in game graphics quickly
	_fade_in_game_graphics()
	
	LogManager.log("GameWorld: Hidden GAME OVER screen and faded in graphics", LogManager.CATEGORY_GAMEPLAY)

@rpc("any_peer", "reliable")
func _sync_arrow_collected(arrow_name: String):
	# Route arrow collection through game_world to avoid node path issues (RPC was on arrow).
	# Collector already added to inventory, played sound, and queue_freed locally.
	# Other peers find arrow in Entities by stable name and remove it.
	if not is_inside_tree() or arrow_name.is_empty():
		return
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	var arrow = entities_node.get_node_or_null(arrow_name)
	if arrow and is_instance_valid(arrow):
		arrow.call_deferred("queue_free")

@rpc("any_peer", "call_local", "reliable")
func _sync_arrow_hit(arrow_name: String, target_name: String):
	# Route arrow hit sync through game_world to avoid node path issues
	# Find arrow by name (may be in Entities or as child of target)
	if not is_inside_tree() or arrow_name.is_empty() or target_name.is_empty():
		return
	
	var arrow = _find_arrow_by_name(arrow_name)
	if not arrow or not is_instance_valid(arrow):
		LogManager.log("GameWorld: Arrow not found for hit sync: " + arrow_name, LogManager.CATEGORY_DUNGEON)
		return
	
	# Find target by name in Entities node
	var target = null
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		target = entities_node.get_node_or_null(target_name)
		# Also check if target is a child of Entities (arrow might be stuck to it)
		if not target:
			for child in entities_node.get_children():
				if child.name == target_name:
					target = child
					break
	
	if not target:
		LogManager.log("GameWorld: Arrow hit target not found: " + target_name, LogManager.CATEGORY_DUNGEON)
		return
	
	# Call the arrow's internal sync method
	if arrow.has_method("_process_arrow_hit_sync"):
		arrow._process_arrow_hit_sync(target)
	else:
		# Fallback: directly stick to target
		if arrow.has_method("_stick_to_target") and target not in arrow.hit_targets:
			arrow.hit_targets[target] = true
			if arrow.has_method("play_impact"):
				arrow.play_impact()
			arrow._stick_to_target(target)

@rpc("any_peer", "call_local", "reliable")
func _sync_arrow_miss(arrow_name: String, target_name: String):
	# Route arrow miss sync through game_world to avoid node path issues
	if not is_inside_tree() or arrow_name.is_empty() or target_name.is_empty():
		return
	
	var arrow = _find_arrow_by_name(arrow_name)
	if not arrow or not is_instance_valid(arrow):
		return
	
	# Find target by name in Entities node
	var target = null
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		target = entities_node.get_node_or_null(target_name)
	
	if target and target not in arrow.hit_targets:
		arrow.hit_targets[target] = true
		LogManager.log("GameWorld: Arrow synced as MISS - continuing through: " + (target.name if target else "unknown"), LogManager.CATEGORY_DUNGEON)

@rpc("any_peer", "call_local", "reliable")
func _sync_arrow_dodge(arrow_name: String, target_name: String):
	# Route arrow dodge sync through game_world to avoid node path issues
	if not is_inside_tree() or arrow_name.is_empty() or target_name.is_empty():
		return
	
	var arrow = _find_arrow_by_name(arrow_name)
	if not arrow or not is_instance_valid(arrow):
		return
	
	# Find target by name in Entities node
	var target = null
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		target = entities_node.get_node_or_null(target_name)
	
	if target and target not in arrow.hit_targets:
		arrow.hit_targets[target] = true
		LogManager.log("GameWorld: Arrow synced as DODGE - continuing through: " + (target.name if target else "unknown"), LogManager.CATEGORY_DUNGEON)

func _find_arrow_by_name(arrow_name: String) -> Node:
	# Find arrow by name - check Entities first, then check children of entities (arrows stuck to enemies)
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return null
	
	# First check direct children of Entities
	var arrow = entities_node.get_node_or_null(arrow_name)
	if arrow and is_instance_valid(arrow):
		return arrow
	
	# Recursively search children of Entities' children (arrows stuck to enemies/players)
	# Arrows can be nested deeper when stuck to targets
	for child in entities_node.get_children():
		var found = _find_node_recursive(child, arrow_name)
		if found:
			return found
	
	return null

func _find_node_recursive(node: Node, target_name: String) -> Node:
	# Recursively search for a node by name
	if not is_instance_valid(node):
		return null
	
	if node.name == target_name:
		return node
	
	for child in node.get_children():
		var found = _find_node_recursive(child, target_name)
		if found:
			return found
	
	return null

func _create_level_complete_ui_programmatically() -> Node:
	# Create level complete UI programmatically
	var canvas_layer = CanvasLayer.new()
	canvas_layer.name = "LevelCompleteUI"
	canvas_layer.layer = 1000 # Very high z_index so it appears above black fade
	add_child(canvas_layer)
	
	# Load standard font (as FontFile)
	var standard_font = load("res://assets/fonts/standard_font.png") as FontFile
	if not standard_font:
		LogManager.log("GameWorld: Warning - Could not load standard_font.png as FontFile", LogManager.CATEGORY_DUNGEON)
	
	# Create theme with standard font
	var theme = Theme.new()
	if standard_font:
		theme.default_font = standard_font
		theme.default_font_size = 10
	
	var vbox = VBoxContainer.new()
	vbox.theme = theme
	canvas_layer.add_child(vbox)
	
	# Center the VBoxContainer properly
	# Use PRESET_CENTER to anchor at center, then set offsets to center horizontally
	var screen_size = get_viewport().get_visible_rect().size
	vbox.set_anchors_preset(Control.PRESET_CENTER)
	# Set offsets so container is centered horizontally (equal left/right offsets)
	# and positioned vertically (offset from center)
	vbox.offset_left = - screen_size.x / 2
	vbox.offset_right = screen_size.x / 2
	vbox.offset_top = -200 # Position a bit up from center
	vbox.offset_bottom = screen_size.y / 2 - 200 # Balance to maintain vertical position
	vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to fill available width
	
	# Title - "LEVEL COMPLETE!" in large size
	var title = Label.new()
	title.name = "TitleLabel"
	title.text = "LEVEL COMPLETE!"
	title.theme = theme
	title.add_theme_font_size_override("font_size", 72) # Large size
	title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	title.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to fill available width
	vbox.add_child(title)
	
	# Stats header - "stats" in smaller size
	var stats_header = Label.new()
	stats_header.name = "StatsHeaderLabel"
	stats_header.text = "stats"
	stats_header.theme = theme
	stats_header.add_theme_font_size_override("font_size", 32) # Smaller than title
	stats_header.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	stats_header.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to fill available width
	vbox.add_child(stats_header)
	
	# Stats container
	var stats_container = VBoxContainer.new()
	stats_container.theme = theme
	stats_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to fill available width
	vbox.add_child(stats_container)
	
	# Stats labels - enemies defeated and level time
	var enemies_label = Label.new()
	enemies_label.name = "EnemiesLabel"
	enemies_label.theme = theme
	enemies_label.add_theme_font_size_override("font_size", 24)
	enemies_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	enemies_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to fill available width
	stats_container.add_child(enemies_label)
	
	var time_label = Label.new()
	time_label.name = "TimeLabel"
	time_label.theme = theme
	time_label.add_theme_font_size_override("font_size", 24)
	time_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	time_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to fill available width
	stats_container.add_child(time_label)
	
	var downed_label = Label.new()
	downed_label.name = "DownedLabel"
	downed_label.theme = theme
	downed_label.add_theme_font_size_override("font_size", 24)
	downed_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	downed_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to fill available width
	stats_container.add_child(downed_label)
	
	var exp_label = Label.new()
	exp_label.name = "ExpLabel"
	exp_label.theme = theme
	exp_label.add_theme_font_size_override("font_size", 24)
	exp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	exp_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to fill available width
	stats_container.add_child(exp_label)
	
	var coins_label = Label.new()
	coins_label.name = "CoinsLabel"
	coins_label.theme = theme
	coins_label.add_theme_font_size_override("font_size", 24)
	coins_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	coins_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to fill available width
	stats_container.add_child(coins_label)
	
	# Add script
	var script = load("res://scripts/level_complete_ui.gd")
	if script:
		canvas_layer.set_script(script)
	
	return canvas_layer

func _create_level_text_ui_programmatically() -> Node:
	# Create level text UI programmatically
	var canvas_layer = CanvasLayer.new()
	canvas_layer.name = "LevelTextUI"
	add_child(canvas_layer)
	
	var vbox = VBoxContainer.new()
	vbox.set_anchors_preset(Control.PRESET_CENTER)
	
	# Center horizontally and position higher up
	var screen_size = get_viewport().get_visible_rect().size
	vbox.offset_left = - screen_size.x / 2
	vbox.offset_right = screen_size.x / 2
	vbox.offset_top = -250 # Position higher up from center
	vbox.offset_bottom = screen_size.y / 2 - 250
	vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to fill width for proper centering
	
	canvas_layer.add_child(vbox)
	
	# Level label
	var level_label = Label.new()
	level_label.name = "LevelLabel"
	level_label.text = "LEVEL 1"
	level_label.add_theme_font_size_override("font_size", 64)
	level_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	level_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL # Expand to center properly
	
	# Load standard_font.png as bitmap font
	var standard_font_resource = null
	if ResourceLoader.exists("res://assets/fonts/standard_font.png"):
		standard_font_resource = load("res://assets/fonts/standard_font.png")
		if standard_font_resource:
			level_label.add_theme_font_override("font", standard_font_resource)
	
	vbox.add_child(level_label)
	
	# Add script
	var script = load("res://scripts/level_text_ui.gd")
	if script:
		canvas_layer.set_script(script)
	
	return canvas_layer

func _move_players_to_host_room(host_room: Dictionary):
	# Move any existing players to spawn points in the host's room
	if host_room.is_empty() or player_manager.spawn_points.is_empty():
		return
	
	# Get all players that belong to this client (local players)
	var my_peer_id = multiplayer.get_unique_id()
	var players_to_move = []
	for player in player_manager.get_all_players():
		# Only move players that belong to this client (local players)
		if player.peer_id == my_peer_id:
			players_to_move.append(player)
	
	if players_to_move.is_empty():
		return
	
	LogManager.log("GameWorld: Moving " + str(players_to_move.size()) + " local players to host room", LogManager.CATEGORY_DUNGEON)
	
	# Move each player to a free spawn point
	var spawn_index = 0
	for player in players_to_move:
		if spawn_index < player_manager.spawn_points.size():
			var new_pos = player_manager.spawn_points[spawn_index]
			player.position = new_pos
			print("GameWorld: Moved player ", player.name, " to ", new_pos)
			spawn_index += 1

func _spawn_blocking_doors():
	# Spawn blocking doors from dungeon data
	if dungeon_data.is_empty() or not dungeon_data.has("blocking_doors"):
		return
	
	var blocking_doors = dungeon_data.blocking_doors
	if blocking_doors == null or not blocking_doors is Array:
		return
	
	var door_scene = load("res://scenes/door.tscn")
	if not door_scene:
		push_error("ERROR: Could not load door scene!")
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		push_error("ERROR: Could not find Entities node!")
		return
	
	LogManager.log("GameWorld: Spawning " + str(blocking_doors.size()) + " blocking doors", LogManager.CATEGORY_DUNGEON)
	
	# Track pillar placement per room to avoid duplicates
	var rooms_with_pillars: Dictionary = {} # Key: room string "x,y", Value: true if pillar exists
	
	for i in range(blocking_doors.size()):
		var door_data = blocking_doors[i]
		if not door_data is Dictionary:
			continue
		
		var door = door_scene.instantiate()
		door.name = "BlockingDoor_%d" % i
		door.add_to_group("blocking_door")
		door.set_meta("door_index", i)
		
		# Set door properties BEFORE adding to scene (so _ready() has correct values)
		door.type = door_data.type if "type" in door_data else "StoneDoor"
		door.direction = door_data.direction if "direction" in door_data else "Up"
		door.is_closed = door_data.is_closed if "is_closed" in door_data else true
		
		# CRITICAL: Set puzzle requirements based on door_data
		if "puzzle_type" in door_data:
			if door_data.puzzle_type == "enemy":
				door.requires_enemies = true
				door.requires_switch = false
				LogManager.log("GameWorld: Door " + str(door.name) + " requires enemies to open (puzzle_type: enemy)", LogManager.CATEGORY_DUNGEON)
			elif door_data.puzzle_type in ["switch_walk", "switch_pillar"]:
				door.requires_enemies = false
				door.requires_switch = true
				LogManager.log("GameWorld: Door " + str(door.name) + " requires switch to open (puzzle_type: " + str(door_data.puzzle_type) + ")", LogManager.CATEGORY_DUNGEON)
		door.blocking_room = door_data.blocking_room if "blocking_room" in door_data else {}
		door.switch_room = door_data.switch_room if "switch_room" in door_data else {}
		
		# CRITICAL: Verify door has blocking_room set - StoneDoor/GateDoor MUST be in a puzzle room
		if (door_data.type == "StoneDoor" or door_data.type == "GateDoor"):
			if not "blocking_room" in door_data or door_data.blocking_room.is_empty():
				push_error("GameWorld: ERROR - Blocking door ", door.name, " (", door_data.type, ") has no blocking_room! This door should not exist! Removing it.")
				door.queue_free()
				continue
			
			# CRITICAL: Verify door has puzzle_type - StoneDoor/GateDoor MUST have a puzzle
			if not "puzzle_type" in door_data:
				push_error("GameWorld: ERROR - Blocking door ", door.name, " (", door_data.type, ") has no puzzle_type! This door should not exist! Removing it.")
				door.queue_free()
				continue
			
			LogManager.log("GameWorld: Creating blocking door " + str(door.name) + " (" + str(door_data.type) + ") for puzzle room (" + str(door_data.blocking_room.x) + ", " + str(door_data.blocking_room.y) + "), puzzle_type: " + str(door_data.puzzle_type), LogManager.CATEGORY_DUNGEON)
		
		# CRITICAL: Store original door connection info from door_data
		# For blocking doors: room1 = puzzle room (where door is IN / leads FROM)
		#                     room2 = other room (where door leads TO)
		#                     blocking_room = puzzle room (same as room1, where puzzle is)
		# Use original_room1 and original_room2 from door_data (which may have been swapped)
		if "original_room1" in door_data and door_data.original_room1:
			door.room1 = door_data.original_room1 # This is always the puzzle room
		elif "door" in door_data and door_data.door is Dictionary:
			# Fallback to original door if original_room1 not set
			var original_door = door_data.door
			if "room1" in original_door and original_door.room1:
				door.room1 = original_door.room1
		
		if "original_room2" in door_data and door_data.original_room2:
			door.room2 = door_data.original_room2 # This is always the other room
		elif "door" in door_data and door_data.door is Dictionary:
			# Fallback to original door if original_room2 not set
			var original_door = door_data.door
			if "room2" in original_door and original_door.room2:
				door.room2 = original_door.room2
		
		# CRITICAL: For StoneDoor/GateDoor, verify door.room1 matches blocking_room
		# The door should be IN the puzzle room (room1 == blocking_room)
		if (door_data.type == "StoneDoor" or door_data.type == "GateDoor") and door.blocking_room and not door.blocking_room.is_empty():
			if not door.room1 or door.room1.is_empty():
				push_error("GameWorld: ERROR - Blocking door ", door.name, " has no room1! Cannot verify it's in puzzle room! Removing it.")
				door.queue_free()
				continue
			
			# Verify room1 (where door is) matches blocking_room (puzzle room)
			var room1_matches_blocking = (door.room1.x == door.blocking_room.x and \
										  door.room1.y == door.blocking_room.y and \
										  door.room1.w == door.blocking_room.w and \
										  door.room1.h == door.blocking_room.h)
			
			if not room1_matches_blocking:
				push_error("GameWorld: ERROR - Blocking door ", door.name, " room1 (", door.room1.x, ",", door.room1.y, ") doesn't match blocking_room (", door.blocking_room.x, ",", door.blocking_room.y, ")! This door is NOT in the puzzle room! Removing it.")
				door.queue_free()
				continue
			
			LogManager.log("GameWorld: Blocking door " + str(door.name) + " verified - room1 (" + str(door.room1.x) + "," + str(door.room1.y) + ") == blocking_room (" + str(door.blocking_room.x) + "," + str(door.blocking_room.y) + ") - door is IN puzzle room", LogManager.CATEGORY_DUNGEON)
		
		# Set multiplayer authority BEFORE adding to scene
		if multiplayer.has_multiplayer_peer():
			door.set_multiplayer_authority(1)
		
		# CRITICAL: Set position BEFORE adding to scene tree (so _ready() can use it)
		door.global_position = door_data.position if "position" in door_data else Vector2.ZERO
		
		# Add to scene (this triggers _ready() which will use the position we just set)
		entities_node.add_child(door)
		# Re-apply name after add_child to avoid auto-renaming
		door.name = "BlockingDoor_%d" % i
		door.set_meta("door_index", i)
		
		# Apply any pending door state sync that arrived before this door spawned
		if pending_door_states.has(door.name):
			_apply_pending_door_state(door)
		
		# NOTE: Doors are connected to room triggers automatically by room_trigger._find_room_entities()
		# No need to manually connect them here
		
		# CRITICAL SAFETY CHECK: Verify door is for a puzzle room (StoneDoor/GateDoor should ONLY exist in puzzle rooms)
		if door_data.type == "StoneDoor" or door_data.type == "GateDoor":
			if not "blocking_room" in door_data or door_data.blocking_room.is_empty():
				push_error("GameWorld: ERROR - Blocking door ", door.name, " (", door_data.type, ") has no blocking_room! This door should not exist!")
				door.queue_free()
				continue
			
			if not "puzzle_type" in door_data:
				push_error("GameWorld: ERROR - Blocking door ", door.name, " (", door_data.type, ") has no puzzle_type! This door should not exist!")
				door.queue_free()
				continue
		
		# CRITICAL: Verify that this door actually has puzzle elements
		# Puzzle elements should already be created in dungeon_generator, but verify they exist
		var has_puzzle_element = false
		
		# Spawn floor switch if this door requires one (puzzle_type is "switch_walk" or "switch_pillar")
		if "puzzle_type" in door_data and (door_data.puzzle_type == "switch_walk" or door_data.puzzle_type == "switch_pillar"):
			if "floor_switch_position" in door_data or ("switch_data" in door_data and door_data.switch_data.has("position")):
				var switch_pos = door_data.floor_switch_position if "floor_switch_position" in door_data else door_data.switch_data.position
				var switch_tile_x = door_data.switch_tile_x if "switch_tile_x" in door_data else door_data.switch_data.tile_x
				var switch_tile_y = door_data.switch_tile_y if "switch_tile_y" in door_data else door_data.switch_data.tile_y
				var switch_type = door_data.switch_type if "switch_type" in door_data else ("walk" if door_data.puzzle_type == "switch_walk" else "pillar")
				var switch_weight = door_data.switch_required_weight if "switch_required_weight" in door_data else (1.0 if switch_type == "walk" else 5.0)
				
				# CRITICAL: Check if switch already exists for THIS SPECIFIC ROOM (to avoid duplicates)
				# Only connect to switches in the SAME blocking_room - never connect across rooms!
				var existing_switch = null
				var door_blocking_room = door_data.blocking_room if "blocking_room" in door_data else {}
				
				# CRITICAL: Verify door has valid blocking_room before searching for switches
				if door_blocking_room.is_empty():
					push_error("GameWorld: ERROR - Door ", door.name, " has empty blocking_room! Cannot find switches!")
					continue
				
				for existing in get_tree().get_nodes_in_group("floor_switch"):
					if not is_instance_valid(existing):
						continue
					
					# CRITICAL: Check ROOM FIRST (most important), then position
					# Switches MUST have switch_room metadata set when spawned
					if not existing.has_meta("switch_room"):
						continue # Switch has no room metadata - skip it (can't verify it's in the right room)
					
					var existing_switch_room = existing.get_meta("switch_room")
					if existing_switch_room.is_empty():
						continue # Invalid room data
					
					# CRITICAL: Verify switch is in the SAME room as door (check room FIRST)
					var room_match = (existing_switch_room.x == door_blocking_room.x and \
									  existing_switch_room.y == door_blocking_room.y and \
									  existing_switch_room.w == door_blocking_room.w and \
									  existing_switch_room.h == door_blocking_room.h)
					
					if not room_match:
						# Switch is in a different room - DO NOT connect, skip it
						continue
					
					# Room matches - now check position (must be exact match)
					var pos_match = existing.global_position.distance_to(switch_pos) < 1.0
					if pos_match:
						# Both room AND position match - this is the correct switch
						existing_switch = existing
						LogManager.log("GameWorld: Found existing switch " + str(existing.name) + " in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ") at position " + str(existing.global_position) + " matching door room and position", LogManager.CATEGORY_DUNGEON)
						break
				
				if existing_switch:
					# CRITICAL: Double-check room match before connecting
					var existing_switch_room_final = existing_switch.get_meta("switch_room")
					var final_room_match = false
					if existing_switch_room_final and not existing_switch_room_final.is_empty() and door_blocking_room and not door_blocking_room.is_empty():
						final_room_match = (existing_switch_room_final.x == door_blocking_room.x and \
											existing_switch_room_final.y == door_blocking_room.y and \
											existing_switch_room_final.w == door_blocking_room.w and \
											existing_switch_room_final.h == door_blocking_room.h)
					
					if final_room_match:
						# Switch already exists in the SAME room - connect door to existing switch
						door.connected_switches.append(existing_switch)
						has_puzzle_element = true
						LogManager.log("GameWorld: Connected door " + str(door.name) + " (room: " + str(door_blocking_room.get("x", "?")) + "," + str(door_blocking_room.get("y", "?")) + ") to existing switch " + str(existing_switch.name) + " in SAME room", LogManager.CATEGORY_DUNGEON)
						
						# If this is a pillar switch, ensure a pillar exists in the room
						# Check if switch is a pillar switch (check both door_data and existing switch)
						var is_pillar_switch = (switch_type == "pillar")
						if not is_pillar_switch and "switch_type" in existing_switch:
							is_pillar_switch = (existing_switch.switch_type == "pillar")
						
						if is_pillar_switch:
							# Check if we've already verified/placed a pillar for this room
							var room_key = str(door_blocking_room.x) + "," + str(door_blocking_room.y)
							if not rooms_with_pillars.has(room_key):
								# First time checking this room - check if pillar exists
								var pillar_exists_in_room = false
								for obj in get_tree().get_nodes_in_group("interactable_object"):
									if is_instance_valid(obj):
										var obj_type = obj.object_type if "object_type" in obj else ""
										if obj_type == "Pillar":
											if obj.has_meta("room"):
												var obj_room = obj.get_meta("room")
												if obj_room and not obj_room.is_empty():
													if obj_room.x == door_blocking_room.x and obj_room.y == door_blocking_room.y and \
													   obj_room.w == door_blocking_room.w and obj_room.h == door_blocking_room.h:
														pillar_exists_in_room = true
														LogManager.log("GameWorld: Found existing pillar in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ")", LogManager.CATEGORY_DUNGEON)
														break
								
								# Pillars should already be in dungeon generation data
								# Just mark room as checked (don't place extra pillars - they won't sync)
								if pillar_exists_in_room:
									LogManager.log("GameWorld: Found existing pillar in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ")", LogManager.CATEGORY_DUNGEON)
								else:
									push_warning("GameWorld: WARNING - Pillar switch found but no pillar in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ") - pillar should have been placed during dungeon generation!")
									rooms_with_pillars[room_key] = true
					else:
						push_error("GameWorld: ERROR - Attempted to connect door ", door.name, " to switch ", existing_switch.name, " but rooms don't match! Door room: (", door_blocking_room.get("x", "?"), ",", door_blocking_room.get("y", "?"), "), Switch room: (", existing_switch_room_final.get("x", "?") if existing_switch_room_final else "?", ",", existing_switch_room_final.get("y", "?") if existing_switch_room_final else "?", ")")
						# Don't connect - spawn a new switch instead
						existing_switch = null
				else:
					# Spawn new switch - CRITICAL: Only spawn if we have valid room data
					if not door_blocking_room or door_blocking_room.is_empty():
						push_error("GameWorld: ERROR - Cannot spawn switch for door ", door.name, " - no blocking_room!")
						continue
					
					# CRITICAL: Verify switch position matches door_data switch position exactly
					# If switch_room in door_data doesn't match blocking_room, it's an error
					if "switch_room" in door_data:
						var door_switch_room = door_data.switch_room
						if door_switch_room and not door_switch_room.is_empty():
							var switch_room_matches = (door_switch_room.x == door_blocking_room.x and \
													   door_switch_room.y == door_blocking_room.y and \
													   door_switch_room.w == door_blocking_room.w and \
													   door_switch_room.h == door_blocking_room.h)
							if not switch_room_matches:
								push_error("GameWorld: ERROR - Door ", door.name, " switch_room (", door_switch_room.x, ",", door_switch_room.y, ") doesn't match blocking_room (", door_blocking_room.x, ",", door_blocking_room.y, ")! This is a bug!")
								door.queue_free()
								continue
					
					var switch = _spawn_floor_switch(switch_pos, switch_weight, switch_tile_x, switch_tile_y, switch_type, door_blocking_room)
					if switch:
						# CRITICAL: Verify switch has room metadata set (should be set in _spawn_floor_switch)
						if not switch.has_meta("switch_room"):
							push_error("GameWorld: ERROR - Switch ", switch.name, " was spawned without switch_room metadata! Setting it now as fallback.")
							switch.set_meta("switch_room", door_blocking_room) # Set it now as fallback
						
						# CRITICAL: Verify switch room matches door blocking_room before connecting
						# This ensures switches are ONLY connected to doors in the SAME room
						var switch_room_check = switch.get_meta("switch_room")
						if switch_room_check and not switch_room_check.is_empty() and door_blocking_room and not door_blocking_room.is_empty():
							var room_match_before_connect = (switch_room_check.x == door_blocking_room.x and \
															 switch_room_check.y == door_blocking_room.y and \
															 switch_room_check.w == door_blocking_room.w and \
															 switch_room_check.h == door_blocking_room.h)
							
							if room_match_before_connect:
								# Connect switch to door ONLY if rooms match exactly
								door.connected_switches.append(switch)
								has_puzzle_element = true
								LogManager.log("GameWorld: Spawned switch " + str(switch.name) + " in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ") and connected to door " + str(door.name) + " in SAME room", LogManager.CATEGORY_DUNGEON)
								
								# If this is a pillar switch, verify pillar exists (should be in dungeon generation data)
								if switch_type == "pillar":
									var room_key = str(door_blocking_room.x) + "," + str(door_blocking_room.y)
									if not rooms_with_pillars.has(room_key):
										# Check if pillar exists in the room
										var pillar_exists = false
										for obj in get_tree().get_nodes_in_group("interactable_object"):
											if is_instance_valid(obj):
												var obj_type = obj.object_type if "object_type" in obj else ""
												if obj_type == "Pillar":
													if obj.has_meta("room"):
														var obj_room = obj.get_meta("room")
														if obj_room and not obj_room.is_empty():
															if obj_room.x == door_blocking_room.x and obj_room.y == door_blocking_room.y and \
															   obj_room.w == door_blocking_room.w and obj_room.h == door_blocking_room.h:
																pillar_exists = true
																LogManager.log("GameWorld: Found existing pillar for new pillar switch in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ")", LogManager.CATEGORY_DUNGEON)
																break
										if not pillar_exists:
											push_warning("GameWorld: WARNING - New pillar switch spawned but no pillar found in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ") - pillar should have been placed during dungeon generation!")
										rooms_with_pillars[room_key] = true
									else:
										LogManager.log("GameWorld: Already checked for pillar in room (" + str(door_blocking_room.x) + "," + str(door_blocking_room.y) + ")", LogManager.CATEGORY_DUNGEON)
							else:
								push_error("GameWorld: ERROR - Switch ", switch.name, " room (", switch_room_check.x, ",", switch_room_check.y, ") doesn't match door blocking_room (", door_blocking_room.x, ",", door_blocking_room.y, ")! NOT connecting! Removing switch.")
								switch.queue_free() # Remove the switch since it's in wrong room
								has_puzzle_element = false # Don't count this as puzzle element
						else:
							push_error("GameWorld: ERROR - Switch ", switch.name, " or door ", door.name, " has invalid room data! Switch room: ", switch_room_check, ", Door room: ", door_blocking_room)
							switch.queue_free() # Remove invalid switch
							has_puzzle_element = false
					else:
						push_warning("GameWorld: WARNING - Failed to spawn floor switch for door ", door.name, "!")
		
		# Place key in room if this is a KeyDoor
		if door_data.type == "KeyDoor" and "key_room" in door_data:
			_place_key_in_room(door_data.key_room)
			has_puzzle_element = true # KeyDoors are always valid
		
		# Spawn enemy spawners if this door requires enemies (puzzle_type is "enemy")
		if "puzzle_type" in door_data and door_data.puzzle_type == "enemy":
			LogManager.log("GameWorld: ===== Door " + str(door.name) + " has puzzle_type 'enemy' - checking for enemy_spawners =====", LogManager.CATEGORY_DUNGEON)
			if "enemy_spawners" in door_data and door_data.enemy_spawners is Array:
				LogManager.log("GameWorld: Door has enemy_spawners array with " + str(door_data.enemy_spawners.size()) + " spawners", LogManager.CATEGORY_DUNGEON)
				var spawner_created = false
				for spawner_data in door_data.enemy_spawners:
					if spawner_data is Dictionary and spawner_data.has("position"):
						# Check if spawner already exists for this room (to avoid duplicates)
						var existing_spawner = null
						for existing in get_tree().get_nodes_in_group("enemy_spawner"):
							if existing.global_position.distance_to(spawner_data.position) < 1.0:
								existing_spawner = existing
								break
						
						if existing_spawner:
							# Spawner already exists - just verify it's set up correctly
							existing_spawner.set_meta("blocking_room", door_data.blocking_room)
							spawner_created = true
							print("GameWorld: Found existing spawner ", existing_spawner.name, " for door ", door.name)
						else:
							# Spawn new spawner
							var spawner = _spawn_enemy_spawner(
								spawner_data.position,
								spawner_data.room if spawner_data.has("room") else door_data.blocking_room,
								spawner_data # Pass spawner_data to access spawn_once flag
							)
							if spawner:
								# Store reference to door for spawner (optional - spawner will be found by room trigger)
								spawner.set_meta("blocking_room", door_data.blocking_room)
								spawner_created = true
								print("GameWorld: Spawned enemy spawner ", spawner.name, " for door ", door.name, " at ", spawner_data.position)
				if spawner_created:
					has_puzzle_element = true
			else:
				push_warning("GameWorld: WARNING - Failed to spawn enemy spawner for door ", door.name, "!")
				if "enemy_spawners" not in door_data:
					push_warning("GameWorld:   Reason: door_data has no 'enemy_spawners' key!")
				elif not door_data.enemy_spawners is Array:
					push_warning("GameWorld:   Reason: door_data.enemy_spawners is not an Array! Type: ", typeof(door_data.enemy_spawners))
				elif door_data.enemy_spawners.size() == 0:
					push_warning("GameWorld:   Reason: door_data.enemy_spawners array is empty!")
		else:
			if "puzzle_type" in door_data:
				LogManager.log("GameWorld: Door " + str(door.name) + " has puzzle_type '" + str(door_data.puzzle_type) + "' (not 'enemy')", LogManager.CATEGORY_DUNGEON)
		
		# CRITICAL: If door has no puzzle elements (neither switch nor spawner), this is an error
		# This should never happen if dungeon_generator logic is correct, but add safety check
		if door_data.type != "KeyDoor" and not has_puzzle_element:
			push_error("GameWorld: ERROR - Blocking door ", door.name, " (type: ", door_data.type, ") has no puzzle elements! This door should not exist!")
			print("GameWorld: Door data keys: ", door_data.keys())
			LogManager.log("GameWorld: Door puzzle_type: " + str(door_data.get("puzzle_type", "MISSING")), LogManager.CATEGORY_DUNGEON)
			print("GameWorld: Door has requires_switch: ", door_data.get("requires_switch", false))
			print("GameWorld: Door has requires_enemies: ", door_data.get("requires_enemies", false))
			print("GameWorld: Door has floor_switch_position: ", "floor_switch_position" in door_data)
			print("GameWorld: Door has enemy_spawners: ", "enemy_spawners" in door_data)
			# Remove the door since it's invalid - it was created without puzzle elements
			door.queue_free()
			LogManager.log("GameWorld: Removed invalid blocking door " + str(door.name) + " - it had no puzzle elements!", LogManager.CATEGORY_DUNGEON)
			continue # Skip to next door
	
	LogManager.log("GameWorld: Spawned " + str(blocking_doors.size()) + " blocking doors", LogManager.CATEGORY_DUNGEON)

func _spawn_floor_switch(i_position: Vector2, required_weight: float, tile_x: int, tile_y: int, switch_type: String = "walk", switch_room: Dictionary = {}) -> Node:
	# Spawn a floor switch using the scene file
	# switch_type: "walk" for walk-on switch (weight 1), "pillar" for pillar switch (weight 5)
	var switch_scene = load("res://scenes/floor_switch.tscn")
	if not switch_scene:
		push_error("ERROR: Could not load floor_switch scene!")
		return null
	
	var switch = switch_scene.instantiate()
	if not switch:
		push_error("ERROR: Could not instantiate floor_switch scene!")
		return null
	
	switch.name = "FloorSwitch_%d_%d" % [tile_x, tile_y]
	switch.add_to_group("floor_switch")
	
	# Set properties
	switch.switch_type = switch_type if switch_type == "walk" or switch_type == "pillar" else "walk"
	switch.required_weight = required_weight # Will be overridden in _ready() based on switch_type, but set it here too
	switch.switch_tile_position = Vector2i(tile_x, tile_y)
	
	# Set multiplayer authority
	if multiplayer.has_multiplayer_peer():
		switch.set_multiplayer_authority(1)
	
	# CRITICAL: Store switch_room metadata BEFORE adding to scene
	# This ensures switches can be matched to doors in the same room
	if switch_room and not switch_room.is_empty():
		switch.set_meta("switch_room", switch_room)
		LogManager.log("GameWorld: Set switch_room metadata for switch - room (" + str(switch_room.x) + ", " + str(switch_room.y) + ")", LogManager.CATEGORY_DUNGEON)
	else:
		push_warning("GameWorld: WARNING - Spawning switch without switch_room metadata! This may cause cross-room connections!")
	
	# Add to scene
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		entities_node.add_child(switch)
		switch.global_position = i_position
		
		# Update tilemap to show switch tile (initial inactive state)
		if dungeon_tilemap_layer:
			var initial_tile: Vector2i
			if switch_type == "pillar":
				initial_tile = Vector2i(16, 9) # Pillar switch inactive
			else:
				initial_tile = Vector2i(11, 9) # Walk-on switch inactive
			dungeon_tilemap_layer.set_cell(Vector2i(tile_x, tile_y), 0, initial_tile)
		
		var room_x_str = str(switch_room.get("x", "?")) if switch_room and not switch_room.is_empty() else "?"
		var room_y_str = str(switch_room.get("y", "?")) if switch_room and not switch_room.is_empty() else "?"
		LogManager.log("GameWorld: Spawned " + str(switch_type) + " floor switch at " + str(i_position) + " tile (" + str(tile_x) + ", " + str(tile_y) + "), room: (" + room_x_str + ", " + room_y_str + ")", LogManager.CATEGORY_DUNGEON)
		return switch
	
	return null

func _spawn_enemy_spawner(i_position: Vector2, room: Dictionary, spawner_data: Dictionary = {}) -> Node:
	# Spawn an enemy spawner for a blocking room
	var spawner_script = load("res://scripts/enemy_spawner.gd")
	if not spawner_script:
		push_error("ERROR: Could not load enemy_spawner script!")
		return null
	
	var spawner = Node2D.new()
	spawner.set_script(spawner_script)
	spawner.name = "EnemySpawner_%d_%d" % [room.x, room.y] if room and not room.is_empty() else "EnemySpawner_%d_%d" % [int(i_position.x), int(i_position.y)]
	spawner.add_to_group("enemy_spawner")
	
	# Set spawner properties - IMPORTANT: spawn_on_ready = false so enemies only spawn when player enters room
	spawner.spawn_on_ready = false # Don't spawn on ready - wait for room trigger
	spawner.respawn_time = 0.0 # Don't respawn - enemies spawn once when entering room
	spawner.max_enemies = 1 # One enemy per spawner
	
	# Check if this spawner should be destroyed after spawning once
	if spawner_data.has("spawn_once") and spawner_data.spawn_once:
		spawner.set_meta("spawn_once", true) # Mark spawner for destruction after spawning
	
	# Set enemy scenes (use default enemy types)
	# enemy_scenes is Array[PackedScene], so we need to properly type it
	var enemy_scenes: Array[PackedScene] = []
	var scene_paths = [
		"res://scenes/enemy_rat.tscn",
		"res://scenes/enemy_humanoid.tscn",
		"res://scenes/enemy_slime.tscn",
		"res://scenes/enemy_bat.tscn",
		"res://scenes/enemy_hand.tscn"
	]
	
	# Load scenes and add to typed array
	for path in scene_paths:
		var scene = load(path) as PackedScene
		if scene:
			enemy_scenes.append(scene)
	
	spawner.enemy_scenes = enemy_scenes
	
	# Set multiplayer authority
	if multiplayer.has_multiplayer_peer():
		spawner.set_multiplayer_authority(1)
	
	# Store room reference
	if room and not room.is_empty():
		spawner.set_meta("room", room)
	
	# Add to scene
	var entities_node = get_node_or_null("Entities")
	if entities_node:
		entities_node.add_child(spawner)
		spawner.global_position = i_position
		var room_x_str = str(room.x) if room and not room.is_empty() else "unknown"
		var room_y_str = str(room.y) if room and not room.is_empty() else "unknown"
		LogManager.log("GameWorld: ✓✓✓ Successfully spawned enemy spawner '" + str(spawner.name) + "' at " + str(i_position) + " for room at (" + room_x_str + ", " + room_y_str + ")", LogManager.CATEGORY_DUNGEON)
		LogManager.log("GameWorld:   Spawner has room metadata: " + str(spawner.has_meta("room")), LogManager.CATEGORY_DUNGEON)
		if spawner.has_meta("room"):
			var spawner_room = spawner.get_meta("room")
			var spawner_room_x = str(spawner_room.x) if spawner_room and not spawner_room.is_empty() else "none"
			var spawner_room_y = str(spawner_room.y) if spawner_room and not spawner_room.is_empty() else "none"
			var spawner_room_w = str(spawner_room.w) if spawner_room and not spawner_room.is_empty() else "none"
			var spawner_room_h = str(spawner_room.h) if spawner_room and not spawner_room.is_empty() else "none"
			LogManager.log("GameWorld:   Spawner room metadata: (" + spawner_room_x + ", " + spawner_room_y + ", " + spawner_room_w + "x" + spawner_room_h + ")", LogManager.CATEGORY_DUNGEON)
		LogManager.log("GameWorld:   Spawner in group 'enemy_spawner': " + str(spawner.is_in_group("enemy_spawner")), LogManager.CATEGORY_DUNGEON)
		var enemy_scenes_size = str(spawner.enemy_scenes.size()) if "enemy_scenes" in spawner else "N/A"
		LogManager.log("GameWorld:   Spawner enemy_scenes.size(): " + enemy_scenes_size, LogManager.CATEGORY_DUNGEON)
		return spawner
	
	return null

func _spawn_room_triggers():
	# Spawn room trigger areas for all rooms
	if dungeon_data.is_empty() or not dungeon_data.has("rooms"):
		return
	
	var rooms = dungeon_data.rooms
	if rooms == null or not rooms is Array:
		return
	
	var trigger_script = load("res://scripts/room_trigger.gd")
	if not trigger_script:
		push_error("ERROR: Could not load room_trigger script!")
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		push_error("ERROR: Could not find Entities node!")
		return
	
	LogManager.log("GameWorld: Spawning " + str(rooms.size()) + " room triggers", LogManager.CATEGORY_DUNGEON)
	
	var triggers_spawned = 0
	for i in range(rooms.size()):
		var room = rooms[i]
		if not room is Dictionary:
			LogManager.log("GameWorld: WARNING - Room at index " + str(i) + " is not a Dictionary, skipping", LogManager.CATEGORY_DUNGEON)
			continue
		
		var trigger = Area2D.new()
		trigger.set_script(trigger_script)
		trigger.name = "RoomTrigger_%d" % i
		trigger.add_to_group("room_trigger")
		
		# Set room data
		trigger.room = room
		
		# Create collision shape covering ONLY the room interior (no overlap with adjacent rooms)
		var collision_shape = CollisionShape2D.new()
		var rect_shape = RectangleShape2D.new()
		var tile_size = 16
		# Room interior is from room.x + 2 to room.x + room.w - 2 (excluding 2-tile walls)
		# This ensures the trigger only covers THIS room, not adjacent rooms or doorways
		var room_world_x = (room.x + 2) * tile_size
		var room_world_y = (room.y + 2) * tile_size
		var room_world_w = (room.w - 4) * tile_size # Width excluding 2-tile walls on each side
		var room_world_h = (room.h - 4) * tile_size # Height excluding 2-tile walls on each side
		rect_shape.size = Vector2(room_world_w, room_world_h)
		collision_shape.shape = rect_shape
		# Position collision shape at center of room (relative to Area2D)
		collision_shape.position = Vector2(room_world_w / 2.0, room_world_h / 2.0)
		trigger.add_child(collision_shape)
		
		# Set Area2D global position to the top-left corner of the room interior
		# This ensures the trigger ONLY covers this specific room
		trigger.global_position = Vector2(room_world_x, room_world_y)
		
		# Set multiplayer authority
		if multiplayer.has_multiplayer_peer():
			trigger.set_multiplayer_authority(1)
		
		# Add to scene
		entities_node.add_child(trigger)
		triggers_spawned += 1
		LogManager.log("GameWorld: Added room trigger " + str(trigger.name) + " for room (" + str(room.x) + ", " + str(room.y) + ") - " + str(triggers_spawned) + "/" + str(rooms.size()), LogManager.CATEGORY_DUNGEON)
	
	LogManager.log("GameWorld: Spawned " + str(triggers_spawned) + " room triggers (out of " + str(rooms.size()) + " rooms)", LogManager.CATEGORY_DUNGEON)

func _place_key_in_room(room: Dictionary):
	# Place a key in the specified room (as loot)
	# Only run on server - keys are not synced via RPC, so clients should not spawn them
	if not multiplayer.is_server() and multiplayer.has_multiplayer_peer():
		return
	
	if room.is_empty():
		return
	
	var loot_scene = preload("res://scenes/loot.tscn")
	if not loot_scene:
		return
	
	var entities_node = get_node_or_null("Entities")
	if not entities_node:
		return
	
	# Find a valid floor position in the room
	var tile_size = 16
	var valid_positions = []
	
	# Room interior is from room.x + 2 to room.x + room.w - 2
	for x in range(room.x + 2, room.x + room.w - 2):
		for y in range(room.y + 2, room.y + room.h - 2):
			if x >= 0 and x < dungeon_data.map_size.x and y >= 0 and y < dungeon_data.map_size.y:
				if dungeon_data.grid[x][y] == 1: # Floor
					var world_x = x * tile_size + tile_size / 2.0
					var world_y = y * tile_size + tile_size / 2.0
					valid_positions.append(Vector2(world_x, world_y))
	
	if valid_positions.size() > 0:
		# Use deterministic seed for key placement (ensures same position on host and clients)
		var rng = RandomNumberGenerator.new()
		var key_seed = dungeon_seed + (room.x * 1000) + (room.y * 100) + 5000 # Offset to avoid collisions with other objects
		rng.seed = key_seed
		var key_pos = valid_positions[rng.randi() % valid_positions.size()]
		
		# Spawn key loot
		var key_loot = loot_scene.instantiate()
		key_loot.name = "KeyLoot_%d_%d" % [int(key_pos.x), int(key_pos.y)]
		key_loot.loot_type = key_loot.LootType.KEY
		
		# Assign unique loot ID for syncing/removal
		var loot_id = loot_id_counter
		loot_id_counter += 1
		key_loot.set_meta("loot_id", loot_id)
		
		# Set multiplayer authority
		if multiplayer.has_multiplayer_peer():
			key_loot.set_multiplayer_authority(1)
		
		entities_node.add_child(key_loot)
		key_loot.global_position = key_pos
		
		# Sync key loot spawn to clients (so pickup/removal works by ID)
		if multiplayer.has_multiplayer_peer():
			_rpc_to_ready_peers("_sync_loot_spawn", [key_pos, key_loot.LootType.KEY, Vector2.ZERO, 0.0, loot_id])
		
		print("GameWorld: Placed key in room at ", key_pos)

func _connect_door_to_room_trigger(door: Node):
	# Connect a door to its room trigger area
	# blocking_room is a variable in door.gd, so it should exist
	var blocking_room = door.blocking_room
	if not blocking_room or blocking_room.is_empty():
		return
	
	# Find the room trigger for this room
	for trigger in get_tree().get_nodes_in_group("room_trigger"):
		if is_instance_valid(trigger):
			# room is a variable in room_trigger.gd, compare by values
			var trigger_room = trigger.room
			if trigger_room and not trigger_room.is_empty() and \
			   trigger_room.x == blocking_room.x and trigger_room.y == blocking_room.y and \
			   trigger_room.w == blocking_room.w and trigger_room.h == blocking_room.h:
				# Connect door to trigger
				door.room_trigger_area = trigger
				# Add door to trigger's doors list (doors_in_room is a variable in room_trigger.gd)
				trigger.doors_in_room.append(door)
				break
