Claude CodeでPygameのゲームを作る — dummyドライバでヘッドレス検証する
画面が見えないClaude CodeにPygameのゲームを検証させる手順です。SDL_VIDEODRIVER=dummyで窓なしに起動し、フレームを回して座標とピクセルを確かめます。
Claude CodeでPygameのゲームを作る — dummyドライバでヘッドレス検証する
Pygameのゲームは、ウィンドウを開いてメインループを回し続けるプログラムです。そのままpython main.pyと実行させると、Claude Codeの側ではウィンドウが閉じられるまで終わらず、画面も見えません。結論から言うと、SDL_VIDEODRIVER=dummyで窓なしの描画先を使い、ループを数十フレームだけ自分で回す検証スクリプトを用意すれば、Claude Codeは座標とピクセルの値でゲームの動作を確かめられます。
この記事では、Pygame 2系で次の4点を順に作ります。
- 窓を開かずに
set_modeまで通す環境変数の指定 - 1フレームずつ進められるゲームの構造
- 入力を再現してピクセルまで確認する
smoke.py - CLAUDE.mdとhookで、編集のたびに検証を回させる設定
手元の確認はPygame 2.6.1(SDL 2.28.4)、Python 3.12.2です。
画面が見えないAgentにはフレームを回させる
Pygameは、実行の主導権をプログラム側が握るライブラリです。公式のQuick startは、この自由さゆえに最初の組み立てでつまずきやすいと説明しています。ループを止める条件はpygame.QUITイベントだけで、これはウィンドウの閉じるボタンから来ます。
画面を持たないClaude Codeには、ここに2つの壁があります。
- 窓を閉じる人がいないので、素のメインループは終わらない
- 描画結果を目で見られないので、「動いた」の根拠が作れない
どちらも、ループを関数に分けて外から回すことで解けます。見るものは画像ではなく、プレイヤーの座標や特定ピクセルの色です。ブラウザゲームでスクリーンショットを撮らせる方法はPhaserゲームの記事で扱っています。Pygameは別ランタイムなので、窓そのものを作らない方向で組み立てます。
SDL_VIDEODRIVER=dummyで窓なしに起動する
Pygame 2ではSDLが映像出力を担当しており、使うドライバを環境変数SDL_VIDEODRIVERで切り替えられます。公式の環境変数一覧には、SDL_VIDEODRIVERとSDL_AUDIODRIVERが載っています。どちらもpygame.init()か、それぞれpygame.display.init()とpygame.mixer.init()を呼ぶ前に設定する必要があります。
dummyは窓を作らないドライバで、set_modeが返すSurfaceにはそのまま描画でき、ピクセルも読めます。公式のリファレンスにこの値の記述は見当たりませんが、手元ではmacOSで次の結果になりました。
SDL_VIDEODRIVER=dummy python -c "
import pygame
pygame.init()
pygame.display.set_mode((320, 240))
print(pygame.display.get_driver())
"出力はdummyです。pygame.display.get_driver()は、初期化時にPygameが選んだ表示バックエンドの内部名を返す関数なので、「いま本当にdummyで動いているか」の確認にそのまま使えます。環境変数を指定しなかった同じ環境ではcocoaが返り、実際の窓が開く経路に入ります。
指定のタイミングを誤ると効きません。pygame.init()を呼んだ後にos.environ["SDL_VIDEODRIVER"] = "dummy"と書いても、手元のget_driver()はcocoaのままでした。公式の「pygame.init()より前に設定する」という注記どおりの挙動です。検証スクリプトでは、import pygameより前の先頭でos.environを設定する形にしておくと、この失敗を避けられます。
1フレームずつ進められる形にゲームを分ける
公式のQuick startにある書き方は、イベント処理・更新・描画が1つのwhileループに入っています。この形のままだと、外から「30フレームだけ進める」ことができません。更新と描画をクラスに出し、ループはmain()の中だけに残します。
import pygame
SPEED = 300 # px/秒
class Game:
def __init__(self, screen):
self.screen = screen
self.player = pygame.Vector2(screen.get_width() / 2, screen.get_height() / 2)
self.held = set() # 押されているキー(自前で管理する)
def handle(self, event):
if event.type == pygame.KEYDOWN:
self.held.add(event.key)
elif event.type == pygame.KEYUP:
self.held.discard(event.key)
def update(self, dt):
if pygame.K_d in self.held:
self.player.x += SPEED * dt
if pygame.K_a in self.held:
self.player.x -= SPEED * dt
def draw(self):
self.screen.fill("purple")
pygame.draw.circle(self.screen, "red", self.player, 40)
pygame.display.flip()
def main():
pygame.init()
screen = pygame.display.set_mode((1280, 720))
clock = pygame.time.Clock()
game = Game(screen)
running = True
dt = 0
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
game.handle(event)
game.update(dt)
game.draw()
dt = clock.tick(60) / 1000
pygame.quit()
if __name__ == "__main__":
main()ポイントは3つです。
update(dt)が時間刻みdtを引数に取る。実時間に依存しないので、検証側が固定値を渡せる- 押されているキーを
heldで自前管理する。理由は次の節で述べる draw()の末尾でpygame.display.flip()を呼ぶ。flip()は表示面全体を更新する関数で、検証でも本番と同じ経路を通せる
clock.tick(60)はmain()にだけ残しています。Clock.tickは1フレームの上限を決めるために待つ関数で、検証で数十フレームを回すたびに待つ必要はありません。検証側では固定のDTを渡してループを直接回します。
検証スクリプトでフレームを回して値を確かめる
smoke.pyは、dummyドライバで起動し、キー入力を再現してフレームを進め、座標とピクセルを確かめます。失敗時は終了コード1で止まるので、Claude Codeは出力と終了コードの両方で結果を判断できます。
import os
os.environ["SDL_VIDEODRIVER"] = "dummy" # pygame.init() より前に置く
os.environ["SDL_AUDIODRIVER"] = "dummy"
import sys
import pygame
from game import Game
pygame.init()
screen = pygame.display.set_mode((1280, 720))
game = Game(screen)
DT = 1 / 60 # 固定の時間刻み。実時間に依存させない
def frames(n):
for _ in range(n):
for event in pygame.event.get():
game.handle(event)
game.update(DT)
game.draw()
start_x = game.player.x
pygame.event.post(pygame.event.Event(pygame.KEYDOWN, key=pygame.K_d))
frames(30) # 0.5 秒ぶん右へ
pygame.event.post(pygame.event.Event(pygame.KEYUP, key=pygame.K_d))
frames(1)
moved = game.player.x - start_x
print(f"driver={pygame.display.get_driver()} moved={moved:.1f}px")
cx, cy = int(game.player.x), int(game.player.y)
pixel = tuple(screen.get_at((cx, cy)))[:3]
print(f"pixel at player={pixel}")
pygame.image.save(screen, "frame.png")
errors = []
if abs(moved - 150) > 1:
errors.append(f"移動量が想定外: {moved}")
if pixel != (255, 0, 0):
errors.append(f"プレイヤー位置の色が赤でない: {pixel}")
if errors:
print("FAIL", *errors, sep="\n- ")
sys.exit(1)
print("OK")実行すると、手元では次の出力でした。
driver=dummy moved=150.0px
pixel at player=(255, 0, 0)
OK1フレームは1/60秒、速度は300ピクセル毎秒、30フレームで0.5秒ぶんなので、150ピクセルの移動が期待値になります。Surface.get_atは指定座標のピクセルを色として返す関数で、赤の円を描いた位置が(255, 0, 0)になっていれば、描画まで通ったことの裏付けになります。背景の"purple"は(160, 32, 240)として読めました。
最後のpygame.image.save(screen, "frame.png")は、SurfaceをBMP・TGA・PNG・JPEGのいずれかで保存する関数です。拡張子が未知だとTGAになる仕様なので、.pngと明示します。保存したPNGは、人が結果を見たいときや、失敗した回の証拠を残したいときに役立ちます。合否の判定自体は、先ほどのような数値の比較に任せるほうが安定します。画像の読み取りは数ピクセルのずれに弱いため、この考え方はPhaserの記事の画像確認の節と共通です。
入力はイベントを投げて再現する
キー入力の再現にはpygame.event.post()を使います。この関数はイベントをキューの末尾に置き、成功したかどうかを真偽値で返します。smoke.pyではKEYDOWNとKEYUPを投げ、ゲーム側のhandle()が押下状態を管理します。
ここで落とし穴になるのがpygame.key.get_pressed()です。公式のQuick startはこの関数でWASDを読んでいますが、event.postで積んだKEYDOWNはget_pressed()の値を変えません。手元でK_dのKEYDOWNを投げてからpump()を呼び、pygame.key.get_pressed()[pygame.K_d]を読むとFalseでした。同じイベントはpygame.event.get()ではKeyDownとして取れています。
つまり、get_pressed()でキーを読む書き方は、そのままだとヘッドレス検証で動きません。対策は2つあります。
| 方法 | 内容 | 向く場面 |
|---|---|---|
| イベント駆動に寄せる | 内容KEYDOWN/KEYUPで押下状態を自分で持つ(本記事のheld) | 向く場面新規に作るゲーム |
| 入力を差し替える | 内容get_pressedを呼ぶ処理を1か所の関数に集め、検証時は偽の入力を返す | 向く場面既存コードを大きく変えたくないとき |
Claude Codeに既存のPygameゲームを渡すときは、「キー入力の取得を1か所に集めてほしい」と先に依頼しておくと、後の検証が組みやすくなります。
CLAUDE.mdに検証ルールを固定する
検証スクリプトを作っても、毎回「smoke.pyを実行して」と頼むのは手間です。CLAUDE.mdに手順を書いておくと、Claude Codeが変更のたびに同じ確認を回せます。公式の記述では、CLAUDE.mdは強制される設定ではなくコンテキストとして扱われます。検証できる具体的な書き方にするほど従われやすくなります。
## Pygameの動作確認ルール
- ゲームのコードを変更したら、完了を宣言する前に `python smoke.py` を実行する
- `python main.py` は実行しない(窓が開いたまま終わらない)
- 新しい機能を足したら、smoke.py にその機能の期待値(座標・色・スコア)を追加する
- キー入力は pygame.key.get_pressed ではなく KEYDOWN/KEYUP イベントで扱う
- 実行結果が OK で終わらなかった項目は「未確認」と報告し、成功と書かない2行目の「python main.pyは実行しない」が、この構成の要です。Pygameのメインループは終了しないので、うっかり実行させるとコマンドが戻らなくなります。禁止を明文化しておくと、この種の事故を防げます。ファイルの長さは、公式が目安として挙げる200行未満に収めます。関連する書き方は、pytestの規約を扱うCLAUDE.mdにpytestの規約を教える記事にもあります。
hookで編集のたびにsmoke.pyを走らせる
CLAUDE.mdの指示は、守られるかどうかが運用次第です。確実に回したいなら、PostToolUsehookでEditとWriteの直後に検証を走らせます。公式のhookガイドには、同じ構造でPrettierを走らせる例があります。検証失敗時にClaude Codeへ知らせる仕組みも、同じガイドと仕様にあります。PostToolUseのhookが終了コード2で終わると、標準エラー出力がClaudeに渡されます。ツールはすでに実行済みなので、編集が取り消されることはありません。
.claude/settings.jsonに次を足します。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "cd \"$CLAUDE_PROJECT_DIR\" && python smoke.py >&2 || exit 2",
"timeout": 60
}
]
}
]
}
}>&2でsmoke.pyの標準出力を標準エラー出力に回しているのは、失敗したときのFAILの内容をClaude Codeに読ませるためです。成功時(終了コード0)の標準エラーはClaudeに渡りません。timeoutは秒単位で、省略時の既定はcommandで600秒です。検証が数秒で終わる前提なら、60秒に絞っておくと、ループが止まらないバグを書いた回でも待ちが長引きません。
hookはドキュメントを編集した場合にも発火するので、それが気になるときはsmoke.pyに渡す前にファイル名で絞る小さなシェルスクリプトを挟みます。テスト実行をhookに載せる設計全般はhooksでテストを自動実行する記事が詳しい内容です。
検証の幅を広げる
smoke.pyの骨格ができたら、同じ型で確認項目を増やせます。
- 当たり判定: 敵を決まった座標に置き、プレイヤーをその位置まで歩かせて、スコアや体力の変化を比較する
- 状態遷移: タイトル、プレイ中、ゲームオーバーの各状態へ、イベントを投げて遷移させる
- 描画の位置:
Rectの中心座標のピクセルをget_atで読み、スプライトが画面の想定位置に描かれているかを見る - 乱数:
random.seed()を固定して、毎回同じ展開にする
どれも「画面を見る」代わりに「値を読む」形です。UnityやC++のプロジェクトでも、コマンドラインで回せる検証を軸に据える点は同じです。進め方はUnityゲーム開発の記事とC++とCMake presetsの記事にあります。
よくあるつまずき
get_driver()がdummyにならない。環境変数を設定した位置がpygame.init()より後になっています。import pygameの前にos.environを書く形に直します。
検証が終わらない。 smoke.pyがmain()を呼んでいる、またはゲームのモジュールがimport時にループを起動する書き方になっています。ループはif __name__ == "__main__":の中に閉じ込めます。
移動量が毎回ずれる。 smoke.pyでclock.tickの返り値をdtに使っていると、実行ごとに値が揺れます。検証では固定のDTを渡します。
音声の初期化でこける。 pygame.mixerを使うゲームは、SDL_AUDIODRIVER=dummyも同じ位置で設定します。公式の環境変数一覧にSDL_AUDIODRIVERが載っており、pygame.init()かpygame.mixer.init()より前に指定する点は映像と同じです。
押した状態を読む処理が効かない。前の節のとおり、get_pressed()は投げたイベントに反応しません。押下状態を自前で持つ形に変えます。
まとめ
窓の出ないPygameの検証は、SDL_VIDEODRIVER=dummyで起動し、get_driver()で確認し、固定のdtでフレームを回して座標とピクセルを比べる、という流れで成立します。ゲーム本体はupdate(dt)とdraw()を持つ形に分け、キー入力はイベント経由で管理しておくと、検証側から再現しやすくなります。
運用の面では、CLAUDE.mdに「smoke.pyを実行する」「main.pyは実行しない」を書き、PostToolUsehookで編集のたびに走らせる構成が基本です。ゲームの機能を足すたびにsmoke.pyの期待値も1つ足していくと、検証の範囲がゲームと同じ速さで広がります。