Status: Draft (2026-10-26), incorporating measured data. Rules and behaviors were read from the repository code as of 2026-10-25, and the recording edit numbers were measured directly from a public match video on 2026-10-13. Recording costs on mobile phones (battery and heat) and human scoring latency are unmeasured. Writing rule: Measured data is the baseline; unmeasured items are marked as "Unmeasured."
Table of Contents
- 1. Why Start with a Manual Scoreboard
- 2. Rule Engine: Turning Regulations into Code
- 3. Setup Screen: Configured Before the Match
- 4. History, Not State
- 5. Misclick Prevention and Remote Control
- 6. Score Text-to-Speech
- 7. Screen: Fullscreen, Scoreboard Size, and Keep-Awake
- 8. Recording and Stripping Breaks
- 9. How Far Does Doubles Go
- 10. Conclusion
- References
- Further Reading
1. Why Start with a Manual Scoreboard
The flagship feature of TT Lab is the camera scoreboard, but the most frequently used part of it is not the auto-judging, but the manually pressed scoreboard. The reason is simple. Auto-judging requires server GPUs, login, and time allocation, and frequently triggers "Confirmation Required" under poor conditions. A manual scoreboard runs anytime with just a single mobile phone.
Therefore, this scoreboard needed to be a complete product even without auto-judging, with auto-judging designed to sit on top of it as a "suggestion" (Part 3). This post covers the underlying rule engine and manual scoring features.
2. Rule Engine: Turning Regulations into Code
Reference Documents
The rules follow Chapter 2, "The Laws of Table Tennis," of the ITTF Statutes (2026, effective 2026-01-01) [1]. Section numbers were verified against the original text, and summarized in Korean below.
| Section | Content (Summary) | Code |
|---|---|---|
| 2.11.1 | The side that scores 11 points first wins a game. If both reach 10 points, the side that subsequently leads by 2 points wins. | award() "11 or more points and a 2-point difference" |
| 2.12.1 | A match is won by the side that wins a majority of odd-numbered games. | games_to_win 2, 3, 4 = Best of 3, 5, 7 games |
| 2.13.3 | The service alternates every 2 points. Once 10:10 is reached, the sequence remains the same but each player serves for only 1 point in turn. | turnIndex() |
| 2.13.6 | The player who served first in a game shall receive first in the next game. | servingTeam() |
| 2.13.7 | Players swap ends at the end of each game. In the final game, ends are swapped when one side first scores 5 points. | leftPlayer(), award() Decider |
| 2.13.4–2.13.6 | First server and receiver in doubles, rotation, changing order of receiving in the final game | cycleFor(), dswap |
| 2.14.3 | If errors are made in service/receive order or ends, play shall be interrupted as soon as the error is discovered and resume with the scores correctly reflecting the score, and all points scored before the discovery shall stand. | "Direct Service Right Designation" (Section 4) |
One easily confused point: Swapping ends between games is 2.13.7 , while the immediately preceding 2.13.6 refers to service and receive order. Because they are numbered consecutively, they are easy to mix up when cited.
Some items have been omitted. The Expedite System (2.15, where service alternates every point and the receiver scores if they make 13 good returns after 10 minutes) was not included in the scoreboard. It is only in the site's rule quiz. Timeouts and intervals between games were not included as they could not be cross-referenced with the original text.
Python Original, JavaScript Copy for the UI
The original rule implementation is from the auto-judging engine ttscore/rules.py . Because mobile screens must count scores without a server, they use backend/static/tt/play/lib/scorekeeper.mjs , a JavaScript port of the same rules. Both implementations read the same test case file (backend/static/tt/play/data/rules-cases.json). The Python tests run as the engine, and the Node tests run the same cases in JavaScript, meaning that fixing only one side will cause the other to fail. Numbers appearing in the site's rule guide (11 points, 2-point difference, 5-point alternation, etc.) are also centralized so that tests can cross-check them against the engine.
Service Turn: A Single-Line Expression
The service turn is determined solely by the "sum of scores so far."
// backend/static/tt/play/lib/scorekeeper.mjs turnIndex() 에서
const total = points.A + points.B;
const deuceAt = 2 * (points_to_win - 1); // 11점 게임이면 20 = 10:10
return total < deuceAt ? Math.floor(total / 2) // 2점마다 한 차례
: deuceAt / 2 + (total - deuceAt); // 10:10 부터 1점마다
If the turn number is even, the first server of that game serves; if odd, the opponent serves. The first server of that game is the initially designated side for odd-numbered games, and the opponent for even-numbered games (2.13.6). Crucially, the scoreboard does not store a separate "service state." Service is a value calculated from the score and the first server, meaning that whenever the score changes, the service is automatically correct. This property enables the "history" design described in Section 4.
Side Swapping: Counting Only the Frequency
Determining who is standing on the left side of the screen is also calculated.
// leftPlayer() 에서 줄임
const swaps = (game_no - 1) + (decider_swapped ? 1 : 0);
return swaps % 2 === 0 ? start : other(start);
Swaps occur once per game, plus once more in the final game the moment 5 points are reached. The final game is identified as game_no === 2 × games_to_win − 1 , and 5 points is calculated as floor((points_to_win + 1) / 2) − 1 . For an 11-point game, this is 5. When a 5-point swap occurs in the final game, award() returns endChange , and the UI displays "Final game 5 points — Time to swap ends!"
When a Match Ends
When the number of won games reaches games_to_win , the winner is determined, and subsequent scores are ignored. The scores of finished games (e.g., 11:9, 12:10) remain in the finished list and carry over into the records (Part 6).
3. Setup Screen: Configured Before the Match
/tt/play/cam Settings chosen under "Match Setup" inbackend/static/tt/play/cam.js).
- Match length: Best of 3, 5, or 7 games (default is 5 games).
- Nickname: Up to 12 characters. A prompt saying "Nickname instead of real name" is shown next to the input field (Part 6).
- First server, Left-side player, Which side am I on.
- Misclick prevention(Section 5).
- "Start with camera on" or "Scoreboard only without camera."
Turning the camera on does not immediately start the match, but instead enters Warm-up state. Pressing the scoring area during warm-up does not add points, but shakes the "Start Match" button to alert the user. Adjustments allowed during warm-up are strictly limited to specifying the service right and swapping left/right. Why this state is necessary is tied to auto-judging and will be covered in detail in Part 3. From the perspective of the manual scoreboard alone, it serves as a "gate to prevent counting warm-up rallies as points."
4. History, Not State
Problem: A Mistake Realized Much Later
The most common mistake on a manual scoreboard is realizing a misclicked score long after it happened. The phone is mounted on a tripod a few meters away from the table, and players do not look at the scoreboard after every rally. Three points later, someone says, "Wait, that point was actually mine."
If the scoreboard only stored the current state(7:5, B serving, A on the left), fixing this mistake becomes difficult. If you change 7:5 to 6:6, what happens to the service turn? What if a game ended in between? Manually editing the state risks violating the rules.
Solution: Store History and Replay It
The match object in the camera scoreboard (thebackend/static/tt/play/lib/camscore.mjs of CamMatch) stores history, not state. An entry in the history is either a scoring event like "A scores" or "B scores," or an adjustment like "Switch service right." The score displayed on screen is always the result of replaying the history from the beginning on a blank scoreboard.
// camscore.mjs 에서 줄임
remove(i) { // i 번째 칸을 지우고 뒤를 다시 돌린다
const [e] = this.log.splice(i, 1);
this._replay(); // 새 Scoreboard 에 남은 칸을 처음부터 넣는다
return e;
}
This approach allows you to delete any entry from the history list (the most recent 60 entries) in the "Score Adjustment" sheet. Deleting an entry automatically recalculates subsequent service turns, side swaps, and game ends correctly according to the rules . There is no need to worry about the cost of replaying: a match has at most a few hundred entries, and adding an entry simply involves adding a few integers. This principle remains the same for human-confirmed designs in auto-judging (Part 3).
Adjustments Are Also Entries in History
Actions available in the score adjustment sheet include incrementing/decrementing scores and games, directly assigning service rights, "Restart this game (0:0)," swapping players' left and right, and deleting history entries. Among these, two features have interesting designs.
- Directly assigning service rights is "flipping the first server." As seen in Section 2, service is a value calculated from the score and the first server, so there is no separate slot to overwrite it. Therefore, when you correct it to "B is currently serving," the code inserts an adjustment entry that flips the first server for that game. Thereafter, it continues to rotate correctly every 2 points (or every 1 point starting from 10:10). Previous scores are left as they are. This behaves the same as rule 2.14.3, which states that even if the service order is wrong, all preceding points shall stand.
- Manually incremented games are excluded from records. If the game count is manually incremented by one, the scoreboard does not know what the score of that game was. The code inserts an entry marked with
adj(winner 11:0) to keep the match flow consistent, but when passing the match to records, that game is omitted and the user is asked to fill it in manually. This prevents fabricated 11:0 scores from entering player ratings.
The undo button is equivalent to deleting the last entry in the history. "Restart" clears the history after confirmation and returns to warm-up.
Storage: On the Phone with Every Change
History is saved to browser localStorage (ttplay:cam) on every change. If the screen is accidentally closed or a phone call comes in, the match remains intact. Settings are stored separately (ttplay:cam-prefs). Nothing is sent to the server.
5. Misclick Prevention and Remote Control
Scoring Area Takes Up Half the Screen
It is difficult to accurately tap a phone set up at a distance. Therefore, the scoring area is not a small button, but half of the entire screen. Tapping anywhere on that side awards a point to that player. However, this also increases the chance of accidental brushes while walking by, so a prevention mode can be selected in the setup screen.
| Mode | Behavior | Constant |
|---|---|---|
| None | Registered on a single tap | — |
| Double | Must tap the same side twice within 1.5 seconds. The first tap only shows a "Tap again" indicator | DOUBLE_MS = 1500 |
| Long press | Must press and hold for 0.5 seconds or longer to register | HOLD_MS = 500 |
Remote Controls and Selfie Sticks
Key inputs are also supported for phones out of reach.
| Action | Default Keys |
|---|---|
| Left player scores | ←, PageUp, Volume Up |
| Right player scores | →, PageDown, Volume Down |
| Undo | Backspace, u, z |
| Start match | Enter, Play/Pause, s |
Presentation remotes usually send ←·→ or PageUp·PageDown, while Bluetooth selfie remotes typically send Volume Up or Enter (see code comments). The issue is that Android and iOS browsers often don't pass volume keys to the page and just change the volume instead. To solve this, we added a "Key Learning" feature: pressing a remote button once maps the incoming key to that action. If one key is mapped to two actions, the later one is discarded.
The "Start Match" key is only active during the warm-up . This is to prevent accidental presses during the match from resetting the game back to the start. To restart during a match, you must confirm on screen.
6. Text-to-Speech Score Announcer
Whenever a point is scored, the score is read aloud usingbackend/static/tt/play/lib/cam-speech.mjs).
- the browser's Speech Synthesis API
speechSynthesis. Nothing is sent to the server. - It supports Korean, English, and Japanese, with two speed settings: normal (1.0) and fast (1.35).
- Like a real referee, it reads the server's score first . If one side leads by a point after a deuce, it appends "Advantage"; if the serve changes, it appends "Service Change".
A small trap remains in the code. The camera scoreboard loads heavy features late to keep the initial screen lightweight, but the speech function was left in the initial screen code instead of being loaded late. This is because iOS Safari only allows sound to play if spoken at the exact moment a user interacts , and waiting for a module to load makes it too late.
7. Screen: Fullscreen, Scoreboard Size, and Wake Lock
- Fullscreen(
lib/fullview.mjs): If the Fullscreen API is available, it uses it to hide even the address bar. Since iPhone Safari lacks a fullscreen request function for general elements (only supporting it on video elements), it falls back to a fixed-position layout that fills the screen and displays a one-time prompt to "Add to Home Screen". - Scoreboard Size and Position(
lib/boardview.mjs): Three modes (normal, large, hidden), scales of 0.8x, 1x, and 1.3x, and five positions (top-left, top-center, top-right, bottom-left, bottom-right). Since the screen and recording read the same settings, the exact scoreboard seen on screen is baked into the recording. - Landscape Lock and Wake Lock: Attempts to lock orientation to landscape and uses Wake Lock to prevent the screen from turning off during a match.
8. Recording and Removing Downtime
Record on Phone, Cut on Phone
Recording overlays the scoreboard onto the camera feed using a canvas and saves it to the phone(cam-rec.js, lib/cam-record.mjs). Nothing is uploaded to the server. The encoder uses WebCodecs to directly generate H.264 when available, falling back to MediaRecorder otherwise.
If a match recording is left as-is, more than half of it consists of ball retrieval, towel breaks, and chatting. Therefore, when you stop recording, an edited version containing only ralliesis generated directly on the phone (enabled by default). The challenge is figuring out "where the rallies were".
Three Markers Recorded During Recording
lib/breakedit.mjs of MarkLog logs three types of data onto the recording timeline.
- Manual Scoring Timestamp. The exact time a person pressed the score button. Automated chip adjudication data is excluded here (overlaps with #2).
- Auto-Adjudication Arrival Time and Rally Length. Available only when auto-scoring is enabled.
- Screen Motion. Measured 8 times per second on a reduced 128×36 grayscale frame, calculating the ratio of changed pixels around the table perimeter (10–90% horizontally, 33–72% vertically).
Rules for Calculating Retained Segments
keepSpans() rules are as follows:
| Marker | Retained Segment | Rationale |
|---|---|---|
| Manual Score (Tap time t) | [t − 10s, t] | When keeping score manually, only the tap time is known, and the rally ended before that. 10 seconds is based on the measurements below. |
| Auto-Adjudication (Arrival time a, rally length d) | [a − d − 1.0 − 1.2, a + 1.5] | Since judgments arrive late due to network/processing lag, we retain extra time back by that delay (estimated at 1.0s). 1.2s before and 1.5s after account for the serve stance and scoring reaction (same values as uploaded edits). |
| Screen Motion | If the start of the manual score window falls in the middle of motion, extend | up to 30s back to where motion began. Preserves rallies longer than 10s (approx. 9%). Never shrinks — serve preparation is quiet, so cutting based solely on motion would clip the serve. |
| Common | 1s padding before and after; segments within 0.5s of each other are merged. | — |
The key point is that motion alone does not create new segments. Times without score or adjudication markers—namely warm-ups and breaks—are never kept, no matter how much motion occurs, because warm-up rallies look identical to match rallies in motion data.
Edits are sometimes skipped entirely. If there are no markers at all (neither manual scores nor auto-adjudications), or if the resulting video shrinks to under 5 seconds or less than 10% of the original, no edit is created. This prevents cases where adding a 2.5-second title card at the beginning would actually make it longer.
Measurement: Why 10 Seconds?
We could not measure how many seconds after a rally ends a human presses the button (d) (unmeasured). Therefore, we simulated it by splitting d into 1, 2, and 3 seconds. Across 275 rallies from 12 public match videos (from serve impact to end label), assuming the button was pressed "d seconds after the end," we measured the proportion of rallies where the period from 1.05s before the serve to the end fell entirely within a single segment, as well as the ratio of the edited length to the match duration (2026-10-13, docs/tt/PERFORMANCE.md 2.11).
| N Seconds Before Tap | d=1s: Intact · Edit Length | d=2s: Intact · Edit Length | d=3s: Intact · Edit Length |
|---|---|---|---|
| 6s | 78.5% · 42.4% | 65.8% · 42.4% | 44.0% · 42.5% |
| 8s | 90.2% · 53.7% | 82.9% · 53.9% | 78.9% · 54.1% |
| 10s (Selected) | 93.1% · 63.3% | 91.3% · 63.5% | 90.5% · 63.7% |
| 12s | 95.3% · 70.7% | 94.2% · 70.9% | 93.1% · 71.2% |
| 15s | 96.0% · 78.2% | 95.6% · 78.4% | 95.3% · 78.7% |
10 seconds is the shortest value exceeding 90% accuracy regardless of whether d is 1, 2, or 3 secondsptor. The lengths of those same rallies had a median of 2.2s, 90th percentile of 5.9s, 95th percentile of 9.6s, and 99th percentile of 29.3s, while the gap between rallies had a median of 10.3s. In short, a 10-second window captures most rallies while effectively cutting out the downtime between them. A few long rallies are extended via screen motion.
Running this end-to-end on a single video (140s, 7 true rallies) reduced a 139-second recording to about 95 seconds, retaining 6 out of 7 rallies starting from the serve stance. The one missed rally was a long 24-second segment where the player stepped out of frame, interrupting the motion data. Since this is based on a single video, the magnitude of the effect cannot be generalized.
Cost
Relative costs measured via Chrome on the same laptop CPU (2026-10-13; read only as ratios since other tasks were running concurrently).
| Metric | WebCodecs (Hardware H.264) | MediaRecorder |
|---|---|---|
| Recording CPU overhead (vs. camera-only) | Approx. 10 CPU-s/min | Approx. 15 CPU-s/min |
| Post-recording edit (49s edit) | 0.4 CPU-s, 0.56s | 22–26 CPU-s, 49s |
| Storage per minute | 16.7MB | Approx. 18MB |
The reason WebCodecs editing is practically free is that it avoids re-encoding . By inserting a keyframe every second during recording, cut points are available within every second, and editing simply requires remuxing the frames of the retained segments into a new file. MediaRecorder, on the other hand, requires playing back the video to capture it, taking time proportional to the length of the edit.
Mobile performance was not measured. Battery drain, thermal throttling, and memory usage for recordings exceeding an hour are unmeasured, and there is no guarantee that laptop and mobile hardware encoders scale identically.
9. How Far Do Doubles Go?
Doubles rules exist in the JavaScript rule engine. Selecting the initial server and receiver, rotating serves among all four players, determining the first receiver for subsequent games (2.13.5), and switching receive order when one side reaches 5 points in the final game (2.13.6) are all implemented and can be used in the camera-free "Serve & Score Helper" (/tt/play/scorekeeper).
However, everything else is singles-only.
| Feature | Doubles |
|---|---|
| JS Rule Engine, Serve & Score Helper | Supported |
| Camera Scoreboard | Not supported (Singles locked) |
Auto-Adjudication Engine (rules.py) |
Not supported (Singles only) |
| Live Sharing | Not supported (Doubles matches are not transmitted) |
| Match History & Ratings | Not supported |
The auto-adjudication system has an additional challenge. In doubles, two partners must take turns hitting, but as seen in Part 3, our referee judges solely by bounce sequence without tracking "who hit the ball." Catching out-of-order shots in doubles requires identifying individual players.
10. Conclusion
- Scoring rules map 1:1 to the rulebook clauses, and the Python original and JavaScript port share the same test case files.
- Serves and positions are not stored; instead, they are computed on the fly from the score and initial server. This allows matches to be saved as a history log, meaning even if you delete a single entry from long ago, all subsequent states correctly recalculate according to the rules.
- Removing downtime from recordings filters out warm-ups and breaks using a single rule: "only score and adjudication markers create segments." The 10-second window was chosen via 275 rally simulations, while human reaction delay and mobile performance remain unmeasured.
A TT tidbit: The scoreboard doesn't memorize "the current score is X to Y." It remembers "who won each point and in what order." That's why deleting a mistake from three points ago automatically fixes the serves and positions.
References
- International Table Tennis Federation. ITTF Statutes 2026 (effective 1 January 2026), Chapter 2: The Laws of Table Tennis, §§2.10–2.15. https://db.ittf.com/document
- Our Measurements: Repository
docs/tt/PERFORMANCE.md2.11. Codebackend/static/tt/play/lib/scorekeeper.mjs·camscore.mjs·breakedit.mjs·cam-speech.mjs·fullview.mjs.
Further Reading
- This Series, Part 3: Auto-Adjudication: Why the warm-up state is crucial for auto-adjudication, and how human input serves as ground truth for the chip.
- This Series, Part 6: Match History and Privacy: How completed match scores translate into records and ratings.
- Previous Series, Dissecting the TT Score Model Part 5: Events and Referees: How the auto-adjudication engine applies the same rules.