refactor: remove multiplayer and Socket.IO in favor of single-player AI games

- Remove Socket.IO dependency, server multiplayer logic (lobby, room management), and client-side socket service
- Replace multiplayer-only flow with single-player AI gameplay; add `OpponentSelectScene` for AI configuration
- Simplify game registry to use `minOpponents`/`maxOpponents` instead of `supportsMultiplayer`/`multiplayerOnly`
- Update game registration to remove multiplayer flags and adjust `games` table schema to hardcode `supports_multiplayer = 0`
- Remove `SOCKET_IO_CORS_ORIGIN` from environment configuration
- Update documentation to reflect single-player architecture and simplified game creation process
- Fix minor UI issues: add width to buttons in auth scenes, fix auth listener cleanup in LandingScene, restore DOM layer visibility in Modal
This commit is contained in:
Brian Fertig 2026-05-20 18:57:41 -06:00
parent afd8c78253
commit 9c3774fb1f
27 changed files with 108 additions and 879 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,
match history, and multiplayer lobbies.
and match history. Games are single-player against AI opponents.
The frontend uses **native browser ES modules** — no bundler, no build step.
The backend is Node.js + Express + Socket.IO with SQLite for persistence.
The backend is Node.js + Express with SQLite for persistence.
---
@ -19,7 +19,6 @@ The backend is Node.js + Express + Socket.IO 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)
@ -36,11 +35,9 @@ The backend is Node.js + Express + Socket.IO 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) ready to be populated by games
- Multiplayer lobby system over Socket.IO with rooms, presence, and broadcast
- Match history (wins / losses / draws) recorded for single-player games
- Pluggable game registry — register tabletop or casino games server-side
- Base classes (`TabletopGame`, `CasinoGame`) that handle the turn loop /
betting loop scaffolding so new games only implement rules
- Single-player games against configurable AI opponents
- 1920×1080 canvas that scales to any viewport via `Phaser.Scale.FIT`
- Vector-only placeholder graphics — drop sprites in later without
refactoring scenes
@ -137,12 +134,6 @@ 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
@ -153,8 +144,8 @@ npm start # plain node, production-style
npm run migrate # apply any pending DB migrations
```
The server serves both the API (`/api/*`), static frontend (`/`, `/src/...`,
`/uploads/...`), and the Socket.IO endpoint (`/socket.io`) on the same port.
The server serves both the API (`/api/*`) and the static frontend (`/`,
`/src/...`, `/uploads/...`) on the same port.
After it starts you should see:
@ -174,7 +165,7 @@ fertig-classic-games/
├── README.md
├── server/ Backend (Node.js, ES modules)
│ ├── index.js Express + Socket.IO bootstrap
│ ├── index.js Express bootstrap
│ ├── config.js Loads & validates .env
│ ├── db/
│ │ ├── index.js better-sqlite3 connection singleton
@ -192,10 +183,8 @@ fertig-classic-games/
│ │ └── routes.js /api/history
│ ├── email/
│ │ └── mailer.js Nodemailer wrapper with console fallback
│ └── multiplayer/
│ ├── index.js Socket.IO server + auth handshake
│ ├── lobby.js Room manager (create/join/leave)
│ └── gameRegistry.js Game definitions
│ └── games/
│ └── registry.js Game definitions
├── public/ Frontend, served as static files
│ ├── index.html Loads Phaser via importmap
@ -206,8 +195,7 @@ fertig-classic-games/
│ ├── config.js UI colors, dimensions, API base
│ ├── services/
│ │ ├── api.js fetch wrapper
│ │ ├── auth.js Client-side auth store
│ │ └── socket.js socket.io-client
│ │ └── auth.js Client-side auth store
│ ├── ui/
│ │ ├── Button.js
│ │ ├── TextInput.js DOM-overlay input that follows canvas scale
@ -221,12 +209,9 @@ fertig-classic-games/
│ │ ├── VerifyScene.js
│ │ ├── ProfileScene.js
│ │ ├── GameMenuScene.js
│ │ ├── LobbyScene.js
│ │ ├── OpponentSelectScene.js
│ │ └── GameRoomScene.js
│ └── games/
│ ├── BaseGame.js
│ ├── tabletop/TabletopGame.js
│ └── casino/CasinoGame.js
│ └── games/ One subdirectory per game (uno/, blackjack/, ...)
└── data/ SQLite database (gitignored)
└── fertig.sqlite
@ -289,65 +274,22 @@ 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 `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'
}
```
| GET | `/api/games` | — | Lists registered games from `games/registry.js`. |
---
## Frontend architecture
- **No bundler.** `public/index.html` uses an `<script type="importmap">` to
resolve `phaser` and `socket.io-client` to CDN ES modules. The rest of the
app uses relative ES imports.
resolve `phaser` to a CDN ES module. 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 → Lobby → GameRoom`.
GameMenu → OpponentSelect → 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
@ -358,49 +300,47 @@ cookie during the handshake and rejects unauthenticated connections.
## Adding a new game
1. **Register the game on the server.** In `server/multiplayer/gameRegistry.js`:
1. **Register the game on the server.** In `server/games/registry.js`:
```js
registerGame({
slug: 'cribbage',
name: 'Cribbage',
category: 'tabletop', // or 'casino'
category: 'tabletop', // or 'casino' or 'cards'
minPlayers: 2,
maxPlayers: 4,
supportsMultiplayer: true,
minOpponents: 1,
maxOpponents: 3,
});
```
The lobby and game menu will pick it up automatically.
The game menu picks it up automatically.
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`:
2. **Implement the game scene.** Each game is a `Phaser.Scene` that reads its
setup from the data passed by `GameRoomScene`:
```js
// public/src/games/tabletop/CribbageGame.js
import TabletopGame from './TabletopGame.js';
// public/src/games/cribbage/CribbageGame.js
import * as Phaser from 'phaser';
export default class CribbageGame extends TabletopGame {
export default class CribbageGame extends Phaser.Scene {
constructor() { super('CribbageGame'); }
createBoard() { /* render felt, pegs, cards (placeholder shapes ok) */ }
applyBoardState(state) { /* reconcile pieces & turn */ }
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 */ }
}
```
4. **Route to it from `GameRoomScene`.** Either switch on `game.slug` and
start the right scene, or load the module dynamically:
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`.
```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).
4. **Record results (optional).** When a match finishes, `POST` to
`/api/history/single-player` to record the win / loss / draw for history.
---
@ -490,10 +430,6 @@ 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.
@ -505,16 +441,9 @@ beat; if it persists, file an issue.
## Roadmap
The framework is intentionally game-agnostic. Concrete games come next:
- [ ] Smarter AI opponents per game
- [ ] Richer match history views and per-game stats
- [ ] Replace placeholder vector graphics with sprites
- [ ] **Backgammon** — 2-player turn-based on `TabletopGame`
- [ ] **Parchisi** — up to 4 players on `TabletopGame`
- [ ] **Blackjack** — 16 players on `CasinoGame`
- [ ] **Texas Hold 'Em** — 28 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.
Contributions welcome — start by registering a game in `games/registry.js`
(see [Adding a new game](#adding-a-new-game)).

View File

@ -37,10 +37,6 @@ 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,13 +10,11 @@
"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",
"socket.io": "^4.7.5"
"nodemailer": "^6.9.14"
},
"engines": {
"node": ">=20"
@ -41,35 +39,6 @@
"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",
@ -179,14 +148,6 @@
}
]
},
"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",
@ -393,14 +354,6 @@
"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",
@ -426,22 +379,6 @@
"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",
@ -552,63 +489,6 @@
"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",
@ -1666,107 +1546,6 @@
"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",
@ -1928,11 +1707,6 @@
"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",
@ -1989,26 +1763,6 @@
"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,12 +15,10 @@
"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",
"socket.io": "^4.7.5"
"nodemailer": "^6.9.14"
}
}

View File

@ -13,8 +13,7 @@
<script type="importmap">
{
"imports": {
"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"
"phaser": "https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.esm.js"
}
}
</script>

View File

@ -1,50 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -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 lobby.', {
this.add.text(CX, CY + 40, 'You have no chips! Return to the menu.', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.modal);
return;

View File

@ -1,34 +0,0 @@
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,7 +9,6 @@ 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';
@ -43,7 +42,6 @@ const config = {
ProfileScene,
GameMenuScene,
OpponentSelectScene,
LobbyScene,
GameRoomScene,
BackgammonGame,
HoldemGame,

View File

@ -1,7 +1,6 @@
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';
@ -64,11 +63,6 @@ export default class GameMenuScene extends Phaser.Scene {
}
openGame(game) {
if (game.multiplayerOnly) {
if (!auth.user) { this.scene.start('Login'); return; }
this.scene.start('Lobby', { game });
} else {
this.scene.start('OpponentSelect', { game });
}
this.scene.start('OpponentSelect', { game });
}
}

View File

@ -1,17 +1,14 @@
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. A concrete game (Backgammon, Blackjack, etc.) will
// later be instantiated here based on `data.game.slug`, taking over the
// rendering and input handling.
// Generic room shell. Dispatches to the concrete game scene
// (Backgammon, Blackjack, etc.) based on `data.game.slug`.
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;
@ -36,9 +33,7 @@ export default class GameRoomScene extends Phaser.Scene {
color: COLORS.textHex,
}).setOrigin(0.5);
this.add.text(cx, 150, this.room
? `Table: ${this.room.name}`
: 'Single-player table',
this.add.text(cx, 150, 'Single-player table',
{ fontSize: '24px', color: COLORS.mutedHex }).setOrigin(0.5);
// Placeholder table felt
@ -50,34 +45,6 @@ export default class GameRoomScene extends Phaser.Scene {
align: 'center',
}).setOrigin(0.5);
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');
new Button(this, cx, GAME_HEIGHT - 100, 'Leave table', () => this.scene.start('GameMenu'));
}
}

View File

@ -37,11 +37,15 @@ export default class LandingScene extends Phaser.Scene {
this.renderButtons();
auth.subscribe(() => {
if (this._authUnsub) this._authUnsub();
this._authUnsub = 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() {
@ -100,15 +104,15 @@ export default class LandingScene extends Phaser.Scene {
}).setOrigin(0.5);
}
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'));
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'));
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, 970, 'Sign out', async () => {
await auth.logout();
}, { variant: 'ghost' });
}, { variant: 'ghost', width: 480 });
} else {
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' });
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 });
}
}
}

View File

@ -1,87 +0,0 @@
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));
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' });
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 });
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));
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' });
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 });
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');
});
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', width: 480 });
}
}

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

@ -1,20 +0,0 @@
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,10 +20,15 @@ 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, () => this.destroy());
scene.time.delayedCall(options.autoCloseMs, () => { restore(); this.destroy(); });
} else {
overlay.on('pointerdown', () => this.destroy());
overlay.on('pointerdown', () => { restore(); this.destroy(); });
}
this.once('destroy', restore);
}
}

View File

@ -58,10 +58,6 @@ 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,12 +24,17 @@ export async function sendVerificationEmail(to, link) {
return { delivered: false, devLogged: true };
}
await transporter.sendMail({
from: config.email.from,
to,
subject,
text,
html,
});
return { delivered: true, devLogged: false };
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 };
}
}

View File

@ -11,8 +11,6 @@ export function registerGame(definition) {
maxPlayers: definition.maxPlayers ?? 2,
minOpponents: definition.minOpponents ?? 1,
maxOpponents: definition.maxOpponents ?? 1,
supportsMultiplayer: definition.supportsMultiplayer ?? true,
multiplayerOnly: definition.multiplayerOnly ?? false,
});
}
@ -24,14 +22,14 @@ export function getGame(slug) {
return registry.get(slug) ?? null;
}
// 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 });
// 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 });

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 '../multiplayer/gameRegistry.js';
import { getGame } from '../games/registry.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 (?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, 0)
ON CONFLICT(slug) DO NOTHING`,
).run(def.slug, def.name, def.category, def.maxPlayers, def.supportsMultiplayer ? 1 : 0);
).run(def.slug, def.name, def.category, def.maxPlayers);
const gameRow = db.prepare('SELECT id FROM games WHERE slug = ?').get(def.slug);

View File

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

View File

@ -1,101 +0,0 @@
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

@ -1,74 +0,0 @@
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();