ゲームが二重に起動してしまう:イベントの重複登録
遊びをつくる執筆にClaudeを使用しています。

ゲームを作っていると、「スタートボタンを押すたびにキャラクターの速度が上がる」「効果音が二重・三重に重なって鳴る」「ループが倍速になる」といった現象に悩まされることがあります。コードそのものに論理ミスはないのに動作がおかしい、という状況です。 こうした症状の多くは、イベントリスナーの重複登録が原因です。同じ要素・同じイベント名に対して addEventListener が複数回呼ばれると、ハンドラが二重・三重に積み重なり、1回のイベントで複数のコールバックが実行されます。ゲームの「起動」処理にこれが起きると、ループやタイマーが並行して走り、まるでゲームが二つ同時に動いているような状態になります。 この記事では、重複登録がなぜ起きるのか、どう見つけるのか、どう直すのかを順に説明します。
なぜ重複登録が起きるのか
最もよくある原因は「初期化関数を複数回呼んでいる」ことです。たとえば init() という関数の中でボタンに click リスナーを登録しているとき、ゲームのリスタートや画面遷移のたびに init() を呼ぶと、同じボタンに同じリスナーが積み重なっていきます。
// init() を3回呼ぶと、clickハンドラも3つ登録される
document.getElementById('startBtn').addEventListener('click', startGame);二つ目の原因は「条件分岐の外で登録している」ことです。ゲームの状態(メニュー表示中・プレイ中・リザルト表示中など)を切り替えるたびに同じ登録コードが走る構造になっていると、状態が変わるたびにリスナーが増えます。
三つ目は「動的に生成した要素に毎回リスナーを付けている」ケースです。ステージ選択ボタンや敵キャラのクリック判定を innerHTML の書き換えと組み合わせて使うとき、古い要素を消して新しい要素を作り直すたびにリスナーを追加していると、DOM上の参照が入れ替わっていても内部的にハンドラが複数走ることがあります。
重複を見つける方法
ブラウザの開発者ツールで確認する
Chrome DevToolsの「Elements」パネルで対象要素を選び、右ペインの「Event Listeners」タブを開くと、その要素に登録されているリスナーの一覧が表示されます。同じイベント名(例:click)が複数並んでいれば重複しています。「Ancestors」チェックをオフにすると、その要素自身のリスナーだけを見られます。
console.log でカウントする
リスナーを登録する直前に console.log('リスナー登録:', new Date()) を仕込んでおくと、何度呼ばれているかがコンソールで確認できます。ゲーム起動やリスタート操作のたびにログが増えるなら、登録コードが繰り返し実行されています。
ゲームループのカウンターを使う
requestAnimationFrame ループが複数走っているかどうかは、ループ内でカウンタをインクリメントして1秒あたりの呼び出し回数を計測すると分かります。60fps設計のゲームで120回/秒を超えていれば、ループが二重に走っていると判断できます。
重複を防ぐ三つのアプローチ
1. removeEventListener で一度外してから登録する
最もシンプルな対処法は、登録前に同名のリスナーを外すことです。ただしこれが有効なのは、ハンドラが名前付き関数で定義されているときに限ります。無名関数(アロー関数や function(){} のインライン記述)を渡すと、removeEventListener に同じ参照を渡せないため外せません。
// NG:無名関数は外せない
btn.removeEventListener('click', () => startGame()); // 別の参照として扱われる
// OK:名前付き関数なら外せる
btn.removeEventListener('click', startGame);
btn.addEventListener('click', startGame);2. フラグで「登録済み」を管理する
ページ読み込み時に一度だけリスナーを登録し、以降は登録しない方法です。let initialized = false というフラグを用意して、init() の冒頭で if (initialized) return; としてから initialized = true にする、という定番のパターンです。リスタートが必要な場合は、リスナーの再登録ではなくゲームの状態変数をリセットする設計にすると、リスナーは最初の一つだけで済みます。
3. AbortController でリスナーをまとめて管理する
ゲームのフェーズ(プレイ中・ポーズ中など)に応じてリスナーを付け外しする場合、AbortController を使うと複数のリスナーを一括で解除できます。フェーズ切り替え時に古いコントローラーを abort() してから新しいコントローラーを作り、次のフェーズのリスナーを登録します。これにより「登録したが外し忘れた」リスナーが残るリスクを減らせます。
let controller = new AbortController();
function enterPlayPhase() {
controller.abort(); // 前のフェーズのリスナーをまとめて解除
controller = new AbortController();
const { signal } = controller;
document.addEventListener('keydown', handleKey, { signal });
document.addEventListener('keyup', handleKeyUp, { signal });
}ゲームループが二重に走るときの追加確認
requestAnimationFrame や setInterval で動くループは、重複登録とは別の経路で多重起動することがあります。startGame() を呼ぶたびに requestAnimationFrame(loop) を呼んでいると、前のフレームがキャンセルされないまま新しいフレームが始まり、ループが並行して走ります。
対策は、ループのIDを変数に保持して cancelAnimationFrame(id) で必ず止めてから再起動することです。setInterval も同様に、再起動前に clearInterval(id) を呼ぶ習慣を付けると多重起動を防げます。イベントリスナーの重複とゲームループの多重起動は別問題ですが、症状が似ているため合わせて確認するとデバッグが速くなります。
失敗しやすいパターンまとめ
リスナーを登録するコードが関数の中にあり、その関数がゲームのリスタートや画面遷移のたびに呼ばれている——これが最も頻繁に遭遇するパターンです。見た目には init() を1回呼んでいるつもりでも、「タイトルに戻ってもう一度プレイ」という操作で2回目が実行されているケースは見落としやすいです。
また、スマートフォン向けにタッチイベント(touchstart・touchend)とマウスイベント(mousedown・click)を両方登録しているとき、タッチ操作では両方が発火してハンドラが二度実行されることがあります。e.preventDefault() の位置や passive オプションの設定と合わせて確認が必要です。
次の行動
DevToolsの「Event Listeners」タブで問題の要素を確認し、同名イベントが複数登録されていないかチェックしてみてください。重複が見つかったら、ハンドラを名前付き関数に変えて removeEventListener で外す、またはフラグで初期化済みを管理する、どちらかのアプローチを選んでください。修正後は実際にリスタートや画面遷移を繰り返し、症状が再現しないことを確かめてから公開に進めると安心です。アソバースでのZIPアップロード前にもこの確認を行うと、プレイヤー環境での予期しない多重起動を防げます。
