ゲームの速さが端末によって違う原因は? 時間で動かすゲームループ入門
遊びをつくる執筆にClaudeを使用しています。

自分のパソコンでは快適に動くゲームを、別の端末で試したら「速すぎてまともに遊べない」と言われた、という経験はありませんか。原因はゲームループの組み方にあります。この記事では、その仕組みと解決策を順を追って説明します。
この記事の目次
なぜ端末によってゲームの速さが変わるのか
ブラウザゲームのアニメーションには、多くの場合 requestAnimationFrame(以下 rAF)が使われます。rAF は「次の画面の書き換えタイミングに合わせてこの関数を呼んでください」とブラウザに伝えるAPIです。ブラウザは画面のリフレッシュレート——1秒間に何回画面を更新するか——に合わせてコールバックを呼び出します。
一般的なノートPCやスマートフォンのリフレッシュレートは60Hzで、1秒間に60回コールバックが呼ばれます。一方、ゲーミングモニターや最新のスマートフォンは120Hzや144Hzに対応しており、1秒間に120〜144回呼ばれます。
ここで問題になるのが、「毎フレーム一定のピクセル数だけ動かす」という書き方です。たとえばキャラクターを毎フレーム5px動かすコードは、60Hzの端末では1秒間に300px進み、120Hzの端末では1秒間に600px進みます。同じコードを動かしているのに、端末によって速さが2倍になってしまいます。
「フレーム数で動かす」と「時間で動かす」の違い
この問題を理解するために、2つの方法を並べて比較します。
方法A:フレームごとに固定量を加算する(問題のある書き方)
let x = 0;
function loop() {
x += 5; // 毎フレーム5px移動(リフレッシュレートに依存する)
draw(x);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);このコードは単純ですが、rAFが1秒間に何回呼ばれるかに結果が左右されます。60Hzなら毎秒300px、120Hzなら毎秒600px——端末の性能次第でゲームの速さが変わります。
方法B:経過時間から移動量を計算する(推奨される書き方)
let x = 0;
let prevTime = null;
const SPEED = 300; // 毎秒300pxの速さ
function loop(timestamp) {
if (prevTime === null) {
prevTime = timestamp;
}
const delta = (timestamp - prevTime) / 1000; // 秒単位に変換
x += SPEED * delta;
draw(x);
prevTime = timestamp;
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);rAFのコールバックは timestamp(DOMHighResTimeStamp) という引数を受け取ります。これはミリ秒単位の高精度な時刻で、ブラウザが管理しています。前のフレームとの差(delta)を求め、それに「毎秒の速さ」を掛けることで、フレームレートに関係なく一定の物理的速さを実現できます。60Hzでも120Hzでも、1秒間に300px進むことが保証されます。
MDN Web DocsのrequestAnimationFrameの解説には、「タイムスタンプを使わないと、高リフレッシュレートの画面でアニメーションが速くなる」と明記されています。このコード例は説明のための簡略版です。実際のゲームへ組み込む際は、deltaが大きくなりすぎるケース(タブを切り替えて戻ったときなど)への上限処理も検討してください。
timestampの正体——DOMHighResTimeStamp とは
rAFのコールバックに渡されるタイムスタンプは、W3C/WHATWGの仕様で定義された DOMHighResTimeStamp という型で、ページが読み込まれた起点(time origin)からのミリ秒数です。小数点以下も持てる高精度な浮動小数点数で、通常1ミリ秒以下の精度で時刻を表せます(ブラウザのセキュリティ設定によって精度が丸められることがあります)。
同じフレームに複数のrAFコールバックが登録されている場合、それらはすべて同じtimestampを受け取ります。これにより、複数のアニメーションを同一フレームで同期させることができます。ゲームオブジェクトが複数あっても、一つのループで全員同じdeltaを使えば動きが揃います。
また、rAFはバックグラウンドのタブや非表示のiframeでは一時停止されます。省電力のための仕様ですが、ゲームにとっては「タブを戻ったとき、前フレームとの差が数秒分になる」という問題にもなります。deltaに上限を設けておくと、復帰直後に一気にオブジェクトが飛んでしまうことを防げます。
すぐ使えるゲームループのテンプレート
以下は、シンプルなブラウザゲームに組み込める最小構成のゲームループです。update関数でゲームの状態を更新し、render関数で描画するという役割分担を明確にしています。
const MAX_DELTA = 0.1; // 100ms以上の差は無視(タブ復帰対策)
let prevTime = null;
function gameLoop(timestamp) {
if (prevTime === null) {
prevTime = timestamp;
requestAnimationFrame(gameLoop);
return;
}
const rawDelta = (timestamp - prevTime) / 1000;
const delta = Math.min(rawDelta, MAX_DELTA); // 上限を設ける
prevTime = timestamp;
update(delta); // ゲーム状態を更新(物理、当たり判定など)
render(); // 画面を描画
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
// 使い方の例
const PLAYER_SPEED = 200; // px/秒
let playerX = 0;
function update(delta) {
if (isKeyDown('ArrowRight')) {
playerX += PLAYER_SPEED * delta;
}
}ポイントは3点です。まず 速さをpx/秒の単位で定義すること——こうすることで、「プレイヤーは毎秒200px動く」という設計意図がコードから読み取れます。次に、最初のフレームはdeltaが計算できないため、初期化して次のフレームに委ねること。そして MAX_DELTAで上限を設けること——タブの切り替えやデバイスの一時停止後に、オブジェクトが瞬間移動するのを防ぎます。
このテンプレートはCanvasとDOM両方で使えます。renderの中でCanvas.clearRect + drawImageを呼ぶか、DOM要素のstyle.transformを更新するかは、ゲームの構造に合わせて選んでください。
よくある落とし穴と確認の手順
経過時間ベースに切り替えたあとも、動きがおかしくなるケースがあります。原因として多いのは次のパターンです。
- deltaを秒単位に変換し忘れている:timestampはミリ秒なので、そのまま使うと速さが1/1000になります。1000で割って秒にしてから掛け算します。
- ループ内でprevTimeを更新し忘れている:毎フレームの終わりに prevTime = timestamp を書かないと、deltaが毎フレーム積み重なり続けます。
- rAFを複数回呼び出している:ループの中とイベントハンドラーの両方でrAFを呼ぶと、1フレームに2回updateが走ることがあります。ループを開始するrAFは一箇所だけにします。
- 物理計算とrender関数を混在させている:描画の中で座標を変更すると、deltaを使った計算が崩れます。update(状態の更新)とrender(描画だけ)を分けて書くと追いやすくなります。
自分の端末だけで確認するのではなく、ChromeのDevToolsのパフォーマンスタブや、ブラウザの「フレームレートを表示」機能を使うと、実際に何Hzで動いているか確認できます。また、CPUスロットリングを使って擬似的に低性能な環境を作り、deltaが大きいときの挙動を意図的に試しておくことで、端末差の問題を事前に潰せます。
次の一歩——実際に動く作品で確かめる
時間ベースのゲームループを理解する近道は、動いているゲームを観察することです。アソバースの「紙ひこうき郵便」は、風の中を飛ぶ紙ひこうきの操作感が特徴の作品です。キャラクターの動きがどのくらい滑らかに感じるか、また端末を変えたときに差があるかを観察してみてください。操作感の違いに気づくことが、ゲームループの理解を深める実践になります。
自分のゲームを作る際は、今回のテンプレートをそのまま出発点として使えます。まず動かすものを一つ決め、その速さをpx/秒で設定し、deltaを掛けた移動量をupdate関数に書く——この順で組んでいくと、フレームレートに依存しない設計が自然に身につきます。ゲームが完成したら、HTMLファイルやZIPとしてアソバースに投稿して公開できます。



