Skip to main content

drawPositions

A drawPosition is a slot in a structure. Participants are assigned to slots, matchUps reference slots, and everything a draw sheet renders is derived from that reference. This page states the rules that govern the drawPositions array — the ones that are load-bearing, easy to violate, and not obvious from the type.

matchUp.drawPositions: number[] // at most two, stored ASCENDING, usually compacted (round robin excepted: § 2)

1. A drawPosition is unique within a STRUCTURE, and means nothing outside it​

Two structures in the same draw both have a drawPosition 3, and they are unrelated. Never compare a drawPosition against matchUps or positionAssignments drawn from a different structure — scope the collection by structureId first.

No drawPosition crosses a link. A link moves a participant, never a number. When a participant moves along a link — placed into, advanced into, or removed from another structure — resolve who they are from the SOURCE structure's positionAssignments, then find THEIR drawPosition in the target. The source structure's number is not an input to that lookup, and there is no case where it is the right answer on the other side: the same number in the target is a different slot, holding a different participant, a bye, or nobody.

DOUBLE_ELIMINATION is where this is easiest to get wrong, because its Main and Backdraw structures both number from 1 and the Backdraw final feeds back into Main: Backdraw 7 and Main 7 are routinely different participants — or a participant and a bye.

2. The array is SORTED, not positional: the index is a side only while BOTH positions are present​

Writers store drawPositions ascending and compacted: a matchUp awaiting its second participant is stored [4] (since 7.7.0 no writer stores a hole; records written before may still hold [undefined, 4], § 6). So a lone position's index is not its side: compacted, it sits at index 0 whichever side it belongs on, and reading a side from the index answers 1 for a position that belongs on side 2. The binding between a side and a slot holds by index only when both positions are present, and then only because the ascending order makes the lower one side 1 (§ 3).

Round-robin groups are the exception to the ascending rule: their matchUps store positions in Berger pairing order, and DRAW_POSITIONS_NOT_SORTED exempts them. Sides there still come from the readers below, which sort a copy.

Read sides from structure, never from the raw index of a lone position:

questionreader
which side is each position on?sides on a hydrated matchUp; getDrawPositionSides on a stored one
which position is on side N?getSideDrawPosition
which position won?getWinningSideDrawPosition
which side is this position on?getDrawPositionSideNumber
which side does a lone exit's position face?getExitWinningSide
ordering a matchUp's positions for displaygetOrderedDrawPositions

Each reads by index when both positions are present — the ascending order makes that exact — and resolves a lone position structurally, through the round profile (§ 4). The raw idioms drawPositions[side - 1] and indexOf(drawPosition) + 1 remain sound only where both positions are guaranteed: a first-round matchUp, including a loser target's (feedRound handled separately), or behind an explicit filter(Boolean).length === 2. None of them fails loudly when used elsewhere. They answer confidently, with the wrong participant.

These readers are engine internals, not public exports (getExitWinningSide and getOrderedDrawPositions included). A consumer reads sides on a hydrated matchUp, which carries each side's drawPosition and sideNumber already resolved.

Every writer must leave drawPositions ascending. Removing a position preserves order and is safe. Substituting one in place does not: a positional map that writes a higher position into the first slot produces [7, 5]. That is a real defect this codebase has shipped, reported as DRAW_POSITIONS_NOT_SORTED.

3. When both positions are present, side 1 is the LOWER drawPosition​

A consequence of the ascending-storage rule in § 2. It holds even where fed positions meet advanced ones in later rounds.

4. On a feed round, a lone position may be FED or ADVANCED — and the array cannot tell you which​

A feed round is a round that receives participants from somewhere other than the previous round of its own structure — usually through a link from another structure, but not always: a FEED_IN draw reserves its fed positions for entrants placed directly into a later round, the ones who do not have to play round 1, and that structure has no links at all. Its matchUps pair a fed position with an advanced one, and

fed positions are { sideNumber: 1 }.

When such a matchUp holds only ONE position, that position may be either the fed one (still waiting for its opponent to advance) or the advanced one (still waiting to be fed). They belong on different sides, and nothing in the array distinguishes them:

[5] 5 is fed, or 5 advanced? the array does not say
[5, undefined] the same question
[undefined, 5] the same question

The test is structural, not numeric: a drawPosition present in the prior round of the same structure played its way here and is ADVANCED; one that is absent from the prior round has just been fed in. getRoundMatchUps makes exactly this test when it builds pairedDrawPositions, and getOrderedDrawPositions makes it when it resolves sides, so the two agree by construction.

feedRound and hasFedDrawPosition are two facts, not one​

