Sim Bootstrap & Walk/Push 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 C# Muscleman sim project (Core + Sim + Tests) and implement the foundation for grid-based gameplay: components, World wrapper around Friflo, spatial index, basic recipes, and walk/push movement with chain resolution. End state: dotnet test passes a suite of headless tests covering all walk/push rules from the design spec.

Architecture: Three-project .NET solution under a new muscleman-godot4/ directory in the existing repo. Muscleman.Core holds engine-agnostic types (grid, enums, events, input). Muscleman.Sim holds the Friflo-backed World and systems. Muscleman.Sim.Tests holds xUnit tests that exercise the sim entirely headlessly. The Friflo API is encapsulated in World.cs so the rest of the sim depends only on a stable in-house API.

Tech Stack: .NET 9, C# 12+, Friflo Engine ECS, xUnit, FluentAssertions.

Reference spec: docs/superpowers/specs/2026-05-03-godot4-ecs-rewrite-design.md — sections 2 (layers), 3 (ECS catalog), 4.1–4.3 (tick phases, determinism, atomicity), and 4.7 (invariants) are the spec for this plan.

Out of scope for this plan (deferred to later plans):

  • Grab/drop, swing scan, swing resolve, swing apply (next plan)
  • Environmental phase (sinking, plates, signals, doors)
  • Save/load
  • Godot adapter / presenter

File Structure

Files this plan creates:

muscleman-godot4/
├── Muscleman.sln
└── src/
    ├── Muscleman.Core/
    │   ├── Muscleman.Core.csproj
    │   ├── Grid/GridPos.cs            # Grid coordinate (x, y) struct
    │   ├── Grid/GridDir.cs            # 4-direction enum + helpers
    │   ├── Enums/KeyColor.cs          # Blue, Red, Yellow, Green
    │   ├── Enums/EntityRecipe.cs      # All recipe ids
    │   ├── Enums/GlobalCounter.cs     # Coins, Ghosts, Apples, ...
    │   ├── Enums/MoveCause.cs         # Player | Push | Swing | Brain
    │   ├── Events/SimEvent.cs         # Abstract record base
    │   ├── Events/Events.cs           # Concrete event records
    │   ├── Events/EventBatch.cs       # List wrapper with filtering helpers
    │   └── Input/Input.cs             # Move, Grab, Drop, Swing, Wait
    ├── Muscleman.Sim/
    │   ├── Muscleman.Sim.csproj
    │   ├── Components/Tags.cs         # All tag components
    │   ├── Components/Position.cs     # GridPos, LevelMember, Facing
    │   ├── Components/Player.cs       # Player, Holding
    │   ├── World.cs                   # Friflo wrapper (Spawn/Has/Get/Set/Remove/Destroy/Query)
    │   ├── SpatialIndex.cs            # GridPos -> entities at that cell
    │   ├── Recipes/RecipeFactory.cs   # EntityRecipe -> component bundle
    │   ├── Systems/PlayerActionSystem.cs  # Resolves player Input
    │   ├── Systems/PushSystem.cs      # Push chain resolution (used by walk and later by swing)
    │   └── StepRunner.cs              # World.Step orchestration
    └── Muscleman.Sim.Tests/
        ├── Muscleman.Sim.Tests.csproj
        ├── TestWorld.cs               # Test fixture/builder helpers
        ├── GridTests.cs
        ├── WorldTests.cs
        ├── SpatialIndexTests.cs
        ├── RecipeTests.cs
        ├── WalkTests.cs
        └── PushTests.cs

Each file has one clear responsibility. The Friflo dependency lives only in World.cs, SpatialIndex.cs, and the systems — everything else is pure C#.


Task 0: Bootstrap solution and projects

Files:

  • Create: muscleman-godot4/Muscleman.sln
  • Create: muscleman-godot4/src/Muscleman.Core/Muscleman.Core.csproj
  • Create: muscleman-godot4/src/Muscleman.Sim/Muscleman.Sim.csproj
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/Muscleman.Sim.Tests.csproj
  • Create: muscleman-godot4/.gitignore
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/SmokeTests.cs

  • Step 1: Create directory and solution scaffold
cd /home/henri/muscleman
mkdir -p muscleman-godot4/src
cd muscleman-godot4
dotnet new sln -n Muscleman
dotnet new classlib -n Muscleman.Core -o src/Muscleman.Core --framework net9.0
dotnet new classlib -n Muscleman.Sim  -o src/Muscleman.Sim  --framework net9.0
dotnet new xunit    -n Muscleman.Sim.Tests -o src/Muscleman.Sim.Tests --framework net9.0
rm src/Muscleman.Core/Class1.cs src/Muscleman.Sim/Class1.cs
dotnet sln add src/Muscleman.Core/Muscleman.Core.csproj
dotnet sln add src/Muscleman.Sim/Muscleman.Sim.csproj
dotnet sln add src/Muscleman.Sim.Tests/Muscleman.Sim.Tests.csproj
  • Step 2: Wire project references
dotnet add src/Muscleman.Sim reference src/Muscleman.Core
dotnet add src/Muscleman.Sim.Tests reference src/Muscleman.Sim
dotnet add src/Muscleman.Sim.Tests reference src/Muscleman.Core
  • Step 3: Add Friflo and FluentAssertions packages
dotnet add src/Muscleman.Sim package Friflo.Engine.ECS
dotnet add src/Muscleman.Sim.Tests package FluentAssertions

If Friflo.Engine.ECS resolves to a version >= 3.0.0, that’s the expected target. If a newer major version is published with breaking API changes, the engineer should consult the Friflo README and adjust later tasks (notably Task 6) accordingly. The shape of the wrapper in this plan is API-stable; only the bodies of World’s methods need updating.

  • Step 4: Write the .gitignore
bin/
obj/
*.user
.vs/
.idea/

Path: muscleman-godot4/.gitignore

  • Step 5: Write a smoke test that proves the toolchain works

Path: muscleman-godot4/src/Muscleman.Sim.Tests/SmokeTests.cs

using Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class SmokeTests
{
    [Fact]
    public void Toolchain_works()
    {
        (1 + 1).Should().Be(2);
    }
}
  • Step 6: Run the smoke test
cd /home/henri/muscleman/muscleman-godot4
dotnet test

Expected: Passed: 1, Failed: 0. If the build fails because Friflo couldn’t restore, fix the version constraint and re-run before continuing.

  • Step 7: Commit
cd /home/henri/muscleman
git add muscleman-godot4
git commit -m "$(cat <<'EOF'
bootstrap: stand up Muscleman.Core/Sim/Tests projects

Three-project .NET 9 solution under muscleman-godot4/. Friflo.Engine.ECS
referenced by Sim, FluentAssertions by Tests. Smoke test passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 1: GridPos and GridDir

Files:

  • Create: muscleman-godot4/src/Muscleman.Core/Grid/GridPos.cs
  • Create: muscleman-godot4/src/Muscleman.Core/Grid/GridDir.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/GridTests.cs

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/GridTests.cs

