Stages/GameStage.cs
using System.Collections.Generic;

namespace BlockParty;

/// <summary>
/// Main gameplay stage. Port of the original <c>GameStage</c>: owns the player, the
/// blocks, projectiles and particles, drives them in a fixed per-step order, and tracks
/// the win condition (all blocks reach max phase).
///
/// Phase 3a: player + 5 blocks + screenshake + win/lose restart. Projectiles/particles
/// (phase 4), block spikes/blink/speed-lines (phase 3b), and subtype attacks (phase 5)
/// are not active yet.
/// </summary>
public sealed class GameStage : StageBase
{
	public readonly List<Block> Blocks = new();
	public readonly List<Fireball> Fireballs = new();
	public readonly List<Bullet> Bullets = new();
	public readonly List<SwapperProjectile> SwapperProjectiles = new();
	public readonly List<BlockBulletProjectile> BlockBullets = new();
	public readonly List<Teardrop> Teardrops = new();
	public readonly List<Particle> Particles = new();
	public readonly List<Portal> Portals = new();
	public readonly List<ImpostorPortal> ImpostorPortals = new();
	public readonly List<Coin> Coins = new();
	public readonly List<CoinFloater> CoinFloaters = new();
	public readonly List<Player> Players = new();

	/// <summary>Coins picked up this run. Sim state (derived from deterministic overlap, no RNG):
	/// read at EndGame for the tally, the submitted score, AND the replay-verify recompute, all from
	/// this one field so they can't diverge.</summary>
	public int CoinsCollected { get; private set; }

	/// <summary>Secondary player bodies: hostile Summoner impostors and friendly character copies.
	/// They share physics and hazard iteration while contact allegiance and chase targets remain explicit.</summary>
	public readonly List<Player> Impostors = new();

	// How much the impostor's box must overlap the player's box (on BOTH axes, in px) to count as a
	// kill. The player box is 8x10, so this needs the clone well ON the player, not just edge-touching.
	const float IMPOSTOR_KILL_OVERLAP = 4.0f;

	/// <summary>Static solid interior wall regions for this level (empty for the classic square arena).
	/// Does NOT include fences — those live in <see cref="_fences"/> so every player/hazard path that
	/// iterates this list ignores them for free.</summary>
	private readonly List<Obstacle> _obstacles = new();

	/// <summary>FENCES: obstacles flagged solid ONLY to blocks (see <see cref="LevelDef.Fences"/>).
	/// Kept out of <see cref="_obstacles"/> — only the block-movement collision paths
	/// (<see cref="GetBlockSolidObstacles"/> / <see cref="GetFences"/>) ever see them.</summary>
	private readonly List<Obstacle> _fences = new();

	/// <summary>The NON-fence subset of <c>Level.Obstacles</c>, split once in <see cref="SpawnArena"/>.
	/// All the wall-face building helpers (flush cutting, elbow/corner patches, band coverage) iterate
	/// this instead of <c>Level.Obstacles</c>: a fence never cuts an arena side or another obstacle's
	/// face, never merges with them, and never carries faces of its own.</summary>
	private readonly List<RectF> _wallObstacleRects = new();

	/// <summary>The FENCE subset of <c>Level.Obstacles</c>, split in the same <see cref="SpawnArena"/>
	/// pass as <see cref="_wallObstacleRects"/>. The fence frame builder consults this — not the
	/// still-filling <see cref="_fences"/> entity list — so the first fence built already sees every
	/// flush fence neighbour.</summary>
	private readonly List<RectF> _fenceRects = new();

	/// <summary>The GLASS subset of <c>Level.Obstacles</c> (see <see cref="LevelDef.Glass"/>), split
	/// in the same pass. Glass ENTITIES live inside <see cref="_obstacles"/> (flagged
	/// <see cref="Obstacle.IsGlass"/>) so every player path treats them as walls; this rect list
	/// exists for the visual builders (border seam cutting between flush glass panels).</summary>
	private readonly List<RectF> _glassRects = new();

	/// <summary>Every fence-frame / glass-border strip actually drawn (the seam-cut survivors),
	/// recorded so the frame elbow pass can probe coverage once ALL panels exist — the 1px-outline
	/// analogue of the <see cref="_faces"/> list (see <see cref="AddFrameElbowPatches"/>).</summary>
	private struct FrameSeg { public bool Horizontal; public float Perp; public float Min, Max; }
	private readonly List<FrameSeg> _fenceFrameSegs = new();
	private readonly List<FrameSeg> _glassBorderSegs = new();
	private readonly List<Vector2> _playerStepStarts = new();

	private TwinsController _twinsController;
	// The looping translucent movement demo, present only on levels with an authored ghost clip
	// (Assets/ghosts/<level-id>.json). Cosmetic-only; see GhostPlayback.
	private GhostPlayback _ghost;
	public Player Player => _twinsController?.ActivePlayer ?? (Players.Count > 0 ? Players[0] : null);
	public int ActivePlayerIndex => _twinsController?.ActiveIndex ?? 0;

	/// <summary>The level this run plays, resolved from the run context on enter (classic when the
	/// context carries no level — e.g. daily challenges).</summary>
	public LevelDef Level { get; private set; }

	// The level whose palette drives the per-stage colour hooks. Read from the run context (set before
	// Stage.Enter, so it's available even though the Level field above is only assigned in OnEnter,
	// which runs AFTER the base Enter() spawns the playfield / sets the camera colour).
	private LevelDef PaletteLevel => Manager.CurrentRunContext.Level;

	protected override Color EffectiveClearColor => PaletteLevel?.OutOfBoundsColor ?? base.EffectiveClearColor;
	protected override string PlayfieldSprite => PaletteLevel?.CheckerboardColor is not null ? "sprites/background_neutral.sprite" : base.PlayfieldSprite;
	protected override Color PlayfieldTint => PaletteLevel?.CheckerboardColor is Color c ? GammaToLinear( c ) : base.PlayfieldTint;
	protected override PlayfieldPattern PlayfieldPatternKind => PaletteLevel?.PlayfieldPattern ?? PlayfieldPattern.Checker;
	protected override int PlayfieldCellScale => PaletteLevel?.PlayfieldCellScale ?? 1;
	protected override System.Collections.Generic.IReadOnlyList<string> PlayfieldPatternRows => PaletteLevel?.PlayfieldPatternRows;
	protected override Color PlayfieldPatternColor => PaletteLevel?.CheckerboardColor ?? base.PlayfieldPatternColor;
	protected override Color? PlayfieldPatternSecondColor => PaletteLevel?.CheckerboardSecondColor;
	protected override bool AlternatePlayfieldEnabled => PaletteLevel?.AlternatePlayfieldEnabled ?? false;
	protected override Color AlternatePlayfieldColor => PaletteLevel?.AlternateCheckerboardColor ?? PlayfieldPatternColor;
	protected override Color? AlternatePlayfieldSecondColor => PaletteLevel?.AlternateCheckerboardSecondColor;
	protected override PlayfieldPattern AlternatePlayfieldPatternKind => PaletteLevel?.AlternatePlayfieldPattern ?? PlayfieldPattern.Checker;
	protected override int AlternatePlayfieldCellScale => PaletteLevel?.AlternatePlayfieldCellScale ?? 1;
	protected override System.Collections.Generic.IReadOnlyList<string> AlternatePlayfieldPatternRows => PaletteLevel?.AlternatePlayfieldPatternRows;
	protected override System.Collections.Generic.IReadOnlyList<RectF> AlternatePlayfieldRects => PaletteLevel?.AlternatePlayfieldRects;
	protected override Color? BackgroundBlockBaseColor => PaletteLevel?.BackgroundBlockColor;
	protected override System.Collections.Generic.IReadOnlyList<Color> BackgroundBlockColors => PaletteLevel?.BackgroundBlockColors;
	protected override float BackgroundBlockScale => PaletteLevel?.BackgroundBlockScale ?? 1f;
	protected override float BackgroundBlockDensity => PaletteLevel?.BackgroundBlockDensity ?? 1f;
	protected override float BackgroundBlockOpacity => PaletteLevel?.BackgroundBlockOpacity ?? 1f;
	protected override float BackgroundDriftSpeed => PaletteLevel?.BackgroundDriftSpeed ?? 1f;
	protected override BackgroundDriftBias BackgroundBlockDriftBias => PaletteLevel?.BackgroundDriftBias ?? BackgroundDriftBias.None;

	// --- background particle ambience (see StageBase.TickBackgroundParticles) -----------
	protected override BackgroundParticleSettings BuildBackgroundParticleSettings()
		=> BackgroundParticleSettings.FromLevel( PaletteLevel );

	protected override void CollectBackgroundParticleStaticSolids( System.Collections.Generic.List<RectF> solids )
	{
		// Ordinary obstacles AND glass, never fences — the same rule as GetParticleSolidObstacles.
		solids.AddRange( _wallObstacleRects );
		solids.AddRange( _glassRects );
	}

	protected override void CollectBackgroundParticleDynamicSolids( System.Collections.Generic.List<RectF> solids )
	{
		foreach ( var block in Blocks )
			if ( !block.IsDead && !block.PhasingIn )
				solids.Add( block.GetRect() );
	}

	// WallColor is the direct displayed colour of the baked art's opaque 58/64/76 wall pixel. The
	// SpriteRenderer still multiplies texture by tint internally, so derive the linear multiplier that
	// maps that source pixel to the authored colour while preserving the art's alpha and shading.
	private Color WallColorLinear => GammaToLinear( PaletteLevel?.WallColor ?? WALL_FILL_COLOR );
	private Color WallSpriteTintLinear
	{
		get
		{
			Color source = GammaToLinear( WALL_FILL_COLOR );
			Color target = WallColorLinear;
			return new Color( target.r / source.r, target.g / source.g, target.b / source.b, target.a );
		}
	}

	// TEMP DEBUG
	private static readonly bool DEBUG_STARTING_BLOCKS = false;

	// --- in-game options overlay --------------------------------------------------------
	// Opening the gear menu freezes the simulation (see BlocksSimulation) and shows the shared
	// volume options over the arena, without ducking the music (so the player hears an accurate
	// level while adjusting). The game state is preserved — closing resumes exactly where it left.
	public OptionsMenuController OptionsController { get; }
	public bool MenuOpen { get; private set; }

	/// <summary>True while the replay HUD's watch-a-code dialog is up (set by GameHud, which owns the
	/// dialog). Folded into <see cref="WantsQuantize"/> so its text isn't mushed by the 240 grid.</summary>
	public bool ReplayImportOpen { get; set; }

	// --- leave-run confirm ------------------------------------------------------------------
	// Back/Home on a live, limited-attempt daily run first ask "leave?" (see NeedsLeaveConfirm): the
	// attempt was spent at launch, so bailing early throws it away. The intercepted action is held
	// here until the prompt's LEAVE carries it out; the prompt freezes the sim like the options menu.
	public enum LeaveAction { None, Back, Home }
	private LeaveAction _pendingLeave;
	public bool LeaveConfirmOpen => _pendingLeave != LeaveAction.None;
	/// <summary>Focused prompt button: 0 = KEEP PLAYING (default), 1 = LEAVE.</summary>
	public int LeaveConfirmSelection { get; private set; }
	/// <summary>Bumped on every prompt open/close/focus change so the HUD repaints.</summary>
	public int LeaveConfirmRevision { get; private set; }

	// The only stage that quantizes to the 240 grid — and only while actually playing; the fade
	// goes out while the in-game options menu covers the arena and snaps back in on resume.
	public override bool WantsQuantize => !MenuOpen && !ShowCharacterHelp && !ReplayImportOpen && !LeaveConfirmOpen;

	/// <summary>True when this stage is replaying a recorded run (driven by recorded input). The
	/// must not tally/submit a new score — it just returns to the leaderboard when it ends.</summary>
	public bool IsReplay { get; }

	/// <summary>Sim version this stage's fixed-step sim runs at — the recorded version during a
	/// replay, <see cref="Sim.VERSION"/> live. Version gates in the sim compare against this, never
	/// against Sim.VERSION directly (see that constant's doc).</summary>
	public int SimVersion => Manager.CurrentRunSimVersion;

	/// <summary>True when this is a level-editor TEST run of an unsaved level: it never submits/tallies,
	/// and pressing Back (or an unattended death) returns to the editor with its state intact.
	/// Set once at construction (see <see cref="GameManager.StartLevelTest"/>).</summary>
	public bool IsTest { get; set; }

	/// <summary>True when this is a DEBUG daily run (daily_play ConCmd): plays a past/future day's
	/// generated daily without submitting, marking progress, or counting an attempt. Unlike
	/// <see cref="IsTest"/> the end-of-run flow is the normal daily one (score screen → daily hub),
	/// not the level editor. Set once at construction (see <see cref="GameManager.StartDailyChallengeDebug"/>).</summary>
	public bool IsDebugRun { get; set; }

	private GameHud _hud;
	public ReplayReactionTooltip ReactionTooltip { get; private set; }
	private ReplayReactionSprites _reactionSprites;

	// Game-over: in the original, death OR winning (all blocks max phase) ends the run and
	// shows the score. We hold for a beat, then submit the score and open the highscore board.
	private float _gameTime;
	private bool _gameOver;
	private float _gameOverTimer;
	private bool _ended;
	// Death (a player death) rather than a win triggered the game-over beat. Gates the in-game
	// restart affordance, which is offered only after the player dies.
	private bool _died;
	// Any body died this run, even one whose partner carried on (a Twins run survives its first death).
	// Feeds the flawless "beat X as Twins" pairing; _died alone can't see a death the run outlived.
	private bool _anyPlayerDied;
	// Captured when gameplay begins so a victory can distinguish first completion from a replay for score.
	private bool _levelWasBeatenOnEntry;
	// The run's score + replay are submitted exactly once, whether the game-over beat plays out into
	// the score tally or the player bails early (restart/home) after the run is decided.
	private bool _submitted;
	// Death ends the run after a short beat; a win holds a touch longer so the death-throes
	// (jitter/dust/explosions) get room to play before cutting to the score tally.
	private const float GAME_OVER_DELAY = 2f;
	private const float WIN_DELAY = 3f;
	private const float VICTORY_EXPLOSION_SFX_INTERVAL_MIN = 0.12f;
	private const float VICTORY_EXPLOSION_SFX_INTERVAL_MAX = 0.28f;
	private float _victoryExplosionSfxTimer = -1f;
	private const float CHARACTER_HELP_FADE_TIME = 1.1f;
	private const float CHARACTER_HELP_ANIMATION_STEP_TIME = 0.1f;
	// Every help-row animation derives its state from this counter (see GameHud's CharacterHelp*State
	// helpers), so the wrap has to be a common multiple of BOTH the fast step's periods and the slow
	// step's (fast/5) — otherwise a row's sequence jumps mid-cycle when the counter rolls over. Longest
	// is the 8-way Blinker row: 64 states of 2 slow steps needs the slow range divisible by 128, and
	// with the 3s, 5s and 9s the other rows want that lands on 5760 slow steps = 28800 raw.
	private const int CHARACTER_HELP_ANIMATION_WRAP = 28800;
	private const float TUTORIAL_SECOND_PROMPT_DELAY = 0.8f;
	private const float TUTORIAL_PROMPT_HOLD_TIME = 0.3f;    // hold any game input this long to dismiss
	private const float TUTORIAL_PROMPT_DISMISS_TIME = 0.4f; // punch + fade; keep in sync with the .dismissing animations (GameHud.razor.scss)

	private enum CharacterHelpPhase
	{
		Closed,
		Visible,
		AutoDismissFading,
	}

	private CharacterHelpPhase _characterHelpPhase;
	private bool _characterHelpFadesOnDismiss;
	private float _characterHelpFadeTimer;
	private float _characterHelpAnimationTimer;
	private int _characterHelpAnimationStep;
	private int _characterHelpRevision;
	private string _characterHelpCharacterId;
	private bool _characterHelpIsMimicForm;

	// The run's character is remembered by ID, not by reference: reload_characters rebuilds every
	// CharacterDef instance (so a cached def would keep serving stale art/help while the dev iterates),
	// and SubmitRunOnce clears Manager.CurrentRunContext while this stage is still on screen (so
	// reading the context live would flip to the menu-selected character during the game-over beat).
	private string _runCharacterId;

	public CharacterDef RunCharacter => _runCharacterId is null ? null : Characters.Get( _runCharacterId );

	/// <summary>The character the "?" button teaches right now. Normally the run's character, but a
	/// Mimic run is a different character every time it transforms, so it follows the body the player is
	/// actually driving (Mimic itself while untransformed). Twins is deliberately NOT tracked this way:
	/// its Player is whichever twin holds control, while the run character is the paired def the help
	/// panel wants.</summary>
	public CharacterDef HelpCharacter
	{
		get
		{
			var run = RunCharacter;
			if ( run is null || run.Id != Characters.Mimic.Id ) return run;
			return Player?.Character ?? run;
		}
	}

	/// <summary>A Mimic is currently wearing another character's shape, so the "?" teaches that shape's
	/// <see cref="CharacterDef.MimicFormHelp"/> (a solo body) rather than its selectable help.</summary>
	public bool IsMimicFormHelp => RunCharacter?.Id == Characters.Mimic.Id && HelpCharacter?.Id != Characters.Mimic.Id;

	/// <summary>The help the "?" button opens right now; null disables the button.</summary>
	public CharacterHelpDef HelpDef => HelpFor( HelpCharacter, IsMimicFormHelp );

	public string HelpTitle => HelpDef?.Title ?? HelpCharacter?.Name;

	private static CharacterHelpDef HelpFor( CharacterDef character, bool mimicForm )
		=> mimicForm ? character?.MimicFormHelp ?? character?.Help : character?.Help;

	/// <summary>The character the OPEN help panel is showing — snapshotted at open time so a form change
	/// can never swap the panel out from under the player mid-read. Resolved through partners too: a
	/// Mimic in Twin 2's shape has a character id that isn't in <see cref="Characters.All"/>.</summary>
	public CharacterDef CharacterHelpCharacter =>
		Characters.TryGetIncludingPartners( _characterHelpCharacterId, out var character ) ? character : null;

	public CharacterHelpDef CharacterHelpDef => HelpFor( CharacterHelpCharacter, _characterHelpIsMimicForm );
	public string CharacterHelpTitle => CharacterHelpDef?.Title ?? CharacterHelpCharacter?.Name;
	/// <summary>The open panel's header shows the partner sprite too — never for a Mimic form, which is one body.</summary>
	public bool CharacterHelpShowsPartner => !_characterHelpIsMimicForm;

	public bool ShowCharacterHelp => _characterHelpPhase != CharacterHelpPhase.Closed;
	/// <summary>The help panel is up and owns input — the HUD hides its "?" button for this, the way
	/// the options overlay takes the gear with it. Goes false again for the auto-dismiss fade, which is
	/// when the nudge pulse (if still owed) first becomes visible to show where the help lives.</summary>
	public bool IsCharacterHelpVisible => _characterHelpPhase == CharacterHelpPhase.Visible;
	public bool IsCharacterHelpFading => _characterHelpPhase == CharacterHelpPhase.AutoDismissFading;
	/// <summary>The "?" button's teaching pulse: real runs only (replays, editor test runs and debug
	/// runs are exempt), and only while the profile still owes the nudge (see
	/// <see cref="CharacterProgress.ShouldNudgeHelpButton"/> for the arm/retire rules).</summary>
	public bool ShouldPulseHelpButton => !IsReplay && !IsTest && !IsDebugRun
		&& CharacterProgress.ShouldNudgeHelpButton;
	public int CharacterHelpAnimationStep => _characterHelpAnimationStep / 5;
	public int CharacterHelpFastAnimationStep => _characterHelpAnimationStep;
	public int CharacterHelpRevision => _characterHelpRevision;

	private enum TutorialPromptPhase
	{
		None,
		Visible,
		Dismissing,
	}

	private bool _tutorialPromptsEnabled;
	private bool _tutorialSecondPromptShown;
	private float _tutorialSecondPromptDelay = -1f;
	private TutorialPromptPhase _tutorialPromptPhase;
	private bool _tutorialPromptHoldArmed;   // a fresh press AFTER the popup appeared started this hold
	private bool _tutorialPromptPointerHeld; // mouse button held on the popup (GameHud mousedown/mouseup)
	private float _tutorialPromptHoldTime;
	private float _tutorialPromptDismissTimer;
	private string _tutorialPromptText;
	private int _tutorialPromptRevision;

	public bool ShowTutorialPrompt => _tutorialPromptPhase != TutorialPromptPhase.None;
	public bool IsTutorialPromptVisible => _tutorialPromptPhase == TutorialPromptPhase.Visible;
	public bool IsTutorialPromptDismissing => _tutorialPromptPhase == TutorialPromptPhase.Dismissing;
	public string TutorialPromptText => _tutorialPromptText;
	public int TutorialPromptRevision => _tutorialPromptRevision;
	/// <summary>Fill (0..1) of the popup's hold-to-dismiss bar; GameHud draws off this. Forced full on
	/// dismissal (including click-dismiss) so the bar reads complete through the fade.</summary>
	public float TutorialPromptHoldProgress => Math.Clamp( _tutorialPromptHoldTime / TUTORIAL_PROMPT_HOLD_TIME, 0f, 1f );

	/// <summary>Elapsed scored run time in seconds. Stops when the run enters its game-over beat.</summary>
	public float GameTime => _gameTime;

	// Screenshake (camera offset).
	private float _shakeH, _shakeV;
	private bool _shakeHPos, _shakeVPos;
	private const float CAM_SHAKE_RECOVERY = 0.85f;
	private const float CAM_SHAKE_STRENGTH = 0.66f;

	// Hit-stop / impact-freeze (diverges from original): strong impacts (a hard bounce, a wall dive)
	// request a brief freeze of the whole gameplay sim for a few fixed steps, selling the impact. It is
	// a deterministic step counter (sim state), driven by deterministic gameplay events, so a replay
	// reproduces the freeze for free without recording it (see GameManager's frozen-step handling).
	// Everything freezes during a hit-stop, including the camera shake (it just holds its last offset).
	private int _hitStopFrames;

	// Deadly-spike warning blink, shared across all blocks.
	private bool _spikeBlink;
	private float _spikeBlinkTimer;
	private const float SPIKE_BLINK_TIME_ON = 0.10f;
	private const float SPIKE_BLINK_TIME_OFF = 0.25f;

	public GameStage( GameManager manager ) : base( manager )
	{
		OptionsController = new OptionsMenuController { OnBack = CloseMenu };
	}

	public GameStage( GameManager manager, bool isReplay ) : this( manager )
	{
		IsReplay = isReplay;
	}

	// --- accessors the Player/Block ports rely on ---------------------------------------
	public List<Block> GetBlocks() => Blocks;
	public List<Fireball> GetFireballs() => Fireballs;
	public List<Bullet> GetBullets() => Bullets;
	public List<BlockBulletProjectile> GetBlockBullets() => BlockBullets;
	public List<Teardrop> GetTeardrops() => Teardrops;
	public List<Obstacle> GetObstacles() => _obstacles;
	public List<Obstacle> GetFences() => _fences;

	/// <summary>The static solids from a HAZARD's point of view: interior walls + the Twin statue.
	/// Skips GLASS (solid only to players) — lasers, bullets, fireballs, teardrops, fields and
	/// player-launched projectiles all pass through it.</summary>
	public SolidObstacleEnumerable GetSolidObstacles() => new( this, includeFences: false, includeGlass: false );

	/// <summary>Static solids for particles: ordinary obstacles and glass, never fences.</summary>
	public IEnumerable<Obstacle> GetParticleSolidObstacles() => _obstacles;

	/// <summary>The static solids from a BLOCK's point of view: <see cref="GetSolidObstacles"/> plus
	/// the fences (still no glass — blocks slide through panes). Block movement/placement paths (the
	/// base mover, slip, wisp float, hunter planning, teleport/summon destinations) use this.</summary>
	public SolidObstacleEnumerable GetBlockSolidObstacles() => new( this, includeFences: true, includeGlass: false );

	/// <summary>The static solids from a PLAYER's point of view: interior walls, GLASS panels and the
	/// Twin statue (no fences). Used by code that plans around the player's own collision (the AI
	/// input source); Player.cs itself iterates <see cref="GetObstacles"/> directly with its
	/// per-character wall gate.</summary>
	public SolidObstacleEnumerable GetPlayerSolidObstacles() => new( this, includeFences: false, includeGlass: true );

	/// <summary>Everything treated as a static solid: the authored interior obstacles plus any living
	/// hardened player (the Twin statue) — plus, for the block-solid view only, the fences. An
	/// allocation-free struct enumerable — the collision probes
	/// call this several times per entity per tick, and the iterator method it replaces allocated its
	/// state machine on every call. Still implements IEnumerable so a cold path can hold it as one
	/// (BlockLaser.BeamBlockers), at the cost of boxing there.
	///
	/// This is a hand-written replacement for a `yield return` iterator — same sequence, but the
	/// state machine is a stack struct instead of a per-call heap object. Same idiom as
	/// List&lt;T&gt;.Enumerator: foreach duck-types onto the public struct GetEnumerator (no
	/// allocation); the explicit interface methods exist only for interface-typed callers. The
	/// "what is solid" rule itself lives entirely in Enumerator.MoveNext.</summary>
	public readonly struct SolidObstacleEnumerable : IEnumerable<Entity2D>
	{
		private readonly GameStage _stage;
		private readonly bool _includeFences;
		private readonly bool _includeGlass;
		internal SolidObstacleEnumerable( GameStage stage, bool includeFences, bool includeGlass )
		{
			_stage = stage;
			_includeFences = includeFences;
			_includeGlass = includeGlass;
		}

		public Enumerator GetEnumerator() => new( _stage, _includeFences, _includeGlass );
		IEnumerator<Entity2D> IEnumerable<Entity2D>.GetEnumerator() => GetEnumerator();
		System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();

		// Shared empty tail for the non-fence views, so MoveNext stays a single unconditional walk.
		private static readonly List<Obstacle> NoFences = new();

		public struct Enumerator : IEnumerator<Entity2D>
		{
			private readonly List<Obstacle> _obstacles;
			private readonly List<Obstacle> _fences;   // empty unless the block-solid view was requested
			private readonly List<Player> _players;
			private readonly bool _includeGlass;       // glass rides inside _obstacles, flagged per entry
			private int _index; // walks the obstacles, then the fences, then the players (filtered to living hardened)

			internal Enumerator( GameStage stage, bool includeFences, bool includeGlass )
			{
				_obstacles = stage._obstacles;
				_fences = includeFences ? stage._fences : NoFences;
				_players = stage.Players;
				_includeGlass = includeGlass;
				_index = 0;
				Current = null;
			}

			public Entity2D Current { get; private set; }
			object System.Collections.IEnumerator.Current => Current;

			public bool MoveNext()
			{
				while ( _index < _obstacles.Count )
				{
					Obstacle ob = _obstacles[_index++];
					if ( ob.IsGlass && !_includeGlass )
						continue;   // glass is solid only to players — hazard/block views skip it
					Current = ob;
					return true;
				}
				if ( _index - _obstacles.Count < _fences.Count )
				{
					Current = _fences[_index++ - _obstacles.Count];
					return true;
				}
				int solids = _obstacles.Count + _fences.Count;
				while ( _index - solids < _players.Count )
				{
					Player player = _players[_index++ - solids];
					if ( player.IsHardened && !player.IsDead )
					{
						Current = player;
						return true;
					}
				}
				return false;
			}

			void System.Collections.IEnumerator.Reset() => _index = 0;
			public void Dispose() { }
		}
	}

	// Predicates for the filtered player views below. Named static methods (the compiler caches the
	// method-group delegate, so a view never allocates), NOT lambdas in static fields: a lambda parked
	// in a static field has no counterpart after a hotload (see DailyLevelGenerator.BuildAltLayout).
	private static bool LivingPlayerFilter( Player player ) => !player.IsDead && !player.IsHardened;
	private static bool FriendlyCloneFilter( Player clone ) => clone.IsSwarmClone && !clone.IsDead;

	/// <summary>Run players that are alive and not hardened — the bodies field effects and block
	/// attacks target. Several block types foreach this every tick, so it's an allocation-free
	/// struct enumerable rather than an iterator (same idiom as <see cref="SolidObstacleEnumerable"/>).</summary>
	public FilteredPlayerEnumerable LivingPlayers => new( Players, LivingPlayerFilter );

	/// <summary>Living swarm copies (friendly player bodies among the Impostors).</summary>
	public FilteredPlayerEnumerable LivingFriendlyClones => new( Impostors, FriendlyCloneFilter );

	/// <summary>A filtered, allocation-free view over a player list. foreach duck-types onto the
	/// struct GetEnumerator (see the SolidObstacleEnumerable note); deliberately NOT IEnumerable —
	/// every consumer foreaches it directly, and keeping the interface off makes an accidental
	/// LINQ/boxing use a compile error instead of a hidden allocation.</summary>
	public readonly struct FilteredPlayerEnumerable
	{
		private readonly List<Player> _players;
		private readonly System.Func<Player, bool> _filter;

		internal FilteredPlayerEnumerable( List<Player> players, System.Func<Player, bool> filter )
		{
			_players = players;
			_filter = filter;
		}

		public Enumerator GetEnumerator() => new( _players, _filter );

		public struct Enumerator
		{
			private readonly List<Player> _players;
			private readonly System.Func<Player, bool> _filter;
			private int _index;

			internal Enumerator( List<Player> players, System.Func<Player, bool> filter )
			{
				_players = players;
				_filter = filter;
				_index = 0;
				Current = null;
			}

			public Player Current { get; private set; }

			public bool MoveNext()
			{
				while ( _index < _players.Count )
				{
					Player player = _players[_index++];
					if ( _filter( player ) )
					{
						Current = player;
						return true;
					}
				}
				return false;
			}
		}
	}

	/// <summary>Nearest free (un-hardened) living body. Swarm copies count: a copy standing closer
	/// than the authoritative player draws hunters, lasers, teleport landings and hostile-clone AI —
	/// the swarm works as a decoy pool.</summary>
	public Player ClosestLivingPlayer( Vector2 origin )
	{
		Player closest = null;
		float closestDistanceSquared = float.MaxValue;
		foreach ( var player in Players )
			Consider( player );
		foreach ( var clone in Impostors )
			if ( clone.IsSwarmClone ) Consider( clone );
		return closest;

		void Consider( Player body )
		{
			if ( body.IsDead || body.IsHardened ) return;
			Vector2 delta = body.Pos - origin;
			float distanceSquared = delta.LengthSquared;
			if ( distanceSquared < closestDistanceSquared )
			{
				closest = body;
				closestDistanceSquared = distanceSquared;
			}
		}
	}

	/// <summary>Aim-target selection for blocks that should keep pressuring a hardened statue: any
	/// free (un-hardened) living player anywhere always wins, no matter how much closer the statue
	/// is; the nearest hardened body is returned only when no free player remains.</summary>
	public Player ClosestTargetablePlayer( Vector2 origin )
	{
		Player free = ClosestLivingPlayer( origin );
		if ( free != null ) return free;

		Player closest = null;
		float closestDistanceSquared = float.MaxValue;
		foreach ( var player in Players )
		{
			if ( player.IsDead || !player.IsHardened ) continue;
			Vector2 delta = player.Pos - origin;
			float distanceSquared = delta.LengthSquared;
			if ( distanceSquared < closestDistanceSquared )
			{
				closest = player;
				closestDistanceSquared = distanceSquared;
			}
		}
		return closest;
	}

	// --- effect hooks -------------------------------------------------------------------
	/// <summary>Spawn the Swapper's persistent portal (see <see cref="Portal"/>). Stationary,
	/// non-colliding and never expires; the swap ability holds the returned reference and moves it.</summary>
	public Portal AddPortal( Vector2 pos )
	{
		var go = CreateChild( "Portal" );
		var p = go.Components.Create<Portal>();
		p.Stage = this;
		p.Pos = pos;
		p.CreateVisuals();
		Portals.Add( p );
		return p;
	}

	public void RemovePortal( Portal portal )
	{
		if ( portal is null ) return;
		Portals.Remove( portal );
		portal.GameObject?.Destroy();
	}

	/// <summary>Begin the delayed, phase-coloured arrival of a Summoner impostor.</summary>
	public ImpostorPortal AddImpostorPortal( Vector2 pos, int phase )
	{
		var go = CreateChild( "Impostor Portal" );
		var portal = go.Components.Create<ImpostorPortal>();
		portal.Stage = this;
		portal.Pos = pos;
		portal.Setup( phase );
		portal.CreateVisuals();
		ImpostorPortals.Add( portal );
		return portal;
	}

	/// <summary>Spawn a collectible coin (see <see cref="Coin"/>). Stationary and non-colliding;
	/// consumes no authoritative RNG, so level coin spawns can't shift any other draw.</summary>
	public Coin AddCoin( Vector2 pos )
	{
		var go = CreateChild( "Coin" );
		var c = go.Components.Create<Coin>();
		c.Stage = this;
		c.Pos = pos;
		c.CreateVisuals();
		Coins.Add( c );
		return c;
	}

	/// <summary>Bank a coin pickup (called by <see cref="Coin.Tick"/> the tick it's collected), and
	/// award the clean-sweep achievement if that was the level's LAST coin.</summary>
	public void OnCoinCollected()
	{
		CoinsCollected++;

		// The sweep achievement lands on the final coin, not at game over: the feat is the collecting,
		// so whether the run then ends in a death or a win is irrelevant. Real runs only — a replay (or
		// the hijacked practice run that shares its stage), an editor test-play and a debug daily run
		// unlock nothing. AUTHORED levels are out entirely, workshop and local alike: either way the
		// player controls the coin layout, so a level papered with the bare minimum would hand this over
		// in one lap. Small levels are out too (see Achievements.AllCoinsMinimum). Level.Coins is the
		// AUTHORED total, which a collected coin doesn't shrink; Unlock is idempotent, so the condition
		// staying true afterwards is harmless.
		int levelCoins = Level?.Coins?.Count ?? 0;
		if ( !IsReplay && !IsTest && !IsDebugRun && Level?.IsWorkshop != true && Level?.IsLocal != true
			&& levelCoins >= Achievements.AllCoinsMinimum && CoinsCollected >= levelCoins )
			Achievements.AwardAllCoinsCollected();
	}

	/// <summary>Spawn the "+N" popup a collected coin leaves behind (see <see cref="CoinFloater"/>).</summary>
	public CoinFloater AddCoinFloater( Vector2 coinPos )
	{
		var go = CreateChild( "CoinFloater" );
		var f = go.Components.Create<CoinFloater>();
		f.CreateVisuals( coinPos );
		CoinFloaters.Add( f );
		return f;
	}

	public SpriteRenderer CreateMimicChoiceSprite( CharacterDef character, out MimicChoicePulse pulse )
	{
		var go = CreateChild( "MimicChoice" );
		var sprite = SpriteLayer.Add( go, character.SpritePath, character.ArtSize, "idle" );
		sprite.GameObject.WorldPosition = new Vector3( 0f, 0f, Globals.DepthToZ( Globals.DEPTH_MIMIC_CHOICE ) );
		pulse = sprite.GameObject.Components.Create<MimicChoicePulse>();
		pulse.Setup( sprite, character.ArtSize );
		return sprite;
	}

	public Particle AddParticle( Vector2 pos, Vector2 vel, float decel, float gravity, ParticleKind kind, float lifetime, int size )
	{
		// Depth is a purely COSMETIC layer pick (which particle plane it draws on) with zero gameplay
		// effect, so it MUST come from the cosmetic stream. AddParticle is shared by both player-
		// triggered emitters (blood/dust, which use the cosmetic stream for everything they control)
		// and block-triggered ones; rolling this on the authoritative stream would let an incidental
		// visual advance the sim's RNG and shift every subsequent block decision — defeating the two-
		// stream split documented in Rng.cs.
		int depth = (Rng.CosmeticInt( 0, 3 ) == 0) ? Globals.DEPTH_PARTICLE_1 : Globals.DEPTH_PARTICLE_0;
		var go = CreateChild( "Particle" );
		var p = go.Components.Create<Particle>();
		p.Stage = this;
		p.Depth = depth;
		p.Pos = pos;
		p.Setup( vel, decel, gravity, kind, lifetime, size );
		p.CreateVisuals();
		Particles.Add( p );
		return p;
	}

	/// <summary>Colliding and bouncing cosmetic fragment with an exact caller-provided tint.</summary>
	public Particle AddPhysicsParticle( Vector2 pos, Vector2 vel, float decel, float gravity, Color color, float lifetime, int size )
	{
		int depth = (Rng.CosmeticInt( 0, 3 ) == 0) ? Globals.DEPTH_PARTICLE_1 : Globals.DEPTH_PARTICLE_0;
		var go = CreateChild( "PhysicsParticle" );
		var p = go.Components.Create<Particle>();
		p.Stage = this;
		p.Depth = depth;
		p.Pos = pos;
		p.Setup( vel, decel, gravity, color, lifetime, size );
		// Death fragments pinned in a sliver gap (block vs block/obstacle/wall) may phase out of the
		// pinning block instead of vibrating invisibly until they expire — see Particle.EnablePinEscape.
		p.EnablePinEscape();
		p.CreateVisuals();
		Particles.Add( p );
		return p;
	}

	/// <summary>Non-colliding cosmetic fragment with an exact caller-provided tint and depth.</summary>
	public Particle AddColoredParticle( Vector2 pos, Vector2 vel, float decel, Color color, float lifetime, int size, int depth, bool translucent = false )
	{
		var go = CreateChild( "ColoredParticle" );
		var p = go.Components.Create<Particle>();
		p.Stage = this;
		p.Depth = depth;
		p.Pos = pos;
		p.Setup( vel, decel, 0f, color, lifetime, size, collides: false, bounces: false );
		p.CreateVisuals( translucent );
		Particles.Add( p );
		return p;
	}

	/// <summary>A particle that steers toward a target (Spikey's spike-send burst). When
	/// <paramref name="anchor"/> is non-null, <paramref name="targetOffset"/> is relative to it
	/// and tracks it; otherwise it's an absolute world point.</summary>
	public TargetedParticle AddTargetedParticle( Vector2 pos, Vector2 vel, float decel, float gravity, ParticleKind kind, float lifetime, int size, Vector2 targetOffset, float targetAccel, Entity2D anchor )
	{
		// Cosmetic layer pick — see AddParticle: never draw this from the authoritative stream.
		int depth = (Rng.CosmeticInt( 0, 3 ) == 0) ? Globals.DEPTH_PARTICLE_1 : Globals.DEPTH_PARTICLE_0;
		var go = CreateChild( "TargetedParticle" );
		var p = go.Components.Create<TargetedParticle>();
		p.Stage = this;
		p.Depth = depth;
		p.Pos = pos;
		p.Setup( vel, decel, gravity, kind, lifetime, size );
		p.SetupTarget( targetOffset, targetAccel, anchor );
		p.CreateVisuals();
		Particles.Add( p );
		return p;
	}

	/// <summary>Non-colliding targeted particle with an exact caller-provided tint and depth.</summary>
	public TargetedParticle AddColoredTargetedParticle( Vector2 pos, Vector2 vel, float decel, Color color,
		float lifetime, int size, int depth, Vector2 targetOffset, float targetAccel, Entity2D anchor,
		bool translucent = false )
	{
		var go = CreateChild( "ColoredTargetedParticle" );
		var p = go.Components.Create<TargetedParticle>();
		p.Stage = this;
		p.Depth = depth;
		p.Pos = pos;
		p.Setup( vel, decel, 0f, color, lifetime, size, collides: false, bounces: false );
		p.SetupTarget( targetOffset, targetAccel, anchor );
		p.CreateVisuals( translucent );
		Particles.Add( p );
		return p;
	}

	/// <summary>Wrap-portal flash (the wrap character's edge/obstacle wrap): a stationary square that
	/// rapidly shrinks away, spawned at both ends of a wrap (the caller layers a dark square and a
	/// smaller light highlight per portal, on separate depth layers). Unlike <see cref="AddParticle"/>
	/// it draws NO Rng at all (the wrap is sim code — a random depth pick would advance the
	/// authoritative stream and desync replays) and sits on a fixed caller-chosen layer over everything
	/// but text, so it reads even where the wrap point is buried in a wall or out of bounds (which is
	/// also why it neither collides nor bounces — the walls must not eject it).</summary>
	public Particle AddPortalParticle( Vector2 pos, float lifetime, int size, ParticleKind kind, int depth )
	{
		var go = CreateChild( "PortalParticle" );
		var p = go.Components.Create<Particle>();
		p.Stage = this;
		p.Depth = depth;
		p.Pos = pos;
		p.Setup( Vector2.Zero, 1f, 0f, kind, lifetime, size, collides: false, bounces: false );
		p.CreateVisuals();
		Particles.Add( p );
		return p;
	}

	/// <summary>Cosmetic wind-lane particle: like <see cref="AddParticle"/> but (a) picks its depth
	/// from the COSMETIC Rng stream so emitting one every tick never advances the authoritative sim
	/// Rng (which would desync replays), and (b) is non-colliding AND non-bouncing so it flies dead
	/// straight through the tunnel. The caller (<see cref="BlockWind"/>) spawns it anywhere along the
	/// lane and sets a short lifetime that expires by the time it reaches the column's blocker.</summary>
	public Particle AddWindParticle( Vector2 pos, Vector2 vel, float decel, float gravity, ParticleKind kind, float lifetime, int size )
	{
		// Fixed at the low particle layer so wind dust reads ON TOP of the lane's translucent overlay
		// (which sits just below this layer) but still behind the blocks/player/entities.
		int depth = Globals.DEPTH_PARTICLE_0;
		var go = CreateChild( "WindParticle" );
		var p = go.Components.Create<Particle>();
		p.Stage = this;
		p.Depth = depth;
		p.Pos = pos;
		p.Setup( vel, decel, gravity, kind, lifetime, size, collides: false, bounces: false );
		p.CreateVisuals();
		Particles.Add( p );
		return p;
	}

	/// <summary>A translucent, non-colliding "field mote" — used for the reverse-gravity field's ambient
	/// upward drift and its enter/leave puff. Like <see cref="AddWindParticle"/> but renders with a smooth
	/// alpha blend (AlphaCutoff 0) so a mostly-transparent colour actually shows. Cosmetic depth pick, so
	/// emitting one every tick never advances the authoritative Rng.</summary>
	public Particle AddFieldParticle( Vector2 pos, Vector2 vel, float decel, float gravity, ParticleKind kind, float lifetime, int size )
	{
		var go = CreateChild( "FieldParticle" );
		var p = go.Components.Create<Particle>();
		p.Stage = this;
		p.Depth = Globals.DEPTH_PARTICLE_0;
		p.Pos = pos;
		p.Setup( vel, decel, gravity, kind, lifetime, size, collides: false, bounces: false );
		p.CreateVisuals( translucent: true );
		Particles.Add( p );
		return p;
	}

	/// <summary>Create a stage-level overlay quad (a tintable pixel sprite) for effects like the wind
	/// lane tint. Parented to the stage root — NOT a moving entity — so the caller drives its world
	/// position / size / colour / enabled directly each tick (mirrors how the playfield/backdrop rects
	/// are placed). Starts disabled.</summary>
	public SpriteRenderer CreateOverlaySprite()
	{
		var go = CreateChild( "WindOverlay" );
		var sr = SpriteLayer.Add( go, "sprites/pixel.sprite", new Vector2( 1, 1 ), "idle" );
		// SpriteRenderer.AlphaCutoff defaults to 0.5 and DISCARDS any pixel whose alpha is below it — that
		// was the hard "invisible under 0.5" cutoff. Zero it so the overlay can be any alpha (true smooth
		// translucency), and keep Opaque off so it alpha-blends rather than dithers.
		sr.Opaque = false;
		sr.AlphaCutoff = 0f;
		sr.Enabled = false;
		return sr;
	}

	/// <summary>Destroy an overlay sprite made by <see cref="CreateOverlaySprite"/>. The renderer sits on
	/// a layer child INSIDE the "WindOverlay" wrapper (see SpriteLayer.Add), so the wrapper is what must
	/// be destroyed — destroying only the renderer's own GameObject would leak an empty wrapper each time.</summary>
	public static void DestroyOverlaySprite( SpriteRenderer sr )
		=> sr?.GameObject?.Parent?.Destroy();

	public Fireball AddFireball( Vector2 pos, Vector2 vel, bool bouncing )
	{
		var go = CreateChild( "Fireball" );
		var f = go.Components.Create<Fireball>();
		f.Stage = this;
		f.Depth = Globals.DEPTH_FIREBALL;
		f.Pos = pos;
		f.Setup( vel, bouncing );
		f.CreateVisuals();
		Fireballs.Add( f );
		return f;
	}

	/// <summary>Spawn a Gunner <see cref="Bullet"/> travelling in <paramref name="direction"/> from
	/// <paramref name="pos"/>; its first collision step sweeps from <paramref name="sweepOrigin"/> (the
	/// shooter's centre) so nothing between shooter and muzzle is skipped. Lives in its own list (never
	/// touched by the wind/magnet lanes) and is reaped when it dies. See <see cref="Bullet"/> for its
	/// impact behaviour.</summary>
	public Bullet AddBullet( Vector2 pos, Vector2 direction, Vector2 sweepOrigin )
	{
		var go = CreateChild( "Bullet" );
		var b = go.Components.Create<Bullet>();
		b.Stage = this;
		b.Depth = Globals.DEPTH_FIREBALL;
		b.Pos = pos;
		b.Setup( direction, sweepOrigin );
		b.CreateVisuals();
		Bullets.Add( b );
		return b;
	}

	/// <summary>Spawn a Swapper bolt at <paramref name="pos"/>; its first collision step sweeps from
	/// <paramref name="sweepOrigin"/> (the caster's centre). See <see cref="AddBullet"/>.</summary>
	public SwapperProjectile AddSwapperProjectile( Vector2 pos, Vector2 direction, Vector2 sweepOrigin, Portal portal, float speed, int impactHitStopFrames, float portalSurfacePadding )
	{
		var go = CreateChild( "SwapperProjectile" );
		var projectile = go.Components.Create<SwapperProjectile>();
		projectile.Stage = this;
		projectile.Depth = Globals.DEPTH_FIREBALL;
		projectile.Pos = pos;
		projectile.Setup( direction, sweepOrigin, portal, speed, impactHitStopFrames, portalSurfacePadding );
		projectile.CreateVisuals();
		SwapperProjectiles.Add( projectile );
		return projectile;
	}

	public void RemoveSwapperProjectiles( Portal portal )
	{
		foreach ( SwapperProjectile projectile in SwapperProjectiles )
			if ( ReferenceEquals( projectile.TargetPortal, portal ) ) projectile.Dead = true;
	}

	public BlockBulletProjectile AddBlockBullet( Vector2 pos, Vector2 direction, BlockBulletVariant variant )
	{
		var go = CreateChild( "BlockBullet" );
		var bullet = go.Components.Create<BlockBulletProjectile>();
		bullet.Stage = this;
		bullet.Depth = BlockBulletProjectile.IsBouncing( variant ) ? Globals.DEPTH_FIREBALL : Globals.DEPTH_WALL;
		bullet.Pos = pos;
		bullet.Setup( direction, variant );
		bullet.CreateVisuals();
		BlockBullets.Add( bullet );
		return bullet;
	}

	public Teardrop AddTeardrop( Vector2 pos, Vector2 vel, Block creatingBlock, TeardropVariant variant = TeardropVariant.Standard )
	{
		var go = CreateChild( "Teardrop" );
		var t = go.Components.Create<Teardrop>();
		t.Stage = this;
		t.Depth = variant == TeardropVariant.Piercing
			? Globals.DEPTH_PIERCING_TEARDROP
			: Globals.DEPTH_TEARDROP;
		t.Pos = pos;
		t.Setup( vel, creatingBlock, variant );
		t.CreateVisuals();
		Teardrops.Add( t );
		return t;
	}

	public void AddHorizontalScreenshake( float amount ) { _shakeH += amount; }
	public void AddVerticalScreenshake( float amount ) { _shakeV += amount; }

	// --- hit-stop (impact freeze) -------------------------------------------------------
	/// <summary>True while the sim is frozen for hit-stop; <see cref="GameManager"/> consumes frozen
	/// steps (advancing the counter) instead of ticking the sim.</summary>
	public bool HitStopActive => _hitStopFrames > 0;

	/// <summary>Request a hit-stop of <paramref name="frames"/> fixed steps. Takes the longer of any
	/// overlapping requests so multiple impacts in one step don't stack into an over-long freeze.</summary>
	public void RequestHitStop( int frames )
	{
		if ( frames > _hitStopFrames ) _hitStopFrames = frames;
	}

	/// <summary>Consume one frozen step. Called by <see cref="GameManager"/> in place of a sim tick
	/// while <see cref="HitStopActive"/>; nothing else advances (deterministic, no Rng consumed).</summary>
	public void TickHitStop()
	{
		if ( _hitStopFrames > 0 ) _hitStopFrames--;
	}

	/// <summary>Cancel any pending hit-stop freeze immediately. Used by the replay single-step transport
	/// so a frame-step tap always advances a real sim frame instead of being swallowed by the freeze.
	/// Safe for determinism: hit-stop only gates HOW MANY frozen display steps play, never the sim state
	/// (positions are a pure function of the fed recorded frames, keyed on the recorded-frame index).</summary>
	public void ClearHitStop() => _hitStopFrames = 0;

	// --- wall-face spikes (phase-2 hazard, sent by Spikey) ------------------------------
	// A "wall face" is a spikeable edge: the arena boundary PLUS each interior obstacle's outward
	// sides. A boundary SIDE can be several faces: an obstacle sitting flush against an arena edge
	// SPLITS that edge into independent segments (e.g. the C-shape's floor becomes a left-channel and
	// a right-channel face), so each spikes on its own. Arena faces carry ArenaSide; the player /
	// wrap ability query them position-aware (WallDeadlyAt) so they hit the right segment.
	private readonly List<WallFace> _faces = new();   // all faces (arena boundary + obstacle)

	private const float ARENA_SPIKE_ADD_TIME = 0.44f;
	private const float ARENA_SPIKE_RETRACT_TIME = 0.5f;

	/// <summary>Does <paramref name="pos"/> fall within a face's extent along its wall (X-extent for a
	/// horizontal wall, Y-extent for a vertical one)? A full-width boundary face covers everything.</summary>
	private static bool CoversAlong( WallFace f, Direction side, Vector2 pos )
	{
		if ( side == Direction.Left || side == Direction.Right )
			return pos.y >= f.Segment.YMin && pos.y <= f.Segment.YMax;
		return pos.x >= f.Segment.XMin && pos.x <= f.Segment.XMax;
	}

	/// <summary>The boundary face on <paramref name="side"/> covering <paramref name="pos"/> (the segment
	/// under the player / block), or null. For an unsplit side there's one full-width face.</summary>
	public WallFace FindArenaFace( Direction side, Vector2 pos )
	{
		foreach ( var f in _faces )
			if ( f.Owner is null && f.ArenaSide == side && CoversAlong( f, side, pos ) )
				return f;
		return null;
	}

	// Boundary spike query, position-aware so a split side hits the right segment. A SINGLE "deadly
	// now" predicate: spikes present AND settled (not mid grow-in / retract). Symmetric with
	// ObstacleFaceDeadlyAt and Block.SideDeadly so no caller has to remember to AND a "has spikes"
	// flag with a "not switching" flag — the asymmetry that let bug #6 slip the wrap gate through a
	// transitioning face.
	public bool WallDeadlyAt( Direction side, Vector2 pos )
	{
		var f = FindArenaFace( side, pos );
		return f is not null && f.SpikesPresent && !f.Switching;
	}

	/// <summary>The obstacle's face with the given outward normal covering <paramref name="pos"/>
	/// along the side, or null. Position-aware like the boundary queries: a side that another
	/// obstacle sits flush against is SPLIT into segments (and the shared seam has no face at all),
	/// so the same (owner, normal) pair can name several independent faces. CoversAlong keys the
	/// extent axis off Left/Right-vs-Up/Down identically for a side and a normal, so passing the
	/// normal is correct here (a Left-normal face is vertical → Y extent).</summary>
	public WallFace FindObstacleFace( Obstacle ob, Direction normal, Vector2 pos )
	{
		foreach ( var f in _faces )
			if ( f.Owner == ob && f.Normal == normal && CoversAlong( f, normal, pos ) )
				return f;
		return null;
	}

	/// <summary>True if the obstacle's face with this normal at this position has live (deadly,
	/// non-switching) spikes — a single "deadly now" predicate, symmetric with <see cref="WallDeadlyAt"/>
	/// and <c>Block.SideDeadly</c>. Used for the player's kill-on-contact and the wrap character's
	/// crossing gate.</summary>
	public bool ObstacleFaceDeadlyAt( Obstacle ob, Direction normal, Vector2 pos )
	{
		var f = FindObstacleFace( ob, normal, pos );
		return f is not null && f.SpikesPresent && !f.Switching;
	}

	/// <summary>Contact-span variant of <see cref="ObstacleFaceDeadlyAt"/> for a body resolved FLUSH
	/// with a face plane: true if any live-spiked obstacle face with this normal lies on the rect's
	/// contacted side and overlaps the rect's extent along the face's tangent axis. The centre-point
	/// query under-killed at face ENDS — a body standing on a spiked top's last pixels with its
	/// centre hung past the corner was "covered" by no segment and survived. Keyed on the contact
	/// LINE rather than a resolved owner so a seam between flush obstacles is airtight too: a
	/// partially-spiked surface is authored as flush rects with one spiked side, and an owner-keyed
	/// query would test whichever rect the unpenetrate loop happened to resolve first — a body
	/// straddling the seam could stand with its toes on the neighbour's teeth. Same plane + tangent
	/// overlap means resting on that face; both are strict, so a body exactly flush with a segment's
	/// END (zero tangent overlap) is beside the face, not on it.</summary>
	public bool ObstacleFaceDeadlyForRect( Direction normal, RectF rect )
	{
		const float PLANE_EPS = 0.01f; // flush placement is float-exact; distinct face planes sit whole pixels apart
		bool verticalFace = normal == Direction.Left || normal == Direction.Right;
		float min = verticalFace ? rect.Bottom : rect.Left;
		float max = verticalFace ? rect.Top : rect.Right;
		// The rect side the face touches: resolved out LEFT of a Left-normal face → our Right, etc.
		float contact = normal switch
		{
			Direction.Left => rect.Right,
			Direction.Right => rect.Left,
			Direction.Down => rect.Top,
			_ => rect.Bottom,
		};
		foreach ( var f in _faces )
		{
			if ( f.Owner is null || f.Normal != normal || !f.SpikesPresent || f.Switching ) continue;
			float plane = verticalFace ? f.Segment.XMin : f.Segment.YMin; // face lines are axis-aligned on edgeCoord
			if ( plane < contact - PLANE_EPS || plane > contact + PLANE_EPS ) continue;
			bool overlaps = verticalFace
				? max > f.Segment.YMin && min < f.Segment.YMax
				: max > f.Segment.XMin && min < f.Segment.XMax;
			if ( overlaps ) return true;
		}
		return false;
	}

	/// <summary>Graft deadly spikes onto any face for <paramref name="time"/> seconds. Plays the grow-in
	/// animation; the face is "switching" (harmless) until it lands.</summary>
	public void AddSpikesToFace( WallFace f, float time )
	{
		if ( f is null || f.SpikesPresent ) return;
		f.SpikesPresent = true;
		f.Timer = time;
		f.Trans = WallTrans.Adding;
		f.TransTimer = ARENA_SPIKE_ADD_TIME;
		f.Switching = true;
		f.Play( "add" );
		Audio.PlaySfx( SfxType.SpikesAdd );
	}

	private void RetractFace( WallFace f )
	{
		f.Trans = WallTrans.Retracting;
		f.TransTimer = ARENA_SPIKE_RETRACT_TIME;
		f.Switching = true;
		f.Play( "retract" );
		Audio.PlaySfx( SfxType.SpikesRetract );
	}

	private void TickArenaSpikes( float dt )
	{
		foreach ( var f in _faces )
		{
			if ( f.Permanent ) continue;   // level-authored spikes never transition, retract, or time out

			if ( f.Trans != WallTrans.None )
			{
				f.TransTimer -= dt;
				if ( f.TransTimer <= 0f )
				{
					if ( f.Trans == WallTrans.Adding )
					{
						f.Switching = false;     // spikes are now live/deadly
						f.Play( "spiked" );
					}
					else // Retracting
					{
						f.SpikesPresent = false;
						f.Switching = false;
						f.Play( "plain" );
					}
					f.Trans = WallTrans.None;
				}
				continue; // don't count down the live-spike timer while switching
			}

			if ( f.SpikesPresent )
			{
				f.Timer -= dt;
				if ( f.Timer <= 0f )
					RetractFace( f );
			}
		}
	}

	/// <summary>Deadly-spike warning flash, in lock-step with the block spikes.</summary>
	private void BlinkWallSpikes( bool blinkOn )
	{
		foreach ( var f in _faces )
		{
			// Permanent (level-authored) spikes are a static hazard — they hold steady, no warning flash.
			if ( !f.SpikesPresent || f.Switching || f.Permanent ) continue;
			f.Play( blinkOn ? "spiked_alt" : "spiked" );
		}
	}

	private static readonly Dictionary<BlockType, string> BlockSprites = new()
	{
		[BlockType.Dragon] = "sprites/blocks/dragon.sprite",
		[BlockType.Squid] = "sprites/blocks/squid.sprite",
		[BlockType.SquidPierce] = "sprites/blocks/squidpierce.sprite",
		[BlockType.Smile] = "sprites/blocks/smile.sprite",
		[BlockType.Spikey] = "sprites/blocks/spikey.sprite",
		[BlockType.Sad] = "sprites/blocks/sad.sprite",
		[BlockType.SadPierce] = "sprites/blocks/sadpierce.sprite",
		[BlockType.Wind] = "sprites/blocks/wind.sprite", // custom airy gust art baked by tools/bake.py (bake_wind)
		[BlockType.Magnet] = "sprites/blocks/magnet.sprite", // custom purple attractor art baked by tools/bake.py (bake_magnet)
		[BlockType.Sticky] = "sprites/blocks/sticky.sprite", // pink recolour baked by tools/bake.py
		[BlockType.Shade] = "sprites/blocks/shade.sprite", // custom spectral art baked by tools/bake.py (bake_shade)
		[BlockType.Stasis] = "sprites/blocks/stasis.sprite", // custom icy time-freeze art baked by tools/bake.py (bake_stasis)
		[BlockType.Shockwave] = "sprites/blocks/shockwave.sprite", // custom teal pulse-ring emitter art baked by tools/bake.py (bake_shockwave)
		[BlockType.Slicer] = "sprites/blocks/slicer.sprite", // custom gunmetal laser-emitter art baked by tools/bake.py (bake_slicer)
		[BlockType.Mimic] = "sprites/blocks/mimic.sprite", // custom shape-shifter art baked by tools/bake.py (bake_mimic)
		[BlockType.Teleport] = "sprites/blocks/teleport.sprite", // custom warp/blink art baked by tools/bake.py (bake_teleport)
		[BlockType.Summoner] = "sprites/blocks/summoner.sprite", // custom sinister crimson clone-summoner art baked by tools/bake.py (bake_summoner)
		[BlockType.Hunter] = "sprites/blocks/hunter.sprite", // custom predatory rust-red stalker art baked by tools/bake.py (bake_hunter)
		[BlockType.Siren] = "sprites/blocks/siren.sprite", // custom rose/gold singer art baked by tools/bake.py (bake_siren)
		[BlockType.Wisp] = "sprites/blocks/wisp.sprite", // custom spectral mint floater art baked by tools/bake.py (bake_wisp)
		[BlockType.Venom] = "sprites/blocks/venom.sprite", // custom toxic acid-trail art baked by tools/bake.py (bake_venom)
		[BlockType.Reverse] = "sprites/blocks/reverse.sprite", // custom violet anti-gravity art baked by tools/bake.py (bake_reverse)
		[BlockType.Laser] = "sprites/blocks/laser.sprite", // cool-blue Squid-derived laser-emitter art baked by tools/bake.py
		[BlockType.Bullet] = "sprites/blocks/bullet.sprite", // cyan/gold Dragon-derived marksman art baked by tools/bake.py
		[BlockType.DragonBounce] = "sprites/blocks/dragonbounce.sprite", // green/violet Dragon-derived ricochet art baked by tools/bake.py
		[BlockType.LaserPierce] = "sprites/blocks/laserpierce.sprite", // coral/gold Squid-derived piercing-laser art baked by tools/bake.py
		[BlockType.StraightLaser] = "sprites/blocks/straightlaser.sprite", // emerald/lime cardinal laser-emitter art baked by tools/bake.py
	};

	protected override void OnEnter()
	{
		// The run context (set in GameManager.ApplyPendingStage, for live runs and replays alike)
		// names the level; everything below spawns from its definition.
		var context = Manager.CurrentRunContext;
		Level = context.Level;
		Audio.PlayLevelMusic( Level );
		_runCharacterId = context.Character.Id; // resolved id (never null/unknown), so RunCharacter always finds it again
		if ( !IsReplay && !IsTest )
			Leaderboard.PrimePersonalBests( context );
		_levelWasBeatenOnEntry = !IsReplay && !IsTest && !context.IsDaily
			&& LevelProgress.IsBeaten( Level?.Id );
		_tutorialPromptsEnabled = !IsReplay && !IsTest && !context.IsDaily
			&& Level?.Id == Levels.TutorialId && !LevelProgress.IsBeaten( Levels.TutorialId );

		if ( _tutorialPromptsEnabled )
			ShowTutorialInstruction( "TOUCH BLOCK SIDES" );
		TryAutoOpenCharacterHelp();

		// The song's run-progression pitch (starts at normal, rises as blocks phase up — see
		// Block.NextPhase) is reset centrally on every stage entry in GameManager.ApplyPendingStage, so a
		// run always begins at the base pitch here; ScaleMusicProgressionToLevel (below, once the blocks
		// exist) reports this level's phase-up count so its eased climb ends where every other level's does.

		SpawnBackgroundBlocks();
		SpawnArena();
		CreateHud();

		// TEMP DEBUG: single block bottom-left heading right, player riding on top, to make the
		// moving-platform edge/handoff cases easy to reproduce. Flip DEBUG_SINGLE_BLOCK to restore
		// the normal 5-block top row. The repro test levels are exempt: their whole point is their
		// exact authored layout, so their ConCmds must work even with this override on.
		if ( DEBUG_STARTING_BLOCKS && !TestLevels.IsTestLevel( Level?.Id ) )
		{
			// ---------- Two blocks, bottom-left and bottom-right, heading horizontally toward each other for collision/handoff tests.
			const float BLOCK_HALF = 20f;
			var blockPos = new Vector2( Arena.WALL_SIZE + BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF );
			SpawnBlock( blockPos, BlockType.Dragon );
			Blocks[0].DebugSetMoveDirection( Direction.Right );

			//// Second block bottom-right, heading left (so the two can meet for collision/handoff tests).
			//var blockPos2 = new Vector2( Arena.WIDTH - Arena.WALL_SIZE - BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF );
			//SpawnBlock( blockPos2, BlockType.Sticky );
			//Blocks[1].DebugSetMoveDirection( Direction.Left );
			var blockPos2 = new Vector2( Arena.WALL_SIZE + BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF + 60 );
			SpawnBlock( blockPos2, BlockType.Sticky );
			Blocks[1].DebugSetMoveDirection( Direction.Right );

			// ---------- Two blocks, bottom-left and top-right, heading vertically toward each other for collision/handoff tests.
			//const float BLOCK_HALF = 20f;
			//var blockPos = new Vector2( Arena.WALL_SIZE + BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF + 30 );
			//SpawnBlock( blockPos, BlockType.Dragon );
			//Blocks[0].DebugSetMoveDirection( Direction.Up );

			//// Second block bottom-right, heading left (so the two can meet for collision/handoff tests).
			//var blockPos2 = new Vector2( Arena.WALL_SIZE + BLOCK_HALF + 30, Arena.HEIGHT - BLOCK_HALF );
			//SpawnBlock( blockPos2, BlockType.Squid );
			//Blocks[1].DebugSetMoveDirection( Direction.Down );

			// ----------
			//const float BLOCK_HALF = 20f;
			//var blockPos = new Vector2( Arena.WALL_SIZE + BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF + 39f );
			//SpawnBlock( blockPos, BlockType.Dragon );
			//Blocks[0].DebugSetMoveDirection( Direction.Right );

			//var blockPos1b = new Vector2( Arena.WALL_SIZE + BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF + 79f );
			//SpawnBlock( blockPos1b, BlockType.Sad );
			//Blocks[1].DebugSetMoveDirection( Direction.Right );

			// Second block bottom-right, heading left (so the two can meet for collision/handoff tests).
			//var blockPos2 = new Vector2( Arena.WIDTH - Arena.WALL_SIZE - BLOCK_HALF, Arena.WALL_SIZE + BLOCK_HALF );
			//SpawnBlock( blockPos2, BlockType.Squid );
			//Blocks[1].DebugSetMoveDirection( Direction.Left );


			//const float BLOCK_HALF = 20f;
			//var blockPos = new Vector2( Arena.WALL_SIZE + BLOCK_HALF + 40, Arena.WALL_SIZE + BLOCK_HALF );
			//SpawnBlock( blockPos, BlockType.Sticky );
			//Blocks[0].DebugSetMoveDirection( Direction.Left);

			//var blockPos2 = new Vector2( Arena.WIDTH - Arena.WALL_SIZE - BLOCK_HALF - 30, Arena.WALL_SIZE + BLOCK_HALF + 30f + 10f );
			//SpawnBlock( blockPos2, BlockType.Sticky );
			//Blocks[1].DebugSetMoveDirection( Direction.Right );


			// Sit the player on the first block's top (block top = blockPos.y + half).
			var onBlock = new Vector2( blockPos.x, blockPos.y + BLOCK_HALF + Player.COLLISION_SIZE.y / 2f );
			SpawnRunPlayers( onBlock );
			ScaleMusicProgressionToLevel();
			return;
		}

		// Spawn the level's blocks. ResolveSpawns draws from the freshly-seeded authoritative Rng
		// (pool picks / position subset / type shuffle), so a replay — which reseeds and re-enters
		// with the same level id — reproduces the identical layout.
		Level.ResolveSpawns( out var types, out var positions, out var configs, out var abilityTimers, out var directions, out var turnModes );
		for ( int i = 0; i < types.Count && i < positions.Count; i++ )
			SpawnBlock( positions[i], types[i], configs[i], abilityTimers[i], directions[i], turnModes[i] );
		if ( positions.Count < types.Count )
			Log.Warning( $"[BlockParty] level '{Level.Id}': {types.Count} blocks but only {positions.Count} spawn positions — {types.Count - positions.Count} not spawned." );

		// Blocks exist (with their authored starting phases applied), so the music's per-phase-up pitch
		// step can be sized to this level.
		ScaleMusicProgressionToLevel();

		// Multi-spawn levels draw the player's start from the run Rng, AFTER ResolveSpawns (the draw
		// order is replay-sensitive). Exactly two Twins spawns are ordered; with three or more they
		// draw distinct random starts. Single-spawn levels consume NO extra draw.
		var playerSpawns = Level.PlayerSpawns;
		bool useOrderedTwinSpawns = RunCharacter.Partner is not null && playerSpawns is { Count: 2 };
		int playerSpawnIndex = useOrderedTwinSpawns ? 0 : playerSpawns is { Count: > 1 } ? Rng.Int( 0, playerSpawns.Count ) : 0;
		var playerSpawn = playerSpawns is { Count: > 0 } ? playerSpawns[playerSpawnIndex] : new Vector2( 16, 40 );
		Vector2? partnerSpawn = null;
		if ( RunCharacter.Partner is not null && playerSpawns is { Count: > 1 } )
		{
			if ( useOrderedTwinSpawns )
			{
				partnerSpawn = playerSpawns[1];
			}
			else
			{
				int partnerOffset = Rng.Int( 1, playerSpawns.Count );
				partnerSpawn = playerSpawns[(playerSpawnIndex + partnerOffset) % playerSpawns.Count];
			}
		}
		SpawnRunPlayers( playerSpawn, partnerSpawn );

		// Level-authored coins. Spawning draws NO authoritative Rng, so this can't shift the
		// spawn/shuffle draws above (old replays keep reproducing).
		if ( Level?.Coins is not null )
			foreach ( var coinPos in Level.Coins )
				AddCoin( coinPos );

		// Repro test levels do their scripted setup (forced block directions, state resets) here;
		// a no-op for every real level.
		TestLevels.OnStageEnter( this );

		SpawnVisionOccluder();

		// A level with an authored tutorial ghost loops it as a faded movement demo. Created after
		// every Rng-consuming spawn above and never touching the sim, so replays are unaffected.
		var ghostClip = Ghosts.Get( Level?.Id );
		if ( ghostClip?.DecodePoses() is { Count: > 0 } ghostPoses )
			_ghost = new GhostPlayback( this, ghostClip, ghostPoses );

		SpawnBackgroundInfo();

		// Authored starts can leave EVERY block already maxed. SetInitialPhase fires no phase-up, so
		// nothing would ever trigger the win, and the only exit (dying) would tally as a victory since
		// the outcome is read from final block phases. Decide such a run as won the moment it begins.
		if ( TryBeginVictory() )
			Log.Warning( $"[BlockParty] level '{Level?.Id}': every block starts at max phase — run won at start." );
	}

	// ---- persistent background info -------------------------------------------------------------
	// Some levels paint a persistent cosmetic hint onto the arena background: world sprites in
	// front of the playfield and the drifting cosmetic blocks, behind every gameplay layer. Purely
	// decorative — no Rng, no sim state — so replays are unaffected. Keyed by level id; extend the
	// switch as more levels want one.

	private const float BACKGROUND_INFO_ALPHA = 0.4f;

	/// <summary>Key art is 14px (a 1px transparent rim around a 12px cap); 16px pitch leaves a 2px
	/// visual gap between neighbouring caps, matching the replay HUD's spaced key boxes.</summary>
	private const float BACKGROUND_INFO_KEY_PITCH = 16f;

	private void SpawnBackgroundInfo()
	{
		switch ( Level?.Id )
		{
			case "tutorial":
				// "MOVE" top middle, the arrow keys under it in the replay-HUD input-display
				// formation (Up on top, Left/Down/Right in a row below).
				float cx = Arena.WIDTH / 2f;
				AddBackgroundInfoSprite( "sprites/ui/word_move.sprite", new Vector2( cx, 228 ), new Vector2( 31, 7 ) );
				var keySize = new Vector2( 14, 14 );
				AddBackgroundInfoSprite( "sprites/ui/arrow_key_up.sprite", new Vector2( cx, 212 ), keySize );
				AddBackgroundInfoSprite( "sprites/ui/arrow_key_left.sprite", new Vector2( cx - BACKGROUND_INFO_KEY_PITCH, 196 ), keySize );
				AddBackgroundInfoSprite( "sprites/ui/arrow_key_down.sprite", new Vector2( cx, 196 ), keySize );
				AddBackgroundInfoSprite( "sprites/ui/arrow_key_right.sprite", new Vector2( cx + BACKGROUND_INFO_KEY_PITCH, 196 ), keySize );
				break;
		}
	}

	/// <summary><paramref name="artSize"/> is the art's real pixel dimensions — used both to land
	/// the sprite's edges on the logical pixel grid and to build the square quad size the engine's
	/// aspect-fit rule needs for non-square art (see SpriteLayer notes).</summary>
	private void AddBackgroundInfoSprite( string spritePath, Vector2 center, Vector2 artSize )
	{
		var go = CreateChild( "BackgroundInfo" );
		Vector2 aligned = SpriteLayer.PixelAlignedCenter( center, artSize );
		go.WorldPosition = new Vector3( aligned.x, aligned.y, Globals.DepthToZ( Globals.DEPTH_BACKGROUND_INFO ) );
		float longEdge = MathF.Max( artSize.x, artSize.y );
		var sr = SpriteLayer.Add( go, spritePath, new Vector2( longEdge, longEdge ), "idle" );
		sr.Opaque = false;   // translucent rendering, like the tutorial ghost
		sr.AlphaCutoff = 0f;
		sr.Color = Color.White.WithAlpha( BACKGROUND_INFO_ALPHA );
	}

	// ---- Mimic block transformation -----------------------------------------------------------
	// Disguise pool: EVERY block type except Mimic (a mimic never disguises as itself). Derived from the
	// enum so any block type added later is AUTOMATICALLY a possible disguise -- no hand-maintained list
	// to keep in sync. Enum.GetValues returns declaration order (stable), so the pick stays deterministic
	// for replays (reordering the enum would change picks -> bump Sim.VERSION if that ever happens).
	private static readonly BlockType[] MimicDisguisePool = BuildMimicDisguisePool();

	// Never a PHASE-2 disguise (Sim.MIMIC_P2_EXCLUSIONS). Still fine at phase 1.
	// Changing this set changes the pick's Rng range -> bump Sim.VERSION + gate.
	private static readonly HashSet<BlockType> MimicPhase2Excluded = new()
	{
		BlockType.StraightLaser,   // permanent four-way beam cross
		BlockType.SquidPierce,     // 10s rotating beam through everything
	};

	private static BlockType[] BuildMimicDisguisePool()
	{
		var all = (BlockType[])System.Enum.GetValues( typeof( BlockType ) );
		var pool = new List<BlockType>( all.Length );
		foreach ( var t in all )
			if ( t != BlockType.Mimic )
				pool.Add( t );
		return pool.ToArray();
	}

	private HashSet<BlockType> _levelBlockTypes;             // types this level specifies (mimic exclusion set)
	private readonly List<BlockType> _mimicCandidates = new();

	/// <summary>The block types this level specifies (its exact Blocks list and/or random Pool). A mimic
	/// only ever disguises as a type NOT in this set, so it always introduces a type "not from this level".</summary>
	private HashSet<BlockType> LevelBlockTypes()
	{
		if ( _levelBlockTypes != null ) return _levelBlockTypes;
		_levelBlockTypes = new HashSet<BlockType>();
		if ( Level?.Blocks != null ) foreach ( var t in Level.Blocks ) _levelBlockTypes.Add( t );
		if ( Level?.Pool != null ) foreach ( var t in Level.Pool ) _levelBlockTypes.Add( t );
		return _levelBlockTypes;
	}

	/// <summary>Create the concrete Block component for a type (shared by the initial spawn and the mimic
	/// transform swap).</summary>
	private static Block CreateBlockComponent( GameObject go, BlockType type ) => type switch
	{
		BlockType.Dragon => go.Components.Create<BlockDragon>(),
		BlockType.Squid => go.Components.Create<BlockSquid>(),
		BlockType.SquidPierce => go.Components.Create<BlockSquidPierce>(),
		BlockType.Spikey => go.Components.Create<BlockSpikey>(),
		BlockType.Smile => go.Components.Create<BlockSmile>(),
		BlockType.Sad => go.Components.Create<BlockSad>(),
		BlockType.SadPierce => go.Components.Create<BlockSadPierce>(),
		BlockType.Wind => go.Components.Create<BlockWind>(),
		BlockType.Magnet => go.Components.Create<BlockMagnet>(),
		BlockType.Sticky => go.Components.Create<BlockSticky>(),
		BlockType.Shade => go.Components.Create<BlockShade>(),
		BlockType.Stasis => go.Components.Create<BlockStasis>(),
		BlockType.Shockwave => go.Components.Create<BlockShockwave>(),
		BlockType.Slicer => go.Components.Create<BlockSlicer>(),
		BlockType.Mimic => go.Components.Create<BlockMimic>(),
		BlockType.Teleport => go.Components.Create<BlockTeleport>(),
		BlockType.Summoner => go.Components.Create<BlockSummoner>(),
		BlockType.Hunter => go.Components.Create<BlockHunter>(),
		BlockType.Siren => go.Components.Create<BlockSiren>(),
		BlockType.Wisp => go.Components.Create<BlockWisp>(),
		BlockType.Venom => go.Components.Create<BlockVenom>(),
		BlockType.Reverse => go.Components.Create<BlockReverse>(),
		BlockType.Laser => go.Components.Create<BlockLaser>(),
		BlockType.Bullet => go.Components.Create<BlockBullet>(),
		BlockType.DragonBounce => go.Components.Create<BlockDragonBounce>(),
		BlockType.LaserPierce => go.Components.Create<BlockLaserPierce>(),
		BlockType.StraightLaser => go.Components.Create<BlockStraightLaser>(),
		_ => go.Components.Create<Block>(),
	};

	/// <summary>Cloud puff for the Mimic block's transform + aura, drawn ABOVE the blocks/player (its own
	/// particle plane) so the "poof" reads on top. Cosmetic: drifts, fades, and neither collides nor bounces.</summary>
	public Particle AddMimicCloud( Vector2 pos, Vector2 vel, ParticleKind kind, float lifetime, int size, Color? color = null )
	{
		var go = CreateChild( "MimicCloud" );
		var p = go.Components.Create<Particle>();
		p.Stage = this;
		p.Depth = Globals.DEPTH_PARTICLE_1;   // above blocks (2) and player (3)
		p.Pos = pos;
		if ( color.HasValue )
			p.Setup( vel, 0.92f, 0f, color.Value, lifetime, size, collides: false, bounces: true );
		else
			p.Setup( vel, 0.92f, 0f, kind, lifetime, size, collides: false, bounces: true );
		p.CreateVisuals();
		Particles.Add( p );
		return p;
	}

	/// <summary>A burst of cloud puffs above a transforming mimic, plus a soft warp cue. Cosmetic.</summary>
	public void SpawnMimicCloudBurst( Vector2 pos, bool playSound = true, bool compact = false, Color? color = null )
	{
		if ( playSound ) Audio.PlaySfx( SfxType.TurnInvisible, pos, 0.6f, 1.1f );
		int count = compact ? 10 : 22;
		for ( int i = 0; i < count; i++ )
		{
			var kind = Rng.CosmeticValue() < 0.5f ? ParticleKind.MimicCloud0 : ParticleKind.MimicCloud1;
			float ang = Rng.CosmeticFloat( 0f, MathF.PI * 2f );
			float speed = compact ? Rng.CosmeticFloat( 8f, 20f ) : Rng.CosmeticFloat( 18f, 55f );
			float upwardBias = compact ? 6f : 22f;
			var vel = new Vector2( MathF.Cos( ang ) * speed, MathF.Sin( ang ) * speed + upwardBias );
			float jitterRange = compact ? 2f : 20f;
			var jitter = new Vector2( Rng.CosmeticFloat( -jitterRange, jitterRange ), Rng.CosmeticFloat( -jitterRange, jitterRange ) );
			float lifetime = compact ? Rng.CosmeticFloat( 0.35f, 0.55f ) : Rng.CosmeticFloat( 0.6f, 1.0f );
			int size = compact ? Rng.CosmeticInt( 4, 7 ) : Rng.CosmeticInt( 9, 15 );
			AddMimicCloud( pos + jitter, vel, kind, lifetime, size, color );
		}
	}

	public void SpawnMimicChoiceSparkles( Vector2 pos )
	{
		const int count = 8;
		for ( int i = 0; i < count; i++ )
		{
			float angle = MathF.PI * 2f * i / count;
			var radial = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
			ParticleKind kind = (i & 1) == 0 ? ParticleKind.MimicWow0 : ParticleKind.MimicWow1;
			AddParticle( pos + radial * Rng.CosmeticFloat( 4f, 7f ), radial * Rng.CosmeticFloat( 14f, 28f ),
				0.92f, 0f, kind, Rng.CosmeticFloat( 0.3f, 0.5f ), Rng.CosmeticInt( 2, 5 ) );
		}
	}

	/// <summary>Swap any mimic block that has reached a new phase for a real disguise type, spawned at
	/// that phase. Deferred to here (after the block tick loop) so the swap never destroys a block mid-
	/// tick. Skipped once the run is over (a phase-2 mimic that just triggered the win is left as-is).</summary>
	private void ProcessMimicTransforms()
	{
		if ( _gameOver ) return;

		List<Block> pending = null;
		foreach ( var b in Blocks )
		{
			if ( b.Dead || b.Mimic == null ) continue;
			if ( b.Phase > b.Mimic.TransformedThroughPhase )
				(pending ??= new List<Block>()).Add( b );
		}
		if ( pending == null ) return;
		foreach ( var b in pending ) TransformMimic( b );
	}

