First Commit

This commit is contained in:
Brian Fertig 2026-05-15 19:35:16 -06:00
commit 96f4cedd3e
43 changed files with 4568 additions and 0 deletions

490
README.md Normal file
View File

@ -0,0 +1,490 @@
# 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.

46
example.env Normal file
View File

@ -0,0 +1,46 @@
# Copy this file to `.env` and fill in real values for local development.
# `.env` is gitignored; `example.env` is committed as the template.
# ---- Server ----
NODE_ENV=development
HOST=0.0.0.0
PORT=3000
# Public base URL used in verification emails and links. No trailing slash.
BASE_URL=http://localhost:3000
# ---- Database ----
# SQLite file path, relative to the server working directory.
DB_PATH=./data/fertig.sqlite
# ---- Auth ----
# Secret for signing session cookies. Generate with:
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
SESSION_SECRET=replace-me-with-a-long-random-string
SESSION_COOKIE_NAME=fcg_sid
SESSION_TTL_DAYS=30
BCRYPT_ROUNDS=12
# ---- Uploads (profile pictures) ----
UPLOAD_DIR=./public/uploads
MAX_UPLOAD_SIZE_MB=5
# Comma-separated list of accepted MIME types.
ALLOWED_UPLOAD_MIME=image/png,image/jpeg,image/webp
# ---- Email verification ----
# If SMTP_HOST is empty, the mailer logs verification links to the server
# console instead of sending email. Useful for local dev.
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM="Fertig Classic Games <no-reply@fertigclassicgames.local>"
VERIFICATION_TOKEN_TTL_HOURS=24
# ---- Multiplayer (Socket.IO) ----
# CORS origin for the socket server. Use "*" only in development.
SOCKET_IO_CORS_ORIGIN=http://localhost:3000
# ---- Logging ----
# One of: error, warn, info, debug
LOG_LEVEL=info

30
game.md Normal file
View File

@ -0,0 +1,30 @@
# Build Guidelines
Create an HTML Phaser 3 video game. The game should be a similar type of game to the old Hoyle Classic Games type of game.
## Tools and Organization
- Phaser version 3.90 HTML game
- Use JavaScript
- Have JavaScript objects reference each other directly via IMPORT and EXPORT using ES6 standards
- Do **NOT** require a web packager.
- Create files and classes in a manner that allows future modifications and scaling at a modular level
- Integrate SQLite to allow for long term web storage.
- allow for account login/password creation, profile pic uploading, profile creation.
- Keep history of games, wins, losses dates etc.
- Use basic email verification on account creation.
- Allow for multiplayer. We'll create a multiplayer lobby for some games, and allow players to simply join some others.
- Just create a basic framework to start with and we'll add the games themselves later
- Let's create the ability to create accounts and save/edit profiles.
- Create a landing page for "Fertig Classic Games" and we'll add the games themselves later. 
- Prepare a framework to allow tabletop type games such as Backgammon and Parchisi, and also Casino games such as backjack and texas hold em.
## Basic Framework
- 1920 x 1080 view
- Scale view to user's viewport.
- Use basic temporary vector graphics that can later be replaced by sprites
## Controls
- Mouse + Keyboard controls for all games.

2018
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "fertig-classic-games",
"version": "0.1.0",
"description": "Fertig Classic Games — Phaser 3 framework for tabletop and casino games",
"type": "module",
"private": true,
"scripts": {
"start": "node server/index.js",
"dev": "node --watch server/index.js",
"migrate": "node server/db/migrate.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.3.0",
"cookie": "^0.6.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^2.0.0",
"nodemailer": "^6.9.14",
"socket.io": "^4.7.5"
}
}

23
public/index.html Normal file
View File

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Fertig Classic Games</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="game-container"></div>
<div id="dom-layer" aria-hidden="true"></div>
<script type="importmap">
{
"imports": {
"phaser": "https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.esm.js",
"socket.io-client": "https://cdn.jsdelivr.net/npm/socket.io-client@4.7.5/+esm"
}
}
</script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

19
public/src/config.js Normal file
View File

@ -0,0 +1,19 @@
export const GAME_WIDTH = 1920;
export const GAME_HEIGHT = 1080;
export const COLORS = {
bg: 0x0a0e14,
bgHex: '#0a0e14',
panel: 0x111923,
panelHex: '#111923',
accent: 0x5aa9e6,
accentHex: '#5aa9e6',
text: 0xe6edf3,
textHex: '#e6edf3',
muted: 0x8a94a6,
mutedHex: '#8a94a6',
danger: 0xe06c75,
dangerHex: '#e06c75',
};
export const API_BASE = '/api';

View File

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

View File

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

View File

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

39
public/src/main.js Normal file
View File

@ -0,0 +1,39 @@
import Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from './config.js';
import BootScene from './scenes/BootScene.js';
import PreloadScene from './scenes/PreloadScene.js';
import LandingScene from './scenes/LandingScene.js';
import LoginScene from './scenes/LoginScene.js';
import RegisterScene from './scenes/RegisterScene.js';
import VerifyScene from './scenes/VerifyScene.js';
import ProfileScene from './scenes/ProfileScene.js';
import GameMenuScene from './scenes/GameMenuScene.js';
import LobbyScene from './scenes/LobbyScene.js';
import GameRoomScene from './scenes/GameRoomScene.js';
const config = {
type: Phaser.AUTO,
parent: 'game-container',
backgroundColor: COLORS.bgHex,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
width: GAME_WIDTH,
height: GAME_HEIGHT,
},
dom: { createContainer: true },
scene: [
BootScene,
PreloadScene,
LandingScene,
LoginScene,
RegisterScene,
VerifyScene,
ProfileScene,
GameMenuScene,
LobbyScene,
GameRoomScene,
],
};
new Phaser.Game(config);

View File

@ -0,0 +1,6 @@
import Phaser from 'phaser';
export default class BootScene extends Phaser.Scene {
constructor() { super('Boot'); }
create() { this.scene.start('Preload'); }
}

View File

