A deterministic two-player card game: 78-card Tarot deck, rules as TypeScript, SolidJS browser UI, optional network multiplayer server. No LLM, no card-text interpretation — every effect is an Op resolved by applyOp.
For AI assistants and new contributors: read this file first, then skim src/cards.ts and src/game.ts for ground truth. Older markdown plans in this folder may be wrong or partially implemented — see Documentation map below.
TAROT-GAME/
├── src/ # Game engine (npm package: tarot-duel-engine)
├── dist/ # Compiled engine — imported by UI and server
├── ui/ # SolidJS + Vite frontend
├── server/ # Express + WebSocket + SQLite multiplayer authority
├── assets/ # Source card art, UI images, SFX/BGM
├── scripts/ # Asset import and publish helpers
├── docs/ # Original engine spec (mostly accurate; verify against code)
├── run-Tarot.bat # Production path: build all + serve on :8787
├── run-Tarot-dev.bat # UI hot-reload on :3000 (needs server separately)
└── run-server.bat # Alias for run-Tarot.bat
Parent repo (../) holds print/tabletop assets (GFX/, TarotGPT/, etc.) — not required to play once art is bootstrapped into assets/.
run-Tarot.batBuilds engine + UI + server, bootstraps art if missing, opens http://localhost:8787.
npm install
npm run bootstrap-art # first time — copies art from ../TarotGPT into assets/
npm run build:all # engine + ui + server
npm run server:start # serves UI + API + WebSocket on port 8787UI dev with hot reload (two terminals):
npm run server # terminal 1 — API/WS on :8787
cd ui && npm run dev # terminal 2 — Vite on :3000, proxies to serverOr use run-Tarot-dev.bat for the UI half only.
Card art: assets/cards/ → published to ui/public/cards/ on every UI build/dev via copy-card-art.mjs.
| Location | Script | Purpose |
|---|---|---|
| root | build |
tsc → compile src/ to dist/ |
| root | build:all |
Engine + UI + server production builds |
| root | start |
build:all then start server |
| root | bootstrap-art |
One-time import from parent repo → assets/ |
| root | copy-art |
Publish assets/ → ui/public/ (cards, sfx, bgm, UI images) |
| root | server / server:start |
Dev watch / production node server |
| root | test |
Run dist/**/*.test.js (smoke test) |
| ui/ | dev |
Copy art + Vite dev server |
| ui/ | build |
Copy art + tsc + Vite production bundle |
| server/ | dev |
tsx watch hot reload |
| server/ | build / start |
Compile and run on port 8787 |
High-level path from player click to new game state:
flowchart TD
UI[UI: GameBoard / stores] --> GS[gameStore actions]
GS --> ENG[Engine: placeCard / advancePhase / resolveCombat / playInstant]
ENG --> AR[ActionResult: state + pendingTriggerOps]
AR --> RA[resolveAction in engine-interactive]
RA --> CH{needsChoice?}
CH -->|yes| IN[inputStore → InputOverlay]
IN --> RA
CH -->|no| AO[applyOp / applyOps in ops.ts]
AO --> ST[New GameState]
ST --> UI
NET[network mode] --> NM[networkManager WebSocket]
NM --> SRV[server actions.ts]
SRV --> ENG
SRV --> CHS[server choices.ts]
CHS --> AO
SRV --> NM
- Pure engine (
src/game.ts,src/ops.ts,src/mimic.ts) — immutableGameState, no UI, no async. Returns{ state, pendingTriggerOps }from player actions. - Interactive layer (
src/engine-interactive.ts) — walkspendingTriggerOps, prompts for targets (scry, discard, mimic, etc.) viaresolveAction+ChoiceResolver, then callsapplyOp. - UI (
ui/src/store/gameStore.ts) — wires engine +resolveAction+inputStore+ sound/save/replay. Tutorial uses scripted Veil opponent fromsrc/tutorial.ts. - Server (
server/src/) — canonical state in SQLite; clients send action verbs, server runs engine + choice resolution, broadcasts sanitized state.
untap → draw → main → combat → response → end → (next player's untap)
advancePhase in game.ts handles skips, delayed draws, heal_per_turn at untap, effect expiration, and win checks.
| File | Role |
|---|---|
types.ts |
GameState, Card, FieldCard, Op, ActiveEffect, PlayerId, phases |
cards.ts |
All 78 cards as data (cardUid, power, effects.on_play / on_field / …) |
ops.ts |
applyOp dispatcher — one handler per verb; injects rng via nextOpRng |
game.ts |
newGame, advancePhase, placeCard, resolveCombat, playInstant, permission queries |
rng.ts |
Seeded splitmix64; GameState.rng_step advances on each op that needs randomness |
mimic.ts |
Mimic execution, field aura application, revert on leave field / combat |
engine-interactive.ts |
resolveAction, needsChoice, getValidTargets, mimic wrapper verbs |
sanitize.ts |
Hide opponent hand/deck for network views |
tutorial.ts |
newTutorialGame, scripted Veil moves (tutorialOpponentMove, block plan) |
index.ts |
Barrel export for UI and server |
smoke.test.ts |
Basic determinism / place / combat sanity check |
Player actions do not apply card effects directly:
const result = placeCard(state, "p1", 0);
// result.pendingTriggerOps = card's on_play ops (may need choices)
const next = await resolveAction(result, "p1", choiceResolver);Same for playInstant, resolveCombat (on_attack / on_block triggers), and phase hooks inside advancePhase.
draw, heal, damage, discard, destroy, add_power, steal_control, scry, shuffle_deck, hand_refresh, exile_graveyard, discard_and_redraw, … — see HANDLERS in ops.ts (~35 verbs).
Not in HANDLERS: mimic wrapper verbs (mimic_opponent, mimic_self, mimic_sword) and heal_per_turn — handled in mimic.ts / applyOnFieldAuras and the untap loop in game.ts.
- Major Arcana instants cost Pentacles tapped on your field (
playInstant), not Cups. - One free field placement per turn in Main; extra plays via
extra_playop; combat placement if you skipped field last turn. - Summoning sickness — field cards can't attack the turn they're played (
turn_played). - Determinism —
GameState.rng_stepmust be serialized with saves/replays/network;applyOpassignsop.rngfromnextOpRng(state). - Mimic — copies a minor on field until it leaves; reverts in combat resolution before graveyard;
source_field_idstamped on triggered ops for attribution. - Card identity — always use
cardUidstrings (e.g.three_of_swords), not display names.
| Area | Key files |
|---|---|
| Screens | MainMenu, GameBoard, CardBrowser, Settings, NetworkMenu, NetworkLobby, LoginScreen |
| Stores | gameStore (engine bridge), inputStore (choice UI), combatStore, tutorialStore, popupStore, settingsStore, authStore |
| Services | networkManager, saveManager (IndexedDB), replayManager, soundManager |
| Components | FieldZone, CardHand, CombatPanel, InputOverlay, GameLog, TutorialOverlay, PentacleTapPanel |
Game modes (gameStore): solo, hotseat, network, tutorial.
Routes in ui/src/main.tsx: /, /game, /cards, /settings, /network, /login, etc.
| Area | Role |
|---|---|
index.ts |
HTTP + static UI + WebSocket /ws |
routes/ |
REST: auth, users, game lobby CRUD |
game/actions.ts |
Apply player verbs against DB-stored GameState |
game/choices.ts |
Server-side choice resolution when ops need targets |
game/combat.ts |
Two-step combat (declare attackers → declare blockers) |
ws/handlers.ts |
Lobby, reconnect, state broadcast |
db/ |
SQLite schema + queries |
Server depends on tarot-duel-engine via "file:.." — rebuild engine after engine changes before server picks them up.
Use this order when docs disagree with code:
| Doc | Trust | Notes |
|---|---|---|
src/*.ts |
Authoritative | Especially cards.ts, game.ts, ops.ts, tutorial.ts |
README.md (this file) |
High | Kept aligned with code; update when architecture changes |
docs/tarot_duel_engine_spec.md |
Medium | Original design spec; good concepts, may lag new verbs/mechanics |
tutorial-design.md |
Low–medium | Narrative/script for tutorial popups; deck lists in src/tutorial.ts override |
multiplayer-spec.md |
Medium | Wire protocol intent; verify against server/src/ws and networkManager.ts |
MIMIC_IMPLEMENTATION_SPEC_v2.md |
Historical | Implementation plan for mimic + 10 card rewrites — done; use code, not spec, for behavior |
MIMIC_IMPLEMENTATION_SPEC.md |
Stale | v1; superseded by v2 — do not use for planning |
When planning features, grep the codebase first. If a spec mentions APIs, verbs, or card effects that don't exist in src/, the spec is wrong.
import {
newGame, advancePhase, placeCard, resolveCombat, playInstant,
resolveAction, getPlayerPermissions, getLegalAttackers, getLegalBlockers,
getPlayableInstants, sanitizeStateForPlayer, concede, passTurn,
newTutorialGame, tutorialOpponentMove,
} from "tarot-duel-engine";| Function | Description |
|---|---|
newGame(seed?, startingLife?) |
Shuffled 78-card decks, opening hands, rng_step: 0 |
advancePhase(state, player?) |
Phase machine + hooks |
placeCard(state, player, handIndex) |
Main/combat placement → ActionResult |
resolveCombat(state, assignments) |
Attack/block resolution → ActionResult |
playInstant(state, player, handIndex, tappedPentacles) |
Major during main/response |
resolveAction(result, player, resolveChoice) |
Async: apply pending ops with UI/server choices |
getPlayerPermissions / getLegalAttackers / getLegalBlockers |
UI gating |
sanitizeStateForPlayer(state, viewer) |
Hide opponent secrets for network |
newTutorialGame() / tutorialOpponentMove(state) |
Scripted tutorial |
- Opponent life ≤ 0
- Opponent decks out on draw
- Concede
npm run build
npm testsmoke.test.ts checks seeded shuffle determinism, a place + phase advance, and combat permissions — not full rules coverage.
MIT