Sim phase 4 — Environmental phase and rule-set completion

Status: Design approved by user, ready for implementation planning. Author: henri (with Claude) Date: 2026-05-13 Parent spec: 2026-05-03 Godot 4 + ECS rewrite design

1. Goal

Close the remaining gameplay rules in the engine-agnostic sim by adding the environmental tick phase and the two adjacent player-phase consequences (fruit eating, key-into-door). After this slice, the sim can host the full puzzle ruleset of the original game without any presenter or level data yet in place.

Why this slice now

The sim already covers walk, push, grab/drop, swing-slide, and swing-transforms. The remaining mechanics — sinking, pressure plates, doors, fruit collection, level transitions, secret triggers, and key-into-door — share an architectural pattern (state-driven, run once per tick after the player action) and so are best designed together. Doing them in one slice avoids re-litigating the tick-order question once per rule.

Non-goals

  • Actor phase / autonomous brains. Phase 10 of the parent spec; the env phase is designed to be insertable before actors when those land, but no actor support is built here.
  • Save / load round-trip (Phase 5).
  • Smashing containers, spawning contents, combining keys. Smashable, Openable, Contents, and Combinable components stay declared but not wired. The swing-transforms plan recognized these patterns; the spawn path is a follow-up.
  • Cauldron / bong OnOpen(counter). Same reasoning — depends on the smash pipeline.
  • Door close on signal release via OpenOnPressed. This behavior latches open; closing requires another rule, not in scope here.

2. Decisions inherited from brainstorming

Decision Choice
Phase scope One slice covering all env-phase rules plus the two closely-tied player-phase rules (fruit, key-into-door).
Pressure-plate group typing int group id.
Plate aggregator OR (group is active iff any plate in the group is pressed).
Signal behaviors Three: OpenWhilePressed, OpenOnPressed, OpenWhileReleased.
Architecture One system per rule, ordered explicitly in StepRunner.Step. Mirrors existing PushSystem / SwingSystem.
Stairs trigger Auto-on-step; one LevelTransitionEvent per visit, debounced by OnLevelTransition tag while the player remains on the stairs cell.
Sink One-way. No unsink path in this slice.
Counters World.Counters: Dictionary<GlobalCounter, int> resource on World.
Secret triggers Reuse the plate/signal infrastructure. Named(string) on entities (e.g. the duck); SecretTrigger(entityName, group) activates the group while the named entity occupies its cell; doors react via SignalTarget.
Cleanup PendingDestroy tag queue, drained by CleanupSystem at end of tick.

3. Components and resources

3.1 New data-bearing components (Muscleman.Sim/Components/)

public struct PressurePlate { int Group; bool RequiresHeavy; }
public struct SignalTarget  { int Group; SignalBehavior Behavior; }
public struct SecretTrigger { string EntityName; int Group; }
public struct Named         { string Name; }
public struct LevelTransition { string TargetLevelId; string TargetSpawnId; }
public struct LockedBy      { KeyColor Color; }
public struct KeyOf         { KeyColor Color; }
public struct Edible        { GlobalCounter Counter; }

3.2 New tags

  • Pressed — set by PressurePlateSystem each tick on plate entities that are currently pressed. Allows downstream systems to read state without re-running plate detection.
  • OnLevelTransition — set by LevelTransitionSystem when the player triggers stairs; cleared when the player leaves that cell. Debounces re-emission on Wait or repeated walks-in-place.
  • PendingDestroy — set by any system queueing an entity for destruction; drained by CleanupSystem.

3.3 New enums (Muscleman.Core/Enums/)

public enum KeyColor { Blue, Red, Yellow, Green, White }
public enum GlobalCounter { Apples, Pears, Cheese, Coins, Ghosts, Books }
public enum SignalBehavior { OpenWhilePressed, OpenOnPressed, OpenWhileReleased }

KeyColor matches the original key.gd palette plus Yellow/Green for the combine plan, plus White for the existing white door/plate puzzle in level1.tscn. GlobalCounter matches constants.gd’s counters.

3.4 World resource

World.CountersDictionary<GlobalCounter, int>, defaulting to zero on first read. Mutated by PlayerActionSystem when fruit is consumed. Initialized empty in World constructor.

4. Systems and tick order

StepRunner.Step becomes:

public static EventBatch Step(World w, int playerId, Input input)
{
    var events = new EventBatch();

    // Player phase
    PlayerActionSystem.Resolve(w, playerId, input, events);

    // (Actor phase — empty in this slice)

    // Environmental phase
    SinkingSystem.Tick(w, events);
    PressurePlateSystem.Tick(w, events);
    SecretTriggerSystem.Tick(w, events);
    SignalSystem.Tick(w);                       // pure derivation, no events
    DoorSystem.Tick(w, events);
    LevelTransitionSystem.Tick(w, playerId, events);

    // Cleanup phase
    CleanupSystem.Tick(w);

    // Emit phase
    return events;
}

4.1 SinkingSystem

Iterate <Position, Heavy, Sinkable> (sorted by EntityId for determinism). For each, check if any entity at the same cell carries WaterCell. If so:

  • Remove tags: BlocksMovement, BlocksPush, Pushable, Sinkable, Heavy.
  • Add tag: Walkable.
  • Emit Sank(entityId).

Order rationale: runs first in the env phase so a heavy pushed onto water during the player phase is already sunk before plate detection runs — no inconsistent “heavy-still-blocking” frame.

4.2 PressurePlateSystem

Iterate <Position, PressurePlate>. For each plate:

  • Find candidate occupants: every other entity in the same cell.
  • A plate is pressed when at least one occupant satisfies its weight rule:
    • RequiresHeavy=true: occupant must carry the Heavy tag.
    • RequiresHeavy=false: occupant is the player (carries Player tag) or any Pushable entity. (Sunk heavy boxes have lost Heavy and Pushable, so they activate neither kind. Players don’t activate heavy plates. Both match the original.)
  • Compare to previous tick’s state (the Pressed tag from last tick):
    • Newly pressed: add Pressed, emit PlatePressed(plateId).
    • Newly released: remove Pressed, emit PlateReleased(plateId).

4.3 SecretTriggerSystem

Iterate <Position, SecretTrigger>. For each trigger, look for any entity at the same cell carrying Named(name) matching the trigger’s EntityName. If found, set Pressed on the trigger entity; otherwise clear it. Emits no events. Reuses the same Pressed tag because the next system (SignalSystem) aggregates indiscriminately.

4.4 SignalSystem

Pure derivation, no events. Maintains a transient HashSet<int> of active groups for this tick:

  • Iterate every entity with PressurePlate or SecretTrigger that has the Pressed tag. Add its group to the active set.
  • Store the active set as World.ActiveSignalGroups (a HashSet<int> resource) for DoorSystem to consume.

4.5 DoorSystem

Iterate <Position, Door, SignalTarget>. For each door, compute desired state from its SignalTarget.Behavior and whether its group is in World.ActiveSignalGroups:

Behavior Group active? Desired state
OpenWhilePressed yes open
OpenWhilePressed no closed
OpenOnPressed yes open (latched — even if was open already)
OpenOnPressed no unchanged
OpenWhileReleased yes closed
OpenWhileReleased no open

Apply by comparing to current DoorOpen tag:

  • Transition closed→open: add DoorOpen; remove BlocksMovement, BlocksPush; emit DoorOpened(doorId).
  • Transition open→closed: remove DoorOpen; add BlocksMovement, BlocksPush; emit DoorClosed(doorId).

Door-by-key (player-phase key consumption) shares the same open/closed mutation logic — extract to a helper DoorSystem.OpenDoor(w, doorId, events) and DoorSystem.CloseDoor(w, doorId, events) used by both phases. (A door can be opened by a key once and then forced shut by a OpenWhilePressed signal target whose plate is released; this is consistent — the open/close transitions are state-machine transitions, not source-tagged.)

4.6 LevelTransitionSystem

Read player position. If the player’s cell contains an entity with LevelTransition:

  • If the player already has OnLevelTransition: no-op.
  • Else: emit LevelTransitionEvent(stairsId, targetLevelId, targetSpawnId); add OnLevelTransition tag to the player.

If the player’s cell does not contain a LevelTransition entity:

  • Remove OnLevelTransition from the player if present.

The actual world-state effect of the transition (changing ActiveLevel, teleporting player to spawn point) is not in this slice — that’s part of the open-world / save-load work. We emit the event; the outer layer (or future system) acts on it.

4.7 CleanupSystem

Iterate <Position, PendingDestroy> (sorted by EntityId for determinism). For each, call World.Destroy(id). After this pass, the tag query is empty.

5. Player-phase extensions

These extend existing systems without creating new ones. They live in PlayerActionSystem / PushSystem, run during the player phase, and queue their destructions via PendingDestroy.

