"""
STARWASTE - Game View
Main Arcade game view with rendering and input

PERFORMANCE OPTIMIZATIONS (Nov 2025):
✅ Cached Text objects for chat (was creating new objects every frame!)
✅ Cached Text objects for UI (FPS, position, status)
✅ Pyglet batch rendering for entity names (ONE draw call for all text!)
✅ Terrain-based SpriteLists with shared texture atlas (10 draw calls vs N)
✅ View frustum culling for entities (skip off-screen rendering)
✅ Squared distance calculations (avoid expensive sqrt in harvest)
✅ Lazy sprite loading for chunks (8 chunks per frame max)
✅ Entity sprite optimization (create on-demand, remove when off-screen)
"""

# ============================================================================
# DEBUG CONFIGURATION - Set to False for production (maximum performance!)
# ============================================================================
DEBUG_ENABLED = False  # Master switch - disables ALL debug output

# Individual debug categories (only work if DEBUG_ENABLED = True)
DEBUG_INIT = False     # Initialization messages (texture loading, setup) - SPAM!
DEBUG_CHUNKS = False    # Chunk load/unload messages  
DEBUG_NETWORK = False   # Network request/response messages
DEBUG_STATS = False     # Periodic stats (FPS, chunks, entities)
DEBUG_SPRITES = False   # Sprite loading messages

def debug_print(category: bool, *args, **kwargs):
    """Print only if DEBUG_ENABLED and category is True"""
    if DEBUG_ENABLED and category:
        console.print(*args, **kwargs)

import arcade
from arcade.types import Color
from shared.utils import console
from arcade.particles import (
    Emitter,
    FadeParticle,
    LifetimeParticle,
    EmitInterval,
)
from arcade.future.light import Light, LightLayer
from client.cone_light import ConeLight, ConeLightLayer
import asyncio
import time
import traceback
import numpy as np
from PIL import Image
from typing import Dict, Optional
import threading
import random
from concurrent.futures import ThreadPoolExecutor
from queue import Queue, Empty
from shared.constants import (
    SCREEN_WIDTH, SCREEN_HEIGHT, SCREEN_TITLE,
    TILE_SIZE, CHUNK_SIZE, PLAYER_SPEED, COLORS
)
from shared.inventory import Inventory
from shared.items import get_item
from client.network import NetworkClient
from client.sprite_loader import DirectionalSprite, DirectionalSprite8Way, get_sprite_manager
from client.inventory_ui import InventoryUI
from client.performance_monitor import PerformanceMonitor
from client.sound_manager import SoundManager
from client.i18n import get_i18n


class Chunk:
    """Client-side chunk container (receives data from server)"""
    
    def __init__(self, chunk_x: int, chunk_y: int, tiles: list = None, corners: list = None):
        self.chunk_x = chunk_x
        self.chunk_y = chunk_y
        # Store tiles as 2D array for easy access
        if tiles:
            # Convert flat list to 2D array
            self.tiles = []
            for y in range(CHUNK_SIZE):
                row = tiles[y * CHUNK_SIZE:(y + 1) * CHUNK_SIZE]
                self.tiles.append(row)
        else:
            self.tiles = [[0] * CHUNK_SIZE for _ in range(CHUNK_SIZE)]
        
        # Store corner data (NW, NE, SW, SE) for each tile
        if corners:
            self.tile_corners = []
            for y in range(CHUNK_SIZE):
                row = corners[y * CHUNK_SIZE:(y + 1) * CHUNK_SIZE]
                self.tile_corners.append(row)
        else:
            # Fallback: use tile type for all corners
            self.tile_corners = [[(t, t, t, t) for t in row] for row in self.tiles]
    
    def get_tile(self, local_x: int, local_y: int) -> int:
        """Get tile at local chunk coordinates"""
        if 0 <= local_y < len(self.tiles) and 0 <= local_x < len(self.tiles[0]):
            return self.tiles[local_y][local_x]
        return 0
    
    def get_corners(self, local_x: int, local_y: int):
        """Get corner terrain types (NW, NE, SW, SE)"""
        if 0 <= local_y < len(self.tile_corners) and 0 <= local_x < len(self.tile_corners[0]):
            return self.tile_corners[local_y][local_x]
        # Fallback
        tile = self.get_tile(local_x, local_y)
        return (tile, tile, tile, tile)


