- Rust 66%
- TypeScript 23.4%
- Svelte 9.2%
- Shell 0.7%
- Dockerfile 0.4%
- Other 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .github/workflows | ||
| core | ||
| docs | ||
| scripts | ||
| server | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| compose.yaml | ||
| Dockerfile | ||
| Makefile | ||
| README.md | ||
| rustfmt.toml | ||
Numtiles
Numtiles is an online A-Math-style number tile game for two players. This repository is a monorepo:
| Path | Package | Purpose |
|---|---|---|
core/ |
numtiles-core |
Authoritative game rules library (Rust). |
server/ |
numtiles-server |
HTTP/WebSocket match server (Rust, Axum + Tokio). |
web/ |
numtiles-web |
SvelteKit client (TypeScript). |
docs/ |
— | Product, engine, contract, and rules documentation. |
scripts/ |
— | Local development helpers (PostgreSQL). |
The game/rule source of truth is docs/rules.md; the engine
design is in docs/engine-plan.md; the frozen
core/server/web contracts are in docs/contracts.md;
work-package sequencing is in
docs/implementation-plan.md; operations and
deployment are in docs/operations.md.
Status: the confirmed scope is implemented — the 100-tile rulebook with the official 15×15 premium map, the authoritative Rust engine, the online server with PostgreSQL persistence, and the SvelteKit client. The follow-on work packages are also implemented: optional accounts (P1), read-only match review (P2), server hardening (P3), and Docker/Compose + CI operations (P4). Six product behaviors are still unresolved and deliberately not implemented (see Open decisions).
Architecture and authority
coreis the single source of truth for rules. It has no networking, storage, or process-global randomness. All randomness enters throughTileDrawSource.serveris the only authority allowed to change a match. It owns guest identities, private rooms, matchmaking, live delivery, and persistence. It callscorefor every accepted action.webpresents and submits intents. It may preview, but it never computes an authoritative score or decides legality.- Privacy: a
PlayerViewcontains only the viewer's own rack plus public board/scores. The opponent's rack and the bag are never sent to a browser or exposed by a persistence query that feeds a client.
Prerequisites
- Rust 1.98+ and Cargo
- Node.js 26+ and npm
- PostgreSQL 18 (optional; the server runs in memory when
DATABASE_URLis unset). Nix users can get it withnix-shell -p postgresql.
Rust workspace
Run from the repository root:
cargo fmt --all -- --check # formatting
cargo check --workspace # type/compile check
cargo test --workspace # unit + integration tests
Web client
Run from web/:
npm ci # install dependencies from package-lock.json
npm run check # svelte-check + strict TypeScript
npm run build # production build
npm run dev # local dev server
npm test # vitest unit tests
Running the server with PostgreSQL
The server reads its bind address from NUMTILES_BIND_ADDR
(default 127.0.0.1:3000) and its connection string from DATABASE_URL. When
DATABASE_URL is set it connects, applies versioned migrations, and restores
active matches after a restart; otherwise it runs in memory (development only).
# Start a throwaway local PostgreSQL and run the DB-gated tests.
scripts/pg-dev.sh
# Or run any command against a throwaway cluster:
scripts/pg-dev.sh cargo test -p numtiles-server
# Start the server against a database (migrations run automatically):
DATABASE_URL=postgres://numtiles@127.0.0.1:55432/numtiles \
cargo run -p numtiles-server
curl -i http://127.0.0.1:3000/health
scripts/pg-dev.sh re-executes itself under nix-shell -p postgresql, starts a
cluster in target/pgdata (override with NUMTILES_PGDATA/NUMTILES_PGPORT),
exports DATABASE_URL, and stops the cluster on exit. Any PostgreSQL 18
instance works equally well; set DATABASE_URL yourself and run
cargo test --workspace.
The WebSocket endpoint is GET /ws; the message envelope and all message types
are frozen in docs/contracts.md §11.
Two developer-tooling variables are accepted, both inert unless set:
NUMTILES_RNG_SEED (a u64 that makes per-match tile draws reproducible) and
NUMTILES_DEBUG=1 (registers the debug-only POST /debug/rack route to force a
seat's rack). Leave NUMTILES_DEBUG unset outside development; see
docs/operations.md.
Accounts, review, and hardening
- Accounts (P1).
POST /auth/register,/auth/login,/auth/logout, andGET /auth/meadd optional accounts on top of the guest identity. An account recovers the same guest (and its active matches/reconnect tokens) on another device; guest display-name semantics and game rules are unchanged. Seedocs/contracts.md§14. - Match review (P2).
GET /matches/{matchId}/reviewreturns the retained history: public board, scores, status, seat labels, and the ordered action log — never racks or the bag. The client route is/review/[matchId]. See §15. - Hardening (P3). Per-connection rate limiting, JSON 404s, WebSocket frame
caps, input validation, graceful shutdown, and an aggregate Prometheus
GET /metricsendpoint. See §16.
Docker and Compose
A Dockerfile builds the numtiles-server binary in a multi-stage
image (runtime contains only the server). compose.yaml runs
PostgreSQL 18 (named volume + healthcheck) plus the server wired to it via
DATABASE_URL:
docker compose up --build
docker compose up -d db # database only
Environment variables, migrations, health, deployment, and shutdown behavior are
documented in docs/operations.md.
Verification
cargo fmt --all -- --check
cargo check --workspace
cargo test --workspace # core engine, server flows, persistence
( cd web && npm ci && npm run check && npm test && npm run build )
DATABASE_URL=... cargo test --workspace # enables the DB-gated tests
A Makefile wraps the common Rust and web commands
(make verify), and scripts/verify.sh runs the same
verification list (DATABASE_URL=... scripts/verify.sh enables the DB-gated
tests). The same checks run in CI
(.github/workflows/ci.yml).
Open decisions
These six behaviors are not specified by the product owner. They are marked
with TODO(decision) in the server and must not be silently chosen. See
docs/contracts.md §13 for the full list. Each item has a
decision brief (context, options, recommendation, impact) under
docs/next-iteration/briefs/; these are
proposals, not implementations.
- Ready-confirmation expiry — how long a pending ready prompt lasts and what happens on expiry. Brief.
- Timed-turn expiry — what happens when a per-turn countdown reaches zero. Deadlines are tracked and delivered, but not enforced. Brief.
- Private room / invite-link expiry. Brief.
- Matchmaking-queue persistence across a server restart (queues are in-memory; rooms are not persisted). Brief.
- Reconnect credential format/rotation — scope and lifetime of the opaque resume token. Brief.
- Queued guest on disconnect — whether a guest waiting in the matchmaking
queue survives a dropped connection (
server/src/hub.rs:351). Brief.
A protocol gap is also recorded: public pairing reuses server.room with an
empty code plus server.queue{state:"matched"}, because the frozen contract
defines no dedicated public-ready carrier. See
docs/contracts.md §11 for the documented re-use.
Roadmap
- Resolve the six open decisions above, then implement the affected behavior. P1-P4 were deliberately kept independent of these decisions.
- Address the demo follow-ups (one rack-state defect plus UX/debug suggestions)
recorded in
docs/demo-followups.md. - Execute the next iteration from
docs/next-iteration/plan.md.