General-purpose tournament engine: Swiss pairing, Round Robin, Elimination formats with pluggable scoring and tiebreaker systems
Project description
cardarena-tournament-core
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
BasePairingorBaseScoringto add your own format - Fully typed — ships a
py.typedmarker; 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 participantsself.rounds— read-only list of submitted roundssubmit_results(round)— appends to history; callsuper().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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cardarena_tournament_core-1.1.0.tar.gz.
File metadata
- Download URL: cardarena_tournament_core-1.1.0.tar.gz
- Upload date:
- Size: 67.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cfb4d8e10145ff45127a362d94201cfbe61332d2c0e80e37f0e1aaa06ed40d61
|
|
| MD5 |
2d704ab89cdffc72f88d9892904e5bc6
|
|
| BLAKE2b-256 |
f39049fe083859662771b363699db8b8bb3df87848c382018061447f9b4655b8
|
Provenance
The following attestation bundles were made for cardarena_tournament_core-1.1.0.tar.gz:
Publisher:
publish.yml on KataCards/cardarena-tournament-core
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cardarena_tournament_core-1.1.0.tar.gz -
Subject digest:
cfb4d8e10145ff45127a362d94201cfbe61332d2c0e80e37f0e1aaa06ed40d61 - Sigstore transparency entry: 1329491577
- Sigstore integration time:
-
Permalink:
KataCards/cardarena-tournament-core@4e09c846ab13d083323939e1bf82f4d7c21dafda -
Branch / Tag:
refs/heads/main - Owner: https://github.com/KataCards
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4e09c846ab13d083323939e1bf82f4d7c21dafda -
Trigger Event:
push
-
Statement type:
File details
Details for the file cardarena_tournament_core-1.1.0-py3-none-any.whl.
File metadata
- Download URL: cardarena_tournament_core-1.1.0-py3-none-any.whl
- Upload date:
- Size: 30.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
903bc118910e1f7834156a4f21b1a4243f95c562a9ebdfcbc733c4acbcc66fdf
|
|
| MD5 |
2cf837e1fc2cedf66778626aaeda3b9e
|
|
| BLAKE2b-256 |
0f2e24b3b8e767a5ef55f24204c9a747826080642f513c5090171249e900c23a
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cardarena_tournament_core-1.1.0-py3-none-any.whl -
Subject digest:
903bc118910e1f7834156a4f21b1a4243f95c562a9ebdfcbc733c4acbcc66fdf - Sigstore transparency entry: 1329491675
- Sigstore integration time:
-
Permalink:
KataCards/cardarena-tournament-core@4e09c846ab13d083323939e1bf82f4d7c21dafda -
Branch / Tag:
refs/heads/main - Owner: https://github.com/KataCards
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4e09c846ab13d083323939e1bf82f4d7c21dafda -
Trigger Event:
push
-
Statement type: