Entities/Block.cs
namespace BlockParty;

public enum BlockType { Dragon, Squid, Smile, Spikey, Sad, Wind, Magnet, Sticky, Shade, Stasis, Shockwave, Slicer, Mimic, Teleport, Summoner, Hunter, Siren, Wisp, Venom, Reverse, Laser, Bullet, DragonBounce, LaserPierce, StraightLaser, SadPierce, SquidPierce }

/// <summary>
/// Enemy "block" — a faithful port of the gameplay core of the original <c>Block</c>:
/// accelerate in a direction, bounce off walls/other blocks, and on a hard impact pause
/// (eye close → pick a new direction → wait → eye open → resume). The player presses the
/// four side-buttons; pressing all four advances the block a phase (3 phases); when every
/// block hits max phase the player wins.
///
/// Phase 3a scope: movement, collision, impact, eye-cycle, side-press → phasing, layered
/// phase-coloured sprites, and shake. Deferred to later sub-phases: palette blink, speed
/// lines, block spikes, and subtype-specific behaviour/attacks (Dragon fireballs, Squid
/// teardrops/laser, Smile invisibility, Spikey spikes, Sad).
/// </summary>
public class Block : Entity2D
{
	public BlockType BlockType { get; protected set; }
	public Direction MoveDirection { get; private set; }
	public int Phase { get; private set; }
	public int ScoreStartPhase { get; private set; }
	private bool _scoreStartLeft, _scoreStartRight, _scoreStartUp, _scoreStartDown;

	/// <summary>Per-block "mimic" memory, or null for a normal block. A block that originated as a Mimic
	/// (BlockType.Mimic) carries this; the stage swaps the instance for a real disguise type at each
	/// phase-up (GameStage.ProcessMimicTransforms) and hands this state to the replacement so the disguise
	/// remembers it is a mimic and transforms again at the next phase.</summary>
	public MimicState Mimic { get; set; }

	/// <summary>True once a mimic transform has swapped this instance out for its disguise (set the same
	/// sim tick, in GameStage.TransformMimic). SIM code must test THIS, never engine IsValid(): Destroy()
	/// only queues the delete, so IsValid() flips at the NEXT FRAME's flush — a boundary that never comes
	/// inside a one-frame re-sim (timeline scrub, hijack rebuild, replay_verify), which desynced replays.</summary>
	public bool Replaced { get; set; }

	/// <summary>A mimic transform swapped <paramref name="old"/> for <paramref name="replacement"/> this
	/// tick — re-point any reference held to the old instance (see GameStage.TransformMimic).</summary>
	public virtual void OnBlockReplaced( Block old, Block replacement ) { }

	/// <summary>True if this block type can obscure the player's line of sight (phase-dependent — see
	/// <see cref="VisionMode"/>). Lets the stage decide whether to spawn the vision cover.</summary>
	public virtual bool BlocksVision => false;

	/// <summary>The line-of-sight cover this block imposes RIGHT NOW (by phase), or null when it isn't
	/// blocking. Drawn by the stage's shared <see cref="VisionOccluder"/>.</summary>
	public virtual VisionBlockMode? VisionMode => null;

	/// <summary>This block's index in the stage's spawn order (0-based), assigned at spawn. Used to give
	/// per-block lane overlays (Wind/Magnet) a unique world-Z sub-offset so overlapping lanes from
	/// different blocks don't z-fight, however many of them a level composes.</summary>
	public int StageIndex { get; set; }

	// TEMP DEBUG: force a starting move direction (used by GameStage's single-block debug setup).
	public void DebugSetMoveDirection( Direction dir ) => MoveDirection = dir;
	// TEMP DEBUG: seed the current travel speed (used by TestLevels' scripted repro setups, e.g. to
	// start two blocks with a fixed speed differential). Still clamped to the phase cap by TickMovement.
	public void DebugSetMoveSpeed( float speed ) => _moveSpeed = speed;
	public const int NUM_PHASES = 3;
	public bool IsStopped { get; set; }
	public bool IsDead { get; private set; }
	public TurnMode TurnMode { get; private set; }

	/// <summary>While a Teleport block is phasing / fading in at a new location it is intangible to the
	/// player and to the squid laser (other blocks still collide with it, so it stays a solid obstacle to
	/// them). The player-vs-block scans and BlockSquid.ComputeBeam skip a block with this set; the
	/// block-vs-block collision does not.</summary>
	public bool PhasingIn { get; set; }

	/// <summary>True for the simulation tick in which a phasing block becomes solid. Projectiles that
	/// were already inside use their pre-move position as the impact point rather than a swept face.</summary>
	public bool BecameSolidThisTick { get; protected set; }

	/// <summary>Set for the single tick on which this block lands a hard SLAM (the impact loud enough to
	/// throw dust + play the impact sfx — speed over the collision threshold, not a slow grind). Cleared
	/// at the top of every <see cref="Tick"/>. Blocks tick before the player, so the player can read it
	/// the same tick to release a sticky grab into its post-slam window (see Player.HandleStickyBlocks);
	/// the fling momentum comes from <see cref="PreImpactVelocity"/>.</summary>
	public bool SlammedThisTick { get; private set; }

	/// <summary>Set for the single tick on which this block came to rest in a way that hands its banked
	/// momentum to an attached player: a real slam (every slam sets this alongside
	/// <see cref="SlammedThisTick"/>) or a full-speed soft brake (<see cref="SoftStopAndRepick"/> — the
	/// hunter re-aiming mid-lane). A quiet grind stop — a failed move that crept sub-pixel into a
	/// neighbour for its grind window and re-stopped at a crawl — leaves this false, so it can never
	/// fling. Cleared at the top of every <see cref="Tick"/>; blocks tick before the player, so the
	/// inertia-fling sites read it the same tick, taking the momentum itself from
	/// <see cref="PreImpactVelocity"/> (subject to their own per-axis speed floors).</summary>
	public bool InertiaHandoffThisTick { get; private set; }

	/// <summary>The velocity this block was travelling at when it last came to rest (captured in
	/// <see cref="ImpactEffects"/> just before <see cref="Entity2D.Velocity"/> is zeroed). This is what
	/// the momentum-inheritance mechanics read — the sticky slam release and the riding/hugging inertia
	/// flings all fire on/after the stop tick, when the block itself is already stationary. Velocity is
	/// always the block's CURRENT motion (zero while stopped or dead), so "is it moving toward me?"
	/// queries (crushing, spikes, platform carries) can read it raw.</summary>
	public Vector2 PreImpactVelocity { get; private set; }

	/// <summary>The block's resolved world displacement during its most recent fixed step. Projectiles
	/// use this instead of live velocity because a block may stop and zero its velocity on impact after
	/// it has already moved into them. Instant jumps (teleports) are excluded — see
	/// <see cref="ExcludeJumpFromStepDisplacement"/> — so a swept collision never treats a teleport as
	/// travelled distance.</summary>
	public Vector2 StepDisplacement { get; private set; }

	// Instant position jumps this step (teleports), subtracted out of StepDisplacement.
	Vector2 _stepJumpDelta;

	/// <summary>Subtypes that move by instant jump rather than travel (BlockTeleport) must report the
	/// jump here so it is left out of <see cref="StepDisplacement"/>: a bullet overlapping the arrival
	/// spot would otherwise sweep the whole jump as if the block flew there, placing the impact at a
	/// bogus point along that fictional path.</summary>
	protected void ExcludeJumpFromStepDisplacement( Vector2 jump ) => _stepJumpDelta += jump;

	/// <summary>DEBUG: when set, each block draws its position/velocity/intended move direction as
	/// world-space text above itself (see <see cref="DrawDebugInfo"/>, driven by GameManager once
	/// per rendered frame). A mutable field (not const) so it can be toggled at runtime.</summary>
	public static bool ShowDebugInfo = false;

	/// <summary>The solid this block last rammed hard enough to <see cref="Impact"/>: another Block, an
	/// interior <see cref="Obstacle"/>, the hardened Twin statue (a <see cref="Player"/>), or null for a
	/// bare arena wall. Lets a subtype (Spikey) tell those apart — every non-block case arrives as
	/// <c>Impact(null)</c>. Only null means an arena wall.</summary>
	protected Entity2D LastImpactOther { get; private set; }

	protected float _moveSpeed;
	float _mimicAuraTimer; // countdown between subtle transformed-mimic aura puffs (cosmetic)
	float _mimicTintTime;
	// Slowly breathe from a cool cast to saturated blue, keeping the disguise readable on its
	// own. Only body layers receive it: buttons and spikes retain their phase / hazard colors.
	const float MIMIC_TINT_PERIOD = 2.2f;
	static readonly Color MIMIC_DISGUISE_TINT = new( 0.8f, 0.9f, 1f );
	static readonly Color MIMIC_DISGUISE_PEAK_TINT = new( 0.22f, 0.50f, 1f );
	// Per-phase accel / max-speed. Not readonly: a subtype (e.g. BlockHunter) reassigns these in OnSetup
	// to change its speed profile per phase.
	protected float[] _accelerations = { 50f, 55f, 60f };
	protected float[] _maxSpeeds = { 220f, 230f, 240f };
	protected float _currentCollidingTime;
	protected const float REQUIRED_COLLISION_SPEED = 40.0f;
	protected const float REQUIRED_COLLISION_TIME = 0.33f;

	public const float DEFAULT_ABILITY_INTERVAL = 3f;
	public const float DEFAULT_ABILITY_CLOSED_TIME = 1f;
	float _abilityInterval = DEFAULT_ABILITY_INTERVAL;
	float _abilityRandomness;
	float _abilityStartOffset;
	bool _abilityStartOffsetPending;
	float _abilityClosedTime = DEFAULT_ABILITY_CLOSED_TIME;
	float _abilityTimer = -1f;
	bool _timedAbilityActivation;
	bool _enclosedThisTick;
	bool _enclosedEyeCycle;
	// True only while the cycle in flight is TickEnclosedEyes' AMBIENT blink (not an ability-initiated
	// close that merely wants the enclosed-gaze reopen, which _enclosedEyeCycle also covers). Only this
	// kind may be held for a busy ability in Tick — holding an ability's own cycle deadlocks a subtype
	// whose attack STARTS from OnEyesClosed (an enclosed Siren froze forever waiting on its own close).
	bool _ambientEnclosedBlink;
	float _enclosedEyeTimer = -1f;
	const float ENCLOSED_EYE_INTERVAL_MIN = 1.5f;
	const float ENCLOSED_EYE_INTERVAL_MAX = 3f;

	/// <summary>Subtypes with a long-running attack report busy here; the enclosed-ability countdown
	/// pauses while this is false, so the next activation comes a full interval after the attack ends.</summary>
	protected virtual bool CanActivateTimedAbility => true;

	/// <summary>Lane blocks (Wind/Magnet) override: while enclosed at phase 1+, the ability timings drive
	/// a strict lane on/off loop — Interval (± Randomness) counts only eyes-fully-open time (lane on),
	/// then the eyes shut for the authored ClosedTime (lane off) and re-open — and the ambient enclosed
	/// blink is suppressed, so the authored loop is the ONLY lull cadence.</summary>
	protected virtual bool HasEnclosedEyeLoop => false;

	bool EnclosedEyeLoopActive => HasEnclosedEyeLoop && Phase >= 1;

	/// <summary>True while the authored enclosed on/off loop is actually driving this block's cadence:
	/// a loop-capable subtype, at phase 1+, boxed in this tick. A subtype whose ability isn't already
	/// eye-driven (BlockStraightLaser's beams) gates on this + <see cref="EyesOpenAmount"/> so ONLY the
	/// enclosed loop toggles it — ordinary eye cycles while free-roaming must not.</summary>
	protected bool EnclosedEyeLoopRunning => EnclosedEyeLoopActive && _enclosedThisTick;

	public float AbilityInterval => _abilityInterval;
	public float AbilityRandomness => _abilityRandomness;
	public float AbilityClosedTime => _abilityClosedTime;

	public void ConfigureTurnMode( TurnMode mode ) => TurnMode = mode;

	/// <summary>Configure the cadence used while this block is physically blocked in all four directions.</summary>
	public void ConfigureAbilityTimer( float interval, float randomness, float startOffset = 0f,
		float closedTime = DEFAULT_ABILITY_CLOSED_TIME )
	{
		_abilityInterval = MathF.Max( 0.05f, interval );
		_abilityRandomness = Math.Clamp( randomness, 0f, MathF.Max( 0f, _abilityInterval - 0.05f ) );
		_abilityStartOffset = Math.Clamp( startOffset, 0f, _abilityInterval );
		_abilityStartOffsetPending = true;
		_abilityClosedTime = MathF.Max( 0f, closedTime );
		_abilityTimer = -1f;
	}

	// Eye / pause cycle.
	enum EyeState { Open, Closing, Waiting, Opening }
	EyeState _eyeState = EyeState.Open;
	float _eyeTimer;
	float _eyesOpenTime = float.MaxValue;
	bool _facingEyeCycle;
	Direction? _eyeCycleFacingOverride;
	float _facingEyesClosedTime;
	const float EYES_OPEN_TIME = 0.2f;
	protected virtual float EyesCloseTime => 0.2f;
	const float EYES_STAY_CLOSED_TIME_MIN = 0.28f;
	const float EYES_STAY_CLOSED_TIME_MAX = 0.60f;

