Godot 4 authoring + debug presenter (Slice A)

Status: Design approved by user, ready for implementation planning. Author: henri (with Claude) Date: 2026-05-14 Parent spec: 2026-05-03 Godot 4 + ECS rewrite design §§5–6 Slice B (follow-up): GD3 → JSON exporter + JSON loader + tutorial1 migration.

1. Goal and non-goals

Goal

Stand up the Godot 4 authoring + visual-runtime pipeline so a level can be hand-built in the editor and played end-to-end against the existing sim. End state: a small TestLevel.tscn exercises walk, push, grab/swing, plate→door, sinking, fruit eating, and key→door, rendered in Godot 4.6 mono.

In scope

  • A TileSet (resources/Tiles.tres) with static-terrain entries: floor, WallStrong, WallWeak, WaterCell. Built from GD3 mono sprites.
  • One parameterized entity scene per family (Player, Box, HeavyBox, Door, SecretDoor, Key, PressurePlate, Stairs, Fruit, Duck) backed by an EntityAuthoring-derived C# script with [Export] properties.
  • An EntityAuthoring abstract base class.
  • A LevelLoader C# adapter that walks a level scene tree (TileMapLayer + EntityAuthoring descendants) and produces a populated World.
  • A debug presenter: one Sprite2D per non-static entity, snap-positioned by grid coord, re-skinned per tick from the sim state.
  • Player input → StepRunner.Step wiring (arrows / WASD, grab, swing-left/right, wait).
  • One hand-built TestLevel.tscn demonstrating the full ruleset visually.
  • Headless-loader tests in the existing GdUnit4 engine lane.

Non-goals

  • GD3 → JSON exporter and JSON loader (Slice B).
  • GD3 level migration (Slice B).
  • Animated tweens, swing-arc interpolation, audio, UI/HUD (Phase 7).
  • Save/load round-trip (Phase 5).
  • Smashable container content spawning, OnOpen counter wiring, hammer, cauldron, bong, book, ghost (still NotImplementedException per Phase 4 plan).

2. Decisions inherited from brainstorming

Decision Choice
Slice depth Authoring + adapter + headless validation plus debug presenter and input.
Migration mode Exporter-based (deferred to Slice B).
Staging Slice A (this doc) before Slice B.
Entity scene shape One parameterized scene per family with [Export] fields.
Sprite source GD3 mono PNGs copied into muscleman-godot4/engine/sprites/.
TileSet content (initial) 1 floor variant, 4 strong-wall variants, 1 weak-wall, 1 water cell.
Player input bindings WASD/arrows for move, Space/J grab, Q/K swing-left, E/L swing-right, ./; wait.

3. Directory layout

muscleman-godot4/engine/
├── project.godot                            MODIFIED — name, InputMap, default scene
├── Muscleman.Engine.csproj                  RENAMED from Muscleman.Engine.Tests.csproj
├── src/                                     NEW
│   ├── Authoring/
│   │   ├── EntityAuthoring.cs               abstract base
│   │   ├── PlayerAuthoring.cs
│   │   ├── BoxAuthoring.cs
│   │   ├── HeavyBoxAuthoring.cs
│   │   ├── DoorAuthoring.cs                 [Export] KeyColor, IsSignalDriven, Group, Behavior
│   │   ├── SecretDoorAuthoring.cs           [Export] Group
│   │   ├── KeyAuthoring.cs                  [Export] KeyColor
│   │   ├── PressurePlateAuthoring.cs        [Export] PlateKind, Group
│   │   ├── StairsAuthoring.cs               [Export] IsUp, TargetLevelId, TargetSpawnId
│   │   ├── FruitAuthoring.cs                [Export] FruitKind
│   │   └── DuckAuthoring.cs
│   ├── Loader/
│   │   ├── TileRecipeMap.cs                 TileData → EntityRecipe (via custom data layers)
│   │   └── LevelLoader.cs                   scene-tree → World.Spawn + ApplyOverrides
│   ├── Presenter/
│   │   ├── PresenterDispatcher.cs           Dict<entityId, PresenterNode>; tick refresh
│   │   ├── PresenterNode.cs                 Sprite2D wrapper, snap-positioned
│   │   └── SpriteCatalog.cs                 (World, entityId) → res:// sprite path; cached Texture2D
│   ├── Input/
│   │   └── PlayerInput.cs                   InputMap polling → sim Input → StepRunner.Step
│   └── GameRoot.cs                          owns World + drives load + tick loop
├── scenes/                                  NEW
│   ├── GameRoot.tscn                        entry scene (set in project.godot)
│   ├── LevelRoot.tscn                       authoring template
│   ├── TestLevel.tscn                       hand-built demo level
│   └── entities/
│       ├── Player.tscn
│       ├── Box.tscn
│       ├── HeavyBox.tscn
│       ├── Door.tscn
│       ├── SecretDoor.tscn
│       ├── Key.tscn
│       ├── PressurePlate.tscn
│       ├── Stairs.tscn
│       ├── Fruit.tscn
│       └── Duck.tscn
├── resources/                               NEW
│   └── Tiles.tres                           TileSet with floor + walls + water
├── sprites/                                 NEW (curated mono PNG copies)
│   ├── characters/player_mono.png
│   ├── doors/door_blue_closed_mono.png, door_blue_open_mono.png, …
│   ├── keys/key_blue_mono.png, …
│   ├── misc/apple_mono.png, stairs_up_mono.png, …
│   ├── objects/duck_mono.png, fake_wall_mono.png, …
│   └── kenney_sheet/colored_transparent_packed_mono.png
├── addons/gdUnit4/                          existing
├── tests/
│   ├── ParityTests.cs                       existing — untouched
│   ├── LevelLoaderTests.cs                  NEW
│   └── LevelLoaderInvariantTests.cs         NEW
└── Makefile (root one updated)              MODIFIED — `make run` opens TestLevel

