ScienceGame.cs

A single-file falling-sand simulation component for s&box. It implements a cellular automata with ~30 materials, painting tools, scenarios, save/load (RLE to a data file), dynamic texture rendering with glow, audio hooks, HUD setup, and progression/milestone tracking.

File AccessExternal Download
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sandbox.UI;

namespace Science;

public enum Element
{
	Empty,
	Wall,
	Sand,
	Water,
	Oil,
	Fire,
	Smoke,
	Steam,
	Gas,
	Cloud,
	TNT,
	C4,
	Bomb,
	Nitro,
	Spark,
	Acid,
	Plant,
	Seed,
	Vine,
	Fuse,
	Metal,
	Stone,
	Glass,
	Ice,
	Salt,
	Mud,
	Lava,
	Mercury,
	Virus,
	Clone,
	Eraser
}

public enum ElementCategory
{
	Powders,
	Liquids,
	Gases,
	Energy,
	Life,
	Explosives,
	Solids,
	Tools
}

/// <summary>
/// Science - a falling-sand sandbox. A cellular-automata simulation of ~30
/// reactive materials, rendered to a single dynamic texture (with baked glow)
/// that is presented through the 2D HUD.
/// </summary>
public sealed class ScienceGame : Component
{
	[Property] public int GridWidth { get; set; } = 160;
	[Property] public int GridHeight { get; set; } = 100;

	// --- Public state read by the HUD -------------------------------------
	public Element CurrentTool { get; private set; } = Element.Sand;
	public int BrushRadius { get; private set; } = 4;
	public bool Paused { get; private set; }
	public int Particles { get; private set; }
	public int Reactions { get; private set; }
	public int Discoveries => _discovered.Count;
	public float SpeedScale { get; private set; } = 1f;
	public string LastReaction { get; private set; } = "Ready";
	public string CurrentToolName => ToolName( CurrentTool );
	public Texture GridTexture { get; private set; }
	public IReadOnlyList<string> RecentReactions => _recentReactions;
	public IReadOnlyList<string> Milestones => _milestones;
	public bool ShowGrid { get; set; } = true;
	public string ActiveExperimentName { get; private set; } = "Open Lab";
	public string ObjectiveText { get; private set; } = "Create reactions and unlock milestones.";
	public string ObjectiveProgress => _objectiveComplete
		? "Complete"
		: $"{Math.Clamp( Reactions - _objectiveStartReactions, 0, _objectiveTarget )} / {_objectiveTarget} reactions";
	public float ObjectivePercent => _objectiveTarget <= 0
		? 1f
		: Math.Clamp( (Reactions - _objectiveStartReactions) / (float)_objectiveTarget, 0f, 1f );
	public bool ObjectiveComplete => _objectiveComplete;
	public int CompletedExperiments => _completedExperiments;
	public int LabScore => Discoveries * 150 + Reactions * 10 + CompletedExperiments * 500 + Particles / 25;
	public string LabRank => LabScore switch
	{
		>= 7000 => "Director",
		>= 4500 => "Senior Scientist",
		>= 2500 => "Research Lead",
		>= 1200 => "Technician",
		_ => "Intern"
	};

	const float SimulationRate = 30f;
	const int MaxBrushRadius = 14;
	const int MinBrushRadius = 1;

	// Supersample factor: each grid cell becomes SS x SS texture pixels so glow
	// has room to bleed and blocks get a subtle bevel.
	const int SS = 4;

	Element[,] _grid;
	int[,] _life;
	int[,] _updated;
	readonly Random _random = new();
	int _tickId;
	float _stepAccumulator;
	bool _renderDirty = true;
	bool _stepRequested;

	// Render buffers (allocated once).
	int _texW, _texH;
	Color32[] _pixels;
	Color32[] _background;
	float[] _glowR, _glowG, _glowB;
	float[] _glowKernel;
	int _glowRadius;

	// Discovery / lab-notebook tracking.
	readonly HashSet<string> _discovered = new();
	readonly List<string> _recentReactions = new();
	readonly List<string> _milestones = new();
	readonly HashSet<string> _milestoneKeys = new();
	int _objectiveStartReactions;
	int _objectiveTarget = 6;
	bool _objectiveComplete;
	int _completedExperiments;

	// Painting input is fed from the HUD canvas rectangle (screen pixels).
	Rect _canvasRect;
	bool _pointerInCanvas;

	// Brush hover preview (drawn into the texture so it reads on the canvas).
	int _hoverX, _hoverY;
	bool _hasHover;

	// Procedural audio.
	ScienceAudio _audio;
	public bool Muted => _audio?.Muted ?? false;

	// Attract mode: the menu runs a live, self-seeding simulation as its backdrop.
	public bool AttractMode { get; private set; }
	float _attractAccumulator;
	int _attractCycle;

	const string SaveFile = "science_sandbox.txt";

	static readonly Element[] ToolOrder =
	{
		Element.Sand, Element.Water, Element.Oil, Element.Fire, Element.Smoke,
		Element.Steam, Element.Gas, Element.Cloud, Element.Spark, Element.Acid,
		Element.Plant, Element.Seed, Element.Vine, Element.Salt, Element.Mud,
		Element.Ice, Element.Lava, Element.Mercury, Element.TNT, Element.C4,
		Element.Bomb, Element.Nitro, Element.Fuse, Element.Metal, Element.Stone,
		Element.Glass, Element.Virus, Element.Clone, Element.Wall, Element.Eraser
	};

	public IReadOnlyList<Element> ToolPalette => ToolOrder;

	// --- Colours ----------------------------------------------------------
	static readonly Color EmptyColor = new( 0.05f, 0.06f, 0.08f );
	static readonly Color WallColor = new( 0.34f, 0.37f, 0.42f );
	static readonly Color SandColor = new( 0.92f, 0.74f, 0.34f );
	static readonly Color WaterColor = new( 0.16f, 0.46f, 0.92f, 0.92f );
	static readonly Color OilColor = new( 0.16f, 0.13f, 0.08f );
	static readonly Color FireColor = new( 1.00f, 0.40f, 0.06f );
	static readonly Color SmokeColor = new( 0.40f, 0.43f, 0.46f, 0.70f );
	static readonly Color SteamColor = new( 0.78f, 0.88f, 0.95f, 0.72f );
	static readonly Color GasColor = new( 0.58f, 0.92f, 0.42f, 0.58f );
	static readonly Color CloudColor = new( 0.70f, 0.78f, 0.86f, 0.64f );
	static readonly Color TntColor = new( 0.86f, 0.14f, 0.12f );
	static readonly Color C4Color = new( 0.94f, 0.52f, 0.22f );
	static readonly Color BombColor = new( 0.12f, 0.13f, 0.15f );
	static readonly Color NitroColor = new( 0.92f, 0.16f, 0.72f, 0.92f );
	static readonly Color SparkColor = new( 1.00f, 0.95f, 0.45f );
	static readonly Color AcidColor = new( 0.45f, 1.00f, 0.22f );
	static readonly Color PlantColor = new( 0.20f, 0.72f, 0.26f );
	static readonly Color SeedColor = new( 0.52f, 0.32f, 0.10f );
	static readonly Color VineColor = new( 0.10f, 0.50f, 0.16f );
	static readonly Color FuseColor = new( 0.76f, 0.52f, 0.26f );
	static readonly Color MetalColor = new( 0.62f, 0.67f, 0.72f );
	static readonly Color StoneColor = new( 0.38f, 0.38f, 0.40f );
	static readonly Color GlassColor = new( 0.66f, 0.90f, 1.00f, 0.62f );
	static readonly Color IceColor = new( 0.56f, 0.82f, 1.00f, 0.88f );
	static readonly Color SaltColor = new( 0.93f, 0.92f, 0.84f );
	static readonly Color MudColor = new( 0.36f, 0.24f, 0.12f );
	static readonly Color LavaColor = new( 1.00f, 0.42f, 0.06f );
	static readonly Color MercuryColor = new( 0.80f, 0.84f, 0.90f );
	static readonly Color VirusColor = new( 0.82f, 0.12f, 1.00f );
	static readonly Color CloneColor = new( 0.24f, 1.00f, 0.80f );

	// =====================================================================
	//  Lifecycle
	// =====================================================================
	protected override void OnStart()
	{
		base.OnStart();

		_grid = new Element[GridWidth, GridHeight];
		_life = new int[GridWidth, GridHeight];
		_updated = new int[GridWidth, GridHeight];

		BuildRenderResources();
		EnsureCameraBackground();
		DisableSkybox();
		EnsureAudio();
		EnsureHud();
		ClearWorld();
		SetAttractMode( true );
		LastReaction = "Ready";

#pragma warning disable CS0612
		try { Mouse.Visible = true; } catch { }
#pragma warning restore CS0612
		Log.Info( "Science ready." );
	}

	protected override void OnUpdate()
	{
		HandleInput();

		if ( AttractMode )
			UpdateAttract();

		if ( !Paused )
		{
			_stepAccumulator += Time.Delta * SpeedScale;
			float stepTime = 1f / SimulationRate;
			int maxSteps = SpeedScale > 2f ? 4 : 2;
			int steps = 0;
			while ( _stepAccumulator >= stepTime && steps < maxSteps )
			{
				_stepAccumulator -= stepTime;
				SimulateStep();
				steps++;
			}
		}
		else if ( _stepRequested )
		{
			SimulateStep();
		}

		_stepRequested = false;

		if ( _renderDirty )
		{
			RenderToTexture();
			_renderDirty = false;
		}

		if ( !AttractMode )
			UpdateProgression();
	}