using Muscleman.Core.Grid;
using Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class GridTests
{
    [Fact]
    public void GridPos_addition_offsets_components()
    {
        var p = new GridPos(2, 3) + GridDir.Right.ToOffset();
        p.Should().Be(new GridPos(3, 3));
    }

    [Fact]
    public void GridPos_subtraction_inverts_addition()
    {
        var a = new GridPos(5, 5);
        var b = new GridPos(2, 1);
        (a - b).Should().Be(new GridPos(3, 4));
    }

    [Fact]
    public void GridDir_offsets_are_cardinal()
    {
        GridDir.Up.ToOffset().Should().Be(new GridPos(0, -1));
        GridDir.Down.ToOffset().Should().Be(new GridPos(0, 1));
        GridDir.Left.ToOffset().Should().Be(new GridPos(-1, 0));
        GridDir.Right.ToOffset().Should().Be(new GridPos(1, 0));
    }

    [Fact]
    public void GridDir_RotateRight_cycles_clockwise()
    {
        GridDir.Up.RotateRight().Should().Be(GridDir.Right);
        GridDir.Right.RotateRight().Should().Be(GridDir.Down);
        GridDir.Down.RotateRight().Should().Be(GridDir.Left);
        GridDir.Left.RotateRight().Should().Be(GridDir.Up);
    }

    [Fact]
    public void GridDir_RotateLeft_cycles_counterclockwise()
    {
        GridDir.Up.RotateLeft().Should().Be(GridDir.Left);
        GridDir.Left.RotateLeft().Should().Be(GridDir.Down);
        GridDir.Down.RotateLeft().Should().Be(GridDir.Right);
        GridDir.Right.RotateLeft().Should().Be(GridDir.Up);
    }
}
  • Step 2: Run tests; confirm failure
dotnet test

Expected: build fails because GridPos and GridDir don’t exist yet.

  • Step 3: Implement GridPos

Path: muscleman-godot4/src/Muscleman.Core/Grid/GridPos.cs

namespace Muscleman.Core.Grid;

public readonly record struct GridPos(int X, int Y)
{
    public static GridPos Zero => new(0, 0);

    public static GridPos operator +(GridPos a, GridPos b) => new(a.X + b.X, a.Y + b.Y);
    public static GridPos operator -(GridPos a, GridPos b) => new(a.X - b.X, a.Y - b.Y);
}
  • Step 4: Implement GridDir

Path: muscleman-godot4/src/Muscleman.Core/Grid/GridDir.cs

Y axis grows downward (Godot convention), so Up has Y = -1.

namespace Muscleman.Core.Grid;

public enum GridDir
{
    Up,
    Right,
    Down,
    Left,
}

public static class GridDirExtensions
{
    public static GridPos ToOffset(this GridDir d) => d switch
    {
        GridDir.Up    => new GridPos(0, -1),
        GridDir.Right => new GridPos(1, 0),
        GridDir.Down  => new GridPos(0, 1),
        GridDir.Left  => new GridPos(-1, 0),
        _ => throw new System.ArgumentOutOfRangeException(nameof(d)),
    };

    public static GridDir RotateRight(this GridDir d) => (GridDir)(((int)d + 1) % 4);
    public static GridDir RotateLeft(this GridDir d)  => (GridDir)(((int)d + 3) % 4);
}
  • Step 5: Run tests; confirm pass
dotnet test

Expected: all tests pass.

  • Step 6: Commit
git add muscleman-godot4
git commit -m "core: add GridPos and GridDir with rotation helpers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 2: Core enums

Files:

  • Create: muscleman-godot4/src/Muscleman.Core/Enums/KeyColor.cs
  • Create: muscleman-godot4/src/Muscleman.Core/Enums/EntityRecipe.cs
  • Create: muscleman-godot4/src/Muscleman.Core/Enums/GlobalCounter.cs
  • Create: muscleman-godot4/src/Muscleman.Core/Enums/MoveCause.cs

These are pure type definitions; no tests required (an enum that compiles is correct).

  • Step 1: Add KeyColor

Path: muscleman-godot4/src/Muscleman.Core/Enums/KeyColor.cs

namespace Muscleman.Core.Enums;

public enum KeyColor
{
    Blue,
    Red,
    Yellow,
    Green,
}
  • Step 2: Add EntityRecipe

Path: muscleman-godot4/src/Muscleman.Core/Enums/EntityRecipe.cs

Includes all recipes from spec §3.3, plus the recipes that downstream tasks will spawn.

namespace Muscleman.Core.Enums;

public enum EntityRecipe
{
    // Player
    Player,

    // Static terrain
    WallStrong,
    WallWeak,
    WaterCell,

    // Boxes
    Box,
    HeavyBox,
    Hammer,
    Bong,
    Cauldron,

    // Keys
    KeyBlue,
    KeyRed,
    KeyYellow,
    KeyGreen,

    // Doors
    DoorBlue,
    DoorRed,
    DoorYellow,
    DoorGreen,
    SecretDoor,

    // Pressure plates
    PressurePlateBlue,
    PressurePlateRed,
    PressurePlateYellow,
    PressurePlateGreen,
    PressurePlateHeavy,

    // Stairs
    StairsUp,
    StairsDown,

    // Fruit / collectibles
    Apple,
    Pear,
    Cheese,
    Coin,

    // Books / NPCs
    Book,
    Ghost,

    // Special
    Duck,
}
  • Step 3: Add GlobalCounter

Path: muscleman-godot4/src/Muscleman.Core/Enums/GlobalCounter.cs

namespace Muscleman.Core.Enums;

public enum GlobalCounter
{
    Coins,
    GhostsReleased,
    GreenKeysMade,
    BongsMade,
    BongsBroken,
    ApplesEaten,
    PearsEaten,
    CheeseEaten,
}
  • Step 4: Add MoveCause

Path: muscleman-godot4/src/Muscleman.Core/Enums/MoveCause.cs

namespace Muscleman.Core.Enums;

public enum MoveCause
{
    Player,
    Push,
    Swing,
    Brain,
}
  • Step 5: Verify build
dotnet build

Expected: build succeeds.

  • Step 6: Commit
git add muscleman-godot4
git commit -m "core: add KeyColor, EntityRecipe, GlobalCounter, MoveCause enums

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 3: Event types and EventBatch

Files:

  • Create: muscleman-godot4/src/Muscleman.Core/Events/SimEvent.cs
  • Create: muscleman-godot4/src/Muscleman.Core/Events/Events.cs
  • Create: muscleman-godot4/src/Muscleman.Core/Events/EventBatch.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/EventBatchTests.cs

This task introduces event types we’ll use immediately (Moved, GrabAttached, GrabReleased, SwingBlocked) and the events from later phases as forward declarations so all downstream code can reference them. Concrete events used in this plan: Moved. The rest will become live in subsequent plans.

  • Step 1: Write the failing test

Path: muscleman-godot4/src/Muscleman.Sim.Tests/EventBatchTests.cs

