Compare commits

..

No commits in common. "cc6544f44e64a50ca0c00efc196f039d565db6fd" and "afd8c78253184ce122763fe403bdaac914d590c4" have entirely different histories.

33 changed files with 910 additions and 358 deletions

157
README.md
View File

@ -2,10 +2,10 @@
A Phaser 3.90 framework for classic tabletop games (Backgammon, Parchisi, ...)
and casino games (Blackjack, Texas Hold 'Em, ...), with accounts, profiles,
and match history. Games are single-player against AI opponents.
match history, and multiplayer lobbies.
The frontend uses **native browser ES modules** — no bundler, no build step.
The backend is Node.js + Express with SQLite for persistence.
The backend is Node.js + Express + Socket.IO with SQLite for persistence.
---
@ -19,6 +19,7 @@ The backend is Node.js + Express with SQLite for persistence.
- [Project layout](#project-layout)
- [Database schema](#database-schema)
- [REST API](#rest-api)
- [Socket.IO events](#socketio-events)
- [Frontend architecture](#frontend-architecture)
- [Adding a new game](#adding-a-new-game)
- [Email verification](#email-verification)
@ -35,9 +36,11 @@ The backend is Node.js + Express with SQLite for persistence.
to the console when SMTP is not set up
- Session cookies backed by SQLite (httpOnly, SameSite=Lax)
- Profile management: display name, bio, avatar upload (PNG / JPEG / WebP)
- Match history (wins / losses / draws) recorded for single-player games
- Match history (wins / losses / draws) ready to be populated by games
- Multiplayer lobby system over Socket.IO with rooms, presence, and broadcast
- Pluggable game registry — register tabletop or casino games server-side
- Single-player games against configurable AI opponents
- Base classes (`TabletopGame`, `CasinoGame`) that handle the turn loop /
betting loop scaffolding so new games only implement rules
- 1920×1080 canvas that scales to any viewport via `Phaser.Scale.FIT`
- Vector-only placeholder graphics — drop sprites in later without
refactoring scenes
@ -134,6 +137,12 @@ the template. Fields:
| `SMTP_FROM` | *(see example.env)* | `From:` header for outgoing mail. |
| `VERIFICATION_TOKEN_TTL_HOURS` | `24` | How long verification links remain valid. |
### Multiplayer
| Variable | Default | Description |
|-------------------------|---------------------------|------------------------------------------------------------|
| `SOCKET_IO_CORS_ORIGIN` | `http://localhost:3000` | CORS origin for the Socket.IO server. Use `*` only in dev. |
---
## Running the server
@ -144,8 +153,8 @@ npm start # plain node, production-style
npm run migrate # apply any pending DB migrations
```
The server serves both the API (`/api/*`) and the static frontend (`/`,
`/src/...`, `/uploads/...`) on the same port.
The server serves both the API (`/api/*`), static frontend (`/`, `/src/...`,
`/uploads/...`), and the Socket.IO endpoint (`/socket.io`) on the same port.
After it starts you should see:
@ -165,7 +174,7 @@ fertig-classic-games/
├── README.md
│
├── server/ Backend (Node.js, ES modules)
│ ├── index.js Express bootstrap
│ ├── index.js Express + Socket.IO bootstrap
│ ├── config.js Loads & validates .env
│ ├── db/
│ │ ├── index.js better-sqlite3 connection singleton
@ -183,8 +192,10 @@ fertig-classic-games/
│ │ └── routes.js /api/history
│ ├── email/
│ │ └── mailer.js Nodemailer wrapper with console fallback
│ └── games/
│ └── registry.js Game definitions
│ └── multiplayer/
│ ├── index.js Socket.IO server + auth handshake
│ ├── lobby.js Room manager (create/join/leave)
│ └── gameRegistry.js Game definitions
│
├── public/ Frontend, served as static files
│ ├── index.html Loads Phaser via importmap
@ -195,7 +206,8 @@ fertig-classic-games/
│ ├── config.js UI colors, dimensions, API base
│ ├── services/
│ │ ├── api.js fetch wrapper
│ │ └── auth.js Client-side auth store
│ │ ├── auth.js Client-side auth store
│ │ └── socket.js socket.io-client
│ ├── ui/
│ │ ├── Button.js
│ │ ├── TextInput.js DOM-overlay input that follows canvas scale
@ -209,9 +221,12 @@ fertig-classic-games/
│ │ ├── VerifyScene.js
│ │ ├── ProfileScene.js
│ │ ├── GameMenuScene.js
│ │ ├── OpponentSelectScene.js
│ │ ├── LobbyScene.js
│ │ └── GameRoomScene.js
│ └── games/ One subdirectory per game (uno/, blackjack/, ...)
│ └── games/
│ ├── BaseGame.js
│ ├── tabletop/TabletopGame.js
│ └── casino/CasinoGame.js
│
└── data/ SQLite database (gitignored)
└── fertig.sqlite
@ -274,22 +289,65 @@ automatically; the client uses `credentials: 'same-origin'` in fetch calls.
| Method | Path | Auth | Description |
|--------|----------------|------|------------------------------------------------------|
| GET | `/api/health` | — | `{ ok: true }`. |
| GET | `/api/games` | — | Lists registered games from `games/registry.js`. |
| GET | `/api/games` | — | Lists registered games from `gameRegistry.js`. |
---
## Socket.IO events
Clients connect to the same origin as the page. The server reads the session
cookie during the handshake and rejects unauthenticated connections.
### Server → client
| Event | Payload | When |
|-----------------|----------------------------------------------------|---------------------------------------------------|
| `hello` | `{ user, games }` | On successful connection. |
| `lobby:update` | `Room[]` | After a room is created, joined, or left. |
| `room:update` | `Room` | When the room's players list changes. |
| `room:message` | `{ from: { id, username }, payload, at }` | Chat / game messages routed within a room. |
### Client → server
| Event | Payload | Ack | Notes |
|--------------------|-------------------------------|----------------------------------|----------------------------------------|
| `lobby:subscribe` | `gameSlug` | — | Joins the lobby for that game slug. |
| `lobby:unsubscribe`| `gameSlug` | — | |
| `room:create` | `{ gameSlug, name? }` | `{ ok, room? , error? }` | Creates a room; you join automatically.|
| `room:join` | `{ roomId }` | `{ ok, room?, error? }` | |
| `room:leave` | `{ roomId }` | `{ ok }` | |
| `room:message` | `{ roomId, payload }` | — | Broadcast to other room members. |
`Room` shape:
```js
{
id: string,
gameSlug: string,
name: string,
hostId: number,
players: [{ id: number, username: string, seat: number }],
maxPlayers: number,
status: 'waiting' | 'in_progress' | 'completed'
}
```
---
## Frontend architecture
- **No bundler.** `public/index.html` uses an `<script type="importmap">` to
resolve `phaser` to a CDN ES module. The rest of the app uses relative ES
imports.
resolve `phaser` and `socket.io-client` to CDN ES modules. The rest of the
app uses relative ES imports.
- **Scenes** live in `public/src/scenes/`. The flow is:
`Boot → Preload → Landing → (Login | Register | Verify) → Profile |
GameMenu → OpponentSelect → GameRoom`.
GameMenu → Lobby → GameRoom`.
- **`auth` store** (`services/auth.js`) is a tiny pub-sub the scenes
subscribe to so they re-render when the signed-in user changes.
- **`api`** (`services/api.js`) is a thin fetch wrapper that throws on
non-2xx responses with `err.status` and `err.data` attached.
- **`socket`** (`services/socket.js`) is a lazily-connected singleton; scenes
call `connectSocket()` when entering a multiplayer flow.
- **DOM-overlay inputs.** Phaser doesn't have a native text input, so
`ui/TextInput.js` positions a real `<input>` element over the canvas and
repositions it on scale-resize. The `#dom-layer` div has
@ -300,47 +358,49 @@ automatically; the client uses `credentials: 'same-origin'` in fetch calls.
## Adding a new game
1. **Register the game on the server.** In `server/games/registry.js`:
1. **Register the game on the server.** In `server/multiplayer/gameRegistry.js`:
```js
registerGame({
slug: 'cribbage',
name: 'Cribbage',
category: 'tabletop', // or 'casino' or 'cards'
category: 'tabletop', // or 'casino'
minPlayers: 2,
maxPlayers: 4,
minOpponents: 1,
maxOpponents: 3,
supportsMultiplayer: true,
});
```
The game menu picks it up automatically.
The lobby and game menu will pick it up automatically.
2. **Implement the game scene.** Each game is a `Phaser.Scene` that reads its
setup from the data passed by `GameRoomScene`:
2. **Add a row in the `games` table** if you want to record matches in
history. Either insert in a new migration or via a one-off statement.
3. **Implement the game scene.** Extend `TabletopGame` or `CasinoGame`:
```js
// public/src/games/cribbage/CribbageGame.js
import * as Phaser from 'phaser';
// public/src/games/tabletop/CribbageGame.js
import TabletopGame from './TabletopGame.js';
export default class CribbageGame extends Phaser.Scene {
export default class CribbageGame extends TabletopGame {
constructor() { super('CribbageGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents; // selected AI opponents
this.playfield = data.playfield;
this.cardBack = data.cardBack;
}
create() { /* render board, run the game + AI locally */ }
createBoard() { /* render felt, pegs, cards (placeholder shapes ok) */ }
applyBoardState(state) { /* reconcile pieces & turn */ }
}
```
3. **Register the scene and route to it.** Add the scene to the `scene` array
in `public/src/main.js`, then add its slug → scene-key mapping to the
`slugDispatch` object in `public/src/scenes/GameRoomScene.js`.
4. **Route to it from `GameRoomScene`.** Either switch on `game.slug` and
start the right scene, or load the module dynamically:
4. **Record results (optional).** When a match finishes, `POST` to
`/api/history/single-player` to record the win / loss / draw for history.
```js
const mod = await import(`../games/${category}/${ClassName}.js`);
this.scene.add(ClassName, mod.default, true, { game, room, socket, user });
```
5. **Send/receive game state.** Use `sendAction({ type, ... })` from the base
class. The server should listen for `game:<slug>:action` and respond with
`game:<slug>:state` (public) and `game:<slug>:private` (per-seat hidden
info, for casino games).
---
@ -430,6 +490,10 @@ email is sent; check the server console for the dev link. If SMTP *is* set,
check provider auth (Gmail requires an app password, not your account
password) and `SMTP_PORT` / `SMTP_SECURE` for your provider.
**Socket connection rejected with `Not authenticated`** — sign in first; the
Socket.IO handshake reads the session cookie. Make sure
`SOCKET_IO_CORS_ORIGIN` matches the origin you're browsing from.
**Avatar upload fails with `Unsupported image type`** — only the MIME types
in `ALLOWED_UPLOAD_MIME` are accepted. Add more if needed.
@ -441,9 +505,16 @@ beat; if it persists, file an issue.
## Roadmap
- [ ] Smarter AI opponents per game
- [ ] Richer match history views and per-game stats
- [ ] Replace placeholder vector graphics with sprites
The framework is intentionally game-agnostic. Concrete games come next:
Contributions welcome — start by registering a game in `games/registry.js`
(see [Adding a new game](#adding-a-new-game)).
- [ ] **Backgammon** — 2-player turn-based on `TabletopGame`
- [ ] **Parchisi** — up to 4 players on `TabletopGame`
- [ ] **Blackjack** — 1–6 players on `CasinoGame`
- [ ] **Texas Hold 'Em** — 2–8 players on `CasinoGame`
- [ ] Server-authoritative game state, per-seat private state for casino games
- [ ] Match recording into `matches` / `match_players` for history
- [ ] Friends list + invites
- [ ] Spectator mode
Contributions welcome — start by registering a game in `gameRegistry.js` and
extending one of the base classes.

View File

@ -37,6 +37,10 @@ SMTP_PASS=
SMTP_FROM="Fertig Classic Games <no-reply@fertigclassicgames.local>"
VERIFICATION_TOKEN_TTL_HOURS=24
# ---- Multiplayer (Socket.IO) ----
# CORS origin for the socket server. Use "*" only in development.
SOCKET_IO_CORS_ORIGIN=http://localhost:3000
# ---- Logging ----
# One of: error, warn, info, debug
LOG_LEVEL=info

248
package-lock.json generated
View File

@ -10,11 +10,13 @@
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.3.0",
"cookie": "^0.6.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^2.0.0",
"nodemailer": "^6.9.14"
"nodemailer": "^6.9.14",
"socket.io": "^4.7.5"
},
"engines": {
"node": ">=20"
@ -39,6 +41,35 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@ -148,6 +179,14 @@
}
]
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/bcrypt": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
@ -354,6 +393,14 @@
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
@ -379,6 +426,22 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -489,6 +552,63 @@
"once": "^1.4.0"
}
},
"node_modules/engine.io": {
"version": "6.6.7",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz",
"integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"@types/ws": "^8.5.12",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -1546,6 +1666,107 @@
"simple-concat": "^1.0.0"
}
},
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.18.3"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-adapter/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -1707,6 +1928,11 @@
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -1763,6 +1989,26 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

@ -15,10 +15,12 @@
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.3.0",
"cookie": "^0.6.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^2.0.0",
"nodemailer": "^6.9.14"
"nodemailer": "^6.9.14",
"socket.io": "^4.7.5"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -13,7 +13,8 @@
<script type="importmap">
{
"imports": {
"phaser": "https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.esm.js"
"phaser": "https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.esm.js",
"socket.io-client": "https://cdn.jsdelivr.net/npm/socket.io-client@4.7.5/+esm"
}
}
</script>

View File

@ -0,0 +1,50 @@
import * as Phaser from 'phaser';
// Common lifecycle and helpers for any game. Concrete games extend
// TabletopGame or CasinoGame, which extend this.
//
// Subclasses should implement:
// - createBoard() set up persistent visuals
// - onState(state) apply an authoritative state update from the server
// - onInput(action) optional, for client-side input handling
export default class BaseGame extends Phaser.Scene {
constructor(key) {
super(key);
this.gameDef = null;
this.room = null;
this.socket = null;
this.localUser = null;
}
init(data = {}) {
this.gameDef = data.game;
this.room = data.room ?? null;
this.socket = data.socket ?? null;
this.localUser = data.user ?? null;
}
create() {
this.createBoard?.();
this.bindNetwork();
}
bindNetwork() {
if (!this.socket) return;
this.socket.on(`game:${this.gameDef.slug}:state`, this.handleState);
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
this.socket.off(`game:${this.gameDef.slug}:state`, this.handleState);
});
}
handleState = (state) => {
this.onState?.(state);
};
sendAction(action) {
if (!this.socket || !this.room) return;
this.socket.emit(`game:${this.gameDef.slug}:action`, {
roomId: this.room.id,
action,
});
}
}

View File

@ -0,0 +1,43 @@
import BaseGame from '../BaseGame.js';
// Base for casino games (Blackjack, Texas Hold 'Em, ...).
//
// Casino games differ from tabletop games in two important ways:
// - Hidden information per seat (your hand vs. opponents').
// - A betting/round structure separate from board moves.
//
// Subclasses typically need:
// - createTable() layout: felt, seats, chip stacks, dealer area
// - onState(state) reconcile public state (community cards, pot, ...)
// - onPrivateState(state) reconcile your private hand
// - bet(amount) sendAction({ type: 'bet', amount })
// - fold/check/call/raise sendAction with matching type
export default class CasinoGame extends BaseGame {
constructor(key) {
super(key);
this.pot = 0;
this.round = null;
this.privateState = null;
}
bindNetwork() {
super.bindNetwork();
if (!this.socket) return;
const slug = this.gameDef.slug;
this.socket.on(`game:${slug}:private`, this.handlePrivate);
this.events.once('shutdown', () => {
this.socket.off(`game:${slug}:private`, this.handlePrivate);
});
}
handlePrivate = (state) => {
this.privateState = state;
this.onPrivateState?.(state);
};
bet(amount) { this.sendAction({ type: 'bet', amount }); }
fold() { this.sendAction({ type: 'fold' }); }
check() { this.sendAction({ type: 'check' }); }
call() { this.sendAction({ type: 'call' }); }
raise(amt) { this.sendAction({ type: 'raise', amount: amt }); }
}

View File

@ -148,7 +148,6 @@ export default class GoFishGame extends Phaser.Scene {
this.buildSeatAreas();
this.buildCenter();
this.buildHUD();
this.buildMatchesPanel();
this.startNewMatch();
}
@ -276,65 +275,6 @@ export default class GoFishGame extends Phaser.Scene {
}).setDepth(D.ui);
}
// ── Matches panel ─────────────────────────────────────────────────────────
buildMatchesPanel() {
const RANK_ORDER = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2'];
const px = 10, py = 10;
const padX = 9, padY = 8;
const titleH = 18;
const rowH = 16;
const panelW = 188;
const panelH = padY + titleH + 5 + RANK_ORDER.length * rowH + 5 + rowH + padY;
const bg = this.add.graphics().setDepth(D.ui - 0.5);
bg.fillStyle(0x000000, 0.70);
bg.fillRoundedRect(px, py, panelW, panelH, 7);
bg.lineStyle(1, COLORS.accent, 0.35);
bg.strokeRoundedRect(px, py, panelW, panelH, 7);
this.add.text(px + padX, py + padY, 'Matches so far', {
fontFamily: 'Righteous', fontSize: '13px', color: COLORS.goldHex,
}).setDepth(D.ui);
this._matchPanelRows = RANK_ORDER.map((rank, i) => {
const label = rank === 'T' ? '10' : rank;
const t = this.add.text(
px + padX,
py + padY + titleH + 5 + i * rowH,
`${label.padEnd(2)} = 0 / 2`,
{ fontFamily: 'Righteous', fontSize: '12px', color: COLORS.textHex }
).setDepth(D.ui);
return { rank, text: t };
});
this._matchRemainingText = this.add.text(
px + padX,
py + padY + titleH + 5 + RANK_ORDER.length * rowH + 5,
'Remaining Matches: 26',
{ fontFamily: 'Righteous', fontSize: '12px', color: COLORS.goldHex }
).setDepth(D.ui);
}
updateMatchesPanel() {
if (!this.gs || !this._matchPanelRows) return;
const tally = new Map(this._matchPanelRows.map(({ rank }) => [rank, 0]));
for (const player of this.gs.players) {
for (const rank of player.pairedRanks) {
tally.set(rank, (tally.get(rank) ?? 0) + 1);
}
}
let totalScored = 0;
for (const { rank, text } of this._matchPanelRows) {
const scored = tally.get(rank) ?? 0;
totalScored += scored;
const label = rank === 'T' ? '10' : rank;
text.setText(`${label.padEnd(2)} = ${scored} / 2`);
text.setColor(scored === 2 ? COLORS.mutedHex : COLORS.textHex);
}
this._matchRemainingText.setText(`Remaining Matches: ${26 - totalScored}`);
}
// ── Match lifecycle ────────────────────────────────────────────────────────
startNewMatch() {
@ -416,34 +356,10 @@ export default class GoFishGame extends Phaser.Scene {
this.time.delayedCall(doneAt, () => {
this.scatterPool(finalState.pool);
this.gs = finalState;
this.animating = false;
this.renderAll();
this.updateStatus();
const initialPairs = (finalState.initialDealPairs ?? []).filter((e) => e.pairedCards.length >= 2);
if (initialPairs.length > 0) {
this.playInitialDealPairs(initialPairs, 0, () => {
this.animating = false;
this.maybeStartAITurn();
});
} else {
this.animating = false;
this.maybeStartAITurn();
}
});
}
playInitialDealPairs(pairs, idx, onComplete) {
if (idx >= pairs.length) { onComplete(); return; }
const { seat, pairedCards } = pairs[idx];
const name = this.opponentName(seat);
const rank = pairedCards[0].rank === 'T' ? '10' : pairedCards[0].rank;
const pairCount = pairedCards.length / 2;
const label = pairCount === 1
? `${name} dealt a starting pair of ${rank}s!`
: `${name} dealt ${pairCount} starting pairs!`;
this.showBanner(label);
this.animatePairedCards(seat, pairedCards, () => {
this.hideBanner();
this.time.delayedCall(250, () => this.playInitialDealPairs(pairs, idx + 1, onComplete));
this.maybeStartAITurn();
});
}
@ -516,7 +432,6 @@ export default class GoFishGame extends Phaser.Scene {
}
this.renderSeatChips();
this.renderTurnIndicator();
this.updateMatchesPanel();
}
renderScatteredPool() {
@ -765,23 +680,23 @@ export default class GoFishGame extends Phaser.Scene {
if (last.newPairs > 0 && last.pairedCards?.length >= 2) {
// Cards from animateAsk are already visible in position.
// Do NOT call renderAll() here — it would destroy the liveSprites.
// renderAll() is deferred to after the pair + refill animations complete.
// renderAll() is deferred to after the pair animation completes.
this.hideBanner();
this.animatePairedCards(askerSeat, last.pairedCards, () => {
this.playRefillsThenFinish(() => {
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.maybeStartAITurn();
});
this.renderAll();
this.updateStatus();
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.maybeStartAITurn();
}, liveSprites ?? null);
} else {
this.time.delayedCall(900, () => {
this.renderAll();
this.updateStatus();
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.hideBanner();
this.playRefillsThenFinish(() => {
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.maybeStartAITurn();
});
this.maybeStartAITurn();
});
}
});
@ -1026,84 +941,6 @@ export default class GoFishGame extends Phaser.Scene {
this.time.delayedCall(2200, onComplete);
}
// ── Refill animation ──────────────────────────────────────────────────────
animateRefill(seat, drawnCards, onComplete) {
if (drawnCards.length === 0) { onComplete(); return; }
const slot = this.slotForSeat[seat];
const layout = slotLayout(slot);
const finalHand = this.gs.players[seat].hand;
const STAGGER = 130;
const FLY = 340;
const targetRot = (layout.rotateCards * Math.PI) / 180;
const name = this.opponentName(seat);
this.showBanner(`${name}'s hand was empty — drawing new cards…`);
drawnCards.forEach((card, i) => {
this.time.delayedCall(i * STAGGER, () => {
// Re-use the existing face-down pool sprite if still in cardObjs.
const poolKey = `pool-${card.id}`;
const existing = this.cardObjs.get(poolKey);
let sprite;
if (existing) {
this.cardObjs.delete(poolKey);
this.transientObjs.push(existing);
sprite = existing;
} else {
const pos = this.poolCardPositions.get(card.id) ?? POOL_POS;
sprite = this.makeCardSprite(card, pos.x, pos.y, { faceUp: false });
this.transientObjs.push(sprite);
}
sprite.setDepth(D.card + 5);
// Destination: the card's actual position in the final hand.
const cardInHand = finalHand.find(c => c.id === card.id);
let destX = layout.handCenter.x;
let destY = layout.handCenter.y;
if (cardInHand) {
const n = finalHand.length;
const idx = finalHand.indexOf(cardInHand);
const offset = idx - (n - 1) / 2;
if (layout.handAxis === 'x') destX = layout.handCenter.x + offset * HAND_SPREAD;
else destY = layout.handCenter.y + offset * HAND_SPREAD;
}
if (i % 2 === 0) playSound(this, SFX.CARD_DEAL);
this.tweens.add({
targets: sprite, x: destX, y: destY, rotation: targetRot,
duration: FLY, ease: 'Cubic.easeOut',
onComplete: () => {
if (seat === 0 && cardInHand) this.flipCardFaceUp(sprite, card);
},
});
});
});
const total = (drawnCards.length - 1) * STAGGER + FLY + (seat === 0 ? 380 : 160);
this.time.delayedCall(total, () => {
this.hideBanner();
onComplete();
});
}
// Run each refill entry sequentially, then call renderAll + updateStatus + onDone.
playRefillsThenFinish(onDone) {
const refills = (this.gs.lastAsk?.refills ?? []).filter(r => r.cards.length > 0);
const next = (idx) => {
if (idx >= refills.length) {
this.renderAll();
this.updateStatus();
onDone();
return;
}
const { seat, cards } = refills[idx];
this.animateRefill(seat, cards, () => next(idx + 1));
};
next(0);
}
spawnFireworks(cx, cy) {
const BURST_COLORS = [0xd4a017, 0xe06c75, 0x61afef, 0xc678dd, 0x98c379, 0xe5c07b];
const COUNT = 20;
@ -1165,23 +1002,23 @@ export default class GoFishGame extends Phaser.Scene {
if (last.newPairs > 0 && last.pairedCards?.length >= 2) {
this.time.delayedCall(600, () => {
this.hideBanner();
this.renderAll();
this.updateStatus();
this.animatePairedCards(askerSeat, last.pairedCards, () => {
this.playRefillsThenFinish(() => {
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.maybeStartAITurn();
});
});
});
} else {
this.time.delayedCall(900, () => {
this.hideBanner();
this.playRefillsThenFinish(() => {
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.maybeStartAITurn();
});
});
} else {
this.time.delayedCall(900, () => {
this.renderAll();
this.updateStatus();
this.animating = false;
if (isGameOver(this.gs)) { this.endGame(); return; }
this.hideBanner();
this.maybeStartAITurn();
});
}
};

View File

@ -103,13 +103,9 @@ export function createInitialState({ playerCount = 4, seed } = {}) {
winnerSeats: [],
seed: seed ?? null,
turnCount: 0,
initialDealPairs: [],
};
// Any starting pairs are scored immediately.
for (const p of state.players) {
const { count, pairedCards } = collectPairs(p, state);
if (count > 0) state.initialDealPairs.push({ seat: p.seat, pairedCards });
}
for (const p of state.players) collectPairs(p, state);
// Edge: if a player was dealt all duplicates and now has 0 cards, refill.
for (const p of state.players) ensureHasCards(state, p.seat);
checkGameOver(state);
@ -160,12 +156,10 @@ export function applyAsk(state, targetSeat, rank) {
pairedCards,
};
next.log.push({ kind: 'ask', askerSeat, targetSeat, rank, result: 'catch', count: matches.length });
const refillLog = [];
// Asker may have emptied target's hand — refill them now (before asker's next ask).
ensureHasCards(next, targetSeat, refillLog);
ensureHasCards(next, targetSeat);
// Asker may have emptied own hand by pairing — refill them too.
ensureHasCards(next, askerSeat, refillLog);
next.lastAsk.refills = refillLog;
ensureHasCards(next, askerSeat);
// If asker still has no cards or can't ask, advance turn.
if (!canAsk(next, askerSeat)) advanceTurn(next);
checkGameOver(next);
@ -198,9 +192,7 @@ export function applyAsk(state, targetSeat, rank) {
newPairs: 0,
pairedCards: [],
};
const emptyPoolRefills = [];
ensureHasCards(next, askerSeat, emptyPoolRefills);
next.lastAsk.refills = emptyPoolRefills;
ensureHasCards(next, askerSeat);
advanceTurn(next);
checkGameOver(next);
return next;
@ -231,9 +223,7 @@ export function applyFishPick(state, cardId) {
};
if (lucky) next.log.push({ kind: 'lucky', askerSeat, rank });
next.phase = 'play';
const pickRefills = [];
ensureHasCards(next, askerSeat, pickRefills);
next.lastAsk.refills = pickRefills;
ensureHasCards(next, askerSeat);
if (!lucky || !canAsk(next, askerSeat)) advanceTurn(next);
checkGameOver(next);
return next;
@ -273,19 +263,15 @@ function collectPairs(player, state) {
* If the player's hand is empty, draw up to REFILL_TARGET from the pool.
* If still empty afterward, mark them as sitting out.
*/
function ensureHasCards(state, seat, refillLog) {
function ensureHasCards(state, seat) {
const player = state.players[seat];
if (player.sittingOut) return;
if (player.hand.length > 0) return;
const drawn = [];
while (player.hand.length < REFILL_TARGET && state.pool.length > 0) {
const card = state.pool.pop();
player.hand.push(card);
drawn.push(cloneCard(card));
player.hand.push(state.pool.pop());
}
if (player.hand.length > 0) {
state.log.push({ kind: 'refill', seat, count: player.hand.length });
if (refillLog && drawn.length > 0) refillLog.push({ seat, cards: drawn });
collectPairs(player, state);
}
if (player.hand.length === 0) {

View File

@ -382,7 +382,7 @@ export default class HoldemGame extends Phaser.Scene {
const modalItems = [overlay, panel, title, balanceTxt, buyInTxt];
if (this.globalChips <= 0) {
this.add.text(CX, CY + 40, 'You have no chips! Return to the menu.', {
this.add.text(CX, CY + 40, 'You have no chips! Return to the lobby.', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.modal);
return;

View File

@ -164,7 +164,6 @@ export default class ParchisiGame extends Phaser.Scene {
this.buildTurnIndicator();
this.buildPawns();
this.initGame();
this.buildDismissHandler();
}
buildPlayfield() {
@ -595,20 +594,6 @@ export default class ParchisiGame extends Phaser.Scene {
return this.add.container(x, y, [g]);
}
buildDismissHandler() {
this.input.on('pointerdown', (_ptr, gameObjects) => {
if (this.selectedPawnIdx === null || this.animating) return;
const hitRelevant = gameObjects.some((o) =>
this.highlightObjs.includes(o) ||
PCOLORS.some((c) => this.pawnObjs[c]?.includes(o))
);
if (!hitRelevant) {
this.clearHighlights();
this.showMovablePawnHints();
}
});
}
// ── Game flow ─────────────────────────────────────────────────────────────
initGame() {
this.clearHighlights();
@ -710,7 +695,6 @@ export default class ParchisiGame extends Phaser.Scene {
this.time.delayedCall(600, () => this.runAITurnMoves());
} else {
this.setStatus('Choose a pawn to move');
this.showMovablePawnHints();
}
});
}
@ -810,7 +794,6 @@ export default class ParchisiGame extends Phaser.Scene {
}
this.setStatus('Choose a pawn');
this.updateButtons();
this.showMovablePawnHints();
return;
}
if (this.gs.phase === 'roll') {
@ -979,31 +962,6 @@ export default class ParchisiGame extends Phaser.Scene {
this.selectedPawnIdx = null;
}
showMovablePawnHints() {
const moves = getValidMoves(this.gs);
const movable = [...new Set(moves.map((m) => m.pawnIdx))];
for (const pawnIdx of movable) {
const obj = this.pawnObjs['red'][pawnIdx];
const px = obj.x;
const py = obj.y;
// Static glow ring — pulses alpha
const glow = this.add.graphics().setDepth(DEPTH.highlight);
glow.lineStyle(2.5, 0x00e5b0, 0.9);
glow.strokeCircle(px, py, PAWN_R + 6);
this.tweens.add({ targets: glow, alpha: { from: 0.9, to: 0.25 }, duration: 550, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
// Expanding ripple ring
const ripple = this.add.graphics().setDepth(DEPTH.highlight);
ripple.setPosition(px, py);
ripple.lineStyle(2, 0x00e5b0, 0.75);
ripple.strokeCircle(0, 0, PAWN_R + 6);
this.tweens.add({ targets: ripple, scaleX: 2.0, scaleY: 2.0, alpha: 0, duration: 950, repeat: -1, ease: 'Cubic.easeOut' });
this.highlightObjs.push(glow, ripple);
}
}
// ── UI updates ────────────────────────────────────────────────────────────
updateButtons() {
const canRoll = !this.animating

View File

@ -0,0 +1,34 @@
import BaseGame from '../BaseGame.js';
// Base for turn-based tabletop games (Backgammon, Parchisi, Chess, ...).
//
// Subclasses typically need:
// - createBoard() lay out the board, pieces, dice (placeholder shapes ok)
// - onState(state) reconcile pieces, current turn, dice rolls
// - requestMove(move) call sendAction({ type: 'move', ...move })
//
// The framework here provides the turn loop scaffolding: who's on move, a
// pending-move queue, and a helper to lock input when it's not your turn.
export default class TabletopGame extends BaseGame {
constructor(key) {
super(key);
this.currentTurnUserId = null;
this.pendingMove = null;
}
isMyTurn() {
return this.localUser && this.currentTurnUserId === this.localUser.id;
}
onState(state) {
this.currentTurnUserId = state.currentTurnUserId ?? null;
this.applyBoardState?.(state);
}
requestMove(move) {
if (!this.isMyTurn()) return false;
this.pendingMove = move;
this.sendAction({ type: 'move', move });
return true;
}
}

View File

@ -9,6 +9,7 @@ import VerifyScene from './scenes/VerifyScene.js';
import ProfileScene from './scenes/ProfileScene.js';
import GameMenuScene from './scenes/GameMenuScene.js';
import OpponentSelectScene from './scenes/OpponentSelectScene.js';
import LobbyScene from './scenes/LobbyScene.js';
import GameRoomScene from './scenes/GameRoomScene.js';
import BackgammonGame from './games/backgammon/BackgammonGame.js';
import HoldemGame from './games/holdem/HoldemGame.js';
@ -42,6 +43,7 @@ const config = {
ProfileScene,
GameMenuScene,
OpponentSelectScene,
LobbyScene,
GameRoomScene,
BackgammonGame,
HoldemGame,

View File

@ -1,6 +1,7 @@
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { api } from '../services/api.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';
import { playMenuMusic } from '../ui/MenuMusic.js';
@ -63,6 +64,11 @@ export default class GameMenuScene extends Phaser.Scene {
}
openGame(game) {
this.scene.start('OpponentSelect', { game });
if (game.multiplayerOnly) {
if (!auth.user) { this.scene.start('Login'); return; }
this.scene.start('Lobby', { game });
} else {
this.scene.start('OpponentSelect', { game });
}
}
}

View File

@ -1,14 +1,17 @@
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { getSocket } from '../services/socket.js';
import { Button } from '../ui/Button.js';
// Generic room shell. Dispatches to the concrete game scene
// (Backgammon, Blackjack, etc.) based on `data.game.slug`.
// Generic room shell. A concrete game (Backgammon, Blackjack, etc.) will
// later be instantiated here based on `data.game.slug`, taking over the
// rendering and input handling.
export default class GameRoomScene extends Phaser.Scene {
constructor() { super('GameRoom'); }
init(data) {
this.game = data.game;
this.room = data.room ?? null;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
@ -33,7 +36,9 @@ export default class GameRoomScene extends Phaser.Scene {
color: COLORS.textHex,
}).setOrigin(0.5);
this.add.text(cx, 150, 'Single-player table',
this.add.text(cx, 150, this.room
? `Table: ${this.room.name}`
: 'Single-player table',
{ fontSize: '24px', color: COLORS.mutedHex }).setOrigin(0.5);
// Placeholder table felt
@ -45,6 +50,34 @@ export default class GameRoomScene extends Phaser.Scene {
align: 'center',
}).setOrigin(0.5);
new Button(this, cx, GAME_HEIGHT - 100, 'Leave table', () => this.scene.start('GameMenu'));
this.seatsText = this.add.text(cx, GAME_HEIGHT - 220, this.formatSeats(this.room), {
fontSize: '22px', color: COLORS.textHex, align: 'center',
}).setOrigin(0.5);
new Button(this, cx, GAME_HEIGHT - 100, 'Leave table', () => this.leave());
if (this.room) {
const socket = getSocket();
socket.on('room:update', this.onRoomUpdate = (room) => {
if (room.id !== this.room.id) return;
this.room = room;
this.seatsText.setText(this.formatSeats(room));
});
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
socket.off('room:update', this.onRoomUpdate);
});
}
}
formatSeats(room) {
if (!room) return 'Solo session';
const seats = room.players.map((p) => `Seat ${p.seat + 1}: ${p.username}`).join(' · ');
return `${room.players.length}/${room.maxPlayers} players\n${seats}`;
}
leave() {
const socket = getSocket();
if (this.room) socket.emit('room:leave', { roomId: this.room.id }, () => {});
this.scene.start('GameMenu');
}
}

View File

@ -37,15 +37,11 @@ export default class LandingScene extends Phaser.Scene {
this.renderButtons();
if (this._authUnsub) this._authUnsub();
this._authUnsub = auth.subscribe(() => {
auth.subscribe(() => {
if (!this.scene.isActive('Landing')) return;
this.children.removeAll();
this.create();
});
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
if (this._authUnsub) { this._authUnsub(); this._authUnsub = null; }
});
}
renderButtons() {
@ -104,15 +100,15 @@ export default class LandingScene extends Phaser.Scene {
}).setOrigin(0.5);
}
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'), { width: 480 });
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'));
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'));
new Button(this, cx, 970, 'Sign out', async () => {
await auth.logout();
}, { variant: 'ghost', width: 480 });
}, { variant: 'ghost' });
} else {
new Button(this, cx, 690, 'Sign in', () => this.scene.start('Login'), { width: 480 });
new Button(this, cx, 770, 'Create account', () => this.scene.start('Register'), { width: 480 });
new Button(this, cx, 850, 'Continue as guest', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 480 });
new Button(this, cx, 690, 'Sign in', () => this.scene.start('Login'));
new Button(this, cx, 770, 'Create account', () => this.scene.start('Register'));
new Button(this, cx, 850, 'Continue as guest', () => this.scene.start('GameMenu'), { variant: 'ghost' });
}
}
}

View File

@ -0,0 +1,87 @@
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { connectSocket, getSocket } from '../services/socket.js';
import { Button } from '../ui/Button.js';
import { Modal } from '../ui/Modal.js';
export default class LobbyScene extends Phaser.Scene {
constructor() { super('Lobby'); }
init(data) { this.game = data.game; }
create() {
const cx = GAME_WIDTH / 2;
this.add.text(cx, 100, `${this.game.name} — Lobby`, {
fontFamily: 'Righteous',
fontSize: '52px',
color: COLORS.textHex,
}).setOrigin(0.5);
this.listText = this.add.text(cx, 220, 'Connecting…', {
fontFamily: '"Julius Sans One"',
fontSize: '24px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
this.roomContainer = this.add.container(0, 280);
new Button(this, cx - 220, GAME_HEIGHT - 120, 'Create table', () => this.createRoom());
new Button(this, cx + 220, GAME_HEIGHT - 120, 'Back', () => this.exitLobby(), { variant: 'ghost' });
const socket = connectSocket();
socket.on('connect_error', (err) => this.listText.setText(`Socket error: ${err.message}`));
socket.on('connect', () => {
this.listText.setText('No tables yet. Create one to start.');
socket.emit('lobby:subscribe', this.game.slug);
});
socket.on('lobby:update', (rooms) => this.renderRooms(rooms));
}
exitLobby() {
const socket = getSocket();
socket.emit('lobby:unsubscribe', this.game.slug);
this.scene.start('GameMenu');
}
renderRooms(rooms) {
this.roomContainer.removeAll(true);
if (!rooms.length) {
this.listText.setText('No tables yet. Create one to start.');
return;
}
this.listText.setText(`${rooms.length} table(s) open`);
const cx = GAME_WIDTH / 2;
rooms.forEach((room, i) => {
const y = i * 90;
const bg = this.add.rectangle(cx, y, 1200, 70, COLORS.panel).setStrokeStyle(1, COLORS.accent);
const label = this.add.text(cx - 580, y, `${room.name} · ${room.players.length}/${room.maxPlayers}`, {
fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0, 0.5);
const joinBtn = new Button(this, cx + 500, y, 'Join', () => this.joinRoom(room.id), { width: 140, height: 52, fontSize: 22 });
this.roomContainer.add([bg, label, joinBtn]);
});
}
createRoom() {
const socket = getSocket();
socket.emit('room:create', { gameSlug: this.game.slug }, (resp) => {
if (!resp?.ok) {
new Modal(this, resp?.error ?? 'Could not create table.', { color: COLORS.dangerHex, autoCloseMs: 2400 });
return;
}
this.scene.start('GameRoom', { game: this.game, room: resp.room });
});
}
joinRoom(roomId) {
const socket = getSocket();
socket.emit('room:join', { roomId }, (resp) => {
if (!resp?.ok) {
new Modal(this, resp?.error ?? 'Could not join.', { color: COLORS.dangerHex, autoCloseMs: 2400 });
return;
}
this.scene.start('GameRoom', { game: this.game, room: resp.room });
});
}
}

View File

@ -23,9 +23,9 @@ export default class LoginScene extends Phaser.Scene {
this.add.text(cx - 180, 460, 'Password', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const passwordInput = new TextInput(this, cx, 500, { width: 480, type: 'password', autocomplete: 'current-password' });
const submit = new Button(this, cx, 620, 'Sign in', () => this.attemptLogin(identifierInput, passwordInput, submit), { width: 480 });
new Button(this, cx, 700, 'Need an account?', () => this.scene.start('Register'), { variant: 'ghost', width: 480 });
new Button(this, cx, 780, 'Back', () => this.scene.start('Landing'), { variant: 'ghost', width: 480 });
const submit = new Button(this, cx, 620, 'Sign in', () => this.attemptLogin(identifierInput, passwordInput, submit));
new Button(this, cx, 700, 'Need an account?', () => this.scene.start('Register'), { variant: 'ghost' });
new Button(this, cx, 780, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
this.input.keyboard.on('keydown-ENTER', () => this.attemptLogin(identifierInput, passwordInput, submit));
}

View File

@ -26,9 +26,9 @@ export default class RegisterScene extends Phaser.Scene {
this.add.text(cx - 180, 540, 'Password (min 8 chars)', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const passwordInput = new TextInput(this, cx, 580, { width: 480, type: 'password', autocomplete: 'new-password' });
const submit = new Button(this, cx, 700, 'Create account', () => this.attempt(emailInput, usernameInput, passwordInput, submit), { width: 480 });
new Button(this, cx, 780, 'Already have an account?', () => this.scene.start('Login'), { variant: 'ghost', width: 480 });
new Button(this, cx, 860, 'Back', () => this.scene.start('Landing'), { variant: 'ghost', width: 480 });
const submit = new Button(this, cx, 700, 'Create account', () => this.attempt(emailInput, usernameInput, passwordInput, submit));
new Button(this, cx, 780, 'Already have an account?', () => this.scene.start('Login'), { variant: 'ghost' });
new Button(this, cx, 860, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
this.input.keyboard.on('keydown-ENTER', () => this.attempt(emailInput, usernameInput, passwordInput, submit));
}

View File

@ -45,7 +45,7 @@ export default class VerifyScene extends Phaser.Scene {
new Button(this, cx, 640, 'I have verified — refresh', async () => {
await auth.refresh();
this.scene.start('Landing');
}, { width: 480 });
new Button(this, cx, 720, 'Continue without verifying', () => this.scene.start('Landing'), { variant: 'ghost', width: 480 });
});
new Button(this, cx, 720, 'Continue without verifying', () => this.scene.start('Landing'), { variant: 'ghost' });
}
}

View File

@ -12,7 +12,7 @@ class AuthStore {
}
emit() {
for (const fn of [...this.listeners]) fn(this.user);
for (const fn of this.listeners) fn(this.user);
}
async refresh() {

View File

@ -0,0 +1,20 @@
import { io } from 'socket.io-client';
let socket = null;
export function getSocket() {
if (!socket) {
socket = io({ withCredentials: true, autoConnect: false });
}
return socket;
}
export function connectSocket() {
const s = getSocket();
if (!s.connected) s.connect();
return s;
}
export function disconnectSocket() {
if (socket?.connected) socket.disconnect();
}

View File

@ -20,15 +20,10 @@ export class Modal extends Phaser.GameObjects.Container {
this.add([overlay, panel, text]);
scene.add.existing(this);
const domLayer = document.getElementById('dom-layer');
if (domLayer) domLayer.style.visibility = 'hidden';
const restore = () => { if (domLayer) domLayer.style.visibility = ''; };
if (options.autoCloseMs) {
scene.time.delayedCall(options.autoCloseMs, () => { restore(); this.destroy(); });
scene.time.delayedCall(options.autoCloseMs, () => this.destroy());
} else {
overlay.on('pointerdown', () => { restore(); this.destroy(); });
overlay.on('pointerdown', () => this.destroy());
}
this.once('destroy', restore);
}
}

View File

@ -58,6 +58,10 @@ const config = {
verificationTtlHours: int(process.env.VERIFICATION_TOKEN_TTL_HOURS, 24),
},
socket: {
corsOrigin: process.env.SOCKET_IO_CORS_ORIGIN ?? 'http://localhost:3000',
},
publicDir: path.join(ROOT, 'public'),
};

View File

@ -24,17 +24,12 @@ export async function sendVerificationEmail(to, link) {
return { delivered: false, devLogged: true };
}
try {
await transporter.sendMail({
from: config.email.from,
to,
subject,
text,
html,
});
return { delivered: true, devLogged: false };
} catch (err) {
console.error(`[mailer] Failed to send verification email to ${to}:`, err);
return { delivered: false, devLogged: false, error: err.message };
}
await transporter.sendMail({
from: config.email.from,
to,
subject,
text,
html,
});
return { delivered: true, devLogged: false };
}

View File

@ -1,7 +1,7 @@
import { Router } from 'express';
import db from '../db/index.js';
import { requireAuth } from '../auth/middleware.js';
import { getGame } from '../games/registry.js';
import { getGame } from '../multiplayer/gameRegistry.js';
const router = Router();
@ -34,9 +34,9 @@ router.post('/single-player', requireAuth, (req, res) => {
const tx = db.transaction(() => {
db.prepare(
`INSERT INTO games (slug, name, category, max_players, supports_multiplayer)
VALUES (?, ?, ?, ?, 0)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(slug) DO NOTHING`,
).run(def.slug, def.name, def.category, def.maxPlayers);
).run(def.slug, def.name, def.category, def.maxPlayers, def.supportsMultiplayer ? 1 : 0);
const gameRow = db.prepare('SELECT id FROM games WHERE slug = ?').get(def.slug);

View File

@ -1,3 +1,4 @@
import http from 'node:http';
import path from 'node:path';
import express from 'express';
import cookieParser from 'cookie-parser';
@ -8,7 +9,8 @@ import authRoutes from './auth/routes.js';
import profileRoutes from './profile/routes.js';
import historyRoutes from './history/routes.js';
import historyRecordRoutes from './history/recordRoutes.js';
import { listGames } from './games/registry.js';
import { listGames } from './multiplayer/gameRegistry.js';
import { attachMultiplayer } from './multiplayer/index.js';
const app = express();
@ -35,6 +37,9 @@ app.use((err, _req, res, _next) => {
res.status(500).json({ error: 'Internal server error.' });
});
app.listen(config.port, config.host, () => {
const server = http.createServer(app);
attachMultiplayer(server);
server.listen(config.port, config.host, () => {
console.log(`[server] listening on http://${config.host}:${config.port}`);
});

View File

@ -11,6 +11,8 @@ export function registerGame(definition) {
maxPlayers: definition.maxPlayers ?? 2,
minOpponents: definition.minOpponents ?? 1,
maxOpponents: definition.maxOpponents ?? 1,
supportsMultiplayer: definition.supportsMultiplayer ?? true,
multiplayerOnly: definition.multiplayerOnly ?? false,
});
}
@ -22,14 +24,14 @@ export function getGame(slug) {
return registry.get(slug) ?? null;
}
// Built-in catalog so the menu has something to show.
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4 });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'yatzi', name: 'Yatzi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5 });
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
// Built-in placeholders so the menu has something to show.
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, multiplayerOnly: false });
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, multiplayerOnly: false });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'yatzi', name: 'Yatzi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5, multiplayerOnly: false });
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });

101
server/multiplayer/index.js Normal file
View File

@ -0,0 +1,101 @@
import { Server as SocketIOServer } from 'socket.io';
import cookie from 'cookie';
import config from '../config.js';
import { findSessionUser } from '../auth/service.js';
import { lobby } from './lobby.js';
import { getGame, listGames } from './gameRegistry.js';
function broadcastRoom(io, room) {
io.to(`room:${room.id}`).emit('room:update', room);
}
function broadcastLobby(io, gameSlug) {
io.to(`lobby:${gameSlug}`).emit('lobby:update', lobby.list(gameSlug));
}
export function attachMultiplayer(httpServer) {
const io = new SocketIOServer(httpServer, {
cors: { origin: config.socket.corsOrigin, credentials: true },
});
io.use((socket, next) => {
const raw = socket.handshake.headers.cookie ?? '';
const cookies = cookie.parse(raw || '');
const sid = cookies[config.auth.cookieName];
const user = findSessionUser(sid);
if (!user) return next(new Error('Not authenticated.'));
socket.data.user = user;
next();
});
io.on('connection', (socket) => {
const { user } = socket.data;
socket.emit('hello', { user, games: listGames() });
socket.on('lobby:subscribe', (gameSlug) => {
if (!getGame(gameSlug)) return;
socket.join(`lobby:${gameSlug}`);
socket.emit('lobby:update', lobby.list(gameSlug));
});
socket.on('lobby:unsubscribe', (gameSlug) => {
socket.leave(`lobby:${gameSlug}`);
});
socket.on('room:create', ({ gameSlug, name }, ack) => {
try {
const room = lobby.create({ gameSlug, hostUser: user, name });
socket.join(`room:${room.id}`);
broadcastLobby(io, gameSlug);
ack?.({ ok: true, room });
} catch (err) {
ack?.({ ok: false, error: err.message });
}
});
socket.on('room:join', ({ roomId }, ack) => {
try {
const room = lobby.join(roomId, user);
socket.join(`room:${room.id}`);
broadcastRoom(io, room);
broadcastLobby(io, room.gameSlug);
ack?.({ ok: true, room });
} catch (err) {
ack?.({ ok: false, error: err.message });
}
});
socket.on('room:leave', ({ roomId }, ack) => {
const before = lobby.get(roomId);
const room = lobby.leave(roomId, user.id);
socket.leave(`room:${roomId}`);
if (room) broadcastRoom(io, room);
if (before) broadcastLobby(io, before.gameSlug);
ack?.({ ok: true });
});
socket.on('room:message', ({ roomId, payload }) => {
const room = lobby.get(roomId);
if (!room || !room.players.some((p) => p.userId === user.id)) return;
io.to(`room:${roomId}`).emit('room:message', {
from: { id: user.id, username: user.username },
payload,
at: Date.now(),
});
});
socket.on('disconnect', () => {
// Drop the user from any rooms they were in.
for (const room of [...lobby.rooms.values()]) {
if (room.players.some((p) => p.userId === user.id)) {
const updated = lobby.leave(room.id, user.id);
if (updated) broadcastRoom(io, updated);
broadcastLobby(io, room.gameSlug);
}
}
});
});
return io;
}

View File

@ -0,0 +1,74 @@
import crypto from 'node:crypto';
import { getGame } from './gameRegistry.js';
export class LobbyManager {
constructor() {
this.rooms = new Map();
}
list(gameSlug) {
return [...this.rooms.values()]
.filter((r) => !gameSlug || r.gameSlug === gameSlug)
.map((r) => this.summary(r));
}
summary(room) {
return {
id: room.id,
gameSlug: room.gameSlug,
name: room.name,
hostId: room.hostId,
players: room.players.map((p) => ({ id: p.userId, username: p.username, seat: p.seat })),
maxPlayers: room.maxPlayers,
status: room.status,
};
}
create({ gameSlug, hostUser, name }) {
const game = getGame(gameSlug);
if (!game) throw new Error(`Unknown game: ${gameSlug}`);
if (!game.supportsMultiplayer) throw new Error(`${game.name} is not multiplayer.`);
const id = crypto.randomBytes(6).toString('hex');
const room = {
id,
gameSlug,
name: name ?? `${hostUser.username}'s table`,
hostId: hostUser.id,
players: [{ userId: hostUser.id, username: hostUser.username, seat: 0 }],
maxPlayers: game.maxPlayers,
status: 'waiting',
};
this.rooms.set(id, room);
return room;
}
join(roomId, user) {
const room = this.rooms.get(roomId);
if (!room) throw new Error('Room not found.');
if (room.players.some((p) => p.userId === user.id)) return room;
if (room.players.length >= room.maxPlayers) throw new Error('Room is full.');
const usedSeats = new Set(room.players.map((p) => p.seat));
let seat = 0;
while (usedSeats.has(seat)) seat += 1;
room.players.push({ userId: user.id, username: user.username, seat });
return room;
}
leave(roomId, userId) {
const room = this.rooms.get(roomId);
if (!room) return null;
room.players = room.players.filter((p) => p.userId !== userId);
if (!room.players.length) {
this.rooms.delete(roomId);
return null;
}
if (room.hostId === userId) room.hostId = room.players[0].userId;
return room;
}
get(roomId) {
return this.rooms.get(roomId) ?? null;
}
}
export const lobby = new LobbyManager();