	// =====================================================================
	//  Public commands (called from the HUD)
	// =====================================================================
	public void SelectTool( Element tool )
	{
		CurrentTool = tool;
		LastReaction = ToolName( tool );
		_audio?.Select();
	}

	// Lightweight hooks the HUD calls so its buttons feel responsive.
	public void PlayClick() => _audio?.Click();

	public void ToggleMute()
	{
		if ( _audio == null ) return;
		_audio.Muted = !_audio.Muted;
		LastReaction = _audio.Muted ? "Muted" : "Sound on";
		if ( !_audio.Muted ) _audio.Click();
	}

	public void IncreaseBrush() => BrushRadius = Math.Min( MaxBrushRadius, BrushRadius + 1 );
	public void DecreaseBrush() => BrushRadius = Math.Max( MinBrushRadius, BrushRadius - 1 );

	public void TogglePause()
	{
		Paused = !Paused;
		LastReaction = Paused ? "Paused" : "Running";
	}

	public void StepOnce()
	{
		if ( !Paused ) Paused = true;
		_stepRequested = true;
		LastReaction = "Stepped";
	}

	public void CycleSpeed()
	{
		SpeedScale = SpeedScale switch
		{
			<= 0.5f => 1f,
			<= 1f => 2f,
			<= 2f => 4f,
			_ => 0.5f
		};
		LastReaction = $"Speed {SpeedScale:0.#}x";
	}

	public void ToggleGrid()
	{
		ShowGrid = !ShowGrid;
		BuildBackground();
		_renderDirty = true;
	}

	public void SetCanvasRect( Rect rect, bool pointerInside )
	{
		_canvasRect = rect;
		_pointerInCanvas = pointerInside;
	}

	public void ClearWorld()
	{
		Array.Clear( _grid, 0, _grid.Length );
		Array.Clear( _life, 0, _life.Length );
		BuildBoundaryCells();
		Particles = CountParticles();
		LastReaction = "Cleared";
		_renderDirty = true;
	}

	// =====================================================================
	//  Attract mode (live menu backdrop)
	// =====================================================================
	public void SetAttractMode( bool on )
	{
		if ( AttractMode == on ) return;
		AttractMode = on;
		ClearWorld();
		Paused = false;
		_attractAccumulator = 0f;
		LastReaction = on ? "Idle lab" : "Ready";
	}

	// Continuously drips a rotating palette of materials from the top so the
	// menu is a constantly-evolving, self-reacting scene rather than a still.
	void UpdateAttract()
	{
		_attractAccumulator += Time.Delta;
		if ( _attractAccumulator < 0.10f ) return;
		_attractAccumulator -= 0.10f;

		// Let the heap breathe: stop feeding once it's comfortably full.
		if ( Particles > GridWidth * GridHeight * 0.34f ) return;

		_attractCycle++;
		int top = GridHeight - 3;
		SpawnBlob( 5 + _random.Next( GridWidth - 10 ), top, AttractMaterial(), 3 );

		// A second emitter weighted toward glow keeps the scene lit and reactive.
		if ( _attractCycle % 3 == 0 )
		{
			Element hot = _random.Next( 2 ) == 0 ? Element.Lava : Element.Fire;
			SpawnBlob( 5 + _random.Next( GridWidth - 10 ), top, hot, 2 );
		}
	}

	Element AttractMaterial()
	{
		int r = _random.Next( 100 );
		if ( r < 24 ) return Element.Sand;
		if ( r < 44 ) return Element.Water;
		if ( r < 58 ) return Element.Oil;
		if ( r < 68 ) return Element.Salt;
		if ( r < 78 ) return Element.Plant;
		if ( r < 86 ) return Element.Acid;
		if ( r < 93 ) return Element.Lava;
		return Element.Spark;
	}

	void SpawnBlob( int cx, int cy, Element element, int radius )
	{
		int radiusSquared = radius * radius;
		for ( int y = cy - radius; y <= cy + radius; y++ )
		{
			for ( int x = cx - radius; x <= cx + radius; x++ )
			{
				if ( !InBounds( x, y ) || IsBoundaryCell( x, y ) ) continue;
				int dx = x - cx;
				int dy = y - cy;
				if ( dx * dx + dy * dy > radiusSquared ) continue;
				if ( _random.NextDouble() > 0.68 ) continue;
				if ( _grid[x, y] == Element.Empty )
					SetCell( x, y, element );
			}
		}
		_renderDirty = true;
	}

	// =====================================================================
	//  Scenario presets
	// =====================================================================
	public void LoadScenario( int index )
	{
		switch ( index )
		{
			case 0:
				ScenarioMixedLab();
				StartExperiment( "Mixed Lab", "Use the starter samples to trigger six reactions.", 6 );
				break;
			case 1:
				ScenarioVolcano();
				StartExperiment( "Volcano", "Turn heat, water, and stone into five chain reactions.", 5 );
				break;
			case 2:
				ScenarioGarden();
				StartExperiment( "Garden", "Grow, burn, sprout, or flood four live reactions.", 4 );
				break;
			case 3:
				ScenarioDemolition();
				StartExperiment( "Demolition", "Wire the structure and cause four blast events.", 4 );
				break;
			default:
				ScenarioMixedLab();
				StartExperiment( "Mixed Lab", "Use the starter samples to trigger six reactions.", 6 );
				break;
		}
	}

	void ScenarioMixedLab()
	{
		ClearWorld();
		for ( int x = 16; x < 52; x++ ) SetCell( x, GridHeight - 14 + _random.Next( 0, 4 ), Element.Sand );
		for ( int x = 64; x < 100; x++ ) SetCell( x, GridHeight - 16 + _random.Next( 0, 4 ), Element.Water );
		for ( int x = 104; x < 132; x++ ) SetCell( x, GridHeight - 20 + _random.Next( 0, 3 ), Element.Oil );
		for ( int x = 54; x < 70; x++ ) { SetCell( x, 14, Element.TNT ); SetCell( x, 15, Element.TNT ); }
		for ( int x = 12; x < 28; x++ ) SetCell( x, 12 + _random.Next( 0, 4 ), Element.Plant );
		for ( int x = 130; x < 146; x++ ) SetCell( x, 48 + _random.Next( 0, 4 ), Element.Acid );
		for ( int x = 26; x < 46; x++ ) SetCell( x, 56 + _random.Next( 0, 3 ), Element.Salt );
		for ( int x = 78; x < 94; x++ ) SetCell( x, 66 + _random.Next( 0, 2 ), Element.Lava );
		for ( int x = 108; x < 122; x++ ) SetCell( x, 38 + _random.Next( 0, 3 ), Element.Mercury );
		for ( int x = 36; x < 50; x++ ) SetCell( x, 30, Element.Metal );
		SetCell( 72, 24, Element.Fuse ); SetCell( 73, 24, Element.Fuse ); SetCell( 74, 24, Element.C4 );
		SetCell( 118, 74, Element.Cloud ); SetCell( 119, 74, Element.Cloud );
		SetCell( 44, 80, Element.Clone ); SetCell( 45, 80, Element.Sand );
		Particles = CountParticles();
		LastReaction = "Mixed Lab";
		_renderDirty = true;
	}

	void ScenarioVolcano()
	{
		ClearWorld();
		int cx = GridWidth / 2;
		for ( int x = 0; x < GridWidth; x++ )
		{
			int slope = Math.Abs( x - cx );
			int top = Math.Max( 4, 30 - slope / 2 );
			for ( int y = 1; y < top; y++ )
				SetCell( x, y, Element.Stone );
		}
		for ( int x = cx - 3; x <= cx + 3; x++ )
			for ( int y = 1; y < 26; y++ )
				SetCell( x, y, Element.Lava );
		for ( int x = 8; x < 40; x++ ) SetCell( x, GridHeight - 12 + _random.Next( 0, 3 ), Element.Water );
		for ( int x = 60; x < 90; x++ ) SetCell( x, 44 + _random.Next( 0, 3 ), Element.Plant );
		Particles = CountParticles();
		LastReaction = "Volcano";
		_renderDirty = true;
	}

	void ScenarioGarden()
	{
		ClearWorld();
		for ( int x = 2; x < GridWidth - 2; x++ )
			for ( int y = 1; y < 8; y++ )
				SetCell( x, y, Element.Mud );
		for ( int x = 10; x < GridWidth - 10; x += 7 )
			SetCell( x, 8, Element.Seed );
		for ( int x = 20; x < GridWidth - 20; x += 14 )
			for ( int y = 9; y < 13; y++ )
				SetCell( x, y, Element.Water );
		for ( int x = 30; x < GridWidth - 30; x += 22 )
			SetCell( x, GridHeight - 6, Element.Cloud );
		Particles = CountParticles();
		LastReaction = "Garden";
		_renderDirty = true;
	}