using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class EventBatchTests
{
    [Fact]
    public void Add_appends_in_order()
    {
        var b = new EventBatch();
        b.Add(new Moved(EntityId: 1, From: new GridPos(0, 0), To: new GridPos(1, 0), Cause: MoveCause.Player));
        b.Add(new Moved(EntityId: 2, From: new GridPos(5, 5), To: new GridPos(5, 6), Cause: MoveCause.Push));

        b.Events.Should().HaveCount(2);
        ((Moved)b.Events[0]).EntityId.Should().Be(1);
        ((Moved)b.Events[1]).EntityId.Should().Be(2);
    }

    [Fact]
    public void OfType_filters_by_concrete_event_type()
    {
        var b = new EventBatch();
        b.Add(new Moved(1, new GridPos(0, 0), new GridPos(1, 0), MoveCause.Player));
        b.Add(new SwingBlocked(2, "wall"));
        b.Add(new Moved(3, new GridPos(2, 2), new GridPos(3, 2), MoveCause.Push));

        b.OfType<Moved>().Should().HaveCount(2);
        b.OfType<SwingBlocked>().Should().HaveCount(1);
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~EventBatchTests

Expected: build fails (event types don’t exist).

  • Step 3: Define SimEvent base

Path: muscleman-godot4/src/Muscleman.Core/Events/SimEvent.cs

namespace Muscleman.Core.Events;

public abstract record SimEvent;
  • Step 4: Define concrete event records

Path: muscleman-godot4/src/Muscleman.Core/Events/Events.cs

using Muscleman.Core.Enums;
using Muscleman.Core.Grid;

namespace Muscleman.Core.Events;

// Used immediately:
public sealed record Moved(int EntityId, GridPos From, GridPos To, MoveCause Cause) : SimEvent;

// Forward declarations — populated by later plans, declared now so callers compile:
public sealed record GrabAttached(int PlayerId, int EntityId) : SimEvent;
public sealed record GrabReleased(int PlayerId, int EntityId) : SimEvent;
public sealed record Swung(int PlayerId, GridPos ArcFrom, GridPos ArcVia, GridPos ArcTo) : SimEvent;
public sealed record SwingBlocked(int Actor, string Reason) : SimEvent;
public sealed record Sank(int EntityId) : SimEvent;
public sealed record Smashed(int EntityId, int[] ContentsSpawnedIds) : SimEvent;
public sealed record KeyConsumed(int KeyId, int DoorId) : SimEvent;
public sealed record DoorOpened(int DoorId) : SimEvent;
public sealed record DoorClosed(int DoorId) : SimEvent;
public sealed record PlatePressed(int PlateId) : SimEvent;
public sealed record PlateReleased(int PlateId) : SimEvent;
public sealed record ItemCollected(int EntityId, GlobalCounter Counter) : SimEvent;
public sealed record LevelTransitionEvent(int StairsId, string TargetLevelId, string TargetSpawnId) : SimEvent;
public sealed record SoundHint(string Kind) : SimEvent;
  • Step 5: Define EventBatch

Path: muscleman-godot4/src/Muscleman.Core/Events/EventBatch.cs

namespace Muscleman.Core.Events;

public sealed class EventBatch
{
    private readonly List<SimEvent> _events = new();
    public IReadOnlyList<SimEvent> Events => _events;

    public void Add(SimEvent e) => _events.Add(e);
    public IEnumerable<T> OfType<T>() where T : SimEvent => _events.OfType<T>();
    public int Count => _events.Count;
}
  • Step 6: Run tests; confirm pass
dotnet test --filter FullyQualifiedName~EventBatchTests

Expected: all tests pass.

  • Step 7: Commit
git add muscleman-godot4
git commit -m "core: add SimEvent hierarchy and EventBatch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 4: Tag components

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Components/Tags.cs

Tag components are empty structs implementing Friflo’s ITag marker. No tests at this layer — these are validated indirectly through World tests (Task 6) and recipe tests (Task 8).

  • Step 1: Define all tag components

Path: muscleman-godot4/src/Muscleman.Sim/Components/Tags.cs

using Friflo.Engine.ECS;

namespace Muscleman.Sim.Components;

// Movement-blocking semantics
public struct BlocksMovement : ITag { }
public struct BlocksPush     : ITag { }
public struct Walkable       : ITag { }

// Push-chain participation
public struct Pushable       : ITag { }

// Carrying & weight
public struct Liftable       : ITag { }
public struct Heavy          : ITag { }

// Behavior modifiers
public struct Sinkable       : ITag { }
public struct Hammer         : ITag { }
public struct Smashable      : ITag { }
public struct Openable       : ITag { }
public struct Opened         : ITag { }

// Terrain markers
public struct WallStrong     : ITag { }
public struct WallWeak       : ITag { }
public struct WaterCell      : ITag { }

// Save/load discriminator
public struct Static         : ITag { }

// Door state
public struct Door           : ITag { }
public struct DoorOpen       : ITag { }
  • Step 2: Verify build
dotnet build

Expected: build succeeds.

  • Step 3: Commit
git add muscleman-godot4
git commit -m "sim: add tag components for movement, terrain, and entity character

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 5: Data-bearing components

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Components/Position.cs
  • Create: muscleman-godot4/src/Muscleman.Sim/Components/Player.cs

These hold per-entity state. Only the components the rest of this plan actually exercises are added here; later plans add the rest (Holding payload, KeyOf, LockedBy, PressurePlate, Door fields, Contents, etc.). Adding only what we need keeps Friflo’s archetype graph small.

  • Step 1: Add Position-related components

Path: muscleman-godot4/src/Muscleman.Sim/Components/Position.cs

using Friflo.Engine.ECS;
using Muscleman.Core.Grid;

namespace Muscleman.Sim.Components;

public struct Position : IComponent
{
    public GridPos Pos;
    public Position(GridPos p) { Pos = p; }
}

public struct LevelMember : IComponent
{
    public string LevelId;
    public LevelMember(string id) { LevelId = id; }
}

public struct Facing : IComponent
{
    public GridDir Dir;
    public Facing(GridDir d) { Dir = d; }
}
  • Step 2: Add Player components

Path: muscleman-godot4/src/Muscleman.Sim/Components/Player.cs

using Friflo.Engine.ECS;

namespace Muscleman.Sim.Components;

public struct Player : ITag { }

public struct Holding : IComponent
{
    public int? EntityId;        // null when nothing held
    public Holding(int? id) { EntityId = id; }
}
  • Step 3: Verify build
dotnet build

Expected: build succeeds.

  • Step 4: Commit
git add muscleman-godot4
git commit -m "sim: add Position, LevelMember, Facing, Player, Holding components

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 6: World wrapper around Friflo

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/World.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/WorldTests.cs

The wrapper centralizes all Friflo-specific code so the rest of the sim depends on a stable in-house API. If Friflo’s exact method names differ from what’s used here (e.g. AddTag<T> vs AddComponent<T> for tags), only this file needs adjustment.

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/WorldTests.cs

using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class WorldTests
{
    [Fact]
    public void Spawn_creates_entity_with_position()
    {
        var w = new World();
        var id = w.SpawnEmpty(new GridPos(2, 3));

        w.Has<Position>(id).Should().BeTrue();
        w.Get<Position>(id).Pos.Should().Be(new GridPos(2, 3));
    }

    [Fact]
    public void AddTag_attaches_tag_to_entity()
    {
        var w = new World();
        var id = w.SpawnEmpty(new GridPos(0, 0));
        w.AddTag<BlocksMovement>(id);

        w.HasTag<BlocksMovement>(id).Should().BeTrue();
    }

    [Fact]
    public void RemoveTag_detaches_tag()
    {
        var w = new World();
        var id = w.SpawnEmpty(new GridPos(0, 0));
        w.AddTag<BlocksMovement>(id);
        w.RemoveTag<BlocksMovement>(id);

        w.HasTag<BlocksMovement>(id).Should().BeFalse();
    }

    [Fact]
    public void Set_overwrites_component_value()
    {
        var w = new World();
        var id = w.SpawnEmpty(new GridPos(0, 0));
        w.Set(id, new Position(new GridPos(7, 8)));

        w.Get<Position>(id).Pos.Should().Be(new GridPos(7, 8));
    }

    [Fact]
    public void Destroy_removes_entity()
    {
        var w = new World();
        var id = w.SpawnEmpty(new GridPos(0, 0));
        w.Destroy(id);

        w.IsAlive(id).Should().BeFalse();
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~WorldTests

Expected: build fails (World doesn’t exist).

  • Step 3: Implement World

Path: muscleman-godot4/src/Muscleman.Sim/World.cs

This is the only place that imports Friflo.Engine.ECS outside of Components/Tags.cs. The Friflo API used here is per the public Friflo Engine ECS surface; if package version differs and method names changed, adjust the bodies — the public methods of World stay the same.

using Friflo.Engine.ECS;
using Muscleman.Core.Grid;
using Muscleman.Sim.Components;

namespace Muscleman.Sim;

/// <summary>
/// Thin wrapper around the Friflo EntityStore. Encapsulates all ECS-specific
/// API so the rest of the sim depends only on this surface.
/// </summary>
public sealed class World
{
    private readonly EntityStore _store = new();

    public int SpawnEmpty(GridPos pos)
    {
        var e = _store.CreateEntity();
        e.AddComponent(new Position(pos));
        return e.Id;
    }

    public void Destroy(int id) => _store.GetEntityById(id).DeleteEntity();

    public bool IsAlive(int id) => _store.GetEntityById(id).IsNull == false;

    // Component (data-bearing) operations
    public bool Has<T>(int id) where T : struct, IComponent =>
        _store.GetEntityById(id).HasComponent<T>();

    public T Get<T>(int id) where T : struct, IComponent =>
        _store.GetEntityById(id).GetComponent<T>();

    public void Set<T>(int id, T value) where T : struct, IComponent
    {
        var e = _store.GetEntityById(id);
        if (e.HasComponent<T>()) e.GetComponent<T>() = value;
        else e.AddComponent(value);
    }

    public void Remove<T>(int id) where T : struct, IComponent =>
        _store.GetEntityById(id).RemoveComponent<T>();

    // Tag (zero-size) operations
    public bool HasTag<T>(int id) where T : struct, ITag =>
        _store.GetEntityById(id).Tags.Has<T>();

    public void AddTag<T>(int id) where T : struct, ITag =>
        _store.GetEntityById(id).AddTag<T>();

    public void RemoveTag<T>(int id) where T : struct, ITag =>
        _store.GetEntityById(id).RemoveTag<T>();

    // Internal access for queries (used by SpatialIndex and systems)
    internal EntityStore Store => _store;
}

If a Friflo method name above doesn’t exist on the installed version, replace with the equivalent: e.GetComponent<T>() = value may need e.SetComponent(value); e.IsNull may be e.Archetype == null. The smoke test in the next step is the verification.

  • Step 4: Run tests; confirm pass
dotnet test --filter FullyQualifiedName~WorldTests

Expected: all 5 tests pass. If a test fails because of a Friflo API mismatch, adjust the corresponding World method body and re-run.

  • Step 5: Commit
git add muscleman-godot4
git commit -m "sim: add World wrapper around Friflo EntityStore

Encapsulates Spawn/Has/Get/Set/Remove/Destroy and tag operations so
the rest of the sim depends only on this in-house API surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 7: Spatial index

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/SpatialIndex.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/SpatialIndexTests.cs

The spatial index maps GridPos → entities at that cell. The sim’s authoritative geometry. Maintained explicitly by the systems that move entities (Position component changes update the index). For this plan, the World API provides Move(id, newPos) that updates Position and the index together.

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/SpatialIndexTests.cs

using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class SpatialIndexTests
{
    [Fact]
    public void Spawn_indexes_entity_by_position()
    {
        var w = new World();
        var id = w.SpawnEmpty(new GridPos(3, 4));
        w.Index.AllAt(new GridPos(3, 4)).Should().ContainSingle().Which.Should().Be(id);
    }

    [Fact]
    public void Move_relocates_entity_in_index()
    {
        var w = new World();
        var id = w.SpawnEmpty(new GridPos(1, 1));
        w.Move(id, new GridPos(2, 1));

        w.Index.AllAt(new GridPos(1, 1)).Should().BeEmpty();
        w.Index.AllAt(new GridPos(2, 1)).Should().ContainSingle().Which.Should().Be(id);
    }

    [Fact]
    public void Destroy_removes_entity_from_index()
    {
        var w = new World();
        var id = w.SpawnEmpty(new GridPos(5, 5));
        w.Destroy(id);

        w.Index.AllAt(new GridPos(5, 5)).Should().BeEmpty();
    }

    [Fact]
    public void AnyAt_returns_true_when_cell_has_entity()
    {
        var w = new World();
        w.SpawnEmpty(new GridPos(0, 0));
        w.Index.AnyAt(new GridPos(0, 0)).Should().BeTrue();
        w.Index.AnyAt(new GridPos(1, 0)).Should().BeFalse();
    }

    [Fact]
    public void HasTagAt_returns_true_when_any_entity_in_cell_has_tag()
    {
        var w = new World();
        var id = w.SpawnEmpty(new GridPos(0, 0));
        w.AddTag<BlocksMovement>(id);
        w.Index.HasTagAt<BlocksMovement>(new GridPos(0, 0)).Should().BeTrue();
        w.Index.HasTagAt<BlocksPush>(new GridPos(0, 0)).Should().BeFalse();
    }

    [Fact]
    public void PushableAt_returns_the_unique_pushable_in_cell()
    {
        var w = new World();
        var box  = w.SpawnEmpty(new GridPos(2, 2));
        var wall = w.SpawnEmpty(new GridPos(2, 2));
        w.AddTag<Pushable>(box);
        w.AddTag<Static>(wall);

        w.Index.PushableAt(new GridPos(2, 2)).Should().Be(box);
        w.Index.PushableAt(new GridPos(0, 0)).Should().BeNull();
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~SpatialIndexTests

Expected: build fails (World.Index, World.Move, SpatialIndex don’t exist).

  • Step 3: Implement SpatialIndex

Path: muscleman-godot4/src/Muscleman.Sim/SpatialIndex.cs

using Friflo.Engine.ECS;
using Muscleman.Core.Grid;
using Muscleman.Sim.Components;

namespace Muscleman.Sim;

public sealed class SpatialIndex
{
    private readonly Dictionary<GridPos, List<int>> _cells = new();
    private readonly EntityStore _store;

    internal SpatialIndex(EntityStore store) { _store = store; }

    internal void Add(int id, GridPos at)
    {
        if (!_cells.TryGetValue(at, out var list))
        {
            list = new List<int>(2);
            _cells[at] = list;
        }
        list.Add(id);
    }

    internal void Remove(int id, GridPos at)
    {
        if (_cells.TryGetValue(at, out var list))
        {
            list.Remove(id);
            if (list.Count == 0) _cells.Remove(at);
        }
    }

    public IReadOnlyList<int> AllAt(GridPos p)
        => _cells.TryGetValue(p, out var list) ? list : (IReadOnlyList<int>)Array.Empty<int>();

    public bool AnyAt(GridPos p) => _cells.ContainsKey(p);

    public bool HasTagAt<T>(GridPos p) where T : struct, ITag
    {
        if (!_cells.TryGetValue(p, out var list)) return false;
        foreach (var id in list)
            if (_store.GetEntityById(id).Tags.Has<T>()) return true;
        return false;
    }

    public int? PushableAt(GridPos p)
    {
        if (!_cells.TryGetValue(p, out var list)) return null;
        foreach (var id in list)
            if (_store.GetEntityById(id).Tags.Has<Pushable>()) return id;
        return null;
    }

    public int? StaticAt(GridPos p)
    {
        if (!_cells.TryGetValue(p, out var list)) return null;
        foreach (var id in list)
            if (_store.GetEntityById(id).Tags.Has<Static>()) return id;
        return null;
    }
}
  • Step 4: Update World to own the index and to provide Move

Replace the body of muscleman-godot4/src/Muscleman.Sim/World.cs with:

using Friflo.Engine.ECS;
using Muscleman.Core.Grid;
using Muscleman.Sim.Components;

namespace Muscleman.Sim;

public sealed class World
{
    private readonly EntityStore _store = new();
    private readonly SpatialIndex _index;

    public World() { _index = new SpatialIndex(_store); }

    public SpatialIndex Index => _index;

    public int SpawnEmpty(GridPos pos)
    {
        var e = _store.CreateEntity();
        e.AddComponent(new Position(pos));
        _index.Add(e.Id, pos);
        return e.Id;
    }

    public void Destroy(int id)
    {
        if (Has<Position>(id)) _index.Remove(id, Get<Position>(id).Pos);
        _store.GetEntityById(id).DeleteEntity();
    }

    public void Move(int id, GridPos newPos)
    {
        var oldPos = Get<Position>(id).Pos;
        if (oldPos == newPos) return;
        _index.Remove(id, oldPos);
        Set(id, new Position(newPos));
        _index.Add(id, newPos);
    }

    public bool IsAlive(int id) => _store.GetEntityById(id).IsNull == false;

    public bool Has<T>(int id) where T : struct, IComponent =>
        _store.GetEntityById(id).HasComponent<T>();

    public T Get<T>(int id) where T : struct, IComponent =>
        _store.GetEntityById(id).GetComponent<T>();

    public void Set<T>(int id, T value) where T : struct, IComponent
    {
        var e = _store.GetEntityById(id);
        if (e.HasComponent<T>()) e.GetComponent<T>() = value;
        else e.AddComponent(value);
    }

    public void Remove<T>(int id) where T : struct, IComponent =>
        _store.GetEntityById(id).RemoveComponent<T>();

    public bool HasTag<T>(int id) where T : struct, ITag =>
        _store.GetEntityById(id).Tags.Has<T>();

    public void AddTag<T>(int id) where T : struct, ITag =>
        _store.GetEntityById(id).AddTag<T>();

    public void RemoveTag<T>(int id) where T : struct, ITag =>
        _store.GetEntityById(id).RemoveTag<T>();

    internal EntityStore Store => _store;
}
  • Step 5: Run tests; confirm pass
dotnet test

Expected: all tests across WorldTests, SpatialIndexTests, GridTests, EventBatchTests, and SmokeTests pass.

  • Step 6: Commit
git add muscleman-godot4
git commit -m "sim: add SpatialIndex and World.Move

Index maps GridPos to entities and is updated automatically on spawn,
move, and destroy. Provides AnyAt, HasTagAt, PushableAt, StaticAt queries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 8: Recipe factory

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Recipes/RecipeFactory.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/RecipeTests.cs

RecipeFactory.Spawn is the canonical entry point for creating entities of a known kind. It bundles the components per spec §3.3. This task implements only the recipes the rest of this plan exercises: Player, WallStrong, WallWeak, WaterCell, Box, HeavyBox. Other recipes are added in subsequent plans.

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/RecipeTests.cs

using Muscleman.Core.Enums;
using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Recipes;
using Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class RecipeTests
{
    [Fact]
    public void Player_recipe_attaches_expected_tags()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.Player, new GridPos(0, 0));

        w.HasTag<Player>(id).Should().BeTrue();
        w.HasTag<BlocksMovement>(id).Should().BeTrue();
        w.HasTag<BlocksPush>(id).Should().BeTrue();
        w.Has<Holding>(id).Should().BeTrue();
        w.Get<Holding>(id).EntityId.Should().BeNull();
    }

    [Fact]
    public void WallStrong_recipe_has_movement_and_push_blocking_and_static()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.WallStrong, new GridPos(1, 1));

        w.HasTag<BlocksMovement>(id).Should().BeTrue();
        w.HasTag<BlocksPush>(id).Should().BeTrue();
        w.HasTag<WallStrong>(id).Should().BeTrue();
        w.HasTag<Static>(id).Should().BeTrue();
    }

    [Fact]
    public void WallWeak_recipe_blocks_movement_only()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.WallWeak, new GridPos(1, 1));

        w.HasTag<BlocksMovement>(id).Should().BeTrue();
        w.HasTag<BlocksPush>(id).Should().BeFalse();
        w.HasTag<WallWeak>(id).Should().BeTrue();
        w.HasTag<Static>(id).Should().BeTrue();
    }

    [Fact]
    public void WaterCell_recipe_has_no_blockers()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.WaterCell, new GridPos(1, 1));

        w.HasTag<BlocksMovement>(id).Should().BeFalse();
        w.HasTag<BlocksPush>(id).Should().BeFalse();
        w.HasTag<WaterCell>(id).Should().BeTrue();
        w.HasTag<Static>(id).Should().BeTrue();
    }

    [Fact]
    public void Box_recipe_is_pushable_liftable_and_blocking()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.Box, new GridPos(1, 1));

        w.HasTag<BlocksMovement>(id).Should().BeTrue();
        w.HasTag<BlocksPush>(id).Should().BeTrue();
        w.HasTag<Pushable>(id).Should().BeTrue();
        w.HasTag<Liftable>(id).Should().BeTrue();
        w.HasTag<Smashable>(id).Should().BeTrue();
        w.HasTag<Openable>(id).Should().BeTrue();
        w.HasTag<Static>(id).Should().BeFalse();
    }

    [Fact]
    public void HeavyBox_recipe_is_heavy_pushable_sinkable_not_liftable()
    {
        var w = new World();
        var id = RecipeFactory.Spawn(w, EntityRecipe.HeavyBox, new GridPos(1, 1));

        w.HasTag<BlocksMovement>(id).Should().BeTrue();
        w.HasTag<BlocksPush>(id).Should().BeTrue();
        w.HasTag<Pushable>(id).Should().BeTrue();
        w.HasTag<Heavy>(id).Should().BeTrue();
        w.HasTag<Sinkable>(id).Should().BeTrue();
        w.HasTag<Liftable>(id).Should().BeFalse();
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~RecipeTests

Expected: build fails (RecipeFactory doesn’t exist).

  • Step 3: Implement RecipeFactory

Path: muscleman-godot4/src/Muscleman.Sim/Recipes/RecipeFactory.cs

using Muscleman.Core.Enums;
using Muscleman.Core.Grid;
using Muscleman.Sim.Components;

namespace Muscleman.Sim.Recipes;

public static class RecipeFactory
{
    /// <summary>
    /// Spawns an entity of the given recipe at the given grid position.
    /// Returns the new entity's id.
    /// </summary>
    public static int Spawn(World w, EntityRecipe recipe, GridPos pos)
    {
        var id = w.SpawnEmpty(pos);
        Apply(w, id, recipe);
        return id;
    }

    private static void Apply(World w, int id, EntityRecipe recipe)
    {
        switch (recipe)
        {
            case EntityRecipe.Player:
                w.AddTag<Player>(id);
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.Set(id, new Holding(null));
                w.Set(id, new Facing(GridDir.Down));
                break;

            case EntityRecipe.WallStrong:
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.AddTag<WallStrong>(id);
                w.AddTag<Static>(id);
                break;

            case EntityRecipe.WallWeak:
                w.AddTag<BlocksMovement>(id);
                w.AddTag<WallWeak>(id);
                w.AddTag<Static>(id);
                break;

            case EntityRecipe.WaterCell:
                w.AddTag<WaterCell>(id);
                w.AddTag<Static>(id);
                break;

            case EntityRecipe.Box:
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.AddTag<Pushable>(id);
                w.AddTag<Liftable>(id);
                w.AddTag<Openable>(id);
                w.AddTag<Smashable>(id);
                break;

            case EntityRecipe.HeavyBox:
                w.AddTag<BlocksMovement>(id);
                w.AddTag<BlocksPush>(id);
                w.AddTag<Pushable>(id);
                w.AddTag<Heavy>(id);
                w.AddTag<Sinkable>(id);
                break;

            // Recipes added in later plans:
            default:
                throw new System.NotImplementedException(
                    $"Recipe {recipe} is not yet implemented in this plan; see the rewrite design spec.");
        }
    }
}
  • Step 4: Run tests; confirm pass
dotnet test --filter FullyQualifiedName~RecipeTests

Expected: all 6 recipe tests pass.

  • Step 5: Commit
git add muscleman-godot4
git commit -m "sim: add RecipeFactory with Player/Wall/Water/Box/HeavyBox recipes

Subsequent plans extend this with the remaining recipes from spec §3.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 9: Input types

Files:

  • Create: muscleman-godot4/src/Muscleman.Core/Input/Input.cs

The input record carries one player intent per tick. We define the discriminated union now even though only Move is exercised in this plan; downstream plans reference Grab, Drop, SwingLeft, SwingRight, Wait.

  • Step 1: Define Input as a discriminated union

Path: muscleman-godot4/src/Muscleman.Core/Input/Input.cs

using Muscleman.Core.Grid;

namespace Muscleman.Core.Input;

public abstract record Input
{
    public sealed record Move(GridDir Dir) : Input;
    public sealed record Grab : Input;
    public sealed record Drop : Input;
    public sealed record SwingLeft : Input;
    public sealed record SwingRight : Input;
    public sealed record Wait : Input;

    public static Input MoveDir(GridDir d) => new Move(d);
}
  • Step 2: Verify build
dotnet build

Expected: build succeeds.

  • Step 3: Commit
git add muscleman-godot4
git commit -m "core: add Input discriminated union (Move, Grab, Drop, Swing*, Wait)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 10: TestWorld helper

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/TestWorld.cs

Tests need a concise way to set up a small world (“player at (5,5), wall to the right, box behind player”). A fluent helper keeps test code readable. This task creates the helper; tasks 11/12 use it.

  • Step 1: Implement TestWorld

Path: muscleman-godot4/src/Muscleman.Sim.Tests/TestWorld.cs

using Muscleman.Core.Enums;
using Muscleman.Core.Grid;
using Muscleman.Sim;
using Muscleman.Sim.Recipes;

namespace Muscleman.Sim.Tests;

/// <summary>
/// Test-only fluent builder for setting up a sim world quickly.
/// </summary>
public sealed class TestWorld
{
    public World World { get; } = new();
    public int PlayerId { get; private set; } = -1;
    public Dictionary<string, int> Named { get; } = new();

    public TestWorld Player(int x, int y, string name = "player")
    {
        PlayerId = RecipeFactory.Spawn(World, EntityRecipe.Player, new GridPos(x, y));
        Named[name] = PlayerId;
        return this;
    }

    public TestWorld Wall(int x, int y, bool strong = true, string? name = null)
    {
        var id = RecipeFactory.Spawn(World, strong ? EntityRecipe.WallStrong : EntityRecipe.WallWeak, new GridPos(x, y));
        if (name is not null) Named[name] = id;
        return this;
    }

    public TestWorld Water(int x, int y, string? name = null)
    {
        var id = RecipeFactory.Spawn(World, EntityRecipe.WaterCell, new GridPos(x, y));
        if (name is not null) Named[name] = id;
        return this;
    }

    public TestWorld Box(int x, int y, string name)
    {
        var id = RecipeFactory.Spawn(World, EntityRecipe.Box, new GridPos(x, y));
        Named[name] = id;
        return this;
    }

    public TestWorld HeavyBox(int x, int y, string name)
    {
        var id = RecipeFactory.Spawn(World, EntityRecipe.HeavyBox, new GridPos(x, y));
        Named[name] = id;
        return this;
    }

    public GridPos PosOf(string name) => World.Get<Muscleman.Sim.Components.Position>(Named[name]).Pos;
    public GridPos PlayerPos => World.Get<Muscleman.Sim.Components.Position>(PlayerId).Pos;
}
  • Step 2: Verify build
dotnet build

Expected: build succeeds.

  • Step 3: Commit
git add muscleman-godot4
git commit -m "tests: add TestWorld fluent helper for sim test setup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 11: Walk system (no boxes)

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs
  • Create: muscleman-godot4/src/Muscleman.Sim/StepRunner.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/WalkTests.cs

The first system. Resolves Input.Move for the player only: if the destination cell has no BlocksMovement from any entity, move the player there and emit Moved. Otherwise no-op. No pushing yet — Task 12 adds that.

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/WalkTests.cs

using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Core.Input;
using Muscleman.Sim;
using Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class WalkTests
{
    [Fact]
    public void Walking_into_open_cell_succeeds_and_emits_Moved()
    {
        var t = new TestWorld().Player(5, 5);
        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.PlayerPos.Should().Be(new GridPos(6, 5));
        batch.OfType<Moved>().Should().ContainSingle()
            .Which.To.Should().Be(new GridPos(6, 5));
    }

    [Fact]
    public void Walking_into_wall_is_a_no_op()
    {
        var t = new TestWorld().Player(5, 5).Wall(6, 5);
        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.PlayerPos.Should().Be(new GridPos(5, 5));
        batch.OfType<Moved>().Should().BeEmpty();
    }

    [Fact]
    public void Walking_into_weak_wall_is_blocked_for_player()
    {
        var t = new TestWorld().Player(5, 5).Wall(6, 5, strong: false);
        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));
        t.PlayerPos.Should().Be(new GridPos(5, 5));
    }

    [Fact]
    public void Walking_in_each_cardinal_direction_works()
    {
        var t = new TestWorld().Player(5, 5);
        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));
        t.PlayerPos.Should().Be(new GridPos(6, 5));
        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Down));
        t.PlayerPos.Should().Be(new GridPos(6, 6));
        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Left));
        t.PlayerPos.Should().Be(new GridPos(5, 6));
        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Up));
        t.PlayerPos.Should().Be(new GridPos(5, 5));
    }

    [Fact]
    public void Wait_input_emits_no_movement()
    {
        var t = new TestWorld().Player(5, 5);
        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.Wait());

        t.PlayerPos.Should().Be(new GridPos(5, 5));
        batch.OfType<Moved>().Should().BeEmpty();
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~WalkTests

Expected: build fails (StepRunner.Step doesn’t exist).

  • Step 3: Implement PlayerActionSystem (walk-only path)

Path: muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs

using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Core.Input;
using Muscleman.Sim.Components;

namespace Muscleman.Sim.Systems;

public static class PlayerActionSystem
{
    public static void Resolve(World w, int playerId, Input input, EventBatch events)
    {
        switch (input)
        {
            case Input.Move move:
                ResolveMove(w, playerId, move.Dir, events);
                break;

            case Input.Wait:
            case Input.Grab:
            case Input.Drop:
            case Input.SwingLeft:
            case Input.SwingRight:
                // Grab/Drop/Swing handled in subsequent plans.
                break;
        }
    }

    private static void ResolveMove(World w, int playerId, GridDir dir, EventBatch events)
    {
        var fromPos = w.Get<Position>(playerId).Pos;
        var toPos = fromPos + dir.ToOffset();

        // Update facing regardless of whether the move succeeds.
        w.Set(playerId, new Facing(dir));

        if (w.Index.HasTagAt<BlocksMovement>(toPos))
        {
            // Blocked. Push handling comes in the next task.
            return;
        }

        w.Move(playerId, toPos);
        events.Add(new Moved(playerId, fromPos, toPos, MoveCause.Player));
    }
}
  • Step 4: Implement StepRunner