5.1 Fruit eaten when held + pushed into hard blocker

In PlayerActionSystem.ResolveMove’s held-entity branch, when the held entity’s destination cell holds a hard blocker (BlocksPush with no Pushable to extend the line) and the held entity is Edible:

  • Increment world.Counters[edible.Counter].
  • Emit ItemCollected(heldId, edible.Counter).
  • Tag the fruit PendingDestroy.
  • Clear Holding, emit GrabReleased(playerId, heldId).
  • Advance the player into the held entity’s old cell (which the fruit still occupies for this tick — co-occupancy is allowed; cleanup destroys the fruit at end of tick). This matches the original GD3 behavior: one move action eats the fruit and advances the player.

5.2 Fruit eaten when player walks into a wall in front of an unheld fruit

When ResolveMove (unheld branch) finds a Pushable in the destination, tries PushSystem.TryPush, and the push fails because the push line terminates in a hard blocker:

  • If the frontmost entity in the line is Edible: increment counter, emit ItemCollected, tag PendingDestroy, advance the player into the fruit’s cell (co-occupancy until cleanup).
  • Otherwise: existing behavior (push fails; player stays put).

5.3 Key consumed by matching door

A closed locked door carries Door + LockedBy(color) + BlocksMovement + BlocksPush and would normally terminate any push line as a hard blocker. The key-into-door rule reinterprets that specific terminus when colors match.

