Skip to main content

Ladder

Overview​

A Ladder is a continuous, challenge-driven competition. Participants occupy an ordered standing, and a participant may challenge someone above them; the result rearranges the standing. There are no rounds, no bracket, no fixed field, and often no end date.

In the factory this draw type is the constant LADDER.

A ladder is not a bracket, and almost nothing about it is derived from geometry. It shares the AD_HOC structure shape — matchUps carry neither roundPosition nor drawPosition — but not its meaning: an AD_HOC draw's positionAssignments are a roster, while a ladder's are the standing and drawPosition reads as rank, where 1 is the top.

Ask isLadder(drawType) wherever that difference matters. isAdHocType(drawType) is true for a ladder and answers a different question — whether the structure has bracket geometry.

Every example below is a tournamentEngine call taking drawId. The engine resolves the drawDefinition, the ladder's structure, and — where one is named — the matchUp, so a caller never holds any of them. Passing a drawDefinition or structure explicitly still works and wins over resolution.

What a ladder is made of​

piecewhere it lives
the standingstructure.positionAssignments — drawPosition is rank
matchesAD_HOC matchUps: participants on sides, no drawPositions
a challengea matchUp with matchUpStatus: CHALLENGED
historydated ScaleItems, one per rank change
the rulesPOLICY_LADDER

drawPositions are deliberately absent from a ladder matchUp. A participant's position moves underneath a match that has already been arranged, so recording positions on the matchUp would freeze a standing that is expected to change.

The challenge​

A challenge is the only fixture in the factory that a participant creates. Everything else arrives from a draw, which is why eligibility is enforced when the challenge is issued rather than when a draw is generated — nothing upstream decided these two would meet.

const { matchUpId } = tournamentEngine.issueChallenge({
challengerParticipantId: 'p9',
defenderParticipantId: 'p7',
issuedAt: '2026-03-01T10:00:00.000Z',
drawId,
});

issueChallenge refuses a downward challenge, a self-challenge, a participant who is not seated on the ladder, a challenge outside the policy's range (naming the range), and any drawType that is not LADDER.

CHALLENGED is scoped to LADDER, and the scope is enforced​

CHALLENGED is the first matchUpStatus whose context is validated rather than merely conventional. Setting it on a matchUp in any other drawType returns ERR_MATCHUP_STATUS_OUT_OF_SCOPE.

The mechanism is general — matchUpStatusScopes declares where a status may be used, and getMatchUpStatusScopeViolation enforces it. A status absent from that map is unscoped and behaves exactly as it always has.

Expiry is derived, never stored​

PENDING and EXPIRED are the same stored matchUp. Nothing runs at the moment a challenge expires to flip a flag, so the state is computed at an instant the caller supplies:

const { state, expiresAt } = getChallengeState({ matchUp, policy, asOf: '2026-03-07T10:00:00.000Z' });

asOf is required. That keeps the function pure and testable without freezing time, and it means accepting late is judged against when the response arrived rather than against when a background job happened to run.

Reporting a result​

On a published ladder, members report their own scores. A reported score and an agreed score are different facts, and conflating them would let one participant reorder the ladder by self-reporting a win nobody contradicted.

tournamentEngine.submitResult({ participantId: 'p9', outcome: { winningSide: 1 }, submittedAt, matchUpId, drawId });
// score is recorded; matchUpStatus becomes AWAITING_RESULT
tournamentEngine.confirmResult({ participantId: 'p7', confirmedAt, matchUpId, drawId });
// matchUpStatus becomes COMPLETED — and only now can the standing move

Peer acceptance and operator validation are the same transition with a different attestor; resultValidation in the policy decides which attestors count (PEER, OPERATOR, EITHER).

Two rules hold under every policy:

  • a participant can never confirm their own score, or "confirmation" is satisfied by submitting twice
  • a standing dispute blocks movement, even after a confirmation — a disputed result is not a result

disputeResult returns a COMPLETED matchUp to AWAITING_RESULT. Nothing resolves the dispute automatically.

Movement​

applyLadderMovement is the only place a ladder standing moves — a challenge result, a lapse consequence and an operator removal all route through it, so the movement rule and the history it writes cannot diverge between paths.

It takes a trigger rather than an outcome, and derives the rest:

tournamentEngine.applyLadderMovement({ trigger: 'RESULT', matchUpId, appliedAt, drawId });
triggerrequires
RESULTa matchUpId. Challenger, defender and who prevailed are read off the matchUp, and the attestation gate decides whether it may move anything
FORFEITthe two participantIds — a declined challenge has no score to attest

A RESULT whose result is not validated returns ERR_RESULT_NOT_VALIDATED with the reason.

SWAP and INSERTION are different competitions​

start p1 p2 p3 p4 p5
p4 beats p2, SWAP → p1 p4 p3 p2 p5 the two exchange; nobody else moves
p4 beats p2, INSERTION → p1 p4 p2 p3 p5 p2 and p3 each shift down one

Under INSERTION a challenger winning from far below displaces a whole run of players. Both are common club settings, which is why neither is hardcoded.

History​

