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. Pass teams anywhere a player is expected:

from cardarena_tournament_core import Team, Swiss

teams = [
    Team(id="t1", name="Mystic Dragons", members=("Alice", "Bob")),
    Team(id="t2", name="Storm Hawks",    members=("Carol", "Dave")),
]
swiss = Swiss(teams)

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,
    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.1.0.tar.gz (67.5 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.1.0-py3-none-any.whl (30.3 kB view details)

Uploaded Python 3

File details

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

File metadata

File hashes

Hashes for cardarena_tournament_core-1.1.0.tar.gz
Algorithm Hash digest
SHA256 cfb4d8e10145ff45127a362d94201cfbe61332d2c0e80e37f0e1aaa06ed40d61
MD5 2d704ab89cdffc72f88d9892904e5bc6
BLAKE2b-256 f39049fe083859662771b363699db8b8bb3df87848c382018061447f9b4655b8

See more details on using hashes here.

Provenance

The following attestation bundles were made for cardarena_tournament_core-1.1.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.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for cardarena_tournament_core-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 903bc118910e1f7834156a4f21b1a4243f95c562a9ebdfcbc733c4acbcc66fdf
MD5 2cf837e1fc2cedf66778626aaeda3b9e
BLAKE2b-256 0f2e24b3b8e767a5ef55f24204c9a747826080642f513c5090171249e900c23a

See more details on using hashes here.

Provenance

The following attestation bundles were made for cardarena_tournament_core-1.1.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