ColourBreakGame.cs

A single-component game class implementing a match-3 style puzzle called Colour Break. It builds and renders a 2D board in 3D space, spawns candy pieces with visuals, handles input (mouse/controller/keyboard), runs the match/clear/collapse/refill phase machine, tracks score, moves, objectives and level/endless modes, and produces visual/audio effects and simple persistence/bug-report hooks.

File AccessNetworkingHttp Calls
using Sandbox;
using System;
using System.Collections.Generic;

public sealed class ColourBreakGame : Component
{
	const int Empty = -1;

	[Property] public int Width { get; set; } = 8;
	[Property] public int Height { get; set; } = 8;
	[Property] public float CellSpacing { get; set; } = 48f;
	[Property] public float CandyScale { get; set; } = 0.50f;
	[Property] public Vector3 BoardOrigin { get; set; } = Vector3.Zero;
	[Property] public Model CandyModel { get; set; }
	[Property] public int CandyTypeCount { get; set; } = 6;
	[Property] public bool AutoFrameMainCamera { get; set; } = true;
	[Property] public int LevelIndex { get; set; } = 1;
	[Property] public int TargetScore { get; set; } = 850;
	[Property] public int MoveLimit { get; set; } = 24;
	[Property] public bool AudioEnabled { get; set; } = true;
	[Property] public bool EffectsEnabled { get; set; } = true;
	[Property] public bool CameraShakeEnabled { get; set; } = true;
	[Property] public bool ColourBlindMode { get; set; }
	[Property] public string StartSound { get; set; } = "sounds/colourbreak_start.sound";
	[Property] public string SwapSound { get; set; } = "sounds/colourbreak_swap.sound";
	[Property] public string ClearSound { get; set; } = "sounds/colourbreak_clear.sound";
	[Property] public string ComboSound { get; set; } = "sounds/colourbreak_combo.sound";
	[Property] public string DeniedSound { get; set; } = "sounds/colourbreak_denied.sound";
	[Property] public bool MusicEnabled { get; set; } = true;
	[Property] public string MusicSound { get; set; } = "sounds/colourbreak_music.sound";
	[Property, Range( 0f, 1f )] public float MusicVolume { get; set; } = 0.42f;
	[Property] public GameMode Mode { get; set; } = GameMode.Levels;

	// Endless uses the full palette and the largest shape set available.
	const int EndlessColourCount = 6;

	Model _sphereModel;
	Model _boxModel;

	Model ModelForType( int t ) => _sphereModel ?? _boxModel;
	Color PieceColor( int type ) => (ColourBlindMode ? ColourBlindPalette : Palette)[Math.Clamp( type, 0, Palette.Length - 1 )];
	Vector3 PieceShape( int type ) => (ColourBlindMode ? ColourBlindShapes : Shapes)[Math.Clamp( type, 0, Shapes.Length - 1 )];

	// Distinct saturated colours that read quickly under motion and bloom.
	readonly Color[] Palette = new[]
	{
		new Color( 1.00f, 0.05f, 0.15f ), // bright red
		new Color( 1.00f, 0.50f, 0.00f ), // bright orange
		new Color( 1.00f, 0.95f, 0.00f ), // bright yellow
		new Color( 0.15f, 0.90f, 0.15f ), // bright green
		new Color( 0.05f, 0.55f, 1.00f ), // bright blue
		new Color( 0.80f, 0.15f, 1.00f ), // bright purple
	};

	// Okabe-Ito-derived hues, pushed brighter/more saturated so they stay distinct
	// under bloom. Backed up by the per-type shape symbols in ApplyColourBlindSymbol.
	readonly Color[] ColourBlindPalette = new[]
	{
		new Color( 1.00f, 0.55f, 0.00f ), // vivid orange
		new Color( 0.30f, 0.78f, 1.00f ), // sky blue
		new Color( 0.00f, 0.78f, 0.50f ), // bluish green
		new Color( 1.00f, 0.95f, 0.20f ), // yellow
		new Color( 0.05f, 0.35f, 0.95f ), // deep blue
		new Color( 0.95f, 0.40f, 0.70f ), // reddish purple / pink
	};

	readonly string[] ColourNames =
	{
		"Ruby",
		"Amber",
		"Gold",
		"Lime",
		"Azure",
		"Violet",
	};

	readonly Vector3[] Shapes = new[]
	{
		new Vector3( 1.00f, 1.00f, 1.00f ),
		new Vector3( 1.18f, 0.92f, 1.08f ),
		new Vector3( 0.92f, 1.08f, 1.24f ),
		new Vector3( 1.08f, 1.00f, 0.86f ),
		new Vector3( 0.94f, 1.06f, 1.14f ),
		new Vector3( 1.10f, 0.90f, 0.98f ),
		new Vector3( 1.00f, 1.00f, 1.00f ),
	};

	readonly Vector3[] ColourBlindShapes = new[]
	{
		new Vector3( 0.78f, 1.00f, 1.42f ),
		new Vector3( 1.42f, 0.92f, 0.78f ),
		new Vector3( 1.00f, 1.00f, 1.00f ),
		new Vector3( 1.28f, 0.92f, 1.28f ),
		new Vector3( 0.78f, 1.08f, 0.78f ),
		new Vector3( 1.48f, 0.86f, 1.08f ),
		new Vector3( 1.00f, 1.00f, 1.00f ),
	};

	readonly Dictionary<string, bool> _prevKeys = new();

	int[,] _types;
	SpecialKind[,] _specials;
	CandyVisual[,] _visuals;
	readonly List<GameObject> _decor = new();

	int _cursorX;
	int _cursorY;
	bool _hasSelection;
	int _selectedX;
	int _selectedY;

	int _score;
	int _moves;
	int _objectiveProgress;
	ColourBreakSaveData _saveData = new();
	bool _prevMouseLeft;
	float _controllerMoveCooldown;

	bool _dragging;
	int _dragStartX;
	int _dragStartY;
	bool _dragSwapped;

	// Idle hint: after a stretch of inactivity, gently pulse a valid move so the
	// player is never truly stuck staring at the board.
	float _idleTime;
	bool _hasHint;
	int _hintAX, _hintAY, _hintBX, _hintBY;
	const float HintDelay = 5.0f;

	// Tracks whether the player has ever made a swap this session, so the
	// how-to-play prompt can fade out once they clearly understand the controls.
	bool _hasEverSwapped;

	// High-level game state driving the menus
	public enum GameState { Menu, Playing, Paused, Won, Lost }
	public enum GameMode { Levels, Endless }
	public GameState CurrentState { get; private set; } = GameState.Menu;
	public bool IsEndless => Mode == GameMode.Endless;
	enum SpecialKind { None, RowBlast, ColumnBlast, Bomb }

	// Exposed for the UI / HUD
	public int Score => _score;
	public int Moves => _moves;
	public int MovesLeft => Math.Max( 0, MoveLimit - _moves );
	public int GoalScore => TargetScore;
	public int Level => LevelIndex;
	public int ColourCount => CandyTypeCount;
	ColourBreakLevelDefinition CurrentLevelDefinition => ColourBreakLevels.Get( LevelIndex );
	public string LevelName => CurrentLevelDefinition.Name;
	public string LevelBrief => CurrentLevelDefinition.Brief;
	public int LevelCount => ColourBreakLevels.Count;
	public bool HasNextLevel => LevelIndex < ColourBreakLevels.Count;
	public int HighestUnlockedLevel => Math.Clamp( _saveData?.HighestUnlockedLevel ?? 1, 1, LevelCount );
	public int BestStars => ColourBreakProgressStore.GetBestStars( _saveData, LevelIndex );
	public int TotalStars
	{
		get
		{
			int total = 0;
			for ( int i = 1; i <= LevelCount; i++ )
				total += ColourBreakProgressStore.GetBestStars( _saveData, i );
			return total;
		}
	}
	public int MaxStars => LevelCount * 3;
	public int EndlessBest => Math.Max( 0, _saveData?.BestEndlessScore ?? 0 );
	public bool EndlessRecordSet { get; private set; }
	public bool SelectedLevelLocked => LevelIndex > HighestUnlockedLevel;
	public bool CanPlaySelectedLevel => !SelectedLevelLocked;
	public bool CanSelectPreviousLevel => CurrentState == GameState.Menu && LevelIndex > 1;
	public bool CanSelectNextLevel => CurrentState == GameState.Menu && LevelIndex < LevelCount;
	public bool HasSecondaryObjective => CurrentLevelDefinition.ObjectiveKind != ColourBreakObjectiveKind.Score && CurrentLevelDefinition.ObjectiveTarget > 0;
	public int ObjectiveProgress => HasSecondaryObjective ? Math.Min( _objectiveProgress, CurrentLevelDefinition.ObjectiveTarget ) : 0;
	public int ObjectiveTarget => HasSecondaryObjective ? CurrentLevelDefinition.ObjectiveTarget : 0;
	public string ObjectiveText => ObjectiveLabel();
	public string ObjectiveProgressText => HasSecondaryObjective
		? $"{ObjectiveProgress} / {ObjectiveTarget}"
		: "Score target";
	public bool ObjectiveComplete => !HasSecondaryObjective || _objectiveProgress >= CurrentLevelDefinition.ObjectiveTarget;
	public int CompletionStars
	{
		get
		{
			if ( CurrentState != GameState.Won )
				return 0;

			int stars = 1;
			if ( MovesLeft >= Math.Max( 3, MoveLimit / 4 ) )
				stars++;
			if ( _score >= MathF.Ceiling( TargetScore * 1.25f ) )
				stars++;

			return Math.Clamp( stars, 1, 3 );
		}
	}
	public string CompletionRank => CompletionStars switch
	{
		3 => "Perfect Break",
		2 => "Clean Break",
		1 => "Level Clear",
		_ => "Keep Going"
	};
	public int ComboCount => _comboCount;
	public bool HasLegalMove => HasPossibleMove();
	public bool MusicIsPlaying => _music.IsValid() && _music.IsPlaying;
	public int ActiveSpecialCount => CountSpecials();
	// Drives the one-time how-to-play prompt and the "stuck?" hint banner in the HUD.
	public bool ShowHowToPlay => CurrentState == GameState.Playing && !_hasEverSwapped;
	public bool HintActive => _hasHint && CurrentState == GameState.Playing;
	public string StatusText => _phase switch
	{
		Phase.SwapForward => "Swapping",
		Phase.SwapBack => "Try another move",
		Phase.Clearing => _comboCount > 1 ? $"Combo x{_comboCount}" : "Colour break",
		Phase.Collapsing => "Dropping",
		Phase.Refilling => "Refilling",
		Phase.Pause => _comboCount > 1 ? $"Chain x{_comboCount}" : "Scoring",
		_ when _hasSelection => "Pick a neighbour",
		_ => CurrentState switch
		{
			GameState.Won => "Level complete",
			GameState.Lost => IsEndless ? "No moves left" : "Out of moves",
			GameState.Playing => IsEndless ? "Keep the chain alive" : "Find a colour chain",
			_ => "Ready"
		}
	};

	// Resolve / animation state machine
	enum Phase { Idle, SwapForward, ValidateAndClear, Clearing, Collapsing, Refilling, Pause, SwapBack }
	Phase _phase = Phase.Idle;
	float _phaseTimer;
	int _swapAX, _swapAY, _swapBX, _swapBY;
	int _comboCount;

	// Camera punch
	CameraComponent _activeCamera;
	float _baseFov = 60f;
	float _fovPunch;
	float _shakeAmount;
	Vector3 _baseCameraPos;

	// Orbiting accent lights
	GameObject _orbitLightL;
	GameObject _orbitLightR;

	// Color-shifting tray
	ModelRenderer _trayInnerRenderer;
	Color _trayBaseColor = new Color( 0.62f, 0.70f, 0.92f );

	const float SwapDuration = 0.18f;
	const float ClearDuration = 0.28f;
	const float FallSpeed = 1100f; // units per second
	const float CascadePause = 0.05f;

	sealed class CandyVisual
	{
		public GameObject Go;
		public ModelRenderer Renderer;
		public GameObject Shine;
		public ModelRenderer ShineRenderer;
		public GameObject Rim;
		public ModelRenderer RimRenderer;
		public GameObject Accent;
		public ModelRenderer AccentRenderer;
		public GameObject SymbolA;
		public ModelRenderer SymbolARenderer;
		public GameObject SymbolB;
		public ModelRenderer SymbolBRenderer;
		public GameObject Face;
		public ModelRenderer FaceRenderer;
		public GameObject Halo;
		public ModelRenderer HaloRenderer;
		public GameObject Guide;
		public ModelRenderer GuideRenderer;
		public GameObject SpecialBadge;
		public ModelRenderer SpecialBadgeRenderer;
		public int Type;
		public SpecialKind Special;
		public Vector3 Anchor;          // resting/target world position
		public Vector3 Velocity;        // for falling
		public float BobPhase;
		public float ClearTimer;        // > 0 while playing destroy anim
		public bool Clearing;
		public bool Spawning;           // newly spawned, scaling up from 0
		public float SpawnTimer;
		public float SwapT;             // 0..1 progress for swap tween
		public Vector3 SwapFrom;
		public Vector3 SwapTo;
		public bool Swapping;
	}

