状態:ドラフト(2026-10-26)、実測値を反映。 ルールと動作は2026-10-25時点のリポジトリコードから読み取り、録画編集の数値は2026-10-13に公開された試合映像で直接測定した値です。スマートフォンでの録画コスト(バッテリー・発熱)と、人間がスコアをタップする際の遅延は未測定です。 作成ルール:実測データを基本とし、測定できなかったものは「未測定」と表記します。
目次
- 1. なぜハンドスコアボードからなのか
- 2. ルールエンジン:規則集の条文をコードに
- 3. 準備画面:試合前に決めること
- 4. 状態ではなく履歴
- 5. 誤押し防止とリモコン
- 6. スコア読み上げ
- 7. 画面:全画面表示、スコアボードのサイズ、スリープ防止
- 8. 録画と休憩時間の除外
- 9. ダブルスはどこまで対応しているか
- 10. まとめ
- 参考文献
- あわせて読みたい
1. なぜハンドスコアボードからなのか
TT Labの代表機能はカメラ付きスコアボードですが、その中で最も多く使われているのは自動判定ではなく 人間がタップするスコアボードです。理由は単純です。自動判定にはサーバーのGPUとログイン、そして処理時間が必要であり、条件が悪いと「確認必要」の表示が頻繁に出ます。ハンドスコアボードはスマートフォン1台でいつでも動作します。
そのため、このスコアボードは自動判定がなくても完結した製品である必要があり、自動判定はその上に「提案」として乗るように設計しました(第3部)。この記事では、その土台となるルールエンジンとハンドスコア機能について取り上げます。
2. ルールエンジン:規則集の条文をコードに
根拠文書
ルールはITTF規則集(Statutes 2026、2026-01-01施行)の第2章「The Laws of Table Tennis」に準拠しています[1]。条文番号は原文を開いて確認し、以下では日本語に要約して記載します。
| 条文 | 内容(要約) | コード |
|---|---|---|
| 2.11.1 | 先に11点を獲得した側がゲームに勝つ。両者が10点になった場合は、その後先に2点リードした側が勝つ | award() の「11点以上かつ2点差」 |
| 2.12.1 | 試合は奇数ゲームのうち過半数を勝った側が勝利する | games_to_win 2・3・4 = 3・5・7ゲーム制 |
| 2.13.3 | 2点ごとにサーブが交代する。10:10になった場合は順番は同じままで、各プレイヤーが1点ずつ交互にサーブする | turnIndex() |
| 2.13.6 | 1つのゲームで最初にサーブした側は、次のゲームで最初にレシーブする | servingTeam() |
| 2.13.7 | ゲームが終了したらコートを交替する。最終ゲームでは、どちらかが先に5点を獲得したときにコートを交替する | leftPlayer(), award() の decider |
| 2.13.4–2.13.6 | ダブルスの最初のサーバー・レシーバー、ローテーション、最終ゲームでのレシーブ順の入れ替え | cycleFor(), dswap |
| 2.14.3 | サーブ・レシーブ・コートの順番を間違えた場合、発覚次第直ちに修正し、それまでに得たポイントはすべて有効とする | 「サーブ権の直接指定」(第4節) |
間違いやすいポイントを1つ。 ゲーム間のコートチェンジは2.13.7 であり、直前の2.13.6はサーブ・レシーブの順番です。番号が近いため混同して引用しやすくなっています。
掲載していない項目もあります。促進ルール(2.15、10分経過後に1点ずつ交代でサーブし、レシーバーが13回正しく返球すればレシーバーの得点)はスコアボードに組み込んでいません。サイトのルールクイズにのみ存在します。タイムアウトとゲーム間の休憩時間は、規定の原文と照合できなかったため除外しています。
オリジナルはPython、画面はJavaScript版
ルールのオリジナルは自動判定エンジンの ttscore/rules.py です。スマートフォンの画面ではサーバーなしでスコアを計算する必要があるため、同じルールをJavaScriptに移植した backend/static/tt/play/lib/scorekeeper.mjs を使用しています。両方の実装は同じテストケースファイル(backend/static/tt/play/data/rules-cases.json)を読み込みます。Pythonのテストはエンジンとして、NodeのテストはJavaScriptとして同じケースを実行するため、一方を修正するともう一方が失敗します。サイトのルール解説記事に登場する数値(11点、2点差、5点交代など)も1箇所に集約され、テストがエンジンと照合します。
サーブの順番:1行の式
サーブの順番は「これまでに獲得した得点の合計」のみで決まります。
// 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점마다
順番の番号が偶数ならそのゲームの最初のサーバー、奇数なら相手がサーブします。そのゲームの最初のサーバーは、奇数ゲームであれば最初に決めた側、偶数ゲームであれば相手になります(2.13.6)。スコアボードが「サーブの状態」を個別に保存していない点が重要です。サーブは スコアと最初のサーバーから計算される値であるため、スコアが変わればサーブは自動的に正しくなります。この性質が第4節の「履歴」設計を可能にしています。
コートチェンジ:数えるのは回数のみ
画面の左側にどちらが立っているかも計算によって求められます。
// leftPlayer() 에서 줄임
const swaps = (game_no - 1) + (decider_swapped ? 1 : 0);
return swaps % 2 === 0 ? start : other(start);
ゲームごとに1回、そして最終ゲームで5点に達した瞬間にさらに1回入れ替えます。最終ゲームは game_no === 2 × games_to_win − 1 であり、5点は floor((points_to_win + 1) / 2) − 1 として計算します。11点ゲームであれば5になります。最終ゲームで5点の交代が発生すると、 award() が endChange を返し、画面には「最終ゲーム 5点 — コートチェンジです!」が表示されます。
試合が終了すると
勝ったゲーム数が games_to_win に達すると winner が確定し、それ以降の得点は無視されます。終了したゲームのスコア(例:11:9、12:10)は finished のリストに残され、記録へと引き継がれます(第6部)。
3. 準備画面:試合前に決めること
/tt/play/cam の「試合準備」で選択する項目は以下の通りです(backend/static/tt/play/cam.js).
- 試合の長さ:3・5・7ゲーム制(デフォルトは5ゲーム制)。
- ニックネーム:12文字まで。入力欄には「本名の代わりにニックネーム」という案内が表示されます(第6部)。
- 最初にサーブする側, 画面左側の選手, 自分はどちらか.
- 誤押し防止(第5節)。
- 「カメラを起動して開始」または「カメラなしでスコアボードのみ」。
カメラを起動するとすぐに試合が始まるわけではなく、 ウォーミングアップ状態に入ります。ウォーミングアップ中に得点エリアをタップしてもスコアは加算されず、「試合開始」ボタンが揺れて知らせてくれます。ウォーミングアップ中に受け付ける調整は、サーブ権の指定と左右の入れ替えの2つのみです。なぜこのような状態が必要なのかは自動判定と密接に関連しているため、第3部で詳しく解説します。ハンドスコアボードの観点から見れば、「ウォームアップのラリーを得点としてカウントしないための仕組み」です。
4. 状態ではなく履歴
問題:ずっと後になって気づくミス
ハンドスコアボードで最もよくあるミスは、 間違えて押したスコアをずっと後になってから気づくことです。スマートフォンは卓球台から数メートル離れた三脚の上にあり、選手は毎ラリーごとにスコアボードを見るわけではありません。3点ほど過ぎてから「さっきの得点は自分の方だった」ということが判明します。
スコアボードが 現在の状態(7:5、Bのサーブ、Aが左側)のみを保存している場合、このミスを修正するのは困難です。7:5を6:6に変更した場合、サーブの順番はどうなるでしょうか?その間にゲームが終了していた場合はどうでしょうか?状態を手動で修正しようとした瞬間に、ルールとの矛盾が生じる余地が生まれます。
解決策:履歴を保存して再生する
カメラ付きスコアボードの試合オブジェクト(backend/static/tt/play/lib/camscore.mjs の CamMatch)は、状態ではなく 履歴を保存します。履歴の1コマは、「Aの得点」、「Bの得点」といった得点か、「サーブ権の変更」といった調整です。画面に表示されるスコアは常に、 空のスコアボードから履歴を最初からもう一度再生した結果です。
// camscore.mjs 에서 줄임
remove(i) { // i 번째 칸을 지우고 뒤를 다시 돌린다
const [e] = this.log.splice(i, 1);
this._replay(); // 새 Scoreboard 에 남은 칸을 처음부터 넣는다
return e;
}
このようにすれば、「スコア調整」シートの履歴リスト(直近60コマ)から任意のコマを削除することができ、削除するとその後のサーブの順番・コートチェンジ・ゲーム終了が ルール通りに自動的に 再計算されます。再計算のコストを心配する必要はありません。1つの試合は多くても数百コマであり、1コマを追加する処理は整数をいくつか足し合わせるだけです。この原則は、自動判定における人間による確認の設計でも同じです(第3部)。
修正も履歴の1コマである
スコア調整シートで行えるのは、スコア・ゲームの増減、サーブ権の直接指定、「このゲームをやり直す(0:0)」、選手の左右入れ替え、履歴コマの削除です。このうち2つは設計がユニークです。
- サーブ権の直接指定は「最初のサーバーの反転」である。 第2節で見たように、サーブはスコアと最初のサーバーから計算される値であるため、個別に上書きして保存する場所はありません。そのため「現在のサーブはBだ」と修正した場合、コードはこのゲームの最初のサーバーを反転させる調整コマを追加します。その後も2点ごと(10:10以降は1点ごと)にルール通りに動作します。これまでのスコアはそのまま維持されます。サーブの順番を間違えてもそれ以前のポイントはすべて有効とするという規定2.14.3と同じ挙動になります。
- 手動で増やしたゲームは記録から除外する。 ゲーム数を手動で1つ増やした場合、そのゲームが何対何だったのかをスコアボードは把握していません。コードは終了したゲームのリストに
adjマークのついたコマ(勝った側が11:0)を追加して試合の流れを整合させつつ、試合を記録として引き渡す際にはそのゲームを除外し、人間が手動で記入するように促します。作られた11:0がレーティングに反映されてはならないためです。
「戻す」ボタンは、履歴の最後のコマを削除することと同じです。「やり直す」は確認を取った上で履歴をクリアし、ウォーミングアップに戻ります。
保存:変更のたびにスマートフォンに
履歴は変更されるたびにブラウザのlocalStorage(ttplay:cam)に保存されます。画面を誤って閉じたり、電話がかかってきて中断しても、試合の状態がそのまま残ります。設定は別個に(ttplay:cam-prefs)保存されます。サーバーには一切送信されません。
5. 誤押し防止とリモコン
得点エリアは画面の半分全体
離れた場所に設置したスマートフォンを正確にタップするのは困難です。そのため、得点エリアは小さなボタンではなく 画面の半分全体にしています。どこを押してもその選手の得点になります。その代わり、通りすがりに誤って触れてしまうリスクも高まるため、準備画面で防止方式を選択できるようにしています。
| 方式 | 動作 | 定数 |
|---|---|---|
| なし | 1回タップすると加算される | — |
| ダブルタップ | 同じ側を1.5秒以内に2回タップする必要がある。1回目は「もう一度」の表示のみ | DOUBLE_MS = 1500 |
| 長押し | 0.5秒以上押し続ける必要がある | HOLD_MS = 500 |
リモコンと自撮り棒
手が届かないスマートフォン向けに、キー入力も受け付けます。
| 動作 | デフォルトキー |
|---|---|
| 左側選手の得点 | ←, PageUp, 音量アップ |
| 右側選手の得点 | →, PageDown, 音量ダウン |
| 元に戻す | Backspace, u, z |
| 試合開始 | Enter, 再生/一時停止, s |
プレゼン用リモコンは通常←・→やPageUp・PageDownを、ブルートゥースの自撮りリモコンは通常ボリュームアップやEnterを送信します(コードのコメント)。問題は、AndroidやiOSのブラウザがボリュームキーをページに渡さず、音量を変えるだけであることが多い点です。そこで「キー学習」を設け、リモコンのボタンを1回押すと実際に飛んでくるキーをその動作に割り当てるようにしました。1つのキーが2つの動作に割り当てられた場合、後のものは破棄されます。
「試合開始」キーは ウォーミングアップ中のみ 有効になります。試合中にリモコンを誤って押して試合が最初に戻ってしまわないようにするためです。試合中の再開は画面で確認を取る必要があります。
6. スコアの読み上げ
得点するたびにスコアを音声で読み上げます(backend/static/tt/play/lib/cam-speech.mjs).
- ブラウザの音声合成(
speechSynthesis)を使用します。サーバーに送信されるものはありません。 - 韓国語・英語・日本語をサポートしており、速度は通常(1.0)と早口(1.35)の2種類です。
- 実際の審判のように サーバー側のスコアを先 に読み上げます。デュースの後に1点リードした場合は「アドバンテージ」を、サーブが交代する場合は「チェンジ・サーブ」を付け加えます。
小さな落とし穴がコードに残っています。カメラ付きスコアボードは最初の画面を軽くするために重い機能を使う時に読み込まれますが、音声合成の関数だけは遅延させず最初の画面のコードに残しました。iOSのSafariは ユーザーがタップしたその瞬間に 1度発話させないと以降音が出ず、モジュールを待ってから発話させるとすでに遅いためです。
7. 画面:フルスクリーン、スコアボードのサイズ、スリープ防止
- フルスクリーン(
lib/fullview.mjs):Fullscreen APIが利用可能な場合はそれを用いてアドレスバーまで隠します。iPhoneのSafariには一般要素に対するフルスクリーン要求関数がないため(video要素のみ)、その時は画面を埋め尽くす固定配置で代用し、「ホーム画面に追加」を一度案内します。 - スコアボードのサイズと位置(
lib/boardview.mjs):通常・大・非表示の3モード、サイズ0.8・1・1.3倍、位置5か所(上左・中央・右、下左・右)。画面と録画が 同じ設定を読み込むため、録画ファイルにも画面で見えている通りのスコアボードが焼き付けられます。 - 横向き固定と画面スリープ防止:横向き固定を試み、Wake Lockによって試合中の画面が暗くならないようにします。
8. 録画と休憩時間の除外
スマホ内で録画し、スマホ内で切り取る
録画はカメラ映像の上にスコアボードをcanvasで合成して 携帯電話に保存します(cam-rec.js, lib/cam-record.mjs)。サーバーにはアップロードしません。エンコーダーはWebCodecsが使える場合はそれにより直接H.264を生成し、使えない場合はMediaRecorderを使用します。
試合の録画をそのままにしておくと、半分以上がボール拾い、タオル拭き、会話になります。そのため録画を停止する際に ラリーのみを残した編集版をスマホ内で作成します(デフォルトでオン)。問題は「ラリーがどこにあったのか」をどう知るかです。
録画中に記録しておく3つのマーク
lib/breakedit.mjs の MarkLog が、録画のタイムライン上に3つの要素を記録します。
- 手動得点の時刻。 人間が得点ボタンを押した時刻です。自動判定チップに応答したものは含めません(2番と重複)。
- 自動判定の到着時刻とラリーの長さ。 自動スコアが有効な場合にのみ存在します。
- 画面の動き。 毎秒8回、128×36のグレースケールに縮小した画面で、卓球台の周囲の帯(横10–90%、縦33–72%)の変化したピクセル比率を測定します。
残す区間の計算ルール
keepSpans() のルールは次のとおりです。
| マーク | 残す区間 | 根拠 |
|---|---|---|
| 手動得点(押下時刻t) | [t − 10秒, t] | 手動で数える時に分かるのは押した時刻の1つだけであり、ラリーはその 前 に終わっていた。10秒は下記の実測に基づく |
| 自動判定(到着時刻a、ラリーの長さd) | [a − d − 1.0 − 1.2, a + 1.5] | 判定はネットや処理のために遅れて届くため、その遅延(1.0秒、推定)分だけさらに前を残す。前1.2秒・後1.5秒はサーブの姿勢と得点のリアクション(アップロードされた編集版と同じ値) |
| 画面の動き | 手動得点ウィンドウの先頭が動きの中央にあれば、動きが始まったところまで最大30秒 延長するのみ | 10秒よりも長いラリー(約9%)を救う。短くはしない — サーブの準備は静かなため、動きだけを頼りに切り取るとサーブが途切れてしまう |
| 共通 | 前後1秒の余裕、0.5秒以内に接近した区間は結合 | — |
核心は 動きが区間を新しく作り出さないという点です。得点や判定のマークがない時間、すなわちウォーミングアップや休憩時間は、どれだけ動きが多くても残りません。ウォーミングアップのラリーは動きの面では試合と全く同じであるためです。
編集版を作成しない場合もあります。マークが一つもない場合(手動スコアも自動判定もなかった)、または5秒未満や元の10%未満にしか短縮されない場合は作成しません。先頭に2.5秒のタイトルカードを付加するため、かえって長くなってしまう可能性があるためです。
実測:なぜ10秒なのか
人間がラリー終了後何秒後に押すか(d)は測定できませんでした(未実測)。そのため、dを1・2・3秒に分けてシミュレーションしました。公開された試合映像12本の275ラリー(サーブのインパクトから終了ラベルまで)に「終了のd秒後に押した」を適用し、サーブの1.05秒前から終了までが1つの区間に完全に収まった割合と、編集版の長さが試合区間に占める割合を測定しました(2026-10-13、 docs/tt/PERFORMANCE.md 2.11).
| 押下前のN秒 | d=1秒:完全・編集長 | d=2秒:完全・編集長 | d=3秒:完全・編集長 |
|---|---|---|---|
| 6秒 | 78.5% · 42.4% | 65.8% · 42.4% | 44.0% · 42.5% |
| 8秒 | 90.2% · 53.7% | 82.9% · 53.9% | 78.9% · 54.1% |
| 10秒(選択) | 93.1% · 63.3% | 91.3% · 63.5% | 90.5% · 63.7% |
| 12秒 | 95.3% · 70.7% | 94.2% · 70.9% | 93.1% · 71.2% |
| 15秒 | 96.0% · 78.2% | 95.6% · 78.4% | 95.3% · 78.7% |
10秒は dが1・2・3秒のいずれであっても90%を超える最も短い値です。同じラリーの長さは中央値2.2秒、90パーセンタイル5.9秒、95パーセンタイル9.6秒、99パーセンタイル29.3秒であり、ラリー間の間隔の中央値は10.3秒でした。つまり10秒のウィンドウは大部分のラリーを収めつつ、ラリー間の休憩時間を概ねうまく切り捨てます。数少ない長いラリーは画面の動きによって延長されます。
同じ映像1本(140秒、正解ラリー7個)で通して再生した結果、編集版は139秒の録画を約95秒に短縮し、7個中6個のラリーがサーブの姿勢から残りました。漏れた1つは24秒の長い区間で、途中で選手が画面外に出て動きが途切れたケースでした。1本の映像であるため効果の規模を一般化することはできません。
コスト
同一のノートPCのCPUでChromeにより測定した相対コストです(2026-10-13、他の作業が同時に実行されていたため比率としてのみ読み取ります)。
| 項目 | WebCodecs(ハードウェアH.264) | MediaRecorder |
|---|---|---|
| 録画が追加するCPU(カメラのみ起動した場合と比較) | 約10 CPU秒/分 | 約15 CPU秒/分 |
| 停止後の編集(編集版49秒) | 0.4 CPU秒、0.56秒 | 22–26 CPU秒、49秒 |
| 1分あたりの保存容量 | 16.7MB | 約18MB |
WebCodecs側の編集がほぼタダである理由は 再エンコードしないから です。録画時に1秒ごとにキーフレームを入れておけば切り取る場所が1秒ごとに1つずつ生まれ、編集は残す区間のフレームを新しいファイルにコピーするだけ(remux)で済みます。MediaRecorder側は編集版をもう一度再生しながら格納する方式であるため、編集版の長さと同じ時間がかかります。
スマートフォンでは測定していません。 バッテリー、発熱、1時間を超える録画のメモリはいずれも未実測であり、ノートPCのハードウェアエンコーダーとスマホのエンコーダーが同じ比率になる根拠もありません。
9. ダブルスはどこまで
ダブルスのルールは JavaScriptのルールエンジンには存在します。 最初のサーバーと最初のレシーバーの選択、4人のサーブのローテーション、次のゲームの最初のレシーバーの決定(2.13.5)、最終ゲームでどちらかが5点になったときにレシーバーの順序を入れ替えること(2.13.6)まで実装されており、カメラのない「サーブ・スコアヘルパー」(/tt/play/scorekeeper)で使用できます。
しかし、残りはシングルスのみです。
| 区分 | ダブルス |
|---|---|
| JavaScriptルールエンジン、サーブ・スコアヘルパー | 可 |
| カメラ付きスコアボード | 不可(シングルス固定) |
自動判定エンジン(rules.py) |
不可(シングルスのみ) |
| ライブ共有 | 不可(ダブルスの試合は送信しない) |
| 試合記録・レーティング | 不可 |
自動判定側にはもう一つ困難があります。ダブルスは同じペアの2人の選手が交互に打たなければなりませんが、パート3で見たように私たちの審判は「誰が打ったか」を見ず、バウンドの順序でのみ判断します。ダブルスで順序を間違えた打球を捉えるには、人を識別する必要があります。
10. まとめ
- スコアのルールは規定集の条文と一対一で対応し、PythonのオリジナルとJavaScriptのコピーを同じユースケースファイルにまとめました。
- サーブとポジションは保存せず、 スコアと最初のサーバーから計算します。そのため試合を履歴として保存でき、ずっと前の1コマを削除しても後ろがルール通りに再び一致します。
- 録画の休憩時間除外は「得点・判定のマークのみが区間を作る」という1つのルールでウォーミングアップと休憩時間を除外します。10秒のウィンドウは275ラリーのシミュレーションにより選定され、人間が押す遅れとスマホのコストはまだ未実測です。
T.T.の一言。 スコアボードは「今何対何か」を覚えていません。「誰が何番目に獲得したか」を覚えています。だから3点前のミスを消してもサーブとポジションが勝手に再び合います。
参考文献
- 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
- 我々の測定:リポジトリ
docs/tt/PERFORMANCE.md2.11. コードbackend/static/tt/play/lib/scorekeeper.mjs·camscore.mjs·breakedit.mjs·cam-speech.mjs·fullview.mjs.
合わせて読みたい
- 本シリーズ 第3部:自動判定:ウォーミングアップの状態が自動判定においてなぜ決定的なのか、人間の手がチップの答えになるルール。
- 本シリーズ 第6部:試合記録と個人情報:終わった試合のゲームスコアが記録やレーティングになるまでの道のり。
- 前シリーズ TT Scoreモデル解剖 第5部:イベントと審判:自動判定エンジンが同じルールを使用する方式。