反射神経テストを作って学んだ「たった数ミリ秒」の世界
ブラウザで反射神経テストを作る過程で直面した、onClick vs onPointerDown、PC/スマホの入力遅延差、タイミング精度の追求について解説します。
はじめに
「画面の色が変わったらタップする」——ただそれだけのゲームなのに、作ってみたら奥が深かった。
反射神経テストを個人開発で作った過程で、ミリ秒単位の精度を追求することになり、ブラウザのイベント処理やデバイスごとの入力遅延について色々わかったことがあります。この記事ではその知見をまとめます。
onClickを使ってはいけない理由
最初は普通に onClick でタップを検知していました。動くには動く。でもタイムを計測してみると、体感より遅いんです。
理由はシンプルで、onClick はブラウザ内部で以下の処理を経て発火します:
- pointerdown(指が触れた瞬間)
- pointerup(指が離れた瞬間)
- click(↑の後に発火)
つまり onClick は「指を離した瞬間」に発火するイベントです。反射神経テストで計りたいのは「指が触れた瞬間」なので、onPointerDown を使うのが正解でした。
// NG: 指を離した瞬間(遅い)
<div onClick={handleTap} />
// OK: 指が触れた瞬間(速い)
<div onPointerDown={handleTap} />
体感で 30〜80ms くらいの差があります。反射神経テストでは200ms台のスコアを競うので、これは無視できない差です。
onTouchStartではダメなのか?
「じゃあ onTouchStart でもいいのでは?」と思うかもしれません。確かにスマホでは動きます。でも問題が2つ:
- PCで動かない(マウスにはtouchイベントがない)
- onTouchStart + onClick の併用は二重発火する
onPointerDown はマウスでもタッチでもペンでも統一的に動く、まさにこういう用途のためのAPIです。
PC vs スマホで公平にランク付けする
テスト中に気づいたのですが、同じ人間が同じタイミングで反応しても、スマホのほうが約60ms遅く計測されるんです。
これはデバイスのタッチパネルのサンプリングレートやOS側の処理遅延が原因です。PCのマウスクリックは比較的ダイレクトに伝わりますが、スマホのタッチは物理的なセンサー→OS→ブラウザという経路が長い。
そこで、デバイス判定をしてランク基準を変えることにしました:
export function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false;
return navigator.maxTouchPoints > 0;
}
navigator.maxTouchPoints でタッチデバイスかどうかを判定し、スマホの場合は各ランクの閾値を +60ms 緩くしています。
| ランク | PC基準 | スマホ基準 |
|---|---|---|
| S | 150ms以下 | 210ms以下 |
| A | 200ms以下 | 260ms以下 |
| B | 250ms以下 | 310ms以下 |
これで「スマホだからSランク取れない」という不公平感を解消しています。
performance.now() で高精度計測
時間計測には Date.now() ではなく performance.now() を使います。
// 開始
startTimeRef.current = performance.now();
// 反応時
const reactionTime = performance.now() - startTimeRef.current;
Date.now() はミリ秒単位の整数ですが、performance.now() はマイクロ秒精度の浮動小数点数を返します。反射神経テストでは1msの違いが体験に影響するので、より高精度なAPIを使うべきです。
フライング検知と待機時間のランダム化
単純に「色が変わったらタップ」だと、リズムを覚えてフライングし放題になります。対策として:
- 待機時間をランダムに(2〜5秒の間)
- フライング3回でゲームオーバー
const MIN_WAIT = 2000;
const MAX_WAIT = 5000;
const delay = MIN_WAIT + Math.random() * (MAX_WAIT - MIN_WAIT);
待機時間の幅が広いほど「いつ来るかわからない」緊張感が出ます。最初は1〜3秒にしていましたが、パターンが読みやすかったので2〜5秒に広げました。
5回計測のメジアンを採用
スコアは5回計測の**メジアン(中央値)**を最終結果にしています。平均値ではなくメジアンにした理由は、外れ値の影響を排除するためです。
export function getMedian(values: number[]): number {
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return Math.round((sorted[mid - 1] + sorted[mid]) / 2);
}
return Math.round(sorted[mid]);
}
たとえば5回の計測が [180, 195, 420, 185, 190] だった場合:
- 平均値: 234ms(1回のミスで大きくブレる)
- メジアン: 190ms(実力に近い値が出る)
1回だけ集中が切れたりタップミスしても、実力が正しく反映されるようにしています。
touch-action: manipulation でダブルタップズームを防ぐ
スマホでゲームを作る時の定番トラブルが「素早くタップしたらズームされる」問題です。
ブラウザはダブルタップをズーム操作として認識するため、連続タップが必要なゲームでは困ります。CSSの touch-action: manipulation を指定することで、ダブルタップズームを無効化しつつスクロールは有効に保てます。
.game-area {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
-webkit-tap-highlight-color: transparent もセットで指定すると、タップ時の青いハイライトも消えてゲームっぽい見た目になります。
UIを「インクスプラッシュ風」にした理由
反射神経テストの世界観として、「インクが飛び散る」ポップなビジュアルを採用しました。
ポイントは画像を1枚も使わず、全てCSS/SVGで表現していること。インクの飛沫、ペンキ風の背景、ネオンっぽいテキスト——すべてCSSのグラデーション、box-shadow、SVGのpath要素で再現しています。
画像を使わないメリットは:
- 読み込みが速い
- サイズが軽い
- 色やアニメーションを動的に変えやすい
まとめ
「反射神経テスト」というシンプルなゲームでも、精度を追求すると意外と深い世界が広がっていました。
- onPointerDown で入力遅延を最小化
- PC/スマホ判定 で公平なランク付け
- performance.now() でマイクロ秒精度の計測
- メジアン で外れ値に強いスコアリング
- touch-action でスマホ操作の罠を回避
ブラウザゲームを作る際の参考になれば幸いです。
🔍 あなたも診断してみよう!