Muscleman — Godot 4 + ECS Rewrite

Status: Design approved, ready for implementation planning. Author: henri (with Claude) Date: 2026-05-03

1. Goal and non-goals

Goal

Rewrite Muscleman as a Godot 4 + C# project with an engine-agnostic ECS simulation core. The current Godot 3 implementation tightly couples gameplay rules to Godot’s TileMap / Area2D / RayCast2D primitives, which made the rule set untenable as features accumulated (sinking-box behavior never worked correctly; new features kept compromising). The rewrite extracts the entire game simulation into a portable C# library that runs without Godot, with Godot 4 acting only as level editor and presenter.

Why now

  • Godot 4 changed how TileMaps work (each layer is now its own node), which forces some level-system rework regardless.
  • The user wants to add autonomous actors (machines that move boxes independently of player input). This requires a clean tick model that the current input-driven raycast architecture cannot support cleanly.
  • The user wants to add an open-world layout where multiple levels persist state across transitions. The current scene-swap architecture cannot save in-flight box positions across levels.
  • Future features (rule-driven walls, more complex puzzle objects, possible web port) are easier to build atop a uniform ECS substrate than to retrofit later.

Non-goals

  • Real-time tick model. The game stays player-paced.
  • Networking / multiplayer. Determinism leaves it possible; not planned.
  • Procedural generation.
  • A non-Godot port. The architecture preserves the option but doesn’t reserve time for it.

2. Layer architecture

Three layers, each ignorant of the layer above it. The cardinal rule: the sim is a pure C# library that compiles and runs without Godot.

┌─────────────────────────────────────────────────────────────┐
│  Godot 4 layer (Muscleman.Godot)                            │
│  - Node tree, scenes, input, audio, sprites, tweens         │
│  - Authoring (TileMapLayers + entity scenes)                │
│  - Presenter (per-entity nodes mirroring sim state)         │
│  - Reads sim state, pushes inputs, animates EventBatches    │
└──────────────────────────────────┬──────────────────────────┘
                                   │ World.Step(input) -> EventBatch
                                   │ World.Spawn(recipe, gridPos)
                                   ▼
┌─────────────────────────────────────────────────────────────┐
│  Sim layer (Muscleman.Sim) — Friflo Engine ECS, pure C#     │
│  - World (entity store), Components, Systems                │
│  - Multi-phase tick resolution                              │
│  - Save/Load via Friflo serialization                       │
│  - Zero Godot references                                    │
└──────────────────────────────────┬──────────────────────────┘
                                   │
                                   ▼
┌─────────────────────────────────────────────────────────────┐
│  Core layer (Muscleman.Core) — pure C#, no Friflo           │
│  - GridPos, GridDir, KeyColor, EntityRecipe enums           │
│  - Event types (Moved, Smashed, Sank, ...)                  │
│  - LevelDescriptor DTOs (the level-file shape)              │
│  - Used by Sim, Godot, tests, and any external tool         │
└─────────────────────────────────────────────────────────────┘

Concrete consequences:

  • Muscleman.Sim is a regular .NET class library. dotnet test runs it without Godot. Levels can be solved/fuzzed/replayed in unit tests.
  • Godot calls world.Step(input), gets back an EventBatch, and animates from that. Godot never writes sim state.
  • The Core layer exists so Godot and external tools can refer to enums and event types without taking a Friflo dependency.

3. ECS catalog

3.1 Terrain is also entities

Walls, water, and any cell-shaped passive thing are full ECS entities, not a separate static grid. This costs a hand-maintained spatial index (Dictionary<GridPos, List<EntityId>>, ~50 lines, with two per-cell uniqueness invariants: at most one Static entity, at most one Pushable entity) and ~hundreds of extra entities per level (negligible for Friflo’s archetype storage). It buys uniform queries everywhere, future-proofing for rule-driven walls (destructible, switch-controlled, secret), one save/event path, and one mental model.

