Skip to main content

General-purpose tournament engine: Swiss pairing, Round Robin, Elimination formats with pluggable scoring and tiebreaker systems

Project description

cardarena-tournament-core

PyPI version Python 3.11+ License: MPL 2.0

A framework-agnostic Python library for running card game tournaments. Swiss pairing, Round Robin, and Single Elimination — with Pokémon TCG and Yu-Gi-Oh! scoring out of the box and a clean extension API for custom formats.

No Django. No ORM. No dependencies. Pure Python 3.11+.


Features

  • Swiss pairing — points-based greedy matching, rematch avoidance, optional OWP/OOWP-weighted tiebreaker sort
  • Round Robin — full schedule via the Berger circle method; even and odd player counts
  • Single Elimination — mirrored seeding (1 vs N, 2 vs N-1, …), automatic bye advancement
  • Pokémon TCG scoring — Win/Draw/Loss/Bye points + OWP and OOWP tiebreakers (floor 25 %)
  • Yu-Gi-Oh! TCG scoring — same points + OWP and OOWP tiebreakers (no floor)
  • Extensible — subclass BasePairing or BaseScoring to add your own format
  • Fully typed — ships a py.typed marker; works with mypy and pyright out of the box

Installation

pip install cardarena-tournament-core

Requires Python 3.11 or later.


Quick Start

Tournament (recommended entry point)

Tournament wires a pairing format and a scoring system together so you never need to manage them separately:

from cardarena_tournament_core import (
    Player, MatchupOutcome, Swiss, PokemonTCG, Tournament, TournamentCompleteError,
)

players = [Player(id=str(i), name=f"Player {i}") for i in range(8)]
tournament = Tournament(pairing=Swiss(players), scoring=PokemonTCG())

try:
    while True:
        round_ = tournament.pair()

        for matchup in round_.matchups:
            if matchup.player2 is not None:
                matchup.outcome = MatchupOutcome.PLAYER1_WINS

        tournament.submit_results(round_)

        for standing in tournament.standings():
            print(f"{standing.rank}. {standing.player.name}{standing.points} pts")

except TournamentCompleteError:
    print("Tournament complete!")

Swap in any combination — RoundRobin + YuGiOh(), SingleElimination + PokemonTCG(), or your own custom subclasses.

Swiss Pairing (standalone)

from cardarena_tournament_core import Player, MatchupOutcome, Swiss

players = [Player(id=str(i), name=f"Player {i}") for i in range(8)]
swiss = Swiss(players)

round1 = swiss.pair()

for matchup in round1.matchups:
    if matchup.player2 is not None:
        matchup.outcome = MatchupOutcome.PLAYER1_WINS

swiss.submit_results(round1)

# Generate round 2 — winners are paired together
round2 = swiss.pair()

Optional: OWP/OOWP-weighted pairing — from round 2 onward, equal-point players are further ordered by opponent win percentage before the greedy matching pass:

swiss = Swiss(players, use_tiebreaker_sort=True)

Round Robin

from cardarena_tournament_core import Player, RoundRobin, TournamentCompleteError

players = [Player(id=str(i), name=f"Player {i}") for i in range(4)]
rr = RoundRobin(players)  # pre-computes all 3 rounds at construction

try:
    while True:
        round_ = rr.pair()
        # ... play and record results ...
        rr.submit_results(round_)
except TournamentCompleteError:
    print("All rounds played!")

Single Elimination

from cardarena_tournament_core import Player, MatchupOutcome, SingleElimination, TournamentCompleteError

players = [Player(id=str(i), name=f"Seed {i+1}") for i in range(8)]
elim = SingleElimination(players)

try:
    while True:
        round_ = elim.pair()
        for matchup in round_.matchups:
            if matchup.player2 is not None:
                matchup.outcome = MatchupOutcome.PLAYER1_WINS
        elim.submit_results(round_)
except TournamentCompleteError as e:
    print(e)  # "Seed 1 is the champion — the tournament is complete."

Scoring — Pokémon TCG

from cardarena_tournament_core import PokemonTCG

# Pass all completed rounds (scoring is stateless — no submit_results needed)
standings = PokemonTCG().calculate(rounds)

for standing in standings:
    print(
        f"{standing.rank}. {standing.player.name} — "
        f"{standing.points} pts  "
        f"OWP={standing.tiebreakers['owp']:.3f}  "
        f"OOWP={standing.tiebreakers['oowp']:.3f}"
    )

Scoring — Yu-Gi-Oh! TCG

from cardarena_tournament_core import YuGiOh

standings = YuGiOh().calculate(rounds)

