mission-gaming / tactician
A modern PHP library for generating structured tournament schedules with deterministic algorithms like Round Robin, Swiss, and Pool play
Requires
- php: ^8.4
Requires (Dev)
- ergebnis/composer-normalize: ^2.39
- friendsofphp/php-cs-fixer: ^3.0
- pestphp/pest: ^4.0
- phpstan/phpstan: ^2.3
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.1
- rector/rector: ^2.7
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.3.0
- v0.2.3
- v0.2.2
- v0.2.1
- v0.2.0
- v0.1.1
- v0.1.0
- dev-docs/release-notes-limit
- dev-chore/release-0-3-0-date
- dev-chore/release-0-3-0
- dev-chore/rector-worker-timeout
- dev-fix/weekly-phpstan-and-example-dates
- dev-feature/seal-the-surface
- dev-fix/verbs-refuse-a-participant-not-an-entrant
- dev-fix/verbs-refuse-an-event-held-twice
- dev-feature/bracket-node-results
- dev-fix/verbs-refuse-a-round-out-of-sequence
- dev-feature/swiss-bye-without-a-bye-value
- dev-fix/derivation-of-a-round-paired-on-incomplete-results
- dev-feature/round-robin-engine
- dev-feature/qualifiers-into-a-bracket
- dev-feature/swiss-per-round-and-fingerprint
- dev-fix/clock-dependent-corpus-on-new-php
- dev-fix/tiers-and-byes-in-placement-tables
- dev-fix/later-legs-role-search-bound
- dev-feature/structured-diagnostics-and-round-accessors
- dev-feature/consumer-seams
- dev-feature/bracket-view
- dev-feature/stage-state-from-history
- dev-feature/repack-objectives
- dev-fix/timeline-gaps-and-huge-counts
- dev-fix/balanced-role-search-with-a-schedule-in-hand
- dev-chore/release-0-2-3
- dev-fix/repack-leftovers-drop-rule-demand
This package is auto-updated.
Last update: 2026-11-10 19:24:50 UTC
README
Overview
A PHP library that generates tournament schedules: who meets whom, in which round, and, when you ask for it, at what time. It covers round robin, Swiss, pot draws, single and double elimination and group stages, and it repairs a schedule that has fallen behind. It has no production dependencies.
Features
Each feature links to its section of the usage guide, except the last one, which is about the library as a whole.
Formats
- Round robin, single or multi-leg, by the circle method. Roles (first-named
and second-named, read as home and away) are balanced where possible: the
split of a leg is as even as the field allows, and where a constraint
rejects those roles the scheduler looks for other balanced ones. Where it
finds none it falls back to the roles of 0.2.x, which alternate by round
and are bounded, not equal, and marks the schedule; so nothing fails for
the sake of balance. Naming a role assignment gives either strictly:
balanced or refused, or the roles of 0.2.x.
Legs after the first are mirrored, repeated or shuffled, and an odd field
gets a bye in every round. A league can also be held round by round as a
stage state (
RoundRobinEngine), its rounds the scheduler's. Basic usage, multi-leg tournaments, role assignment, leagues run round by round - Swiss: each round is paired from the standings so far (Monrad pairing), avoiding repeat pairings, rotating byes, balancing roles, and allowing withdrawals. A whole schedule can also be drawn before any result exists, from a seed, for any number of rounds without a repeat. Swiss tournaments
- Pot draw: the entrants are cut into pots of equal size in seeding order, and every entrant meets a fixed number of opponents from every pot, its own included, with no rematch and balanced roles. Every round is drawn up front from a seed. This is the league-phase format that is often called "Swiss"; nothing in it is paired from results. Pot draws
- Single and double elimination: fold seeding by list position, byes, round
labels, one- or two-legged ties; re-seeding each round and an optional
third-place tie for single elimination, and an optional grand-final reset
for double elimination. An event or a tie that finishes level is decided
by a typed decider on its result, which names who advances and changes no
score. A structured view of the bracket (
getBracket()) has every round of every side, the rounds to come included, so a bracket can be drawn from it at any point. Elimination brackets, drawing the bracket, a third-place tie, recording a level event - Group stages and multi-stage tournaments, composed from serpentine pools and progression selectors. A selection by rank keeps the entrants of one pool apart in the bracket it feeds, and is refused where results do not decide who holds a selected place, until the application supplies the order as a decision or tells the selector to go on. The best few of one rank across the pools (the two best of those placed third) are selected too, by a comparison the caller names where the pools are not of one size. A declared chain of stages can be checked for matching entrant counts before any event exists. Pools, progression, and multi-stage tournaments
- Position decides: every stage takes its entrants from the order of the list it is given, and where results leave two of them level, that order decides between them. A label or a seed attribute never does. Tied sets
Rules and quality
- Constraints: seed protection, a minimum number of rounds between repeat meetings, limits on consecutive roles, role balance, rules over participant metadata, and your own predicates. Constraints are hard filters. Constraint system
- Loud failure: a round robin that cannot be completed under its constraints throws, with a report that names the constraint and the pairings it blocks; a partial schedule is never returned. The scheduler first retries a bounded number of rotated participant orders, then looks for other roles its role assignment keeps, and an opt-in backtracking search covers the round layouts those cannot reach. (Balanced roles are a preference of the default and not a constraint: a schedule that has none is returned with the roles the generator proposes and a mark, not refused.) Every exception the library throws on purpose implements one interface, and a configuration error carries a reason that code can match without reading the message. Backtracking generation, error handling, configuration errors, catching every library exception
- Schedule quality: lower-is-better metrics, a weighted scorer, and an optimizer that keeps the best of N seeded samples. Schedule quality and optimization
Results, time and repair
- Results and standings: a ranking strategy (3/1/0 and 1/½/0 presets), then a chain of tiebreakers (wins, score difference, scores-for, Buchholz, Sonneborn–Berger, and any of them over the events between the tied participants only), which plain data can build. What a bye is worth is the caller's choice, and the same value orders the next round and stands in the final table. The table gives every entry its own position and reports the sets of entries that no result separates. The table of a bracket is its placement: rank 1 is the entrant who won it. Results and standings, tied sets
- Ties settled by a decision: where every figure is level, the application decides the order by its own rule and gives it to the table or to a rank selector as a list of participant IDs, best first, with a label and a reason. The decision orders one whole set of level entries and never overrides a result; the table and the selection mark every place it settled, and a decision that fits no tied set is rejected with a reason. Settling a tied set by a decision
- Awarded results and adjustments: a result can be marked as awarded, for an event that was not played, and counts for the participant it names; a competition chooses whether its nominal score counts and whether it counts between tied participants. An adjustment adds to or takes from one participant's ranking value for a reason that is no event, shows in the table as a figure of its own, and is carried by the stage state, so the next Swiss round is paired from the adjusted totals. The library records both and decides neither. Awarded results, adjustments
- Stage state between rounds: the state of a results-driven stage is
plain data that an application stores between rounds. A recorded result of
the last round can be replaced, and a state can be stamped with the
fingerprint of the engine that pairs it, so that another engine or another
configuration refuses it. A platform that keeps its own fixtures and
results builds the state from those rows on each request
(
StageState::fromHistory()). Correcting a recorded result, stages resolved from your own rows, recording which engine pairs a state - Timeline assignment: kickoff times and named resources for every event from a declarative slot pattern, with minimum-rest and blackout rules. Kickoffs are emitted in UTC. Timeline assignment
- Schedule repacking: existing events, some of them pinned, placed onto an irregular grid of sessions so that nobody is double-booked and each participant plays back to back within a session. What cannot be satisfied comes back as itemised violations instead of an exception. A grid can be given without times and with no limit on the events in a slot, and an outcome has a fingerprint that tells whether a plan computed again is the plan that was shown. An outcome also carries the figures to rank it by (events unplaced, gap slots, lone-event sessions, idle sessions, events moved); a repack can be asked to move as few events as it can from where they are, and a helper finds the fewest sessions that place everything. Schedule repacking, objectives, the shortest grid, grids without instants, unbounded capacity, outcome fingerprints
Foundations
- Deterministic: the library never reads the clock and uses no global
random function. Randomness comes from a
Random\Randomizeryou pass in, and a freshly seeded one gives the same output for the same input. Nothing in the library makes an unseeded one. A scheduler that holds a randomizer draws from it on every call, so its second call continues the sequence and returns a different schedule. A pot draw, a pre-drawn Swiss schedule, the Swiss engine, the round-robin engine and the shuffled leg strategy also take an integer seed, which is plain data and gives the same output on every call; the Swiss engine draws each round from the seed and the round number, and the round-robin engine draws the whole schedule from it again on every call, so a stage rebuilt from stored rows pairs its next round as the stage played round by round. Deterministic randomization - Serialization:
Schedule,StageStateandScheduledScheduleround-trip JSON, and the value objects convert to and from arrays. Serialization - Schedules are plain collections: a schedule holds its events in memory and is iterable, countable, and groupable by round. Iterating and counting
- PHP 8.4+, strictly typed:
strict_typesthroughout, immutable value objects, final classes, andsrc/held to PHPStan level 9 with zero errors by the build. The library is at 0.x: see Versioning and stability for which namespaces are stable.
Runnable examples of most of the above are in examples/;
the usage guide describes the rest (role constraints and Swiss
withdrawals).
Installation
Install via Composer:
composer require mission-gaming/tactician
Requirements:
- PHP 8.4+
- No external dependencies in production
Quick Start
<?php use MissionGaming\Tactician\DTO\Participant; use MissionGaming\Tactician\Scheduling\RoundRobinScheduler; use MissionGaming\Tactician\Constraints\ConstraintSet; use MissionGaming\Tactician\Constraints\SeedProtectionConstraint; // Create seeded participants $participants = [ new Participant('celtic', 'Celtic', 1), // Top seed new Participant('athletic', 'Athletic Bilbao', 2), // 2nd seed new Participant('livorno', 'AS Livorno', 3), new Participant('redstar', 'Red Star FC', 4), new Participant('rayo', 'Rayo Vallecano', 5), new Participant('clapton', 'Clapton Community FC', 6), ]; // Configure constraints to protect top seeds from early meetings $constraints = ConstraintSet::create() ->add(new SeedProtectionConstraint(2, 0.5)) // Protect top 2 seeds for 50% of tournament ->build(); // Generate schedule $scheduler = new RoundRobinScheduler($constraints); $schedule = $scheduler->schedule($participants); // Iterate through matches foreach ($schedule as $event) { $round = $event->getRound(); echo ($round ? "Round {$round->getNumber()}" : "No Round") . ": "; echo "{$event->getParticipants()[0]->getLabel()} vs {$event->getParticipants()[1]->getLabel()}\n"; }
Beyond Round Robin
Results feed standings, and standings drive the incremental engines for Swiss
pairing, elimination brackets, and multi-stage tournaments. In the loop below,
$participants is the list from the Quick Start and playRound() stands for
your application playing a round and returning one Result per event:
use MissionGaming\Tactician\DTO\Result; use MissionGaming\Tactician\Scheduling\SwissPairingEngine; use MissionGaming\Tactician\Stage\StageState; $engine = new SwissPairingEngine(plannedRounds: 5); // One driver loop covers every results-driven format: pair the round, // record it as paired, play it, add its results $state = StageState::start($participants); while (!$engine->isComplete($state)) { $pairing = $engine->pairNextRound($state); $state = $state->withRoundPaired($pairing); $results = playRound($pairing); // application-side $state = $state->withAdditionalResults($results); } // Every format finishes as an outcome you can select from $outcome = $engine->getOutcome($state); $table = $outcome->getStandings();
The round is recorded when it is paired, before any result exists, so that
a withdrawal or an adjustment made while it is played is recorded after it
and the round is read as it was paired. Single and double elimination
(SingleEliminationEngine, DoubleEliminationEngine) drive through the
same loop, and so does a league held between rounds (RoundRobinEngine,
whose rounds are the round-robin schedule's), and group stages
compose from pools and progression selectors (PoolDistributor,
RankRangeSelector, BestOfRankSelector, BracketQualifiersSelector,
MatchOutcomeSelector) — see the
usage guide. Stage state and schedules serialize to JSON
(StageState::toJson(), $schedule->toJson()), so platforms persist
between rounds instead of re-deriving. A platform that keeps its fixtures
and results in its own tables stores no state and builds one from them on
each request (StageState::fromHistory()): the rounds it holds are read
as they are, and the engine derives only the next one.
Versioning and stability
Tactician follows Semantic Versioning with the 0.x
allowance: until 1.0.0, a minor release may contain breaking changes. Every
release is recorded in the changelog.
- Patch releases (0.x.y) never change a correct output for a fixed input and seed, and never change a public signature.
- A patch may change an output that was itself broken: one that reported its own failure, stated something false, or contradicted the documented contract of the component that produced it. The changelog marks every such case with the heading "Output change (fix)".
- Minor releases (0.x) may contain breaking changes. The changelog lists each one with a migration note.
- Deprecations precede removals. A deprecated method keeps working until
1.0.0. The usage guide lists each one with what to use instead, under Deprecations. Any release may deprecate a method, a patch release included. That changes no output and no signature, but a call to a deprecated method emits anE_USER_DEPRECATEDnotice, so an application that turns notices into exceptions can see such a call fail after a patch update. The library never calls a deprecated method itself. - Serialized forms are versioned. Every array a
toArray()returns states the version of its form underformat_version. A form changes only in a minor release, with its next version number and a changelog entry, and the earlier versions stay readable until a major release; data stored by 0.2.x, which wrote no version, is read as version 1. ADR 0005 records the rule. - Generated output is pinned. Golden fixtures in
tests/Fixtures/golden/pin generated schedules, bracket pairings, repack assignments, and the JSON wire shapes for a set of fixed inputs and seeds, so a change to that output fails the test suite instead of shipping unnoticed. The fixtures cover those cases only; they are not a proof about every input.
Supported PHP versions: ^8.4 (the Composer constraint). CI runs the suite
on PHP 8.4 and 8.5.
Classes are final; the extension points are the interfaces. Every
concrete class of the library is final, so a class is extended by no one
and its with-ers return exactly that class. To change what the library does,
implement one of its interfaces (Constraints\ConstraintInterface,
LegStrategies\LegStrategyInterface, RoleAssignment\RoleAssignmentInterface,
Standings\RankingStrategy, Standings\TiebreakerInterface,
Stage\StageEngineInterface, Quality\QualityMetric and the others), or
wrap a class of the library in one of your own. The one class left open is
Exceptions\InvalidConfigurationException, which the library's own
exceptions extend; catch it, and do not extend it. Making a class final
that was not is a breaking change, made in a minor release before 1.0.0
(ADR 0022).
The rules above apply to every namespace. The two lists below say where
breaking changes are expected. Namespaces are relative to
MissionGaming\Tactician.
Stable (the API is settled and no breaking change is planned):
DTO- Round-robin scheduling:
Scheduling\RoundRobinScheduler,Scheduling\RoundRobinOptions, and the leg strategies inLegStrategies Repack, excludingRepack\InternalExceptions
Experimental (still being designed; expected to change in a minor release
before 1.0.0):
- The stage engines:
Scheduling\SwissPairingEngine,Scheduling\SwissScheduler,Scheduling\SwissOptions,Scheduling\SingleEliminationEngine,Scheduling\DoubleEliminationEngine,Scheduling\EliminationOptions, andScheduling\RoundRobinEngine(the round-robin scheduler and its options are stable; the engine that holds a league as a stage state is not) - The pot draw:
Scheduling\PotDrawScheduler,Scheduling\PotDrawOptions, andStage\PotDrawPlan StageStandingsTimelineQualityConstraintsValidationDiagnosticsRoleAssignmentSerialization, which holds no public type: its one class,Serialization\PlainData, is internal
Anything not listed as stable is experimental, including the rest of
Scheduling. Repack\Internal is internal: it is not public API and carries
no compatibility guarantee. Neither does a class in any other namespace whose
docblock is marked @internal (Stage\PairKey and Timeline\DateTimeString,
for example).
Every class, interface, trait and enum in the library states its own status in its docblock, with exactly one of three annotations:
@api: stable surface. An entry of the stable list covers it: its namespace, or the class by name.@experimental: public, and expected to change in a minor release before1.0.0. It is everything else that is public: what an entry of the experimental list covers, and the rest ofScheduling.@internal: not public API. Do not call it, extend it or type against it; it can change or disappear in any release.
An architecture test (tests/Feature/StabilityAnnotationsTest.php) holds the
annotations to the two lists above. It fails when a type has no annotation or
more than one, when a type under a stable entry is @experimental or a type
under an experimental entry is @api, and when a namespace is in neither
list. A type under a stable entry cannot be @internal either, unless the
stable list excludes it, as it does Repack\Internal. An experimental type
can be, and the test pins which ones are.
Some stable signatures carry experimental types. For example,
RoundRobinScheduler accepts a Constraints\ConstraintSet and returns a
Stage\RoundRobinPlan from getPlan(), RoundRobinOptions carries a
RoleAssignment\RoleAssignmentInterface, the leg strategies receive a
Constraints\ConstraintSet and a Scheduling\SchedulingContext, and
Exceptions\IncompleteScheduleException exposes Stage, Validation, and
Diagnostics types. Those types are experimental, so the parts of a stable
class that use them can change with them. The same goes for the two
interfaces the stable round-robin classes implement,
Scheduling\SchedulerInterface and Scheduling\SchedulerOptions: what
RoundRobinScheduler and RoundRobinOptions offer is stable, and the
interfaces, as types to implement or to type against, are experimental.
No public signature hands out or asks for an @internal type. A public
method that a class takes from an @internal trait belongs to that class and
has its status: getViolationCollector() on a scheduler is one. The
members such a trait supplies that are not public are internal, like the
trait.
Releases are cut with the release checklist.
Documentation
📝 Changelog - Release history and output changes
📚 Complete Usage Guide - Comprehensive examples and patterns
🧩 Framework Integration - The adapter an application writes around Tactician, with Symfony and Laravel code
🏗️ Architecture - Technical design and core components
🛣️ Roadmap - What has shipped, known limitations and deferred work
📖 Contributing Guidelines - Development setup and contribution process
📚 Background - Mission Gaming story and problem space details
Sponsorship
Initial development of this library was sponsored by Tag1 Consulting, the absolute legends.
Tag1 blog & Tag1TeamTalks Podcast
License
This project is licensed under the MIT License - see the LICENSE file for details.