Path: muscleman-godot4/src/Muscleman.Sim/StepRunner.cs

using Muscleman.Core.Events;
using Muscleman.Core.Input;
using Muscleman.Sim.Systems;

namespace Muscleman.Sim;

public static class StepRunner
{
    /// <summary>
    /// Public sim-step entry point. Resolves one player input, returns the
    /// resulting EventBatch. Phases: Player -> (Actor, Environmental, Cleanup
    /// added in later plans) -> Emit.
    /// </summary>
    public static EventBatch Step(World w, int playerId, Input input)
    {
        var events = new EventBatch();
        PlayerActionSystem.Resolve(w, playerId, input, events);
        return events;
    }
}
  • Step 5: Run tests; confirm pass
dotnet test --filter FullyQualifiedName~WalkTests

Expected: all 5 walk tests pass.

  • Step 6: Commit
git add muscleman-godot4
git commit -m "sim: add PlayerActionSystem + StepRunner with walk resolution

Walk only — push handling deferred to next task. Wait/Grab/Drop/Swing
are recognized but no-op for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 12: Single-box push

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim/Systems/PushSystem.cs
  • Modify: muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs
  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/PushTests.cs

Walking into a Pushable triggers a push attempt. If the cell behind the box is clear (no BlocksMovement), the box moves and the player follows. If blocked, both stay put.

  • Step 1: Write the failing tests

