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, ...) A Phaser 3.90 framework for classic tabletop games (Backgammon, Parchisi, ...)
and casino games (Blackjack, Texas Hold 'Em, ...), with accounts, profiles, 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 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) - [Project layout](#project-layout)
- [Database schema](#database-schema) - [Database schema](#database-schema)
- [REST API](#rest-api) - [REST API](#rest-api)
- [Socket.IO events](#socketio-events)
- [Frontend architecture](#frontend-architecture) - [Frontend architecture](#frontend-architecture)
- [Adding a new game](#adding-a-new-game) - [Adding a new game](#adding-a-new-game)
- [Email verification](#email-verification) - [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 to the console when SMTP is not set up
- Session cookies backed by SQLite (httpOnly, SameSite=Lax) - Session cookies backed by SQLite (httpOnly, SameSite=Lax)
- Profile management: display name, bio, avatar upload (PNG / JPEG / WebP) - Profile management: display name, bio, avatar upload (PNG / JPEG / WebP)
- Match history (wins / losses / draws) ready to be populated by games - Match history (wins / losses / draws) recorded for single-player games
- Multiplayer lobby system over Socket.IO with rooms, presence, and broadcast
- Pluggable game registry — register tabletop or casino games server-side - Pluggable game registry — register tabletop or casino games server-side
- Base classes (`TabletopGame`, `CasinoGame`) that handle the turn loop / - Single-player games against configurable AI opponents
betting loop scaffolding so new games only implement rules
- 1920×1080 canvas that scales to any viewport via `Phaser.Scale.FIT` - 1920×1080 canvas that scales to any viewport via `Phaser.Scale.FIT`
- Vector-only placeholder graphics — drop sprites in later without - Vector-only placeholder graphics — drop sprites in later without
refactoring scenes refactoring scenes
@ -137,12 +134,6 @@ the template. Fields:
| `SMTP_FROM` | *(see example.env)* | `From:` header for outgoing mail. | | `SMTP_FROM` | *(see example.env)* | `From:` header for outgoing mail. |
| `VERIFICATION_TOKEN_TTL_HOURS` | `24` | How long verification links remain valid. | | `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 ## Running the server
@ -153,8 +144,8 @@ npm start # plain node, production-style
npm run migrate # apply any pending DB migrations npm run migrate # apply any pending DB migrations
``` ```
The server serves both the API (`/api/*`), static frontend (`/`, `/src/...`, The server serves both the API (`/api/*`) and the static frontend (`/`,
`/uploads/...`), and the Socket.IO endpoint (`/socket.io`) on the same port. `/src/...`, `/uploads/...`) on the same port.
After it starts you should see: After it starts you should see:
@ -174,7 +165,7 @@ fertig-classic-games/
├── README.md ├── README.md
├── server/ Backend (Node.js, ES modules) ├── server/ Backend (Node.js, ES modules)
│ ├── index.js Express + Socket.IO bootstrap │ ├── index.js Express bootstrap
│ ├── config.js Loads & validates .env │ ├── config.js Loads & validates .env
│ ├── db/ │ ├── db/
│ │ ├── index.js better-sqlite3 connection singleton │ │ ├── index.js better-sqlite3 connection singleton
@ -192,10 +183,8 @@ fertig-classic-games/
│ │ └── routes.js /api/history │ │ └── routes.js /api/history
│ ├── email/ │ ├── email/
│ │ └── mailer.js Nodemailer wrapper with console fallback │ │ └── mailer.js Nodemailer wrapper with console fallback
│ └── multiplayer/ │ └── games/
│ ├── index.js Socket.IO server + auth handshake │ └── registry.js Game definitions
│ ├── lobby.js Room manager (create/join/leave)
│ └── gameRegistry.js Game definitions
├── public/ Frontend, served as static files ├── public/ Frontend, served as static files
│ ├── index.html Loads Phaser via importmap │ ├── index.html Loads Phaser via importmap
@ -206,8 +195,7 @@ fertig-classic-games/
│ ├── config.js UI colors, dimensions, API base │ ├── config.js UI colors, dimensions, API base
│ ├── services/ │ ├── services/
│ │ ├── api.js fetch wrapper │ │ ├── api.js fetch wrapper
│ │ ├── auth.js Client-side auth store │ │ └── auth.js Client-side auth store
│ │ └── socket.js socket.io-client
│ ├── ui/ │ ├── ui/
│ │ ├── Button.js │ │ ├── Button.js
│ │ ├── TextInput.js DOM-overlay input that follows canvas scale │ │ ├── TextInput.js DOM-overlay input that follows canvas scale
@ -221,12 +209,9 @@ fertig-classic-games/
│ │ ├── VerifyScene.js │ │ ├── VerifyScene.js
│ │ ├── ProfileScene.js │ │ ├── ProfileScene.js
│ │ ├── GameMenuScene.js │ │ ├── GameMenuScene.js
│ │ ├── LobbyScene.js │ │ ├── OpponentSelectScene.js
│ │ └── GameRoomScene.js │ │ └── GameRoomScene.js
│ └── games/ │ └── games/ One subdirectory per game (uno/, blackjack/, ...)
│ ├── BaseGame.js
│ ├── tabletop/TabletopGame.js
│ └── casino/CasinoGame.js
└── data/ SQLite database (gitignored) └── data/ SQLite database (gitignored)
└── fertig.sqlite └── fertig.sqlite
@ -289,65 +274,22 @@ automatically; the client uses `credentials: 'same-origin'` in fetch calls.
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|----------------|------|------------------------------------------------------| |--------|----------------|------|------------------------------------------------------|
| GET | `/api/health` | — | `{ ok: true }`. | | GET | `/api/health` | — | `{ ok: true }`. |
| GET | `/api/games` | — | Lists registered games from `gameRegistry.js`. | | GET | `/api/games` | — | Lists registered games from `games/registry.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 ## Frontend architecture
- **No bundler.** `public/index.html` uses an `<script type="importmap">` to - **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 resolve `phaser` to a CDN ES module. The rest of the app uses relative ES
app uses relative ES imports. imports.
- **Scenes** live in `public/src/scenes/`. The flow is: - **Scenes** live in `public/src/scenes/`. The flow is:
`Boot → Preload → Landing → (Login | Register | Verify) → Profile | `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 - **`auth` store** (`services/auth.js`) is a tiny pub-sub the scenes
subscribe to so they re-render when the signed-in user changes. subscribe to so they re-render when the signed-in user changes.
- **`api`** (`services/api.js`) is a thin fetch wrapper that throws on - **`api`** (`services/api.js`) is a thin fetch wrapper that throws on
non-2xx responses with `err.status` and `err.data` attached. 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 - **DOM-overlay inputs.** Phaser doesn't have a native text input, so
`ui/TextInput.js` positions a real `<input>` element over the canvas and `ui/TextInput.js` positions a real `<input>` element over the canvas and
repositions it on scale-resize. The `#dom-layer` div has 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 ## 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 ```js
registerGame({ registerGame({
slug: 'cribbage', slug: 'cribbage',
name: 'Cribbage', name: 'Cribbage',
category: 'tabletop', // or 'casino' category: 'tabletop', // or 'casino' or 'cards'
minPlayers: 2, minPlayers: 2,
maxPlayers: 4, 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 2. **Implement the game scene.** Each game is a `Phaser.Scene` that reads its
history. Either insert in a new migration or via a one-off statement. setup from the data passed by `GameRoomScene`:
3. **Implement the game scene.** Extend `TabletopGame` or `CasinoGame`:
```js ```js
// public/src/games/tabletop/CribbageGame.js // public/src/games/cribbage/CribbageGame.js
import TabletopGame from './TabletopGame.js'; import * as Phaser from 'phaser';
export default class CribbageGame extends TabletopGame { export default class CribbageGame extends Phaser.Scene {
constructor() { super('CribbageGame'); } constructor() { super('CribbageGame'); }
createBoard() { /* render felt, pegs, cards (placeholder shapes ok) */ } init(data) {
applyBoardState(state) { /* reconcile pieces & turn */ } 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 3. **Register the scene and route to it.** Add the scene to the `scene` array
start the right scene, or load the module dynamically: in `public/src/main.js`, then add its slug → scene-key mapping to the
`slugDispatch` object in `public/src/scenes/GameRoomScene.js`.
```js 4. **Record results (optional).** When a match finishes, `POST` to
const mod = await import(`../games/${category}/${ClassName}.js`); `/api/history/single-player` to record the win / loss / draw for history.
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).
--- ---
@ -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 check provider auth (Gmail requires an app password, not your account
password) and `SMTP_PORT` / `SMTP_SECURE` for your provider. 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 **Avatar upload fails with `Unsupported image type`** — only the MIME types
in `ALLOWED_UPLOAD_MIME` are accepted. Add more if needed. in `ALLOWED_UPLOAD_MIME` are accepted. Add more if needed.
@ -505,16 +441,9 @@ beat; if it persists, file an issue.
## Roadmap ## 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` Contributions welcome — start by registering a game in `games/registry.js`
- [ ] **Parchisi** — up to 4 players on `TabletopGame` (see [Adding a new game](#adding-a-new-game)).
- [ ] **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.

View File

@ -37,10 +37,6 @@ SMTP_PASS=
SMTP_FROM="Fertig Classic Games <no-reply@fertigclassicgames.local>" SMTP_FROM="Fertig Classic Games <no-reply@fertigclassicgames.local>"
VERIFICATION_TOKEN_TTL_HOURS=24 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 ---- # ---- Logging ----
# One of: error, warn, info, debug # One of: error, warn, info, debug
LOG_LEVEL=info LOG_LEVEL=info

248
package-lock.json generated
View File

@ -10,13 +10,11 @@
"dependencies": { "dependencies": {
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"better-sqlite3": "^11.3.0", "better-sqlite3": "^11.3.0",
"cookie": "^0.6.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"multer": "^2.0.0", "multer": "^2.0.0",
"nodemailer": "^6.9.14", "nodemailer": "^6.9.14"
"socket.io": "^4.7.5"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@ -41,35 +39,6 @@
"node-pre-gyp": "bin/node-pre-gyp" "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": { "node_modules/abbrev": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "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": { "node_modules/bcrypt": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
@ -393,14 +354,6 @@
"node": ">= 0.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": { "node_modules/cookie-parser": {
"version": "1.4.7", "version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", "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", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" "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": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -552,63 +489,6 @@
"once": "^1.4.0" "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": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -1666,107 +1546,6 @@
"simple-concat": "^1.0.0" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "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", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" "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": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" "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": { "node_modules/yallist": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

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

View File

@ -13,8 +13,7 @@
<script type="importmap"> <script type="importmap">
{ {
"imports": { "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> </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]; const modalItems = [overlay, panel, title, balanceTxt, buyInTxt];
if (this.globalChips <= 0) { 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, fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.modal); }).setOrigin(0.5).setDepth(D.modal);
return; 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 ProfileScene from './scenes/ProfileScene.js';
import GameMenuScene from './scenes/GameMenuScene.js'; import GameMenuScene from './scenes/GameMenuScene.js';
import OpponentSelectScene from './scenes/OpponentSelectScene.js'; import OpponentSelectScene from './scenes/OpponentSelectScene.js';
import LobbyScene from './scenes/LobbyScene.js';
import GameRoomScene from './scenes/GameRoomScene.js'; import GameRoomScene from './scenes/GameRoomScene.js';
import BackgammonGame from './games/backgammon/BackgammonGame.js'; import BackgammonGame from './games/backgammon/BackgammonGame.js';
import HoldemGame from './games/holdem/HoldemGame.js'; import HoldemGame from './games/holdem/HoldemGame.js';
@ -43,7 +42,6 @@ const config = {
ProfileScene, ProfileScene,
GameMenuScene, GameMenuScene,
OpponentSelectScene, OpponentSelectScene,
LobbyScene,
GameRoomScene, GameRoomScene,
BackgammonGame, BackgammonGame,
HoldemGame, HoldemGame,

View File

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

View File

@ -1,17 +1,14 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { getSocket } from '../services/socket.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
// Generic room shell. A concrete game (Backgammon, Blackjack, etc.) will // Generic room shell. Dispatches to the concrete game scene
// later be instantiated here based on `data.game.slug`, taking over the // (Backgammon, Blackjack, etc.) based on `data.game.slug`.
// rendering and input handling.
export default class GameRoomScene extends Phaser.Scene { export default class GameRoomScene extends Phaser.Scene {
constructor() { super('GameRoom'); } constructor() { super('GameRoom'); }
init(data) { init(data) {
this.game = data.game; this.game = data.game;
this.room = data.room ?? null;
this.opponents = data.opponents ?? []; this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null; this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null; this.cardBack = data.cardBack ?? null;
@ -36,9 +33,7 @@ export default class GameRoomScene extends Phaser.Scene {
color: COLORS.textHex, color: COLORS.textHex,
}).setOrigin(0.5); }).setOrigin(0.5);
this.add.text(cx, 150, this.room this.add.text(cx, 150, 'Single-player table',
? `Table: ${this.room.name}`
: 'Single-player table',
{ fontSize: '24px', color: COLORS.mutedHex }).setOrigin(0.5); { fontSize: '24px', color: COLORS.mutedHex }).setOrigin(0.5);
// Placeholder table felt // Placeholder table felt
@ -50,34 +45,6 @@ export default class GameRoomScene extends Phaser.Scene {
align: 'center', align: 'center',
}).setOrigin(0.5); }).setOrigin(0.5);
this.seatsText = this.add.text(cx, GAME_HEIGHT - 220, this.formatSeats(this.room), { new Button(this, cx, GAME_HEIGHT - 100, 'Leave table', () => this.scene.start('GameMenu'));
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,11 +37,15 @@ export default class LandingScene extends Phaser.Scene {
this.renderButtons(); this.renderButtons();
auth.subscribe(() => { if (this._authUnsub) this._authUnsub();
this._authUnsub = auth.subscribe(() => {
if (!this.scene.isActive('Landing')) return; if (!this.scene.isActive('Landing')) return;
this.children.removeAll(); this.children.removeAll();
this.create(); this.create();
}); });
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
if (this._authUnsub) { this._authUnsub(); this._authUnsub = null; }
});
} }
renderButtons() { renderButtons() {
@ -100,15 +104,15 @@ export default class LandingScene extends Phaser.Scene {
}).setOrigin(0.5); }).setOrigin(0.5);
} }
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu')); new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'), { width: 480 });
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile')); new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
new Button(this, cx, 970, 'Sign out', async () => { new Button(this, cx, 970, 'Sign out', async () => {
await auth.logout(); await auth.logout();
}, { variant: 'ghost' }); }, { variant: 'ghost', width: 480 });
} else { } else {
new Button(this, cx, 690, 'Sign in', () => this.scene.start('Login')); new Button(this, cx, 690, 'Sign in', () => this.scene.start('Login'), { width: 480 });
new Button(this, cx, 770, 'Create account', () => this.scene.start('Register')); 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' }); 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); 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 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)); 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' }); 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' }); 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)); 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); 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 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)); 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' }); 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' }); 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)); 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 () => { new Button(this, cx, 640, 'I have verified — refresh', async () => {
await auth.refresh(); await auth.refresh();
this.scene.start('Landing'); this.scene.start('Landing');
}); }, { width: 480 });
new Button(this, cx, 720, 'Continue without verifying', () => this.scene.start('Landing'), { variant: 'ghost' }); 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() { emit() {
for (const fn of this.listeners) fn(this.user); for (const fn of [...this.listeners]) fn(this.user);
} }
async refresh() { 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]); this.add([overlay, panel, text]);
scene.add.existing(this); 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) { if (options.autoCloseMs) {
scene.time.delayedCall(options.autoCloseMs, () => this.destroy()); scene.time.delayedCall(options.autoCloseMs, () => { restore(); this.destroy(); });
} else { } 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), 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'), publicDir: path.join(ROOT, 'public'),
}; };

