@anatolykhelmer/judo-core
v0.1.0
Published
Framework-agnostic tournament engine for judo competitions: IJF repechage, double elimination and round-robin brackets as pure, serializable data.
Maintainers
Readme
@anatolykhelmer/judo-core
A tournament engine for judo competitions — brackets, seeding, promotion and standings — with no database, no clock and no framework attached.
A competition is plain data. Every operation is a pure function from one state to the next, so you can store a competition as JSON, send it over the wire, replay it, diff it, or drive it from React, a worker or a CLI without the engine caring which.
npm install @anatolykhelmer/judo-coreQuick start
import {
createCompetition,
applyResult,
getNextMatch,
getStandings,
} from '@anatolykhelmer/judo-core';
const competitors = [
{ id: '1', name: 'Ono' },
{ id: '2', name: 'Riner' },
{ id: '3', name: 'Krpálek' },
{ id: '4', name: 'Abe' },
{ id: '5', name: 'Heydarov' },
];
// Seeded draws are reproducible — the same seed always yields the same bracket.
let competition = createCompetition(competitors, {
format: 'olympic',
seed: 'worlds-2026',
});
let match = getNextMatch(competition);
while (match !== null) {
competition = applyResult(competition, match.id, {
kind: 'win',
winner: 'white',
by: 'ippon',
});
match = getNextMatch(competition);
}
console.log(getStandings(competition));
// { kind: 'elimination', complete: true, places: [ { place: 1, competitorId: '3' }, … ] }Byes are handled for you: createCompetition settles every match the draw
already decides, so the first match you are offered is a real contest.
Formats
| Format | What it is | Minimum field |
| --- | --- | --- |
| olympic | Single elimination with the judo repechage: quarterfinal losers get a second chance, two bronze medals | 5 |
| double-elimination | Full repechage — one loss drops you to the losers bracket, two put you out | 5 |
| round-robin | Everybody fights everybody, ranked by a points table | 2 |
See docs/formats.md for bracket diagrams, promotion rules and the exact repechage pairing.
Core concepts
State is data. CompetitionState is { schemaVersion, format, competitors, matches }
— no classes, no cycles, no object identity. JSON.stringify it and you have
lost nothing. Field order is stable, so stored competitions diff and hash cleanly.
Results are descriptive. A result says who advances and why:
applyResult(competition, 'W_2_1', {
kind: 'win',
winner: 'blue',
by: 'waza-ari',
score: { white: { wazaAri: 0, yuko: 0, shido: 1 }, blue: { wazaAri: 1, yuko: 0, shido: 0 } },
time: '4:00',
technique: 'uchi-mata',
});Beyond kind: 'win' there is kind: 'walkover' (bye, fusen-gachi,
kiken-gachi) and kind: 'void' for a double disqualification or a match
neither competitor reached. Results are validated: you cannot declare an empty
corner the winner, or have someone win with three shido against them.
Nothing is mutated. applyResult returns a new state and leaves the old one
untouched, so undo and time travel are free:
const before = competition;
const after = applyResult(competition, matchId, result);
// `before` is still the state before the resultDraws are reproducible. Randomness is injected, never reached for. Pass a
seed (or your own rng) and the draw is auditable and repeatable.
API
Building and running
createCompetition(competitors, { format, seed?, rng?, shuffle? })— draw a competitionapplyResult(state, matchId, result)— record a result; promotions and walkovers cascade before it returnsautoResult(match)— the result a match settles into on its own, if any
Reading
getNextMatch(state)— the next match to call to the mat, ornullgetReadyMatches(state)— everything that could be fought right now (parallel mats)getMatch(state, id)/findMatch(state, id)/getCompetitor(state, id)getMatchesByRound(state)— matches grouped into consecutive same-round blocksgetStandings(state, { points? })— the podium, or the round-robin tableisComplete(state)formatBracket(state)— the whole draw as plain text, for terminals and test failures
Storing
toJSON(state)/fromJSON(data)—fromJSONvalidates untrusted input and throwsInvalidStateErrorwith the offending path
Also exported
createSeededRng, shuffle, normalizeDraw, nextPowerOfTwo,
validateResult, resultPoints, IJF_POINTS, winnerId, loserId,
isReady, buildTopology, and every error class.
Class facade
If you prefer methods to threading state through calls:
import { Competition } from '@anatolykhelmer/judo-core';
const competition = Competition.create(competitors, { format: 'olympic', seed: 1 });
let match = competition.getNextMatch();
while (match !== null) {
competition.finishMatch(match.id, { kind: 'win', winner: 'white', by: 'ippon' });
match = competition.getNextMatch();
}
console.log(competition.getStandings());
console.log(String(competition));competition.state hands back an immutable snapshot; snapshots taken before a
result stay valid afterwards.
Standings
Knockout formats return the judo podium — gold, silver, two bronzes and two fifth places — truncated when the field is too small to fill every place:
{ kind: 'elimination', complete: true, places: [{ place: 1, competitorId: '3' }, …] }Round-robin returns a table ranked by classification points (ippon and
hansoku-make 10, waza-ari 7, yuko 5, decision 1 by default), then by the result between
tied competitors, then by fewest points conceded, then by most wins by ippon.
Pass your own points table if your federation counts differently.
Notes
- Zero runtime dependencies. ESM and CJS builds, full type declarations. Node 20+, and it runs unchanged in a browser.
applyResultis O(matches) per call, since it rebuilds the match list. A 64-competitor knockout plays out in about 2 ms; an all-play-all of the same size — 2016 matches, which nobody actually runs — takes about 400 ms.- The engine knows nothing about weight categories, age groups, mats or check-in.
Attach whatever you need to a competitor through
meta; it is carried around untouched.
License
MIT