Rationale. Runtime C# lives in src/; GdUnit4 tests stay under tests/ per its convention. The single Godot-managed assembly (renamed Muscleman.Engine) compiles both. Scenes group by purpose (scenes/entities/ parallels GD3’s scene/gameobjects/). The sprites/ copy is curated — only the mono variants for recipes we author, ~25 PNGs rather than the full ~150 in /sprites/.

4. TileSet (static terrain)

resources/Tiles.tres is a Godot 4 TileSet with tile_size = 16×16 and three atlas sources:

Source name PNG Initial tile entries
walls sprites/kenney_sheet/colored_transparent_packed_mono.png 4 strong-wall variants + 1 weak-wall variant
water sprites/water_tiles_mono.png 1 entry
floor sprites/kenney_sheet/colored_transparent_packed_mono.png 1 entry

Two custom data layers on the TileSet:

  • recipe_id (int) — (int)EntityRecipe. WallStrong, WallWeak, WaterCell entries set this.
  • is_floor (int) — 0/1. Floor tiles set this to 1; the loader skips them.

src/Loader/TileRecipeMap.cs:

public static class TileRecipeMap
{
    public static EntityRecipe? Lookup(TileData? tileData)
    {
        if (tileData is null) return null;
        if (tileData.GetCustomData("is_floor").AsInt32() == 1) return null;
        var recipeId = tileData.GetCustomData("recipe_id").AsInt32();
        if (recipeId == 0) return null; // unset → skip (defensive)
        return (EntityRecipe)recipeId;
    }
}

Tile variants beyond the initial set are added by editing the TileSet in Godot’s editor; no code change required.

5. EntityAuthoring base + concrete authoring scripts

5.1 Base

public abstract partial class EntityAuthoring : Node2D
{
    public abstract EntityRecipe Recipe { get; }
    public virtual void ApplyOverrides(World world, int entityId) { }
}

5.2 Concrete authoring scripts

Class Recipe [Export] fields Override applied to sim state
PlayerAuthoring Player
BoxAuthoring Box
HeavyBoxAuthoring HeavyBox
KeyAuthoring switch on ColorKeyBlue/Red/Yellow/Green KeyColor Color — (color baked into recipe choice)
DoorAuthoring switch on ColorDoorBlue/Red/Yellow/Green KeyColor Color, bool IsSignalDriven, int Group, SignalBehavior Behavior If IsSignalDriven: world.Remove<LockedBy>(id); world.Set(id, new SignalTarget(Group, Behavior)).
SecretDoorAuthoring SecretDoor int Group world.Set(id, new SignalTarget(Group, SignalBehavior.OpenWhilePressed)) (overwrites the recipe’s scaffold group 100).
PressurePlateAuthoring switch on KindPressurePlateBlue/Red/Yellow/Green/Heavy enum PlateKind { Blue, Red, Yellow, Green, Heavy }, int Group world.Set(id, new PressurePlate(Group, RequiresHeavy: Kind==Heavy)) (overwrites the recipe’s scaffold group).
StairsAuthoring StairsUp if IsUp else StairsDown bool IsUp, string TargetLevelId, string TargetSpawnId world.Set(id, new LevelTransition(TargetLevelId, TargetSpawnId)).
FruitAuthoring switch on KindApple/Pear/Cheese/Coin enum FruitKind { Apple, Pear, Cheese, Coin }
DuckAuthoring Duck

