Game/GameManager.cs
namespace BlockParty;
/// <summary>
/// Root of the game. Owns the orthographic 2D camera, the deterministic fixed-step
/// simulation loop, and the active <see cref="StageBase"/> (title / game / score ...).
///
/// The whole game is tuned for a fixed 60Hz step with a precise per-entity update order,
/// so we accumulate real time and run discrete <see cref="Arena.STEP"/> ticks here rather
/// than relying on sbox's OnUpdate/OnFixedUpdate cadence.
/// </summary>
/// <summary>Who submitted a leaderboard run, carried into a replay so the HUD can show the
/// submitter's avatar, name, country and submission date.</summary>
public readonly record struct ReplaySubmitter( long SteamId, string Name, string CountryCode, System.DateTimeOffset SubmittedAt );
/// <summary>Remembered GIF-export overlay settings (the span heads), kept while a single replay is
/// being watched so reopening the overlay restores them; forgotten when the replay ends or a
/// different one starts.</summary>
public readonly record struct GifExportConfig( int StartTick, int EndTick );
public sealed class GameManager : Component
{
public static GameManager Instance { get; private set; }
[Property] public int Seed { get; set; } = 1;
/// <summary>Letterbox / pillarbox colour shown OUTSIDE the square play area. Kept much darker
/// than both the playfield and the (blue-grey) arena walls so the 240x240 game always reads as a
/// clean fixed square with a crisp boundary. The edge-blocker quads (StageBase) are colour-matched
/// to this so anything poking past the arena edge is masked invisibly.</summary>
[Property] public Color ClearColor { get; set; } = new Color( 0f, 32f / 255f, 40f / 255f );
public CameraComponent Camera { get; private set; }
public StageBase Stage { get; private set; }
// Editor level-browser MAP scroll, scoped to THIS play session (the GameManager is recreated each
// time the game is launched, but persists across stage changes — game / leaderboards / editor — within
// a session). So the map view centres + bottoms on the FIRST view of a session and restores this
// in-session scroll on later views. Lives here (not a C# static, which survives across editor Play
// sessions) so a new Play session starts centred again. Scroll stored in 1080-reference px.
public bool LevelBrowserMapShown { get; set; }
public Vector2 LevelBrowserScroll { get; set; }
private StageBase _pendingStage;
private float _accumulator;
public GameplayTimeScale GameplayTime { get; } = new();
// --- stage-transition wipe ----------------------------------------------------------
// Port of the original BaseStage FadeOut/FadeIn: a centred square (TransitionOverlay) grows to
// cover the view when LEAVING a visible stage, and shrinks to reveal it when ENTERING one.
// Each half is opt-in per stage (like the original's independent coroutines), driven by
// StageBase.CoveredBackground: "covered" stages (score tally / leaderboard) paint their own dark
// fade-colour backdrop, so the square appears to PERSIST across them — it grows over the game on
// game-over, the tally/leaderboard live on the same dark fill, then it shrinks away to the menu.
// - leaving a NON-covered stage -> grow the square (cover) so its content is hidden for the swap
// - entering a NON-covered stage -> shrink the square (reveal) to uncover it
// - covered<->covered -> instant swap, no animation (both share the same dark fill)
private enum TransitionPhase { None, Covering, Revealing }
private TransitionPhase _transPhase = TransitionPhase.None;
private float _transTimer;
private float _cover; // current square coverage, 0 (hidden) .. 1 (full)
private StageBase _transNext;
private bool _revealAfter; // reveal the next stage after covering, or leave it covered?
private bool _hideOverlayOnSwap; // drop the overlay the frame the next (self-covered) stage applies
private TransitionOverlay _transition;
private GifExportOverlay _gifOverlay;
private UnlockRevealOverlay _unlockOverlay;
/// <summary>The shared "YOU UNLOCKED" reveal, drawn over whatever stage is up. Owned here (not by
/// a stage) because a reveal outlives the screen that started it — see <see cref="UnlockReveal"/>
/// and <see cref="TickUnlockReveal"/>.</summary>
public UnlockReveal UnlockReveal { get; } = new();
/// <summary>GIF-export overlay settings remembered across overlay open/close within the current replay
/// session (null = use defaults). Cleared when the replay ends or a different run is launched.</summary>
public GifExportConfig? SavedGifConfig { get; set; }
// Duration of EACH half of the wipe (cover, then reveal). Kept shorter than the original for
// snappier menu/stage changes while preserving the same easing and covered-stage rules.
private const float TRANSITION_HALF = 0.32f;
/// <summary>True while a stage-transition wipe is in progress; stages lock out input during it.</summary>
public bool IsTransitioning => _transPhase != TransitionPhase.None;
/// <summary>The FULL condition under which <see cref="TransitionToStage"/> refuses: a wipe is
/// running, or a completed cover is holding the screen for the one frame until its self-covered
/// stage applies (<see cref="_hideOverlayOnSwap"/> — IsTransitioning is already false there).
/// Any method that mutates state and then transitions must gate on THIS, not IsTransitioning
/// alone, or that one-frame window lands the side effects while the transition is silently
/// swallowed (a counted daily attempt with no run, replay state with no replay stage).</summary>
public bool StageSwapPending => IsTransitioning || _hideOverlayOnSwap;
// Cap the number of catch-up steps per frame to avoid a spiral of death after a hitch.
private const int MAX_STEPS_PER_FRAME = 5;
// --- hold-to-restart (live runs only) -------------------------------------------------
// During an ACTIVE live run a bare R tap would be an expensive mis-press, so R must be HELD for
// the configured delay to restart (zero is instant); after game-over R stays instant. Replays and editor tests
// keep their own instant restart paths. The sim keeps running during the hold — the HUD just
// dims the arena and fills a progress bar off RestartHoldAlpha/RestartHoldProgress.
// Maximum fade time; shortened to 30% of the configured hold for quick restarts.
private const float RESTART_HOLD_FADE_SECONDS = 0.15f;
private bool _restartHoldArmed; // a fresh R press started this hold (a key still held across a restart can't chain another)
private float _restartHoldTime; // seconds R has been held this attempt
private float _restartHoldAlpha; // overlay fade level 0..1
private float _restartHoldProgress; // displayed bar fill 0..1 (frozen during the release fade-out)
/// <summary>Fade level (0 = hidden) of the hold-to-restart overlay; GameHud draws off this.</summary>
public float RestartHoldAlpha => _restartHoldAlpha;
/// <summary>Fill (0..1) of the hold-to-restart progress bar. Freezes at its last value while the
/// overlay fades out after a release, so the bar doesn't snap empty mid-fade.</summary>
public float RestartHoldProgress => _restartHoldProgress;
// Replay transport. Playback rate is driven by a slider (0..1) mapped logarithmically so the
// centre is exactly 1x (left = 0.1x, right = 8x). Holding Left/Right lerps the slider toward an
// end; releasing lerps it back to centre. The fast end needs a higher per-frame step budget than
// live play so a sped-up replay isn't throttled (and so dropped) by the live cap at low frame
// rates: max steps/frame ≈ REPLAY_MAX_SPEED × (Time.Delta / STEP).
private const int MAX_REPLAY_STEPS_PER_FRAME = 32;
private const float REPLAY_MIN_SPEED = 0.1f; // slider hard-left
private const float REPLAY_MAX_SPEED = 8f; // slider hard-right
private const float REPLAY_SPEED_LERP = 14f; // key-driven slider lerp rate (per second)
private const float REPLAY_TRIGGER_DEADZONE = 0.01f;
private const string REPLAY_SLOW_ACTION = "ReplaySlow";
private const string REPLAY_FAST_ACTION = "ReplayFast";
// While paused, a held direction must persist past this before playback resumes — a shorter tap of
// Right single-steps one tick instead.
private const float REPLAY_PAUSE_HOLD_RESUME = 0.2f;
// After a timeline scrub, resume playback if it was running when the scrub began (vs. staying
// paused on the scrubbed frame). Flip to false to make scrubbing always pause-and-hold.
private const bool REPLAY_SCRUB_AUTO_RESUME = true;
// SFX volume multiplier for the per-step sounds a forward scrub fires, so they sit under live play.
private const float REPLAY_SCRUB_SFX_VOLUME = 0.5f;
private static readonly bool ShowDebugScreenText = false;
// Reused to query the engine's modal/overlay state (the s&box escape menu).
private readonly Game.Overlay _overlay = new();
/// <summary>True while the sbox escape menu (pause overlay) is showing; freezes the sim.</summary>
public bool IsPaused => _overlay.IsPauseMenuOpen;
// Ability shortcut buttons belong only to an active, player-controlled run. Menus still sample
// ordinary directions for navigation, and watching a replay never samples live ability input.
private bool CanUseAbilityShortcuts => Stage is GameStage gameStage
&& !gameStage.IsGameOver && !gameStage.BlocksSimulation && !gameStage.ReplayImportOpen
&& !IsPaused && !StageSwapPending && !UnlockReveal.IsActive && !IsGifPreviewActive
&& (!gameStage.IsReplay || IsHijacked) && (!IsReplaying || (IsHijacked && !_replayFinished));
// Tracks the previous frame's pause state so we only react on transitions (e.g. ducking music).
private bool _wasPaused;
// --- determinism / replay -----------------------------------------------------------
// Each fresh run gets its own random seed so the layout varies; it's recorded with the run's
// input (RunRecorder) so the exact run can be replayed from the leaderboard. A replay reuses the
// recorded seed and feeds the recorded input back into the sim instead of sampling live input.
private static readonly System.Random _seedSource = new();
private RunContext _pendingRunContext = RunContext.Normal;
public RunContext CurrentRunContext { get; private set; } = RunContext.Normal;
private RunData _replayData;
private RunPlayback _replay;
/// <summary>True while a recorded run is being played back (sim is driven by recorded input).</summary>
public bool IsReplaying { get; private set; }
// True once the replayed run has reached its end (death/win or recorded frames exhausted). The
// playback freezes on the final frame instead of leaving — the viewer chooses to restart or go
// back. Reset when a fresh replay starts or is restarted.
private bool _replayFinished;
/// <summary>True while a finished replay is frozen on its last frame (awaiting restart/back).</summary>
public bool ReplayFinished => _replayFinished;
// Set true if the finished replay's recomputed score didn't match the recorded one (a determinism
// drift). Surfaced in the HUD so non-editor players see it too, not just the console warning.
private bool _replayDesynced;
/// <summary>True if the just-finished replay diverged from the original run (score mismatch).</summary>
public bool ReplayDesynced => _replayDesynced;
// Submitter metadata for the run being replayed (from the leaderboard entry), shown in the HUD.
private ReplaySubmitter _replaySubmitter;
/// <summary>Who submitted the run currently being replayed (avatar/name/country/date for the HUD).</summary>
public ReplaySubmitter ReplaySubmitterInfo => _replaySubmitter;
/// <summary>Global counts for this replay visit, retained through restarts and seeking.</summary>
public ReplayViewStats CurrentReplayViews { get; private set; }
public ReplayReactions CurrentReplayReactions { get; private set; }
public bool IsPlacingReaction { get; private set; }
private bool _reactionWasPaused;
public string ReactionEffect { get; private set; } = "wow";
public bool CanReact => IsReplaying && !_hijacked && !_replayFinished && !StageSwapPending
&& !IsTransitioning && ReplayFrame < ReplayLength
&& !_gifPreviewActive && !IsScrubbingTimeline && CurrentReplayReactions is not null
&& Stage is GameStage { BlocksSimulation: false, ReplayImportOpen: false };
public void BeginReaction()
{
if ( !CanReact || IsPlacingReaction ) return;
_reactionWasPaused = _replayPaused;
_replayPaused = true;
_accumulator = 0f;
IsPlacingReaction = true;
}
public void SelectReaction( string effect )
{
if ( ReplayReactionEffects.Find( effect ) is not null ) ReactionEffect = effect;
}
public void CancelReaction()
{
if ( !IsPlacingReaction ) return;
IsPlacingReaction = false;
_replayPaused = _reactionWasPaused;
_accumulator = 0f;
}
public bool ReactionCursorPosition( out Vector2 position )
{
position = default;
if ( !Camera.IsValid() ) return false;
var ray = Camera.ScreenPixelToRay( Mouse.Position );
position = new Vector2( MathF.Round( ray.Position.x ), MathF.Round( ray.Position.y ) );
return position.x >= Arena.WALL_SIZE && position.x <= Arena.WIDTH - Arena.WALL_SIZE
&& position.y >= Arena.WALL_SIZE && position.y <= Arena.HEIGHT - Arena.WALL_SIZE;
}
public void PlaceReaction()
{
if ( !IsPlacingReaction || !CanReact || !ReactionCursorPosition( out var position ) ) return;
if ( !CurrentReplayReactions.Submit( new ReplayReaction
{ Frame = ReplayFrame, X = position.x, Y = position.y, Effect = ReactionEffect } ) ) return;
IsPlacingReaction = false;
_replayPaused = false;
_accumulator = 0f;
}
/// <summary>True when the current replay payload already exists in the local replay archive.</summary>
public bool CurrentReplayIsLocal => IsReplaying && LocalReplays.Contains( _replayData, _replaySubmitter.SteamId );
/// <summary>True when the current replay is archived locally and hearted.</summary>
public bool CurrentReplayIsFavorite => IsReplaying && (LocalReplays.Find( _replayData, _replaySubmitter.SteamId )?.IsFavorite ?? false);
/// <summary>Stable identity for the run currently being replayed, or null when not replaying.
/// Level + seed + submitter + the recorded input stream (step count and hash): the stream is what
/// makes this describe the RUN rather than the board — a daily's level id AND seed are shared by
/// every run of that day (the seed is date-derived by design), so without it all of one player's
/// exports of a day's daily collapsed to a single key. Two different runs always differ in their
/// recorded inputs, and an identical stream IS the same run; the SteamId separates identical runs
/// by different players. Used to tell "another replay" from "the same replay again" when counting
/// GIF exports.</summary>
public string CurrentReplayKey => IsReplaying && _replayData is not null
? $"{_replayData.LevelId}|{_replayData.Seed}|{_replaySubmitter.SteamId}|{_replayData.StepCount}|{CurrentReplayRunHash}"
: null;
/// <summary>Eight hex digits identifying the run being replayed (hash of its recorded inputs), or null
/// when not replaying. Short enough for a filename; see <see cref="CurrentReplayKey"/>.</summary>
public string CurrentReplayRunHash => IsReplaying && _replayData is not null
? InputStreamHash( _replayData.InputDeltas ).ToString( "x8" )
: null;
// FNV-1a over the encoded input stream, for the replay-identity key above.
private static uint InputStreamHash( string inputDeltas )
{
uint h = 2166136261u;
foreach ( char c in inputDeltas ?? "" )
h = (h ^ c) * 16777619u;
return h;
}
// Tracks where the replay was launched from so EndReplay returns to the right screen: the MY REPLAYS
// archive when set, otherwise the leaderboard.
private bool _replayFromLocalReplays;
private string _replayReturnDailyId;
private bool _replayReturnToLevelSelect;
private bool _replayLeaderboardBackToLevelSelect;
// Workshop level id whose board launched the replay, so EndReplay reopens that board (which in
// turn Backs to the workshop screen). Null for every non-workshop launch.
private string _replayReturnWorkshopLevelId;
// Row that was selected on the launching list (MY REPLAYS / leaderboard), so EndReplay can reopen
// that screen with the same row focused. -1 = none / not applicable.
private int _replayReturnSelection = -1;
// The score tally this replay chain hangs off (its footer replay button, or a board opened from it),
// rebuilt already-counted-out on the way back. Null unless a tally started the chain.
private ScoreReturn _replayReturnScore;
// True when the replay was launched STRAIGHT from the tally, so EndReplay goes back to it rather
// than to the leaderboard that would otherwise carry _replayReturnScore.
private bool _replayReturnToScore;
// Slider position in [0,1] (0.5 = 1x). Speed is derived from this via SliderToSpeed.
private float _replaySpeedT = 0.5f;
// True while the slider is being lerped by held keys (so releasing returns it to centre, and a
// mouse drag — which clears this — leaves the slider where the user dropped it).
private bool _replayKeyActive;
private bool _replayPaused;
// How long a direction key has been continuously held while paused (drives hold-to-resume), and
// one-shot flags set when a direction is tapped while paused to step a single tick.
private float _replayPausedHoldTime;
private bool _replayStepOnce;
private bool _replayStepBackOnce;
// Whether playback was running when the current timeline scrub began, so EndReplayScrub can resume
// it on release (see REPLAY_SCRUB_AUTO_RESUME).
private bool _scrubResumePlaying;
// --- hijack (take control mid-replay) -----------------------------------------------
// Pressing HIJACK during a replay hands control to the live player from the current frame: live
// input drives the (still IsReplay) GameStage instead of the recorded stream, so nothing is
// recorded, no score/tally happens, and a win/death just freezes here. _hijackFrame remembers the
// recorded-frame index it began at so Space (on the frozen end screen) or Restart can replay to
// that exact point and resume control there; A/D forget it (-1).
private bool _hijacked;
private int _hijackFrame = -1;
/// <summary>True while live player input is driving the replayed run (HIJACK active).</summary>
public bool IsHijacked => _hijacked;
/// <summary>True while a hijack restore point is armed (so Space re-enters control at that frame).</summary>
public bool HijackArmed => _hijackFrame >= 0;
/// <summary>True when the HIJACK button should be offered (watching a live/paused replay).</summary>
public bool CanHijack => IsReplaying && !_replayFinished && !_hijacked;
/// <summary>Current replay playback rate (1 = real time, below 1 is slowed, >1 is sped up).</summary>
public float ReplaySpeed => SliderToSpeed( _replaySpeedT );
/// <summary>Replay speed slider position in [0,1] (0.5 = 1x); bound to the HUD slider.</summary>
public float ReplaySpeedT => _replaySpeedT;
/// <summary>True while replay playback is paused (no steps advance).</summary>
public bool ReplayPaused => _replayPaused;
/// <summary>Replay completion in [0,1] (recorded steps played / total). 0 when not replaying.</summary>
public float ReplayProgress => _replay is null || _replay.Length == 0 ? 0f : (float)_replay.Position / _replay.Length;
/// <summary>Current replayed frame index (recorded steps played so far). 0 when not replaying.</summary>
public int ReplayFrame => _replay?.Position ?? 0;
/// <summary>Total recorded-step count of the current replay (the seekable timeline length). 0 when
/// not replaying.</summary>
public int ReplayLength => _replay?.Length ?? 0;
/// <summary>Elapsed playback time of the current replay, in seconds.</summary>
public float ReplayTimeSeconds => _replay is null ? 0f : _replay.Position / (float)Arena.TICK_RATE;
/// <summary>Total length of the current replay, in seconds.</summary>
public float ReplayTotalSeconds => _replay is null ? 0f : _replay.Length / (float)Arena.TICK_RATE;
/// <summary>Share/import string for the replay currently being watched. Carries the run's
/// submitter (falling back to the local player) so importers attribute it correctly.</summary>
public string CurrentReplayCode => IsReplaying ? ReplayCodeCodec.Encode( _replayData, _replaySubmitter.SteamId ) : "";
// Map the slider position to a playback rate with a piecewise-log curve so the centre is exactly
// 1x: [0,0.5] interpolates 0.1x->1x, [0.5,1] interpolates 1x->8x (both geometric, so the slider
// feels symmetric in "doublings" either side of real-time).
private static float SliderToSpeed( float t )
{
t = Math.Clamp( t, 0f, 1f );
return t <= 0.5f
? REPLAY_MIN_SPEED * MathF.Pow( 1f / REPLAY_MIN_SPEED, t / 0.5f ) // 0.1 .. 1
: MathF.Pow( REPLAY_MAX_SPEED, ( t - 0.5f ) / 0.5f ); // 1 .. 8
}
// Map a replay watch speed onto the music pitch band in LOG space, anchored at 1x = normal pitch,
// so the band is reached only at the speed extremes (0.1x -> MusicPitchMin, 8x -> MusicPitchMax)
// rather than clamping early. Piecewise around 1x because the speed range isn't symmetric in log.
private static float SpeedToMusicPitch( float speed )
{
speed = Math.Clamp( speed, REPLAY_MIN_SPEED, REPLAY_MAX_SPEED );
if ( speed <= 1f )
{
// 0.1x..1x -> MusicPitchMin..1
float t = MathF.Log( speed / REPLAY_MIN_SPEED ) / MathF.Log( 1f / REPLAY_MIN_SPEED );
return MathF.Pow( Audio.MusicPitchMin, 1f - t );
}
// 1x..8x -> 1..MusicPitchMax
float u = MathF.Log( speed ) / MathF.Log( REPLAY_MAX_SPEED );
return MathF.Pow( Audio.MusicPitchMax, u );
}
private static float ReplayTriggerAmount( InputAnalog analog )
{
float value = Math.Clamp( Input.GetAnalog( analog ), 0f, 1f );
return value <= REPLAY_TRIGGER_DEADZONE ? 0f : value;
}
// -1 = full slow, 0 = neutral, +1 = full fast. Keyboard/dpad speed controls stay digital;
// triggers are proportional, so a 30% pull moves 30% of the way from centre to that side.
private static float ReplaySpeedAxisFromInput()
{
bool slowDigital = Input.Down( "Left" ) || Input.Down( "LeftArrow" ) || Input.Down( REPLAY_SLOW_ACTION, complainOnMissing: false );
bool fastDigital = Input.Down( "Right" ) || Input.Down( "RightArrow" ) || Input.Down( REPLAY_FAST_ACTION, complainOnMissing: false );
if ( slowDigital ) return -1f;
if ( fastDigital ) return 1f;
return ReplayTriggerAmount( InputAnalog.RightTrigger ) - ReplayTriggerAmount( InputAnalog.LeftTrigger );
}
/// <summary>Seed of the run currently being recorded (for diagnostics).</summary>
public int CurrentRunSeed { get; private set; }
/// <summary>Sim version the active GameStage runs at: the recorded version for a replay (so its
/// version gates take the old paths), <see cref="Sim.VERSION"/> for a live run. Set in
/// <see cref="ApplyPendingStage"/> alongside the reseed; read through <see cref="GameStage.SimVersion"/>.</summary>
public int CurrentRunSimVersion { get; private set; } = Sim.VERSION;
/// <summary>Start a live run on a specific level (from the level-select screen; also how a
/// score-screen restart re-rolls the same level). <paramref name="characterId"/> overrides the
/// picker's selected character for this run only (used by repro ConCmds that need a specific
/// character, e.g. wrap_repro forcing WRAPPER); null = the selected character, the normal case.</summary>
public void StartLevel( string levelId, string characterId = null )
{
if ( StageSwapPending ) return;
// An unregistered workshop id would silently resolve to classic all the way down (RunContext,
// stat names) and submit this run to classic's board — refuse instead. The workshop screen
// installs+registers a level before starting it, so this only fires on a broken flow.
if ( WorkshopLevels.IsWorkshopId( levelId ) && Levels.Get( levelId ) is null )
{
Log.Warning( $"[BlockParty] StartLevel: workshop level '{levelId}' isn't installed." );
return;
}
string selectedId = characterId ?? Settings.Current.SelectedCharacterId;
_pendingRunContext = RunContext.ForLevel( levelId, ResolveLevelCharacter( Levels.Get( levelId ), selectedId ) );
TransitionToStage( new GameStage( this ) );
}
private static string ResolveLevelCharacter( LevelDef level, string selectedId )
=> Characters.TryGet( level?.ForcedCharacterId, out var forced ) ? forced.Id : selectedId;
// ── level-editor test play ──────────────────────────────────────────────────────────────────────
private const string TestLevelId = "__editor_test__";
private EditorLevel _editorSessionLevel;
private LevelEditorStage.EditTool _editorSessionTool;
private string _editorSessionCharacterId;
/// <summary>Undo/redo snapshots for the level editor. Owned here (like the remembered session) so
/// the history survives test-play round trips, stage rebuilds and editor re-opens. Session-only —
/// never written to disk; saved level files carry no history.</summary>
public EditorUndoHistory LevelEditorUndoHistory { get; } = new();
private LevelDef _testLevel;
private EditorLevel _testEditorLevel;
private LevelEditorStage.EditTool _testEditorTool;
// The character selected in the editor when Test Play was launched. Kept so the test run plays as that
// character and the editor is restored to it when the run ends.
private string _testEditorCharacterId;
/// <summary>True while a level-editor TEST run is active (a transient, unsaved level is being played).</summary>
public bool IsTestingLevel => _testLevel is not null;
/// <summary>Keep the current editor model in memory while navigating other stages. This is deliberately
/// session-only: it preserves unsaved work while the game keeps running without writing project JSON.</summary>
public void RememberLevelEditorSession( EditorLevel level, LevelEditorStage.EditTool tool, string characterId )
{
if ( level is null ) return;
_editorSessionLevel = level;
_editorSessionTool = tool;
_editorSessionCharacterId = characterId;
}
public bool TryRestoreLevelEditorSession( out EditorLevel level, out LevelEditorStage.EditTool tool, out string characterId )
{
level = _editorSessionLevel;
tool = _editorSessionTool;
characterId = _editorSessionCharacterId;
return level is not null;
}
/// <summary>Test-play an unsaved editor level: build a transient <see cref="LevelDef"/> from the
/// editor model, register it so the run resolves the in-progress edits (without writing to disk),
/// and start a non-submitting <see cref="GameStage"/>. When the run ends (death, win, or Back) it
/// returns to a fresh level editor restored to <paramref name="editorLevel"/> + <paramref name="tool"/>
/// (see <see cref="ReturnFromTestPlay"/>). The editor model is kept by reference, untouched by the run.</summary>
public void StartLevelTest( EditorLevel editorLevel, LevelEditorStage.EditTool tool, string characterId )
{
if ( StageSwapPending || editorLevel is null ) return;
_testEditorLevel = editorLevel;
_testEditorTool = tool;
_testEditorCharacterId = characterId;
_testLevel = editorLevel.ToLevelDef();
// Ensure a non-empty id so Get()/the run context agree (a brand-new level may have a blank id).
if ( string.IsNullOrEmpty( _testLevel.Id ) ) _testLevel.Id = TestLevelId;
Levels.RegisterTransient( _testLevel );
_pendingRunContext = RunContext.ForLevel( _testLevel.Id, ResolveLevelCharacter( _testLevel, _testEditorCharacterId ) );
TransitionToStage( new GameStage( this ) { IsTest = true } );
}
/// <summary>End a test run and return to the level editor with the same authored level + tool it
/// was launched from. Clears the transient level so the persistent registry is unshadowed again.</summary>
public void ReturnFromTestPlay()
{
Levels.ClearTransient();
var editor = new LevelEditorStage( this )
{
Level = _testEditorLevel ?? new EditorLevel(),
Tool = _testEditorTool,
SelectedCharacterId = _testEditorCharacterId ?? Settings.Current.SelectedCharacterId,
};
_testLevel = null;
_testEditorLevel = null;
TransitionToStage( editor );
}
/// <summary>Restart the current editor TEST run: re-run the same authored level with a fresh seed,
/// snapping straight to a new test <see cref="GameStage"/> (no wipe, like <see cref="RestartRun"/>).
/// Used when the game-over beat finishes and when R is pressed during a test. Falls back to returning
/// to the editor if there is no test model to re-run.</summary>
public void RestartTestPlay()
{
if ( _testEditorLevel is null ) { ReturnFromTestPlay(); return; }
// Rebuild the transient def from the (unchanged) editor model + re-register it so the fresh run
// resolves the same in-progress edits, then snap to a new test stage with a new seed.
_testLevel = _testEditorLevel.ToLevelDef();
if ( string.IsNullOrEmpty( _testLevel.Id ) ) _testLevel.Id = TestLevelId;
Levels.RegisterTransient( _testLevel );
_accumulator = 0f;
_pendingRunContext = RunContext.ForLevel( _testLevel.Id, ResolveLevelCharacter( _testLevel, _testEditorCharacterId ) );
SetStage( new GameStage( this ) { IsTest = true } );
}
public void StartDailyChallenge( string dailyId )
{
// Only TODAY's challenge can launch a counted attempt — checked live so a hub, score screen
// or die-restart loop held across UTC midnight can't keep submitting new attempts to a day
// whose board is closed to everyone else. (Covers future days too.)
if ( StageSwapPending || dailyId != DailyChallenge.TodayId )
return;
var daily = DailyLevels.Get( dailyId );
if ( daily is null || !DailyChallengeProgress.HasAttemptsRemaining( dailyId, daily.MaxAttempts ) )
return;
DailyChallengeProgress.RegisterAttemptStart( dailyId );
_pendingRunContext = RunContext.Daily( dailyId, ResolveLevelCharacter( daily.Level, Settings.Current.SelectedCharacterId ) );
TransitionToStage( new GameStage( this ) );
}
/// <summary>DEBUG: play any past/future day's generated daily to evaluate the generator (see the
/// daily_play ConCmd). Skips the date/attempt guards, counts no attempt, and the run never
/// submits or marks progress (<see cref="GameStage.IsDebugRun"/>).</summary>
public void StartDailyChallengeDebug( string dailyId )
{
if ( StageSwapPending )
return;
var daily = DailyLevels.Get( dailyId );
if ( daily is null )
return;
// Debug launches count into their own display-only file (never real progress/limits), so the
// attempts UI can be exercised via daily_debug_nav.
DailyChallengeProgress.RegisterDebugAttemptStart( dailyId );
_pendingRunContext = RunContext.Daily( dailyId, ResolveLevelCharacter( daily.Level, Settings.Current.SelectedCharacterId ) );
TransitionToStage( new GameStage( this ) { IsDebugRun = true } );
}
public void ClearRunContext()
{
_pendingRunContext = RunContext.Normal;
CurrentRunContext = RunContext.Normal;
}
protected override void OnAwake()
{
Instance = this;
Rng.Seed( Seed );
// The dedicated Jump action (Space / gamepad A) resolves to "away from the effective floor" of
// the controlled body — the active twin for Twins, the primary body otherwise; Up outside a run.
// Mid charge wind-up it resolves away from the CHARGED surface instead (the cancel key).
// Registered once here so every Sample/Prime call site (live loop, hijack, menu overlay) agrees.
InputState.JumpDirectionResolver = () =>
(Instance?.Stage as GameStage)?.Player?.JumpScreenDirection ?? Direction.Up;
// While a charge jump winds, the stick's held bit toward the charge's hold direction is
// latched (see InputState) so aim sweeps can't release the charge early.
InputState.ChargeHoldDirectionResolver = () =>
(Instance?.Stage as GameStage)?.Player?.ChargeHoldScreenDirection ?? Direction.None;
// The engine's controller virtual cursor (left stick moves the mouse, gamepad A left-clicks the
// hovered panel, right stick scrolls) is permanently off: the stick steers the player and A
// jumps, and a cursor that comes and goes per context proved confusing. Menus are driven by
// d-pad/Confirm and the real mouse.
Input.EnableVirtualCursor = false;
}
protected override void OnDestroy()
{
CurrentReplayReactions?.Cancel();
CurrentReplayViews?.Cancel();
CurrentReplayViews = null;
base.OnDestroy();
}
protected override void OnStart()
{
SetupCamera();
SetupTransitionOverlay();
SetupUnlockRevealOverlay();
SetupGifExportOverlay();
SetupSoftwareCursor();
// Load persisted player settings and push the volume factors into the audio layer before the
// music starts, so the first note already respects the saved levels.
Settings.Load();
// Merge cloud-mirrored beaten levels into the loaded profile (fire-and-forget best-effort;
// see CloudProgress). Restores map progress on a fresh machine / reinstall / lost data folder.
_ = CloudProgress.MergeAsync();
// Grant any achievement the loaded profile already qualifies for — covers a player who met the
// condition before the achievement existed, and re-covers one whose unlock request was lost.
Achievements.CheckProgress();
Audio.SetVolumes(
Settings.Current.MasterVolume / 100f,
Settings.Current.SfxVolume / 100f,
Settings.Current.MusicVolume / 100f );
Haptics.SetStrength( Settings.Current.VibrationStrength / 100f );
Audio.PlayMusic();
SetStage( new TitleStage( this ) );
}
private void SetupCamera()
{
var camGo = new GameObject();
camGo.Name = "Camera2D";
camGo.SetParent( GameObject );
// Centre the view on the playfield, sitting along +Z and looking down -Z so the XY
// plane faces the screen. s&box is Z-up with +Y = "left"; with forward=-Z and up=+Y
// the camera's right works out to +X, giving screen X->right, Y->up (sprites upright,
// gravity correct). (right = cross(forward, up) = cross(-Z,+Y) = +X.)
camGo.WorldPosition = new Vector3( Arena.WIDTH / 2f, Arena.HEIGHT / 2f, 2000f );
camGo.WorldRotation = Rotation.LookAt( Vector3.Down, Vector3.Left );
Camera = camGo.Components.Create<CameraComponent>();
Camera.Orthographic = true;
Camera.OrthographicHeight = Arena.HEIGHT;
Camera.BackgroundColor = ClearColor;
// Tight near/far: entities span z ~ -40 (playfield) .. 999 (text), i.e. ~1001..2040 units in
// front of the camera. Orthographic depth is linear, so a tighter far plane spreads the depth
// buffer over a smaller range and cleanly separates our 1-3 unit layer gaps (no z-fighting).
Camera.ZNear = 1f;
Camera.ZFar = 3000f;
Camera.IsMainCamera = true;
// Settings live on the component itself; toggle/tune it under Camera2D in the scene tree.
camGo.Components.Create<RetroArcadePostProcess>();
}
// The OS cursor is replaced by a drawn pixel arrow so the retro shader curves it together with
// the UI it points at — see SoftwareCursor. Lives on its own persistent GameObject.
private void SetupSoftwareCursor()
{
var go = new GameObject();
go.Name = "SoftwareCursor";
go.SetParent( GameObject );
go.Components.Create<SoftwareCursor>();
}
// Persistent screen-space overlay that draws the transition square. Lives on its own GameObject
// (NOT under a stage Root) so it survives the stage swap that happens in the middle of a wipe,
// and sits above every stage's UI (stage panels use ZIndex 100).
private void SetupTransitionOverlay()
{
var go = new GameObject();
go.Name = "TransitionOverlay";
go.SetParent( GameObject );
var screen = go.Components.Create<ScreenPanel>();
screen.ZIndex = 1000;
_transition = go.Components.Create<TransitionOverlay>();
}
// Persistent screen-space overlay for the "YOU UNLOCKED" reveal. Own GameObject for the same
// reason as the transition square: a reveal is handed the exit the score tally was about to take,
// so it has to survive that stage swap. Sits above every stage's UI (ZIndex 100) but BELOW the
// transition square (1000), so a wipe still covers it.
private void SetupUnlockRevealOverlay()
{
var go = new GameObject();
go.Name = "UnlockRevealOverlay";
go.SetParent( GameObject );
var screen = go.Components.Create<ScreenPanel>();
screen.ZIndex = 900;
_unlockOverlay = go.Components.Create<UnlockRevealOverlay>();
_unlockOverlay.Reveal = UnlockReveal;
}
// Stages a still-owed unlock reveal may pop itself on: the menus a player lands on after
// abandoning a won run, plus the title screen they boot into. Deliberately NOT the score tally
// (it shows its own reveal, with the exit deferred behind it), a live run, or the editor.
private bool CanAutoShowUnlockReveal
{
get
{
if ( IsTransitioning )
return false;
return Stage switch
{
// The map plays its own timed newly-unlocked-node presentation on entry; covering that
// would make the player miss it, so queue behind it.
LevelSelectStage levelSelect => !levelSelect.UnlockPresentationActive,
TitleStage or DailyChallengeStage => true,
_ => false,
};
}
}
// Advance the "YOU UNLOCKED" reveal once per rendered frame, after this frame's input sample.
// While one is up it OWNS that input: the edges are consumed here so the screen behind the
// overlay can't be driven blind by the same press that dismisses it.
private void TickUnlockReveal( float dt )
{
if ( !UnlockReveal.IsActive )
{
// Nothing on screen: deliver a reveal the player is still owed (a won run abandoned before
// its tally, or one queued in a previous session) as soon as they're on a menu.
if ( !CanAutoShowUnlockReveal || !CharacterProgress.HasPendingReveal || !UnlockReveal.ShowPending() )
return;
}
UnlockReveal.Tick( dt );
// Edges AND held bits: the menus behind the overlay navigate on held input (hold-to-repeat),
// so clearing only the edges would let a direction walk the title menu / map selection while
// the reveal is up — including the very press that dismisses it.
InputState.ConsumeMenuInput();
}
// Persistent screen-space overlay for GIF export. Like the transition overlay, it lives on its own
// GameObject (NOT under a stage Root) so it survives the stage rebuilds that a backward replay seek
// triggers — otherwise dragging a timeline head would tear the overlay down mid-interaction and leave
// the camera stuck in preview framing (gif mode "half on"). It renders nothing until a preview begins.
private void SetupGifExportOverlay()
{
var go = new GameObject();
go.Name = "GifExportOverlay";
go.SetParent( GameObject );
var screen = go.Components.Create<ScreenPanel>();
screen.ZIndex = 1100; // above the in-game HUD (100); the wipe (1000) doesn't run during a preview
_gifOverlay = go.Components.Create<GifExportOverlay>();
}
/// <summary>Switch to a new stage immediately, tearing down the previous one (no wipe). Used at
/// boot and internally by the transition wipe once the screen is fully covered.</summary>
public void SetStage( StageBase stage )
{
_pendingStage = stage;
}
/// <summary>Switch to a new stage with the growing/shrinking square wipe. Covering and revealing
/// are each opt-in based on whether the current / next stage paints its own dark backdrop
/// (<see cref="StageBase.CoveredBackground"/>), so the square appears to persist across the
/// score/leaderboard screens and only shrinks away when returning to a normal stage. Ignored if a
/// wipe is already running.</summary>
public void TransitionToStage( StageBase next )
{
if ( StageSwapPending ) return;
// Stop active run-owned sounds as soon as leaving is requested; navigation and transition cues
// are deliberately excluded by Audio.StopGameSfx and can finish over the wipe.
if ( Stage is GameStage )
Audio.StopGameSfx();
bool fromCovered = Stage?.CoveredBackground ?? false;
bool toCovered = next.CoveredBackground;
if ( fromCovered && toCovered )
{
// Both stages share the same dark fill — no visible wipe, just swap the content.
SetStage( next );
return;
}
_transNext = next;
_revealAfter = !toCovered; // only uncover the next stage if it doesn't paint its own backdrop
if ( fromCovered )
{
// The current screen is already the dark fill; skip the grow and go straight to a full
// cover, swap under it, then reveal the (non-covered) next stage.
_cover = 1f;
PushCover();
SetStage( next );
_transNext = null;
_transPhase = TransitionPhase.Revealing;
_transTimer = 0f;
}
else
{
// Grow the square over the current (visible) stage; the swap + reveal happen at full cover.
_transPhase = TransitionPhase.Covering;
_transTimer = 0f;
}
}
// Advance the wipe once per rendered frame (real time, like the original coroutine). Covering
// grows the square 0->1 (SineEaseOut); at full cover we hand off to the next stage. If the next
// stage paints its own backdrop we drop the overlay once that stage applies (seamless, same
// colour); otherwise we Revealing-shrink it 1->0 (SineEaseIn) to uncover the new stage.
private void TickTransition( float dt )
{
if ( _transPhase == TransitionPhase.None ) return;
_transTimer += dt;
float t = Math.Clamp( _transTimer / TRANSITION_HALF, 0f, 1f );
if ( _transPhase == TransitionPhase.Covering )
{
_cover = MathF.Sin( t * MathF.PI / 2f ); // ease-out: 0 -> 1
if ( t >= 1f )
{
_cover = 1f;
SetStage( _transNext ); // applied next frame, while the square fully covers the view
_transNext = null;
_transPhase = TransitionPhase.None;
if ( _revealAfter )
{
_transPhase = TransitionPhase.Revealing;
_transTimer = 0f;
}
else
{
// Keep the square full until the (self-covered) stage applies, then drop the
// overlay so its identical backdrop takes over — no flash of the old stage.
_hideOverlayOnSwap = true;
}
}
}
else // Revealing
{
_cover = MathF.Cos( t * MathF.PI / 2f ); // ease-in: 1 -> 0
if ( t >= 1f )
{
_cover = 0f;
_transPhase = TransitionPhase.None;
}
}
PushCover();
}
private void PushCover()
{
if ( _transition is not null )
_transition.Cover = _cover;
}
protected override void OnUpdate()
{
// Before anything reads input: a clicked button must not keep the keyboard in UI mode.
KeyboardFocusGuard.Tick();
ApplyPendingStage();
if ( !CanUseAbilityShortcuts ) InputState.SuppressAbilityShortcuts();
if ( Stage is null ) return;
// A covering wipe into a self-covered stage keeps the square full until that stage applies;
// now that it has (its identical dark backdrop is up), drop the overlay with no visible flash.
if ( _hideOverlayOnSwap )
{
_hideOverlayOnSwap = false;
_cover = 0f;
PushCover();
}
// Advance the stage-transition wipe every frame, even while the sim is paused/frozen below,
// so the cover/reveal animation always plays smoothly and the stage swap lands on schedule.
TickTransition( Time.Delta );
// Drive controller vibration every rendered frame, BEFORE any early-out below, so haptics run
// identically in live play, replays, menus and while paused (the voices just decay). This is the
// single point that mixes all active pulses and pushes one rumble command to the gamepad.
// Continuous channels are asserted from fixed sim TICKS instead, so the mixer is also told how
// far apart those currently sit in real time: slow motion spaces them out, and a channel waiting
// on the next stretched step hasn't ended (see Haptics.Sustain).
Haptics.SetSimStepInterval( Arena.STEP / MathF.Max( GameplayTime.Scale, 0.01f ) );
Haptics.Tick( Time.Delta );
GameplayTime.Tick( Time.Delta );
if ( !IsReplaying || _hijacked )
Audio.SetMusicPitch( SpeedToMusicPitch( GameplayTime.Scale ) );
// React to pause transitions: duck the music while the escape menu is up, restore on close.
bool paused = IsPaused;
if ( paused != _wasPaused )
{
Audio.SetMusicPaused( paused );
_wasPaused = paused;
}
// While the s&box escape menu is open, freeze the gameplay simulation: don't sample
// input, accumulate time, or tick the stage. We drop any leftover accumulator so the
// sim resumes cleanly on the next frame rather than catching up the paused interval.
if ( paused )
{
_accumulator = 0f;
return;
}
// An in-game menu (the options overlay) freezes the gameplay simulation but still needs to
// process its own navigation input. Checked BEFORE the replay branch so the menu also works
// over a replay: while it's open we pump the menu (W/S nav, slider nudge, Confirm on CLOSE)
// and consume the input here, so nothing reaches the run/replay. Unlike the s&box escape menu
// we deliberately do NOT duck the music — the player is adjusting volume and wants an accurate
// level. Drop the accumulator so the sim resumes cleanly, and skip both replay and sim ticks.
if ( Stage.BlocksSimulation )
{
Audio.SetLoopingSfxPaused( true );
InputState.Sample( allowAbilityShortcuts: false );
Stage.TickMenu( Time.Delta );
InputState.ClearEdges();
Stage.SyncTransforms();
_accumulator = 0f;
return;
}
Audio.SetLoopingSfxPaused( IsReplaying && _replayPaused );
// Replay: the sim is driven by recorded input instead of live sampling. Index-driven (one
// recorded frame per fixed step) so it reproduces regardless of the viewer's frame rate.
if ( IsReplaying )
{
TickReplay();
// The replay path returns before the normal end-of-frame debug draw below, so mirror it here —
// the on-screen player state readout is most useful precisely when stepping through a replay.
if ( ShowDebugScreenText )
DrawDebug();
return;
}
// Restart: after game-over (the death beat) the Restart input (R) restarts immediately with a
// fresh random seed, as before. During an ACTIVE run R is held for the configured delay (see the
// hold-to-restart fields) — the sim keeps running underneath while the HUD dims the arena and
// fills a progress bar; releasing early fades it back out from wherever it was. Daily
// eligibility is enforced by GameStage.CanRestart; replays and editor tests keep their instant
// dedicated paths below. Snaps straight to a new GameStage.
if ( Stage is GameStage rgs && rgs.CanRestart )
{
if ( rgs.IsGameOver || Settings.Current.RestartButtonDelay <= 0f )
{
// Down (not just Pressed) so a hold begun before the death also completes here — dying
// mid-hold restarts without demanding a re-press.
if ( Input.Pressed( "Restart" ) || (_restartHoldArmed && Input.Down( "Restart" )) )
{
ResetRestartHold();
RestartRun();
return;
}
TickRestartHold( false );
}
else
{
// Arm only on a fresh press (and not mid-wipe, where RestartRun would refuse anyway).
if ( Input.Pressed( "Restart" ) && !IsTransitioning )
_restartHoldArmed = true;
bool holding = _restartHoldArmed && Input.Down( "Restart" );
TickRestartHold( holding );
if ( holding && _restartHoldTime >= Settings.Current.RestartButtonDelay )
{
ResetRestartHold();
RestartRun();
return;
}
}
}
else
{
// Not a restartable live run (menus, tests, a won game-over…): fade out any leftover overlay.
TickRestartHold( false );
}
// Editor TEST run: R (Restart) restarts the test level at ANY time — before, during, or after the
// game-over beat — re-running the same authored level with a fresh seed instead of returning to
// the editor. Checked per rendered frame so the key edge is never dropped.
if ( Stage is GameStage rts && rts.IsTest && Input.Pressed( "Restart" ) )
{
RestartTestPlay();
return;
}
// Sample input once per rendered frame; editor-test menu actions and the fixed-step sim consume
// this same snapshot. Sampling before the test Back check keeps the controller Back button / Backspace aligned
// with every other menu and preserves a press across frames that run zero simulation steps.
InputState.Sample( allowAbilityShortcuts: CanUseAbilityShortcuts );
// The "YOU UNLOCKED" reveal sits over whatever stage is up and eats this frame's menu input
// while it's visible, so the screen behind it never acts on the press that dismisses it. Also
// where an owed reveal pops itself once the player reaches a menu.
TickUnlockReveal( Time.Delta );
// Editor TEST run: Back leaves the test and returns to the level editor. ExitTest self-guards
// against re-entry and is also driven by the on-screen stop button.
if ( Stage is GameStage tgs && tgs.IsTest && InputState.BackJust )
{
tgs.ExitTest();
return;
}
// Live run: Back returns to the screen that launched it (daily hub / map), like the test-run
// and replay Back paths. A limited-attempt daily asks first (see RequestBackToMenu). Consume
// the press: nothing below runs this frame, so the latched edge would otherwise survive into
// the prompt's first menu-pumped frame and cancel it on the spot.
if ( Stage is GameStage && InputState.BackJust )
{
RequestBackToMenu();
InputState.ConsumeMenuInput();
return;
}
// Fixed-step simulation.
_accumulator += Time.Delta * GameplayTime.Scale;
int steps = 0;
while ( _accumulator >= Arena.STEP && steps < MAX_STEPS_PER_FRAME )
{
var result = AdvanceOneSimStep( SimStepInput.Live );
_accumulator -= Arena.STEP;
steps++;
if ( result == SimStepResult.Froze ) continue;
if ( result == SimStepResult.StageEnded ) break;
}
// If we hit the step cap, drop the backlog rather than accumulating lag.
if ( steps >= MAX_STEPS_PER_FRAME )
_accumulator = 0f;
// Push logical positions into transforms once per rendered frame.
Stage?.SyncTransforms();
DebugTools.TickGameStage( Stage as GameStage, Camera );
if ( ShowDebugScreenText )
DrawDebug();
if ( Player.ShowCrushProbeOverlay && Stage is GameStage crushGs && crushGs.Player is not null )
crushGs.Player.DrawCrushProbeOverlay();
if ( Block.ShowDebugInfo && Stage is GameStage blockGs )
foreach ( var block in blockGs.Blocks )
block.DrawDebugInfo();
}
private void DrawDebug()
{
var info = $"BlockParty debug\nfps:{(1f / Time.Delta):0} accum:{_accumulator * 1000:0.0}ms";
if ( Stage is GameStage gs && gs.Player is not null )
info += "\n" + gs.Player.DebugInfo();
Gizmo.Draw.Color = Color.White;
Gizmo.Draw.ScreenText( info, new Vector2( 20, 120 ), "Consolas", 16, TextFlag.LeftTop );
}
private void ApplyPendingStage()
{
if ( _pendingStage is null ) return;
InputState.SuppressAbilityShortcuts(); // never carry an unfinished gesture into another run
// Stop run-owned sfx at the moment the outgoing GameStage actually ends. This is the single
// choke point for every swap path (restarts, replay rebuilds, wipe handoffs), and it runs AFTER
// any sim steps the old stage ticked last frame — so nothing fired between the stop-at-request
// (TransitionToStage) and the real swap can leak into the next screen.
if ( Stage is GameStage )
Audio.StopGameSfx();
Stage?.Exit();
// Exit requests cancel replay work immediately, but the outgoing HUD remains visible
// during the covering wipe. Retire its display metadata only after that HUD is gone.
if ( !IsReplaying )
{
CurrentReplayReactions = null;
CurrentReplayViews = null;
_replaySubmitter = default;
}
GameplayTime.Reset();
Stage = _pendingStage;
_pendingStage = null;
// Reseed the simulation immediately before the stage's OnEnter (which spawns/shuffles blocks
// and so consumes Rng), so a run reproduces exactly: a replay reuses the recorded seed; a
// fresh run picks a new random seed and starts recording.
if ( Stage is GameStage )
{
if ( _replay is not null )
{
// A replay reproduces the recorded run's level and character (GameStage reads both off
// this context when spawning), not whatever context a live run left behind. A daily
// replay regenerates its level from the daily id instead of the registry.
CurrentRunContext = _replayData?.IsDaily == true
? RunContext.Daily( _replayData.DailyId, _replayData.CharacterId )
: RunContext.ForLevel( _replayData?.LevelId, _replayData?.CharacterId );
Rng.Seed( _replay.Seed );
CurrentRunSimVersion = _replay.SimVersion;
RunRecorder.Stop();
// The live trace trails belong to ONE specific run: seed + exact input stream. Seed
// alone is NOT identity — every attempt of a daily (by every player) shares the day's
// seed, so a same-day daily replay would diff against an unrelated live trail and log
// false desyncs (and pollute desync reports). Each kept trail snapshots its run's
// stream when the run ends (FinalizeLiveTrace), so a watch still finds its match after
// restarts / later runs — not just when it was the most recent one.
FinalizeLiveTrace();
_replayLiveTrace = _replayData is null ? null
: _recentLiveTraces.LastOrDefault( t =>
t.Seed == _replay.Seed
&& t.StepCount == _replayData.StepCount
&& t.Encoded == _replayData.InputDeltas );
// Fresh replay sim (first watch, restart, or a scrub rebuild): restart its desync-trace
// trail so checkpoints line up with the live run's from step 0.
_replayTraceHash = ReplayRegression.FNV_OFFSET;
_replayTraceStep = 0;
_replayTraceDivergenceLogged = false;
_replayTraceCheckpoints.Clear();
_replayTraceDivergenceStep = -1;
_replayPressEvents.Clear();
_pressTraceMismatchLogged = false;
}
else
{
CurrentRunContext = _pendingRunContext;
_pendingRunContext = RunContext.Normal;
// Seal the previous run's trail BEFORE Begin wipes the recorder's stream (its identity).
FinalizeLiveTrace();
CurrentRunSeed = CurrentRunContext.IsDaily ? CurrentRunContext.Seed : _seedSource.Next();
CurrentRunSimVersion = Sim.VERSION;
Rng.Seed( CurrentRunSeed );
RunRecorder.Begin( CurrentRunSeed );
// Start this live run's desync-trace trail; the last few runs' trails are kept so a
// board watch after restarts / later runs still finds its match.
_liveTrace = new RunTrace { Seed = CurrentRunSeed };
_recentLiveTraces.Add( _liveTrace );
if ( _recentLiveTraces.Count > MAX_LIVE_TRACES )
_recentLiveTraces.RemoveAt( 0 );
}
}
// Reset the run-progression music pitch at every stage entry: a GameStage begins low and rises
// as blocks phase up (see Block.NextPhase); every other screen (title, score, leaderboard, local
// replays, daily) plays at the base start pitch. Centralised here — the single choke point for
// Stage.Enter() — so no screen (nor a replay's exit target) is missed, and a backward-scrub re-sim
// resets to base before ticking forward to rebuild the landed frame's pitch.
Audio.ResetMusicProgression();
Stage.Enter();
}
// ── desync trace ────────────────────────────────────────────────────────────────────────────────
// A rolling ReplayRegression.FoldStep hash of the LIVE run's sim state, checkpointed once per
// sim-second. When a replay of the SAME run (matched by seed) is watched later this session, the
// replay recomputes identical checkpoints and the FIRST mismatching one is logged with its step
// window — pinpointing where the sim first diverged instead of just flagging the final score.
// Session-only diagnostics: nothing is persisted and payloads are unchanged. Both trails reset in
// ApplyPendingStage (the single (re)seed choke point, which scrub rebuilds also pass through).
/// <summary>One awarded block-side press: when, which block/face, and WHO pressed (the player, a
/// swarm clone, or a world source like a bullet/statue). The presser kind is part of equality, so
/// the same press attributed differently live-vs-replay reads as a divergence too.</summary>
private readonly record struct PressEvent( int Step, int Block, BlockType Type, Direction Side, string Presser );
// One live run's trail: rolling state hash, per-second checkpoints, every awarded press — plus,
// once the run ends, its recorded input stream (the run's true identity; dailies share seeds).
private sealed class RunTrace
{
public int Seed;
public uint Hash = ReplayRegression.FNV_OFFSET;
public int Step;
public int StepCount = -1; // sealed by FinalizeLiveTrace (-1 while the run is still live)
public string Encoded; // recorded input stream, sealed by FinalizeLiveTrace
public readonly List<uint> Checkpoints = new();
public readonly List<PressEvent> Presses = new();
}
// The last few live runs' trails (newest last): a board watch after restarts or later runs still
// finds its match — a single-trail version lost the evidence to any run in between.
private const int MAX_LIVE_TRACES = 8;
private readonly List<RunTrace> _recentLiveTraces = new();
private RunTrace _liveTrace; // the live run currently recording (also in the list above)
private RunTrace _replayLiveTrace; // the kept trail matching the watched replay (null = none)
private uint _replayTraceHash;
private int _replayTraceStep;
private bool _replayTraceDivergenceLogged;
private readonly List<uint> _replayTraceCheckpoints = new();
private readonly List<PressEvent> _replayPressEvents = new();
private bool _pressTraceMismatchLogged;
// First step of the divergent window the trace found (-1 = none) — carried into the desync report.
private int _replayTraceDivergenceStep = -1;
/// <summary>Seal the current live trail with its run's recorded input stream — its identity for
/// replay matching. Called before the recorder begins a new run (Begin wipes the stream) and
/// before a replay looks for its matching trail. No-op once sealed or with no trail.</summary>
private void FinalizeLiveTrace()
{
if ( _liveTrace is null || _liveTrace.Encoded is not null )
return;
_liveTrace.StepCount = RunRecorder.StepCount;
_liveTrace.Encoded = RunRecorder.Encode();
}
/// <summary>Record one awarded side press into the live or replay press trail; on a replay of a
/// kept same-session run (matched by seed + input stream), log the first press that disagrees
/// with the live run's sequence the moment it happens. Hijack presses are practice input and
/// aren't traced.</summary>
public void TracePressAward( Block block, Direction side, Player presser )
{
if ( Stage is not GameStage || block is null )
return;
string who = presser is null ? "world"
: presser.IsSwarmClone ? "clone"
: presser.IsImpostor ? "impostor"
: "player";
if ( !IsReplaying )
{
_liveTrace?.Presses.Add( new PressEvent( _liveTrace.Step + 1, block.StageIndex, block.BlockType, side, who ) );
return;
}
if ( _hijacked )
return;
var evt = new PressEvent( _replayTraceStep + 1, block.StageIndex, block.BlockType, side, who );
_replayPressEvents.Add( evt );
if ( _pressTraceMismatchLogged || _replayLiveTrace is null )
return;
int index = _replayPressEvents.Count - 1;
if ( index < _replayLiveTrace.Presses.Count && _replayLiveTrace.Presses[index] == evt )
return;
_pressTraceMismatchLogged = true;
string expected = index < _replayLiveTrace.Presses.Count
? DescribePress( _replayLiveTrace.Presses[index] )
: "(the live run had no further press)";
Log.Warning( $"BlockParty press trace: replay press #{index + 1} is {DescribePress( evt )}, but the live run's press #{index + 1} was {expected}." );
}
private static string DescribePress( PressEvent e ) =>
$"step {e.Step} (t≈{e.Step / (float)Arena.TICK_RATE:0.0}s) block#{e.Block} {e.Type} {e.Side} by {e.Presser}";
/// <summary>One line per press, for the desync report.</summary>
private static string FormatPressTrail( List<PressEvent> events ) =>
events.Count == 0 ? "(none)" : string.Join( "; ", events.Select( DescribePress ) );
private void FoldDesyncTrace( SimStepInput source, GameStage stage )
{
if ( source == SimStepInput.Live )
{
if ( _liveTrace is null )
return;
_liveTrace.Hash = ReplayRegression.FoldStep( stage, _liveTrace.Hash );
_liveTrace.Step++;
if ( _liveTrace.Step % ReplayRegression.CHECKPOINT_EVERY == 0 )
_liveTrace.Checkpoints.Add( _liveTrace.Hash );
return;
}
if ( source != SimStepInput.Replay )
return; // hijack is player-driven and meant to diverge
_replayTraceHash = ReplayRegression.FoldStep( stage, _replayTraceHash );
_replayTraceStep++;
if ( _replayTraceStep % ReplayRegression.CHECKPOINT_EVERY != 0 )
return;
_replayTraceCheckpoints.Add( _replayTraceHash );
int index = _replayTraceStep / ReplayRegression.CHECKPOINT_EVERY - 1;
if ( _replayTraceDivergenceLogged
|| _replay is null || _replayLiveTrace is null
|| index >= _replayLiveTrace.Checkpoints.Count
|| _replayLiveTrace.Checkpoints[index] == _replayTraceHash )
return;
_replayTraceDivergenceLogged = true;
int stepA = _replayTraceStep - ReplayRegression.CHECKPOINT_EVERY + 1;
_replayTraceDivergenceStep = stepA;
Log.Warning( $"BlockParty desync trace: replaying THIS SESSION's run (seed {_replay.Seed}) first diverged from the live sim "
+ $"during steps {stepA}-{_replayTraceStep} (t≈{stepA / (float)Arena.TICK_RATE:0.0}-{_replayTraceStep / (float)Arena.TICK_RATE:0.0}s)." );
}
// ── the one shared sim-step primitive ───────────────────────────────────────────────────────────
// EVERY fixed-step loop in this class (live play, replay playback, single-step, hijack practice,
// hijack rebuild, timeline scrub) advances the deterministic simulation through THIS one method, so
// they can never drift out of lock-step. Replay determinism relies on every path feeding the sim
// identically; previously six copies of the loop body had to be kept in sync by hand.
// Where a single sim step's input comes from.
private enum SimStepInput
{
Live, // the frame already sampled by InputState.Sample(); recorded to the run stream
Replay, // the next recorded frame is fed in (InputExhausted if none remain); nothing recorded
Hijack, // live input like Live, but nothing recorded (practice-only take-over of a replay)
}
// Outcome of a single sim step, so callers can drive their catch-up loop / finish handling.
private enum SimStepResult
{
Stepped, // advanced one normal sim step
Froze, // consumed a hit-stop freeze step (no input fed, no recorded frame consumed)
InputExhausted, // replay had no recorded frame left; the sim was NOT ticked (caller decides finish)
StageEnded, // the step ended the stage/run (Stage swapped away, or the replay finished)
}
// Advance the deterministic sim by exactly one Arena.STEP (or reproduce one hit-stop freeze step),
// taking the step's input from <paramref name="source"/>. Callers own the accumulator/step-count
// bookkeeping and how they react to the returned result.
private SimStepResult AdvanceOneSimStep( SimStepInput source )
{
// Hit-stop: consume the step as a frozen one — advance the freeze counter but DON'T feed input,
// tick the sim, or clear edges. The freeze is reproduced from the same deterministic trigger in
// every loop (never recorded), and preserving unread edges lets a press made mid-freeze fire on
// resume. Checked first so it takes precedence over feeding a recorded/live frame.
if ( Stage is GameStage hs && hs.HitStopActive )
{
hs.TickHitStop();
return SimStepResult.Froze;
}
switch ( source )
{
case SimStepInput.Live:
// Record the input this step consumes (live gameplay only) BEFORE the tick, so the stored
// byte is exactly what the sim sees this step and the run can be replayed later.
if ( Stage is GameStage )
RunRecorder.RecordStep( InputState.PackByte() );
break;
case SimStepInput.Replay:
// Feed the next recorded frame; if none remain, don't tick — the caller decides how to finish.
if ( _replay is null || !_replay.HasNext )
return SimStepResult.InputExhausted;
InputState.ApplyReplayFrame( _replay.Next() );
break;
case SimStepInput.Hijack:
// Live input already sampled by the caller drives the sim; nothing is recorded (practice only).
break;
}
Stage.Tick( Arena.STEP );
// Desync trace: fold this step's sim state into the live/replay checkpoint trail (see the
// fields above ApplyPendingStage). BEFORE ApplyPendingStage — a stage swap resets the trails.
if ( Stage is GameStage traceStage )
FoldDesyncTrace( source, traceStage );
// Live/hijack sample once per rendered frame, so clear the edge events (jump/wall-jump) this tick
// consumed to stop later sub-steps re-firing them. A replay re-applies the exact edges every step
// via ApplyReplayFrame, so it must NOT clear them here.
if ( source != SimStepInput.Replay )
{
// This tick may have ended the run or opened an overlay. Cancel before advancing the
// script so a later catch-up step in this rendered frame cannot fire its second press.
if ( !CanUseAbilityShortcuts ) InputState.SuppressAbilityShortcuts();
InputState.ClearEdges();
}
ApplyPendingStage();
// The step ended the run when the stage swapped away, or (in any replay-driven path) when the
// replay stopped / finished this step. Live play only cares about the stage swap.
bool ended = Stage is null;
if ( source != SimStepInput.Live )
ended |= !IsReplaying || _replayFinished;
return ended ? SimStepResult.StageEnded : SimStepResult.Stepped;
}
// Advance a replay: feed one recorded input frame per fixed step. When the recorded frames run
// out (or the viewer presses Back) the replay ends and returns to the leaderboard.
private void TickReplay()
{
if ( IsPlacingReaction && Input.Pressed( "Back" ) )
{
CancelReaction();
return;
}
// While the GIF-export overlay is open it owns the replay: hold the current frame and ignore the
// normal transport/back/step keys. The overlay seeks explicitly (timeline heads, and the exporter's
// capture loop) via SeekReplayToFrame; without this guard Space/arrows/Back would un-pause, restart
// or exit the run out from under the overlay (and corrupt an in-progress capture).
if ( _gifPreviewActive )
{
// Stop the music while the export overlay is up (it's a still, paused frame), like the normal
// paused branch does below.
Audio.SetMusicPlaying( false );
_accumulator = 0f;
Stage?.SyncTransforms();
return;
}
// Hold while a stage-transition wipe is running (e.g. the entry wipe): the new replay GameStage
// hasn't entered yet, so feeding recorded input into the outgoing stage would consume frames
// and leave the fresh run starting mid-recording (out of sync). Resume once the wipe ends.
// Must run BEFORE the Restart/Back checks: pressing Back mid-wipe would tear down replay state
// while EndReplay's own return transition is swallowed by the running wipe, which then still
// applies the queued replay stage — now seeded as a live run (_replay is null) that can never
// leave its game-over screen.
if ( IsTransitioning )
{
_accumulator = 0f;
Stage?.SyncTransforms();
return;
}
// Restart returns an armed hijacked practice run to its takeover point, whether pressed before or
// after death. Otherwise it restarts the recorded replay from frame zero.
if ( Input.Pressed( "Restart" ) )
{
if ( HijackArmed )
ResumeHijack();
else
RestartReplay();
return;
}
if ( Input.Pressed( "Back" ) )
{
EndReplay();
return;
}
// Reached the end of the recorded run: freeze on the final frame (the viewer restarts or goes
// back). Don't advance the sim or bank elapsed time.
if ( _replayFinished )
{
if ( HijackArmed )
{
// A hijacked practice run has ended (frozen). Space replays from the start to the hijack
// frame and hands control back there; A/D restart the recorded run normally and forget
// the hijack.
if ( Input.Pressed( "Jump" ) )
{
ResumeHijack();
return;
}
if ( Input.Pressed( "Left" ) || Input.Pressed( "LeftArrow" ) || Input.Pressed( "Right" ) || Input.Pressed( "RightArrow" ) )
{
RestartReplay();
return;
}
}
// Space / Left / A restarts the finished replay (mirrors the RESTART transport button).
else if ( Input.Pressed( "Jump" ) || Input.Pressed( "Left" ) || Input.Pressed( "LeftArrow" ) )
{
RestartReplay();
return;
}
// Restore normal-speed music under the end screen.
Audio.SetMusicPlaying( true );
Audio.SetMusicPitch( 1f );
_accumulator = 0f;
Stage?.SyncTransforms();
return;
}
// Hijacked: the live player has taken over from this frame on. Drive the (still IsReplay) stage
// with live input like a normal run — checked BEFORE the transport so Space/A/D act as gameplay
// (jump/move), not pause/speed.
if ( _hijacked )
{
TickHijack();
return;
}
// Transport input (keys) is read every frame, even while paused, so Space can toggle pause, taps
// of Left/Right can single-step, and holding a direction can un-pause the run.
UpdateReplayTransport();
if ( _replayPaused )
{
// Frozen: don't advance the sim and don't bank elapsed time as a backlog. A queued single
// step advances exactly one frame, then we re-freeze. Pause the music too so it stops with
// the picture.
if ( _replayStepBackOnce )
StepReplayBackOnce();
else if ( _replayStepOnce )
StepReplayOnce();
Audio.SetMusicPlaying( false );
_accumulator = 0f;
Stage?.SyncTransforms();
return;
}
// Slow/speed the music to match the watch speed (the speed range is mapped onto the music pitch
// band in log space, so only the extremes reach the band ends). A SoundHandle pitch resamples,
// so it pitches AND slows together — a tape/vinyl effect.
Audio.SetMusicPlaying( true );
Audio.SetMusicPitch( SpeedToMusicPitch( ReplaySpeed * GameplayTime.Scale ) );
_accumulator += Time.Delta * ReplaySpeed * GameplayTime.Scale;
int steps = 0;
while ( _accumulator >= Arena.STEP && steps < MAX_REPLAY_STEPS_PER_FRAME )
{
var result = AdvanceOneSimStep( SimStepInput.Replay );
if ( result == SimStepResult.InputExhausted )
{
// Recorded input ran out: finish with the locked-in score if the game-over beat was
// skipped (un-reproducible), otherwise a genuine "ran out before the end" desync.
FinishReplayOnInputExhausted();
break;
}
_accumulator -= Arena.STEP;
steps++;
if ( result == SimStepResult.Froze ) continue;
// The recorded run ended this step (death/win -> GameStage.EndGame -> FinishReplay), so stop
// advancing and hold the final frame.
if ( result == SimStepResult.StageEnded ) break;
}
if ( steps >= MAX_REPLAY_STEPS_PER_FRAME )
_accumulator = 0f;
Stage?.SyncTransforms();
}
// Replay transport keys, read every frame (even while paused):
// - Space (Jump) toggles pause/play.
// - While paused: tap Right to advance a single tick; tap Left to rebuild the replay at the
// previous tick. Hold either direction past REPLAY_PAUSE_HOLD_RESUME to resume play, handing
// speed control back to the held key.
// - While playing: hold Left/Right (A/D or arrows) to lerp the speed slider toward an end; release
// to lerp it back to centre (1x). A mouse drag clears _replayKeyActive, so it won't fight a slider
// the user set.
private void UpdateReplayTransport()
{
_replayStepOnce = false;
_replayStepBackOnce = false;
// Space toggles pause/play (mirrors the HUD pause/play button).
if ( Input.Pressed( "Jump" ) )
{
ToggleReplayPause();
_replayPausedHoldTime = 0f;
}
float speedAxis = ReplaySpeedAxisFromInput();
bool speedHeld = MathF.Abs( speedAxis ) > 0f;
if ( _replayPaused )
{
// Direction taps queue one-tick steps; only a sustained hold resumes playback.
if ( Input.Pressed( "Left" ) || Input.Pressed( "LeftArrow" ) || Input.Pressed( REPLAY_SLOW_ACTION ) )
_replayStepBackOnce = true;
if ( Input.Pressed( "Right" ) || Input.Pressed( "RightArrow" ) || Input.Pressed( REPLAY_FAST_ACTION ) )
_replayStepOnce = true;
if ( speedHeld )
{
_replayPausedHoldTime += Time.Delta;
if ( _replayPausedHoldTime < REPLAY_PAUSE_HOLD_RESUME )
return; // still within the tap window — stay paused
// Held long enough: resume and let the held key drive the speed below.
_replayPaused = false;
if ( IsPlacingReaction ) _reactionWasPaused = false;
_replayPausedHoldTime = 0f;
_replayKeyActive = true;
}
else
{
_replayPausedHoldTime = 0f;
return; // paused with no direction held
}
}
float k = MathF.Min( 1f, REPLAY_SPEED_LERP * Time.Delta );
if ( speedHeld )
{
float target = 0.5f + speedAxis * 0.5f;
_replaySpeedT += (target - _replaySpeedT) * k;
_replayKeyActive = true;
}
else if ( _replayKeyActive )
{
_replaySpeedT += (0.5f - _replaySpeedT) * k;
if ( MathF.Abs( _replaySpeedT - 0.5f ) < 0.002f )
{
_replaySpeedT = 0.5f;
_replayKeyActive = false;
}
}
}
// Advance the replay by exactly one fixed step (used while paused to single-step a frame). Mirrors
// one iteration of the catch-up loop in TickReplay: honour an active hit-stop without consuming a
// recorded frame, otherwise feed the next recorded input and tick the sim once.
private void StepReplayOnce()
{
if ( IsPlacingReaction ) _reactionWasPaused = true;
// Skip any active hit-stop freeze so a single frame-step tap advances one REAL recorded frame
// rather than being swallowed by a freeze step (viewers scrub frame-by-frame, not freeze-by-
// freeze). Backward stepping already skips it — SeekReplayToFrame targets the recorded-frame
// index, and freeze steps don't advance that index.
if ( Stage is GameStage gs ) gs.ClearHitStop();
if ( AdvanceOneSimStep( SimStepInput.Replay ) == SimStepResult.InputExhausted )
FinishReplayOnInputExhausted();
}
// Step backward by rebuilding the replay at the previous recorded frame. The sim has no reverse
// integrator, so this deliberately uses the same deterministic re-sim path as timeline scrubbing.
private void StepReplayBackOnce()
{
if ( _replay is null || _replay.Position <= 0 )
return;
SeekReplayToFrame( _replay.Position - 1, playSfx: false );
}
/// <summary>Take control of the replay at the current frame (HIJACK button). Live player input drives
/// the run from here on; it is practice-only (nothing recorded, no score/tally) and the frame is
/// remembered so <see cref="ResumeHijack"/> can return to it.</summary>
public void HijackReplay()
{
if ( IsPlacingReaction ) return;
if ( !IsReplaying || _replay is null || _replayFinished || _hijacked || IsTransitioning )
return;
// Refuse if the recorded run has already entered its game-over beat — there's nothing left to
// take control of (it would just re-finish immediately).
if ( Stage is GameStage gs && gs.IsGameOver )
return;
Achievements.AwardReplayHijacked(); // after the guards, so only a real take-over counts
_hijackFrame = _replay.Position; // arm the restore point at the current frame
_hijacked = true;
_replayPaused = false; // hand control over immediately
_replayKeyActive = false;
_accumulator = 0f;
// Re-baseline input so a key held at the moment of hijack isn't read as a fresh press next step.
InputState.Prime();
// Live control drops the replay transport multiplier but retains any active gameplay slow motion.
Audio.SetMusicPlaying( true );
Audio.SetMusicPitch( SpeedToMusicPitch( GameplayTime.Scale ) );
}
// Drive a hijacked replay with LIVE input, mirroring the live fixed-step loop in OnUpdate. The stage
// is still an IsReplay GameStage (so EndGame freezes via FinishReplay instead of going to the score
// tally), but input comes from Sample() and NOTHING is recorded. 1x real time, live step budget.
private void TickHijack()
{
InputState.Sample( allowAbilityShortcuts: CanUseAbilityShortcuts );
_accumulator += Time.Delta * GameplayTime.Scale;
int steps = 0;
while ( _accumulator >= Arena.STEP && steps < MAX_STEPS_PER_FRAME )
{
var result = AdvanceOneSimStep( SimStepInput.Hijack );
_accumulator -= Arena.STEP;
steps++;
if ( result == SimStepResult.Froze ) continue;
// The hijacked run ended this step (death/win -> EndGame -> FinishReplay), so stop and freeze.
if ( result == SimStepResult.StageEnded ) break;
}
if ( steps >= MAX_STEPS_PER_FRAME )
_accumulator = 0f;
Stage?.SyncTransforms();
}
// Rebuild a fresh replay GameStage and silently replay from step 0 up to targetFrame, reproducing the
// exact deterministic state at that frame (the sim is forward-only, like a backward timeline scrub).
// Shared by hijack resume (Space) and hijack stop.
private void RebuildToFrame( int targetFrame )
{
// ApplyPendingStage reseeds the Rng from _replay.Seed and enters the stage synchronously.
_replay.Reset();
SetStage( new GameStage( this, isReplay: true ) );
ApplyPendingStage();
bool prevSuppress = Audio.SuppressSfx;
bool prevHaptics = Haptics.Suppress;
Audio.SuppressSfx = true; // a full-run re-sim would otherwise fire the whole run's SFX at once
Haptics.Suppress = true; // ...and the whole run's vibration
try
{
while ( _replay.Position < targetFrame && IsReplaying )
{
var result = AdvanceOneSimStep( SimStepInput.Replay );
if ( result == SimStepResult.Froze ) continue;
// A silent rebuild toward a known-good pre-game-over frame: on exhaustion just stop (no
// FinishReplayOnInputExhausted — the caller owns the finish/hijack state).
if ( result == SimStepResult.InputExhausted || result == SimStepResult.StageEnded )
break;
}
}
finally
{
Audio.SuppressSfx = prevSuppress;
Haptics.Suppress = prevHaptics;
}
RestoreReplayPresentation();
}
private void RestoreReplayPresentation()
{
GameplayTime.Reset();
if ( Stage is GameStage gameStage )
foreach ( var player in gameStage.Players )
player.RestoreReplayPresentation();
}
// Replay from the start to the armed hijack frame (instant + silent), reproducing the exact pre-hijack
// state, then hand control back to the player at that frame.
private void ResumeHijack()
{
if ( !IsReplaying || _replay is null || _hijackFrame < 0 )
return;
_replayFinished = false;
_replayDesynced = false;
_replayPaused = false;
_accumulator = 0f;
RebuildToFrame( _hijackFrame );
_hijacked = true; // control handed back at the hijack frame
// Re-baseline input so the Space still held from "retry" isn't read as a jump on the first step.
InputState.Prime();
Stage?.SyncTransforms();
}
/// <summary>The HUD restart button during a hijacked practice run — identical to pressing R: replay
/// silently to the armed takeover frame and hand control back there.</summary>
public void RestartHijack()
{
// Same transition gate the key path gets from TickReplay's early-return: a click mustn't rebuild
// the stage while a wipe is mid-flight.
if ( HijackArmed && !IsTransitioning )
ResumeHijack();
}
/// <summary>End an in-progress hijack (STOP button) and return to normal recorded playback, paused on
/// the exact frame the hijack began. Forgets the hijack point so the run plays out normally from here.</summary>
public void StopHijack()
{
if ( !IsReplaying || _replay is null || !_hijacked )
return;
int target = _hijackFrame;
_hijacked = false;
_hijackFrame = -1; // back to normal replay; the hijack is cancelled
_replayFinished = false;
_replayDesynced = false;
_accumulator = 0f;
RebuildToFrame( target );
_replayPaused = true; // hold on the hijack frame, as if scrubbed there
Audio.SetMusicPitch( 1f ); // (the paused branch in TickReplay pauses the music itself)
Stage?.SyncTransforms();
}
/// <summary>Set the replay speed from the HUD slider (0..1). Leaves the value where dropped and
/// un-pauses playback.</summary>
public void SetReplaySpeedSlider( float t )
{
if ( _hijacked ) return; // transport is gameplay during hijack; ignore stray slider sets
_replaySpeedT = Math.Clamp( t, 0f, 1f );
_replayKeyActive = false; // a deliberate mouse set; don't auto-return to centre
_replayPaused = false;
if ( IsPlacingReaction ) _reactionWasPaused = false;
}
/// <summary>True while a timeline scrub gesture is in progress (mouse-down .. mouse-up). The HUD uses
/// this to put up a full-screen transparent catcher so the mouse-release is still processed when it
/// lands over a region with no interactive panel (the arena / letterbox) — otherwise the panel input
/// system early-outs there and the drag never ends.</summary>
public bool IsScrubbingTimeline { get; private set; }
/// <summary>Begin a timeline scrub gesture (progress-bar mouse-down): freeze playback and remember
/// whether it was running, so <see cref="EndReplayScrub"/> can resume it on release.</summary>
public void BeginReplayScrub()
{
if ( !IsReplaying || _hijacked )
return;
_scrubResumePlaying = !_replayPaused && !_replayFinished;
_replayPaused = true; // hold the picture for the duration of the scrub
IsScrubbingTimeline = true;
}
/// <summary>End a timeline scrub gesture (progress-bar mouse-up): resume playback if it was running
/// when the scrub began and auto-resume is enabled; otherwise stay paused on the scrubbed frame.</summary>
public void EndReplayScrub()
{
IsScrubbingTimeline = false;
if ( REPLAY_SCRUB_AUTO_RESUME && _scrubResumePlaying && IsReplaying && !_replayFinished && !IsPlacingReaction )
_replayPaused = false;
_scrubResumePlaying = false;
}
// ── GIF export preview framing ──────────────────────────────────────────────────────────────────
// The GIF-export overlay shrinks the live replay into the top of the screen (leaving room for the
// export controls below) by zooming the orthographic camera out and shifting its centre downward, so
// the 240x240 arena sits centred in the top GIF_PREVIEW_TOP_FRACTION of the view. This only changes
// what's shown live — the export itself re-frames tight (see GifExporter), so the GIF is unaffected.
/// <summary>Fraction of screen height the shrunk arena occupies (from the top) during GIF preview.</summary>
private const float GIF_PREVIEW_TOP_FRACTION = 0.6f;
private Vector3 _gifCamPosSaved;
private float _gifCamOrthoSaved;
private bool _gifPreviewActive;
/// <summary>True while the GIF-export overlay is shrinking the replay into the top of the screen.</summary>
public bool IsGifPreviewActive => _gifPreviewActive;
/// <summary>Enter GIF-preview framing: pause playback and shrink the arena into the top region. Saves the
/// camera transform so <see cref="EndGifPreview"/> can restore it exactly.</summary>
public void BeginGifPreview()
{
CancelReaction();
if ( !IsReplaying || _hijacked || Camera is null || _gifPreviewActive )
return;
_gifCamPosSaved = Camera.WorldPosition;
_gifCamOrthoSaved = Camera.OrthographicHeight;
_gifPreviewActive = true;
_replayPaused = true; // hold the picture so a head's frame stays put while the overlay is open
// Zoom out so the arena (Arena.HEIGHT tall) fills GIF_PREVIEW_TOP_FRACTION of the view height, then
// shift the camera centre DOWN so the arena rides above screen-centre into the top region. (Camera
// looks -Z with up = +Y, so a smaller world-Y at screen-centre pushes the arena upward on screen.)
float ortho = Arena.HEIGHT / GIF_PREVIEW_TOP_FRACTION;
float offset = (0.5f - GIF_PREVIEW_TOP_FRACTION / 2f) * ortho;
Camera.OrthographicHeight = ortho;
Camera.WorldPosition = new Vector3( Arena.WIDTH / 2f, Arena.HEIGHT / 2f - offset, _gifCamPosSaved.z );
}
/// <summary>Restore the camera framing saved by <see cref="BeginGifPreview"/> (overlay closed).</summary>
public void EndGifPreview()
{
if ( !_gifPreviewActive )
return;
_gifPreviewActive = false;
if ( Camera is not null )
{
Camera.WorldPosition = _gifCamPosSaved;
Camera.OrthographicHeight = _gifCamOrthoSaved;
}
}
/// <summary>Scrub the replay to a normalised timeline position in [0,1] (progress-bar click/drag).
/// <paramref name="playSfx"/> lets a small forward drag keep its per-step audio; a click-jump or a
/// backward seek passes false so its burst of re-simulated SFX stays silent.</summary>
public void SeekReplayToProgress( float t, bool playSfx = false )
{
if ( _replay is null )
return;
SeekReplayToFrame( (int)MathF.Round( Math.Clamp( t, 0f, 1f ) * _replay.Length ), playSfx );
}
/// <summary>Scrub the replay to an absolute recorded-frame index, reconstructing that exact frame by
/// re-simulating the deterministic run. A FORWARD seek just keeps feeding recorded input from the
/// current position (cheap, incremental); a BACKWARD seek can't run the sim in reverse, so it rebuilds
/// a fresh replay <see cref="GameStage"/> (reseeded from the recording) and replays forward from step 0.
/// Either way the intermediate steps render nothing — the sim is ticked with no per-step transform
/// sync, and a single <see cref="StageBase.SyncTransforms"/> paints the landed frame. SFX are gated
/// by <paramref name="playSfx"/> (a small forward drag keeps its per-step audio; a click-jump or a
/// backward rebuild stays silent so it doesn't fire a burst of layered sounds at once). Playback is
/// left paused on that frame (resume with the existing transport controls / auto-resume).</summary>
public void SeekReplayToFrame( int targetFrame, bool playSfx = false )
{
if ( !IsReplaying || _replay is null || IsTransitioning || _hijacked )
return;
targetFrame = Math.Clamp( targetFrame, 0, _replay.Length );
// Once the run has reproduced its game-over (THE END), the game-over IS the replay's real end —
// the recording carries extra frames past it (captured during the score-screen transition), but
// ticking into those re-sims nothing meaningful and then "runs out of input", reporting a spurious
// desync. So ignore a forward seek while finished; only seeking BACK (to review) is allowed.
if ( _replayFinished && targetFrame >= _replay.Position )
return;
// Scrubbing holds the chosen frame: clear the finished/desync freeze so the sim can advance
// (it's re-armed below if we fast-forward into the run's game-over) and pause on arrival.
_replayFinished = false;
_replayDesynced = false;
_replayPaused = true;
_accumulator = 0f;
// Choosing a new moment is an explicit pause. Keep the manager-owned picker and
// selected effect through a backward stage rebuild, and stay here on Cancel too.
if ( IsPlacingReaction ) _reactionWasPaused = true;
// A backward seek rebuilds the run from step 0 — always silence that (it re-fires the whole run's
// SFX). A forward drag only re-runs a few steps and may keep its audio (playSfx).
bool backward = targetFrame < _replay.Position;
bool prevSuppress = Audio.SuppressSfx;
bool prevHaptics = Haptics.Suppress;
float prevSfxScale = Audio.SfxVolumeScale;
Audio.SuppressSfx = backward || !playSfx;
// Vibration tracks the SFX gate: silent (backward/click-jump) re-sims mustn't buzz the pad; a
// small forward drag that keeps its audio keeps its vibration too.
Haptics.Suppress = backward || !playSfx;
// A forward scrub's SFX play live but ducked, so they sit under normal-speed playback.
Audio.SfxVolumeScale = REPLAY_SCRUB_SFX_VOLUME;
try
{
// Seeking backward (the sim is forward-only): rebuild a fresh replay GameStage and replay from
// step 0. ApplyPendingStage reseeds the Rng from _replay.Seed and enters the stage synchronously,
// so it's ready to tick in the loop below — mirrors RestartReplay, but applied here-and-now.
if ( backward )
{
_replay.Reset();
SetStage( new GameStage( this, isReplay: true ) );
ApplyPendingStage();
}
// Fast-forward to the target, mirroring one iteration of TickReplay's catch-up loop per step:
// reproduce a hit-stop freeze without consuming a recorded frame, otherwise feed the next recorded
// input and tick the sim once. No SyncTransforms here — this is a pure state advance.
while ( _replay.Position < targetFrame && IsReplaying && !_replayFinished )
{
var result = AdvanceOneSimStep( SimStepInput.Replay );
if ( result == SimStepResult.Froze ) continue;
if ( result == SimStepResult.InputExhausted )
{
// Recorded input ran out before reaching the target: finish with the locked-in score
// if the game-over beat was skipped, else a genuine "ran out early" desync.
FinishReplayOnInputExhausted();
break;
}
if ( result == SimStepResult.StageEnded )
break;
}
}
finally
{
Audio.SuppressSfx = prevSuppress;
Haptics.Suppress = prevHaptics;
Audio.SfxVolumeScale = prevSfxScale;
}
// Paint the landed frame once — the only render of the whole re-sim.
RestoreReplayPresentation();
Stage?.SyncTransforms();
}
/// <summary>Toggle replay pause (HUD pause/play button).</summary>
public void ToggleReplayPause()
{
if ( !IsReplaying || _hijacked || _replayFinished || IsTransitioning ) return;
_replayPaused = !_replayPaused;
if ( IsPlacingReaction ) _reactionWasPaused = _replayPaused;
}
/// <summary>Restart the current replay from the beginning (HUD restart button).</summary>
public void RestartReplay()
{
if ( !IsReplaying || _replay is null )
return;
_replay.Reset();
_accumulator = 0f;
_replayPaused = IsPlacingReaction;
if ( IsPlacingReaction ) _reactionWasPaused = true;
_replayFinished = false;
_replayDesynced = false;
// Restarting forgets any hijack (the RESTART button / A·D path): play back the recorded run.
_hijacked = false;
_hijackFrame = -1;
// Restart immediately (no grow/shrink wipe) — a replay restart should snap straight back to
// frame 0; ApplyPendingStage reseeds the fresh GameStage from _replay.Seed.
SetStage( new GameStage( this, isReplay: true ) );
}
/// <summary>Begin playing back a recorded run from the leaderboard. Reseeds the sim with the
/// recorded seed (in <see cref="ApplyPendingStage"/>) and feeds the recorded input each step.</summary>
/// <param name="data">The recorded run payload.</param>
/// <param name="submitter">Leaderboard metadata for the run's author (shown in the replay HUD).</param>
/// <param name="fromLocalReplays">True when launched from the MY REPLAYS archive (so exiting returns
/// there instead of the leaderboard); leaderboard launches leave it false.</param>
/// <param name="returnDailyId">When set, Back exits the replay to that daily challenge page instead
/// of the normal leaderboard.</param>
/// <param name="returnToLevelSelect">When true, Back exits the replay to the level-select map instead
/// of the normal leaderboard (used by the level-select footer's rows).</param>
/// <param name="leaderboardBackToLevelSelect">When true, a replay returning to the normal leaderboard
/// preserves that page's level-select BACK destination.</param>
/// <param name="returnSelectionIndex">Row index selected on the launching list, restored when the
/// replay exits back to the MY REPLAYS archive or the leaderboard (-1 = none).</param>
/// <param name="returnWorkshopLevelId">When set, Back exits the replay to that workshop level's
/// board (whose own BACK returns to the workshop screen) instead of the normal leaderboard.</param>
/// <param name="returnScore">The score tally this chain started from, so exiting rebuilds it (already
/// counted out) — either directly (<paramref name="returnToScore"/>) or via the board it opened.</param>
/// <param name="returnToScore">True when the replay was launched straight from that tally, so Back
/// returns to it instead of to a leaderboard.</param>
public void StartReplay( RunData data, ReplaySubmitter submitter = default, bool fromLocalReplays = false, string returnDailyId = null, bool returnToLevelSelect = false, bool leaderboardBackToLevelSelect = false, int returnSelectionIndex = -1, string returnWorkshopLevelId = null, ScoreReturn returnScore = null, bool returnToScore = false )
{
if ( data is null || !data.CanReplay || StageSwapPending )
return;
// A different run is being launched: forget any remembered GIF-export settings and preview.
EndGifPreview();
SavedGifConfig = null;
_replayData = data;
_replay = new RunPlayback( data );
_replaySubmitter = submitter;
// A test-play local replay embeds its level (a transient snapshot of the editor model, not in
// the persistent registry). Register it as a transient so it resolves by id while the replay
// runs (and any restart re-enters); ResetReplayState clears it when the replay ends so it can't
// shadow a real level of the same id afterwards.
if ( data.LevelData is not null )
Levels.RegisterTransient( data.LevelData );
_replayFromLocalReplays = fromLocalReplays;
_replayReturnDailyId = returnDailyId;
_replayReturnToLevelSelect = returnToLevelSelect;
_replayLeaderboardBackToLevelSelect = leaderboardBackToLevelSelect;
_replayReturnWorkshopLevelId = returnWorkshopLevelId;
_replayReturnSelection = returnSelectionIndex;
_replayReturnScore = returnScore;
_replayReturnToScore = returnToScore && returnScore is not null;
IsReplaying = true;
_replaySpeedT = 0.5f; // 1x
_replayKeyActive = false;
_replayPaused = false;
_replayFinished = false;
_replayDesynced = false;
_hijacked = false;
_hijackFrame = -1;
_accumulator = 0f;
TransitionToStage( new GameStage( this, isReplay: true ) );
// Every user-facing source enters here. Restarts, seeks, GIF preview and headless regression
// playback do not, so rebuilding a replay stage cannot accidentally submit another view.
CurrentReplayViews?.Cancel();
CurrentReplayViews = ReplayViewStats.Begin( data, submitter.SteamId );
CurrentReplayReactions?.Cancel();
CurrentReplayReactions = ReplayReactions.Begin( data, submitter.SteamId );
IsPlacingReaction = false;
}
/// <summary>Copy the currently watched replay's share code to the system clipboard.</summary>
public bool CopyCurrentReplayCode()
{
string code = CurrentReplayCode;
if ( string.IsNullOrEmpty( code ) )
{
Log.Info( "BlockParty replay code: copy failed (no replay is currently being watched)." );
return false;
}
Sandbox.UI.Clipboard.SetText( code );
Log.Info( $"BlockParty replay code: copied to clipboard ({ReplayCodeCodec.Describe( _replayData )}, {code.Length} chars)." );
return true;
}
/// <summary>Save the currently watched replay into the local archive. Duplicate saves are no-ops.</summary>
public bool SaveCurrentReplay()
{
if ( !IsReplaying || _replayData?.CanReplay != true )
return false;
long steamId = _replaySubmitter.SteamId != 0 ? _replaySubmitter.SteamId : (long)Game.SteamId;
string displayName = !string.IsNullOrWhiteSpace( _replaySubmitter.Name )
? _replaySubmitter.Name
: new Friend( steamId ).Name;
LocalReplays.Add( _replayData, steamId, displayName, _replaySubmitter.CountryCode );
return CurrentReplayIsLocal;
}
/// <summary>Remove the currently watched replay from the local archive. No-op if it is not saved.</summary>
public bool DeleteCurrentReplayLocal()
{
var entry = LocalReplays.Find( _replayData, _replaySubmitter.SteamId );
if ( entry is null )
return false;
LocalReplays.Remove( entry );
return !CurrentReplayIsLocal;
}
/// <summary>Toggle the current replay's local favorite state. No-op until the replay is saved locally.</summary>
public void ToggleCurrentReplayFavorite()
{
var entry = LocalReplays.Find( _replayData, _replaySubmitter.SteamId );
if ( entry is null )
return;
LocalReplays.ToggleFavorite( entry );
}
/// <summary>Decode a pasted replay code and immediately watch it. This does not write to the local
/// replay archive; it is a pure string round-trip.</summary>
public bool TryStartReplayCode( string code )
{
if ( !ReplayCodeCodec.TryDecode( code, out var data, out long steamId ) )
{
Log.Info( $"BlockParty replay code: watch failed (invalid or incompatible code, {(code ?? "").Length} chars)." );
return false;
}
// The code carries who did the run, so the HUD (and a save from this watch) attributes it to
// them rather than the local player. Name/avatar resolve from the id; there's no country or
// submission date in a code. Id 0 (encoded with no Steam session) means the importer.
if ( steamId == 0 )
steamId = (long)Game.SteamId;
var submitter = new ReplaySubmitter( steamId, new Friend( steamId ).Name, "", default );
bool returnToLocal = IsReplaying && _replayFromLocalReplays;
StartReplay( data, submitter, fromLocalReplays: returnToLocal, returnDailyId: _replayReturnDailyId, returnToLevelSelect: _replayReturnToLevelSelect,
leaderboardBackToLevelSelect: _replayLeaderboardBackToLevelSelect, returnSelectionIndex: _replayReturnSelection,
returnWorkshopLevelId: _replayReturnWorkshopLevelId, returnScore: _replayReturnScore, returnToScore: _replayReturnToScore );
bool started = IsReplaying && _replayData == data;
if ( started )
Achievements.AwardReplayCodeUsed();
Log.Info( started
? $"BlockParty replay code: watch started ({ReplayCodeCodec.Describe( data )})."
: "BlockParty replay code: watch failed (replay could not start right now)." );
return started;
}
/// <summary>Freeze a finished replay on its final frame (called when the replayed run reaches
/// game-over, or the recorded input is exhausted). The viewer then restarts or goes back. Runs the
/// determinism self-check once, with the score recomputed from the reproduced run.</summary>
/// <param name="reproducedScore">The final score recomputed from the replayed run, or -1 when the
/// recorded input ran out before a game-over (no score to verify).</param>
public void FinishReplay( int reproducedScore, System.Collections.Generic.IReadOnlyList<BlockResult> reproducedBlocks = null )
{
if ( !IsReplaying || _replayFinished )
return;
if ( _hijacked )
{
// A hijacked run is player-controlled and meant to diverge from the recording — skip the
// determinism check (it would always "DESYNC"). Stop live control but keep the hijack point
// armed so Space can re-enter at the same frame.
_hijacked = false;
}
else
{
CheckReplayDesync( reproducedScore, reproducedBlocks );
}
_replayFinished = true;
_replayPaused = false; // the dedicated finished-freeze in TickReplay holds the frame
_accumulator = 0f;
}
// The replay consumed its last recorded frame without the run ending on its own. Recording stops at
// game-over, so a run that ended by the player SKIPPING the game-over beat (an input edge the replay
// can't reproduce) lands here with the beat still counting down — finish it with the score already
// locked in. Only a run that never reached game-over at all is a genuine "ran out early" desync (-1).
private void FinishReplayOnInputExhausted()
{
// `Stage is GameStage gs` is a type-pattern: true (with `gs` bound) whenever the current stage is
// a GameStage — which it always is mid-replay (a replay runs in a GameStage with IsReplay set), so
// this is really just a typed handle to call the method below. FinishGameOverForReplay itself is
// the one that decides whether we're mid game-over (it checks _gameOver internally) — if we are,
// it runs EndGame, which calls back into FinishReplay with the real score and sets _replayFinished.
if ( Stage is GameStage gs )
gs.FinishGameOverForReplay();
// If that didn't finish us (the run never reached game-over), it's a genuine input-exhausted desync.
if ( !_replayFinished )
FinishReplay( -1 );
}
// Determinism self-check: a correct replay reproduces the exact block states, so the score
// recomputed from them must equal the score the original run submitted. A mismatch means the
// sim diverged (an RNG/ordering/timestep determinism bug) — log loudly so it gets noticed.
private void CheckReplayDesync( int reproducedScore, System.Collections.Generic.IReadOnlyList<BlockResult> reproducedBlocks = null )
{
if ( _replay is null )
return;
int expected = _replay.ExpectedFinalScore;
// reproducedScore < 0 means the recorded input ran out before a game-over. If the original run
// actually had a score, that's a divergence too — the replay should have reached the same death.
if ( reproducedScore < 0 )
{
if ( expected > 0 )
{
_replayDesynced = true;
Log.Warning( $"BlockParty replay DESYNC: input exhausted before game-over but recorded score was {expected} (seed {_replay.Seed}). The replay never reproduced the original run." );
SaveDesyncReport( expected, reproducedScore, null );
}
return;
}
if ( reproducedScore != expected )
{
_replayDesynced = true;
Log.Warning( $"BlockParty replay DESYNC: reproduced score {reproducedScore} != recorded {expected} (seed {_replay.Seed}). The replay diverged from the original run." );
LogDesyncBlockDiff( reproducedBlocks );
SaveDesyncReport( expected, reproducedScore, reproducedBlocks );
}
else
Log.Info( $"BlockParty replay verified: score {reproducedScore} matches the recorded run (seed {_replay.Seed})." );
}
// Write the sendable bug-report file (logs/ in the data folder — see DesyncReport). The live
// checkpoint/press trails only belong in it when they're actually THIS run's (same seed AND input
// stream — dailies share seeds); trails from some other run this session would read as a wall of
// false divergence.
private void SaveDesyncReport( int expected, int reproduced, System.Collections.Generic.IReadOnlyList<BlockResult> reproducedBlocks )
{
var live = _replayLiveTrace;
if ( live is not null && live.Presses.Count != _replayPressEvents.Count )
Log.Warning( $"BlockParty press trace: live run awarded {live.Presses.Count} presses, replay awarded {_replayPressEvents.Count} — see the desync report for both trails." );
DesyncReport.Save( _replayData, expected, reproduced, reproducedBlocks, _replayTraceDivergenceStep,
live?.Checkpoints ?? (System.Collections.Generic.IReadOnlyList<uint>)System.Array.Empty<uint>(), _replayTraceCheckpoints,
live is not null ? FormatPressTrail( live.Presses ) : "(run not from this session)",
FormatPressTrail( _replayPressEvents ) );
}
// Forensics for a score desync: the payload carries the ORIGINAL run's final block states (the
// board rows draw their tally graphic from them), so diffing against the reproduced states names
// the block(s) whose mechanic diverged. Both sides go through the same pack/decode + type ordering
// so the lists line up; runs recorded pre-v3 (no block data) just skip this.
private void LogDesyncBlockDiff( System.Collections.Generic.IReadOnlyList<BlockResult> reproducedBlocks )
{
if ( _replayData?.Blocks is null || reproducedBlocks is null )
return;
var recorded = _replayData.DecodeBlocks();
var reproduced = new RunData { Blocks = RunData.PackBlocks( reproducedBlocks ) }.DecodeBlocks();
static string Line( System.Collections.Generic.List<BlockTallyState> blocks ) =>
string.Join( " ", blocks.Select( b =>
$"{b.Type} p{b.Phase} {(b.Left ? "L" : "-")}{(b.Right ? "R" : "-")}{(b.Up ? "U" : "-")}{(b.Down ? "D" : "-")}" ) );
Log.Warning( $"BlockParty replay DESYNC blocks — recorded: {Line( recorded )}" );
Log.Warning( $"BlockParty replay DESYNC blocks — reproduced: {Line( reproduced )}" );
}
// ── replay regression harness (see ReplayRegression) ─────────────────────────────────────────────
// Drives whole recorded runs through the SAME AdvanceOneSimStep replay path as on-screen playback,
// but headless: synchronous, no per-step transform sync, audio/haptics suppressed. Every saved
// replay thereby doubles as a physics regression test — replay_bless stores golden state traces,
// replay_verify re-runs them after a sim change and reports where behaviour drifted.
/// <summary>Run every replayable local-archive run headless and either save the traces as the new
/// golden baselines (<paramref name="bless"/>) or compare against the stored baselines and report
/// divergences. Refused mid-transition, mid-replay, or during a live run (the harness swaps stages,
/// which would abandon the run and its recording). Lands on the title screen when done.</summary>
public void RunReplayRegression( bool bless )
{
if ( IsTransitioning || IsReplaying )
{
Log.Warning( "[replay-regression] busy (mid-transition or mid-replay) — try again from a menu." );
return;
}
if ( Stage is GameStage )
{
Log.Warning( "[replay-regression] a live run is active — finish or leave it first (the harness would abandon it)." );
return;
}
// A console command runs outside the scene tick, so Game.ActiveScene can be unset — notably after
// the editor's Stop, which destroys the play scene but leaves this static Instance reachable.
// Every GameObject the batch spawns takes its Scene from the active scene AT CONSTRUCTION and
// never again: unset, they're orphans whose components never enable, and anything that touches
// Scene synchronously (the Swapper portal's pointer) NREs mid-batch. Refuse without a live scene
// and pin ours for the whole batch.
if ( Scene is null )
{
Log.Warning( "[replay-regression] no live game scene (was the game stopped?) — press Play and run it from the game." );
return;
}
using var sceneScope = Scene.Push();
var runnable = new System.Collections.Generic.List<LocalReplayEntry>();
var skipped = new System.Collections.Generic.List<(LocalReplayEntry Entry, ReplayUnavailableReason Why)>();
foreach ( var e in LocalReplays.All )
{
if ( e.Run is null ) continue;
var why = e.Run.UnavailableReason;
if ( why == ReplayUnavailableReason.None ) runnable.Add( e );
else skipped.Add( (e, why) );
}
if ( runnable.Count == 0 )
{
Log.Warning( $"[replay-regression] no replayable runs in the local archive ({ReplayRegression.DescribeSkipped( skipped )}). Save some replays first — favourites make good permanent fixtures." );
return;
}
var started = System.DateTimeOffset.Now;
var traces = new System.Collections.Generic.Dictionary<string, RegressionTrace>();
foreach ( var entry in runnable )
{
string key = ReplayRegression.KeyFor( entry.Run );
// The same deterministic payload can be archived twice (two submitters); one trace suffices.
if ( traces.ContainsKey( key ) )
continue;
traces[key] = RunReplayHeadless( entry );
}
// The batch consumed whatever stage was up; land somewhere neutral.
SetStage( new TitleStage( this ) );
ApplyPendingStage();
double seconds = (System.DateTimeOffset.Now - started).TotalSeconds;
if ( bless )
ReplayRegression.SaveBaselines( traces, seconds );
else
ReplayRegression.CompareAndReport( traces, skipped, seconds );
}
// Advance one whole recorded run headless, folding the sim state into a rolling hash after every
// step and emitting a checkpoint each sim-second (see ReplayRegression.FoldStep). Mirrors the
// silent fast-forward in SeekReplayToFrame: reseed + fresh replay GameStage, then the shared step
// primitive until the run reproduces its end or exhausts its recorded input.
private RegressionTrace RunReplayHeadless( LocalReplayEntry entry )
{
RunData data = entry.Run;
var trace = new RegressionTrace { Label = ReplayRegression.LabelFor( entry ) };
_replayData = data;
_replay = new RunPlayback( data );
// A test-play local replay embeds its (unsaved / mid-edit) level, which the persistent registry
// can't resolve — register it as a transient exactly like StartReplay does, or the headless run
// resolves the id to the CURRENT on-disk level and replays different geometry (a deterministic
// false desync). ResetReplayState (finally, below) clears it between batch runs.
if ( data.LevelData is not null )
Levels.RegisterTransient( data.LevelData );
_replaySubmitter = default;
IsReplaying = true;
_replayPaused = false;
_replayFinished = false;
_replayDesynced = false;
_hijacked = false;
_hijackFrame = -1;
_accumulator = 0f;
bool prevSuppress = Audio.SuppressSfx;
bool prevHaptics = Haptics.Suppress;
Audio.SuppressSfx = true;
Haptics.Suppress = true;
try
{
SetStage( new GameStage( this, isReplay: true ) );
ApplyPendingStage();
uint hash = ReplayRegression.FNV_OFFSET;
int lastCheckpoint = 0;
// Freeze (hit-stop) steps consume no recorded frame, so bound the loop well above the
// recorded length rather than trusting it to terminate — hit-stop is a few frames per
// slam, never a whole run.
int safety = data.StepCount * 4 + 4096;
while ( safety-- > 0 )
{
var result = AdvanceOneSimStep( SimStepInput.Replay );
if ( result == SimStepResult.InputExhausted )
{
FinishReplayOnInputExhausted();
break;
}
if ( Stage is GameStage gs )
{
hash = ReplayRegression.FoldStep( gs, hash );
if ( _replay.Position != lastCheckpoint && _replay.Position % ReplayRegression.CHECKPOINT_EVERY == 0 )
{
lastCheckpoint = _replay.Position;
trace.Checkpoints.Add( new RegressionCheckpoint
{
Step = _replay.Position,
Hash = hash,
PlayerX = gs.Player?.X ?? 0f,
PlayerY = gs.Player?.Y ?? 0f,
} );
}
}
if ( result == SimStepResult.StageEnded )
break;
}
trace.FinishedAtStep = _replay?.Position ?? 0;
trace.FinalHash = hash;
trace.DesyncedVsRecording = _replayDesynced;
}
finally
{
Audio.SuppressSfx = prevSuppress;
Haptics.Suppress = prevHaptics;
ResetReplayState();
}
return trace;
}
/// <summary>End the current replay and return to the leaderboard. Idempotent. Refused while a
/// wipe is running: the return transition below would be swallowed by the active one, whose queued
/// stage would then apply against the torn-down replay state (a replay GameStage seeded as a live
/// run).</summary>
public void EndReplay()
{
if ( !IsReplaying || StageSwapPending )
return;
ResetReplayState();
// Return to whichever screen launched the replay: MY REPLAYS archive if we came from there,
// otherwise the leaderboard. Both restore the row that was selected when the replay launched.
if ( _replayReturnToScore )
TransitionToStage( _replayReturnScore.Create( this ) );
else if ( _replayFromLocalReplays )
TransitionToStage( new LocalReplaysStage( this, _replayReturnSelection ) );
else if ( !string.IsNullOrEmpty( _replayReturnDailyId ) )
TransitionToStage( new DailyChallengeStage( this, _replayReturnDailyId ) );
else if ( _replayReturnToLevelSelect )
TransitionToStage( new LevelSelectStage( this ) );
else if ( !string.IsNullOrEmpty( _replayReturnWorkshopLevelId ) )
TransitionToStage( new HighscoreStage( this, levelId: _replayReturnWorkshopLevelId,
returnSelection: _replayReturnSelection, backToWorkshop: true, returnScore: _replayReturnScore ) );
else
TransitionToStage( new HighscoreStage( this, returnSelection: _replayReturnSelection,
backToLevelSelect: _replayLeaderboardBackToLevelSelect, returnScore: _replayReturnScore ) );
_replayReturnDailyId = null;
_replayReturnToLevelSelect = false;
_replayLeaderboardBackToLevelSelect = false;
_replayReturnWorkshopLevelId = null;
_replayReturnSelection = -1;
_replayReturnScore = null;
_replayReturnToScore = false;
}
/// <summary>Advance the hold-to-restart timer and overlay fade one rendered frame. Real time (not
/// GameplayTime-scaled), so slow-mo can't stretch the hold. The displayed bar fill freezes at its
/// last value during the release fade-out (only the alpha animates), then clears once hidden.</summary>
private void TickRestartHold( bool holding )
{
float delay = Settings.Current.RestartButtonDelay;
if ( delay <= 0f )
{
ResetRestartHold();
return;
}
float fadeSeconds = Math.Min( RESTART_HOLD_FADE_SECONDS, delay * 0.3f );
if ( holding )
{
_restartHoldTime += Time.Delta;
_restartHoldProgress = Math.Clamp( _restartHoldTime / delay, 0f, 1f );
_restartHoldAlpha = Math.Min( 1f, _restartHoldAlpha + Time.Delta / fadeSeconds );
}
else
{
_restartHoldArmed = false;
_restartHoldTime = 0f;
_restartHoldAlpha = Math.Max( 0f, _restartHoldAlpha - Time.Delta / fadeSeconds );
if ( _restartHoldAlpha <= 0f )
_restartHoldProgress = 0f;
}
}
/// <summary>Drop the hold-to-restart overlay instantly (a restart is firing — the stage snap is a
/// hard visual cut anyway, and the fresh run must not start with a half-armed hold).</summary>
private void ResetRestartHold()
{
_restartHoldArmed = false;
_restartHoldTime = 0f;
_restartHoldAlpha = 0f;
_restartHoldProgress = 0f;
}
/// <summary>Restart the current run with a fresh random seed. Available during active live play and
/// after a death (see <see cref="GameStage.CanRestart"/>); callers gate on that.
/// Snaps straight to a new <see cref="GameStage"/> (no wipe) — ApplyPendingStage picks a new seed
/// and starts a fresh recording because _replay is null on a live run.</summary>
public void RestartRun()
{
// Refuse mid-swap: once a transition is in flight (e.g. the game-over wipe to the score
// tally), TickTransition will SetStage(_transNext) at full cover and clobber any run we
// start here — after RegisterAttemptStart already consumed a daily attempt for it. The
// _hideOverlayOnSwap frame is the same hazard in reverse: our SetStage would clobber the
// completed wipe's queued stage.
if ( Stage is not GameStage gs || !gs.CanRestart || StageSwapPending )
return;
// The restarted run replays the same level and character. Captured before SubmitRunOnce,
// which clears the current run context.
var context = CurrentRunContext;
string levelId = gs.Level?.Id;
string characterId = context.CharacterId;
// Bank the dead run's score + replay before we abandon it for a fresh one. Single-submit
// guarded inside GameStage, so this never double-submits with the score-tally path.
gs.SubmitRunOnce();
_accumulator = 0f;
// A daily restart re-runs the same day (same seed/layout) and consumes an attempt; a DEBUG
// daily restart counts only into the display-only debug file. CanRestart already verified
// attempts remain.
if ( context.IsDaily )
{
if ( !gs.IsDebugRun )
DailyChallengeProgress.RegisterAttemptStart( context.DailyId );
else
DailyChallengeProgress.RegisterDebugAttemptStart( context.DailyId );
_pendingRunContext = RunContext.Daily( context.DailyId, characterId );
SetStage( new GameStage( this ) { IsDebugRun = gs.IsDebugRun } );
return;
}
_pendingRunContext = RunContext.ForLevel( levelId, characterId );
SetStage( new GameStage( this ) );
}
/// <summary>Home button entry point: a live, limited-attempt daily run asks for confirmation first
/// (<see cref="GameStage.TryOpenLeaveConfirm"/>, whose LEAVE then calls <see cref="QuitToMenu"/>);
/// anything else quits straight away. Refused mid-wipe like the leave itself, so the prompt never
/// opens for a leave that would then be swallowed.</summary>
public void RequestQuitToMenu()
{
if ( StageSwapPending ) return;
if ( Stage is GameStage gs && gs.TryOpenLeaveConfirm( GameStage.LeaveAction.Home ) ) return;
QuitToMenu();
}
/// <summary>Back button / hotkey entry point; see <see cref="RequestQuitToMenu"/>.</summary>
public void RequestBackToMenu()
{
if ( StageSwapPending ) return;
if ( Stage is GameStage gs && gs.TryOpenLeaveConfirm( GameStage.LeaveAction.Back ) ) return;
BackToMenu();
}
/// <summary>Abandon whatever is on screen — a live run or a replay being watched — and return
/// all the way to the title screen. Any replay state is torn down first so the next run starts
/// clean. Refused while a wipe is running (same rule as <see cref="EndReplay"/>): the title
/// transition below would be swallowed by the active one, leaving the torn-down replay/test
/// state behind a stage that still expects it.</summary>
public void QuitToMenu()
{
if ( StageSwapPending )
return;
// If we're leaving a decided run (a death or a win) before its score tally opened, bank the
// score + replay first so it isn't lost. No-op on a live (undecided) run, a replay, or if the
// tally already submitted — GameStage.SubmitRunOnce guards all of those and double submits.
if ( Stage is GameStage gs )
gs.SubmitRunOnce();
if ( IsReplaying )
ResetReplayState();
else
{
// A live test run registered the unsaved editor level as a transient (StartLevelTest).
// Quitting via the home button skips ReturnFromTestPlay, so unshadow the persistent
// registry here too — otherwise every later run/preview/replay of this level id would
// resolve the mid-edit geometry for the rest of the session. The editor model itself is
// safe: OnExit already banked it via RememberLevelEditorSession.
if ( IsTestingLevel )
{
Levels.ClearTransient();
_testLevel = null;
_testEditorLevel = null;
}
// Restore normal-speed playing music in case a live run had altered it.
Audio.SetMusicPlaying( true );
}
_replayReturnDailyId = null;
_replayReturnToLevelSelect = false;
_replayLeaderboardBackToLevelSelect = false;
_replayReturnWorkshopLevelId = null;
_replayReturnSelection = -1;
_replayReturnScore = null;
_replayReturnToScore = false;
TransitionToStage( new TitleStage( this ) );
}
/// <summary>Return to the menu the current run/replay was launched from (the daily hub for a daily
/// run, otherwise the level-select map) rather than all the way to the title screen like
/// <see cref="QuitToMenu"/>. For a replay this defers to <see cref="EndReplay"/>, which already
/// restores whichever screen opened it.</summary>
public void BackToMenu()
{
// Same mid-wipe refusal as QuitToMenu: the transition below would be swallowed, leaving this
// method's side effects (submit, return-field clears) applied with no navigation.
if ( StageSwapPending )
return;
// Editor test runs launch from the level editor, not the level-select map. Reuse the same guarded
// return path as the test HUD's stop button and the Back input.
if ( Stage is GameStage { IsTest: true } testStage )
{
testStage.ExitTest();
return;
}
// Watching a replay: EndReplay knows the exact origin (MY REPLAYS / daily / level-select /
// leaderboard), so reuse it rather than duplicating that routing here.
if ( IsReplaying )
{
EndReplay();
return;
}
// Snapshot the context BEFORE submitting: SubmitRunOnce clears it on a decided run, and the
// routing below reads it (same rule as ScoreStage's snapshot-before-submit). Reading after
// sent a decided daily/workshop run to the level-select map instead of its own hub.
var ctx = CurrentRunContext;
// Bank a decided run's score + replay before leaving (same guard/rationale as QuitToMenu — a
// no-op on a live, undecided run).
if ( Stage is GameStage gs )
gs.SubmitRunOnce();
// Restore normal-speed playing music in case a live run had altered it.
Audio.SetMusicPlaying( true );
// Runs launch from the daily hub, the workshop screen or the level-select map (see
// StartDailyChallenge / StartLevel), so route back to whichever one applies.
_replayReturnDailyId = null;
_replayReturnToLevelSelect = false;
_replayLeaderboardBackToLevelSelect = false;
_replayReturnWorkshopLevelId = null;
_replayReturnSelection = -1;
_replayReturnScore = null;
_replayReturnToScore = false;
TransitionToStage( ctx.IsDaily
? new DailyChallengeStage( this, ctx.DailyId )
: ctx.Level?.IsWorkshop == true
? new WorkshopStage( this )
: new LevelSelectStage( this ) );
}
// Tear down all replay-playback state so we can leave a replay cleanly. Callers decide which
// screen to transition to afterwards.
private void ResetReplayState()
{
IsPlacingReaction = false;
CurrentReplayReactions?.Cancel();
CurrentReplayViews?.Cancel();
// Keep the cancelled counts and author for the outgoing HUD until ApplyPendingStage.
// Leaving the replay: drop any GIF-export preview/state so the next replay starts fresh.
EndGifPreview();
SavedGifConfig = null;
// Drop any embedded test-level transient registered for this replay (StartReplay) so it can't
// shadow a real level of the same id once we're back on a menu. No-op for normal replays.
Levels.ClearTransient();
IsReplaying = false;
_replayFinished = false;
_hijacked = false;
_hijackFrame = -1;
_replayData = null;
_replay = null;
_accumulator = 0f;
GameplayTime.Reset();
// Restore normal-speed, playing music for the screen we're returning to.
Audio.SetMusicPlaying( true );
Audio.SetMusicPitch( 1f );
}
}