Path: muscleman-godot4/src/Muscleman.Sim.Tests/PushTests.cs

using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Core.Input;
using Muscleman.Sim;
using Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class PushTests
{
    [Fact]
    public void Pushing_a_box_into_open_cell_moves_both_player_and_box()
    {
        var t = new TestWorld().Player(5, 5).Box(6, 5, "box");
        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.PlayerPos.Should().Be(new GridPos(6, 5));
        t.PosOf("box").Should().Be(new GridPos(7, 5));
        batch.OfType<Moved>().Should().HaveCount(2);
    }

    [Fact]
    public void Pushing_a_box_into_a_wall_is_blocked()
    {
        var t = new TestWorld().Player(5, 5).Box(6, 5, "box").Wall(7, 5);
        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.PlayerPos.Should().Be(new GridPos(5, 5));
        t.PosOf("box").Should().Be(new GridPos(6, 5));
    }

    [Fact]
    public void Push_emits_box_Moved_with_Push_cause()
    {
        var t = new TestWorld().Player(5, 5).Box(6, 5, "box");
        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        batch.OfType<Moved>()
            .Should().Contain(e => e.EntityId == t.Named["box"] && e.Cause == MoveCause.Push);
    }

    [Fact]
    public void Pushing_a_heavy_box_works_the_same_as_a_normal_box()
    {
        var t = new TestWorld().Player(5, 5).HeavyBox(6, 5, "heavy");
        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.PlayerPos.Should().Be(new GridPos(6, 5));
        t.PosOf("heavy").Should().Be(new GridPos(7, 5));
    }
}
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~PushTests

