NewTon DC Tournament Manager ↔ Chalker
The Tournament Manager (TM) and Chalker communicate via QR codes — no network infrastructure required. Two message types cover the full match lifecycle:
The payload schemas and integrity checking are designed to be transport-agnostic — the same format lends itself to network communication as well.
The QR workflow was added without breaking changes. The underlying tournament data structure is compatible with the one used since the earliest beta — no migration has ever been required in the lifetime of the software, and the addition of QR communication is no exception.
v field enables forward-compatible changes.| Field | Description | Example |
|---|---|---|
v | Schema version | 1 |
t | Message type | "a" (assign) or "r" (result) |
mid | Match ID (from TM) | "FS-1-3" |
tid | Tournament ID | "1708123456789" |
sid | Server/TM instance ID (12-char hex) | "f47ac10b58cc" |
ts | Unix timestamp (seconds) | 1708123456 |
crc | CRC-32 of all other fields | "a1b2c3d4" |
The sid (server ID) is a 12-character hex string generated once per TM installation and persisted in global config. It identifies which TM instance created the match. 12 hex chars = 48 bits of entropy (281 trillion possible values) — collision risk is negligible for local tournament use.
{
"v": 1,
"t": "a",
"mid": "FS-1-3",
"tid": "1708123456789",
"sid": "f47ac10b58cc",
"p1": "John Smith",
"p2": "Jane Doe",
"sc": 501,
"bo": 3,
"mr": 13,
"ln": 3,
"ref": "Frank",
"ts": 1708123456,
"crc": "a1b2c3d4"
}
Estimated size: ~180–220 bytes → QR version ~7–9, very easy to scan.
| Field | Type | Description |
|---|---|---|
p1 | string | Player 1 name |
p2 | string | Player 2 name |
sc | number | Starting score — from Global Config (101 / 201 / 301 / 501, default 501) |
bo | number | Best-of legs — from Global Config match configuration |
mr | number | Max rounds before tiebreak — from Global Config (default 13, range 7–20) |
ln | number | Lane number (1–20). Optional. Omitted if unassigned. |
ref | string | Referee name. Optional. Omitted if unassigned. |
The result payload carries raw visit scores only — no computed stats. The TM derives everything (averages, score ranges, high finishes, best/worst leg, etc.) from the raw scores. This keeps the payload minimal and makes the TM the single source of truth for statistics.
What the TM derives from raw scores:
fls + alternatingWhat the TM cannot derive (must be in payload):
w per leg) — when max rounds is reached without checkout, a separate 3-dart tiebreak round is played (highest score wins). This round is not recorded in the visit scores.cd) — how many darts used on the final visit (1, 2, or 3){
"v": 1,
"t": "r",
"mid": "FS-1-3",
"tid": "1708123456789",
"sid": "f47ac10b58cc",
"p1": "Klas Krantz",
"p2": "Hans Blomqvist",
"t1": "Nya Pikado C",
"t2": "Newton A",
"sc": 501,
"bo": 5,
"mr": 13,
"ln": 3,
"w": 2,
"fls": 2,
"legs": [
{ "w": 2, "s": "SSkoFlEcNxULExU7AA==|GVFCXzwKKTkhAQgMAA==", "cd": 0 },
{ "w": 2, "s": "LS0DLV8NHTYpPw==|KBpRPFUcNChJEA==", "cd": 3 },
{ "w": 1, "s": "GgtfHTkZDR48NwkGVQ==|ZBoWCxwaGlMsZRIA", "cd": 3 }
],
"ts": 1708123789,
"crc": "d4c3b2a1"
}
Estimated size: ~380–500 bytes (typical Bo3–Bo5) → QR version ~12–16, easy to scan.
| Field | Type | Description |
|---|---|---|
p1 | string | Player 1 name |
p2 | string | Player 2 name |
t1 | string | Team 1 name. Omitted if not set. |
t2 | string | Team 2 name. Omitted if not set. |
sc | number | Starting score (501, 301, etc.) |
bo | number | Best-of legs |
mr | number | Max rounds before tiebreak |
ln | number | Lane number. Omitted if unassigned. |
w | 1 or 2 | Match winner (player number) |
fls | 1 or 2 | First leg starter (alternates each leg) |
legs | array | Per-leg data |
legs[].w | 1 or 2 | Leg winner (essential for tiebreaks) |
legs[].s | string | Base64-encoded visit scores: P1_SCORES|P2_SCORES |
legs[].cd | 0–3 | Checkout darts used (0 = tiebreak, 1–3 = darts on final visit) |
Dropped fields: wl (winner’s legs) and ll (loser’s legs) are not in the payload — the TM derives them by counting legs[].w values.
Null field omission: Fields with no value (t1, t2, ln) are omitted entirely rather than sent as null. The TM treats missing fields as unset.
Each visit score (0–180) fits in a single unsigned byte. Per leg, both players’ visit scores are packed into byte arrays, base64-encoded, and joined with | (pipe is not in the base64 alphabet, so it’s a safe separator).
Encode: [73, 45, 40, 22, 81, ...] → Uint8Array → btoa() → "SSkoFlE..."
Decode: atob("SSkoFlE...") → Uint8Array → [73, 45, 40, 22, 81, ...]
Combined: "SSkoFlEcNxULExU7AA==|GVFCXzwKKTkhAQgMAA=="
← Player 1 scores → ← Player 2 scores →
This saves ~45% compared to JSON number arrays. A 13-round leg goes from ~90 chars ("v1":[73,45,...],"v2":[25,81,...]) to ~50 chars ("s":"...base64...|...base64...").
When max rounds (mr) is reached without a checkout, a separate tiebreak round is played: 3 darts each, highest score wins the leg. This deciding round is not recorded on the scoresheet. The legs[].w field is the only record of who won, and legs[].cd = 0 signals the leg was a tiebreak.
| Format | Legs × Rounds | Payload | QR Version |
|---|---|---|---|
| Bo3, typical | 2–3 × 8 | ~380 bytes | ~12 |
| Bo5, all 5 legs | 5 × 10 | ~520 bytes | ~16 |
| Bo7, all max rounds | 7 × 13 | ~680 bytes | ~20 |
| Bo13, all max rounds | 13 × 13 | ~1100 bytes | ~30 |
| QR v40 limit (EC-M) | — | 2331 bytes | 40 |
Even the absolute worst case (Bo13, all legs going to max rounds with long player/team names) stays well within QR capacity with ~50% headroom.
Detects data corruption from scanning errors, alongside QR’s built-in error correction.
A standalone utility module (js/newton-integrity.js / chalker/js/newton-integrity.js) — same file, shared between TM and Chalker:
const NewtonIntegrity = {
// CRC-32 lookup table (generated once)
_table: null,
// Compute CRC-32 of a string → 8-char hex
crc32(str) { ... },
// Sign a payload object: compute CRC of JSON-sorted fields, append as 'crc'
sign(payload) { ... },
// Verify a payload: extract 'crc', recompute, compare
verify(payload) { ... }
};
Key design decisions:
crc field is excluded before computing (sign adds it, verify strips it)verify() interface is algorithm-agnostic — a stronger algorithm could be swapped in without changing callers.completeMatch(matchId, winnerPlayerNumber, winnerLegs, loserLegs) handles everything structural — winner declaration, bracket advancement, and the history transaction. Called directly with data from the QR payload. The TM derives winnerLegs and loserLegs by counting legs[].w values.
Achievements are stored on player.stats, not on the match:
| Field | Type | Description |
|---|---|---|
oneEighties | counter | Visit scores of exactly 180 |
tons | counter | Visit scores 100–179 |
highOuts | array | Checkout scores ≥ 101 (stored as individual values) |
shortLegs | array | Dart counts for short legs (stored as individual dart counts) |
lollipops | counter | Visit scores of exactly 3 (cosmetic) |
All are fully derivable from the result QR payload:
| Achievement | Derivation |
|---|---|
| 180s | All visits where score === 180 |
| Tons | All visits where 100 ≤ score ≤ 179 |
| High outs | Last visit in the winner’s score array, if score ≥ 101 |
| Short legs | (winner_visits − 1) × 3 + cd darts |
| Lollipops | All visits where score === 3 |
The Chalker delivers raw facts — visit scores, leg winners, checkout dart counts. It does not compute, interpret, or summarise. The TM is solely responsible for deriving meaning from the raw data.
A dedicated module (js/newton-stats.js) handles all extraction. It is a pure function module — takes raw leg data in, returns structured stats out. No side effects. No coupling to QR, bracket, or player management.
const NewtonStats = {
extractMatchStats(legs, startingScore, firstLegStarter) { ... },
extractPlayerStats(legs, playerIndex, startingScore) { ... },
extractAchievements(legs, playerIndex, startingScore) { ... },
count180s(visits) { ... },
countTons(visits) { ... },
highOuts(legs, playerIndex, startingScore) { ... },
shortLegs(legs, playerIndex) { ... },
legAverages(legs, playerIndex) { ... },
matchAverage(legs, playerIndex) { ... },
};
The individual extractors are callable independently — the QR result flow calls what it needs without pulling in unrelated machinery. No extractor depends on another module. New stat types are new named functions; existing callers are unaffected.
When the result preview modal is open after a successful scan, the operator sees the winner, score, and extracted achievements. They choose how to record the result:
Option A — Score only
completeMatch() called with completionType: 'QR'player.statsachievements field is empty (null per player)Option B — Score + achievements
completeMatch() called as abovenewton-stats.js extracts achievements from the decoded payloadplayer.statsachievements field populated with exactly what was writtenThe operator decides. The completionType: 'QR' and achievements field together give the undo dialog full knowledge of what was recorded and how.
completeMatch() is blocked if the match is already completed. Achievements are additive on player.stats — applying them a second time corrupts the totals. The achievement application is gated by the same state check: if the match is already completed, neither completeMatch() nor the achievement application is called.
When the TM scans a result QR for match FS-1-3:
FS-1-3 in the tournamenttid matches current tournament — reject if from a different tournamentsid matches this TM instance — reject if from a different serverSimple and robust — no nonces or timestamps required.
After CRC verification passes, the TM performs arithmetic validation on the decoded scores. This catches encoding/decoding bugs and Chalker scoring bugs that CRC cannot detect — CRC only proves the data wasn’t corrupted in transit, not that it was correct in the first place.
| Check | Rule | Catches |
|---|---|---|
| Score range | Every visit score is 0–180 | Encoding errors, byte overflow |
| Visit count parity | Both players have equal visits, or first-thrower has exactly +1 | Data corruption, missing visits |
| Checkout sum | Normal leg (cd > 0): winner’s scores sum to sc | Scoring bugs, wrong winner |
| Tiebreak sum | Tiebreak leg (cd == 0): neither player’s scores sum to sc | Misclassified tiebreak |
| Winner consistency | legs[].w matches the player whose scores sum to sc | Wrong leg winner assignment |
| Leg count | Winner has exactly ceil(bo/2) legs won | Impossible match outcome |
Soft warning, not hard rejection: If validation fails, the TM accepts the data but flags it visually. Rejecting the QR would force manual entry — worse UX for what might be a harmless edge case. The derived stats will also look visibly wrong, which is self-correcting.
A 12-character hex string generated once when the TM is first used, stored in global config (localStorage):
if (!config.serverId) {
config.serverId = crypto.randomUUID().replace(/-/g, '').substring(0, 12);
saveGlobalConfig();
}
12 hex chars = 48 bits of entropy (281 trillion possible values) — collision risk is negligible for local tournament use.
Purpose:
mid, tid, sid in match state for inclusion in the result QRmid, tid, sid against current tournamentcompleteMatch() with winner + leg scoresIf QR scanning isn’t available, both sides fall back gracefully:
| Side | Need | Library | Notes |
|---|---|---|---|
| TM | Generate assignment QR | qrcode-generator (~8KB) | Generates QR as canvas/SVG |
| TM | Scan result QR | BarcodeDetector API (native) + jsQR fallback (~250KB) | jsQR used on Windows Enterprise where BarcodeDetector is unavailable |
| Chalker | Scan assignment QR | BarcodeDetector API (native) | Mobile/tablet Chrome always has it; no fallback needed |
| Chalker | Generate result QR | qrcode-generator (~8KB) | Displayed via Stats → Result QR |
All libraries are standalone JS — no npm or build process required. Loaded via <script> tag.