An online implementation of the classic Preferans card game.
This project uses SvelteKit for the web app UI and Cloudflare platform services for deployment and runtime.
| Layer | Technology |
|---|---|
| Web application | SvelteKit 2 + Svelte 5 (runes mode) |
| Primary deployment target | Cloudflare Pages |
| Realtime game rooms | Cloudflare Durable Objects |
| Game API runtime (local dev and legacy split deploy) | Cloudflare Workers |
| Database | Cloudflare D1 (SQLite) |
| Authentication | Google OAuth 2.0 |
preferans/
|- src/ # SvelteKit application
| |- app.css # Global design tokens (colors, type, spacing, motion)
| |- routes/ # Pages, auth routes, game routes
| |- lib/components/ # UI components
| |- lib/stores/ # Client stores (auth, game websocket)
| |- lib/types/ # Shared TypeScript domain types
| |- hooks.server.ts # Session and locale middleware
|- worker/ # Realtime/game worker code
| |- src/
| | |- index.ts # REST + websocket entry for worker runtime
| | |- gameEngine.ts # Preferans rules engine
| | |- durable-objects/
| | |- GameRoom.ts # Durable Object room state + websocket handling
| |- migrations/ # D1 SQL migrations
| |- wrangler.toml # Worker runtime config (dev/split mode)
|- wrangler.toml # Cloudflare Pages project config (source of truth)
|- svelte.config.js
|- vite.config.ts
|- package.json
The project is now documented and configured with Cloudflare Pages as the primary project type:
- Root
wrangler.tomlis the source of truth for Cloudflare Pages settings. - D1 migration scripts use
worker/wrangler.tomlbecause Pages config does not support migration settings. - OAuth/session values are configured as Pages secrets via
wrangler pages secret put ... --project-name <your-pages-project-name>.
The worker/ directory is still used for local realtime API development and for legacy split deployments.
Game WebSocket messages use authoritative state revisions and idempotent action IDs. Heartbeats only request a full snapshot when the client revision is stale, while coarse connection-quality updates let the table explain slow or reconnecting players without continuously sending latency data. The lobby also refreshes its single-use token automatically after transient disconnects and keeps the latest live snapshot visible while reconnecting.
npm installCreate .dev.vars in the project root:
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
SESSION_SECRET=a_random_secret_at_least_32_chars
TEST_LOGIN_SECRET=shared_code_for_dummy_test_logins# Create production database
npx wrangler d1 create preferans-db
# Create preview database (used for non-main branches)
npx wrangler d1 create preferans-db-preview
# Copy database IDs into both files:
# - wrangler.toml
# - worker/wrangler.toml
#
# Replace REPLACE_WITH_PREFERANS_DB_PREVIEW_ID with
# the preview database_id from the command output.npm run db:migrate:localRun these in separate terminals:
npm run worker:dev
npm run devBefore deployment, make sure the Pages project exists in your account and matches the name value in wrangler.toml.
# List existing Pages projects
npx wrangler pages project list
# If missing, create one (choose your own project name)
npx wrangler pages project create <your-pages-project-name>If your real Pages project name is not preferans, update name in wrangler.toml.
npm run pages:deploynpm run pages:deploy is branch-aware:
mainbranch deploys production worker + production Pages deployment.- Any other branch deploys preview worker (
preferans-preview) + preview Pages deployment.
Alternative: connect the Git repository in Cloudflare Pages with:
- Build command:
npm run build - Build output directory:
.svelte-kit/cloudflare
Use Pages-specific secret commands:
npx wrangler pages secret put GOOGLE_CLIENT_ID --project-name <your-pages-project-name>
npx wrangler pages secret put GOOGLE_CLIENT_SECRET --project-name <your-pages-project-name>
npx wrangler pages secret put SESSION_SECRET --project-name <your-pages-project-name>
npx wrangler pages secret put TEST_LOGIN_SECRET --project-name <your-pages-project-name>
# Preview environment secrets (non-main branches)
npx wrangler pages secret put GOOGLE_CLIENT_ID --project-name <your-pages-project-name> --env preview
npx wrangler pages secret put GOOGLE_CLIENT_SECRET --project-name <your-pages-project-name> --env preview
npx wrangler pages secret put SESSION_SECRET --project-name <your-pages-project-name> --env preview
npx wrangler pages secret put TEST_LOGIN_SECRET --project-name <your-pages-project-name> --env preview# Production (main)
npm run db:migrate
# Preview (non-main)
npm run db:migrate:previewNote: migration commands are intentionally bound to worker/wrangler.toml.
If you already have a single-environment setup:
- Create the preview D1 database:
npx wrangler d1 create preferans-db-preview
- Set
database_idforenv.previewin bothwrangler.tomlfiles. - Add preview secrets with
wrangler pages secret put ... --env preview. - Deploy once from a non-
mainbranch (npm run pages:deploy) to create the preview Durable Object layer. - Apply preview migrations (
npm run db:migrate:preview).
- The login page exposes the dummy sign-in section on Pages preview deployments under
*.preferans-6bq.pages.dev. - Set
TEST_LOGIN_SECRETon those secured test deployments to enable one-click creation of numbered temporary dummy players. - Dummy sign-in is disabled on the production domain
pref.khrapunov.com.
The implemented variant is Ленинградка (Leningrad tournament conventions) — see CONVENTIONS.md for the full convention list.
- Deck: 32 cards (7 through Ace); suits rank ♠ < ♣ < ♦ < ♥ < no-trump
- Players: exactly 3
- Deal: 10 cards per player + 2 cards in the widow (прикуп)
- Bidding: multi-round ascending auction from 6♠; misère may only be a player's first bid and is outbid only by nine-level contracts
- Widow: the auction winner takes the widow, discards two cards, and announces the final contract (not lower than the winning bid)
- Whisting: defenders declare вист / пас / полвиста; pass-pass throws the contract in, a lone whister may invite the passer's cards (gentleman's whist) and chooses light or open play
- Распасовка: if everyone passes, an all-pass round is played — widow cards dictate the first two lead suits, trick price grows 2 → 4 → 6 with consecutive all-pass rounds
- Play: trick-taking, must follow suit, must trump when void; misère and light play reveal hands after the first (dark) lead
- Scoring: пуля (pool), гора (mountain, doubled undertricks), and висты; the bullet closes when every player fills the pool, then scores settle via the standard mountain/pool/whist formula
- A table auto-deals as soon as the third player joins; seating order is randomized at that moment.
- Leaving is only available while the table is still in
waiting. - During an active game, players can propose:
- early finish (unanimous yes closes the table with current scores),
- pause (fixed duration or indefinite, also unanimous).
- Admins can pin tables and manually deal out or dismiss tables from the lobby.
- Non-pinned stale tables are auto-closed:
- incomplete waiting tables after 1 hour,
- inactive active tables after 1 hour,
- paused tables at pause deadline or after 1 week of inactivity.
npm run check # TypeScript + Svelte checks
npm run lint # ESLint + Prettier checks
npm run format # Format code
npm run db:migrate # Apply D1 migrations to remote DB
npm run db:migrate:preview # Apply D1 migrations to preview remote DB
npm run db:migrate:local # Apply D1 migrations to local DB