Multiple entities can co-occupy a cell — the canonical case is a swung box landing on a WallWeak, which is the entire mechanic behind plate-toggling puzzles where the player is walled off from the plate.

3.2 Component catalog

Components are flat structs/tags grouped by purpose:

Identity & placement

  • GridPos(x, y) — required on everything that occupies a cell
  • Facing(dir) — player and any directional actor
  • LevelMember(levelId) — which level region owns this entity

Movement & physical character

  • BlocksMovement — entities with this in a cell prevent walkers from entering
  • BlocksPush — entities with this terminate push/swing lines (unless they’re also Pushable, in which case they extend the line instead)
  • Walkable — explicit “this cell is traversable terrain” tag; sunk heavy boxes carry it
  • Pushable — chain-pushable; at most one per cell
  • Liftable — player can grab and carry
  • Heavy — too heavy to lift; activates heavy pressure plates; sinks in water (with Sinkable)
  • Sinkable — when in a WaterCell, sheds BlocksMovement/BlocksPush/Pushable/Sinkable and gains Walkable

The BlocksMovement / BlocksPush split is what lets WallWeak block walking but permit swinging. Most blockers carry both tags; weak walls carry only BlocksMovement; sunk heavy boxes carry neither.

Static terrain

  • WallStrongBlocksMovement + BlocksPush; swings rebound and lines cannot pass
  • WallWeakBlocksMovement only; swing lines pass over and may land on it
  • WaterCell — neither blocker; triggers Sinkable behavior on entities co-located
  • Static — save-skip tag; entity is reconstructible from the level file

Containers & openables

  • Openable, Opened, Smashable
  • Contents(WeightedList<EntityRecipe>) — what spawns on open
  • Hammer — held + swung opens whatever is in the line
  • Combinable(withRecipe, resultRecipe) — blue + yellow → green key

Locks & signals

  • KeyOf(KeyColor), LockedBy(KeyColor)
  • Door, DoorOpen
  • PressurePlate(group, requiresHeavy)
  • SignalTarget(group, behavior) — listens for plate signals
  • SecretTrigger(triggerName) — secret door reads named entity (the duck)

Collection & counters

  • Edible(GlobalCounter) — fruit; eaten when shoved into a wall by player
  • OnOpen(GlobalCounter) — increments counter when opened (cauldron, bong)

World transitions

  • LevelTransition(targetLevelId, targetSpawnId)

Text & NPCs

  • Readable(textKey) — books, ghosts, gravestones, NPCs

Player

  • Player, Holding(EntityId?) — single-direction holding (player → held entity)

Autonomous actors (forward-looking)

  • Actor(brainKind) — has a turn each tick
  • Brain* — one component per AI kind (e.g. ConveyorBrain(dir))

3.3 Entity recipes

Recipe Components
Player Player, GridPos, Facing, BlocksMovement, BlocksPush, Holding
Box GridPos, BlocksMovement, BlocksPush, Pushable, Liftable, Openable, Smashable, Contents
HeavyBox GridPos, BlocksMovement, BlocksPush, Pushable, Heavy, Sinkable
Hammer GridPos, BlocksMovement, BlocksPush, Pushable, Liftable, Hammer
Key (blue) GridPos, BlocksMovement, BlocksPush, Pushable, Liftable, KeyOf(Blue), Combinable(Yellow → Green)
Door (closed, red) GridPos, BlocksMovement, BlocksPush, Door, LockedBy(Red)
Door (open) GridPos, Door, DoorOpen (lost movement/push blocking)
PressurePlate GridPos, PressurePlate(group, requiresHeavy) (walkable; no blocking)
Stairs GridPos, LevelTransition(...) (walkable)
Apple GridPos, BlocksMovement, BlocksPush, Pushable, Liftable, Edible(Apples)
Cauldron GridPos, BlocksMovement, BlocksPush, Pushable, Openable, OnOpen(Ghosts)
Book GridPos, BlocksMovement, BlocksPush, Pushable, Heavy, Readable("book_credits")
WallStrong GridPos, BlocksMovement, BlocksPush, WallStrong, Static
WallWeak GridPos, BlocksMovement, WallWeak, Static
WaterCell GridPos, WaterCell, Static
Sunk HeavyBox (state) GridPos, Walkable (BlocksMovement, BlocksPush, Pushable, Sinkable, Heavy all removed)

