Skip to content

Phase machine

The engine’s lifecycle is three classes: Phase (string-keyed identifier with display name), PhaseFlow (the per-game driver interface with next, onEnter, onExit, onTick), and PhaseMachine (the per-instance runner that calls those hooks and applies transitions). Engine-shipped phase constants are LOBBY, PRE_GAME, IN_GAME, ROUND_BREAK, CELEBRATION, CLEANUP. Two built-in flows ship: defaultMulti() and defaultSingleton().

  • You’re picking a built-in phase flow or writing your own.
  • You need to force an explicit transition from an event handler (the host clicked Start).
  • You want per-phase enter/exit side effects (announce, open doors, play effects).
package me.zlex.conduit.game;
public record Phase(String name, String displayName) {
public static final Phase LOBBY, PRE_GAME, IN_GAME, ROUND_BREAK, CELEBRATION, CLEANUP;
public static Phase of(String name);
}
public interface PhaseFlow {
Phase next(Phase current, GameInstance ctx);
default void onTick(Phase current, GameInstance ctx) {}
default void onEnter(Phase entered, @Nullable Phase previous, GameInstance ctx) {}
default void onExit(Phase exited, Phase next, GameInstance ctx) {}
static PhaseFlow defaultMulti(); // LOBBY → IN_GAME → CELEBRATION → CLEANUP → LOBBY
static PhaseFlow defaultSingleton(); // LOBBY → IN_GAME → CLEANUP
}
public final class PhaseMachine {
public PhaseMachine(PhaseFlow flow, GameInstance ctx, Phase initial);
public Phase current();
public void tick(); // call from your server-tick handler
public void transitionTo(Phase target); // force a transition
}
import me.zlex.conduit.game.Phase;
import me.zlex.conduit.game.PhaseFlow;
import me.zlex.conduit.instance.GameInstance;
import me.zlex.conduit.fx.Effects;
public final class SprintRaceFlow implements PhaseFlow {
public static final Phase RACING = Phase.of("RACING");
public static final Phase CELEBRATE = Phase.CELEBRATION;
@Override
public Phase next(Phase current, GameInstance ctx) {
var state = SprintState.of(ctx);
if (current.equals(Phase.LOBBY) && state.allReady()) return RACING;
if (current.equals(RACING) && state.finishersAtLeast(3)) return CELEBRATE;
if (current.equals(CELEBRATE) && state.celebrationElapsed()) return Phase.CLEANUP;
if (current.equals(Phase.CLEANUP)) return Phase.LOBBY;
return current;
}
@Override
public void onEnter(Phase entered, Phase previous, GameInstance ctx) {
if (entered.equals(RACING)) {
Effects.roundStartChime(SprintState.of(ctx).players());
}
}
}
  • Phase.equals compares only by namedisplayName is for logs and inspector UI only.
  • PhaseMachine.tick calls onTick first, then asks next — if next returns a different phase, the transition fires onExit(old) then onEnter(new).
  • For event-driven moves (button click, command), call transitionTo(target) directly.