A customizable card game engine driven by declarative JSON rulesets, with multi-device gameplay over local WiFi.
Card Game Engine lets you define the rules of any card game — blackjack, poker, and more — in a .cardgame.json file. The engine loads the ruleset at runtime, manages game state through a phase-based finite state machine, and enforces all rules without a single line of game-specific code. An Android TV acts as the shared display and game host, while players connect their phones as controllers by scanning a QR code.
+------------------+ CouchKit (WiFi) +------------------+
| Android TV | <---------------------------> | Phone (1..n) |
| (Host) | | (Client) |
| | game state, player views | |
| Expo + RN-tvOS | ----------------------------> | Vite + React |
| expo-file-system | | |
| | player actions | |
| Game Engine | <---------------------------- | Controller UI |
+------------------+ +------------------+
|
v
.cardgame.json ruleset
The TV runs the authoritative game engine: it loads the ruleset, advances the FSM, evaluates expressions, and filters state per player. Phones receive only their own view of the game and send actions back to the host. All networking is handled by CouchKit over local WiFi with no internet required.
- Declarative JSON rulesets — define game logic without writing code
- Safe expression language — conditions and effects use a constrained (non-Turing-complete) evaluator with
if()conditional branching andwhile()loops - 41 query builtins + 23 effect builtins — covering common card game mechanics (draw, discard, shuffle, score, card matching, pattern matching, turn order, trick-taking, string variables, etc.)
- Phase-based FSM — supports automatic, player_action, and simultaneous phase types
- Turn order mechanics — clockwise/counterclockwise direction, reverse, skip, and set-next-player effects
- Seeded PRNG — mulberry32 with
crypto.getRandomValuesseed hardening enables deterministic replay from an action log - Hidden information — per-player state filtering via
createPlayerViewwith per-variablepublicvisibility - Security hardening — internal actions (
advance_phase,reset_round) blocked from client submissions, action log capped at 500 entries - Zod schema validation — rulesets are validated against a strict schema at load time
- 2 deck presets + custom decks —
standard52,standard54, plus fully custom card lists
card-game-engine/
├── packages/
│ ├── shared/ @card-engine/shared — game engine core (types, expression
│ │ evaluator, interpreter, PRNG)
│ ├── schema/ @card-engine/schema — JSON Schema, Zod validation, types
│ │ (card, ruleset, state)
│ ├── host/ @card-engine/host — Android TV app (Expo + CouchKit host
│ │ + expo-file-system storage)
│ └── client/ @card-engine/client — phone controller (Vite + React +
│ CouchKit client)
├── rulesets/ .cardgame.json rule files
├── package.json Bun monorepo root (workspaces)
└── tsconfig.json composite TS project references
| Package | Runtime | Key Dependencies |
|---|---|---|
schema |
Pure TypeScript | Zod |
shared |
Pure TypeScript, zero framework deps | @card-engine/schema, Zod |
host |
Expo + React Native | CouchKit host, expo-file-system |
client |
Vite + React 18 | CouchKit client |
- Bun >= 1.2.19
- Android TV device or emulator (for running the host)
- Modern browser (for client development)
git clone https://github.com/faluciano/card-game-engine.git
cd card-game-engine
bun installStart the client dev server (hot-reloading web app):
bun run dev:clientTests live in the shared, schema, and host packages and use Vitest:
cd packages/shared
bunx vitest run883 tests across the shared (770), schema (19), and host (94) packages cover the engine core (expression evaluator, builtins, interpreter, PRNG, schema validation, player views, game phases, integration scenarios), schema meta fields, and the host package (storage, importers). The client package is verified via tsc type-checking and Vite production build.
Build the client, bundle it for CouchKit, and run on Android TV:
bun run build:androidThis is a shorthand that bundles the client assets and launches the Expo Android build. You can also run the steps individually:
bun run build:client # TypeScript check + Vite production build
bun run bundle:client # Bundle client dist into the host's Android assetsType-check the shared and client packages:
bun run typecheck| Command | Description |
|---|---|
bun run dev:client |
Start the client Vite dev server with HMR |
bun run build:client |
TypeScript check + Vite production build |
bun run bundle:client |
Bundle client dist into host's Android assets |
bun run build:android |
Bundle client + Expo Android build |
bun run typecheck |
Type-check shared and client packages |
bun run validate |
Validate all rulesets against the JSON Schema |
bun run catalog |
Generate catalog.json from all rulesets' metadata |
A .cardgame.json file declaratively defines everything the engine needs to run a card game: metadata, deck composition, zones, roles, phases (FSM), scoring, visibility rules, and UI hints.
The rulesets/ directory contains example rulesets:
blackjack.cardgame.json— the reference implementation demonstrating dealer AI, hand value scoring, and partial visibilitycrazy-eights.cardgame.json— a matching/shedding game demonstrating wild 8s (suit choosing), per-card play validation, string variables, draw pile reshuffle, andif()conditional branching
Rulesets support optional catalog fields (description, tags, license) in their meta block. Run bun run catalog to generate a catalog.json index of all rulesets for browsing and discovery. Run bun run validate to validate all rulesets against the schema.
See the Ruleset Authoring Guide for the full format specification, expression language reference, and annotated examples. The Engine API Reference documents all public functions and builtins.
All four implementation phases are complete with 883 passing tests across shared (770), schema (19), and host (94) packages.
| Phase | Status | Tests |
|---|---|---|
| Phase 1 — Engine Core | ✅ Complete | 770 |
| Phase 1.5 — Documentation | ✅ Complete | — |
| Phase 2 — Storage & Import | ✅ Complete | 94 |
| Phase 3 — Host Screens & CouchKit Integration | ✅ Complete | — |
| Phase 3.4 — Schema Package & Catalog | ✅ Complete | 19 |
| Phase 4 — Client Controller App | ✅ Complete | — |
The app builds and deploys to Android TV via bun run build:android. The host runs an HTTP+WebSocket server via CouchKit; phones connect by scanning a QR code displayed on the TV.
Besides the Android TV host, the whole game runs in a browser, so players on
different networks can join without the native app. A display page
(packages/display) owns the authoritative game — the same hostReducer the TV
runs — and reaches phones through a small, game-agnostic relay.
graph LR
subgraph PHONES["📱 Phones"]
P1["Player 1"]
P2["Player 2"]
end
RELAY["🔀 Relay<br/>(one room each)"]
DISPLAY["🖥️ Display<br/>(owns the game)"]
P1 & P2 -- "actions ➡" --> RELAY
RELAY -- "➡ by room" --> DISPLAY
DISPLAY -- "⬅ state updates" --> RELAY
RELAY -- "⬅ to the room" --> P1 & P2
Live:
| Display (put this on the TV) | https://card-game-display.pages.dev |
| Controller (phones) | https://card-game-controller.pages.dev |
Both deploy from main via Cloudflare Pages.
The display shows a room code and a QR linking to
<controller-url>?room=CODE. Opening the controller without a code shows a join
screen where it can be typed; codes are case-insensitive, and a wrong or expired
one says so instead of hanging. The display keeps every feature of the TV
version — ruleset picker, store, lobby, and table.
export VITE_RELAY_URL="wss://couch-kit-relay.faluciano.workers.dev"
export VITE_CONTROLLER_URL="http://localhost:5173" # display's join link
bun run dev:display # room code + QR
bun run dev:client # the controllerThe relay URL is required config with no default in the SDK; to run your own,
see services/relay-worker (Cloudflare) or services/relay (Bun) in the
@couch-kit repo.
all_players_donesentinel always returns true — after any declare action the engine immediately advances through all automatic phases. Affects games where multiple players must each complete an action before the round advances.- JDK version after prebuild —
expo prebuild --cleanregeneratesgradle.properties, removing theorg.gradle.java.homeoverride. Must re-add JDK 17 path andlocal.propertieswithsdk.dirafter each prebuild.
MIT