491 lines
21 KiB
Markdown
491 lines
21 KiB
Markdown
# Fertig Classic Games
|
||
|
||
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.
|
||
|
||
The frontend uses **native browser ES modules** — no bundler, no build step.
|
||
The backend is Node.js + Express + Socket.IO with SQLite for persistence.
|
||
|
||
---
|
||
|
||
## Table of contents
|
||
|
||
- [Features](#features)
|
||
- [Prerequisites](#prerequisites)
|
||
- [Quick start](#quick-start)
|
||
- [Configuration (`.env`)](#configuration-env)
|
||
- [Running the server](#running-the-server)
|
||
- [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)
|
||
- [Profile pictures](#profile-pictures)
|
||
- [Troubleshooting](#troubleshooting)
|
||
- [Roadmap](#roadmap)
|
||
|
||
---
|
||
|
||
## Features
|
||
|
||
- Account creation with email + username + password (bcrypt hashed)
|
||
- Email verification with configurable SMTP — falls back to logging dev links
|
||
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
|
||
- 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
|
||
- 1920×1080 canvas that scales to any viewport via `Phaser.Scale.FIT`
|
||
- Vector-only placeholder graphics — drop sprites in later without
|
||
refactoring scenes
|
||
- Mouse + keyboard controls
|
||
|
||
---
|
||
|
||
## Prerequisites
|
||
|
||
- **Node.js 20 or newer** (uses `node --watch`, native fetch, ES modules)
|
||
- **npm 9 or newer**
|
||
- A C/C++ toolchain for `better-sqlite3` and `bcrypt` to build native bindings:
|
||
- **Linux**: `build-essential`, `python3`
|
||
- **macOS**: Xcode command line tools (`xcode-select --install`)
|
||
- **Windows**: `npm install --global windows-build-tools` (older Windows)
|
||
or install Visual Studio Build Tools
|
||
|
||
No bundler, no Docker, no external database required to get started.
|
||
|
||
---
|
||
|
||
## Quick start
|
||
|
||
```bash
|
||
git clone <this-repo>
|
||
cd fertig-classic-games
|
||
|
||
cp example.env .env
|
||
# Edit .env — at minimum, set SESSION_SECRET to a long random string.
|
||
# Generate one with:
|
||
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||
|
||
npm install
|
||
npm run migrate
|
||
npm run dev
|
||
```
|
||
|
||
Open http://localhost:3000 in your browser. Register an account; if SMTP is
|
||
not configured (the default), the verification link is printed to the server
|
||
console — click it to verify.
|
||
|
||
---
|
||
|
||
## Configuration (`.env`)
|
||
|
||
All configuration lives in `.env` at the project root. Use `example.env` as
|
||
the template. Fields:
|
||
|
||
### Server
|
||
|
||
| Variable | Default | Description |
|
||
|-------------|--------------------------|------------------------------------------|
|
||
| `NODE_ENV` | `development` | `development` or `production`. |
|
||
| `HOST` | `0.0.0.0` | Bind address. |
|
||
| `PORT` | `3000` | HTTP port. |
|
||
| `BASE_URL` | `http://localhost:3000` | Public URL used in verification emails. |
|
||
| `LOG_LEVEL` | `info` | `error`, `warn`, `info`, `debug`. |
|
||
|
||
### Database
|
||
|
||
| Variable | Default | Description |
|
||
|-----------|-------------------------|-----------------------------------|
|
||
| `DB_PATH` | `./data/fertig.sqlite` | SQLite file path. Auto-created. |
|
||
|
||
### Auth
|
||
|
||
| Variable | Default | Description |
|
||
|------------------------|---------------|--------------------------------------------------------------------------------------------------------------|
|
||
| `SESSION_SECRET` | *(required)* | Long random string. **Required in production.** A dev fallback is used if empty in development with a warning. |
|
||
| `SESSION_COOKIE_NAME` | `fcg_sid` | Cookie name. |
|
||
| `SESSION_TTL_DAYS` | `30` | Session lifetime in days. |
|
||
| `BCRYPT_ROUNDS` | `12` | bcrypt cost factor. |
|
||
|
||
### Uploads (profile pictures)
|
||
|
||
| Variable | Default | Description |
|
||
|------------------------|--------------------------------------|--------------------------------------|
|
||
| `UPLOAD_DIR` | `./public/uploads` | Directory for avatar files. |
|
||
| `MAX_UPLOAD_SIZE_MB` | `5` | Max image size in MB. |
|
||
| `ALLOWED_UPLOAD_MIME` | `image/png,image/jpeg,image/webp` | Comma-separated MIME allowlist. |
|
||
|
||
### Email
|
||
|
||
| Variable | Default | Description |
|
||
|---------------------------------|---------|------------------------------------------------------------------------|
|
||
| `SMTP_HOST` | *(empty)* | If empty, verification links log to the console instead of sending. |
|
||
| `SMTP_PORT` | `587` | SMTP port. |
|
||
| `SMTP_SECURE` | `false` | `true` for SMTPS (usually port 465). |
|
||
| `SMTP_USER` | *(empty)* | SMTP username, if your provider requires auth. |
|
||
| `SMTP_PASS` | *(empty)* | SMTP password. |
|
||
| `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
|
||
|
||
```bash
|
||
npm run dev # node --watch, auto-restart on file changes
|
||
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.
|
||
|
||
After it starts you should see:
|
||
|
||
```
|
||
[server] listening on http://0.0.0.0:3000
|
||
```
|
||
|
||
---
|
||
|
||
## Project layout
|
||
|
||
```
|
||
fertig-classic-games/
|
||
├── example.env Configuration template (commit this)
|
||
├── .env Your local configuration (gitignored)
|
||
├── package.json
|
||
├── README.md
|
||
│
|
||
├── server/ Backend (Node.js, ES modules)
|
||
│ ├── index.js Express + Socket.IO bootstrap
|
||
│ ├── config.js Loads & validates .env
|
||
│ ├── db/
|
||
│ │ ├── index.js better-sqlite3 connection singleton
|
||
│ │ ├── migrate.js SQL migration runner
|
||
│ │ └── migrations/
|
||
│ │ └── 001_init.sql Initial schema
|
||
│ ├── auth/
|
||
│ │ ├── routes.js /api/auth/* endpoints
|
||
│ │ ├── service.js bcrypt, sessions, verification tokens
|
||
│ │ └── middleware.js loadUser, requireAuth
|
||
│ ├── profile/
|
||
│ │ ├── routes.js /api/profile/*, multer upload
|
||
│ │ └── service.js
|
||
│ ├── history/
|
||
│ │ └── 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
|
||
│
|
||
├── public/ Frontend, served as static files
|
||
│ ├── index.html Loads Phaser via importmap
|
||
│ ├── styles.css
|
||
│ ├── uploads/ Avatars (gitignored)
|
||
│ └── src/
|
||
│ ├── main.js Phaser.Game + scale config
|
||
│ ├── config.js UI colors, dimensions, API base
|
||
│ ├── services/
|
||
│ │ ├── api.js fetch wrapper
|
||
│ │ ├── auth.js Client-side auth store
|
||
│ │ └── socket.js socket.io-client
|
||
│ ├── ui/
|
||
│ │ ├── Button.js
|
||
│ │ ├── TextInput.js DOM-overlay input that follows canvas scale
|
||
│ │ └── Modal.js
|
||
│ ├── scenes/
|
||
│ │ ├── BootScene.js
|
||
│ │ ├── PreloadScene.js
|
||
│ │ ├── LandingScene.js
|
||
│ │ ├── LoginScene.js
|
||
│ │ ├── RegisterScene.js
|
||
│ │ ├── VerifyScene.js
|
||
│ │ ├── ProfileScene.js
|
||
│ │ ├── GameMenuScene.js
|
||
│ │ ├── LobbyScene.js
|
||
│ │ └── GameRoomScene.js
|
||
│ └── games/
|
||
│ ├── BaseGame.js
|
||
│ ├── tabletop/TabletopGame.js
|
||
│ └── casino/CasinoGame.js
|
||
│
|
||
└── data/ SQLite database (gitignored)
|
||
└── fertig.sqlite
|
||
```
|
||
|
||
---
|
||
|
||
## Database schema
|
||
|
||
Created by `server/db/migrations/001_init.sql`. Run `npm run migrate` to apply
|
||
any pending migrations.
|
||
|
||
- **`users`** — `id, email, username, password_hash, email_verified,
|
||
verification_token, verification_expires_at, created_at`
|
||
- **`sessions`** — `id, user_id, expires_at, created_at`
|
||
- **`profiles`** — `user_id (PK/FK), display_name, avatar_path, bio,
|
||
updated_at`
|
||
- **`games`** — `id, slug, name, category ('tabletop'|'casino'),
|
||
max_players, supports_multiplayer`
|
||
- **`matches`** — `id, game_id, started_at, ended_at, status`
|
||
- **`match_players`** — `match_id, user_id, seat, result
|
||
('win'|'loss'|'draw'|'abandoned'), score`
|
||
|
||
Add new migrations as `server/db/migrations/00N_description.sql` — they are
|
||
applied in lexicographic order and tracked in `schema_migrations`.
|
||
|
||
---
|
||
|
||
## REST API
|
||
|
||
All endpoints are JSON. Session is carried by the `fcg_sid` cookie
|
||
automatically; the client uses `credentials: 'same-origin'` in fetch calls.
|
||
|
||
### Auth — `/api/auth`
|
||
|
||
| Method | Path | Auth | Description |
|
||
|--------|-------------|------|----------------------------------------------------------------------------------------------|
|
||
| POST | `/register` | — | Body: `{ email, username, password }`. Creates user, sends verification, sets session. |
|
||
| POST | `/login` | — | Body: `{ identifier, password }` where `identifier` is email or username. |
|
||
| POST | `/logout` | — | Destroys session. |
|
||
| GET | `/me` | — | Returns `{ user }` or `{ user: null }`. |
|
||
| GET | `/verify` | — | `?token=...`. Marks user as verified. Returns HTML so a user clicking the email link sees text. |
|
||
|
||
### Profile — `/api/profile`
|
||
|
||
| Method | Path | Auth | Description |
|
||
|--------|------------|-----------|-----------------------------------------------------------------------------------|
|
||
| GET | `/` | Required | Returns the current user's profile. |
|
||
| PATCH | `/` | Required | Body: `{ displayName?, bio? }`. Returns the updated profile. |
|
||
| POST | `/avatar` | Required | Multipart with field `avatar`. Stores the file and updates `avatar_path`. |
|
||
|
||
### History — `/api/history`
|
||
|
||
| Method | Path | Auth | Description |
|
||
|--------|-------|----------|------------------------------------------------------------|
|
||
| GET | `/` | Required | Last 100 matches for the user, plus `{ wins, losses, draws }`. |
|
||
|
||
### Misc
|
||
|
||
| 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'
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 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.
|
||
- **Scenes** live in `public/src/scenes/`. The flow is:
|
||
`Boot → Preload → Landing → (Login | Register | Verify) → Profile |
|
||
GameMenu → Lobby → GameRoom`.
|
||
- **`auth` store** (`services/auth.js`) is a tiny pub-sub the scenes
|
||
subscribe to so they re-render when the signed-in user changes.
|
||
- **`api`** (`services/api.js`) is a thin fetch wrapper that throws on
|
||
non-2xx responses with `err.status` and `err.data` attached.
|
||
- **`socket`** (`services/socket.js`) is a lazily-connected singleton; scenes
|
||
call `connectSocket()` when entering a multiplayer flow.
|
||
- **DOM-overlay inputs.** Phaser doesn't have a native text input, so
|
||
`ui/TextInput.js` positions a real `<input>` element over the canvas and
|
||
repositions it on scale-resize. The `#dom-layer` div has
|
||
`pointer-events: none` so the canvas stays interactive, and child elements
|
||
re-enable pointer events.
|
||
|
||
---
|
||
|
||
## Adding a new game
|
||
|
||
1. **Register the game on the server.** In `server/multiplayer/gameRegistry.js`:
|
||
|
||
```js
|
||
registerGame({
|
||
slug: 'cribbage',
|
||
name: 'Cribbage',
|
||
category: 'tabletop', // or 'casino'
|
||
minPlayers: 2,
|
||
maxPlayers: 4,
|
||
supportsMultiplayer: true,
|
||
});
|
||
```
|
||
|
||
The lobby and game menu will pick 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`:
|
||
|
||
```js
|
||
// public/src/games/tabletop/CribbageGame.js
|
||
import TabletopGame from './TabletopGame.js';
|
||
|
||
export default class CribbageGame extends TabletopGame {
|
||
constructor() { super('CribbageGame'); }
|
||
createBoard() { /* render felt, pegs, cards (placeholder shapes ok) */ }
|
||
applyBoardState(state) { /* reconcile pieces & turn */ }
|
||
}
|
||
```
|
||
|
||
4. **Route to it from `GameRoomScene`.** Either switch on `game.slug` and
|
||
start the right scene, or load the module dynamically:
|
||
|
||
```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).
|
||
|
||
---
|
||
|
||
## Email verification
|
||
|
||
When `SMTP_HOST` is set, `nodemailer` is used to send a real email. When it's
|
||
empty, `server/email/mailer.js` instead prints the verification link to the
|
||
server console:
|
||
|
||
```
|
||
[mailer:dev] Verification link for user@example.com:
|
||
http://localhost:3000/api/auth/verify?token=...
|
||
```
|
||
|
||
Click that link (or visit it in the browser) to verify the account. The
|
||
registration response also includes the dev link as `verification.devLink`,
|
||
which the frontend shows on the verify screen.
|
||
|
||
### Using Gmail / a real provider in development
|
||
|
||
Add to `.env`:
|
||
|
||
```
|
||
SMTP_HOST=smtp.gmail.com
|
||
SMTP_PORT=587
|
||
SMTP_SECURE=false
|
||
SMTP_USER=you@gmail.com
|
||
SMTP_PASS=your-app-password # NOT your normal password, use an app password
|
||
SMTP_FROM="Fertig Classic Games <you@gmail.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Profile pictures
|
||
|
||
- Uploaded via `POST /api/profile/avatar` (multipart form, field name
|
||
`avatar`).
|
||
- Stored on the filesystem under `UPLOAD_DIR` (default `./public/uploads`).
|
||
- The DB stores the **public path** (e.g. `/uploads/u1-1715800000-abcd.png`),
|
||
not the file contents.
|
||
- The `public/uploads/` directory is gitignored. Back it up alongside
|
||
`data/fertig.sqlite` if you want to preserve user content.
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
**`SESSION_SECRET must be set in production`** — set a long random
|
||
`SESSION_SECRET` in `.env`. In `NODE_ENV=development` the server falls back
|
||
to an insecure default with a warning.
|
||
|
||
**`SqliteError: no such table: ...`** — you haven't run migrations. Run
|
||
`npm run migrate`.
|
||
|
||
**`better-sqlite3` or `bcrypt` build failure on install** — install platform
|
||
build tools (see [Prerequisites](#prerequisites)) and re-run `npm install`.
|
||
|
||
**Verification email never arrives** — if `SMTP_HOST` is empty, by design no
|
||
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.
|
||
|
||
**Layout looks wrong / inputs misaligned** — the DOM overlay repositions on
|
||
the Phaser `resize` event. If you resize the window very fast, give it a
|
||
beat; if it persists, file an issue.
|
||
|
||
---
|
||
|
||
## Roadmap
|
||
|
||
The framework is intentionally game-agnostic. Concrete games come next:
|
||
|
||
- [ ] **Backgammon** — 2-player turn-based on `TabletopGame`
|
||
- [ ] **Parchisi** — up to 4 players on `TabletopGame`
|
||
- [ ] **Blackjack** — 1–6 players on `CasinoGame`
|
||
- [ ] **Texas Hold 'Em** — 2–8 players on `CasinoGame`
|
||
- [ ] Server-authoritative game state, per-seat private state for casino games
|
||
- [ ] Match recording into `matches` / `match_players` for history
|
||
- [ ] Friends list + invites
|
||
- [ ] Spectator mode
|
||
|
||
Contributions welcome — start by registering a game in `gameRegistry.js` and
|
||
extending one of the base classes.
|