	private void TransformMimic( Block old )
	{
		var mimic = old.Mimic;
		int phase = old.Phase;                     // the phase it just reached (1 or 2)
		BlockType disguise = PickMimicType( mimic, phase );
		if ( phase == 1 ) mimic.Phase1Type = disguise;
		mimic.TransformedThroughPhase = phase;

		var pos = old.Pos;
		var dir = old.MoveDirection;
		int stageIndex = old.StageIndex;
		int listIndex = Blocks.IndexOf( old );
		float abilityInterval = old.AbilityInterval;
		float abilityRandomness = old.AbilityRandomness;
		float abilityClosedTime = old.AbilityClosedTime;

		// Cloud "poof" above the block, then destroy the old instance and spawn the real disguise at the
		// same spot/phase, carrying the mimic memory + heading forward. Stage-parented overlays (lane
		// tints, trails, rings, lines) do NOT die with the block's GameObject — release them explicitly
		// or they stay frozen on screen (with their gameplay effect gone) for the rest of the run.
		SpawnMimicCloudBurst( pos );
		old.DestroyStageVisuals();
		old.GameObject?.Destroy();

		var go = CreateChild( $"Block_{disguise}" );
		Block b = CreateBlockComponent( go, disguise );
		b.Stage = this;
		b.StageIndex = stageIndex;
		b.Mimic = mimic;
		b.Setup( disguise, BlockSprites[disguise] );
		b.Pos = pos;
		b.CreateVisuals();
		b.ConfigureAbilityTimer( abilityInterval, abilityRandomness, closedTime: abilityClosedTime );
		b.ConfigureTurnMode( old.TurnMode );
		b.SetInitialPhase( phase );
		b.CopySpikesFrom( old ); // a spiked side no longer blocks phase-up, so live grafts must survive the swap
		b.CopyStickyFrom( old ); // transferred goo likewise outlives the swap (keeps stuck players stuck)
		b.CopyScoreStartFrom( old );
		b.SetMoveDirection( dir );

		if ( listIndex >= 0 ) Blocks[listIndex] = b;
		else Blocks.Add( b );

		// Deterministic handoff: engine IsValid() on the destroyed instance only flips when the queued
		// delete flushes at the NEXT FRAME boundary — which never comes inside a one-frame re-sim
		// (timeline scrub, hijack rebuild, replay_verify), so sim code polling it desynced those paths
		// (glued to the old instance's frozen "phantom" rect). Mark the swap in sim terms and re-point
		// every live reference to the disguise on this same tick instead.
		old.Replaced = true;
		foreach ( var p in Players ) p.OnBlockReplaced( old, b );
		foreach ( var imp in Impostors ) imp.OnBlockReplaced( old, b );
		foreach ( var blk in Blocks ) if ( blk != b ) blk.OnBlockReplaced( old, b );
		foreach ( var t in Teardrops ) t.OnBlockReplaced( old, b );
	}

	/// <summary>Pick a disguise type: a curated block type NOT used by this level, never Mimic, and (for
	/// the phase-2 transform) never the same type the phase-1 disguise used nor one of
	/// <see cref="MimicPhase2Excluded"/>. Uses the AUTHORITATIVE Rng stream -- the chosen type changes
	/// gameplay (each type behaves differently), so it is part of the deterministic sim.</summary>
	private BlockType PickMimicType( MimicState mimic, int phase )
	{
		var levelTypes = LevelBlockTypes();
		bool p2Exclusions = phase >= 2 && SimVersion >= Sim.MIMIC_P2_EXCLUSIONS;
		_mimicCandidates.Clear();
		foreach ( var t in MimicDisguisePool )
		{
			if ( levelTypes.Contains( t ) ) continue;
			if ( phase >= 2 && t == mimic.Phase1Type ) continue;
			if ( p2Exclusions && MimicPhase2Excluded.Contains( t ) ) continue;
			_mimicCandidates.Add( t );
		}
		// Degenerate fallback (a level somehow reserved every disguise): relax the level exclusion, keep
		// the hard rules (not Mimic / not the phase-1 type / not a phase-2 exclusion) so a valid type is
		// always available.
		if ( _mimicCandidates.Count == 0 )
			foreach ( var t in MimicDisguisePool )
				if ( (phase < 2 || t != mimic.Phase1Type) && !(p2Exclusions && MimicPhase2Excluded.Contains( t )) )
					_mimicCandidates.Add( t );

		return _mimicCandidates[Rng.Int( 0, _mimicCandidates.Count )];
	}

	/// <summary>Tell the music how many phase-ups this level has left to give, so the song's pitch climb
	/// ends in the same place whether the level is one nearly-maxed block or eight fresh ones. Counts the
	/// spawned blocks' remaining phases; mimic transforms carry their phase over, so the total holds for
	/// the whole run.</summary>
	private void ScaleMusicProgressionToLevel()
	{
		int phaseUps = 0;
		foreach ( var b in Blocks )
			phaseUps += Math.Max( 0, Block.NUM_PHASES - 1 - b.Phase );
		Audio.ScaleMusicProgression( phaseUps );
	}

	private void SpawnBlock( Vector2 pos, BlockType type, BlockStart start = null, BlockAbilityTimer abilityTimer = null,
		Direction startDirection = Direction.None, TurnMode turnMode = TurnMode.Free )
	{
		var go = CreateChild( $"Block_{type}" );
		Block b = CreateBlockComponent( go, type );
		b.Stage = this;
		b.StageIndex = Blocks.Count; // spawn-order index; drives per-block lane-overlay Z (see Block.StageIndex)
		if ( type == BlockType.Mimic ) b.Mimic = new MimicState();
		b.Setup( type, BlockSprites[type] );
		if ( startDirection != Direction.None ) b.SetMoveDirection( startDirection );
		b.ConfigureTurnMode( turnMode );
		b.ConfigureAbilityTimer( abilityTimer?.Interval ?? Block.DEFAULT_ABILITY_INTERVAL,
			abilityTimer?.Randomness ?? 0f, abilityTimer?.StartOffset ?? 0f,
			abilityTimer?.ClosedTime ?? Block.DEFAULT_ABILITY_CLOSED_TIME );
		b.Pos = pos;
		b.CreateVisuals();
		// Optional authored starting phase / pre-pressed sides (see LevelDef.BlockStarts / PoolStartPhase).
		// Must run after CreateVisuals (it drives the face / side-button sprites).
		if ( start is not null && (start.Phase != 0 || (start.PressedSides is { Count: > 0 })) )
			b.SetInitialPhase( start.Phase, start.PressedSides );
		b.SetScoreStart( start?.Phase ?? 0, start?.PressedSides );
		Blocks.Add( b );
	}

	/// <summary>Spawn the line-of-sight cover if this level opts in (obstacles flagged to block vision
	/// and/or a vision-blocking block). The cover uses the level's resolved out-of-bounds colour so the
	/// hidden area reads as "outside the arena". See <see cref="VisionOccluder"/>.</summary>
	private void SpawnVisionOccluder()
	{
		bool hasExplicit = Level.VisionBlockers is { Count: > 0 };
		bool blockBlocksVision = Blocks.Exists( b => b.BlocksVision );
		// A Mimic block can transform into a vision-blocking Shade mid-run, so spawn the occluder up front
		// whenever a mimic is present (it produces no cover until a real blocker actually exists).
		bool mimicPresent = Blocks.Exists( b => b.Mimic != null );
		if ( !hasExplicit && !blockBlocksVision && !mimicPresent )
			return;

		var go = CreateChild( "VisionOccluder" );
		var vo = go.Components.Create<VisionOccluder>();
		vo.PlayerSource = () => Player;
		// Fill the hidden area with the level's direct wall colour so the cover reads as a wall
		// occluding the view.
		vo.Cover = WallColorLinear;
		vo.BlockerSource = EnumerateVisionBlockers;
		// The stage can render before this newly created component receives its first OnUpdate.
		vo.RebuildGeometry();
	}

	/// <summary>The current vision blockers: the vision-flagged OBSTACLE rects (as
	/// <see cref="VisionBlockMode.Umbra"/>), plus every vision-blocking BLOCK at its current
	/// phase-dependent mode. Evaluated every frame by the occluder, so a moving block's rect and a
	/// block phasing up are picked up live.</summary>
	private void EnumerateVisionBlockers( List<VisionBlocker> into )
	{
		if ( Level.VisionBlockers is not null )
		{
			// Vision blockers are solid OBSTACLE rects. An obstacle face draws its band as an exact quad
			// filling the WALL_SIZE strip just inside the collision edge, and its (spiked) teeth grow
			// outward from that edge (see BuildTiledFace) — so casting the umbra from the exact edge
			// would leave the whole band and the teeth uncovered, plus a faint colour seam on unspiked
			// far faces. Inset the caster so the umbra base lands half a pixel INSIDE the band: cover and
			// band are the same wall colour, so the overlap is seamless, and the margin keeps the base
			// off the dark fill while the umbra swallows the obstacle's own far walls/spikes.
			const float inset = Arena.WALL_SIZE - 0.5f;
			foreach ( var r in Level.VisionBlockers )
				into.Add( new VisionBlocker( new RectF( r.Left + inset, r.Bottom + inset, r.Right - inset, r.Top - inset ), VisionBlockMode.Umbra ) );
		}

		// Vision-blocking blocks contribute their current (phase-dependent) cover; a moving block's rect
		// is read live each frame. Inset it ~1px so the cover sits just inside the block art — otherwise a
		// 1px seam shows as the cover trails the moving block and at the block's rounded-corner pixels.
		const float blockInset = 1f;
		foreach ( var b in Blocks )
		{
			// A mimic disguise that reached a new phase but hasn't been swapped out yet (ProcessMimicTransforms
			// runs later in the tick) would briefly expose its NEW phase's cover -- e.g. a phase-1 Shade hitting
			// phase 2 flashing its spotlight for a frame before it becomes a different type. Skip a mimic that's
			// mid-transform so that transient cover never renders.
			if ( b.Mimic != null && b.Phase > b.Mimic.TransformedThroughPhase )
				continue;
			if ( b.VisionMode is VisionBlockMode mode )
			{
				RectF br = b.GetRect();
				into.Add( new VisionBlocker( new RectF( br.Left + blockInset, br.Bottom + blockInset, br.Right - blockInset, br.Top - blockInset ), mode ) );
			}
		}
	}

	private Player SpawnPlayer( Vector2 pos, CharacterDef character )
	{
		var go = CreateChild( "Player" );
		var p = go.Components.Create<Player>();
		p.Stage = this;
		p.Pos = pos;
		// The run context names the character (art + movement feel); resolve it before building
		// visuals so the correct sprite sheet and movement tuning are used.
		p.Character = character;
		p.CreateVisuals();
		return p;
	}

	private void SpawnRunPlayers( Vector2 pos, Vector2? authoredPartnerPos = null )
	{
		CharacterDef character = RunCharacter;
		var first = SpawnPlayer( pos, character );
		Players.Add( first );

		if ( character.Partner is not CharacterDef partner )
			return;

		var second = SpawnPlayer( authoredPartnerPos ?? FindPartnerSpawn( pos, first ), partner );
		Players.Add( second );
		_twinsController = new TwinsController( this, Players );
	}

	private Vector2 FindPartnerSpawn( Vector2 origin, Player first )
	{
		float dx = Player.COLLISION_SIZE.x + 2f;
		float dy = Player.COLLISION_SIZE.y + 2f;
		int maxRing = (int)MathF.Ceiling( MathF.Max( Arena.WIDTH / dx, Arena.HEIGHT / dy ) );
		for ( int ring = 1; ring <= maxRing; ring++ )
		{
			Vector2[] preferredOffsets =
			{
				new( ring * dx, 0f ), new( -ring * dx, 0f ), new( 0f, ring * dy ), new( 0f, -ring * dy ),
				new( ring * dx, ring * dy ), new( -ring * dx, ring * dy ),
				new( ring * dx, -ring * dy ), new( -ring * dx, -ring * dy ),
			};
			foreach ( var offset in preferredOffsets )
			{
				Vector2 candidate = origin + offset;
				if ( IsPlayerSpawnClear( candidate, first ) ) return candidate;
			}

			for ( int step = 1; step < ring; step++ )
			{
				Vector2[] edgeOffsets =
				{
					new( ring * dx, step * dy ), new( -ring * dx, step * dy ),
					new( ring * dx, -step * dy ), new( -ring * dx, -step * dy ),
					new( step * dx, ring * dy ), new( -step * dx, ring * dy ),
					new( step * dx, -ring * dy ), new( -step * dx, -ring * dy ),
				};
				foreach ( var offset in edgeOffsets )
				{
					Vector2 candidate = origin + offset;
					if ( IsPlayerSpawnClear( candidate, first ) ) return candidate;
				}
			}
		}

		Log.Warning( "[BlockParty] no clear partner spawn exists; spawning both twins at the authored player position." );
		return origin;
	}

	private bool IsPlayerSpawnClear( Vector2 center, Player first )
	{
		float halfWidth = Player.COLLISION_SIZE.x / 2f;
		float halfHeight = Player.COLLISION_SIZE.y / 2f;
		var rect = new RectF( center.x - halfWidth, center.y - halfHeight, center.x + halfWidth, center.y + halfHeight );
		if ( rect.Left < Arena.WALL_SIZE || rect.Right > Arena.WIDTH - Arena.WALL_SIZE
			|| rect.Bottom < Arena.WALL_SIZE || rect.Top > Arena.HEIGHT - Arena.WALL_SIZE )
			return false;
		if ( rect.Intersects( first.GetRect() ) ) return false;
		foreach ( var block in Blocks )
			if ( !block.Dead && rect.Intersects( block.GetRect() ) ) return false;
		foreach ( var obstacle in _obstacles )
			if ( rect.Intersects( obstacle.GetRect() ) ) return false;
		return !FallLandingDeadly( rect );
	}

	/// <summary>True if a body spawned at <paramref name="rect"/> would fall onto level-authored spikes.
	/// The partner spawns in the air with no control, so the STATIC surface its fall column ends on must
	/// be harmless: a spiked floor stretch or spiked obstacle top under any part of the rect rejects the
	/// candidate. Blocks don't count as support — harmless at spawn, they move away and drop the twin
	/// onto whatever waits beneath. Landing is the only spike kill in play here: spikes kill on relative
	/// motion INTO them, and a spawn never touches a side/ceiling face (candidates keep a 1px wall gap,
	/// and an uncontrolled fall only moves down).</summary>
	private bool FallLandingDeadly( RectF rect )
	{
		// Sample the landing across the rect's span (corners nudged inward so a seam-exact edge doesn't
		// probe the neighbouring face segment): a ledge landing can deflect either way, so EVERY column's
		// own support must be safe, not just the highest one.
		for ( int i = 0; i < 3; i++ )
		{
			float x = i switch
			{
				0 => rect.Left + 0.5f,
				1 => (rect.Left + rect.Right) * 0.5f,
				_ => rect.Right - 0.5f,
			};

			Obstacle support = null;
			float supportTop = Arena.WALL_SIZE; // the arena floor backstops every column
			foreach ( var obstacle in _obstacles )
			{
				RectF r = obstacle.GetRect();
				if ( x < r.Left || x > r.Right ) continue;
				if ( r.Top > rect.Bottom || r.Top < supportTop ) continue;
				support = obstacle;
				supportTop = r.Top;
			}

			var landing = new Vector2( x, supportTop );
			bool deadly = support is null
				? WallDeadlyAt( Direction.Down, landing )
				: ObstacleFaceDeadlyAt( support, Direction.Up, landing );
			if ( deadly ) return true;
		}
		return false;
	}

	/// <summary>Spawn a Summoner impostor (a hostile AI clone of the player) at <paramref name="pos"/>. It
	/// uses Original's movement, its own baked phase sprite, an AI input brain, and joins the Impostors list so
	/// it ticks and can be pushed / kill like the player.</summary>
	public Player SpawnImpostor( Vector2 pos, int phase )
	{
		var go = CreateChild( "Impostor" );
		var p = go.Components.Create<Player>();
		p.Stage = this;
		p.Pos = pos;
		p.MakeImpostor( new AiInputSource() );
		p.Character = BuildImpostorCharacter( phase );
		p.CreateVisuals();
		Impostors.Add( p );
		return p;
	}

	public Player SpawnSwarmClone( Player owner, out Player anchor )
	{
		if ( !TryFindSwarmSpawn( owner, out Vector2 pos, out anchor ) ) return null;

		var go = CreateChild( "Swarm Copy" );
		var clone = go.Components.Create<Player>();
		clone.Stage = this;
		clone.Pos = pos;
		clone.MakeSwarmClone( owner );
		clone.SwarmTraceId = ++_swarmTraceNextId;
		clone.Character = BuildSwarmCloneCharacter();
		clone.CreateVisuals();

		Player.RewindState state = owner.CaptureRewindState() with { Position = pos };
		clone.RestoreRewindState( state );
		if ( Rng.Float( 0f, 1f ) < SWARM_SPAWN_EXTRA_MOVE_CHANCE )
		{
			Vector2 outward = Utils.Normalized( pos - anchor.Pos );
			Vector2 direction = Utils.RotateVector( outward, Rng.Float( -SWARM_SPAWN_EXTRA_MOVE_ANGLE, SWARM_SPAWN_EXTRA_MOVE_ANGLE ) );
			clone.ApplySwarmRepel( direction, Rng.Float( SWARM_SPAWN_EXTRA_MOVE_MIN, SWARM_SPAWN_EXTRA_MOVE_MAX ) );
		}
		if ( owner.ReverseGravityFieldActive ) clone.ApplyReverseGravity();
		clone.RefreshContactsAfterTeleport();
		Impostors.Add( clone );
		return clone;
	}

	// A new copy buds off a RANDOM existing swarm body (the player or any live clone), deliberately
	// OVERLAPPING it: offset far enough to read as two bodies, close enough that the penetration stays
	// at/above SWARM_LAUNCH_MIN_OVERLAP on both axes, so the same-frame ResolveSwarmRepel pass fires the
	// launch and the newborn pops out in a random direction — multiplication feels unpredictable.
	// Bounds are matched to the 8×10 body: |dx| ≤ 6 keeps overlapX ≥ 2, |dy| ≤ 8 keeps overlapY ≥ 2.
	private const float SWARM_SPAWN_OFFSET_X_MIN = 3.5f;
	private const float SWARM_SPAWN_OFFSET_X_MAX = 6.0f;
	private const float SWARM_SPAWN_OFFSET_Y_MIN = 4.5f;
	private const float SWARM_SPAWN_OFFSET_Y_MAX = 8.0f;
	private const float SWARM_SPAWN_EXTRA_MOVE_CHANCE = 0.45f;
	private const float SWARM_SPAWN_EXTRA_MOVE_ANGLE = 35f;
	private const float SWARM_SPAWN_EXTRA_MOVE_MIN = 20f;
	private const float SWARM_SPAWN_EXTRA_MOVE_MAX = 55f;

	// Trace-only: stamps each bud with a stable label for `swarm_trace` (never read by the sim).
	private int _swarmTraceNextId;

	private bool TryFindSwarmSpawn( Player owner, out Vector2 position, out Player anchor )
	{
		var bodies = new List<Player>( 6 ) { owner };
		foreach ( Player clone in Impostors )
			if ( !clone.IsDead && clone.IsSwarmClone && ReferenceEquals( clone.SwarmOwner, owner ) )
				bodies.Add( clone );

		const int attempts = 24;
		for ( int i = 0; i < attempts; i++ )
		{
			anchor = bodies[Rng.Int( 0, bodies.Count )];
			float angle = Rng.Float( 0f, MathF.PI * 2f );
			Vector2 offset = new(
				MathF.Cos( angle ) * Rng.Float( SWARM_SPAWN_OFFSET_X_MIN, SWARM_SPAWN_OFFSET_X_MAX ),
				MathF.Sin( angle ) * Rng.Float( SWARM_SPAWN_OFFSET_Y_MIN, SWARM_SPAWN_OFFSET_Y_MAX ) );
			Vector2 candidate = anchor.Pos + offset;
			if ( IsSwarmSpawnClear( owner, candidate ) )
			{
				position = candidate;
				return true;
			}
		}

		position = default;
		anchor = null;
		return false;
	}

	private bool IsSwarmSpawnClear( Player owner, Vector2 center )
	{
		float halfWidth = Player.COLLISION_SIZE.x / 2f;
		float halfHeight = Player.COLLISION_SIZE.y / 2f;
		var rect = new RectF( center.x - halfWidth, center.y - halfHeight, center.x + halfWidth, center.y + halfHeight );
		if ( rect.Left <= Arena.WALL_SIZE || rect.Right >= Arena.WIDTH - Arena.WALL_SIZE
			|| rect.Bottom <= Arena.WALL_SIZE || rect.Top >= Arena.HEIGHT - Arena.WALL_SIZE )
			return false;
		foreach ( var block in Blocks )
			if ( !block.Dead && !block.PhasingIn && rect.Intersects( block.GetRect() ) ) return false;
		foreach ( var obstacle in _obstacles )
			if ( rect.Intersects( obstacle.GetRect() ) ) return false;
		// A newborn deliberately overlaps its own swarm (that's what launches it) — only OTHER run
		// players and hostile impostors block the spot.
		foreach ( var player in Players )
			if ( !player.IsDead && !ReferenceEquals( player, owner ) && rect.Intersects( player.GetRect() ) ) return false;
		foreach ( var impostor in Impostors )
		{
			if ( impostor.IsDead ) continue;
			if ( impostor.IsSwarmClone && ReferenceEquals( impostor.SwarmOwner, owner ) ) continue;
			if ( rect.Intersects( impostor.GetRect() ) ) return false;
		}
		return true;
	}

	private static CharacterDef BuildSwarmCloneCharacter() => new()
	{
		Id = "swarm-copy",
		Name = "SWARM COPY",
		SpritePath = Characters.Swarm.SpritePath,
		ArtSize = Characters.Swarm.ArtSize,
		Abilities = Characters.Swarm.Abilities with { HasSwarm = false },
		Movement = Characters.Swarm.Movement,
	};

	/// <summary>The impostor's character: Original's art + abilities, but a deliberately
	/// FLOATIER movement feel — slower top speed, weaker jump, less ground friction and lighter gravity —
	/// so the clone reads as a drifting menace. Built fresh each spawn so a live tuning edit (hotload)
	/// takes effect on the next summon. The softer, faster-recovering wall-jump also lets it climb a wall
	/// by repeatedly kicking off it (see AiInputSource wall-climb).</summary>
	private static CharacterDef BuildImpostorCharacter( int phase ) => new()
	{
		Id = "impostor",
		Name = "IMPOSTOR",
		// Phase-1 slams summon the red clone; phase-2 slams the deeper crimson-magenta one. Each is a
		// dedicated sprite (Assets/sprites/player_impostor_p{1,2}_sheet.png) so the art can be hand-edited.
		SpritePath = phase >= 2 ? "sprites/player_impostor_p2.sprite" : "sprites/player_impostor_p1.sprite",
		ArtSize = phase >= 2 ? new Vector2( 14f, 14f ) : Player.ART_SIZE,
		Abilities = Characters.Original.Abilities,
		Movement = phase >= 2 ? ImpostorMovementP2() : ImpostorMovementP1(),
		Audio = new CharacterAudio
		{
			Jump = phase >= 2 ? SfxType.ImpostorJumpP2 : SfxType.ImpostorJumpP1,
			// Own footstep events (not the shared player_walk) so a crowd's steps can be rate-limited
			// in Audio without gating a fast real character's cadence.
			Footstep = phase >= 2 ? SfxType.ImpostorWalkP2 : SfxType.ImpostorWalkP1,
			PlayLandSfx = false,
		},
	};

	/// <summary>Phase-2 impostor movement — the base "drifting menace": slow top speed, a weak jump,
	/// light gravity, low ground friction, and a soft, fast-recovering wall-jump so it can climb a wall
	/// by repeatedly kicking off it (see AiInputSource wall-climb).</summary>
	private static CharacterMovement ImpostorMovementP2() => new()
	{
		MaxXSpeed = 42.0f,                // slower than the player's 86
		JumpPower = 62.0f,                // weaker than 112
		MaxRiseSpeed = 110.0f,            // cap upward speed so chained wall-jumps can't stack into a rocket
		HorizontalDeceleration = 210.0f,  // less than 330 — slides / floats more
		Gravity = 155.0f,                 // lighter than 300 — floatier fall
		ActiveUpGravityFactor = 0.40f,    // extra float while holding Up (0.5 default)
		WallJumpVerticalPower = 96.0f,    // matches the softer jump
		WallJumpHorizontalPower = 48.0f,  // smaller kick off the wall (70 default) so it stays near it
		WallJumpTime = 0.45f,             // friction recovers faster (1.0 default) so it returns to the wall to climb
	};

	/// <summary>Phase-1 impostor movement — an EVEN slower, floatier version of the phase-2 feel (a
	/// gentler early menace): lower top speed and lighter gravity so it drifts toward the player rather
	/// than rushing. Phase 2 keeps the punchier feel above.</summary>
	private static CharacterMovement ImpostorMovementP1() => new()
	{
		MaxXSpeed = 20.0f,                // MUCH slower than phase 2's 42 — a crawling drift
		JumpPower = 50.0f,                // weaker still
		MaxRiseSpeed = 88.0f,             // tight rise cap — no runaway wall-jump stacking
		MaxFallSpeed = 120.0f,            // capped descent so it drifts DOWN slowly (very floaty)
		HorizontalDeceleration = 140.0f,  // very low friction — long slippery glides
		Gravity = 82.0f,                  // far lighter than phase 2's 155 — hangs in the air
		ActiveUpGravityFactor = 0.24f,    // lots of float while holding Up
		WallJumpVerticalPower = 80.0f,
		WallJumpHorizontalPower = 42.0f,
		WallJumpTime = 0.45f,
	};

	/// <summary>Advance every secondary body, expire AI clones at the end of their configured lifetime,
	/// and resolve hostile contact plus soft Swarm separation.</summary>
	private void TickImpostors( float dt )
	{
		// Once the run is decided the impostors stop chasing/killing. Hostile clones celebrate a player
		// death and pop instantly on a win. Swarm copies retire silently with their dead owner, but on a
		// win they stay alive and keep mirroring input alongside the winner — hazards can still kill them
		// normally during the beat.
		if ( _gameOver )
		{
			foreach ( var imp in Impostors )
			{
				if ( imp.IsDead ) continue;
				if ( imp.IsSwarmClone )
				{
					if ( _died ) imp.RetireSwarmCloneSilently();
					else imp.Tick( dt );
				}
				else if ( _died )
				{
					imp.Ai.Celebrate( imp, dt );
					imp.Tick( dt );
				}
				else
				{
					imp.ExpireImpostor(); // player won — pop instantly
				}
			}
			// Keep the surviving swarm readable while it celebrates (a no-op when the owner died).
			ResolveSwarmRepel();
			return;
		}

		foreach ( var imp in Impostors )
		{
			if ( imp.IsDead ) continue;
			if ( imp.IsSwarmClone )
			{
				imp.Tick( dt );
				continue;
			}
			Player targetPlayer = ClosestTargetablePlayer( imp.Pos );
			Vector2 target = targetPlayer is { IsDead: false }
				? targetPlayer.Pos
				: new Vector2( Arena.WIDTH / 2f, Arena.HEIGHT / 2f );
			imp.Ai.Think( imp, target, dt );
			if ( imp.Ai.Expired ) { imp.ExpireImpostor(); continue; }
			imp.Tick( dt );
		}

		// A live impostor kills the player only on a SUBSTANTIAL overlap (not a bare edge touch): the
		// clone has to be well on top of them, with at least IMPOSTOR_KILL_OVERLAP px of overlap on BOTH axes.
		foreach ( var player in Players )
		{
			if ( player.IsDead || player.IsHardened ) continue;
			RectF pr = player.GetRect();
			foreach ( var imp in Impostors )
			{
				if ( imp.IsDead || imp.IsSwarmClone ) continue;
				RectF ir = imp.GetRect();
				float ox = MathF.Min( pr.Right, ir.Right ) - MathF.Max( pr.Left, ir.Left );
				float oy = MathF.Min( pr.Top, ir.Top ) - MathF.Max( pr.Bottom, ir.Bottom );
				if ( ox >= IMPOSTOR_KILL_OVERLAP && oy >= IMPOSTOR_KILL_OVERLAP )
				{
					player.KilledByImpostor( imp.Pos );
					break;
				}
			}
		}

		foreach ( var clone in Impostors )
		{
			if ( clone.IsDead || !clone.IsSwarmClone ) continue;
			RectF cloneRect = clone.GetRect();
			foreach ( var imp in Impostors )
			{
				if ( imp.IsDead || imp.IsSwarmClone ) continue;
				RectF impRect = imp.GetRect();
				if ( cloneRect.Intersects( impRect ) )
				{
					clone.KilledByImpostor( imp.Pos );
					break;
				}
			}
		}

		ResolveSwarmRepel();
	}