Systems query components, never types: world.Query<GridPos, Sinkable>(), never if (entity is HeavyBox). Adding a new entity type is a recipe choice, not a subclass.

4. Tick phases and resolution

A tick is the unit between input and “world is settled again”. The sim’s public step API:

EventBatch World.Step(Input input);

4.1 Phase order

  1. Player phase. The player’s intent (move, grab/drop, swing-left/right, wait) resolves atomically. Each handler reads world state, computes the full chain of changes, and either commits or rejects — never partial.
  2. Actor phase. Every entity with Actor runs its brain in deterministic order (by EntityId, optionally by a Priority component). Each brain attempts an action through the same chain helpers as the player. Conflicts resolve by phase order: first writer wins.
  3. Environmental phase. Reads current state and applies state-dependent rules: sinking, pressure-plate occupancy, signal-driven door states, secret triggers.
  4. Cleanup phase. Destroy queued entities (used keys, eaten fruit), spawn queued entities (box contents, combined keys). Spawns and destroys never happen mid-phase.
  5. Emit phase. The accumulated EventBatch is finalized and returned.

4.2 Determinism

Same world state + same input → byte-identical EventBatch. Required for: replays, deterministic save/load, future networking, property-based tests. Iteration order is always sorted (OrderBy(EntityId)) where it matters.

4.3 Atomicity within a phase

Systems read state once, derive all changes, and write them in one staged commit. The sim never reads its own writes mid-resolution. This is the property that prevents the bug class in which the current code’s sinking-box logic lives.

4.4 Swing resolution (the tricky one)

The swing breaks into three pure phases:

Scan — walk the swing line iteratively in C# (recursive in spirit; iterative for stack safety):

SwingLine ScanSwingLine(GridPos start, GridDir swingDir, World w);

Per cell visited in the swing direction:

  • If a Pushable is here, append it to the line and continue.
  • Else if any entity here has BlocksMovement or BlocksPush, stop — that entity is the terminus.
  • Else (cell is empty, or only contains walkable terrain like a sunk heavy box or open door), continue past.

This makes sunk heavy boxes and open doors transparent to swings (they carry neither blocker tag), while a WallWeak becomes the terminus (it has BlocksMovement, even though it lacks BlocksPush). The resolver then decides what to do with that terminus.

Resolve — pure function from (line, held, world) → List<SwingOp>:

abstract record SwingOp {
    record Move(EntityId entity, GridDir dir)                : SwingOp;
    record Open(EntityId container)                          : SwingOp;
    record Combine(EntityId a, EntityId b, EntityRecipe out) : SwingOp;
    record Block(EntityId blocker)                           : SwingOp;  // animation hint
}

The resolver returns a flat list of atomic ops. No discriminated SwingOutcome union, no CompoundOutcome.

Apply — mechanical switch over op kinds; one effect per op; never recursive.

4.5 Swing rule matrix

Heavy boxes slide as part of a line — the bridge-building mechanic depends on it (push line of heavies onto water → they slide → environmental phase sinks them → repeat). Heavy means unliftable and sinks in water, not unmovable.

Movement and transformation are mutually exclusive: a swing line either slides or transforms, never both.

A terminus is soft if it lacks BlocksPush (open cell, WallWeak, open door). Soft terminus permits slides. A terminus is hard if it has BlocksPush (WallStrong, closed door, edge of map).

Scenario Op list
Any line, soft terminus (open or WallWeak) Move(e, dir) for every entity in line
Non-heavy line, hard terminus, no hammer [Block(terminus)]
Non-heavy line, hard terminus, hammer held [Open(b₁), …, Open(bₙ), Block(terminus)]
Line with H_X_H and/or H_K_K_H patterns, hard terminus All patterns fire simultaneously: union of Open(...) and Combine(...) ops + [Block(terminus)]
Line with heavies but no pattern, hard terminus [Block(terminus)]