Every rank change is mirrored to a dated ScaleItem (scaleType: RANKING, scaleName the ladder's drawId) as a side effect of the position mutation — never as a separate call a caller might skip. "Where was I in March" is then an ordinary scale lookup rather than a second store.

Ordering: RANK or RATING​

Ordering is a property of the ladder, and it changes what the machinery does:

orderingthe standing ismovement
RANK (default)positionAssignments, mutated by the movement ruleson a resolved challenge
RATINGderived from each participant's ratingnone — the ordering already is the scale

Under RATING, positionAssignments becomes a projection rather than the source of truth and applyLadderMovement does nothing, reporting moved: false. Always read the ordering through getLadderOrdering rather than the policy field, and read the standing through getLadderStanding rather than assuming positionAssignments.

const standing = tournamentEngine.getLadderStanding({ drawId });
// [{ position: 1, participantId: 'p3', ratingValue: 12.5 }, …]

Direction is read, never assumed. ratingsParameters[ratingType].ascending decides which end is the top: WTN, BWF and USAR are lower-is-better, while UTR, ELO, DUPR, PSA and the squash ratings are higher-is-better. Hardcoding "higher wins" would invert an entire WTN ladder and raise no error.

With dynamicRating, a factory-maintained <ratingType>.DYNAMIC value is preferred and the published rating is the starting position — the same pattern DrawMatic uses, so a club can run a rating-ordered ladder without every member holding a current published rating.

An unrated participant sorts last: absent is not "best". An unknown ratingType returns the stored order with no ratingValue, rather than presenting a misconfiguration as a working ladder.

Lapses​

Declining a challenge, ignoring one, and accepting then not turning up are one mechanism, not three. From the challenger's side they are the same offence — being unavailable to the people below you — and clubs already treat them that way.

lapsewhat happened
DECLINEthe defender said no
EXPIRYthe defender never answered
UNPLAYEDaccepted, then not played by the play-by date

All three count by default. Omitting EXPIRY makes the policy avoidable: a defender simply never responds and accumulates nothing.

Lapses are counted only from the defender's side; issuing challenges is not an offence. They are evaluated when a challenge resolves, never by a background sweep — which has one consequence worth knowing: inactivity here means unresponsive to challenges, never "has not played". A participant nobody challenges never lapses.

const { count, exceeded, consequence } = tournamentEngine.getLapses({ participantId, asOf, drawId });

Windows: SEASON counts everything, ROLLING forgets beyond windowDays, and CONSECUTIVE is forgiven by turning up — a played match breaks the run.

declineChallenge records the decline and then evaluates the lapse, so that decline is among those counted; evaluating first would give every defender one permanent free decline.

Consequences: FORFEIT_POSITION hands the position to the challenger of the resolving challenge, DROP moves the participant down (nobody takes their place — everyone passed moves up one), and REMOVE takes them off the ladder.

Removing someone by hand​

Because a participant nobody challenges never lapses, there is no automatic route for the member who has left the club, is injured for the season, or has died. removeLadderParticipant is that route:

tournamentEngine.removeLadderParticipant({ participantId, reason: 'left the club', removedAt, drawId });

It closes the ladder up beneath them — a ladder with a hole in it is not a ranking — and records the reason. A manual override without one is indistinguishable from a mistake.

Policy​

{
ladder: {
ordering: 'RANK', // or RATING
movement: 'SWAP', // or INSERTION
challengeRange: 3, // positions above; or 'ANY'
resultValidation: 'EITHER', // PEER | OPERATOR | EITHER
acceptanceDays: 5,
playByDays: 14,
lapsePolicy: {
allowance: 0,
window: 'ROLLING', // SEASON | ROLLING | CONSECUTIVE
windowDays: 30,
countsAsLapse: ['DECLINE', 'EXPIRY', 'UNPLAYED'],
consequence: 'FORFEIT_POSITION',
},
},
}

Policies resolve through the ordinary hierarchy, so a club-wide policy is inherited and a draw may override part of it. An absent policy is not an error — a ladder with no policy runs on the defaults, which is the common case rather than a misconfiguration. declineForfeitsPosition: true remains as sugar for { allowance: 0, consequence: 'FORFEIT_POSITION' }.

Joining a ladder​

entryPlacement decides where a newcomer lands.

BOTTOM is the default and the conservative choice: joining costs nobody anything, and the newcomer earns their way up by challenging.

BY_RATING seats them where their rating says they belong. Fairer to a strong newcomer, but on a RANK ladder it is a real intervention — every position beneath them shifts down, and those positions were earned by challenge. An unrated participant under BY_RATING goes to the bottom, because absent is not "best". Direction is honoured here as everywhere: on a WTN ladder a lower number seats higher.

tournamentEngine.addLadderParticipant({ participantId, addedAt, drawId });

Under RATING ordering placement is moot — the participant is appended and getLadderStanding sorts them correctly on the next read.

Ratings on a rating-ordered ladder​

The factory does not fetch ratings. UTR, WTN and DUPR are other people's systems with their own credentials and terms; retrieving values is an operator's job and belongs in an ingest adapter, not in a competition engine.

That leaves two modes, and the default is the first:

Dynamic (the default). The factory computes the rating from ladder results, seeding from each participant's published rating as a starting position. A validated result updates the <ratingType>.DYNAMIC scale via the same generateDynamicRatings machinery DrawMatic uses, and the standing re-derives.

External. Ratings belong to a provider. An operator refreshes them in bulk:

tournamentEngine.refreshLadderRatings({ ratings: { [participantId]: 12.4 }, refreshedAt, drawId });

Between refreshes the standing holds still, and that is not a block on play: a participant awaiting new values may challenge and be challenged exactly as normal — only their position is unaffected until the refresh lands, at which point the whole standing re-derives at once.

A partial refresh is the normal case, because a provider will not have a value for everyone; participants absent from the map are left as they were rather than cleared. A rating for someone not seated on the ladder is reported back in skipped rather than written quietly.

Not yet built​

  • Dispute resolution. A disputed result is blocked from moving the ladder, but nothing resolves it.