diff --git a/Autoloads/EventBus.cs b/Autoloads/EventBus.cs
index bb24e52..4266885 100644
--- a/Autoloads/EventBus.cs
+++ b/Autoloads/EventBus.cs
@@ -154,6 +154,16 @@ public partial class EventBus : Node
[Signal] public delegate void CoinsChangedEventHandler(int totalCoins);
[Signal] public delegate void LivesChangedEventHandler(int lives);
+ ///
+ /// The Playthrough has no lives left. Raised as part of the same operation that spends the last
+ /// life, so a subscriber can never observe lives that have not been decremented yet — which is
+ /// the race that lets the death screen sit there forever on the last life.
+ ///
+ [Signal] public delegate void RunEndedEventHandler();
+
+ public static void EmitRunEnded()
+ => Instance?.EmitSignal(SignalName.RunEnded);
+
public static void EmitCoinsChanged(int totalCoins)
=> Instance?.EmitSignal(SignalName.CoinsChanged, totalCoins);
diff --git a/Autoloads/ProgressionEventRepublisher.cs b/Autoloads/ProgressionEventRepublisher.cs
new file mode 100644
index 0000000..5868093
--- /dev/null
+++ b/Autoloads/ProgressionEventRepublisher.cs
@@ -0,0 +1,72 @@
+using System.Collections.Generic;
+using Godot;
+using Mr.BrickAdventures.Progression;
+using Mr.BrickAdventures.scripts.Resources;
+
+namespace Mr.BrickAdventures.Autoloads;
+
+///
+/// Translates domain events into Godot signals on the .
+///
+///
+///
+/// The adapter half of ADR-0004. Aggregates raise plain records with no engine dependency; this is the
+/// single place that turns them into signals, which is what makes "state changed ⇒ signal fired"
+/// un-bypassable. Existing subscribers and scene wiring keep working unchanged.
+///
+///
+/// Not a Node — it has no lifecycle of its own. The composition root owns one and hands it every
+/// batch of drained events.
+///
+///
+public sealed class ProgressionEventRepublisher
+{
+ private readonly LevelCatalogue _catalogue;
+ private readonly SceneTree _tree;
+
+ public ProgressionEventRepublisher(LevelCatalogue catalogue, SceneTree tree)
+ {
+ _catalogue = catalogue;
+ _tree = tree;
+ }
+
+ public void Publish(IReadOnlyList events)
+ {
+ foreach (var @event in events)
+ Publish(@event);
+ }
+
+ private void Publish(ProgressionEvent @event)
+ {
+ switch (@event)
+ {
+ case LevelStarted started:
+ // The existing signal is keyed by array index because GhostManager names its replay
+ // files ghost_level_{index}.json. Transitional: once ghosts key on LevelId this
+ // becomes a straight pass-through and the index lookup goes away.
+ EventBus.EmitLevelStarted(IndexOf(started.Level), _tree.CurrentScene);
+ break;
+
+ case RunEnded:
+ EventBus.EmitRunEnded();
+ break;
+
+ case AchievementUnlocked unlocked:
+ EventBus.EmitAchievementUnlocked(unlocked.Achievement.Value);
+ break;
+
+ default:
+ GD.PushWarning($"No signal is published for domain event {@event.GetType().Name}.");
+ break;
+ }
+ }
+
+ private int IndexOf(LevelId level)
+ {
+ for (var i = 0; i < _catalogue.Levels.Count; i++)
+ if (_catalogue.Levels[i]?.Id == level.Value)
+ return i;
+
+ return -1;
+ }
+}
diff --git a/Autoloads/ProgressionRoot.cs b/Autoloads/ProgressionRoot.cs
new file mode 100644
index 0000000..f9bfc95
--- /dev/null
+++ b/Autoloads/ProgressionRoot.cs
@@ -0,0 +1,147 @@
+using Godot;
+using Mr.BrickAdventures.Progression;
+using Mr.BrickAdventures.Progression.Adapters;
+using Mr.BrickAdventures.scripts.Resources;
+
+namespace Mr.BrickAdventures.Autoloads;
+
+///
+/// Composition root for player progression: builds the stores, loads the Profile, and owns the
+/// .
+///
+///
+///
+/// The only Node in this area. The aggregates are plain objects, so their lifetime is no longer tied
+/// to the scene tree — which is why the operations below need no null-guarding of the store itself.
+///
+///
+/// Every mutating operation is wrapped so that draining and republishing cannot be forgotten. That is
+/// the one risk of the DrainEvents design, and wrapping collapses it to this file.
+///
+///
+public partial class ProgressionRoot : Node
+{
+ public static ProgressionRoot Instance { get; private set; }
+
+ [Export] public LevelCatalogue Catalogue { get; set; }
+
+ /// Lives a new Playthrough starts with. A tuning value, so it is authored here rather than baked into the domain.
+ [Export] public int StartingLives { get; set; } = 3;
+
+ private ProgressionService _service;
+ private ProgressionEventRepublisher _republisher;
+
+ /// Levels in campaign order, as the domain sees them.
+ public Campaign Campaign { get; private set; }
+
+ public override void _Ready()
+ {
+ Instance = this;
+
+ if (Catalogue == null)
+ {
+ GD.PushError("ProgressionRoot has no LevelCatalogue assigned; progression is unavailable.");
+ return;
+ }
+
+ foreach (var problem in Catalogue.Validate())
+ GD.PushError($"LevelCatalogue: {problem}");
+
+ Campaign = Catalogue.ToCampaign();
+
+ var directory = ProjectSettings.GlobalizePath("user://");
+ var playthroughs = new JsonPlaythroughStore(directory);
+ var profiles = new JsonProfileStore(directory);
+
+ _service = new ProgressionService(
+ LoadProfile(profiles), playthroughs, profiles,
+ Campaign, BuildSkillCatalogue(), Lives.Of(StartingLives));
+
+ _republisher = new ProgressionEventRepublisher(Catalogue, GetTree());
+ }
+
+ public override void _ExitTree()
+ {
+ if (Instance == this) Instance = null;
+ }
+
+ ///
+ /// A missing Profile is a first run. A corrupt one is reported loudly and replaced with an empty
+ /// Profile so the game still starts — but the file is left alone, so it can still be recovered.
+ ///
+ private static Profile LoadProfile(IProfileStore profiles)
+ {
+ var result = profiles.Load();
+
+ if (result.Outcome == LoadOutcome.Corrupt)
+ GD.PushError($"Profile could not be read and has been left untouched: {result.Problem}");
+
+ return result.Outcome == LoadOutcome.Loaded ? result.Value : Profile.Empty();
+ }
+
+ ///
+ /// Reads the authored upgrade tables into the domain's view of them. A Skill with no upgrade
+ /// entries yields an empty cost list, which the domain reports as not purchasable rather than
+ /// throwing on Upgrades[0].
+ ///
+ private static SkillCatalogue BuildSkillCatalogue()
+ {
+ var available = SkillManager.Instance?.AvailableSkills;
+ if (available == null) return SkillCatalogue.Empty();
+
+ var costs = new System.Collections.Generic.Dictionary>();
+
+ foreach (var skill in available)
+ {
+ if (skill == null || string.IsNullOrWhiteSpace(skill.Name)) continue;
+
+ var levels = new System.Collections.Generic.List();
+ foreach (var upgrade in skill.Upgrades)
+ levels.Add(Coins.Of(Mathf.Max(0, upgrade.Cost)));
+
+ costs[SkillId.Of(skill.Name)] = levels;
+ }
+
+ return SkillCatalogue.Of(costs);
+ }
+
+ #region Operations — each drains and republishes, so callers cannot forget to
+
+ public void StartPlaythrough(PlaythroughId id) => Run(() => _service.StartPlaythrough(id));
+
+ public bool EnterLevel(LevelId level)
+ {
+ if (_service == null) return false;
+
+ var entered = _service.EnterLevel(level);
+ Publish();
+ return entered;
+ }
+
+ public void RecordCoinsFound(Coins amount) => Run(() => _service.RecordCoinsFound(amount));
+
+ public void HandlePlayerDeath() => Run(() => _service.HandlePlayerDeath());
+
+ public void FinishLevel(LevelTime time) => Run(() => _service.FinishLevel(time));
+
+ public PurchaseOutcome PurchaseSkill(SkillId skill)
+ {
+ if (_service == null) return PurchaseOutcome.NotPlaying;
+
+ var outcome = _service.PurchaseSkill(skill);
+ Publish();
+ return outcome;
+ }
+
+ private void Run(System.Action operation)
+ {
+ if (_service == null) return;
+
+ operation();
+ Publish();
+ }
+
+ private void Publish() => _republisher?.Publish(_service.DrainEvents());
+
+ #endregion
+}
diff --git a/objects/progression_root.tscn b/objects/progression_root.tscn
new file mode 100644
index 0000000..a734334
--- /dev/null
+++ b/objects/progression_root.tscn
@@ -0,0 +1,9 @@
+[gd_scene load_steps=3 format=3]
+
+[ext_resource type="Script" path="res://Autoloads/ProgressionRoot.cs" id="1_script"]
+[ext_resource type="Resource" path="res://resources/levels/level_catalogue.tres" id="2_catalogue"]
+
+[node name="ProgressionRoot" type="Node"]
+script = ExtResource("1_script")
+Catalogue = ExtResource("2_catalogue")
+StartingLives = 3
diff --git a/project.godot b/project.godot
index 3c9be25..5f9324a 100644
--- a/project.godot
+++ b/project.godot
@@ -54,6 +54,7 @@ StatisticsEventHandler="*res://scripts/Events/StatisticsEventHandler.cs"
CoinStateHandler="*res://scripts/Events/CoinStateHandler.cs"
LivesStateHandler="*res://scripts/Events/LivesStateHandler.cs"
SkillCollectHandler="*res://scripts/Events/SkillCollectHandler.cs"
+ProgressionRoot="*res://objects/progression_root.tscn"
GameStateStore="*res://Autoloads/GameStateStore.cs"
GameManager="*res://objects/game_manager.tscn"