Multiple squeeze patterns in a single line all fire simultaneously. The resolver dedupes by entity id (so a Hammer + nutcrack overlap on the same box produces one Open, not two).

Slide landing on a WallWeak. This is a load-bearing puzzle mechanic, not an edge case: weak walls let the player gate themselves off from a region while still allowing boxes to be swung over to toggle pressure plates beyond. When a slide lands a Pushable entity on a WallWeak cell, both occupy the cell. The wall remains. The BlocksMovement from the wall continues to deny the player walking access; the box on top is itself BlocksMovement and can be re-swung off the wall on a subsequent action. The component split (BlocksMovement vs BlocksPush) is precisely what makes this co-occupancy legal and unambiguous.

4.6 Event types (initial set)

Moved(entityId, fromPos, toPos, mover: Player|Push|Swing|Brain)
Swung(playerId, arcFrom, arcVia, arcTo)
Sank(entityId)
Smashed(entityId, contentsSpawnedIds)
KeyConsumed(keyId, doorId)
DoorOpened(doorId) / DoorClosed(doorId)
PlatePressed(plateId) / PlateReleased(plateId)
ItemCollected(entityId, counterKind)
LevelTransition(stairsId, targetLevelId, targetSpawnId)
GrabAttached(playerId, entityId) / GrabReleased(playerId, entityId)
SwingBlocked(actor, reason)
SoundHint(kind)

4.7 Invariants

  1. At most one Static entity per cell. Walls and water don’t stack.
  2. At most one Pushable entity per cell. Pushes and swing-pushes have unambiguous targets.
  3. Multiple BlocksMovement entities can co-exist in a cell (e.g., box on WallWeak). Walking is blocked if any BlocksMovement is present.
  4. The sim never holds in-flight visual state. Tweening, arc interpolation, and animation timing live in the presenter exclusively.

5. Level files, saves, and the open world

5.1 Authoring stays in Godot 4

Godot 4 is the level editor. The sim core never reads .tscn. The reconciliation is at the boundary: the GD4 layer walks the scene tree (and/or reads exported JSON) and translates each tile and each instanced entity into world.Spawn(recipe, gridPos) calls.

The TileSet palette → scene → recipe chain:

TileSet palette entry  →  Godot scene instance (e.g. Box.tscn)  →  EntityRecipe + overrides  →  Friflo entity

Two flavors of palette entry, mixed within one TileSet:

  • Plain tiles for static terrain (WallStrong, WallWeak, WaterCell, Floor). Texture only; tile id maps to a recipe via a small TileSet → Recipe resource.
  • Scene tiles for entities with per-instance state (Box, Door, Key, PressurePlate, Stairs). Painting the palette entry instances the entity scene at the cell. The instance has an EntityAuthoring script with [Export] properties.
public abstract partial class EntityAuthoring : Node2D {
    public abstract EntityRecipe Recipe { get; }
    public virtual void ApplyOverrides(World w, EntityId id) { }
}

5.2 Adapter walk

void LoadLevel(Node levelRoot, World world, LevelId levelId) {
    foreach (var layer in levelRoot.FindChildrenOfType<TileMapLayer>()) {
        foreach (var cell in layer.GetUsedCells()) {
            var recipe = TileSetRecipeMap.Lookup(layer.TileSet, layer.GetCellSourceId(cell), layer.GetCellAtlasCoords(cell));
            if (recipe is null) continue;
            var id = world.Spawn(recipe, GridPos.FromCell(cell), levelId);
            world.AddTag<Static>(id);
        }
    }
    foreach (var auth in levelRoot.FindChildrenOfType<EntityAuthoring>(deep: true)) {
        var pos = GridPos.FromWorldPx(auth.Position);
        var id = world.Spawn(auth.Recipe, pos, levelId);
        auth.ApplyOverrides(world, id);
        auth.QueueFree();
    }
}

