ゲーム用のタイマー実装:setIntervalを使わない理由
遊びをつくる執筆にClaudeを使用しています。

ゲームのループやカウントダウンを実装しようとしたとき、最初に思い浮かぶのは setInterval や setTimeout でしょう。「100ミリ秒ごとに処理を呼ぶ」という書き方はシンプルで直感的ですが、ゲームに使うと動きがカクつく、タイミングがずれる、タブを切り替えると壊れる、といった問題が出てきます。 この記事では、なぜ setInterval がゲームに向かないのかを具体的に説明し、代わりに使う requestAnimationFrame(以下 rAF)の仕組みと実装パターンを順に示します。読み終えたあとに実際のコードへ置き換えられる内容を目指しています。
この記事の目次
setIntervalがゲームで壊れる三つの理由
呼び出しタイミングがブラウザに保証されない
setInterval(fn, 16) と書いても、fn が正確に16ミリ秒ごとに呼ばれるとは限りません。JavaScriptはシングルスレッドで動いており、メインスレッドが重い処理を実行しているあいだはコールバックの呼び出しが後回しになります。結果として、16ms・16ms・16ms……のはずが、16ms・31ms・8ms……のようにバラついた間隔で呼ばれます。
ゲームでは「前のフレームから何ミリ秒経ったか」を元にキャラクターの位置や物理演算を更新するため、この誤差が積み重なると動きが不安定になります。
バックグラウンドタブでも動き続ける
ブラウザがタブをバックグラウンドに切り替えると、setInterval の呼び出し頻度は絞られます(おおむね1秒に1回程度)。フォアグラウンドに戻った瞬間、溜まった呼び出しがまとめて実行されたり、タイマーのカウントが現実の経過時間とずれたりします。プレイヤーが別のタブを開いてゲームに戻ったとき、時間や位置計算がおかしくなるのはこれが原因です。
画面のリフレッシュレートと同期しない
モニターの描画は一定のリフレッシュレート(60Hz・120Hz・144Hzなど)で行われています。setInterval(fn, 16) は60Hzに合わせているつもりでも、実際の画面更新タイミングとは無関係に走るため、描画更新と論理更新がずれてティアリング(画面の部分的なちぐはぐ)やフレームの二重描画が起きやすくなります。
requestAnimationFrameが解決すること
requestAnimationFrame はブラウザが「次の画面更新のタイミングで呼んでほしい」と約束するAPIです。60Hzの環境なら約16.7ms、120Hzなら約8.3ms間隔で呼ばれ、しかも画面更新と同期しています。
重要な点が三つあります。
- コールバックには高精度のタイムスタンプが渡される。 performance.now() と同じ精度(マイクロ秒単位)で現在時刻が引数として来るため、前のフレームとの差分(デルタタイム)を正確に測れます。
- バックグラウンドでは自動的に停止する。 タブが非表示になると rAF は呼ばれなくなり、フォアグラウンドに戻ったときに再開します。タイマーの暴走が起きません。
- ブラウザが最適なタイミングを管理する。 GPUの準備が整ったタイミングで描画呼び出しが行われるため、無駄な再描画を避けられます。
基本的な実装パターン
最小限の構造は次のようになります。
let previousTime = null;
function gameLoop(timestamp) {
if (previousTime === null) {
previousTime = timestamp;
}
const delta = timestamp - previousTime; // ミリ秒
previousTime = timestamp;
update(delta); // ゲームの状態を更新
draw(); // 画面に描画
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);delta は「前のフレームから経過したミリ秒数」です。キャラクターを毎秒200px動かしたい場合、x += 200 * (delta / 1000) と書けば、フレームレートに関係なく一定速度で移動します。これを「デルタタイム法」と呼びます。
ループを止める方法
requestAnimationFrame は呼び出すたびにIDを返します。cancelAnimationFrame(id) にそのIDを渡すとループを停止できます。
let rafId = null;
function start() {
rafId = requestAnimationFrame(gameLoop);
}
function stop() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}ゲームオーバー・ポーズ・画面遷移のタイミングで stop() を呼ぶと、不要な描画が走り続けるのを防げます。
カウントダウンタイマーの実装例
rAF を使いながら「残り60秒」を表示するカウントダウンの実装です。
const TOTAL_MS = 60000; // 60秒
let elapsed = 0;
let previousTime = null;
let rafId = null;
function tick(timestamp) {
if (previousTime === null) previousTime = timestamp;
const delta = timestamp - previousTime;
previousTime = timestamp;
elapsed += delta;
const remaining = Math.max(0, TOTAL_MS - elapsed);
const seconds = Math.ceil(remaining / 1000);
document.getElementById('timer').textContent = seconds;
if (remaining > 0) {
rafId = requestAnimationFrame(tick);
} else {
onTimeUp();
}
}
function startTimer() {
elapsed = 0;
previousTime = null;
rafId = requestAnimationFrame(tick);
}setInterval でカウントダウンを書くと「1秒ごとに1を引く」実装になりがちですが、この方法は累積経過時間を直接計算するため、フレームが重なっても飛んでも残り秒数が現実の時間とズレません。
setIntervalを使ってよい場面
rAF は描画ループに最適ですが、すべての用途で setInterval を禁止する必要はありません。
- 通信や状態の定期ポーリング(サーバーへのスコア送信など)は描画と無関係なので setInterval や setTimeout で十分です。
- デバッグ用のFPS計測 など、画面に表示しない内部処理は rAF でなくてもかまいません。
「画面に何かを動かす」「時間に応じてゲーム状態を変える」ような処理は rAF を選び、それ以外の非同期処理は用途に応じて使い分けるのが実際の判断基準です。
よくある落とし穴
デルタタイムが大きくなりすぎるケース。 タブを長時間バックグラウンドにしてから戻ると、最初の delta が数秒〜数十秒になることがあります。const delta = Math.min(timestamp - previousTime, 100) のように上限を設けると、復帰直後のオブジェクトの位置飛びを防げます。
previousTime の初期化忘れ。 ゲームをリスタートしたときに previousTime をリセットしないと、前回ループの最終タイムスタンプとの差分が初回デルタになり、瞬間移動のような挙動が起きます。リスタート処理では必ず previousTime = null に戻してください。
複数回 rAF を登録してしまう。 requestAnimationFrame(gameLoop) を意図せず複数箇所から呼ぶと、ループが二重・三重に走ります。rafId を使って「すでにループが動いているかどうか」を確認してから start() を呼ぶ習慣をつけると防げます。
次の行動
setInterval でゲームループを書いているコードがあれば、まず requestAnimationFrame + デルタタイム法のパターンに置き換え、delta をコンソールに出力してフレーム間隔を確認してみてください。動きの滑らかさとタブ切り替え後の挙動が改善されていれば、移行は成功です。完成したブラウザゲームはアソバースにZIPまたは単一HTMLで投稿して公開できます。
参考資料
- MDN Web Docs — window.requestAnimationFrame()
- MDN Web Docs — performance.now()
- HTML Living Standard — Animation Frames(WHATWG)