	sealed class Burst
	{
		public GameObject Go;
		public ModelRenderer Renderer;
		public Vector3 Velocity;
		public float Life;
		public float MaxLife;
		public Color BaseTint;
		public float StartScale;
	}

	readonly List<Burst> _bursts = new();

	// Particle pool. Bursts/fire spawn dozens of short-lived renderer objects per
	// clear; recycling them instead of new/Destroy each time avoids GC churn.
	readonly Stack<GameObject> _burstPool = new();
	const int MaxPooledBursts = 256;

	GameObject RentBurstObject( string name )
	{
		GameObject go;
		if ( _burstPool.Count > 0 )
		{
			go = _burstPool.Pop();
			go.Name = name;
			go.Enabled = true;
		}
		else
		{
			go = new GameObject( true, name );
			go.Flags = GameObjectFlags.NotNetworked;
			go.AddComponent<ModelRenderer>();
		}
		return go;
	}

	void ReturnBurstObject( GameObject go )
	{
		if ( go == null || !go.IsValid() ) return;
		if ( _burstPool.Count >= MaxPooledBursts )
		{
			go.Destroy();
			return;
		}
		go.Enabled = false;
		go.SetParent( null );
		_burstPool.Push( go );
	}

	sealed class Shockwave
	{
		public GameObject Go;
		public ModelRenderer Renderer;
		public Color Tint;
		public float Life;
		public float MaxLife;
		public float Radius;
	}

	readonly List<Shockwave> _shockwaves = new();

	float _audioCooldown;
	SoundHandle _music;

	// -------------------- lifecycle --------------------

	protected override void OnStart()
	{
		base.OnStart();

		_saveData = ColourBreakProgressStore.Load();
		ApplyLevelSettings();
		_boxModel = Model.Load( "models/dev/box.vmdl" );
		_sphereModel = Model.Load( "models/dev/sphere.vmdl" );
		CandyModel ??= _boxModel;

		EnsureSceneLighting();
		BuildEnvironment();
		BuildFreshBoard();

		if ( AutoFrameMainCamera )
			FrameMainCameraToBoard();

		Mouse.Visibility = MouseVisibility.Visible;

		// Start at the main menu; the board sits in the background as a live backdrop.
		CurrentState = GameState.Menu;

		Log.Info( "Colour Break ready. Press Play to start. Drag a colour piece onto an adjacent one to swap. R resets, Esc pauses." );
	}

	protected override void OnUpdate()
	{
		HandlePauseHotkeys();
		UpdateMusic();

		// Paused: freeze the board entirely until the player resumes.
		if ( CurrentState == GameState.Paused )
			return;

		AnimateCandies();
		AnimateBursts();
		AnimateShockwaves();
		UpdatePhase();
		UpdateAmbience();
		UpdateCameraPunch();
		_audioCooldown = MathF.Max( 0f, _audioCooldown - Time.Delta );
		_controllerMoveCooldown = MathF.Max( 0f, _controllerMoveCooldown - Time.Delta );

		// Only accept gameplay input while actually playing.
		if ( CurrentState == GameState.Playing && _phase == Phase.Idle )
		{
			HandleMouse();
			HandleInput();
		}

		UpdateHint();
		ApplyHighlights();
	}

	// -------------------- game state / menu API --------------------

	void ApplyLevelSettings()
	{
		LevelIndex = Math.Clamp( LevelIndex, 1, ColourBreakLevels.Count );

		var level = CurrentLevelDefinition;
		TargetScore = level.TargetScore;
		MoveLimit = level.MoveLimit;
		CandyTypeCount = Math.Clamp( level.ColourCount, 4, Math.Min( Palette.Length, Shapes.Length ) );
	}

	public int GetBestStars( int levelIndex )
	{
		return ColourBreakProgressStore.GetBestStars( _saveData, levelIndex );
	}

	void SaveWinProgress()
	{
		ColourBreakProgressStore.RecordWin( _saveData, LevelIndex, CompletionStars, HasNextLevel );
		SubmitLeaderboardStats();
	}

	// Cumulative stats feed s&box leaderboards once the published package defines
	// them. Guarded so it is a harmless no-op in the editor / offline.
	void SubmitLeaderboardStats()
	{
		try
		{
			Sandbox.Services.Stats.Increment( "levels-cleared", 1 );
			Sandbox.Services.Stats.Increment( "stars-earned", CompletionStars );
			Sandbox.Services.Stats.Increment( "total-score", _score );
		}
		catch ( Exception e )
		{
			Log.Info( $"Leaderboard stats unavailable: {e.Message}" );
		}
	}

	string ObjectiveLabel()
	{
		var level = CurrentLevelDefinition;
		return level.ObjectiveKind switch
		{
			ColourBreakObjectiveKind.ClearPieces => $"Break {level.ObjectiveTarget} pieces",
			ColourBreakObjectiveKind.ClearColour => $"Break {level.ObjectiveTarget} {ColourName( level.ObjectiveType )}",
			ColourBreakObjectiveKind.MakeSpecials => $"Create {level.ObjectiveTarget} specials",
			ColourBreakObjectiveKind.TriggerSpecials => $"Trigger {level.ObjectiveTarget} specials",
			ColourBreakObjectiveKind.ChainCombos => $"Chain {level.ObjectiveTarget} cascades",
			_ => $"Score {TargetScore}"
		};
	}

	string ColourName( int type )
	{
		if ( type >= 0 && type < ColourNames.Length )
			return ColourNames[type];

		return "colour";
	}

	void ApplyEndlessSettings()
	{
		CandyTypeCount = Math.Clamp( EndlessColourCount, 4, Math.Min( Palette.Length, Shapes.Length ) );
		TargetScore = int.MaxValue;   // no score win condition in endless
		MoveLimit = int.MaxValue;     // no move limit in endless
	}

	// Switch the selected mode from the main menu and refresh the backdrop board.
	public void SetMode( GameMode mode )
	{
		if ( CurrentState != GameState.Menu || Mode == mode )
			return;

		Mode = mode;
		if ( IsEndless ) ApplyEndlessSettings();
		else ApplyLevelSettings();
		BuildFreshBoard();
	}

	public void SelectLevelsMode() => SetMode( GameMode.Levels );
	public void SelectEndlessMode() => SetMode( GameMode.Endless );

	public void StartNewGame()
	{
		EndlessRecordSet = false;

		if ( IsEndless )
		{
			ApplyEndlessSettings();
		}
		else
		{
			ApplyLevelSettings();
			if ( SelectedLevelLocked )
			{
				LevelIndex = HighestUnlockedLevel;
				ApplyLevelSettings();
			}
		}

		BuildFreshBoard();
		CurrentState = GameState.Playing;
		_comboCount = 0;
		PlayUiSound( StartSound, 0.85f, 1f );
		SpawnFireColumn( BoardOrigin + new Vector3( 0f, -14f, 0f ), 0.80f );
		SpawnShockwave( BoardOrigin, new Color( 1f, 0.42f, 0.08f ), 0.55f, 110f );
		PunchCamera( 3.5f, 2.0f );
		Mouse.Visibility = MouseVisibility.Visible;
	}

	public void RestartGame() => StartNewGame();

	public void StartNextLevel()
	{
		if ( HasNextLevel && LevelIndex < HighestUnlockedLevel )
			LevelIndex++;

		StartNewGame();
	}

	public void SelectPreviousLevel()
	{
		if ( CurrentState != GameState.Menu )
			return;

		LevelIndex = Math.Max( 1, LevelIndex - 1 );
		ApplyLevelSettings();
		BuildFreshBoard();
	}

	public void SelectNextLevel()
	{
		if ( CurrentState != GameState.Menu )
			return;

		LevelIndex = Math.Min( LevelCount, LevelIndex + 1 );
		ApplyLevelSettings();
		BuildFreshBoard();
	}

	public void PauseGame()
	{
		if ( CurrentState == GameState.Playing )
			CurrentState = GameState.Paused;
	}

	public void ResumeGame()
	{
		if ( CurrentState == GameState.Paused )
			CurrentState = GameState.Playing;
	}

	public void QuitToMenu()
	{
		if ( IsEndless ) ApplyEndlessSettings();
		else ApplyLevelSettings();
		BuildFreshBoard();          // rebuild a fresh backdrop and reset stats
		CurrentState = GameState.Menu;
	}

	public void ToggleAudio() => AudioEnabled = !AudioEnabled;
	public void ToggleMusic() => MusicEnabled = !MusicEnabled;
	public void ToggleEffects() => EffectsEnabled = !EffectsEnabled;
	public void ToggleCameraShake() => CameraShakeEnabled = !CameraShakeEnabled;
	public void ToggleColourBlindMode()
	{
		ColourBlindMode = !ColourBlindMode;
		RefreshAllPieceVisuals();
	}

	// -------------------- bug reporting --------------------

	// Reports are stored locally; only this Steam ID sees the in-game manager.
	const long DevSteamId = 76561198271608270L;

	public bool IsDeveloper => LocalSteamId() == DevSteamId;
	public int OpenBugReportCount => ColourBreakBugReportStore.OpenCount();

	static long LocalSteamId()
	{
		try { return Connection.Local?.SteamId.Value ?? 0L; }
		catch { return 0L; }
	}

	string LocalReporterName()
	{
		try
		{
			var c = Connection.Local;
			if ( c != null && !string.IsNullOrWhiteSpace( c.DisplayName ) )
				return c.DisplayName;
		}
		catch { }
		return "Player";
	}

	string BugContext()
	{
		if ( IsEndless )
			return $"Endless - Score {_score}, Moves {_moves}";
		return $"Level {LevelIndex} ({LevelName}) - {CurrentState}";
	}

	public bool SubmitBugReport( string message )
	{
		if ( string.IsNullOrWhiteSpace( message ) )
			return false;

		var report = new ColourBreakBugReport
		{
			Message = message,
			Reporter = LocalReporterName(),
			SteamId = LocalSteamId(),
			Context = BugContext(),
			Version = "1.0",
		};

		bool ok = ColourBreakBugReportStore.Add( report );
		if ( ok )
			PlayUiSound( ClearSound, 0.6f, 1.1f );
		return ok;
	}

	public List<ColourBreakBugReport> GetBugReports() => ColourBreakBugReportStore.GetAll();
	public void ResolveBugReport( string id ) => ColourBreakBugReportStore.SetResolved( id, true );
	public void ReopenBugReport( string id ) => ColourBreakBugReportStore.SetResolved( id, false );
	public void DeleteBugReport( string id ) => ColourBreakBugReportStore.Delete( id );
	public void ClearBugReports() => ColourBreakBugReportStore.Clear();

	void HandlePauseHotkeys()
	{
		// Esc toggles pause while in a game; ignored on the main menu.
		if ( CurrentState == GameState.Menu || CurrentState == GameState.Won || CurrentState == GameState.Lost ) return;

		if ( Pressed( "Escape" ) || ActionPressed( "menu", "pause" ) )
		{
			if ( CurrentState == GameState.Playing ) PauseGame();
			else if ( CurrentState == GameState.Paused ) ResumeGame();
		}
	}

	// -------------------- environment --------------------

