Sunder is a procedural duel card game inspired by Hearthstone, with LLM-assisted pack generation. Two pirate-radio broadcast stations fight for the airwaves — you play tracks, air broadcasts, and fight for signal in a fully deterministic rules engine. The twist: new card packs are generated with an LLM, but the LLM never invents mechanics — it only shapes flavor, while a seeded sampler produces cards that are schema-valid, budgeted, and executable by the same engine that runs the game.
Everything classic is renamed via sunder/lore.py:
| Classic | Sunder |
|---|---|
| Hero / life | Station / Signal (30) |
| Mana | Bandwidth (cap 10, +1/turn) |
| Deck | Playlist |
| Discard pile | Static |
| Minion | Broadcast |
| Spell | Track |
| Enchantment | Loop |
| Token | Echo |
| Damage | Noise |
# game + web UI server (needs Python >= 3.11)
pip install -e .
python -m sunder # web: http://localhost:8000
# terminal battle (Station 1 = you, Station 2 = AI)
python -m sunder cli
# production SPA build (served automatically at / if web/dist exists)
npm install && npm run build --prefix web
npm run test --prefix web # frontend testsVersus: open the VERSUS tab, create a room and send the invite link — two humans, server-authoritative, with a turn clock, concede and reconnect-on-refresh (docs/multiplayer.md).
Right now matches pit the two starter decks (neon_frequency vs cinderworks) against each other; the AI is a greedy heuristic opponent — no search, no LLM. Going second gets a Signal Spike (a free Track that grants +1 Bandwidth this turn) as second-player compensation — the coin analog.
python -m sunder codex [query] # browse the card set, show budget verdicts
python -m sunder codex -j # same, raw JSON
python -m sunder gen "radio ghost" # generate a 5-card LLM pack for a theme
python -m sunder gen "radio ghost" --count 8 --seed 42 --json
python -m sunder gen "radio ghost" --no-llm # deterministic-only packpython -m sunder gen <theme>Pipeline (one call = one pack of up to 10 cards):
- Theme profile (
gen/profile.py) — one LLM call: affinity weights over the closed vocabulary (types/rarities/triggers/effects/targets/keywords/tags), a name pool and a flavor brief. May propose ≤ 2 new tags with optional structuredmechanic, validated and registered. - Sampler (
gen/sampler.py) — deterministicrandom.Random(seed): rarity, type, keywords, ability tuples, stats, tags. Tag mechanics load onto the carrying card so "a card that carries the tag actually does what the tag promises". - Novelty ledger (
gen/ledger.py) — tests each candidate design against the whole starter codex so packs don't re-ship existing designs. - Budget (
budget.py) — cost is set so every card is in-budget by construction; over-budget cards go through a downgrade ladder (drop ability → drop keyword → shave stats) instead of a reroll. - Humanizer (
gen/humanizer.py) — the only other LLM touch, one tight batched call: card names, flavor text, tag picks — strictly validated (≤ 40 chars, unique names, tags chosen verbatim from the offered menu). Up to 3 retries, then deterministic fallback.
Output mode is "llm" only if the humanizer succeeded with a non-fallback profile, else "deterministic". Profiles are cached in sunder/gen/cache/ keyed by theme + schema version. Graceful degradation throughout — bad JSON, timeout or missing gateway just means smaller/deterministic output, never a crash.
The client (gen/client.py) is a small hand-rolled OpenAI-compatible chat-completions client over httpx, all defaults configurable:
| Env var | Default |
|---|---|
SUNDER_LLM_BASE_URL |
http://127.0.0.1:20128/v1 |
SUNDER_LLM_MODEL |
oc/big-pickle |
SUNDER_LLM_API_KEY |
(optional) |
SUNDER_LLM_TIMEOUT |
120 |
FastAPI app (sunder/server/app.py), docs at /api/docs:
GET /api/cards/api/keywords/api/tags/api/registry/api/healthPOST /api/registry— propose a tagPOST /api/registry/{kind}/{id}/approve— approve a proposalPOST /api/generate— rungenerate_pack(async thread)GET /api/rooms/{code}— versus room status (never exposes tokens)WS /ws— the match protocol:start/restart/play {hand, target?}/attack {board, target}/end_turn/concede, plus the versus lobbycreate_room/join_room/resume/leave_room; every mutation answers each seat with its ownstatesnapshot
Snapshots (state.py) are viewer-aware: opponent hand is a count, hidden B-SIDEs are masked AND precomputed server-side (is_playable, attack_targets, legal_targets) — the client stays dumb.
primitives.py— the closed vocabulary: 3 card types, 4 rarities (incl. mythicSUNDERED), 9 triggers, 13 effects, 8 targets, 6 scalings.schema.py—Carddataclass +parse_card()validation: 0–3 abilities, 0–4 known keywords, stats only for Broadcasts, tags must be registered/approved.engine.py— the data-driven ability resolver: runs any schema-conforming card deterministically, with trigger firing, death processing, stacking,Xscaling.game.py— zone/turn loop, combat, board cap 6, stacks,check_winat 0 Signal (single implementation — the engine delegates to it).ai.py— greedy heuristic opponent: lethal damage → removal → most expensive playable → value trades → face damage, paced with jittered delays for realism.
Costs don't need hand-balancing: budget.py derives cost from clause scores (suggested_cost = score / 2), so any mechanical shape resolves to a fair price tier by construction, with rarity-tiered tolerance (COMMON 2 … SUNDERED 4).
| Key | Display | Rules |
|---|---|---|
LOUD |
LOUD | Can attack the turn it's aired. |
RUSH |
RUSH | Can attack Broadcasts immediately, but not the rival station. |
ECHO |
ECHO | When this goes silent, do the listed effect. |
AMP |
AMP | +1/+1 each time a Broadcast is aired. |
CARRIER |
CARRIER | Always-on ability (aura). |
BEACON |
BEACON | Enemy Broadcasts must target this one (taunt). |
DISTORT |
DISTORT | Noise this deals to Broadcasts is lethal. |
FILTER |
FILTER | Negates the first Noise dealt to this. |
DRAIN |
DRAIN | Noise this deals also boosts your Signal. |
FEEDBACK |
FEEDBACK | Attacks twice. |
MASKED |
MASKED | Can't be targeted until it attacks. |
FUSE |
FUSE | Detonates when the fuse expires. |
B_SIDE |
B-SIDE | Hidden until its listed condition triggers. |
Sets live in sunder/cards/*.json — lists of cards:
{
"id": "neonfreq_001", "name": "Vinyl Golem", "set": "neon_frequency", "plane": "neon_frequency",
"rarity": "COMMON", "cost": 4, "type": "BROADCAST", "stats": {"atk": 3, "def": 5},
"keywords": ["ECHO"],
"tags": ["vinyl", "static"],
"abilities": [{"trigger": "ON_DEATH", "effect": "GAIN_LIFE", "target": "SELF", "scaling": "FLAT", "value": 3}],
"flavor": "…"
}Tag + keyword governance lives in sunder/cards/registry.json (proposed → approved lifecycle, mechanic attachments, keyword reminders). See python -m sunder codex for the human view.
Full documentation lives in docs/:
- Rules — turn loop, combat, all 13 keywords
- Architecture — module map, closed vocabulary, engine design
- Card format — JSON schema, budget model, registry governance
- Card generation — the LLM pipeline + review gate
- Multiplayer — versus rooms, clocks, resume, server authority, roadmap
- Server API — REST + WebSocket protocol, snapshot shape
- Developing — setup, tests, lint, CI, Docker/homelab deploy
pytest tests -x- Engine — combat, interactions, all 13 keywords, B-side, bandwidth, state snapshots
- Match — AI vs AI across seeds: every match terminates, every seed is deterministic
- Generation — every module in isolation (client/SSE, profile, sampler, ledger, humanizer, legality, end-to-end with a
FakeLLMdouble), including graceful-degradation paths - Web — API routes, tag registry, WS loop
- Versus — seat-aware rooms, clocks, grace/resume, lobby protocol, full PvP match over two sockets
- v0.4.0 — Versus. Invite-code PvP rooms with server-side turn clocks, concede, and resume-on-refresh; every match runs in a seat-aware
MatchRoom(the bot is just another seat). Fixes a Station-1-win bug (winner == 0read as "no winner") and hidden B-SIDE names leaking through the log. - v0.3.7.5 — card faces redrawn at the standard 5:7 ratio, with an edition/rarity badge, a station watermark, clamped rules, and a playability signal on the hand cost bars; flavor moves to the inspector. Profile icons are a separate pick from sleeves: the station avatar opens an emblem grid, and the glyph takes your sleeve's ink.
- v0.3.6.1 — changelog inside the game UI (Changelog tab) with a short roadmap.
- Card backs are hand-editable SVG art (
web/src/art/sleeves/*.svg), one file per sleeve, themed via palette channels fromsunder/cards/sleeves.json. Browse + pick in the codex (SLEEVES tab), carried server-side per station so opponents see your back too. Adding a sleeve = one JSON entry + one SVG. - Second station starts with a Signal Spike — the coin analog for the first-turn tempo advantage.
- AMP is a "trigger macro" keyword: implemented engine-side, it buffs the aired card +1/+1 for every Broadcast aired (counting itself, either station, and DOUBLE_NEXT_TRIGGER doubles). ECHO is declarative — the card's ON_DEATH ability carries the behavior, and schema validation guarantees every ECHO card actually has one.