	/// <summary>How open the eyes currently are, 0 (fully shut) → 1 (fully open), ramping across the
	/// close/open animations. Lets a subtype fade an eyes-gated effect (e.g. the Wind block's gust)
	/// in and out with the eye-cycle. During Closing the timer counts full→0 (so open = timer/close),
	/// during Opening it counts full→0 as the eyes open (so open = 1 - timer/open).</summary>
	protected float EyesOpenAmount => _eyeState switch
	{
		EyeState.Open => 1f,
		EyeState.Closing => Math.Clamp( _eyeTimer / EyesCloseTime, 0f, 1f ),
		EyeState.Waiting => 0f,
		EyeState.Opening => Math.Clamp( 1f - _eyeTimer / EYES_OPEN_TIME, 0f, 1f ),
		_ => 1f,
	};

	// Per-side state. Each side is EITHER a pressable button OR spikes, never both at once — a single
	// state machine per side makes that exclusivity structural. This was previously 8 parallel
	// Dictionary<Direction,…> maps advanced by TWO independent tickers (button-pop + spike add/retract)
	// that BOTH wrote the same per-side sprite; a spike graft landing mid button-pop let the still-
	// running pop finish and stomp the spike sprite (a deadly side that rendered as a harmless button).
	// One mode per side removes that whole class of bug — the modes below are mutually exclusive:
	//
	//   Out ──press──▶ Pressed ──phase-up──▶ Retracting ──▶ Popping ──▶ Out
	//   (any) ──graft──▶ SpikesAdding ──▶ Spiked ──timeout──▶ SpikesRetracting ──▶ Popping ──▶ Out
	//                                                                          └─▶ Pressed (if pressed when grafted)
	//
	// Spikes SUSPEND a press rather than erase it: PressedUnderSpikes latches at graft time and is only
	// read while HasSpikes. It still counts toward phase-up (the spikes just stay put through the
	// phase-up); if no phase-up consumes it, the press returns on retract instead of a fresh pop.
	//
	enum SideMode
	{
		Out,              // resting button, pressable
		Pressed,          // button pressed in (counts toward phase-up)
		Retracting,       // pressed button retracting in (phase-up transition, part 1)
		Popping,          // new phase's button popping out (phase-up part 2, or after spikes retract)
		SpikesAdding,     // spikes growing in — harmless + unpressable
		Spiked,           // spikes live and deadly
		SpikesRetracting, // spikes retracting away
	}

	class SideState
	{
		public SideMode Mode = SideMode.Out;
		public float Timer;          // countdown for the current timed mode (every mode but Out/Pressed)
		public float SpikeLiveTime;  // deadly duration to run once SpikesAdding finishes growing
		public SpriteRenderer Sprite;
		public float StickyTime;
		public SpriteRenderer StickyGoo;

		public bool PressedUnderSpikes; // was Pressed when spikes were grafted; meaningful only while HasSpikes

		public bool IsPressed => Mode == SideMode.Pressed;
		public bool HasSpikes => Mode is SideMode.SpikesAdding or SideMode.Spiked or SideMode.SpikesRetracting;
		// "Switching" = mid transition animation, so harmless + unpressable (Out/Pressed/Spiked are settled).
		public bool IsSwitching => Mode is SideMode.SpikesAdding or SideMode.SpikesRetracting or SideMode.Retracting or SideMode.Popping;
	}

	readonly Dictionary<Direction, SideState> _side = new();
	bool _spikeBlinkOn;

	const float SPIKE_ADD_TIME = 0.25f;
	const float SPIKE_RETRACT_TIME = 0.2f;
	// A lethal surface always keeps a tell: spiked sides and sticky goo never fade below these
	// on an invisible (Smile) block, even though the body fades to 0.
	const float SPIKE_MIN_ALPHA = 0.35f;
	const float STICKY_GOO_INVIS_FACTOR = 0.6f;
	// Button pop animation. On phase-up the pressed button retracts in, then the new phase's button
	// pops back out (rather than snapping); a spike retract likewise ends by popping a fresh button
	// out. Mirrors the original's UnpressSideCoroutine.
	const float BTN_RETRACT_TIME = 0.15f;
	const float BTN_POP_OUT_TIME = 0.2f;   // out0/out1 pop (frames 0->3)
	const float BTN_POP_MAX_TIME = 0.15f;  // max pop (frames 0->2)
	// Public so the level editor's spawn previews tint their unpressed phase-1 buttons the same way.
	public static readonly Color PHASE_1_OUT_TINT = new Color( 1f, 0.65f, 0.65f );
	const float STICKY_GOO_FADE_SPEED = 14f;
	const float STICKY_GOO_DIM_ALPHA = 0.35f;
	const float STICKY_GOO_BRIGHT_ALPHA = 0.82f;
	const float STICKY_GOO_PULSE_SPEED = 10f;
	float _stickyGooPulse;

	public bool IsSidePressed( Direction dir ) => _side[dir].IsPressed;
	/// <summary>Pressed, OR pressed-but-suspended under a spike graft: the player's earned progress on this
	/// side. Drives phase-up and the final tally. <see cref="IsSidePressed"/> is the strict button state
	/// (a spiked side is never pressable / never a button).</summary>
	public bool IsSidePressedOrSuspended( Direction dir )
	{
		var s = _side[dir];
		return s.IsPressed || (s.HasSpikes && s.PressedUnderSpikes);
	}

	/// <summary>Every side's full sim state packed 4 bits each (L,R,U,D): mode + suspended-press flag. For
	/// the replay checkpoint hash — Switching/Spiked/suspended all change what the next press does.</summary>
	public int SideStateCode()
	{
		int code = 0, shift = 0;
		foreach ( var dir in Globals.GetAllDirections() )
		{
			var s = _side[dir];
			code |= ((int)s.Mode | (s.PressedUnderSpikes ? 8 : 0)) << shift;
			shift += 4;
		}
		return code;
	}
	public bool WasSidePressedAtScoreStart( Direction dir ) => dir switch
	{
		Direction.Left => _scoreStartLeft,
		Direction.Right => _scoreStartRight,
		Direction.Up => _scoreStartUp,
		Direction.Down => _scoreStartDown,
		_ => false,
	};

	/// <summary>Record authored progress that existed before gameplay so the score and tally can
	/// exclude it. Kept separate from <see cref="SetInitialPhase"/>, which mimic transforms also use.</summary>
	public void SetScoreStart( int phase, IReadOnlyList<Direction> pressedSides = null )
	{
		ScoreStartPhase = Math.Clamp( phase, 0, NUM_PHASES - 1 );
		_scoreStartLeft = pressedSides?.Contains( Direction.Left ) ?? false;
		_scoreStartRight = pressedSides?.Contains( Direction.Right ) ?? false;
		_scoreStartUp = pressedSides?.Contains( Direction.Up ) ?? false;
		_scoreStartDown = pressedSides?.Contains( Direction.Down ) ?? false;
	}

	public void CopyScoreStartFrom( Block other )
	{
		ScoreStartPhase = other.ScoreStartPhase;
		_scoreStartLeft = other._scoreStartLeft;
		_scoreStartRight = other._scoreStartRight;
		_scoreStartUp = other._scoreStartUp;
		_scoreStartDown = other._scoreStartDown;
	}

	// Shake (per-layer visual offset).
	Vector2 _shakeVector;
	const float SHAKE_RECOVERY = 0.5f;
	const float SHAKE_STRENGTH = 0.02f;
	float _stunTimer;
	float _stunParticleTimer;
	bool _resumeAfterStun;
	const float STUN_PARTICLE_INTERVAL = 0.11f;
	static readonly Color STUN_SPARK_COLOR = new( 0.45f, 0.92f, 1f );
	static readonly Color STUN_SPARK_HIGHLIGHT = new( 1f, 0.9f, 0.35f );

	public bool IsStunned => _stunTimer > 0f;

	// Layered sprite renderers.
	protected string SpritePath;
	protected SpriteRenderer _face, _eyes, _mouth, _eyebrows;
	float _layersAlpha = 1f;

	// Speed lines (trailing motion streaks).
	class SpeedLine { public int offset; public float length; public float acceleration; }
	readonly List<SpeedLine> _speedLines = new();
	readonly List<SpriteRenderer> _speedLineSprites = new();
	const int MAX_SPEED_LINES = 6;
	static readonly Color SPEEDLINE_COLOR = new Color( 248 / 255f, 245 / 255f, 230 / 255f );

	// Palette blink (phase 1/2 the FACE alternates between phase colour and the phase-0 colour;
	// the other layers have no alt art and never blink).
	float _paletteBlinkTimer;
	bool _paletteAlt;
	/// <summary>When set, the palette blink toggles this face anim (+"_alt") instead of the regular
	/// face_{Phase} — e.g. "face_shooting_2" while the Dragon family shoots, so the blink keeps
	/// going through the attack.</summary>
	protected string FaceBlinkAnimOverride;
	/// <summary>When set, the eye blink cycle leaves the eyes alone (e.g. Squid's shooting eyes).</summary>
	protected bool SuppressEyeBlink;
	const float PALETTE_BLINK_TIME_PHASE_1 = 0.15f;
	const float PALETTE_BLINK_TIME_PHASE_2 = 0.1f;
	const float PALETTE_BLINK_ON_TIME = 0.05f;

	static readonly Vector2 BLOCK_SIZE = new Vector2( 40, 40 );

	/// <summary>Native pixel size of the side-spike frames: 42x42 so the teeth overhang the
	/// block edge by 1px (button art is 40x40 like the block).</summary>
	static readonly Vector2 SPIKE_FRAME_SIZE = new Vector2( 42, 42 );

	/// <summary>Play a side-layer animation with the quad sized to the art's NATIVE pixels
	/// (spikes 42, buttons 40). Rendering the 42px spike art in the 40px box shrank it ~5%,
	/// which the retro CRT pipeline's 240px grid turns into dropped tooth rows that crawl as
	/// the block moves at subpixel positions — native size keeps every texel 1:1 on the grid.</summary>
	static void PlaySideAnim( SpriteRenderer sprite, string anim )
	{
		sprite.Size = anim.StartsWith( "spikes", StringComparison.Ordinal ) ? SPIKE_FRAME_SIZE : BLOCK_SIZE;
		sprite.PlayAnimation( anim );
	}

	/// <summary>Spike anim for a side in its current spike mode (grow / settled / retract). Coloured like
	/// the button underneath: max yellow on a maxed block, the pressed colour while a press is suspended
	/// under the spikes, else plain (see tools/bake.py SPIKE_PRESSED_BAR).</summary>
	string SpikeAnim( SideState s, Direction dir )
	{
		string variant = Phase >= NUM_PHASES - 1 ? "_max" : s.PressedUnderSpikes ? $"_pressed{Phase}" : "";
		string stage = s.Mode == SideMode.SpikesAdding ? "_add" : s.Mode == SideMode.SpikesRetracting ? "_retract" : "";
		return $"spikes{variant}{stage}_{Dir( dir )}";
	}

	/// <summary>What a settled (Spiked) side shows right now: the shared red alt frame during the deadly
	/// blink's on-window, else its coloured spikes.</summary>
	string SettledSpikeAnim( SideState s, Direction dir ) => _spikeBlinkOn ? $"spikes_alt_{Dir( dir )}" : SpikeAnim( s, dir );

	// ----------------------------------------------------------------------------------------
	public void Setup( BlockType type, string spritePath )
	{
		BlockType = type;
		SpritePath = spritePath;
		Size = BLOCK_SIZE;
		Depth = Globals.DEPTH_BLOCK;
		MoveDirection = Globals.GetRandomDirection();
		_moveSpeed = 0f;
		Phase = 0;

		foreach ( var dir in Globals.GetAllDirections() )
		{
			// Setup runs before CreateVisuals (which assigns each side's Sprite), so create the state
			// here and leave the renderer to be wired up there.
			var s = _side.TryGetValue( dir, out var existing ) ? existing : (_side[dir] = new SideState());
			s.Mode = SideMode.Out;
			s.Timer = 0f;
			s.SpikeLiveTime = 0f;
			s.StickyTime = 0f;
		}

		InitSpeedLines();
		OnSetup();
	}

	/// <summary>Hook: called at the end of <see cref="Setup"/> (before CreateVisuals). Subtypes override to
	/// adjust per-type tunables such as the per-phase <see cref="_accelerations"/>/<see cref="_maxSpeeds"/>.</summary>
	protected virtual void OnSetup() { }

	void InitSpeedLines()
	{
		_speedLines.Clear();
		int n = Rng.Int( 1, 6 );
		for ( int i = 0; i < n; i++ )
			_speedLines.Add( new SpeedLine { offset = Rng.Int( 1, (int)BLOCK_SIZE.x - 1 ), length = 0f, acceleration = Rng.Float( 0.0001f, 0.0021f ) } );
	}