	void BuildEnvironment()
	{
		CleanupDecor();

		float boardW = Width * CellSpacing;
		float boardH = Height * CellSpacing;
		float edge = 18f;
		float depth = 28f;

		AddStageBackdrop( boardW, boardH, edge, depth );

		_decor.Add( CreateBlock( "Tray",
			BoardOrigin + new Vector3( 0f, depth * 0.5f + 8f, 0f ),
			new Vector3( boardW + edge * 6.8f, depth, boardH + edge * 6.8f ),
			new Color( 0.025f, 0.028f, 0.050f ) ) );

		var trayInner = CreateBlock( "TrayInner",
			BoardOrigin + new Vector3( 0f, depth * 0.5f + 2f, 0f ),
			new Vector3( boardW + edge * 2.6f, depth - 4f, boardH + edge * 2.6f ),
			new Color( 0.055f, 0.065f, 0.105f ) );
		_trayInnerRenderer = trayInner.GetComponent<ModelRenderer>();
		_trayBaseColor = new Color( 0.055f, 0.065f, 0.105f );

		for ( int y = 0; y < Height; y++ )
		{
			for ( int x = 0; x < Width; x++ )
			{
				float cellX = (x - (Width - 1) * 0.5f) * CellSpacing;
				float cellZ = (y - (Height - 1) * 0.5f) * CellSpacing;
				float alt = ((x + y) & 1) == 0 ? 0.00f : 0.018f;
				var tile = CreateBlock( $"WallTile_{x}_{y}",
					BoardOrigin + new Vector3( cellX, depth * 0.5f - 2f, cellZ ),
					new Vector3( CellSpacing * 0.86f, 3.5f, CellSpacing * 0.86f ),
					new Color( 0.075f + alt, 0.085f + alt, 0.135f + alt ) );
				tile.WorldRotation = Rotation.FromYaw( ((x + y) % 3 - 1) * 1.4f );
			}
		}

		for ( int x = 0; x <= Width; x++ )
		{
			float gridX = (x - Width * 0.5f) * CellSpacing + CellSpacing * 0.5f;
			_decor.Add( CreateBlock( $"GridV_{x}",
				BoardOrigin + new Vector3( gridX, depth * 0.5f - 5f, 0f ),
				new Vector3( 1.2f, 1.8f, boardH + edge * 0.6f ),
				new Color( 0.025f, 0.22f, 0.34f ) ) );
		}

		for ( int y = 0; y <= Height; y++ )
		{
			float gridZ = (y - Height * 0.5f) * CellSpacing + CellSpacing * 0.5f;
			_decor.Add( CreateBlock( $"GridH_{y}",
				BoardOrigin + new Vector3( 0f, depth * 0.5f - 5f, gridZ ),
				new Vector3( boardW + edge * 0.6f, 1.8f, 1.2f ),
				new Color( 0.025f, 0.22f, 0.34f ) ) );
		}

		var frameTint = new Color( 0.19f, 0.10f, 0.31f );
		_decor.Add( CreateBlock( "FrameTop",
			BoardOrigin + new Vector3( 0f, -4f, boardH * 0.5f + edge * 1.0f ),
			new Vector3( boardW + edge * 5.2f, edge * 1.8f, edge * 1.8f ), frameTint ) );
		_decor.Add( CreateBlock( "FrameBottom",
			BoardOrigin + new Vector3( 0f, -4f, -boardH * 0.5f - edge * 1.0f ),
			new Vector3( boardW + edge * 5.2f, edge * 1.8f, edge * 1.8f ), frameTint ) );
		_decor.Add( CreateBlock( "FrameLeft",
			BoardOrigin + new Vector3( -boardW * 0.5f - edge * 1.0f, -4f, 0f ),
			new Vector3( edge * 1.8f, edge * 1.8f, boardH + edge * 5.2f ), frameTint ) );
		_decor.Add( CreateBlock( "FrameRight",
			BoardOrigin + new Vector3( boardW * 0.5f + edge * 1.0f, -4f, 0f ),
			new Vector3( edge * 1.8f, edge * 1.8f, boardH + edge * 5.2f ), frameTint ) );

		var railTint = new Color( 0.05f, 0.78f, 1f );
		_decor.Add( CreateBlock( "GlowRailTop",
			BoardOrigin + new Vector3( 0f, -12f, boardH * 0.5f + edge * 2.2f ),
			new Vector3( boardW + edge * 4.6f, edge * 0.28f, edge * 0.30f ), railTint ) );
		_decor.Add( CreateBlock( "GlowRailBottom",
			BoardOrigin + new Vector3( 0f, -12f, -boardH * 0.5f - edge * 2.2f ),
			new Vector3( boardW + edge * 4.6f, edge * 0.28f, edge * 0.30f ), new Color( 1f, 0.28f, 0.05f ) ) );
		_decor.Add( CreateBlock( "GlowRailLeft",
			BoardOrigin + new Vector3( -boardW * 0.5f - edge * 2.2f, -12f, 0f ),
			new Vector3( edge * 0.30f, edge * 0.28f, boardH + edge * 4.6f ), new Color( 0.82f, 0.16f, 1f ) ) );
		_decor.Add( CreateBlock( "GlowRailRight",
			BoardOrigin + new Vector3( boardW * 0.5f + edge * 2.2f, -12f, 0f ),
			new Vector3( edge * 0.30f, edge * 0.28f, boardH + edge * 4.6f ), new Color( 1f, 0.65f, 0.08f ) ) );

		AddPointLight( "AccentL", BoardOrigin + new Vector3( -boardW * 0.72f, -boardH * 0.50f, 118f ), new Color( 1f, 0.35f, 0.95f ), 1000f );
		AddPointLight( "AccentR", BoardOrigin + new Vector3( boardW * 0.72f, -boardH * 0.50f, 118f ), new Color( 0.25f, 0.92f, 1f ), 1000f );
		AddPointLight( "AccentTop", BoardOrigin + new Vector3( 0f, -boardH * 0.62f, boardH * 0.82f ), new Color( 1f, 0.85f, 0.48f ), 1200f );

		_orbitLightL = AddPointLightReturn( "OrbitL", BoardOrigin, new Color( 1f, 0.28f, 0.08f ), 760f );
		_orbitLightR = AddPointLightReturn( "OrbitR", BoardOrigin, new Color( 0.25f, 0.95f, 1f ), 760f );

		_decor.Add( CreateBlock( "Floor",
			BoardOrigin + new Vector3( 0f, depth + 66f, -boardH * 0.5f - edge * 5.0f ),
			new Vector3( boardW * 1.78f, 7f, edge * 7.0f ),
			new Color( 0.018f, 0.020f, 0.035f ) ) );

		AddBottomCabinetLighting( boardW, boardH, edge );
		AddSideCabinetControls( -1, boardW, boardH, edge, new Color( 0.82f, 0.16f, 1f ), new Color( 1f, 0.55f, 0.08f ) );
		AddSideCabinetControls( 1, boardW, boardH, edge, new Color( 1f, 0.55f, 0.08f ), new Color( 0.05f, 0.78f, 1f ) );
	}

	GameObject CreateBlock( string name, Vector3 pos, Vector3 scale, Color tint )
	{
		var go = new GameObject( true, name );
		go.Flags = GameObjectFlags.NotNetworked;
		go.WorldPosition = pos;
		go.WorldScale = scale / 50f; // box.vmdl is ~50 units
		var mr = go.AddComponent<ModelRenderer>();
		mr.Model = _boxModel ?? CandyModel;
		mr.Tint = tint;
		_decor.Add( go );
		return go;
	}

	void AddStageBackdrop( float boardW, float boardH, float edge, float depth )
	{
		float backY = depth + 142f;
		var backTint = new Color( 0.030f, 0.028f, 0.056f );

		CreateBlock( "StageBackdrop",
			BoardOrigin + new Vector3( 0f, backY, 0f ),
			new Vector3( boardW * 3.25f, 10f, boardH * 2.45f ),
			backTint );

		CreateBlock( "StageBackdropCore",
			BoardOrigin + new Vector3( 0f, backY - 6f, 0f ),
			new Vector3( boardW * 1.55f, 6f, boardH * 1.55f ),
			new Color( 0.050f, 0.055f, 0.090f ) );

		CreateBlock( "StageLeftWing",
			BoardOrigin + new Vector3( -boardW * 1.08f, backY - 3f, 0f ),
			new Vector3( edge * 3.2f, 8f, boardH * 2.28f ),
			new Color( 0.052f, 0.032f, 0.085f ) );

		CreateBlock( "StageRightWing",
			BoardOrigin + new Vector3( boardW * 1.08f, backY - 3f, 0f ),
			new Vector3( edge * 3.2f, 8f, boardH * 2.28f ),
			new Color( 0.030f, 0.060f, 0.088f ) );

		CreateBlock( "StageTopLine",
			BoardOrigin + new Vector3( 0f, backY - 8f, boardH * 0.78f ),
			new Vector3( boardW * 2.25f, 4f, edge * 0.18f ),
			new Color( 0.05f, 0.78f, 1f ) );

		CreateBlock( "StageBottomLine",
			BoardOrigin + new Vector3( 0f, backY - 8f, -boardH * 0.78f ),
			new Vector3( boardW * 2.05f, 4f, edge * 0.18f ),
			new Color( 1f, 0.34f, 0.06f ) );

		AddPointLight( "StageWashLeft",
			BoardOrigin + new Vector3( -boardW * 0.95f, depth - 36f, boardH * 0.12f ),
			new Color( 0.75f, 0.18f, 1f ),
			900f );

		AddPointLight( "StageWashRight",
			BoardOrigin + new Vector3( boardW * 0.95f, depth - 36f, -boardH * 0.08f ),
			new Color( 0.08f, 0.78f, 1f ),
			900f );
	}

	void AddSideCabinetControls( int side, float boardW, float boardH, float edge, Color ledTint, Color buttonTint )
	{
		string label = side < 0 ? "Left" : "Right";
		float x = side * (boardW * 0.5f + edge * 1.28f);
		float y = -18.5f;

		_decor.Add( CreateBlock( $"Cabinet{label}Inset",
			BoardOrigin + new Vector3( x, y + 0.4f, 0f ),
			new Vector3( edge * 0.58f, edge * 0.52f, boardH * 0.74f ),
			new Color( 0.075f, 0.045f, 0.115f ) ) );

		_decor.Add( CreateBlock( $"Cabinet{label}LedRail",
			BoardOrigin + new Vector3( x - side * edge * 0.18f, y - 4.2f, 0f ),
			new Vector3( edge * 0.12f, edge * 0.12f, boardH * 0.70f ),
			ledTint ) );

		_decor.Add( CreateBlock( $"Cabinet{label}GoldTrimTop",
			BoardOrigin + new Vector3( x - side * edge * 0.02f, y - 3.4f, boardH * 0.35f ),
			new Vector3( edge * 0.42f, edge * 0.18f, edge * 0.12f ),
			new Color( 1f, 0.72f, 0.22f ) ) );
		_decor.Add( CreateBlock( $"Cabinet{label}GoldTrimBottom",
			BoardOrigin + new Vector3( x - side * edge * 0.02f, y - 3.4f, -boardH * 0.35f ),
			new Vector3( edge * 0.42f, edge * 0.18f, edge * 0.12f ),
			new Color( 1f, 0.72f, 0.22f ) ) );

		AddSideButton( $"Cabinet{label}ButtonTop", BoardOrigin + new Vector3( x - side * edge * 0.03f, y - 5.0f, boardH * 0.22f ), buttonTint, 1.0f );
		AddSideButton( $"Cabinet{label}ButtonBottom", BoardOrigin + new Vector3( x - side * edge * 0.03f, y - 5.0f, -boardH * 0.18f ), Color.Lerp( buttonTint, Color.White, 0.24f ), 0.82f );

		AddPointLight( $"Cabinet{label}SideGlow",
			BoardOrigin + new Vector3( x - side * edge * 0.25f, y - 35f, 0f ),
			ledTint,
			430f );
	}

	void AddBottomCabinetLighting( float boardW, float boardH, float edge )
	{
		float z = -boardH * 0.5f - edge * 1.12f;
		float y = -18.5f;

		_decor.Add( CreateBlock( "CabinetBottomInset",
			BoardOrigin + new Vector3( 0f, y + 0.3f, z ),
			new Vector3( boardW + edge * 2.8f, edge * 0.48f, edge * 0.58f ),
			new Color( 0.070f, 0.042f, 0.105f ) ) );

		_decor.Add( CreateBlock( "CabinetBottomGoldRail",
			BoardOrigin + new Vector3( 0f, y - 3.6f, z - edge * 0.24f ),
			new Vector3( boardW + edge * 2.4f, edge * 0.14f, edge * 0.14f ),
			new Color( 1f, 0.68f, 0.18f ) ) );

		_decor.Add( CreateBlock( "CabinetBottomGlowRail",
			BoardOrigin + new Vector3( 0f, y - 4.8f, z + edge * 0.18f ),
			new Vector3( boardW + edge * 1.7f, edge * 0.10f, edge * 0.12f ),
			new Color( 1f, 0.33f, 0.05f ) ) );

		for ( int i = 0; i < 5; i++ )
		{
			float x = MathX.Lerp( -boardW * 0.42f, boardW * 0.42f, i / 4f );
			var tint = i % 2 == 0 ? new Color( 1f, 0.48f, 0.08f ) : new Color( 0.05f, 0.78f, 1f );
			AddFooterLamp( $"CabinetBottomLamp_{i}", BoardOrigin + new Vector3( x, y - 5.4f, z + edge * 0.02f ), tint );
		}

		AddPointLight( "CabinetBottomGlow",
			BoardOrigin + new Vector3( 0f, y - 38f, z + edge * 0.10f ),
			new Color( 1f, 0.42f, 0.08f ),
			520f );
	}

	void AddFooterLamp( string name, Vector3 pos, Color tint )
	{
		var rim = new GameObject( true, $"{name}Rim" );
		rim.Flags = GameObjectFlags.NotNetworked;
		rim.WorldPosition = pos;
		rim.WorldScale = new Vector3( 0.105f, 0.024f, 0.105f );
		var rimRenderer = rim.AddComponent<ModelRenderer>();
		rimRenderer.Model = _sphereModel ?? _boxModel ?? CandyModel;
		rimRenderer.Tint = new Color( 0.40f, 0.42f, 0.50f );
		_decor.Add( rim );

		var face = new GameObject( true, $"{name}Face" );
		face.Flags = GameObjectFlags.NotNetworked;
		face.WorldPosition = pos + new Vector3( 0f, -1.4f, 0f );
		face.WorldScale = new Vector3( 0.070f, 0.018f, 0.070f );
		var faceRenderer = face.AddComponent<ModelRenderer>();
		faceRenderer.Model = _sphereModel ?? _boxModel ?? CandyModel;
		faceRenderer.Tint = tint;
		_decor.Add( face );
	}

