Muscleman — Godot 4 + ECS Rewrite
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.Simis a regular .NET class library.dotnet testruns it without Godot. Levels can be solved/fuzzed/replayed in unit tests.- Godot calls
world.Step(input), gets back anEventBatch, 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 cellFacing(dir)— player and any directional actorLevelMember(levelId)— which level region owns this entity
Movement & physical character
BlocksMovement— entities with this in a cell prevent walkers from enteringBlocksPush— entities with this terminate push/swing lines (unless they’re alsoPushable, in which case they extend the line instead)Walkable— explicit “this cell is traversable terrain” tag; sunk heavy boxes carry itPushable— chain-pushable; at most one per cellLiftable— player can grab and carryHeavy— too heavy to lift; activates heavy pressure plates; sinks in water (withSinkable)Sinkable— when in aWaterCell, shedsBlocksMovement/BlocksPush/Pushable/Sinkableand gainsWalkable
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
WallStrong—BlocksMovement+BlocksPush; swings rebound and lines cannot passWallWeak—BlocksMovementonly; swing lines pass over and may land on itWaterCell— neither blocker; triggersSinkablebehavior on entities co-locatedStatic— save-skip tag; entity is reconstructible from the level file
Containers & openables
Openable,Opened,SmashableContents(WeightedList<EntityRecipe>)— what spawns on openHammer— held + swung opens whatever is in the lineCombinable(withRecipe, resultRecipe)— blue + yellow → green key
Locks & signals
KeyOf(KeyColor),LockedBy(KeyColor)Door,DoorOpenPressurePlate(group, requiresHeavy)SignalTarget(group, behavior)— listens for plate signalsSecretTrigger(triggerName)— secret door reads named entity (the duck)
Collection & counters
Edible(GlobalCounter)— fruit; eaten when shoved into a wall by playerOnOpen(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 tickBrain*— 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
- 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.
- Actor phase. Every entity with
Actorruns its brain in deterministic order (byEntityId, optionally by aPrioritycomponent). Each brain attempts an action through the same chain helpers as the player. Conflicts resolve by phase order: first writer wins. - Environmental phase. Reads current state and applies state-dependent rules: sinking, pressure-plate occupancy, signal-driven door states, secret triggers.
- Cleanup phase. Destroy queued entities (used keys, eaten fruit), spawn queued entities (box contents, combined keys). Spawns and destroys never happen mid-phase.
- Emit phase. The accumulated
EventBatchis 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
Pushableis here, append it to the line and continue. - Else if any entity here has
BlocksMovementorBlocksPush, 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
- At most one
Staticentity per cell. Walls and water don’t stack. - At most one
Pushableentity per cell. Pushes and swing-pushes have unambiguous targets. - Multiple
BlocksMovemententities can co-exist in a cell (e.g., box onWallWeak). Walking is blocked if anyBlocksMovementis present. - 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 smallTileSet → Reciperesource. - 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 anEntityAuthoringscript 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:
- 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.Coreparses the file and callsworld.Spawnfor each entry. The same loader is reused by headless tests to load real levels without Godot. - 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 aproperties: {...}blob applied as component overrides post-spawn. - Wrap output in a
levelenvelope: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:
- Read
meta.json→ know active level. - Open the corresponding level (Godot scene or exported JSON).
- Run the adapter — spawns all
Staticentities (walls, water, terrain). - Apply
world.jsonvia Friflo — overlays dynamic entities and their states. - 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
- Group events by phase (player → actors → environmental → cleanup), preserving sim order.
- Within a phase, animate in parallel (all
Moves tween at once, allSanks splash at once). - Between phases, brief stagger (~40–80ms) so causality reads visually.
- 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+RayCast2Dcollision queries (sim handles it). MoveChecker(replaced by the spatial index).move_tween,loop_tweenon entities (driven byMoveevents in the dispatcher).- The 300-line
player.gd::_process/move/rotate_tween(becomes a few dozen lines). - Per-entity
_processfor water-detection onHeavyBox(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
- Friflo serialization edge cases. Inter-entity references (
Holding(EntityId)) and global state need a round-trip test in Phase 5, not later. - 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). - Save format lock-in. Keep
saveVersion: 1until parity ships. Don’t lock during exploration. - 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
- Sim —
Muscleman.Sim. The pure C# ECS simulation. Engine-agnostic. - Core —
Muscleman.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.Spawncalls. - 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 oneEventBatch. - 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.