スコアが一度に2回増える:イベントの二重登録を追う
遊びをつくる執筆にClaudeを使用しています。

ボタンを1回押しただけなのに、スコアが+10ではなく+20になる場面を考えてみます。加点の式を見直しても「10」としか書いていないのに、画面は20を示します。こうなったとき、式を疑い続けても答えは出ません。多くの場合、式は正しく、式が呼ばれる回数が2回になっています。 この記事で確かめるのは、その2回目がコードのどこから来ているかを、ログだけで絞り込む手順です。ログを置くのは「イベントを登録する側」と「点を加える側」の2か所です。読むのは、行数、イベントの時刻の印、呼び出しの連鎖の3つです。これで、リスナーの再登録なのか、1回の操作が2種類のイベントになっているのか、加点処理が別の場所からも呼ばれているのかを切り分けられます。 後半に、二重登録を意図的に含む30行ほどの最小ゲームを載せます。手元のHTMLファイルとして保存して開けば、同じ手順をそのまま試せます。
この記事の目次
2回目の正体を3つに分ける
「2回増える」の原因は、ログを見る前に3つの仮説に分けられます。
- 登録が2回ある:初期化関数が2回呼ばれる、やり直し(リスタート)のたびに登録をやり直しているなど。同じ操作に対してリスナーが2つ付いている状態です。
- 1回の操作が2種類のイベントを生む:親要素と子要素の両方にclickリスナーがある(バブリング)、タッチ操作でtouchstartと後続のclickの両方を拾っている、キーを押しっぱなしにしてkeydownが繰り返されている、など。
- 加点の呼び出し元が2つある:イベント処理と、衝突判定やタイマー処理の両方が加点関数を呼んでいる状態です。
最初に立てる問いは1つです。操作1回につき、加点ログは何行出るか。 期待は1行です。2行以上なら、行同士の時刻の印と呼び出し元を比べて、上の3つのどれかに当てはめます。
ログを2か所に置く
次のHTMLを1ファイルに保存します。前半の<script>が診断用で、ゲーム本体のスクリプトより前に置くのが条件です。後から登録されたリスナーを数えるには、登録の入口を先に包んでおく必要があるためです。
<!doctype html><meta charset="utf-8">
<script>
// ① 診断用:ゲームのスクリプトより前に置く
const count = new Map();
const orig = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, fn, opt) {
const key = type + "@" + (this.id || this.nodeName || "window");
const n = (count.get(key) || 0) + 1;
count.set(key, n);
console.log("登録", n, "回目:", key);
if (n > 1) console.trace("2回目以降の登録元");
return orig.call(this, type, fn, opt);
};
</script>
<button id="hit">+10</button>
<button id="again">やり直し</button>
<p id="s">0</p>
<script>
// ② 二重登録を含む最小ゲーム
const $ = id => document.getElementById(id);
let score = 0;
function addScore(p, from) {
score += p;
$("s").textContent = score;
console.log("加点", p, "合計", score, from);
}
function setup() {
$("hit").addEventListener("click",
e => addScore(10, e.type + " " + e.timeStamp.toFixed(1)));
}
function restart() { score = 0; $("s").textContent = 0; setup(); }
$("again").addEventListener("click", restart);
setup();
</script>ログの役割は次のとおりです。
- 登録ログは、「種類@対象」ごとに何回目の登録かを数えます。2回目以降はconsole.traceが、その登録に至った呼び出しの連鎖を出します。
- 加点ログは、加点した量、合計、そしてイベントの種類とtimeStamp(イベントが生じた時刻の印)を出します。
このコードは、restartがsetupを呼び、setupがリスナーを登録する構造です。そのため「やり直し」を押したあとは、click@hitの2回目の登録ログと、setupからrestartへとたどれるトレースが出るはずです。以降の「+10」では加点ログが2行になるはずです。必ず手元で動かして、出方を自分の目で確かめてください。
登録の入口を包む方法は、ゲーム本体のコードを1行も変えずに使えるのが利点です。ただし、キーはidか要素名で作っているので、idのない同種の要素が複数あると同じ数え方になります。対象が多い場合は、キーに加える情報を増やしてください。
ログの読み方:行数・時刻の印・呼び出しの連鎖
加点ログが2行出たら、次の表で絞り込みます。
- timeStampが同じ値で2行、登録ログの様子:同じ「種類@対象」が2回以上、疑う場所:登録の重複(初期化の再実行、リスタート時の再登録)
- timeStampが同じ値で2行、登録ログの様子:親と子など、対象が別々に1回ずつ、疑う場所:バブリング(登録位置の見直し、stopPropagationの検討)
- timeStampが違い、typeも違う(touchstartとclickなど)、登録ログの様子:種類が別々、疑う場所:入力種別の重複(pointerdownなどへの一本化)
- timeStampが違い、typeは同じ(keydownなど)、登録ログの様子:登録は1回、疑う場所:押しっぱなしの自動リピート(e.repeatの確認)や連打
- fromの表示がイベント以外、登録ログの様子:登録は1回、疑う場所:加点処理が別の場所からも呼ばれている
決め手はtimeStampです。同じイベントを複数のリスナーが受け取ると、同じイベントオブジェクトを共有するため、時刻の印は同じ値になります。同じ値が2行なら「1回のイベントを2つの処理が受けた」と判断できます。値が違うなら、イベントそのものが2回起きています。
同じ値のときは、登録ログの対象を見ます。同じ対象で2回登録されていれば重複登録です。別の対象なら、バブリングの可能性が高くなります。なお、ブラウザは「同じ対象・同じ種類・同じ関数の参照・同じ指定」の登録を2回目から無視します。二重になるのは、アロー関数や無名関数のように、登録のたびに新しい関数が作られる書き方のときです。名前付き関数を登録しているのに二重なら、別の原因を疑ってください。
呼び出しの連鎖は、2回目の登録ログに付いたconsole.traceの出力で読みます。最上段はログ用のラッパー自身なので無視してください。その下に、実際のaddEventListenerを呼んだ関数、その呼び出し元、とたどれます。ここまで来れば、直すべき行がほぼ見えています。
再現条件を変えて、3回ずつ数える
1回の再現だけで結論を出すと、原因を取り違えやすくなります。条件を変えて、同じ数え方を繰り返してください。
記録の単位は「操作1回につき、加点ログが何行出たか」です。条件ごとに3回ずつ操作して、次のような表に書き込みます。
- ① 読み込み直後、1回目:、2回目:、3回目:
- ② 「やり直し」を1回押したあと、1回目:、2回目:、3回目:
- ③ 「やり直し」を3回押したあと、1回目:、2回目:、3回目:
- ④ タッチ操作(実機または端末エミュレーション)、1回目:、2回目:、3回目:
結果は次のように読みます。
- ①から常に2行なら、起動時から二重です。初期化で登録が2回走っているか、2か所で登録しています。
- ①は1行で、②から2行になるなら、やり直し時の再登録です。
- ③が、②より行数が増えているなら、やり直しのたびに登録が積み上がっています。放置するほど加点が膨らみます。
- ①〜③が1行で、④だけ2行なら、入力種別の重複です。
期待値を先に決めておく
この表が使えるのは、「正解は1行」と言い切れるときだけです。何点入るのが正しいのかが曖昧だと、二重かどうかも判断できません。
公式作品の確認済みの仕様に、その考え方の例があります。「こだまの森」は、聴き直しで15点減り、間違いで30点減り、間違いが3回で終了する設計です。点の増減と終了条件が数字で決まっているので、聴き直しを1回押したら減点が1回分で済んでいるか、という期待値を立てられます。「紙ひこうき郵便」の「風船に3回ぶつかると終了」や、「時計じかけの台所」の「ミスは3回まで」も同様です。回数に上限がある作品では、1回の出来事が2回数えられると、体感が大きく変わってしまいます。
この3作品が内部で実際にどう数えているかは、ここでは扱いません。作品を開いて操作して確かめてください。自作でも、加点・減点・ミスの仕様を数字で書き出してから、ログを見てください。
直し方と、直ったことの確かめ方
直し方は主に2つです。
1つ目は、登録を起動時の1回だけにすることです。 restartでは状態(scoreなど)だけを戻し、setupは呼びません。初期化と状態のリセットを別の関数に分けるのが、根本的な対策です。
2つ目は、登録し直す必要がある場合に、前回の登録をまとめて解除することです。 AbortControllerのsignalを渡すと、abort()で一括解除できます。
let ac;
function setup() {
ac?.abort(); // 前回の登録をまとめて解除
ac = new AbortController();
$("hit").addEventListener("click",
e => addScore(10, e.type + " " + e.timeStamp.toFixed(1)),
{ signal: ac.signal });
}この形にしたあとも、登録ログの回数は「2回目、3回目」と増えていきます。ログは解除を数えないためです。直ったかどうかの判定は登録ログではなく、加点ログの行数で行います。先ほどの表をもう一度、同じ条件で埋めて、すべて1行になっていれば完了です。
バブリングが原因なら登録位置を見直します。入力種別の重複なら、pointerdownなどの1種類に絞るのが定番の対処です。キーの押しっぱなしなら、e.repeatがtrueのときは処理を飛ばします。
投稿の前後で気をつけること
アソバースに投稿する前後に、確認済みの範囲で押さえておきたい点が4つあります。
- ログは手元で取ります。 作品は外部通信なしの独立したHTMLとして動き、サーバー用コードは投稿の対象外です。ログを集める仕組みは作らず、ブラウザの開発者ツールのコンソールで読みます。
- 開発者ツールの対象に注意します。 アップロードされたゲームは、sandbox付きのiframeで、メインサイトとは別のオリジンから配信されて動きます。公開後の画面で調べるときは、コンソールの対象がページ本体ではなくiframe内になっているかを確認してください。切り替えの操作はブラウザごとに違うため、お使いのブラウザの案内で確かめます。
- 診断用の<script>は、アップロードするファイルに含めません。 addEventListenerを書き換えるので、他のコードの挙動にも影響します。公開前に外し、外した状態でもう一度、表の数え方を通してください。投稿は「入力→表示の確認→公開」の3段階です。「表示の確認」の段階でも、同じ回数確認を行うと安心です。直した版は、公開内容の更新で反映できます。
- 保存した値は、直しても自動では戻りません。 SDKのクラウドセーブ(作品ごとにJSONで128KiBまで)を使う場合、未同期の端末セーブはクラウドの古いデータより優先して再送される仕組みです。二重加算で膨らんだ値が保存済みの端末では、修正版を出したあとも、その値が読み込まれる可能性があります。セーブにバージョン番号を持たせ、読み込み時に値が想定の範囲内かを検証する設計が有効です。
なお、ゲーム内スコアの共通ランキングは現在提供されていないため、二重加算がほかの人の順位を直接動かすことはありません。その分、作品内の得点が正しいかどうかは、作者の確認にかかっています。
次の行動
まず上のHTMLを保存し、「やり直し」を押してから「+10」を押して、加点ログが何行出るかを見てください。次に、自作のゲームで、スコアや減点が変わる操作の期待値を紙に書き出し、4つの条件で3回ずつ数えます。行数が1行でない条件が見つかったら、timeStampと登録ログのconsole.traceをたどって、直す行を特定します。
参考資料
- MDN Web Docs「EventTarget.addEventListener()」
- MDN Web Docs「AbortController」「AbortSignal」
- MDN Web Docs「console.trace()」
- MDN Web Docs「Event.timeStamp」「KeyboardEvent.repeat」