	void AddSideButton( string name, Vector3 pos, Color tint, float size )
	{
		var rim = new GameObject( true, $"{name}Rim" );
		rim.Flags = GameObjectFlags.NotNetworked;
		rim.WorldPosition = pos;
		rim.WorldScale = new Vector3( 0.26f * size, 0.070f * size, 0.26f * size );
		var rimRenderer = rim.AddComponent<ModelRenderer>();
		rimRenderer.Model = _sphereModel ?? _boxModel ?? CandyModel;
		rimRenderer.Tint = new Color( 0.46f, 0.48f, 0.58f );
		_decor.Add( rim );

		var face = new GameObject( true, $"{name}Face" );
		face.Flags = GameObjectFlags.NotNetworked;
		face.WorldPosition = pos + new Vector3( 0f, -2.4f, 0f );
		face.WorldScale = new Vector3( 0.18f * size, 0.050f * size, 0.18f * size );
		var faceRenderer = face.AddComponent<ModelRenderer>();
		faceRenderer.Model = _sphereModel ?? _boxModel ?? CandyModel;
		faceRenderer.Tint = tint;
		_decor.Add( face );

		var shine = new GameObject( true, $"{name}Shine" );
		shine.Flags = GameObjectFlags.NotNetworked;
		shine.WorldPosition = pos + new Vector3( -3.4f * size, -4.2f, 4.2f * size );
		shine.WorldScale = new Vector3( 0.055f * size, 0.018f * size, 0.032f * size );
		var shineRenderer = shine.AddComponent<ModelRenderer>();
		shineRenderer.Model = _sphereModel ?? _boxModel ?? CandyModel;
		shineRenderer.Tint = new Color( 1f, 1f, 1f, 0.82f );
		_decor.Add( shine );
	}

	void AddPointLight( string name, Vector3 pos, Color color, float radius )
	{
		var go = new GameObject( true, name );
		go.Flags = GameObjectFlags.NotNetworked;
		go.WorldPosition = pos;
		var l = go.AddComponent<PointLight>();
		l.LightColor = color;
		l.Radius = radius;
		_decor.Add( go );
	}

	GameObject AddPointLightReturn( string name, Vector3 pos, Color color, float radius )
	{
		var go = new GameObject( true, name );
		go.Flags = GameObjectFlags.NotNetworked;
		go.WorldPosition = pos;
		var l = go.AddComponent<PointLight>();
		l.LightColor = color;
		l.Radius = radius;
		_decor.Add( go );
		return go;
	}

	// -------------------- board --------------------

	void BuildFreshBoard()
	{
		ClearBoardObjects();

		_types = new int[Width, Height];
		_specials = new SpecialKind[Width, Height];
		_visuals = new CandyVisual[Width, Height];

		int attempts = 0;
		do
		{
			ClearBoardObjects();
			_types = new int[Width, Height];
			_specials = new SpecialKind[Width, Height];
			_visuals = new CandyVisual[Width, Height];
			for ( int y = 0; y < Height; y++ )
			{
				for ( int x = 0; x < Width; x++ )
				{
					int type = PickTypeAvoidingImmediateMatch( x, y );
					SpawnCandy( x, y, type, instant: true );
				}
			}
		}
		while ( !HasPossibleMove() && ++attempts < 20 );

		_cursorX = Width / 2;
		_cursorY = Height / 2;
		_hasSelection = false;
		_score = 0;
		_moves = 0;
		_objectiveProgress = 0;
		_phase = Phase.Idle;
		_idleTime = 0f;
		_hasHint = false;
	}

	void SpawnCandy( int x, int y, int type, bool instant )
	{
		_types[x, y] = type;

		var anchor = CellToWorld( x, y );
		var go = new GameObject( true, $"Piece_{x}_{y}" );
		go.Flags = GameObjectFlags.NotNetworked;
		go.WorldRotation = Rotation.FromYaw( Game.Random.Float( -7f, 7f ) );
		var renderer = go.AddComponent<ModelRenderer>();
		renderer.Model = ModelForType( type );
		var pieceColor = PieceColor( type );
		renderer.Tint = Color.Lerp( pieceColor, Color.Black, 0.28f );

		var rim = AddPiecePart( go, $"PieceRim_{x}_{y}", _sphereModel ?? _boxModel,
			new Vector3( 0f, -4.0f, 0f ), new Vector3( 1.08f, 0.24f, 1.08f ), Color.Lerp( pieceColor, Color.Black, 0.42f ) );
		var face = AddPiecePart( go, $"PieceFace_{x}_{y}", _sphereModel ?? _boxModel,
			new Vector3( 0f, -9.5f, 0.6f ), new Vector3( 0.76f, 0.14f, 0.76f ), Color.Lerp( pieceColor, Color.White, 0.20f ) );
		var halo = AddPiecePart( go, $"PieceHalo_{x}_{y}", _sphereModel ?? _boxModel,
			new Vector3( 0f, -5.4f, 0f ), Vector3.Zero, new Color( 1f, 0.95f, 0.22f, 0.82f ) );
		var guide = AddPiecePart( go, $"PieceGuide_{x}_{y}", _sphereModel ?? _boxModel,
			new Vector3( 0f, -5.0f, 0f ), Vector3.Zero, new Color( 0.22f, 0.92f, 1f, 0.62f ) );
		var accent = AddPiecePart( go, $"PieceAccent_{x}_{y}", _boxModel ?? _sphereModel,
			new Vector3( 0f, -13.0f, 7.0f ), new Vector3( 0.34f, 0.034f, 0.048f + (type % 3) * 0.020f ), Color.Lerp( pieceColor, Color.White, 0.70f ) );
		accent.LocalRotation = Rotation.FromYaw( type * 28f );
		var symbolA = AddPiecePart( go, $"PieceSymbolA_{x}_{y}", _boxModel ?? _sphereModel,
			new Vector3( 0f, -18.0f, 0.0f ), Vector3.Zero, new Color( 0.02f, 0.025f, 0.035f, 1f ) );
		var symbolB = AddPiecePart( go, $"PieceSymbolB_{x}_{y}", _boxModel ?? _sphereModel,
			new Vector3( 0f, -18.4f, 0.0f ), Vector3.Zero, new Color( 1f, 1f, 1f, 1f ) );
		var shine = AddPiecePart( go, $"PieceShine_{x}_{y}", _sphereModel ?? _boxModel,
			new Vector3( -8.5f, -14.0f, 9.0f ), new Vector3( 0.16f, 0.026f, 0.080f ), new Color( 1f, 1f, 1f, 0.86f ) );
		var badge = AddPiecePart( go, $"PieceSpecial_{x}_{y}", _boxModel ?? _sphereModel,
			new Vector3( 0f, -15.0f, -8.0f ), Vector3.Zero, new Color( 1f, 0.82f, 0.18f, 1f ) );

		var v = new CandyVisual
		{
			Go = go,
			Renderer = renderer,
			Shine = shine,
			ShineRenderer = shine.GetComponent<ModelRenderer>(),
			Rim = rim,
			RimRenderer = rim.GetComponent<ModelRenderer>(),
			Face = face,
			FaceRenderer = face.GetComponent<ModelRenderer>(),
			Halo = halo,
			HaloRenderer = halo.GetComponent<ModelRenderer>(),
			Guide = guide,
			GuideRenderer = guide.GetComponent<ModelRenderer>(),
			Accent = accent,
			AccentRenderer = accent.GetComponent<ModelRenderer>(),
			SymbolA = symbolA,
			SymbolARenderer = symbolA.GetComponent<ModelRenderer>(),
			SymbolB = symbolB,
			SymbolBRenderer = symbolB.GetComponent<ModelRenderer>(),
			SpecialBadge = badge,
			SpecialBadgeRenderer = badge.GetComponent<ModelRenderer>(),
			Type = type,
			Special = SpecialKind.None,
			Anchor = anchor,
			BobPhase = Game.Random.Float( 0f, MathF.PI * 2f ),
			Spawning = !instant,
			SpawnTimer = instant ? 0f : 0.25f,
		};

		if ( instant )
		{
			go.WorldPosition = anchor;
			go.WorldScale = Vector3.One * CandyScale * PieceShape( type );
		}
		else
		{
			go.WorldPosition = anchor + new Vector3( 0f, 0f, Height * CellSpacing * 0.6f + Game.Random.Float( 0f, 60f ) );
			go.WorldScale = Vector3.One * CandyScale * PieceShape( type ) * 0.4f;
		}

		_visuals[x, y] = v;
		ApplyPieceVisualStyle( v );
	}

	GameObject AddPiecePart( GameObject parent, string name, Model model, Vector3 localPosition, Vector3 localScale, Color tint )
	{
		var part = new GameObject( true, name );
		part.Flags = GameObjectFlags.NotNetworked;
		part.SetParent( parent, false );
		part.LocalPosition = localPosition;
		part.LocalScale = localScale;
		var mr = part.AddComponent<ModelRenderer>();
		mr.Model = model;
		mr.Tint = tint;
		return part;
	}

	void ApplyPieceVisualStyle( CandyVisual v )
	{
		if ( v == null ) return;

		var color = PieceColor( v.Type );
		if ( v.Renderer != null ) v.Renderer.Tint = Color.Lerp( color, Color.Black, 0.28f );
		if ( v.RimRenderer != null ) v.RimRenderer.Tint = Color.Lerp( color, Color.Black, 0.42f );
		if ( v.FaceRenderer != null ) v.FaceRenderer.Tint = Color.Lerp( color, Color.White, 0.20f );
		if ( v.AccentRenderer != null ) v.AccentRenderer.Tint = Color.Lerp( color, Color.White, 0.70f );

		ApplyColourBlindSymbol( v );
	}

	void ApplyColourBlindSymbol( CandyVisual v )
	{
		if ( v?.SymbolA == null || v.SymbolB == null )
			return;

		if ( !ColourBlindMode )
		{
			v.SymbolA.LocalScale = Vector3.Zero;
			v.SymbolB.LocalScale = Vector3.Zero;
			return;
		}

		v.SymbolA.LocalRotation = Rotation.Identity;
		v.SymbolB.LocalRotation = Rotation.Identity;
		v.SymbolA.LocalScale = Vector3.Zero;
		v.SymbolB.LocalScale = Vector3.Zero;
		if ( v.SymbolARenderer != null ) v.SymbolARenderer.Tint = new Color( 0.02f, 0.025f, 0.035f, 1f );
		if ( v.SymbolBRenderer != null ) v.SymbolBRenderer.Tint = new Color( 1f, 1f, 1f, 1f );

		switch ( v.Type )
		{
			case 0:
				v.SymbolB.LocalScale = new Vector3( 0.19f, 0.036f, 0.70f );
				v.SymbolA.LocalScale = new Vector3( 0.11f, 0.040f, 0.58f );
				break;
			case 1:
				v.SymbolB.LocalScale = new Vector3( 0.70f, 0.036f, 0.19f );
				v.SymbolA.LocalScale = new Vector3( 0.58f, 0.040f, 0.11f );
				break;
			case 2:
				v.SymbolB.LocalScale = new Vector3( 0.68f, 0.036f, 0.17f );
				v.SymbolB.LocalRotation = Rotation.FromYaw( 45f );
				v.SymbolA.LocalScale = new Vector3( 0.56f, 0.040f, 0.10f );
				v.SymbolA.LocalRotation = Rotation.FromYaw( 45f );
				break;
			case 3:
				v.SymbolA.LocalScale = new Vector3( 0.60f, 0.040f, 0.12f );
				v.SymbolB.LocalScale = new Vector3( 0.12f, 0.042f, 0.60f );
				break;
			case 4:
				v.SymbolB.LocalScale = new Vector3( 0.42f, 0.036f, 0.42f );
				v.SymbolA.LocalScale = new Vector3( 0.30f, 0.040f, 0.30f );
				break;
			default:
				v.SymbolA.LocalScale = new Vector3( 0.60f, 0.040f, 0.12f );
				v.SymbolA.LocalRotation = Rotation.FromYaw( -45f );
				v.SymbolB.LocalScale = new Vector3( 0.60f, 0.042f, 0.12f );
				v.SymbolB.LocalRotation = Rotation.FromYaw( 45f );
				break;
		}
	}

	void RefreshAllPieceVisuals()
	{
		if ( _visuals == null ) return;

		for ( int y = 0; y < _visuals.GetLength( 1 ); y++ )
			for ( int x = 0; x < _visuals.GetLength( 0 ); x++ )
				ApplyPieceVisualStyle( _visuals[x, y] );
	}

	void RemoveCandyAt( int x, int y )
	{
		var v = _visuals[x, y];
		v?.Go?.Destroy();
		_visuals[x, y] = null;
	}

	Vector3 CellToWorld( int x, int y )
	{
		float ox = (x - (Width - 1) * 0.5f) * CellSpacing;
		float oz = (y - (Height - 1) * 0.5f) * CellSpacing;
		return BoardOrigin + new Vector3( ox, 0f, oz );
	}