@ -0,0 +1,68 @@
import Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { api } from '../services/api.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';
export default class GameMenuScene extends Phaser.Scene {
constructor() { super('GameMenu'); }
async create() {
const cx = GAME_WIDTH / 2;
this.add.text(cx, 120, 'Choose a game', {
fontFamily: 'system-ui, sans-serif',
fontSize: '64px',
color: COLORS.textHex,
}).setOrigin(0.5);
const loadingText = this.add.text(cx, 220, 'Loading game list…', {
fontSize: '24px', color: COLORS.mutedHex,
}).setOrigin(0.5);
let games = [];
try {
const res = await api.get('/games');
games = res.games ?? [];
} catch (err) {
loadingText.setText(`Failed to load games: ${err.message}`);
return;
}
loadingText.destroy();
const tabletop = games.filter((g) => g.category === 'tabletop');
const casino = games.filter((g) => g.category === 'casino');
this.renderColumn('Tabletop', tabletop, cx - 420, 260);
this.renderColumn('Casino', casino, cx + 420, 260);
new Button(this, cx, GAME_HEIGHT - 100, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
}
renderColumn(title, games, x, y) {
this.add.text(x, y, title, {
fontFamily: 'system-ui, sans-serif',
fontSize: '40px',
color: COLORS.accentHex,
}).setOrigin(0.5);
games.forEach((game, i) => {
const btn = new Button(this, x, y + 80 + i * 90, game.name, () => this.openGame(game), {
width: 360,
});
void btn;
});
}
openGame(game) {
if (!auth.user && game.supportsMultiplayer) {
this.scene.start('Login');
return;
}
if (game.supportsMultiplayer) {
this.scene.start('Lobby', { game });
} else {
this.scene.start('GameRoom', { game });
}
}
}

View File

@ -0,0 +1,70 @@
import Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { getSocket } from '../services/socket.js';
import { Button } from '../ui/Button.js';
// Generic room shell. A concrete game (Backgammon, Blackjack, etc.) will
// later be instantiated here based on `data.game.slug`, taking over the
// rendering and input handling.
export default class GameRoomScene extends Phaser.Scene {
constructor() { super('GameRoom'); }
init(data) {
this.game = data.game;
this.room = data.room ?? null;
}
create() {
const cx = GAME_WIDTH / 2;
this.add.text(cx, 80, `${this.game.name}`, {
fontFamily: 'system-ui, sans-serif',
fontSize: '52px',
color: COLORS.textHex,
}).setOrigin(0.5);
this.add.text(cx, 150, this.room
? `Table: ${this.room.name}`
: 'Single-player table',
{ fontSize: '24px', color: COLORS.mutedHex }).setOrigin(0.5);
// Placeholder table felt
this.add.rectangle(cx, GAME_HEIGHT / 2 + 40, 1400, 700, 0x14532d).setStrokeStyle(4, COLORS.accent);
this.add.text(cx, GAME_HEIGHT / 2 + 40, `${this.game.name} board placeholder\n(game logic plugs in here)`, {
fontFamily: 'system-ui, sans-serif',
fontSize: '28px',
color: COLORS.mutedHex,
align: 'center',
}).setOrigin(0.5);
this.seatsText = this.add.text(cx, GAME_HEIGHT - 220, this.formatSeats(this.room), {
fontSize: '22px', color: COLORS.textHex, align: 'center',
}).setOrigin(0.5);
new Button(this, cx, GAME_HEIGHT - 100, 'Leave table', () => this.leave());
if (this.room) {
const socket = getSocket();
socket.on('room:update', this.onRoomUpdate = (room) => {
if (room.id !== this.room.id) return;
this.room = room;
this.seatsText.setText(this.formatSeats(room));
});
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
socket.off('room:update', this.onRoomUpdate);
});
}
}
formatSeats(room) {
if (!room) return 'Solo session';
const seats = room.players.map((p) => `Seat ${p.seat + 1}: ${p.username}`).join(' · ');
return `${room.players.length}/${room.maxPlayers} players\n${seats}`;
}
leave() {
const socket = getSocket();
if (this.room) socket.emit('room:leave', { roomId: this.room.id }, () => {});
this.scene.start('GameMenu');
}
}

View File