	void ScenarioDemolition()
	{
		ClearWorld();
		for ( int x = 30; x < GridWidth - 30; x++ )
			for ( int y = 1; y < 40; y++ )
			{
				bool brick = (x / 6 + y / 4) % 2 == 0;
				SetCell( x, y, brick ? Element.Stone : Element.Metal );
			}
		for ( int x = 40; x < GridWidth - 40; x += 12 )
			for ( int y = 6; y < 34; y += 10 )
			{
				SetCell( x, y, Element.C4 );
				SetCell( x, y - 1, Element.Fuse );
			}
		for ( int x = 34; x < GridWidth - 34; x++ ) SetCell( x, 41, Element.Fuse );
		Particles = CountParticles();
		LastReaction = "Demolition";
		_renderDirty = true;
	}

	// =====================================================================
	//  Save / Load
	// =====================================================================
	public void SaveState()
	{
		try
		{
			var sb = new StringBuilder();
			sb.Append( GridWidth ).Append( 'x' ).Append( GridHeight ).Append( ':' );
			// Run-length encode the grid (column-major matches iteration order).
			Element run = _grid[0, 0];
			int count = 0;
			for ( int y = 0; y < GridHeight; y++ )
			{
				for ( int x = 0; x < GridWidth; x++ )
				{
					var e = _grid[x, y];
					if ( e == run ) { count++; continue; }
					sb.Append( (int)run ).Append( '*' ).Append( count ).Append( ';' );
					run = e; count = 1;
				}
			}
			sb.Append( (int)run ).Append( '*' ).Append( count ).Append( ';' );
			FileSystem.Data.WriteAllText( SaveFile, sb.ToString() );
			LastReaction = "Saved";
		}
		catch ( Exception e )
		{
			Log.Warning( $"Science save failed: {e.Message}" );
			LastReaction = "Save failed";
		}
	}

	public void LoadState()
	{
		try
		{
			if ( !FileSystem.Data.FileExists( SaveFile ) )
			{
				LastReaction = "No save";
				return;
			}

			var text = FileSystem.Data.ReadAllText( SaveFile );
			int colon = text.IndexOf( ':' );
			if ( colon < 0 ) { LastReaction = "Bad save"; return; }

			ClearWorld();
			var body = text[(colon + 1)..];
			int index = 0;
			foreach ( var token in body.Split( ';', StringSplitOptions.RemoveEmptyEntries ) )
			{
				int star = token.IndexOf( '*' );
				if ( star < 0 ) continue;
				int value = int.Parse( token[..star] );
				int length = int.Parse( token[(star + 1)..] );
				for ( int i = 0; i < length; i++ )
				{
					int x = index % GridWidth;
					int y = index / GridWidth;
					index++;
					if ( y >= GridHeight ) break;
					SetCell( x, y, (Element)value );
				}
			}

			Particles = CountParticles();
			LastReaction = "Loaded";
			_renderDirty = true;
		}
		catch ( Exception e )
		{
			Log.Warning( $"Science load failed: {e.Message}" );
			LastReaction = "Load failed";
		}
	}

	// =====================================================================
	//  Input / painting
	// =====================================================================
	void HandleInput()
	{
		if ( Input.Pressed( "slot1" ) ) SelectTool( Element.Sand );
		if ( Input.Pressed( "slot2" ) ) SelectTool( Element.Water );
		if ( Input.Pressed( "slot3" ) ) SelectTool( Element.Fire );
		if ( Input.Pressed( "slot4" ) ) SelectTool( Element.Stone );
		if ( Input.Pressed( "slot5" ) ) SelectTool( Element.Lava );
		if ( Input.Pressed( "slot6" ) ) SelectTool( Element.Acid );
		if ( Input.Pressed( "slot7" ) ) SelectTool( Element.Plant );
		if ( Input.Pressed( "slot8" ) ) SelectTool( Element.TNT );
		if ( Input.Pressed( "slot9" ) ) SelectTool( Element.Eraser );
		if ( Input.Pressed( "reload" ) ) TogglePause();
		if ( Input.Pressed( "use" ) ) LoadScenario( 0 );

		bool hadHover = _hasHover;
		int prevX = _hoverX, prevY = _hoverY;

		if ( !_pointerInCanvas )
		{
			_hasHover = false;
			if ( hadHover ) _renderDirty = true;
			return;
		}

		if ( TryGetMouseCell( out var x, out var y ) )
		{
			_hoverX = x;
			_hoverY = y;
			_hasHover = true;
			if ( !hadHover || prevX != x || prevY != y ) _renderDirty = true;
		}
		else
		{
			_hasHover = false;
			if ( hadHover ) _renderDirty = true;
		}

		if ( Input.Down( "attack1" ) && TryGetMouseCell( out x, out y ) )
			PaintBrush( x, y, CurrentTool );

		if ( Input.Down( "attack2" ) && TryGetMouseCell( out x, out y ) )
			PaintBrush( x, y, Element.Eraser );
	}

	bool TryGetMouseCell( out int cellX, out int cellY )
	{
		cellX = 0;
		cellY = 0;
		if ( _canvasRect.Width < 1f || _canvasRect.Height < 1f ) return false;

		var m = Mouse.Position;
		float u = (m.x - _canvasRect.Left) / _canvasRect.Width;
		float v = (m.y - _canvasRect.Top) / _canvasRect.Height;
		if ( u < 0f || u > 1f || v < 0f || v > 1f ) return false;

		cellX = (int)(u * GridWidth);
		// Texture row 0 is the top of the image, which is grid y = GridHeight-1.
		cellY = GridHeight - 1 - (int)(v * GridHeight);
		return InBounds( cellX, cellY );
	}

	void PaintBrush( int centerX, int centerY, Element tool )
	{
		int radiusSquared = BrushRadius * BrushRadius;
		bool dense = tool is Element.Wall or Element.Metal or Element.Stone or Element.Glass or Element.Eraser;
		for ( int y = centerY - BrushRadius; y <= centerY + BrushRadius; y++ )
		{
			for ( int x = centerX - BrushRadius; x <= centerX + BrushRadius; x++ )
			{
				if ( !InBounds( x, y ) ) continue;
				if ( IsBoundaryCell( x, y ) ) continue;
				int deltaX = x - centerX;
				int deltaY = y - centerY;
				if ( deltaX * deltaX + deltaY * deltaY > radiusSquared ) continue;
				if ( !dense && _random.NextDouble() > 0.78 ) continue;
				SetCell( x, y, tool == Element.Eraser ? Element.Empty : tool );
			}
		}

		Particles = CountParticles();
		_renderDirty = true;
		if ( tool == Element.Eraser ) _audio?.Erase(); else _audio?.Paint();
	}

	// =====================================================================
	//  Simulation  (cellular automata - preserved behaviour)
	// =====================================================================
	void SimulateStep()
	{
		_tickId++;
		int direction = _random.Next( 2 ) == 0 ? 1 : -1;

		for ( int y = 0; y < GridHeight; y++ )
		{
			for ( int column = 0; column < GridWidth; column++ )
			{
				int x = direction > 0 ? column : GridWidth - 1 - column;
				if ( _updated[x, y] == _tickId ) continue;

				switch ( _grid[x, y] )
				{
					case Element.Sand: SimulateSand( x, y ); break;
					case Element.Salt: SimulateSalt( x, y ); break;
					case Element.Stone: SimulateStone( x, y ); break;
					case Element.Mud: SimulateMud( x, y ); break;
					case Element.Water: SimulateWater( x, y ); break;
					case Element.Oil: SimulateOil( x, y ); break;
					case Element.Nitro: SimulateNitro( x, y ); break;
					case Element.Lava: SimulateLava( x, y ); break;
					case Element.Mercury: SimulateMercury( x, y ); break;
					case Element.Fire: SimulateFire( x, y ); break;
					case Element.Smoke: SimulateSmoke( x, y ); break;
					case Element.Steam: SimulateSteam( x, y ); break;
					case Element.Gas: SimulateGas( x, y ); break;
					case Element.Cloud: SimulateCloud( x, y ); break;
					case Element.TNT: SimulateExplosive( x, y, 12, "TNT armed" ); break;
					case Element.C4: SimulateExplosive( x, y, 16, "C4 armed" ); break;
					case Element.Bomb: SimulateExplosive( x, y, 20, "Bomb armed" ); break;
					case Element.Spark: SimulateSpark( x, y ); break;
					case Element.Acid: SimulateAcid( x, y ); break;
					case Element.Plant: SimulatePlant( x, y ); break;
					case Element.Seed: SimulateSeed( x, y ); break;
					case Element.Vine: SimulateVine( x, y ); break;
					case Element.Fuse: SimulateFuse( x, y ); break;
					case Element.Ice: SimulateIce( x, y ); break;
					case Element.Virus: SimulateVirus( x, y ); break;
					case Element.Clone: SimulateClone( x, y ); break;
				}
			}
		}

		_renderDirty = true;
	}

	void SimulateSand( int x, int y )
	{
		if ( TryMoveInto( x, y, x, y - 1, true ) ) return;
		int direction = RandomDirection();
		if ( TryMoveInto( x, y, x + direction, y - 1, true ) ) return;
		TryMoveInto( x, y, x - direction, y - 1, true );
	}

	void SimulateSalt( int x, int y )
	{
		if ( TryFindNeighbor( x, y, Element.Water, out _, out _ ) )
		{
			SetCell( x, y, Element.Empty );
			RecordReaction( "Salt dissolve" );
			return;
		}

		SimulateSand( x, y );
	}