	// -------------------- input --------------------

	bool TryHitBoardCell( out int cx, out int cy )
	{
		cx = cy = -1;
		if ( !TryGetMainCamera( out var cam ) ) return false;

		var ray = cam.ScreenPixelToRay( Mouse.Position );
		float planeY = BoardOrigin.y;
		float denom = ray.Forward.y;
		if ( MathF.Abs( denom ) < 0.0001f ) return false;
		float t = (planeY - ray.Position.y) / denom;
		if ( t <= 0 ) return false;

		var hit = ray.Position + ray.Forward * t;
		float localX = hit.x - BoardOrigin.x + (Width - 1) * 0.5f * CellSpacing;
		float localZ = hit.z - BoardOrigin.z + (Height - 1) * 0.5f * CellSpacing;
		int tx = (int)MathF.Floor( localX / CellSpacing + 0.5f );
		int ty = (int)MathF.Floor( localZ / CellSpacing + 0.5f );
		if ( tx < 0 || tx >= Width || ty < 0 || ty >= Height ) return false;
		cx = tx; cy = ty;
		return true;
	}

	void HandleMouse()
	{
		bool overBoard = TryHitBoardCell( out int cx, out int cy );
		if ( overBoard )
		{
			if ( cx != _cursorX || cy != _cursorY )
				ResetIdle();
			_cursorX = cx;
			_cursorY = cy;
		}

		bool mouseLeft = Input.Down( "attack1" );
		bool justPressed = mouseLeft && !_prevMouseLeft;
		bool justReleased = !mouseLeft && _prevMouseLeft;
		_prevMouseLeft = mouseLeft;

		if ( overBoard && (cx != _cursorX || cy != _cursorY) )
			ResetIdle();

		if ( justPressed && overBoard )
		{
			ResetIdle();
			_dragging = true;
			_dragSwapped = false;
			_dragStartX = cx;
			_dragStartY = cy;
			_selectedX = cx;
			_selectedY = cy;
			_hasSelection = true;
			SpawnShockwave( CellToWorld( cx, cy ), new Color( 1f, 1f, 1f ), 0.28f, 26f );
			return;
		}

		if ( _dragging && mouseLeft && overBoard && !_dragSwapped )
		{
			if ( (cx != _dragStartX || cy != _dragStartY) && AreAdjacent( _dragStartX, _dragStartY, cx, cy ) )
			{
				BeginSwap( _dragStartX, _dragStartY, cx, cy );
				_dragSwapped = true;
				_hasSelection = false;
			}
		}

		if ( justReleased )
		{
			_dragging = false;
		}
	}

	void HandleInput()
	{
		if ( Pressed( "r" ) || ActionPressed( "reload" ) )
		{
			BuildFreshBoard();
			Log.Info( "Board reset." );
			return;
		}

		if ( PressedEither( "ArrowUp", "w" ) ) _cursorY = Math.Min( Height - 1, _cursorY + 1 );
		if ( PressedEither( "ArrowDown", "s" ) ) _cursorY = Math.Max( 0, _cursorY - 1 );
		if ( PressedEither( "ArrowLeft", "a" ) ) _cursorX = Math.Max( 0, _cursorX - 1 );
		if ( PressedEither( "ArrowRight", "d" ) ) _cursorX = Math.Min( Width - 1, _cursorX + 1 );
		HandleControllerCursor();

		if ( Pressed( "Space" ) || Pressed( "Enter" ) || Pressed( "e" ) || ActionPressed( "jump", "use", "attack1" ) )
			HandleSelectKey();

		if ( _hasSelection && ActionPressed( "attack2", "duck" ) )
			_hasSelection = false;
	}

	void HandleControllerCursor()
	{
		if ( !Input.UsingController && Input.ControllerCount <= 0 )
			return;

		if ( _controllerMoveCooldown > 0f )
			return;

		var move = Input.AnalogMove;
		float ax = move.x;
		float ay = move.y;
		const float threshold = 0.45f;
		bool moved = false;

		if ( MathF.Abs( ax ) > MathF.Abs( ay ) && MathF.Abs( ax ) > threshold )
		{
			_cursorX = Math.Clamp( _cursorX + (ax > 0f ? 1 : -1), 0, Width - 1 );
			moved = true;
		}
		else if ( MathF.Abs( ay ) > threshold )
		{
			_cursorY = Math.Clamp( _cursorY + (ay > 0f ? 1 : -1), 0, Height - 1 );
			moved = true;
		}

		if ( moved )
			_controllerMoveCooldown = 0.16f;
	}

	void HandleSelectKey()
	{
		if ( !_hasSelection )
		{
			_selectedX = _cursorX;
			_selectedY = _cursorY;
			_hasSelection = true;
			SpawnShockwave( CellToWorld( _cursorX, _cursorY ), new Color( 1f, 1f, 1f ), 0.28f, 26f );
			return;
		}
		if ( _selectedX == _cursorX && _selectedY == _cursorY )
		{
			_hasSelection = false;
			return;
		}
		if ( !AreAdjacent( _selectedX, _selectedY, _cursorX, _cursorY ) )
		{
			SpawnShockwave( CellToWorld( _cursorX, _cursorY ), new Color( 0.22f, 0.92f, 1f ), 0.22f, 18f );
			_selectedX = _cursorX;
			_selectedY = _cursorY;
			return;
		}
		BeginSwap( _selectedX, _selectedY, _cursorX, _cursorY );
		_hasSelection = false;
	}

	// -------------------- hint system --------------------

	void ResetIdle()
	{
		_idleTime = 0f;
		_hasHint = false;
	}

	void UpdateHint()
	{
		// Hints only make sense while the player can actually act on the board.
		if ( CurrentState != GameState.Playing || _phase != Phase.Idle || _hasSelection )
		{
			_idleTime = 0f;
			_hasHint = false;
			return;
		}

		_idleTime += Time.Delta;
		if ( _idleTime < HintDelay )
		{
			_hasHint = false;
			return;
		}

		if ( !_hasHint )
			_hasHint = FindHintMove( out _hintAX, out _hintAY, out _hintBX, out _hintBY );
	}

	bool FindHintMove( out int ax, out int ay, out int bx, out int by )
	{
		ax = ay = bx = by = -1;
		if ( _types == null ) return false;

		// Prefer activating an existing special if one is on the board.
		for ( int y = 0; y < Height; y++ )
		{
			for ( int x = 0; x < Width; x++ )
			{
				if ( _specials != null && _specials[x, y] != SpecialKind.None )
				{
					if ( x + 1 < Width ) { ax = x; ay = y; bx = x + 1; by = y; return true; }
					if ( y + 1 < Height ) { ax = x; ay = y; bx = x; by = y + 1; return true; }
				}
			}
		}

		for ( int y = 0; y < Height; y++ )
		{
			for ( int x = 0; x < Width; x++ )
			{
				if ( x + 1 < Width && SwapWouldMatch( x, y, x + 1, y ) ) { ax = x; ay = y; bx = x + 1; by = y; return true; }
				if ( y + 1 < Height && SwapWouldMatch( x, y, x, y + 1 ) ) { ax = x; ay = y; bx = x; by = y + 1; return true; }
			}
		}
		return false;
	}

	// -------------------- phase machine --------------------

	void BeginSwap( int x1, int y1, int x2, int y2 )
	{
		_hasEverSwapped = true;
		ResetIdle();
		PlayUiSound( SwapSound, 0.55f, 0.95f + Game.Random.Float( 0f, 0.12f ) );
		_swapAX = x1; _swapAY = y1; _swapBX = x2; _swapBY = y2;
		StartSwapTween( x1, y1, x2, y2 );
		_phase = Phase.SwapForward;
		_phaseTimer = 0f;
	}

	void StartSwapTween( int x1, int y1, int x2, int y2 )
	{
		var a = _visuals[x1, y1];
		var b = _visuals[x2, y2];
		if ( a == null || b == null ) return;

		var posA = CellToWorld( x1, y1 );
		var posB = CellToWorld( x2, y2 );
		a.SwapFrom = a.Go.WorldPosition; a.SwapTo = posB; a.SwapT = 0f; a.Swapping = true; a.Anchor = posB;
		b.SwapFrom = b.Go.WorldPosition; b.SwapTo = posA; b.SwapT = 0f; b.Swapping = true; b.Anchor = posA;

		// Update the logical board now so subsequent logic is correct
		(_types[x1, y1], _types[x2, y2]) = (_types[x2, y2], _types[x1, y1]);
		(_specials[x1, y1], _specials[x2, y2]) = (_specials[x2, y2], _specials[x1, y1]);
		(_visuals[x1, y1], _visuals[x2, y2]) = (_visuals[x2, y2], _visuals[x1, y1]);
	}

	void UpdatePhase()
	{
		_phaseTimer += Time.Delta;

		switch ( _phase )
		{
			case Phase.Idle: return;

			case Phase.SwapForward:
				if ( _phaseTimer >= SwapDuration )
				{
					var matches = FindMatches();
					var specialActivation = GetSpecialActivationForSwap();
					foreach ( var idx in specialActivation )
						matches.Add( idx );

					if ( matches.Count == 0 )
					{
						PlayUiSound( DeniedSound, 0.55f, 0.82f );
						SpawnShockwave( (CellToWorld( _swapAX, _swapAY ) + CellToWorld( _swapBX, _swapBY )) * 0.5f, new Color( 1f, 0.18f, 0.06f ), 0.34f, 52f );
						PunchCamera( 1.0f, 0.85f );
						// Swap back
						StartSwapTween( _swapAX, _swapAY, _swapBX, _swapBY );
						_phase = Phase.SwapBack;
						_phaseTimer = 0f;
					}
					else
					{
						_moves++;
						_comboCount = 1;
						BeginClear( matches );
					}
				}
				break;

			case Phase.SwapBack:
				if ( _phaseTimer >= SwapDuration )
				{
					_phase = Phase.Idle;
					_phaseTimer = 0f;
				}
				break;

			case Phase.Clearing:
				if ( AllClearAnimsDone() )
				{
					DestroyClearedCandies();
					_phase = Phase.Collapsing;
					_phaseTimer = 0f;
					CollapseColumns();
				}
				break;

			case Phase.Collapsing:
				if ( AllAtAnchor() )
				{
					_phase = Phase.Refilling;
					_phaseTimer = 0f;
					Refill();
				}
				break;

			case Phase.Refilling:
				if ( AllAtAnchor() )
				{
					_phase = Phase.Pause;
					_phaseTimer = 0f;
				}
				break;

			case Phase.Pause:
				if ( _phaseTimer >= CascadePause )
				{
					var matches = FindMatches();
					if ( matches.Count > 0 )
					{
						_comboCount++;
						BeginClear( matches );
					}
					else
					{
						_phase = Phase.Idle;
						CheckLevelEnd();
						if ( CurrentState == GameState.Playing && !HasPossibleMove() )
						{
							// Levels rescue the player with a reshuffle; endless ends
							// precisely when there is nothing left to match.
							if ( IsEndless )
								EndEndlessRun();
							else
								ReshuffleBoardForAvailableMove();
						}
						Log.Info( $"Score: {_score} | Moves: {_moves}" );
					}
				}
				break;
		}
	}

	void BeginClear( HashSet<int> matches )
	{
		bool activatingSpecial = ContainsSpecial( matches );
		int triggeredSpecials = CountSpecialsInCells( matches );
		var rewardKind = SpecialKind.None;
		int rewardIdx = -1;
		if ( !activatingSpecial )
			TryChooseSpecialReward( matches, out rewardIdx, out rewardKind );

		ExpandSpecialClears( matches );
		if ( rewardIdx >= 0 && matches.Count > 3 )
			matches.Remove( rewardIdx );

		TrackObjectiveProgress( matches, rewardKind, triggeredSpecials );
		_score += matches.Count * 10 * _comboCount + (activatingSpecial ? 75 * _comboCount : 0);
		PlayUiSound( _comboCount > 1 ? ComboSound : ClearSound, MathF.Min( 1f, 0.55f + _comboCount * 0.12f ), 0.95f + _comboCount * 0.06f );
		var center = Vector3.Zero;
		int centerCount = 0;

		if ( rewardIdx >= 0 && rewardKind != SpecialKind.None )
		{
			int sx = rewardIdx % Width;
			int sy = rewardIdx / Width;
			SetSpecialAt( sx, sy, rewardKind );
			var specialVisual = _visuals[sx, sy];
			if ( specialVisual != null )
			{
				SpawnShockwave( specialVisual.Go.WorldPosition, new Color( 1f, 0.88f, 0.18f ), 0.52f, 72f );
				SpawnFireColumn( specialVisual.Go.WorldPosition, 0.65f );
			}
		}

		foreach ( var idx in matches )
		{
			int x = idx % Width;
			int y = idx / Width;
			var v = _visuals[x, y];
			if ( v == null ) continue;
			v.Clearing = true;
			v.ClearTimer = ClearDuration;
			_specials[x, y] = SpecialKind.None;
			var clearColor = PieceColor( v.Type );
			SpawnBurstAt( v.Go.WorldPosition, clearColor );
			SpawnShockwave( v.Go.WorldPosition, clearColor, 0.34f, 38f + _comboCount * 10f );
			center += v.Go.WorldPosition;
			centerCount++;
		}
		if ( centerCount > 0 )
		{
			SpawnFireColumn( center / centerCount, MathF.Min( 1.8f, 1f + matches.Count * 0.05f + _comboCount * 0.18f ) );
			SpawnShockwave( center / centerCount, Color.White, 0.48f, 70f + matches.Count * 4f );
		}
		// Camera punch scales with combo size and chain depth
		float punchFov = MathF.Min( 8f, 2.5f + matches.Count * 0.3f + _comboCount * 1.2f );
		float punchShake = MathF.Min( 6f, 1.5f + _comboCount * 0.8f );
		PunchCamera( punchFov, punchShake );
		_phase = Phase.Clearing;
		_phaseTimer = 0f;
	}