5.3 Scene structure

Each entity scene has the shape:

EntityRoot (Node2D + <Family>Authoring script)
  └── Sprite2D                            ← preview texture set at editor time;
                                            runtime texture chosen by PresenterDispatcher

The Sprite2D’s editor-time texture is a single placeholder per family (e.g. door_blue_closed_mono.png for Door.tscn) so the inspector preview looks plausible. The runtime texture is overwritten by the presenter per tick.

5.4 Why one DoorAuthoring covers both keyed and signal-driven doors

A door has the same physical character (BlocksMovement + BlocksPush when closed; loses both when open). What varies is the open trigger: a LockedBy keyhole vs. a SignalTarget plate wire. The recipe sets the keyhole; the authoring script’s IsSignalDriven flag swaps it out for a plate wire at load time. This avoids PlateDoorAuthoring as a near-duplicate class.

Visual color caveat for signal-driven doors. SpriteCatalog.Pick reads LockedBy to choose the door color. Setting IsSignalDriven=true strips LockedBy, so the door renders via the “Door + SignalTarget” branch (white sprites). The [Export] KeyColor Color field has no visual effect on signal-driven doors in Slice A. Acceptable for debug-quality presentation — Phase 7’s full presenter can introduce per-door color metadata if needed.

5.5 Up vs Down sprite distinction (presenter-only, no sim change)

Parent spec §10 is explicit: “Up vs Down is a presenter-only distinction. The sim treats both identically.” Slice A respects this by keeping the up/down split out of the sim and tracking it in a side-table owned by the level loader:

public sealed class LevelLoaderResult
{
    public required World World { get; init; }
    public required int PlayerId { get; init; }
    public required Vector2I TilePixelSize { get; init; }
    public required IReadOnlyDictionary<int, StairsKind> StairsByEntity { get; init; }
}

public enum StairsKind { Up, Down }

StairsAuthoring.ApplyOverrides still sets LevelTransition on the entity (as in §5.2). After invoking ApplyOverrides, LevelLoader special-cases StairsAuthoring to record the kind:

// inside LevelLoader.Load, after auth.ApplyOverrides(world, id):
if (auth is StairsAuthoring s)
    stairsByEntity[id] = s.IsUp ? StairsKind.Up : StairsKind.Down;

The loader returns the built dictionary as part of LevelLoaderResult. SpriteCatalog.Pick receives the dictionary (passed via PresenterDispatcher.Initialize and stored as a field) and consults it when picking the sprite for Stairs-bearing entities.

No sim-side changes are made in Slice A. The RecipeFactory.StairsUp / StairsDown shared case stays as-is. The special-case is acceptable here because it’s a one-off presenter discriminator; if a second similar case appears later, generalize via a GetPresenterMetadata virtual on EntityAuthoring.

6. LevelLoader adapter

6.1 Public surface

public sealed class LevelLoaderResult
{
    public required World World { get; init; }
    public required int PlayerId { get; init; }
    public required Vector2I TilePixelSize { get; init; }
    public required IReadOnlyDictionary<int, StairsKind> StairsByEntity { get; init; }
}

public static class LevelLoader
{
    public static LevelLoaderResult Load(Node levelRoot);
}

6.2 Load steps

  1. Construct fresh World. Find first TileMapLayer under levelRoot and read its tile size; assert (16, 16) (throw otherwise — fail loudly).
  2. For each TileMapLayer descendant of levelRoot:
    • For each cell in layer.GetUsedCells():
      • var recipe = TileRecipeMap.Lookup(layer.GetCellTileData(cell)). Skip if null.
      • var pos = new GridPos(cell.X, cell.Y).
      • var id = RecipeFactory.Spawn(world, recipe.Value, pos);. (Static tag already added by terrain recipes; no double-add.)
  3. Snapshot all EntityAuthoring descendants of levelRoot into a List<EntityAuthoring> before iterating (avoids mid-iteration QueueFree invalidation).
  4. For each auth in the snapshot:
    • var pos = new GridPos(Mathf.RoundToInt(auth.Position.X / 16), Mathf.RoundToInt(auth.Position.Y / 16));
    • var id = RecipeFactory.Spawn(world, auth.Recipe, pos);
    • auth.ApplyOverrides(world, id);
    • auth.QueueFree();
  5. Resolve the player: scan spawned ids for the entity tagged Player. Throw if zero. Warn + take first if multiple.
  6. Validate Pushable co-occupancy invariant (parent spec §4.7): warn + keep first when duplicates detected.
  7. Return LevelLoaderResult { World, PlayerId, TilePixelSize: (16, 16) }.

6.3 LevelRoot template

scenes/LevelRoot.tscn is the template a level author duplicates. Structure:

LevelRoot (Node2D)
  ├── Floor (TileMapLayer)        ← decorative, recipe_id unset
  ├── Walls (TileMapLayer)        ← WallStrong + WallWeak
  ├── Water (TileMapLayer)        ← WaterCell
  └── Entities (Node2D)           ← container for entity .tscn instances
       └── (Player + props dragged in here)

LevelLoader finds layers and authoring nodes via deep descendant walks; the template grouping is purely for editor ergonomics.

7. Debug presenter

7.1 SpriteCatalog

SpriteCatalog is an instance (not static) so it can carry the StairsByEntity dictionary from LevelLoaderResult without using a static field:

public sealed class SpriteCatalog
{
    private readonly IReadOnlyDictionary<int, StairsKind> _stairsByEntity;
    private readonly Dictionary<string, Texture2D> _cache = new();

    public SpriteCatalog(IReadOnlyDictionary<int, StairsKind> stairsByEntity)
    {
        _stairsByEntity = stairsByEntity;
    }

    public Texture2D Pick(World w, int entityId);
}

State-driven sprite selection. Cases:

Recipe / state Sprite path
Player res://sprites/characters/player_mono.png
Player + Holding ≠ null res://sprites/characters/player_carrying_mono.png
Box (not sunk) res://sprites/kenney_sheet/colored_transparent_packed_mono.png (region — use a dedicated box_mono.png if available)
HeavyBox + Sinkable res://sprites/misc/stump_mono.png
HeavyBox sunk (no Sinkable, Walkable) res://sprites/misc/stump_sunk_mono.png
Door + LockedBy(c) + closed res://sprites/doors/door_<c>_closed_mono.png
Door + DoorOpen res://sprites/doors/door_<c>_open_mono.png (or door_white_open_mono.png if no LockedBy and no captured color)
Door + SignalTarget + closed res://sprites/doors/door_white_closed_mono.png
Key res://sprites/keys/key_<c>_mono.png
PressurePlate (light) res://sprites/pressure_plates/pressure_plate_<color>_mono.png keyed by the authoring PlateKind; if the matching PNG is absent from the curated copy, copy it from /sprites/pressure_plates/ at sprite-curation time
PressurePlate (heavy) res://sprites/pressure_plates/pressure_plate_heavy_mono.png
Stairs (StairsByEntity = Up) res://sprites/misc/stairs_up_mono.png
Stairs (StairsByEntity = Down) res://sprites/misc/stairs_down_mono.png
Apple/Pear/Cheese res://sprites/misc/<name>_mono.png
Coin res://sprites/objects/coin_mono.png
Duck res://sprites/objects/duck_mono.png

Textures are cached in a Dictionary<string, Texture2D> on first load; Pick returns the cached Texture2D directly.

7.2 PresenterNode

public partial class PresenterNode : Node2D
{
    public int EntityId { get; init; }
    public Sprite2D Sprite { get; private set; } = null!;
    public override void _Ready() { Sprite = new Sprite2D(); AddChild(Sprite); }
    public void Refresh(World w, Vector2I tilePixelSize, SpriteCatalog catalog)
    {
        var pos = w.Get<Components.Position>(EntityId).Pos;
        Position = new Vector2(pos.X * tilePixelSize.X, pos.Y * tilePixelSize.Y);
        Sprite.Texture = catalog.Pick(w, EntityId);
    }
}

7.3 PresenterDispatcher

