Dual-platform remake of the Atari arcade game Missile Command: pure shared core rules with thin Quil hosts for JVM and browser.
Design specification: docs/superpowers/specs/2026-07-24-missile-command-design.md
User stories: docs/user-stories/
- Clojure CLI
- Babashka (
bb) - APS tools on
PATH:gherkin-parser(and optionallygherkin-mutator) from Acceptance-Pipeline-Specification
src/missile_command/ pure core (.cljc) and acceptance runtime
spec/missile_command/ Speclj unit tests
features/ Gherkin feature files
acceptance/generated/ generated acceptance entrypoints (build product)
build/acceptance/ir/ Gherkin JSON IR (build product)
resources/public/js/ generated browser JavaScript (build product)
Host namespaces (missile-command.jvm, missile-command.browser) contain thin
Quil shells around the shared core. Core unit tests do not load host code.
From the project root:
bb testRuns Speclj structure check on spec/, then Speclj unit tests.
bb acceptRuns gherkin-parser → project acceptance entrypoint generator → generated
executable tests.
Generated outputs are disposable and not source-controlled:
acceptance/generated/, build/acceptance/, and resources/public/js/.
Local caches .cpcache/ and .shadow-cljs/ are also disposable. Do not edit
generated acceptance files by hand. Use bb clean-generated to remove those
outputs without touching source files, SwarmForge state, package manifests, or
tmp/ working notes.
bb arch-checkFails if pure core (.cljc game rules) requires hosts, acceptance, or IO libs.
bb propertyRuns generative property tests (test-property/) separately from unit coverage.
bb playOpens a resizable Quil window at full playfield resolution (default 800×600;
optional bb play 1280 720). Requested sizes are fitted to the largest 4:3
playfield that fits inside them, so 1280×720 becomes 960×720 and 1920×1080
becomes 1440×1080. The window opens on the screen of the terminal
where bb play was typed (tmux client TTY → Terminal.app window match; then
process TTY; then frontmost window; then pointer) and does not steal keyboard
focus — the previous frontmost app (e.g. Terminal) is restored after open;
click the game window when you want to type into it. Mouse moves the crosshair
(clamped to the playfield). Click fires by horizontal third (with empty/destroyed
fallback). Default fire keys: left Z/1, center X/2, right C/3. Esc quits.
The OS cursor is hidden only while playing; other screens show the normal cursor.
Host only draws and routes input; rules stay in the pure core. The HUD includes ammo, score, and wave.
The title, high-score, and options screens include click/tap buttons for their
screen navigation.
High scores and options persist under tmp/missile-command-settings.edn
(override with MC_SETTINGS_PATH).
Global high scores are enabled by default and use:
https://missile-command-leaderboard.unclebob-missile-command.workers.dev.
The high-score screen starts with local scores, then rotates to network scores
after five seconds once a leaderboard read succeeds. Score rows show name,
score, and submission date/time. Network reads and writes run in the background;
slow, unavailable, or rate-limited service responses do not block play. Accepted
network score submissions are limited by the service to one per minute and
twenty per day for both player id and source IP.
When a run ends with a qualifying score, the host opens a player-name dialog and
records that name with the local and network score.
Desktop overrides:
bb play --leaderboard-url https://example.workers.dev --leaderboard-name "Club Board"
bb play --player-name "Uncle Bob"
bb play --no-global-scoresEnvironment overrides are also supported: MC_LEADERBOARD_URL,
MC_LEADERBOARD_NAME, and MC_PLAYER_NAME.
Needs Node/npm once for shadow-cljs and Quil’s p5 dependency:
npm install
bb browserOr watch mode (live reload on port 8020):
npm install
npx shadow-cljs watch browser
# open http://localhost:8020bb browser compiles to resources/public/js/main.js. Open
resources/public/index.html (or http://localhost:8020 in watch mode)
for mouse/keyboard play with the same pure core. Options and high scores
persist in localStorage. On phone-sized/mobile browsers, the title screen
shows a tappable High Scores button and hides/disables Options. A qualifying
score uses the browser name prompt, which opens the phone keyboard. The normal
mouse cursor is visible on every non-play screen; the play screen hides it and
draws the game crosshair instead.
Current note: Safari on iPhone can be laggy during play. Desktop browsers and non-Safari mobile browsers are generally smoother.
The browser host also uses the official global leaderboard by default. To point
at another leaderboard, define window.MISSILE_COMMAND_LEADERBOARD before
loading js/main.js:
<script>
window.MISSILE_COMMAND_LEADERBOARD = {
url: "https://example.workers.dev",
name: "Club Board",
playerName: "Uncle Bob"
};
</script>Use { disabled: true } to run without global scores.
WAVs in resources/sounds/ (mirrored under resources/public/sounds/ for the
browser). Both hosts play them for core SFX events (launch, explosion,
city/battery destroy, low ammo, wave clear, bonus city, THE END). Mute in
options suppresses playback.
These are royalty-free free SFX (Mixkit, etc.), not original Atari ROM
samples (copyrighted). See resources/sounds/CREDITS.md.
QA uses a small, stable launch surface—not a private core API:
Executable QA scripts can be run through the suite wrapper:
bb qa-suite smoke
bb qa-suite feature sound-events
bb qa-suite fullSuite mode runs global architecture/property checks once and skips duplicate
in-script global checks during the script pass. Direct scripts remain available
for focused debugging, for example bb qa/scripts/sound-events.qa.bb.
| Flag | Role |
|---|---|
--qa |
Enable QA mode: telemetry on, accept scenario/events |
--qa-telemetry |
Alias for --qa (telemetry on) |
--qa-scenario <file> |
Initial world state (EDN) |
--qa-events <file> |
Timed input script (text); wait is wall-clock seconds |
--qa-speed <n> |
Multiply sim-time advance vs wall clock (default 1) |
--no-keyfocus |
QA mode only: prevent focus activation and ignore real mouse/key callbacks |
--qa-enemy city:N / battery:… |
Spawn one enemy missile toward a city or battery |
--qa-target x,y |
Add a destroyable test target at playfield coordinates |
--qa-fireball x,y,r |
Seed a live fireball at coordinates with max radius |
--destroy-batteries left,center,… |
Mark listed batteries destroyed at start |
--player-name <name> |
Default player name for desktop prompts and global score identity |
bb play --qa --no-keyfocus
# equivalent: bb play --qa-telemetry --no-keyfocus
bb play --qa --no-keyfocus --qa-events tmp/clicks.txt
bb play --qa --no-keyfocus --qa-scenario tmp/wave-rearm.edn
bb play --qa --no-keyfocus --qa-speed 10 --qa-scenario tmp/setup.edn --qa-events tmp/clicks.txt
bb play 1280 720 --qa --no-keyfocus --qa-scenario tmp/setup.edn # fitted to 960x720
bb play --qa-telemetry --no-keyfocus --qa-enemy city:0
bb play --qa-telemetry --no-keyfocus --qa-target 400,200
bb play --qa-telemetry --no-keyfocus --destroy-batteries left --qa-events tmp/clicks.txtAutomated desktop QA scripts use --no-keyfocus by default. With that flag,
scripted --qa-events still drive the game through the internal queue, while
real keyboard and mouse callbacks from the OS are ignored.
Optional: --qa-events alone with --qa --no-keyfocus (default new-game state,
scripted input only).
Multiply simulation time advance relative to wall clock (default 1). Host
substeps at the normal physics max-dt so large factors stay stable. wait in
event scripts remains wall-clock seconds.
# ~10× faster sim: a 5.7s enemy flight finishes in ~0.6s wall clock
bb play --qa --no-keyfocus --qa-speed 10 --qa-enemy city:0 --qa-events tmp/events.txtOne structured map for setup. New stories add keys; they do not add new CLI flags. Omitted keys keep normal new-game defaults.
{:width 800
:height 600
:wave 1
:batteries {:left {:ammo 2}
:center {:ammo 2}
:right {:ammo 2 :destroyed false}}
:cities {:destroyed [4 5]} ; indices 0–5 left-to-right; others living
:enemies [{:origin [50 0] :target [:city 0]} ; optional angled sky entry
{:target [:battery :left]}]
:targets [{:x 400 :y 200}]} ; optional destroyable stubs (fireball tests)bb play --qa --no-keyfocus --qa-speed 10 --qa-scenario tmp/wave-rearm-depleted.edn --qa-events tmp/events.txt| Key | Meaning |
|---|---|
:width / :height |
Playfield size (else CLI size / default) |
:wave |
Starting wave number |
:batteries |
Per :left / :center / :right: :ammo (0–10), optional :destroyed |
:cities |
:destroyed and/or :alive vectors of city indices 0–5; layout positions follow normal world layout |
:enemies |
Scripted enemies at start; each :target is [:city n] or `[:battery :left |
:targets |
Optional destroyable test targets at playfield coordinates |
:high-score-capacity |
Max table length (default 10) |
:high-scores |
Seed table vector of {:initials "AAA" :score 1000} (replaces loaded table when present) |
:screen |
Optional shell/play screen keyword (e.g. :playing) for staged host setups |
:wave-attack |
Begin sequential wave attack k (1–3) after setup (ballistics; specials on last) |
Examples for common setups:
;; Depleted ammo, two cities ruined — stage a wave rearm check
{:batteries {:left {:ammo 2} :center {:ammo 2} :right {:ammo 2}}
:cities {:destroyed [4 5]}
:enemies [{:target [:city 0]}]}
;; Left battery destroyed; empty preferred click-zone fallback tests
{:batteries {:left {:destroyed true :ammo 10}}}
;; Fireball hit/miss stub target
{:targets [{:x 400 :y 200}]}Spawn one enemy ballistic missile toward a city index or battery.
bb play --qa-telemetry --no-keyfocus --qa-enemy city:0
bb play --qa-telemetry --no-keyfocus --qa-enemy battery:leftActions over time only (not initial state). Host applies events through the same input path as mouse/keyboard:
aim 400 200
click 100 150
key z
key 1
key x
wait 1.2
quit
| Line | Meaning |
|---|---|
aim <x> <y> |
Move crosshair (clamped) |
click <x> <y> |
Click-zone fire at point |
key <name> |
Key fire / UI key (z, 1, x, 2, c, 3, …) |
wait <n> |
Wait n wall-clock seconds (sim advances n * qa-speed) |
start |
Leave title and begin a fresh playing run |
confirm |
Confirm THE END → high-score-entry if score qualifies, else title |
open-high-scores / close-high-scores |
View table from title / return to title (H also toggles; desktop Esc quits) |
initials NAME |
Submit a high-score display name on high-score-entry; stored rows keep a normalized 3-char initials field plus display name and player id when available |
open-options / leave-options |
Options from title / return to title (O also toggles) |
mute true|false |
Set mute on options (or any shell; stored in options) |
difficulty easy|normal|arcade |
Set difficulty scaling for wave metrics |
bind-fire left|center|right <key> |
Remap a fire battery to a single key |
reset-scenario <file> |
Reset the running QA host to a fresh game plus the named scenario |
pause / resume |
Pause/resume play (P / Esc also toggle while playing) |
quit |
Exit cleanly |
Default file: tmp/missile-command-settings.edn.
Override with MC_SETTINGS_PATH or, for QA, --scores-file path (isolated EDN load/save).
Host loads the table at startup; after a successful name submit, the host
rewrites the file. Local rows preserve the display name and a short persistent
local player id plus created-at submit time, while still keeping normalized
initials for older tools. Shape:
{:high-scores [{:initials "UNC" :display-name "Uncle Bob" :public-code "AB12CD"
:score 1000 :created-at "2026-07-28T12:34:56Z"}
{:initials "BOB" :score 500}]
:local-player-code "AB12CD"
:high-score-capacity 10}Network high scores are separate from the local table. A player can appear on both pages; the network page shows the configured leaderboard name plus each player display name. Network score submission is best-effort and skipped until a leaderboard read has succeeded.
Line-oriented key=value records after fires and simulation updates:
qa-fire battery=left missiles_in_flight=1 origin_x=40 origin_y=540 target_x=200 target_y=120
qa-fireball id=3 phase=start t=1.2 center_x=200 center_y=120 radius=0.0
qa-fireball id=3 phase=max t=1.6 center_x=200 center_y=120 radius=40.0
qa-fireball id=3 phase=shrink t=1.7 center_x=200 center_y=120 radius=30.0
qa-fireball id=3 phase=end t=2.0 center_x=0 center_y=0 radius=0.0
qa-sim t=1.5 missiles_in_flight=0 fireballs=1 enemy_missiles=1 center_x=200 center_y=120 radius=20.0
enemy_x=... enemy_y=... enemy_target=city:0 cities_alive=6
battery_left_ammo=10 battery_center_ammo=10 battery_right_ammo=10
wave=1 wave_complete=false wave_enemy_count=6 wave_enemy_speed=1.0
| Field | Meaning |
|---|---|
battery= |
left, center, right, or none (fire attempts) |
missiles_in_flight= |
Defensive missiles in flight |
origin_* / target_* |
Per defensive missile flight vector |
| Each live fireball | Required: center_x, center_y, radius |
Fireball phase= |
start | max | shrink | end with monotonic t= |
enemy_missiles= |
Enemy missiles in flight |
enemy_x / enemy_y / enemy_target= |
Per-enemy position and target (city:N or battery:id) |
enemy_origin_x= / enemy_origin_y= |
Per-enemy sky entry (typically y=0); used for angle checks |
enemy_target_x= / enemy_target_y= |
Per-enemy impact aim point |
cities_alive= / battery destroyed flags |
Living cities / battery state |
battery_*_ammo= |
Remaining missiles per battery |
wave= / wave_complete= |
Wave lifecycle |
wave_attack= |
Current sequential salvo within the wave (1–3, or none) |
wave_attacks_per_wave= |
Number of sequential attacks per wave (3) |
wave_enemy_count= / wave_enemy_speed= |
Ballistics per attack (salvo size) and speed |
wave_mirv_count= / wave_smart_bomb_count= |
Scheduled MIRV / smart-bomb counts |
wave_bomber_count= / wave_satellite_count= |
Scheduled flyer counts |
ballistic_missiles= |
Live ballistic enemies only (excludes MIRV/smart) |
mirv_parents= / smart_bombs= |
Live MIRV parents and smart bombs in flight |
flyers_bomber= / flyers_satellite= |
Live bombers / satellites |
score= |
Running score (starts at 0) |
final_score= |
Score frozen at THE END (else current score) |
multiplier= |
Score multiplier for the current wave (1–6) |
bonus_cities= |
Usable bonus-city reserve |
screen= |
title, playing, paused, the-end, high-score-entry, high-scores, options, or wave-banner |
title_game_name= |
Title copy token (e.g. Missile_Command) |
hud_score= / hud_wave= / hud_multiplier= |
HUD projection of score/wave/mult |
hud_living_cities= / hud_bonus_cities= |
HUD living/bonus city counts |
hud_full= |
true when the full playing HUD is active |
the_end= |
true when THE END sequence is active |
end_message= |
End copy as one token (THE_END or none); never Game_Over |
end_fireball_radius= |
Screen-fill end blast radius (0 when not in THE END) |
end_message_reveal= |
0–1 reveal fraction for THE END lettering |
high_score_count= / high_score_capacity= |
Table length and max N |
pending_high_score= |
Score awaiting high-score name entry (none if not on entry) |
submitted_initials= |
Last submitted initials (none if none) |
initials_draft= |
Host high-score name typing buffer while entering (none if empty) |
hs_rankN_initials= / hs_rankN_score= |
Ranked rows 1–10 when present |
mute= / difficulty= |
Options mute flag and difficulty preset |
fire_key_left= / fire_key_center= / fire_key_right= |
Bound fire keys (comma-separated) |
pause_keys= |
Bound pause keys |
sfx_count= / sfx_last= |
Cumulative SFX log size and recent types |
qa-sfx type=… played=true|false mute=… |
Host play attempt per new core SFX event |
banner_text= / banner_phase= |
Between-wave banner copy (WAVE_N) and enter/exit |
banner_x= / banner_y= |
Banner text position while on wave-banner |
banner_announced_wave= |
Next wave number announced by the banner |
| Destroyable targets | position and destroyed=true|false |
Fireball phase order for one blast: start.t ≤ max.t ≤ shrink.t ≤ end.t.
Radius at max > start; shrink radius < max; center stays on detonation point.
When documented by the host, force or report the current wave index for QA
(rearm, schedule). Prefer README flags; if only events exist, use wave N in
--qa-events.
bb mutate src/missile_command/core.cljc --max-workers 8
bb accept-mutate
bb crap
bb dryLanguage mutation uses differential manifests embedded in source files.
Gherkin acceptance mutation uses gherkin-mutator with the project runner
adapter (clojure -M:acceptance-mutation-runner).
(require '[missile-command.core :as core])
(def state (core/new-game {:width 800 :height 600}))
(core/playfield-width state) ; => 800
(core/playfield-height state) ; => 600