	HashSet<int> GetSpecialActivationForSwap()
	{
		var result = new HashSet<int>();
		AddSpecialCells( _swapAX, _swapAY, result );
		AddSpecialCells( _swapBX, _swapBY, result );
		return result;
	}

	bool ContainsSpecial( HashSet<int> cells )
	{
		foreach ( var idx in cells )
		{
			int x = idx % Width;
			int y = idx / Width;
			if ( InBounds( x, y ) && _specials[x, y] != SpecialKind.None )
				return true;
		}
		return false;
	}

	int CountSpecialsInCells( HashSet<int> cells )
	{
		int count = 0;
		foreach ( var idx in cells )
		{
			int x = idx % Width;
			int y = idx / Width;
			if ( InBounds( x, y ) && _specials[x, y] != SpecialKind.None )
				count++;
		}
		return count;
	}

	void TrackObjectiveProgress( HashSet<int> clearedCells, SpecialKind rewardKind, int triggeredSpecials )
	{
		if ( !HasSecondaryObjective )
			return;

		var level = CurrentLevelDefinition;
		int add = 0;
		switch ( level.ObjectiveKind )
		{
			case ColourBreakObjectiveKind.ClearPieces:
				add = clearedCells.Count;
				break;
			case ColourBreakObjectiveKind.ClearColour:
				foreach ( var idx in clearedCells )
				{
					int x = idx % Width;
					int y = idx / Width;
					if ( InBounds( x, y ) && _types[x, y] == level.ObjectiveType )
						add++;
				}
				break;
			case ColourBreakObjectiveKind.MakeSpecials:
				add = rewardKind == SpecialKind.None ? 0 : 1;
				break;
			case ColourBreakObjectiveKind.TriggerSpecials:
				add = triggeredSpecials;
				break;
			case ColourBreakObjectiveKind.ChainCombos:
				add = _comboCount > 1 ? 1 : 0;
				break;
		}

		if ( add > 0 )
			_objectiveProgress = Math.Min( level.ObjectiveTarget, _objectiveProgress + add );
	}

	void ExpandSpecialClears( HashSet<int> cells )
	{
		bool changed;
		int guard = 0;
		do
		{
			changed = false;
			var snapshot = new List<int>( cells );
			foreach ( var idx in snapshot )
			{
				int before = cells.Count;
				AddSpecialCells( idx % Width, idx / Width, cells );
				if ( cells.Count > before ) changed = true;
			}
		}
		while ( changed && ++guard < Width * Height );
	}

	void AddSpecialCells( int x, int y, HashSet<int> cells )
	{
		if ( !InBounds( x, y ) ) return;
		switch ( _specials[x, y] )
		{
			case SpecialKind.RowBlast:
				for ( int cx = 0; cx < Width; cx++ ) cells.Add( y * Width + cx );
				break;
			case SpecialKind.ColumnBlast:
				for ( int cy = 0; cy < Height; cy++ ) cells.Add( cy * Width + x );
				break;
			case SpecialKind.Bomb:
				for ( int cy = y - 2; cy <= y + 2; cy++ )
					for ( int cx = x - 2; cx <= x + 2; cx++ )
						if ( InBounds( cx, cy ) ) cells.Add( cy * Width + cx );
				break;
		}
	}

	void TryChooseSpecialReward( HashSet<int> matches, out int rewardIdx, out SpecialKind kind )
	{
		rewardIdx = -1;
		kind = SpecialKind.None;
		if ( matches.Count < 4 ) return;

		int preferred = matches.Contains( _swapBX + _swapBY * Width ) ? _swapBX + _swapBY * Width :
			matches.Contains( _swapAX + _swapAY * Width ) ? _swapAX + _swapAY * Width :
			matches.First();

		rewardIdx = preferred;
		kind = matches.Count >= 5 ? SpecialKind.Bomb : SpecialKind.RowBlast;

		int x = preferred % Width;
		int y = preferred / Width;
		int sameRow = 0;
		int sameColumn = 0;
		foreach ( var idx in matches )
		{
			if ( idx / Width == y ) sameRow++;
			if ( idx % Width == x ) sameColumn++;
		}
		if ( matches.Count < 5 )
			kind = sameColumn > sameRow ? SpecialKind.ColumnBlast : SpecialKind.RowBlast;
	}

	void SetSpecialAt( int x, int y, SpecialKind kind )
	{
		if ( !InBounds( x, y ) ) return;
		_specials[x, y] = kind;
		var v = _visuals[x, y];
		if ( v == null ) return;
		v.Special = kind;
		UpdateSpecialVisual( v );
	}

	void UpdateSpecialVisual( CandyVisual v )
	{
		if ( v?.SpecialBadgeRenderer == null ) return;
		v.SpecialBadgeRenderer.Tint = v.Special switch
		{
			SpecialKind.RowBlast => new Color( 1f, 0.86f, 0.12f, 1f ),
			SpecialKind.ColumnBlast => new Color( 0.22f, 0.92f, 1f, 1f ),
			SpecialKind.Bomb => new Color( 1f, 0.20f, 0.06f, 1f ),
			_ => new Color( 1f, 1f, 1f, 0f )
		};
		v.SpecialBadge.LocalRotation = v.Special switch
		{
			SpecialKind.ColumnBlast => Rotation.FromYaw( 90f ),
			SpecialKind.Bomb => Rotation.FromYaw( 45f ),
			_ => Rotation.Identity
		};
		v.SpecialBadge.LocalScale = v.Special == SpecialKind.Bomb
			? new Vector3( 0.22f, 0.04f, 0.22f )
			: v.Special == SpecialKind.None
				? Vector3.Zero
				: new Vector3( 0.44f, 0.035f, 0.06f );
	}

	void SpawnBurstAt( Vector3 pos, Color color )
	{
		if ( !EffectsEnabled ) return;
		int count = 24;
		for ( int i = 0; i < count; i++ )
		{
			var go = RentBurstObject( "Colour Spark" );
			go.WorldPosition = pos + new Vector3( Game.Random.Float( -4f, 4f ), Game.Random.Float( -3f, 3f ), Game.Random.Float( -4f, 4f ) );
			go.WorldRotation = Rotation.Random;
			bool hot = i % 3 != 0;
			float scale = hot ? Game.Random.Float( 0.09f, 0.18f ) : Game.Random.Float( 0.045f, 0.10f );
			go.WorldScale = hot ? new Vector3( scale * 0.75f, scale * 0.22f, scale * 1.9f ) : Vector3.One * scale;
			var mr = go.GetComponent<ModelRenderer>();
			mr.Model = hot ? (_boxModel ?? _sphereModel) : ((i % 2 == 0 && _sphereModel != null) ? _sphereModel : _boxModel);
			var fireTint = i % 4 == 0 ? new Color( 1f, 0.18f, 0.02f ) : new Color( 1f, 0.70f, 0.10f );
			mr.Tint = hot ? Color.Lerp( fireTint, color, 0.28f ) : Color.Lerp( color, Color.White, 0.45f );

			float ang = (i / (float)count) * MathF.PI * 2f + Game.Random.Float( -0.4f, 0.4f );
			float speed = hot ? Game.Random.Float( 180f, 420f ) : Game.Random.Float( 320f, 620f );
			var vel = new Vector3( MathF.Cos( ang ) * speed, Game.Random.Float( -180f, -30f ), Game.Random.Float( 260f, 650f ) + MathF.Sin( ang ) * speed * 0.25f );

			_bursts.Add( new Burst
			{
				Go = go,
				Renderer = mr,
				Velocity = vel,
				Life = hot ? 0.55f : 0.82f,
				MaxLife = hot ? 0.55f : 0.82f,
				BaseTint = mr.Tint,
				StartScale = scale,
			} );
		}
	}

	void SpawnFireColumn( Vector3 pos, float intensity )
	{
		if ( !EffectsEnabled ) return;
		int count = (int)(18 * intensity);
		for ( int i = 0; i < count; i++ )
		{
			var go = RentBurstObject( "Match Fire" );
			go.WorldPosition = pos + new Vector3( Game.Random.Float( -18f, 18f ), Game.Random.Float( -8f, 4f ), Game.Random.Float( -8f, 8f ) );
			go.WorldRotation = Rotation.Random;
			float scale = Game.Random.Float( 0.12f, 0.28f ) * intensity;
			go.WorldScale = new Vector3( scale * 0.75f, scale * 0.16f, scale * Game.Random.Float( 2.2f, 3.8f ) );
			var mr = go.GetComponent<ModelRenderer>();
			mr.Model = _boxModel ?? _sphereModel;
			mr.Tint = i % 3 == 0 ? new Color( 1f, 0.95f, 0.35f, 0.95f ) : new Color( 1f, 0.28f, 0.03f, 0.85f );

			_bursts.Add( new Burst
			{
				Go = go,
				Renderer = mr,
				Velocity = new Vector3( Game.Random.Float( -80f, 80f ), Game.Random.Float( -60f, 10f ), Game.Random.Float( 520f, 920f ) * intensity ),
				Life = Game.Random.Float( 0.42f, 0.72f ),
				MaxLife = 0.72f,
				BaseTint = mr.Tint,
				StartScale = scale,
			} );
		}

		var lightGo = new GameObject( true, "Match Fire Light" );
		lightGo.Flags = GameObjectFlags.NotNetworked;
		lightGo.WorldPosition = pos + new Vector3( 0f, -18f, 22f );
		var light = lightGo.AddComponent<PointLight>();
		light.LightColor = new Color( 1f, 0.42f, 0.08f );
		light.Radius = 360f * intensity;
		_bursts.Add( new Burst
		{
			Go = lightGo,
			Renderer = null,
			Velocity = Vector3.Zero,
			Life = 0.34f,
			MaxLife = 0.34f,
			BaseTint = Color.White,
			StartScale = 1f,
		} );
	}

	void AnimateBursts()
	{
		float dt = Time.Delta;
		for ( int i = _bursts.Count - 1; i >= 0; i-- )
		{
			var b = _bursts[i];
			b.Life -= dt;
			if ( b.Life <= 0f || b.Go == null )
			{
				// Renderer bursts go back to the pool; the lone PointLight burst is destroyed.
				if ( b.Go != null && b.Renderer != null )
					ReturnBurstObject( b.Go );
				else
					b.Go?.Destroy();
				_bursts.RemoveAt( i );
				continue;
			}

			// gravity + drag
			b.Velocity.z -= 1400f * dt;
			b.Velocity *= 1f - dt * 1.4f;
			b.Go.WorldPosition += b.Velocity * dt;
			b.Go.WorldRotation *= Rotation.From( new Angles( dt * 540f, dt * 720f, dt * 360f ) );

			float k = b.Life / b.MaxLife;
			if ( b.Renderer != null )
			{
				b.Go.WorldScale *= 1f + dt * 0.55f;
				var t = b.BaseTint;
				t.a = k;
				b.Renderer.Tint = t;
			}
		}
	}

	void SpawnShockwave( Vector3 pos, Color color, float life, float radius )
	{
		if ( !EffectsEnabled ) return;
		var go = new GameObject( true, "ColourBreak Shockwave" );
		go.Flags = GameObjectFlags.NotNetworked;
		go.WorldPosition = pos + new Vector3( 0f, -3f, 0f );
		go.WorldScale = new Vector3( 0.08f, 0.01f, 0.08f );
		var mr = go.AddComponent<ModelRenderer>();
		mr.Model = _sphereModel ?? _boxModel;
		mr.Tint = new Color( color.r, color.g, color.b, 0.55f );
		_shockwaves.Add( new Shockwave
		{
			Go = go,
			Renderer = mr,
			Tint = mr.Tint,
			Life = life,
			MaxLife = life,
			Radius = radius,
		} );
	}