@ -0,0 +1,65 @@
import Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';
export default class LandingScene extends Phaser.Scene {
constructor() { super('Landing'); }
create() {
const cx = GAME_WIDTH / 2;
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg);
this.add.text(cx, 220, 'Fertig Classic Games', {
fontFamily: 'Georgia, "Times New Roman", serif',
fontSize: '96px',
color: COLORS.textHex,
}).setOrigin(0.5);
this.add.text(cx, 320, 'Cards, dice, and classic tables.', {
fontFamily: 'system-ui, sans-serif',
fontSize: '32px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
this.renderButtons();
auth.subscribe(() => {
if (!this.scene.isActive('Landing')) return;
this.children.removeAll();
this.create();
});
}
renderButtons() {
const cx = GAME_WIDTH / 2;
const user = auth.user;
if (user) {
this.add.text(cx, 480, `Welcome back, ${user.username}`, {
fontFamily: 'system-ui, sans-serif',
fontSize: '36px',
color: COLORS.accentHex,
}).setOrigin(0.5);
if (!user.emailVerified) {
this.add.text(cx, 540, 'Email not yet verified — check the server console for the link in dev.', {
fontFamily: 'system-ui, sans-serif',
fontSize: '22px',
color: COLORS.dangerHex,
}).setOrigin(0.5);
}
new Button(this, cx, 660, 'Play', () => this.scene.start('GameMenu'));
new Button(this, cx, 740, 'Profile', () => this.scene.start('Profile'));
new Button(this, cx, 820, 'Sign out', async () => {
await auth.logout();
}, { variant: 'ghost' });
} else {
new Button(this, cx, 540, 'Sign in', () => this.scene.start('Login'));
new Button(this, cx, 620, 'Create account', () => this.scene.start('Register'));
new Button(this, cx, 700, 'Continue as guest', () => this.scene.start('GameMenu'), { variant: 'ghost' });
}
}
}

View File

@ -0,0 +1,87 @@
import 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: 'system-ui, sans-serif',
fontSize: '52px',
color: COLORS.textHex,
}).setOrigin(0.5);
this.listText = this.add.text(cx, 220, 'Connecting…', {
fontFamily: 'system-ui, sans-serif',
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

@ -0,0 +1,50 @@
import Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';
import { TextInput } from '../ui/TextInput.js';
import { Modal } from '../ui/Modal.js';
export default class LoginScene extends Phaser.Scene {
constructor() { super('Login'); }
create() {
const cx = GAME_WIDTH / 2;
this.add.text(cx, 200, 'Sign in', {
fontFamily: 'system-ui, sans-serif',
fontSize: '64px',
color: COLORS.textHex,
}).setOrigin(0.5);
this.add.text(cx - 180, 340, 'Email or username', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const identifierInput = new TextInput(this, cx, 380, { width: 480, placeholder: 'you@example.com', autocomplete: 'username' });
this.add.text(cx - 180, 460, 'Password', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const passwordInput = new TextInput(this, cx, 500, { width: 480, type: 'password', autocomplete: 'current-password' });
const submit = new Button(this, cx, 620, 'Sign in', () => this.attemptLogin(identifierInput, passwordInput, submit));
new Button(this, cx, 700, 'Need an account?', () => this.scene.start('Register'), { variant: 'ghost' });
new Button(this, cx, 780, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
this.input.keyboard.on('keydown-ENTER', () => this.attemptLogin(identifierInput, passwordInput, submit));
}
async attemptLogin(identifierInput, passwordInput, button) {
const identifier = identifierInput.value.trim();
const password = passwordInput.value;
if (!identifier || !password) {
new Modal(this, 'Enter your username/email and password.', { color: COLORS.dangerHex, autoCloseMs: 2000 });
return;
}
button.setEnabled(false);
try {
await auth.login(identifier, password);
this.scene.start('Landing');
} catch (err) {
new Modal(this, err.message ?? 'Sign in failed.', { color: COLORS.dangerHex, autoCloseMs: 2400 });
} finally {
button.setEnabled(true);
}
}
}

View File

@ -0,0 +1,33 @@
import Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js';
export default class PreloadScene extends Phaser.Scene {
constructor() { super('Preload'); }
preload() {
const w = GAME_WIDTH;
const h = GAME_HEIGHT;
const barWidth = 600;
const bg = this.add.rectangle(w / 2, h / 2, barWidth + 8, 28, COLORS.panel)
.setStrokeStyle(2, COLORS.accent);
const bar = this.add.rectangle(w / 2 - barWidth / 2, h / 2, 0, 20, COLORS.accent)
.setOrigin(0, 0.5);
this.add.text(w / 2, h / 2 - 60, 'Loading…', {
fontFamily: 'system-ui, sans-serif',
fontSize: '32px',
color: COLORS.textHex,
}).setOrigin(0.5);
this.load.on('progress', (p) => bar.width = barWidth * p);
this.load.on('complete', () => { bg.destroy(); bar.destroy(); });
// Placeholder asset slot — drop sprite files here later, no asset is
// required for the framework to boot.
}
async create() {
await auth.refresh();
this.scene.start('Landing');
}
}

View File

@ -0,0 +1,124 @@
import Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { api } from '../services/api.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';
import { TextInput } from '../ui/TextInput.js';
import { Modal } from '../ui/Modal.js';
export default class ProfileScene extends Phaser.Scene {
constructor() { super('Profile'); }
async create() {
if (!auth.user) {
this.scene.start('Login');
return;
}
const cx = GAME_WIDTH / 2;
this.add.text(cx, 140, 'Profile', {
fontFamily: 'system-ui, sans-serif',
fontSize: '64px',
color: COLORS.textHex,
}).setOrigin(0.5);
this.statusText = this.add.text(cx, 220, 'Loading profile…', {
fontFamily: 'system-ui, sans-serif',
fontSize: '24px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
let profile;
try {
const { profile: p } = await api.get('/profile');
profile = p;
} catch (err) {
this.statusText.setText(err.message);
return;
}
this.statusText.destroy();
// Avatar slot (placeholder vector)
const avatarX = cx - 480;
const avatarY = 380;
const avatarBg = this.add.circle(avatarX, avatarY, 96, COLORS.panel).setStrokeStyle(3, COLORS.accent);
const initial = (profile.displayName ?? profile.username ?? '?').charAt(0).toUpperCase();
this.add.text(avatarX, avatarY, initial, {
fontFamily: 'system-ui, sans-serif',
fontSize: '72px',
color: COLORS.accentHex,
}).setOrigin(0.5);
void avatarBg;
if (profile.avatarPath) {
this.load.image(`avatar-${profile.id}`, profile.avatarPath);
this.load.once('complete', () => {
const img = this.add.image(avatarX, avatarY, `avatar-${profile.id}`);
img.setDisplaySize(180, 180);
});
this.load.start();
}
this.add.text(cx - 320, 300, profile.username, {
fontFamily: 'system-ui, sans-serif',
fontSize: '40px',
color: COLORS.textHex,
}).setOrigin(0, 0.5);
this.add.text(cx - 320, 350, profile.email, {
fontFamily: 'system-ui, sans-serif',
fontSize: '24px',
color: COLORS.mutedHex,
}).setOrigin(0, 0.5);
if (!profile.emailVerified) {
this.add.text(cx - 320, 390, '(Email not verified)', {
fontSize: '22px', color: COLORS.dangerHex,
}).setOrigin(0, 0.5);
}
// Editable fields
this.add.text(cx - 320, 480, 'Display name', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const displayNameInput = new TextInput(this, cx + 80, 520, { width: 600, value: profile.displayName ?? '', maxLength: 60 });
this.add.text(cx - 320, 600, 'Bio', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const bioInput = new TextInput(this, cx + 80, 660, { width: 600, height: 120, multiline: true, value: profile.bio ?? '', maxLength: 500 });
new Button(this, cx - 200, 820, 'Save profile', async () => {
try {
const { profile: updated } = await api.patch('/profile', {
displayName: displayNameInput.value,
bio: bioInput.value,
});
new Modal(this, 'Profile saved.', { autoCloseMs: 1500 });
profile = updated;
} catch (err) {
new Modal(this, err.message, { color: COLORS.dangerHex, autoCloseMs: 2400 });
}
});
new Button(this, cx + 100, 820, 'Upload avatar', () => this.pickAvatar());
new Button(this, cx + 400, 820, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
}
pickAvatar() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png,image/jpeg,image/webp';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const fd = new FormData();
fd.append('avatar', file);
try {
await api.upload('/profile/avatar', fd);
new Modal(this, 'Avatar uploaded. Reopening…', { autoCloseMs: 1200 });
this.time.delayedCall(1200, () => this.scene.restart());
} catch (err) {
new Modal(this, err.message, { color: COLORS.dangerHex, autoCloseMs: 2400 });
}
};
input.click();
}
}

View File

@ -0,0 +1,54 @@
import Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';
import { TextInput } from '../ui/TextInput.js';
import { Modal } from '../ui/Modal.js';
export default class RegisterScene extends Phaser.Scene {
constructor() { super('Register'); }
create() {
const cx = GAME_WIDTH / 2;
this.add.text(cx, 180, 'Create account', {
fontFamily: 'system-ui, sans-serif',
fontSize: '64px',
color: COLORS.textHex,
}).setOrigin(0.5);
this.add.text(cx - 180, 300, 'Email', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const emailInput = new TextInput(this, cx, 340, { width: 480, type: 'email', autocomplete: 'email' });
this.add.text(cx - 180, 420, 'Username', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const usernameInput = new TextInput(this, cx, 460, { width: 480, autocomplete: 'username' });
this.add.text(cx - 180, 540, 'Password (min 8 chars)', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const passwordInput = new TextInput(this, cx, 580, { width: 480, type: 'password', autocomplete: 'new-password' });
const submit = new Button(this, cx, 700, 'Create account', () => this.attempt(emailInput, usernameInput, passwordInput, submit));
new Button(this, cx, 780, 'Already have an account?', () => this.scene.start('Login'), { variant: 'ghost' });
new Button(this, cx, 860, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
this.input.keyboard.on('keydown-ENTER', () => this.attempt(emailInput, usernameInput, passwordInput, submit));
}
async attempt(emailInput, usernameInput, passwordInput, button) {
button.setEnabled(false);
try {
const result = await auth.register({
email: emailInput.value.trim(),
username: usernameInput.value.trim(),
password: passwordInput.value,
});
this.scene.start('Verify', { verification: result.verification });
} catch (err) {
const messages = err.data?.errors
? Object.values(err.data.errors).join('\n')
: err.message ?? 'Sign up failed.';
new Modal(this, messages, { color: COLORS.dangerHex, autoCloseMs: 3000 });
} finally {
button.setEnabled(true);
}
}
}

View File

@ -0,0 +1,51 @@
import Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';
export default class VerifyScene extends Phaser.Scene {
constructor() { super('Verify'); }
init(data) {
this.verification = data?.verification ?? { sent: false, devLink: null };
}
create() {
const cx = GAME_WIDTH / 2;
this.add.text(cx, 220, 'Verify your email', {
fontFamily: 'system-ui, sans-serif',
fontSize: '64px',
color: COLORS.textHex,
}).setOrigin(0.5);
const body = this.verification.sent
? 'We sent a verification link to your email. Click the link to confirm your account.'
: 'SMTP is not configured. Use the dev link below to verify your email.';
this.add.text(cx, 360, body, {
fontFamily: 'system-ui, sans-serif',
fontSize: '26px',
color: COLORS.mutedHex,
wordWrap: { width: 1200 },
align: 'center',
}).setOrigin(0.5);
if (this.verification.devLink) {
this.add.text(cx, 470, this.verification.devLink, {
fontFamily: 'monospace',
fontSize: '22px',
color: COLORS.accentHex,
wordWrap: { width: 1400 },
align: 'center',
}).setOrigin(0.5).setInteractive({ useHandCursor: true })
.on('pointerup', () => window.open(this.verification.devLink, '_blank'));
}
new Button(this, cx, 640, 'I have verified — refresh', async () => {
await auth.refresh();
this.scene.start('Landing');
});
new Button(this, cx, 720, 'Continue without verifying', () => this.scene.start('Landing'), { variant: 'ghost' });
}
}

View File

@ -0,0 +1,32 @@
import { API_BASE } from '../config.js';
async function request(method, path, { body, formData } = {}) {
const init = { method, credentials: 'same-origin', headers: {} };
if (formData) {
init.body = formData;
} else if (body !== undefined) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
const res = await fetch(`${API_BASE}${path}`, init);
const text = await res.text();
let data = null;
if (text) {
try { data = JSON.parse(text); } catch { data = { raw: text }; }
}
if (!res.ok) {
const err = new Error(data?.error ?? `HTTP ${res.status}`);
err.status = res.status;
err.data = data;
throw err;
}
return data;
}
export const api = {
get: (path) => request('GET', path),
post: (path, body) => request('POST', path, { body }),
patch: (path, body) => request('PATCH', path, { body }),
delete: (path) => request('DELETE', path),
upload: (path, formData) => request('POST', path, { formData }),
};

View File

@ -0,0 +1,50 @@
import { api } from './api.js';
class AuthStore {
constructor() {
this.user = null;
this.listeners = new Set();
}
subscribe(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
emit() {
for (const fn of this.listeners) fn(this.user);
}
async refresh() {
try {
const { user } = await api.get('/auth/me');
this.user = user;
} catch {
this.user = null;
}
this.emit();
return this.user;
}
async login(identifier, password) {
const { user } = await api.post('/auth/login', { identifier, password });
this.user = user;
this.emit();
return user;
}
async register({ email, username, password }) {
const result = await api.post('/auth/register', { email, username, password });
this.user = result.user;
this.emit();
return result;
}
async logout() {
try { await api.post('/auth/logout'); } catch { /* ignore */ }
this.user = null;
this.emit();
}
}
export const auth = new AuthStore();

View File

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

53
public/src/ui/Button.js Normal file
View File

@ -0,0 +1,53 @@
import Phaser from 'phaser';
import { COLORS } from '../config.js';
export class Button extends Phaser.GameObjects.Container {
constructor(scene, x, y, label, onClick, options = {}) {
super(scene, x, y);
const {
width = 280,
height = 64,
bg = COLORS.panel,
bgHover = COLORS.accent,
textColor = COLORS.textHex,
fontSize = 28,
variant = 'solid',
} = options;
this.options = { width, height, bg, bgHover, textColor, fontSize, variant };
this.bgRect = scene.add.rectangle(0, 0, width, height, bg, variant === 'ghost' ? 0 : 1);
this.bgRect.setStrokeStyle(2, COLORS.accent, 1);
this.text = scene.add.text(0, 0, label, {
fontFamily: 'system-ui, sans-serif',
fontSize: `${fontSize}px`,
color: textColor,
}).setOrigin(0.5);
this.add([this.bgRect, this.text]);
this.setSize(width, height);
this.setInteractive({ useHandCursor: true });
this.on('pointerover', () => this.bgRect.setFillStyle(bgHover, 1));
this.on('pointerout', () => this.bgRect.setFillStyle(bg, variant === 'ghost' ? 0 : 1));
this.on('pointerdown', () => this.bgRect.setScale(0.97));
this.on('pointerup', () => this.bgRect.setScale(1));
this.on('pointerupoutside', () => this.bgRect.setScale(1));
if (onClick) this.on('pointerup', onClick);
scene.add.existing(this);
}
setLabel(label) {
this.text.setText(label);
return this;
}
setEnabled(enabled) {
this.setAlpha(enabled ? 1 : 0.5);
if (enabled) this.setInteractive({ useHandCursor: true });
else this.disableInteractive();
return this;
}
}

29
public/src/ui/Modal.js Normal file
View File

@ -0,0 +1,29 @@
import Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
export class Modal extends Phaser.GameObjects.Container {
constructor(scene, message, options = {}) {
super(scene, 0, 0);
const overlay = scene.add.rectangle(
GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6,
).setInteractive();
const panel = scene.add.rectangle(
GAME_WIDTH / 2, GAME_HEIGHT / 2, 720, 280, COLORS.panel, 1,
).setStrokeStyle(2, COLORS.accent);
const text = scene.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 30, message, {
fontFamily: 'system-ui, sans-serif',
fontSize: '28px',
color: options.color ?? COLORS.textHex,
wordWrap: { width: 660 },
align: 'center',
}).setOrigin(0.5);
this.add([overlay, panel, text]);
scene.add.existing(this);
if (options.autoCloseMs) {
scene.time.delayedCall(options.autoCloseMs, () => this.destroy());
} else {
overlay.on('pointerdown', () => this.destroy());
}
}
}

View File

@ -0,0 +1,65 @@
import Phaser from 'phaser';
// DOM-overlay text input. Positions a real <input> element above the canvas
// using the scene's scale so it lines up with where you'd draw it in Phaser.
export class TextInput {
constructor(scene, x, y, options = {}) {
this.scene = scene;
this.gameX = x;
this.gameY = y;
this.width = options.width ?? 360;
this.height = options.height ?? 48;
this.layer = document.getElementById('dom-layer');
const isTextarea = options.multiline === true;
this.el = document.createElement(isTextarea ? 'textarea' : 'input');
if (!isTextarea) this.el.type = options.type ?? 'text';
if (options.placeholder) this.el.placeholder = options.placeholder;
if (options.value !== undefined) this.el.value = options.value;
if (options.maxLength) this.el.maxLength = options.maxLength;
if (options.autocomplete) this.el.autocomplete = options.autocomplete;
this.el.style.position = 'absolute';
this.el.style.boxSizing = 'border-box';
this.layer.appendChild(this.el);
this.reposition = this.reposition.bind(this);
this.scene.scale.on('resize', this.reposition);
this.reposition();
this.scene.events.once(Phaser.Scenes.Events.SHUTDOWN, () => this.destroy());
this.scene.events.once(Phaser.Scenes.Events.DESTROY, () => this.destroy());
}
reposition() {
const cam = this.scene.cameras.main;
const zoom = this.scene.scale.displayScale; // {x, y} canvas-to-css scale
const canvas = this.scene.scale.canvas;
const rect = canvas.getBoundingClientRect();
const cssX = rect.left + (this.gameX - this.width / 2) / zoom.x;
const cssY = rect.top + (this.gameY - this.height / 2) / zoom.y;
const cssW = this.width / zoom.x;
const cssH = this.height / zoom.y;
this.el.style.left = `${cssX}px`;
this.el.style.top = `${cssY}px`;
this.el.style.width = `${cssW}px`;
this.el.style.height = `${cssH}px`;
this.el.style.fontSize = `${20 / zoom.y}px`;
// Avoid unused-variable lint complaints
void cam;
}
get value() { return this.el.value; }
set value(v) { this.el.value = v; }
focus() { this.el.focus(); }
on(event, handler) {
this.el.addEventListener(event, handler);
return this;
}
destroy() {
this.scene.scale.off('resize', this.reposition);
this.el.remove();
}
}

58
public/styles.css Normal file
View File

@ -0,0 +1,58 @@
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #0a0e14;
color: #e6edf3;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
overflow: hidden;
}
#game-container {
width: 100%;
height: 100%;
}
#game-container canvas {
display: block;
margin: 0 auto;
}
/* DOM overlay used by UI components (text inputs, file pickers) positioned
absolutely above the canvas. Pointer events are enabled per-element. */
#dom-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
#dom-layer * {
pointer-events: auto;
}
#dom-layer input[type="text"],
#dom-layer input[type="email"],
#dom-layer input[type="password"],
#dom-layer textarea {
font: inherit;
background: rgba(15, 23, 36, 0.95);
color: #e6edf3;
border: 1px solid #2c3a4d;
border-radius: 6px;
padding: 8px 10px;
outline: none;
}
#dom-layer input:focus,
#dom-layer textarea:focus {
border-color: #5aa9e6;
box-shadow: 0 0 0 2px rgba(90, 169, 230, 0.3);
}
#dom-layer button {
font: inherit;
}

14
server/auth/middleware.js Normal file
View File

@ -0,0 +1,14 @@
import config from '../config.js';
import { findSessionUser } from './service.js';
export function loadUser(req, _res, next) {
const sid = req.cookies?.[config.auth.cookieName];
req.user = findSessionUser(sid);
req.sessionId = sid ?? null;
next();
}
export function requireAuth(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'Not signed in.' });
next();
}

89
server/auth/routes.js Normal file
View File

@ -0,0 +1,89 @@
import { Router } from 'express';
import config from '../config.js';
import { sendVerificationEmail } from '../email/mailer.js';
import {
consumeVerificationToken,
createSession,
createUser,
destroySession,
validateRegistration,
verifyPassword,
} from './service.js';
const router = Router();
function setSessionCookie(res, session) {
res.cookie(config.auth.cookieName, session.id, {
httpOnly: true,
sameSite: 'lax',
secure: config.env === 'production',
expires: new Date(session.expiresAt),
});
}
router.post('/register', async (req, res) => {
const { email, username, password } = req.body ?? {};
const errors = validateRegistration({ email, username, password });
if (Object.keys(errors).length) return res.status(400).json({ errors });
let user;
try {
user = await createUser({ email, username, password });
} catch (err) {
if (err.code === 'EMAIL_TAKEN') return res.status(409).json({ errors: { email: err.message } });
if (err.code === 'USERNAME_TAKEN') return res.status(409).json({ errors: { username: err.message } });
throw err;
}
const link = `${config.baseUrl}/api/auth/verify?token=${user.verificationToken}`;
const mailResult = await sendVerificationEmail(user.email, link);
const session = createSession(user.id);
setSessionCookie(res, session);
res.status(201).json({
user: { id: user.id, email: user.email, username: user.username, emailVerified: false },
verification: { sent: mailResult.delivered, devLink: mailResult.devLogged ? link : null },
});
});
router.post('/login', async (req, res) => {
const { identifier, password } = req.body ?? {};
if (!identifier || !password) return res.status(400).json({ error: 'Missing credentials.' });
const user = await verifyPassword(identifier, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials.' });
const session = createSession(user.id);
setSessionCookie(res, session);
res.json({
user: {
id: user.id,
email: user.email,
username: user.username,
emailVerified: !!user.email_verified,
},
});
});
router.post('/logout', (req, res) => {
destroySession(req.sessionId);
res.clearCookie(config.auth.cookieName);
res.json({ ok: true });
});
router.get('/me', (req, res) => {
if (!req.user) return res.json({ user: null });
res.json({ user: req.user });
});
router.get('/verify', (req, res) => {
const token = String(req.query.token ?? '');
const userId = consumeVerificationToken(token);
if (!userId) {
return res.status(400).send('<h1>Verification failed</h1><p>Link invalid or expired.</p>');
}
res.send('<h1>Email verified</h1><p>You can close this tab and return to the game.</p>');
});
export default router;

126
server/auth/service.js Normal file
View File

@ -0,0 +1,126 @@
import crypto from 'node:crypto';
import bcrypt from 'bcrypt';
import db from '../db/index.js';
import config from '../config.js';
const EMAIL_RX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const USERNAME_RX = /^[a-zA-Z0-9_]{3,24}$/;
export function validateRegistration({ email, username, password }) {
const errors = {};
if (!email || !EMAIL_RX.test(email)) errors.email = 'Invalid email address.';
if (!username || !USERNAME_RX.test(username)) {
errors.username = 'Username must be 3-24 chars (letters, digits, underscore).';
}
if (!password || password.length < 8) {
errors.password = 'Password must be at least 8 characters.';
}
return errors;
}
export async function createUser({ email, username, password }) {
const lowerEmail = email.toLowerCase();
const existing = db
.prepare('SELECT id FROM users WHERE email = ? OR username = ?')
.get(lowerEmail, username);
if (existing) {
const conflict = db
.prepare('SELECT email, username FROM users WHERE id = ?')
.get(existing.id);
if (conflict.email === lowerEmail) {
throw Object.assign(new Error('Email already in use.'), { code: 'EMAIL_TAKEN' });
}
throw Object.assign(new Error('Username already in use.'), { code: 'USERNAME_TAKEN' });
}
const hash = await bcrypt.hash(password, config.auth.bcryptRounds);
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(
Date.now() + config.email.verificationTtlHours * 3600 * 1000,
).toISOString();
const info = db
.prepare(
`INSERT INTO users (email, username, password_hash, verification_token, verification_expires_at)
VALUES (?, ?, ?, ?, ?)`,
)
.run(lowerEmail, username, hash, token, expires);
db.prepare(
'INSERT INTO profiles (user_id, display_name) VALUES (?, ?)',
).run(info.lastInsertRowid, username);
return {
id: info.lastInsertRowid,
email: lowerEmail,
username,
verificationToken: token,
};
}
export async function verifyPassword(identifier, password) {
const ident = identifier.toLowerCase();
const user = db
.prepare(
'SELECT id, email, username, password_hash, email_verified FROM users WHERE email = ? OR LOWER(username) = ?',
)
.get(ident, ident);
if (!user) return null;
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return null;
return user;
}
export function createSession(userId) {
const id = crypto.randomBytes(32).toString('hex');
const expires = new Date(
Date.now() + config.auth.sessionTtlDays * 86400 * 1000,
).toISOString();
db.prepare(
'INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)',
).run(id, userId, expires);
return { id, expiresAt: expires };
}
export function destroySession(sessionId) {
if (!sessionId) return;
db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
}
export function findSessionUser(sessionId) {
if (!sessionId) return null;
const row = db
.prepare(
`SELECT u.id, u.email, u.username, u.email_verified, s.expires_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.id = ?`,
)
.get(sessionId);
if (!row) return null;
if (new Date(row.expires_at).getTime() < Date.now()) {
destroySession(sessionId);
return null;
}
return {
id: row.id,
email: row.email,
username: row.username,
emailVerified: !!row.email_verified,
};
}
export function consumeVerificationToken(token) {
const user = db
.prepare(
`SELECT id, verification_expires_at FROM users
WHERE verification_token = ? AND email_verified = 0`,
)
.get(token);
if (!user) return null;
if (new Date(user.verification_expires_at).getTime() < Date.now()) return null;
db.prepare(
`UPDATE users SET email_verified = 1, verification_token = NULL,
verification_expires_at = NULL WHERE id = ?`,
).run(user.id);
return user.id;
}

76
server/config.js Normal file
View File

@ -0,0 +1,76 @@
import 'dotenv/config';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
function bool(v, fallback = false) {
if (v === undefined || v === '') return fallback;
return /^(1|true|yes|on)$/i.test(v);
}
function int(v, fallback) {
const n = Number.parseInt(v, 10);
return Number.isFinite(n) ? n : fallback;
}
function resolveFromRoot(p) {
return path.isAbsolute(p) ? p : path.resolve(ROOT, p);
}
const config = {
root: ROOT,
env: process.env.NODE_ENV ?? 'development',
host: process.env.HOST ?? '0.0.0.0',
port: int(process.env.PORT, 3000),
baseUrl: process.env.BASE_URL ?? 'http://localhost:3000',
logLevel: process.env.LOG_LEVEL ?? 'info',
db: {
path: resolveFromRoot(process.env.DB_PATH ?? './data/fertig.sqlite'),
migrationsDir: path.join(__dirname, 'db', 'migrations'),
},
auth: {
sessionSecret: process.env.SESSION_SECRET ?? '',
cookieName: process.env.SESSION_COOKIE_NAME ?? 'fcg_sid',
sessionTtlDays: int(process.env.SESSION_TTL_DAYS, 30),
bcryptRounds: int(process.env.BCRYPT_ROUNDS, 12),
},
uploads: {
dir: resolveFromRoot(process.env.UPLOAD_DIR ?? './public/uploads'),
maxSizeMb: int(process.env.MAX_UPLOAD_SIZE_MB, 5),
allowedMime: (process.env.ALLOWED_UPLOAD_MIME ?? 'image/png,image/jpeg,image/webp')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
},
email: {
host: process.env.SMTP_HOST ?? '',
port: int(process.env.SMTP_PORT, 587),
secure: bool(process.env.SMTP_SECURE, false),
user: process.env.SMTP_USER ?? '',
pass: process.env.SMTP_PASS ?? '',
from: process.env.SMTP_FROM ?? 'Fertig Classic Games <no-reply@localhost>',
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'),
};
if (!config.auth.sessionSecret) {
if (config.env === 'production') {
throw new Error('SESSION_SECRET must be set in production');
}
console.warn('[config] SESSION_SECRET is empty — using an insecure dev default');
config.auth.sessionSecret = 'dev-insecure-secret-do-not-use-in-production';
}
export default config;

12
server/db/index.js Normal file
View File

@ -0,0 +1,12 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import config from '../config.js';
fs.mkdirSync(path.dirname(config.db.path), { recursive: true });
const db = new Database(config.db.path);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
export default db;

36
server/db/migrate.js Normal file
View File

@ -0,0 +1,36 @@
import fs from 'node:fs';
import path from 'node:path';
import db from './index.js';
import config from '../config.js';
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
const applied = new Set(
db.prepare('SELECT name FROM schema_migrations').all().map((r) => r.name),
);
const files = fs
.readdirSync(config.db.migrationsDir)
.filter((f) => f.endsWith('.sql'))
.sort();
let ran = 0;
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(config.db.migrationsDir, file), 'utf8');
const tx = db.transaction(() => {
db.exec(sql);
db.prepare('INSERT INTO schema_migrations (name) VALUES (?)').run(file);
});
tx();
console.log(`[migrate] applied ${file}`);
ran += 1;
}
if (ran === 0) console.log('[migrate] nothing to do');
else console.log(`[migrate] applied ${ran} migration(s)`);

View File

@ -0,0 +1,63 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
verification_token TEXT,
verification_expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
CREATE TABLE profiles (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
display_name TEXT,
avatar_path TEXT,
bio TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
category TEXT NOT NULL CHECK (category IN ('tabletop', 'casino')),
max_players INTEGER NOT NULL DEFAULT 2,
supports_multiplayer INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id INTEGER NOT NULL REFERENCES games(id),
started_at TEXT NOT NULL DEFAULT (datetime('now')),
ended_at TEXT,
status TEXT NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress', 'completed', 'abandoned'))
);
CREATE INDEX idx_matches_game ON matches(game_id);
CREATE INDEX idx_matches_status ON matches(status);
CREATE TABLE match_players (
match_id INTEGER NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
seat INTEGER NOT NULL,
result TEXT CHECK (result IN ('win', 'loss', 'draw', 'abandoned')),
score INTEGER,
PRIMARY KEY (match_id, user_id)
);
CREATE INDEX idx_match_players_user ON match_players(user_id);

35
server/email/mailer.js Normal file
View File

@ -0,0 +1,35 @@
import nodemailer from 'nodemailer';
import config from '../config.js';
let transporter = null;
if (config.email.host) {
transporter = nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
secure: config.email.secure,
auth: config.email.user
? { user: config.email.user, pass: config.email.pass }
: undefined,
});
}
export async function sendVerificationEmail(to, link) {
const subject = 'Verify your Fertig Classic Games account';
const text = `Welcome! Confirm your email by visiting:\n\n${link}\n\nThis link expires in ${config.email.verificationTtlHours} hour(s).`;
const html = `<p>Welcome!</p><p>Confirm your email by clicking the link below:</p><p><a href="${link}">${link}</a></p><p>This link expires in ${config.email.verificationTtlHours} hour(s).</p>`;
if (!transporter) {
console.log(`\n[mailer:dev] Verification link for ${to}:\n ${link}\n`);
return { delivered: false, devLogged: true };
}
await transporter.sendMail({
from: config.email.from,
to,
subject,
text,
html,
});
return { delivered: true, devLogged: false };
}

35
server/history/routes.js Normal file
View File

@ -0,0 +1,35 @@
import { Router } from 'express';
import db from '../db/index.js';
import { requireAuth } from '../auth/middleware.js';
const router = Router();
router.get('/', requireAuth, (req, res) => {
const rows = db
.prepare(
`SELECT m.id AS match_id, g.slug, g.name, g.category,
m.started_at, m.ended_at, m.status,
mp.seat, mp.result, mp.score
FROM match_players mp
JOIN matches m ON m.id = mp.match_id
JOIN games g ON g.id = m.game_id
WHERE mp.user_id = ?
ORDER BY m.started_at DESC
LIMIT 100`,
)
.all(req.user.id);
const summary = db
.prepare(
`SELECT
SUM(CASE WHEN result = 'win' THEN 1 ELSE 0 END) AS wins,
SUM(CASE WHEN result = 'loss' THEN 1 ELSE 0 END) AS losses,
SUM(CASE WHEN result = 'draw' THEN 1 ELSE 0 END) AS draws
FROM match_players WHERE user_id = ?`,
)
.get(req.user.id) ?? { wins: 0, losses: 0, draws: 0 };
res.json({ matches: rows, summary });
});
export default router;

43
server/index.js Normal file
View File

@ -0,0 +1,43 @@
import http from 'node:http';
import path from 'node:path';
import express from 'express';
import cookieParser from 'cookie-parser';
import config from './config.js';
import './db/index.js';
import { loadUser } from './auth/middleware.js';
import authRoutes from './auth/routes.js';
import profileRoutes from './profile/routes.js';
import historyRoutes from './history/routes.js';
import { listGames } from './multiplayer/gameRegistry.js';
import { attachMultiplayer } from './multiplayer/index.js';
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(cookieParser());
app.use(loadUser);
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.get('/api/games', (_req, res) => res.json({ games: listGames() }));
app.use('/api/auth', authRoutes);
app.use('/api/profile', profileRoutes);
app.use('/api/history', historyRoutes);
app.use(express.static(config.publicDir, { extensions: ['html'] }));
app.get('*', (_req, res) => {
res.sendFile(path.join(config.publicDir, 'index.html'));
});
app.use((err, _req, res, _next) => {
console.error('[server]', err);
res.status(500).json({ error: 'Internal server error.' });
});
const server = http.createServer(app);
attachMultiplayer(server);
server.listen(config.port, config.host, () => {
console.log(`[server] listening on http://${config.host}:${config.port}`);
});

View File

@ -0,0 +1,27 @@
const registry = new Map();
export function registerGame(definition) {
if (!definition?.slug) throw new Error('Game definition needs a slug.');
registry.set(definition.slug, {
slug: definition.slug,
name: definition.name ?? definition.slug,
category: definition.category ?? 'tabletop',
minPlayers: definition.minPlayers ?? 2,
maxPlayers: definition.maxPlayers ?? 2,
supportsMultiplayer: definition.supportsMultiplayer ?? true,
});
}
export function listGames() {
return [...registry.values()];
}
export function getGame(slug) {
return registry.get(slug) ?? null;
}
// Built-in placeholders so the menu has something to show.
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2 });
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 2, maxPlayers: 4 });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', minPlayers: 1, maxPlayers: 6 });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', minPlayers: 2, maxPlayers: 8 });

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

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