Expected: 4 tests fail (boxes don’t move; player blocked).

  • Step 3: Implement PushSystem

Path: muscleman-godot4/src/Muscleman.Sim/Systems/PushSystem.cs

using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Sim.Components;

namespace Muscleman.Sim.Systems;

public static class PushSystem
{
    /// <summary>
    /// Attempts to push the entity at <paramref name="boxPos"/> in <paramref name="dir"/>.
    /// Returns true if the push succeeded (entity moved); false if blocked.
    /// Emits Moved events for moved entities.
    /// </summary>
    public static bool TryPush(World w, GridPos boxPos, GridDir dir, EventBatch events)
    {
        var pushableId = w.Index.PushableAt(boxPos);
        if (pushableId is null) return false;

        var dst = boxPos + dir.ToOffset();
        if (w.Index.HasTagAt<BlocksMovement>(dst)) return false;

        w.Move(pushableId.Value, dst);
        events.Add(new Moved(pushableId.Value, boxPos, dst, MoveCause.Push));
        return true;
    }
}
  • Step 4: Update PlayerActionSystem to attempt a push when blocked by a Pushable

Replace the body of ResolveMove in muscleman-godot4/src/Muscleman.Sim/Systems/PlayerActionSystem.cs:

private static void ResolveMove(World w, int playerId, GridDir dir, EventBatch events)
{
    var fromPos = w.Get<Position>(playerId).Pos;
    var toPos = fromPos + dir.ToOffset();

    w.Set(playerId, new Facing(dir));

    // If destination is occupied by something pushable, attempt to push.
    if (w.Index.PushableAt(toPos) is int)
    {
        if (!PushSystem.TryPush(w, toPos, dir, events))
            return; // push failed — player stays put
        // Push succeeded — fall through to move the player.
    }
    else if (w.Index.HasTagAt<BlocksMovement>(toPos))
    {
        return; // blocked by a non-pushable (wall, door, etc.)
    }

    w.Move(playerId, toPos);
    events.Add(new Moved(playerId, fromPos, toPos, MoveCause.Player));
}
  • Step 5: Run tests; confirm pass
dotnet test

Expected: all push tests + all walk tests + previous suites pass.

  • Step 6: Commit
git add muscleman-godot4
git commit -m "sim: add single-box push, integrated with walk resolution

Walking into a Pushable triggers TryPush; if the cell behind is clear,
both move; otherwise both stay put.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 13: Push chain (line of boxes)

Files:

  • Modify: muscleman-godot4/src/Muscleman.Sim/Systems/PushSystem.cs
  • Modify: muscleman-godot4/src/Muscleman.Sim.Tests/PushTests.cs

Walking into a line of boxes pushes all of them as long as the cell beyond the line is clear. If any cell along the chain has a non-Pushable BlocksMovement, the entire chain is rejected.

  • Step 1: Add the failing chain tests

Append to muscleman-godot4/src/Muscleman.Sim.Tests/PushTests.cs:

    [Fact]
    public void Pushing_a_line_of_two_boxes_moves_both()
    {
        var t = new TestWorld()
            .Player(5, 5)
            .Box(6, 5, "b1")
            .Box(7, 5, "b2");

        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.PlayerPos.Should().Be(new GridPos(6, 5));
        t.PosOf("b1").Should().Be(new GridPos(7, 5));
        t.PosOf("b2").Should().Be(new GridPos(8, 5));
    }

    [Fact]
    public void Pushing_a_line_of_three_boxes_moves_all_three()
    {
        var t = new TestWorld()
            .Player(5, 5)
            .Box(6, 5, "b1")
            .Box(7, 5, "b2")
            .Box(8, 5, "b3");

        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.PosOf("b1").Should().Be(new GridPos(7, 5));
        t.PosOf("b2").Should().Be(new GridPos(8, 5));
        t.PosOf("b3").Should().Be(new GridPos(9, 5));
    }

    [Fact]
    public void Line_blocked_by_wall_at_terminus_does_not_move()
    {
        var t = new TestWorld()
            .Player(5, 5)
            .Box(6, 5, "b1")
            .Box(7, 5, "b2")
            .Wall(8, 5);

        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.PlayerPos.Should().Be(new GridPos(5, 5));
        t.PosOf("b1").Should().Be(new GridPos(6, 5));
        t.PosOf("b2").Should().Be(new GridPos(7, 5));
    }

    [Fact]
    public void Line_emits_Moved_for_every_pushed_entity()
    {
        var t = new TestWorld()
            .Player(5, 5)
            .Box(6, 5, "b1")
            .Box(7, 5, "b2");

        var batch = StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        batch.OfType<Moved>().Should().HaveCount(3); // player + 2 boxes
        batch.OfType<Moved>().Where(e => e.Cause == MoveCause.Push).Should().HaveCount(2);
    }
  • Step 2: Run tests; confirm failure