	void SimulateStone( int x, int y )
	{
		if ( TryMoveInto( x, y, x, y - 1, true ) ) return;
		int direction = RandomDirection();
		if ( TryMoveInto( x, y, x + direction, y - 1, true ) ) return;
		TryMoveInto( x, y, x - direction, y - 1, true );
	}

	void SimulateMud( int x, int y )
	{
		if ( TouchingHot( x, y ) )
		{
			ReactCell( x, y, Element.Stone, "Mud baked" );
			return;
		}

		if ( _random.NextDouble() > 0.46 )
		{
			MarkUpdated( x, y );
			return;
		}

		if ( TryMoveInto( x, y, x, y - 1, false ) ) return;
		int direction = RandomDirection();
		if ( TryMoveInto( x, y, x + direction, y - 1, false ) ) return;
		if ( _random.NextDouble() < 0.28 && TryMoveInto( x, y, x + direction, y, false ) ) return;
		MarkUpdated( x, y );
	}

	void SimulateWater( int x, int y )
	{
		if ( TouchingHot( x, y ) )
		{
			ReactCell( x, y, Element.Steam, "Steam" );
			return;
		}

		if ( TryFindNeighbor( x, y, Element.Sand, out var sandX, out var sandY ) && _random.NextDouble() < 0.05 )
		{
			SetCell( sandX, sandY, Element.Mud );
			SetCell( x, y, Element.Empty );
			RecordReaction( "Mud" );
			return;
		}

		if ( TryMoveInto( x, y, x, y - 1, false ) ) return;
		int direction = RandomDirection();
		if ( TryMoveInto( x, y, x + direction, y - 1, false ) ) return;
		if ( TryMoveInto( x, y, x - direction, y - 1, false ) ) return;
		if ( TryMoveInto( x, y, x + direction, y, false ) ) return;
		TryMoveInto( x, y, x - direction, y, false );
	}

	void SimulateOil( int x, int y )
	{
		if ( TouchingHot( x, y ) )
		{
			ReactCell( x, y, Element.Fire, "Oil burn" );
			return;
		}

		if ( InBounds( x, y + 1 ) && _grid[x, y + 1] == Element.Water )
		{
			SwapCells( x, y, x, y + 1 );
			return;
		}

		if ( TryMoveInto( x, y, x, y - 1, false ) ) return;
		int direction = RandomDirection();
		if ( TryMoveInto( x, y, x + direction, y - 1, false ) ) return;
		if ( TryMoveInto( x, y, x - direction, y - 1, false ) ) return;
		if ( TryMoveInto( x, y, x + direction, y, false ) ) return;
		TryMoveInto( x, y, x - direction, y, false );
	}

	void SimulateNitro( int x, int y )
	{
		if ( TouchingHot( x, y ) )
		{
			Explode( x, y, 9 );
			return;
		}

		if ( TryMoveInto( x, y, x, y - 1, false ) ) return;
		int direction = RandomDirection();
		if ( TryMoveInto( x, y, x + direction, y - 1, false ) ) return;
		if ( TryMoveInto( x, y, x - direction, y - 1, false ) ) return;
		if ( TryMoveInto( x, y, x + direction, y, false ) ) return;
		TryMoveInto( x, y, x - direction, y, false );
	}

	void SimulateLava( int x, int y )
	{
		_life[x, y]--;
		if ( TryFindNeighbor( x, y, Element.Water, out var waterX, out var waterY ) || TryFindNeighbor( x, y, Element.Ice, out waterX, out waterY ) )
		{
			SetCell( waterX, waterY, Element.Steam );
			if ( _random.NextDouble() < 0.46 ) SetCell( x, y, Element.Stone );
			RecordReaction( "Quench" );
			return;
		}

		HeatNeighbors( x, y );
		if ( _life[x, y] <= 0 )
		{
			SetCell( x, y, Element.Stone );
			MarkUpdated( x, y );
			return;
		}

		if ( _random.NextDouble() < 0.28 )
		{
			MarkUpdated( x, y );
			return;
		}

		if ( TryMoveInto( x, y, x, y - 1, true ) ) return;
		int direction = RandomDirection();
		if ( TryMoveInto( x, y, x + direction, y - 1, true ) ) return;
		if ( TryMoveInto( x, y, x - direction, y - 1, true ) ) return;
		if ( _random.NextDouble() < 0.34 && TryMoveInto( x, y, x + direction, y, true ) ) return;
		MarkUpdated( x, y );
	}

	void SimulateMercury( int x, int y )
	{
		if ( TryMoveInto( x, y, x, y - 1, true ) ) return;
		int direction = RandomDirection();
		if ( TryMoveInto( x, y, x + direction, y - 1, true ) ) return;
		if ( TryMoveInto( x, y, x - direction, y - 1, true ) ) return;
		if ( TryMoveInto( x, y, x + direction, y, false ) ) return;
		TryMoveInto( x, y, x - direction, y, false );
	}

	void SimulateAcid( int x, int y )
	{
		if ( ReactAcid( x, y ) ) return;
		if ( TryMoveInto( x, y, x, y - 1, false ) ) return;
		int direction = RandomDirection();
		if ( TryMoveInto( x, y, x + direction, y - 1, false ) ) return;
		if ( TryMoveInto( x, y, x - direction, y - 1, false ) ) return;
		if ( TryMoveInto( x, y, x + direction, y, false ) ) return;
		TryMoveInto( x, y, x - direction, y, false );
	}

	void SimulateFire( int x, int y )
	{
		_life[x, y]--;
		SpreadFire( x, y );

		if ( _life[x, y] <= 0 )
		{
			SetCell( x, y, _random.NextDouble() < 0.55 ? Element.Smoke : Element.Empty );
			return;
		}

		if ( _random.NextDouble() < 0.32 && TryMoveInto( x, y, x + _random.Next( -1, 2 ), y + 1, false ) ) return;
		MarkUpdated( x, y );
	}

	void SimulateSmoke( int x, int y )
	{
		_life[x, y]--;
		if ( _life[x, y] <= 0 )
		{
			SetCell( x, y, Element.Empty );
			return;
		}

		if ( TryRise( x, y ) ) return;
		MarkUpdated( x, y );
	}

	void SimulateSteam( int x, int y )
	{
		_life[x, y]--;
		if ( _life[x, y] <= 0 )
		{
			SetCell( x, y, Element.Water );
			MarkUpdated( x, y );
			return;
		}

		if ( TryRise( x, y ) ) return;
		MarkUpdated( x, y );
	}

	void SimulateGas( int x, int y )
	{
		_life[x, y]--;
		if ( TouchingHot( x, y ) )
		{
			Explode( x, y, 6 );
			return;
		}

		if ( _life[x, y] <= 0 )
		{
			SetCell( x, y, Element.Smoke );
			return;
		}

		if ( TryRise( x, y ) ) return;
		int direction = RandomDirection();
		if ( _random.NextDouble() < 0.58 && TryMoveInto( x, y, x + direction, y, false ) ) return;
		MarkUpdated( x, y );
	}

	void SimulateCloud( int x, int y )
	{
		_life[x, y]--;
		if ( TouchingHot( x, y ) )
		{
			ReactCell( x, y, Element.Steam, "Warm cloud" );
			return;
		}

		if ( _life[x, y] <= 0 )
		{
			SetCell( x, y, Element.Empty );
			return;
		}

		if ( InBounds( x, y - 1 ) && _grid[x, y - 1] == Element.Empty && _random.NextDouble() < 0.018 )
		{
			SetCell( x, y - 1, Element.Water );
			RecordReaction( "Rain" );
		}

		if ( _random.NextDouble() < 0.46 && TryRise( x, y ) ) return;
		int direction = RandomDirection();
		if ( TryMoveInto( x, y, x + direction, y, false ) ) return;
		MarkUpdated( x, y );
	}

	void SimulateExplosive( int x, int y, int radius, string armedReaction )
	{
		if ( _life[x, y] > 0 )
		{
			_life[x, y]--;
			if ( _life[x, y] <= 0 )
				Explode( x, y, radius );
			else
				MarkUpdated( x, y );
			return;
		}

		if ( TouchingHot( x, y ) )
		{
			_life[x, y] = 6;
			MarkUpdated( x, y );
			RecordReaction( armedReaction );
		}
	}

	void SimulateSpark( int x, int y )
	{
		_life[x, y]--;
		IgniteNeighbors( x, y );

		if ( _life[x, y] <= 0 )
		{
			SetCell( x, y, Element.Fire );
			return;
		}

		int nx = x + _random.Next( -1, 2 );
		int ny = y + _random.Next( -1, 2 );
		if ( TryMoveInto( x, y, nx, ny, false ) ) return;
		MarkUpdated( x, y );
	}

	void SimulatePlant( int x, int y )
	{
		if ( TouchingHot( x, y ) )
		{
			ReactCell( x, y, Element.Fire, "Plant burn" );
			return;
		}

		if ( _random.NextDouble() > 0.06 || !TryFindNeighbor( x, y, Element.Water, out var waterX, out var waterY ) ) return;

		for ( int attempt = 0; attempt < 5; attempt++ )
		{
			int nx = x + _random.Next( -1, 2 );
			int ny = y + _random.Next( 0, 2 );
			if ( InBounds( nx, ny ) && _grid[nx, ny] == Element.Empty )
			{
				SetCell( nx, ny, Element.Plant );
				SetCell( waterX, waterY, Element.Empty );
				RecordReaction( "Growth" );
				return;
			}
		}
	}

