「押してないのにミスになる」を潰した300ミリ秒
制限時間が2秒のゲームで起きた、理不尽な失点のバグ。原因は時間切れと次の問題の切り替わりが同じフレームで起きることでした。
ラッシュ系のゲームを作っていて、いちばん厄介だったバグの話です。
症状はこうでした。時間切れになった直後、押してもいない次の問題で即座にミスになる。しかも毎回ではなく、制限時間が2秒まで縮んだ終盤にだけ起きます。
遊んでいる側からすると、こう見えます。「時間切れでライフが減った。あ、次の問題だ」と思った瞬間、もうライフがもう1つ減っている。何が起きたのか分かりません。
何が起きていたか
原因は、時間切れの検出と次の問題の表示が、同じフレームで同期的に起きていたことでした。
ラッシュ系ゲームの毎フレームの処理は、こうなっています。
update() が毎フレーム呼ばれる
→ 経過時間を計算
→ 残り時間が0以下なら onTimeout() を呼ぶ
→ handleWrong() でライフを1つ減らす
→ nextProblem() で次の問題を生成する
問題は最後の行です。nextProblem() が呼ばれた瞬間、画面上の問題も、内部で保持している正解も、その場で新しいものに差し替わります。
いっぽう、プレイヤーの指はどうでしょうか。制限時間ぎりぎりまで粘って、時間切れの直前にタップしたとします。タップのイベントがブラウザから届くのは、ほんのわずかに遅れます。その数ミリ秒の間に、問題が次のものに切り替わっている。
結果、「前の問題に対する回答」が「新しい問題に対する回答」として採点されます。正解であるはずがありません。だから即座にミスになるのです。
なぜ2秒モードでだけ起きたのか
制限時間が10秒あるうちは、時間切れになる前にほとんどの人が答えます。時間切れ自体がめったに起きないので、このバグも表面化しませんでした。
制限時間が2秒になると話が変わります。間に合うか間に合わないかの境界で答えることが増えるので、「時間切れの直前にタップした」という状況が頻発します。
このゲームでいちばん緊張感のある場面で、いちばん理不尽なことが起きていたわけです。最悪の場所にあるバグでした。
解決策:時間切れのあとだけ入力を止める
いくつか案を考えました。
案1:タップの時刻と時間切れの時刻を比較する
タップイベントに記録されている時刻を見て、時間切れより前なら前の問題への回答として扱う。理屈は正しいのですが、時刻の基準がブラウザによって微妙に違ううえ、境界の扱いで新しいバグを生みそうでした。時刻の比較は、ずれたときに原因が追えなくなります。
案2:問題ごとに通し番号を振る
回答イベントに「どの問題への回答か」を持たせて、番号が一致しなければ無視する。これは堅実です。ただ、入力を扱う全ての場所に番号を持ち回る必要があり、コードが煩雑になりました。
案3:時間切れの直後だけ、少しの間なにも受け付けない
最終的にこれを選びました。時間切れでライフを減らしたあと、300ミリ秒だけ入力を無効にしてから、次の問題を出す。
if (reason === 'timeout') {
this.playing = false;
this.time.delayedCall(300, () => {
this.playing = true;
this.nextProblem();
});
return;
}
playing が false のあいだは、入力ハンドラの先頭で弾かれます。遅れて届いたタップは、行き場がなく捨てられます。
なぜ300ミリ秒なのか
短すぎると効きません。長すぎるとテンポが悪くなります。
100ミリ秒では、端末によってはまだ取りこぼしがありました。処理が重い瞬間はフレームが飛ぶので、そのぶん入力の到達も遅れます。
500ミリ秒にすると、今度は**「次の問題がなかなか出ない」**とはっきり感じられるようになりました。2秒で答えるゲームで0.5秒待たされるのは、体感としてかなり長いのです。
300ミリ秒は、取りこぼしがなくなり、かつ待たされた感じがしない境界でした。しかもこの300ミリ秒には、思わぬ副産物がありました。
待ち時間が「失敗を理解する時間」になった
入力を止めている300ミリ秒のあいだ、画面には時間切れになった問題と、ライフが減る演出が表示されたままになります。
修正前は、時間切れの瞬間に次の問題へ切り替わっていたので、何を間違えたのか確認する暇がありませんでした。気づいたらライフが減っている。
300ミリ秒の間があることで、「あ、いま時間切れになったんだ」と認識できるようになりました。バグを直すために入れた待ち時間が、結果的に体験の質を上げていたわけです。
こういうことは、たまにあります。技術的な制約への対処が、そのままデザインとして機能する。
ミスのときは待たない
ひとつ細かい話があります。この300ミリ秒の待ちは、時間切れのときだけ適用しています。プレイヤーが自分でタップして間違えた場合は、待たずにすぐ次の問題に進みます。
理由は、自分で押して間違えた場合は、何が起きたか本人が分かっているからです。確認の時間は要りません。むしろテンポを止めるほうが邪魔になります。
同じ「ミス」でも、原因が違えば適切な扱いは違う。そう考えて、handleWrong() には「なぜ間違えたのか」を表す引数('miss' か 'timeout' か)を渡すようにしています。
学んだこと
このバグから引き出した教訓は2つあります。
ひとつは、状態が切り替わる瞬間は必ず疑うべきだということ。時間切れ、レベルアップ、ゲームオーバー。こうした境界では、直前の入力と直後の状態が食い違う可能性が常にあります。
もうひとつは、ユーザーから見て何が起きたかで判断することです。実装から見れば「1フレーム内で正しく処理された」のですが、遊ぶ側から見れば「押していないのにミスになった」でしかありません。正しく動いていることと、正しく感じられることは別です。
この300ミリ秒の入力ロックは、いまではラッシュ系ゲームを新しく作るときの必須項目として、プロジェクトの設計ガイドラインに「省略不可」と明記してあります。一度踏んだ落とし穴には、柵を立てておくべきなので。
実際の挙動は計算ラッシュや方向パニックの終盤、2秒モードで時間切れになったときに確かめられます。ライフが減ってから次の問題が出るまで、ほんの一拍の間があるはずです。