The paragraph above runs two questions together, and the engine used to answer both with feedRound:

questionflaghow it is derived
does a position arriving here take side 1, leaving the prior round's advancer on side 2?feedRoundthe round's matchUpsCount equals the prior round's — a round that pairs an arrival with an advancer does not halve
is a drawPosition reserved here for that arrival?hasFedDrawPositionthe same, and no WINNER link targets the round

They give the same answer everywhere but one place, and that place is 4a. Read feedRound to order sides; read hasFedDrawPosition to ask whether a slot exists — that is what side.participantFed and side.participantAdvanced now mark, and what a caller deciding whether a position can still be fed into this matchUp must use.

Measured 2026-09-18

Over 111 generated draws — 20 draw types × 9 draw sizes, with no byes, so a drawPosition held in a round beyond the first is a reserved feed slot and nothing else — 521 rounds and 1,739 matchUps:

discriminator for "this round reserves a fed drawPosition"misses
matchUpsCount equality alone5 — DOUBLE_ELIMINATION's Main final, at every draw size
a LOSER link targets the round4 — FEED_IN round 2 at every non-power-of-two size. Its reserved positions are held for entrants placed directly into a later round — the ones who do not have to play round 1, seeds among them — so they come from the draw's own entries and the structure has no links at all. This is why the answer cannot simply be read off the links
count equality and no WINNER link0

It is a round fact and not a per-matchUp one: 1,739 of 1,739 matchUps agreed with their round. (The positive control for that number: the same survey over draws with byes reports 337 disagreements, because a round-1 bye advances a participant into round 2 at generation.)

The numeric shortcut is nearly right, and wrong for DOUBLE_ELIMINATION

Fed positions are usually numbered below the first round's block, which makes "is it lower than the lowest round-1 drawPosition" look like an equivalent, cheaper test. Measured across two frozen 600-seed windows on both propagation arms it agrees on 113,626 of 113,632 live cases — and the exceptions are a whole draw type rather than noise.