	// Minimum penetration depth (along the separation axis) before an overlap counts as a real press
	// and fires the swarm launch; shallower contact only feeds the soft depenetration channel.
	private const float SWARM_LAUNCH_MIN_OVERLAP = 2.0f;

	// Reused by ResolveSwarmRepel (it runs every tick; only rebuilt while a swarm actually exists).
	private readonly List<Player> _swarmBodies = new();

	private void ResolveSwarmRepel()
	{
		// No live swarm copies -> every bodies list below would be a single owner and the pair loop
		// a no-op. Skip the whole pass (this runs every tick, for every character).
		bool anySwarm = false;
		foreach ( Player clone in Impostors )
			if ( !clone.IsDead && clone.IsSwarmClone ) { anySwarm = true; break; }
		if ( !anySwarm ) return;

		foreach ( Player owner in Players )
		{
			if ( owner.IsDead ) continue;
			List<Player> bodies = _swarmBodies;
			bodies.Clear();
			bodies.Add( owner );
			foreach ( Player clone in Impostors )
				if ( !clone.IsDead && clone.IsSwarmClone && ReferenceEquals( clone.SwarmOwner, owner ) )
					bodies.Add( clone );

			for ( int i = 0; i < bodies.Count; i++ )
			{
				for ( int j = i + 1; j < bodies.Count; j++ )
				{
					Player first = bodies[i];
					Player second = bodies[j];
					RectF firstRect = first.GetRect();
					RectF secondRect = second.GetRect();
					float overlapX = MathF.Min( firstRect.Right, secondRect.Right ) - MathF.Max( firstRect.Left, secondRect.Left );
					float overlapY = MathF.Min( firstRect.Top, secondRect.Top ) - MathF.Max( firstRect.Bottom, secondRect.Bottom );
					if ( overlapX <= 0f || overlapY <= 0f ) continue;

					Vector2 delta = second.Pos - first.Pos;
					Vector2 direction = overlapX <= overlapY
						? new Vector2( delta.x >= 0f ? 1f : -1f, 0f )
						: new Vector2( 0f, delta.y >= 0f ? 1f : -1f );
					float strength = Math.Clamp( 12f + MathF.Min( overlapX, overlapY ) * 5f, 12f, 40f );
					first.ApplySwarmRepel( -direction, strength );
					second.ApplySwarmRepel( direction, strength );

					// A substantial press (not a 1px graze) additionally fires the strong one-shot
					// bounce — the pair splits apart hard, and landing on a copy works as a jump. Each
					// body launches on its OWN cooldown: a ready body can still spring off a partner
					// that just launched (chain-hopping across the swarm), the pop just lands one-sided.
					if ( MathF.Min( overlapX, overlapY ) >= SWARM_LAUNCH_MIN_OVERLAP )
					{
						if ( first.SwarmLaunchReady ) first.ApplySwarmLaunch( -direction, second );
						if ( second.SwarmLaunchReady ) second.ApplySwarmLaunch( direction, first );
					}
				}
			}
		}
	}

	// --- in-game HUD / options overlay --------------------------------------------------
	/// <summary>Spawn the screen-space HUD (the gear button + the options overlay it toggles).</summary>
	private void CreateHud()
	{
		var ui = CreateChild( "UI" );
		var screen = ui.Components.Create<ScreenPanel>();
		screen.ZIndex = 100;
		_hud = ui.Components.Create<GameHud>();
		_hud.Stage = this;
	}

	public void EnsureReactionSprites()
	{
		if ( !IsReplay || _reactionSprites.IsValid() ) return;
		_reactionSprites = CreateChild( "ReactionSprites" ).Components.Create<ReplayReactionSprites>();
		_reactionSprites.Stage = this;
	}

	public void ShowReactionTooltip( string name )
	{
		if ( !IsReplay ) return;
		// Lazy creation also supports hotloading this feature into an already-open replay.
		if ( !ReactionTooltip.IsValid() )
		{
			ReactionTooltip = CreateChild( "ReactionTooltip" ).Components.Create<ReplayReactionTooltip>();
			ReactionTooltip.Stage = this;
		}
		ReactionTooltip.Show( name );
	}

	/// <summary>Open the in-game options overlay (gear button). Freezes the sim; shows the cursor.</summary>
	public void OpenMenu()
	{
		if ( MenuOpen || ShowCharacterHelp || LeaveConfirmOpen ) return;
		MenuOpen = true;
		Audio.PlaySfx( SfxType.MenuStart );
	}

	/// <summary>Close the overlay and resume gameplay where it left off.</summary>
	public void CloseMenu()
	{
		if ( !MenuOpen ) return;
		MenuOpen = false;
	}

	/// <summary>Whether leaving should be confirmed first: a live, undecided, non-debug daily run on a
	/// day with an attempt limit. The attempt was consumed at launch (RegisterAttemptStart), so bailing
	/// early spends it with nothing to show. A decided run (game-over beat begun) banks its score on
	/// the way out, so it leaves freely; unlimited days lose nothing by leaving.</summary>
	public bool NeedsLeaveConfirm => !IsReplay && !IsTest && !IsDebugRun && !_gameOver && !_ended
		&& Manager.CurrentRunContext.IsDaily
		&& DailyLevels.Get( Manager.CurrentRunContext.DailyId )?.MaxAttempts is not null;

	/// <summary>Intercept a Back/Home leave. Opens the confirm prompt (holding <paramref name="action"/>
	/// for its LEAVE button) and returns true when <see cref="NeedsLeaveConfirm"/>; returns false so
	/// the caller leaves immediately otherwise. A prompt already up swallows the request.</summary>
	public bool TryOpenLeaveConfirm( LeaveAction action )
	{
		if ( LeaveConfirmOpen ) return true;
		if ( action == LeaveAction.None || !NeedsLeaveConfirm ) return false;

		_pendingLeave = action;
		LeaveConfirmSelection = 0;
		LeaveConfirmRevision++;
		return true;
	}

	/// <summary>The prompt's LEAVE: carry out the intercepted Back/Home. Closes the prompt first so the
	/// manager's leave path sees a plain live stage.</summary>
	public void ConfirmLeave()
	{
		var action = _pendingLeave;
		if ( action == LeaveAction.None ) return;

		CloseLeaveConfirm();
		Audio.PlaySfx( SfxType.MenuStart );
		Haptics.MenuConfirm();
		if ( action == LeaveAction.Home ) Manager.QuitToMenu();
		else Manager.BackToMenu();
	}

	/// <summary>The prompt's KEEP PLAYING (also Back, and a click on the dim): resume where the run froze.</summary>
	public void CancelLeave()
	{
		if ( !LeaveConfirmOpen ) return;
		CloseLeaveConfirm();
		Audio.PlaySfx( SfxType.MenuBlip );
	}

	/// <summary>Move the prompt's focus (mouse hover or a direction press). Blips only on a keyed change.</summary>
	public void SetLeaveConfirmSelection( int selection, bool blip = false )
	{
		selection = selection == 0 ? 0 : 1;
		if ( !LeaveConfirmOpen || selection == LeaveConfirmSelection ) return;
		LeaveConfirmSelection = selection;
		LeaveConfirmRevision++;
		if ( blip ) Audio.PlaySfx( SfxType.MenuBlip );
	}

	private void CloseLeaveConfirm()
	{
		_pendingLeave = LeaveAction.None;
		LeaveConfirmRevision++;
	}

	/// <summary>Attempts line for the leave prompt: what the day has left once this run is spent
	/// (the merged counter already includes this run). Null when the day has no limit.</summary>
	public string LeaveConfirmAttemptsText
	{
		get
		{
			var ctx = Manager.CurrentRunContext;
			if ( !ctx.IsDaily || DailyLevels.Get( ctx.DailyId )?.MaxAttempts is not int max )
				return null;
			int left = System.Math.Max( 0, max - DailyChallengeProgress.DisplayedAttemptsUsed( ctx.DailyId ) );
			return left == 0 ? "NO ATTEMPTS LEFT AFTER THIS" : $"{left}/{max} ATTEMPTS LEFT AFTER THIS";
		}
	}

	public void OpenCharacterHelp()
	{
		if ( HelpDef is null || ShowCharacterHelp || MenuOpen || ShowTutorialPrompt || LeaveConfirmOpen )
			return;

		// One manual open anywhere (replays and test runs included — it teaches the same lesson)
		// retires the button's nudge pulse for good.
		CharacterProgress.MarkHelpButtonUsed();
		ShowCharacterHelpOverlay( fadesOnDismiss: false );
		Audio.PlaySfx( SfxType.MenuStart );
	}

	public void DismissCharacterHelp()
	{
		if ( _characterHelpPhase != CharacterHelpPhase.Visible )
			return;

		Audio.PlaySfx( SfxType.MenuBlip, volume: 0.7f, pitch: 1.25f );
		if ( !_characterHelpFadesOnDismiss )
		{
			HideCharacterHelp();
			return;
		}

		_characterHelpPhase = CharacterHelpPhase.AutoDismissFading;
		_characterHelpFadeTimer = CHARACTER_HELP_FADE_TIME;
		_characterHelpRevision++;
	}

	private void TryAutoOpenCharacterHelp()
	{
		if ( IsReplay || IsTest || IsDebugRun || RunCharacter?.Help is not { AutoShowOnFirstPlay: true }
			|| CharacterProgress.HasSeenHelp( RunCharacter.Id ) )
			return;

		CharacterProgress.MarkHelpSeen( RunCharacter.Id );
		ShowCharacterHelpOverlay( fadesOnDismiss: true );
	}

	private void ShowCharacterHelpOverlay( bool fadesOnDismiss )
	{
		_characterHelpCharacterId = HelpCharacter?.Id;
		_characterHelpIsMimicForm = IsMimicFormHelp;
		_characterHelpFadesOnDismiss = fadesOnDismiss;
		_characterHelpFadeTimer = 0f;
		_characterHelpAnimationTimer = 0f;
		_characterHelpAnimationStep = 0;
		_characterHelpPhase = CharacterHelpPhase.Visible;
		_characterHelpRevision++;
	}

	private void HideCharacterHelp()
	{
		_characterHelpPhase = CharacterHelpPhase.Closed;
		_characterHelpFadesOnDismiss = false;
		_characterHelpFadeTimer = 0f;
		_characterHelpRevision++;
	}

	/// <summary>While an in-game overlay owns input, simulation is frozen. The tutorial popup only
	/// freezes it while VISIBLE: the moment its hold-to-dismiss bar fills the sim resumes, so the key
	/// the player is already holding drives play through the popup's fade with no dead frame.</summary>
	public override bool BlocksSimulation => MenuOpen || ShowCharacterHelp || IsTutorialPromptVisible || LeaveConfirmOpen;

	/// <summary>Pump whichever overlay currently owns the frozen simulation.</summary>
	public override void TickMenu( float dt )
	{
		if ( LeaveConfirmOpen )
		{
			// Two buttons side by side: Confirm activates the focused one, Back backs out of the prompt
			// (a guard against leaving must never itself leave), and any direction edge flips the focus.
			// Confirm/Back are resolved BEFORE the flip: Confirm shares Space/A with Jump, and a Jump
			// press also raises the directional edge for its resolved direction (InputState.Sample), so
			// with a sideways jump direction (wall cling/hug, Shifter on a wall, a wall-charge wind-up)
			// one press carries ConfirmJust AND LeftJust/RightJust. Flipping first would move the focus
			// from KEEP PLAYING onto LEAVE and then confirm it.
			if ( InputState.ConfirmJust )
			{
				if ( LeaveConfirmSelection == 1 ) ConfirmLeave();
				else CancelLeave();
				return;
			}
			if ( InputState.BackJust )
			{
				CancelLeave();
				return;
			}

			if ( InputState.LeftJust || InputState.RightJust || InputState.NavUpJust || InputState.NavDownJust )
				SetLeaveConfirmSelection( 1 - LeaveConfirmSelection, blip: true );
			return;
		}

		if ( ShowCharacterHelp )
		{
			_characterHelpAnimationTimer += dt;
			while ( _characterHelpAnimationTimer >= CHARACTER_HELP_ANIMATION_STEP_TIME )
			{
				_characterHelpAnimationTimer -= CHARACTER_HELP_ANIMATION_STEP_TIME;
				_characterHelpAnimationStep = (_characterHelpAnimationStep + 1) % CHARACTER_HELP_ANIMATION_WRAP;
			}

			if ( _characterHelpPhase == CharacterHelpPhase.Visible && InputState.ConfirmJust )
				DismissCharacterHelp();
			else if ( _characterHelpPhase == CharacterHelpPhase.AutoDismissFading )
			{
				_characterHelpFadeTimer -= dt;
				if ( _characterHelpFadeTimer <= 0f )
					HideCharacterHelp();
			}
			return;
		}

		if ( IsTutorialPromptVisible )
		{
			// Hold-to-dismiss: holding any game input (Space/WASD/arrows/controller — see
			// PopupDismissHeld) fills the bar; releasing snaps it empty. Arms only on a FRESH press
			// after the popup appeared (like hold-to-restart) — a key still held from play when the
			// popup pops must not bleed into a dismissal the player never chose. A held mouse button
			// on the popup counts too (its mousedown can only happen after the popup appeared, so
			// it's a fresh press by construction). The Dismissing fade is NOT pumped here: it ticks
			// in Tick(), because the sim is already live again by then (see BlocksSimulation).
			if ( InputState.PopupDismissJust )
				_tutorialPromptHoldArmed = true;
			bool holding = (_tutorialPromptHoldArmed && InputState.PopupDismissHeld) || _tutorialPromptPointerHeld;
			_tutorialPromptHoldTime = holding ? _tutorialPromptHoldTime + dt : 0f;
			if ( _tutorialPromptHoldTime >= TUTORIAL_PROMPT_HOLD_TIME )
				DismissTutorialInstruction();
			return;
		}

		OptionsController.Tick( dt );
	}

	/// <summary>The mouse button went down/up on the popup (GameHud's mousedown/mouseup) — held, it
	/// fills the dismiss bar exactly like a held key.</summary>
	public void SetTutorialPromptPointerHeld( bool held ) => _tutorialPromptPointerHeld = held;

	private void DismissTutorialInstruction()
	{
		if ( _tutorialPromptPhase != TutorialPromptPhase.Visible )
			return;

		_tutorialPromptPhase = TutorialPromptPhase.Dismissing;
		_tutorialPromptHoldTime = TUTORIAL_PROMPT_HOLD_TIME; // bar reads full through the fade
		_tutorialPromptDismissTimer = TUTORIAL_PROMPT_DISMISS_TIME;
		_tutorialPromptPointerHeld = false;
		_tutorialPromptRevision++;
		Audio.PlaySfx( SfxType.MenuBlip, volume: 0.7f, pitch: 1.25f );
	}

	private void ShowTutorialInstruction( string text )
	{
		_tutorialPromptText = text;
		_tutorialPromptHoldArmed = false;
		_tutorialPromptPointerHeld = false;
		_tutorialPromptHoldTime = 0f;
		_tutorialPromptPhase = TutorialPromptPhase.Visible;
		_tutorialPromptRevision++;
	}

	private void HideTutorialInstruction()
	{
		_tutorialPromptPhase = TutorialPromptPhase.None;
		_tutorialPromptRevision++;
	}

	/// <summary>Called by the player when it dies. Death ends the run (after a short beat).</summary>
	public void PlayerHasDied( Player player )
	{
		_twinsController?.PlayerDied( player );
		// A death AFTER the run is decided (a still-flying block bullet or live wall spike during the
		// victory beat) must not touch the outcome: it would deny the flawless Twins pairing, count a
		// death against a won run, and re-arm the restart affordance over a win. _gameOver from a death
		// means every player is already dead, so this only ever trips post-victory. The Twins hand-off
		// above still runs so the surviving body keeps control through the beat.
		if ( _gameOver ) return;
		_anyPlayerDied = true;
		foreach ( var candidate in Players )
			if ( !candidate.IsDead ) return;

		_died = true;
		foreach ( var b in Blocks ) b.PlayerHasDied();
		BeginGameOver( GAME_OVER_DELAY );
	}

	public void PlayerHardened( Player player )
	{
		_twinsController?.PlayerHardened( player );
	}

	/// <summary>Called when a block hits max phase; win once every block is max phase.</summary>
	public void BlockReachedMaxPhase( Block block ) => TryBeginVictory();

	/// <summary>Start the win beat if every block is at max phase and the run isn't already decided.
	/// Returns true if the win began here.</summary>
	private bool TryBeginVictory()
	{
		if ( _gameOver || Blocks.Count == 0 ) return false;
		foreach ( var b in Blocks )
			if ( b.Phase < Block.NUM_PHASES - 1 ) return false;

		foreach ( var b in Blocks ) b.Die();
		_victoryExplosionSfxTimer = 0f;
		BeginGameOver( WIN_DELAY );
		return true;
	}

	/// <summary>Arm the second Tutorial instruction after its block first reaches phase one.</summary>
	public void BlockReachedPhase( Block block )
	{
		if ( !_tutorialPromptsEnabled || _tutorialSecondPromptShown || block?.Phase != 1 )
			return;

		_tutorialSecondPromptShown = true;
		_tutorialSecondPromptDelay = TUTORIAL_SECOND_PROMPT_DELAY;
	}

	private void BeginGameOver( float delay )
	{
		if ( _gameOver ) return;
		_gameOver = true;
		_gameOverTimer = delay;
	}

	/// <summary>True once the run has entered its game-over beat (death/win) but before <see cref="EndGame"/>
	/// has actually fired. The score is already locked in here (the beat is just a cosmetic countdown).</summary>
	public bool IsGameOver => _gameOver;

	/// <summary>Leave an editor TEST run and return to the editor. Driven by the Back key + the HUD exit
	/// button; unattended deaths also return through <see cref="EndGame"/>. A no-op if this isn't a test
	/// run or the run already concluded. Marks <c>_ended</c> so the game-over timer can't also fire a
	/// restart after the user has chosen to leave.</summary>
	public void ExitTest()
	{
		// Only exit once, and only when the manager can actually perform the return. If a stage wipe is
		// still running (e.g. the start-test transition, right as the level appears), TransitionToStage
		// silently ignores overlapping wipes — so setting _ended here without the return happening would
		// permanently dead-lock the button: _ended stays true and every later click is rejected by the
		// !_ended guard. Ignoring the click while transitioning lets a later click (once the wipe ends)
		// exit normally.
		if ( IsTest && !_ended && !Manager.IsTransitioning )
		{
			_ended = true;
			Manager.ReturnFromTestPlay();
		}
	}

	/// <summary>Whether a live run can restart now. Active runs may restart immediately with R; after
	/// game-over, only a death remains restartable so a victory still proceeds to its score tally.
	/// Replays and editor tests use their own restart paths. A daily run allows restarting
	/// only while the day is still TODAY (a run held across UTC midnight can finish but not relaunch
	/// on the closed board) and has attempts remaining (the restart consumes one — see
	/// <see cref="GameManager.RestartRun"/>); a DEBUG daily restarts freely.</summary>
	public bool CanRestart => (!_gameOver || _died) && !IsReplay && !IsTest
		&& (!Manager.CurrentRunContext.IsDaily
			|| IsDebugRun
			|| (Manager.CurrentRunContext.DailyId == DailyChallenge.TodayId
				&& DailyChallengeProgress.HasAttemptsRemaining( Manager.CurrentRunContext.DailyId,
					DailyLevels.Get( Manager.CurrentRunContext.DailyId )?.MaxAttempts )) );

	/// <summary>The on-screen restart affordance remains death-only; active-run restarts are an R shortcut.</summary>
	public bool ShowRestartButton => _died && CanRestart;

	/// <summary>Restart affordance for a hijacked practice run: R retries from the takeover point at any
	/// time during a hijack, but the button appears only once this attempt has died — a visual reminder,
	/// in the same letterbox spot as the live-run restart. An armed hijack point implies this stage is a
	/// hijack practice run (arming is cleared whenever normal playback resumes).</summary>
	public bool ShowHijackRestartButton => _died && Manager.HijackArmed;

	/// <summary>A replay's recorded input ran out while the game-over beat was still counting down. The
	/// original ended the beat either by the timer expiring or by the player skipping it with Confirm —
	/// and a skip isn't reproducible (that input edge isn't recorded), so the timer-driven replay can't
	/// reach <see cref="EndGame"/> on its own. Finish it now with the score that was already locked in at
	/// game-over, so the determinism check verifies a real score instead of a spurious "input exhausted".</summary>
	public void FinishGameOverForReplay()
	{
		// _gameOver: the death/win beat has begun (so the score is locked in). !_ended: EndGame hasn't
		// fired yet. Both true == we're mid game-over and can conclude it now; otherwise do nothing
		// (the run hadn't reached game-over, so the caller treats it as a real input-exhausted desync).
		// The only other way EndGame can be called during a replay is when the post-gameover countdown expires. 
		if ( IsReplay && _gameOver && !_ended )
			EndGame();
	}

	/// <summary>Snapshot the blocks and hand off to the animated score-tally stage.</summary>
	private void EndGame()
	{
		if ( _ended ) return;
		_ended = true;

		// Transition "whoosh" as we leave the play field (original played this on the
		// game -> score handoff, for both a win and a player death).
		Audio.PlaySfx( SfxType.LeaveGame );

		// Editor TEST run: an unattended death returns to the editor instead of repeatedly restarting and
		// archiving empty-input replays. Once the player has interacted, death/win keeps the iteration loop.
		if ( IsTest )
		{
			if ( _died && !RunRecorder.HasInput )
			{
				RunRecorder.Stop();
				Manager.ReturnFromTestPlay();
				return;
			}

			SaveTestReplayOnce();
			Manager.RestartTestPlay();
			return;
		}

		var results = BuildBlockResults();

		// A replay just watches a recorded run — don't tally or submit a new score. Recompute the
		// final score from the reproduced block states with the same deterministic function the
		// original submit used (so any determinism drift is flagged), then freeze on this final
		// frame instead of leaving: the viewer chooses to restart or go back from the replay HUD.
		// This branch also covers a HIJACKED practice run (it runs in an IsReplay stage): it must
		// never submit/tally either. NOTE: when achievements are added, any unlock hook must gate on
		// !IsReplay so neither a plain replay nor a hijacked practice run can trigger them.
		if ( IsReplay )
		{
			Manager.FinishReplay( ScoreCalc.Compute( results, _gameTime, CoinsCollected ), results );
			return;
		}

		// Snapshot the context before submitting (SubmitRunOnce clears it) so the tally knows where to
		// return afterwards (daily page vs high-score board).
		var context = Manager.CurrentRunContext;
		bool victory = ScoreCalc.AllMaxPhase( results );
		bool firstTutorialVictory = !context.IsDaily && victory && context.Level.Id == Levels.TutorialId
			&& !LevelProgress.IsBeaten( Levels.TutorialId );

		// Bank the score + replay now — normally the first and only submit for this run. If a dead
		// player already bailed early via restart/home, SubmitRunOnce fired then and this is a guarded
		// no-op (and we wouldn't reach here anyway, having already switched stages).
		SubmitRunOnce();

		// Pass the precise (un-rounded) elapsed time so the win time bonus keeps sub-second
		// resolution for the leaderboard tiebreaker (ScoreStage floors it for the visible tally).
		Manager.TransitionToStage( new ScoreStage( Manager, results, _gameTime, context, firstTutorialVictory,
			levelWasBeatenOnEntry: _levelWasBeatenOnEntry, isDebugRun: IsDebugRun, coinsCollected: CoinsCollected,
			runData: _submittedRun ) );
	}

	// Snapshot every block's final state into the serialisable BlockResult list the score tally and
	// leaderboard payload are built from (the live Blocks are destroyed when the stage exits).
	private List<BlockResult> BuildBlockResults()
	{
		var results = new List<BlockResult>();
		foreach ( var b in Blocks )
		{
			results.Add( new BlockResult
			{
				Type = b.BlockType,
				WasMimic = b.Mimic is not null,
				SpritePath = BlockSprites[b.BlockType],
				Phase = b.Phase,
				MoveDirection = b.MoveDirection,
				Left = b.IsSidePressedOrSuspended( Direction.Left ),
				Right = b.IsSidePressedOrSuspended( Direction.Right ),
				Up = b.IsSidePressedOrSuspended( Direction.Up ),
				Down = b.IsSidePressedOrSuspended( Direction.Down ),
				ScoreStartPhase = b.ScoreStartPhase,
				ScoreStartLeft = b.WasSidePressedAtScoreStart( Direction.Left ),
				ScoreStartRight = b.WasSidePressedAtScoreStart( Direction.Right ),
				ScoreStartUp = b.WasSidePressedAtScoreStart( Direction.Up ),
				ScoreStartDown = b.WasSidePressedAtScoreStart( Direction.Down ),
			} );
		}
		return results;
	}

	/// <summary>Submit this run's score to the leaderboard and save its replay locally — exactly once.
	/// Called when the game-over beat hands off to the score tally, AND when a player bails early
	/// (restart / home) after the run is decided (a death or a win) but before the tally opens, so the
	/// run is never lost. No-op for replays, if the run hasn't reached game-over, or if already done.
	/// The recorded input the replay needs is read from RunRecorder inside Leaderboard.Submit.</summary>
	/// <summary>This run's recorded payload, kept from <see cref="SubmitRunOnce"/> so the score tally can
	/// offer "watch this run" without waiting on the board to serve it back. Null until submitted.</summary>
	private RunData _submittedRun;

