Godot 4 authoring + debug presenter (Slice A) Implementation Plan
Godot 4 authoring + debug presenter (Slice A) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
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 — walk, push, grab/swing, plates/doors, sinking, fruit, key-into-door — visible inside Godot 4.6 mono.
Architecture: Extend the existing muscleman-godot4/engine/ Godot project from “test harness only” to “game runtime + tests.” A TileSet with custom data layers carries static-terrain recipe ids. Parameterized entity scenes (one per family) carry per-instance overrides via EntityAuthoring-derived C# scripts. A LevelLoader adapter walks the scene tree → World.Spawn + overrides. A snap-to-state debug presenter mirrors entity positions/sprites; player input drives StepRunner.Step each frame.
Tech Stack: C# / .NET 9 · Godot 4.6.2 mono · Godot.NET.Sdk · Friflo.Engine.ECS 3.6.0 (already present) · GdUnit4Net 5.0.0 (engine-lane tests).
Spec reference
This plan implements docs/superpowers/specs/2026-05-14-gd4-authoring-and-debug-presenter-design.md. Read §3 (directory layout), §4 (TileSet + TileRecipeMap), §5 (EntityAuthoring), §6 (LevelLoader), §7 (presenter), §8 (input) before starting.
Spec-to-task coverage
| Spec section | Task(s) |
|---|---|
| §3 directory layout, csproj rename | 1 |
| §3 sprite copy | 2 |
| §4 TileSet + TileRecipeMap | 3, 4 |
| §5.1 EntityAuthoring base | 5 |
| §5.2 concrete authoring scripts | 6, 7 |
| §5.5 StairsKind side-table | 8 |
| §6 LevelLoader (TileMapLayer walk) | 9 |
| §6 LevelLoader (EntityAuthoring walk + overrides + player resolve) | 10 |
| §6 LevelLoader invariants | 11 |
| §5.3 entity .tscn scenes | 12 |
| §7.1 SpriteCatalog | 13 |
| §7.2 PresenterNode | 14 |
| §7.3 PresenterDispatcher | 14 |
| §8 InputMap + PlayerInput | 15 |
| §8.3 GameRoot + GameRoot.tscn | 16 |
| §9 TestLevel.tscn | 17 |
| Manual smoke + PR | 18 |
File structure target
muscleman-godot4/engine/
├── project.godot MODIFIED — name, InputMap, default scene
├── Muscleman.Engine.csproj RENAMED from Muscleman.Engine.Tests.csproj
├── src/ NEW
│ ├── Authoring/ 1 base + 10 concrete
│ ├── Loader/ TileRecipeMap, LevelLoader, LevelLoaderResult
│ ├── Presenter/ SpriteCatalog, PresenterNode, PresenterDispatcher
│ ├── Input/ PlayerInput
│ └── GameRoot.cs
├── scenes/ NEW
│ ├── GameRoot.tscn
│ ├── LevelRoot.tscn
│ ├── TestLevel.tscn
│ └── entities/ 10 entity .tscn files
├── resources/
│ └── Tiles.tres NEW TileSet
├── sprites/ NEW (curated mono PNG copies)
└── tests/
├── ParityTests.cs existing — untouched
├── LevelLoaderTests.cs NEW
└── LevelLoaderInvariantTests.cs NEW
Conventions used throughout
- Branch:
slice-a-gd4-authoring-presenter. All commits go there. Final PR targetsmain. - Build verification after each task:
make -C muscleman-godot4 test-engine(also runsdotnet build). - Sim-side suite (
make test-simordotnet test muscleman-godot4/Muscleman.sln) must remain green — Slice A introduces no sim-side changes. - Commit messages follow the repo’s
engine:,engine-tests:,engine-scenes:prefix convention. IncludeCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>. - C# namespace conventions:
Muscleman.Engine.Authoring— authoring scriptsMuscleman.Engine.Loader— adapter + resultMuscleman.Engine.Presenter— visual mirrorMuscleman.Engine.Input— player input
.tscn/.tresfiles use Godot 4’s text format. Reference for syntax: any existing Godot 4 doc; or the Slice-A repo will accumulate examples after Task 3.- All
Sprite2Dtextures in entity .tscn files use a single placeholder per family (e.g.Door.tscnusesdoor_blue_closed_mono.png) so the inspector preview is meaningful. The runtime texture is set by the presenter every tick.
Task 0 (one-shot prelude): create the work branch
Files: (no file changes, just git state)
- Step 1: Create and switch to feature branch
git -C /home/henri/muscleman checkout -b slice-a-gd4-authoring-presenter
Expected: switches to a new branch.
- Step 2: Verify clean tree at HEAD of main
git -C /home/henri/muscleman status --short
Expected: empty output (no untracked / modified files).
No commit — this task is git-state only.
Task 1: Rename csproj and bootstrap directory structure
Files:
- Rename:
muscleman-godot4/engine/Muscleman.Engine.Tests.csproj→muscleman-godot4/engine/Muscleman.Engine.csproj - Modify:
muscleman-godot4/engine/project.godot(assembly_name) - Modify:
muscleman-godot4/Makefile(csproj reference) -
Create directories:
engine/src/Authoring/,engine/src/Loader/,engine/src/Presenter/,engine/src/Input/,engine/scenes/,engine/scenes/entities/,engine/resources/,engine/sprites/characters/,engine/sprites/doors/,engine/sprites/keys/,engine/sprites/misc/,engine/sprites/objects/,engine/sprites/pressure_plates/,engine/sprites/kenney_sheet/ - Step 1: Rename the csproj
git -C /home/henri/muscleman mv muscleman-godot4/engine/Muscleman.Engine.Tests.csproj \
muscleman-godot4/engine/Muscleman.Engine.csproj
- Step 2: Update the csproj’s RootNamespace + AssemblyName
Edit muscleman-godot4/engine/Muscleman.Engine.csproj. Replace these two lines:
<RootNamespace>Muscleman.Engine.Tests</RootNamespace>
<AssemblyName>Muscleman.Engine.Tests</AssemblyName>
with:
<RootNamespace>Muscleman.Engine</RootNamespace>
<AssemblyName>Muscleman.Engine</AssemblyName>
- Step 3: Update project.godot assembly name
In muscleman-godot4/engine/project.godot, replace:
project/assembly_name="Muscleman.Engine.Tests"
with:
project/assembly_name="Muscleman.Engine"
And replace the header comment block:
; Engine-side test harness for the Muscleman sim.
; This project exists solely to host GdUnit4 parity tests under godot --headless.
; No scenes, no presenter; the sim runs inside Godot's mono runtime.
config_version=5
[application]
config/name="MusclemanEngineTests"
config/features=PackedStringArray("4.6", "C#", "Forward Plus")
config/icon="res://icon.svg"
with:
; Godot 4 game runtime + engine-side parity tests for Muscleman.
; Hosts the authoring scenes, the LevelLoader adapter, the debug presenter,
; player input, and the GdUnit4 parity tests under godot --headless.
config_version=5
[application]
config/name="Muscleman"
config/features=PackedStringArray("4.6", "C#", "Forward Plus")
config/icon="res://icon.svg"
- Step 4: Update existing Makefile
Edit muscleman-godot4/Makefile. Replace:
test-engine: engine-prime
dotnet build $(ENGINE_DIR)/Muscleman.Engine.Tests.csproj
$(GODOT) --headless --path $(ENGINE_DIR) -s $(GDUNIT_RUNNER) -a res://tests -c --ignoreHeadlessMode
with:
test-engine: engine-prime
dotnet build $(ENGINE_DIR)/Muscleman.Engine.csproj
$(GODOT) --headless --path $(ENGINE_DIR) -s $(GDUNIT_RUNNER) -a res://tests -c --ignoreHeadlessMode
# Open the Godot editor on TestLevel.tscn for manual play-test.
run:
$(GODOT) --path $(ENGINE_DIR) --editor
Append run to .PHONY:
.PHONY: test test-sim test-engine engine-prime clean run
- Step 5: Create the runtime + scene directories
mkdir -p /home/henri/muscleman/muscleman-godot4/engine/src/Authoring \
/home/henri/muscleman/muscleman-godot4/engine/src/Loader \
/home/henri/muscleman/muscleman-godot4/engine/src/Presenter \
/home/henri/muscleman/muscleman-godot4/engine/src/Input \
/home/henri/muscleman/muscleman-godot4/engine/scenes/entities \
/home/henri/muscleman/muscleman-godot4/engine/resources \
/home/henri/muscleman/muscleman-godot4/engine/sprites/characters \
/home/henri/muscleman/muscleman-godot4/engine/sprites/doors \
/home/henri/muscleman/muscleman-godot4/engine/sprites/keys \
/home/henri/muscleman/muscleman-godot4/engine/sprites/misc \
/home/henri/muscleman/muscleman-godot4/engine/sprites/objects \
/home/henri/muscleman/muscleman-godot4/engine/sprites/pressure_plates \
/home/henri/muscleman/muscleman-godot4/engine/sprites/kenney_sheet
- Step 6: Add
.gitkeepto the empty new dirs that won’t get files in Task 1
These directories will be populated in later tasks. Add a tracked sentinel so the diff shows the layout:
touch /home/henri/muscleman/muscleman-godot4/engine/src/Authoring/.gitkeep \
/home/henri/muscleman/muscleman-godot4/engine/src/Loader/.gitkeep \
/home/henri/muscleman/muscleman-godot4/engine/src/Presenter/.gitkeep \
/home/henri/muscleman/muscleman-godot4/engine/src/Input/.gitkeep \
/home/henri/muscleman/muscleman-godot4/engine/scenes/entities/.gitkeep \
/home/henri/muscleman/muscleman-godot4/engine/resources/.gitkeep \
/home/henri/muscleman/muscleman-godot4/engine/sprites/.gitkeep
(Per-sub-folder .gitkeep files under sprites/ aren’t needed; the parent one tracks the top-level layout; sub-dirs get tracked when sprite PNGs are added in Task 2.)
- Step 7: Verify build still passes
make -C /home/henri/muscleman/muscleman-godot4 test
Expected: both test-sim (139 xUnit) and test-engine (9 GdUnit4) green. The rename + reorg must not have broken anything.
- Step 8: Commit
git -C /home/henri/muscleman add -A
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: rename csproj and scaffold game-runtime directory layout
Muscleman.Engine.Tests.csproj → Muscleman.Engine.csproj (this project is
no longer test-only). project.godot assembly_name + name updated. Adds
src/{Authoring,Loader,Presenter,Input}, scenes/{,entities}, resources/,
sprites/ subdirs ready for the slice-A code drops. `make run` target
opens the editor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 2: Copy curated mono sprites
Files:
-
Create (via copy): ~25 mono PNGs under
muscleman-godot4/engine/sprites/ -
Step 1: Copy the sprite assets
Run from the repo root:
cd /home/henri/muscleman
# Characters
cp sprites/characters/player_mono.png muscleman-godot4/engine/sprites/characters/
cp sprites/characters/player_carrying_mono.png muscleman-godot4/engine/sprites/characters/
# Doors (closed + open for blue, red, yellow, green; plus the white plate-door pair)
for color in blue red yellow green white; do
for state in closed open; do
src="sprites/doors/door_${color}_${state}_mono.png"
if [ -f "$src" ]; then
cp "$src" muscleman-godot4/engine/sprites/doors/
fi
done
done
# Keys (4 colors)
for color in blue red yellow green; do
cp sprites/keys/key_${color}_mono.png muscleman-godot4/engine/sprites/keys/
done
# Misc (apple, pear, cheese, stairs_up, stairs_down, stump, stump_sunk)
for n in apple pear cheese stairs_up stairs_down stump stump_sunk; do
cp sprites/misc/${n}_mono.png muscleman-godot4/engine/sprites/misc/
done
# Objects (duck, coin)
cp sprites/objects/duck_mono.png muscleman-godot4/engine/sprites/objects/
cp sprites/objects/coin_mono.png muscleman-godot4/engine/sprites/objects/
# Pressure plates (blue, red, white, heavy — up + down variants)
for color in blue red white heavy; do
for state in up down; do
cp sprites/pressure_plates/pressure_plate_${color}_${state}_mono.png muscleman-godot4/engine/sprites/pressure_plates/
done
done
# Kenney sheet (for tileset)
cp sprites/kenney_sheet/colored_transparent_packed_mono.png muscleman-godot4/engine/sprites/kenney_sheet/
# Water (for tileset)
cp sprites/water_tiles_mono.png muscleman-godot4/engine/sprites/
- Step 2: Re-prime Godot import cache so the new pngs are imported
make -C /home/henri/muscleman/muscleman-godot4 clean
make -C /home/henri/muscleman/muscleman-godot4 engine-prime
(Running clean first ensures stale .godot/ state doesn’t shadow the new files. engine-prime walks the project, imports every PNG, and writes the .godot/global_script_class_cache.cfg sentinel.)
- Step 3: Verify imports created
ls /home/henri/muscleman/muscleman-godot4/engine/sprites/characters/player_mono.png.import 2>/dev/null && echo "OK"
Expected: OK. (Godot creates a .import sidecar per asset on first project open / --import. If the file isn’t there, the prime step didn’t run — re-run make engine-prime.)
- Step 4: Verify the engine test lane still passes after the asset shuffle
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 9/9 green.
- Step 5: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/sprites/
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: copy curated mono sprite assets
Adds ~25 mono PNGs under engine/sprites/ for use by entity scenes and
the TileSet. Sources: GD3 /sprites/. Includes the kenney sheet for
walls and the water tile sheet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 3: TileSet resource (resources/Tiles.tres)
Files:
- Create:
muscleman-godot4/engine/resources/Tiles.tres
The TileSet is a hand-written .tres text resource referencing the kenney sheet + water sheet, with two custom data layers (recipe_id, is_floor). Godot 4.6 TileSet syntax used.
- Step 1: Create the TileSet resource
Write muscleman-godot4/engine/resources/Tiles.tres:
[gd_resource type="TileSet" load_steps=3 format=3 uid="uid://b8tilesetslicea"]
[ext_resource type="Texture2D" uid="uid://b8kenneymono" path="res://sprites/kenney_sheet/colored_transparent_packed_mono.png" id="1_kenney"]
[ext_resource type="Texture2D" uid="uid://b8watermono" path="res://sprites/water_tiles_mono.png" id="2_water"]
[sub_resource type="TileSetAtlasSource" id="walls"]
texture = ExtResource("1_kenney")
margins = Vector2i(0, 0)
separation = Vector2i(0, 0)
texture_region_size = Vector2i(16, 16)
0:0/0 = 0
0:0/0/custom_data_0 = 1
1:0/0 = 0
1:0/0/custom_data_0 = 1
2:0/0 = 0
2:0/0/custom_data_0 = 1
3:0/0 = 0
3:0/0/custom_data_0 = 1
4:0/0 = 0
4:0/0/custom_data_0 = 2
[sub_resource type="TileSetAtlasSource" id="floor"]
texture = ExtResource("1_kenney")
margins = Vector2i(0, 0)
separation = Vector2i(0, 0)
texture_region_size = Vector2i(16, 16)
0:5/0 = 0
0:5/0/custom_data_1 = 1
[sub_resource type="TileSetAtlasSource" id="water"]
texture = ExtResource("2_water")
margins = Vector2i(0, 0)
separation = Vector2i(0, 0)
texture_region_size = Vector2i(16, 16)
0:0/0 = 0
0:0/0/custom_data_0 = 3
[resource]
tile_size = Vector2i(16, 16)
custom_data_layer_0/name = "recipe_id"
custom_data_layer_0/type = 2
custom_data_layer_1/name = "is_floor"
custom_data_layer_1/type = 2
sources/0 = SubResource("walls")
sources/1 = SubResource("floor")
sources/2 = SubResource("water")
Tile mapping summary:
wallssource (kenney sheet, mono): atlas coords (0,0)..(3,0) →recipe_id = 1(WallStrong); (4,0) →recipe_id = 2(WallWeak). The atlas coords map to actual wall sprite positions in the kenney sheet. (The actualEntityRecipeenum values are:Player=0, WallStrong=1, WallWeak=2, WaterCell=3, Box=4, HeavyBox=5, …— seeMuscleman.Core/Enums/EntityRecipe.cs.)floorsource: atlas coord (0,5) →is_floor = 1, norecipe_id. Loader skips this.watersource: atlas (0,0) →recipe_id = 3(WaterCell).- Custom data layer types:
2=int.
If the atlas coordinates don’t line up with usable wall sprites in the kenney sheet (the sheet has many decorative sprites in those positions), substitute the actual usable atlas positions when running this task. The exact (x, y) coordinates are best confirmed by opening the kenney sheet in an image viewer or in the Godot editor. Acceptable substitutions:
- WallStrong variants: any plain solid-fill 16×16 region.
-
WallWeak: a visually weaker wall (cracked, faded). The GD3 tileset used position (1, 1) of the kenney sheet for the strong solid wall — replicate.
- Step 2: Re-prime imports
make -C /home/henri/muscleman/muscleman-godot4 engine-prime
- Step 3: Verify the engine test lane still passes
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 9/9 green. The new TileSet doesn’t affect any test path yet, just needs to be valid syntax.
- Step 4: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/resources/Tiles.tres
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: add Tiles.tres TileSet with floor + walls + water sources
Two custom data layers: recipe_id (int) maps tiles to EntityRecipe
values; is_floor (int) flags decorative tiles for the loader to skip.
Initial entries: 4 strong-wall variants, 1 weak-wall, 1 floor, 1 water.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 4: TileRecipeMap + GdUnit4 test
Files:
- Create:
muscleman-godot4/engine/src/Loader/TileRecipeMap.cs - Modify:
muscleman-godot4/engine/src/Authoring/.gitkeep(delete since dir gets a real file) — handled bygit add -A. -
Create:
muscleman-godot4/engine/tests/TileRecipeMapTests.cs - Step 1: Write the failing test
muscleman-godot4/engine/tests/TileRecipeMapTests.cs:
using GdUnit4;
using Godot;
using Muscleman.Core.Enums;
using Muscleman.Engine.Loader;
using static GdUnit4.Assertions;
namespace Muscleman.Engine.Tests;
[TestSuite]
public class TileRecipeMapTests
{
[TestCase]
public void Lookup_returns_null_for_null_tile_data()
{
var result = TileRecipeMap.Lookup(null);
AssertThat(result.HasValue).IsFalse();
}
[TestCase]
public void Lookup_skips_floor_tiles()
{
var tileData = new TileData();
tileData.SetCustomDataByLayerId(1, 1); // is_floor (layer 1) = 1
tileData.SetCustomDataByLayerId(0, 99); // recipe_id (layer 0) set but should be ignored
var result = TileRecipeMap.Lookup(tileData);
AssertThat(result.HasValue).IsFalse();
}
[TestCase]
public void Lookup_returns_recipe_for_normal_tile()
{
var tileData = new TileData();
tileData.SetCustomDataByLayerId(0, (int)EntityRecipe.WallStrong);
tileData.SetCustomDataByLayerId(1, 0);
var result = TileRecipeMap.Lookup(tileData);
AssertThat(result.HasValue).IsTrue();
AssertThat((int)result!.Value).IsEqual((int)EntityRecipe.WallStrong);
}
[TestCase]
public void Lookup_returns_null_when_recipe_id_is_zero()
{
var tileData = new TileData();
// recipe_id not set, defaults to 0 — defensive skip
var result = TileRecipeMap.Lookup(tileData);
AssertThat(result.HasValue).IsFalse();
}
}
- Step 2: Run to verify the build fails (TileRecipeMap doesn’t exist)
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: build error, TileRecipeMap not found.
- Step 3: Implement TileRecipeMap
muscleman-godot4/engine/src/Loader/TileRecipeMap.cs:
using Godot;
using Muscleman.Core.Enums;
namespace Muscleman.Engine.Loader;
/// <summary>
/// Resolves a TileSet TileData record to an EntityRecipe via the two
/// custom data layers on the TileSet: "recipe_id" (int, the enum value)
/// and "is_floor" (int, 0/1).
/// </summary>
public static class TileRecipeMap
{
public static EntityRecipe? Lookup(TileData? tileData)
{
if (tileData is null) return null;
if (tileData.GetCustomDataByLayerId(1).AsInt32() == 1) return null; // is_floor
var recipeId = tileData.GetCustomDataByLayerId(0).AsInt32(); // recipe_id
if (recipeId == 0) return null; // unset/defensive
return (EntityRecipe)recipeId;
}
}
Note: we look up by layer id (0, 1) rather than by name ("recipe_id", "is_floor") because TileData.GetCustomDataByLayerId(int) is the stable Godot 4.6 API and matches the layer order in Tiles.tres. The TileSet’s custom_data_layer_0/name = "recipe_id" documents the convention for humans.
- Step 4: Run engine lane
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 9 prior + 4 new = 13 tests pass.
- Step 5: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/src/Loader/TileRecipeMap.cs \
muscleman-godot4/engine/tests/TileRecipeMapTests.cs
git -C /home/henri/muscleman rm -f muscleman-godot4/engine/src/Loader/.gitkeep
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: TileRecipeMap — TileData → EntityRecipe via custom data layers
Reads layer 0 (recipe_id) and layer 1 (is_floor) on the TileSet.
Returns null for floor tiles and for tiles with recipe_id unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 5: EntityAuthoring abstract base class
Files:
-
Create:
muscleman-godot4/engine/src/Authoring/EntityAuthoring.cs -
Step 1: Create the base
muscleman-godot4/engine/src/Authoring/EntityAuthoring.cs:
using Godot;
using Muscleman.Core.Enums;
using Muscleman.Sim;
namespace Muscleman.Engine.Authoring;
/// <summary>
/// Base class for level-authoring nodes. Each entity scene's root carries
/// an EntityAuthoring-derived script that exposes its sim Recipe and an
/// optional per-instance ApplyOverrides hook.
/// LevelLoader discovers these nodes via the scene tree.
/// </summary>
public abstract partial class EntityAuthoring : Node2D
{
public abstract EntityRecipe Recipe { get; }
public virtual void ApplyOverrides(World world, int entityId) { }
}
- Step 2: Verify build
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: build green, tests still 13/13.
- Step 3: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/src/Authoring/EntityAuthoring.cs
git -C /home/henri/muscleman rm -f muscleman-godot4/engine/src/Authoring/.gitkeep
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: EntityAuthoring abstract base for entity scenes
Each entity scene's root has an EntityAuthoring-derived script
exposing its sim Recipe and an optional ApplyOverrides hook.
LevelLoader discovers these via scene-tree walk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 6: Trivial authoring scripts (Player, Box, HeavyBox, Duck)
Files:
- Create:
muscleman-godot4/engine/src/Authoring/PlayerAuthoring.cs - Create:
muscleman-godot4/engine/src/Authoring/BoxAuthoring.cs - Create:
muscleman-godot4/engine/src/Authoring/HeavyBoxAuthoring.cs -
Create:
muscleman-godot4/engine/src/Authoring/DuckAuthoring.cs - Step 1: Create the four scripts
muscleman-godot4/engine/src/Authoring/PlayerAuthoring.cs:
using Godot;
using Muscleman.Core.Enums;
namespace Muscleman.Engine.Authoring;
public partial class PlayerAuthoring : EntityAuthoring
{
public override EntityRecipe Recipe => EntityRecipe.Player;
}
muscleman-godot4/engine/src/Authoring/BoxAuthoring.cs:
using Godot;
using Muscleman.Core.Enums;
namespace Muscleman.Engine.Authoring;
public partial class BoxAuthoring : EntityAuthoring
{
public override EntityRecipe Recipe => EntityRecipe.Box;
}
muscleman-godot4/engine/src/Authoring/HeavyBoxAuthoring.cs:
using Godot;
using Muscleman.Core.Enums;
namespace Muscleman.Engine.Authoring;
public partial class HeavyBoxAuthoring : EntityAuthoring
{
public override EntityRecipe Recipe => EntityRecipe.HeavyBox;
}
muscleman-godot4/engine/src/Authoring/DuckAuthoring.cs:
using Godot;
using Muscleman.Core.Enums;
namespace Muscleman.Engine.Authoring;
public partial class DuckAuthoring : EntityAuthoring
{
public override EntityRecipe Recipe => EntityRecipe.Duck;
}
- Step 2: Build + run tests
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 13/13 still green.
- Step 3: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/src/Authoring/PlayerAuthoring.cs \
muscleman-godot4/engine/src/Authoring/BoxAuthoring.cs \
muscleman-godot4/engine/src/Authoring/HeavyBoxAuthoring.cs \
muscleman-godot4/engine/src/Authoring/DuckAuthoring.cs
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: trivial authoring scripts — Player, Box, HeavyBox, Duck
Each binds to its recipe; no per-instance overrides.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 7: Parameterized authoring scripts (Key, Door, SecretDoor, PressurePlate, Stairs, Fruit)
Files:
- Create:
muscleman-godot4/engine/src/Authoring/KeyAuthoring.cs - Create:
muscleman-godot4/engine/src/Authoring/DoorAuthoring.cs - Create:
muscleman-godot4/engine/src/Authoring/SecretDoorAuthoring.cs - Create:
muscleman-godot4/engine/src/Authoring/PressurePlateAuthoring.cs - Create:
muscleman-godot4/engine/src/Authoring/StairsAuthoring.cs -
Create:
muscleman-godot4/engine/src/Authoring/FruitAuthoring.cs - Step 1: Create KeyAuthoring
muscleman-godot4/engine/src/Authoring/KeyAuthoring.cs:
using Godot;
using Muscleman.Core.Enums;
namespace Muscleman.Engine.Authoring;
public partial class KeyAuthoring : EntityAuthoring
{
[Export] public KeyColor Color { get; set; } = KeyColor.Blue;
public override EntityRecipe Recipe => Color switch
{
KeyColor.Blue => EntityRecipe.KeyBlue,
KeyColor.Red => EntityRecipe.KeyRed,
KeyColor.Yellow => EntityRecipe.KeyYellow,
KeyColor.Green => EntityRecipe.KeyGreen,
_ => throw new System.InvalidOperationException($"Unsupported key color: {Color}"),
};
}
- Step 2: Create DoorAuthoring
muscleman-godot4/engine/src/Authoring/DoorAuthoring.cs:
using Godot;
using Muscleman.Core.Enums;
using Muscleman.Sim;
using Muscleman.Sim.Components;
namespace Muscleman.Engine.Authoring;
public partial class DoorAuthoring : EntityAuthoring
{
[Export] public KeyColor Color { get; set; } = KeyColor.Blue;
[Export] public bool IsSignalDriven { get; set; } = false;
[Export] public int Group { get; set; } = 0;
[Export] public SignalBehavior Behavior { get; set; } = SignalBehavior.OpenWhilePressed;
public override EntityRecipe Recipe => Color switch
{
KeyColor.Blue => EntityRecipe.DoorBlue,
KeyColor.Red => EntityRecipe.DoorRed,
KeyColor.Yellow => EntityRecipe.DoorYellow,
KeyColor.Green => EntityRecipe.DoorGreen,
_ => throw new System.InvalidOperationException($"Unsupported door color: {Color}"),
};
public override void ApplyOverrides(World world, int entityId)
{
if (!IsSignalDriven) return;
if (world.Has<LockedBy>(entityId)) world.Remove<LockedBy>(entityId);
world.Set(entityId, new SignalTarget(Group, Behavior));
}
}
- Step 3: Create SecretDoorAuthoring
muscleman-godot4/engine/src/Authoring/SecretDoorAuthoring.cs:
using Godot;
using Muscleman.Core.Enums;
using Muscleman.Sim;
using Muscleman.Sim.Components;
namespace Muscleman.Engine.Authoring;
public partial class SecretDoorAuthoring : EntityAuthoring
{
[Export] public int Group { get; set; } = 100;
public override EntityRecipe Recipe => EntityRecipe.SecretDoor;
public override void ApplyOverrides(World world, int entityId)
{
world.Set(entityId, new SignalTarget(Group, SignalBehavior.OpenWhilePressed));
}
}
- Step 4: Create PressurePlateAuthoring
muscleman-godot4/engine/src/Authoring/PressurePlateAuthoring.cs:
using Godot;
using Muscleman.Core.Enums;
using Muscleman.Sim;
using Muscleman.Sim.Components;
namespace Muscleman.Engine.Authoring;
public partial class PressurePlateAuthoring : EntityAuthoring
{
public enum PlateKind { Blue, Red, Yellow, Green, Heavy }
[Export] public PlateKind Kind { get; set; } = PlateKind.Blue;
[Export] public int Group { get; set; } = 1;
public override EntityRecipe Recipe => Kind switch
{
PlateKind.Blue => EntityRecipe.PressurePlateBlue,
PlateKind.Red => EntityRecipe.PressurePlateRed,
PlateKind.Yellow => EntityRecipe.PressurePlateYellow,
PlateKind.Green => EntityRecipe.PressurePlateGreen,
PlateKind.Heavy => EntityRecipe.PressurePlateHeavy,
_ => throw new System.InvalidOperationException($"Unsupported plate kind: {Kind}"),
};
public override void ApplyOverrides(World world, int entityId)
{
world.Set(entityId, new PressurePlate(Group, requiresHeavy: Kind == PlateKind.Heavy));
}
}
- Step 5: Create StairsAuthoring
muscleman-godot4/engine/src/Authoring/StairsAuthoring.cs:
using Godot;
using Muscleman.Core.Enums;
using Muscleman.Sim;
using Muscleman.Sim.Components;
namespace Muscleman.Engine.Authoring;
public partial class StairsAuthoring : EntityAuthoring
{
[Export] public bool IsUp { get; set; } = true;
[Export] public string TargetLevelId { get; set; } = "";
[Export] public string TargetSpawnId { get; set; } = "";
public override EntityRecipe Recipe => IsUp ? EntityRecipe.StairsUp : EntityRecipe.StairsDown;
public override void ApplyOverrides(World world, int entityId)
{
world.Set(entityId, new LevelTransition(TargetLevelId, TargetSpawnId));
}
}
- Step 6: Create FruitAuthoring
muscleman-godot4/engine/src/Authoring/FruitAuthoring.cs:
using Godot;
using Muscleman.Core.Enums;
namespace Muscleman.Engine.Authoring;
public partial class FruitAuthoring : EntityAuthoring
{
public enum FruitKind { Apple, Pear, Cheese, Coin }
[Export] public FruitKind Kind { get; set; } = FruitKind.Apple;
public override EntityRecipe Recipe => Kind switch
{
FruitKind.Apple => EntityRecipe.Apple,
FruitKind.Pear => EntityRecipe.Pear,
FruitKind.Cheese => EntityRecipe.Cheese,
FruitKind.Coin => EntityRecipe.Coin,
_ => throw new System.InvalidOperationException($"Unsupported fruit kind: {Kind}"),
};
}
- Step 7: Build + tests
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 13/13 still green.
- Step 8: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/src/Authoring/
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: parameterized authoring scripts — Key, Door, SecretDoor, Plate, Stairs, Fruit
Each takes [Export] fields. Door routes keyed vs signal-driven via
IsSignalDriven flag. PressurePlate, SecretDoor, Stairs each apply
their per-instance overrides on top of the scaffold recipe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 8: LevelLoaderResult + StairsKind
Files:
-
Create:
muscleman-godot4/engine/src/Loader/LevelLoaderResult.cs -
Step 1: Create the result type + StairsKind enum
muscleman-godot4/engine/src/Loader/LevelLoaderResult.cs:
using System.Collections.Generic;
using Godot;
using Muscleman.Sim;
namespace Muscleman.Engine.Loader;
public enum StairsKind { Up, Down }
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; }
}
- Step 2: Verify build
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 13/13 green.
- Step 3: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/src/Loader/LevelLoaderResult.cs
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: LevelLoaderResult + StairsKind enum
Result carries the populated World, the player's entity id, the tile
pixel size for the presenter, and a side-table tracking each Stairs
entity's Up/Down kind (kept out of the sim per parent spec §10).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 9: LevelLoader.Load — TileMapLayer walk
Files:
- Create:
muscleman-godot4/engine/src/Loader/LevelLoader.cs -
Create:
muscleman-godot4/engine/tests/LevelLoaderTests.cs - Step 1: Write the failing test (TileMapLayer-only scenario)
muscleman-godot4/engine/tests/LevelLoaderTests.cs:
using System.Collections.Generic;
using GdUnit4;
using Godot;
using Muscleman.Core.Enums;
using Muscleman.Core.Grid;
using Muscleman.Engine.Loader;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using static GdUnit4.Assertions;
namespace Muscleman.Engine.Tests;
[TestSuite]
public class LevelLoaderTests
{
// Builds a TileSet with two custom data layers + one wall + one water atlas source.
// Returns a fresh TileSet usable by tests; tests can paint cells via TileMapLayer.SetCell.
private static TileSet BuildTestTileSet()
{
var ts = new TileSet { TileSize = new Vector2I(16, 16) };
ts.AddCustomDataLayer();
ts.SetCustomDataLayerName(0, "recipe_id");
ts.SetCustomDataLayerType(0, Variant.Type.Int);
ts.AddCustomDataLayer();
ts.SetCustomDataLayerName(1, "is_floor");
ts.SetCustomDataLayerType(1, Variant.Type.Int);
var wallTex = MakeBlankTexture();
var wallSource = new TileSetAtlasSource { Texture = wallTex, TextureRegionSize = new Vector2I(16, 16) };
wallSource.CreateTile(new Vector2I(0, 0));
var wallData = wallSource.GetTileData(new Vector2I(0, 0), 0);
wallData.SetCustomDataByLayerId(0, (int)EntityRecipe.WallStrong);
ts.AddSource(wallSource, 0);
var waterTex = MakeBlankTexture();
var waterSource = new TileSetAtlasSource { Texture = waterTex, TextureRegionSize = new Vector2I(16, 16) };
waterSource.CreateTile(new Vector2I(0, 0));
var waterData = waterSource.GetTileData(new Vector2I(0, 0), 0);
waterData.SetCustomDataByLayerId(0, (int)EntityRecipe.WaterCell);
ts.AddSource(waterSource, 1);
return ts;
}
private static ImageTexture MakeBlankTexture()
{
var img = Image.CreateEmpty(16, 16, false, Image.Format.Rgba8);
return ImageTexture.CreateFromImage(img);
}
[TestCase]
public void TileMapLayer_walls_spawn_into_world_as_Static_BlocksMovement_entities()
{
var ts = BuildTestTileSet();
var levelRoot = new Node2D();
var wallLayer = new TileMapLayer { TileSet = ts };
levelRoot.AddChild(wallLayer);
// Paint two cells: (2, 3) and (5, 7), both with the wall atlas source (id 0).
wallLayer.SetCell(new Vector2I(2, 3), sourceId: 0, atlasCoords: new Vector2I(0, 0));
wallLayer.SetCell(new Vector2I(5, 7), sourceId: 0, atlasCoords: new Vector2I(0, 0));
// Add a Player so Load doesn't throw on missing player.
var player = new Authoring.PlayerAuthoring { Position = new Vector2(16, 16) };
levelRoot.AddChild(player);
var result = LevelLoader.Load(levelRoot);
// Two walls + one player in the World.
AssertThat(result.TilePixelSize).IsEqual(new Vector2I(16, 16));
AssertHasEntityAt(result.World, new GridPos(2, 3), needTag: typeof(WallStrong));
AssertHasEntityAt(result.World, new GridPos(5, 7), needTag: typeof(WallStrong));
}
private static void AssertHasEntityAt(World w, GridPos pos, System.Type needTag)
{
foreach (var id in w.Index.AllAt(pos))
{
// Reflection-free tag check via switch on a few known cases (extend as needed).
if (needTag == typeof(WallStrong) && w.HasTag<WallStrong>(id)) return;
if (needTag == typeof(WaterCell) && w.HasTag<WaterCell>(id)) return;
if (needTag == typeof(Player) && w.HasTag<Player>(id)) return;
}
AssertThat(false).OverrideFailureMessage($"No entity with tag {needTag.Name} at {pos}").IsTrue();
}
}
- Step 2: Verify the failure (LevelLoader doesn’t exist)
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: build error.
- Step 3a: Expose
World.Storepublicly so engine code can query Friflo
The engine project needs to call world.Store.Query<...>() from LevelLoader and PresenterDispatcher. World.Store is currently internal EntityStore Store => _store; (visible only inside Muscleman.Sim).
Edit muscleman-godot4/src/Muscleman.Sim/World.cs. Find the existing line:
internal EntityStore Store => _store;
Replace it with:
public EntityStore Store => _store;
Update the surrounding comment block to reflect the new contract:
// Internal access for queries (used by SpatialIndex, sim systems,
// and engine-side loaders/presenters).
public EntityStore Store => _store;
This is the only sim-side change Slice A makes; documented in the design doc §5.5 risk note as a known consequence of decoupling the engine layer.
- Step 3b: Implement LevelLoader (TileMapLayer walk + minimal player resolve)
muscleman-godot4/engine/src/Loader/LevelLoader.cs:
using System;
using System.Collections.Generic;
using Godot;
using Muscleman.Core.Grid;
using Muscleman.Engine.Authoring;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Recipes;
namespace Muscleman.Engine.Loader;
public static class LevelLoader
{
public static LevelLoaderResult Load(Node levelRoot)
{
var world = new World();
// First TileMapLayer's tile size sets the level's tile-pixel scale.
var firstLayer = FindFirstDescendant<TileMapLayer>(levelRoot);
var tilePixelSize = firstLayer is null
? new Vector2I(16, 16)
: firstLayer.TileSet.TileSize;
if (tilePixelSize != new Vector2I(16, 16))
throw new InvalidOperationException(
$"LevelLoader requires 16×16 tiles; got {tilePixelSize}.");
// Walk all TileMapLayers.
foreach (var layer in FindAllDescendants<TileMapLayer>(levelRoot))
{
foreach (var cell in layer.GetUsedCells())
{
var data = layer.GetCellTileData(cell);
var recipe = TileRecipeMap.Lookup(data);
if (recipe is null) continue;
var pos = new GridPos(cell.X, cell.Y);
RecipeFactory.Spawn(world, recipe.Value, pos);
}
}
// Walk EntityAuthoring nodes (snapshot first — QueueFree invalidates iterators).
var authoringNodes = new List<EntityAuthoring>();
CollectDescendants(levelRoot, authoringNodes);
var stairsByEntity = new Dictionary<int, StairsKind>();
foreach (var auth in authoringNodes)
{
var pos = new GridPos(
Mathf.RoundToInt(auth.Position.X / tilePixelSize.X),
Mathf.RoundToInt(auth.Position.Y / tilePixelSize.Y));
var id = RecipeFactory.Spawn(world, auth.Recipe, pos);
auth.ApplyOverrides(world, id);
if (auth is StairsAuthoring s)
stairsByEntity[id] = s.IsUp ? StairsKind.Up : StairsKind.Down;
auth.QueueFree();
}
// Resolve the player.
var playerId = ResolvePlayerId(world);
return new LevelLoaderResult
{
World = world,
PlayerId = playerId,
TilePixelSize = tilePixelSize,
StairsByEntity = stairsByEntity,
};
}
private static int ResolvePlayerId(World world)
{
int? found = null;
var query = world.Store.Query().AllTags(Friflo.Engine.ECS.Tags.Get<Player>());
foreach (var entity in query.Entities)
{
if (found is null) found = entity.Id;
// Multiple Players — log + keep first.
else GD.PushWarning($"LevelLoader: multiple Player entities; keeping id={found.Value}");
}
if (found is null)
throw new InvalidOperationException("LevelLoader: no Player entity found in the level.");
return found.Value;
}
private static T? FindFirstDescendant<T>(Node root) where T : Node
{
if (root is T t) return t;
foreach (var child in root.GetChildren())
{
var found = FindFirstDescendant<T>(child);
if (found is not null) return found;
}
return null;
}
private static IEnumerable<T> FindAllDescendants<T>(Node root) where T : Node
{
if (root is T t) yield return t;
foreach (var child in root.GetChildren())
foreach (var inner in FindAllDescendants<T>(child))
yield return inner;
}
private static void CollectDescendants(Node root, List<EntityAuthoring> sink)
{
if (root is EntityAuthoring e) sink.Add(e);
foreach (var child in root.GetChildren())
CollectDescendants(child, sink);
}
}
- Step 4: Run the tests
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 14 tests pass (the new TileMapLayer test + 13 prior).
- Step 5: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/src/Loader/LevelLoader.cs \
muscleman-godot4/engine/tests/LevelLoaderTests.cs
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: LevelLoader.Load — TileMapLayer walk + player resolve
Walks every TileMapLayer descendant of the level root, spawns each
non-floor cell via TileRecipeMap. Walks EntityAuthoring nodes,
applies their overrides, special-cases StairsAuthoring to record
the Up/Down kind. Asserts 16×16 tile size. Throws if no Player.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 10: LevelLoader — EntityAuthoring exercise + override tests
Files:
-
Modify:
muscleman-godot4/engine/tests/LevelLoaderTests.cs— add cases -
Step 1: Append entity-side tests inside the same class
In muscleman-godot4/engine/tests/LevelLoaderTests.cs, add inside the existing LevelLoaderTests class:
[TestCase]
public void Entity_authoring_nodes_spawn_with_overrides_applied()
{
var levelRoot = new Node2D();
// Player at (3, 5) in tile coords → (48, 80) in pixels.
var player = new Authoring.PlayerAuthoring { Position = new Vector2(48, 80) };
levelRoot.AddChild(player);
// Signal-driven blue door at (6, 5), group=7, OpenWhilePressed.
var door = new Authoring.DoorAuthoring
{
Color = KeyColor.Blue,
IsSignalDriven = true,
Group = 7,
Behavior = SignalBehavior.OpenWhilePressed,
Position = new Vector2(96, 80),
};
levelRoot.AddChild(door);
// Pressure plate at (5, 5), group=7.
var plate = new Authoring.PressurePlateAuthoring
{
Kind = Authoring.PressurePlateAuthoring.PlateKind.Blue,
Group = 7,
Position = new Vector2(80, 80),
};
levelRoot.AddChild(plate);
var result = LevelLoader.Load(levelRoot);
AssertThat(result.PlayerId).IsGreater(0);
// Door: has Door tag, no LockedBy, has SignalTarget(7, OpenWhilePressed).
var doorId = FindOnlyEntityAt(result.World, new GridPos(6, 5),
id => result.World.HasTag<Door>(id));
AssertThat(result.World.Has<LockedBy>(doorId)).IsFalse();
AssertThat(result.World.Has<SignalTarget>(doorId)).IsTrue();
var st = result.World.Get<SignalTarget>(doorId);
AssertThat(st.Group).IsEqual(7);
AssertThat((int)st.Behavior).IsEqual((int)SignalBehavior.OpenWhilePressed);
// Plate: group=7, requiresHeavy=false.
var plateId = FindOnlyEntityAt(result.World, new GridPos(5, 5),
id => result.World.Has<PressurePlate>(id));
var p = result.World.Get<PressurePlate>(plateId);
AssertThat(p.Group).IsEqual(7);
AssertThat(p.RequiresHeavy).IsFalse();
}
[TestCase]
public void StairsAuthoring_records_kind_in_StairsByEntity_side_table()
{
var levelRoot = new Node2D();
var player = new Authoring.PlayerAuthoring { Position = new Vector2(0, 0) };
levelRoot.AddChild(player);
var stairsUp = new Authoring.StairsAuthoring
{
IsUp = true,
TargetLevelId = "level2",
TargetSpawnId = "start",
Position = new Vector2(32, 32),
};
levelRoot.AddChild(stairsUp);
var stairsDown = new Authoring.StairsAuthoring
{
IsUp = false,
TargetLevelId = "basement",
TargetSpawnId = "",
Position = new Vector2(64, 32),
};
levelRoot.AddChild(stairsDown);
var result = LevelLoader.Load(levelRoot);
var upId = FindOnlyEntityAt(result.World, new GridPos(2, 2),
id => result.World.Has<LevelTransition>(id));
var downId = FindOnlyEntityAt(result.World, new GridPos(4, 2),
id => result.World.Has<LevelTransition>(id));
AssertThat((int)result.StairsByEntity[upId]).IsEqual((int)StairsKind.Up);
AssertThat((int)result.StairsByEntity[downId]).IsEqual((int)StairsKind.Down);
AssertThat(result.World.Get<LevelTransition>(upId).TargetLevelId).IsEqual("level2");
AssertThat(result.World.Get<LevelTransition>(downId).TargetLevelId).IsEqual("basement");
}
private static int FindOnlyEntityAt(World w, GridPos pos, System.Func<int, bool> predicate)
{
int? found = null;
foreach (var id in w.Index.AllAt(pos))
{
if (!predicate(id)) continue;
if (found is not null) AssertThat(false).OverrideFailureMessage(
$"Multiple matching entities at {pos}").IsTrue();
found = id;
}
if (found is null) AssertThat(false).OverrideFailureMessage(
$"No matching entity at {pos}").IsTrue();
return found!.Value;
}
- Step 2: Run engine lane
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 16 tests pass (2 new + 14 prior).
- Step 3: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/tests/LevelLoaderTests.cs
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine-tests: LevelLoader applies overrides; StairsByEntity tracked
Verifies DoorAuthoring(IsSignalDriven=true) strips LockedBy + adds
SignalTarget; PressurePlateAuthoring(Group=7) writes group=7 onto the
plate; StairsAuthoring(IsUp=true/false) populates the side-table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 11: LevelLoader invariants
Files:
- Create:
muscleman-godot4/engine/tests/LevelLoaderInvariantTests.cs -
Modify:
muscleman-godot4/engine/src/Loader/LevelLoader.cs— dup-Pushable warning - Step 1: Write the failing test
muscleman-godot4/engine/tests/LevelLoaderInvariantTests.cs:
using GdUnit4;
using Godot;
using Muscleman.Engine.Loader;
using static GdUnit4.Assertions;
namespace Muscleman.Engine.Tests;
[TestSuite]
public class LevelLoaderInvariantTests
{
[TestCase]
public void Missing_player_throws()
{
var levelRoot = new Node2D();
// No Player.
AssertThrown(() => LevelLoader.Load(levelRoot));
}
[TestCase]
public void Non_16x16_TileMapLayer_throws()
{
var ts = new TileSet { TileSize = new Vector2I(32, 32) };
var levelRoot = new Node2D();
var layer = new TileMapLayer { TileSet = ts };
levelRoot.AddChild(layer);
var player = new Authoring.PlayerAuthoring();
levelRoot.AddChild(player);
AssertThrown(() => LevelLoader.Load(levelRoot));
}
[TestCase]
public void Duplicate_pushable_at_same_cell_keeps_first_with_warning()
{
var levelRoot = new Node2D();
levelRoot.AddChild(new Authoring.PlayerAuthoring { Position = new Vector2(0, 0) });
var b1 = new Authoring.BoxAuthoring { Position = new Vector2(48, 48) };
var b2 = new Authoring.BoxAuthoring { Position = new Vector2(48, 48) };
levelRoot.AddChild(b1);
levelRoot.AddChild(b2);
var result = LevelLoader.Load(levelRoot);
// Both entities exist (we don't destroy the dup, we just warn).
// The Pushable predicate still finds the first via SpatialIndex.PushableAt.
var first = result.World.Index.PushableAt(new Muscleman.Core.Grid.GridPos(3, 3));
AssertThat(first.HasValue).IsTrue();
}
private static void AssertThrown(System.Action action)
{
bool threw = false;
try { action(); } catch { threw = true; }
AssertThat(threw).OverrideFailureMessage("Expected an exception, but the action returned normally.").IsTrue();
}
}
- Step 2: Run to verify duplicate-pushable test passes / others pass
make -C /home/henri/muscleman/muscleman-godot4 test-engine
The “missing player” and “non-16×16” tests should pass (loader already throws). The “duplicate pushable” test should pass too (loader doesn’t currently prevent duplicates, and the test only asserts PushableAt finds one). If a warning isn’t emitted, the test still passes — but add the diagnostic per Step 3.
- Step 3: Add the duplicate-pushable warning to LevelLoader
In muscleman-godot4/engine/src/Loader/LevelLoader.cs, after the foreach loop spawning entities and before resolving the player, append:
// Diagnostic: warn on duplicate Pushable per cell (parent spec §4.7 invariant).
var pushableCells = new HashSet<GridPos>();
var dupQuery = world.Store.Query<Components.Position>()
.AllTags(Friflo.Engine.ECS.Tags.Get<Pushable>());
foreach (var entity in dupQuery.Entities)
{
var p = entity.GetComponent<Components.Position>().Pos;
if (!pushableCells.Add(p))
GD.PushWarning($"LevelLoader: duplicate Pushable at {p}; spatial index keeps insertion order.");
}
Make sure using Muscleman.Sim.Components; is at the top of LevelLoader.cs (it should be already).
- Step 4: Run engine lane
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 19 tests pass (3 new + 16 prior).
- Step 5: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/tests/LevelLoaderInvariantTests.cs \
muscleman-godot4/engine/src/Loader/LevelLoader.cs
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: LevelLoader invariants — missing player, tile size, dup Pushable
Throws for missing Player or non-16×16 TileMapLayer; warns on
duplicate Pushable per cell. New invariant test suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 12: Entity .tscn scenes
Files:
- Create 10 .tscn files under
muscleman-godot4/engine/scenes/entities/
Each .tscn is a minimal Node2D + Sprite2D + the appropriate authoring script attached. Hand-written in Godot 4 text format. The preview texture is a single mono PNG per family.
- Step 1: Create Player.tscn
muscleman-godot4/engine/scenes/entities/Player.tscn:
[gd_scene load_steps=3 format=3 uid="uid://b8sceneplayer"]
[ext_resource type="Script" path="res://src/Authoring/PlayerAuthoring.cs" id="1_script"]
[ext_resource type="Texture2D" path="res://sprites/characters/player_mono.png" id="2_tex"]
[node name="Player" type="Node2D"]
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
centered = false
- Step 2: Create Box.tscn
muscleman-godot4/engine/scenes/entities/Box.tscn:
[gd_scene load_steps=3 format=3 uid="uid://b8scenebox"]
[ext_resource type="Script" path="res://src/Authoring/BoxAuthoring.cs" id="1_script"]
[ext_resource type="Texture2D" path="res://sprites/kenney_sheet/colored_transparent_packed_mono.png" id="2_tex"]
[node name="Box" type="Node2D"]
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
region_enabled = true
region_rect = Rect2(64, 80, 16, 16)
centered = false
The region_rect here picks one box-looking 16×16 region from the kenney sheet. The exact coords may need tweaking at run time — the visible sprite isn’t critical since the presenter overwrites it.
- Step 3: Create HeavyBox.tscn
muscleman-godot4/engine/scenes/entities/HeavyBox.tscn:
[gd_scene load_steps=3 format=3 uid="uid://b8sceneheavybox"]
[ext_resource type="Script" path="res://src/Authoring/HeavyBoxAuthoring.cs" id="1_script"]
[ext_resource type="Texture2D" path="res://sprites/misc/stump_mono.png" id="2_tex"]
[node name="HeavyBox" type="Node2D"]
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
centered = false
- Step 4: Create Door.tscn
muscleman-godot4/engine/scenes/entities/Door.tscn:
[gd_scene load_steps=3 format=3 uid="uid://b8scenedoor"]
[ext_resource type="Script" path="res://src/Authoring/DoorAuthoring.cs" id="1_script"]
[ext_resource type="Texture2D" path="res://sprites/doors/door_blue_closed_mono.png" id="2_tex"]
[node name="Door" type="Node2D"]
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
centered = false
- Step 5: Create SecretDoor.tscn
muscleman-godot4/engine/scenes/entities/SecretDoor.tscn:
[gd_scene load_steps=3 format=3 uid="uid://b8scenesecretdoor"]
[ext_resource type="Script" path="res://src/Authoring/SecretDoorAuthoring.cs" id="1_script"]
[ext_resource type="Texture2D" path="res://sprites/doors/door_white_closed_mono.png" id="2_tex"]
[node name="SecretDoor" type="Node2D"]
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
centered = false
- Step 6: Create Key.tscn
muscleman-godot4/engine/scenes/entities/Key.tscn:
[gd_scene load_steps=3 format=3 uid="uid://b8scenekey"]
[ext_resource type="Script" path="res://src/Authoring/KeyAuthoring.cs" id="1_script"]
[ext_resource type="Texture2D" path="res://sprites/keys/key_blue_mono.png" id="2_tex"]
[node name="Key" type="Node2D"]
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
centered = false
- Step 7: Create PressurePlate.tscn
muscleman-godot4/engine/scenes/entities/PressurePlate.tscn:
[gd_scene load_steps=3 format=3 uid="uid://b8sceneplate"]
[ext_resource type="Script" path="res://src/Authoring/PressurePlateAuthoring.cs" id="1_script"]
[ext_resource type="Texture2D" path="res://sprites/pressure_plates/pressure_plate_blue_up_mono.png" id="2_tex"]
[node name="PressurePlate" type="Node2D"]
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
centered = false
- Step 8: Create Stairs.tscn
muscleman-godot4/engine/scenes/entities/Stairs.tscn:
[gd_scene load_steps=3 format=3 uid="uid://b8scenestairs"]
[ext_resource type="Script" path="res://src/Authoring/StairsAuthoring.cs" id="1_script"]
[ext_resource type="Texture2D" path="res://sprites/misc/stairs_up_mono.png" id="2_tex"]
[node name="Stairs" type="Node2D"]
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
centered = false
- Step 9: Create Fruit.tscn
muscleman-godot4/engine/scenes/entities/Fruit.tscn:
[gd_scene load_steps=3 format=3 uid="uid://b8scenefruit"]
[ext_resource type="Script" path="res://src/Authoring/FruitAuthoring.cs" id="1_script"]
[ext_resource type="Texture2D" path="res://sprites/misc/apple_mono.png" id="2_tex"]
[node name="Fruit" type="Node2D"]
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
centered = false
- Step 10: Create Duck.tscn
muscleman-godot4/engine/scenes/entities/Duck.tscn:
[gd_scene load_steps=3 format=3 uid="uid://b8sceneduck"]
[ext_resource type="Script" path="res://src/Authoring/DuckAuthoring.cs" id="1_script"]
[ext_resource type="Texture2D" path="res://sprites/objects/duck_mono.png" id="2_tex"]
[node name="Duck" type="Node2D"]
script = ExtResource("1_script")
[node name="Sprite2D" type="Sprite2D" parent="."]
texture = ExtResource("2_tex")
centered = false
- Step 11: Verify engine lane
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 19/19 still green. (Adding .tscn files doesn’t add tests but shouldn’t break anything.)
- Step 12: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/scenes/entities/
git -C /home/henri/muscleman rm -f muscleman-godot4/engine/scenes/entities/.gitkeep
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine-scenes: 10 entity scenes (Player, Box, HeavyBox, Door, …)
One scene per family per the parameterized-scene design. Each scene
binds an EntityAuthoring-derived script + a placeholder Sprite2D
texture for editor preview. The presenter overwrites the texture at
runtime per entity state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 13: SpriteCatalog
Files:
-
Create:
muscleman-godot4/engine/src/Presenter/SpriteCatalog.cs -
Step 1: Implement SpriteCatalog
muscleman-godot4/engine/src/Presenter/SpriteCatalog.cs:
using System.Collections.Generic;
using Godot;
using Muscleman.Core.Enums;
using Muscleman.Engine.Loader;
using Muscleman.Sim;
using Muscleman.Sim.Components;
namespace Muscleman.Engine.Presenter;
/// <summary>
/// Picks a Texture2D for an entity based on its current sim state.
/// Caches textures by path. Held by PresenterDispatcher.
/// </summary>
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)
{
var path = PickPath(w, entityId);
if (!_cache.TryGetValue(path, out var tex))
{
tex = GD.Load<Texture2D>(path);
_cache[path] = tex;
}
return tex;
}
private string PickPath(World w, int entityId)
{
if (w.HasTag<Player>(entityId))
return w.Get<Holding>(entityId).EntityId is not null
? "res://sprites/characters/player_carrying_mono.png"
: "res://sprites/characters/player_mono.png";
if (w.HasTag<Door>(entityId))
{
var open = w.HasTag<DoorOpen>(entityId);
if (w.Has<LockedBy>(entityId))
{
var c = w.Get<LockedBy>(entityId).Color;
return open
? $"res://sprites/doors/door_{ColorName(c)}_open_mono.png"
: $"res://sprites/doors/door_{ColorName(c)}_closed_mono.png";
}
// Signal-driven (no LockedBy) → white-door sprites.
return open
? "res://sprites/doors/door_white_open_mono.png"
: "res://sprites/doors/door_white_closed_mono.png";
}
if (w.Has<KeyOf>(entityId))
return $"res://sprites/keys/key_{ColorName(w.Get<KeyOf>(entityId).Color)}_mono.png";
if (w.Has<PressurePlate>(entityId))
{
var pressed = w.HasTag<Pressed>(entityId);
var heavy = w.Get<PressurePlate>(entityId).RequiresHeavy;
if (heavy)
return pressed
? "res://sprites/pressure_plates/pressure_plate_heavy_down_mono.png"
: "res://sprites/pressure_plates/pressure_plate_heavy_up_mono.png";
// Light plate: use blue sprite (the catalog only ships blue/red/white in slice A).
return pressed
? "res://sprites/pressure_plates/pressure_plate_blue_down_mono.png"
: "res://sprites/pressure_plates/pressure_plate_blue_up_mono.png";
}
if (w.Has<LevelTransition>(entityId))
{
if (_stairsByEntity.TryGetValue(entityId, out var kind))
return kind == StairsKind.Up
? "res://sprites/misc/stairs_up_mono.png"
: "res://sprites/misc/stairs_down_mono.png";
return "res://sprites/misc/stairs_up_mono.png";
}
if (w.Has<Edible>(entityId))
return w.Get<Edible>(entityId).Counter switch
{
GlobalCounter.ApplesEaten => "res://sprites/misc/apple_mono.png",
GlobalCounter.PearsEaten => "res://sprites/misc/pear_mono.png",
GlobalCounter.CheeseEaten => "res://sprites/misc/cheese_mono.png",
GlobalCounter.Coins => "res://sprites/objects/coin_mono.png",
_ => "res://sprites/misc/apple_mono.png",
};
if (w.Has<Named>(entityId) && w.Get<Named>(entityId).Name == "duck")
return "res://sprites/objects/duck_mono.png";
// HeavyBox: Walkable+Sinkable-stripped means sunk.
if (w.HasTag<Walkable>(entityId) && !w.HasTag<Sinkable>(entityId)
&& !w.HasTag<BlocksMovement>(entityId)
&& w.HasTag<Static>(entityId) == false)
return "res://sprites/misc/stump_sunk_mono.png";
// Default: Box-sized fallback.
return "res://sprites/misc/stump_mono.png";
}
private static string ColorName(KeyColor c) => c switch
{
KeyColor.Blue => "blue",
KeyColor.Red => "red",
KeyColor.Yellow => "yellow",
KeyColor.Green => "green",
_ => "blue",
};
}
The catalog has a default fallback (stump_mono.png) for any entity that doesn’t match a specific case — primarily this catches the regular Box recipe. The exact sprite isn’t critical for the debug presenter; if a Box renders as stump_mono.png, the user knows the level loaded but the visual mapping is rough.
- Step 2: Build
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 19/19 still green.
- Step 3: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/src/Presenter/SpriteCatalog.cs
git -C /home/henri/muscleman rm -f muscleman-godot4/engine/src/Presenter/.gitkeep
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: SpriteCatalog — state-driven sprite picker for the presenter
Returns a Texture2D for an entity based on its current tags/components.
Per-path caching so reloads are O(1) after first hit. Handles player
(carrying/idle), doors (open/closed × color/signal-white), keys, plates
(pressed/released × light/heavy), stairs (up/down), fruits, duck,
sunk heavy boxes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 14: PresenterNode + PresenterDispatcher
Files:
- Create:
muscleman-godot4/engine/src/Presenter/PresenterNode.cs -
Create:
muscleman-godot4/engine/src/Presenter/PresenterDispatcher.cs - Step 1: Create PresenterNode
muscleman-godot4/engine/src/Presenter/PresenterNode.cs:
using Godot;
using Muscleman.Sim;
using Muscleman.Sim.Components;
namespace Muscleman.Engine.Presenter;
/// <summary>One Sprite2D per non-static entity, snap-positioned by GridPos.</summary>
public partial class PresenterNode : Node2D
{
public int EntityId { get; init; }
public Sprite2D Sprite { get; private set; } = null!;
public override void _Ready()
{
Sprite = new Sprite2D { Centered = false };
AddChild(Sprite);
}
public void Refresh(World w, Vector2I tilePixelSize, SpriteCatalog catalog)
{
var pos = w.Get<Position>(EntityId).Pos;
Position = new Vector2(pos.X * tilePixelSize.X, pos.Y * tilePixelSize.Y);
Sprite.Texture = catalog.Pick(w, EntityId);
}
}
- Step 2: Create PresenterDispatcher
muscleman-godot4/engine/src/Presenter/PresenterDispatcher.cs:
using System.Collections.Generic;
using System.Linq;
using Godot;
using Muscleman.Core.Events;
using Muscleman.Engine.Loader;
using Muscleman.Sim;
using Muscleman.Sim.Components;
namespace Muscleman.Engine.Presenter;
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<Position>().Entities)
{
if (entity.Tags.Has<Static>()) continue;
Attach(entity.Id);
}
RefreshAll();
}
public void OnTick(EventBatch _)
{
// Drop dead entity nodes.
foreach (var id in _nodes.Keys.ToArray())
{
if (!_world.IsAlive(id))
{
_nodes[id].QueueFree();
_nodes.Remove(id);
}
}
// Add nodes for new entities (e.g. content spawned by smashing later — placeholder).
foreach (var entity in _world.Store.Query<Position>().Entities)
{
if (entity.Tags.Has<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);
}
}
- Step 3: Build
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 19/19 still green.
- Step 4: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/src/Presenter/PresenterNode.cs \
muscleman-godot4/engine/src/Presenter/PresenterDispatcher.cs
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: PresenterNode + PresenterDispatcher — snap-to-state visual mirror
One Sprite2D per non-static entity, positioned at its GridPos*tile. The
dispatcher attaches presenter nodes for every non-Static entity at
init, drops dead ones each tick, refreshes positions+textures from
SpriteCatalog. No tweens; Phase 7 will replace OnTick with an event
choreographer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 15: PlayerInput + InputMap actions
Files:
- Modify:
muscleman-godot4/engine/project.godot— add InputMap actions -
Create:
muscleman-godot4/engine/src/Input/PlayerInput.cs - Step 1: Add InputMap actions to
project.godot
In muscleman-godot4/engine/project.godot, append to the end of the file (before EOF):
[input]
move_up={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null),Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)]
}
move_down={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null),Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)]
}
move_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null),Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)]
}
move_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null),Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)]
}
grab={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null),Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)]
}
swing_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null),Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":75,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)]
}
swing_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null),Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":76,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)]
}
wait={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":46,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null),Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":59,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)]
}
Keycodes used: W=87, S=83, A=65, D=68, Up=4194320, Down=4194322, Left=4194319, Right=4194321, Space=32, J=74, Q=81, K=75, E=69, L=76, Period=46, Semicolon=59.
Note on the InputMap text format: Godot 4 stores InputMap actions in this verbose Object(InputEventKey,...) form. If you want, instead of hand-writing the block, you can open the project in the editor, define the actions through Project Settings → Input Map, and save — Godot will rewrite project.godot with valid syntax. The block above mirrors what a freshly-saved Godot project produces.
- Step 2: Create PlayerInput
muscleman-godot4/engine/src/Input/PlayerInput.cs:
using Godot;
using Muscleman.Core.Grid;
using Muscleman.Core.Input;
using Muscleman.Engine.Presenter;
using Muscleman.Sim;
using GodotInput = Godot.Input;
using SimInput = Muscleman.Core.Input.Input;
namespace Muscleman.Engine.Input;
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)
{
if (_world is null) return; // not initialized yet
var sim = ReadInput(delta);
if (sim is null) return;
var batch = StepRunner.Step(_world, _playerId, sim);
Presenter.OnTick(batch);
}
private SimInput? ReadInput(double delta)
{
// Edge-triggered actions first (one shot per press).
if (GodotInput.IsActionJustPressed("grab")) return new SimInput.Grab();
if (GodotInput.IsActionJustPressed("swing_left")) return new SimInput.SwingLeft();
if (GodotInput.IsActionJustPressed("swing_right")) return new SimInput.SwingRight();
if (GodotInput.IsActionJustPressed("wait")) return new SimInput.Wait();
// Held movement: produce a Move on the press edge and at MoveRepeatSeconds intervals while held.
GridDir? dir = null;
if (GodotInput.IsActionPressed("move_up")) dir = GridDir.Up;
else if (GodotInput.IsActionPressed("move_down")) dir = GridDir.Down;
else if (GodotInput.IsActionPressed("move_left")) dir = GridDir.Left;
else if (GodotInput.IsActionPressed("move_right")) dir = GridDir.Right;
if (dir is null)
{
_moveHoldElapsed = 0;
return null;
}
if (GodotInput.IsActionJustPressed("move_up") ||
GodotInput.IsActionJustPressed("move_down") ||
GodotInput.IsActionJustPressed("move_left") ||
GodotInput.IsActionJustPressed("move_right"))
{
_moveHoldElapsed = 0;
return new SimInput.Move(dir.Value);
}
_moveHoldElapsed += delta;
if (_moveHoldElapsed >= MoveRepeatSeconds)
{
_moveHoldElapsed -= MoveRepeatSeconds;
return new SimInput.Move(dir.Value);
}
return null;
}
}
- Step 3: Build + tests
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 19/19 still green. PlayerInput isn’t covered by automated tests; manual smoke verifies it.
- Step 4: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/project.godot \
muscleman-godot4/engine/src/Input/PlayerInput.cs
git -C /home/henri/muscleman rm -f muscleman-godot4/engine/src/Input/.gitkeep
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: PlayerInput + InputMap (WASD/arrows, Space/J grab, Q/K, E/L, ./;)
Each frame: edge-triggered actions consume; otherwise held movement
repeats every 0.18s. Dispatches Input → StepRunner.Step → Presenter.OnTick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 16: GameRoot + GameRoot.tscn + project.godot default scene
Files:
- Create:
muscleman-godot4/engine/src/GameRoot.cs - Create:
muscleman-godot4/engine/scenes/GameRoot.tscn -
Modify:
muscleman-godot4/engine/project.godot— setrun/main_scene - Step 1: Create GameRoot.cs
muscleman-godot4/engine/src/GameRoot.cs:
using Godot;
using Muscleman.Engine.Input;
using Muscleman.Engine.Loader;
using Muscleman.Engine.Presenter;
namespace Muscleman.Engine;
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()
{
if (LevelScene is null)
{
GD.PushError("GameRoot.LevelScene is not assigned.");
return;
}
var levelRoot = LevelScene.Instantiate<Node>();
AddChild(levelRoot);
var result = LevelLoader.Load(levelRoot);
Presenter.Initialize(result);
Input.Initialize(result.World, result.PlayerId);
}
}
- Step 2: Create GameRoot.tscn
muscleman-godot4/engine/scenes/GameRoot.tscn:
[gd_scene load_steps=4 format=3 uid="uid://b8scenegameroot"]
[ext_resource type="Script" path="res://src/GameRoot.cs" id="1_root"]
[ext_resource type="Script" path="res://src/Presenter/PresenterDispatcher.cs" id="2_presenter"]
[ext_resource type="Script" path="res://src/Input/PlayerInput.cs" id="3_input"]
[ext_resource type="PackedScene" uid="uid://b8scenetestlevel" path="res://scenes/TestLevel.tscn" id="4_level"]
[node name="GameRoot" type="Node"]
script = ExtResource("1_root")
LevelScene = ExtResource("4_level")
Presenter = NodePath("Presenter")
Input = NodePath("Input")
[node name="Presenter" type="Node2D" parent="."]
script = ExtResource("2_presenter")
[node name="Input" type="Node" parent="."]
script = ExtResource("3_input")
Presenter = NodePath("../Presenter")
The [ext_resource] referencing TestLevel.tscn is forward — Task 17 creates that file. The make test-engine lane won’t execute this scene (only headless GdUnit4 tests), so the missing reference at this task won’t break the build but will warn on load. The next task fixes it.
- Step 3: Set run/main_scene in project.godot
In muscleman-godot4/engine/project.godot, in the [application] section, after config/icon="res://icon.svg", add:
run/main_scene="res://scenes/GameRoot.tscn"
- Step 4: Build + tests
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 19/19 still green. May see a missing-scene warning from the project loader pointing at TestLevel.tscn — non-fatal.
- Step 5: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/src/GameRoot.cs \
muscleman-godot4/engine/scenes/GameRoot.tscn \
muscleman-godot4/engine/project.godot
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine: GameRoot + GameRoot.tscn — top-level scene wiring
Instantiates LevelScene, calls LevelLoader.Load, initializes the
presenter + input. project.godot's run/main_scene points at
GameRoot.tscn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 17: TestLevel.tscn — hand-built demo level
Files:
- Create:
muscleman-godot4/engine/scenes/TestLevel.tscn
The level has a Walls TileMapLayer enclosing a 10×8 play area plus an Entities subtree with Player, Box, HeavyBox, signal-driven Door + matching Plate, KeyBlue → DoorBlue, Apple, water cells, and Stairs.
- Step 1: Create the TestLevel scene
muscleman-godot4/engine/scenes/TestLevel.tscn:
[gd_scene load_steps=12 format=3 uid="uid://b8scenetestlevel"]
[ext_resource type="TileSet" path="res://resources/Tiles.tres" id="1_tiles"]
[ext_resource type="PackedScene" uid="uid://b8sceneplayer" path="res://scenes/entities/Player.tscn" id="2_player"]
[ext_resource type="PackedScene" uid="uid://b8scenebox" path="res://scenes/entities/Box.tscn" id="3_box"]
[ext_resource type="PackedScene" uid="uid://b8sceneheavybox" path="res://scenes/entities/HeavyBox.tscn" id="4_heavy"]
[ext_resource type="PackedScene" uid="uid://b8scenedoor" path="res://scenes/entities/Door.tscn" id="5_door"]
[ext_resource type="PackedScene" uid="uid://b8scenekey" path="res://scenes/entities/Key.tscn" id="6_key"]
[ext_resource type="PackedScene" uid="uid://b8sceneplate" path="res://scenes/entities/PressurePlate.tscn" id="7_plate"]
[ext_resource type="PackedScene" uid="uid://b8scenefruit" path="res://scenes/entities/Fruit.tscn" id="8_fruit"]
[ext_resource type="PackedScene" uid="uid://b8scenestairs" path="res://scenes/entities/Stairs.tscn" id="9_stairs"]
[node name="TestLevel" type="Node2D"]
[node name="Walls" type="TileMapLayer" parent="."]
tile_set = ExtResource("1_tiles")
tile_map_data = PackedByteArray()
[node name="Water" type="TileMapLayer" parent="."]
tile_set = ExtResource("1_tiles")
tile_map_data = PackedByteArray()
[node name="Entities" type="Node2D" parent="."]
[node name="Player" parent="Entities" instance=ExtResource("2_player")]
position = Vector2(32, 32)
[node name="Box1" parent="Entities" instance=ExtResource("3_box")]
position = Vector2(64, 32)
[node name="Box2" parent="Entities" instance=ExtResource("3_box")]
position = Vector2(80, 32)
[node name="Plate" parent="Entities" instance=ExtResource("7_plate")]
position = Vector2(48, 64)
Kind = 0
Group = 1
[node name="Door" parent="Entities" instance=ExtResource("5_door")]
position = Vector2(112, 64)
Color = 0
IsSignalDriven = true
Group = 1
Behavior = 0
[node name="HeavyBox" parent="Entities" instance=ExtResource("4_heavy")]
position = Vector2(48, 96)
[node name="Key" parent="Entities" instance=ExtResource("6_key")]
position = Vector2(96, 128)
Color = 0
[node name="DoorRed" parent="Entities" instance=ExtResource("5_door")]
position = Vector2(112, 128)
Color = 1
[node name="Apple" parent="Entities" instance=ExtResource("8_fruit")]
position = Vector2(48, 128)
Kind = 0
[node name="Stairs" parent="Entities" instance=ExtResource("9_stairs")]
position = Vector2(144, 32)
IsUp = true
TargetLevelId = "tutorial1"
TargetSpawnId = "start"
Note: The Walls and Water TileMapLayers are empty (tile_map_data = PackedByteArray()) — the level will play but it will have no walls around the perimeter. Painting tiles is done in the Godot editor after this task, by:
- Open the project in Godot.
- Open
TestLevel.tscn. - Select the
WallsTileMapLayer, switch to the TileMap panel, paint a perimeter wall around the entities. Save.
This task delivers a playable scene; perimeter walls are an aesthetic refinement the user does in 30 seconds in the editor.
- Step 2: Verify the test lane
make -C /home/henri/muscleman/muscleman-godot4 test-engine
Expected: 19/19 still green.
- Step 3: Headless verification: launch Godot with the scene and quit after 2s
cd /home/henri/muscleman/muscleman-godot4
/home/henri/godot/godot --headless --path engine --quit-after 2 2>&1 | tail -20
Expected: the level loads. The output may include GD.PushWarning about missing perimeter walls (acceptable). What we DON’T want to see: a hard error or PushError from GameRoot._Ready or LevelLoader.Load.
If LevelLoader.Load throws (e.g. “no Player entity”), inspect the scene file for typos.
- Step 4: Commit
git -C /home/henri/muscleman add muscleman-godot4/engine/scenes/TestLevel.tscn
git -C /home/henri/muscleman commit -m "$(cat <<'EOF'
engine-scenes: TestLevel.tscn — hand-built slice-A demo level
Player + 2 boxes + heavy + plate(group=1) + signal-driven door
(group=1) + KeyBlue + DoorRed + Apple + StairsUp. TileMapLayer walls
are empty; user paints them in the editor for a polished play test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
Task 18: Smoke + push + open PR
Files: (no file changes; pushes branch and opens PR)
- Step 1: Final full-suite check
make -C /home/henri/muscleman/muscleman-godot4 test
Expected: both lanes pass. xUnit: 139/139. GdUnit4: 19/19. The xUnit lane is unchanged by Slice A (we made no sim-side changes); the engine lane grew from 9 to 19 (Tasks 4, 9, 10, 11 added tests).
- Step 2: Push the branch
git -C /home/henri/muscleman push -u origin slice-a-gd4-authoring-presenter
Expected: push succeeds.
- Step 3: Open the PR
Use the gh CLI. Body summarizes the slice end-to-end:
gh pr create --title "Slice A: Godot 4 authoring + debug presenter" --body "$(cat <<'EOF'
## Summary
Stands up the Godot 4 authoring + visual-runtime pipeline. After this PR, you can open Godot 4, paint a level using the new TileSet + entity scenes, hit Play, and walk around inside the sim — push boxes, sink heavies, plates trigger doors, keys open doors, fruit gets eaten.
This is Slice A of the Phase 6 + 7 work; Slice B (GD3 → JSON exporter + JSON loader + migrate tutorial1) lands next.
**Spec:** `docs/superpowers/specs/2026-05-14-gd4-authoring-and-debug-presenter-design.md`
**Plan:** `docs/superpowers/plans/2026-05-14-gd4-authoring-and-debug-presenter.md`
### What's new
- **TileSet** (`engine/resources/Tiles.tres`) with two custom data layers (`recipe_id`, `is_floor`) on floor + wall + water sources.
- **EntityAuthoring** base + 10 concrete authoring classes (Player, Box, HeavyBox, Door, SecretDoor, Key, PressurePlate, Stairs, Fruit, Duck) with `[Export]` per-instance config.
- **LevelLoader** (`engine/src/Loader/LevelLoader.cs`) — scene tree → `World.Spawn` + `ApplyOverrides`. Asserts 16×16 tile size; throws on missing Player; warns on duplicate Pushable.
- **Debug presenter** — `SpriteCatalog` + `PresenterNode` + `PresenterDispatcher`. State-driven sprite selection per entity, snap-to-grid positioning, no tweens. Phase 7 will replace with animated dispatch.
- **PlayerInput** — WASD/arrows for move, Space/J grab, Q/K + E/L swing, ./ ; wait. Held movement repeats at 0.18s.
- **GameRoot** entry scene; **TestLevel.tscn** hand-built demo.
### What's deferred
- GD3 → JSON exporter + loader (Slice B).
- Migrating tutorial1/level1–4 (Slice B).
- Animated presenter (Phase 7).
- Save/load (Phase 5).
- Hammer/Bong/Cauldron/Book/Ghost (still NotImplementedException).
## Test plan
- [ ] Open the project in Godot 4.6.2 mono; hit Play.
- [ ] Push a box, check it moves.
- [ ] Push the heavy box onto water; verify it sinks (presenter swaps to `stump_sunk_mono.png`) and the player can step onto it.
- [ ] Walk onto the blue plate; verify the signal-driven door opens.
- [ ] Push KeyBlue into DoorRed (the keyed door); verify nothing happens.
- [ ] Push KeyBlue into a matching DoorBlue (rebuild a small scene if needed); verify the door opens and the key disappears.
- [ ] Walk into the apple; verify `apples_eaten` counter increments (visible via `GD.Print` in the dispatcher if logging is on).
- [ ] Walk onto the stairs; verify `LevelTransitionEvent` logs to the Godot console.
- [ ] `make test` is green (both lanes).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
Expected: gh prints the new PR URL. Capture it and surface in the final report.
- Step 4: Report the PR URL
(No commit. Pass the URL back through the implementer’s report.)
Definition of done
- 19 tasks committed (including Task 0 + a final no-op task surfaces the PR URL).
- Branch
slice-a-gd4-authoring-presenterpushed to origin. - Pull request open against
main. make testis green: xUnit 139/139, GdUnit4 19/19.- The new files in this PR cover every spec section in the coverage table.
Known follow-ups
- Slice B: GD3 → JSON exporter + JSON loader in
Muscleman.Core/LevelData/, migrate tutorial1. - Phase 7 visual polish: tweens, swing arcs, audio, UI, swing animation.
- Phase 5 save/load +
PressurePlateSystem.Initialize. - Late-press input buffering (parent spec §6.3).
- Smash/spawn-contents pipeline; Hammer/Bong/Cauldron/Book/Ghost recipes.