View File

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

56
server/profile/routes.js Normal file
View File

@ -0,0 +1,56 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { Router } from 'express';
import multer from 'multer';
import config from '../config.js';
import { requireAuth } from '../auth/middleware.js';
import { getProfile, setAvatarPath, updateProfile } from './service.js';
fs.mkdirSync(config.uploads.dir, { recursive: true });
const storage = multer.diskStorage({
destination(_req, _file, cb) {
cb(null, config.uploads.dir);
},
filename(req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase().slice(0, 8) || '';
const random = crypto.randomBytes(8).toString('hex');
cb(null, `u${req.user.id}-${Date.now()}-${random}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: config.uploads.maxSizeMb * 1024 * 1024 },
fileFilter(_req, file, cb) {
if (!config.uploads.allowedMime.includes(file.mimetype)) {
return cb(new Error('Unsupported image type.'));
}
cb(null, true);
},
});
const router = Router();
router.get('/', requireAuth, (req, res) => {
res.json({ profile: getProfile(req.user.id) });
});
router.patch('/', requireAuth, (req, res) => {
const { displayName, bio } = req.body ?? {};
const profile = updateProfile(req.user.id, { displayName, bio });
res.json({ profile });
});
router.post('/avatar', requireAuth, (req, res, next) => {
upload.single('avatar')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file uploaded.' });
const publicPath = `/uploads/${path.basename(req.file.path)}`;
const profile = setAvatarPath(req.user.id, publicPath);
res.json({ profile });
});
});
export default router;

48
server/profile/service.js Normal file
View File

@ -0,0 +1,48 @@
import db from '../db/index.js';
export function getProfile(userId) {
const row = db
.prepare(
`SELECT u.id, u.email, u.username, u.email_verified,
p.display_name, p.avatar_path, p.bio, p.updated_at
FROM users u LEFT JOIN profiles p ON p.user_id = u.id
WHERE u.id = ?`,
)
.get(userId);
if (!row) return null;
return {
id: row.id,
email: row.email,
username: row.username,
emailVerified: !!row.email_verified,
displayName: row.display_name,
avatarPath: row.avatar_path,
bio: row.bio,
updatedAt: row.updated_at,
};
}
export function updateProfile(userId, { displayName, bio }) {
const fields = [];
const values = [];
if (displayName !== undefined) {
fields.push('display_name = ?');
values.push(String(displayName).slice(0, 60));
}
if (bio !== undefined) {
fields.push('bio = ?');
values.push(String(bio).slice(0, 500));
}
if (!fields.length) return getProfile(userId);
fields.push("updated_at = datetime('now')");
values.push(userId);
db.prepare(`UPDATE profiles SET ${fields.join(', ')} WHERE user_id = ?`).run(...values);
return getProfile(userId);
}
export function setAvatarPath(userId, avatarPath) {
db.prepare(
`UPDATE profiles SET avatar_path = ?, updated_at = datetime('now') WHERE user_id = ?`,
).run(avatarPath, userId);
return getProfile(userId);
}