Extend PushSystem.TryPush: after computing the push line, if the line’s hard terminus is an entity with Door + LockedBy(color) and the frontmost pushable is KeyOf(color') with color == color', then:

  • Treat the terminus as soft for this resolution: the line slides into it, landing the key on the door’s cell.
  • Open the door via DoorSystem.OpenDoor(w, doorId, events), which removes LockedBy, BlocksMovement, BlocksPush, adds DoorOpen, and emits DoorOpened(doorId).
  • Emit KeyConsumed(keyId, doorId).
  • Tag the key PendingDestroy.

The key briefly co-occupies the now-open door’s cell within this tick — no system in the remaining tick depends on the overlap (SignalSystem doesn’t care about keys; DoorSystem.Tick will see DoorOpen already on the door and treat it as the current state for the signal-target computation). The key is removed in cleanup.

If the colors don’t match: the door is a normal hard terminus; the push fails; the key stays. Default push behavior covers this; no extra code.

6. Events

All event types are already declared in Muscleman.Core/Events/Events.cs. This slice wires them:

  • Sank(entityId) — by SinkingSystem.
  • PlatePressed(plateId), PlateReleased(plateId) — by PressurePlateSystem.
  • DoorOpened(doorId), DoorClosed(doorId) — by DoorSystem.OpenDoor / DoorSystem.CloseDoor, called from both env phase (via signal change) and player phase (via key consumption).
  • ItemCollected(entityId, counter) — by PlayerActionSystem for fruit, by anyone else triggering global counters later.
  • KeyConsumed(keyId, doorId) — by PushSystem (or PlayerActionSystem if extracted).
  • LevelTransitionEvent(stairsId, targetLevelId, targetSpawnId) — by LevelTransitionSystem.

No new event types in this slice.

7. Test strategy

7.1 Per-system unit tests (Muscleman.Sim.Tests/<System>Tests.cs)

One test file per new system. Each builds a minimal world and calls the system’s Tick directly, asserting tag/component/event outcomes. Bypasses the full step pipeline where useful for isolation.

  • SinkingSystemTests — heavy on water becomes walkable; non-heavy on water unchanged; multiple heavies sink simultaneously.
  • PressurePlateSystemTests — light plate + player; light plate + light box; heavy plate + heavy box; heavy plate + light box (no-op); heavy plate + player (no-op); plate releases on occupant leaving.
  • SignalSystemTests — OR aggregation across multiple plates and secret triggers in the same group.
  • DoorSystemTests — all 6 transition matrix cases per behavior.
  • SecretTriggerSystemTests — named entity enters and leaves trigger cell.
  • LevelTransitionSystemTests — emit-once-per-visit semantics; debounce on standing still; re-emit on leave+re-enter.
  • CleanupSystemTests — pending entities destroyed; non-pending unchanged.

7.2 End-to-end scenarios (Muscleman.Sim.TestFixtures/Scenarios.cs)

Each scenario runs World.Step round-trips and shares with GdUnit4 parity tests. Added to Scenarios.All:

  1. HeavyOntoWaterSinks — push heavy onto water; Sank emitted; sunken box Walkable.
  2. WalkOnSunkHeavyBox — sequel: player walks over the sunk box.
  3. LightOnLightPlateOpensDoor — light box onto light plate; PlatePressed + DoorOpened.
  4. LightOnHeavyPlateNoOp — light box / player on heavy plate, no signal.
  5. HeavyOnHeavyPlateOpensDoor — covers RequiresHeavy=true.
  6. LeavingPlateClosesDoorOpenWhilePressed, occupant pushed off; door closes.
  7. LatchedDoorStaysOpenOpenOnPressed; plate releases; door stays open.
  8. InvertedDoorClosesWhenPressedOpenWhileReleased.
  9. SecretTriggerOpensSecretDoor — Named “duck” walks onto a secret-trigger cell; secret door opens.
  10. FruitEatenWhenHeldIntoWall — held apple, push into wall, counter incremented, ItemCollected + GrabReleased emitted, fruit destroyed in cleanup, player advances into the fruit’s old cell.
  11. FruitEatenWhenPushedIntoWall — same without holding; player advances into the fruit’s old cell.
  12. KeyConsumedByMatchingDoor — push blue key into blue locked door; KeyConsumed + DoorOpened; key gone.
  13. KeyBouncesOffNonMatchingDoor — push blue key into red door; push fails; key stays.
  14. StairsEmitTransitionOnce — player steps onto stairs; one event; Wait on stairs; no second event; walk off and back, second event fires.

7.3 Determinism and regression

All new systems iterate sorted by EntityId. Existing scenarios (WalkOneStepNorth, PushOneBoxEast, GrabThenDrop, plus the swing scenarios from the prior plans) remain green — verified by make test (xUnit + GdUnit4 lanes).

8. Risks

  1. Door-state read order across phases. Key-into-door runs in player phase and pre-opens the door before DoorSystem.Tick. DoorSystem.Tick then sees an already-open door and a possibly-pressed signal target; it applies the desired-state computation. If the door has SignalTarget(group, OpenWhilePressed) and no plate is pressed, the door would re-close immediately on the same tick. Mitigation: this is acceptable behavior (it matches a real signal-driven re-close), and the scenario KeyConsumedByMatchingDoor validates the simple case (no signal target on the door). If a door has both a lock and a signal, it’s a level-author choice.
  2. Plate state inferred from Pressed tag is one tick stale relative to occupants. Plates compute pressed/released by comparing current occupancy vs. the Pressed tag set last tick. The first tick of a save load needs PressurePlateSystem to seed Pressed tags from current state without emitting events. Mitigation: add an Initialize(World w) helper on PressurePlateSystem called during level load (Phase 5/6 concern; documented here so the next slice doesn’t forget).
  3. Sinking ordering vs. player held-heavy push onto water. Player phase moves the heavy onto water; same-tick sinking fires; the heavy becomes walkable; on next tick the player can walk over it. This is desired. Validated by HeavyOntoWaterSinksWalkOnSunkHeavyBox sequel.
  4. PendingDestroy entities exist for the rest of the tick. Systems that run after the queueing system see the entity. All env-phase systems must tolerate this: SignalSystem doesn’t care about keys, fruit, etc.; DoorSystem only reads doors; LevelTransitionSystem only reads the player’s cell. Mitigation: documented; no current system breaks.

9. Glossary additions

  • Env phase / environmental phase — the systems running after PlayerActionSystem and before CleanupSystem each tick.
  • Group — an int identifier shared between PressurePlate / SecretTrigger (sources) and SignalTarget (consumers).
  • Pressed — tag set each tick on plates and secret triggers whose source condition is currently satisfied.
  • OnLevelTransition — debounce tag on the player while standing on a stairs cell.
  • PendingDestroy — queue-tag drained by CleanupSystem at end of tick.

10. Open follow-ups (not in this slice)

  • Smash / Openable content spawning. Needed for the cauldron-opens-on-swing flow and combinable keys.
  • OnOpen(GlobalCounter) wiring (cauldron, bong increment ghost / books counters when smashed open).
  • Door-with-no-SignalTarget close mechanism — currently OpenOnPressed latches forever. The original game doesn’t seem to need closing latched doors, but worth confirming with the new open-world design.
  • Save / load round-trip including the initialization of Pressed / OnLevelTransition / DoorOpen tags from spawned static state. Phase 5.
  • Actor phase. Phase 10 in the parent spec; the env phase explicitly leaves room for it between player and env in StepRunner.Step.