public sealed partial class PresenterDispatcher : Node2D
{
    private readonly Dictionary<int, PresenterNode> _nodes = new();
    private World _world = null!;
    private Vector2I _tilePixelSize;
    private SpriteCatalog _catalog = null!;

    public void Initialize(LevelLoaderResult result)
    {
        _world = result.World;
        _tilePixelSize = result.TilePixelSize;
        _catalog = new SpriteCatalog(result.StairsByEntity);
        foreach (var entity in _world.Store.Query<Components.Position>().Entities)
        {
            if (entity.Tags.Has<Components.Static>()) continue;
            Attach(entity.Id);
        }
        RefreshAll();
    }

    public void OnTick(EventBatch _)
    {
        // Snap-to-state: refresh all live entities, drop dead ones, add new ones.
        foreach (var id in _nodes.Keys.ToArray())
        {
            if (!_world.IsAlive(id)) { _nodes[id].QueueFree(); _nodes.Remove(id); }
        }
        foreach (var entity in _world.Store.Query<Components.Position>().Entities)
        {
            if (entity.Tags.Has<Components.Static>()) continue;
            if (!_nodes.ContainsKey(entity.Id)) Attach(entity.Id);
        }
        RefreshAll();
    }

    private void Attach(int id) { var n = new PresenterNode { EntityId = id }; _nodes[id] = n; AddChild(n); }
    private void RefreshAll() { foreach (var n in _nodes.Values) n.Refresh(_world, _tilePixelSize, _catalog); }
}

Rationale for snap-to-state vs. per-event dispatch: this is a debug presenter. Phase 7 will replace OnTick with an event choreographer that tweens, plays audio, etc.

8. Player input + tick driver

8.1 InputMap actions (added to project.godot)

Action Keys Sim input
move_up W, Up Input.Move(GridDir.Up); held → repeat at 0.18s
move_down S, Down Input.Move(GridDir.Down)
move_left A, Left Input.Move(GridDir.Left)
move_right D, Right Input.Move(GridDir.Right)
grab Space, J Input.Grab() (edge-triggered)
swing_left Q, K Input.SwingLeft()
swing_right E, L Input.SwingRight()
wait ., ; Input.Wait()

8.2 PlayerInput node

public sealed partial class PlayerInput : Node
{
    [Export] public PresenterDispatcher Presenter { get; set; } = null!;
    private World _world = null!;
    private int _playerId;
    private double _moveHoldElapsed;
    private const double MoveRepeatSeconds = 0.18;

    public void Initialize(World world, int playerId) { _world = world; _playerId = playerId; }

    public override void _Process(double delta)
    {
        var sim = ReadInput(delta);
        if (sim is null) return;
        var batch = StepRunner.Step(_world, _playerId, sim);
        Presenter.OnTick(batch);
    }

    private Input? ReadInput(double delta)
    {
        // Edge-triggered actions use Godot.Input.IsActionJustPressed; held
        // movement uses Godot.Input.IsActionPressed gated by MoveRepeatSeconds.
        // Priority: edge actions before movement, so a grab+walk-in-same-frame
        // grabs first and walks next tick.
        // Implementation detail filled in by the task; this is the contract.
        return null;
    }
}

Late-press buffering (parent spec §6.3) is not in Slice A.

8.3 GameRoot

public partial class GameRoot : Node
{
    [Export] public PackedScene LevelScene { get; set; } = null!;
    [Export] public PresenterDispatcher Presenter { get; set; } = null!;
    [Export] public PlayerInput Input { get; set; } = null!;

    public override void _Ready()
    {
        var levelRoot = LevelScene.Instantiate<Node>();
        AddChild(levelRoot);
        var result = LevelLoader.Load(levelRoot);
        Presenter.Initialize(result);
        Input.Initialize(result.World, result.PlayerId);
    }
}

scenes/GameRoot.tscn is the entry scene (set in project.godot). Its LevelScene is TestLevel.tscn by default.

9. TestLevel.tscn

Hand-built level demonstrating the full feature set this slice must support. Approximate layout (~12×10 cells):

 . . . . . . . . . . . .
 . W W W W W W W W W W .
 . W . . . . . . . . W .
 . W . P B B . D . T W .       P=Player, B=Box, D=Door (blue, signal-driven, group=1)
 . W . . . . . p . . W .       p=Plate (blue, group=1), T=Stairs (target=tutorial1)
 . W . . . . . . . . W .
 . W . H w w w . . . W .       H=HeavyBox, w=WaterCell, K=Key
 . W . . . . . . K . W .
 . W . A A . . . . . W .       A=Apple, d=DoorRed (keyed)
 . W . . . . . d D . W .
 . W W W W W W W W W W .

