| name | screens |
| description | Screens in FlatRedBall2. Use when working with screen lifecycle, screen transitions, MoveToScreen, passing data between screens, CustomInitialize/CustomActivity/CustomDestroy, starting the first screen, pause menu, or pausing/resuming a screen. Trigger on any screen management or game state transition question. |
Screens in FlatRedBall2
A Screen is the top-level container for a game state — a level, a menu, a game-over sequence. Each screen owns its entities, collision relationships, Gum UI, and camera. Only one screen is active at a time.
Creating a Screen
Subclass Screen and override the lifecycle hooks you need:
public class GameScreen : Screen
{
private Factory<Player> _playerFactory = null!;
public override void CustomInitialize()
{
_playerFactory = new Factory<Player>(this);
_playerFactory.Create();
}
public override void CustomActivity(FrameTime time)
{
}
public override void CustomDestroy()
{
}
}
Lifecycle Order Each Frame
See engine-overview for the full 8-step frame loop. Key ordering for screens:
- Entity
CustomActivity runs before Screen CustomActivity — the screen always sees post-entity, post-collision state.
CustomInitialize runs once when the screen is activated, before the first frame.
What's Available on a Screen
| Property | Type | Notes |
|---|
Camera | Camera | This screen's camera — set background color, world bounds, position |
Engine | FlatRedBallService | Access to Random, Input, Audio, etc. |
ContentLoader | ContentLoader | Load textures, fonts, and other content |
RenderList | IReadOnlyList<IRenderable> | All renderables sorted and drawn each frame; use Add/Remove to modify |
Layers | List<Layer> | Named layers for render ordering |
Returning Data from a Sub-Screen
When a sub-screen (e.g., a battle screen) needs to pass results back to the parent, use the configure callback on the return transition. The configure runs before the destination screen's CustomInitialize, so the data is ready immediately:
MoveToScreen<ExplorationScreen>(s => s.ReturnedBattleResult = result);
public BattleResult? ReturnedBattleResult { get; set; }
public override void CustomInitialize()
{
if (ReturnedBattleResult != null) { }
}
If the return data must survive a full screen teardown (e.g., the destination is always freshly initialized and the caller doesn't retain a reference to set the configure payload), use a static field cleared in CustomInitialize. There is no push/pop screen stack by design — MoveToScreen + configure covers the common cases, and pause/HUD overlays belong in Gum:
public class ExplorationScreen : Screen
{
public static BattleResult? PendingReturn;
public override void CustomInitialize()
{
var result = PendingReturn;
PendingReturn = null;
if (result != null) { }
}
}
Navigating Between Screens
Call MoveToScreen<T>() from anywhere inside a screen (e.g., CustomActivity, a collision event, a Gum button click). The transition is deferred to the start of the next frame so the current frame completes cleanly.
MoveToScreen<MainMenuScreen>();
MoveToScreen<GameOverScreen>(s =>
{
s.Winner = "Player 1";
s.FinalScore = _score;
});
The old screen's CustomDestroy runs, its content is unloaded, and then the new screen's CustomInitialize runs — all in that order.
Passing Data to a Screen
Declare public properties on the destination screen. The configure callback receives the new screen instance before CustomInitialize is called, so the data is available immediately:
using Gum.Forms.Controls;
public class GameOverScreen : Screen
{
public string Winner { get; set; } = "";
public int FinalScore { get; set; }
public override void CustomInitialize()
{
var label = new Label();
label.Text = $"{Winner} wins! Score: {FinalScore}";
Add(label);
}
}
Caller:
MoveToScreen<GameOverScreen>(s =>
{
s.Winner = _winner;
s.FinalScore = _score;
});
Starting the First Screen
In Game1.Initialize, after FlatRedBallService.Default.Initialize(this):
FlatRedBallService.Default.Start<MainMenuScreen>();
Start<T> activates the first screen and accepts the same optional configure callback as MoveToScreen:
FlatRedBallService.Default.Start<GameScreen>(s => s.DebugMode = true);
Observability — Screen.Entities and SceneSnapshot
Screen.Entities exposes all managed entities as IReadOnlyList<Entity> (read-only — mutation still goes through Factory<T>). Combined with SceneSnapshot.Capture(screen), this lets test code inspect the full game state:
var harness = TestHarness.Create<GameScreen>();
harness.Screen.CustomInitialize();
harness.Step(10);
var snap = harness.Snapshot();
snap.OfType<Player>().ShouldHaveSingleItem();
snap.Named("coin").Count.ShouldBe(5);
snap.NearPoint(new Vector2(0, 0), 100f).ShouldNotBeEmpty();
Gotchas
- Delay before transitioning — to pause before a screen change (e.g., a flash effect),
await Engine.Time.DelaySeconds(t, Token) first, then call MoveToScreen<T>(). Never use Thread.Sleep — it freezes the render thread and blocks the game loop entirely.
MoveToScreen is deferred — the transition does not happen immediately. Code after MoveToScreen<T>() in the same frame still runs. If you want to stop processing, return after the call.
- Do not cache entity references across
MoveToScreen — the source screen's entities are destroyed on transition. Singletons and static fields must store data values only, not object references to entities.
- Save data is not a content asset — player save files (
PlayerData, inventory, progress) cannot be loaded via ContentLoader.Load<T>(). Use System.IO.File.ReadAllText + System.Text.Json.JsonSerializer directly.
- All entities and factories are destroyed automatically on screen change. You do not need to manually destroy them in
CustomDestroy.
- Gum elements are also cleared automatically — no teardown needed for elements added via
Add.
CustomDestroy is for external resources only — e.g., file handles or network connections you opened yourself.
- Do not call
MoveToScreen from CustomInitialize — the screen hasn't finished initializing yet. Use CustomActivity or an event callback.
- Restarting the current screen: call
RestartScreen(). The engine recreates a fresh instance of the same type and replays the most recently retained configure callback. Use this for death/retry. Like MoveToScreen, the transition is deferred to the next frame.
RestartScreen();
this.RestartScreen(s => s.LevelIndex++);
For "advance to next level" you can use either MoveToScreen<SameType> or the typed RestartScreen overload — both fully tear down and recreate. Use whichever reads more clearly at the call site.
Do not manually destroy entities or collision relationships before calling either — the engine handles all teardown automatically.
-
One configure slot, last-writer-wins. The engine retains a single configure callback per session. Start<T>(d) and MoveToScreen<T>(d) set it. RestartScreen(d) (typed extension) replaces it. Plain RestartScreen() replays whatever is currently in the slot.
-
Closure gotcha — prefer literals to captured locals. Because the retained callback is replayed against its current closure environment (not a snapshot), any mutable local the callback closed over will be re-read at restart time. Write s => s.LevelIndex = 3 rather than s => s.LevelIndex = level if level may have changed by restart time.
Hot-Reload Restart
When a content file changes on disk and you want to restart the screen without losing session state (player position, score, timer), use the hot-reload restart mode:
RestartScreen(RestartMode.HotReload);
This is identical to a death/retry restart with two extra calls bracketing the teardown:
SaveHotReloadState(state) runs on the OLD instance, before teardown — live game state is still intact, so you can read your fields and stuff them into the typed bag.
RestoreHotReloadState(state) runs on the NEW instance, after CustomInitialize — the level has been freshly built, then your restore patches saved values on top.
public override void SaveHotReloadState(HotReloadState state)
{
state.Set("score", _score);
state.Set("timeRemaining", _timeRemaining);
}
public override void RestoreHotReloadState(HotReloadState state)
{
_score = state.Get<int>("score");
_timeRemaining = state.Get<float>("timeRemaining");
}
HotReloadState is a typed key/value bag: Set<T>(key, value), Get<T>(key) (throws if missing), TryGet<T>(key, out value).
Plain RestartScreen() is RestartMode.DeathRetry and never calls these hooks — by design, so death/retry can't accidentally preserve stale state across a death.
Restore runs after CustomInitialize intentionally. CustomInitialize builds the level from scratch, then restore patches saved values on top. The reverse order would let CustomInitialize clobber whatever restore set.
The engine does not auto-preserve anything. Hot-reload preservation is entirely user-driven via Save/RestoreHotReloadState. In particular, preserve player position to avoid a jarring camera pop: if the player is restored to their pre-reload position, any CameraControllingEntity will follow them on the first frame and the camera lands correctly automatically. If you don't restore the player, the player respawns at the spawn marker and the camera snaps to the spawn point, even if you tried to preserve Camera.X/Y directly (the controller overwrites it on frame 1).
Canonical recipe:
public override void SaveHotReloadState(HotReloadState state)
{
state.Preserve(_player);
state.Set("score", _score);
}
public override void RestoreHotReloadState(HotReloadState state)
{
state.Restore(_player);
if (state.TryGet<int>("score", out var s)) _score = s;
}
state.Preserve(entity) / state.Restore(entity) bundle the eight kinematic properties (six for Camera, which has no rotation). Call them in matching order in Save and Restore — keys are auto-generated per type (Player_1, Player_2, etc.), so asymmetric call order silently swaps state between entities of the same type. For multi-instance scenarios where explicit names are clearer, pass the optional second argument: state.Preserve(_heroA, "heroA").
Use Set/Get/TryGet directly for everything else (score, timers, inventory) — the helper only covers motion-and-rotation.
Pausing
Screen has built-in pause support:
PauseThisScreen();
UnpauseThisScreen();
bool paused = IsPaused;
What pauses: entity physics, entity CustomActivity, collision processing.
What keeps running: Screen.CustomActivity, Gum UI, input — so pause-menu logic lives in CustomActivity and Gum overlays still update.
Typical pattern:
public override void CustomActivity(FrameTime time)
{
if (Engine.Input.Keyboard.WasKeyPressed(Keys.Escape))
{
if (IsPaused) { UnpauseThisScreen(); _pauseOverlay.IsVisible = false; }
else { PauseThisScreen(); _pauseOverlay.IsVisible = true; }
}
}
await-based delays from Engine.Time.DelaySeconds and DelayUntil are suspended while the screen is paused — screen time freezes, so timed tasks don't fire. DelayFrames is not pause-aware; frames always advance. See the timing skill for full async delay details.