class GameView(arcade.View):
    """Main game view"""
    
    def __init__(self, network_client: NetworkClient = None):
        super().__init__()

        self.i18n = get_i18n()
        
        # Enable FPS tracking
        arcade.enable_timings()

        # Network messages arrive on a background thread (see client/main.py).
        # Only enqueue there; process on the Arcade/main thread in on_update.
        self._network_message_queue: Queue = Queue()
        
        # Network connection (required - no offline mode)
        self.network = network_client
        if self.network:
            self.network.set_message_callback(self.on_network_message)
        self.world_seed = None
        self.biome_id = None
        self.player_username = "Player"
        
        # Player state
        self.player_x = 0.0
        self.player_y = 0.0
        
        # Environmental status (from server)
        self.player_radiation = 0.0  # 0-100
        self.player_cold = 0.0  # 0-100
        
        # Day/Night cycle
        self.game_time = 360.0  # Game time in minutes (starts at 6:00 AM)
        
        # 🔥 OPTIMIZATION: Cache viewport bounds to avoid recalculation every frame
        self._cached_viewport = None
        self._cached_camera_pos = (0.0, 0.0)
        self._cached_camera_zoom = 1.0
        self.player_vx = 0.0
        self.player_vy = 0.0
        self.server_x = 0.0  # Server-authoritative position
        self.server_y = 0.0
        # Movement smoothing/prediction
        # NOTE: Without prediction, local movement depends on server updates and will feel laggy
        # (and can stutter badly when chunk streaming saturates TCP).
        self.enable_client_prediction = True
        self.enable_client_collision_prediction = True
        self._client_collision_radius_px = 200.0  # matches server resource collision culling
        self._client_collision_lookahead_px = 1.0  # probe slightly ahead in movement direction
        # Make client collision slightly conservative so it stops BEFORE server and avoids rubberband.
        self._client_resource_collision_margin_px = 2.0
        self._have_server_position = False
        self._render_correction_x = 0.0
        self._render_correction_y = 0.0
        self._player_feet_offset_y = 28  # Must match server collision point (see server.check_player_collision_after_move)
        self._correction_snap_distance_px = 120.0
        self._server_stuck_ticks = 0
        self._last_server_x = 0.0
        self._last_server_y = 0.0
        # Tuned/adapted at runtime based on measured RTT (see on_update).
        self._server_stuck_snap_distance_px = 32.0
        self.server_vx = 0.0
        self.server_vy = 0.0
        self._was_moving = False
        self._stop_grace_timer = 0.0
        self._stop_grace_seconds = 0.12
        self._stop_snap_epsilon_px = 3.0
        # Start grace prevents "snap back" right after starting to move under RTT.
        self._start_grace_timer = 0.0
        self._start_grace_seconds = 0.10
        self.last_sent_vx = None  # Track last sent velocity to avoid redundant updates
        self.last_sent_vy = None

        # Movement debug (console)
        self.movement_debug_console = False
        self._movement_debug_last_print = 0.0
        self._movement_debug_print_interval = 0.25
        
        # World data (received from server)
        self.biome_assets_loaded = False  # Track if pre-loading done
        self.loaded_chunks = {}  # (chunk_x, chunk_y) -> Chunk object
        
        # NEW: Terrain-based SpriteLists (Arcade-style batching!)
        # One SpriteList per terrain type = optimal GPU batching
        self.terrain_sprite_lists = {}  # terrain_id -> arcade.SpriteList
        self.tracked_tiles = {}  # (tile_x, tile_y) -> arcade.Sprite (for fast removal)
        
        # Track which chunks have sprites created (for unloading)
        self.chunks_with_sprites = set()  # (chunk_x, chunk_y)
        
        # 🔥 ASYNC CHUNK RENDERING: PIL/numpy in worker threads!
        self.chunk_render_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="chunk_render")
        # Queue of (chunk_key, PIL.Image) ready for texture creation (producer=worker threads, consumer=main thread)
        self.ready_pil_images: Queue = Queue()
        
        # Tile textures and wang caches
        
        # Resource sprites (trees, rocks, etc. - using SpriteList for batch rendering)
        self.resource_sprite_list = arcade.SpriteList()
        self.resource_sprites = {}  # (entity_id) -> arcade.Sprite for fast removal
        self.tree_collision_data = {}  # tree_id -> list of (x,y) polygon points
        self.rock_collision_data = {}  # rock_id -> list of (x,y) polygon points
        self.tree_horizon_data = {}  # tree_id -> (x, y) horizon point for Y-sorting
        self.rock_horizon_data = {}  # rock_id -> (x, y) horizon point for Y-sorting
        self.resource_transparency_enabled = False  # Enable/disable transparency when player is behind resources
        self.particle_spawn_timer = 0.0
        self.wang_texture_cache = {}  # (corner_types, biome_id) -> arcade.Texture (PRE-GENERATED!)
        self.wang_pil_cache = {}  # (corner_types, biome_id) -> PIL.Image (for chunk baking!)
        self.chunk_texture_cache = {}  # chunk_key -> arcade.Texture (GPU rendered chunks - MUST PERSIST!)
        self.tileset_cache = {}  # (biome_id, tileset_path) -> tileset_data from load_tileset_with_tiles()
        self.biome_config = None  # Loaded ONCE at startup
        
        # 🔥 OPTIMIZATION: Persistent SpriteList for visible terrain chunks (avoid creating new list every frame!)
        # 🔥 TEMPORARY visible terrain list (rebuilt each frame with viewport culling)
        # NO spatial hash - just for drawing visible sprites!
        # LAZY=TRUE - defer GPU upload until draw() for better batching
        self.visible_terrain_list = arcade.SpriteList(use_spatial_hash=False, lazy=True)
        self._visible_sprites_cache = []  # Cache visible sprites between frames
        
        # Other entities (with interpolation)
        self.entities: Dict[int, dict] = {}
        self.entity_display_pos: Dict[int, tuple] = {}  # Smooth display positions
        self.resource_entities: Dict[int, dict] = {}  # 🔥 OPTIMIZED: Pre-filtered resources (type 5) for faster harvest lookup
        self.item_entities: Dict[int, dict] = {}  # Pre-filtered ground items (type 3)

        # Item drop icon cache (sprite path -> arcade.Texture)
        self._item_drop_texture_cache: Dict[str, arcade.Texture] = {}
        self._item_drop_sprite_cache: Dict[str, arcade.Sprite] = {}

        # Auto-pickup throttle
        self._last_pickup_attempt_time = 0.0

        # One-shot pickup animation triggers (client-side)
        self._pending_pickup_entity_id: Optional[int] = None
        self._pending_pickup_requested_at: float = 0.0
        self._last_inventory_total_qty: int = 0
        self._last_harvesting_target = None
        
        # Text rendering (using pyglet Batch for optimal performance!)
        # All entity names drawn in ONE batch = ONE draw call!
        import pyglet
        self.text_batch = pyglet.graphics.Batch()  # Shared batch for all text
        self.entity_name_labels = {}  # entity_id -> pyglet.text.Label
        self.entity_name_shadow_labels = {}  # entity_id -> pyglet.text.Label (shadow)
        self.player_name_label = None  # Our player's name label
        self.player_name_shadow_label = None  # Our player's name label shadow
        self.last_visible_entity_count = 0  # Track visible entities for debug
        
        # Tile debug
        self.last_tile_debug_time = 0
        
        # Input
        self.keys_pressed = set()
        
        # Mouse state for continuous actions
        self.mouse_pressed = False
        self.mouse_x = 0
        self.mouse_y = 0
        self.harvest_cooldown = 0.0  # Time until next harvest attempt
        self.harvest_cooldown_time = 0.1  # Seconds between harvest attempts when holding
        
        # Custom cursor for harvestable objects
        self.pickaxe_cursor = None  # Will be loaded from assets
        self.default_cursor = self.window.get_system_mouse_cursor(self.window.CURSOR_DEFAULT)
        self.current_cursor_is_pickaxe = False
        
        # Camera (Arcade 3.3.3 uses Camera2D) with zoom support
        self.camera = arcade.Camera2D()
        
        # Zoom settings
        self.camera_zoom = 1.0  # Default zoom level
        self.camera_min_zoom = 0.5  # Max zoom out
        self.camera_max_zoom = 3.0  # Max zoom in
        
        # Sprites (using SpriteList for efficient batch rendering)
        self.player_sprite: Optional[DirectionalSprite8Way] = None
        self.entity_sprites: Dict[int, arcade.Sprite] = {}  # entity_id -> sprite (can be DirectionalSprite or DirectionalSprite8Way)
        self.entity_sprite_list = arcade.SpriteList(
            use_spatial_hash=True,  # Fast collision detection for entities (players, resources)
            lazy=False  # Entities added incrementally, no benefit from lazy
        )
        self.use_sprites = True  # Try to use sprite sheets (fallback to circles if missing)
        
        # Sprite manager
        self.sprite_manager = get_sprite_manager()
        
        # Background color (using Arcade built-in color conversion)
        arcade.set_background_color(Color.from_hex_string(COLORS["space_dark"]))
        
        # Create Text objects for GUI (will be positioned in update_ui_positions())
        self.seed_text = arcade.Text("", 10, 10, arcade.color.YELLOW, 14, bold=True)
        self.fps_text = arcade.Text("", 10, 10, arcade.color.WHITE, 14)
        self.pos_text = arcade.Text("", 10, 10, arcade.color.WHITE, 14)
        self.status_text = arcade.Text("", 10, 10, arcade.color.WHITE, 14)
        self.ping_text = arcade.Text("", 10, 10, arcade.color.WHITE, 14)
        self.env_status_text = arcade.Text("", 10, 10, arcade.color.WHITE, 14)
        self.time_text = arcade.Text("", 10, 10, arcade.color.YELLOW, 14, bold=True)
        self.zoom_text = arcade.Text("", 10, 10, arcade.color.CYAN, 14, bold=True)
        self.title_text = arcade.Text("STARWASTE", 10, 10,
                                     Color.from_hex_string(COLORS["rust_orange"]), 24,
                                     anchor_x="center", bold=True)

        # Toggleable movement/network debug overlay (F4)
        self.show_movement_debug = False
        self.movement_debug_text = arcade.Text("", 10, 10, arcade.color.WHITE, 12)

        # Drop-shadow copies for readability on bright backgrounds (cached like main texts)
        shadow_col = (0, 0, 0, 210)
        self.seed_text_shadow = arcade.Text("", 10, 10, shadow_col, 14, bold=True)
        self.fps_text_shadow = arcade.Text("", 10, 10, shadow_col, 14)
        self.pos_text_shadow = arcade.Text("", 10, 10, shadow_col, 14)
        self.status_text_shadow = arcade.Text("", 10, 10, shadow_col, 14)
        self.ping_text_shadow = arcade.Text("", 10, 10, shadow_col, 14)
        self.env_status_text_shadow = arcade.Text("", 10, 10, shadow_col, 14)
        self.time_text_shadow = arcade.Text("", 10, 10, shadow_col, 14, bold=True)
        self.zoom_text_shadow = arcade.Text("", 10, 10, shadow_col, 14, bold=True)
        self.title_text_shadow = arcade.Text(
            "STARWASTE", 10, 10, shadow_col, 24, anchor_x="center", bold=True
        )

        self.movement_debug_text_shadow = arcade.Text("", 10, 10, shadow_col, 12)
        
        # Update UI positions based on initial window size
        self.update_ui_positions()

        # State update timing debug
        self._last_state_update_received_at = None
        
        # Ping timer
        self.ping_timer = 0.0
        
        # Debug stats (throttled logging)
        self.last_debug_time = 0
        self.debug_interval = 5.0  # Log stats every 5 seconds
        self.frame_count = 0
        self.debug_initialized = False  # Flag to skip first incomplete interval
        
        # Coordinate sync debug (1x per second)
        self.last_coord_debug = 0
        self.coord_debug_interval = 1.0
        
        # 🔥 CHUNK MANAGEMENT THROTTLING - Don't check EVERY frame!
        self.last_chunk_check = 0
        self.chunk_check_interval = 0.25  # Check for new chunks only every 0.25s (4fps)
        
        # Chat system
        self.chat_messages = []  # List of (username, message, timestamp) tuples
        self.chat_input = ""
        self.chat_active = False
        self.chat_display_messages = 5  # Show last 5 messages when chat closed
        self.chat_history_size = 50  # Keep last 50 messages in history
        self.chat_fade_timer = 0.0
        self.chat_fade_duration = 10.0  # Messages disappear after 10 seconds
        
        # PERFORMANCE: Cache chat text objects to avoid creating new ones every frame!
        self.chat_text_objects = []  # List of arcade.Text objects (reused)
        self.chat_input_text = arcade.Text("", 10, 65, arcade.color.WHITE, 14)  # Cached input text
        
        # Inventory system
        self.inventory = Inventory(main_slots=40)
        self.inventory_ui = InventoryUI(self.inventory, self.window)
        self.inventory_ui.set_move_request_callback(self._request_inventory_move)
        self.inventory_ui.set_drop_request_callback(self._request_drop_item)
        
        # Performance monitor
        self.perf_monitor = PerformanceMonitor()
        self.show_perf_monitor = False  # Toggle with F3
        
        # Sound system
        self.sound_manager = SoundManager()
        
        # Weather particles (snow for frozen world)
        self.snow_emitter = None
        
        # Weather effects (fog for toxic wasteland)
        self.fog_enabled = False
        
        # --- Light system ---
        self.light_layer = None  # Will be created after window is ready
        self.player_light = None  # Circular light on player
        self.cone_light_layer = None  # Cone light layer
        self.player_cone_light = None  # Directional cone light (flashlight)
        self.remote_cone_lights: dict[int, ConeLight] = {}
        self.remote_facing_angles: dict[int, float] = {}
        self.lighting_enabled = True  # Toggle with L key
        self.player_facing_angle = -90  # Current facing direction in degrees (start facing down)
        
        # Add some test items
        from shared.items import ItemType
        self.inventory.add_item(ItemType.RESOURCE_SCRAP.value, 50)
        self.inventory.add_item(ItemType.RESOURCE_WOOD.value, 30)
        self.inventory.add_item(ItemType.WEAPON_MELEE_SCRAP_SWORD.value, 1)
        self.inventory.add_item(ItemType.TOOL_PICKAXE.value, 1)

        self._last_inventory_total_qty = self._inventory_total_quantity(self.inventory)

        # Loot feedback popups (harvest / pickup)
        # Stored as list of dicts: {"item_id": str|None, "qty": int, "t0": float}
        self._loot_popups = []
        self._loot_popup_duration_s = 0.9
        self._loot_popup_text_cache = []
        self._loot_popup_text_shadow_cache = []

        # Harvest animation (client-only feedback)
        self._last_harvest_anim_time = 0.0
        self._last_harvest_anim_target_id = None
        # Harvest VFX timing (pulse shake to match local "hit" feedback)
        self._harvest_fx_target_id = None
        self._harvest_fx_last_intent_t = 0.0  # perf_counter
        self._harvest_fx_last_hit_t = 0.0     # perf_counter
        self._harvest_fx_shake_duration_s = 0.14
        self._harvest_fx_shake_amp = 0.75
        self._harvest_fx_shake_freq = 78.0
        # Small delay so shake aligns better with perceived audio start
        self._harvest_fx_shake_delay_s = 0.1

        # Harvest completion VFX (one-shot shrink/fade of the resource)
        self._harvest_complete_fx = []
        self._harvest_complete_fx_duration_s = 0.38
        # Keep last-seen resource data for a short time after removal so completion VFX can spawn.
        self._resource_last_seen: dict[int, dict] = {}
        self._resource_last_seen_ttl_s = 2.0

        # Multiplayer visibility (remote harvest/pickup one-shots)
        self._resource_harvest_step_idx: dict[int, int] = {}
        self._harvest_fx_last_hit_by_target: dict[int, float] = {}
        self._harvest_complete_triggered_at: dict[int, float] = {}
        
        debug_print(DEBUG_INIT, "🎮 GameView initialized")

    def _inventory_total_quantity(self, inv: Inventory) -> int:
        """Best-effort total item quantity for detecting pickups/loot changes."""
        total = 0
        try:
            for stack in inv.items.values():
                if stack:
                    total += int(getattr(stack, "quantity", 0) or 0)
            for stack in inv.equipment.values():
                if stack:
                    total += int(getattr(stack, "quantity", 0) or 0)
        except Exception:
            return 0
        return total

    def _inventory_totals_by_item_id(self, inv_data: dict | None) -> dict[str, int]:
        """Aggregate inventory snapshot (dict) into totals per item_id."""
        totals: dict[str, int] = {}
        if not isinstance(inv_data, dict):
            return totals

        try:
            items = inv_data.get("items") or {}
            if isinstance(items, dict):
                for _slot, stack in items.items():
                    if not isinstance(stack, dict):
                        continue
                    item_id = stack.get("item_id")
                    if not item_id:
                        continue
                    qty = int(stack.get("quantity", 0) or 0)
                    if qty <= 0:
                        continue
                    totals[str(item_id)] = totals.get(str(item_id), 0) + qty

            equipment = inv_data.get("equipment") or {}
            if isinstance(equipment, dict):
                for _slot, stack in equipment.items():
                    if not isinstance(stack, dict):
                        continue
                    item_id = stack.get("item_id")
                    if not item_id:
                        continue
                    qty = int(stack.get("quantity", 0) or 0)
                    if qty <= 0:
                        continue
                    totals[str(item_id)] = totals.get(str(item_id), 0) + qty
        except Exception:
            return totals

        return totals

    def _enqueue_loot_popups(self, delta_by_item_id: dict[str, int], *, now: float):
        """Queue short-lived visual feedback popups above the player."""
        if not delta_by_item_id:
            return

        # Prefer showing biggest deltas first; keep it small to avoid clutter.
        pairs = [(iid, int(qty)) for iid, qty in delta_by_item_id.items() if int(qty) > 0]
        if not pairs:
            return
        pairs.sort(key=lambda p: p[1], reverse=True)

        # Cap number of lines to keep it readable.
        max_lines = 3
        for item_id, qty in pairs[:max_lines]:
            self._loot_popups.append({"item_id": str(item_id), "qty": int(qty), "t0": float(now)})

        if len(pairs) > max_lines:
            rest_qty = sum(q for _, q in pairs[max_lines:])
            if rest_qty > 0:
                # No reliable single icon; show aggregated text only.
                self._loot_popups.append({"item_id": None, "qty": int(rest_qty), "t0": float(now)})

    def _draw_loot_popups(self):
        """Draw floating '+qty' popups (world-space) above the local player."""
        if not self._loot_popups:
            return

        import time
        now = time.perf_counter()
        duration = float(getattr(self, "_loot_popup_duration_s", 0.9) or 0.9)
        if duration <= 0.0:
            duration = 0.9

        base_x = float(getattr(self, "player_x", 0.0) or 0.0)
        base_y = float(getattr(self, "player_y", 0.0) or 0.0) + float(TILE_SIZE) + 10.0

        alive = []
        line_index = 0
        for popup in list(self._loot_popups):
            try:
                age = now - float(popup.get("t0", now) or now)
            except Exception:
                age = 0.0
            if age >= duration:
                continue

            t = max(0.0, min(1.0, age / duration))
            rise_px = 18.0
            y = base_y + (line_index * 16.0) + (t * rise_px)
            # Keep text fully opaque to avoid per-frame color churn.
            alpha = 255

            item_id = popup.get("item_id")
            qty = int(popup.get("qty", 0) or 0)
            text = f"+{qty}" if qty != 0 else "+0"

            icon_drawn = False
            icon_size = 18
            if item_id:
                try:
                    item_def = get_item(str(item_id))
                    sprite_path = item_def.sprite if item_def else None
                    if sprite_path:
                        texture = self._item_drop_texture_cache.get(sprite_path)
                        if texture is None:
                            try:
                                texture = arcade.load_texture(sprite_path)
                                self._item_drop_texture_cache[sprite_path] = texture
                            except Exception:
                                texture = None
                        if texture is not None:
                            if hasattr(arcade, "draw_texture_rectangle"):
                                arcade.draw_texture_rectangle(
                                    base_x,
                                    y,
                                    icon_size,
                                    icon_size,
                                    texture,
                                    alpha=alpha,
                                )
                            else:
                                spr = self._item_drop_sprite_cache.get(sprite_path)
                                if spr is None:
                                    spr = arcade.Sprite()
                                    spr.texture = texture
                                    self._item_drop_sprite_cache[sprite_path] = spr
                                spr.center_x = base_x
                                spr.center_y = y
                                spr.width = icon_size
                                spr.height = icon_size
                                spr.alpha = alpha
                                spr.draw()
                            icon_drawn = True
                except Exception:
                    icon_drawn = False

            # Text to the right of icon (or centered if no icon). Use cached Text objects.
            text_x = base_x + (icon_size * 0.6 + 12) if icon_drawn else base_x

            while len(self._loot_popup_text_cache) <= line_index:
                self._loot_popup_text_cache.append(
                    arcade.Text("", 0, 0, arcade.color.LIME_GREEN, 12, anchor_x="center")
                )
                self._loot_popup_text_shadow_cache.append(
                    arcade.Text("", 0, 0, (0, 0, 0, 220), 12, anchor_x="center")
                )

            main_text = self._loot_popup_text_cache[line_index]
            shadow_text = self._loot_popup_text_shadow_cache[line_index]

            main_text.text = text
            main_text.x = text_x
            main_text.y = y - 6

            shadow_text.text = text
            shadow_text.x = text_x + 1
            shadow_text.y = y - 7

            shadow_text.draw()
            main_text.draw()

            alive.append(popup)
            line_index += 1

        self._loot_popups = alive

    def _play_local_picking_up_animation(self):
        """Play one-shot 'Picking Up' animation on the local player sprite (if available)."""
        try:
            local_pid = getattr(getattr(self, "network", None), "player_id", None)
            if local_pid is not None:
                if self._play_action_on_player_entity(int(local_pid), "picking_up"):
                    return
        except Exception:
            pass
        if isinstance(self.player_sprite, DirectionalSprite8Way):
            self.player_sprite.play_action("picking_up")

    def _sfx_volume_for_world_pos(self, x: float, y: float, *, max_distance: float = 650.0) -> float:
        """Simple distance attenuation (1.0 near -> 0.0 far)."""
        try:
            dx = float(x) - float(getattr(self, "player_x", 0.0) or 0.0)
            dy = float(y) - float(getattr(self, "player_y", 0.0) or 0.0)
            d = (dx * dx + dy * dy) ** 0.5
            md = float(max_distance or 0.0)
            if md <= 0.0:
                return 1.0
            v = 1.0 - (d / md)
            if v <= 0.0:
                return 0.0
            # Slight curve so it's less abrupt.
            return max(0.0, min(1.0, v * v))
        except Exception:
            return 1.0

    def _play_action_on_player_entity(self, player_entity_id: int, action_key: str) -> bool:
        """Play an action animation on local or remote player sprite (if available)."""
        try:
            pid = int(player_entity_id)
        except Exception:
            return False

        sprite = None
        try:
            local_pid = getattr(getattr(self, "network", None), "player_id", None)
            if local_pid is not None and int(local_pid) == pid:
                sprite = getattr(self, "player_sprite", None)
            else:
                sprite = getattr(self, "entity_sprites", {}).get(pid)
        except Exception:
            sprite = None

        if isinstance(sprite, DirectionalSprite8Way):
            try:
                return bool(sprite.play_action(action_key, loop=False, restart=True))
            except Exception:
                return False
        return False

    def _spawn_harvest_complete_fx(self, resource_id: int, *, info: dict | None, now_fx: float) -> None:
        """Spawn the harvest completion burst VFX at the last-known resource position."""
        import math
        import random

        tid = int(resource_id)
        x = float((info or {}).get("x", 0.0) or 0.0)
        y = float((info or {}).get("y", 0.0) or 0.0)
        if x == 0.0 and y == 0.0:
            x = float(getattr(self, "player_x", 0.0) or 0.0)
            y = float(getattr(self, "player_y", 0.0) or 0.0)

        rtype = (info or {}).get("resource_type") or getattr(self, "_last_local_harvest_resource_type", None)
        variant_id = (info or {}).get("variant_id")

        w = h = 32.0
        if rtype is not None and variant_id is not None:
            key = (str(rtype), variant_id)
            dims = getattr(self, "resource_dimensions", {}).get(key)
            if dims:
                try:
                    w = float(dims[0] or w)
                    h = float(dims[1] or h)
                except Exception:
                    pass
            else:
                if str(rtype) == "tree":
                    w = h = 96.0
                else:
                    w = h = 48.0

        rng = random.Random(int(tid) * 1000003)
        base_y = y - (h * (0.38 if str(rtype) == "tree" else 0.20))

        parts = []
        for _ in range(9):
            ang = rng.uniform(-math.pi * 0.15, math.pi * 1.15)
            spd = rng.uniform(10.0, 26.0)
            ln = rng.uniform(6.0, 12.0)
            wln = rng.choice([2, 2, 3])
            parts.append({"a": ang, "s": spd, "l": ln, "w": wln})

        fx = {
            "kind": "burst",
            "t0": float(now_fx),
            "duration": float(getattr(self, "_harvest_complete_fx_duration_s", 0.38) or 0.38),
            "x": x,
            "y": y,
            "base_y": base_y,
            "ring_r0": 6.0,
            "ring_r1": 26.0,
            "parts": parts,
        }

        lst = getattr(self, "_harvest_complete_fx", None)
        if not isinstance(lst, list):
            self._harvest_complete_fx = [fx]
        else:
            lst.append(fx)
    
    def _preload_biome_assets(self):
        """
        Pre-load ALL biome assets at startup to avoid disk I/O during gameplay!
        This is CRITICAL for performance - loading on-demand kills FPS.
        """
        import json
        import os
        from shared.wang_tiles import load_tileset_with_tiles
        
        # console.print(f"🎨 Pre-loading biome assets for {self.biome_id}...")
        
        # 1. Load biome config ONCE
        biome_config_path = os.path.join("assets", "biomes", self.biome_id, "biome_config.json")
        if not os.path.exists(biome_config_path):
            console.print(f"⚠️ Biome config not found: {biome_config_path}")
            return
        
        with open(biome_config_path) as f:
            self.biome_config = json.load(f)
        
        terrain_layers = self.biome_config.get("terrain_layers", [])
        
        # 2. Pre-load BOTH tilesets
        # 🔥 OPTIMIZED: Use set for O(1) lookup instead of O(n) list comprehension
        tilesets_to_load = []
        seen_tilesets = set()
        for layer in terrain_layers:
            if "tileset" in layer:
                tileset_path = layer["tileset"]
                if tileset_path not in seen_tilesets:
                    seen_tilesets.add(tileset_path)
                    tilesets_to_load.append((self.biome_id, tileset_path))
        
        for biome_id, tileset_path in tilesets_to_load:
            cache_key = (biome_id, tileset_path)
            if cache_key not in self.tileset_cache:
                # console.print(f"  📦 Loading tileset: {tileset_path}")
                tileset_data = load_tileset_with_tiles(biome_id, tileset_path)
                if tileset_data:
                    self.tileset_cache[cache_key] = tileset_data
        
        # 3. Pre-generate ALL possible tile combinations
        # For 3 terrain types (0, 1, 2), max 3^4 = 81 corner combinations
        # But we only need combinations that actually appear in game
        # console.print(f"  🎨 Pre-generating tile textures...")
        
        # Initialize random seed for overlay generation (different per client)
        import random
        import time
        random.seed(int(time.time() * 1000) % 1000000)  # Millisecond-based seed
        
        possible_terrains = [0, 1, 2]  # water, wasteland, desert
        generated_count = 0
        
        # Storage for homogeneous tile variants
        self.homogeneous_variants = {}  # {corner_types: [pil_image1, pil_image2, ...]}
        
        # Generate all corner combinations
        from itertools import product
        for nw, ne, sw, se in product(possible_terrains, repeat=4):
            corner_types = (nw, ne, sw, se)
            is_homogeneous = (nw == ne == sw == se)
            
            if is_homogeneous:
                # Generate 15 variants
                variants = []
                
                # For middle layer (terrain 1 = wasteland/tundra): MINIMAL overlay (7%)
                if nw == 1:
                    # 1 WITH overlay, 14 WITHOUT (7% overlay)
                    for variant_idx in range(1):
                        # 🔥 DETERMINISTIC: Use world_seed + corner_types + variant_idx for reproducible generation
                        variant_seed = self.world_seed + hash(corner_types) + variant_idx
                        pil_image = self._get_wang_pil_image_uncached(corner_types, self.biome_id, force_overlay=True, variant_seed=variant_seed)
                        if pil_image:
                            if pil_image.mode != 'RGBA':
                                pil_image = pil_image.convert('RGBA')
                            variants.append(pil_image)  # 🔥 OPTIMIZED: No .copy() needed - PIL images are immutable
                    
                    for variant_idx in range(1, 15):
                        # 🔥 DETERMINISTIC: Use world_seed + corner_types + variant_idx for reproducible generation
                        variant_seed = self.world_seed + hash(corner_types) + variant_idx
                        pil_image = self._get_wang_pil_image_uncached(corner_types, self.biome_id, force_overlay=False, variant_seed=variant_seed)
                        if pil_image:
                            if pil_image.mode != 'RGBA':
                                pil_image = pil_image.convert('RGBA')
                            variants.append(pil_image)  # 🔥 OPTIMIZED: No .copy() needed
                else:
                    # Other layers: keep normal ratio (5 WITH, 10 WITHOUT = 33%)
                    for variant_idx in range(5):
                        # 🔥 DETERMINISTIC: Use world_seed + corner_types + variant_idx for reproducible generation
                        variant_seed = self.world_seed + hash(corner_types) + variant_idx
                        pil_image = self._get_wang_pil_image_uncached(corner_types, self.biome_id, force_overlay=True, variant_seed=variant_seed)
                        if pil_image:
                            if pil_image.mode != 'RGBA':
                                pil_image = pil_image.convert('RGBA')
                            variants.append(pil_image)  # 🔥 OPTIMIZED: No .copy() needed
                    
                    for variant_idx in range(5, 15):
                        # 🔥 DETERMINISTIC: Use world_seed + corner_types + variant_idx for reproducible generation
                        variant_seed = self.world_seed + hash(corner_types) + variant_idx
                        pil_image = self._get_wang_pil_image_uncached(corner_types, self.biome_id, force_overlay=False, variant_seed=variant_seed)
                        if pil_image:
                            if pil_image.mode != 'RGBA':
                                pil_image = pil_image.convert('RGBA')
                            variants.append(pil_image)  # 🔥 OPTIMIZED: No .copy() needed
                
                if variants:
                    self.homogeneous_variants[corner_types] = variants
                    generated_count += len(variants)
                continue
            
            cache_key = (corner_types, self.biome_id)
            
            # Skip if already cached
            if cache_key in self.wang_texture_cache:
                continue
            
            # Generate PIL image for transition tiles
            pil_image = self._get_wang_pil_image_uncached(corner_types, self.biome_id)
            if pil_image:
                # Ensure RGBA mode
                if pil_image.mode != 'RGBA':
                    pil_image = pil_image.convert('RGBA')
                
                # 🔥 CACHE PIL IMAGE (for chunk baking!)
                self.wang_pil_cache[cache_key] = pil_image.copy()
                
                # CREATE ARCADE TEXTURE ONCE (not per frame!)
                texture = arcade.Texture(
                    name=f"tile_{nw}_{ne}_{sw}_{se}_{self.biome_id}",
                    image=pil_image
                )
                self.wang_texture_cache[cache_key] = texture
                generated_count += 1
        
        # console.print(f"  ✅ Pre-generated {generated_count} textures (transitions + 15 variants per homogeneous)")
        # console.print(f"  ✅ Cache: {len(self.wang_texture_cache)} transitions, {len(self.homogeneous_variants)} homogeneous types")
    
    def _create_snow_emitter(self):
        """Create snow particle emitter for frozen world biome"""
        console.print(f"❄️ Preparing snow emitter... window size: {self.window.width}x{self.window.height}")
        
        try:
            # Create small, subtle white circle texture
            from PIL import Image, ImageDraw
            
            size = 10  # Small, subtle snowflakes
            img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
            draw = ImageDraw.Draw(img)
            
            # Draw a soft white circle
            draw.ellipse([1, 1, size - 2, size - 2], fill=(255, 255, 255, 200))
            
            # Convert to Arcade texture
            self.snow_texture = arcade.Texture(
                name="snowflake",
                image=img
            )
            
            console.print(f"✅ Snow texture created, emitter will be initialized on first update")
            # Don't create emitter yet - will be created in on_update to avoid segfault
            self.snow_emitter = "pending"  # Marker that we want snow
            
        except Exception as e:
            console.print(f"❌ Error creating snow texture: {e}")
            import traceback
            traceback.print_exc()
            self.snow_emitter = None
    
    def _initialize_snow_emitter_deferred(self):
        """Initialize snow emitter (deferred to avoid segfault during setup)"""
        if not hasattr(self, 'snow_texture'):
            return False
            
        try:
            console.print("❄️ Initializing snow emitter...")
            # Emitter will be positioned relative to player, updated each frame
            self.snow_emitter = "initialized"  # Marker
            console.print("✅ Snow emitter ready (will spawn relative to camera)")
            return True
        except Exception as e:
            console.print(f"❌ Error initializing snow emitter: {e}")
            import traceback
            traceback.print_exc()
            self.snow_emitter = None
            return False
            self.snow_emitter = None
            return False
    
    def _create_fog_effect(self):
        """Create fog effect for toxic wasteland biome - stronger near toxic water"""
        console.print(f"🌫️ Creating fog effect for toxic wasteland...")
        
        try:
            self.fog_enabled = True
            self.fog_base_alpha = 25  # Base toxicity level (away from water) - zvýšeno
            console.print(f"✅ Fog effect enabled (water proximity-based)")
            
        except Exception as e:
            console.print(f"❌ Error enabling fog: {e}")
            import traceback
            traceback.print_exc()
            self.fog_enabled = False
    
    def setup(self):
        """Setup game view"""
        # Try to create player sprite from sprite sheets
        available_chars = self.sprite_manager.list_available_characters()
        
        # Use astronaut_hires character (128×128)
        if "astronaut_hires" in available_chars:
            debug_print(DEBUG_SPRITES, "✅ Using sprite sheet: astronaut_hires (128×128)")
            self.player_sprite = DirectionalSprite8Way(
                "astronaut_hires",
                scale=1.0
            )
            self.player_sprite.center_x = self.player_x
            self.player_sprite.center_y = self.player_y
        else:
            debug_print(DEBUG_SPRITES, "⚠️ Sprite sheets not found, using placeholder circle")
            # Fallback to placeholder circle (using Arcade built-in)
            self.player_sprite = arcade.SpriteSolidColor(
                TILE_SIZE, TILE_SIZE,
                Color.from_hex_string(COLORS["rust_orange"])
            )
            self.player_sprite.center_x = self.player_x
            self.player_sprite.center_y = self.player_y
            self.use_sprites = False
        
        # Load resource sprites for current biome (UNIFIED)
        from client.sprite_loader import (
            load_resource_sprites, load_resource_collision_data, 
            load_resource_horizon_data, load_resource_dimensions
        )
        if self.biome_id:
            # Unified dictionaries: resource_type + variant_id -> data
            # Format: {("tree", 1): texture, ("rock", 5): texture, ...}
            self.resource_textures = {}
            self.resource_collision_data = {}
            self.resource_horizon_data = {}
            self.resource_dimensions = {}
            
            # Load trees
            for variant_id, texture in load_resource_sprites(self.biome_id, "tree").items():
                self.resource_textures[("tree", variant_id)] = texture
            for variant_id, collision in load_resource_collision_data(self.biome_id, "tree").items():
                self.resource_collision_data[("tree", variant_id)] = collision
            for variant_id, horizon in load_resource_horizon_data(self.biome_id, "tree").items():
                self.resource_horizon_data[("tree", variant_id)] = horizon
            for variant_id, dims in load_resource_dimensions(self.biome_id, "tree").items():
                self.resource_dimensions[("tree", variant_id)] = dims
            
            # Load rocks
            for variant_id, texture in load_resource_sprites(self.biome_id, "rock").items():
                self.resource_textures[("rock", variant_id)] = texture
            for variant_id, collision in load_resource_collision_data(self.biome_id, "rock").items():
                self.resource_collision_data[("rock", variant_id)] = collision
            for variant_id, horizon in load_resource_horizon_data(self.biome_id, "rock").items():
                self.resource_horizon_data[("rock", variant_id)] = horizon
            for variant_id, dims in load_resource_dimensions(self.biome_id, "rock").items():
                self.resource_dimensions[("rock", variant_id)] = dims
        else:
            self.resource_textures = {}
            self.resource_collision_data = {}
            self.resource_horizon_data = {}
            self.resource_dimensions = {}
        
        # Load pickaxe cursor
        self._load_pickaxe_cursor()
        
        debug_print(DEBUG_INIT, "✅ GameView setup complete")
    
    def _load_pickaxe_cursor(self):
        """Load pickaxe icon as custom cursor"""
        import os
        from PIL import Image
        import pyglet
        
        cursor_path = "assets/sprites/pickaxe_cursor.png"
        if not os.path.exists(cursor_path):
            debug_print(DEBUG_INIT, f"⚠️ Pickaxe cursor not found: {cursor_path}")
            return
        
        try:
            # Load image
            img = Image.open(cursor_path)
            
            # Ensure RGBA mode
            if img.mode != 'RGBA':
                img = img.convert('RGBA')
            
            # Resize to reasonable cursor size (32x32 is standard)
            img = img.resize((32, 32), Image.Resampling.NEAREST)
            
            # Flip Y axis (PIL uses top-left origin, OpenGL uses bottom-left)
            img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
            
            # Create cursor (hotspot at center of image)
            width, height = img.size
            hotspot_x = width // 2
            hotspot_y = height // 2
            
            # Convert to pyglet image
            image_data = img.tobytes()
            pyglet_img = pyglet.image.ImageData(width, height, 'RGBA', image_data)
            
            # Create cursor
            self.pickaxe_cursor = pyglet.window.ImageMouseCursor(pyglet_img, hotspot_x, hotspot_y)
            debug_print(DEBUG_INIT, f"✅ Loaded pickaxe cursor: {cursor_path} ({width}×{height})")
        except Exception as e:
            console.print(f"❌ Failed to load pickaxe cursor: {e}")
    
    def on_show_view(self):
        """Called when view is shown"""
        # Create light layer (must be done after window is available)
        if self.light_layer is None:
            self.light_layer = LightLayer(self.window.width, self.window.height)
            self.light_layer.set_background_color(arcade.color.BLACK)
            
            # Dummy invisible light with radius=0 (LightLayer needs at least one light to function)
            # This won't be visible but allows the lighting system to work
            self.player_light = Light(0, 0, 0, (0, 0, 0), 'soft')
            self.light_layer.add(self.player_light)
            
            # Cone light layer for directional flashlight
            self.cone_light_layer = ConeLightLayer(self.window.ctx, self.window.width, self.window.height)
            
            # Create cone light (flashlight effect) - Factorio style
            self.player_cone_light = ConeLight(
                0, 0,
                radius=800,  # Very long reach - far beyond visible area
                color=(255, 255, 255),  # Pure white for maximum brightness
                mode='hard',  # Hard mode = no attenuation fade
                angle=-90,  # Start facing down (same as player sprite default)
                cone_angle=70  # 70° spread (35° each side) - wider view
            )
            self.cone_light_offset = 30  # Offset in front of player
            self.cone_light_layer.add(self.player_cone_light)
            
            debug_print(DEBUG_INIT, f"💡 Lighting system initialized (circular + cone light)")
    
    def on_hide_view(self):
        """Called when view is hidden - cleanup resources"""
        # Shutdown thread pools
        if hasattr(self, 'sprite_executor'):
            self.sprite_executor.shutdown(wait=False)
        if hasattr(self, 'chunk_render_executor'):
            self.chunk_render_executor.shutdown(wait=False)
    
    def update_ui_positions(self):
        """Update UI element positions based on window size (like PixelPiratesBoom)"""
        # Seed and stats - top left (fixed positions)
        self.seed_text.x = 10
        self.seed_text.y = self.window.height - 30
        
        self.fps_text.x = 10
        self.fps_text.y = self.window.height - 50
        
        self.pos_text.x = 10
        self.pos_text.y = self.window.height - 70
        
        self.status_text.x = 10
        self.status_text.y = self.window.height - 90
        
        self.ping_text.x = 10
        self.ping_text.y = self.window.height - 110
        
        self.time_text.x = 10
        self.time_text.y = self.window.height - 130
        
        self.zoom_text.x = 10
        self.zoom_text.y = self.window.height - 150

        self.movement_debug_text.x = 10
        self.movement_debug_text.y = self.window.height - 190
        
        # Title - top center (fixed position)
        self.title_text.x = self.window.width // 2
        self.title_text.y = self.window.height - 30
    
    def _get_daynight_overlay_color(self) -> tuple:
        """
        Calculate day/night overlay color and alpha based on game time.
        Returns (r, g, b, alpha) tuple.
        
        Time schedule:
        - 6:00-7:00 (360-420): Dawn (orange → clear)
        - 7:00-17:00 (420-1020): Day (no overlay)
        - 17:00-18:00 (1020-1080): Dusk (clear → orange → blue)
        - 18:00-6:00 (1080-360): Night (dark blue)
        """
        import math
        from shared.constants import SUNRISE_TIME, SUNSET_TIME, DAY_DURATION_MINUTES
        
        # Normalize time to 0-1440 range
        time = self.game_time % DAY_DURATION_MINUTES
        
        # Dawn (6:00-7:00) - orange fading to clear
        if 360 <= time < 420:
            progress = (time - 360) / 60  # 0 to 1
            # Start with orange, fade to transparent
            alpha = int(50 * (1 - progress))  # 50 → 0
            return (255, 180, 100, alpha)  # Orange
        
        # Day (7:00-17:00) - no overlay
        elif 420 <= time < 1020:
            return (0, 0, 0, 0)  # Transparent
        
        # Dusk (17:00-18:00) - clear to orange to blue
        elif 1020 <= time < 1080:
            progress = (time - 1020) / 60  # 0 to 1
            if progress < 0.3:  # First 18 minutes - orange glow
                sub_progress = progress / 0.3
                alpha = int(40 * sub_progress)
                return (255, 150, 80, alpha)  # Orange
            else:  # Last 42 minutes - transition to blue
                sub_progress = (progress - 0.3) / 0.7
                # Blend from orange to blue
                r = int(255 - 140 * sub_progress)  # 255 → 115
                g = int(150 - 90 * sub_progress)   # 150 → 60
                b = int(80 + 100 * sub_progress)   # 80 → 180
                alpha = int(40 + 60 * sub_progress)  # 40 → 100
                return (r, g, b, alpha)
        
        # Night (18:00-6:00) - dark overlay
        else:  # 1080-1440 or 0-360
            # Convert to continuous night timeline
            if time >= 1080:  # 18:00-24:00
                night_minutes = time - 1080  # 0-360
            else:  # 0:00-6:00
                night_minutes = time + 360  # 360-720
            
            # Night phases:
            # 18:00-22:00 (0-240 min): Darkening
            # 22:00-5:00 (240-660 min): Full darkness
            # 5:00-6:00 (660-720 min): Dawn brightening
            
            if night_minutes < 240:  # 18:00-22:00 - getting darker
                progress = night_minutes / 240  # 0 to 1
                alpha = int(100 + 140 * progress)  # 100 → 240
                return (20, 25, 40, alpha)
            
            elif night_minutes < 660:  # 22:00-5:00 - full darkness
                alpha = 240  # Maximum darkness
                return (15, 20, 35, alpha)  # Almost black
            
            else:  # 5:00-6:00 - dawn brightening
                progress = (night_minutes - 660) / 60  # 0 to 1
                alpha = int(240 - 190 * progress)  # 240 → 50
                # Gradually add orange tint for dawn
                r = int(15 + 40 * progress)  # 15 → 55
                g = int(20 + 30 * progress)  # 20 → 50
                b = int(35 + 5 * progress)   # 35 → 40
                return (r, g, b, alpha)
    
    def on_resize(self, width, height):
        """Handler pro změnu velikosti okna"""
        super().on_resize(width, height)
        
        # Klíčové: match_window zajistí že kamera správně reaguje na resize
        self.camera.match_window()
        
        # Resize light layer
        if self.light_layer:
            self.light_layer.resize(width, height)
        
        # Update UI positions to new window size
        self.update_ui_positions()
        self.inventory_ui.update_positions()
    
    def _draw_with_cone_light(self, ambient_color):
        """
        Custom light drawing that injects cone light into light buffer.
        Based on LightLayer.draw() but adds cone light rendering.
        """
        # Render to light buffer (like normal LightLayer does)
        self.light_layer._light_buffer.use()
        self.light_layer._light_buffer.clear()
        
        # DO NOT render circular lights - ONLY cone light!
        # (removed circular light rendering)
        
        # Render ONLY cone light into light buffer
        if self.cone_light_layer:
            # Pass ambient brightness to shader for inversion
            ambient_brightness = ambient_color[0] / 255.0  # Normalize to 0-1
            self.cone_light_layer.set_ambient_brightness(ambient_brightness)
            
            # Use additive blending
            self.light_layer.ctx.enable(self.light_layer.ctx.BLEND)
            self.light_layer.ctx.blend_func = self.light_layer.ctx.BLEND_ADDITIVE
            self.cone_light_layer.render(position=(0, 0))
            self.light_layer.ctx.blend_func = self.light_layer.ctx.BLEND_DEFAULT
        
        # Combine pass (diffuse + light buffer)
        self.window.use()
        self.light_layer._combine_program["diffuse_buffer"] = 0
        self.light_layer._combine_program["light_buffer"] = 1
        self.light_layer._combine_program["ambient"] = ambient_color[:3]
        self.light_layer._fbo.color_attachments[0].use(0)
        self.light_layer._light_buffer.color_attachments[0].use(1)
        
        self.light_layer._quad_fs.render(self.light_layer._combine_program)
    
    def on_draw(self):
        """Render the screen"""
        import time
        frame_start = time.perf_counter()
        
        self.perf_monitor.start_frame()
        self.perf_monitor.start_draw()
        
        clear_start = time.perf_counter()
        self.clear()
        clear_time = time.perf_counter() - clear_start
        
        # Calculate ambient color based on time of day
        r, g, b, alpha = self._get_daynight_overlay_color()
        # Ambient = inverse of darkness (darker overlay = darker ambient)
        ambient_brightness = max(10, 255 - alpha)
        ambient_color = (ambient_brightness, ambient_brightness, ambient_brightness, 255)
        
        # Draw world (with camera)
        camera_start = time.perf_counter()
        self.camera.use()
        camera_time = time.perf_counter() - camera_start
        
        # --- Everything affected by lighting goes here ---
        if self.light_layer and self.lighting_enabled:
            with self.light_layer:
                # Draw ground tiles
                ground_start = time.perf_counter()
                self._draw_ground()
                ground_time = time.perf_counter() - ground_start
                self.perf_monitor.record_ground_draw(ground_time)

                # Draw ground items (dropped items)
                self._draw_item_drops()
                
                # Draw all entity sprites
                sprite_start = time.time()
                entity_draw_start = time.perf_counter()
                # Other players are drawn in _draw_sorted_sprites() for correct depth vs resources
                entity_draw_time = time.perf_counter() - entity_draw_start
                
                # Draw player name labels
                player_draw_start = time.perf_counter()
                if self.player_sprite and (not self.network or self.network.player_id):
                    if self.player_name_label is None:
                        import pyglet
                        # Shadow first so main text draws on top
                        self.player_name_shadow_label = pyglet.text.Label(
                            self.player_username,
                            font_name='Arial',
                            font_size=10,
                            x=0, y=0,
                            anchor_x='center',
                            anchor_y='bottom',
                            color=(0, 0, 0, 200),
                            batch=self.text_batch
                        )
                        self.player_name_label = pyglet.text.Label(
                            self.player_username,
                            font_name='Arial',
                            font_size=10,
                            x=0, y=0,
                            anchor_x='center',
                            anchor_y='bottom',
                            color=(255, 255, 255, 255),
                            batch=self.text_batch
                        )
                    if self.player_sprite:
                        self.player_name_label.x = self.player_sprite.center_x
                        self.player_name_label.y = self.player_sprite.center_y + TILE_SIZE + 10
                        if self.player_name_shadow_label is not None:
                            self.player_name_shadow_label.x = self.player_name_label.x + 1
                            self.player_name_shadow_label.y = self.player_name_label.y - 1
                    else:
                        self.player_name_label.x = self.player_x
                        self.player_name_label.y = self.player_y + TILE_SIZE + 10
                        if self.player_name_shadow_label is not None:
                            self.player_name_shadow_label.x = self.player_name_label.x + 1
                            self.player_name_shadow_label.y = self.player_name_label.y - 1
                player_draw_time = time.perf_counter() - player_draw_start
                
                # Text batch is drawn after world sprites for correct layering
                text_batch_time = 0.0
                
                # Draw resource nodes
                resource_start = time.perf_counter()
                self._draw_resource_nodes()
                resource_time = time.perf_counter() - resource_start
                
                # Draw sorted sprites
                sorted_start = time.perf_counter()
                self._draw_sorted_sprites()
                sorted_time = time.perf_counter() - sorted_start
                
                # Draw health bars
                health_start = time.perf_counter()
                self._draw_health_bars()
                health_time = time.perf_counter() - health_start
                
                # Draw snow particles
                snow_start = time.perf_counter()
                if hasattr(self, '_snow_particles'):
                    self._snow_particles.draw()
                snow_time = time.perf_counter() - snow_start

                # Draw player name labels (topmost world text)
                text_batch_start = time.perf_counter()
                self.text_batch.draw()
                text_batch_time = time.perf_counter() - text_batch_start
            
            # Draw the lit scene (this combines diffuse + lights)
            # We need to inject cone light into light buffer BEFORE combine
            self._draw_with_cone_light(ambient_color)

            # World-space feedback (not affected by lighting)
            self._draw_harvest_complete_fx()

            # World-space feedback (not affected by lighting): loot popups
            self._draw_loot_popups()
        else:
            # Fallback: draw without lighting
            ground_start = time.perf_counter()
            self._draw_ground()
            ground_time = time.perf_counter() - ground_start
            self.perf_monitor.record_ground_draw(ground_time)

            # Draw ground items (dropped items)
            self._draw_item_drops()
            
            sprite_start = time.time()
            entity_draw_start = time.perf_counter()
            # Other players are drawn in _draw_sorted_sprites() for correct depth vs resources
            entity_draw_time = time.perf_counter() - entity_draw_start
            
            player_draw_start = time.perf_counter()
            if self.player_sprite and (not self.network or self.network.player_id):
                if self.player_name_label is None:
                    import pyglet
                    self.player_name_shadow_label = pyglet.text.Label(
                        self.player_username,
                        font_name='Arial',
                        font_size=10,
                        x=0, y=0,
                        anchor_x='center',
                        anchor_y='bottom',
                        color=(0, 0, 0, 200),
                        batch=self.text_batch
                    )
                    self.player_name_label = pyglet.text.Label(
                        self.player_username,
                        font_name='Arial',
                        font_size=10,
                        x=0, y=0,
                        anchor_x='center',
                        anchor_y='bottom',
                        color=(255, 255, 255, 255),
                        batch=self.text_batch
                    )
                if self.player_sprite:
                    self.player_name_label.x = self.player_sprite.center_x
                    self.player_name_label.y = self.player_sprite.center_y + TILE_SIZE + 10
                    if self.player_name_shadow_label is not None:
                        self.player_name_shadow_label.x = self.player_name_label.x + 1
                        self.player_name_shadow_label.y = self.player_name_label.y - 1
                else:
                    self.player_name_label.x = self.player_x
                    self.player_name_label.y = self.player_y + TILE_SIZE + 10
                    if self.player_name_shadow_label is not None:
                        self.player_name_shadow_label.x = self.player_name_label.x + 1
                        self.player_name_shadow_label.y = self.player_name_label.y - 1
            player_draw_time = time.perf_counter() - player_draw_start
            
            # Text batch is drawn after world sprites for correct layering
            text_batch_time = 0.0
            
            resource_start = time.perf_counter()
            self._draw_resource_nodes()
            resource_time = time.perf_counter() - resource_start
            
            sorted_start = time.perf_counter()
            self._draw_sorted_sprites()
            sorted_time = time.perf_counter() - sorted_start
            
            health_start = time.perf_counter()
            self._draw_health_bars()
            health_time = time.perf_counter() - health_start
            
            snow_start = time.perf_counter()
            if hasattr(self, '_snow_particles'):
                self._snow_particles.draw()
            snow_time = time.perf_counter() - snow_start

            # Draw player name labels (topmost world text)
            text_batch_start = time.perf_counter()
            self.text_batch.draw()
            text_batch_time = time.perf_counter() - text_batch_start

            # World-space feedback: loot popups
            self._draw_harvest_complete_fx()
            self._draw_loot_popups()
        
        # --- Environmental overlays (NOT affected by lighting) ---
        # Draw fog effect for toxic wasteland (in world space)
        fog_start = time.perf_counter()
        if self.biome_id == "toxic_wasteland" and self.fog_enabled:
            # Use server-provided radiation level (0-100) to determine fog intensity
            # Map radiation to alpha: 0 = light fog (25), 100 = heavy fog (75)
            radiation_normalized = self.player_radiation / 100.0  # 0.0-1.0
            alpha = int(25 + radiation_normalized * 75)  # 25-75
            green = int(180 + radiation_normalized * 65)  # 180-245
            
            cam_x, cam_y = self.camera.position
            view_w = self.window.width / self.camera_zoom
            view_h = self.window.height / self.camera_zoom
            
            arcade.draw_lbwh_rectangle_filled(
                cam_x - view_w/2, cam_y - view_h/2,
                view_w, view_h,
                (110, green, 50, alpha)  # Greenish fog, intensity from server
            )
        
        # Cold/ice effect for frozen_world
        elif self.biome_id == "frozen_world":
            # Use server-provided cold level (0-100) to determine ice overlay intensity
            # Map cold to alpha: 0 = no overlay (0), 100 = heavy ice (120)
            cold_normalized = self.player_cold / 100.0  # 0.0-1.0
            alpha = int(cold_normalized * 150)  # 0-120 (stronger white overlay)
            white = int(220 + cold_normalized * 35)  # 220-255 (pure white at max)
            
            if alpha > 0:  # Only draw if there's cold
                cam_x, cam_y = self.camera.position
                view_w = self.window.width / self.camera_zoom
                view_h = self.window.height / self.camera_zoom
                
                arcade.draw_lbwh_rectangle_filled(
                    cam_x - view_w/2, cam_y - view_h/2,
                    view_w, view_h,
                    (white, white, 255, alpha)  # Whitish-blue ice overlay, intensity from server
                )
        
        fog_time = time.perf_counter() - fog_start
        
        self.perf_monitor.record_sprite_draw(time.time() - sprite_start)
        
        # Debug methods removed (dead code cleanup)
        
        # Draw GUI - use fresh Camera2D for UI (like PixelPiratesBoom)
        # This ensures UI has correct viewport matching window size
        ui_start = time.perf_counter()
        ui_camera = arcade.Camera2D()
        ui_camera.use()
        
        self._draw_gui()
        self._draw_chat()
        
        # Draw inventory UI (on top of everything)
        self.inventory_ui.draw()
        ui_time = time.perf_counter() - ui_start
        
        # Draw performance monitor
        perf_start = time.perf_counter()
        if self.show_perf_monitor:
            self.perf_monitor.draw(self.window.width, self.window.height)
        perf_time = time.perf_counter() - perf_start
        
        self.perf_monitor.end_draw()
        
        # 🔥 DETAILED PROFILING: Print breakdown if frame is slow
        # DISABLE for production - set threshold to 999ms or comment out
        draw_total = time.perf_counter() - frame_start
        if draw_total > 0.050:  # 50ms threshold - only warn for significant slowdowns
            # Calculate sum of measured parts
            measured_sum = (clear_time + camera_time + ground_time + entity_draw_time + 
                          player_draw_time + text_batch_time + resource_time + sorted_time + 
                          health_time + snow_time + fog_time + ui_time + perf_time)
            unmeasured = draw_total - measured_sum
            
            console.print(f"\n⚠️ SLOW FRAME: {draw_total*1000:.1f}ms total")
            console.print(f"  Clear: {clear_time*1000:.2f}ms")
            console.print(f"  Camera: {camera_time*1000:.2f}ms")
            console.print(f"  Ground: {ground_time*1000:.2f}ms")
            console.print(f"  Entity sprites: {entity_draw_time*1000:.2f}ms")
            console.print(f"  Player: {player_draw_time*1000:.2f}ms")
            console.print(f"  Text batch: {text_batch_time*1000:.2f}ms")
            console.print(f"  Resource nodes: {resource_time*1000:.2f}ms")
            console.print(f"  Sorted sprites: {sorted_time*1000:.2f}ms")
            console.print(f"  Health bars: {health_time*1000:.2f}ms")
            console.print(f"  Snow particles: {snow_time*1000:.2f}ms")
            console.print(f"  Fog effect: {fog_time*1000:.2f}ms")
            console.print(f"  UI: {ui_time*1000:.2f}ms")
            console.print(f"  Perf monitor: {perf_time*1000:.2f}ms")
            console.print(f"  ---")
            console.print(f"  Measured sum: {measured_sum*1000:.2f}ms")
            console.print(f"  Unmeasured (arcade overhead): {unmeasured*1000:.2f}ms")
    
    def _draw_ground(self):
        """
        Draw ground using terrain-based SpriteLists!
        🔥 FPS FIX: Spatial hash culling + temporary SpriteList for batch rendering
        
        NOTE: Chunk loading/unloading happens in on_update() - NEVER block in on_draw!
        """
        import time
        draw_start = time.perf_counter()
        
        # 🔥 CRITICAL OPTIMIZATION: Early exit if no terrain to draw
        if not self.terrain_sprite_lists:
            return
        
        # 🔥 OPTIMIZED: Cache viewport bounds (recalculate only when camera moves)
        cam_x, cam_y = self.camera.position
        zoom = self.camera.zoom
        
        viewport_calc_start = time.perf_counter()
        if (self._cached_viewport is None or 
            self._cached_camera_pos != (cam_x, cam_y) or 
            self._cached_camera_zoom != zoom):
            # Camera changed - recalculate viewport
            view_width = SCREEN_WIDTH / zoom
            view_height = SCREEN_HEIGHT / zoom
            padding = CHUNK_SIZE * TILE_SIZE
            
            viewport_left = cam_x - view_width / 2 - padding
            viewport_right = cam_x + view_width / 2 + padding
            viewport_bottom = cam_y - view_height / 2 - padding
            viewport_top = cam_y + view_height / 2 + padding
            
            self._cached_viewport = (viewport_left, viewport_right, viewport_bottom, viewport_top)
            self._cached_camera_pos = (cam_x, cam_y)
            self._cached_camera_zoom = zoom
        else:
            # Use cached viewport
            viewport_left, viewport_right, viewport_bottom, viewport_top = self._cached_viewport
        viewport_calc_time = (time.perf_counter() - viewport_calc_start) * 1000
        
        # 🔥 OPTIMIZED: Viewport culling with early exit and single extend
        # Problem: 42 chunks × 1024×1024px = too many large textures
        # Solution: Draw only ~9-12 visible chunks
        visible_list = self.visible_terrain_list
        clear_start = time.perf_counter()
        visible_list.clear()
        clear_time = (time.perf_counter() - clear_start) * 1000
        
        # 🔥 Use spatial hash for FAST culling (O(1) instead of O(n)!)
        from arcade import LRBT
        
        rect_create_start = time.perf_counter()
        viewport_rect = LRBT(viewport_left, viewport_right, viewport_bottom, viewport_top)
        rect_create_time = (time.perf_counter() - rect_create_start) * 1000
        
        query_start = time.perf_counter()
        visible_sprites = []
        total_sprites_in_lists = 0
        lists_with_spatial_hash = 0
        lists_without_spatial_hash = 0
        
        for terrain_list in self.terrain_sprite_lists.values():
            total_sprites_in_lists += len(terrain_list)
            
            # Use SpriteList's spatial_hash.get_sprites_near_rect() for fast viewport culling
            if hasattr(terrain_list, 'spatial_hash') and terrain_list.spatial_hash:
                lists_with_spatial_hash += 1
                # Fast spatial query - only checks sprites in viewport cells
                visible_sprites.extend(
                    terrain_list.spatial_hash.get_sprites_near_rect(viewport_rect)
                )
            else:
                lists_without_spatial_hash += 1
                # Fallback: manual iteration (only if spatial hash disabled)
                visible_sprites.extend(
                    s for s in terrain_list
                    if viewport_left <= s.center_x <= viewport_right
                    and viewport_bottom <= s.center_y <= viewport_top
                )
        query_time = (time.perf_counter() - query_start) * 1000
        
        # 🔥 NEW APPROACH: Draw directly from source lists with manual culling
        # Skip the intermediate visible_list entirely
        draw_call_start = time.perf_counter()
        
        for sprite in visible_sprites:
            arcade.draw_sprite(sprite)
        
        draw_call_time = (time.perf_counter() - draw_call_start) * 1000
        
        clear_time = 0.0
        extend_time = 0.0
        
        clear_time = 0.0
        extend_time = 0.0
        rebuild_needed = False  # For logging
        
        total_time = (time.perf_counter() - draw_start) * 1000
        
        # 🔥 DEBUG LOG: Print detailed breakdown if slow (>50ms)
        if total_time > 50:
            console.print(f"🐌 SLOW GROUND RENDERING: {total_time:.2f}ms")
            console.print(f"   Viewport calc: {viewport_calc_time:.2f}ms")
            console.print(f"   LRBT rect create: {rect_create_time:.2f}ms")
            console.print(f"   Spatial query: {query_time:.2f}ms")
            console.print(f"   Clear: {clear_time:.2f}ms")
            console.print(f"   Extend: {extend_time:.2f}ms")
            console.print(f"   Batch draw: {draw_call_time:.2f}ms")
            console.print(f"   ---")
            console.print(f"   Total sprite lists: {len(self.terrain_sprite_lists)}")
            console.print(f"   Lists WITH spatial hash: {lists_with_spatial_hash}")
            console.print(f"   Total sprites in lists: {total_sprites_in_lists}")
            console.print(f"   Visible sprites: {len(visible_sprites)}")
    
    # OLD threading code - removed (terrain-based SpriteLists are simpler and faster!)
    

    def _render_chunk_pil_async(self, chunk_key, chunk):
        """
        🚀 ASYNC PIL RENDERING: Use numpy + PIL in WORKER THREAD!
        Returns PIL.Image (NOT arcade.Texture - that must be created in main thread).
        
        This function is thread-safe:
        - PIL/numpy operations are thread-safe
        - Only reads from caches (wang_pil_cache, homogeneous_variants)
        - No OpenGL/arcade operations
        """
        start_time = time.perf_counter()
        
        chunk_x, chunk_y = chunk_key
        chunk_size_px = CHUNK_SIZE * TILE_SIZE
        
        # Create empty RGBA image
        chunk_img = Image.new('RGBA', (chunk_size_px, chunk_size_px), (0, 0, 0, 0))
        
        fetch_start = time.perf_counter()
        # 🔥 OPTIMIZED: Pre-fetch all PIL images (minimize type conversions)
        tile_images = {}
        for local_y in range(CHUNK_SIZE):
            for local_x in range(CHUNK_SIZE):
                tile_type = chunk.get_tile(local_x, local_y)
                corner_types = chunk.get_corners(local_x, local_y)
                
                # Fast path: corner_types already tuple or None
                if corner_types is None:
                    corner_types = (tile_type, tile_type, tile_type, tile_type)
                elif not isinstance(corner_types, tuple):
                    corner_types = tuple(corner_types)
                
                # Flip corners for PIL rendering (Y-axis flip)
                # Server: (NW, NE, SW, SE) → PIL: (SW, SE, NW, NE)
                flipped_corners = (corner_types[2], corner_types[3], corner_types[0], corner_types[1])
                
                # 🔥 CRITICAL: Use GLOBAL tile coordinates for hash (not local 0-15!)
                global_tile_x = chunk_x * CHUNK_SIZE + local_x
                global_tile_y = chunk_y * CHUNK_SIZE + local_y
                
                # Get PIL image (cached)
                pil_img = self._get_wang_pil_for_chunk(flipped_corners, global_tile_x, global_tile_y)
                if pil_img:
                    tile_images[(local_x, local_y)] = pil_img
        fetch_duration = time.perf_counter() - fetch_start
        
        paste_start = time.perf_counter()
        # 🔥 SUPER OPTIMIZED: Build entire chunk array at once (vectorized numpy)
        # Avoid loop + dict lookup + individual array conversions
        chunk_array = np.zeros((chunk_size_px, chunk_size_px, 4), dtype=np.uint8)
        
        # Pre-convert all tiles to numpy arrays (batch operation)
        tile_arrays = {}
        for key, pil_img in tile_images.items():
            tile_arrays[key] = np.asarray(pil_img, dtype=np.uint8)
        
        # Single loop with direct memory copy (no intermediate conversions)
        for (local_x, local_y), tile_array in tile_arrays.items():
            paste_x = local_x * TILE_SIZE
            paste_y = (CHUNK_SIZE - 1 - local_y) * TILE_SIZE
            
            # Direct numpy slice assignment (C-speed memory copy)
            chunk_array[paste_y:paste_y+TILE_SIZE, paste_x:paste_x+TILE_SIZE] = tile_array
        
        # Single conversion at the end
        chunk_img = Image.fromarray(chunk_array, mode='RGBA')
        paste_duration = time.perf_counter() - paste_start
        
        duration = time.perf_counter() - start_time
        # DEBUG: Timing (commented out for production - print can cause GPU sync!)
        # console.print(f"🎨 PIL chunk {chunk_key} in {duration*1000:.1f}ms (PIL/numpy in worker thread)")
        
        # Return PIL.Image (NOT Texture - that must be created in main thread!)
        return chunk_img
    
    def _get_wang_pil_for_chunk(self, corner_types, global_x: int, global_y: int):
        """Get PIL image for wang tile with deterministic selection using GLOBAL tile coordinates."""
        nw, ne, sw, se = corner_types
        is_homogeneous = (nw == ne == sw == se)
        
        if is_homogeneous and corner_types in self.homogeneous_variants:
            variants = self.homogeneous_variants[corner_types]
            
            # 🔥 IMPROVED HASH: FNV-1a style hash for better randomness without visible patterns
            # Combines world_seed, position (x,y) into high-quality pseudo-random distribution
            seed = self.world_seed if self.world_seed else 0
            
            # FNV-1a hash algorithm (better distribution, no visible patterns)
            hash_val = 2166136261  # FNV offset basis
            hash_val ^= (seed & 0xFFFFFFFF)
            hash_val = (hash_val * 16777619) & 0xFFFFFFFF
            hash_val ^= (global_x & 0xFFFFFFFF)
            hash_val = (hash_val * 16777619) & 0xFFFFFFFF
            hash_val ^= (global_y & 0xFFFFFFFF)
            hash_val = (hash_val * 16777619) & 0xFFFFFFFF
            
            variant_idx = hash_val % len(variants)
            rotation_idx = (hash_val >> 16) % 4  # 🔥 DETERMINISTIC ROTATION: 0, 90, 180, 270 degrees
            
            # 🔥 CACHE rotated images! PIL.rotate() is SLOW (can take 60ms!)
            cache_key = (corner_types, variant_idx, rotation_idx)
            if cache_key in self.wang_pil_cache:
                return self.wang_pil_cache[cache_key]
            
            base_img = variants[variant_idx]
            
            if rotation_idx > 0:
                angle = rotation_idx * 90
                rotated = base_img.rotate(angle, expand=False)
                self.wang_pil_cache[cache_key] = rotated
                return rotated
            
            self.wang_pil_cache[cache_key] = base_img
            return base_img
        
        # Non-homogeneous: lookup wang tile with biome_id
        cache_key = (corner_types, self.biome_id)
        if cache_key in self.wang_pil_cache:
            return self.wang_pil_cache[cache_key]
        
        return None
    
    def _get_wang_texture_for_chunk(self, tile_type: int, corner_types, biome_id, local_x: int, local_y: int):
        """
        🔥 OPTIMIZED: Get Wang tile texture with SEEDED random for consistency.
        Uses deterministic random based on tile position to avoid recalculating.
        """
        if corner_types is not None and isinstance(corner_types, list):
            corner_types = tuple(corner_types)
        
        if corner_types is None:
            corner_types = (tile_type, tile_type, tile_type, tile_type)
        
        # Check if homogeneous (all corners same)
        nw, ne, sw, se = corner_types
        is_homogeneous = (nw == ne == sw == se)
        
        # For homogeneous tiles: use SEEDED random for consistent selection
        if is_homogeneous and corner_types in self.homogeneous_variants:
            # 🔥 CRITICAL OPTIMIZATION: Use deterministic selection based on position
            # This avoids random.choice() overhead and ensures same tile always gets same variant
            variants = self.homogeneous_variants[corner_types]
            variant_idx = (local_x * 7 + local_y * 13) % len(variants)  # Fast deterministic "random"
            rotation_idx = (local_x * 11 + local_y * 17) % 4  # 0,1,2,3 for 0°,90°,180°,270°
            
            # Get pre-cached texture (PIL images were pre-loaded at startup)
            base_img = variants[variant_idx]
            
            # 🔥 OPTIMIZATION: Check if we have pre-rotated textures cached
            cache_key = (corner_types, variant_idx, rotation_idx)
            if cache_key in self.wang_texture_cache:
                return self.wang_texture_cache[cache_key]
            
            # Create texture with rotation (only happens once per unique combo)
            if rotation_idx > 0:
                angle = rotation_idx * 90
                tile_img = base_img.rotate(angle, expand=False)
            else:
                tile_img = base_img
            
            texture = arcade.Texture(
                name=f"homo_{corner_types}_{variant_idx}_{rotation_idx}",
                image=tile_img
            )
            self.wang_texture_cache[cache_key] = texture
            return texture
        
        # For transition tiles: use cached texture
        cache_key = (corner_types, biome_id)
        if cache_key in self.wang_texture_cache:
            return self.wang_texture_cache[cache_key]
        
        return None
    
    # DEPRECATED PIL functions removed - GPU rendering only!
    

    def _add_ready_sprites(self):
        """
        🔥 ASYNC GPU RENDERING: Create textures from PIL images rendered in worker threads!
        
        Worker threads: PIL/numpy rendering (5-7ms in background)
        Main thread: Only arcade.Texture creation (1-2ms, fast!)
        
        Result: Near-zero frame impact from chunk loading!
        """
        if self.ready_pil_images.empty():
            return
        
        method_start = time.perf_counter()
        chunk_start = time.perf_counter()
        MAX_CHUNKS_PER_FRAME = 2  # 🔥 Can process more now - texture creation is fast!
        chunks_processed = 0
        
        # 🔥 DEBUG: Log queue size
        if DEBUG_CHUNKS:
            try:
                console.print(f"  🎨 Ready PIL image queue: {self.ready_pil_images.qsize()} chunks waiting")
            except Exception:
                pass

        # Process ready PIL images (fast - just create textures!)
        while chunks_processed < MAX_CHUNKS_PER_FRAME:
            try:
                chunk_key, pil_image = self.ready_pil_images.get_nowait()
            except Empty:
                break
            
            if chunk_key in self.chunks_with_sprites:
                if DEBUG_CHUNKS:
                    console.print(f"  ⚠️ Skipping {chunk_key} - already has sprites")
                continue  # Already added, skip
            
            # 🔥 FAST TEXTURE CREATION: PIL image already rendered in worker thread!
            try:
                texture_start = time.perf_counter()
                chunk_texture = arcade.Texture(
                    name=f"chunk_{chunk_key[0]}_{chunk_key[1]}",
                    image=pil_image
                )
                texture_time = (time.perf_counter() - texture_start) * 1000
                
                # Store in cache
                self.chunk_texture_cache[chunk_key] = chunk_texture
                
                if DEBUG_CHUNKS:
                    console.print(f"  ✅ Texture created in {texture_time:.2f}ms")
            except Exception as e:
                console.print(f"❌ Texture creation failed for {chunk_key}: {e}")
                traceback.print_exc()
                continue
            
            # Calculate chunk center position
            chunk_x, chunk_y = chunk_key
            pixel_x = chunk_x * CHUNK_SIZE * TILE_SIZE + (CHUNK_SIZE * TILE_SIZE) / 2
            # 🔥 FIX: No Y inversion needed - server and client use same coordinate system!
            pixel_y = chunk_y * CHUNK_SIZE * TILE_SIZE + (CHUNK_SIZE * TILE_SIZE) / 2
            
            # Ensure terrain SpriteList exists (use terrain_id = 999 for chunk sprites)
            CHUNK_TERRAIN_ID = 999
            if CHUNK_TERRAIN_ID not in self.terrain_sprite_lists:
                # 🔥 CRITICAL: Use lazy=True for dynamic sprite batching!
                # From Arcade docs: lazy=True defers geometry updates until draw()
                # This allows Arcade to batch multiple textures efficiently
                # 🔥 SPATIAL HASH: Enabled for fast viewport culling (O(1) instead of O(n))
                self.terrain_sprite_lists[CHUNK_TERRAIN_ID] = arcade.SpriteList(
                    use_spatial_hash=True,  # 🔥 Fast spatial queries!
                    lazy=True  # 🔥 KEY: Deferred batching for better performance
                )
            
            # Create ONE sprite for entire chunk
            sprite_start = time.perf_counter()
            sprite = arcade.Sprite(center_x=pixel_x, center_y=pixel_y)
            sprite.texture = chunk_texture
            sprite.scale = 1.0
            sprite_time = time.perf_counter() - sprite_start
            
            # Add to terrain SpriteList
            append_start = time.perf_counter()
            self.terrain_sprite_lists[CHUNK_TERRAIN_ID].append(sprite)
            append_time = time.perf_counter() - append_start
            
            # Track for removal
            self.tracked_tiles[chunk_key] = sprite
            
            chunks_processed += 1
            
            # Mark chunk as complete
            self.chunks_with_sprites.add(chunk_key)
            
            if DEBUG_CHUNKS:
                console.print(f"✅ GPU rendered chunk {chunk_key} (1024×1024px texture)")
        
        # Track chunk processing time
        chunk_duration = time.perf_counter() - chunk_start
        self.perf_monitor.record_chunk_process(chunk_duration)
        
        # DEBUG: Processing stats (commented for production)
        # if chunks_processed > 0:
        #     console.print(f"⚡ _add_ready_sprites: processed {chunks_processed} chunks in {chunk_duration*1000:.1f}ms (avg {chunk_duration*1000/chunks_processed:.1f}ms/chunk)")
    

    
    def _unload_chunk_sprites(self, chunk_key):
        """
        🔥 NEW: Remove chunk sprite (just ONE sprite per chunk now!)
        """
        if chunk_key not in self.chunks_with_sprites:
            return  # Chunk has no sprites
        
        # Remove single chunk sprite
        if chunk_key in self.tracked_tiles:
            sprite = self.tracked_tiles[chunk_key]
            
            # Find which terrain list it's in and remove it
            for terrain_list in self.terrain_sprite_lists.values():
                if sprite in terrain_list:
                    terrain_list.remove(sprite)
                    break
            
            # 🔥 CRITICAL: Delete texture from cache to free GPU memory!
            # This was the REAL memory leak - chunk_texture_cache kept growing forever!
            if chunk_key in self.chunk_texture_cache:
                del self.chunk_texture_cache[chunk_key]
            
            # Delete sprite reference to allow garbage collection
            del self.tracked_tiles[chunk_key]
            # Explicitly delete sprite object
            del sprite
        
        # Mark chunk as unloaded
        self.chunks_with_sprites.discard(chunk_key)
        if DEBUG_CHUNKS:
            console.print(f"🗑️ Removed 1 chunk sprite for {chunk_key}")

    def _draw_item_drops(self):
        """Draw ground item drops (type 3)."""
        if not self.item_entities:
            return

        cam_x, cam_y = self.camera.position
        zoom = self.camera.zoom
        view_buffer = 600
        view_w = self.window.width / zoom
        view_h = self.window.height / zoom
        screen_left = cam_x - view_w / 2 - view_buffer
        screen_right = cam_x + view_w / 2 + view_buffer
        screen_bottom = cam_y - view_h / 2 - view_buffer
        screen_top = cam_y + view_h / 2 + view_buffer

        # Snapshot for thread-safety
        items_snapshot = list(self.item_entities.items())
        color = Color.from_hex_string(COLORS["warning_yellow"])

        # Cache quantity labels to avoid re-allocating Text every frame
        if not hasattr(self, '_item_drop_qty_cache'):
            self._item_drop_qty_cache = {}
        if not hasattr(self, '_item_drop_qty_shadow_cache'):
            self._item_drop_qty_shadow_cache = {}

        for entity_id, entity_data in items_snapshot:
            x = entity_data.get("x", 0)
            y = entity_data.get("y", 0)

            if not (screen_left <= x <= screen_right and screen_bottom <= y <= screen_top):
                continue

            item_id = entity_data.get("item_id") or entity_data.get("item_type")
            sprite_path = None
            if item_id:
                item_def = get_item(str(item_id))
                if item_def and item_def.sprite:
                    sprite_path = item_def.sprite

            if sprite_path:
                texture = self._item_drop_texture_cache.get(sprite_path)
                if texture is None:
                    try:
                        texture = arcade.load_texture(sprite_path)
                        self._item_drop_texture_cache[sprite_path] = texture
                    except Exception:
                        texture = None

                if texture is not None:
                    icon_size = 28
                    sprite = self._item_drop_sprite_cache.get(sprite_path)
                    if sprite is None:
                        sprite = arcade.Sprite()
                        sprite.texture = texture
                        self._item_drop_sprite_cache[sprite_path] = sprite
                    sprite.center_x = x
                    sprite.center_y = y
                    sprite.width = icon_size
                    sprite.height = icon_size
                    arcade.draw_sprite(sprite)
                else:
                    arcade.draw_circle_filled(x, y, 8, color)
            else:
                arcade.draw_circle_filled(x, y, 8, color)

            qty = int(entity_data.get("quantity", 1) or 1)
            if qty > 1:
                key = f"drop_{entity_id}"
                text = self._item_drop_qty_cache.get(key)
                if text is None:
                    text = arcade.Text(str(qty), x + 10, y + 6, arcade.color.WHITE, 10)
                    self._item_drop_qty_cache[key] = text
                    self._item_drop_qty_shadow_cache[key] = arcade.Text(str(qty), x + 11, y + 5, (0, 0, 0, 220), 10)
                else:
                    text.text = str(qty)
                    text.x = x + 10
                    text.y = y + 6

                    shadow = self._item_drop_qty_shadow_cache.get(key)
                    if shadow is not None:
                        shadow.text = str(qty)
                        shadow.x = x + 11
                        shadow.y = y + 5

                shadow = self._item_drop_qty_shadow_cache.get(key)
                if shadow is not None:
                    shadow.draw()
                text.draw()
    
    def _draw_resource_nodes(self):
        """Draw resource nodes (type 5) as rectangles with health bars
        
        Called in on_draw but only for visible resources.
        """
        # Calculate visible area
        cam_x, cam_y = self.camera.position
        zoom = self.camera.zoom
        view_buffer = 1000
        # Use actual window size (not design constants) to keep culling correct after resize
        view_w = self.window.width / zoom
        view_h = self.window.height / zoom
        screen_left = cam_x - view_w / 2 - view_buffer
        screen_right = cam_x + view_w / 2 + view_buffer
        screen_bottom = cam_y - view_h / 2 - view_buffer
        screen_top = cam_y + view_h / 2 + view_buffer

        # 🔥 Thread-safe: snapshot only resources (much smaller than all entities)
        import time
        import math
        now = time.perf_counter()
        resources_snapshot = list(self.resource_entities.items())
        for entity_id, entity_data in resources_snapshot:
            entity_x = entity_data.get("x", 0)
            entity_y = entity_data.get("y", 0)
            
            # CULLING
            if not (screen_left <= entity_x <= screen_right and screen_bottom <= entity_y <= screen_top):
                continue
            
            # Get position (use interpolated if available)
            if entity_id in self.entity_display_pos:
                x, y = self.entity_display_pos[entity_id]
            else:
                x, y = entity_x, entity_y
                self.entity_display_pos[entity_id] = (x, y)
            
            resource_type = entity_data.get("resource_type", "unknown")
            health = entity_data.get("health", 100)
            max_health = entity_data.get("max_health", 100)
            variant_id = entity_data.get("variant_id")

            # Harvest FX state
            being_harvested = bool(entity_data.get("being_harvested", False))
            # 0..1 from server (safe default)
            try:
                harvest_progress = float(entity_data.get("harvest_progress", 0.0) or 0.0)
            except Exception:
                harvest_progress = 0.0

            # Small shake while harvesting (cheap, purely visual)
            shake_x = 0.0
            shake_y = 0.0
            if being_harvested:
                # Short, subtle, fast shake burst after each local "hit" while holding mouse.
                fx_target_id = getattr(self, "_harvest_fx_target_id", None)
                fx_last_intent_t = float(getattr(self, "_harvest_fx_last_intent_t", 0.0) or 0.0)
                fx_last_hit_t = float(getattr(self, "_harvest_fx_last_hit_t", 0.0) or 0.0)
                fx_duration = float(getattr(self, "_harvest_fx_shake_duration_s", 0.14) or 0.14)
                fx_amp = float(getattr(self, "_harvest_fx_shake_amp", 0.75) or 0.75)
                fx_freq = float(getattr(self, "_harvest_fx_shake_freq", 78.0) or 78.0)
                fx_delay = float(getattr(self, "_harvest_fx_shake_delay_s", 0.035) or 0.035)

                # Only shake for the locally harvested target (and only while mouse is held).
                locally_harvesting = (fx_target_id is not None and int(fx_target_id) == int(entity_id) and (now - fx_last_intent_t) < 0.25)
                if locally_harvesting and fx_last_hit_t > 0.0:
                    elapsed = (now - fx_last_hit_t) - fx_delay
                    if 0.0 <= elapsed < fx_duration:
                        # Ease-out envelope so it reads as a quick impact, not continuous mashing.
                        strength = 1.0 - (elapsed / fx_duration)
                        amp = fx_amp * strength
                        shake_x = math.sin(now * fx_freq + entity_id * 0.17 + harvest_progress * 9.0) * amp
                        shake_y = math.cos(now * (fx_freq * 0.92) + entity_id * 0.13 + harvest_progress * 11.0) * (amp * 0.6)
                elif not locally_harvesting:
                    # Remote harvesting: use per-target hit timestamps (computed from server harvest_progress).
                    try:
                        hit_map = getattr(self, "_harvest_fx_last_hit_by_target", None)
                        hit_t = float(hit_map.get(int(entity_id), 0.0) or 0.0) if isinstance(hit_map, dict) else 0.0
                        if hit_t > 0.0:
                            elapsed = (now - hit_t)
                            if 0.0 <= elapsed < fx_duration:
                                strength = 1.0 - (elapsed / fx_duration)
                                amp = (fx_amp * 0.85) * strength
                                shake_x = math.sin(now * fx_freq + entity_id * 0.17 + harvest_progress * 9.0) * amp
                                shake_y = math.cos(now * (fx_freq * 0.92) + entity_id * 0.13 + harvest_progress * 11.0) * (amp * 0.6)
                    except Exception:
                        pass
            
            # Create sprite if not exists (UNIFIED)
            resource_key = (resource_type, variant_id)
            if variant_id and resource_key in self.resource_textures:
                if entity_id not in self.resource_sprites:
                    texture = self.resource_textures[resource_key]
                    sprite = arcade.Sprite(center_x=x, center_y=y)
                    sprite.texture = texture
                    # Store metadata for Y-sorting
                    sprite.resource_type = resource_type
                    sprite.resource_id = variant_id
                    sprite.entity_id = int(entity_id)
                    self.resource_sprite_list.append(sprite)
                    self.resource_sprites[entity_id] = sprite
                else:
                    sprite = self.resource_sprites[entity_id]
                    sprite.center_x = x + shake_x
                    sprite.center_y = y + shake_y
            
            else:
                # Fallback: Draw as colored rectangle for missing sprites
                if resource_type == "tree":
                    color = Color.from_hex_string("#2d5016")  # Dark green
                elif resource_type == "rock":
                    color = Color.from_hex_string("#5a5a5a")  # Gray
                elif resource_type == "scrap_pile":
                    color = Color.from_hex_string("#8b7355")  # Brown
                elif resource_type == "iron_ore":
                    color = Color.from_hex_string("#4a4e69")  # Blue-gray
                else:
                    color = Color.from_hex_string("#666666")
                
                # Draw resource (larger than players)
                size = TILE_SIZE * 0.8
                arcade.draw_lbwh_rectangle_filled((x + shake_x) - size/2, (y + shake_y) - size/2, size, size, color)
    
    def _draw_health_bars(self):
        """Draw health bars on top of all sprites (highest layer)"""
        for entity_id, entity in self.resource_entities.items():
            health = entity.get("health", 100)
            max_health = entity.get("max_health", 100)
            
            # Only draw if damaged
            if health >= max_health:
                continue
            
            x = entity.get("x", 0)
            y = entity.get("y", 0)
            
            # Get sprite to determine bar width
            sprite = self.resource_sprites.get(entity_id)
            if sprite:
                bar_width = sprite.width * 0.8
                bar_y = y + sprite.height / 2 + 5
            else:
                bar_width = TILE_SIZE * 0.8
                bar_y = y + TILE_SIZE * 0.4 + 5
            
            bar_height = 4
            
            # Background (red)
            arcade.draw_lbwh_rectangle_filled(
                x - bar_width/2, bar_y - bar_height/2,
                bar_width, bar_height,
                Color.from_hex_string("#c1121f")
            )
            
            # Foreground (green)
            health_percent = health / max_health
            arcade.draw_lbwh_rectangle_filled(
                x - bar_width/2, bar_y - bar_height/2,
                bar_width * health_percent, bar_height,
                Color.from_hex_string("#2ec4b6")
            )

    def _draw_harvest_complete_fx(self):
        """Draw one-shot completion VFX (resource shrinks/fades into itself)."""
        fx_list = getattr(self, "_harvest_complete_fx", None)
        if not fx_list:
            return

        import time
        now = time.perf_counter()
        duration_default = float(getattr(self, "_harvest_complete_fx_duration_s", 0.38) or 0.38)

        alive = []
        for fx in fx_list:
            t0 = float(fx.get("t0", 0.0) or 0.0)
            duration = float(fx.get("duration", duration_default) or duration_default)
            age = now - t0
            if age < 0.0 or age >= duration:
                continue

            p = age / duration  # 0..1
            kind = fx.get("kind", "burst")

            # More visible default: burst ring + debris at the base
            if kind == "burst":
                x = float(fx.get("x", 0.0) or 0.0)
                y = float(fx.get("base_y", fx.get("y", 0.0)) or 0.0)

                # Fade out fast but readable
                alpha = int(255 * ((1.0 - p) ** 0.55))
                col = (255, 255, 255, max(0, min(255, alpha)))

                # Expanding ring (reads as "pop")
                r0 = float(fx.get("ring_r0", 6.0) or 6.0)
                r1 = float(fx.get("ring_r1", 26.0) or 26.0)
                ring_r = r0 + (r1 - r0) * p
                ring_w = 3 if p < 0.35 else 2
                try:
                    arcade.draw_circle_outline(x, y, ring_r, col, ring_w)
                except Exception:
                    # Some arcade versions may not support outline; fall back to thin filled ring-ish
                    arcade.draw_circle_filled(x, y, ring_r, (255, 255, 255, max(0, min(90, alpha))))

                # Debris lines (deterministic per target)
                parts = fx.get("parts") or []
                if parts:
                    import math
                    for part in parts:
                        ang = float(part.get("a", 0.0) or 0.0)
                        spd = float(part.get("s", 1.0) or 1.0)
                        ln = float(part.get("l", 8.0) or 8.0)
                        w = int(part.get("w", 2) or 2)

                        # Quick arc with slight gravity (down)
                        dx = math.cos(ang) * (spd * (0.3 + 0.9 * p))
                        dy = math.sin(ang) * (spd * (0.3 + 0.9 * p)) - (10.0 * (p ** 2))
                        x1 = x + dx
                        y1 = y + dy
                        x2 = x1 + math.cos(ang) * ln * (1.0 - p)
                        y2 = y1 + math.sin(ang) * ln * (1.0 - p)
                        arcade.draw_line(x1, y1, x2, y2, col, w)

            else:
                # Legacy shrink/fade (kept as fallback)
                alpha = int(255 * ((1.0 - p) ** 0.65))
                scale = 1.05 - (0.85 * p)  # 1.05 -> 0.20
                sink = 14.0 * p

                x = float(fx.get("x", 0.0) or 0.0)
                y0 = float(fx.get("y", 0.0) or 0.0)
                texture = fx.get("texture")
                if texture is not None:
                    w = float(fx.get("w", 32.0) or 32.0) * scale
                    h = float(fx.get("h", 32.0) or 32.0) * scale

                    h0 = float(fx.get("h", 32.0) or 32.0)
                    y = y0 - sink + (h0 * (scale - 1.0)) * 0.5

                    spr = fx.get("sprite")
                    if spr is None:
                        spr = arcade.Sprite()
                        spr.texture = texture
                        fx["sprite"] = spr
                    spr.center_x = x
                    spr.center_y = y
                    spr.width = w
                    spr.height = h
                    spr.alpha = max(0, min(255, int(alpha)))
                    arcade.draw_sprite(spr)
                else:
                    r = 12.0 * scale
                    arcade.draw_circle_filled(x, y0 - sink, r, (255, 255, 255, max(0, min(255, alpha))))

            alive.append(fx)

        self._harvest_complete_fx = alive
    
    def _draw_sorted_sprites(self):
        """
        Draw player and resources sorted by Y position for proper depth/layering.
        Objects further down (lower Y) are drawn first, so they appear behind.
        
        🔥 OPTIMIZED: Cache sorted list, viewport culling, pre-calculated sort keys
        """
        # 🔥 OPTIMIZED: Cache viewport bounds calculation (only recalculate when camera changes)
        cam_x, cam_y = self.camera.position
        cam_zoom = self.camera.zoom
        
        # Check if camera changed (position or zoom) or cache not initialized
        if self._cached_viewport is None or \
           self._cached_camera_pos != (cam_x, cam_y) or \
           self._cached_camera_zoom != cam_zoom:
            # Recalculate viewport bounds
            view_width = self.window.width / cam_zoom
            view_height = self.window.height / cam_zoom
            self._cached_viewport = (
                cam_x - view_width / 2 - 200,   # left
                cam_x + view_width / 2 + 200,   # right
                cam_y - view_height / 2 - 200,  # bottom
                cam_y + view_height / 2 + 200   # top
            )
            self._cached_camera_pos = (cam_x, cam_y)
            self._cached_camera_zoom = cam_zoom
        
        viewport_left, viewport_right, viewport_bottom, viewport_top = self._cached_viewport
        
        # Check if we need to re-sort (sprite list changed or player moved significantly)
        need_resort = False
        
        # Initialize cache if needed
        if not hasattr(self, '_sorted_sprites_cache'):
            self._sorted_sprites_cache = []
            self._last_player_sort_y = 0
            self._last_sprite_count = 0
            need_resort = True
        
        # Check if sprite count changed (resource added/removed, players visible)
        current_sprite_count = (
            len(self.resource_sprite_list)
            + len(self.entity_sprite_list)
            + (1 if self.player_sprite else 0)
        )
        if current_sprite_count != self._last_sprite_count:
            need_resort = True
            self._last_sprite_count = current_sprite_count
        
        # 🔥 OPTIMIZED: Check if player moved significantly (>16px Y change = 0.5 tiles)
        # Re-sort more frequently for accurate depth sorting with horizon points
        if self.player_sprite:
            player_y = self.player_sprite.center_y
            if abs(player_y - self._last_player_sort_y) > 16:
                need_resort = True
                self._last_player_sort_y = player_y

        # If other players move between frames, we still need to resort occasionally
        if not hasattr(self, '_last_other_players_sort_hash'):
            self._last_other_players_sort_hash = None
            need_resort = True
        else:
            # Cheap hash based on quantized feet Y (detects depth changes without full sort every frame)
            other_hash = 0
            for s in self.entity_sprite_list:
                other_hash = (other_hash * 1315423911) ^ int((s.center_y - 28) // 16)
            if other_hash != self._last_other_players_sort_hash:
                need_resort = True
                self._last_other_players_sort_hash = other_hash
        
        if need_resort:
            # 🔥 PRE-CALCULATE sort keys for all sprites (avoid repeated lookups)
            sprite_sort_data = []
            
            # Add player sprite
            if self.player_sprite:
                sort_key = self.player_sprite.center_y - 28  # Player feet
                sprite_sort_data.append((sort_key, self.player_sprite))

            # Add other player sprites (feet-based sorting)
            for sprite in self.entity_sprite_list:
                # entity_sprite_list is already culled to visible players in on_update
                sort_key = sprite.center_y - 28
                sprite_sort_data.append((sort_key, sprite))
            
            # Add visible resource sprites with pre-calculated sort keys
            for sprite in self.resource_sprite_list:
                # 🔥 VIEWPORT CULLING: Skip off-screen sprites
                if (sprite.center_x < viewport_left or sprite.center_x > viewport_right or
                    sprite.center_y < viewport_bottom or sprite.center_y > viewport_top):
                    continue
                
                # Calculate sort key once
                sort_key = sprite.center_y  # default
                
                if hasattr(sprite, 'resource_type') and hasattr(sprite, 'resource_id'):
                    res_type = sprite.resource_type
                    res_id = sprite.resource_id
                    resource_key = (res_type, res_id)
                    
                    # Use UNIFIED data structures
                    horizon_data = self.resource_horizon_data.get(resource_key)
                    if horizon_data:
                        horizon_y = horizon_data[1]
                        dims = self.resource_dimensions.get(resource_key)
                        default_center_y = dims[1] / 2 if dims else (96 if res_type == "tree" else 24)
                        sort_key = sprite.center_y + (default_center_y - horizon_y)
                
                sprite_sort_data.append((sort_key, sprite))
            
            # Sort once by pre-calculated keys (reverse=higher Y drawn first)
            sprite_sort_data.sort(key=lambda x: x[0], reverse=True)
            self._sorted_sprites_cache = [sprite for _, sprite in sprite_sort_data]
            sprites_to_draw = self._sorted_sprites_cache
        else:
            # 🔥 FAST PATH: Filter cached sprites by viewport (optimized conditions)
            sprites_to_draw = [
                s for s in self._sorted_sprites_cache 
                if s is self.player_sprite or (
                    viewport_left <= s.center_x <= viewport_right and
                    viewport_bottom <= s.center_y <= viewport_top
                )
            ]
            # Use full cache if filtering would remove player
            if not sprites_to_draw or (self.player_sprite and self.player_sprite not in sprites_to_draw):
                sprites_to_draw = self._sorted_sprites_cache
        
        # 🔥 PRE-CALCULATE transparency once if enabled
        if self.resource_transparency_enabled and self.player_sprite:
            player_x = self.player_sprite.center_x
            player_y = self.player_sprite.center_y
            
            for sprite in sprites_to_draw:
                if sprite == self.player_sprite:
                    continue
                
                # Quick distance check (squared distance avoids sqrt)
                dx = sprite.center_x - player_x
                dy = sprite.center_y - player_y
                dist_sq = dx * dx + dy * dy
                
                # If player is behind resource and close (50px = 2500px²)
                if player_y > sprite.center_y - 25 and dist_sq < 2500:
                    sprite.alpha = 100
                else:
                    sprite.alpha = 255
        
        # Draw in sorted order
        for sprite in sprites_to_draw:
            arcade.draw_sprite(sprite)
            
            # Movement debug: collision probe point at feet (RED CROSS)
            if self.show_movement_debug and sprite == self.player_sprite:
                feet_x = sprite.center_x
                feet_y = sprite.center_y - 28  # Feet position
                arcade.draw_line(feet_x - 5, feet_y, feet_x + 5, feet_y, arcade.color.RED, 2)
                arcade.draw_line(feet_x, feet_y - 5, feet_x, feet_y + 5, arcade.color.RED, 2)
    
    def _draw_gui(self):
        """Draw GUI elements"""
        # 🔥 OPTIMIZED: Lazy init - only update text every 5 frames to reduce overhead
        if not hasattr(self, '_gui_frame_counter'):
            self._gui_frame_counter = 0
        
        self._gui_frame_counter += 1
        should_update = (self._gui_frame_counter % 5 == 0)  # Update every 5 frames (12Hz)
        
        if should_update:
            # Update Text objects (faster than arcade.draw_text)
            self.seed_text.text = f"Seed: {self.world_seed}" if self.world_seed else "Seed: ..."
            self.fps_text.text = f"FPS: {arcade.get_fps(60):.0f}"
            self.pos_text.text = f"Pos: ({self.player_x:.0f}, {self.player_y:.0f})"
            
            # Format game time as HH:MM
            hours = int(self.game_time // 60) % 24
            minutes = int(self.game_time % 60)
            # Add time of day indicator
            if 6 <= hours < 12:
                time_icon = "🌅"  # Morning
            elif 12 <= hours < 18:
                time_icon = "☀️"  # Day
            elif 18 <= hours < 21:
                time_icon = "🌆"  # Evening
            else:
                time_icon = "🌙"  # Night
            self.time_text.text = f"{time_icon} {hours:02d}:{minutes:02d}"
            
            # Update zoom text
            self.zoom_text.text = f"🔍 Zoom: {self.camera_zoom:.1f}x"
            
            if self.network:
                self.status_text.text = "🟢 Connected" if self.network.connected else "🔴 Disconnected"
                self.ping_text.text = f"Ping: {self.network.ping_ms}ms" if self.network.connected else ""
                
                # Environmental status (biome-specific)
                if self.biome_id == "toxic_wasteland":
                    self.env_status_text.text = "☢️ " + self.i18n.t(
                        "ui.environment.radiation_status",
                        default="Radiation: {value}%",
                        value=int(self.player_radiation),
                    )
                elif self.biome_id == "frozen_world":
                    self.env_status_text.text = "❄️ " + self.i18n.t(
                        "ui.environment.cold_status",
                        default="Cold: {value}%",
                        value=int(self.player_cold),
                    )
                else:
                    self.env_status_text.text = ""
                
                # Update player username from network (once connected)
                if self.network.connected and self.network.username:
                    self.player_username = self.network.username
            else:
                self.status_text.text = "🔥 OFFLINE MODE"
                self.ping_text.text = ""
                self.env_status_text.text = ""

            # Keep shadow texts in sync (only when content changes)
            self.seed_text_shadow.text = self.seed_text.text
            self.fps_text_shadow.text = self.fps_text.text
            self.pos_text_shadow.text = self.pos_text.text
            self.status_text_shadow.text = self.status_text.text
            self.ping_text_shadow.text = self.ping_text.text
            self.env_status_text_shadow.text = self.env_status_text.text
            self.time_text_shadow.text = self.time_text.text
            self.zoom_text_shadow.text = self.zoom_text.text
            self.title_text_shadow.text = self.title_text.text

            if self.show_movement_debug:
                import time
                have_server = bool(getattr(self, "_have_server_position", False))
                sx = float(getattr(self, "server_x", 0.0) or 0.0)
                sy = float(getattr(self, "server_y", 0.0) or 0.0)
                svx = float(getattr(self, "server_vx", 0.0) or 0.0)
                svy = float(getattr(self, "server_vy", 0.0) or 0.0)

                cx = float(self.player_x)
                cy = float(self.player_y)
                cvx = float(getattr(self, "player_vx", 0.0) or 0.0)
                cvy = float(getattr(self, "player_vy", 0.0) or 0.0)

                dx = (cx - sx) if have_server else 0.0
                dy = (cy - sy) if have_server else 0.0
                dist = (dx * dx + dy * dy) ** 0.5 if have_server else 0.0

                now = time.perf_counter()
                last_state = getattr(self, "_last_state_update_received_at", None)
                state_age_ms = (now - last_state) * 1000.0 if last_state is not None else None

                # Collision flags at current feet probe point
                feet_offset = float(getattr(self, "_player_feet_offset_y", 28) or 28)
                feet_x = cx
                feet_y = cy - feet_offset

                water = False
                resource = False
                collides = False
                try:
                    if getattr(self, "enable_client_collision_prediction", False):
                        water = bool(self._client_point_has_water(feet_x, feet_y))
                        resource = bool(self._client_point_collides_with_resources(feet_x, feet_y))
                        collides = bool(water or resource)
                except Exception:
                    pass

                moving = (abs(cvx) > 0.01) or (abs(cvy) > 0.01)
                stop_grace = float(getattr(self, "_stop_grace_timer", 0.0) or 0.0)
                stuck_ticks = int(getattr(self, "_server_stuck_ticks", 0) or 0)
                pred_on = bool(getattr(self, "enable_client_prediction", False))
                colpred_on = bool(getattr(self, "enable_client_collision_prediction", False))

                age_str = f"{state_age_ms:.1f}ms" if state_age_ms is not None else "n/a"
                self.movement_debug_text.text = (
                    f"DBG(move): pred={pred_on} colpred={colpred_on} moving={moving}\n"
                    f"client: pos=({cx:.2f},{cy:.2f}) v=({cvx:.2f},{cvy:.2f})\n"
                    + (f"server: pos=({sx:.2f},{sy:.2f}) v=({svx:.2f},{svy:.2f}) age={age_str}\n" if have_server else f"server: (no data) age={age_str}\n")
                    + (f"delta: d=({dx:.2f},{dy:.2f}) |d|={dist:.2f} stuck_ticks={stuck_ticks} stop_grace={stop_grace:.2f}\n" if have_server else f"stuck_ticks={stuck_ticks} stop_grace={stop_grace:.2f}\n")
                    + f"feet: ({feet_x:.1f},{feet_y:.1f}) collides={collides} water={water} resource={resource}"
                )
                self.movement_debug_text_shadow.text = self.movement_debug_text.text
            else:
                self.movement_debug_text.text = ""
                self.movement_debug_text_shadow.text = ""

        # Keep shadow positions in sync (every frame; cheap)
        dx, dy = 1, -1
        self.seed_text_shadow.x, self.seed_text_shadow.y = self.seed_text.x + dx, self.seed_text.y + dy
        self.fps_text_shadow.x, self.fps_text_shadow.y = self.fps_text.x + dx, self.fps_text.y + dy
        self.pos_text_shadow.x, self.pos_text_shadow.y = self.pos_text.x + dx, self.pos_text.y + dy
        self.status_text_shadow.x, self.status_text_shadow.y = self.status_text.x + dx, self.status_text.y + dy
        self.ping_text_shadow.x, self.ping_text_shadow.y = self.ping_text.x + dx, self.ping_text.y + dy
        self.env_status_text_shadow.x, self.env_status_text_shadow.y = self.env_status_text.x + dx, self.env_status_text.y + dy
        self.time_text_shadow.x, self.time_text_shadow.y = self.time_text.x + dx, self.time_text.y + dy
        self.zoom_text_shadow.x, self.zoom_text_shadow.y = self.zoom_text.x + dx, self.zoom_text.y + dy
        self.title_text_shadow.x, self.title_text_shadow.y = self.title_text.x + dx, self.title_text.y + dy

        if self.show_movement_debug:
            self.movement_debug_text_shadow.x = self.movement_debug_text.x + dx
            self.movement_debug_text_shadow.y = self.movement_debug_text.y + dy
        
        # Always draw (text content cached)
        self.seed_text_shadow.draw()
        self.seed_text.draw()
        self.fps_text_shadow.draw()
        self.fps_text.draw()
        self.pos_text_shadow.draw()
        self.pos_text.draw()
        self.status_text_shadow.draw()
        self.status_text.draw()
        self.ping_text_shadow.draw()
        self.ping_text.draw()
        if self.env_status_text.text:
            self.env_status_text_shadow.draw()
        self.env_status_text.draw()
        self.time_text_shadow.draw()
        self.time_text.draw()
        self.zoom_text_shadow.draw()
        self.zoom_text.draw()
        self.title_text_shadow.draw()
        self.title_text.draw()

        if self.show_movement_debug and self.movement_debug_text.text:
            self.movement_debug_text_shadow.draw()
            self.movement_debug_text.draw()
    
    def _draw_chat(self):
        """Draw chat messages and input - OPTIMIZED with cached Text objects!"""
        current_time = time.time()
        
        if self.chat_active:
            # Chat is open - show full history with background
            chat_bg_height = 300
            chat_bottom = 100
            chat_top = chat_bottom + chat_bg_height
            input_box_top = 110  # Top of input box
            
            arcade.draw_lbwh_rectangle_filled(
                5, chat_bottom, self.window.width - 10, chat_bg_height,
                (0, 0, 0, 200)
            )
            
            # Draw messages from BOTTOM up (newest at bottom, right above input)
            max_visible = 15  # Can fit ~15 messages
            visible_messages = self.chat_messages[-max_visible:]
            
            # Ensure we have enough cached text objects
            while len(self.chat_text_objects) < len(visible_messages):
                self.chat_text_objects.append(
                    arcade.Text("", 10, 100, arcade.color.WHITE, 12)
                )
            
            # Start just above input box
            y_offset = input_box_top - 5
            
            # Draw newest to oldest (bottom to top) - REUSE text objects!
            for idx, (username, message, timestamp) in enumerate(reversed(visible_messages)):
                text_obj = self.chat_text_objects[idx]
                text_obj.text = f"{username}: {message}"
                text_obj.x = 10
                text_obj.y = y_offset
                text_obj.draw()
                y_offset += 20  # Go UP (add to Y)
            
            # Draw chat input box at bottom
            arcade.draw_lbwh_rectangle_filled(
                5, 50, self.window.width - 10, 40,
                (0, 0, 0, 220)
            )
            
            # REUSE cached input text object
            self.chat_input_text.text = f"> {self.chat_input}_"
            self.chat_input_text.x = 10
            self.chat_input_text.y = 65
            self.chat_input_text.draw()
        
        else:
            # Chat is closed - show only recent messages (no fade, just disappear after 10s)
            recent_messages = []
            for username, message, timestamp in self.chat_messages[-self.chat_display_messages:]:
                age = current_time - timestamp
                if age < self.chat_fade_duration:
                    recent_messages.append((username, message))
            
            # Draw recent messages from bottom up (newest at bottom)
            if recent_messages:
                # Shadow cache for readability on bright backgrounds
                if not hasattr(self, "chat_text_shadow_objects"):
                    self.chat_text_shadow_objects = []

                # Ensure we have enough cached text objects
                while len(self.chat_text_objects) < len(recent_messages):
                    self.chat_text_objects.append(
                        arcade.Text("", 10, 100, arcade.color.WHITE, 12)
                    )

                while len(self.chat_text_shadow_objects) < len(recent_messages):
                    self.chat_text_shadow_objects.append(
                        arcade.Text("", 10, 100, (0, 0, 0, 210), 12)
                    )
                
                y_offset = 100  # Bottom position (start low)
                # Draw newest to oldest (bottom to top) - REUSE text objects!
                for idx, (username, message) in enumerate(reversed(recent_messages)):
                    text_obj = self.chat_text_objects[idx]
                    text_obj.text = f"{username}: {message}"
                    text_obj.x = 10
                    text_obj.y = y_offset

                    shadow_obj = self.chat_text_shadow_objects[idx]
                    shadow_obj.text = text_obj.text
                    shadow_obj.x = text_obj.x + 1
                    shadow_obj.y = text_obj.y - 1

                    shadow_obj.draw()
                    text_obj.draw()
                    y_offset += 20  # Go UP (add to Y)
    
    def _log_player_tile_info(self):
        """Log what tile player is standing on (from server chunks)"""
        from shared.constants import TILE_SIZE
        
        # Calculate tile position (player center)
        tile_x = int(self.player_x // TILE_SIZE)
        tile_y = int(self.player_y // TILE_SIZE)
        
        # Get terrain from loaded chunk
        chunk_x = tile_x // CHUNK_SIZE
        chunk_y = tile_y // CHUNK_SIZE
        chunk_key = (chunk_x, chunk_y)
        
        if chunk_key in self.loaded_chunks:
            chunk = self.loaded_chunks[chunk_key]
            local_x = tile_x % CHUNK_SIZE
            local_y = tile_y % CHUNK_SIZE
            terrain_id = chunk.get_tile(local_x, local_y)
            
            terrain_names = {0: "WATER", 1: "WASTELAND", 2: "DESERT"}
            terrain_name = terrain_names.get(terrain_id, f"UNKNOWN({terrain_id})")
            
            console.print(f"🎮 CLIENT: Pos({self.player_x:.1f}, {self.player_y:.1f}) | Tile({tile_x}, {tile_y}) = {terrain_name} (ID {terrain_id})")
        else:
            console.print(f"🎮 CLIENT: Pos({self.player_x:.1f}, {self.player_y:.1f}) | Chunk({chunk_x}, {chunk_y}) NOT IN loaded_chunks - SERVER NOT SENDING DATA!")
    
    def _log_client_stats(self, frames: int, elapsed: float):
        """Log client statistics"""
        actual_fps = frames / elapsed if elapsed > 0 else 0
        
        cam_x, cam_y = self.camera.position
        chunk_x = int(cam_x // (CHUNK_SIZE * TILE_SIZE))
        chunk_y = int(cam_y // (CHUNK_SIZE * TILE_SIZE))
        
        if DEBUG_STATS:
            console.print(f"\n📊 Client Stats ({self.player_username}):")
            console.print(f"   FPS: {actual_fps:.1f}")
            console.print(f"   Player Pos: ({self.player_x:.0f}, {self.player_y:.0f}) Chunk: ({chunk_x}, {chunk_y})")
            console.print(f"   Loaded Chunks: {len(self.loaded_chunks)}")
            console.print(f"   Chunks with Sprites: {len(self.chunks_with_sprites)}")
            console.print(f"   Tracked Tiles: {len(self.tracked_tiles)}")
            console.print(f"   Terrain SpriteLists: {len(self.terrain_sprite_lists)}")
            console.print(f"   Connected: {self.network.connected}, Ping: {self.network.ping_ms:.0f}ms")
            console.print(f"   Entities: {self.last_visible_entity_count} visible / {len(self.entities)} total")
            console.print(f"   Cached Text Labels: {len(self.entity_name_labels)}")
    
    def _update_entity_label_positions(self):
        """Update text label positions in on_update (not on_draw!)
        
        This updates pyglet Label positions for entity names.
        The actual drawing happens in one batch draw call in on_draw.
        """
        # Calculate visible area for culling (with zoom support)
        cam_x, cam_y = self.camera.position
        zoom = self.camera.zoom
        view_buffer = 1000  # Large fixed buffer so resources don't pop in/out
        screen_left = cam_x - (SCREEN_WIDTH / zoom) // 2 - view_buffer
        screen_right = cam_x + (SCREEN_WIDTH / zoom) // 2 + view_buffer
        screen_bottom = cam_y - (SCREEN_HEIGHT / zoom) // 2 - view_buffer
        screen_top = cam_y + (SCREEN_HEIGHT / zoom) // 2 + view_buffer
        
        for entity_data in self.entities.values():
            entity_id = entity_data.get("id")
            entity_type = entity_data.get("type", 0)
            
            # Skip our player
            if entity_id == self.network.player_id:
                continue
            
            # CULLING: Skip off-screen entities
            entity_x = entity_data.get("x", 0)
            entity_y = entity_data.get("y", 0)
            if not (screen_left <= entity_x <= screen_right and screen_bottom <= entity_y <= screen_top):
                continue
            
            # Update player name labels
            if self.use_sprites and entity_type == 0 and entity_id in self.entity_sprites:
                if "username" in entity_data:
                    # Get interpolated position
                    if entity_id not in self.entity_display_pos:
                        self.entity_display_pos[entity_id] = (entity_x, entity_y)
                    x, y = self.entity_display_pos[entity_id]

                    # Pixel-perfect text: snap to integer pixels to prevent blur.
                    x = int(round(x))
                    y = int(round(y))
                    
                    # Create pyglet Label (in batch!) if doesn't exist
                    if entity_id not in self.entity_name_labels:
                        import pyglet
                        # Shadow first so main draws on top
                        self.entity_name_shadow_labels[entity_id] = pyglet.text.Label(
                            entity_data["username"],
                            font_name='Arial',
                            font_size=10,
                            x=x + 1, y=y + TILE_SIZE + 9,
                            anchor_x='center',
                            anchor_y='bottom',
                            color=(0, 0, 0, 200),
                            batch=self.text_batch
                        )
                        self.entity_name_labels[entity_id] = pyglet.text.Label(
                            entity_data["username"],
                            font_name='Arial',
                            font_size=10,
                            x=x, y=y + TILE_SIZE + 10,
                            anchor_x='center',
                            anchor_y='bottom',
                            color=(255, 255, 255, 255),
                            batch=self.text_batch
                        )
                    # Update position only (batch will draw all at once!)
                    label = self.entity_name_labels[entity_id]
                    label.x = x
                    label.y = y + TILE_SIZE + 10
                    shadow = self.entity_name_shadow_labels.get(entity_id)
                    if shadow is not None:
                        shadow.x = x + 1
                        shadow.y = y + TILE_SIZE + 9
    
    def _manage_chunks_in_update(self):
        """Manage chunk loading/unloading in on_update (non-blocking!)
        
        🔥 CRITICAL: This is NOW called only 2x per second (0.5s throttle in on_update)!
        No internal throttling needed - just do all the work when called.
        """
        import time
        method_start = time.perf_counter()
        current_time = time.time()
        
        # Calculate visible area in tiles (accounting for zoom)
        calc_start = time.perf_counter()
        cam_x, cam_y = self.camera.position
        zoom = self.camera.zoom
        view_width = (SCREEN_WIDTH / zoom) // TILE_SIZE
        view_height = (SCREEN_HEIGHT / zoom) // TILE_SIZE
        
        left_tile = int(cam_x // TILE_SIZE - view_width // 2) - 1
        right_tile = int(cam_x // TILE_SIZE + view_width // 2) + 2
        bottom_tile = int(cam_y // TILE_SIZE - view_height // 2) - 1
        top_tile = int(cam_y // TILE_SIZE + view_height // 2) + 2
        
        # Calculate chunk range
        # 🔥 GPU RENDERING: Balanced buffer for smooth loading without overwhelming GPU
        CHUNK_BUFFER = 1  # Buffer 1 = 4×4 grid = 16 chunks (sweet spot for performance)
        
        left_chunk = (left_tile // CHUNK_SIZE) - CHUNK_BUFFER
        right_chunk = ((right_tile - 1) // CHUNK_SIZE + 1) + CHUNK_BUFFER
        
        # Calculate chunk range for Y axis
        # No Y inversion needed - server uses same coordinate system!
        bottom_chunk = (bottom_tile // CHUNK_SIZE) - CHUNK_BUFFER
        top_chunk = ((top_tile - 1) // CHUNK_SIZE + 1) + CHUNK_BUFFER
        
        # 🔥 DEBUG: Check if chunk range makes sense
        if DEBUG_CHUNKS and (bottom_chunk >= top_chunk or left_chunk >= right_chunk):
            console.print(f"⚠️ WARNING: Invalid chunk range!")
            console.print(f"  X: [{left_chunk}, {right_chunk}), Y: [{bottom_chunk}, {top_chunk})")
            console.print(f"  Camera: ({cam_x:.0f}, {cam_y:.0f})")
            console.print(f"  Tiles: X=[{left_tile}, {right_tile}), Y=[{bottom_tile}, {top_tile})")
        
        # Build set of chunks we need (visible + buffer)
        needed_chunk_keys = set()
        needed_chunks = set()
        
        for chunk_x in range(left_chunk, right_chunk):
            for chunk_y in range(bottom_chunk, top_chunk):
                chunk_key = (chunk_x, chunk_y)
                needed_chunk_keys.add(chunk_key)
                
                if chunk_key not in self.loaded_chunks:
                    needed_chunks.add(chunk_key)
        
        calc_time = time.perf_counter() - calc_start
        
        # Unload chunks that are no longer needed
        unload_start = time.perf_counter()
        chunks_to_remove = [key for key in self.loaded_chunks.keys() if key not in needed_chunk_keys]
        
        # 🔥 DEBUG: Always log chunk management stats
        if DEBUG_CHUNKS:
            console.print(f"\n📊 CHUNK MANAGEMENT:")
            console.print(f"  Camera: ({cam_x:.0f}, {cam_y:.0f})")
            console.print(f"  Chunk range: X=[{left_chunk}, {right_chunk}), Y=[{bottom_chunk}, {top_chunk})")
            console.print(f"  Needed: {len(needed_chunk_keys)}, Loaded: {len(self.loaded_chunks)}, With sprites: {len(self.chunks_with_sprites)}")
            console.print(f"  To unload: {len(chunks_to_remove)}, To load: {len(needed_chunks)}")
            if len(chunks_to_remove) > 0:
                console.print(f"  🗑️ Unloading: {chunks_to_remove[:5]}")
            if len(needed_chunks) > 0:
                console.print(f"  📥 Loading: {list(needed_chunks)[:5]}")
        
        unload_count = 0
        for chunk_key in chunks_to_remove:
            self._unload_chunk_sprites(chunk_key)
            del self.loaded_chunks[chunk_key]
            unload_count += 1
        unload_time = time.perf_counter() - unload_start
        
        # Request any needed chunks from server
        request_start = time.perf_counter()
        if needed_chunks:
            self._request_chunks(needed_chunks)
        
        request_time = time.perf_counter() - request_start
        
        # NOTE: _add_ready_sprites() is now called EVERY FRAME in on_update()
        # This spreads GPU work across frames for smoother performance!
        
        # DEBUG: chunk_mgmt breakdown (commented for production)
        # total_time = time.perf_counter() - method_start
        # if total_time > 0.010:
        #     console.print(f"📦 chunk_mgmt: {total_time*1000:.1f}ms (calc:{calc_time*1000:.1f}ms unload:{unload_time*1000:.1f}ms [{unload_count}] request:{request_time*1000:.1f}ms terrain:{terrain_time*1000:.1f}ms)")
    

    
    def _interpolate_entities(self, delta_time: float):
        """Smooth entity movement using arcade's lerp - OPTIMIZED: Only process player entities (type 0)"""
        # Interpolation factor (0.0 to 1.0)
        # Higher = faster catch-up, lower = smoother but more lag
        lerp_factor = min(1.0, delta_time * 15.0)  # 15x per second
        
        for entity_id, entity_data in self.entities.items():
            # 🔥 OPTIMIZED: Skip non-player entities (resources are type 5, don't need interpolation)
            entity_type = entity_data.get("type", 0)
            if entity_type != 0:
                continue
            
            # Skip our own player
            if entity_id == self.network.player_id:
                continue
            
            # Get target position from server
            target_x = entity_data["x"]
            target_y = entity_data["y"]
            
            # Get current display position
            if entity_id in self.entity_display_pos:
                current_x, current_y = self.entity_display_pos[entity_id]
                
                # Use arcade.math.lerp for smooth interpolation
                new_x = arcade.math.lerp(current_x, target_x, lerp_factor)
                new_y = arcade.math.lerp(current_y, target_y, lerp_factor)
                
                self.entity_display_pos[entity_id] = (new_x, new_y)
            else:
                # First time - no interpolation
                self.entity_display_pos[entity_id] = (target_x, target_y)

            # Note: sprite animation is updated in _update_entity_sprites_optimized()
            # so it runs exactly once per frame (prevents "sped up" animations).
    
    def _update_entity_sprites_optimized(self, delta_time: float = 1/60):
        """
        Update entity sprites efficiently (called in on_update, not on_draw!)
        Only updates sprites for visible entities, creates sprites on demand.
        """
        # Calculate visible area (accounting for zoom)
        cam_x, cam_y = self.camera.position
        zoom = self.camera.zoom
        # When zoomed out (zoom < 1), need to see larger area, but cap the buffer
        view_buffer = min(500, 200 / zoom)  # Cap buffer at 500 pixels
        screen_left = cam_x - (SCREEN_WIDTH / zoom) // 2 - view_buffer
        screen_right = cam_x + (SCREEN_WIDTH / zoom) // 2 + view_buffer
        screen_bottom = cam_y - (SCREEN_HEIGHT / zoom) // 2 - view_buffer
        screen_top = cam_y + (SCREEN_HEIGHT / zoom) // 2 + view_buffer
        
        current_entity_ids = set()
        visible_count = 0
        
        for entity_data in self.entities.values():
            entity_id = entity_data.get("id")
            current_entity_ids.add(entity_id)
            
            # Skip our own player entity
            if entity_id == self.network.player_id:
                continue
            
            entity_type = entity_data.get("type", 0)
            if entity_type != 0:  # Only handle player entities
                continue
            
            # Check if visible
            entity_x = entity_data.get("x", 0)
            entity_y = entity_data.get("y", 0)
            is_visible = (screen_left <= entity_x <= screen_right and 
                         screen_bottom <= entity_y <= screen_top)
            
            if is_visible:
                visible_count += 1
                
                # Create sprite if needed
                if entity_id not in self.entity_sprites:
                    self._create_entity_sprite(entity_id, entity_data)
                
                # Update sprite position
                if entity_id in self.entity_sprites:
                    sprite = self.entity_sprites[entity_id]
                    
                    # Get interpolated position
                    if entity_id in self.entity_display_pos:
                        x, y = self.entity_display_pos[entity_id]
                    else:
                        x, y = entity_x, entity_y
                        self.entity_display_pos[entity_id] = (x, y)
                    # Update position
                    sprite.center_x = x
                    sprite.center_y = y
                    
                    # Update velocity for animation
                    vx = entity_data.get("vx", 0)
                    vy = entity_data.get("vy", 0)

                    # Track facing angle for remote flashlight direction
                    if vx != 0 or vy != 0:
                        import math
                        self.remote_facing_angles[entity_id] = math.degrees(math.atan2(vy, vx))
                    
                    if isinstance(sprite, DirectionalSprite8Way):
                        sprite.update_velocity(vx, vy)
                        # Update animation based on velocity (60Hz, no interpolation)
                        sprite.update_animation(delta_time)
                    elif isinstance(sprite, DirectionalSprite):
                        sprite.change_x = vx
                        sprite.change_y = vy
                        # Update animation for basic directional sprite
                        sprite.update_animation(delta_time)
                    # Ensure sprite is in list
                    if sprite not in self.entity_sprite_list:
                        self.entity_sprite_list.append(sprite)
            else:
                # Off-screen - remove from sprite list (but keep sprite cached)
                if entity_id in self.entity_sprites:
                    sprite = self.entity_sprites[entity_id]
                    if sprite in self.entity_sprite_list:
                        self.entity_sprite_list.remove(sprite)
        
        # Cleanup stale text labels (removes from batch automatically)
        stale_text_ids = set(self.entity_name_labels.keys()) - current_entity_ids
        for entity_id in stale_text_ids:
            label = self.entity_name_labels[entity_id]
            label.delete()  # Remove from batch and free resources
            del self.entity_name_labels[entity_id]

            shadow = self.entity_name_shadow_labels.get(entity_id)
            if shadow is not None:
                shadow.delete()
                del self.entity_name_shadow_labels[entity_id]

        # Cleanup stale remote cone lights
        stale_light_ids = set(self.remote_cone_lights.keys()) - current_entity_ids
        for entity_id in stale_light_ids:
            light = self.remote_cone_lights.pop(entity_id)
            self.remote_facing_angles.pop(entity_id, None)
            if self.cone_light_layer and light in self.cone_light_layer._lights:
                self.cone_light_layer.remove(light)
        
        # Track for debug
        self.last_visible_entity_count = visible_count
    
    def _create_entity_sprite(self, entity_id: int, entity_data: dict):
        """Create sprite for entity (called once per entity)"""
        if not self.use_sprites:
            return
        
        available_chars = self.sprite_manager.list_available_characters()
        
        # Use astronaut_hires character only
        if "astronaut_hires" in available_chars:
            char_name = "astronaut_hires"
            sprite_scale = 1.0
        else:
            return
        
        # Get initial position
        if entity_id in self.entity_display_pos:
            x, y = self.entity_display_pos[entity_id]
        else:
            x = entity_data.get("x", 0)
            y = entity_data.get("y", 0)
        
        sprite = DirectionalSprite8Way(char_name, scale=sprite_scale)
        sprite.center_x = x
        sprite.center_y = y
        self.entity_sprites[entity_id] = sprite
    
    def on_update(self, delta_time: float):
        """Update game logic"""
        import time
        update_start = time.perf_counter()
        
        self.perf_monitor.start_update()

        # Network messages arrive on a background thread; handle them here (Arcade/main thread).
        self._drain_network_messages()
        
        # Frame counting for debug
        self.frame_count += 1
        current_time = time.time()
        
        # Initialize debug timer on first frame
        if not self.debug_initialized:
            self.last_debug_time = current_time
            self.last_tile_debug_time = current_time
            self.debug_initialized = True
        
        # Debug tile info every 1 second (disabled for production)
        # if current_time - self.last_tile_debug_time >= 1.0:
        #     self._log_player_tile_info()
        #     self.last_tile_debug_time = current_time
        
        # Coordinate sync debug - compare client vs server position every 1 second (DISABLED)
        # if current_time - self.last_coord_debug >= self.coord_debug_interval:
        #     dx = self.player_x - self.server_x
        #     dy = self.player_y - self.server_y
        #     distance = (dx*dx + dy*dy) ** 0.5
        #     console.print(f"📍 Position Sync: Client({self.player_x:.1f}, {self.player_y:.1f}) | Server({self.server_x:.1f}, {self.server_y:.1f}) | Δ={distance:.1f}px")
        #     self.last_coord_debug = current_time
        
        # Debug stats every 5 seconds
        if current_time - self.last_debug_time >= self.debug_interval:
            self._log_client_stats(self.frame_count, current_time - self.last_debug_time)
            self.frame_count = 0
            self.last_debug_time = current_time
        
        # Update harvest cooldown
        if self.harvest_cooldown > 0:
            self.harvest_cooldown -= delta_time
        
        # Continuous harvesting when left mouse button held
        if self.mouse_pressed and self.harvest_cooldown <= 0:
            if self._try_harvest_at_mouse():
                self.harvest_cooldown = self.harvest_cooldown_time
        
        # Don't update movement if inventory is open
        if not self.inventory_ui.visible:
            # Optional movement debug output to console (throttled)
            if getattr(self, "movement_debug_console", False):
                import time as _time
                now = _time.perf_counter()
                last = float(getattr(self, "_movement_debug_last_print", 0.0) or 0.0)
                interval = float(getattr(self, "_movement_debug_print_interval", 0.25) or 0.25)
                if now - last >= interval:
                    self._movement_debug_last_print = now

                    have_server = bool(getattr(self, "_have_server_position", False))
                    cx = float(self.player_x)
                    cy = float(self.player_y)
                    cvx = float(getattr(self, "player_vx", 0.0) or 0.0)
                    cvy = float(getattr(self, "player_vy", 0.0) or 0.0)

                    sx = float(getattr(self, "server_x", 0.0) or 0.0)
                    sy = float(getattr(self, "server_y", 0.0) or 0.0)
                    svx = float(getattr(self, "server_vx", 0.0) or 0.0)
                    svy = float(getattr(self, "server_vy", 0.0) or 0.0)

                    if have_server:
                        dx = cx - sx
                        dy = cy - sy
                        dist = (dx * dx + dy * dy) ** 0.5
                    else:
                        dx = dy = dist = 0.0

                    last_state = getattr(self, "_last_state_update_received_at", None)
                    age_ms = (now - last_state) * 1000.0 if last_state is not None else None
                    age_str = f"{age_ms:.1f}ms" if age_ms is not None else "n/a"

                    feet_offset = float(getattr(self, "_player_feet_offset_y", 28) or 28)
                    feet_x = cx
                    feet_y = cy - feet_offset
                    water = resource = collides = False
                    try:
                        if getattr(self, "enable_client_collision_prediction", False):
                            water = bool(self._client_point_has_water(feet_x, feet_y))
                            margin = float(getattr(self, "_client_resource_collision_margin_px", 0.0) or 0.0)
                            resource = bool(self._client_point_collides_with_resources_expanded(feet_x, feet_y, margin))
                            collides = bool(water or resource)
                    except Exception:
                        pass

                    stuck_ticks = int(getattr(self, "_server_stuck_ticks", 0) or 0)
                    stop_grace = float(getattr(self, "_stop_grace_timer", 0.0) or 0.0)
                    lookahead = float(getattr(self, "_client_collision_lookahead_px", 0.0) or 0.0)
                    margin = float(getattr(self, "_client_resource_collision_margin_px", 0.0) or 0.0)

                    console.print(
                        "DBG(move) "
                        f"c=({cx:.2f},{cy:.2f}) v=({cvx:.2f},{cvy:.2f}) "
                        + (f"s=({sx:.2f},{sy:.2f}) sv=({svx:.2f},{svy:.2f}) " if have_server else "s=(n/a) ")
                        + (f"d=({dx:.2f},{dy:.2f}) |d|={dist:.2f} " if have_server else "")
                        + f"age={age_str} stuck={stuck_ticks} grace={stop_grace:.2f} "
                        + f"feet=({feet_x:.1f},{feet_y:.1f}) col={int(collides)} w={int(water)} r={int(resource)} "
                        + f"lookahead={lookahead:.1f} margin={margin:.1f}"
                    )

            # Send ping every 2 seconds
            if self.network:
                self.ping_timer += delta_time
                if self.ping_timer >= 2.0:
                    self.network.send_ping()
                    self.ping_timer = 0.0
            
            # Update player velocity based on input
            self.player_vx = 0
            self.player_vy = 0
            
            if arcade.key.W in self.keys_pressed or arcade.key.UP in self.keys_pressed:
                self.player_vy = PLAYER_SPEED
            if arcade.key.S in self.keys_pressed or arcade.key.DOWN in self.keys_pressed:
                self.player_vy = -PLAYER_SPEED
            if arcade.key.A in self.keys_pressed or arcade.key.LEFT in self.keys_pressed:
                self.player_vx = -PLAYER_SPEED
            if arcade.key.D in self.keys_pressed or arcade.key.RIGHT in self.keys_pressed:
                self.player_vx = PLAYER_SPEED
            
            # Normalize diagonal movement to prevent faster movement
            if self.player_vx != 0 and self.player_vy != 0:
                # Diagonal: divide by sqrt(2) to maintain consistent speed
                factor = 0.7071  # 1/sqrt(2) ≈ 0.7071
                self.player_vx *= factor
                self.player_vy *= factor
            
            # Client-side prediction for our own player to avoid input lag.
            # Server remains authoritative; we keep a smoothed correction offset for rendering.
            if self.enable_client_prediction:
                if self.enable_client_collision_prediction:
                    # No sliding + no tunneling: substep along velocity vector.
                    new_x, new_y, blocked = self._client_predict_move_no_slide(
                        self.player_x,
                        self.player_y,
                        float(self.player_vx),
                        float(self.player_vy),
                        float(delta_time),
                    )
                    self.player_x = new_x
                    self.player_y = new_y

                    # If client hit an obstacle, STOP pushing: send zero velocity to server too.
                    if blocked and (self.player_vx != 0 or self.player_vy != 0):
                        self.player_vx = 0
                        self.player_vy = 0
                else:
                    # No collision prediction, just integrate.
                    self.player_x += (self.player_vx * delta_time)
                    self.player_y += (self.player_vy * delta_time)

            # Send movement to server.
            # If client collision blocked us, vx/vy is forced to 0 so we don't keep "pushing" into obstacles.
            if self.network:
                # 🔥 NET OPTIMIZATION: Only send when velocity actually changes.
                if self.player_vx != self.last_sent_vx or self.player_vy != self.last_sent_vy:
                    self.network.send_move(self.player_vx, self.player_vy)
                    self.last_sent_vx = self.player_vx
                    self.last_sent_vy = self.player_vy
            
            # 🔥 OPTIMIZED: Cache terrain lookup (only check every 0.2s instead of every frame)
            # Terrain doesn't change that frequently, footsteps need it rarely
            if not hasattr(self, '_last_terrain_check_time'):
                self._last_terrain_check_time = 0
                self._cached_terrain_id = None
            
            if current_time - self._last_terrain_check_time > 0.2:  # Update every 200ms
                self._cached_terrain_id = self._get_terrain_under_player()
                self._last_terrain_check_time = current_time
            
            # Play footstep sounds when moving
            is_moving = self.player_vx != 0 or self.player_vy != 0
            self.sound_manager.play_footstep(current_time, is_moving, self._cached_terrain_id)
            
            # Reconciliation strategy:
            # - While moving: render pure prediction (no constant pull-back to server),
            #   otherwise you'll ALWAYS look like you're lagging by network latency.
            # - Snap when server indicates we're blocked (server position stuck) or divergence is large.
            # - When not moving: avoid the ugly "stop twitch" by waiting until server also stops.
            is_moving = (self.player_vx != 0 or self.player_vy != 0)

            # Adapt a few tolerance knobs to measured RTT so we stay stable under ~50-80ms
            # without inflating grace windows unnecessarily.
            try:
                rtt_ms = float(getattr(self.network, "ping_ms", 0.0) or 0.0) if self.network else 0.0
            except Exception:
                rtt_ms = 0.0
            rtt_s = max(0.0, rtt_ms / 1000.0)
            one_way_s = 0.5 * rtt_s

            # Start grace: roughly one-way latency + ~1-2 ticks margin.
            # Clamp so it doesn't feel "floaty" even on higher RTT.
            if rtt_ms > 0.0:
                self._start_grace_seconds = float(min(0.12, max(0.06, one_way_s + 0.04)))

            # Stuck snap distance: allow normal server lag (speed*RTT) before considering it "stuck".
            # Clamp to keep it responsive.
            if rtt_ms > 0.0:
                self._server_stuck_snap_distance_px = float(min(64.0, max(24.0, PLAYER_SPEED * rtt_s)))

            # Detect transition moving -> stopped
            if self._was_moving and not is_moving:
                self._stop_grace_timer = self._stop_grace_seconds

            # Detect transition stopped -> moving (start grace)
            if (not self._was_moving) and is_moving:
                self._start_grace_timer = self._start_grace_seconds
            self._was_moving = is_moving

            if self._stop_grace_timer > 0.0:
                self._stop_grace_timer = max(0.0, self._stop_grace_timer - delta_time)

            if getattr(self, "_start_grace_timer", 0.0) > 0.0:
                self._start_grace_timer = max(0.0, self._start_grace_timer - delta_time)

            if self.enable_client_prediction and self._have_server_position:
                dx = self.server_x - self.player_x
                dy = self.server_y - self.player_y
                dist_sq = (dx * dx + dy * dy)

                # If server is not advancing while we keep pushing, assume collision and snap earlier.
                start_grace = float(getattr(self, "_start_grace_timer", 0.0) or 0.0)
                if is_moving and start_grace <= 0.0 and self._server_stuck_ticks >= 2 and dist_sq > (self._server_stuck_snap_distance_px * self._server_stuck_snap_distance_px):
                    self.player_x = self.server_x
                    self.player_y = self.server_y
                    self._render_correction_x = 0.0
                    self._render_correction_y = 0.0
                elif dist_sq > (self._correction_snap_distance_px * self._correction_snap_distance_px):
                    self.player_x = self.server_x
                    self.player_y = self.server_y
                    self._render_correction_x = 0.0
                    self._render_correction_y = 0.0
                else:
                    if is_moving:
                        # While moving: don't chase server each frame.
                        self._render_correction_x = 0.0
                        self._render_correction_y = 0.0
                    else:
                        # While stopped: DO NOT reconcile towards server.
                        # This completely eliminates the "stop twitch" caused by tiny server/client differences.
                        # We rely on:
                        # - client collision prediction to stop early
                        # - snap while moving when collision/stuck is detected
                        # - hard snap on large divergence (handled above)
                        self._render_correction_x = 0.0
                        self._render_correction_y = 0.0

            display_x = self.player_x
            display_y = self.player_y
            
            # Update player sprite
            if self.player_sprite:
                self.player_sprite.center_x = display_x
                self.player_sprite.center_y = display_y
                
                # Update sprite velocity and animation
                if isinstance(self.player_sprite, DirectionalSprite8Way):
                    # 8-way directional system
                    self.player_sprite.update_velocity(self.player_vx, self.player_vy)
                    self.player_sprite.update_animation(delta_time)
                elif isinstance(self.player_sprite, DirectionalSprite):
                    # Original 4-way system (AnimatedWalkingSprite)
                    self.player_sprite.change_x = self.player_vx
                    self.player_sprite.change_y = self.player_vy
                    self.player_sprite.update_animation(delta_time)
            
            # Update camera to follow player (Camera2D uses .position directly)
            self.camera.position = (display_x, display_y)

            # Auto-pickup items near player
            self._try_auto_pickup_items()
        
        # Smooth other players (remote) via interpolation.
        self._interpolate_entities(delta_time)
        
        # Update entity sprites (moved from on_draw for performance!)
        self._update_entity_sprites_optimized(delta_time)
        
        # 🔥 Update entity label positions (for text batch)
        self._update_entity_label_positions()
        
        # 🔥 CHUNK MANAGEMENT (moved from on_draw for better performance!)
        # 🔥 CRITICAL FPS FIX: Only run chunk management every 0.5 seconds (not every frame!)
        if not hasattr(self, '_last_chunk_mgmt_time'):
            self._last_chunk_mgmt_time = 0
        
        current_time = time.perf_counter()
        chunk_mgmt_time = 0
        
        if current_time - self._last_chunk_mgmt_time >= 0.25:  # 🔥 4 times per second (faster response)
            chunk_mgmt_start = time.perf_counter()
            self._manage_chunks_in_update()
            self._last_chunk_mgmt_time = current_time
            chunk_mgmt_time = time.perf_counter() - chunk_mgmt_start
        
        # 🔥 CRITICAL: Process ready sprites EVERY FRAME for smooth loading!
        # This spreads GPU rendering across multiple frames instead of bursts
        sprites_start = time.perf_counter()
        self._add_ready_sprites()
        sprites_time = time.perf_counter() - sprites_start
        
        # Update weather particles (snow for frozen world)
        if self.snow_emitter == "pending":
            # Deferred initialization to avoid segfault
            self._initialize_snow_emitter_deferred()
        elif self.snow_emitter == "initialized":
            # Simple snow: spawn particles manually relative to camera
            if not hasattr(self, '_snow_particles'):
                self._snow_particles = arcade.SpriteList()
                self._snow_spawn_timer = 0.0
                self._snow_debug_timer = 0.0
                self._snow_pool: list[arcade.Sprite] = []
                self._snow_max_particles = 150
                self._snow_spawn_interval = 0.05
            
            # Debug stats every 5 seconds (DISABLED)
            # self._snow_debug_timer += delta_time
            # if self._snow_debug_timer >= 5.0:
            #     particle_count = len(self._snow_particles)
            #     console.print(f"❄️ Snow stats: {particle_count} active particles")
            #     self._snow_debug_timer = 0.0
            
            # Compute camera/view only once per frame (hot path)
            cam_x, cam_y = self.camera.position
            view_w = self.window.width / self.camera_zoom
            view_h = self.window.height / self.camera_zoom
            despawn_y = cam_y - (view_h / 2) - 100

            # Spawn new snowflakes (with max limit)
            self._snow_spawn_timer += delta_time
            if self._snow_spawn_timer >= self._snow_spawn_interval and len(self._snow_particles) < self._snow_max_particles:
                self._snow_spawn_timer = 0.0

                if self._snow_pool:
                    snow = self._snow_pool.pop()
                else:
                    snow = arcade.Sprite()
                    snow.texture = self.snow_texture

                # Spawn snowflake at random X, above camera view
                snow.center_x = cam_x + random.uniform(-view_w / 2, view_w / 2)
                snow.center_y = cam_y + (view_h / 2) + 50
                snow.change_x = random.uniform(-0.3, 0.3)
                snow.change_y = random.uniform(-1.0, -2.0)
                snow.scale = random.uniform(0.3, 0.5)
                snow.alpha = 200
                self._snow_particles.append(snow)

            # Update + despawn without mutating during iteration (avoid slowframe spikes)
            snow_list = self._snow_particles
            i = 0
            while i < len(snow_list):
                snow = snow_list[i]
                snow.center_x += snow.change_x
                snow.center_y += snow.change_y

                if snow.center_y < despawn_y:
                    # O(1) remove: swap current with last, then pop
                    last_index = len(snow_list) - 1
                    if i != last_index:
                        snow_list.swap(i, last_index)
                    dead = snow_list.pop()
                    self._snow_pool.append(dead)
                    continue

                i += 1
        
        # Fog effect for toxic wasteland is drawn procedurally (no update needed)
        
        # Update cursor based on harvestable objects
        self._update_cursor_for_harvestables()
        
        # --- Update player light position ---
        import math
        
        # Small ambient light follows player
        if self.player_light:
            self.player_light.position = (self.player_x, self.player_y)
        
        # Update facing direction based on movement
        if self.player_vx != 0 or self.player_vy != 0:
            # Calculate angle from velocity (atan2 returns radians, convert to degrees)
            self.player_facing_angle = math.degrees(math.atan2(self.player_vy, self.player_vx))
        
        # Update cone light (flashlight) - offset in front of player like Factorio
        if self.player_cone_light:
            import math
            # Calculate offset position in front of player
            angle_rad = math.radians(self.player_facing_angle)
            offset_x = self.cone_light_offset * math.cos(angle_rad)
            offset_y = self.cone_light_offset * math.sin(angle_rad)
            
            self.player_cone_light.position = (self.player_x + offset_x, self.player_y + offset_y)
            self.player_cone_light.angle = self.player_facing_angle
            
            # Auto on/off based on time of day (like Factorio) - simple ON/OFF, no fade
            from shared.constants import DAY_DURATION_MINUTES
            current_time = self.game_time % DAY_DURATION_MINUTES
            
            # Simple logic: ON at night (18:00-5:30), OFF during day (5:30-18:00)
            should_be_on = current_time >= 1080 or current_time < 330  # 18:00-24:00 or 0:00-5:30
            
            # Toggle light on/off by adding/removing from layer
            is_currently_on = self.player_cone_light in self.cone_light_layer._lights
            
            if should_be_on and not is_currently_on:
                # Turn ON - add to layer
                self.cone_light_layer.add(self.player_cone_light)
            elif not should_be_on and is_currently_on:
                # Turn OFF - remove from layer
                self.cone_light_layer.remove(self.player_cone_light)

        # Update cone lights for other players (flashlights)
        if self.cone_light_layer and self.lighting_enabled:
            from shared.constants import DAY_DURATION_MINUTES
            current_time = self.game_time % DAY_DURATION_MINUTES
            should_be_on = current_time >= 1080 or current_time < 330  # 18:00-24:00 or 0:00-5:30

            local_player_id = getattr(self.network, "player_id", None)

            # Safety: never render a "remote" cone for our own player
            if local_player_id is not None and local_player_id in self.remote_cone_lights:
                light = self.remote_cone_lights.pop(local_player_id)
                self.remote_facing_angles.pop(local_player_id, None)
                if light in self.cone_light_layer._lights:
                    self.cone_light_layer.remove(light)

            for entity_id, entity_data in self.entities.items():
                # Only players (type 0), skip self
                if entity_data.get("type", 0) != 0:
                    continue

                # Robust self-skip: IDs and/or username
                actual_id = entity_data.get("id", entity_id)
                if local_player_id is not None and actual_id == local_player_id:
                    continue
                if entity_data.get("username") == self.player_username:
                    continue

                # Get interpolated position for smoothness
                if entity_id in self.entity_display_pos:
                    x, y = self.entity_display_pos[entity_id]
                else:
                    x = entity_data.get("x", 0)
                    y = entity_data.get("y", 0)

                # Pixel-perfect: match sprites (avoid subpixel blur)
                x = int(round(x))
                y = int(round(y))

                facing = self.remote_facing_angles.get(entity_id, -90.0)
                angle_rad = math.radians(facing)
                offset_x = self.cone_light_offset * math.cos(angle_rad)
                offset_y = self.cone_light_offset * math.sin(angle_rad)

                light = self.remote_cone_lights.get(entity_id)
                if light is None:
                    light = ConeLight(
                        0,
                        0,
                        radius=800,
                        color=(255, 255, 255),
                        mode="hard",
                        angle=facing,
                        cone_angle=70,
                    )
                    self.remote_cone_lights[entity_id] = light

                light.position = (x + offset_x, y + offset_y)
                light.angle = facing

                is_currently_on = light in self.cone_light_layer._lights
                if should_be_on and not is_currently_on:
                    self.cone_light_layer.add(light)
                elif not should_be_on and is_currently_on:
                    self.cone_light_layer.remove(light)
        
        self.perf_monitor.end_update()
        
        # 🔥 DETAILED PROFILING: Print breakdown if update is slow
        # DISABLE for production - set threshold to 999ms or comment out
        update_total = time.perf_counter() - update_start
        if update_total > 0.050:  # 🔥 50ms threshold - only log significant slowdowns
            console.print(f"\n⚠️ SLOW UPDATE: {update_total*1000:.1f}ms total")
            console.print(f"  Ready sprites: {sprites_time*1000:.2f}ms")
            console.print(f"  Chunk management: {chunk_mgmt_time*1000:.2f}ms")
    
    def on_key_press(self, key, modifiers):
        """Handle key press"""
        # Inventory UI has priority
        if self.inventory_ui.on_key_press(key, modifiers):
            return
        
        # Chat mode
        if self.chat_active:
            if key == arcade.key.ENTER:
                # Send chat message
                if self.network and self.chat_input.strip():
                    self.network.send_chat(self.chat_input.strip())
                self.chat_input = ""
                self.chat_active = False
            elif key == arcade.key.ESCAPE:
                # Cancel chat
                self.chat_input = ""
                self.chat_active = False
            elif key == arcade.key.BACKSPACE:
                # Delete character
                self.chat_input = self.chat_input[:-1]
            return
        
        # Normal mode
        self.keys_pressed.add(key)
        
        # F3 to toggle performance monitor
        if key == arcade.key.F3:
            self.show_perf_monitor = not self.show_perf_monitor

        # F4 to toggle movement/network debug overlay
        if key == arcade.key.F4:
            self.show_movement_debug = not self.show_movement_debug

        # F5 to toggle movement debug printing to console
        if key == arcade.key.F5:
            self.movement_debug_console = not self.movement_debug_console
        
        # M to toggle sound/music
        if key == arcade.key.M:
            enabled = self.sound_manager.toggle_enabled()
            status = "🔊 ON" if enabled else "🔇 OFF"
            console.print(f"[yellow]Sound: {status}[/yellow]")
        
        # ESC to quit (only if inventory not open)
        if key == arcade.key.ESCAPE and not self.inventory_ui.visible:
            arcade.close_window()
        
        # T or ENTER to open chat
        if key == arcade.key.T or key == arcade.key.ENTER:
            self.chat_active = True
            self.chat_input = ""
    
    def on_key_release(self, key, modifiers):
        """Handle key release"""
        self.keys_pressed.discard(key)

    def _request_inventory_move(self, src_type: str, src_slot, dst_type: str, dst_slot):
        """Send inventory move request to server (called from InventoryUI)."""
        if not self.network:
            return
        try:
            self.network.send_inventory_move(
                src_type=src_type,
                src_slot=src_slot,
                dst_type=dst_type,
                dst_slot=dst_slot,
            )
        except Exception:
            # Network is best-effort; authoritative state will resync.
            pass

    def _request_drop_item(self, src_type: str, src_slot):
        """Send drop request to server (called from InventoryUI)."""
        if not self.network:
            return
        try:
            import math

            angle_rad = math.radians(getattr(self, "player_facing_angle", -90))
            facing_dx = math.cos(angle_rad)
            facing_dy = math.sin(angle_rad)
            self.network.send_drop_item(
                src_type=src_type,
                src_slot=src_slot,
                facing_dx=facing_dx,
                facing_dy=facing_dy,
            )
        except Exception:
            pass
    
    def on_text(self, text: str):
        """Handle text input (for chat)"""
        if self.chat_active:
            # Add character to chat input
            if len(self.chat_input) < 100:  # Max 100 chars
                self.chat_input += text
    
    def on_network_message(self, message: dict):
        """Network thread callback: enqueue message for main-thread processing."""
        try:
            self._network_message_queue.put(message)
        except Exception:
            pass

    def _process_network_message(self, message: dict):
        """Handle network message from server (main thread)."""
        import time
        net_start = time.perf_counter()
        
        msg_type = message.get("type")
        
        if msg_type == "welcome":
            # Set initial spawn position
            self.player_x = message.get("spawn_x", 0)
            self.player_y = message.get("spawn_y", 0)
            self.world_seed = message.get("world_seed")
            received_biome_id = message.get("biome_id", "toxic_wasteland")
            
            # 🔥 OPTIMIZED: Only reload resources if biome actually changed
            biome_changed = (self.biome_id != received_biome_id)
            self.biome_id = received_biome_id
            
            # Set biome for sound system
            self.sound_manager.set_biome(self.biome_id)
            
            # Store username from network client (for matching server entities)
            if self.network and hasattr(self.network, 'username'):
                self.player_username = self.network.username
            
            # 🔥 OPTIMIZED: Only load resources if biome changed or first time
            if biome_changed or not hasattr(self, 'resource_textures') or not self.resource_textures:
                # Load resource sprites and collision data for this biome (UNIFIED)
                from client.sprite_loader import (
                    load_resource_sprites, load_resource_collision_data,
                    load_resource_horizon_data, load_resource_dimensions
                )
                
                # Reload unified dictionaries for new biome
                self.resource_textures = {}
                self.resource_collision_data = {}
                self.resource_horizon_data = {}
                self.resource_dimensions = {}
                
                # Load trees
                for variant_id, texture in load_resource_sprites(self.biome_id, "tree").items():
                    self.resource_textures[("tree", variant_id)] = texture
                for variant_id, collision in load_resource_collision_data(self.biome_id, "tree").items():
                    self.resource_collision_data[("tree", variant_id)] = collision
                for variant_id, horizon in load_resource_horizon_data(self.biome_id, "tree").items():
                    self.resource_horizon_data[("tree", variant_id)] = horizon
                for variant_id, dims in load_resource_dimensions(self.biome_id, "tree").items():
                    self.resource_dimensions[("tree", variant_id)] = dims
                
                # Load rocks
                for variant_id, texture in load_resource_sprites(self.biome_id, "rock").items():
                    self.resource_textures[("rock", variant_id)] = texture
                for variant_id, collision in load_resource_collision_data(self.biome_id, "rock").items():
                    self.resource_collision_data[("rock", variant_id)] = collision
                for variant_id, horizon in load_resource_horizon_data(self.biome_id, "rock").items():
                    self.resource_horizon_data[("rock", variant_id)] = horizon
                for variant_id, dims in load_resource_dimensions(self.biome_id, "rock").items():
                    self.resource_dimensions[("rock", variant_id)] = dims
            
            # Pre-load biome assets (we know which biome!)
            if not self.biome_assets_loaded or biome_changed:
                self._preload_biome_assets()
                self.biome_assets_loaded = True
            
            # Initialize weather effects for frozen world (after biome_id is set)
            if self.biome_id == "frozen_world" and self.snow_emitter is None:
                self._create_snow_emitter()
                debug_print(DEBUG_INIT, "❄️ Snow particles initialized for frozen_world")
            
            # Initialize fog effect for toxic wasteland
            if self.biome_id == "toxic_wasteland" and not self.fog_enabled:
                self._create_fog_effect()
                debug_print(DEBUG_INIT, "🌫️ Fog effect initialized for toxic_wasteland")
        
        elif msg_type == "chunk_data":
            # 🔥 Receive chunk data from server (ONLINE mode only!)
            import time
            receive_start = time.perf_counter()
            
            chunks_data = message.get("chunks", {})
            deserialize_time = (time.perf_counter() - receive_start) * 1000
            
            processing_start = time.perf_counter()
            chunks_processed = 0
            
            for chunk_key, chunk_info in chunks_data.items():
                # Parse chunk coordinates from "x,y" format
                parts = chunk_key.split(",")
                if len(parts) != 2:
                    continue
                
                try:
                    chunk_x = int(parts[0])
                    chunk_y = int(parts[1])
                    
                    # Get terrain data
                    tiles = chunk_info.get("tiles", [])
                    corners = chunk_info.get("corners", [])
                    
                    if not tiles:
                        continue  # Empty chunk
                    
                    # Flatten 2D arrays for Chunk constructor
                    tiles_flat = [tile for row in tiles for tile in row]
                    corners_flat = [corner for row in corners for corner in row]
                    
                    # Create chunk (client-side container)
                    chunk = Chunk(chunk_x, chunk_y)
                    chunk.tiles = tiles
                    chunk.tile_corners = corners
                    
                    # Store chunk
                    self.loaded_chunks[(chunk_x, chunk_y)] = chunk
                    
                    # 🔥 ASYNC: Submit chunk rendering to worker thread!
                    if (chunk_x, chunk_y) not in self.chunks_with_sprites:
                        # Submit PIL/numpy work to thread pool (non-blocking!)
                        chunk_key_for_callback = (chunk_x, chunk_y)
                        future = self.chunk_render_executor.submit(
                            self._render_chunk_pil_async,
                            chunk_key_for_callback,
                            chunk
                        )
                        # Add callback to queue result when done
                        def on_render_complete(fut, key=chunk_key_for_callback):
                            try:
                                pil_image = fut.result()
                                # Queue for texture creation in main thread
                                self.ready_pil_images.put((key, pil_image))
                                if DEBUG_CHUNKS:
                                    console.print(f"  🎨 Worker thread finished chunk {key}, queued for texture creation")
                            except Exception as e:
                                console.print(f"❌ Async chunk render failed for {key}: {e}")
                                traceback.print_exc()
                        future.add_done_callback(on_render_complete)
                    
                    chunks_processed += 1
                    
                    if DEBUG_CHUNKS:
                        console.print(f"✅ Loaded chunk ({chunk_x}, {chunk_y}) from server")
                    
                    # NOTE: Entities are server-authoritative and arrive via "state_update".
                    # chunk_data is treated as terrain-only payload (tiles/corners).
                    # The old code that injected entities from chunk_data is intentionally removed
                    # to avoid duplicate/ghost entities and inconsistent IDs.
                
                except ValueError:
                    continue
            
            # 🔥 PERFORMANCE LOG: Zobraz timing pokud trvalo >50ms nebo DEBUG_NETWORK
            processing_time = (time.perf_counter() - processing_start) * 1000
            total_time = (time.perf_counter() - receive_start) * 1000
            
            if total_time > 50 or DEBUG_NETWORK:
                console.print(f"⏱️  CLIENT CHUNK RECEIVE: {chunks_processed} chunks")
                console.print(f"   Deserialization: {deserialize_time:.2f}ms")
                console.print(f"   Processing: {processing_time:.2f}ms")
                console.print(f"   Total: {total_time:.2f}ms")
        
        elif msg_type == "state_update":
            import time
            self._last_state_update_received_at = time.perf_counter()
            # Keep previous state for one-shot animation triggers
            prev_item_entities = self.item_entities
            prev_resource_entities = self.resource_entities
            # Persist previous snapshots for inventory_update-triggered animations.
            self._prev_item_entities = prev_item_entities
            self._prev_harvesting_target = getattr(self, "_last_harvesting_target", None)

            # Update entities
            entities = message.get("entities", [])
            
            # Update game time (day/night cycle)
            self.game_time = message.get("game_time", 360.0)
            
            # 🔥 OPTIMIZED: Minimize set/dict operations
            # Build new dicts in single pass
            new_entities = {}
            new_resource_entities = {}
            new_item_entities = {}
            
            for e in entities:
                eid = e["id"]
                new_entities[eid] = e
                # 🔥 OPTIMIZED: Filter resources in same pass (avoid second iteration)
                if e.get("type") == 5:
                    new_resource_entities[eid] = e
                elif e.get("type") == 3:
                    new_item_entities[eid] = e
            
            # 🔥 OPTIMIZED: Only calculate removed IDs if we have sprites to clean up
            if self.resource_sprites:
                # Calculate removed entities (only resources need sprite cleanup)
                old_resource_ids = set(self.resource_entities.keys())
                new_resource_ids = set(new_resource_entities.keys())
                removed_ids = old_resource_ids - new_resource_ids
                
                # Clean up resource sprites for removed entities
                for entity_id in removed_ids:
                    if entity_id in self.resource_sprites:
                        sprite = self.resource_sprites[entity_id]
                        self.resource_sprite_list.remove(sprite)
                        del self.resource_sprites[entity_id]

            # Detect removed resources/items for multiplayer one-shot effects
            try:
                old_resource_ids = set((prev_resource_entities or {}).keys())
                new_resource_ids = set(new_resource_entities.keys())
                removed_resource_ids = old_resource_ids - new_resource_ids
            except Exception:
                removed_resource_ids = set()

            try:
                old_item_ids = set((prev_item_entities or {}).keys())
                new_item_ids = set(new_item_entities.keys())
                removed_item_ids = old_item_ids - new_item_ids
            except Exception:
                removed_item_ids = set()

            # Update last-seen cache for completion VFX
            try:
                import time
                now_seen = time.perf_counter()
                last_seen = getattr(self, "_resource_last_seen", None)
                if not isinstance(last_seen, dict):
                    self._resource_last_seen = {}
                    last_seen = self._resource_last_seen
                for rid, re in new_resource_entities.items():
                    last_seen[int(rid)] = {
                        "x": float(re.get("x", 0.0) or 0.0),
                        "y": float(re.get("y", 0.0) or 0.0),
                        "resource_type": re.get("resource_type"),
                        "variant_id": re.get("variant_id"),
                        "harvester_id": re.get("harvester_id"),
                        "t_seen": now_seen,
                    }

                # TTL prune (keeps cache small but doesn't delete immediately on removal)
                ttl = float(getattr(self, "_resource_last_seen_ttl_s", 2.0) or 2.0)
                if ttl > 0 and len(last_seen) > max(256, len(new_resource_entities) * 2):
                    for rid, info in list(last_seen.items()):
                        if int(rid) in new_resource_entities:
                            continue
                        try:
                            t_seen = float((info or {}).get("t_seen", 0.0) or 0.0)
                        except Exception:
                            t_seen = 0.0
                        if t_seen and (now_seen - t_seen) > ttl:
                            last_seen.pop(int(rid), None)
            except Exception:
                pass
            
            # Atomic replacement - safe even if draw thread is reading!
            self.entities = new_entities
            self.resource_entities = new_resource_entities
            self.item_entities = new_item_entities

            # Multiplayer: show other players harvesting (anim + positional SFX + shake)
            try:
                import time
                now_perf = time.perf_counter()
                now_time = time.time()
                sm = getattr(self, "sound_manager", None)
                local_pid = getattr(getattr(self, "network", None), "player_id", None)

                # Convert continuous harvest_progress to discrete "hit" steps.
                cooldown = 0.70
                if sm is not None:
                    cooldown = float(getattr(sm, "harvest_cooldown", cooldown) or cooldown)
                step = max(0.18, min(0.45, cooldown / 2.0))

                idx_map = getattr(self, "_resource_harvest_step_idx", None)
                if not isinstance(idx_map, dict):
                    self._resource_harvest_step_idx = {}
                    idx_map = self._resource_harvest_step_idx

                hit_map = getattr(self, "_harvest_fx_last_hit_by_target", None)
                if not isinstance(hit_map, dict):
                    self._harvest_fx_last_hit_by_target = {}
                    hit_map = self._harvest_fx_last_hit_by_target

                for rid, re in new_resource_entities.items():
                    if not isinstance(re, dict):
                        continue
                    being = bool(re.get("being_harvested", False))
                    if not being:
                        idx_map.pop(int(rid), None)
                        continue

                    try:
                        p = float(re.get("harvest_progress", 0.0) or 0.0)
                    except Exception:
                        p = 0.0
                    p = max(0.0, min(1.0, p))

                    idx = int(p / step)
                    prev_idx = int(idx_map.get(int(rid), -999))
                    if idx == prev_idx:
                        continue

                    idx_map[int(rid)] = idx
                    hit_map[int(rid)] = now_perf

                    harvester_id = re.get("harvester_id")
                    if harvester_id is None:
                        continue
                    try:
                        harvester_id = int(harvester_id)
                    except Exception:
                        continue

                    # Avoid double-playing local feedback (local has its own cadence).
                    if local_pid is not None and int(local_pid) == int(harvester_id):
                        continue

                    rx = float(re.get("x", 0.0) or 0.0)
                    ry = float(re.get("y", 0.0) or 0.0)
                    rtype = str(re.get("resource_type") or "")
                    vol = self._sfx_volume_for_world_pos(rx, ry)

                    if sm is not None and rtype:
                        sm.play_harvest(
                            current_time=now_time,
                            resource_type=rtype,
                            target_id=int(rid),
                            volume_scale=vol,
                        )

                    self._play_action_on_player_entity(harvester_id, "throw_object")
            except Exception:
                pass

            # Multiplayer: completion VFX+SFX when a resource disappears (remote or local)
            try:
                if removed_resource_ids:
                    import time
                    now_fx = time.perf_counter()
                    now_time = time.time()
                    sm = getattr(self, "sound_manager", None)
                    last_seen = getattr(self, "_resource_last_seen", {}) or {}
                    local_pid = getattr(getattr(self, "network", None), "player_id", None)

                    pickup_throttle = getattr(self, "_remote_harvest_pickup_anim_at", None)
                    if not isinstance(pickup_throttle, dict):
                        self._remote_harvest_pickup_anim_at = {}
                        pickup_throttle = self._remote_harvest_pickup_anim_at
                    fired = getattr(self, "_harvest_complete_triggered_at", None)
                    if not isinstance(fired, dict):
                        self._harvest_complete_triggered_at = {}
                        fired = self._harvest_complete_triggered_at

                    for rid in removed_resource_ids:
                        rid = int(rid)
                        last_t = float(fired.get(rid, 0.0) or 0.0)
                        if last_t and (now_fx - last_t) < 1.0:
                            continue

                        info = last_seen.get(rid) if isinstance(last_seen, dict) else None
                        self._spawn_harvest_complete_fx(rid, info=info, now_fx=now_fx)
                        fired[rid] = now_fx

                        # Remote: also play pickup animation on the harvester at completion.
                        try:
                            harvester_id = (info or {}).get("harvester_id")
                            if harvester_id is not None:
                                hid = int(harvester_id)
                                if local_pid is None or int(local_pid) != hid:
                                    last_anim_t = float(pickup_throttle.get(hid, 0.0) or 0.0)
                                    if (now_fx - last_anim_t) >= 0.45:
                                        self._play_action_on_player_entity(hid, "picking_up")
                                        pickup_throttle[hid] = now_fx
                        except Exception:
                            pass

                        # Completion sound (positional)
                        try:
                            rtype = str((info or {}).get("resource_type") or "")
                            rx = float((info or {}).get("x", 0.0) or 0.0)
                            ry = float((info or {}).get("y", 0.0) or 0.0)
                            vol = self._sfx_volume_for_world_pos(rx, ry)
                            if sm is not None and rtype:
                                sm.play_harvest_complete(
                                    current_time=now_time,
                                    resource_type=rtype,
                                    target_id=rid,
                                    volume_scale=vol,
                                )
                        except Exception:
                            pass
            except Exception:
                pass

            # Multiplayer: pickup animation on the player that likely picked the item (best-effort)
            try:
                if removed_item_ids:
                    local_pid = getattr(getattr(self, "network", None), "player_id", None)
                    for drop_id in removed_item_ids:
                        drop_id = int(drop_id)
                        prev = (prev_item_entities or {}).get(drop_id)
                        if not isinstance(prev, dict):
                            continue
                        ix = float(prev.get("x", 0.0) or 0.0)
                        iy = float(prev.get("y", 0.0) or 0.0)

                        nearest_pid = None
                        nearest_d2 = None
                        for pid, ent in new_entities.items():
                            if not isinstance(ent, dict) or ent.get("type") != 0:
                                continue
                            px = float(ent.get("x", 0.0) or 0.0)
                            py = float(ent.get("y", 0.0) or 0.0)
                            dx = px - ix
                            dy = py - iy
                            d2 = dx * dx + dy * dy
                            if nearest_d2 is None or d2 < nearest_d2:
                                nearest_d2 = d2
                                nearest_pid = int(pid)

                        if nearest_pid is None:
                            continue
                        if local_pid is not None and int(nearest_pid) == int(local_pid):
                            continue
                        if nearest_d2 is not None and nearest_d2 > (95.0 * 95.0):
                            continue

                        self._play_action_on_player_entity(nearest_pid, "picking_up")
            except Exception:
                pass
            
            # At 60Hz tickrate, use server position directly (server-authoritative)
            # Find our player in entities and apply server position
            # PLAYER entity type = 0 (from ENTITY_TYPES["PLAYER"])
            found_player = False
            for entity in entities:
                if entity.get("type") == 0 and entity.get("username") == self.player_username:
                    # Store for debug
                    new_server_x = entity.get("x", self.player_x)
                    new_server_y = entity.get("y", self.player_y)

                    # Track if server position is stuck ONLY while the client is actively pushing movement.
                    # Otherwise this counter grows indefinitely while idle and makes the heuristic noisy.
                    pushing = (abs(getattr(self, "player_vx", 0.0) or 0.0) > 0.01) or (abs(getattr(self, "player_vy", 0.0) or 0.0) > 0.01)
                    start_grace = float(getattr(self, "_start_grace_timer", 0.0) or 0.0)
                    if pushing and start_grace <= 0.0 and self._have_server_position and (abs(new_server_x - self._last_server_x) < 0.01) and (abs(new_server_y - self._last_server_y) < 0.01):
                        self._server_stuck_ticks += 1
                    else:
                        self._server_stuck_ticks = 0
                    self._last_server_x = float(new_server_x)
                    self._last_server_y = float(new_server_y)

                    self.server_x = float(new_server_x)
                    self.server_y = float(new_server_y)
                    self.server_vx = float(entity.get("vx", 0.0) or 0.0)
                    self.server_vy = float(entity.get("vy", 0.0) or 0.0)
                    self._have_server_position = True

                    # If prediction is disabled, follow server directly.
                    # If enabled, we keep local predicted pos and only correct visually.
                    if not self.enable_client_prediction:
                        self.player_x = self.server_x
                        self.player_y = self.server_y
                    
                    # Update environmental status from server
                    self.player_radiation = entity.get("radiation", 0.0)
                    self.player_cold = entity.get("cold", 0.0)
                    
                    # Update Geiger counter based on radiation
                    if self.sound_manager and self.biome_id == "toxic_wasteland":
                        self.sound_manager.update_geiger_counter(int(self.player_radiation))

                    # Sync inventory (server-authoritative)
                    # Harvesting state (used for inventory_update-triggered harvest completion)
                    self._last_harvesting_target = entity.get("harvesting_target")
                    
                    found_player = True
                    break
            
            # Debug: warn if we couldn't find our player in server entities
            if not found_player and len(entities) > 0:
                console.print(f"⚠️  Could not find our player '{self.player_username}' in {len(entities)} server entities")

        elif msg_type == "inventory_update":
            inv_data = message.get("inventory")
            if inv_data is None:
                return

            # Only apply when payload changed (keeps UI/cache work minimal).
            last_inv_data = getattr(self, "_last_inventory_data", None)
            if inv_data == last_inv_data:
                return
            prev_inv_data = last_inv_data
            self._last_inventory_data = inv_data

            prev_total_qty = getattr(self, "_last_inventory_total_qty", 0)
            prev_selected = getattr(self.inventory, "selected_hotbar_slot", 0)
            new_inv = Inventory.from_dict(inv_data)
            # Hotbar selection is a client-side UI concern; keep local selection.
            new_inv.selected_hotbar_slot = prev_selected
            self.inventory = new_inv
            self.inventory_ui.inventory = new_inv
            self.inventory_ui._invalidate_ui_cache()

            # Trigger one-shot animations based on inventory changes.
            new_total_qty = self._inventory_total_quantity(self.inventory)
            now = time.perf_counter()

            # Clear stale pending pickup
            if self._pending_pickup_entity_id is not None and (now - self._pending_pickup_requested_at) > 1.0:
                self._pending_pickup_entity_id = None

            gained_items = new_total_qty > prev_total_qty

            prev_harvest_target = getattr(self, "_prev_harvesting_target", None)
            harvested = (prev_harvest_target is not None and self._last_harvesting_target is None and gained_items)

            # Play completion SFX when a resource breaks (server-authoritative via harvest target -> None + inventory gain)
            if harvested:
                try:
                    import time
                    sm = getattr(self, "sound_manager", None)
                    last_tid = getattr(self, "_last_local_harvest_target_id", None)
                    last_type = getattr(self, "_last_local_harvest_resource_type", None)
                    if sm is not None and last_tid is not None and last_type and int(last_tid) == int(prev_harvest_target):
                        sm.play_harvest_complete(
                            current_time=time.time(),
                            resource_type=str(last_type),
                            target_id=int(prev_harvest_target),
                            volume_scale=1.0,
                        )
                except Exception:
                    pass

                # Completion VFX (shared helper; dedupes with state_update removal trigger)
                try:
                    import time
                    now_fx = time.perf_counter()
                    tid = int(prev_harvest_target)

                    fired = getattr(self, "_harvest_complete_triggered_at", None)
                    if not isinstance(fired, dict):
                        self._harvest_complete_triggered_at = {}
                        fired = self._harvest_complete_triggered_at
                    last_t = float(fired.get(tid, 0.0) or 0.0)
                    if not last_t or (now_fx - last_t) >= 1.0:
                        last_seen = getattr(self, "_resource_last_seen", {}) or {}
                        info = last_seen.get(tid) if isinstance(last_seen, dict) else None
                        self._spawn_harvest_complete_fx(tid, info=info, now_fx=now_fx)
                        fired[tid] = now_fx
                except Exception:
                    pass

            pending_pickup = False
            if self._pending_pickup_entity_id is not None and gained_items:
                pending_pickup = True
                prev_items = getattr(self, "_prev_item_entities", {}) or {}
                if self._pending_pickup_entity_id in prev_items and self._pending_pickup_entity_id not in self.item_entities:
                    pending_pickup = True

            if harvested or pending_pickup:
                self._play_local_picking_up_animation()
                self._pending_pickup_entity_id = None

                # Show loot feedback: compute per-item deltas from inventory snapshots.
                try:
                    prev_totals = self._inventory_totals_by_item_id(prev_inv_data)
                    new_totals = self._inventory_totals_by_item_id(inv_data)
                    deltas: dict[str, int] = {}
                    for item_id, new_qty in new_totals.items():
                        old_qty = int(prev_totals.get(item_id, 0) or 0)
                        diff = int(new_qty) - old_qty
                        if diff > 0:
                            deltas[item_id] = diff
                    if deltas and gained_items:
                        self._enqueue_loot_popups(deltas, now=now)
                except Exception:
                    pass

            self._last_inventory_total_qty = new_total_qty
        
        elif msg_type == "chat":
            username = message.get("username", "Unknown")
            text = message.get("message", "")
            timestamp = message.get("timestamp", time.time())  # Use server timestamp
            # console.print(f"💬 {username}: {text}")  # Chat shown in UI
            # Add to chat display with server timestamp (global ordering)
            self.chat_messages.append((username, text, timestamp))
            # Reset fade timer for new message
            self.chat_fade_timer = 0.0
            # Keep only last N messages
            if len(self.chat_messages) > self.chat_history_size:
                self.chat_messages.pop(0)
        
        elif msg_type == "player_joined":
            username = message.get("username")
            # console.print(f"👤 {username} joined the game")
        
        elif msg_type == "player_left":
            player_id = message.get("player_id")
            # console.print(f"👤 Player {player_id} left the game")
            # Clean up display position
            if player_id in self.entity_display_pos:
                del self.entity_display_pos[player_id]
        
        # Track network processing time
        net_duration = time.perf_counter() - net_start
        self.perf_monitor.record_network(net_duration)

    def _drain_network_messages(self, *, max_messages: int = 200, time_budget_ms: float = 4.0):
        """Process queued network messages on the main thread with a small time budget."""
        import time

        start = time.perf_counter()
        processed = 0
        last_state_update = None
        last_inventory_update = None
        pending_chunk_data = None
        while processed < max_messages:
            if (time.perf_counter() - start) * 1000.0 >= time_budget_ms:
                break
            try:
                msg = self._network_message_queue.get_nowait()
            except Empty:
                break

            # Coalesce high-frequency updates so we don't process multiple ticks in one frame.
            # This prevents burst-triggered animations/SFX from looking "sped up" when the queue backs up.
            try:
                mtype = (msg or {}).get("type") if isinstance(msg, dict) else None
            except Exception:
                mtype = None

            if mtype == "state_update":
                last_state_update = msg
            elif mtype == "inventory_update":
                last_inventory_update = msg
            elif mtype == "chunk_data":
                # Merge multiple chunk payloads into one to reduce per-frame overhead.
                if pending_chunk_data is None:
                    pending_chunk_data = msg
                else:
                    try:
                        a = pending_chunk_data.get("chunks", {}) if isinstance(pending_chunk_data, dict) else {}
                        b = msg.get("chunks", {}) if isinstance(msg, dict) else {}
                        if isinstance(a, dict) and isinstance(b, dict):
                            a.update(b)
                            pending_chunk_data["chunks"] = a
                    except Exception:
                        pass
            else:
                try:
                    self._process_network_message(msg)
                except Exception:
                    if DEBUG_NETWORK:
                        console.print("⚠️ Exception while processing network message")
                        traceback.print_exc()
            processed += 1

        # Process coalesced messages at most once per frame (newest wins).
        if pending_chunk_data is not None:
            try:
                self._process_network_message(pending_chunk_data)
            except Exception:
                if DEBUG_NETWORK:
                    console.print("⚠️ Exception while processing chunk_data")
                    traceback.print_exc()

        if last_state_update is not None:
            try:
                self._process_network_message(last_state_update)
            except Exception:
                if DEBUG_NETWORK:
                    console.print("⚠️ Exception while processing state_update")
                    traceback.print_exc()

        if last_inventory_update is not None:
            try:
                self._process_network_message(last_inventory_update)
            except Exception:
                if DEBUG_NETWORK:
                    console.print("⚠️ Exception while processing inventory_update")
                    traceback.print_exc()
    
    def on_mouse_press(self, x: int, y: int, button: int, modifiers: int):
        """Handle mouse press"""
        # Pass to inventory UI first (hotbar is always interactive)
        if self.inventory_ui.on_mouse_press(x, y, button):
            return
        if self.inventory_ui.visible:
            return
        
        # Middle click - reset zoom to default
        if button == arcade.MOUSE_BUTTON_MIDDLE:
            self.camera_zoom = 1.0
            self.camera.zoom = self.camera_zoom
            return
        
        # Left click - start continuous harvesting
        if button == arcade.MOUSE_BUTTON_LEFT:
            self.mouse_pressed = True
            self.mouse_x = x
            self.mouse_y = y
            self.harvest_cooldown = 0.0  # Start harvesting immediately
    
    def on_mouse_release(self, x: int, y: int, button: int, modifiers: int):
        """Handle mouse release"""
        if self.inventory_ui.on_mouse_release(x, y, button):
            return
        if self.inventory_ui.visible:
            return

        if button == arcade.MOUSE_BUTTON_LEFT:
            self.mouse_pressed = False
            # Cancel any ongoing harvest
            if self.network:
                self.network.send_queue.put({"type": "harvest_cancel"})
    
    def on_mouse_motion(self, x: int, y: int, dx: int, dy: int):
        """Handle mouse motion and update cursor based on harvestable objects"""
        # Update mouse position for continuous harvesting
        self.mouse_x = x
        self.mouse_y = y

        self.inventory_ui.on_mouse_motion(x, y, dx, dy)
        if self.inventory_ui.visible:
            return
        
        # Check if mouse is over a harvestable object in range
        self._update_cursor_for_harvestables()
    
    def on_mouse_scroll(self, x: int, y: int, scroll_x: int, scroll_y: int):
        """Handle mouse scroll - zoom in/out"""
        # Ignore if inventory is open
        if self.inventory_ui.visible:
            return
        
        # scroll_y > 0 = scroll up = zoom IN (přiblížit)
        # scroll_y < 0 = scroll down = zoom OUT (oddálit)
        zoom_speed = 0.1
        if scroll_y > 0:
            self.camera_zoom = min(self.camera_max_zoom, self.camera_zoom + zoom_speed)
        elif scroll_y < 0:
            self.camera_zoom = max(self.camera_min_zoom, self.camera_zoom - zoom_speed)
        
        # Apply zoom to camera
        self.camera.zoom = self.camera_zoom
    
    def _try_harvest_at_mouse(self):
        """Try to harvest resource at current mouse position.

        Uses the same hit-test logic as the pickaxe cursor (horizon-range + pixel alpha),
        but runs unthrottled to avoid "first hold click" races with cursor throttling.
        """
        target_id = self._compute_harvest_target_at_mouse()
        if target_id is None:
            return False

        # Record local "holding" intent so VFX can depend on mouse hold.
        tid = int(target_id)
        try:
            import time
            now_perf = time.perf_counter()
            self._harvest_fx_target_id = tid
            self._harvest_fx_last_intent_t = now_perf
        except Exception:
            pass

        # Local feedback should "cycle" while holding: sound + throw + shake should happen together.
        # Important: DO NOT tie this to network send rate (that would look like constant shaking).
        hit_happened = False
        try:
            import time
            now = time.time()

            # Restart immediately when target changes; otherwise throttle.
            if tid != getattr(self, "_last_harvest_anim_target_id", None):
                self._last_harvest_anim_target_id = tid
                self._last_harvest_anim_time = 0.0

            # Use the same cadence as harvest sound cooldown if available.
            feedback_cooldown = 0.70
            sm = getattr(self, "sound_manager", None)
            if sm is not None:
                feedback_cooldown = float(getattr(sm, "harvest_cooldown", feedback_cooldown) or feedback_cooldown)

            if now - float(getattr(self, "_last_harvest_anim_time", 0.0) or 0.0) >= feedback_cooldown:
                # Play sound (if available) and start throw animation at the same "hit" moment.
                try:
                    entity = self.resource_entities.get(tid) if hasattr(self, "resource_entities") else None
                    resource_type = (entity or {}).get("resource_type") if isinstance(entity, dict) else None

                    # Remember what we're harvesting locally (used for completion SFX)
                    if resource_type:
                        self._last_local_harvest_target_id = int(tid)
                        self._last_local_harvest_resource_type = str(resource_type)

                    if resource_type and sm is not None:
                        hit_happened = bool(
                            sm.play_harvest(
                                current_time=now,
                                resource_type=str(resource_type),
                                target_id=tid,
                                volume_scale=1.0,
                            )
                        )
                except Exception:
                    pass

                # Animation is part of the same cadence (even if sound is missing).
                try:
                    local_pid = getattr(getattr(self, "network", None), "player_id", None)
                    if local_pid is not None:
                        if self._play_action_on_player_entity(int(local_pid), "throw_object"):
                            hit_happened = True
                    elif isinstance(getattr(self, "player_sprite", None), DirectionalSprite8Way):
                        if self.player_sprite.play_action("throw_object", loop=False, restart=True):
                            hit_happened = True
                except Exception:
                    pass

                self._last_harvest_anim_time = now

                # Mark timing for VFX burst (slightly delayed to match perceived audio start)
                if hit_happened:
                    try:
                        import time
                        delay = float(getattr(self, "_harvest_fx_shake_delay_s", 0.035) or 0.035)
                        self._harvest_fx_last_hit_t = time.perf_counter() + max(0.0, delay)

                        # Populate multiplayer hit map as well (used for remote rendering path).
                        hit_map = getattr(self, "_harvest_fx_last_hit_by_target", None)
                        if isinstance(hit_map, dict):
                            hit_map[int(tid)] = time.perf_counter()
                    except Exception:
                        pass
        except Exception:
            pass

        if self.network:
            # Send harvest request (server ignores repeats for same target).
            self.network.send_harvest_start(tid)
            return True
        return False

    def _try_auto_pickup_items(self):
        """Auto-pickup nearest ground item within range (throttled)."""
        if not self.network or not self.network.connected:
            return
        if not self.item_entities:
            return

        import time
        from shared.constants import PICKUP_RANGE_CLIENT, PICKUP_VERTICAL_BONUS

        now = time.perf_counter()
        if now - self._last_pickup_attempt_time < 0.20:
            return
        self._last_pickup_attempt_time = now

        px = self.player_x
        py = self.player_y
        base_r = float(PICKUP_RANGE_CLIENT)
        vertical_bonus = float(PICKUP_VERTICAL_BONUS)

        nearest_id = None
        nearest_d2 = None

        for eid, e in self.item_entities.items():
            ex = e.get("x", 0)
            ey = e.get("y", 0)
            dx = ex - px
            dy = ey - py
            r = base_r + (vertical_bonus if abs(dy) > abs(dx) else 0.0)
            r2 = r * r
            d2 = dx * dx + dy * dy
            if d2 <= r2 and (nearest_d2 is None or d2 < nearest_d2):
                nearest_d2 = d2
                nearest_id = eid

        if nearest_id is not None:
            self.network.send_pickup_item(nearest_id)
            # Mark pending pickup so we can play one-shot animation on success
            import time
            self._pending_pickup_entity_id = int(nearest_id)
            self._pending_pickup_requested_at = time.perf_counter()
    
    def _is_harvestable_in_range(self) -> bool:
        """
        Check if mouse is over a harvestable object (hitbox) within player's reach (horizon)
        🔥 OPTIMIZED: Throttled to 10 FPS (100ms) and early exits
        """
        # 🔥 OPTIMIZED: Throttle expensive checks to 10 FPS (100ms between checks)
        import time
        current_time = time.perf_counter()

        if not hasattr(self, '_last_harvestable_check_time'):
            self._last_harvestable_check_time = 0
            self._last_harvestable_result = False

        # Return cached result if checked recently (within 100ms)
        if current_time - self._last_harvestable_check_time < 0.1:
            return self._last_harvestable_result

        self._last_harvestable_check_time = current_time
        self._last_harvestable_result = (self._compute_harvest_target_at_mouse() is not None)
        return self._last_harvestable_result

    def _compute_harvest_target_at_mouse(self):
        """Return resource entity_id if mouse is over a harvestable target in range.

        Unthrottled and matches cursor logic:
        - player feet within HARVEST_RANGE_CLIENT of resource horizon
        - mouse within HARVEST_RANGE_CLIENT of resource horizon
        - pixel-perfect hit on the resource sprite
        """
        from shared.constants import HARVEST_RANGE_CLIENT

        # Convert screen coordinates to world coordinates
        world_pos = self.camera.unproject((self.mouse_x, self.mouse_y))
        world_x, world_y = world_pos[0], world_pos[1]

        # Player foot position
        if self.player_sprite:
            player_foot_x = self.player_sprite.center_x
            player_foot_y = self.player_sprite.center_y - 28
        else:
            player_foot_x = self.player_x
            player_foot_y = self.player_y - 28

        # Early exit if no resources loaded/visible
        if not self.resource_sprites:
            return None

        best_id = None
        best_mouse_d2 = None
        range_sq = float(HARVEST_RANGE_CLIENT) * float(HARVEST_RANGE_CLIENT)

        for entity_id, sprite in self.resource_sprites.items():
            entity = self.resource_entities.get(entity_id)
            if not entity:
                continue

            resource_type = entity.get("resource_type", "")
            variant_id = entity.get("variant_id")
            if not variant_id or not resource_type:
                continue

            resource_key = (resource_type, variant_id)
            horizon_point = self.resource_horizon_data.get(resource_key)

            if not horizon_point:
                horizon_x = sprite.center_x
                horizon_y = sprite.center_y - sprite.height / 2
            else:
                dims = self.resource_dimensions.get(resource_key)
                if dims:
                    width, height = dims
                else:
                    width, height = sprite.width, sprite.height

                sprite_center_x = width / 2
                sprite_center_y = height / 2
                horizon_x = sprite.center_x + (horizon_point[0] - sprite_center_x)
                horizon_y = sprite.center_y + (sprite_center_y - horizon_point[1])

            # Player within range
            dxp = horizon_x - player_foot_x
            dyp = horizon_y - player_foot_y
            if (dxp * dxp + dyp * dyp) > range_sq:
                continue

            # Mouse within range
            dxm = horizon_x - world_x
            dym = horizon_y - world_y
            mouse_d2 = dxm * dxm + dym * dym
            if mouse_d2 > range_sq:
                continue

            # Pixel-perfect on visible sprite
            if not self._pixel_perfect_collision(sprite, world_x, world_y):
                continue

            if best_mouse_d2 is None or mouse_d2 < best_mouse_d2:
                best_mouse_d2 = mouse_d2
                best_id = entity_id

        return best_id
    
    def _pixel_perfect_collision(self, sprite: arcade.Sprite, world_x: float, world_y: float) -> bool:
        """
        Check if world position hits a visible (non-transparent) pixel in sprite.
        
        Args:
            sprite: Arcade sprite to test
            world_x, world_y: World coordinates to test
            
        Returns:
            True if hit visible pixel (alpha > 10), False otherwise
        """
        # Convert world coordinates to sprite-local coordinates
        local_x = world_x - sprite.center_x
        local_y = world_y - sprite.center_y
        
        # Get sprite texture
        texture = sprite.texture
        if not texture or not texture.image:
            return False
        
        # Get texture size (original size, not scaled)
        tex_width = texture.width
        tex_height = texture.height
        
        # Convert local coordinates to texture pixel coordinates
        # Sprite center is at (0, 0) in local coords
        # Texture origin is at top-left in PIL, but center in Arcade
        pixel_x = int(local_x + tex_width / 2)
        pixel_y = int(tex_height / 2 - local_y)  # Flip Y axis (Arcade Y-up, PIL Y-down)
        
        # Check bounds
        if pixel_x < 0 or pixel_x >= tex_width or pixel_y < 0 or pixel_y >= tex_height:
            return False
        
        # Get pixel alpha value from PIL image
        try:
            pil_image = texture.image
            if pil_image.mode != 'RGBA':
                pil_image = pil_image.convert('RGBA')
            
            pixel = pil_image.getpixel((pixel_x, pixel_y))
            alpha = pixel[3] if len(pixel) > 3 else 255
            
            # Consider pixel "solid" if alpha > 10 (mostly opaque)
            return alpha > 10
        except Exception as e:
            # Fallback to bounding box if pixel access fails
            return True
    
    def _update_cursor_for_harvestables(self):
        """Update mouse cursor based on whether we're hovering a harvestable object"""
        # Skip if pickaxe cursor not loaded
        if not self.pickaxe_cursor:
            return
            
        should_show_pickaxe = self._is_harvestable_in_range()
        
        # Only update if cursor state changed (avoid redundant updates)
        if should_show_pickaxe and not self.current_cursor_is_pickaxe:
            self.window.set_mouse_cursor(self.pickaxe_cursor)
            self.current_cursor_is_pickaxe = True
            if DEBUG_ENABLED:
                console.print("⛏️ Kurzor změněn na krumpáč")
        elif not should_show_pickaxe and self.current_cursor_is_pickaxe:
            self.window.set_mouse_cursor(self.default_cursor)
            self.current_cursor_is_pickaxe = False
            if DEBUG_ENABLED:
                console.print("🖱️ Kurzor změněn zpět na výchozí")

    
    def _request_chunks(self, chunk_keys: set):
        """Request chunks from server"""
        if not chunk_keys:
            return
        
        if not self.network or not self.network.connected:
            return  # Can't request if disconnected
        
        # Convert chunk_keys to list of "x,y" strings
        chunk_list = [f"{chunk_x},{chunk_y}" for chunk_x, chunk_y in chunk_keys]
        self.network.send_chunk_request(chunk_list)
        
        if DEBUG_NETWORK:
            console.print(f"📡 Requested {len(chunk_keys)} chunks from server")

    def _client_point_has_water(self, x: float, y: float) -> bool:
        """Return True if point is on water/transition (any corner==0).

        Client-side approximation used only for local prediction.
        If chunk data is missing, we return False (allow movement) and let server correct.
        """
        tile_x = int(x // TILE_SIZE)
        tile_y = int(y // TILE_SIZE)

        chunk_x = tile_x // CHUNK_SIZE
        chunk_y = tile_y // CHUNK_SIZE
        chunk_key = (chunk_x, chunk_y)

        chunk = self.loaded_chunks.get(chunk_key)
        if not chunk:
            return False

        local_x = tile_x % CHUNK_SIZE
        local_y = tile_y % CHUNK_SIZE
        if not (0 <= local_x < CHUNK_SIZE and 0 <= local_y < CHUNK_SIZE):
            return False

        corners = chunk.get_corners(local_x, local_y)
        return any(c == 0 for c in corners)

    @staticmethod
    def _client_point_in_polygon(x: float, y: float, polygon: list[tuple[float, float]]) -> bool:
        """Ray-casting point-in-polygon in local TSX coords."""
        n = len(polygon)
        if n < 3:
            return False
        inside = False
        p1x, p1y = polygon[0]
        for i in range(1, n + 1):
            p2x, p2y = polygon[i % n]
            if y > min(p1y, p2y):
                if y <= max(p1y, p2y):
                    if x <= max(p1x, p2x):
                        if p1y != p2y:
                            xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
                        else:
                            xinters = p1x
                        if p1x == p2x or x <= xinters:
                            inside = not inside
            p1x, p1y = p2x, p2y
        return inside

    def _client_point_collides_with_resources(self, x: float, y: float) -> bool:
        """Return True if point (x,y) collides with a collidable resource (tree/rock).

        Uses collision polygons loaded from TSX via client.sprite_loader.
        Point is expected to be the player's feet point.
        """
        if not getattr(self, "resource_collision_data", None):
            return False
        if not self.resource_entities:
            return False

        radius_sq = self._client_collision_radius_px * self._client_collision_radius_px

        for entity in self.resource_entities.values():
            ex = float(entity.get("x", 0.0))
            ey = float(entity.get("y", 0.0))
            dx = ex - x
            dy = ey - y
            if (dx * dx + dy * dy) > radius_sq:
                continue

            resource_type = entity.get("resource_type")
            variant_id = entity.get("variant_id")
            if resource_type not in ("tree", "rock"):
                continue
            if not isinstance(variant_id, int):
                continue

            poly = self.resource_collision_data.get((resource_type, variant_id))
            if not poly:
                continue

            if resource_type == "tree":
                sprite_height = 192.0
                center_x, center_y = 96.0, 96.0
            else:
                dims = self.resource_dimensions.get((resource_type, variant_id)) if getattr(self, "resource_dimensions", None) else None
                if dims and isinstance(dims, (tuple, list)) and len(dims) == 2:
                    w, h = float(dims[0]), float(dims[1])
                else:
                    w, h = 48.0, 48.0
                sprite_height = h
                center_x, center_y = w / 2.0, h / 2.0

            # Match server transform (server/collision.py CollisionManager.check_collision)
            local_x = x - ex + center_x
            local_y = sprite_height - (y - ey + center_y)

            if self._client_point_in_polygon(local_x, local_y, poly):
                return True

        return False

    def _client_point_collides_with_resources_expanded(self, x: float, y: float, margin_px: float) -> bool:
        """Like _client_point_collides_with_resources, but with a small safety margin.

        We test multiple points around the feet point (center + 4 offsets).
        This makes the client stop a bit earlier than server near polygon edges.
        """
        margin = float(margin_px or 0.0)
        if margin <= 0.0:
            return self._client_point_collides_with_resources(x, y)

        if not getattr(self, "resource_collision_data", None):
            return False
        if not self.resource_entities:
            return False

        offsets = ((0.0, 0.0), (margin, 0.0), (-margin, 0.0), (0.0, margin), (0.0, -margin))

        radius = float(getattr(self, "_client_collision_radius_px", 200.0) or 200.0) + margin
        radius_sq = radius * radius

        for entity in self.resource_entities.values():
            ex = float(entity.get("x", 0.0))
            ey = float(entity.get("y", 0.0))
            dx = ex - x
            dy = ey - y
            if (dx * dx + dy * dy) > radius_sq:
                continue

            resource_type = entity.get("resource_type")
            variant_id = entity.get("variant_id")
            if resource_type not in ("tree", "rock"):
                continue
            if not isinstance(variant_id, int):
                continue

            poly = self.resource_collision_data.get((resource_type, variant_id))
            if not poly:
                continue

            if resource_type == "tree":
                sprite_height = 192.0
                center_x, center_y = 96.0, 96.0
            else:
                dims = self.resource_dimensions.get((resource_type, variant_id)) if getattr(self, "resource_dimensions", None) else None
                if dims and isinstance(dims, (tuple, list)) and len(dims) == 2:
                    w, h = float(dims[0]), float(dims[1])
                else:
                    w, h = 48.0, 48.0
                sprite_height = h
                center_x, center_y = w / 2.0, h / 2.0

            # Match server transform (server/collision.py CollisionManager.check_collision)
            for ox, oy in offsets:
                px = x + ox
                py = y + oy
                local_x = px - ex + center_x
                local_y = sprite_height - (py - ey + center_y)
                if self._client_point_in_polygon(local_x, local_y, poly):
                    return True

        return False

    def _client_point_collides(self, x: float, y: float) -> bool:
        """Combined client-side collision check for prediction (water + resources)."""
        if self._client_point_has_water(x, y):
            return True
        margin = float(getattr(self, "_client_resource_collision_margin_px", 0.0) or 0.0)
        if self._client_point_collides_with_resources_expanded(x, y, margin):
            return True
        return False

    def _client_predict_move_no_slide(self, x: float, y: float, vx: float, vy: float, dt: float) -> tuple[float, float, bool]:
        """Predict movement with collision, without sliding, and without tunneling.

        We move along the full velocity vector but in small substeps (<=1px) so we can't
        "skip" through thin polygon edges when moving diagonally (e.g. holding W+D).

        Returns: (new_x, new_y, blocked)
        """
        dx = float(vx) * float(dt)
        dy = float(vy) * float(dt)
        if dx == 0.0 and dy == 0.0:
            return x, y, False

        step_px = 1.0
        max_delta = max(abs(dx), abs(dy))
        steps = int(max_delta // step_px)
        if (max_delta - (steps * step_px)) > 1e-9:
            steps += 1
        steps = max(1, min(steps, 20))

        step_dx = dx / steps
        step_dy = dy / steps

        lookahead = float(getattr(self, "_client_collision_lookahead_px", 0.0) or 0.0)
        mag = float((vx * vx + vy * vy) ** 0.5)
        dir_x = (vx / mag) if mag > 1e-9 else 0.0
        dir_y = (vy / mag) if mag > 1e-9 else 0.0

        blocked = False
        nx, ny = x, y
        for _ in range(steps):
            cand_x = nx + step_dx
            cand_y = ny + step_dy

            probe_x = cand_x + dir_x * lookahead
            probe_y = cand_y + dir_y * lookahead

            if self._client_point_collides(probe_x, probe_y - self._player_feet_offset_y):
                blocked = True
                break

            nx, ny = cand_x, cand_y

        return nx, ny, blocked
    
    def _get_terrain_under_player(self) -> Optional[int]:
        """Get terrain ID under player position for footstep sounds
        
        Returns:
            Terrain ID (1, 2, etc.) or None if not available
        """
        # Convert player position to tile coordinates
        tile_x = int(self.player_x // TILE_SIZE)
        tile_y = int(self.player_y // TILE_SIZE)
        
        # Get chunk coordinates
        chunk_x = tile_x // CHUNK_SIZE
        chunk_y = tile_y // CHUNK_SIZE
        
        # Check if chunk is loaded
        chunk_key = (chunk_x, chunk_y)
        if chunk_key not in self.loaded_chunks:
            return None
        
        chunk = self.loaded_chunks[chunk_key]
        
        # Get local tile position within chunk
        local_x = tile_x % CHUNK_SIZE
        local_y = tile_y % CHUNK_SIZE
        
        # Get terrain ID
        terrain_id = chunk.get_tile(local_x, local_y)
        return terrain_id
    
    # REMOVED: _get_tile_neighbors() and _get_tile_at() - no longer needed!
    # Wang indices are now pre-calculated by server for maximum performance!
    
    def _get_wang_pil_image_uncached(self, corner_types, biome_id, force_overlay=None, variant_seed=None):
        """
        SLOW PATH: Load Wang tile from disk (used during pre-loading).
        All PIL operations done here - NO Arcade operations!
        
        Args:
            force_overlay: True=always add overlay, False=natural chance, None=natural chance
            variant_seed: Seed for deterministic random generation (tile selection, rotation)
        
        Returns PIL.Image or None.
        """
        import json
        import os
        from PIL import Image
        from shared.wang_tiles import load_tileset_with_tiles, get_wang_tile_from_tileset
        
        nw, ne, sw, se = corner_types
        corner_set = {nw, ne, sw, se}
        
        # Check if this is a homogeneous tile (all corners same)
        all_same = (nw == ne == sw == se)
        
        # Use pre-loaded biome config (FAST!)
        if self.biome_config is None:
            console.print(f"⚠️ Biome config not loaded!")
            return None
        
        terrain_layers = self.biome_config.get("terrain_layers", [])
        
        # 🔥 SUPER OPTIMIZED: Cache tileset terrain map instead of rebuilding every call!
        if not hasattr(self, '_tileset_terrain_map_cache'):
            self._tileset_terrain_map_cache = {}
        
        if biome_id not in self._tileset_terrain_map_cache:
            # Build tileset_terrain_map ONCE per biome
            tileset_terrain_map = {}  # {tileset_path: {terrain_id: layer_info}}
            for layer in terrain_layers:
                tileset_path = layer.get("tileset")
                terrain_id = layer.get("id")
                if tileset_path and terrain_id is not None:
                    if tileset_path not in tileset_terrain_map:
                        tileset_terrain_map[tileset_path] = {}
                    tileset_terrain_map[tileset_path][terrain_id] = layer
            self._tileset_terrain_map_cache[biome_id] = tileset_terrain_map
        else:
            tileset_terrain_map = self._tileset_terrain_map_cache[biome_id]
        
        # 🔥 OPTIMIZED: Cache tileset selection per corner_set to avoid nested loops
        if not hasattr(self, '_tileset_selection_cache'):
            self._tileset_selection_cache = {}
        
        selection_key = (biome_id, corner_types if all_same else frozenset(corner_set))
        if selection_key in self._tileset_selection_cache:
            chosen_tileset, chosen_layers = self._tileset_selection_cache[selection_key]
        else:
            # Find tileset that contains all corner terrain IDs
            # For homogeneous tiles (all_same), prefer PRIMARY tileset
            chosen_tileset = None
            chosen_layers = None
            
            if all_same:
                # Homogeneous tile - find PRIMARY tileset for this terrain
                terrain_id = nw
                for tileset_path, terrain_map in tileset_terrain_map.items():
                    if terrain_id in terrain_map and terrain_map[terrain_id].get("primary", False):
                        chosen_tileset = tileset_path
                        chosen_layers = terrain_map
                        break
            
            # If not found (or not homogeneous), find any tileset containing all IDs
            if not chosen_tileset:
                for tileset_path, terrain_map in tileset_terrain_map.items():
                    if corner_set.issubset(terrain_map.keys()):
                        chosen_tileset = tileset_path
                        chosen_layers = terrain_map
                        break
            
            # Cache the selection
            self._tileset_selection_cache[selection_key] = (chosen_tileset, chosen_layers)
        
        if not chosen_tileset:
            return None
        
        # Load or get cached tileset
        tileset_cache_key = (biome_id, chosen_tileset)
        if tileset_cache_key not in self.tileset_cache:
            tileset_data = load_tileset_with_tiles(biome_id, chosen_tileset)
            if tileset_data:
                self.tileset_cache[tileset_cache_key] = tileset_data
                debug_print(DEBUG_INIT, f"✅ Loaded tileset: {biome_id}/{chosen_tileset}")
            else:
                return None
        
        tileset_data = self.tileset_cache[tileset_cache_key]
        
        # Use homogeneous tile logic
        if all_same and chosen_layers:
            # Use solid center_tile + optional overlay
            terrain_type = nw  # All corners same
            
            # Find the layer config for this terrain_type in chosen tileset
            layer_config = chosen_layers.get(terrain_type)
            if not layer_config:
                return get_wang_tile_from_tileset(corner_types, tileset_data, biome_id)
            
            rendering = layer_config.get("rendering", {})
            render_type = rendering.get("type")
            
            if render_type == "solid_tile":
                # Load solid tile
                solid_file = rendering.get("file")
                if not solid_file:
                    return get_wang_tile_from_tileset(corner_types, tileset_data, biome_id)
                
                solid_path = os.path.join("assets", "biomes", biome_id, chosen_tileset, solid_file)
                    
                if os.path.exists(solid_path):
                    tile_img = Image.open(solid_path).copy()
                    
                    # Add wang_center_overlay (random decorations on solid tiles)
                    overlay_cfg = rendering.get("wang_center_overlay", {})
                    if overlay_cfg.get("enabled", False):
                        import random
                        
                        # 🔥 Use LOCAL Random instance for deterministic generation
                        if variant_seed is not None:
                            rng = random.Random(variant_seed)
                        else:
                            rng = random  # Fallback to global random
                        
                        coverage = overlay_cfg.get("coverage", 0.1)
                        
                        # Decide if we should add overlay
                        should_add_overlay = force_overlay if force_overlay is not None else (rng.random() < coverage)
                        
                        if should_add_overlay:
                            # Use tileset_terrain field to determine corner value
                            corner_value = layer_config.get("tileset_terrain", "lower")
                            
                            # Find fully-homogeneous wang tiles (all corners same)
                            overlay_tiles = []
                            for tile_data in tileset_data["metadata"]["tileset_data"]["tiles"]:
                                c = tile_data["corners"]
                                if (c["NW"] == corner_value and c["NE"] == corner_value and 
                                    c["SW"] == corner_value and c["SE"] == corner_value):
                                    overlay_tiles.append(tile_data["id"])
                            
                            if overlay_tiles:
                                overlay_tile_id = rng.choice(overlay_tiles)
                                overlay = tileset_data["tiles"][overlay_tile_id].copy()
                                
                                # Apply deterministic rotation using local RNG
                                if overlay_cfg.get("random_rotation", False):
                                    rotations = overlay_cfg.get("rotations", [0, 90, 180, 270])
                                    angle = rng.choice(rotations)
                                    if angle != 0:
                                        overlay = overlay.rotate(angle, expand=False)
                                
                                # Composite overlay onto solid tile
                                if overlay.mode == 'RGBA':
                                    tile_img.paste(overlay, (0, 0), overlay)
                                else:
                                    tile_img.paste(overlay, (0, 0))
                    
                    # Return PIL Image
                    return tile_img
                else:
                    # Fallback to wang tile
                    return get_wang_tile_from_tileset(corner_types, tileset_data, biome_id)
            else:
                # type == "wang_tile" or missing
                return get_wang_tile_from_tileset(corner_types, tileset_data, biome_id)
        else:
            # Mixed corners - use wang tile
            return get_wang_tile_from_tileset(corner_types, tileset_data, biome_id)
    
    def _check_tree_collision(self, x: float, y: float) -> bool:
        """
        Check if point (player feet position) collides with any tree collision polygon
        
        Args:
            x, y: Player feet position in world coordinates
        
        Returns:
            True if collision detected, False otherwise
        """
        if not self.tree_collision_data:
            return False
        
        # Check collision with each resource sprite (trees)
        for entity_id, sprite in self.resource_sprites.items():
            # Direct lookup instead of loop - O(1) instead of O(n)
            tree_entity = self.entities.get(entity_id)
            
            if not tree_entity or tree_entity.get("type") != 5:
                continue
            
            tree_id = tree_entity.get("tree_id")
            if not tree_id or tree_id not in self.tree_collision_data:
                continue
            
            # Get collision polygon for this tree
            polygon_points = self.tree_collision_data[tree_id]
            
            # Convert polygon to world coordinates (same as in _draw_tree_collision_debug)
            sprite_x = sprite.center_x
            sprite_y = sprite.center_y
            
            world_points = []
            for px, py in polygon_points:
                world_x = sprite_x + (px - 64)  # 64 = half width (128px / 2)
                world_y = sprite_y + (96 - py)  # 96 = half height (192px / 2), flip Y
                world_points.append((world_x, world_y))
            
            # Check if point is inside this polygon
            # Use arcade.math.is_point_in_polygon or implement simple point-in-polygon test
            if self._point_in_polygon(x, y, world_points):
                return True
        
        return False
    
    def _point_in_polygon(self, x: float, y: float, polygon: list) -> bool:
        """
        Ray casting algorithm for point-in-polygon test
        
        Args:
            x, y: Point coordinates
            polygon: List of (x, y) tuples representing polygon vertices
        
        Returns:
            True if point is inside polygon
        """
        n = len(polygon)
        inside = False
        
        p1x, p1y = polygon[0]
        for i in range(1, n + 1):
            p2x, p2y = polygon[i % n]
            if y > min(p1y, p2y):
                if y <= max(p1y, p2y):
                    if x <= max(p1x, p2x):
                        if p1y != p2y:
                            xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
                        if p1x == p2x or x <= xinters:
                            inside = not inside
            p1x, p1y = p2x, p2y
        
        return inside