	public bool HasSpikes( Direction dir ) => _side[dir].HasSpikes;
	public bool IsSwitchingSide( Direction dir ) => _side[dir].IsSwitching;
	/// <summary>Single "deadly now" predicate for a block side: spikes present AND settled (not mid
	/// grow-in / retract). Symmetric with <c>GameStage.WallDeadlyAt</c> / <c>ObstacleFaceDeadlyAt</c>
	/// so kill checks never have to AND HasSpikes with !IsSwitchingSide by hand.</summary>
	public bool SideDeadly( Direction dir ) => _side[dir].HasSpikes && !_side[dir].IsSwitching;
	public bool HasAnySpikes() => HasSpikes( Direction.Left ) || HasSpikes( Direction.Right ) || HasSpikes( Direction.Down ) || HasSpikes( Direction.Up );
	/// <summary>Sticky strength currently coating this face: 0 for none, 1 for movable glue, 2 for locking glue.</summary>
	public virtual int StickyPhase( Direction dir ) => !IsDead && _side[dir].StickyTime > 0f ? 2 : 0;

	/// <summary>Temporarily coat one face in phase-2 sticky goo. Reapplication refreshes its lifetime.</summary>
	public void AddStickyToSide( Direction dir, float time )
	{
		var s = _side[dir];
		s.StickyTime = MathF.Max( s.StickyTime, time );
		if ( s.StickyGoo is null )
		{
			s.StickyGoo = SpriteLayer.Add( GameObject, "sprites/blocks/sticky_goo.sprite",
				BLOCK_SIZE, $"goo_{Dir( dir )}", childOrder: 6 );
			s.StickyGoo.Opaque = false;
			s.StickyGoo.AlphaCutoff = 0f;
			s.StickyGoo.Color = new Color( 1f, 1f, 1f, 0f );
		}
		s.StickyGoo.Enabled = true;
	}

	/// <summary>Add deadly spikes to a side for <paramref name="time"/> seconds (called by Spikey).
	/// Plays the grow-in animation; the side is "switching" (harmless, unpressable) until it lands.</summary>
	public void AddSpikesToSide( Direction dir, float time )
	{
		// Enter the spike branch of the side's state machine. Because a side has exactly ONE mode, this
		// unconditionally cancels any in-flight button pop/retract — no separate ticker can finish later
		// and stomp the spike sprite, so the old "deadly side rendering as a button" bug is now
		// unrepresentable rather than merely patched.
		var s = _side[dir];
		// Suspend (not erase) an existing press. A re-graft onto an already spiked side (two Spikeys' delayed
		// sends landing in a row) keeps the press it is already holding.
		s.PressedUnderSpikes = s.Mode == SideMode.Pressed || (s.HasSpikes && s.PressedUnderSpikes);
		s.Mode = SideMode.SpikesAdding;
		s.Timer = SPIKE_ADD_TIME;
		s.SpikeLiveTime = time;   // deadly countdown begins once the grow-in finishes (see TickSides)
		RefreshSideTint( s );
		PlaySideAnim( s.Sprite, SpikeAnim( s, dir ) );
		Audio.PlaySfx( SfxType.SpikesAdd, Position );
	}

	/// <summary>Carry another block's spike grafts over (mimic swap): same modes + timers, so the sim is
	/// unchanged. The presses under them were consumed by the phase-up that triggered the swap.</summary>
	public void CopySpikesFrom( Block other )
	{
		foreach ( var dir in Globals.GetAllDirections() )
		{
			var o = other._side[dir];
			if ( !o.HasSpikes ) continue;
			var s = _side[dir];
			s.Mode = o.Mode;
			s.Timer = o.Timer;
			s.SpikeLiveTime = o.SpikeLiveTime;
			s.PressedUnderSpikes = false;
			RefreshSideTint( s );
			PlaySideAnim( s.Sprite, s.Mode == SideMode.Spiked ? SettledSpikeAnim( s, dir ) : SpikeAnim( s, dir ) );
		}
	}

	/// <summary>Carry another block's transferred sticky goo over (mimic swap): each face keeps its remaining
	/// glue time, so a player stuck to the mimic stays stuck for exactly as long as they would have.</summary>
	public void CopyStickyFrom( Block other )
	{
		foreach ( var dir in Globals.GetAllDirections() )
		{
			float time = other._side[dir].StickyTime;
			if ( time > 0f ) AddStickyToSide( dir, time );
		}
	}

	void RetractSpikes( Direction dir )
	{
		var s = _side[dir];
		s.Mode = SideMode.SpikesRetracting;
		s.Timer = SPIKE_RETRACT_TIME;
		PlaySideAnim( s.Sprite, SpikeAnim( s, dir ) );
		Audio.PlaySfx( SfxType.SpikesRetract, Position );
	}

	/// <summary>Advance every side's state machine one step (called from Tick). Replaces the old
	/// separate button-pop and spike add/retract tickers: one machine per side, so a side can never be
	/// simultaneously mid-button-pop and spiked (which is what let the pop stomp the spike sprite).</summary>
	void TickSides( float dt )
	{
		foreach ( var dir in Globals.GetAllDirections() )
		{
			var s = _side[dir];
			switch ( s.Mode )
			{
				case SideMode.Retracting:
					if ( (s.Timer -= dt) <= 0f )
						StartButtonPop( dir );                  // retract done -> pop the new phase's button out
					break;

				case SideMode.Popping:
					if ( (s.Timer -= dt) <= 0f )
					{
						s.Mode = SideMode.Out;                  // settle on the resting button
						PlaySideAnim( s.Sprite, $"{SideOutState()}_{Dir( dir )}" );
					}
					break;

				case SideMode.SpikesAdding:
					if ( (s.Timer -= dt) <= 0f )
					{
						s.Mode = SideMode.Spiked;               // spikes are now live + deadly
						s.Timer = s.SpikeLiveTime;              // begin the deadly-duration countdown
						PlaySideAnim( s.Sprite, SettledSpikeAnim( s, dir ) );
					}
					break;

				case SideMode.Spiked:
					if ( (s.Timer -= dt) <= 0f )
						RetractSpikes( dir );
					break;

				case SideMode.SpikesRetracting:
					if ( (s.Timer -= dt) <= 0f )
					{
						if ( s.PressedUnderSpikes ) RestoreSuspendedPress( dir ); // spikes gone -> press comes back
						else StartButtonPop( dir );             // spikes gone -> pop a fresh, pressable button out
					}
					break;
			}
		}
	}

	void TickStickySides( float dt )
	{
		_stickyGooPulse += dt * STICKY_GOO_PULSE_SPEED;
		float pulse = 0.5f + 0.5f * MathF.Sin( _stickyGooPulse );
		float activeAlpha = STICKY_GOO_DIM_ALPHA + (STICKY_GOO_BRIGHT_ALPHA - STICKY_GOO_DIM_ALPHA) * pulse;
		float step = Math.Clamp( dt * STICKY_GOO_FADE_SPEED, 0f, 1f );

		foreach ( var dir in Globals.GetAllDirections() )
		{
			var s = _side[dir];
			if ( s.StickyTime > 0f )
				s.StickyTime = MathF.Max( 0f, s.StickyTime - dt );
			if ( s.StickyGoo is null ) continue;

			var color = s.StickyGoo.Color;
			float targetAlpha = s.StickyTime > 0f && !IsDead
				? activeAlpha * MathF.Max( _layersAlpha, STICKY_GOO_INVIS_FACTOR )
				: 0f;
			color.a += (targetAlpha - color.a) * step;
			s.StickyGoo.Color = color;
			if ( s.StickyTime <= 0f && color.a <= 0.01f )
				s.StickyGoo.Enabled = false;
		}
	}

	// ----------------------------------------------------------------------------------------
	public virtual void CreateVisuals()
	{
		_face = SpriteLayer.Add( GameObject, SpritePath, BLOCK_SIZE, "face_0", childOrder: 0 );
		_mouth = SpriteLayer.Add( GameObject, SpritePath, BLOCK_SIZE, "mouth_0", childOrder: 2 );
		_eyes = SpriteLayer.Add( GameObject, SpritePath, BLOCK_SIZE, EyeAnim(), childOrder: 3 );
		_eyebrows = SpriteLayer.Add( GameObject, SpritePath, BLOCK_SIZE, "eyebrows_0", childOrder: 4 );
		RefreshBodyTint();

		// Side buttons use pre-rotated baked sprites per direction (no runtime rotation).
		foreach ( var dir in Globals.GetAllDirections() )
		{
			var s = _side[dir];
			s.Sprite = SpriteLayer.Add( GameObject, "sprites/sides.sprite", BLOCK_SIZE, $"out0_{Dir( dir )}", childOrder: 1 );
			RefreshSideTint( s );
		}

		// Body layers follow SpriteLayer's default opacity; fades flip to translucent via
		// SetLayersAlpha/SetLayersTranslucent and back at full alpha.

		// Speed-line streaks (drawn behind the block).
		for ( int i = 0; i < MAX_SPEED_LINES; i++ )
		{
			var sr = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", new Vector2( 1, 1 ), "idle", childOrder: -1 );
			sr.Color = SPEEDLINE_COLOR;
			sr.Enabled = false;
			_speedLineSprites.Add( sr );
		}
	}

	/// <summary>Force this block to <paramref name="phase"/> and refresh the face/eye/mouth/eyebrow +
	/// resting side buttons to match, WITHOUT running the phase-up transition or scoring. Used when the
	/// stage swaps a mimic out for a real disguise block spawned directly at the phase it just reached,
	/// and when a level authors a block's starting phase / pre-pressed sides
	/// (see <see cref="LevelDef.BlockStarts"/>).</summary>
	/// <param name="phase">Phase to force (clamped to 0..<see cref="NUM_PHASES"/>-1).</param>
	/// <param name="pressedSides">Optional sides to mark pre-pressed for this phase (below max phase):
	/// the buttons render depressed, but scoring / sfx / phase-up do NOT fire — the block just starts
	/// partway through its current phase.</param>
	public void SetInitialPhase( int phase, IReadOnlyList<Direction> pressedSides = null )
	{
		Phase = Math.Clamp( phase, 0, NUM_PHASES - 1 );
		RefreshBodyTint();
		_face.PlayAnimation( $"face_{Phase}" );
		_eyebrows.PlayAnimation( $"eyebrows_{Phase}" );
		_mouth.PlayAnimation( $"mouth_{Phase}" );
		_eyes.PlayAnimation( EyeAnim() );
		foreach ( var dir in Globals.GetAllDirections() )
		{
			var s = _side[dir];
			s.Mode = SideMode.Out;
			s.Timer = 0f;
			RefreshSideTint( s );
			PlaySideAnim( s.Sprite, $"{SideOutState()}_{Dir( dir )}" );
		}

		// Pre-pressed sides: mark the buttons down for this phase without any scoring/sfx/phase-up. Only
		// meaningful below max phase (a maxed block has no pressable buttons). IsSidePressed reads Mode ==
		// Pressed; the separate score-start snapshot lets the tally distinguish these from earned presses.
		if ( pressedSides is not null && Phase < NUM_PHASES - 1 )
		{
			foreach ( var dir in pressedSides )
			{
				if ( dir == Direction.None ) continue;
				ShowPressed( dir );
			}
		}
	}

	/// <summary>Set the travel direction (used when a mimic transform carries the disguise's heading over
	/// so it does not visibly stop dead on the swap).</summary>
	public void SetMoveDirection( Direction dir ) => MoveDirection = dir;

	/// <summary>Turn to face <paramref name="dir"/> mid-travel: updates <see cref="MoveDirection"/> and,
	/// when the eyes are steady-open, replays the matching eye animation (during a close/open the eye
	/// cycle owns them and re-resolves the direction itself). For subtypes whose facing follows a free
	/// velocity (BlockWisp) rather than being fixed per leg.</summary>
	protected void SetFacing( Direction dir )
	{
		if ( dir == MoveDirection || dir == Direction.None ) return;
		MoveDirection = dir;
		if ( _eyeState == EyeState.Open && !IsDead )
			_eyes?.PlayAnimation( EyeAnim() );
	}

	/// <summary>Close the eyes before facing <paramref name="dir"/> without stopping movement or running
	/// recovery hooks. Requests keep updating the gaze while the eyes are closing or fully shut.</summary>
	protected void FaceAfterBlink( Direction dir, float minimumOpenTime, float eyesClosedTime )
	{
		if ( IsDead || dir == Direction.None || SuppressEyeBlink || !CanActivateTimedAbility ) return;
		if ( _facingEyeCycle && _eyeState is EyeState.Closing or EyeState.Waiting )
		{
			_eyeCycleFacingOverride = dir;
			return;
		}
		if ( _eyeState != EyeState.Open || dir == MoveDirection || _eyesOpenTime < minimumOpenTime ) return;

		_facingEyeCycle = true;
		_eyeCycleFacingOverride = dir;
		_facingEyesClosedTime = MathF.Max( 0f, eyesClosedTime );
		StartEyeCycle( enclosedEyeCycle: false );
	}