	void SimulateSeed( int x, int y )
	{
		if ( TouchingHot( x, y ) )
		{
			ReactCell( x, y, Element.Fire, "Seed burn" );
			return;
		}

		if ( TryFindNeighbor( x, y, Element.Water, out var waterX, out var waterY ) )
		{
			SetCell( x, y, _random.NextDouble() < 0.24 ? Element.Vine : Element.Plant );
			if ( _random.NextDouble() < 0.56 ) SetCell( waterX, waterY, Element.Empty );
			RecordReaction( "Sprout" );
			return;
		}

		SimulateSand( x, y );
	}

	void SimulateVine( int x, int y )
	{
		if ( TouchingHot( x, y ) )
		{
			ReactCell( x, y, Element.Fire, "Vine burn" );
			return;
		}

		if ( _random.NextDouble() > 0.08 || !TryFindNeighbor( x, y, Element.Water, out var waterX, out var waterY ) ) return;

		for ( int attempt = 0; attempt < 6; attempt++ )
		{
			int nx = x + _random.Next( -1, 2 );
			int ny = y + _random.Next( 0, 2 );
			if ( InBounds( nx, ny ) && _grid[nx, ny] == Element.Empty )
			{
				SetCell( nx, ny, Element.Vine );
				SetCell( waterX, waterY, Element.Empty );
				RecordReaction( "Vine" );
				return;
			}
		}
	}

	void SimulateFuse( int x, int y )
	{
		if ( _life[x, y] > 0 )
		{
			_life[x, y]--;
			IgniteFuseNeighbors( x, y );
			if ( _life[x, y] <= 0 )
				SetCell( x, y, Element.Fire );
			else
				MarkUpdated( x, y );

			return;
		}

		if ( TouchingHot( x, y ) )
		{
			_life[x, y] = 5;
			MarkUpdated( x, y );
			RecordReaction( "Fuse" );
		}
	}

	void SimulateIce( int x, int y )
	{
		if ( TouchingHot( x, y ) )
		{
			ReactCell( x, y, Element.Water, "Melt" );
			return;
		}

		if ( Touching( x, y, Element.Salt ) && _random.NextDouble() < 0.08 )
			ReactCell( x, y, Element.Water, "Salt melt" );
	}

	void SimulateVirus( int x, int y )
	{
		_life[x, y]--;
		if ( TouchingHot( x, y ) )
		{
			ReactCell( x, y, Element.Smoke, "Virus burn" );
			return;
		}

		if ( _life[x, y] <= 0 )
		{
			SetCell( x, y, Element.Empty );
			return;
		}

		if ( _random.NextDouble() < 0.22 )
		{
			for ( int attempt = 0; attempt < 4; attempt++ )
			{
				int nx = x + _random.Next( -1, 2 );
				int ny = y + _random.Next( -1, 2 );
				if ( nx == x && ny == y || !InBounds( nx, ny ) ) continue;
				var target = _grid[nx, ny];
				if ( target is Element.Empty or Element.Wall or Element.Metal or Element.Glass or Element.Virus ) continue;

				SetCell( nx, ny, Element.Virus );
				RecordReaction( "Virus" );
				break;
			}
		}

		MarkUpdated( x, y );
	}

	void SimulateClone( int x, int y )
	{
		if ( !TryFindCloneSource( x, y, out var source ) ) return;

		for ( int attempt = 0; attempt < 6; attempt++ )
		{
			int nx = x + _random.Next( -1, 2 );
			int ny = y + _random.Next( -1, 2 );
			if ( nx == x && ny == y || !InBounds( nx, ny ) || _grid[nx, ny] != Element.Empty ) continue;

			SetCell( nx, ny, source );
			RecordReaction( "Clone" );
			return;
		}
	}

	bool ReactAcid( int x, int y )
	{
		for ( int attempt = 0; attempt < 4; attempt++ )
		{
			int nx = x + _random.Next( -1, 2 );
			int ny = y + _random.Next( -1, 2 );
			if ( nx == x && ny == y ) continue;
			if ( !InBounds( nx, ny ) ) continue;

			var target = _grid[nx, ny];
			if ( target is Element.Empty or Element.Wall or Element.Acid or Element.Smoke or Element.Steam or Element.Gas or Element.Cloud or Element.Fire ) continue;
			if ( target is Element.TNT or Element.C4 or Element.Bomb or Element.Nitro )
			{
				Explode( nx, ny, 7 );
				SetCell( x, y, Element.Smoke );
				RecordReaction( "Acid burst" );
				return true;
			}

			SetCell( nx, ny, _random.NextDouble() < 0.35 ? Element.Smoke : Element.Empty );
			if ( _random.NextDouble() < 0.12 ) SetCell( x, y, Element.Steam );
			RecordReaction( "Dissolve" );
			return true;
		}

		return false;
	}

	void HeatNeighbors( int x, int y )
	{
		for ( int dy = -1; dy <= 1; dy++ )
		{
			for ( int dx = -1; dx <= 1; dx++ )
			{
				if ( dx == 0 && dy == 0 ) continue;
				int nx = x + dx;
				int ny = y + dy;
				if ( !InBounds( nx, ny ) ) continue;

				switch ( _grid[nx, ny] )
				{
					case Element.Water:
					case Element.Ice:
						SetCell( nx, ny, Element.Steam );
						RecordReaction( "Steam" );
						break;
					case Element.Oil:
					case Element.Gas:
					case Element.Plant:
					case Element.Seed:
					case Element.Vine:
					case Element.Fuse:
						SetCell( nx, ny, Element.Fire );
						RecordReaction( "Ignition" );
						break;
					case Element.Sand:
						if ( _random.NextDouble() < 0.10 )
						{
							SetCell( nx, ny, Element.Glass );
							RecordReaction( "Glass" );
						}
						break;
					case Element.Nitro:
						Explode( nx, ny, 9 );
						break;
					case Element.TNT:
					case Element.C4:
					case Element.Bomb:
						if ( _life[nx, ny] <= 0 )
						{
							_life[nx, ny] = 5;
							RecordReaction( "Armed" );
						}
						break;
				}
			}
		}
	}

	void IgniteFuseNeighbors( int x, int y )
	{
		for ( int dy = -1; dy <= 1; dy++ )
		{
			for ( int dx = -1; dx <= 1; dx++ )
			{
				if ( dx == 0 && dy == 0 ) continue;
				int nx = x + dx;
				int ny = y + dy;
				if ( !InBounds( nx, ny ) ) continue;

				switch ( _grid[nx, ny] )
				{
					case Element.Fuse when _life[nx, ny] <= 0:
						_life[nx, ny] = 5;
						RecordReaction( "Fuse" );
						break;
					case Element.TNT:
					case Element.C4:
					case Element.Bomb:
						if ( _life[nx, ny] <= 0 )
						{
							_life[nx, ny] = 5;
							RecordReaction( "Armed" );
						}
						break;
					case Element.Nitro:
						Explode( nx, ny, 9 );
						break;
				}
			}
		}
	}

	void SpreadFire( int x, int y )
	{
		for ( int dy = -1; dy <= 1; dy++ )
		{
			for ( int dx = -1; dx <= 1; dx++ )
			{
				if ( dx == 0 && dy == 0 ) continue;
				int nx = x + dx;
				int ny = y + dy;
				if ( !InBounds( nx, ny ) ) continue;

				switch ( _grid[nx, ny] )
				{
					case Element.Water:
					case Element.Ice:
						SetCell( nx, ny, Element.Steam );
						SetCell( x, y, Element.Smoke );
						RecordReaction( "Steam" );
						return;
					case Element.Gas:
						Explode( nx, ny, 6 );
						return;
					case Element.Nitro:
						Explode( nx, ny, 9 );
						return;
					case Element.Oil:
					case Element.Plant:
					case Element.Seed:
					case Element.Vine:
					case Element.Fuse:
						if ( _random.NextDouble() < 0.32 )
						{
							SetCell( nx, ny, Element.Fire );
							RecordReaction( "Ignition" );
						}
						break;
					case Element.TNT:
					case Element.C4:
					case Element.Bomb:
						if ( _life[nx, ny] <= 0 )
						{
							_life[nx, ny] = 5;
							RecordReaction( "Armed" );
						}
						break;
				}
			}
		}
	}

	void IgniteNeighbors( int x, int y )
	{
		for ( int dy = -1; dy <= 1; dy++ )
		{
			for ( int dx = -1; dx <= 1; dx++ )
			{
				if ( dx == 0 && dy == 0 ) continue;
				int nx = x + dx;
				int ny = y + dy;
				if ( !InBounds( nx, ny ) ) continue;
				if ( _grid[nx, ny] is Element.Oil or Element.Gas or Element.Plant or Element.Seed or Element.Vine or Element.Fuse )
				{
					SetCell( nx, ny, Element.Fire );
					RecordReaction( "Ignition" );
				}
				else if ( _grid[nx, ny] == Element.Nitro )
				{
					Explode( nx, ny, 9 );
				}
				else if ( (_grid[nx, ny] is Element.TNT or Element.C4 or Element.Bomb) && _life[nx, ny] <= 0 )
				{
					_life[nx, ny] = 4;
					RecordReaction( "Armed" );
				}
			}
		}
	}