View File

@ -24,12 +24,17 @@ export async function sendVerificationEmail(to, link) {
return { delivered: false, devLogged: true }; return { delivered: false, devLogged: true };
} }
await transporter.sendMail({ try {
from: config.email.from, await transporter.sendMail({
to, from: config.email.from,
subject, to,
text, subject,
html, text,
}); html,
return { delivered: true, devLogged: false }; });
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, maxPlayers: definition.maxPlayers ?? 2,
minOpponents: definition.minOpponents ?? 1, minOpponents: definition.minOpponents ?? 1,
maxOpponents: definition.maxOpponents ?? 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; return registry.get(slug) ?? null;
} }
// Built-in placeholders so the menu has something to show. // 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, multiplayerOnly: false }); 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, multiplayerOnly: false }); 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, multiplayerOnly: false }); 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, multiplayerOnly: false }); 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, multiplayerOnly: false }); 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, multiplayerOnly: false }); 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, multiplayerOnly: false }); 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, multiplayerOnly: false }); 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, multiplayerOnly: false }); 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, multiplayerOnly: false }); 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 { Router } from 'express';
import db from '../db/index.js'; import db from '../db/index.js';
import { requireAuth } from '../auth/middleware.js'; import { requireAuth } from '../auth/middleware.js';
import { getGame } from '../multiplayer/gameRegistry.js'; import { getGame } from '../games/registry.js';
const router = Router(); const router = Router();
@ -34,9 +34,9 @@ router.post('/single-player', requireAuth, (req, res) => {
const tx = db.transaction(() => { const tx = db.transaction(() => {
db.prepare( db.prepare(
`INSERT INTO games (slug, name, category, max_players, supports_multiplayer) `INSERT INTO games (slug, name, category, max_players, supports_multiplayer)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, 0)
ON CONFLICT(slug) DO NOTHING`, 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); 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 path from 'node:path';
import express from 'express'; import express from 'express';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
@ -9,8 +8,7 @@ import authRoutes from './auth/routes.js';
import profileRoutes from './profile/routes.js'; import profileRoutes from './profile/routes.js';
import historyRoutes from './history/routes.js'; import historyRoutes from './history/routes.js';
import historyRecordRoutes from './history/recordRoutes.js'; import historyRecordRoutes from './history/recordRoutes.js';
import { listGames } from './multiplayer/gameRegistry.js'; import { listGames } from './games/registry.js';
import { attachMultiplayer } from './multiplayer/index.js';
const app = express(); const app = express();
@ -37,9 +35,6 @@ app.use((err, _req, res, _next) => {
res.status(500).json({ error: 'Internal server error.' }); res.status(500).json({ error: 'Internal server error.' });
}); });
const server = http.createServer(app); app.listen(config.port, config.host, () => {
attachMultiplayer(server);
server.listen(config.port, config.host, () => {
console.log(`[server] listening on http://${config.host}:${config.port}`); 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();