Authoring nodes are discarded after load; the presenter creates fresh visual nodes from the resulting spawn events. One spawn path covers both load-time and runtime entities.

5.3 Existing exporter

export/exporter.gd already walks the GD3 scene tree and emits per-tilemap JSON, and synthesizes an ObjectsTileMap that treats every game-object node (Box, HeavyBox, Key, Door, etc.) as a 1×1 tile with type + position. This is structurally identical to what the GD4 sim adapter needs: a uniform list of (type, gridPos, properties) records.

The exporter has two roles in the rewrite:

  1. GD3 → GD4 migration bridge. Run it once on each existing GD3 level, emit JSON, drop into the new project. A portable JSON level-loader in Muscleman.Core parses the file and calls world.Spawn for each entry. The same loader is reused by headless tests to load real levels without Godot.
  2. Future GD4 authoring pipeline. Port the exporter to GD4 (or write a fresh one against the same shape). Levels in the new project are authored in the GD4 editor and exported to JSON on save or build. The JSON files become the canonical at-rest level format — Godot is the editor, JSON is the artifact, the scene-tree adapter is an editor convenience for “play test now” without re-exporting.

Two extensions when porting to GD4:

  • Emit each entity’s [Export] properties as a properties: {...} blob applied as component overrides post-spawn.
  • Wrap output in a level envelope: id, name, signal wiring, level-graph neighbors.

5.4 Saves

saves/<slot>/
  meta.json     # active level id, global counters, save version, visited levels
  world.json    # Friflo dump of non-Static entities only

Load sequence:

  1. Read meta.json → know active level.
  2. Open the corresponding level (Godot scene or exported JSON).
  3. Run the adapter — spawns all Static entities (walls, water, terrain).
  4. Apply world.json via Friflo — overlays dynamic entities and their states.
  5. Hand off to the presenter.

Save files stay tiny (statics are reconstructible). Layout changes propagate to existing saves transparently. Schema versioning by integer saveVersion.

5.5 Open world

Single Friflo world, every entity tagged LevelMember(levelId), only the active level’s Actors tick. Transitions update an ActiveLevel resource and move the player to the destination spawn point — no world swap, no serialize round-trip per stairs.

Invariant: ticks operate on a single level. Swing chains, push chains, and signal propagation never cross level boundaries.

If the project ever ships dozens of levels and memory matters, the LevelMember indirection allows lazy unloading without changes to the rest of the model.

6. Presenter and input

6.1 One presenter node per sim entity

The presenter layer is a thin Godot 4 wrapper around sim state. Sim → presenter, never back.

World.Step(input) ──► EventBatch ──► PresenterDispatcher
                                        │
                       ┌────────────────┼─────────────────┐
                       ▼                ▼                 ▼
                  per-entity       per-event          global
                  nodes            choreography       (camera, UI, audio)

The dispatcher maintains Dictionary<EntityId, PresenterNode>. Each presenter node holds its sprite, AnimatedSprite, tween, audio players. It listens (via dispatcher routing, not Godot signals) for events tagged with its entity id and updates its visual state accordingly.

6.2 Choreography of an EventBatch

  1. Group events by phase (player → actors → environmental → cleanup), preserving sim order.
  2. Within a phase, animate in parallel (all Moves tween at once, all Sanks splash at once).
  3. Between phases, brief stagger (~40–80ms) so causality reads visually.
  4. Total batch duration wraps at the longest in-flight tween; input unlocks on settle.

6.3 Input

  • Movement (held = repeating). While a direction is held, the presenter samples it on every batch settle and feeds it as the next input.
  • Edge-triggered actions. Grab/drop, swing-left/right. Each press fires once and is cleared.
  • Late-press buffer for edge actions. A press within ~100ms of the previous batch’s settle queues for the next tick. No multi-input queue beyond that — at most one buffered edge action.
public sealed class InputBuffer {
    GridDir? _heldDir;
    Queue<DiscreteAction> _edge;     // capacity-1 in practice
    DateTime _lastEdgePressUtc;

