MakeReady

Scoring rules

Central discipline rules

The rules of each discipline (scoring profile, default divisions and categories, RO notes) live centrally in the portal (discipline_rules), managed by the MakeReady admin only. Staff and members can read them (GET /api/v1/rules), the web portal shows them, and tablets sync them automatically whenever they are online. Every change increments the rules version.

A match copies the scoring profile from the central rules when it is created and records rulesVersion. A later rules change therefore never alters a running or finished match; a match admin can re-apply the current rules to a match that has no approved scores yet (POST /matches/:id/rules/reapply). The code defaults in packages/shared/src/profiles.ts are only the seed for version 1.

Scoring engine

The engine in packages/scoring is profile-driven: when a match is created it gets a copy of the ScoringProfile of its discipline (packages/shared/src/profiles.ts) and can adjust it per match. Later rule changes therefore never affect old matches.

Input per competitor per stage (StageScore)

  • hits[] per target: A/C/D (paper), M (explicit misses), NS (at most 2 no-shoot hits count per target; the tablet and the portal correction form do not accept more), hit (steel 0/1).
  • On the tablet, "Scoring complete!" is only possible once a time is recorded and every paper target has exactly its required hits entered (A/C/D/M); steel is always complete (hit or standing). A DQ ends the stage regardless.
  • procedurals, dnf, dq. On the tablet every procedural penalty is given with a reason chosen from the IPSC procedural penalties (rulebook chapter 10.2: stage briefing not followed, faulting a line, mandatory reload not performed, wrong hand, wrong position or port, creeping, failure to engage a target, mover not activated, shooting after leaving the position, reload or handling outside the designated area, or a free-text "other"). The reasons travel with the score (proceduralReasons, one entry per penalty), are shown on the approval screen and appear with the result in the app and on the website (packages/shared/src/procedurals.ts).
  • timeSeconds and optionally all shotTimes[] from the timer.
  • revision: incrementing per (competitor, stage). Corrections = new revision.
  • approvedAt: the score only counts officially after the competitor has approved it.

Normalisation of hits (normalizeHits)

  1. Per paper target only the best requiredHits hits count (A > C > D).
  2. Missing required hits become misses, unless the RO already entered more misses (maximum of both) or the target is disappearing.
  3. Steel: hit = 1 scores, otherwise a miss. A target without input counts as completely missed.
  4. No-shoots always count, even on targets that are no longer in the stage.

IPSC Handgun — Comstock (method: comstock, ranking: stage_points)

Major (PF ≥ 160)Minor (PF ≥ 125)
A55
C43
D21
Steel55
Miss / No-shoot / Procedural−10−10
  • A competitor's stage points are never negative.
  • Hit factor = points / time (4 decimals). Without a valid time the stage counts for zero.
  • The stage winner (highest HF) gets the maximum (roundCount × 5); the others get HF / topHF × max. This happens within the chosen population: a division ranking has its own stage winners, and so does the overall ranking.
  • Match points = sum of stage points; percentage relative to the winner.
  • The scoring method (Comstock or time plus) is a property of the match, not of a stage; every stage of a Comstock match is scored as Comstock.
  • Divisions can force a power factor (forcedPowerFactor): Production and Production Optics are always minor.
  • DQ (dq: true on a score or competitor.disqualified): the competitor disappears from the standings (rank 0, all stages zero). DNF: that stage counts zero.

FDS (FDS_PROFILE)

Exactly the IPSC Handgun rules and divisions; FDS is simply not an official IPSC discipline. It is a separate discipline value (fds) so matches and results can be filtered on it.

LVR — Low Velocity Rifle (LVR_PROFILE)

Comstock with minor scoring only (A5/C3/D1), comparable to IPSC Mini Rifle; allowMajor: false ignores a declared major. See docs/open-questions.md for the details still to be confirmed.

Multigun — Time plus (method: time_plus)

  • Total time = raw time + zone time + penalty time.
  • Zone time per scored hit: A 0 s, C 0.5 s, D 1.5 s (timePenalties.zones). Only the best requiredHits hits per paper target count, as in Comstock.
  • Penalties: miss 5 s (per missing required hit, derived from requiredHits), no-shoot 5 s, procedural 5 s, failure to neutralize 0 s. All values are configurable per match (PATCH /matches/:id with scoring).
  • DNF: explicit dnfTimeSeconds of the stage, otherwise par time + all misses, otherwise the slowest valid time of the field + all misses; a DNF therefore always finishes last.
  • Ranking:
  • time_percentage (default): stage winner 100 points, others fastest / own × 100; sum over stages; match percentage relative to the winner.
  • total_time: sum of times, lowest wins; missing stages count the DNF time so that incomplete competitors do not end up on top.

Tests

pnpm --filter @makeready/scoring test — 15 tests cover zones/PF, derived misses, best-hits rule, steel, disappearing targets, DNF/DQ, LVR, FDS and division defaults, forced minor via division, stage points, division-specific stage winners, revisions/approval, time-plus zone times and both time-plus rankings.

Shootoff (elimination bracket)

A shootoff has no stage scores: shooters or teams (each registered as a competitor) meet head to head in an elimination bracket. The format is chosen when the bracket is started (and can be changed when it is regenerated):

SettingOptionsMeaning
Elimination (lives)1, 2, 3losses before a competitor is out: single elimination; double elimination (winners + losers bracket); triple, with a last chance bracket that receives the losers of the losers bracket and a lower final between the champions of both lower brackets
Bouts1, 3, 5 heatsa bout is won by the first side with more than half of the heats (2 of 3, 3 of 5); the RO records every heat
Finals1, 3, 5 heatsthe same for the lower final and the grand final

The finals are series: the grand final is replayed as long as the loser still has a life left, so the winners' champion (no losses) must be beaten lives times in a row by a challenger who is already carrying losses. The replays exist in the bracket from the start and are marked not needed once the series is decided.

  • No squads: a shootoff only has competitors or teams (each team registered as one competitor); the squad screens are hidden for it.
  • Start: pressed once, just before the first round (portal tab Bracket or the tablet). The bracket is generated from the competitor numbers (1 vs last, 2 vs second last, …) into the next power of two; missing entrants are byes and walk the opponent through. From then on the entrants cannot change. An admin can regenerate the bracket at any time (portal or tablet, after a typed/explicit confirmation): a fresh bracket is built from the current competitors and every decided bout is discarded.
  • Winners bracket: the loser of every bout drops into the losers bracket. Losers bracket: odd rounds pair the survivors, even rounds bring in the losers of the next winners round; a loss here eliminates.
  • Grand final: winners' champion against losers' champion. Because the winners' champion has not lost yet, a win by the losers' champion forces a reset final; that bout is in the bracket from the start and is marked not needed when the winners' champion takes the first final.
  • Deciding a bout: the RO taps the winner (green); the other side turns red. In best-of bouts each tap records a heat for that side and the bout turns green/red once a side has enough heats. "Clear" (portal) or holding a side (tablet) removes the last heat or the bout's result as long as no later bout that depends on it is decided. Decisions sync like scores (the latest decision per bout wins; the portal validates every one).
  • Placements: the champion first, then by the bout in which a competitor lost its last life (later rounds place higher; equal rounds share a place).

Engine: generateBracket, resolveBracket, validateBoutResult in packages/scoring/src/bracket.ts, with tests.