	/// <summary>Pulse the transformed-mimic tint and emit occasional drifting cloud puffs. The pulse
	/// follows game time; particle rolls use only the cosmetic Rng stream.</summary>
	void TickMimicAura( float dt )
	{
		_mimicTintTime = (_mimicTintTime + dt) % MIMIC_TINT_PERIOD;
		RefreshBodyTint();

		_mimicAuraTimer -= dt;
		if ( _mimicAuraTimer > 0f ) return;
		_mimicAuraTimer = Rng.CosmeticFloat( 0.14f, 0.24f );

		var kind = Rng.CosmeticValue() < 0.5f ? ParticleKind.MimicCloud0 : ParticleKind.MimicCloud1;
		var offset = new Vector2(
			Rng.CosmeticFloat( -BLOCK_SIZE.x * 0.4f, BLOCK_SIZE.x * 0.4f ),
			Rng.CosmeticFloat( -BLOCK_SIZE.y * 0.3f, BLOCK_SIZE.y * 0.5f ) );
		var vel = new Vector2( Rng.CosmeticFloat( -6f, 6f ), Rng.CosmeticFloat( 8f, 20f ) );
		Stage.AddMimicCloud( Pos + offset, vel, kind, Rng.CosmeticFloat( 0.4f, 0.7f ), Rng.CosmeticInt( 2, 4 ) );
	}

	static string Dir( Direction d ) => Globals.GetStringForDirection( d );

	string EyeAnim() => $"eyes_{Globals.GetStringForDirection( MoveDirection )}_{Phase}";

	/// <summary>Restore the face/mouth to the current phase (used by subtypes after attacking).</summary>
	protected void RestoreFaceAndMouth()
	{
		_face.PlayAnimation( $"face_{Phase}" );
		_mouth.PlayAnimation( $"mouth_{Phase}" );
	}

	// ======================================================================================
	public override void Tick( float dt )
	{
		Vector2 stepStart = Pos;
		BecameSolidThisTick = false;
		SlammedThisTick = false; // set again below only if we land a hard slam this tick
		InertiaHandoffThisTick = false; // ...or come to rest in a way that flings an attached player
		_enclosedThisTick = IsBlockedInEveryDirection();
		bool stunned = TickStun( dt );

		// Shake decay (applied to layer offsets each step).
		ApplyShakeOffsets( _shakeVector );
		_shakeVector *= SHAKE_RECOVERY;
		_shakeVector *= -1f;

		// While dead (win sequence) keep the death face: the eye-cycle and palette-blink replay
		// the live phase art each frame and would stomp the dead expression set in Die() (the
		// original only swapped palette SwatchIndex, not the image, so its dead face survived).
		if ( !IsDead )
		{
			// An active subtype animation owns its eyes. If an AMBIENT enclosed blink was already in
			// progress, hold that cycle until the ability finishes rather than stomping its eye art.
			// Ability-initiated cycles are never held: a subtype whose attack fires from OnEyesClosed
			// (Siren) is busy until that very cycle advances, so holding it would deadlock.
			if ( !stunned && !SuppressEyeBlink && (!_ambientEnclosedBlink || CanActivateTimedAbility) )
				TickEyeCycle( dt );
			if ( !stunned ) TickEnclosedEyes( dt );
			BlinkPalette( dt );
		}

		TickSides( dt );
		TickStickySides( dt );
		TickEnclosedAbility( dt );

		// The slow blue pulse and cloud shimmer identify a transformed mimic. Cosmetic only.
		if ( Mimic != null && Phase >= 1 && !IsDead ) TickMimicAura( dt );

		// Death throes (win condition): every block jitters violently and sheds dust during the
		// post-win hold before the score tally. GameStage emits the shared explosion-sfx cadence so
		// adding blocks doesn't make the mix progressively louder.
		if ( IsDead )
		{
			float DEATH_SHAKE_STRENGTH = 70f;
			AddShake( new Vector2( Rng.Float( -1f, 1f ), Rng.Float( -1f, 1f ) ) * DEATH_SHAKE_STRENGTH * dt );
			_moveSpeed *= 0.97f;

			if ( Rng.Value() < 0.10f ) AddDeathDust();
		}

		if ( _enclosedThisTick )
		{
			// Movement is suppressed, so collision consumers must see a stationary solid. Keep
			// _moveSpeed (and subtype-owned intent) intact so the block can resume when freed.
			Velocity = Vector2.Zero;
			DecaySpeedLines( 0.5f );
			RenderSpeedLines();
		}
		else
		{
			TickMovement( dt );
		}
		StepDisplacement = Pos - stepStart - _stepJumpDelta;
		_stepJumpDelta = Vector2.Zero;
	}

	bool TickStun( float dt )
	{
		if ( _stunTimer <= 0f ) return false;

		_stunTimer -= dt;
		if ( _stunTimer <= 0f )
		{
			_stunTimer = 0f;
			if ( _resumeAfterStun ) IsStopped = false;
			return false;
		}

		IsStopped = true;
		Velocity = Vector2.Zero;
		_moveSpeed = 0f;
		AddShake( new Vector2( Rng.CosmeticFloat( -0.7f, 0.7f ), Rng.CosmeticFloat( -0.35f, 0.35f ) ) );

		_stunParticleTimer -= dt;
		if ( _stunParticleTimer <= 0f )
		{
			_stunParticleTimer += STUN_PARTICLE_INTERVAL;
			EmitStunBolt();
		}
		return true;
	}

	void EmitStunBolt()
	{
		const float FACE_INSET = 2f;
		Vector2 origin = new(
			Rng.CosmeticFloat( Left + FACE_INSET, Right - FACE_INSET ),
			Rng.CosmeticFloat( Bottom + FACE_INSET, Top - FACE_INSET ) );
		float angle = Rng.CosmeticFloat( 0f, MathF.PI * 2f );
		Vector2 forward = new( MathF.Cos( angle ), MathF.Sin( angle ) );
		Vector2 side = new( -forward.y, forward.x );
		int segmentCount = Rng.CosmeticInt( 5, 10 );
		int branchAt = Rng.CosmeticValue() < 0.45f ? Rng.CosmeticInt( 1, segmentCount - 1 ) : -1;
		float stepLength = Rng.CosmeticFloat( 1.9f, 2.8f );
		float bend = Rng.CosmeticFloat( 1f, 2.2f );
		float bendSign = Rng.CosmeticValue() < 0.5f ? -1f : 1f;
		float center = (segmentCount - 1) * 0.5f;

		for ( int i = 0; i < segmentCount; i++ )
		{
			float zig = (i % 2 == 0 ? -bend : bend) * bendSign;
			Vector2 pos = origin + forward * ((i - center) * stepLength) + side * zig;
			pos = new Vector2(
				Math.Clamp( pos.x, Left + FACE_INSET, Right - FACE_INSET ),
				Math.Clamp( pos.y, Bottom + FACE_INSET, Top - FACE_INSET ) );
			Color color = Rng.CosmeticValue() < 0.3f ? STUN_SPARK_HIGHLIGHT : STUN_SPARK_COLOR;
			Stage.AddColoredParticle( pos, Vector2.Zero, 1f, color,
				Rng.CosmeticFloat( 0.1f, 0.2f ), 2, Globals.DEPTH_PARTICLE_1 );

			if ( i != branchAt ) continue;
			Vector2 branchDirection = Utils.Normalized( side * (Rng.CosmeticValue() < 0.5f ? -1f : 1f) + forward * 0.35f );
			int branchLength = Rng.CosmeticInt( 2, 5 );
			for ( int branchStep = 1; branchStep <= branchLength; branchStep++ )
			{
				Vector2 branchPos = pos + branchDirection * (stepLength * branchStep);
				branchPos = new Vector2(
					Math.Clamp( branchPos.x, Left + FACE_INSET, Right - FACE_INSET ),
					Math.Clamp( branchPos.y, Bottom + FACE_INSET, Top - FACE_INSET ) );
				Stage.AddColoredParticle( branchPos, Vector2.Zero, 1f, STUN_SPARK_HIGHLIGHT,
					Rng.CosmeticFloat( 0.09f, 0.17f ), 2, Globals.DEPTH_PARTICLE_1 );
			}
		}
	}

	void TickEnclosedEyes( float dt )
	{
		if ( !_enclosedThisTick )
		{
			_enclosedEyeTimer = -1f;
			_enclosedEyeCycle = false;
			_ambientEnclosedBlink = false;
			return;
		}

		// The authored on/off loop owns the eyes: an ambient blink is a surprise lane lull, so the
		// loop's cadence must be the only one. (_enclosedEyeCycle stays untouched — the loop's own
		// cycles set it and TickEyeCycle clears it on completion.)
		if ( EnclosedEyeLoopActive )
		{
			_enclosedEyeTimer = -1f;
			return;
		}

		if ( _eyeState != EyeState.Open || SuppressEyeBlink || !CanActivateTimedAbility ) return;
		if ( _enclosedEyeTimer < 0f )
			_enclosedEyeTimer = Rng.Float( ENCLOSED_EYE_INTERVAL_MIN, ENCLOSED_EYE_INTERVAL_MAX );

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

		CloseEyesAndPickNewDirection();
		_enclosedEyeCycle = true;
		_ambientEnclosedBlink = true;
		_enclosedEyeTimer = -1f;
	}

	void TickEnclosedAbility( float dt )
	{
		if ( IsDead || IsStunned || Phase < 1 || !_enclosedThisTick ||
			(!HasSlamActivatedAbility() && !HasEnclosedEyeLoop) )
		{
			_abilityTimer = -1f;
			return;
		}

		// The authored spawn offset advances only the first interval. Re-entering an enclosure later
		// always arms a complete interval, matching the cadence after an activation.
		if ( _abilityTimer < 0f )
		{
			ResetAbilityTimer( _abilityStartOffsetPending ? _abilityStartOffset : 0f );
			_abilityStartOffsetPending = false;
		}

		// The lane on/off loop: the interval measures LANE-ON time only, so the countdown runs only
		// while the eyes are fully open. Firing just closes the eyes (direction stays pinned — see
		// GetEnclosedGazeDirection); the eye cycle's Waiting leg then holds the authored ClosedTime
		// before re-opening, and the next interval arms on this fire (it can't tick until re-open).
		if ( EnclosedEyeLoopActive )
		{
			// Zero off-time = the loop is OFF: a permanently-on lane (otherwise it would blink
			// pointlessly — close and immediately reopen — every interval).
			if ( _abilityClosedTime <= 0f ) return;
			if ( _eyeState != EyeState.Open ) return;
			_abilityTimer -= dt;
			if ( _abilityTimer > 0f ) return;
			CloseEyesAndPickNewDirection();
			ResetAbilityTimer();
			return;
		}

		// While the subtype's long-running attack is busy (a Laser mid-volley, a Siren mid-song), the
		// countdown PAUSES: the interval only elapses over idle time, so the next attack lands a full
		// interval after the previous one ends. Pausing (rather than letting the timer run negative)
		// also keeps the "< 0" sentinel above meaning exclusively "not armed" — a timer that expired
		// under a busy attack used to trip it and re-arm a fresh interval, making the post-attack gap
		// a random fraction of the interval instead of a dependable beat.
		if ( !CanActivateTimedAbility ) return;

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

		IsColliding( MoveDirection, out Entity2D other );
		float previousSpeed = _moveSpeed;
		bool previousSlammed = SlammedThisTick;
		_timedAbilityActivation = true;
		_moveSpeed = _maxSpeeds[Phase];
		SlammedThisTick = true;
		LastImpactOther = other;
		try
		{
			Impact( other as Block );
		}
		finally
		{
			_timedAbilityActivation = false;
			_moveSpeed = previousSpeed;
			SlammedThisTick = previousSlammed;
		}
		ResetAbilityTimer();
	}

	void ResetAbilityTimer( float elapsed = 0f )
	{
		float jitter = _abilityRandomness > 0f ? Rng.Float( -_abilityRandomness, _abilityRandomness ) : 0f;
		_abilityTimer = MathF.Max( 0.05f, _abilityInterval + jitter - elapsed );
	}

	bool IsBlockedInEveryDirection()
	{
		foreach ( Direction direction in Globals.GetAllDirections() )
			if ( !IsEnclosedByObstacleOrArena( direction ) ) return false;
		return true;
	}

	bool IsEnclosedByObstacleOrArena( Direction direction )
	{
		Vector2 offset = Globals.GetVectorForDirection( direction );
		float x = X + offset.x;
		float y = Y + offset.y;
		if ( !IsInBounds( x, y, direction ) ) return true;
		foreach ( Obstacle obstacle in Stage.GetObstacles() )
		{
			if ( obstacle.IsGlass ) continue;   // glass is solid only to players — it can't box a block in
			if ( PenetratesOnPixelGrid( x, y, obstacle ) ) return true;
		}
		// Fences are solid to blocks, so they enclose exactly like a normal obstacle does.
		foreach ( Obstacle fence in Stage.GetFences() )
			if ( PenetratesOnPixelGrid( x, y, fence ) ) return true;
		return false;
	}

	/// <summary>What a laser block's fresh aim refuses to point into: nothing (a beam stopped by nothing),
	/// arena walls only (a piercing beam), or walls plus the obstacles that stop the beam.</summary>
	protected enum AimAvoid { Nothing, Walls, WallsAndObstacles }

	/// <summary>True when a laser-blocking surface sits flush against this block's <paramref name="direction"/>
	/// face. Arena walls always count; obstacles only when <paramref name="includeObstacles"/> — and glass and
	/// fences never do, since beams pass straight through them.</summary>
	protected bool IsTouchingBeamBlocker( Direction direction, bool includeObstacles )
	{
		Vector2 offset = Globals.GetVectorForDirection( direction );
		float x = X + offset.x;
		float y = Y + offset.y;
		if ( !IsInBounds( x, y, direction ) ) return true;
		if ( !includeObstacles ) return false;
		foreach ( Entity2D obstacle in Stage.GetSolidObstacles() )
			if ( PenetratesOnPixelGrid( x, y, obstacle ) ) return true;
		return false;
	}

