Godot 4 authoring + debug presenter (Slice A)
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
EntityAuthoringabstract base class. - A
LevelLoaderC# adapter that walks a level scene tree (TileMapLayer+EntityAuthoringdescendants) and produces a populatedWorld. - A debug presenter: one
Sprite2Dper non-static entity, snap-positioned by grid coord, re-skinned per tick from the sim state. - Player input →
StepRunner.Stepwiring (arrows / WASD, grab, swing-left/right, wait). - One hand-built
TestLevel.tscndemonstrating 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,
OnOpencounter 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 to1; 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 Color → KeyBlue/Red/Yellow/Green |
KeyColor Color |
— (color baked into recipe choice) |
DoorAuthoring |
switch on Color → DoorBlue/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 Kind → PressurePlateBlue/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 Kind → Apple/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
- Construct fresh
World. Find firstTileMapLayerunderlevelRootand read its tile size; assert(16, 16)(throw otherwise — fail loudly). - For each
TileMapLayerdescendant oflevelRoot:- For each
cellinlayer.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);. (Statictag already added by terrain recipes; no double-add.)
- For each
- Snapshot all
EntityAuthoringdescendants oflevelRootinto aList<EntityAuthoring>before iterating (avoids mid-iterationQueueFreeinvalidation). - For each
authin 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();
- Resolve the player: scan spawned ids for the entity tagged
Player. Throw if zero. Warn + take first if multiple. - Validate
Pushableco-occupancy invariant (parent spec §4.7): warn + keep first when duplicates detected. - 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 →
LevelTransitionEventfires (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 smallNode2D+TileMapLayer(a couple of WallStrong + one WaterCell tile) +Entitiescontainer with scripted Player, Box, signal-driven Door(group=7), PressurePlate(group=7). CallsLevelLoader.Load. Asserts:- World has
Playerat expected GridPos. - World has
Box(Pushable + BlocksMovement) at expected GridPos. - World has
DoorwithSignalTarget(7, OpenWhilePressed)and noLockedBy. - World has
PressurePlate(7, requiresHeavy: false). - PlayerId resolves correctly.
- World has
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
- 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).
EntityAuthoring.QueueFree()mid-load — snapshot the descendants first, then iterate; documented in §6.2 step 3.- Sprite-load churn —
GD.Load<Texture2D>per refresh is wasteful;SpriteCatalogcachesTexture2Dper path. - InputMap clashing with
ui_*defaults — define explicitmove_up/grabactions; do not reuse Godot built-ins. - Tile-size assumption (16×16) —
LevelLoaderasserts; fails loudly otherwise. StairsUpvsStairsDownvisual distinction — kept out of the sim per parent spec §10 by recording the kind inLevelLoaderResult.StairsByEntity; see §5.5. The sim is unchanged.- The csproj rename (
Muscleman.Engine.Tests.csproj→Muscleman.Engine.csproj) requires the existingmake test-engineflow to still find the assembly. Mitigation: update the Makefile’s$(ENGINE_DIR)/Muscleman.Engine.Tests.csprojreference 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
Statictag; reconstructible from the TileMapLayer. Rendered by the TileMapLayer directly, not byPresenterNode.
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.Initializeand 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).