(Exact layout chosen at implementation time; this is illustrative.)

Each feature this exercises:

  • Walk + push: player pushes B chain.
  • Heavy + sink: push H onto w → sinks; subsequently walkable.
  • Pressure plate → signal door: stand on p (blue plate, group=1) → D (signal-driven door) opens.
  • Keyed door: push K into d (red lock) → key consumed, door opens.
  • Fruit eaten: walk into A while pushing it against the wall → consumed.
  • Stairs: walk onto T → LevelTransitionEvent fires (no actual transition in this slice — Slice B/C; the event surfaces in the engine console).

10. Test strategy

10.1 xUnit (dotnet test muscleman-godot4/Muscleman.sln)

Unchanged. Sim tests stay green. No new xUnit tests in Slice A — the new code is engine-side and requires Godot.

10.2 GdUnit4 (make test-engine)

  • LevelLoaderTests.cs — programmatically constructs a small Node2D + TileMapLayer (a couple of WallStrong + one WaterCell tile) + Entities container with scripted Player, Box, signal-driven Door(group=7), PressurePlate(group=7). Calls LevelLoader.Load. Asserts:
    • World has Player at expected GridPos.
    • World has Box (Pushable + BlocksMovement) at expected GridPos.
    • World has Door with SignalTarget(7, OpenWhilePressed) and no LockedBy.
    • World has PressurePlate(7, requiresHeavy: false).
    • PlayerId resolves correctly.
  • LevelLoaderInvariantTests.cs — assert:
    • Missing Player → throws.
    • Two Pushables at the same cell → warning logged; first kept.
    • TileMapLayer with non-16×16 tile size → throws.

10.3 Manual smoke

Open TestLevel.tscn in the Godot 4 editor → Play. Walk, push, drop heavy on water, step on plate (door opens), push key into red door (door opens), eat an apple. Acceptance is binary — does the level play correctly.

11. Risks

  1. Godot 4.6 mono + TileMapLayer custom data + Friflo + GdUnit4 — combinatorially novel. Mitigation: TileSet built incrementally (one tile in the first task; verify roundtrip; add more).
  2. EntityAuthoring.QueueFree() mid-load — snapshot the descendants first, then iterate; documented in §6.2 step 3.
  3. Sprite-load churnGD.Load<Texture2D> per refresh is wasteful; SpriteCatalog caches Texture2D per path.
  4. InputMap clashing with ui_* defaults — define explicit move_up / grab actions; do not reuse Godot built-ins.
  5. Tile-size assumption (16×16)LevelLoader asserts; fails loudly otherwise.
  6. StairsUp vs StairsDown visual distinction — kept out of the sim per parent spec §10 by recording the kind in LevelLoaderResult.StairsByEntity; see §5.5. The sim is unchanged.
  7. The csproj rename (Muscleman.Engine.Tests.csprojMuscleman.Engine.csproj) requires the existing make test-engine flow to still find the assembly. Mitigation: update the Makefile’s $(ENGINE_DIR)/Muscleman.Engine.Tests.csproj reference and re-prime in the same task.

12. Glossary additions

  • Slice A — this slice: authoring + scene-tree adapter + debug presenter + input.
  • Slice B — follow-up: GD3 → JSON exporter + JSON loader + migrate one level.
  • Authoring node — a Godot Node2D with an EntityAuthoring-derived script attached. Represents an entity to be spawned at level load.
  • Static terrain — entities with the Static tag; reconstructible from the TileMapLayer. Rendered by the TileMapLayer directly, not by PresenterNode.

13. Open follow-ups (not in this slice)

  • Slice B: GD3 → JSON exporter dust-off, JSON loader in Muscleman.Core/LevelData/, migrate tutorial1.
  • Slice C / Phase 7: animated presenter (tweens, swing arcs, audio, UI).
  • Phase 5: save/load including PressurePlateSystem.Initialize and presenter rehydration from saved state.
  • Smashable + Contents + Combinable spawn pipeline (Hammer, Bong, Cauldron, Book, Ghost still NotImplementedException).
  • Late-press input buffering (parent spec §6.3).