fertig-classic-games/README.md

491 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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** — 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.