	void Explode( int centerX, int centerY, int radius )
	{
		int radiusSquared = radius * radius;
		for ( int y = centerY - radius; y <= centerY + radius; y++ )
		{
			for ( int x = centerX - radius; x <= centerX + radius; x++ )
			{
				if ( !InBounds( x, y ) ) continue;
				int dx = x - centerX;
				int dy = y - centerY;
				int distanceSquared = dx * dx + dy * dy;
				if ( distanceSquared > radiusSquared ) continue;
				if ( _grid[x, y] == Element.Wall ) continue;
				if ( _grid[x, y] == Element.Metal && _random.NextDouble() > 0.22 ) continue;

				double roll = _random.NextDouble();
				if ( roll < 0.18 ) SetCell( x, y, Element.Spark );
				else if ( roll < 0.58 ) SetCell( x, y, Element.Fire );
				else if ( roll < 0.82 ) SetCell( x, y, Element.Smoke );
				else SetCell( x, y, Element.Empty );
				MarkUpdated( x, y );
			}
		}

		Particles = CountParticles();
		_audio?.Explosion( radius );
		RecordReaction( "Boom" );
	}

	bool TryMoveInto( int x, int y, int nx, int ny, bool displaceLiquid )
	{
		if ( !InBounds( nx, ny ) ) return false;

		var target = _grid[nx, ny];
		if ( target == Element.Empty || IsGas( target ) || displaceLiquid && IsLiquid( target ) )
		{
			SwapCells( x, y, nx, ny );
			return true;
		}

		return false;
	}

	bool TryRise( int x, int y )
	{
		int direction = RandomDirection();
		if ( TryMoveInto( x, y, x, y + 1, false ) ) return true;
		if ( TryMoveInto( x, y, x + direction, y + 1, false ) ) return true;
		if ( TryMoveInto( x, y, x - direction, y + 1, false ) ) return true;
		if ( _random.NextDouble() < 0.35 && TryMoveInto( x, y, x + direction, y, false ) ) return true;
		return false;
	}

	void SwapCells( int x, int y, int nx, int ny )
	{
		var element = _grid[x, y];
		int life = _life[x, y];
		_grid[x, y] = _grid[nx, ny];
		_life[x, y] = _life[nx, ny];
		_grid[nx, ny] = element;
		_life[nx, ny] = life;
		MarkUpdated( x, y );
		MarkUpdated( nx, ny );
	}

	void ReactCell( int x, int y, Element element, string reaction )
	{
		SetCell( x, y, element );
		MarkUpdated( x, y );
		RecordReaction( reaction );
	}

	void SetCell( int x, int y, Element element, int life = -1 )
	{
		if ( !InBounds( x, y ) ) return;
		if ( element == Element.Eraser ) element = Element.Empty;

		var previous = _grid[x, y];
		_grid[x, y] = element;
		_life[x, y] = element == Element.Empty ? 0 : life >= 0 ? life : DefaultLife( element );

		if ( previous == Element.Empty && element != Element.Empty ) Particles++;
		if ( previous != Element.Empty && element == Element.Empty ) Particles--;
	}

	// =====================================================================
	//  Rendering - single dynamic texture with baked glow
	// =====================================================================
	void BuildRenderResources()
	{
		_texW = GridWidth * SS;
		_texH = GridHeight * SS;
		_pixels = new Color32[_texW * _texH];
		_background = new Color32[_texW * _texH];
		_glowR = new float[_texW * _texH];
		_glowG = new float[_texW * _texH];
		_glowB = new float[_texW * _texH];

		BuildGlowKernel();
		BuildBackground();

		GridTexture = Texture.Create( _texW, _texH, ImageFormat.RGBA8888 )
			.WithName( "science_grid" )
			.WithDynamicUsage()
			.WithData( _background )
			.Finish();
	}

	void BuildGlowKernel()
	{
		_glowRadius = SS * 2;
		int size = _glowRadius * 2 + 1;
		_glowKernel = new float[size * size];
		float sigma = _glowRadius * 0.62f;
		float twoSigmaSq = 2f * sigma * sigma;
		for ( int j = 0; j < size; j++ )
		{
			for ( int i = 0; i < size; i++ )
			{
				int dx = i - _glowRadius;
				int dy = j - _glowRadius;
				float d2 = dx * dx + dy * dy;
				_glowKernel[j * size + i] = MathF.Exp( -d2 / twoSigmaSq );
			}
		}
	}

	void BuildBackground()
	{
		float cx = _texW * 0.5f;
		float cy = _texH * 0.5f;
		float maxDist = MathF.Sqrt( cx * cx + cy * cy );
		var baseCol = EmptyColor;
		var gridCol = new Color( 0.085f, 0.10f, 0.13f );

		for ( int py = 0; py < _texH; py++ )
		{
			for ( int px = 0; px < _texW; px++ )
			{
				float dx = px - cx;
				float dy = py - cy;
				float dist = MathF.Sqrt( dx * dx + dy * dy ) / maxDist;
				float vignette = 1f - dist * dist * 0.55f;

				var col = baseCol * vignette;

				if ( ShowGrid )
				{
					// Faint lab grid every cell boundary.
					bool line = (px % SS == 0) || (py % SS == 0);
					if ( line ) col = Color.Lerp( col, gridCol, 0.5f );
				}

				_background[py * _texW + px] = ToColor32( col );
			}
		}
	}

	void RenderToTexture()
	{
		if ( _pixels == null || GridTexture == null || !GridTexture.IsValid ) return;

		Array.Copy( _background, _pixels, _pixels.Length );
		Array.Clear( _glowR, 0, _glowR.Length );
		Array.Clear( _glowG, 0, _glowG.Length );
		Array.Clear( _glowB, 0, _glowB.Length );

		for ( int y = 0; y < GridHeight; y++ )
		{
			int row0 = (GridHeight - 1 - y) * SS;
			for ( int x = 0; x < GridWidth; x++ )
			{
				var element = _grid[x, y];
				if ( element == Element.Empty ) continue;

				int col0 = x * SS;
				var color = ElementColor( element, _life[x, y] );
				DrawCellBlock( col0, row0, color, element );

				float glow = EmissiveStrength( element, _life[x, y] );
				if ( glow > 0f )
					SplatGlow( col0 + SS / 2, row0 + SS / 2, color, glow );
			}
		}

		CompositeGlow();
		DrawBrushPreview();
		GridTexture.Update( _pixels, 0, 0, _texW, _texH );
	}

	// Outline the brush footprint at the hovered cell so painting has a clear
	// aim point. A bright ring marks the edge; a faint wash marks the fill area.
	void DrawBrushPreview()
	{
		if ( !_hasHover ) return;

		Color tint = CurrentTool == Element.Eraser
			? new Color( 1f, 0.42f, 0.42f )
			: ElementColor( CurrentTool, 0 );
		// Keep the ring readable even for dark materials.
		tint = Color.Lerp( tint, new Color( 1f, 1f, 1f ), 0.35f );

		float r = BrushRadius;
		int span = BrushRadius + 1;
		for ( int dy = -span; dy <= span; dy++ )
		{
			for ( int dx = -span; dx <= span; dx++ )
			{
				int gx = _hoverX + dx;
				int gy = _hoverY + dy;
				if ( !InBounds( gx, gy ) ) continue;

				float dist = MathF.Sqrt( dx * dx + dy * dy );
				float ringDelta = MathF.Abs( dist - r );
				if ( ringDelta <= 0.7f )
					BlendCellBlock( gx, gy, tint, 0.8f );
				else if ( dist < r )
					BlendCellBlock( gx, gy, tint, 0.09f );
			}
		}
	}

	void BlendCellBlock( int gx, int gy, Color color, float alpha )
	{
		int col0 = gx * SS;
		int row0 = (GridHeight - 1 - gy) * SS;
		for ( int ry = 0; ry < SS; ry++ )
		{
			int py = row0 + ry;
			if ( py < 0 || py >= _texH ) continue;
			int rowIdx = py * _texW + col0;
			for ( int rx = 0; rx < SS; rx++ )
			{
				int px = col0 + rx;
				if ( px < 0 || px >= _texW ) continue;
				var bg = FromColor32( _pixels[rowIdx + rx] );
				_pixels[rowIdx + rx] = ToColor32( Color.Lerp( bg, color, alpha ) );
			}
		}
	}

	void DrawCellBlock( int col0, int row0, Color color, Element element )
	{
		bool translucent = IsGas( element ) || element is Element.Glass or Element.Water or Element.Oil or Element.Acid or Element.Mercury;
		float alpha = color.a;

		for ( int ry = 0; ry < SS; ry++ )
		{
			int py = row0 + ry;
			if ( py < 0 || py >= _texH ) continue;

			// Subtle bevel: top rows brighter, bottom rows darker for powder depth.
			float shade = 1f + (1f - ry / (float)(SS - 1)) * 0.16f - 0.12f * (ry / (float)(SS - 1));
			int rowIdx = py * _texW + col0;

			for ( int rx = 0; rx < SS; rx++ )
			{
				int px = col0 + rx;
				if ( px < 0 || px >= _texW ) continue;

				var shaded = new Color( color.r * shade, color.g * shade, color.b * shade );
				if ( translucent && alpha < 0.999f )
				{
					// Blend over existing background for see-through materials.
					var bg = FromColor32( _pixels[rowIdx + rx] );
					shaded = Color.Lerp( bg, shaded, alpha );
				}
				_pixels[rowIdx + rx] = ToColor32( shaded );
			}
		}
	}

