Skip to content

Phases and the state machine

tilttek/game-engine

A game's phases and the transitions between them make a state machine of Symfony Workflow. GameDefinition builds it in Phases, exposed as $definition->machine:

new GameDefinition('scene', [
    new Phase('exploration'),
    new Phase('encounter', TurnOrder::Initiative),
    new Phase('aftermath'),
], [
    new Transition('start-encounter', ['exploration', 'aftermath'], 'encounter'),
    new Transition('end-encounter', ['encounter'], 'aftermath'),
    new Transition('explore', ['encounter', 'aftermath'], 'exploration'),
]);

A Transition has a name, the phases it may leave from and the phase it leads to. Workflow's StateMachineValidator checks the definition — a state machine has one phase at a time, and a transition leaving one phase has a name of its own —, and a definition it refuses is refused with InvalidGame.

MethodWhat it gives
can()Whether a transition may leave a phase
apply()The phase a transition leads to, or IllegalMove
from()The transitions that may leave a phase
mermaid()The machine as a Mermaid flowchart, the current phase marked when one is given

A seat changes the phase with the engine's move engine.transition, Move::transition('start-encounter', $seat); the game's allows() decides who may, and its enter() gives the data of the phase entered. The turn starts again in the new phase, in its turn order, from round 1.

Why Symfony Workflow fits replay

Replaying a log applies its moves again, so a change of phase must depend on nothing but the phase it leaves and the transition it names. Workflow's state machine does exactly that when it is given no event dispatcher: no guard, no listener, no clock is consulted, and applying the same transitions from the same initial phase always reaches the same phase. The engine therefore builds the machine without a dispatcher, and asks the game's allows() — a pure function of the state — for what Workflow guards would otherwise decide.

What Workflow brings is kept: its definition and validator, the transitions enabled from a phase, its exceptions for a transition that does not apply, and its dumpers, which draw a game's machine for its documentation. The marking lives in a cursor made for each move and dropped after it: the phase itself is kept in GameState, a value the log rebuilds.