	void AnimateShockwaves()
	{
		float dt = Time.Delta;
		for ( int i = _shockwaves.Count - 1; i >= 0; i-- )
		{
			var wave = _shockwaves[i];
			wave.Life -= dt;
			if ( wave.Life <= 0f || wave.Go == null )
			{
				wave.Go?.Destroy();
				_shockwaves.RemoveAt( i );
				continue;
			}

			float k = 1f - wave.Life / wave.MaxLife;
			float eased = EaseOutCubic( k );
			float scale = MathX.Lerp( 0.04f, wave.Radius / 50f, eased );
			wave.Go.WorldScale = new Vector3( scale, 0.012f, scale );
			var tint = wave.Tint;
			tint.a = 0.55f * (1f - k);
			wave.Renderer.Tint = tint;
		}
	}

	bool AllClearAnimsDone()
	{
		for ( int y = 0; y < Height; y++ )
			for ( int x = 0; x < Width; x++ )
			{
				var v = _visuals[x, y];
				if ( v != null && v.Clearing && v.ClearTimer > 0f ) return false;
			}
		return true;
	}

	void DestroyClearedCandies()
	{
		for ( int y = 0; y < Height; y++ )
			for ( int x = 0; x < Width; x++ )
			{
				var v = _visuals[x, y];
				if ( v != null && v.Clearing )
				{
					v.Go?.Destroy();
					_visuals[x, y] = null;
					_types[x, y] = Empty;
				}
			}
	}

	bool AllAtAnchor()
	{
		for ( int y = 0; y < Height; y++ )
			for ( int x = 0; x < Width; x++ )
			{
				var v = _visuals[x, y];
				if ( v == null ) continue;
				if ( v.Spawning ) return false;
				if ( (v.Go.WorldPosition - v.Anchor).LengthSquared > 4f ) return false;
			}
		return true;
	}

	void CollapseColumns()
	{
		for ( int x = 0; x < Width; x++ )
		{
			int writeY = 0;
			for ( int y = 0; y < Height; y++ )
			{
				if ( _types[x, y] == Empty ) continue;
				if ( y != writeY )
				{
					_types[x, writeY] = _types[x, y];
					_types[x, y] = Empty;
					_specials[x, writeY] = _specials[x, y];
					_specials[x, y] = SpecialKind.None;
					_visuals[x, writeY] = _visuals[x, y];
					_visuals[x, y] = null;
					var v = _visuals[x, writeY];
					if ( v != null )
					{
						v.Anchor = CellToWorld( x, writeY );
						v.Special = _specials[x, writeY];
						UpdateSpecialVisual( v );
					}
				}
				writeY++;
			}
		}
	}

	void Refill()
	{
		for ( int x = 0; x < Width; x++ )
		{
			int spawnIndex = 0;
			for ( int y = 0; y < Height; y++ )
			{
				if ( _types[x, y] != Empty ) continue;
				int type = Game.Random.Int( 0, CandyTypeCount - 1 );
				_specials[x, y] = SpecialKind.None;
				SpawnCandy( x, y, type, instant: false );
				// Stagger spawn position so they form a falling stream
				var v = _visuals[x, y];
				if ( v != null )
				{
					var p = v.Go.WorldPosition;
					p.z += spawnIndex * CellSpacing * 0.6f;
					v.Go.WorldPosition = p;
				}
				spawnIndex++;
			}
		}
	}

	HashSet<int> FindMatches()
	{
		var result = new HashSet<int>();
		for ( int y = 0; y < Height; y++ )
		{
			int runStart = 0;
			for ( int x = 1; x <= Width; x++ )
			{
				bool atEnd = x == Width;
				if ( !atEnd && _types[x, y] == _types[runStart, y] && _types[x, y] != Empty ) continue;
				int runLen = x - runStart;
				if ( _types[runStart, y] != Empty && runLen >= 3 )
					for ( int rx = runStart; rx < x; rx++ ) result.Add( y * Width + rx );
				runStart = x;
			}
		}
		for ( int x = 0; x < Width; x++ )
		{
			int runStart = 0;
			for ( int y = 1; y <= Height; y++ )
			{
				bool atEnd = y == Height;
				if ( !atEnd && _types[x, y] == _types[x, runStart] && _types[x, y] != Empty ) continue;
				int runLen = y - runStart;
				if ( _types[x, runStart] != Empty && runLen >= 3 )
					for ( int ry = runStart; ry < y; ry++ ) result.Add( ry * Width + x );
				runStart = y;
			}
		}
		return result;
	}

	bool HasPossibleMove()
	{
		if ( _types == null ) return false;
		for ( int y = 0; y < Height; y++ )
		{
			for ( int x = 0; x < Width; x++ )
			{
				if ( _specials != null && _specials[x, y] != SpecialKind.None )
					return true;
				if ( x + 1 < Width && SwapWouldMatch( x, y, x + 1, y ) ) return true;
				if ( y + 1 < Height && SwapWouldMatch( x, y, x, y + 1 ) ) return true;
			}
		}
		return false;
	}

	bool SwapWouldMatch( int x1, int y1, int x2, int y2 )
	{
		(_types[x1, y1], _types[x2, y2]) = (_types[x2, y2], _types[x1, y1]);
		bool result = IsMatchAt( x1, y1 ) || IsMatchAt( x2, y2 );
		(_types[x1, y1], _types[x2, y2]) = (_types[x2, y2], _types[x1, y1]);
		return result;
	}

	bool IsMatchAt( int x, int y )
	{
		int type = _types[x, y];
		if ( type == Empty ) return false;

		int count = 1;
		for ( int cx = x - 1; cx >= 0 && _types[cx, y] == type; cx-- ) count++;
		for ( int cx = x + 1; cx < Width && _types[cx, y] == type; cx++ ) count++;
		if ( count >= 3 ) return true;

		count = 1;
		for ( int cy = y - 1; cy >= 0 && _types[x, cy] == type; cy-- ) count++;
		for ( int cy = y + 1; cy < Height && _types[x, cy] == type; cy++ ) count++;
		return count >= 3;
	}

	void ReshuffleBoardForAvailableMove()
	{
		var types = new List<int>( Width * Height );
		for ( int y = 0; y < Height; y++ )
			for ( int x = 0; x < Width; x++ )
				if ( _types[x, y] != Empty ) types.Add( _types[x, y] );

		for ( int attempt = 0; attempt < 40; attempt++ )
		{
			ShuffleList( types );
			int i = 0;
			for ( int y = 0; y < Height; y++ )
			{
				for ( int x = 0; x < Width; x++ )
				{
					_types[x, y] = types[i++ % types.Count];
					_specials[x, y] = SpecialKind.None;
				}
			}
			if ( FindMatches().Count == 0 && HasPossibleMove() )
				break;
		}

		for ( int y = 0; y < Height; y++ )
		{
			for ( int x = 0; x < Width; x++ )
			{
				var v = _visuals[x, y];
				if ( v == null ) continue;
				v.Type = _types[x, y];
				v.Special = SpecialKind.None;
				v.Renderer.Model = ModelForType( v.Type );
				ApplyPieceVisualStyle( v );
				UpdateSpecialVisual( v );
			}
		}

		SpawnShockwave( BoardOrigin, new Color( 0.25f, 0.92f, 1f ), 0.7f, 150f );
		PunchCamera( 2f, 1.2f );
		Log.Info( "Board reshuffled: no available moves." );
	}

	void EndEndlessRun()
	{
		CurrentState = GameState.Lost;
		EndlessRecordSet = ColourBreakProgressStore.RecordEndlessScore( _saveData, _score );

		try
		{
			Sandbox.Services.Stats.Increment( "endless-runs", 1 );
			Sandbox.Services.Stats.SetValue( "endless-best", EndlessBest, "", null );
		}
		catch ( Exception e )
		{
			Log.Info( $"Leaderboard stats unavailable: {e.Message}" );
		}

		PlayUiSound( DeniedSound, 0.85f, 0.72f );
		SpawnShockwave( BoardOrigin, new Color( 1f, 0.18f, 0.06f ), 0.7f, 150f );
		PunchCamera( 3f, 2.4f );
	}

	void CheckLevelEnd()
	{
		if ( CurrentState != GameState.Playing ) return;
		if ( IsEndless ) return;   // endless ends only on a dead board
		if ( _score >= TargetScore && ObjectiveComplete )
		{
			CurrentState = GameState.Won;
			SaveWinProgress();
			PlayUiSound( ComboSound, 1f, 1.18f );
			SpawnFireColumn( BoardOrigin, 1.4f );
			SpawnShockwave( BoardOrigin, new Color( 1f, 0.86f, 0.12f ), 0.85f, 190f );
			PunchCamera( 5f, 3.2f );
			return;
		}
		if ( _moves >= MoveLimit )
		{
			CurrentState = GameState.Lost;
			PlayUiSound( DeniedSound, 0.85f, 0.72f );
			SpawnShockwave( BoardOrigin, new Color( 1f, 0.18f, 0.06f ), 0.7f, 130f );
			PunchCamera( 2.5f, 2.0f );
		}
	}

	void ShuffleList( List<int> list )
	{
		for ( int i = list.Count - 1; i > 0; i-- )
		{
			int j = Game.Random.Int( 0, i );
			(list[i], list[j]) = (list[j], list[i]);
		}
	}

	int CountSpecials()
	{
		if ( _specials == null ) return 0;
		int count = 0;
		for ( int y = 0; y < Height; y++ )
			for ( int x = 0; x < Width; x++ )
				if ( _specials[x, y] != SpecialKind.None ) count++;
		return count;
	}

	// -------------------- visuals --------------------

	void AnimateCandies()
	{
		if ( _visuals == null ) return;
		float dt = Time.Delta;

		for ( int y = 0; y < Height; y++ )
		{
			for ( int x = 0; x < Width; x++ )
			{
				var v = _visuals[x, y];
				if ( v == null || v.Go == null ) continue;

				int t = v.Type;
				var baseShape = PieceShape( t );
				float baseScale = CandyScale;

				// 1) Clearing animation: shrink + spin fast
				if ( v.Clearing )
				{
					v.ClearTimer -= dt;
					float k = MathF.Max( 0f, v.ClearTimer / ClearDuration );
					float s = baseScale * EaseOutBack( k ) * 1.4f * k;
					v.Go.WorldScale = baseShape * s;
					v.Go.WorldRotation *= Rotation.FromYaw( dt * 720f );
					v.Renderer.Tint = Color.Lerp( PieceColor( t ), Color.White, 1f - k );
					continue;
				}

				// 2) Swap tween
				if ( v.Swapping )
				{
					v.SwapT += dt / SwapDuration;
					float k = MathF.Min( 1f, v.SwapT );
					float e = EaseInOutCubic( k );
					var pos = Vector3.Lerp( v.SwapFrom, v.SwapTo, e );
					// Lift slightly off the board for an arc
					pos.y -= MathF.Sin( k * MathF.PI ) * CellSpacing * 0.18f;
					v.Go.WorldPosition = pos;
					if ( k >= 1f ) { v.Swapping = false; v.Go.WorldPosition = v.SwapTo; v.Anchor = v.SwapTo; }
				}
				else
				{
					// 3) Falling toward anchor (used for collapse + refill)
					var pos = v.Go.WorldPosition;
					var to = v.Anchor;
					var diff = to - pos;
					if ( diff.LengthSquared > 1f )
					{
						// gravity-ish acceleration toward anchor on Z (vertical)
						v.Velocity.z -= 4500f * dt;
						pos.z += v.Velocity.z * dt;
						pos.x = MathX.Lerp( pos.x, to.x, dt * 14f );
						pos.y = MathX.Lerp( pos.y, to.y, dt * 14f );

						if ( pos.z <= to.z )
						{
							pos.z = to.z;
							// Squash on landing
							v.Velocity.z = 0f;
						}
						v.Go.WorldPosition = pos;
					}
					else
					{
						v.Go.WorldPosition = to;
						v.Velocity = Vector3.Zero;
					}
				}

				// 4) Spawn pop-in
				float spawnScale = 1f;
				if ( v.Spawning )
				{
					v.SpawnTimer -= dt;
					float k = MathF.Max( 0f, v.SpawnTimer / 0.25f );
					spawnScale = MathX.Lerp( 1f, 0.4f, k );
					if ( v.SpawnTimer <= 0f ) v.Spawning = false;
				}

				// 5) Idle bob + gentle spin
				v.BobPhase += dt;
				float bob = MathF.Sin( v.BobPhase * 2.4f ) * 1.2f;
				if ( !v.Swapping && (v.Go.WorldPosition - v.Anchor).LengthSquared < 4f )
				{
					var p = v.Anchor;
					p.y -= bob;
					v.Go.WorldPosition = p;
				}
				v.Go.WorldRotation *= Rotation.FromYaw( dt * (10f + (t * 3f)) );

				// 6) Final scale (with highlight applied later in ApplyHighlights)
				v.Go.WorldScale = baseShape * baseScale * spawnScale;
			}
		}
	}