	void SplatGlow( int centerX, int centerY, Color color, float strength )
	{
		int size = _glowRadius * 2 + 1;
		for ( int j = 0; j < size; j++ )
		{
			int py = centerY - _glowRadius + j;
			if ( py < 0 || py >= _texH ) continue;
			int rowIdx = py * _texW;
			for ( int i = 0; i < size; i++ )
			{
				int px = centerX - _glowRadius + i;
				if ( px < 0 || px >= _texW ) continue;
				float k = _glowKernel[j * size + i] * strength;
				int idx = rowIdx + px;
				_glowR[idx] += color.r * k;
				_glowG[idx] += color.g * k;
				_glowB[idx] += color.b * k;
			}
		}
	}

	void CompositeGlow()
	{
		for ( int i = 0; i < _pixels.Length; i++ )
		{
			float gr = _glowR[i];
			if ( gr <= 0.0001f && _glowG[i] <= 0.0001f && _glowB[i] <= 0.0001f ) continue;

			var c = _pixels[i];
			int r = c.r + (int)(MathF.Min( gr, 1.7f ) * 255f);
			int g = c.g + (int)(MathF.Min( _glowG[i], 1.7f ) * 255f);
			int b = c.b + (int)(MathF.Min( _glowB[i], 1.7f ) * 255f);
			_pixels[i] = new Color32( (byte)Math.Min( r, 255 ), (byte)Math.Min( g, 255 ), (byte)Math.Min( b, 255 ), 255 );
		}
	}

	float EmissiveStrength( Element element, int life )
	{
		return element switch
		{
			Element.Fire => 1.1f,
			Element.Spark => 1.5f,
			Element.Lava => 0.95f,
			Element.Nitro => 0.30f,
			Element.Clone => 0.42f,
			Element.Virus => 0.36f,
			Element.Acid => 0.30f,
			_ => 0f
		};
	}

	Color ElementColor( Element element, int life )
	{
		return element switch
		{
			Element.Wall => WallColor,
			Element.Sand => Jitter( SandColor, 0.06f ),
			Element.Water => WaterColor,
			Element.Oil => OilColor,
			Element.Fire => life % 3 == 0 ? SparkColor : FireColor,
			Element.Smoke => new Color( SmokeColor.r, SmokeColor.g, SmokeColor.b, Math.Clamp( life / 120f, 0.22f, 0.75f ) ),
			Element.Steam => new Color( SteamColor.r, SteamColor.g, SteamColor.b, Math.Clamp( life / 150f, 0.20f, 0.72f ) ),
			Element.Gas => new Color( GasColor.r, GasColor.g, GasColor.b, Math.Clamp( life / 220f, 0.24f, 0.62f ) ),
			Element.Cloud => new Color( CloudColor.r, CloudColor.g, CloudColor.b, Math.Clamp( life / 360f, 0.22f, 0.68f ) ),
			Element.TNT => life > 0 && life % 2 == 0 ? SparkColor : TntColor,
			Element.C4 => life > 0 && life % 2 == 0 ? SparkColor : C4Color,
			Element.Bomb => life > 0 && life % 2 == 0 ? TntColor : BombColor,
			Element.Nitro => NitroColor,
			Element.Spark => SparkColor,
			Element.Acid => AcidColor,
			Element.Plant => PlantColor,
			Element.Seed => SeedColor,
			Element.Vine => VineColor,
			Element.Fuse => life > 0 && life % 2 == 0 ? SparkColor : FuseColor,
			Element.Metal => MetalColor,
			Element.Stone => Jitter( StoneColor, 0.04f ),
			Element.Glass => GlassColor,
			Element.Ice => IceColor,
			Element.Salt => Jitter( SaltColor, 0.03f ),
			Element.Mud => MudColor,
			Element.Lava => life % 4 == 0 ? SparkColor : LavaColor,
			Element.Mercury => MercuryColor,
			Element.Virus => life % 5 == 0 ? AcidColor : VirusColor,
			Element.Clone => life % 2 == 0 ? CloneColor : SparkColor,
			_ => EmptyColor
		};
	}

	Color Jitter( Color color, float amount )
	{
		float offset = ((float)_random.NextDouble() - 0.5f) * amount;
		return new Color(
			Math.Clamp( color.r + offset, 0f, 1f ),
			Math.Clamp( color.g + offset, 0f, 1f ),
			Math.Clamp( color.b + offset, 0f, 1f ),
			color.a
		);
	}

	static Color32 ToColor32( Color c ) => new(
		(byte)(Math.Clamp( c.r, 0f, 1f ) * 255f),
		(byte)(Math.Clamp( c.g, 0f, 1f ) * 255f),
		(byte)(Math.Clamp( c.b, 0f, 1f ) * 255f),
		(byte)(Math.Clamp( c.a, 0f, 1f ) * 255f) );

	static Color FromColor32( Color32 c ) => new( c.r / 255f, c.g / 255f, c.b / 255f, c.a / 255f );

	// =====================================================================
	//  Scene helpers
	// =====================================================================
	void BuildBoundaryCells()
	{
		for ( int x = 0; x < GridWidth; x++ )
			SetCell( x, 0, Element.Wall );
		for ( int y = 0; y < GridHeight; y++ )
		{
			SetCell( 0, y, Element.Wall );
			SetCell( GridWidth - 1, y, Element.Wall );
		}
	}

	void EnsureCameraBackground()
	{
		var camera = Scene.GetAllComponents<CameraComponent>().FirstOrDefault( c => c.IsMainCamera )
			?? Scene.GetAllComponents<CameraComponent>().FirstOrDefault();
		if ( camera == null ) return;

		camera.BackgroundColor = new Color( 0.02f, 0.025f, 0.035f );
		camera.ClearFlags = ClearFlags.Color | ClearFlags.Depth | ClearFlags.Stencil;
	}

	void DisableSkybox()
	{
		foreach ( var sky in Scene.GetAllComponents<SkyBox2D>() )
			sky.Enabled = false;
		foreach ( var probe in Scene.GetAllComponents<EnvmapProbe>() )
			probe.Enabled = false;
	}

	void EnsureAudio()
	{
		_audio = GameObject.Components.Get<ScienceAudio>() ?? GameObject.Components.Create<ScienceAudio>();
	}

	void EnsureHud()
	{
		var existingHud = Scene.GetAllComponents<ScienceHud>().FirstOrDefault();
		if ( existingHud != null )
		{
			existingHud.Game = this;
			ConfigureHudScreenPanel( existingHud.GameObject );
			return;
		}

		var hudObject = Scene.CreateObject();
		hudObject.Name = "Science HUD";
		ConfigureHudScreenPanel( hudObject );
		var hud = hudObject.Components.Create<ScienceHud>();
		hud.Game = this;
	}

	void ConfigureHudScreenPanel( GameObject hudObject )
	{
		var screenPanel = hudObject.Components.Get<ScreenPanel>() ?? hudObject.Components.Create<ScreenPanel>();
		screenPanel.AutoScreenScale = false;
	}

	// =====================================================================
	//  Small helpers
	// =====================================================================
	bool TryFindCloneSource( int x, int y, out Element source )
	{
		for ( int dy = -1; dy <= 1; dy++ )
		{
			for ( int dx = -1; dx <= 1; dx++ )
			{
				if ( dx == 0 && dy == 0 ) continue;
				int nx = x + dx;
				int ny = y + dy;
				if ( !InBounds( nx, ny ) ) continue;
				var element = _grid[nx, ny];
				if ( CanClone( element ) )
				{
					source = element;
					return true;
				}
			}
		}

		source = Element.Empty;
		return false;
	}

	bool TryFindNeighbor( int x, int y, Element element, out int foundX, out int foundY )
	{
		for ( int dy = -1; dy <= 1; dy++ )
		{
			for ( int dx = -1; dx <= 1; dx++ )
			{
				if ( dx == 0 && dy == 0 ) continue;
				int nx = x + dx;
				int ny = y + dy;
				if ( InBounds( nx, ny ) && _grid[nx, ny] == element )
				{
					foundX = nx;
					foundY = ny;
					return true;
				}
			}
		}

		foundX = 0;
		foundY = 0;
		return false;
	}

	void RecordReaction( string reaction )
	{
		// The menu backdrop reacts constantly; don't let it bank score, fill the
		// notebook, or play chimes before the player has even entered the lab.
		if ( AttractMode )
		{
			LastReaction = reaction;
			return;
		}

		LastReaction = reaction;
		Reactions++;
		if ( _discovered.Add( reaction ) )
		{
			_recentReactions.Insert( 0, reaction );
			if ( _recentReactions.Count > 8 ) _recentReactions.RemoveAt( _recentReactions.Count - 1 );
			_audio?.Discovery();
		}
		else
		{
			PlayReactionSound( reaction );
		}
	}