dotnet test --filter FullyQualifiedName~PushTests

Expected: chain tests fail (only the first box moves; rest stay).

  • Step 3: Replace PushSystem.TryPush with chain-aware logic

Replace the entire body of muscleman-godot4/src/Muscleman.Sim/Systems/PushSystem.cs:

using Muscleman.Core.Enums;
using Muscleman.Core.Events;
using Muscleman.Core.Grid;
using Muscleman.Sim.Components;

namespace Muscleman.Sim.Systems;

public static class PushSystem
{
    /// <summary>
    /// Attempts to push the chain of pushables starting at <paramref name="boxPos"/>
    /// in <paramref name="dir"/>. The chain is the maximal contiguous run of
    /// pushable entities beginning at boxPos. The push succeeds atomically if
    /// the cell beyond the last pushable is free of BlocksMovement (and is not
    /// itself pushable). Returns true on success; false on rejection.
    /// </summary>
    public static bool TryPush(World w, GridPos boxPos, GridDir dir, EventBatch events)
    {
        var firstPushable = w.Index.PushableAt(boxPos);
        if (firstPushable is null) return false;

        // Walk the line of pushables.
        var line = new List<int>();
        var positions = new List<GridPos>();
        var cursor = boxPos;
        var offset = dir.ToOffset();

        while (true)
        {
            var hereId = w.Index.PushableAt(cursor);
            if (hereId is null) break;
            line.Add(hereId.Value);
            positions.Add(cursor);
            cursor += offset;
        }

        // Cell after the last pushable: must be clear of BlocksMovement.
        if (w.Index.HasTagAt<BlocksMovement>(cursor)) return false;

        // Atomic commit: move every entity in the line by one step in `dir`.
        // Iterate from the end so each entity's destination is empty when it moves.
        for (int i = line.Count - 1; i >= 0; i--)
        {
            var id = line[i];
            var from = positions[i];
            var to = from + offset;
            w.Move(id, to);
            events.Add(new Moved(id, from, to, MoveCause.Push));
        }

        return true;
    }
}
  • Step 4: Run tests; confirm pass
dotnet test

Expected: all chain tests pass; all earlier tests still pass.

  • Step 5: Commit
git add muscleman-godot4
git commit -m "sim: extend push to chain of boxes

TryPush now walks the contiguous line of Pushables, atomically moves
all entities by one tile if the cell beyond the line is unblocked, or
rejects the whole chain otherwise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 14: Sunk-heavy-box transparency for walking

Files:

  • Create: muscleman-godot4/src/Muscleman.Sim.Tests/SunkBoxWalkTests.cs

A sunk heavy box loses BlocksMovement/BlocksPush/Pushable and gains Walkable (per spec §3.3). The actual sinking rule lives in the environmental phase (later plan), but we can manually apply the post-sink state in a test and verify that walking onto the sunk box succeeds.

This task is verification-only — it confirms the design’s claim that sunk-box walkability falls out of the component model with no special-case code.

  • Step 1: Write the test

Path: muscleman-godot4/src/Muscleman.Sim.Tests/SunkBoxWalkTests.cs

using Muscleman.Core.Enums;
using Muscleman.Core.Grid;
using Muscleman.Core.Input;
using Muscleman.Sim;
using Muscleman.Sim.Components;
using Muscleman.Sim.Recipes;
using Xunit;
using FluentAssertions;

namespace Muscleman.Sim.Tests;

public class SunkBoxWalkTests
{
    [Fact]
    public void Player_can_walk_onto_a_sunk_heavy_box()
    {
        var t = new TestWorld().Player(5, 5);
        var heavyId = RecipeFactory.Spawn(t.World, EntityRecipe.HeavyBox, new GridPos(6, 5));

        // Manually apply post-sink state (the env-phase rule that does this is in a later plan).
        t.World.RemoveTag<BlocksMovement>(heavyId);
        t.World.RemoveTag<BlocksPush>(heavyId);
        t.World.RemoveTag<Pushable>(heavyId);
        t.World.RemoveTag<Sinkable>(heavyId);
        t.World.AddTag<Walkable>(heavyId);

        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        t.PlayerPos.Should().Be(new GridPos(6, 5));
    }

    [Fact]
    public void An_un_sunk_heavy_box_at_the_same_position_blocks_walking()
    {
        var t = new TestWorld().Player(5, 5).HeavyBox(6, 5, "heavy");
        StepRunner.Step(t.World, t.PlayerId, new Input.Move(GridDir.Right));

        // Heavy box is pushable and the cell behind is clear, so player+heavy both move.
        t.PlayerPos.Should().Be(new GridPos(6, 5));
        t.PosOf("heavy").Should().Be(new GridPos(7, 5));
    }
}
  • Step 2: Run tests; confirm pass
dotnet test

Expected: both pass on the existing implementation. (No code changes needed — this is verification of the design property.)

  • Step 3: Commit
git add muscleman-godot4
git commit -m "tests: verify sunk-heavy-box walkability falls out of component model

No new sim code — this confirms the design claim from spec §3.3 that the
post-sink component state (Walkable, no blockers) lets walking succeed
without any special-case logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 15: Final integration — all-tests pass + suite summary

Files: none new.

  • Step 1: Run the full test suite
cd /home/henri/muscleman/muscleman-godot4
dotnet test

Expected output: Passed: N, Failed: 0 where N is the total number of tests across SmokeTests (1) + GridTests (5) + EventBatchTests (2) + WorldTests (5) + SpatialIndexTests (6) + RecipeTests (6) + WalkTests (5) + PushTests (8) + SunkBoxWalkTests (2). N = 40 tests.

If anything fails, fix the underlying issue. Do not commit a passing-locally / failing-CI state.

  • Step 2: Tag the milestone in git
cd /home/henri/muscleman
git tag -a sim-walk-push -m "Sim foundation: bootstrap, components, World, recipes, walk, push chain"
  • Step 3: Verify the tag
git tag -l sim-walk-push

Expected: sim-walk-push


Definition of Done

This plan is complete when:

  1. dotnet test from muscleman-godot4/ passes 40 tests.
  2. The sim-walk-push git tag points to a commit with all of the above tasks merged.
  3. The sim is genuinely Godot-free — grep -r "Godot" muscleman-godot4/src/Muscleman.Core muscleman-godot4/src/Muscleman.Sim returns nothing.
  4. No // TODO, // FIXME, or throw new NotImplementedException outside of RecipeFactory’s default branch (which is intentional and explicit).

What’s Next

The next plan in the sequence is Sim grab/drop and swing slide — adds Holding resolution, ScanSwingLine, the slide branch of ResolveSwing, ApplySwingOps, and the Swung event. Approximate scope: 8–10 tasks.