	public void SubmitRunOnce()
	{
		if ( _submitted || IsReplay || IsTest || !_gameOver )
			return;
		_submitted = true;

		// The run's outcome is locked in at game-over; stop recording so the encoded replay ends there
		// (later frames freeze on the game-over beat and carry nothing the replay needs).
		RunRecorder.Stop();

		var results = BuildBlockResults();
		bool victory = ScoreCalc.AllMaxPhase( results );
		int finalScore = ScoreCalc.Compute( results, _gameTime, CoinsCollected );

		// Sub-integer time tiebreaker rides along so equal-score runs order by speed (see ScoreStage).
		double submitValue = finalScore + ScoreCalc.TimeTiebreaker( _gameTime );

		var context = Manager.CurrentRunContext;

		// DEBUG daily runs never submit, touch progress or unlock anything — but bank a LOCAL replay
		// (My Replays) so a debug-played day can be reviewed.
		if ( IsDebugRun )
		{
			_submittedRun = Leaderboard.BuildRunData( _gameTime, victory, finalScore, results, context );
			LocalReplays.Add( _submittedRun );
			return;
		}

		var run = Leaderboard.Submit( submitValue, _gameTime, victory, finalScore, results, context );
		_submittedRun = run;
		bool isWorkshop = context.Level?.IsWorkshop == true;

		// Read from the run's own death flag, not from !victory: a run can end without a win for other
		// reasons, and only an actual death should count.
		if ( _died )
			Achievements.SubmitDeath();

		// Bank this run's coins toward the lifetime tally the coin achievement watches. Campaign and
		// daily runs count (win or lose); WORKSHOP runs don't — a downloaded level can be papered with
		// coins, which would turn a long-haul achievement into one trivially farmable lap.
		if ( !isWorkshop )
		{
			Achievements.SubmitRunCoins( CoinsCollected );

			// Block types maxed this run, win or lose — a death after maxing three of four blocks still
			// banked those three. Workshop runs stay out (like the coins): an authored all-types sampler
			// would hand out the collection in one lap. Submit right away when something new landed
			// (deaths and daily wins never reach the victory path's CheckProgress below; for the runs
			// that do, the session cache makes the second call a no-op).
			if ( Achievements.RecordMaxPhasedTypes( results ) )
				Achievements.CheckProgress();
		}

		if ( context.IsDaily )
			DailyChallengeProgress.MarkCompleted( context.DailyId, run );
		// Workshop wins don't unlock characters: a trivial downloaded level forcing an un-owned
		// character would bypass its real forced-level unlock.
		if ( victory && !isWorkshop )
			CharacterProgress.Unlock( context.Character.Id );

		// Daily runs play their generated-for-the-day level and never touch the beaten flags. Workshop
		// wins DO mark beaten locally — it drives the tally's first-completion affordance and it's what
		// makes a repeat win read as "returning for score" — but stay out of the cloud map-gate mirror
		// (ws ids would be noise there; MergeAsync only ever pulls backend→local, so a local ws flag
		// can't leak up either).
		if ( !context.IsDaily && victory )
		{
			// Read before marking: the beaten flag is what tells a first win from a return visit, and
			// it's what keeps the workshop achievement counting distinct LEVELS rather than wins.
			bool firstWin = !LevelProgress.IsBeaten( context.Level.Id );
			LevelProgress.MarkBeaten( context.Level.Id );

			if ( isWorkshop )
			{
				if ( firstWin )
					Achievements.AwardWorkshopBeaten();
			}
			else
			{
				CloudProgress.SubmitBeaten( context.Level.Id ); // cross-machine mirror of the beaten flag
				// Records only; CheckProgress below is what submits the percent, so this must come first.
				Achievements.RecordCapstoneWin( context.Level.Id, context.Character.Id );
				// Deliberately outside the firstWin branch: a level already beaten as someone else must
				// still count the first time it's beaten as the pairing's character.
				Achievements.AwardCharacterLevelChallenge( context.Level.Id, context.Character.Id, _anyPlayerDied );
				Achievements.CheckProgress(); // this may have been the last map level / last character
			}
		}

		Manager.ClearRunContext();
	}

	// Guards SaveTestReplayOnce so an editor test run archives at most one local replay per run.
	private bool _testReplaySaved;

	/// <summary>Save an editor TEST run's replay into the LOCAL archive (My Replays) — exactly once.
	/// Test runs never submit to the leaderboard, but the recorded input plus the exact (possibly
	/// unsaved / mid-edit) level are embedded in the payload so the run can be rewatched later. No-op
	/// unless this is a test run that has reached game-over.</summary>
	private void SaveTestReplayOnce()
	{
		if ( _testReplaySaved || !IsTest || !_gameOver )
			return;
		_testReplaySaved = true;

		// Outcome is locked in at game-over; stop recording so the encoded replay ends there (later
		// frames just freeze on the game-over beat and carry nothing the replay needs).
		RunRecorder.Stop();

		var results = BuildBlockResults();
		bool victory = ScoreCalc.AllMaxPhase( results );
		int finalScore = ScoreCalc.Compute( results, _gameTime, CoinsCollected );

		var context = Manager.CurrentRunContext;
		var run = Leaderboard.BuildRunData( _gameTime, victory, finalScore, results, context );
		// The editor always test-plays a transient snapshot of the in-memory model (even a saved level),
		// which won't reliably resolve by id once the test ends, so embed its exact definition — the
		// replay re-registers it to re-simulate the layout.
		run.LevelData = context.Level;
		LocalReplays.Add( run );
	}
	private struct Span { public float Min; public float Max; }

	private const float SPIKE_TILE = 5f;   // baked tooth tile width (the arena strip repeats every 5px)

	// --- arena background + walls -------------------------------------------------------
	private void SpawnArena()
	{
		// The play background is the teal camera clear colour plus the moving cosmetic
		// background blocks (SpawnBackgroundBlocks) — matching the original, which had no
		// separate fill rect here.
		//
		// A boundary side is normally one full-width baked strip (240x4 / 4x240). IMPORTANT:
		// SpriteRenderer.Size is a SQUARE bounding box (engine aspect-correction), so we pass a square
		// Size = the long dimension (240) and the strip's own aspect is fit; the literal (240,4) would
		// render 1/60th as tall (invisible). When an obstacle sits flush against a side, that side is
		// instead SPLIT into per-segment tiled faces (see BuildArenaSide) so each segment spikes alone.
		// Split the level's obstacles into real walls, FENCES and GLASS up front — every face-building
		// helper below (flush cutting, corner/elbow patches, band coverage) must see only the walls:
		// fences and glass never merge with, cut, or share faces with them (see LevelDef.Fences /
		// LevelDef.Glass).
		_wallObstacleRects.Clear();
		_fenceRects.Clear();
		_glassRects.Clear();
		_fenceFrameSegs.Clear();
		_glassBorderSegs.Clear();
		foreach ( var rect in Level.Obstacles )
			(IsFenceRect( rect ) ? _fenceRects : IsGlassRect( rect ) ? _glassRects : _wallObstacleRects).Add( rect );

		const float D = 4f;     // wall art depth (px)
		var span = new Vector2( Arena.WIDTH, Arena.WIDTH );
		BuildArenaSide( Direction.Down, new Vector2( Arena.WIDTH / 2f, D / 2f ), span );
		BuildArenaSide( Direction.Up, new Vector2( Arena.WIDTH / 2f, Arena.HEIGHT - D / 2f ), span );
		BuildArenaSide( Direction.Left, new Vector2( D / 2f, Arena.HEIGHT / 2f ), span );
		BuildArenaSide( Direction.Right, new Vector2( Arena.WIDTH - D / 2f, Arena.HEIGHT / 2f ), span );
		AddArenaCornerPatches();

		// Interior solid regions (empty for the classic square arena). Collision rides the existing
		// block-collision paths (see Obstacle); each side facing playable space gets its own faces.
		// Fences spawn as their own entity list (solid only to blocks, no faces, own look); GLASS
		// spawns into the normal obstacle list flagged IsGlass (solid only to players, own look).
		foreach ( var rect in Level.Obstacles )
		{
			if ( IsFenceRect( rect ) )
				AddFence( rect );
			else if ( IsGlassRect( rect ) )
				AddGlass( rect );
			else
				AddObstacle( rect );
		}

		// Obstacle elbow patches need EVERY face to exist first (a notch can sit in a rect that is
		// not either meeting face's owner — see AddObstacleElbowPatches), so they go in one pass here.
		AddObstacleElbowPatches();

		// Fence frames and glass borders need the same pass: their 1px strips are seam-cut around
		// flush same-kind neighbours, so a composite's inner corner leaves a one-pixel notch where
		// two perpendicular strips only corner-touch (see AddFrameElbowPatches).
		Color fenceFrame = GammaToLinear( Level.FenceColor ?? LevelCosmeticsRandomizer.DefaultFenceColor ).WithAlpha( FENCE_BORDER_ALPHA );
		AddFrameElbowPatches( _fenceFrameSegs, _fenceRects, FENCE_FRAME,
			c => AddFenceQuad( c, new Vector2( FENCE_FRAME, FENCE_FRAME ), fenceFrame, WallFace.ORDER_TEETH ) );
		Color glassBorder = GammaToLinear( Level.GlassColor ?? LevelCosmeticsRandomizer.DefaultGlassColor );
		AddFrameElbowPatches( _glassBorderSegs, _glassRects, GLASS_BORDER,
			c => AddGlassQuad( c, new Vector2( GLASS_BORDER, GLASS_BORDER ), glassBorder, GLASS_ORDER_BORDER, opaque: true ) );
	}

	// Build one boundary side. With no obstacle flush against it, it's a single full-width baked strip
	// (the classic look). Otherwise the flush obstacles CUT the side into surviving segments, each a
	// tiled face that spikes independently (so e.g. the C-shape's two channel floors are separate).
	private void BuildArenaSide( Direction side, Vector2 fullCenter, Vector2 fullSpan )
	{
		// Level-authored permanent spikes: every face on this side (all segments if the side is split
		// by a flush obstacle) starts deadly and never retracts.
		bool permanent = Contains( Level.SpikedWalls, side );

		var cuts = FlushObstacleSpans( side );
		if ( cuts.Count == 0 )
		{
			AddWall( side, fullCenter, fullSpan, permanent );
			return;
		}

		bool horizontal = side == Direction.Down || side == Direction.Up;
		// Perpendicular walls own the outer corner bands. Including them here creates a sub-tile
		// face whose full-size art overhangs an obstacle that reaches both arena walls.
		float min = Arena.WALL_SIZE;
		float max = (horizontal ? Arena.WIDTH : Arena.HEIGHT) - Arena.WALL_SIZE;
		float edgeCoord = side switch
		{
			Direction.Down => Arena.WALL_SIZE,
			Direction.Up => Arena.HEIGHT - Arena.WALL_SIZE,
			Direction.Left => Arena.WALL_SIZE,
			_ => Arena.WIDTH - Arena.WALL_SIZE,
		};
		Direction normal = Globals.GetOppositeDirection( side );   // arena spikes grow inward

		foreach ( var seg in SubtractSpans( min, max, cuts ) )
		{
			BuildTiledFace( normal, null, side, edgeCoord, seg.Min, seg.Max, ArenaSegmentLine( side, seg.Min, seg.Max ), permanent );

			// Elbow patch: at a segment end that abuts a flush obstacle, the wall band's grey and the
			// obstacle's perpendicular grey border form an L whose corner square is covered by NEITHER —
			// the arena tiles stop at the cut and the obstacle's border tiles stop at its face — so the
			// obstacle FILL (outside-teal) showed through. A small wall-colour quad plugs exactly that
			// square (extending wall art segments outward instead would collide with the spike teeth).
			if ( seg.Min > min ) AddWallElbowPatch( side, seg.Min - Arena.WALL_SIZE / 2f );
			if ( seg.Max < max ) AddWallElbowPatch( side, seg.Max + Arena.WALL_SIZE / 2f );
		}

		// A vision blocker CUTS this side like any other flush obstacle (so the segments either side
		// stay independently-spiking faces and no teeth are drawn inside its body), but the wall BAND
		// is then painted straight back through its footprint: a vision pillar standing on the boundary
		// must read as a separate object on an unbroken wall, not as a hole in it. Its fill stops at the
		// wall instead of extending past the arena edge to cover the band (ObstacleFillVisualRect).
		foreach ( var span in FlushObstacleSpans( side, visionOnly: true ) )
			AddWallBandPatch( side, span.Min, span.Max, min, max );
	}

	// The plain arena strip's solid band colour, sampled from the baked art (arena/down_0.png).
	private static readonly Color WALL_FILL_COLOR = new Color( 58 / 255f, 64 / 255f, 76 / 255f );

	// A WALL_SIZE-square wall-colour quad centred <paramref name="alongCenter"/> along the given
	// boundary side, sitting in the wall band (between the arena boundary and the collision edge).
	private void AddWallElbowPatch( Direction side, float alongCenter )
	{
		bool horizontal = side == Direction.Down || side == Direction.Up;
		AddElbowPatch( horizontal, alongCenter, WallBandCenter( side ), arenaBoundary: true );
	}

	// The band strip's centre line on a boundary side (half a WALL_SIZE in from the arena boundary).
	private static float WallBandCenter( Direction side ) => side switch
	{
		Direction.Down => Arena.WALL_SIZE / 2f,
		Direction.Up => Arena.HEIGHT - Arena.WALL_SIZE / 2f,
		Direction.Left => Arena.WALL_SIZE / 2f,
		_ => Arena.WIDTH - Arena.WALL_SIZE / 2f,
	};

	// The wall band painted through [alongMin,alongMax] of a boundary side as a plain quad — no face,
	// so it can never spike (see BuildArenaSide's vision-blocker pass). Clamped to the side's own
	// [min,max] range: the perpendicular walls own the corner squares, exactly as the segments do.
	private void AddWallBandPatch( Direction side, float alongMin, float alongMax, float min, float max )
	{
		float lo = alongMin < min ? min : alongMin;
		float hi = alongMax > max ? max : alongMax;
		if ( hi - lo <= FLUSH_EPS )
			return;   // nothing (or a sub-EPS sliver) of this blocker actually lies along the side

		bool horizontal = side == Direction.Down || side == Direction.Up;
		float alongCenter = (lo + hi) / 2f, perpCenter = WallBandCenter( side );
		var center = horizontal ? new Vector2( alongCenter, perpCenter ) : new Vector2( perpCenter, alongCenter );
		var size = horizontal ? new Vector2( hi - lo, Arena.WALL_SIZE ) : new Vector2( Arena.WALL_SIZE, hi - lo );
		AddWallQuad( "WallBand", center, size, arenaBoundary: true );
	}

	// Corner squares: a split side's segments stop WALL_SIZE short of the corners — the perpendicular
	// wall's full strip owns them (see BuildArenaSide). When BOTH walls meeting at a corner are split,
	// neither side's art reaches it, so the WALL_SIZE corner square would show the fill colour — plug
	// it. EXCEPT a corner an obstacle sits flush into (flush against both walls): there the band frame
	// opens to the outside and the square must stay fill-coloured like everything around it.
	private void AddArenaCornerPatches()
	{
		const float W = Arena.WALL_SIZE;
		var corners = new (Direction sideA, Direction sideB, float x, float y)[]
		{
			(Direction.Down, Direction.Left, W / 2f, W / 2f),
			(Direction.Down, Direction.Right, Arena.WIDTH - W / 2f, W / 2f),
			(Direction.Up, Direction.Left, W / 2f, Arena.HEIGHT - W / 2f),
			(Direction.Up, Direction.Right, Arena.WIDTH - W / 2f, Arena.HEIGHT - W / 2f),
		};
		foreach ( var c in corners )
		{
			if ( FlushObstacleSpans( c.sideA ).Count == 0 || FlushObstacleSpans( c.sideB ).Count == 0 )
				continue;   // an unsplit side's full-width strip covers the corner

			bool occupied = false;
			foreach ( var r in _wallObstacleRects )
				if ( !ObstacleBlocksVision( r ) && FlushWithArenaWall( r, c.sideA ) && FlushWithArenaWall( r, c.sideB ) ) { occupied = true; break; }
			if ( !occupied )
				AddElbowPatch( true, c.x, c.y, arenaBoundary: true );
		}
	}

	// The shared elbow quad: a WALL_SIZE square of wall colour plugging the corner square that two
	// perpendicular grey border bands leave uncovered (arena-wall/obstacle AND obstacle/obstacle).
	private void AddElbowPatch( bool horizontal, float alongCenter, float perpCenter, bool arenaBoundary = false )
	{
		Vector2 c = horizontal ? new Vector2( alongCenter, perpCenter ) : new Vector2( perpCenter, alongCenter );
		AddWallQuad( "WallElbow", c, new Vector2( Arena.WALL_SIZE, Arena.WALL_SIZE ), arenaBoundary );
	}

	// A flat wall-colour quad in the band sub-layer (elbow patches and vision-blocker band patches).
	private void AddWallQuad( string name, Vector2 center, Vector2 size, bool arenaBoundary )
	{
		var go = CreateChild( name );
		// The band sub-layer, like the tiles it sits between: in front of the obstacle fill quad it
		// must paint over (a full 0.1 step — a 0.01 depth sub-offset z-fights the fill under the
		// ortho camera, see SpriteLayer / DEPTH_PORTAL_HIGHLIGHT).
		float rootZ = arenaBoundary ? Globals.DEPTH_ARENA_WALL : Globals.DepthToZ( Globals.DEPTH_WALL );
		go.WorldPosition = new Vector3( center.x, center.y, rootZ );
		// pixel.sprite is a 1x1 texture, so a non-square Size maps straight to the quad (unlike the
		// baked wall strips, whose own art aspect is fitted into a square Size — see SpawnArena).
		var sr = SpriteLayer.Add( go, "sprites/pixel.sprite", size, "idle", WallFace.ORDER_BAND );
		sr.Opaque = true;
		// The pixel sprite is white, so its linear tint is the direct wall colour.
		sr.Color = WallColorLinear;
	}

	// Tolerance for "sitting flush" edge comparisons (arena walls and obstacle-obstacle seams).
	private const float FLUSH_EPS = 0.5f;

	// THE definition of an obstacle side sitting flush against its arena wall. Shared by
	// FlushObstacleSpans (cut the arena side around it) and AddObstacle (skip building the
	// obstacle's own face there) — if these ever disagreed, a fractionally-offset rect would
	// both cut the wall AND build a buried-but-lethal face inside the wall band.
	private static bool FlushWithArenaWall( RectF r, Direction side ) => side switch
	{
		Direction.Down => r.Bottom <= Arena.WALL_SIZE + FLUSH_EPS,
		Direction.Up => r.Top >= Arena.HEIGHT - Arena.WALL_SIZE - FLUSH_EPS,
		Direction.Left => r.Left <= Arena.WALL_SIZE + FLUSH_EPS,
		_ => r.Right >= Arena.WIDTH - Arena.WALL_SIZE - FLUSH_EPS,
	};

	// The obstacle's FILL quad. A flush side's fill runs out past the arena edge: the boundary side is
	// cut there, so nothing else paints that band strip and the interior must read as continuous with
	// the outside. A VISION BLOCKER is the exception — the band survives through its footprint
	// (BuildArenaSide), so its fill stops at its own rect and it reads as a pillar on an intact wall.
	private RectF ObstacleFillVisualRect( RectF r )
	{
		if ( ObstacleBlocksVision( r ) )
			return r;

		float left = FlushWithArenaWall( r, Direction.Left ) ? 0f : r.Left;
		float bottom = FlushWithArenaWall( r, Direction.Down ) ? 0f : r.Bottom;
		float right = FlushWithArenaWall( r, Direction.Right ) ? Arena.WIDTH : r.Right;
		float top = FlushWithArenaWall( r, Direction.Up ) ? Arena.HEIGHT : r.Top;
		return new RectF( left, bottom, right, top );
	}

	// The along-spans of obstacles sitting flush against a boundary side (they cut that side's face).
	private List<Span> FlushObstacleSpans( Direction side ) => FlushObstacleSpans( side, visionOnly: false );

	// <paramref name="visionOnly"/> narrows the result to the VISION BLOCKERS among them: they cut the
	// side's face like any other flush obstacle, but the band is painted back through their footprint
	// (AddWallBandPatch) so they read as separate pillars rather than merging into the boundary.
	private List<Span> FlushObstacleSpans( Direction side, bool visionOnly )
	{
		bool horizontal = side == Direction.Down || side == Direction.Up;
		var spans = new List<Span>();
		foreach ( var r in _wallObstacleRects )
		{
			if ( !FlushWithArenaWall( r, side ) || (visionOnly && !ObstacleBlocksVision( r )) )
				continue;
			spans.Add( horizontal
				? new Span { Min = r.Left, Max = r.Right }
				: new Span { Min = r.Bottom, Max = r.Top } );
		}
		return spans;
	}

	// Is this obstacle rect one the level flagged as a vision blocker? Matched by value, float-exact,
	// like ObstacleSideSpiked — the editor and the daily generator both copy the entry straight out of
	// Level.Obstacles, so a flagged rect is always bit-identical to its obstacle.
	private bool ObstacleBlocksVision( RectF rect )
	{
		if ( Level.VisionBlockers is null )
			return false;
		foreach ( var blocker in Level.VisionBlockers )
		{
			if ( blocker.Left == rect.Left && blocker.Bottom == rect.Bottom
				&& blocker.Right == rect.Right && blocker.Top == rect.Top )
				return true;
		}
		return false;
	}

	// Is this obstacle rect one the level flagged as a FENCE (solid only to blocks)? Matched by
	// value, float-exact, exactly like ObstacleBlocksVision.
	private bool IsFenceRect( RectF rect )
	{
		if ( Level.Fences is null )
			return false;
		foreach ( var fence in Level.Fences )
		{
			if ( fence.Left == rect.Left && fence.Bottom == rect.Bottom
				&& fence.Right == rect.Right && fence.Top == rect.Top )
				return true;
		}
		return false;
	}

	// Is this obstacle rect one the level flagged as GLASS (solid only to players)? Matched by
	// value, float-exact, exactly like ObstacleBlocksVision.
	private bool IsGlassRect( RectF rect )
	{
		if ( Level.Glass is null )
			return false;
		foreach ( var glass in Level.Glass )
		{
			if ( glass.Left == rect.Left && glass.Bottom == rect.Bottom
				&& glass.Right == rect.Right && glass.Top == rect.Top )
				return true;
		}
		return false;
	}

	// [min,max] minus the cut intervals -> the surviving segments (gaps). Segments no longer than
	// FLUSH_EPS are DROPPED, not emitted: flushness is EPS-tolerant, so a fractionally-offset flush
	// rect can leave a sub-EPS sliver (against a range end, or between two nearly-abutting cuts)
	// whose face would draw a full-size tile overhanging the obstacle it abuts — the same artifact
	// exact-flush clamping already prevents. Nothing legitimate is that short: WALL_SIZE is 2.
	private static List<Span> SubtractSpans( float min, float max, List<Span> cuts )
	{
		cuts.Sort( ( p, q ) => p.Min.CompareTo( q.Min ) );
		var result = new List<Span>();
		float cursor = min;
		foreach ( var c in cuts )
		{
			// Clamp BOTH ends into [min,max]: a flush neighbour that only corner-touches the side
			// contributes a cut lying entirely outside it, which unclamped would emit a bogus
			// segment extending past the side's far end.
			float s = c.Min < min ? min : (c.Min > max ? max : c.Min);
			float e = c.Max < min ? min : (c.Max > max ? max : c.Max);
			if ( e <= cursor ) continue;
			if ( s > cursor + FLUSH_EPS ) result.Add( new Span { Min = cursor, Max = s } );
			cursor = e > cursor ? e : cursor;
		}
		if ( cursor < max - FLUSH_EPS ) result.Add( new Span { Min = cursor, Max = max } );
		return result;
	}

	private void AddObstacle( RectF rect )
	{
		var center = new Vector2( (rect.Left + rect.Right) / 2f, (rect.Bottom + rect.Top) / 2f );
		var size = new Vector2( rect.Width, rect.Height );

		var go = CreateChild( "Obstacle" );
		var ob = go.Components.Create<Obstacle>();
		ob.Setup( center, size );
		// Interior fill reads as non-playable space — the SAME colour as outside the arena (the edge
		// blockers / camera clear). The wall-grey border tiles frame it. Tint is consumed as LINEAR.
		var fill = ObstacleFillVisualRect( rect );
		var fillCenter = new Vector2( (fill.Left + fill.Right) / 2f, (fill.Bottom + fill.Top) / 2f );
		var fillSize = new Vector2( fill.Width, fill.Height );
		ob.CreateVisuals( GammaToLinear( EffectiveClearColor ), fillCenter, fillSize );
		_obstacles.Add( ob );

		// Spikeable faces on each side that borders playable space (skip a side flush with an arena
		// wall — nothing can stand there, and buried spikes would be invisible). Flushness MUST use
		// the same predicate that cuts the arena side (FlushWithArenaWall). A side that another
		// obstacle sits flush against is SPLIT around the shared seam (BuildObstacleSide), so two
		// flush rects read and behave as ONE composite solid (an L, T, …).
		foreach ( var dir in new[] { Direction.Left, Direction.Right, Direction.Down, Direction.Up } )
		{
			if ( !FlushWithArenaWall( rect, dir ) )
				BuildObstacleSide( ob, rect, dir, ObstacleSideSpiked( rect, dir ) );
		}
	}

	// ── fences ───────────────────────────────────────────────────────────────────────────────────
	// A level authors ONE fence colour (LevelDef.FenceColor, default warm amber): the frame/border
	// draws it directly and the bars derive a brighter version (LevelCosmeticsRandomizer.FenceBarColor)
	// — deliberately NOT the level's wall/OOB colours, so a fence reads as its own object class:
	// open bars only blocks collide with, never more wall.
	// Tints are consumed as LINEAR, like every pixel-quad tint (see WALL_FILL_COLOR usage).
	private const float FENCE_FRAME = 1f;        // frame thickness (px), lighter than the 2px wall band
	private const float FENCE_BAR = 1f;          // vertical bar width (px)
	private const float FENCE_BAR_SPACING = 5f;  // bar rhythm (px), world-grid aligned (see AddFence)
	// Both layers are translucent so the playfield shows through (a fence must read "not solid to
	// you") — the bars strongly, the frame only slightly. A translucent frame can't fully hide the
	// bar ends it caps: each bar tip shows through it as a faint blend on the 5px rhythm.
	private const float FENCE_BAR_ALPHA = 0.5f;
	private const float FENCE_BORDER_ALPHA = 0.85f;