	/// <summary>Which of this block's faces a beam from <paramref name="origin"/> leaves through (the nearer
	/// slab crossing of the block's own rect).</summary>
	Direction BeamExitFace( Vector2 origin, Vector2 direction )
	{
		RectF rect = GetRect( X, Y );
		float tx = direction.x > 0f ? (rect.Right - origin.x) / direction.x
			: direction.x < 0f ? (rect.Left - origin.x) / direction.x : float.MaxValue;
		float ty = direction.y > 0f ? (rect.Top - origin.y) / direction.y
			: direction.y < 0f ? (rect.Bottom - origin.y) / direction.y : float.MaxValue;
		if ( tx <= ty ) return direction.x > 0f ? Direction.Right : Direction.Left;
		return direction.y > 0f ? Direction.Up : Direction.Down;
	}

	/// <summary>True when a beam fired from <paramref name="origin"/> along <paramref name="direction"/> exits
	/// through a face that's flush against a blocker — it dies the instant it's born (a squid wedged in a
	/// corner firing into that corner).</summary>
	protected bool AimsIntoTouchingSurface( Vector2 origin, Vector2 direction, AimAvoid avoid )
		=> avoid != AimAvoid.Nothing
			&& direction.LengthSquared > 0.0001f
			&& IsTouchingBeamBlocker( BeamExitFace( origin, direction ), avoid == AimAvoid.WallsAndObstacles );

	/// <summary>Slide an aim off a surface the block is resting against: mirror it across that face (same
	/// angle, other side), else run it along the face, else any still-open cardinal. Returns the aim
	/// unchanged when it already points somewhere the beam can travel.</summary>
	protected Vector2 DeflectAimOffSurfaces( Vector2 origin, Vector2 direction, AimAvoid avoid )
	{
		if ( !AimsIntoTouchingSurface( origin, direction, avoid ) ) return direction;

		bool horizontal = BeamExitFace( origin, direction ) is Direction.Left or Direction.Right;
		Vector2 mirrored = horizontal
			? new Vector2( -direction.x, direction.y )
			: new Vector2( direction.x, -direction.y );
		if ( !AimsIntoTouchingSurface( origin, mirrored, avoid ) ) return mirrored;

		Vector2 alongFace = horizontal
			? new Vector2( 0f, direction.y >= 0f ? 1f : -1f )
			: new Vector2( direction.x >= 0f ? 1f : -1f, 0f );
		if ( !AimsIntoTouchingSurface( origin, alongFace, avoid ) ) return alongFace;

		foreach ( Direction cardinal in Globals.GetAllDirections() )
			if ( !IsTouchingBeamBlocker( cardinal, avoid == AimAvoid.WallsAndObstacles ) )
				return Globals.GetVectorForDirection( cardinal );
		return direction;   // boxed in on every side: nothing to aim at, fire as asked
	}

	// Wind/Magnet are NOT here: their enclosed cadence is the lane on/off eye loop (HasEnclosedEyeLoop),
	// not an interval-fired Impact.
	bool HasSlamActivatedAbility() => BlockType is
		BlockType.Dragon or BlockType.Squid or BlockType.SquidPierce or BlockType.Smile or BlockType.Spikey or
		BlockType.Shockwave or BlockType.Slicer or BlockType.Teleport or BlockType.Summoner or
		BlockType.Siren or BlockType.Laser or BlockType.Bullet or BlockType.DragonBounce or
		BlockType.LaserPierce;

	/// <summary>The movement half of <see cref="Tick"/>: integrate along <see cref="MoveDirection"/>,
	/// resolve collisions, fire <see cref="Impact"/>, and drive the speed lines. Virtual so a subtype
	/// with a fundamentally different mover (BlockWisp's free 2D float) can replace it while keeping
	/// all the shared upkeep (eye cycle, sides, palette blink, shake) that Tick runs first.</summary>
	protected virtual void TickMovement( float dt )
	{
		if ( IsStopped || IsDead )
		{
			DecaySpeedLines( 0.5f );
			RenderSpeedLines();
			return;
		}

		Velocity = Globals.GetVectorForDirection( MoveDirection ) * _moveSpeed;
		Pos += GetMovementDelta( dt );

		bool collidingForward = IsColliding( X, Y, MoveDirection, out Entity2D other );

		// A corner graze — only a pixel of overlap on the cross axis — reads as "shouldn't have
		// hit at all", so slide past it instead of impacting (when safely possible).
		if ( collidingForward && other is Block grazedBlock && TryCornerSlip( grazedBlock ) )
			collidingForward = IsColliding( X, Y, MoveDirection, out other );

		if ( collidingForward )
		{
			// Push back out of a solid block OR interior obstacle (a bare arena wall leaves other=null
			// and is handled by ClampToBounds below). Then Impact(other as Block) — an obstacle hit is
			// null-block, i.e. treated exactly like a wall hit (stop + pick a new direction).
			if ( other is Block || other is Obstacle )
				Unpenetrate( X, Y, other );

			_currentCollidingTime += dt;
			if ( _moveSpeed > REQUIRED_COLLISION_SPEED || _currentCollidingTime >= REQUIRED_COLLISION_TIME )
			{
				LastImpactOther = other;   // Block, Obstacle, statue Player, or null (bare arena wall) — see Spikey
				PressFaceSlammedIntoStatue( other );
				Impact( other as Block );
			}
			else
				foreach ( var sl in _speedLines ) sl.length *= Rng.Float( 0.5f, 0.75f );
		}
		else if ( IsColliding( MoveDirection, out Entity2D pressedOther ) )
		{
			// Still pressing: after Unpenetrate we rest flush and re-advance by sub-pixel amounts,
			// so the pixel-grid collision only registers every few ticks. Without this probe the
			// in-between ticks would reset the contact timer (killing the slow-push
			// REQUIRED_COLLISION_TIME impact entirely) and accelerate, turning every gentle lean
			// into an eventual hard impact. Grid-based like the collision itself, so a sub-pixel
			// corner graze still doesn't count as contact. The timer must fire the impact from
			// here too: a block pressing at speed 0 (boxed in) never advances onto a colliding
			// tick, and would otherwise hold forever instead of re-picking a direction.
			_currentCollidingTime += dt;
			if ( _currentCollidingTime >= REQUIRED_COLLISION_TIME )
			{
				LastImpactOther = pressedOther;   // Block, Obstacle, statue Player, or null (bare arena wall) — see Spikey
				PressFaceSlammedIntoStatue( pressedOther );
				Impact( pressedOther as Block );
			}
			else
				foreach ( var sl in _speedLines ) sl.length *= Rng.Float( 0.5f, 0.75f );
		}
		else
		{
			if ( !IsDead )
			{
				_moveSpeed += _accelerations[Phase] * dt;
				_moveSpeed = Math.Clamp( _moveSpeed, 0f, _maxSpeeds[Phase] );
			}
			_currentCollidingTime = 0f;
			foreach ( var sl in _speedLines ) sl.length += _moveSpeed * _moveSpeed * sl.acceleration * dt;
		}

		ClampToBounds( X, Y );
		// Obstacles aren't part of ClampToBounds' arena-edge snap: push out of any we've drifted into
		// (e.g. shoved by another block's Unpenetrate). Forward-motion hits are already resolved above.
		foreach ( Entity2D ob in Stage.GetBlockSolidObstacles() )
			Unpenetrate( X, Y, ob );
		RenderSpeedLines();
	}

	/// <summary>Slamming into the hardened Twin statue reads as the statue pressing back: award this
	/// block's impacting face (the one facing travel), credited to the statue player. Fires from both
	/// Impact triggers — the hard hit and the slow-lean timeout — since either ends with this face
	/// planted against immovable stone. PressSide's own refusals (already pressed, spiked, switching,
	/// max phase) still apply. Protected: BlockWisp's replacement mover slams via OnAxisHit (after
	/// SetFacing has pointed MoveDirection at the impact) and must award the same press.</summary>
	protected void PressFaceSlammedIntoStatue( Entity2D other )
	{
		if ( other is Player { IsDead: false, IsHardened: true } statue )
			PressSide( MoveDirection, statue );
	}

	/// <summary>Returns this tick's translation. Specialized movers can cap a step without replacing
	/// the shared collision, acceleration, speed-line, and displacement bookkeeping.</summary>
	protected virtual Vector2 GetMovementDelta( float dt ) => Velocity * dt;

