LG1 Demo App: Mini Invaders
Build a small desktop game (Invaders-style) for Windows 11 that uses only the Python standard library.
UI
- Tkinter GUI
- Draw the game state on a Canvas
- Left / Right keys move the player
- Space fires a shot
- P pauses / resumes
- R restarts the game
- Show the score and lives in Labels
- Show a message in a Label on game over / clear
- Drawing only reads the game state and never changes it
- Time advances by calling tick at a fixed interval with tkinter's after. Tests can call tick directly without relying on after
Domain
- The board is a grid of integer cells, 20 wide and 15 high
- The player is on the bottom row, with x from 0 to 19. It does not move out of range (it stops at the edge)
- The move direction is only left or right. Anything else is a ValueError
- Initially the invaders are 4 rows × 8 columns, left-aligned, starting on the second row from the top
- The invader group moves one cell sideways per tick; when it reaches an edge it moves down one row and reverses direction
- Only one player shot at a time. It moves up one cell per tick. Firing while a shot is in flight is a no-op
- When a shot enters the same cell as an invader, remove that invader, add 10 points to the score, and remove the shot
- Which invader drops a bomb is decided by an injected random.Random(seed). The same seed gives the same result
- Bombs move down one cell per tick. When a bomb enters the player's cell, lose one life and remove the bomb
- Game over when lives reach 0 or the invaders reach the player's row
- Clear when all invaders are destroyed
- After game over / clear, and while paused, operations and tick do not change the state
- A state snapshot is not the internal list itself
- Do not treat bool as an integer coordinate
Persistence
- The high score is JSON
- Saved to data/highscore.json
- UTF-8
- Format: {"high_score": an integer of 0 or more}
- An existing empty JSON file is a StoreError
- Malformed JSON is a StoreError
- Invalid UTF-8 is a StoreError
- Strict schema validation (reject unknown keys, negative numbers, bool, non-integers)
- If the file does not exist, treat it as high_score 0
Atomic save
- Temporary file in the same directory as the destination
- flush
- os.fsync
- os.replace
- Handle failures of mkstemp/write/fsync/replace properly
- A failure before replace does not damage the existing destination
- Clean up the temporary file
Transaction contract
If an exception occurs while persisting the high-score update at the end of a game:
- Fully roll back the game's in-memory state (score, game_over, high_score) to before the operation
- Re-raise the original exception from the persistence layer as is, without converting it
- After rollback, the state the game exposes matches the state that the next save would write
Structure
At least:
- game_model.py (data classes for the state, and StoreError)
- game_engine.py (pure game logic that does not depend on Tk)
- score_store.py (high-score persistence)
- app.py (Tkinter GUI)
- test_game.py
ScoreStore is mainly responsible for persistence and must not co-own the game's mutable state with GameEngine.
Constraints
- Python standard library only
- The app itself does no network communication
- The app itself does not run subprocesses
- No special branches just for tests
- game_engine.py does not import tkinter
Original (Japanese)
LG1 Demo App: Mini Invaders
Windows 11で動くPython標準ライブラリのみの小規模デスクトップゲーム(インベーダー風)を作成する。
UI
- Tkinter GUI
- Canvasにゲーム状態を描画する
- 左キー / 右キーで自機を移動する
- Spaceキーで弾を発射する
- Pキーで一時停止 / 再開する
- Rキーでゲームをリスタートする
- スコアとライフをLabelで表示する
- ゲームオーバー / クリア時にメッセージをLabelで表示する
- 描画はゲーム状態を読み取るだけで、状態を変更しない
- 時間進行はtkinterのafterで一定間隔にtickを呼ぶ。テストからはafterに頼らずtickを直接呼べる
Domain
- ゲーム盤は整数セルの格子で、幅は20、高さは15
- 自機は最下行にいて、xは0以上19以下。範囲外へは移動しない(端で止まる)
- 移動方向はleftまたはrightのみ。それ以外はValueError
- インベーダーは初期状態で4行×8列。最上段から2行目の位置に左寄せで並ぶ
- インベーダー群はtickごとに1セル横へ進み、端に達したら1行下がって向きを反転する
- 自機の弾は同時に1発まで。tickごとに1セル上へ進む。発射済みの間の発射はno-op
- 弾がインベーダーと同じセルに入ったらそのインベーダーを削除し、スコアを10点加算し、弾を消す
- インベーダーの爆弾は、注入されたrandom.Random(seed)でどのインベーダーが撃つかを決める。同じseedなら同じ結果になる
- 爆弾はtickごとに1セル下へ進む。自機と同じセルに入ったらライフを1減らし、爆弾を消す
- ライフが0になるか、インベーダーが自機の行に到達したらゲームオーバー
- インベーダーを全滅させたらクリア
- ゲームオーバー / クリア後および一時停止中の操作とtickは状態を変更しない
- 状態のスナップショットは内部のlistそのものではない
- boolを整数座標として扱わない
Persistence
- ハイスコアはJSON
- 保存先は data/highscore.json
- UTF-8
- 形式は {"high_score": 0以上の整数}
- 既存の空JSONファイルはStoreError
- malformed JSONはStoreError
- invalid UTF-8はStoreError
- strict schema validation(未知のキー、負数、bool、整数以外を拒否)
- ファイルが存在しない場合はhigh_score 0として扱う
Atomic save
- 保存先と同一ディレクトリにtemporary file
- flush
- os.fsync
- os.replace
- mkstemp/write/fsync/replace失敗を適切に扱う
- replace前の失敗では既存destinationを破壊しない
- temporary fileをcleanupする
Transaction contract
ゲーム終了時のハイスコア更新の永続化処理中に例外が発生した場合:
- ゲームのin-memory state(score, game_over, high_score)を操作前へ完全rollbackする
- persistence層から発生した元例外を別の例外へ変更せず、そのまま再raiseする
- rollback後もゲームが公開する状態と次回save対象状態が一致する
Structure
最低限:
- game_model.py (状態を表すデータクラスとStoreError)
- game_engine.py (Tkに依存しない純粋なゲームロジック)
- score_store.py (ハイスコアの永続化)
- app.py (Tkinter GUI)
- test_game.py
ScoreStoreは永続化を主責務とし、ゲームのmutable stateをGameEngineと二重所有しないこと。
Constraints
- Python標準ライブラリのみ
- アプリ本体はネットワーク通信しない
- アプリ本体はsubprocessを実行しない
- テスト専用の特殊分岐を実装しない
- game_engine.pyはtkinterをimportしない