	void ApplyHighlights()
	{
		if ( _visuals == null ) return;
		for ( int y = 0; y < Height; y++ )
		{
			for ( int x = 0; x < Width; x++ )
			{
				var v = _visuals[x, y];
				if ( v == null || v.Renderer == null || v.Clearing ) continue;

				var baseTint = PieceColor( v.Type );
				bool isCursor = x == _cursorX && y == _cursorY;
				bool isSelected = _hasSelection && x == _selectedX && y == _selectedY;
				bool isLegalNeighbour = _hasSelection && !isSelected && AreAdjacent( _selectedX, _selectedY, x, y );
				bool isHint = _hasHint && ((x == _hintAX && y == _hintAY) || (x == _hintBX && y == _hintBY));

				float scaleMul = 1f;
				if ( isHint && !isCursor && !isSelected )
				{
					float p = 0.5f + 0.5f * MathF.Sin( Time.Now * 7f );
					baseTint = Color.Lerp( baseTint, Color.White, 0.30f + 0.45f * p );
					scaleMul += 0.18f + 0.16f * p;
				}
				if ( isCursor )
				{
					float p = 0.5f + 0.5f * MathF.Sin( Time.Now * 12f );
					// Instead of a box, pulse the candy itself significantly brighter and bigger
					baseTint = Color.Lerp( baseTint, Color.White, 0.40f + 0.40f * p );
					scaleMul += 0.25f + 0.15f * p;
				}
				if ( isSelected )
				{
					// Selection gets a distinct yellow overlay
					baseTint = Color.Lerp( baseTint, new Color( 1f, 0.95f, 0.2f ), 0.60f );
					scaleMul += 0.35f;
				}
				if ( isLegalNeighbour )
				{
					float p = 0.5f + 0.5f * MathF.Sin( Time.Now * 9f );
					baseTint = Color.Lerp( baseTint, new Color( 0.22f, 0.92f, 1f ), 0.18f + 0.14f * p );
					scaleMul += 0.08f + 0.05f * p;
				}

				v.Renderer.Tint = baseTint;
				if ( v.RimRenderer != null )
					v.RimRenderer.Tint = Color.Lerp( baseTint, Color.Black, isSelected ? 0.12f : isLegalNeighbour ? 0.22f : 0.36f );
				if ( v.FaceRenderer != null )
					v.FaceRenderer.Tint = Color.Lerp( baseTint, Color.White, isCursor || isSelected || isLegalNeighbour ? 0.45f : 0.20f );
				if ( v.AccentRenderer != null )
					v.AccentRenderer.Tint = Color.Lerp( baseTint, Color.White, isCursor || isSelected || isLegalNeighbour ? 0.82f : 0.52f );
				if ( v.ShineRenderer != null )
					v.ShineRenderer.Tint = isCursor || isSelected || isLegalNeighbour ? new Color( 1f, 1f, 1f, 1f ) : new Color( 1f, 1f, 1f, 0.74f );
				if ( v.Halo != null )
				{
					float p = 0.5f + 0.5f * MathF.Sin( Time.Now * 10f );
					if ( isSelected )
					{
						v.Halo.LocalScale = new Vector3( 1.34f + 0.05f * p, 0.055f, 1.34f + 0.05f * p );
						if ( v.HaloRenderer != null ) v.HaloRenderer.Tint = new Color( 1f, 0.86f, 0.08f, 0.90f );
					}
					else if ( isCursor )
					{
						v.Halo.LocalScale = new Vector3( 1.20f + 0.05f * p, 0.040f, 1.20f + 0.05f * p );
						if ( v.HaloRenderer != null ) v.HaloRenderer.Tint = new Color( 1f, 1f, 1f, 0.68f );
					}
					else
					{
						v.Halo.LocalScale = Vector3.Zero;
					}
				}
				if ( v.Guide != null )
				{
					float p = 0.5f + 0.5f * MathF.Sin( Time.Now * 8f );
					if ( isLegalNeighbour )
					{
						v.Guide.LocalScale = new Vector3( 1.16f + 0.07f * p, 0.030f, 1.16f + 0.07f * p );
						if ( v.GuideRenderer != null ) v.GuideRenderer.Tint = new Color( 0.22f, 0.92f, 1f, 0.62f );
					}
					else
					{
						v.Guide.LocalScale = Vector3.Zero;
					}
				}
				if ( !v.Swapping && !v.Spawning )
					v.Go.WorldScale = PieceShape( v.Type ) * CandyScale * scaleMul;
			}
		}
	}

	int PickTypeAvoidingImmediateMatch( int x, int y )
	{
		for ( int attempt = 0; attempt < 12; attempt++ )
		{
			int type = Game.Random.Int( 0, CandyTypeCount - 1 );
			bool badH = x >= 2 && _types[x - 1, y] == type && _types[x - 2, y] == type;
			bool badV = y >= 2 && _types[x, y - 1] == type && _types[x, y - 2] == type;
			if ( !badH && !badV ) return type;
		}
		return Game.Random.Int( 0, CandyTypeCount - 1 );
	}

	// -------------------- helpers --------------------

	bool AreAdjacent( int x1, int y1, int x2, int y2 ) => Math.Abs( x1 - x2 ) + Math.Abs( y1 - y2 ) == 1;
	bool InBounds( int x, int y ) => x >= 0 && x < Width && y >= 0 && y < Height;

	bool PressedEither( string a, string b ) => Pressed( a ) || Pressed( b );

	bool ActionPressed( params string[] actions )
	{
		foreach ( var action in actions )
			if ( Input.Pressed( action ) )
				return true;

		return false;
	}

	bool Pressed( string key )
	{
		bool down = Input.Keyboard.Down( key );
		_prevKeys.TryGetValue( key, out bool prev );
		_prevKeys[key] = down;
		return down && !prev;
	}

	void ClearBoardObjects()
	{
		if ( _visuals == null ) return;
		for ( int y = 0; y < _visuals.GetLength( 1 ); y++ )
			for ( int x = 0; x < _visuals.GetLength( 0 ); x++ )
				_visuals[x, y]?.Go?.Destroy();
		foreach ( var burst in _bursts )
		{
			if ( burst.Go != null && burst.Renderer != null )
				ReturnBurstObject( burst.Go );
			else
				burst.Go?.Destroy();
		}
		foreach ( var wave in _shockwaves ) wave.Go?.Destroy();
		_bursts.Clear();
		_shockwaves.Clear();
	}

	void CleanupDecor()
	{
		foreach ( var go in _decor ) go?.Destroy();
		_decor.Clear();
	}

	bool TryGetMainCamera( out CameraComponent cam )
	{
		cam = null;
		if ( Scene == null ) return false;
		foreach ( var c in Scene.GetAllComponents<CameraComponent>() )
		{
			if ( c.IsMainCamera ) { cam = c; return true; }
			cam ??= c;
		}
		return cam != null;
	}

	void FrameMainCameraToBoard()
	{
		if ( !TryGetMainCamera( out var cam ) || cam.GameObject == null ) return;
		float boardW = Width * CellSpacing;
		float boardH = Height * CellSpacing;
		float verticalFov = 44f;
		float verticalFovRad = verticalFov * MathF.PI / 180f;
		float aspect = 16f / 9f;
		float targetW = boardW + CellSpacing * 4.8f;
		float targetH = boardH + CellSpacing * 6.4f;
		float distanceForHeight = (targetH * 0.5f) / MathF.Tan( verticalFovRad * 0.5f );
		float horizontalFovRad = 2f * MathF.Atan( MathF.Tan( verticalFovRad * 0.5f ) * aspect );
		float distanceForWidth = (targetW * 0.5f) / MathF.Tan( horizontalFovRad * 0.5f );
		float distance = MathF.Max( distanceForHeight, distanceForWidth ) * 1.45f;
		var center = BoardOrigin + new Vector3( 0f, 2f, 0f );
		var camPos = center + new Vector3( 0f, -distance, 0f );
		cam.GameObject.WorldPosition = camPos;
		cam.GameObject.WorldRotation = Rotation.LookAt( center - camPos, Vector3.Up );
		cam.FieldOfView = verticalFov;
		cam.BackgroundColor = new Color( 0.055f, 0.045f, 0.095f );
		cam.EnablePostProcessing = true;
		cam.ZNear = 1f;
		cam.ZFar = MathF.Max( cam.ZFar, 20000f );
		_activeCamera = cam;
		_baseFov = cam.FieldOfView;
		_baseCameraPos = cam.GameObject.WorldPosition;
	}

	void UpdateAmbience()
	{
		float t = Time.Now;
		if ( _orbitLightL != null )
		{
			float r = MathF.Max( Width, Height ) * CellSpacing * 0.7f;
			_orbitLightL.WorldPosition = BoardOrigin + new Vector3( MathF.Cos( t * 0.7f ) * r, -CellSpacing * 1.2f, MathF.Sin( t * 0.7f ) * r );
		}
		if ( _orbitLightR != null )
		{
			float r = MathF.Max( Width, Height ) * CellSpacing * 0.7f;
			_orbitLightR.WorldPosition = BoardOrigin + new Vector3( MathF.Cos( t * 0.7f + MathF.PI ) * r, -CellSpacing * 1.2f, MathF.Sin( t * 0.7f + MathF.PI ) * r );
		}

		if ( _trayInnerRenderer != null )
		{
			float r = 0.05f + 0.025f * MathF.Sin( t * 0.55f );
			float g = 0.07f + 0.025f * MathF.Sin( t * 0.55f + 2f );
			float b = 0.12f + 0.035f * MathF.Sin( t * 0.55f + 4f );
			var shift = new Color( r, g, b );
			_trayInnerRenderer.Tint = Color.Lerp( _trayBaseColor, shift, 0.45f );
		}

	}

	void UpdateCameraPunch()
	{
		if ( _activeCamera == null ) return;
		float dt = Time.Delta;
		_fovPunch = MathX.Lerp( _fovPunch, 0f, dt * 6f );
		_shakeAmount = MathX.Lerp( _shakeAmount, 0f, dt * 5f );
		_activeCamera.FieldOfView = _baseFov + _fovPunch;

		if ( _shakeAmount > 0.01f && _activeCamera.GameObject != null )
		{
			var offset = new Vector3(
				Game.Random.Float( -1f, 1f ),
				Game.Random.Float( -1f, 1f ),
				Game.Random.Float( -1f, 1f ) ) * _shakeAmount;
			_activeCamera.GameObject.WorldPosition = _baseCameraPos + offset;
		}
		else if ( _activeCamera.GameObject != null )
		{
			_activeCamera.GameObject.WorldPosition = _baseCameraPos;
		}
	}

	void PunchCamera( float fovDelta, float shake )
	{
		if ( !CameraShakeEnabled ) return;
		_fovPunch -= fovDelta; // negative FOV = zoom in
		_shakeAmount = MathF.Max( _shakeAmount, shake );
	}

	// Seamless-ish looping background music. Re-triggers when the clip ends; the
	// synthesized loop wraps at matching amplitude so the seam isn't audible.
	void UpdateMusic()
	{
		if ( !MusicEnabled || string.IsNullOrWhiteSpace( MusicSound ) )
		{
			if ( _music.IsValid() )
			{
				_music.Stop( 0.4f );
				_music = default;
			}
			return;
		}

		if ( !_music.IsValid() || _music.IsStopped || _music.Finished )
		{
			try
			{
				_music = Sound.Play( MusicSound );
				if ( _music.IsValid() )
				{
					_music.SpacialBlend = 0f;
					_music.Volume = MusicVolume;
				}
			}
			catch
			{
				// Music asset missing/unsupported — silently continue without it.
			}
		}
		else
		{
			_music.Volume = MusicVolume;
		}
	}

	void PlayUiSound( string sound, float volume, float pitch )
	{
		if ( !AudioEnabled ) return;
		if ( string.IsNullOrWhiteSpace( sound ) || _audioCooldown > 0f ) return;
		try
		{
			var handle = Sound.Play( sound, 0f );
			handle.Volume = volume;
			handle.Pitch = pitch;
			handle.SpacialBlend = 0f;
			_audioCooldown = 0.035f;
		}
		catch
		{
			_audioCooldown = 0.2f;
		}
	}

	void EnsureSceneLighting()
	{
		if ( Scene == null ) return;
		foreach ( var l in Scene.GetAllComponents<DirectionalLight>() )
			if ( l != null ) return;

		var lightGo = new GameObject( true, "Auto Directional Light" );
		lightGo.Flags = GameObjectFlags.NotNetworked;
		lightGo.WorldRotation = Rotation.LookAt( new Vector3( 0.4f, -0.25f, -1f ).Normal, Vector3.Up );
		var d = lightGo.AddComponent<DirectionalLight>();
		d.LightColor = new Color( 0.95f, 0.98f, 1f );
		d.Shadows = true;
	}

	// -------------------- easing --------------------

	static float EaseInOutCubic( float t ) => t < 0.5f ? 4f * t * t * t : 1f - MathF.Pow( -2f * t + 2f, 3f ) / 2f;
	static float EaseOutCubic( float t ) => 1f - MathF.Pow( 1f - t, 3f );
	static float EaseOutBack( float t )
	{
		const float c1 = 1.70158f;
		const float c3 = c1 + 1f;
		float u = t - 1f;
		return 1f + c3 * u * u * u + c1 * u * u;
	}
}