	// ----------------------------------------------------------------------------------------
	/// <summary>DEBUG (see <see cref="ShowDebugInfo"/>): draw this block's position, velocity and
	/// intended move direction as world-space text just above the block. Called by GameManager once
	/// per rendered frame after transform sync. Logical pixels map 1:1 to world XY (same convention
	/// as Player.DrawCrushProbeOverlay), so positions can be used directly.</summary>
	public void DrawDebugInfo()
	{
		float z = Globals.DepthToZ( Globals.DEPTH_TEXT );
		var transform = new Transform( new Vector3( X, Y + 2f, z ) );

		string info =
			$"pos ({X:0}, {Y:0})\n" +
			$"vel ({VelX:0}, {VelY:0})\n" +
			$"dir {MoveDirection}";

		Gizmo.Draw.Color = Color.White;
		Gizmo.Draw.Text( info, transform, "Consolas", 12 );
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Shrink every speed line by <paramref name="factor"/> (the resting/stopped decay).</summary>
	protected void DecaySpeedLines( float factor )
	{
		foreach ( var sl in _speedLines ) sl.length *= factor;
	}

	/// <summary>Grow the speed lines for one step of travel at <paramref name="speed"/> — the same
	/// speed²-scaled growth the default mover applies, exposed for subtypes that move themselves.</summary>
	protected void GrowSpeedLines( float speed, float dt )
	{
		foreach ( var sl in _speedLines ) sl.length += speed * speed * sl.acceleration * dt;
	}

	protected void RenderSpeedLines()
	{
		const float GAP = 2f;
		for ( int i = 0; i < _speedLineSprites.Count; i++ )
		{
			var sr = _speedLineSprites[i];
			if ( i >= _speedLines.Count ) { sr.Enabled = false; continue; }

			var sl = _speedLines[i];
			// Clamp to whole grid pixels: the block's rendered corners land on integer world
			// coords (Entity2D.SyncTransform), so a whole-pixel length keeps both ends of the
			// streak on cell boundaries, and the half-pixel perpendicular shift makes the 1px
			// body fill exactly one 240-grid row/column instead of straddling two (which the
			// CRT downsample turns into flickering/ghost double lines).
			float len = MathF.Floor( sl.length );
			if ( len < 1f ) { sr.Enabled = false; continue; }
			sr.Enabled = true;

			// offset is measured across the trailing edge; replicate the original layout.
			Vector3 local;
			Vector2 size;
			float perpH = Height / 2f - sl.offset + 0.5f; // for horizontal movement
			float perpV = -Width / 2f + sl.offset + 0.5f; // for vertical movement
			switch ( MoveDirection )
			{
				case Direction.Left: // trails to the right (+X)
					size = new Vector2( len, 1 );
					local = new Vector3( Width / 2f + GAP + len / 2f, perpH, 0 );
					break;
				case Direction.Right: // trails to the left (-X)
					size = new Vector2( len, 1 );
					local = new Vector3( -(Width / 2f + GAP + len / 2f), perpH, 0 );
					break;
				case Direction.Down: // trails up (+Y)
					size = new Vector2( 1, len );
					local = new Vector3( perpV, Height / 2f + GAP + len / 2f, 0 );
					break;
				default: // Up: trails down (-Y)
					size = new Vector2( 1, len );
					local = new Vector3( perpV, -(Height / 2f + GAP + len / 2f), 0 );
					break;
			}
			sr.Size = size;
			sr.GameObject.LocalPosition = local + new Vector3( 0, 0, -SpriteLayer.LAYER_Z_STEP );
		}
	}

	// ----------------------------------------------------------------------------------------
	void BlinkPalette( float dt )
	{
		if ( Phase != 1 && Phase != 2 ) return;

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

		_paletteAlt = !_paletteAlt;
		// Only the face blinks (mouth/eyebrows/eyes have no alt art). A subtype may swap in its
		// own face anim during an attack (e.g. the Dragon family's shooting face).
		string faceAnim = FaceBlinkAnimOverride ?? $"face_{Phase}";
		_face.PlayAnimation( _paletteAlt ? $"{faceAnim}_alt" : faceAnim );

		_paletteBlinkTimer = _paletteAlt
			? (Phase == 1 ? PALETTE_BLINK_TIME_PHASE_1 : PALETTE_BLINK_TIME_PHASE_2)
			: PALETTE_BLINK_ON_TIME;
	}

	/// <summary>How long the block sits with eyes shut after picking a new direction, before it re-opens
	/// and resumes moving — i.e. its post-slam recovery pause. Subtypes override to recover faster/slower
	/// (e.g. BlockHunter shortens this at phase 2 so it gets moving again sooner). Draws from the
	/// authoritative Rng, so overrides must too, to keep the sim deterministic for replays.</summary>
	protected virtual float EyesStayClosedTime() => Rng.Float( EYES_STAY_CLOSED_TIME_MIN, EYES_STAY_CLOSED_TIME_MAX );

	// ----------------------------------------------------------------------------------------
	void TickEyeCycle( float dt )
	{
		switch ( _eyeState )
		{
			case EyeState.Open:
				_eyesOpenTime += dt;
				break;
			case EyeState.Closing:
				_eyeTimer -= dt;
				if ( _eyeTimer <= 0f )
				{
					PickDirectionAfterClosing();
					_eyeState = EyeState.Waiting;
					// Enclosed lane-loop cycles hold the authored lane-off time; everything else uses
					// the normal recovery pause. (Skipping EyesStayClosedTime here skips its Rng draw —
					// deterministic, since the loop condition is itself deterministic.)
					_eyeTimer = _facingEyeCycle ? _facingEyesClosedTime
						: _enclosedEyeCycle && EnclosedEyeLoopActive ? _abilityClosedTime
						: EyesStayClosedTime();
					if ( !_facingEyeCycle )
						OnEyesClosed(); // recovery eyes shut, new direction chosen, about to wait then re-open
				}
				break;
			case EyeState.Waiting:
				_eyeTimer -= dt;
				if ( _eyeTimer <= 0f )
				{
					if ( _facingEyeCycle && _eyeCycleFacingOverride is Direction facingOverride )
						MoveDirection = facingOverride;
					else if ( _enclosedEyeCycle && _enclosedThisTick )
						MoveDirection = GetEnclosedGazeDirection();
					_eyes?.PlayAnimation( $"eyes_{Dir( MoveDirection )}_open_{Phase}" );
					_eyeState = EyeState.Opening;
					_eyeTimer = EYES_OPEN_TIME;
				}
				break;
			case EyeState.Opening:
				_eyeTimer -= dt;
				if ( _eyeTimer <= 0f )
				{
					_eyes?.PlayAnimation( EyeAnim() );
					_eyeState = EyeState.Open;
					_enclosedEyeCycle = false;
					_ambientEnclosedBlink = false;
					_eyesOpenTime = 0f;
					_eyeCycleFacingOverride = null;
					if ( _facingEyeCycle )
					{
						_facingEyeCycle = false;
					}
					else
					{
						IsStopped = false;
						_moveSpeed = 0f;
						InitSpeedLines();
					}
				}
				break;
		}
	}

	void PickDirectionAfterClosing()
	{
		if ( _facingEyeCycle )
		{
			if ( _eyeCycleFacingOverride is Direction facingOverride )
				MoveDirection = facingOverride;
			return;
		}

		if ( !_enclosedEyeCycle || !_enclosedThisTick )
		{
			Direction previousDirection = MoveDirection;
			// Cycle modes own the whole pick and draw no Rng — deterministic, since the mode is
			// authored per spawn slot (same reasoning as the enclosed-loop draw skip in TickEyeCycle).
			MoveDirection = TurnMode is TurnMode.Clockwise or TurnMode.CounterClockwise
				? PickCycleDirection( previousDirection )
				: EnforceAlwaysTurn( previousDirection, GetNewDirection() );
		}
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Default impact: play the effects, then close eyes and pick a new direction.
	/// Subtypes override to insert an attack (and may defer the eye-cycle).</summary>
	public virtual void Impact( Block otherBlock )
	{
		ImpactEffects( otherBlock );
		CloseEyesAndPickNewDirection();
	}

	/// <summary>Stops this block without producing a slam, inertia handoff, or impact ability.</summary>
	public void Stun( float duration )
	{
		if ( IsDead || duration <= 0f ) return;
		if ( !IsStunned )
		{
			_resumeAfterStun = !IsStopped;
			_stunParticleTimer = 0f;
			Audio.PlaySfx( SfxType.LaserHitPlayer, Position, 0.8f, 1.2f );
		}

		_stunTimer = MathF.Max( _stunTimer, duration );
		IsStopped = true;
		Velocity = Vector2.Zero;
		_moveSpeed = 0f;
		_currentCollidingTime = 0f;
		Pos = new Vector2( MathF.Round( Pos.x ), MathF.Round( Pos.y ) );
	}

	/// <summary>Come to rest WITHOUT a slam — no screenshake, dust or impact sfx, and
	/// <see cref="SlammedThisTick"/> stays false (so the sticky release and the slam-triggered block
	/// attacks don't fire) — then run the normal eye-close → re-pick → open cycle. A subtype
	/// (BlockHunter) uses this to brake mid-lane and re-aim at the player, instead of only ever
	/// re-picking after a hard wall/block slam. Banks the travel velocity in
	/// <see cref="PreImpactVelocity"/> and zeroes the live one exactly like <see cref="ImpactEffects"/>,
	/// and flags <see cref="InertiaHandoffThisTick"/>: this is a full-speed brake, not a quiet grind
	/// stop, so momentum-inheritance (a riding player) and crush checks behave the same as a slam.</summary>
	protected void SoftStopAndRepick()
	{
		if ( IsStopped || IsDead ) return;

		IsStopped = true;
		InertiaHandoffThisTick = true;
		PreImpactVelocity = Velocity;
		Velocity = Vector2.Zero;
		// Snap to the pixel grid on rest (clears cross-axis sub-pixel drift) — same reasoning as
		// ImpactEffects; visually a no-op since rendering already rounds the position.
		Pos = new Vector2( MathF.Round( Pos.x ), MathF.Round( Pos.y ) );
		_currentCollidingTime = 0f;

		CloseEyesAndPickNewDirection();
	}

	/// <summary>Start the eye-close → re-pick → open → resume cycle (block stays stopped until it finishes).</summary>
	protected void CloseEyesAndPickNewDirection()
	{
		if ( IsDead ) return;
		if ( _facingEyeCycle )
		{
			UpgradeFacingBlinkToRecovery();
			return;
		}
		_eyeCycleFacingOverride = null;
		StartEyeCycle( _enclosedThisTick );
	}

	void UpgradeFacingBlinkToRecovery()
	{
		_facingEyeCycle = false;
		_eyeCycleFacingOverride = null;
		_enclosedEyeCycle = _enclosedThisTick;

		if ( _eyeState == EyeState.Closing )
			return; // finish the close already in progress; recovery owns the shut/open tail

		if ( _eyeState == EyeState.Waiting )
		{
			PickDirectionAfterClosing();
			_eyeTimer = EyesStayClosedTime();
			OnEyesClosed();
			return;
		}

		StartEyeCycle( _enclosedThisTick );
	}

	void StartEyeCycle( bool enclosedEyeCycle )
	{
		// Timed abilities commonly enter the normal post-impact eye cycle. Preserve enclosed gaze
		// selection for those closes too; otherwise they reopen using GetNewDirection's random fallback.
		// Whatever cycle was in flight is stomped here, so it is no longer the ambient blink — the
		// caller that IS the ambient blink (TickEnclosedEyes) re-flags itself right after this returns.
		_enclosedEyeCycle = enclosedEyeCycle;
		_ambientEnclosedBlink = false;
		_eyes?.PlayAnimation( $"eyes_{Dir( MoveDirection )}_close_{Phase}" );
		_eyeState = EyeState.Closing;
		_eyeTimer = EyesCloseTime;
		_currentCollidingTime = 0f;
	}

	protected void ImpactEffects( Block otherBlock )
	{
		IsStopped = true;
		if ( _timedAbilityActivation )
		{
			Velocity = Vector2.Zero;
			Pos = new Vector2( MathF.Round( Pos.x ), MathF.Round( Pos.y ) );
			_currentCollidingTime = 0f;
			return;
		}

		// Come to rest for real: bank the travel velocity for the momentum-inheritance mechanics
		// (sticky slam release, inertia flings) and zero the live one. Tick early-outs on IsStopped
		// without recomputing Velocity, so without this a stopped block would keep advertising its
		// pre-impact velocity to every "is this block moving toward me?" query (phantom crushes).
		PreImpactVelocity = Velocity;
		Velocity = Vector2.Zero;

		// Snap to the integer pixel grid the moment we come to rest. Blocks move with float
		// velocity, so a block accumulates sub-pixel drift along its travel axis; once it stops
		// and later turns 90°, that stale drift sits on the CROSS axis and makes its AABB overlap
		// a perpendicular neighbour by a fraction of a pixel — so it halts at a corner even though
		// (rounded for rendering) it looks like it should slide cleanly past with 0 gap. Rendering
		// already rounds the position (Entity2D.SyncTransform), so snapping here is visually a
		// no-op while keeping the resting field perfectly grid-aligned.
		Pos = new Vector2( MathF.Round( Pos.x ), MathF.Round( Pos.y ) );

		// A slow contact still stops and re-picks after its grind window, but only a real slam gets
		// impact feedback. Keeping every shake alongside the dust/audio prevents a quiet stop from
		// producing a delayed visual jolt that reads like a second collision.
		if ( _moveSpeed > REQUIRED_COLLISION_SPEED && _currentCollidingTime < REQUIRED_COLLISION_TIME )
		{
			SlammedThisTick = true; // a real slam (loud impact) — the sticky release keys off this
			InertiaHandoffThisTick = true; // a slam always hands off momentum to attached players

			Vector2 impactVector = new Vector2(
				(MoveDirection == Direction.Left || MoveDirection == Direction.Right) ? 1f : 0f,
				(MoveDirection == Direction.Down || MoveDirection == Direction.Up) ? 1f : 0f );
			AddShake( impactVector * _moveSpeed * SHAKE_STRENGTH );

			if ( MoveDirection == Direction.Left || MoveDirection == Direction.Right )
				Stage.AddHorizontalScreenshake( _moveSpeed );
			else
				Stage.AddVerticalScreenshake( _moveSpeed );

			float amt = Utils.Map( _moveSpeed, 0f, _maxSpeeds[Phase], 0f, 1f, true, EasingType.Linear );
			int numParticles = (int)MathF.Floor( amt * 12f );
			float velAdd = amt * 160f;
			Vector2 pos = Position + Globals.GetVectorForDirection( MoveDirection ) * (Width / 2);
			for ( int i = 0; i < numParticles; i++ )
			{
				Vector2 ppos = pos;
				if ( MoveDirection == Direction.Left || MoveDirection == Direction.Right )
					ppos += new Vector2( 0, Rng.Int( 0, 2 ) == 0 ? Rng.Float( -Height / 2, -Height / 6 ) : Rng.Float( Height / 6, Height / 2 ) );
				else
					ppos += new Vector2( Rng.Int( 0, 2 ) == 0 ? Rng.Float( -Width / 2, -Width / 6 ) : Rng.Float( Width / 6, Width / 2 ), 0 );
				Vector2 vel = new Vector2( impactVector.y * Rng.Float( -1f, 1f ), impactVector.x * Rng.Float( -1f, 1f ) ) * velAdd;
				Stage.AddParticle( ppos, vel, Rng.Float( 0.90f, 0.97f ), Globals.GRAVITY_STR_DUST, ParticleKind.Dust, Rng.Float( 0.35f, 0.75f ), Rng.Int( 3, 7 ) );
			}

			float vol = Utils.Map( _moveSpeed, 0f, 80f, 0f, 1f, true, EasingType.SineEaseIn );
			Audio.PlaySfx( SfxType.BlockImpact, Position, vol );

			// Block slam: pan the rumble by the block's horizontal offset from the player, and make it
			// strongest when the block is closest (a far slam is a faint thud, one right next to you hits
			// hard). Heavy/low-frequency to sell the mass of the block against the wall.
			var player = Stage?.Player;
			if ( player != null )
			{
				float pan = Math.Clamp( (X - player.X) / (Arena.WIDTH * 0.5f), -1f, 1f );
				float prox = Utils.Map( (Position - player.Position).Length, 0f, Arena.WIDTH, 1f, 0.2f, true, EasingType.CubicEaseOut );
				Haptics.Pulse( vol * prox, 0.13f, pan, Haptics.TONE_HEAVY );
			}
		}
	}

	public GameStage Stage { get; set; }

	// ----------------------------------------------------------------------------------------
	bool IsColliding( float x, float y, Direction direction, out Entity2D other )
	{
		other = null;
		return IsCollidingWithBlock( x, y, out other )
			|| IsCollidingWithObstacle( x, y, out other )
			|| !IsInBounds( x, y, direction );
	}

	protected bool IsColliding( Direction dir ) => IsColliding( dir, out _ );

	/// <summary>Probe one pixel ahead in <paramref name="dir"/> (blocks on the pixel grid, walls raw).</summary>
	bool IsColliding( Direction dir, out Entity2D other )
	{
		Vector2 offset = Globals.GetVectorForDirection( dir );
		return IsColliding( X + offset.x, Y + offset.y, dir, out other );
	}

	protected bool IsCollidingWithBlock( float x, float y, out Entity2D other )
	{
		other = null;
		foreach ( Block block in Stage.GetBlocks() )
		{
			if ( block != this && PenetratesOnPixelGrid( x, y, block ) )
			{
				other = block;
				return true;
			}
		}
		return false;
	}

	/// <summary>A static interior obstacle stops a block exactly like an arena wall (same pixel-grid
	/// test as block-vs-block). Reported as the colliding entity so <see cref="Tick"/> pushes the block
	/// back out of it (obstacles aren't covered by the arena-bounds clamp). Uses the BLOCK-solid view,
	/// which includes fences — obstacles solid only to blocks.</summary>
	protected bool IsCollidingWithObstacle( float x, float y, out Entity2D other )
	{
		other = null;
		foreach ( Entity2D ob in Stage.GetBlockSolidObstacles() )
		{
			if ( PenetratesOnPixelGrid( x, y, ob ) )
			{
				other = ob;
				return true;
			}
		}
		return false;
	}

	/// <summary>Block-vs-block overlap tested on the integer pixel grid the sprites render on
	/// (same rounding as Entity2D.SyncTransform), requiring at least one real pixel of overlap
	/// (flush contact doesn't collide). Blocks carry sub-pixel float drift on their cross axis,
	/// so raw-float AABBs can overlap by a fraction of a pixel while the rendered sprites are
	/// exactly flush — stopping a block on a collision that isn't visible on screen. Testing on
	/// the rendered grid makes "collides" mean "the sprite pixels overlap": sub-pixel grazes
	/// slide past with no position adjustment (so no physics side-effects for a riding player).</summary>
	bool PenetratesOnPixelGrid( float x, float y, Entity2D other )
	{
		RectF a = GetPixelRect( x, y );
		RectF b = other.GetPixelRect( other.X, other.Y );
		return a.Right > b.Left && a.Left < b.Right && a.Top > b.Bottom && a.Bottom < b.Top;
	}

	/// <summary>Max rendered-pixel overlap on the cross axis that counts as a "graze" a block
	/// slides past rather than impacting.</summary>
	const float CORNER_SLIP_PIXELS = 1f;

	/// <summary>If the forward collision with <paramref name="other"/> is only a corner graze —
	/// at most <see cref="CORNER_SLIP_PIXELS"/> of overlap on the axis perpendicular to travel —
	/// resolve it by snapping one of the two blocks flush on that axis so they slide past instead
	/// of stopping. Prefers moving this (the detecting) block; if a wall or another block pins it
	/// (e.g. grazing along the arena floor), the OTHER block is nudged instead — the graze is
	/// mutual, so it shouldn't matter which block's tick happened to notice it first. A slipped
	/// position must be verifiably free (no other block, no wall) or that candidate is rejected;
	/// if neither block can move, the impact proceeds as normal — so a slip can never create a
	/// new overlap that would itself need resolving (no cascades).</summary>
	bool TryCornerSlip( Block other )
	{
		RectF a = GetRect( MathF.Round( X ), MathF.Round( Y ) );
		RectF b = other.GetRect( MathF.Round( other.X ), MathF.Round( other.Y ) );

		bool horizontal = MoveDirection == Direction.Left || MoveDirection == Direction.Right;
		float crossOverlap = horizontal
			? MathF.Min( a.Top, b.Top ) - MathF.Max( a.Bottom, b.Bottom )
			: MathF.Min( a.Right, b.Right ) - MathF.Max( a.Left, b.Left );

		if ( crossOverlap <= 0f || crossOverlap > CORNER_SLIP_PIXELS )
			return false;

		return TrySlipBlock( this, other, horizontal ) || TrySlipBlock( other, this, horizontal );
	}

	/// <summary>Try to move <paramref name="mover"/> flush against <paramref name="anchor"/> on
	/// the graze's cross axis (<paramref name="horizontal"/> = the detecting block's travel axis).
	/// Snaps to the pixel grid, which also clears any sub-pixel drift on that axis (same idea as
	/// the resting snap in ImpactEffects). Fails without moving anything if the slipped position
	/// touches a wall or another block.</summary>
	static bool TrySlipBlock( Block mover, Block anchor, bool horizontal )
	{
		RectF b = anchor.GetRect( MathF.Round( anchor.X ), MathF.Round( anchor.Y ) );

		// Slip away from the anchor's centre.
		Direction slipDir;
		float newX = mover.X, newY = mover.Y;
		if ( horizontal )
		{
			bool slipUp = mover.Y > anchor.Y;
			slipDir = slipUp ? Direction.Up : Direction.Down;
			newY = slipUp ? b.Top + mover.Height / 2 : b.Bottom - mover.Height / 2;
		}
		else
		{
			bool slipRight = mover.X > anchor.X;
			slipDir = slipRight ? Direction.Right : Direction.Left;
			newX = slipRight ? b.Right + mover.Width / 2 : b.Left - mover.Width / 2;
		}

		if ( !mover.IsInBounds( newX, newY, slipDir ) || mover.IsCollidingWithBlock( newX, newY, out _ )
			|| mover.IsCollidingWithObstacle( newX, newY, out _ ) )
			return false;

		mover.Pos = new Vector2( newX, newY );
		return true;
	}

	// ----------------------------------------------------------------------------------------
	protected virtual Direction GetNewDirection()
	{
		int tries = 0;
		Direction nextDirection = MoveDirection;
		while ( tries < 10 )
		{
			tries++;
			nextDirection = GetPreferredNextDirection( MoveDirection );
			if ( nextDirection == Globals.GetOppositeDirection( MoveDirection ) && tries < 5 )
				continue;
			if ( nextDirection != MoveDirection && !IsColliding( nextDirection ) )
				return nextDirection;
		}
		return nextDirection;
	}

	protected bool IsNewDirectionAllowed( Direction previousDirection, Direction proposedDirection )
	{
		if ( TurnMode != TurnMode.Always || proposedDirection != Globals.GetOppositeDirection( previousDirection ) )
			return true;

		bool wasHorizontal = previousDirection is Direction.Left or Direction.Right;
		Direction firstTurn = wasHorizontal ? Direction.Up : Direction.Left;
		Direction secondTurn = wasHorizontal ? Direction.Down : Direction.Right;
		return IsColliding( firstTurn ) && IsColliding( secondTurn );
	}

	protected Direction EnforceAlwaysTurn( Direction previousDirection, Direction proposedDirection )
	{
		// A proposal the block is already flush against (GetNewDirection's bounded random search can
		// fall through with one) would be a ZERO-TRAVEL leg: it grinds against the contact for the
		// slow-push window, then re-picks with previousDirection replaced by the doomed heading — which
		// legalises the reversal the flag forbids on the very next pick (slam down → "left" into the
		// wall it's touching → forced up, a two-step backtrack). Treat it like a proposed reversal so
		// the turn rule substitutes a real perpendicular; a fully boxed block still falls through to
		// the proposal below, and a true dead end still reverses via IsNewDirectionAllowed's escape.
		bool doomedProposal = TurnMode == TurnMode.Always && IsColliding( proposedDirection );
		if ( !doomedProposal && IsNewDirectionAllowed( previousDirection, proposedDirection ) )
			return proposedDirection;

		bool wasHorizontal = previousDirection is Direction.Left or Direction.Right;
		Direction firstTurn = wasHorizontal ? Direction.Up : Direction.Left;
		Direction secondTurn = wasHorizontal ? Direction.Down : Direction.Right;
		bool firstOpen = !IsColliding( firstTurn );
		bool secondOpen = !IsColliding( secondTurn );

		if ( firstOpen && secondOpen )
			return Rng.Int( 0, 2 ) == 0 ? firstTurn : secondTurn;
		if ( firstOpen ) return firstTurn;
		if ( secondOpen ) return secondTurn;
		return proposedDirection;
	}

	/// <summary>The whole direction pick for the CW/CCW cycle modes (see <see cref="TurnMode"/>): keep
	/// the heading while it's open, take the single cycle turn where GEOMETRY (arena wall / obstacle /
	/// fence) blocks it, and otherwise hold the heading and wait — resuming into the blocked contact
	/// just presses for the grind window and re-picks, so a queued block sets off (or takes its corner)
	/// as soon as the block ahead clears. A blocking BLOCK is traffic, not track, and always means
	/// hold: turning around one would let a cycler that turned into a lane someone else then claimed
	/// rotate a second time — back along the leg it arrived on, driving against the flow (and a head-on
	/// cycler pair can never unjam). Only walls mark the turn, so a cycler can never back up.</summary>
	Direction PickCycleDirection( Direction current )
	{
		if ( !IsColliding( current, out Entity2D blocker ) ) return current;
		if ( blocker is Block ) return current;
		Direction turn = TurnMode == TurnMode.Clockwise
			? Globals.GetClockwiseDirection( current )
			: Globals.GetCounterClockwiseDirection( current );
		return IsColliding( turn ) ? current : turn;
	}

	/// <summary>Where an enclosed block's eyes (and travel direction) re-aim on each enclosed blink /
	/// interval cycle: toward the player. Virtual so a subtype whose ability is direction-coupled
	/// (LaneBlock's lane) can pin it instead of tracking the player.</summary>
	protected virtual Direction GetEnclosedGazeDirection()
	{
		Player target = Stage?.ClosestTargetablePlayer( Position );
		if ( target is null ) return Globals.GetRandomDirection();

		Vector2 delta = target.Position - Position;
		if ( delta.LengthSquared < 0.0001f ) return Globals.GetRandomDirection();
		if ( MathF.Abs( delta.x ) >= MathF.Abs( delta.y ) )
			return delta.x < 0f ? Direction.Left : Direction.Right;
		return delta.y < 0f ? Direction.Down : Direction.Up;
	}

	protected virtual Direction GetPreferredNextDirection( Direction currentDir ) => Globals.GetRandomDirection();

	/// <summary>Hook: eyes have finished closing and a new direction is picked (block still
	/// paused, about to wait then re-open). Subtypes use this to time effects to the pause.</summary>
	protected virtual void OnEyesClosed() { }

	// ----------------------------------------------------------------------------------------
	// Two blocks pressed/phased within a tick or two of each other (a corner graze awarding both
	// sides, twins/swarm bodies landing together) fire the identical one-shot twice, which reads as
	// one LOUD crack rather than two events. These gates keep the first and drop the pile-up.
	// Gated here rather than in Audio's per-SfxType coalesce map because the events are shared:
	// the press sounds with the level-select menu, glitter with Harden/Mimic, the max-phase chime
	// with the score tally — none of those should gate against blocks.
	protected const float SFX_COALESCE_WINDOW = 0.1f;
	static float _lastPressSfxTime, _lastPhaseSfxTime;

	/// <summary>True if enough time has passed to play a coalesced cue again (and stamps it).</summary>
	protected static bool TrySfxGate( ref float lastTime )
	{
		if ( RealTime.Now - lastTime < SFX_COALESCE_WINDOW ) return false;
		lastTime = RealTime.Now;
		return true;
	}

	public bool PressSide( Direction dir, Player presser = null )
	{
		if ( IsSwitchingSide( dir ) ) return false;
		if ( Phase >= NUM_PHASES - 1 ) return false;

		bool pressed = false;
		if ( !IsSidePressed( dir ) && !HasSpikes( dir ) )
		{
			ShowPressed( dir );

			float SHAKE_AMOUNT = 1.0f;
			if ( dir == Direction.Left ) AddShake( new Vector2( SHAKE_AMOUNT, 0 ) );
			else if ( dir == Direction.Right ) AddShake( new Vector2( -SHAKE_AMOUNT, 0 ) );
			else if ( dir == Direction.Down ) AddShake( new Vector2( 0, SHAKE_AMOUNT ) );
			else if ( dir == Direction.Up ) AddShake( new Vector2( 0, -SHAKE_AMOUNT ) );

			AddSideGlitterParticles( dir, Phase );

			// Button + glitter are one cue, so they share a single gate and can never separate.
			if ( TrySfxGate( ref _lastPressSfxTime ) )
			{
				Audio.PlaySfx( Phase == 0 ? SfxType.BlockSidePressed0 : SfxType.BlockSidePressed1, Position );
				Audio.PlaySfx( Phase == 0 ? SfxType.Glitter0 : SfxType.Glitter1, Position, 0.75f );
			}
			// Light crisp tick for the scoring press, panned to the block's side of the player.
			var feedbackPlayer = presser ?? Stage?.Player;
			if ( feedbackPlayer != null )
			{
				float pan = Math.Clamp( (X - feedbackPlayer.X) / (Arena.WIDTH * 0.5f), -1f, 1f );
				Haptics.Pulse( 0.18f, 0.05f, pan, Haptics.TONE_CRISP, EasingType.ExpoEaseOut );
			}
			pressed = true;

			// Desync diagnostics: every awarded press is traced (incl. WHO pressed) so a replay can be
			// diffed against its live run press-by-press (see GameManager.TracePressAward — the
			// observed desyncs are a single vanished press with otherwise identical kinematics).
			Stage?.Manager?.TracePressAward( this, dir, presser );
		}

		// A press suspended under a spike graft still counts: the phase completes immediately rather than
		// waiting for the spikes to retract (NextPhase leaves the spiked side alone and consumes its press).
		if ( IsSidePressedOrSuspended( Direction.Left ) && IsSidePressedOrSuspended( Direction.Right ) &&
			 IsSidePressedOrSuspended( Direction.Down ) && IsSidePressedOrSuspended( Direction.Up ) )
		{
			NextPhase();
		}

		return pressed;
	}

	// ----------------------------------------------------------------------------------------
	protected virtual void NextPhase()
	{
		if ( Phase >= NUM_PHASES - 1 ) return;

		int fromPhase = Phase;

		// Increment first so the pop-out animation uses the NEW phase's button (at max phase the
		// sides become yellow "max" and can't be pressed).
		Phase = Math.Min( Phase + 1, NUM_PHASES - 1 );
		RefreshBodyTint();

		// Move each side out of Pressed immediately (so this NextPhase can't re-fire every frame the
		// player stays against the block): StartButtonUnpress switches it to Retracting, then the new
		// phase's button pops back out (see TickSides).
		foreach ( var dir in Globals.GetAllDirections() )
		{
			if ( HasSpikes( dir ) )
			{
				// Spikes stay put; the press under them is consumed. A settled side re-skins to the new phase's
				// colour now; a mid-grow/retract side finishes its anim as-is (the engine restarts a swapped
				// anim at frame 0, so re-skinning it would visibly replay) and lands in the right colour.
				var s = _side[dir];
				s.PressedUnderSpikes = false;
				if ( s.Mode == SideMode.Spiked ) PlaySideAnim( s.Sprite, SettledSpikeAnim( s, dir ) );
				continue;
			}
			StartButtonUnpress( dir, fromPhase );
		}

		_face.PlayAnimation( $"face_{Phase}" );
		_eyebrows.PlayAnimation( $"eyebrows_{Phase}" );
		_mouth.PlayAnimation( $"mouth_{Phase}" );
		_eyes.PlayAnimation( EyeAnim() );
		_paletteAlt = false;
		_paletteBlinkTimer = 0f;

		if ( TrySfxGate( ref _lastPhaseSfxTime ) )
			Audio.PlaySfx( Phase == NUM_PHASES - 1 ? SfxType.BlockPhaseReachMax : SfxType.BlockPhaseReach1, Position, 0.85f );
		Stage.BlockReachedPhase( this );

		// Nudge the music pitch up a touch each phase-up, so the song rises in tension across the run.
		Audio.AdvanceMusicProgression();

		if ( Phase >= NUM_PHASES - 1 )
			Stage.BlockReachedMaxPhase( this );
	}

	string SideOutState()
	{
		if ( Phase >= NUM_PHASES - 1 ) return "max";
		return Phase == 0 ? "out0" : "out1";
	}

	void RefreshSideTint( SideState side )
	{
		Color tint = Phase == 1 && side.Mode is SideMode.Out or SideMode.Popping
			? PHASE_1_OUT_TINT
			: Color.White;
		float alpha = side.HasSpikes ? MathF.Max( _layersAlpha, SPIKE_MIN_ALPHA ) : _layersAlpha;
		side.Sprite.Color = tint.WithAlpha( alpha );
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Phase-up transition for one side: retract the pressed button in, then pop the
	/// new phase's button out. The side counts as "switching" (unpressable) until it lands.</summary>
	void StartButtonUnpress( Direction dir, int fromPhase )
	{
		var s = _side[dir];
		s.Mode = SideMode.Retracting;
		s.Timer = BTN_RETRACT_TIME;
		RefreshSideTint( s );
		PlaySideAnim( s.Sprite, $"pressed{fromPhase}retract_{Dir( dir )}" );
	}

	/// <summary>Pop the current phase's button out from flat (no retract first; used after spikes
	/// retract). Holds the side "switching" until the pop completes.</summary>
	void StartButtonPop( Direction dir )
	{
		var s = _side[dir];
		s.Mode = SideMode.Popping;
		s.PressedUnderSpikes = false;
		bool max = Phase >= NUM_PHASES - 1;
		s.Timer = max ? BTN_POP_MAX_TIME : BTN_POP_OUT_TIME;
		RefreshSideTint( s );
		PlaySideAnim( s.Sprite, max ? $"maxadd_{Dir( dir )}" : $"out{Phase}add_{Dir( dir )}" );
	}

	/// <summary>Mark a side Pressed and show the pressed button for the current phase (no scoring/sfx).
	/// Shared by PressSide, authored starts and spike-retract restores; <paramref name="popIn"/> animates
	/// the button up from flat (pressed{N}add) instead of snapping to its resting frame.</summary>
	void ShowPressed( Direction dir, bool popIn = false )
	{
		var s = _side[dir];
		s.Mode = SideMode.Pressed;
		s.PressedUnderSpikes = false;
		RefreshSideTint( s );
		PlaySideAnim( s.Sprite, $"pressed{Phase}{(popIn ? "add" : "")}_{Dir( dir )}" );
	}

	/// <summary>Spikes retracted off a side that was pressed when grafted: the press comes back, popping
	/// up from the flat bar the retract ends on. No scoring / sfx — the press was already awarded.</summary>
	void RestoreSuspendedPress( Direction dir ) => ShowPressed( dir, popIn: true );

	// ----------------------------------------------------------------------------------------
	void AddSideGlitterParticles( Direction dir, int phase )
	{
		// Cosmetic + PLAYER-TRIGGERED (fired from PressSide when the player pushes a side). Must use
		// the cosmetic RNG stream: drawing from the authoritative stream here would let this purely
		// visual flourish advance the sim's RNG and shift every OTHER block's subsequent movement
		// decisions. Keeping incidental visuals off the authoritative stream keeps its draw sequence a
		// function of gameplay alone (so a run reproduces exactly on input-replay, and a seed's initial
		// layout is identical for every player — e.g. daily challenges).
		Vector2 pos = Position + Globals.GetVectorForDirection( dir ) * (Width / 2);
		for ( int i = (int)(-Width / 2); i < (int)(Width / 2); i += 2 )
		{
			if ( Rng.CosmeticFloat( 0f, 1f ) < 0.25f ) continue;

			Vector2 ppos = pos;
			if ( dir == Direction.Left || dir == Direction.Right ) ppos += new Vector2( 0, i );
			else ppos += new Vector2( i, 0 );

			Vector2 vel = new Vector2( Rng.CosmeticFloat( -1f, 1f ), Rng.CosmeticFloat( -1f, 1f ) ) * Rng.CosmeticFloat( 20f, 40f );
			Stage.AddParticle( ppos, vel, Rng.CosmeticFloat( 0.90f, 0.97f ), Globals.GRAVITY_STR_GLITTER,
				phase == 0 ? ParticleKind.Glitter0 : ParticleKind.Glitter1, Rng.CosmeticFloat( 0.35f, 0.75f ), Rng.CosmeticInt( 3, 7 ) );
		}
	}

	// ----------------------------------------------------------------------------------------
	public void AddShake( Vector2 amount ) => _shakeVector += amount;

	/// <summary>Fade the body layers' alpha (Smile invisibility). Speed lines are left alone
	/// so an invisible Smile still trails visible speed-lines as a tell. Layers stay enabled
	/// (we only change alpha), so blinks/animations keep running underneath.</summary>
	protected void SetLayersAlpha( float a )
	{
		// Opaque layers dither partial alpha, so any fade (or held sub-1 state like the smile
		// block's invisibility spikes) needs the translucent path; back to opaque only at full
		// alpha so the block rejoins the depth-writing batch (see CreateVisuals).
		bool wasFull = _layersAlpha >= 1f;
		_layersAlpha = a;
		if ( wasFull != a >= 1f )
			SetLayersTranslucent( a < 1f );
		RefreshBodyTint();
		foreach ( var dir in Globals.GetAllDirections() )
			RefreshSideTint( _side[dir] );
	}

	void RefreshBodyTint()
	{
		// Reapply through fades too, so Smile and Teleport disguises retain their mimic identity.
		Color tint = Color.White;
		if ( Mimic is not null && BlockType != BlockType.Mimic && Phase >= 1 )
		{
			// Cosine eases into both ends, with no abrupt flash or pause at the loop boundary.
			float pulse = 0.5f - 0.5f * MathF.Cos( _mimicTintTime * (2f * MathF.PI / MIMIC_TINT_PERIOD) );
			tint = Color.Lerp( MIMIC_DISGUISE_TINT, MIMIC_DISGUISE_PEAK_TINT, pulse );
		}
		var color = tint.WithAlpha( _layersAlpha );
		_face.Color = color;
		_mouth.Color = color;
		_eyes.Color = color;
		_eyebrows.Color = color;
	}

	/// <summary>Toggle the body + side layers between opaque (SpriteLayer's default) and
	/// smooth-translucent (Opaque off + AlphaCutoff 0, like the lane overlays / Portal rings) so an
	/// alpha fade blends cleanly instead of dithering out at the 0.5 cutoff.
	/// <see cref="SetLayersAlpha"/> toggles this automatically at the full-alpha boundary; the
	/// Teleport block also drives it directly around its fade-in.</summary>
	protected void SetLayersTranslucent( bool translucent )
	{
		void Apply( SpriteRenderer sr )
		{
			if ( sr is null ) return;
			// Restore to the game-wide default, NOT a literal true: while OPAQUE_DEFAULT is off no
			// sprite may join the engine's opaque batch (its bounds decide batch draw order — see
			// SpriteLayer.Add).
			sr.Opaque = !translucent && SpriteLayer.OPAQUE_DEFAULT;
			sr.AlphaCutoff = translucent ? 0f : 0.5f;
		}
		Apply( _face );
		Apply( _mouth );
		Apply( _eyes );
		Apply( _eyebrows );
		foreach ( var dir in Globals.GetAllDirections() )
			Apply( _side[dir].Sprite );
	}

	void ApplyShakeOffsets( Vector2 offset )
	{
		var o = new Vector3( MathF.Round( offset.x ), MathF.Round( offset.y ), 0 );
		SetLayerOffset( _face, o, 0 );
		SetLayerOffset( _mouth, o, 2 );
		SetLayerOffset( _eyes, o, 3 );
		SetLayerOffset( _eyebrows, o, 4 );
		foreach ( var dir in Globals.GetAllDirections() )
			SetLayerOffset( _side[dir].Sprite, o, 1 );
	}

	static void SetLayerOffset( SpriteRenderer sr, Vector3 offset, int childOrder )
	{
		if ( sr is null ) return;
		sr.GameObject.LocalPosition = new Vector3( offset.x, offset.y, childOrder * SpriteLayer.LAYER_Z_STEP );
	}

	// ----------------------------------------------------------------------------------------
	/// <summary>Destroy any STAGE-PARENTED visuals this block pools (lane overlays, trail rects, ring /
	/// line segments — anything from <see cref="GameStage.CreateOverlaySprite"/>). Sprites parented to the
	/// block's own GameObject die with it, but stage-level overlay quads do NOT: destroying the block
	/// leaves them enabled, frozen at their last rendered frame, for the rest of the run. The stage calls
	/// this before destroying a block instance mid-run (the mimic transform swap).</summary>
	public virtual void DestroyStageVisuals() { }

	/// <summary>Destroy a pool of stage-parented overlay sprites and empty the list — the shared body of
	/// the <see cref="DestroyStageVisuals"/> overrides.</summary>
	protected static void DestroyOverlayPool( List<SpriteRenderer> pool )
	{
		foreach ( var sr in pool )
			GameStage.DestroyOverlaySprite( sr );
		pool.Clear();
	}

	// ----------------------------------------------------------------------------------------
	public virtual void Die()
	{
		if ( IsDead ) return;
		_eyes.PlayAnimation( "eyes_dead" );
		_mouth.PlayAnimation( "mouth_dead" );
		_eyebrows.PlayAnimation( "eyebrows_dead" );
		IsDead = true;
		// Dead blocks early-out in Tick without recomputing Velocity; a block killed mid-flight
		// would otherwise advertise its last travel velocity through the whole win sequence.
		Velocity = Vector2.Zero;
	}

	/// <summary>Spawn a small burst of dust puffs that drift outward from the block centre — the
	/// per-frame death-throes particle effect (ported from the original's AddDeathDust).</summary>
	void AddDeathDust()
	{
		int numParticles = Rng.Int( 1, 3 );
		for ( int i = 0; i < numParticles; i++ )
		{
			Vector2 particlePos = Position + new Vector2( Rng.Float( -Width / 3, Width / 3 ), Rng.Float( -Height / 3, Height / 3 ) );
			Vector2 vel = (particlePos - Position) * Rng.Float( 12f, 16f );
			vel += new Vector2( Rng.Float( -1f, 1f ), Rng.Float( -1f, 1f ) ) * Rng.Float( 1f, 3f );

			Stage.AddParticle(
				particlePos,
				vel,
				Rng.Float( 0.80f, 0.84f ),
				Globals.GRAVITY_STR_DUST,
				ParticleKind.Dust,
				Rng.Float( 0.45f, 1.2f ),
				Rng.Int( 15, 25 ) );
		}
	}

	public virtual void PlayerHasDied() { }

	/// <summary>Flash spiked sides between normal and alt colour (deadly-warning blink).</summary>
	public virtual void BlinkSpikes( bool blinkOn )
	{
		if ( _spikeBlinkOn == blinkOn ) return;
		_spikeBlinkOn = blinkOn;
		foreach ( var dir in Globals.GetAllDirections() )
		{
			if ( HasSpikes( dir ) && !IsSwitchingSide( dir ) )
				PlaySideAnim( _side[dir].Sprite, SettledSpikeAnim( _side[dir], dir ) );
		}
	}
}