diff --git a/Theriapolis.Godot/Platform/SaveSlotFormat.cs b/Theriapolis.Godot/Platform/SaveSlotFormat.cs
new file mode 100644
index 0000000..f5eb5a9
--- /dev/null
+++ b/Theriapolis.Godot/Platform/SaveSlotFormat.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Globalization;
+using Theriapolis.Core.Persistence;
+
+namespace Theriapolis.GodotHost.Platform;
+
+///
+/// Slot-picker label formatting. Pulls the in-game time from
+/// (e.g. "Howlwind — Y0 Spring D5
+/// (Tier 1)") and appends the wall-clock saved-at time parsed from
+/// , rendered in the player's local
+/// timezone with a relative label when recent.
+///
+/// Shared between (load picker
+/// from Title) and 's save picker
+/// so both surfaces present the same row format.
+///
+public static class SaveSlotFormat
+{
+ /// Composed row label: "{slot} — {in-game} · saved {when}".
+ public static string FormatRow(string slotPrefix, SaveHeader header)
+ => $"{slotPrefix} — {header.SlotLabel()} · saved {FormatSavedAt(header.SavedAtUtc)}";
+
+ /// Parses the SaveHeader's UTC saved-at timestamp and
+ /// renders it relative to now, in local time. Returns "<unknown>"
+ /// for empty / unparseable inputs so the row still shows something.
+ public static string FormatSavedAt(string savedAtUtc)
+ {
+ if (string.IsNullOrWhiteSpace(savedAtUtc)) return "";
+ if (!DateTime.TryParse(
+ savedAtUtc, CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
+ out var utc))
+ return savedAtUtc;
+
+ DateTime local = utc.ToLocalTime();
+ DateTime now = DateTime.Now;
+ if (local.Date == now.Date)
+ return $"today, {local:HH:mm}";
+ if (local.Date == now.Date.AddDays(-1))
+ return $"yesterday, {local:HH:mm}";
+ if (local.Year == now.Year)
+ return local.ToString("MMM d, HH:mm", CultureInfo.InvariantCulture);
+ return local.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
+ }
+}
diff --git a/Theriapolis.Godot/Scenes/PauseMenuScreen.cs b/Theriapolis.Godot/Scenes/PauseMenuScreen.cs
new file mode 100644
index 0000000..023735f
--- /dev/null
+++ b/Theriapolis.Godot/Scenes/PauseMenuScreen.cs
@@ -0,0 +1,306 @@
+using System;
+using System.IO;
+using Godot;
+using Theriapolis.Core;
+using Theriapolis.Core.Persistence;
+using Theriapolis.Core.Rules.Character;
+using Theriapolis.GodotHost.Platform;
+using Theriapolis.GodotHost.UI;
+
+namespace Theriapolis.GodotHost.Scenes;
+
+///
+/// M7.4 — pause overlay. Pushed by PlayScreen when Esc is pressed.
+/// Halts the game clock via GetTree().Paused = true so the
+/// player + streamer + controller all freeze; the overlay itself
+/// runs with ProcessModeEnum.WhenPaused so it stays
+/// responsive to input.
+///
+/// Two sub-states: shows the main menu
+/// (Resume / Level Up / Save Game / Quicksave / Quit). Clicking
+/// "Save Game" flips to — a per-slot
+/// row list that calls back into .
+/// Esc backs out of the slot picker to main, then closes the
+/// overlay on a second press.
+///
+/// Save-from-pause shows a status line inside the panel (separate
+/// from PlayScreen's save-flash toast, which is suppressed while
+/// the tree is paused).
+///
+public partial class PauseMenuScreen : CanvasLayer
+{
+ private readonly PlayScreen _playScreen;
+ private Control _root = null!;
+ private PanelContainer _panel = null!;
+ private VBoxContainer _content = null!;
+ private Label _statusLabel = null!;
+ private bool _showingSlots;
+ // Edge-detection for the Esc that opened the overlay — without this
+ // the same press both opens AND closes the menu on the next frame.
+ private bool _consumedOpeningEsc;
+
+ public PauseMenuScreen(PlayScreen playScreen)
+ {
+ _playScreen = playScreen ?? throw new ArgumentNullException(nameof(playScreen));
+ }
+
+ public override void _Ready()
+ {
+ Layer = 50;
+ ProcessMode = ProcessModeEnum.WhenPaused;
+ GetTree().Paused = true;
+
+ _root = new Control
+ {
+ MouseFilter = Control.MouseFilterEnum.Stop,
+ ProcessMode = ProcessModeEnum.WhenPaused,
+ };
+ _root.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
+ AddChild(_root);
+
+ // Half-opaque scrim so the world reads as backgrounded.
+ var scrim = new ColorRect
+ {
+ Color = new Color(0, 0, 0, 0.55f),
+ MouseFilter = Control.MouseFilterEnum.Ignore,
+ };
+ scrim.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
+ _root.AddChild(scrim);
+
+ var center = new CenterContainer { MouseFilter = Control.MouseFilterEnum.Ignore };
+ center.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
+ _root.AddChild(center);
+
+ // Apply the codex theme on the panel root so the cascade reaches
+ // every button (overlays mount outside the PlayScreen Control tree,
+ // so the parent theme doesn't cascade automatically).
+ _panel = new PanelContainer
+ {
+ ThemeTypeVariation = "Card",
+ Theme = CodexTheme.Build(),
+ CustomMinimumSize = new Vector2(360, 0),
+ };
+ center.AddChild(_panel);
+
+ var margin = new MarginContainer();
+ margin.AddThemeConstantOverride("margin_left", 28);
+ margin.AddThemeConstantOverride("margin_right", 28);
+ margin.AddThemeConstantOverride("margin_top", 20);
+ margin.AddThemeConstantOverride("margin_bottom", 20);
+ _panel.AddChild(margin);
+
+ _content = new VBoxContainer();
+ _content.AddThemeConstantOverride("separation", 10);
+ margin.AddChild(_content);
+
+ BuildMain();
+ }
+
+ private void BuildMain()
+ {
+ _showingSlots = false;
+ ClearContent();
+
+ _content.AddChild(new Label
+ {
+ Text = "PAUSED",
+ ThemeTypeVariation = "H2",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+
+ AddSpacer(8);
+
+ var resume = MakeMenuButton("Resume", primary: true);
+ resume.Pressed += Close;
+ _content.AddChild(resume);
+
+ // Level-up affordance — only when eligible. Disabled in M7 with a
+ // tooltip; the actual LevelUpScreen ships with M8.
+ var pc = _playScreen.PlayerCharacter();
+ if (pc is not null && LevelUpFlow.CanLevelUp(pc))
+ {
+ var lvlBtn = MakeMenuButton($"★ Level Up ({pc.Level} → {pc.Level + 1})", primary: false);
+ lvlBtn.Disabled = true;
+ lvlBtn.TooltipText = "Level-up screen ships with M8.";
+ _content.AddChild(lvlBtn);
+ }
+
+ var saveBtn = MakeMenuButton("Save Game", primary: false);
+ saveBtn.Pressed += BuildSlotPicker;
+ _content.AddChild(saveBtn);
+
+ var quickSave = MakeMenuButton("Quicksave (autosave slot)", primary: false);
+ quickSave.Pressed += OnQuicksave;
+ _content.AddChild(quickSave);
+
+ var quitBtn = MakeMenuButton("Quit to Title", primary: false);
+ quitBtn.Pressed += OnQuitToTitle;
+ _content.AddChild(quitBtn);
+
+ AddSpacer(6);
+
+ _statusLabel = new Label
+ {
+ Text = " ",
+ ThemeTypeVariation = "Eyebrow",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ AutowrapMode = TextServer.AutowrapMode.WordSmart,
+ };
+ _content.AddChild(_statusLabel);
+ }
+
+ private void BuildSlotPicker()
+ {
+ _showingSlots = true;
+ ClearContent();
+
+ _content.AddChild(new Label
+ {
+ Text = "SAVE TO SLOT",
+ ThemeTypeVariation = "Eyebrow",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ });
+ AddSpacer(4);
+
+ for (int i = 1; i <= C.SAVE_SLOT_COUNT; i++)
+ {
+ int slotNum = i;
+ string path = SavePaths.SlotPath(slotNum);
+ string prefix = $"Slot {slotNum:D2}";
+ string label = prefix;
+ if (File.Exists(path))
+ {
+ try
+ {
+ var bytes = File.ReadAllBytes(path);
+ var header = SaveCodec.DeserializeHeaderOnly(bytes);
+ label = SaveSlotFormat.FormatRow(prefix, header);
+ }
+ catch { label += " — "; }
+ }
+ else
+ {
+ label += " — ";
+ }
+
+ var btn = new Button
+ {
+ Text = label,
+ CustomMinimumSize = new Vector2(0, 36),
+ SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
+ Alignment = HorizontalAlignment.Left,
+ };
+ btn.Pressed += () => OnSaveToSlot(slotNum, path);
+ _content.AddChild(btn);
+ }
+
+ AddSpacer(6);
+ var back = MakeMenuButton("← Back", primary: false);
+ back.Pressed += BuildMain;
+ _content.AddChild(back);
+
+ _statusLabel = new Label
+ {
+ Text = " ",
+ ThemeTypeVariation = "Eyebrow",
+ HorizontalAlignment = HorizontalAlignment.Center,
+ AutowrapMode = TextServer.AutowrapMode.WordSmart,
+ };
+ _content.AddChild(_statusLabel);
+ }
+
+ private void OnQuicksave()
+ {
+ bool ok = _playScreen.SaveTo(SavePaths.AutosavePath());
+ ShowStatus(ok ? "Quicksaved." : "Quicksave failed.");
+ }
+
+ private void OnSaveToSlot(int slotNum, string path)
+ {
+ bool ok = _playScreen.SaveTo(path);
+ if (ok)
+ {
+ BuildMain();
+ ShowStatus($"Saved to slot {slotNum:D2}.");
+ }
+ else
+ {
+ ShowStatus("Save failed.");
+ }
+ }
+
+ private void OnQuitToTitle()
+ {
+ // Autosave on quit-to-title, matching MonoGame behaviour. A failed
+ // autosave doesn't block the quit — better to let the user leave
+ // than trap them with an angry dialog.
+ _playScreen.SaveTo(SavePaths.AutosavePath());
+
+ GetTree().Paused = false;
+ var playParent = _playScreen.GetParent();
+ if (playParent is not null)
+ {
+ foreach (Node child in playParent.GetChildren())
+ child.QueueFree();
+ playParent.AddChild(new TitleScreen());
+ }
+ QueueFree();
+ }
+
+ private void Close()
+ {
+ GetTree().Paused = false;
+ QueueFree();
+ }
+
+ public override void _UnhandledInput(InputEvent @event)
+ {
+ if (@event is not InputEventKey { Pressed: true, Keycode: Key.Escape } key) return;
+ if (key.Echo) return;
+
+ // The same physical Esc press that *opened* this overlay produces
+ // exactly one event. We're routed via _UnhandledInput on the layer,
+ // which dispatches AFTER PlayScreen.Input has run + consumed the
+ // event with SetInputAsHandled — so this branch fires on the NEXT
+ // Esc press. The _consumedOpeningEsc guard is a belt-and-braces
+ // for the case where input routing skips the consumed flag.
+ if (!_consumedOpeningEsc)
+ {
+ _consumedOpeningEsc = true;
+ return;
+ }
+
+ GetViewport().SetInputAsHandled();
+ if (_showingSlots) BuildMain();
+ else Close();
+ }
+
+ // ──────────────────────────────────────────────────────────────────────
+ // Layout helpers
+
+ private void ClearContent()
+ {
+ foreach (Node child in _content.GetChildren()) child.QueueFree();
+ }
+
+ private void AddSpacer(int height)
+ => _content.AddChild(new Control { CustomMinimumSize = new Vector2(0, height) });
+
+ private static Button MakeMenuButton(string text, bool primary)
+ {
+ var btn = new Button
+ {
+ Text = text,
+ FocusMode = Control.FocusModeEnum.None,
+ SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
+ CustomMinimumSize = new Vector2(0, 40),
+ };
+ if (primary) btn.ThemeTypeVariation = "PrimaryButton";
+ return btn;
+ }
+
+ private void ShowStatus(string text)
+ {
+ if (_statusLabel is not null) _statusLabel.Text = text;
+ }
+}
diff --git a/Theriapolis.Godot/Scenes/PlayScreen.cs b/Theriapolis.Godot/Scenes/PlayScreen.cs
index 697ebc3..b07397d 100644
--- a/Theriapolis.Godot/Scenes/PlayScreen.cs
+++ b/Theriapolis.Godot/Scenes/PlayScreen.cs
@@ -269,6 +269,12 @@ public partial class PlayScreen : Control
public override void _Input(InputEvent @event)
{
if (@event is not InputEventKey { Pressed: true } key) return;
+ if (key.Echo) return;
+ // Skip key events while the game is paused — the pause overlay
+ // owns input handling for itself; PlayScreen shouldn't see Esc/F5
+ // again until the overlay closes.
+ if (GetTree().Paused) return;
+
switch (key.Keycode)
{
case Key.F5:
@@ -277,11 +283,17 @@ public partial class PlayScreen : Control
break;
case Key.Escape:
GetViewport().SetInputAsHandled();
- BackToTitle();
+ AddChild(new PauseMenuScreen(this));
break;
}
}
+ /// Read-only accessor for the live player Character — used
+ /// by to surface the level-up affordance
+ /// when eligible.
+ public Theriapolis.Core.Rules.Character.Character? PlayerCharacter()
+ => _actors?.Player?.Character;
+
private Vector2 ScreenToWorld(Vector2 screenPos)
=> _render.Camera.GetCanvasTransform().AffineInverse() * screenPos;
@@ -594,7 +606,7 @@ public partial class PlayScreen : Control
$"{viewBlock}\n" +
$"Time: Day {_clock.Day}, {_clock.Hour:D2}:{_clock.Minute:D2}\n" +
$"{status}\n" +
- "F5 quicksaves · Esc → title (pause menu lands M7.4)";
+ "F5 quicksaves · Esc opens pause";
}
// ──────────────────────────────────────────────────────────────────────
diff --git a/Theriapolis.Godot/Scenes/SaveLoadScreen.cs b/Theriapolis.Godot/Scenes/SaveLoadScreen.cs
index 115a0e8..d4b31cd 100644
--- a/Theriapolis.Godot/Scenes/SaveLoadScreen.cs
+++ b/Theriapolis.Godot/Scenes/SaveLoadScreen.cs
@@ -86,7 +86,7 @@ public partial class SaveLoadScreen : Control
var header = SaveCodec.DeserializeHeaderOnly(bytes);
if (SaveCodec.IsCompatible(header))
{
- text = $"{label} — {header.SlotLabel()}";
+ text = SaveSlotFormat.FormatRow(label, header);
clickable = true;
}
else
diff --git a/Theriapolis.Godot/Scenes/TitleScreen.cs b/Theriapolis.Godot/Scenes/TitleScreen.cs
index aff246d..50ae3ce 100644
--- a/Theriapolis.Godot/Scenes/TitleScreen.cs
+++ b/Theriapolis.Godot/Scenes/TitleScreen.cs
@@ -18,7 +18,7 @@ namespace Theriapolis.GodotHost.Scenes;
///
public partial class TitleScreen : Control
{
- private const string VersionLabel = "PORT / GODOT · M7.3";
+ private const string VersionLabel = "PORT / GODOT · M7.4";
private const string WizardScenePath = "res://Scenes/Wizard.tscn";
public override void _Ready()