Skip to content

Writing a game

tilttek/game-engine

A game implements Contract\Game. It holds every rule; the engine holds none. Every method is pure: it reads the state, the move and the randomness it is given, and nothing else — no clock, no storage, no service with a state —, so that replaying a log gives the same state (The log and replay).

MethodWhat it gives
definition()The GameDefinition, the same at every call
setup()The game's data when a table opens
allows()Whether a move is allowed now, the engine's moves included
play()The game's data after one of its own moves, or IllegalMove
enter()The game's data when the table enters a phase by a transition
outcome()How the game ended, with its statistics, or null while it goes on
view()What one seat sees of the state, shaped by its player's preferences, or the audience when the seat is null

The game's data is a mapping of plain values the game alone reads and writes, in GameState::$data.

The definition

new GameDefinition(
    'race',
    phases: [new Phase('race', TurnOrder::Sequential, movesPerTurn: 1)],
    transitions: [],
    initial: null,                 // the first phase
    minimumSeats: 2,
    maximumSeats: 4,
    options: [Option::integer('target', 20, 10, 100), Option::choice('track', 'short', ['short', 'long'])],
    outOfTurn: ['race.taunt'],
    undo: true,
    version: '1',
    preferences: [Option::boolean('leader-first', false)],
    statistics: [Statistic::table('rounds'), Statistic::seat('position')],
);
ArgumentWhat it declares
gameThe game's name, lowercase words joined by - or _
phasesIts phases, at least one, each with its turn order and the moves that end a turn
transitionsThe changes of phase it allows (Phases and the state machine)
initialThe phase a table starts in; the first when null
minimumSeats, maximumSeatsHow many seats a table holds, 1 to 64
optionsThe options it offers its tables
outOfTurnThe move types a seat may make out of its turn
undoWhether the last move may be taken back, when it drew nothing at random
versionThe version of its rules: a log written under another is not replayed
preferencesThe preferences it offers each player, which change the view of a seat only
statisticsThe statistics its outcome reports, for the table or for each seat

A definition that does not hold together is refused with InvalidGame: two phases, options, preferences or statistics of one name, a transition to a phase the game lacks, two transitions of one name leaving one phase, an engine move declared out of turn.

Turn orders

TurnOrderWho acts
FreeAnyone, at any time: there are no turns
SequentialOne seat after the other, in the order of the table, round after round
InitiativeOne seat after the other, the highest initiative first, round after round
SimultaneousEvery seat once a round, in any order; the round ends when all have ended their turn

A phase with turns may end a turn by itself after 1 to 100 moves of the game, movesPerTurn; otherwise a seat ends it with engine.end-turn (Tables, moves and turns).

Options

An Option is a boolean, a whole number between bounds, or a choice among texts, with its default. A table's TableOptions are resolved by Symfony's OptionsResolver when it opens and when its log is replayed: an option the game does not offer, or a value it does not allow, is refused. A game reads them with bool(), int() and string() on $state->options.

Preferences

A preference is an Option the game offers each player rather than each table: how the cards of a hand are sorted, whether the positions are listed from the leader down, a colour scheme for the colour-blind. A seat's Preferences are resolved by the same OptionsResolver, from the values its player chose, and given to view(); a game reads them with bool(), int() and string(). They are not part of the log and never reach play(): two players with other preferences share the same state, and a replay ignores them. A preference changes how a seat sees what it may see, never what it may see.

public function view(GameState $state, ?string $seat, Preferences $preferences): array
{
    $data = $this->withoutTheOthersSecrets($state, $seat);

    if ($preferences->bool('leader-first')) {
        arsort($data['positions']);
    }

    return $data;
}

Allowing and playing moves

The engine checks the seat and the turn first, then asks allows(). A game refuses here what its rules forbid at this moment — the engine's moves included: a game where only the first seat changes the phase says so for engine.transition. A move type the game does not know is refused by allows() too.

play() receives only the game's own moves. It reads the payload with $move->int() and $move->text(), draws chance from the Randomness it is given, never from elsewhere, and returns the new data, or throws IllegalMove for a payload its rules refuse.

Views and secrets

view() filters the data for one seat: a hand of cards, a hidden role, a secret objective. The engine never sends the data itself to a player; the publisher tells the players that the table changed, and each reads the view of its seat.

Endings

outcome() is read after every move. An Outcome lists the winning seats — none for a draw or a scene that simply ends — and the game's word for the ending; once a table has one, every move is refused.

Statistics of the end

A game declares the statistics it reports when it ends — Statistic::table('rounds'), Statistic::seat('points') — and its outcome gives their values, whole numbers, in a Statistics: those of the table by name, those of each seat by seat and name. The engine checks them after every move: a statistic the game does not declare, or one of the wrong scope, or the statistics of a seat not at the table, is refused with InvalidGame, a fault of the game rather than of the player. A game reports only what it counts; a statistic it leaves out is read as null.

return Outcome::won(['ann'], 'target-reached', new Statistics(
    ['rounds' => $state->turn->round],
    ['ann' => ['position' => 21], 'bob' => ['position' => 17]],
));

The statistics are part of the state, rebuilt with it from the log; $state->outcome->statistics->ofSeat('ann', 'position') reads one.