	// Maps ongoing reactions to ambient crackle / hiss. Play() rate-limits each
	// channel, so a roaring fire becomes a steady texture rather than noise.
	void PlayReactionSound( string reaction )
	{
		switch ( reaction )
		{
			case "Ignition":
			case "Oil burn":
			case "Plant burn":
			case "Seed burn":
			case "Vine burn":
			case "Fuse":
			case "Virus burn":
				_audio?.Ignite();
				break;
			case "Steam":
			case "Quench":
			case "Melt":
			case "Salt melt":
			case "Warm cloud":
				_audio?.Quench();
				break;
		}
	}

	void StartExperiment( string name, string objective, int reactionTarget )
	{
		ActiveExperimentName = name;
		ObjectiveText = objective;
		_objectiveStartReactions = Reactions;
		_objectiveTarget = Math.Max( 1, reactionTarget );
		_objectiveComplete = false;
		LastReaction = name;
		AddMilestone( $"Loaded {name}", $"experiment:{name}" );
	}

	void UpdateProgression()
	{
		if ( !_objectiveComplete && Reactions - _objectiveStartReactions >= _objectiveTarget )
		{
			_objectiveComplete = true;
			_completedExperiments++;
			LastReaction = $"{ActiveExperimentName} complete";
			AddMilestone( $"{ActiveExperimentName} complete", $"complete:{ActiveExperimentName}:{_completedExperiments}", playSound: false );
			_audio?.Complete();
		}

		if ( Discoveries >= 3 ) AddMilestone( "First findings", "discoveries:3" );
		if ( Discoveries >= 8 ) AddMilestone( "Reaction catalog", "discoveries:8" );
		if ( Discoveries >= 16 ) AddMilestone( "Advanced chemistry", "discoveries:16" );
		if ( Discoveries >= 24 ) AddMilestone( "Full notebook", "discoveries:24" );
		if ( Reactions >= 25 ) AddMilestone( "Chain reactor", "reactions:25" );
		if ( Reactions >= 75 ) AddMilestone( "Unstable lab", "reactions:75" );
		if ( Particles >= 2500 ) AddMilestone( "Dense world", "particles:2500" );
	}

	void AddMilestone( string label, string key, bool playSound = true )
	{
		if ( !_milestoneKeys.Add( key ) ) return;
		_milestones.Insert( 0, label );
		if ( _milestones.Count > 6 ) _milestones.RemoveAt( _milestones.Count - 1 );
		if ( playSound ) _audio?.Milestone();
	}

	void MarkUpdated( int x, int y )
	{
		if ( InBounds( x, y ) ) _updated[x, y] = _tickId;
	}

	bool TouchingHot( int x, int y ) => Touching( x, y, Element.Fire ) || Touching( x, y, Element.Spark ) || Touching( x, y, Element.Lava );
	bool Touching( int x, int y, Element element ) => TryFindNeighbor( x, y, element, out _, out _ );

	bool InBounds( int x, int y ) => x >= 0 && x < GridWidth && y >= 0 && y < GridHeight;
	bool IsBoundaryCell( int x, int y ) => x == 0 || y == 0 || x == GridWidth - 1;
	int RandomDirection() => _random.Next( 2 ) == 0 ? -1 : 1;
	static bool IsGas( Element element ) => element is Element.Smoke or Element.Steam or Element.Gas or Element.Cloud;
	static bool IsLiquid( Element element ) => element is Element.Water or Element.Oil or Element.Acid or Element.Nitro or Element.Lava or Element.Mercury or Element.Mud;
	static bool CanClone( Element element ) => element is not Element.Empty and not Element.Wall and not Element.Clone and not Element.Eraser;

	int CountParticles()
	{
		int count = 0;
		for ( int y = 0; y < GridHeight; y++ )
			for ( int x = 0; x < GridWidth; x++ )
				if ( _grid[x, y] != Element.Empty ) count++;
		return count;
	}

	int DefaultLife( Element element )
	{
		return element switch
		{
			Element.Fire => _random.Next( 18, 42 ),
			Element.Smoke => _random.Next( 90, 145 ),
			Element.Steam => _random.Next( 100, 170 ),
			Element.Gas => _random.Next( 150, 230 ),
			Element.Cloud => _random.Next( 260, 420 ),
			Element.Spark => _random.Next( 9, 18 ),
			Element.Lava => _random.Next( 260, 460 ),
			Element.Virus => _random.Next( 160, 260 ),
			_ => 0
		};
	}

	// =====================================================================
	//  Metadata used by the HUD (names, categories, descriptions)
	// =====================================================================
	public static ElementCategory CategoryOf( Element element )
	{
		return element switch
		{
			Element.Sand or Element.Salt or Element.Mud or Element.Seed => ElementCategory.Powders,
			Element.Water or Element.Oil or Element.Acid or Element.Mercury or Element.Lava => ElementCategory.Liquids,
			Element.Smoke or Element.Steam or Element.Gas or Element.Cloud => ElementCategory.Gases,
			Element.Fire or Element.Spark => ElementCategory.Energy,
			Element.Plant or Element.Vine or Element.Virus or Element.Clone => ElementCategory.Life,
			Element.TNT or Element.C4 or Element.Bomb or Element.Nitro or Element.Fuse => ElementCategory.Explosives,
			Element.Metal or Element.Stone or Element.Glass or Element.Ice or Element.Wall => ElementCategory.Solids,
			_ => ElementCategory.Tools
		};
	}

	public static string CategoryName( ElementCategory category ) => category switch
	{
		ElementCategory.Powders => "Powders",
		ElementCategory.Liquids => "Liquids",
		ElementCategory.Gases => "Gases",
		ElementCategory.Energy => "Energy",
		ElementCategory.Life => "Life",
		ElementCategory.Explosives => "Explosives",
		ElementCategory.Solids => "Solids",
		_ => "Tools"
	};

	public static string Describe( Element element ) => element switch
	{
		Element.Sand => "Granular powder. Piles into slopes; bakes to glass near heat.",
		Element.Water => "Flows and levels out. Boils to steam, makes mud with sand.",
		Element.Oil => "Floats on water and ignites on contact with heat.",
		Element.Fire => "Spreads to fuels, boils water, arms explosives, fades to smoke.",
		Element.Smoke => "Rises and dissipates after fire burns out.",
		Element.Steam => "Hot vapour from boiled water; condenses back to water.",
		Element.Gas => "Flammable vapour that explodes when it touches heat.",
		Element.Cloud => "Drifts upward and occasionally rains water below.",
		Element.Spark => "Skittering energy that ignites fuels then becomes fire.",
		Element.Acid => "Dissolves most materials; bursts explosives.",
		Element.Plant => "Grows toward water; burns readily.",
		Element.Seed => "Falls like sand, sprouts into plant or vine near water.",
		Element.Vine => "Climbing growth that creeps toward water.",
		Element.Salt => "Powder that dissolves in water and melts ice.",
		Element.Mud => "Heavy sludge that bakes into stone when heated.",
		Element.Ice => "Frozen solid; melts to water near heat or salt.",
		Element.Lava => "Molten rock. Cools to stone, quenches to steam in water.",
		Element.Mercury => "Dense liquid metal that pools and flows.",
		Element.TNT => "Classic explosive. Arms from heat or fuse, then detonates.",
		Element.C4 => "High-yield charge with a larger blast radius.",
		Element.Bomb => "Massive payload for the biggest explosions.",
		Element.Nitro => "Volatile liquid that detonates the instant it touches heat.",
		Element.Fuse => "Burns along its length to trigger distant charges.",
		Element.Metal => "Sturdy barrier that mostly resists blasts.",
		Element.Stone => "Solid rock that tumbles and piles.",
		Element.Glass => "Transparent solid formed when sand is superheated.",
		Element.Virus => "Infects and converts nearby matter; killed by fire.",
		Element.Clone => "Copies whatever material touches it into empty space.",
		Element.Wall => "Indestructible boundary that nothing can pass.",
		Element.Eraser => "Removes material from the canvas.",
		_ => ""
	};

	public static string ToolName( Element element ) => element switch
	{
		Element.Empty => "Empty",
		Element.Wall => "Wall",
		Element.Sand => "Sand",
		Element.Water => "Water",
		Element.Oil => "Oil",
		Element.Fire => "Fire",
		Element.Smoke => "Smoke",
		Element.Steam => "Steam",
		Element.Gas => "Gas",
		Element.Cloud => "Cloud",
		Element.TNT => "TNT",
		Element.C4 => "C4",
		Element.Bomb => "Bomb",
		Element.Nitro => "Nitro",
		Element.Spark => "Spark",
		Element.Acid => "Acid",
		Element.Plant => "Plant",
		Element.Seed => "Seed",
		Element.Vine => "Vine",
		Element.Fuse => "Fuse",
		Element.Metal => "Metal",
		Element.Stone => "Stone",
		Element.Glass => "Glass",
		Element.Ice => "Ice",
		Element.Salt => "Salt",
		Element.Mud => "Mud",
		Element.Lava => "Lava",
		Element.Mercury => "Mercury",
		Element.Virus => "Virus",
		Element.Clone => "Clone",
		Element.Eraser => "Erase",
		_ => element.ToString()
	};
}