30分で作る「リズムに合わせて押す」ゲーム:4拍の判定
遊びをつくる執筆にClaudeを使用しています。

「リズムに合っていた」という判断を、プログラムはどうやって下すのでしょうか。クリック音が一定の間隔で鳴り、それに合わせてボタンを押す場面を考えてみます。画面には「GOOD」か「MISS」と出ます。この二択を分けているのは、拍が鳴るはずだった時刻と、実際に押された時刻の差だけです。 この記事では、その差だけで判定する最小の音ゲーを、1つのHTMLファイルで作ります。前半の4拍は聞くだけ、後半の4拍で押す、という構成です。外部ライブラリやサーバーは使いません。作ったあとは、自分の押し方に癖があるかを数字で確かめる手順まで進みます。 30分の目安は、判定の考え方に5分、コードを写して動かすのに10分、記録に10分、改造に5分です。判定の仕組み自体は短く、時間を使うのは動かしたあとの「ズレの読み方」です。
判定の考え方:差は「押した時刻 − 拍の時刻」
用意する量は3つです。1拍の長さ、拍の時刻、押した時刻です。テンポを100BPM(1分間に100拍)にすると、1拍は 60 ÷ 100 で0.6秒です。開始時刻を t0 とすれば、k番目の拍は t0 + k × 0.6 秒に鳴ります。
押された時刻から「どの拍を狙ったのか」を決めるには、いちばん近い拍に丸めます。押した時刻と t0 の差を0.6で割って四捨五入すれば拍の番号になります。差は「押した時刻 − その拍の時刻」とします。プラスなら遅い、マイナスなら早い押しです。符号を残しておくと、あとで癖が読み取れます。
差の絶対値を幅と比べて判定します。この記事では出発点として次のように決めます。
- GOOD、差の絶対値:0.05秒(50ms)以内、位置づけ:出発点の仮の値
- OK、差の絶対値:0.12秒(120ms)以内、位置づけ:出発点の仮の値
- MISS、差の絶対値:それより大きい、位置づけ:
この幅は標準規格ではなく、あとで調整する前提の仮置きです。目安として、60fpsの画面の1フレームは約16.7msです。50msはその3フレーム分ほどになります。
時刻の基準には、setTimeout ではなく Web Audio API の AudioContext.currentTime を使います。setTimeout は他の処理に押されて遅れることがあり、拍の時刻の基準としては不安定です。音の予約と判定の両方を同じ時計に載せると、拍と入力の比較が一貫します。
全コード:49行の単独HTML
次のコードを rhythm.html という名前で保存し、ブラウザで開きます。ボタンのIDを hit にしないのは、同名の関数と衝突するためです。
<!doctype html>
<meta charset="utf-8">
<button id="go">スタート</button>
<button id="btn">押す (Space)</button>
<pre id="out">スタートを押してください</pre>
<script>
const SEC = 60 / 100, GOOD = 0.05, OK = 0.12;
let ctx, t0, res = [], timer;
function click(t, high) {
const o = ctx.createOscillator(), g = ctx.createGain();
o.frequency.value = high ? 1200 : 800;
g.gain.setValueAtTime(0.3, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.06);
o.connect(g).connect(ctx.destination);
o.start(t); o.stop(t + 0.07);
}
go.onclick = () => {
ctx = ctx || new AudioContext(); ctx.resume();
go.blur(); res = []; clearTimeout(timer);
t0 = ctx.currentTime + 0.5;
for (let i = 0; i < 8; i++) click(t0 + i * SEC, i % 4 === 0);
out.textContent = '前半4拍は聞くだけ。後半4拍で押します';
timer = setTimeout(show, (0.5 + 8 * SEC) * 1000);
};
function hit() {
if (!ctx) return;
const now = ctx.currentTime;
const n = Math.round((now - t0) / SEC) - 4;
if (n < 0 || n > 3 || res[n] !== undefined) return;
res[n] = now - (t0 + (n + 4) * SEC);
}
function judge(d) {
const a = Math.abs(d);
return a <= GOOD ? 'GOOD' : a <= OK ? 'OK' : 'MISS';
}
function show() {
let s = '';
for (let n = 0; n < 4; n++) {
const d = res[n];
s += (n + 1) + '拍目: ' + (d === undefined ? '押さず MISS' :
judge(d) + ' (' + Math.round(d * 1000) + 'ms)') + '\n';
}
out.textContent = s;
}
btn.onpointerdown = hit;
onkeydown = e => {
if (e.code === 'Space' && !e.repeat) { e.preventDefault(); hit(); }
};
</script>動かす手順は次のとおりです。
- 「スタート」を押します。0.5秒後に8回のクリック音が鳴ります。音が出ない場合は、端末の音量と、ブラウザのタブがミュートされていないかを確認します。
- 1〜4回目の音(高い音で始まります)は聞くだけにします。
- 5〜8回目の音に合わせて、Spaceキーかボタンを押します。ボタンは押し込んだ瞬間(pointerdown)に反応します。
- 約5秒後に、拍ごとの判定と差(ミリ秒)が表示されます。
読みどころ3か所と、起こりやすい誤り
音は先に予約する
click 関数の o.start(t) は「時刻 t に鳴らす」という予約です。8回分をスタートの瞬間にまとめて予約しているので、画面の描画や他の処理が重くなっても、音の間隔は崩れにくくなります。拍を「鳴らす瞬間に毎回計算する」作りにすると、そのたびに時刻がぶれます。ここが、音ゲーの土台です。
丸めと「1拍1回」
Math.round((now - t0) / SEC) - 4 が、押した時刻をいちばん近い拍に帰属させる処理です。後半の拍を0〜3番にしているので、0〜3の範囲外は無視されます。つまり前半4拍の間に押しても記録されず、練習に使えます。
res[n] !== undefined の判定は、同じ拍に2回以上押しても最初の1回だけを採用するためのものです。これがないと、連打すれば当たる拍が出てしまいます。
丸めの副作用も押さえておきます。拍と拍の中間より遅く押すと、次の拍を狙った「早い押し」として扱われます。0.6秒間隔なら、どの押しも最大で±0.3秒の差に収まるため、MISSでも差は0.3秒以内の値になります。
起こりやすい誤り
- AudioContext をページ読み込み時に作る:多くのブラウザは、ユーザー操作の前の音声再生を制限しています。ここでは「スタート」のクリック内で作っています。
- スタートボタンにフォーカスが残る:Spaceキーでスタートが再実行されてしまいます。go.blur() はそれを避けるためです。
- 時計を混ぜる:拍の時刻に currentTime、入力の時刻に Date.now() を使うと、基準がずれて比較になりません。同じ時計で測ります。
- ずれを全部「腕前」と思い込む:次の節で扱います。音が耳に届くまでの出力遅延などが、差に一定量上乗せされることがあります。
自分で記録し、結果を読む
この作品は「自分の押し方の傾向」を調べる道具にもなります。数字を見る前に、問いを1つに絞ります。ここでは「自分の差は、早い側と遅い側のどちらに偏っているか」とします。
記録の取り方
- 1回の試行は4拍分で、差を符号つきのミリ秒で書き留めます。
- 10回繰り返し、合計40個の値にします。最初の2〜3回は慣れの影響が出やすいため、別に印をつけておきます。
- 同時に、ブラウザの名前、音の出力先(本体スピーカー、有線イヤホン、無線イヤホンなど)、入力方法(Space、ボタン)も書いておきます。
- 比較したいときは、条件を1つだけ変えます。たとえば出力先だけを替えて、同じ10回を取り直します。
結果の読み方
平均値と、値の散らばり方の2つを見ます。
- 40個の大半が同じ符号で、平均がその側に寄っている、疑うこと:出力・入力の遅延や、押し方の癖による一定のズレ、次の一手:平均値を差から引く補正を足す。端末で変わるため、固定値より「調整できる設定」にする
- 符号が入り混じり、値が大きい、疑うこと:押し方のばらつき、次の一手:判定幅を見直す。または速度を落として再計測
- 拍4に近づくほど早くなる、または遅くなる、疑うこと:テンポ感の癖、次の一手:BPMを変えて同じ10回を取り直し、傾向が残るか見る
- 「押さず MISS」が多い、疑うこと:キー入力を取れていない、次の一手:フォーカスの位置、Spaceキー以外の動作を確認
出力先を替えて平均が大きく動くなら、遅延の影響を疑う根拠になります。ブラウザには出力遅延を調べる AudioContext.outputLatency などの項目がありますが、対応状況はブラウザごとに違います。使う場合は、公式ドキュメントで確認してください。値が取れても、それだけで補正が足りるとは限りません。最後は自分の記録で確かめるのが確実です。
30分のうちにできる改造
- 後半を無音にする:for 文の条件を i < 4 に変えると、前半4拍だけ鳴ります。後半は自分の体内のテンポで押すことになり、差のばらつきが変わるかを観察できます。
- BPMを変える:1行目の 100 を変えて、遅い・速いテンポで同じ10回を比べます。
- 判定幅を変える:GOOD と OK を狭めると難しくなります。自分の40個の値を見て「GOODが半分ほど出る幅」を探すと、ゲームとして手応えのある設定を決めやすくなります。
- 音以外の合図を加える:拍の時刻に画面を光らせる処理を足すと、音が出せない環境でも遊べます。
次の行動
まず rhythm.html を保存し、10回分の差を紙に書いて、平均が早い側か遅い側かを確かめてみてください。傾向が見えたら、補正を1行足すか、判定幅を変えるかを選びます。動くものができたら、このHTMLを直下の index.html にして、アソバースへの投稿画面で表示の確認まで進められます。下書きとして保存できるので、まず公開前の状態で試せます。
参考資料
- MDN Web Docs「Web Audio API」
- MDN Web Docs「AudioContext.currentTime」
- MDN Web Docs「AudioContext.outputLatency」