	/// <summary>Spawn one FENCE: an <see cref="Obstacle"/> body that only the block-solid collision
	/// view sees (<see cref="GetBlockSolidObstacles"/>), drawn as an open frame with vertical bars —
	/// the playfield stays visible through it, telling the player it isn't solid to them. No
	/// <see cref="WallFace"/>s are built (fences can't be spiked), no arena side is cut, and no fill
	/// hides the space behind it. Two flush fences merge visually: the shared seam gets no frame
	/// (BuildFenceFrameSide), and the bars sit on a WORLD-aligned 5px grid so they run continuously
	/// across the seam of a composite fence.</summary>
	private void AddFence( RectF rect )
	{
		var center = new Vector2( (rect.Left + rect.Right) / 2f, (rect.Bottom + rect.Top) / 2f );
		var size = new Vector2( rect.Width, rect.Height );

		var go = CreateChild( "Fence" );
		var ob = go.Components.Create<Obstacle>();
		ob.Setup( center, size );
		// Fences render UNDER blocks/player/hazards (unlike real obstacles at DEPTH_WALL): everything
		// that ignores a fence visibly passes in front of its bars.
		ob.Depth = Globals.DEPTH_FENCE;
		ob.SyncTransform();
		_fences.Add( ob );

		Color border = Level.FenceColor ?? LevelCosmeticsRandomizer.DefaultFenceColor;
		Color frame = GammaToLinear( border ).WithAlpha( FENCE_BORDER_ALPHA );
		Color bar = GammaToLinear( LevelCosmeticsRandomizer.FenceBarColor( border ) );

		// Bars first (ORDER_BAND), edge-to-edge on both axes so they run straight through a
		// fence-fence seam without breaking the 5px rhythm; the frame paints over them on a nearer
		// sub-layer (ORDER_TEETH), so where a frame edge exists it caps/hides the bars cleanly and
		// where it's cut away (a seam) the bars continue.
		for ( float x = MathF.Ceiling( rect.Left / FENCE_BAR_SPACING ) * FENCE_BAR_SPACING; x <= rect.Right - FENCE_BAR; x += FENCE_BAR_SPACING )
			AddFenceQuad( new Vector2( x + FENCE_BAR / 2f, center.y ), new Vector2( FENCE_BAR, rect.Height ), bar.WithAlpha( FENCE_BAR_ALPHA ), WallFace.ORDER_BAND );

		foreach ( var dir in new[] { Direction.Left, Direction.Right, Direction.Down, Direction.Up } )
			BuildFenceFrameSide( rect, dir, frame );
	}

	// One side of a fence's 1px frame, drawn just inside the collision edge. A NEIGHBOURING FENCE
	// sitting flush against this side cuts the frame around the shared span (the same SubtractSpans
	// used for wall faces), so two flush fences read as one composite pen. Normal obstacles and
	// arena walls deliberately do NOT cut it — a fence never merges with them.
	private void BuildFenceFrameSide( RectF rect, Direction normal, Color frame )
	{
		bool horizontal = normal == Direction.Up || normal == Direction.Down;
		float alongStart = horizontal ? rect.Left : rect.Bottom;
		float alongEnd = horizontal ? rect.Right : rect.Top;
		// The frame strip's centre line, half a frame inside the edge.
		float perpCenter = normal switch
		{
			Direction.Left => rect.Left + FENCE_FRAME / 2f,
			Direction.Right => rect.Right - FENCE_FRAME / 2f,
			Direction.Down => rect.Bottom + FENCE_FRAME / 2f,
			_ => rect.Top - FENCE_FRAME / 2f,
		};

		foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushKinNeighbourSpans( _fenceRects, rect, normal ) ) )
		{
			var c = horizontal
				? new Vector2( (seg.Min + seg.Max) / 2f, perpCenter )
				: new Vector2( perpCenter, (seg.Min + seg.Max) / 2f );
			var s = horizontal
				? new Vector2( seg.Max - seg.Min, FENCE_FRAME )
				: new Vector2( FENCE_FRAME, seg.Max - seg.Min );
			AddFenceQuad( c, s, frame, WallFace.ORDER_TEETH );
			_fenceFrameSegs.Add( new FrameSeg { Horizontal = horizontal, Perp = perpCenter, Min = seg.Min, Max = seg.Max } );
		}
	}

	// The along-spans of OTHER rects of the same KIND (<paramref name="kin"/> = the fence or glass
	// rect split) sitting flush against this side of <paramref name="rect"/> — the same-kind-only
	// analogue of FlushNeighbourSpans (fences/glass only combine with their own kind). Reads the
	// build-time rect splits, not the entity lists: those are still filling while panels spawn.
	private static List<Span> FlushKinNeighbourSpans( List<RectF> kin, RectF rect, Direction normal )
	{
		const float EPS = FLUSH_EPS;
		var spans = new List<Span>();
		foreach ( var q in kin )
		{
			if ( q.Left == rect.Left && q.Bottom == rect.Bottom && q.Right == rect.Right && q.Top == rect.Top )
				continue;   // self
			bool flush;
			float a, b;
			switch ( normal )
			{
				case Direction.Left: flush = q.Right > rect.Left - EPS && q.Right < rect.Left + EPS; a = q.Bottom; b = q.Top; break;
				case Direction.Right: flush = q.Left > rect.Right - EPS && q.Left < rect.Right + EPS; a = q.Bottom; b = q.Top; break;
				case Direction.Down: flush = q.Top > rect.Bottom - EPS && q.Top < rect.Bottom + EPS; a = q.Left; b = q.Right; break;
				default: flush = q.Bottom > rect.Top - EPS && q.Bottom < rect.Top + EPS; a = q.Left; b = q.Right; break;
			}
			if ( flush )
				spans.Add( new Span { Min = a, Max = b } );
		}
		return spans;
	}

	// Frame elbow patches, in ONE pass after every fence/glass panel is built — the 1px-outline
	// analogue of AddObstacleElbowPatches. A flush same-kind neighbour cuts a frame/border side, so
	// at an inner corner of a composite (an L — or the staircase, where the notch square belongs to
	// a THIRD rect with no surviving strips of its own there) the two perpendicular strips only
	// corner-touch, leaving a frame-sized notch in the outline. At each strip end, probe the square
	// just past it: patch iff it lies inside some same-kind rect (open space past a true outer
	// corner isn't) and no strip covers it (a collinear continuation across a seam does).
	private static void AddFrameElbowPatches( List<FrameSeg> strips, List<RectF> kin, float w, Action<Vector2> addPatch )
	{
		var done = new HashSet<Vector2>();   // the two strips meeting at an L both probe their shared notch
		foreach ( var f in strips )
		{
			TryFrameElbowPatch( strips, kin, f.Horizontal, f.Min - w / 2f, f.Perp, w, done, addPatch );
			TryFrameElbowPatch( strips, kin, f.Horizontal, f.Max + w / 2f, f.Perp, w, done, addPatch );
		}
	}

	private static void TryFrameElbowPatch( List<FrameSeg> strips, List<RectF> kin, bool horizontal, float alongCenter, float perpCenter, float w, HashSet<Vector2> done, Action<Vector2> addPatch )
	{
		var c = horizontal ? new Vector2( alongCenter, perpCenter ) : new Vector2( perpCenter, alongCenter );
		if ( !InsideAnyRect( kin, c ) || CoveredByFrameStrip( strips, c, w ) )
			return;
		if ( done.Add( c ) )
			addPatch( c );
	}

	private static bool InsideAnyRect( List<RectF> rects, Vector2 pos )
	{
		foreach ( var r in rects )
			if ( pos.x > r.Left && pos.x < r.Right && pos.y > r.Bottom && pos.y < r.Top )
				return true;
		return false;
	}

	// Does any drawn strip of the same kind cover this point? (Inclusive at the edges, like
	// CoveredByFaceBand — a collinear neighbour's strip abutting the probe point counts.)
	private static bool CoveredByFrameStrip( List<FrameSeg> strips, Vector2 pos, float w )
	{
		foreach ( var f in strips )
		{
			float along = f.Horizontal ? pos.x : pos.y;
			float perp = f.Horizontal ? pos.y : pos.x;
			if ( along >= f.Min && along <= f.Max && perp >= f.Perp - w / 2f && perp <= f.Perp + w / 2f )
				return true;
		}
		return false;
	}

	// A flat fence-colour quad on the fence layer — under blocks/player/hazards, over the playfield
	// (see DEPTH_FENCE) — with the fence's own sub-layer split: bars on ORDER_BAND, frame on
	// ORDER_TEETH so it caps the bar ends. Always translucent (see FENCE_BAR_ALPHA/FENCE_BORDER_ALPHA).
	private void AddFenceQuad( Vector2 center, Vector2 size, Color color, int order )
	{
		var go = CreateChild( "FenceQuad" );
		go.WorldPosition = new Vector3( center.x, center.y, Globals.DepthToZ( Globals.DEPTH_FENCE ) );
		var sr = SpriteLayer.Add( go, "sprites/pixel.sprite", size, "idle", order );
		sr.Opaque = false;
		sr.AlphaCutoff = 0f;   // match the glass/editor translucent path exactly
		sr.Color = color;
	}

	// ── glass ────────────────────────────────────────────────────────────────────────────────────
	// A level authors ONE glass colour (LevelDef.GlassColor, default lavender): the translucent pane
	// tint, the reflection streaks, the solid border and the spike-teeth tint all derive from it.
	private const float GLASS_BORDER = 1f;         // solid border thickness (px)
	private const float GLASS_FILL_ALPHA = 0.30f;  // pane tint opacity
	private const float GLASS_STREAK_ALPHA = 0.50f;// reflection streaks: a bit more opaque than the pane
	private const float GLASS_STREAK_WIDTH = 3f;   // streak thickness (px), perpendicular to the diagonal
	private const float GLASS_STREAK_PERIOD = 24f; // diagonal rhythm (px), world-aligned (see AddGlassStreaks)
	private const int GLASS_ORDER_FILL = 1;        // pane, then streaks, then border, then spike teeth
	private const int GLASS_ORDER_STREAK = 2;
	private const int GLASS_ORDER_BORDER = 3;
	public const int GLASS_ORDER_TEETH = 4;

	/// <summary>Spawn one GLASS panel: an <see cref="Obstacle"/> flagged <see cref="Obstacle.IsGlass"/>
	/// in the NORMAL obstacle list — so every player collision/crush/spike/landing path treats it as
	/// an interior wall (including the wrap character, which collides instead of wrapping — see
	/// Player.ObstacleSolidToUs) — while the block/hazard views skip it. Drawn as a translucent
	/// tinted pane with sparse diagonal reflection streaks and a solid 1px border, at
	/// <see cref="Globals.DEPTH_GLASS"/>: blocks and shots visibly slide OVER it. Authored spikes
	/// build player-lethal faces on its sides (tinted teeth, no wall band). Two flush glass panels
	/// merge visually: the shared border seam is dropped and the streak rhythm is world-aligned so
	/// streaks run continuously across the seam.</summary>
	private void AddGlass( RectF rect )
	{
		var center = new Vector2( (rect.Left + rect.Right) / 2f, (rect.Bottom + rect.Top) / 2f );
		var size = new Vector2( rect.Width, rect.Height );

		var go = CreateChild( "Glass" );
		var ob = go.Components.Create<Obstacle>();
		ob.Setup( center, size );
		ob.IsGlass = true;
		ob.SyncTransform();
		_obstacles.Add( ob );

		Color glass = GammaToLinear( Level.GlassColor ?? LevelCosmeticsRandomizer.DefaultGlassColor );

		AddGlassQuad( center, size, glass.WithAlpha( GLASS_FILL_ALPHA ), GLASS_ORDER_FILL, opaque: false );
		AddGlassStreaks( rect, glass.WithAlpha( GLASS_STREAK_ALPHA ) );

		foreach ( var dir in new[] { Direction.Left, Direction.Right, Direction.Down, Direction.Up } )
		{
			BuildGlassBorderSide( rect, dir, glass );
			// Authored spikes only — a block can never ram a pane, so Spikey grafts can't happen and
			// every glass face is a permanent level spike. A side flush with an arena wall is skipped
			// exactly like an obstacle's (a buried face would be invisible but lethal).
			if ( !FlushWithArenaWall( rect, dir ) && ObstacleSideSpiked( rect, dir ) )
				BuildGlassSide( ob, rect, dir );
		}
	}

	// One side of a glass panel's 1px solid border, seam-cut around flush GLASS neighbours (glass
	// only combines with glass) — the pane analogue of BuildFenceFrameSide.
	private void BuildGlassBorderSide( RectF rect, Direction normal, Color border )
	{
		bool horizontal = normal == Direction.Up || normal == Direction.Down;
		float alongStart = horizontal ? rect.Left : rect.Bottom;
		float alongEnd = horizontal ? rect.Right : rect.Top;
		float perpCenter = normal switch
		{
			Direction.Left => rect.Left + GLASS_BORDER / 2f,
			Direction.Right => rect.Right - GLASS_BORDER / 2f,
			Direction.Down => rect.Bottom + GLASS_BORDER / 2f,
			_ => rect.Top - GLASS_BORDER / 2f,
		};

		foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushKinNeighbourSpans( _glassRects, rect, normal ) ) )
		{
			var c = horizontal
				? new Vector2( (seg.Min + seg.Max) / 2f, perpCenter )
				: new Vector2( perpCenter, (seg.Min + seg.Max) / 2f );
			var s = horizontal
				? new Vector2( seg.Max - seg.Min, GLASS_BORDER )
				: new Vector2( GLASS_BORDER, seg.Max - seg.Min );
			AddGlassQuad( c, s, border, GLASS_ORDER_BORDER, opaque: true );
			_glassBorderSegs.Add( new FrameSeg { Horizontal = horizontal, Perp = perpCenter, Min = seg.Min, Max = seg.Max } );
		}
	}

	// One authored-spiked side of a glass panel: tiled spike faces split around flush GLASS
	// neighbours (composite panes spike per-segment like composite obstacles), always permanent.
	private void BuildGlassSide( Obstacle ob, RectF rect, Direction normal )
	{
		bool horizontal = normal == Direction.Up || normal == Direction.Down;
		float edgeCoord = normal switch
		{
			Direction.Left => rect.Left,
			Direction.Right => rect.Right,
			Direction.Down => rect.Bottom,
			_ => rect.Top,
		};
		float alongStart = horizontal ? rect.Left : rect.Bottom;
		float alongEnd = horizontal ? rect.Right : rect.Top;

		foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushKinNeighbourSpans( _glassRects, rect, normal ) ) )
		{
			Line line = horizontal
				? new Line( new Vector2( seg.Min, edgeCoord ), new Vector2( seg.Max, edgeCoord ) )
				: new Line( new Vector2( edgeCoord, seg.Min ), new Vector2( edgeCoord, seg.Max ) );
			BuildTiledFace( normal, ob, Direction.None, edgeCoord, seg.Min, seg.Max, line, permanent: true );
		}
	}

	// Sparse diagonal "reflection" streaks on a WORLD-aligned rhythm (the 45° bands
	// |x - y - c| <= h, one per GLASS_STREAK_PERIOD px). Each streak is rasterized as abutting
	// 1px-wide columns of the exact band∩pane region, so it runs fully edge-to-edge (no missing
	// end wedges — a rotated quad's diagonal end cut couldn't fill the corner against an
	// axis-aligned edge without spilling), never leaves the pane, and continues seamlessly across
	// the seams of a composite pane: adjacent panels rasterize the same world-space band and
	// their columns abut exactly at the shared edge, with no flush-detection needed. In-game the
	// CRT quantizes to the 240px grid anyway, so the 1px staircase renders identically to a
	// smooth rotated band — and the editor schematic now shows the same pixel-true shape.
	private void AddGlassStreaks( RectF rect, Color color )
	{
		const float P = GLASS_STREAK_PERIOD;
		// Perpendicular half-thickness w/2 → the band is |x - y - c| <= (w/2)·√2.
		float h = GLASS_STREAK_WIDTH * MathF.Sqrt( 2f ) / 2f;

		float cMin = rect.Left - rect.Top - h, cMax = rect.Right - rect.Bottom + h;
		for ( float c = MathF.Ceiling( cMin / P ) * P; c <= cMax; c += P )
		{
			// Columns the band can reach; starting on an integer keeps columns aligned across the
			// seams of pixel-snapped composite panes.
			float xLo = MathF.Max( rect.Left, MathF.Floor( rect.Bottom + c - h ) );
			float xHi = MathF.Min( rect.Right, rect.Top + c + h );
			for ( float x0 = xLo; x0 < xHi; x0 += 1f )
			{
				float x1 = MathF.Min( x0 + 1f, rect.Right );
				float xc = (x0 + x1) / 2f;
				float yLo = MathF.Max( rect.Bottom, xc - c - h );
				float yHi = MathF.Min( rect.Top, xc - c + h );
				if ( yHi <= yLo ) continue;
				AddGlassQuad( new Vector2( xc, (yLo + yHi) / 2f ), new Vector2( x1 - x0, yHi - yLo ),
					color, GLASS_ORDER_STREAK, opaque: false );
			}
		}
	}

	// A glass quad at the glass depth — above fences, still under blocks/player/hazards.
	private SpriteRenderer AddGlassQuad( Vector2 center, Vector2 size, Color color, int order, bool opaque )
	{
		var go = CreateChild( "GlassQuad" );
		go.WorldPosition = new Vector3( center.x, center.y, Globals.DepthToZ( Globals.DEPTH_GLASS ) );
		var sr = SpriteLayer.Add( go, "sprites/pixel.sprite", size, "idle", order );
		sr.Opaque = opaque;
		if ( !opaque )
			sr.AlphaCutoff = 0f;   // match the editor schematic's translucent path exactly
		sr.Color = color;
		return sr;
	}

	// True if the level authored permanent spikes on this obstacle rect's given outward side. The rect
	// is matched by value against SpikedObstacleSides entries (float-exact, like FlushNeighbourSpans'
	// self-skip) — authors share the same RectF value between Obstacles and the spec.
	private bool ObstacleSideSpiked( RectF rect, Direction normal )
	{
		foreach ( var spec in Level.SpikedObstacleSides )
		{
			if ( spec.Rect.Left != rect.Left || spec.Rect.Bottom != rect.Bottom
				|| spec.Rect.Right != rect.Right || spec.Rect.Top != rect.Top )
				continue;
			if ( Contains( spec.Sides, normal ) )
				return true;
		}
		return false;
	}

	// Minimal membership test (GameStage doesn't pull in System.Linq).
	private static bool Contains( IReadOnlyList<Direction> list, Direction d )
	{
		for ( int i = 0; i < list.Count; i++ )
			if ( list[i] == d ) return true;
		return false;
	}

	// Build one outward side of an obstacle. A neighbouring obstacle sitting flush (edge-to-edge)
	// against this side CUTS it, exactly like a flush obstacle cuts an arena boundary side: no face
	// exists along the shared seam — the two interiors are one contiguous non-playable region — and
	// each surviving segment becomes its own independently-spiking tiled face. Elbow patches for the
	// corner notches the bands leave uncovered are added in one pass after every obstacle is built
	// (AddObstacleElbowPatches) — a notch can belong to a rect that is neither meeting face's owner.
	private void BuildObstacleSide( Obstacle ob, RectF rect, Direction normal, bool permanent )
	{
		bool horizontal = normal == Direction.Up || normal == Direction.Down;
		float edgeCoord = normal switch
		{
			Direction.Left => rect.Left,
			Direction.Right => rect.Right,
			Direction.Down => rect.Bottom,
			_ => rect.Top,
		};
		float alongStart = horizontal ? rect.Left : rect.Bottom;
		float alongEnd = horizontal ? rect.Right : rect.Top;

		foreach ( var seg in SubtractSpans( alongStart, alongEnd, FlushNeighbourSpans( rect, normal ) ) )
		{
			Line line = horizontal
				? new Line( new Vector2( seg.Min, edgeCoord ), new Vector2( seg.Max, edgeCoord ) )
				: new Line( new Vector2( edgeCoord, seg.Min ), new Vector2( edgeCoord, seg.Max ) );
			BuildTiledFace( normal, ob, Direction.None, edgeCoord, seg.Min, seg.Max, line, permanent );
		}
	}

	// Obstacle elbow patches, in ONE pass after every obstacle's faces exist. At each face-segment
	// end the grey band stops; if the band-strip square just past the end lies inside an obstacle
	// body and no face's band covers it, that body's fill (outside-teal) pokes through the corner
	// notch of the L the two perpendicular bands form — plug it. (The arena-wall flavour of the same
	// problem is handled in BuildArenaSide.) Probing COVERAGE rather than patching cut-created
	// endpoints (the old rule, which this subsumes) also catches the staircase corner three flush
	// rects make (one stacked diagonally between two others): there BOTH meeting faces run the full
	// length of their own sides — neither end is cut-created — and the notch square belongs to a
	// THIRD rect whose faces there were entirely cut away, so no single side can see it needs one.
	private void AddObstacleElbowPatches()
	{
		const float W = Arena.WALL_SIZE;
		var done = new HashSet<Vector2>();   // the two faces meeting at an L both probe their shared notch
		foreach ( var f in _faces )
		{
			if ( f.Owner is null or { IsGlass: true } )   // glass faces have no band, so no notches to plug
				continue;
			bool horizontal = f.Normal == Direction.Up || f.Normal == Direction.Down;
			float edge = horizontal ? f.Segment.YMin : f.Segment.XMin;
			// The band's centre line sits half a band inside the body (the tile's solid half).
			float perpInside = f.Normal == Direction.Left || f.Normal == Direction.Down
				? edge + W / 2f
				: edge - W / 2f;
			float min = horizontal ? f.Segment.XMin : f.Segment.YMin;
			float max = horizontal ? f.Segment.XMax : f.Segment.YMax;
			TryElbowPatch( horizontal, min - W / 2f, perpInside, done );
			TryElbowPatch( horizontal, max + W / 2f, perpInside, done );
		}
	}

	// One candidate notch square (centre along/perp): patch it iff obstacle fill would show there —
	// inside some obstacle body but covered by no face band. A collinear neighbour's band continuing
	// straight through the probe needs no patch; open playable space past the end needs none either.
	private void TryElbowPatch( bool horizontal, float alongCenter, float perpCenter, HashSet<Vector2> done )
	{
		Vector2 c = horizontal ? new Vector2( alongCenter, perpCenter ) : new Vector2( perpCenter, alongCenter );
		if ( !InsideAnyObstacle( c ) || CoveredByFaceBand( c ) )
			return;
		if ( done.Add( c ) )
			AddElbowPatch( horizontal, alongCenter, perpCenter );
	}

	private bool InsideAnyObstacle( Vector2 pos )
	{
		foreach ( var r in _wallObstacleRects )
			if ( pos.x > r.Left && pos.x < r.Right && pos.y > r.Bottom && pos.y < r.Top )
				return true;
		return false;
	}

	// Does any obstacle face's grey band strip (the WALL_SIZE-deep solid half of its tiles, just
	// inside the owner's collision edge) cover this point?
	private bool CoveredByFaceBand( Vector2 pos )
	{
		const float W = Arena.WALL_SIZE;
		foreach ( var f in _faces )
		{
			if ( f.Owner is null or { IsGlass: true } )   // glass faces draw no band, so they cover nothing
				continue;
			if ( f.Normal == Direction.Up || f.Normal == Direction.Down )
			{
				float inner = f.Normal == Direction.Up ? f.Segment.YMin - W : f.Segment.YMin;
				if ( pos.x >= f.Segment.XMin && pos.x <= f.Segment.XMax && pos.y >= inner && pos.y <= inner + W )
					return true;
			}
			else
			{
				float inner = f.Normal == Direction.Right ? f.Segment.XMin - W : f.Segment.XMin;
				if ( pos.y >= f.Segment.YMin && pos.y <= f.Segment.YMax && pos.x >= inner && pos.x <= inner + W )
					return true;
			}
		}
		return false;
	}

	// A face shorter than one 5px tile has unavoidable overhang. Check whether an adjoining
	// obstacle completely hides that overhang within the face's solid band, so it can be moved
	// away from an exposed endpoint instead of producing a one-pixel tab at a thin elbow.
	private bool ObstacleCoversBandExtension( Direction normal, float edgeCoord, float alongMin, float alongMax )
	{
		const float W = Arena.WALL_SIZE;
		const float EPS = 0.01f;
		bool horizontal = normal == Direction.Up || normal == Direction.Down;
		float perpMin = normal == Direction.Up || normal == Direction.Right ? edgeCoord - W : edgeCoord;
		float perpMax = perpMin + W;

		// The owner is deliberately NOT skipped: it self-covers this band only at an INTERIOR
		// (flush-cut) endpoint, never at a true corner — there the extension pokes past the owner's
		// own along-range and fails the test. That's exactly the distinction we want ("exposed corner
		// vs. interior cut"), and it's what makes a face cut short by a stacked neighbour shift the
		// right way. Adding a self-skip here would break that case.
		foreach ( var r in _wallObstacleRects )
		{
			bool covered = horizontal
				? r.Left <= alongMin + EPS && r.Right >= alongMax - EPS
					&& r.Bottom <= perpMin + EPS && r.Top >= perpMax - EPS
				: r.Bottom <= alongMin + EPS && r.Top >= alongMax - EPS
					&& r.Left <= perpMin + EPS && r.Right >= perpMax - EPS;
			if ( covered )
				return true;
		}
		return false;
	}

	// The along-spans of OTHER obstacles sitting flush (edge-to-edge, within EPS) against this side
	// of <paramref name="rect"/> — they cut its face exactly like FlushObstacleSpans cuts an arena
	// boundary side. Composite shapes must be authored as non-overlapping edge-to-edge rects.
	private List<Span> FlushNeighbourSpans( RectF rect, Direction normal )
	{
		const float EPS = FLUSH_EPS;
		var spans = new List<Span>();
		foreach ( var q in _wallObstacleRects )
		{
			if ( q.Left == rect.Left && q.Bottom == rect.Bottom && q.Right == rect.Right && q.Top == rect.Top )
				continue;   // self
			bool flush;
			float a, b;
			switch ( normal )
			{
				case Direction.Left: flush = q.Right > rect.Left - EPS && q.Right < rect.Left + EPS; a = q.Bottom; b = q.Top; break;
				case Direction.Right: flush = q.Left > rect.Right - EPS && q.Left < rect.Right + EPS; a = q.Bottom; b = q.Top; break;
				case Direction.Down: flush = q.Top > rect.Bottom - EPS && q.Top < rect.Bottom + EPS; a = q.Left; b = q.Right; break;
				default: flush = q.Bottom > rect.Top - EPS && q.Bottom < rect.Top + EPS; a = q.Left; b = q.Right; break;
			}
			if ( flush )
				spans.Add( new Span { Min = a, Max = b } );
		}
		return spans;
	}

	// Build a spike face as a row of baked tooth tiles centred ON the collision edge (<paramref
	// name="edgeCoord"/>): the tile's solid half sits on the wall/body side, its spikes grow OUTWARD
	// into playable space. Works identically for an arena boundary segment and an obstacle side — the
	// sprite for "spikes pointing <normal>" is the arena strip of the OPPOSITE side (e.g. spikes-up =
	// the bottom wall's "down" tile). Horizontal faces (normal Up/Down) tile along X, vertical along Y.
	private WallFace BuildTiledFace( Direction normal, Obstacle owner, Direction arenaSide, float edgeCoord, float alongStart, float alongEnd, Line segment, bool permanent = false )
	{
		var face = new WallFace { Owner = owner, ArenaSide = arenaSide, Normal = normal, Segment = segment };
		face.BandColor = WallColorLinear;

		string spriteDir = Globals.GetStringForDirection( Globals.GetOppositeDirection( normal ) );
		bool horizontal = normal == Direction.Up || normal == Direction.Down;
		// Usually ceil so the teeth visually cover the whole deadly face. A single trailing pixel is the
		// exception: leave it as plain band instead of adding a final tile that overlaps its neighbour by
		// four pixels. Sub-5px faces still need one tile so the hazard remains visible.
		float len = alongEnd - alongStart;
		int count = (int)(len / SPIKE_TILE);
		float remainder = len - count * SPIKE_TILE;
		if ( count == 0 || remainder > 1.01f ) count++;

		// GLASS faces deviate: they sit on the glass depth (under blocks — a block visibly slides
		// over the teeth it is immune to), draw NO wall band (the pane's own 1px border marks the
		// edge), and tint the normally-white teeth toward the glass colour as the "player-only" cue.
		bool glass = owner is { IsGlass: true };

		// Draw the resting wall as one exact-size quad. Repeating 5px plain tiles forced the final
		// tile to overlap its neighbour on non-multiple lengths; after the CRT downsample/halation pass,
		// those overlapping endpoints read as soft one-pixel tabs. Teeth still need the baked tiles for
		// animation, but the static two-pixel band has no reason to inherit their fixed-size geometry.
		float rootZ = owner is null
			? Globals.DEPTH_ARENA_WALL
			: Globals.DepthToZ( glass ? Globals.DEPTH_GLASS : Globals.DEPTH_WALL );
		if ( !glass )
		{
			float insideOffset = Arena.WALL_SIZE / 2f;
			Vector2 bandCenter = normal switch
			{
				Direction.Up => new Vector2( (alongStart + alongEnd) / 2f, edgeCoord - insideOffset ),
				Direction.Down => new Vector2( (alongStart + alongEnd) / 2f, edgeCoord + insideOffset ),
				Direction.Right => new Vector2( edgeCoord - insideOffset, (alongStart + alongEnd) / 2f ),
				_ => new Vector2( edgeCoord + insideOffset, (alongStart + alongEnd) / 2f ),
			};
			Vector2 bandSize = horizontal
				? new Vector2( len, Arena.WALL_SIZE )
				: new Vector2( Arena.WALL_SIZE, len );
			var bandGo = CreateChild( "WallBand" );
			bandGo.WorldPosition = new Vector3( bandCenter.x, bandCenter.y, rootZ );
			var band = SpriteLayer.Add( bandGo, "sprites/pixel.sprite", bandSize, "idle", WallFace.ORDER_BAND );
			band.Opaque = true;
			band.Color = WallColorLinear;
			face.Sprites.Add( band );
		}

		// The final tile clamps flush to the far end (alongEnd) so a non-5-multiple face is fully
		// covered without overhanging that corner; it overlaps its previous tile instead. A face
		// shorter than one tile normally centres the unavoidable overhang. At a thin elbow, shift
		// all of it into the adjoining obstacle when exactly one endpoint can hide the full excess.
		float maxAlong = alongEnd - SPIKE_TILE / 2f;
		if ( len < SPIKE_TILE )
		{
			float overflow = SPIKE_TILE - len;
			bool coveredBefore = owner is not null
				&& ObstacleCoversBandExtension( normal, edgeCoord, alongStart - overflow, alongStart );
			bool coveredAfter = owner is not null
				&& ObstacleCoversBandExtension( normal, edgeCoord, alongEnd, alongEnd + overflow );

			maxAlong = coveredBefore == coveredAfter
				? (alongStart + alongEnd) / 2f
				: coveredBefore ? alongEnd - SPIKE_TILE / 2f : alongStart + SPIKE_TILE / 2f;
		}

		// Teeth are normally pure white (a hazard reads the same across every theme); GLASS teeth
		// shade toward the glass colour instead — the signal that only the player dies on them.
		Color teethColor = glass
			? GammaToLinear( LevelCosmeticsRandomizer.GlassTeethTint( Level.GlassColor ?? LevelCosmeticsRandomizer.DefaultGlassColor ) )
			: Color.White;
		int teethOrder = glass ? GLASS_ORDER_TEETH : WallFace.ORDER_TEETH;

		for ( int i = 0; i < count; i++ )
		{
			float along = alongStart + SPIKE_TILE * (i + 0.5f);
			if ( along > maxAlong ) along = maxAlong;
			Vector2 c = horizontal ? new Vector2( along, edgeCoord ) : new Vector2( edgeCoord, along );
			var go = CreateChild( "WallSpike" );
			go.WorldPosition = new Vector3( c.x, c.y, rootZ );

			// Teeth overlay (band removed from the art) drawn white on the nearer sub-layer, so the
			// coloured band beneath always matches the non-spiked walls. Hidden until spikes appear.
			var teeth = SpriteLayer.Add( go, $"sprites/arena/tile_{spriteDir}_teeth.sprite",
				new Vector2( SPIKE_TILE, SPIKE_TILE ), "spiked", teethOrder );
			teeth.Opaque = true;
			teeth.Color = teethColor;
			teeth.Enabled = false;
			face.TeethSprites.Add( teeth );
		}

		if ( permanent ) MakePermanent( face );
		_faces.Add( face );
		return face;
	}

	// Full-width baked strip for an unsplit boundary side (the classic look; one sprite, no tiles).
	private void AddWall( Direction side, Vector2 center, Vector2 size, bool permanent = false )
	{
		var go = CreateChild( $"Wall_{side}" );
		go.WorldPosition = new Vector3( center.x, center.y, Globals.DEPTH_ARENA_WALL );
		// Created on the band sub-layer — the same depth WallFace.Play sets for "plain" — so the
		// strip's resting depth doesn't jump on its first spike cycle.
		var sr = SpriteLayer.Add( go, $"sprites/arena/{Globals.GetStringForDirection( side )}.sprite", size, "plain", WallFace.ORDER_BAND );
		sr.Opaque = true;
		sr.Color = WallSpriteTintLinear;

		var face = new WallFace
		{
			ArenaSide = side,
			Normal = Globals.GetOppositeDirection( side ),   // arena spikes grow inward
			Owner = null,
			Segment = ArenaSegmentLine( side, 0f, side == Direction.Down || side == Direction.Up ? Arena.WIDTH : Arena.HEIGHT ),
			BandColor = WallSpriteTintLinear,   // map baked wall grey to the direct colour; teeth stay white
		};
		face.Sprites.Add( sr );

		// Teeth overlay (band removed from the art) drawn white on the nearer sub-layer, so the
		// coloured band beneath always matches the non-spiked walls. Hidden until spikes appear.
		var teeth = SpriteLayer.Add( go, $"sprites/arena/{Globals.GetStringForDirection( side )}_teeth.sprite", size, "spiked", WallFace.ORDER_TEETH );
		teeth.Opaque = true;
		teeth.Color = Color.White;
		teeth.Enabled = false;
		face.TeethSprites.Add( teeth );

		if ( permanent ) MakePermanent( face );
		_faces.Add( face );
	}

	// Set a freshly-built face permanently spiked: deadly from frame one, no grow-in transition, no
	// timer (never retracts), and skipped by the warning blink (see TickArenaSpikes / BlinkWallSpikes).
	private static void MakePermanent( WallFace f )
	{
		f.Permanent = true;
		f.SpikesPresent = true;
		f.Switching = false;
		f.Play( "spiked" );
	}

	private static Line ArenaSegmentLine( Direction side, float a, float b )
	{
		float w = Arena.WIDTH, h = Arena.HEIGHT;
		return side switch
		{
			Direction.Left => new Line( new Vector2( 0, a ), new Vector2( 0, b ) ),
			Direction.Right => new Line( new Vector2( w, a ), new Vector2( w, b ) ),
			Direction.Down => new Line( new Vector2( a, 0 ), new Vector2( b, 0 ) ),
			_ => new Line( new Vector2( a, h ), new Vector2( b, h ) ),
		};
	}

	public override void Tick( float dt )
	{
		TickBackgroundBlocks( dt );
		// Note: blocks haven't moved yet this tick, so the ambience squares collide against last
		// tick's rects — a one-tick lag that's invisible at these speeds and keeps the ambience
		// entirely outside the sim's per-step order.
		TickBackgroundParticles( dt );

		if ( _tutorialSecondPromptDelay >= 0f )
		{
			_tutorialSecondPromptDelay -= dt;
			if ( _tutorialSecondPromptDelay <= 0f )
			{
				_tutorialSecondPromptDelay = -1f;
				ShowTutorialInstruction( "DO IT AGAIN" );
			}
		}

		// The popup's dismiss fade runs while the sim is already live again (the hold completing
		// unfreezes input immediately — see BlocksSimulation), so its countdown lives here, not in
		// TickMenu. Cosmetic only, and tutorial prompts never run in replays (_tutorialPromptsEnabled).
		if ( _tutorialPromptPhase == TutorialPromptPhase.Dismissing )
		{
			_tutorialPromptDismissTimer -= dt;
			if ( _tutorialPromptDismissTimer <= 0f )
				HideTutorialInstruction();
		}

		// Clear the player's per-tick stasis flags BEFORE the blocks tick: a Stasis block's trail sets them
		// (blocks tick before the player), and the player consumes them across several later tick phases
		// (input gating + gravity), so they must be reset here at frame start rather than mid-player-tick.
		foreach ( var player in Players ) player.ResetStasis();
		foreach ( var imp in Impostors ) imp.ResetStasis();
		if ( Player.SwarmTracing ) Player.SwarmTraceTick++;

		// Original per-step order: blocks -> projectiles -> player (last) -> particles.
		foreach ( var b in Blocks ) if ( !b.Dead ) b.Tick( dt );
		TickVictoryExplosionSfx( dt );
		foreach ( var f in Fireballs ) if ( !f.Dead ) f.Tick( dt );
		foreach ( var t in Teardrops ) if ( !t.Dead ) t.Tick( dt );
		foreach ( var bullet in BlockBullets ) if ( !bullet.Dead ) bullet.Tick( dt );
		// Gunner bullets tick BEFORE the player so a close-range hit's repel (applied via the player's
		// isolated shockwave channel) moves the player this same frame.
		foreach ( var b in Bullets ) if ( !b.Dead ) b.Tick( dt );
		foreach ( var projectile in SwapperProjectiles ) if ( !projectile.Dead ) projectile.Tick( dt );
		_twinsController?.PreTick();
		_playerStepStarts.Clear();
		foreach ( var player in Players ) _playerStepStarts.Add( player.Pos );
		foreach ( var player in Players ) player.Tick( dt );
		ResolvePlayerPairCollisions();
		_twinsController?.EndStep();
		foreach ( var portal in ImpostorPortals ) if ( !portal.Dead ) portal.Tick( dt );
		TickImpostors( dt );
		// SWARM TRACE: one line per body once everything has moved (see Player.TraceSwarmTick).
		if ( Player.SwarmTracing )
		{
			foreach ( var player in Players ) if ( player.IsSwarmBody && !player.IsDead ) player.TraceSwarmTick();
			foreach ( var imp in Impostors ) if ( imp.IsSwarmClone && !imp.IsDead ) imp.TraceSwarmTick();
		}
		// Coins tick after the impostors so a friendly clone's final position this tick can collect.
		// COLLECTION stops the moment the run is decided — Coin.Tick bails at IsGameOver, so the
		// frozen-_gameTime beat can't hand out free points. The _ended gate here additionally stops
		// the (still-sparkling) coins once EndGame has fired: the stage then only persists under the
		// ~0.3s cover wipe, and test-mode return-to-edit sets _ended without a game-over beat at all.
		if ( !_ended )
			foreach ( var c in Coins ) if ( !c.Dead ) c.Tick( dt );
		foreach ( var p in Particles ) if ( !p.Dead ) p.Tick( dt );
		foreach ( var portal in Portals ) portal.Tick( dt );
		foreach ( var f in CoinFloaters ) if ( !f.Dead ) f.Tick( dt );

		// A mimic that reached a new phase this tick swaps itself for a real disguise type. Run this AFTER
		// every entity has ticked (the player + Gunner bullets press block sides during THEIR ticks, so a
		// phase-up only lands here) so the swap happens BEFORE the frame renders -- otherwise the disguise's
		// phase-2 form (e.g. a mimic-Shade's spotlight vision cover) would flash for one frame before the
		// swap. Deferred like this the swap also never destroys a block mid-tick.
		ProcessMimicTransforms();

		// Repro test levels run their scripted drivers here; a no-op for every real level.
		TestLevels.OnStageTick( this );

		// Screenshake decay + oscillation. The flip rolls are COSMETIC (camera-shake direction only) and
		// run every tick, so they must draw from the cosmetic stream — rolling them on the authoritative
		// stream would advance the sim's RNG twice per frame and couple every block decision to the
		// camera shake (see the two-stream split in Rng.cs).
		_shakeH *= CAM_SHAKE_RECOVERY;
		_shakeV *= CAM_SHAKE_RECOVERY;
		if ( Rng.CosmeticValue() < 0.80f ) _shakeHPos = !_shakeHPos;
		if ( Rng.CosmeticValue() < 0.80f ) _shakeVPos = !_shakeVPos;

		// Advance arena-wall spike grow/retract transitions + live-spike timers.
		TickArenaSpikes( dt );

		// Deadly-spike warning blink (blocks + arena walls, in lock-step).
		_spikeBlinkTimer -= dt;
		if ( _spikeBlinkTimer <= 0f )
		{
			_spikeBlink = !_spikeBlink;
			foreach ( var b in Blocks ) b.BlinkSpikes( _spikeBlink );
			BlinkWallSpikes( _spikeBlink );
			_spikeBlinkTimer = _spikeBlink ? SPIKE_BLINK_TIME_ON : SPIKE_BLINK_TIME_OFF;
		}

		if ( _gameOver )
		{
			_gameOverTimer -= dt;
			// Hold for the beat, or let the player skip the wait with Confirm. The skip is disallowed in
			// ANY replay (including a hijacked practice run): a normal replay can't skip anyway (recorded
			// input never sets ConfirmJust), and a hijack must play the beat out fully before freezing.
			if ( _gameOverTimer <= 0f || ( InputState.ConfirmJust && !IsReplay ) )
				EndGame();
		}
		else
		{
			_gameTime += dt;
		}

		// Tutorial ghost: recording taps the frame's FINAL player pose (after the pair resolve and
		// every visual update above); playback advances its loop. Both are tick-driven and Rng-free,
		// so runs, replays, and scrubs reproduce identically with or without them.
		GhostRecorder.CaptureStep( this );
		_ghost?.Tick( Player );

		ReapDead();
	}

	private void TickVictoryExplosionSfx( float dt )
	{
		if ( _victoryExplosionSfxTimer < 0f || Blocks.Count == 0 ) return;

		_victoryExplosionSfxTimer -= dt;
		if ( _victoryExplosionSfxTimer > 0f ) return;

		Block source = Blocks[Rng.CosmeticInt( 0, Blocks.Count )];
		Audio.PlaySfx( SfxType.BlockExplosion, source.Position, Rng.CosmeticFloat( 0.6f, 0.9f ) );
		_victoryExplosionSfxTimer = Rng.CosmeticFloat(
			VICTORY_EXPLOSION_SFX_INTERVAL_MIN,
			VICTORY_EXPLOSION_SFX_INTERVAL_MAX );
	}

	/// <summary>Resolve living player pairs once after every body has moved. Keeping this outside
	/// <see cref="Player.Tick"/> removes stable-list-order bias: neither twin treats the other's old
	/// position as authoritative, and block pressure can move whichever body has valid static space
	/// on a glue-free axis (see <see cref="Player.CanAcceptTwinResolution"/>).</summary>
	private void ResolvePlayerPairCollisions()
	{
		int count = Math.Min( Players.Count, _playerStepStarts.Count );
		for ( int firstIndex = 0; firstIndex < count; firstIndex++ )
		{
			Player first = Players[firstIndex];
			if ( first.IsDead ) continue;

			for ( int secondIndex = firstIndex + 1; secondIndex < count; secondIndex++ )
			{
				Player second = Players[secondIndex];
				if ( second.IsDead ) continue;
				// Re-sample contact flags ONLY for a pair the solver corrected. Everywhere else the flags
				// are a start-of-tick probe that survives to the next tick (_onFloorLastTick's fall-damage
				// gate and the pre-refresh readers like HandleDash key off that timing), so refreshing
				// unconditionally would shift cross-tick flag semantics for every character.
				if ( ResolvePlayerPairCollision( first, second, _playerStepStarts[firstIndex], _playerStepStarts[secondIndex] ) )
				{
					first.RefreshTwinResolvedContacts();
					second.RefreshTwinResolvedContacts();
				}
			}
		}
	}

	/// <summary>True when a correction was applied (positions moved and/or motion into the sibling
	/// stopped); false when the pair never collided or is statically trapped and left compressed.</summary>
	private static bool ResolvePlayerPairCollision( Player first, Player second, Vector2 firstStart, Vector2 secondStart )
	{
		if ( first.IsTwinDashing || second.IsTwinDashing ) return false;

		RectF firstRect = first.GetRect();
		RectF secondRect = second.GetRect();
		float overlapX = Math.Min( firstRect.Right, secondRect.Right ) - Math.Max( firstRect.Left, secondRect.Left );
		float overlapY = Math.Min( firstRect.Top, secondRect.Top ) - Math.Max( firstRect.Bottom, secondRect.Bottom );
		RectF firstStartRect = first.GetRect( firstStart.x, firstStart.y );
		RectF secondStartRect = second.GetRect( secondStart.x, secondStart.y );
		float startOverlapX = Math.Min( firstStartRect.Right, secondStartRect.Right ) - Math.Max( firstStartRect.Left, secondStartRect.Left );
		float startOverlapY = Math.Min( firstStartRect.Top, secondStartRect.Top ) - Math.Max( firstStartRect.Bottom, secondStartRect.Bottom );

		if ( (startOverlapX <= 0f || startOverlapY <= 0f) &&
			TryGetSweptPlayerCollision( first, second, firstStart, secondStart,
				out bool sweptHorizontal, out bool sweptFirstBefore, out float collisionTime ) )
		{
			Vector2 sweptOutward = sweptHorizontal
				? new Vector2( sweptFirstBefore ? -1f : 1f, 0f )
				: new Vector2( 0f, sweptFirstBefore ? -1f : 1f );
			float separation = sweptHorizontal
				? (first.Width + second.Width) * 0.5f
				: (first.Height + second.Height) * 0.5f;
			Vector2 firstMove = first.Pos - firstStart;
			Vector2 secondMove = second.Pos - secondStart;

			Vector2 splitFirst = Vector2.Zero;
			Vector2 splitSecond = Vector2.Zero;
			Vector2 fullSweptFirst = Vector2.Zero;
			Vector2 fullSweptSecond = Vector2.Zero;
			if ( sweptHorizontal )
			{
				float targetFirst = firstStart.x + firstMove.x * collisionTime;
				float targetSecond = secondStart.x + secondMove.x * collisionTime;
				splitFirst = new Vector2( targetFirst - first.X, 0f );
				splitSecond = new Vector2( targetSecond - second.X, 0f );
				fullSweptFirst = new Vector2( second.X + (sweptFirstBefore ? -separation : separation) - first.X, 0f );
				fullSweptSecond = new Vector2( first.X + (sweptFirstBefore ? separation : -separation) - second.X, 0f );
			}
			else
			{
				float targetFirst = firstStart.y + firstMove.y * collisionTime;
				float targetSecond = secondStart.y + secondMove.y * collisionTime;
				splitFirst = new Vector2( 0f, targetFirst - first.Y );
				splitSecond = new Vector2( 0f, targetSecond - second.Y );
				fullSweptFirst = new Vector2( 0f, second.Y + (sweptFirstBefore ? -separation : separation) - first.Y );
				fullSweptSecond = new Vector2( 0f, first.Y + (sweptFirstBefore ? separation : -separation) - second.Y );
			}

			return ApplyPlayerPairCorrection( first, second, firstStart, secondStart,
				splitFirst, splitSecond, fullSweptFirst, fullSweptSecond, sweptOutward );
		}

		if ( overlapX > 0f && overlapY > 0f )
		{
			bool horizontal = overlapX <= overlapY;
			bool firstBefore = FirstBeforeSecond( first, second, firstStart, secondStart, horizontal );
			Vector2 firstOutward = horizontal
				? new Vector2( firstBefore ? -1f : 1f, 0f )
				: new Vector2( 0f, firstBefore ? -1f : 1f );
			float depth = horizontal ? overlapX : overlapY;
			Vector2 fullFirst = firstOutward * depth;
			Vector2 fullSecond = -fullFirst;
			return ApplyPlayerPairCorrection( first, second, firstStart, secondStart,
				fullFirst * 0.5f, fullSecond * 0.5f, fullFirst, fullSecond, firstOutward );
		}

		return false;
	}

	private static bool ApplyPlayerPairCorrection( Player first, Player second,
		Vector2 firstStart, Vector2 secondStart, Vector2 splitFirst, Vector2 splitSecond,
		Vector2 fullFirst, Vector2 fullSecond, Vector2 firstOutward )
	{
		bool resolved = false;
		if ( first.CanAcceptTwinResolution( splitFirst ) && second.CanAcceptTwinResolution( splitSecond ) )
		{
			first.ApplyTwinResolution( splitFirst );
			second.ApplyTwinResolution( splitSecond );
			resolved = true;
		}
		else
		{
			float firstInward = Math.Max( 0f, Vector2.Dot( first.Pos - firstStart, -firstOutward ) );
			float secondInward = Math.Max( 0f, Vector2.Dot( second.Pos - secondStart, firstOutward ) );
			bool tryFirstFirst = firstInward >= secondInward;

			if ( tryFirstFirst )
				resolved = TryMoveFirstOnly() || TryMoveSecondOnly();
			else
				resolved = TryMoveSecondOnly() || TryMoveFirstOnly();

			bool TryMoveFirstOnly()
			{
				if ( !first.CanAcceptTwinResolution( fullFirst ) ) return false;
				first.ApplyTwinResolution( fullFirst );
				return true;
			}

			bool TryMoveSecondOnly()
			{
				if ( !second.CanAcceptTwinResolution( fullSecond ) ) return false;
				second.ApplyTwinResolution( fullSecond );
				return true;
			}
		}

		if ( !resolved ) return false; // Both are statically trapped: ordinary twins compress harmlessly.
		first.StopMotionIntoTwin( firstOutward );
		second.StopMotionIntoTwin( -firstOutward );
		return true;
	}

	private static bool FirstBeforeSecond( Player first, Player second,
		Vector2 firstStart, Vector2 secondStart, bool horizontal )
	{
		float firstThen = horizontal ? firstStart.x : firstStart.y;
		float secondThen = horizontal ? secondStart.x : secondStart.y;
		if ( firstThen != secondThen ) return firstThen < secondThen;

		float firstNow = horizontal ? first.X : first.Y;
		float secondNow = horizontal ? second.X : second.Y;
		return firstNow == secondNow || firstNow < secondNow;
	}

	private static bool TryGetSweptPlayerCollision( Player first, Player second,
		Vector2 firstStart, Vector2 secondStart, out bool horizontal, out bool firstBefore, out float collisionTime )
	{
		horizontal = false;
		firstBefore = false;
		collisionTime = 0f;
		RectF firstStartRect = first.GetRect( firstStart.x, firstStart.y );
		RectF secondStartRect = second.GetRect( secondStart.x, secondStart.y );

		Vector2 relativeMove = (first.Pos - firstStart) - (second.Pos - secondStart);
		SweepAxis( firstStartRect.Left, firstStartRect.Right, secondStartRect.Left, secondStartRect.Right,
			relativeMove.x, out float xEntry, out float xExit );
		SweepAxis( firstStartRect.Bottom, firstStartRect.Top, secondStartRect.Bottom, secondStartRect.Top,
			relativeMove.y, out float yEntry, out float yExit );

		float entry = Math.Max( xEntry, yEntry );
		float exit = Math.Min( xExit, yExit );
		if ( entry < 0f || entry > 1f || entry > exit ) return false;

		collisionTime = entry;
		horizontal = xEntry > yEntry || (xEntry == yEntry && Math.Abs( relativeMove.x ) >= Math.Abs( relativeMove.y ));
		firstBefore = horizontal
			? firstStart.x <= secondStart.x
			: firstStart.y <= secondStart.y;
		return true;
	}

	private static void SweepAxis( float firstMin, float firstMax, float secondMin, float secondMax,
		float relativeMove, out float entry, out float exit )
	{
		if ( relativeMove > 0f )
		{
			entry = (secondMin - firstMax) / relativeMove;
			exit = (secondMax - firstMin) / relativeMove;
		}
		else if ( relativeMove < 0f )
		{
			entry = (secondMax - firstMin) / relativeMove;
			exit = (secondMin - firstMax) / relativeMove;
		}
		else if ( firstMax <= secondMin || firstMin >= secondMax )
		{
			entry = float.PositiveInfinity;
			exit = float.NegativeInfinity;
		}
		else
		{
			entry = float.NegativeInfinity;
			exit = float.PositiveInfinity;
		}
	}

	private void ReapDead()
	{
		ReapList( Fireballs );
		ReapList( Bullets );
		ReapList( SwapperProjectiles );
		ReapList( BlockBullets );
		ReapList( Teardrops );
		ReapList( Particles );
		ReapList( ImpostorPortals );
		ReapList( Coins );
		ReapList( CoinFloaters );

		// Impostors are Players tracked by their own IsDead flag (not the Entity2D.Dead flag ReapList uses).
		Impostors.RemoveAll( imp =>
		{
			if ( imp.IsDead ) { imp.GameObject?.Destroy(); return true; }
			return false;
		} );
	}

	private static void ReapList<T>( List<T> list ) where T : Entity2D
	{
		list.RemoveAll( e =>
		{
			if ( e.Dead ) { e.GameObject?.Destroy(); return true; }
			return false;
		} );
	}

	public override void SyncTransforms()
	{
		SyncBackgroundBlocks();
		SyncBackgroundParticles();

		foreach ( var b in Blocks ) b.SyncTransform();
		foreach ( var f in Fireballs ) f.SyncTransform();
		foreach ( var b in Bullets ) b.SyncTransform();
		foreach ( var projectile in SwapperProjectiles ) projectile.SyncTransform();
		foreach ( var bullet in BlockBullets ) bullet.SyncTransform();
		foreach ( var t in Teardrops ) t.SyncTransform();
		foreach ( var player in Players ) player.SyncTransform();
		_twinsController?.SyncPresentation();
		foreach ( var imp in Impostors ) imp.SyncTransform();
		foreach ( var p in Particles ) p.SyncTransform();
		foreach ( var portal in Portals ) portal.SyncTransform();
		foreach ( var portal in ImpostorPortals ) portal.SyncTransform();
		// Coins are deliberately absent: stationary, positioned once at spawn (fractional depth layer).
		foreach ( var f in CoinFloaters ) f.SyncTransform();

		ApplyScreenshake();
	}

	private void ApplyScreenshake()
	{
		if ( Manager.Camera is null ) return;
		// The GIF-export overlay owns the camera framing while it's open (shrunk preview / tight capture);
		// don't fight it by snapping the camera back to the arena centre each SyncTransforms.
		if ( Manager.IsGifPreviewActive ) return;
		// Player-tunable intensity: slider-space 0..100 → 0..1 factor. 0 pins the camera dead centre.
		float shakeScale = Settings.Current.Screenshake / 100f;
		float ox = _shakeH * (_shakeHPos ? 1f : -1f) * CAM_SHAKE_STRENGTH * Arena.STEP * shakeScale;
		float oy = _shakeV * (_shakeVPos ? 1f : -1f) * CAM_SHAKE_STRENGTH * Arena.STEP * shakeScale;
		ox = MathF.Round( ox );
		oy = MathF.Round( oy );
		Manager.Camera.WorldPosition = new Vector3( Arena.WIDTH / 2f + ox, Arena.HEIGHT / 2f + oy, 2000f );
	}
}