TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

ハンドスコアボードと試合ルールエンジン:11点・デュース・サーブ交代から「状態ではなく履歴」まで

状態:ドラフト(2026-10-26)、実測値を反映。 ルールと動作は2026-10-25時点のリポジトリコードから読み取り、録画編集の数値は2026-10-13に公開された試合映像で直接測定した値です。スマートフォンでの録画コスト(バッテリー・発熱)と、人間がスコアをタップする際の遅延は未測定です。 作成ルール:実測データを基本とし、測定できなかったものは「未測定」と表記します。

目次

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).

カメラを起動するとすぐに試合が始まるわけではなく、 ウォーミングアップ状態に入ります。ウォーミングアップ中に得点エリアをタップしてもスコアは加算されず、「試合開始」ボタンが揺れて知らせてくれます。ウォーミングアップ中に受け付ける調整は、サーブ権の指定と左右の入れ替えの2つのみです。なぜこのような状態が必要なのかは自動判定と密接に関連しているため、第3部で詳しく解説します。ハンドスコアボードの観点から見れば、「ウォームアップのラリーを得点としてカウントしないための仕組み」です。

4. 状態ではなく履歴

저장하는 것: 이력(칸의 목록), 첫 서버 A A 득점 1 B 득점 2 A 득점 3 B 득점 4 B 득점 5 A 득점 6 A 득점 7 B 득점 8 다시 돌린 결과: A 4 : 4 B · 다음 서브 A 5번째 칸(잘못 누름)을 지운다 → 빈 점수판에 남은 칸을 처음부터 다시 넣는다 A 득점 B 득점 A 득점 B 득점 A 득점 A 득점 B 득점 다시 돌린 결과: A 4 : 3 B · 다음 서브 B 서브 차례 = 점수 합과 그 게임의 첫 서버에서 계산 자리 바꾸기 = 게임 번호와 마지막 게임 5점 교대에서 계산 그래서 한 칸을 지워도 뒤의 서브·자리·게임 끝이 규칙대로 맞는다
図:履歴の1コマを削除すると、後ろのサーブ・コート・ゲーム終了が再計算される

問題:ずっと後になって気づくミス

ハンドスコアボードで最もよくあるミスは、 間違えて押したスコアをずっと後になってから気づくことです。スマートフォンは卓球台から数メートル離れた三脚の上にあり、選手は毎ラリーごとにスコアボードを見るわけではありません。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つは設計がユニークです。

「戻す」ボタンは、履歴の最後のコマを削除することと同じです。「やり直す」は確認を取った上で履歴をクリアし、ウォーミングアップに戻ります。

保存:変更のたびにスマートフォンに

履歴は変更されるたびにブラウザの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).

小さな落とし穴がコードに残っています。カメラ付きスコアボードは最初の画面を軽くするために重い機能を使う時に読み込まれますが、音声合成の関数だけは遅延させず最初の画面のコードに残しました。iOSのSafariは ユーザーがタップしたその瞬間に 1度発話させないと以降音が出ず、モジュールを待ってから発話させるとすでに遅いためです。

7. 画面:フルスクリーン、スコアボードのサイズ、スリープ防止

8. 録画と休憩時間の除外

スマホ内で録画し、スマホ内で切り取る

録画はカメラ映像の上にスコアボードをcanvasで合成して 携帯電話に保存します(cam-rec.js, lib/cam-record.mjs)。サーバーにはアップロードしません。エンコーダーはWebCodecsが使える場合はそれにより直接H.264を生成し、使えない場合はMediaRecorderを使用します。

試合の録画をそのままにしておくと、半分以上がボール拾い、タオル拭き、会話になります。そのため録画を停止する際に ラリーのみを残した編集版をスマホ内で作成します(デフォルトでオン)。問題は「ラリーがどこにあったのか」をどう知るかです。

録画中に記録しておく3つのマーク

lib/breakedit.mjs の MarkLog が、録画のタイムライン上に3つの要素を記録します。

  1. 手動得点の時刻。 人間が得点ボタンを押した時刻です。自動判定チップに応答したものは含めません(2番と重複)。
  2. 自動判定の到着時刻とラリーの長さ。 自動スコアが有効な場合にのみ存在します。
  3. 画面の動き。 毎秒8回、128×36のグレースケールに縮小した画面で、卓球台の周囲の帯(横10–90%、縦33–72%)の変化したピクセル比率を測定します。

残す区間の計算ルール

움직임 표시 남김 0초 30초 60초 90초 120초 몸풀기 쉬는 시간 판정 손 득점 움직임으로 늘림 손 득점: 누른 시각 앞 10초를 남김(앞뒤 1초 여유) 판정: 랠리 길이 + 늦음 1.0초 + 앞 1.2초 ~ 뒤 1.5초 움직임: 창 앞머리가 움직임 한가운데면 최대 30초 늘림 움직임은 구간을 새로 만들지 않는다 → 표시 없는 몸풀기·쉬는 시간은 남지 않는다 0.5초 안으로 붙은 구간은 합친다
図:録画のタイムライン上で残す区間を計算するルール

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. まとめ

T.T.の一言。 スコアボードは「今何対何か」を覚えていません。「誰が何番目に獲得したか」を覚えています。だから3点前のミスを消してもサーブとポジションが勝手に再び合います。

参考文献

  1. 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
  2. 我々の測定:リポジトリ docs/tt/PERFORMANCE.md 2.11. コード backend/static/tt/play/lib/scorekeeper.mjs·camscore.mjs·breakedit.mjs·cam-speech.mjs·fullview.mjs.

合わせて読みたい

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます