AI のスクリーンショット確認をすり抜けた不具合 2 例
AI コーディングエージェントに画面の実装を任せると、仕上げに自分でスクリーンショットを撮って「見た目を確認しました」と報告してくれます。この確認が成立していなかった不具合を 2 件続けて踏みました。どちらも静止画では写らない種類のもので、遊んだ人からの指摘で初めて分かりました。
題材は、ブラウザで動く放置ゲームです。画面に猫が歩いてきて止まり、クリックして撫でるとコインがたまります。背景はドット絵の部屋で、壁には外が見える窓があります。実装は Claude Code に書かせていて、私はコードと動作を確認する側に回っていました。
この記事では、その 2 件の症状と原因と対処を記録したあと、AI のスクリーンショット確認にどんな盲点があったのかをまとめます。AI にフロントエンドの実装を任せていて、確認の報告をどこまで信じてよいか迷っている人向けの内容です。
事例 1: 窓の外の雲が窓枠からはみ出していた
症状
遊んだ人から「雲が窓からはみ出ている」と指摘がありました。窓の外には雲が 1 つだけゆっくり流れる演出を入れていて、その雲が、本来は見えないはずの窓枠や壁の上に重なって描かれていました。
AI 側のスクリーンショット確認では、この状態は写っていなかったです。撮った瞬間に、たまたま雲が窓の中にいたためです。雲は時間とともに動くので、はみ出すのは周期の一部だけです。
原因
雲の x 座標を決めていた式が、窓ガラスの範囲と一致していなかったです。
const cx = 63 + ((time * 2) % 30) - 6;
この式で雲の左端は 57 から 87 の間を動きます。一方で窓ガラスは 63 から 87 の範囲です。つまり雲は、窓ガラスの左端より 6 px 手前から姿を見せ始めることになります。その 6 px のぶんが、窓枠と壁の上に乗っていました。

根本的な原因は、動く物の移動範囲と表示領域を別々に決めていたことです。
| 決めたもの | 値 | どこで決めたか |
|---|---|---|
| 窓ガラスの表示領域 | 63 から 87 | 窓を描く処理 |
| 雲の移動範囲 | 57 から 87 | 雲を描く処理 |
2 つの数値が別の場所に書かれていると、片方を直した時にもう片方が追従しないです。窓の大きさと雲の動きは、コードの上では無関係な 2 行として並んでいました。
対処
雲の位置を計算で合わせ込むのはやめて、窓ガラスの矩形で切り抜いてから描くようにしました。Canvas では ctx.clip() で描画範囲を制限できます。
ctx.save();
ctx.beginPath();
ctx.rect(63, 7, 24, 18);
ctx.clip();
const cx = 63 - 8 + ((time * 2) % 32);
rect(ctx, cx, 11, 8, 2, '#ffffff');
rect(ctx, cx + 2, 9, 5, 2, '#ffffff');
ctx.restore();
ctx.rect() に渡しているのは、窓ガラスを描く時と同じ矩形です。この中に収まらない部分は描かれないので、雲の式が範囲から外れても画面には出てこないです。
そのうえで、雲の周期を窓幅と雲幅の合計である 32 にしました。窓幅が 24、雲幅が 8 なので、雲は窓の左外から入ってきて、右端から出ていったところで左外に戻ります。切り抜きがあるため、窓の外にいる間は見えないです。
https://developer.mozilla.org/ja/docs/Web/API/CanvasRenderingContext2D/clip
事例 2: 撫でていないのに撫でる演出が出た
症状
猫を撫でた時に手が頭の上を 1 往復する演出を追加したところ、これも遊んだ人から報告がありました。猫が画面に歩いてきて止まった瞬間に、何も操作していないのに撫でる手が出る、という症状です。
AI 側のスクリーンショット確認では気づけなかったです。クリックしたあとの画面しか撮っていなかったためです。手が出ている画面は撮れていたので、演出そのものは正しく動いているように見えていました。
原因
演出の開始条件に、既存の sinceLastPet という変数を流用していました。最後に撫でてからの秒数を持つ変数です。
const stroking = cat.sinceLastPet < STROKE_SEC;
一見すると妥当に見えますが、この変数は元々、撫でる演出とは別の用途で用意したものでした。放置ゲームなので、猫は一定時間撫でられないと帰ってしまいます。この我慢時間を測るための変数です。そのため、猫が歩いてきて止まったタイミングでも 0 に戻していました。
| 場面 | sinceLastPet の動き | 本来の意図 |
|---|---|---|
| 猫が歩いてきて止まった | 0 に戻す | 我慢時間の計測を始める |
| 撫でた | 0 に戻す | 我慢時間を仕切り直す |
| 撫でられるのを待っている間 | 経過時間を足す | 我慢時間を超えたら帰す |
演出側から見ると、0 に戻ったことは「いま撫でられた」という意味です。猫が到着した瞬間もこの条件を満たしてしまうため、操作していないのに手が出ていました。

根本的な原因は、1 つの変数に撫でた時刻と待機の起点という 2 つの意味を持たせていたことです。片方の用途から見て正しい更新が、もう片方の用途では誤発火になります。
対処
演出専用の sinceStroke を足して、意味を分けました。初期値は撫でていない状態を表す大きな値にして、撫でる処理の中でだけ 0 に戻します。
/** 撫でていない状態を表す sinceStroke の初期値 */
export const NO_STROKE = 999;
猫が生まれる時点では NO_STROKE を入れておきます。こうしておけば、到着しても演出の条件を満たさないです。
this.cat = {
kind: pickKind(stats.kindCount, this.rng),
x: dir === 1 ? -CAT_W : WORLD_W,
dir,
phase: 'arriving',
t: 0,
affection: 0,
sinceLastPet: 0,
sinceStroke: NO_STROKE,
};
0 に戻すのは、撫でる処理の中だけに限定します。自動で撫でる機能もこの処理を通るので、演出は自動のぶんでも出ます。
c.affection += 1;
c.sinceLastPet = 0;
c.sinceStroke = 0;
最後に、撫でるまで演出が始まらないことを単体テストで固定しました。猫が待機状態になるまで時間を進め、その時点で sinceStroke が大きい値のままであることを確認しています。
it('撫でるまで撫でる演出は始まらず、撫でると始まる', () => {
const sim = new CatSim(() => 0.1);
for (let i = 0; i < 20 * 30 && sim.cat?.phase !== 'waiting'; i++) sim.update(1 / 30, stats);
expect(sim.cat?.phase).toBe('waiting');
expect(sim.cat!.sinceStroke).toBeGreaterThan(NO_STROKE - 1);
sim.pet(stats);
expect(sim.cat!.sinceStroke).toBe(0);
});
見た目の演出そのものは画像を比べないと検証しにくいですが、その演出を動かしている状態の値は、こうして直接確認できます。
2 つに共通していた見落としの型
2 件がすり抜けた理由は、それぞれ別のものでした。ただ、どちらもスクリーンショットという確認手段そのものの性質から来ています。整理すると、時間軸と状態軸の 2 つに分かれます。
| 軸 | 静止画 1 枚で捉えられないもの | 今回の事例 |
|---|---|---|
| 時間軸 | 周期の中の特定の瞬間 | 雲が窓枠に重なっている一瞬 |
| 状態軸 | 操作していない状態 | 猫が到着しただけの画面 |
時間軸の盲点は、アニメーションする物に付きまといます。雲は十数秒で 1 周しますが、はみ出して見えるのはそのうちのごく一部です。1 枚撮って正常なら、それはおかしくなる瞬間を撮り逃がしただけという可能性が残ります。
状態軸の盲点は、確認の動機から生まれます。撫でる演出を追加したあとに撮りたくなるのは、撫でた画面です。実装した機能が出ている画面を撮れば、その機能が動いていることは確かめられます。確かめられないのは、出ないはずの場面で出ていないかどうかです。
- 時間軸は、1 枚では足りないという問題
- 状態軸は、どの状態を撮るかという問題
- どちらも、撮った画像そのものは正しい
やっかいなのは、AI の報告が嘘ではないところです。撮った画像には不具合が写っていないので、確認した結果として問題なしと言うのは筋が通っています。成立していないのは、その画像で全体を代表できるという前提のほうです。
確認の進め方をどう変えたか
2 件を踏んだあと、AI に実装を任せる時の確認内容を次のように変えました。
まず、スクリーンショットの依頼の仕方です。何枚撮るかではなく、どの時刻とどの状態を撮るかを指定するようにしました。
| 対象 | 指定する内容 |
|---|---|
| 動く表現 | 周期の端にあたる時刻。始まり、中間、終わりの 3 点 |
| 操作で出る表現 | 操作する前の画面。起動直後と、待機している状態 |
次に、動く物の位置計算そのものを信用しないようにしました。雲の例のように、移動範囲と表示領域を別に持つと、どちらかを直した時にずれます。表示していい領域で切り抜いてから描けば、範囲の計算が多少ずれても画面には出てこないです。範囲を合わせ込むより、確実です。
変数を追加する時の観点も増えました。既存の変数を別の目的に流用していないかを見ます。コードの上では値の意味が合っていても、更新するタイミングまで一致しているかは別の話です。
- その変数は、どんな時に更新されるか
- 更新する箇所は、新しい用途から見ても妥当か
- 両方の用途を満たす初期値を決められるか
3 つ目が決められない場合は、意味が 2 つに分かれている証拠なので、変数も分けます。
最後に、画面で確認しにくいものを単体テストに落とすようにしました。撫でる演出の見た目はテストしにくいですが、演出を動かしている値の初期状態はテストできます。見た目と状態を切り分けて、状態のほうをテストで固定しておくと、同じ誤発火には戻らないです。
まとめ
AI にスクリーンショットで確認させた画面に、2 件続けて見落としがありました。どちらも撮った画像は正しく、その画像で全体を代表できるという前提だけが間違っていました。AI に画面の実装を任せる時は、次の点を押さえておくとよいでしょう。
- 動きのある表現は、静止画 1 枚ではおかしくなる瞬間を写せないため、どの時刻を撮るかまで指定する
- 機能を追加したあとの確認は操作した画面に偏りやすいので、何もしていない状態の画面も撮っておく
- 画面では確かめにくい部分は、その表示を動かしている値の初期状態をテストで固定する