DOUBLE_ELIMINATION's Main final is fed from the Backdraw, and the Backdraw winner re-enters Main at the Main drawPosition they already held. Its fed positions therefore sit inside the first round's numeric range, and the shortcut calls them advanced. (The Backdraw has its own positions, numbered from 1 like Main's — overlapping numbers, not shared ones; see rule 1.) Use the prior-round test. See 4a for why that structure is shaped the way it is.

4a. The one exception: DOUBLE_ELIMINATION's Main final​

DOUBLE_ELIMINATION is the only structure in the factory whose feed round has no reserved fed drawPosition, and it is worth knowing about because it looks like a violation of rule 4 and is not going to be changed.

The Main structure is generated as a feed-in of drawSize + 1 with linkFedFinishingRoundNumbers: [1] — the only use of that parameter anywhere. It tells feedInMatchUps that the final round is fed by a link from another structure, and link-fed positions are subtracted from the local allocation:

positionsFed = positionsFed - positionsFedByLinks;

So Main asks for drawSize + 1 and receives drawSize positionAssignments. The extra matchUp exists; the extra slot does not. The Backdraw winner returning to the Main final is placed at whichever Main drawPosition they already held, which is why:

  • both of the Main final's positions are ADVANCED by the prior-round test in rule 4 — there is no fed position to find;
  • its drawPositions are not structurally determined. Measured across 16 winner patterns on a DE 8, Main|4|1 took 11 distinct drawPosition pairs. A genuine feed round varies only on the advanced side; the fed side is a constant.

Why it stays​

Allocating the missing slot produces exactly the layout rule 4 describes — Main 1..9, round 1 at 2..9, the final fed at drawPosition 1. It also does this:

BEFORE DE 8/8 Main assignments 8 participants 8 BYES 0 BYE matchUps: 0
AFTER DE 8/8 Main assignments 9 participants 8 BYES 1 BYE matchUps: 2

A full 8-of-8 double elimination acquires a BYE, and it propagates into the Backdraw. Main is the ENTRY structure, so any unfilled position in it becomes a bye — a consolation's unfilled positions do not, because they are fed rather than entered. The suppression is load-bearing: it exists so the feed slot is not counted as an entry slot.

A real fix would need a reserved feed position that is excluded from the entry pool and from bye assignment — a positioning-layer concept the factory does not have — plus link retargeting, and it would renumber every Main drawPosition in every DOUBLE_ELIMINATION draw ever stored.

Nothing depends on the numbering. Side resolution uses the structural prior-round test, not the numeric one, so DOUBLE_ELIMINATION hydrates correctly as it stands. Tracked as P22 on the CourtHive design-flaws punch list.

It is still a feed round for SIDE ORDERING, and never for a reserved slot​

Both halves of that sentence matter, and conflating them is what the hasFedDrawPosition split in rule 4 exists to stop.

feedRound is right here. The Backdraw winner arriving over the link does take side 1, and the undefeated main-bracket winner does sit on side 2. Removing the flag from this round moves the lone position to whichever chunk of the prior round it came from — and the prior round is a single matchUp, so the pair collapses to one entry and the position lands on side 1, handing a pending walkover to the side that holds nobody. That is a real regression, reached and reverted while this was being worked out.

hasFedDrawPosition is false here, and every consumer that asks "is a slot reserved" wanted that answer. getSide marked an empty side 1 participantFed on every Main final in every double elimination, for a slot that does not exist; doubleExitAdvancement then read that mark back as one half of its condition for admitting a double exit's BYE into the target structure — beside feedRound, which is what set the mark, so the condition tested one fact twice.

5. The SHAPE of the array is not information​

The engine spells the same occupancy several ways, depending on which writer last touched the matchUp:

spellingwritten by
[5]every writer since 7.7.0, through normalizeDrawPositions
[undefined, 5]records written before 7.7.0 (releaseAdvancedDrawPosition, positionClear, BYE advancement)
[]buildRound, resetDrawDefinition, luckyDrawAdvancement, and any removal that empties a matchUp
absentpruneDrawDefinition

All of them must hydrate identically. A consumer reads sides and has no idea which writer ran. This is enforced by drawPositionsRepresentationIndependence.test.ts, which asserts that every spelling of every occupancy produces the same sides and that the side is the correct one.

Two corollaries for anyone writing engine code:

  • A hole is not a drawPosition. ensureInt returns 0 for undefined and null, and isNaN(0) is false — so any test written as !isNaN(ensureInt(x)) silently accepts a hole. Exclude undefined and null explicitly.
  • Never branch on the array's length or on allNumeric / noNumeric. Filter to the real positions once, then branch on how many there are. allNumeric is true for a one-element array, which is how a compacted lone position used to bypass the feed-round rule entirely.

6. No hole is stored​

Since 7.7.0 normalizeDrawPositions, which every removal, substitution, placement and BYE-advancement writer routes through, stores the positions present, ascending: a lone survivor is [5] whatever its side, and a matchUp holding nothing is []. A hole serialises as null, which tournament.schema.json rejects (CA's Q2 ruling, 2026-10-05), so the side of a lone survivor is not carried by a hole: it is read structurally, through the round profile (§ 4), by the helpers in getDrawPositionSides. Every engine reader that once indexed the raw array was moved onto them first; drawPositionsAreReadByStructure.test.ts holds the rest to an exact allow-list. drawPositionsNormalizationBypass.test.ts fails on any writer that does not route through the normaliser.

Records stored before 7.7.0 may still hold a hole: [undefined, 5], [5, null], [null]. The engine reads them by the same structural rule and they resolve the same, which is why the shape is not information (§ 5).

7. [] is published as an ABSENT key, and that is the ordinary case​

addMatchUpContext hydrates through definedAttributes(obj, undefined, true), which drops empty arrays. So a matchUp holding no position carries no drawPositions key at all on the inContext matchUp that consumers render from.

This is not an edge case. It is what every unreached matchUp looks like from the moment a draw is generated — on a 16 draw with every position filled:

drawTypematchUps publishing no drawPositions
SINGLE_ELIMINATION7 of 15
DOUBLE_ELIMINATION11 of 31
FEED_IN_CHAMPIONSHIP_TO_SF10 of 28
COMPASS12 of 32

sides is always length 2 regardless, so nothing downstream needs drawPositions to know how many sides a matchUp has.

For consumers

Read matchUp.drawPositions defensively — ?.[n], ?? [], || []. An absent key is normal, not a fault. And be careful with .every(): [].every(predicate) is vacuously true, so a matchUp holding no position satisfies every such filter. Check for a non-empty array first if you are partitioning matchUps.

Where these rules live in code​

rulesource
ascending order, and the reader idioms that depend on itgetOrderedDrawPositions
reading a side from structuregetDrawPositionSides
crossing a link by participantdirectWinner, releaseLinkedWinnerAdvancement
fed vs advanced, and side resolutiongetOrderedDrawPositions, getRoundMatchUps
a reserved fed slot vs a round that merely feeds sidesgetRoundMatchUps, getWinnerLinkRoundNumbers
storing the positions present, ascending, with no holenormalizeDrawPositions
the published shapeaddMatchUpContext, via definedAttributes