for standing in standings:
    print(
        f"{standing.rank}. {standing.player.name} — "
        f"{standing.points} pts  "
        f"OWP={standing.tiebreakers['owp']:.3f}  "
        f"OOWP={standing.tiebreakers['oowp']:.3f}"
    )

Teams

Team is a first-class participant alongside Player. Teams are composed of Player objects and can be used anywhere a player is expected:

from cardarena_tournament_core import Player, Team, Swiss

# Create individual players
alice = Player(id="p1", name="Alice")
bob = Player(id="p2", name="Bob")
carol = Player(id="p3", name="Carol")
dave = Player(id="p4", name="Dave")

# Create teams with Player objects as members
teams = [
    Team(id="t1", name="Mystic Dragons", members=(alice, bob)),
    Team(id="t2", name="Storm Hawks", members=(carol, dave)),
]

# Use teams in any pairing format
swiss = Swiss(teams)

# Access team member information
for team in teams:
    print(f"{team.name}: {', '.join(m.name for m in team.members)}")
    # Output: Mystic Dragons: Alice, Bob
    #         Storm Hawks: Carol, Dave

Serialization: Teams serialize to JSON-friendly dictionaries with nested player data:

team_dict = teams[0].to_dict()
# {
#     "type": "team",
#     "id": "t1",
#     "name": "Mystic Dragons",
#     "members": [
#         {"type": "player", "id": "p1", "name": "Alice"},
#         {"type": "player", "id": "p2", "name": "Bob"}
#     ]
# }

# Reconstruct from dictionary
restored_team = Team.from_dict(team_dict)

Stateless Tournament Reconstruction

Swiss and Single Elimination formats support stateless reconstruction from round history. This enables you to:

  • Persist tournaments to any database (SQL, NoSQL, file storage)
  • Reconstruct state deterministically from historical data
  • Resume tournaments across server restarts or different processes
  • Migrate tournaments between systems

Core Concepts

Tournament state is derived from:

  1. Participant list — all registered players/teams
  2. Round history — completed rounds with outcomes
  3. Active participant IDs — participants still eligible for pairing
  4. Configuration — format-specific settings (points, tiebreakers, etc.)

Swiss Reconstruction

from cardarena_tournament_core import Player, Swiss, MatchupOutcome

# Original tournament
players = [Player(id=str(i), name=f"Player {i}") for i in range(8)]
swiss = Swiss(players)

# Play round 1
round1 = swiss.pair()
round1.matchups[0].outcome = MatchupOutcome.PLAYER1_WINS
round1.matchups[1].outcome = MatchupOutcome.DRAW
round1.matchups[2].outcome = MatchupOutcome.PLAYER2_WINS
round1.matchups[3].outcome = MatchupOutcome.PLAYER1_WINS
swiss.submit_results(round1)

# Play round 2
round2 = swiss.pair()
for matchup in round2.matchups:
    if matchup.player2:
        matchup.outcome = MatchupOutcome.PLAYER1_WINS
swiss.submit_results(round2)

# Serialize complete state
tournament_state = swiss.to_dict()
# Save to database: db.tournaments.insert_one(tournament_state)

# Later: Reconstruct from database
# tournament_data = db.tournaments.find_one({"_id": tournament_id})
reconstructed = Swiss.from_dict(tournament_state)

# State is identical — continue tournament
round3 = reconstructed.pair()

Single Elimination Reconstruction

from cardarena_tournament_core import Player, SingleElimination, MatchupOutcome

# Original tournament
players = [Player(id=str(i), name=f"Seed {i+1}") for i in range(8)]
elim = SingleElimination(players)

# Play quarterfinals
round1 = elim.pair()
for matchup in round1.matchups:
    if matchup.player2:
        matchup.outcome = MatchupOutcome.PLAYER1_WINS
elim.submit_results(round1)

# Serialize and save
state = elim.to_dict()

# Reconstruct
reconstructed = SingleElimination.from_dict(state)

# Continue with semifinals
round2 = reconstructed.pair()

Serialization Format

Swiss:

{
    "participants": [
        {"type": "player", "id": "0", "name": "Player 0"},
        {"type": "player", "id": "1", "name": "Player 1"},
        # ... all participants
    ],
    "rounds": [
        {
            "round_number": 1,
            "matchups": [
                {
                    "player1": {"type": "player", "id": "0", "name": "Player 0"},
                    "player2": {"type": "player", "id": "1", "name": "Player 1"},
                    "outcome": "player1_wins"
                },
                # ... all matchups
            ]
        },
        # ... all rounds
    ],
    "active_participant_ids": ["0", "1", "2", "3", "4", "5", "6", "7"],
    "config": {
        "win_points": 3,
        "draw_points": 1,
        "bye_points": 3,
        "use_tiebreaker_sort": false,
        "tiebreaker_min_win_pct": 0.25
    }
}