    public Input? Drain(bool simReady);
}

6.4 What dies in the rewrite

  • All Area2D + RayCast2D collision queries (sim handles it).
  • MoveChecker (replaced by the spatial index).
  • move_tween, loop_tween on entities (driven by Move events in the dispatcher).
  • The 300-line player.gd::_process / move / rotate_tween (becomes a few dozen lines).
  • Per-entity _process for water-detection on HeavyBox (sim’s environmental phase).

7. Migration plan

7.1 Repo strategy

A new directory muscleman-godot4/ (or v2/) under the same repo. The shipped GD3 build keeps working for reference and itch.io builds; the exporter reads the GD3 levels in scene/levels/*.tscn directly. When the rewrite reaches feature parity, the GD3 tree moves to legacy/ and the new tree gets promoted to root.

7.2 Phase sequence

Phase Goal Approx scope
0. Bootstrap C# project skeleton, Friflo + xUnit wired up, three-project layout ½ day
1. Sim: walk & push Components, spatial index, basic movement, push chain 1–2 days
2. Sim: grab & swing slide Holding, swing scan, line-slide resolution (no transforms) 1–2 days
3. Sim: swing transforms Hammer-break, nutcrack, key-combine, simultaneous patterns 1–2 days
4. Sim: environmental phase Sinking, pressure plates, signals, doors, fruit collection, level transitions 2–3 days
5. Sim: save/load Friflo serialization round-trip; static-vs-dynamic split 1 day
6. Godot adapter GD4 scene-tree walk, palette + scene-tile authoring, EntityAuthoring base 2–3 days
7. Godot presenter PresenterDispatcher, per-entity scenes, event choreography, audio, camera, HUD 3–4 days
8. Migrate existing levels Port exporter.gd to GD4 or run GD3 exporter once, import JSON 1–2 days
9. Parity polish Sweep gaps in input feel, animation timing, sound, edge cases 2–4 days
10. Forward development Autonomous machines, new puzzle mechanics, expanded open world open-ended

Total runway to feature parity: rough order of three weeks of focused work.

7.3 Risks

  1. Friflo serialization edge cases. Inter-entity references (Holding(EntityId)) and global state need a round-trip test in Phase 5, not later.
  2. GD4 C# + Friflo + Tile Scene Collections composition. Three relatively new things together. Phase 6 is the first real integration check; budget extra time and keep a fallback (manual [Export] Array<PackedScene> instead of TileSet scene collections).
  3. Save format lock-in. Keep saveVersion: 1 until parity ships. Don’t lock during exploration.
  4. Autonomous actors’ phase model. Designed for, not built. Phase 10 may reveal gaps requiring sim refactors. Acceptable; over-designing now is worse.

7.4 Leading indicators

After each phase:

  • Tests are easy to write. If a sim test is tortuous to set up, the API is wrong — refactor before adding more rules.
  • A new rule requires ~one new file in the sim and ~one new switch case in the presenter. Wider ripples mean wrong boundaries.

8. Glossary

  • SimMuscleman.Sim. The pure C# ECS simulation. Engine-agnostic.
  • CoreMuscleman.Core. Shared enums, event types, level DTOs. Pure C#.
  • Presenter — Godot 4 layer that mirrors sim state with visual nodes, plays animations and audio.
  • Adapter — code that walks a Godot scene tree and translates it into world.Spawn calls.
  • Recipe — a named composition of components. The way to spawn an entity of a known kind.
  • Tick — one World.Step(input); multi-phase resolution producing one EventBatch.
  • Event — atomic record of a state change emitted by the sim, consumed by the presenter.
  • SwingOp — atomic operation produced by the swing resolver. The flat list returned by ResolveSwing.
  • Static — tag for entities reconstructible from level files; excluded from save dumps.
  • LevelMember — component carrying which level region an entity belongs to.
  • ActiveLevel — the level the player is currently on; the only level whose Actors tick.