Sim phase 4 — Environmental phase and rule-set completion
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, andCombinablecomponents 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 byPressurePlateSystemeach tick on plate entities that are currently pressed. Allows downstream systems to read state without re-running plate detection.OnLevelTransition— set byLevelTransitionSystemwhen the player triggers stairs; cleared when the player leaves that cell. Debounces re-emission onWaitor repeated walks-in-place.PendingDestroy— set by any system queueing an entity for destruction; drained byCleanupSystem.
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.Counters — Dictionary<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 theHeavytag.RequiresHeavy=false: occupant is the player (carriesPlayertag) or anyPushableentity. (Sunk heavy boxes have lostHeavyandPushable, so they activate neither kind. Players don’t activate heavy plates. Both match the original.)
- Compare to previous tick’s state (the
Pressedtag from last tick):- Newly pressed: add
Pressed, emitPlatePressed(plateId). - Newly released: remove
Pressed, emitPlateReleased(plateId).
- Newly pressed: add
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
PressurePlateorSecretTriggerthat has thePressedtag. Add its group to the active set. - Store the active set as
World.ActiveSignalGroups(aHashSet<int>resource) forDoorSystemto 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; removeBlocksMovement,BlocksPush; emitDoorOpened(doorId). - Transition open→closed: remove
DoorOpen; addBlocksMovement,BlocksPush; emitDoorClosed(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); addOnLevelTransitiontag to the player.
If the player’s cell does not contain a LevelTransition entity:
- Remove
OnLevelTransitionfrom 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, emitGrabReleased(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, emitItemCollected, tagPendingDestroy, 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 removesLockedBy,BlocksMovement,BlocksPush, addsDoorOpen, and emitsDoorOpened(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)— bySinkingSystem.PlatePressed(plateId),PlateReleased(plateId)— byPressurePlateSystem.DoorOpened(doorId),DoorClosed(doorId)— byDoorSystem.OpenDoor/DoorSystem.CloseDoor, called from both env phase (via signal change) and player phase (via key consumption).ItemCollected(entityId, counter)— byPlayerActionSystemfor fruit, by anyone else triggering global counters later.KeyConsumed(keyId, doorId)— byPushSystem(orPlayerActionSystemif extracted).LevelTransitionEvent(stairsId, targetLevelId, targetSpawnId)— byLevelTransitionSystem.
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:
HeavyOntoWaterSinks— push heavy onto water;Sankemitted; sunken boxWalkable.WalkOnSunkHeavyBox— sequel: player walks over the sunk box.LightOnLightPlateOpensDoor— light box onto light plate;PlatePressed+DoorOpened.LightOnHeavyPlateNoOp— light box / player on heavy plate, no signal.HeavyOnHeavyPlateOpensDoor— coversRequiresHeavy=true.LeavingPlateClosesDoor—OpenWhilePressed, occupant pushed off; door closes.LatchedDoorStaysOpen—OpenOnPressed; plate releases; door stays open.InvertedDoorClosesWhenPressed—OpenWhileReleased.SecretTriggerOpensSecretDoor— Named “duck” walks onto a secret-trigger cell; secret door opens.FruitEatenWhenHeldIntoWall— held apple, push into wall, counter incremented,ItemCollected+GrabReleasedemitted, fruit destroyed in cleanup, player advances into the fruit’s old cell.FruitEatenWhenPushedIntoWall— same without holding; player advances into the fruit’s old cell.KeyConsumedByMatchingDoor— push blue key into blue locked door;KeyConsumed+DoorOpened; key gone.KeyBouncesOffNonMatchingDoor— push blue key into red door; push fails; key stays.StairsEmitTransitionOnce— player steps onto stairs; one event;Waiton 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
- Door-state read order across phases. Key-into-door runs in player phase and pre-opens the door before
DoorSystem.Tick.DoorSystem.Tickthen sees an already-open door and a possibly-pressed signal target; it applies the desired-state computation. If the door hasSignalTarget(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 scenarioKeyConsumedByMatchingDoorvalidates 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. - Plate state inferred from
Pressedtag is one tick stale relative to occupants. Plates compute pressed/released by comparing current occupancy vs. thePressedtag set last tick. The first tick of a save load needsPressurePlateSystemto seedPressedtags from current state without emitting events. Mitigation: add anInitialize(World w)helper onPressurePlateSystemcalled during level load (Phase 5/6 concern; documented here so the next slice doesn’t forget). - 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
HeavyOntoWaterSinks→WalkOnSunkHeavyBoxsequel. PendingDestroyentities exist for the rest of the tick. Systems that run after the queueing system see the entity. All env-phase systems must tolerate this:SignalSystemdoesn’t care about keys, fruit, etc.;DoorSystemonly reads doors;LevelTransitionSystemonly reads the player’s cell. Mitigation: documented; no current system breaks.
9. Glossary additions
- Env phase / environmental phase — the systems running after
PlayerActionSystemand beforeCleanupSystemeach tick. - Group — an
intidentifier shared betweenPressurePlate/SecretTrigger(sources) andSignalTarget(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
CleanupSystemat end of tick.
10. Open follow-ups (not in this slice)
- Smash /
Openablecontent 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-
SignalTargetclose mechanism — currentlyOpenOnPressedlatches 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/DoorOpentags 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.