Single Elimination:

{
    "participants": [
        {"type": "player", "id": "0", "name": "Seed 1"},
        {"type": "player", "id": "1", "name": "Seed 2"},
        # ... all participants
    ],
    "rounds": [
        {
            "round_number": 1,
            "matchups": [
                {
                    "player1": {"type": "player", "id": "0", "name": "Seed 1"},
                    "player2": {"type": "player", "id": "7", "name": "Seed 8"},
                    "outcome": "player1_wins"
                },
                # ... all matchups
            ]
        }
    ],
    "active_participant_ids": ["0", "2", "4", "6"]  # Winners advance
}

Advanced: Reconstruction from History

For maximum flexibility, reconstruct directly from round history:

from cardarena_tournament_core import Swiss, Player, Round, participant_from_dict

# Load from your database
tournament_data = db.tournaments.find_one({"_id": tournament_id})

# Reconstruct participants
participants = [
    participant_from_dict(p) for p in tournament_data["participants"]
]

# Reconstruct rounds
rounds = [Round.from_dict(r) for r in tournament_data["rounds"]]

# Reconstruct Swiss config
config = tournament_data.get("config", {})

# Reconstruct Swiss state
swiss = Swiss.from_history(
    participants=participants,
    rounds=rounds,
    active_participant_ids=set(tournament_data["active_participant_ids"]),
    win_points=config.get("win_points", 3),
    draw_points=config.get("draw_points", 1),
    bye_points=config.get("bye_points", 3),
    use_tiebreaker_sort=config.get("use_tiebreaker_sort", False),
    tiebreaker_min_win_pct=config.get("tiebreaker_min_win_pct", 0.25)
)

# Continue tournament
next_round = swiss.pair()

Validation & Error Handling

Reconstruction validates all input data:

from cardarena_tournament_core import PairingStateError

try:
    swiss = Swiss.from_history(
        participants=participants,
        rounds=rounds,
        active_participant_ids=active_ids
    )
except PairingStateError as e:
    # Handles:
    # - Incomplete rounds (pending matchups)
    # - Unknown participant IDs in active set
    # - Invalid round data
    print(f"Reconstruction failed: {e}")

Best Practices

  1. Always validate before saving:

    # Ensure round is complete before serializing
    if not round_.is_complete:
        raise ValueError("Cannot save incomplete round")
    
    state = swiss.to_dict()
    db.save(state)
    
  2. Store configuration explicitly:

    # Don't rely on defaults — store all config
    state = swiss.to_dict()
    assert "win_points" in state
    assert "use_tiebreaker_sort" in state
    
  3. Version your schema:

    state = swiss.to_dict()
    state["schema_version"] = "1.0"
    db.save(state)
    
  4. Handle participant drops:

    # Active IDs track who's still playing
    swiss.remove_active_participant("player_7")
    state = swiss.to_dict()
    # state["active_participant_ids"] excludes "player_7"
    
  5. Test reconstruction in CI:

    # Verify state preservation
    original = Swiss(players)
    # ... play rounds ...
    
    state = original.to_dict()
    reconstructed = Swiss.from_dict(state)
    
    assert original.rounds == reconstructed.rounds
    assert original.active_participant_ids == reconstructed.active_participant_ids
    

Extending

Custom Pairing Format

from cardarena_tournament_core import BasePairing, Round

class SnakePairing(BasePairing):
    def pair(self) -> Round:
        # implement your logic here
        ...

BasePairing provides:

  • self.participants — read-only list of all participants
  • self.rounds — read-only list of submitted rounds
  • submit_results(round) — appends to history; call super().submit_results(round) in overrides

Custom Scoring System

from cardarena_tournament_core import BaseScoring, Round, Standing

class ChessScoring(BaseScoring):
    def calculate(self, rounds: list[Round]) -> list[Standing]:
        # compute and return sorted standings with rank set
        ...

Project Structure

cardarena_tournament_core/
├── __init__.py             # Public API
├── common/
│   ├── __init__.py
│   ├── errors.py           # Semantic exception hierarchy
│   └── models.py           # Core data models
├── tournament.py           # Tournament orchestrator
├── utils.py                # Win%, OWP, OOWP helpers
├── py.typed                # PEP 561 typed package marker
├── pairings/
│   ├── __init__.py
│   ├── base.py             # BasePairing ABC
│   ├── swiss.py
│   ├── round_robin.py
│   └── elimination.py
└── scoring/
    ├── __init__.py
    ├── base.py             # BaseScoring and shared TCG scoring helpers
    ├── pokemon.py
    └── yugioh.py

Public API

All public names are importable directly from the package root:

from cardarena_tournament_core import (
    # Orchestrator
    Tournament,
    # Models
    Player, Team, Participant,
    Matchup, MatchupOutcome,
    Round, Standing,
    # Serialization helpers
    participant_to_dict, participant_from_dict,
    # Errors
    TournamentCompleteError,
    # Pairings
    BasePairing, Swiss, RoundRobin, SingleElimination,
    # Scoring
    BaseScoring, PokemonTCG, YuGiOh,
)

Participant Lifecycle

Full Roster vs. Active Roster

Every tournament format tracks two distinct participant sets:

  • Full roster (pairing.participants) — all participants registered at initialization. Immutable. Used for historical scoring and tiebreaker calculations.
  • Active roster (pairing.active_participant_ids) — participants eligible for future pairings. Starts equal to the full roster; updated as participants are removed or (optionally) reactivated.

Removing a Participant

tournament.remove_active_participant(player_id)
  • The participant is excluded from all future rounds generated by pair().
  • All historical rounds, points, and tiebreaker data are preserved.
  • Standings computed after removal still include the removed participant's historical entries.
  • Rounds that were already paired (before the removal) can still be submitted normally.

Reactivating a Participant

pairing.reactivate_participant(player_id)

Returns an inactive participant to the active roster. Not exposed through Tournament by default; use the underlying pairing object directly when needed.

Round Robin Limitation (Phase 1)

RoundRobin does not support dynamic removal. The full schedule is pre-computed at initialization; calling remove_active_participant raises PairingStateError immediately.

Use Swiss pairing if your tournament requires dropping participants mid-event.

Example: Mid-Tournament Drop (Swiss)

from cardarena_tournament_core import Player, MatchupOutcome, Swiss, PokemonTCG, Tournament

players = [Player(id=str(i), name=f"Player {i}") for i in range(8)]
tournament = Tournament(pairing=Swiss(players), scoring=PokemonTCG())

# Play round 1
round1 = tournament.pair()
for matchup in round1.matchups:
    if matchup.player2:
        matchup.outcome = MatchupOutcome.PLAYER1_WINS
tournament.submit_results(round1)

# Player 7 drops after round 1
tournament.remove_active_participant("7")

# Round 2 pairs the remaining 7 active players (one receives a bye)
round2 = tournament.pair()

# Standings still include Player 7's round 1 result
standings = tournament.standings()

Development

git clone https://github.com/KataCards/cardarena-tournament-core
cd cardarena-tournament-core
uv sync --extra dev
uv run ruff check .
uv run mypy cardarena_tournament_core tests
uv run pytest

Dev dependencies include pytest, pytest-cov, ruff, and mypy.


License

Mozilla Public License 2.0 — see LICENSE.

Part of the CardArena ecosystem, maintained by KataCards.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cardarena_tournament_core-1.2.0.tar.gz (75.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cardarena_tournament_core-1.2.0-py3-none-any.whl (35.9 kB view details)

Uploaded Python 3

File details

Details for the file cardarena_tournament_core-1.2.0.tar.gz.

File metadata

File hashes

Hashes for cardarena_tournament_core-1.2.0.tar.gz
Algorithm Hash digest
SHA256 77d55c48791846787e0e0255654e9353c9b5454fca9b82ceca0bf503bac71630
MD5 15c85cf04c627619f4234f93da0c5b71
BLAKE2b-256 16a2187d958f974eb02bda2625a703b66b82ecbc2c90450996c8f159e492549d

See more details on using hashes here.

Provenance

The following attestation bundles were made for cardarena_tournament_core-1.2.0.tar.gz:

Publisher: publish.yml on KataCards/cardarena-tournament-core

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file cardarena_tournament_core-1.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for cardarena_tournament_core-1.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 fba8890b2358db4451a5ee941838067b0d14cf85722d0a0cf46c05931b934f72
MD5 7692e5b63a442c3dd9b2b6c3bf950241
BLAKE2b-256 e9d80bca84c8fd92272f1f097e7908c3ca87741a4e5b80750fbd7b485385a874

See more details on using hashes here.

Provenance

The following attestation bundles were made for cardarena_tournament_core-1.2.0-py3-none-any.whl:

Publisher: publish.yml on KataCards/cardarena-tournament-core

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page