Rust backend for the connected pinball system. ESP32 (MQTT) <-> Central API (WebSocket) communication.
- 1 feature = add unit tests
- Follow DRY & SOLID principles
- Write clean code (meaningful variable names, use named constants, etc.)
- Add inline comments
//and doc comments///for important parts
- One PR = one concern
- Commit messages must be descriptive (please follow the convention)
crates/
├── api/ # HTTP + WebSocket server (Axum) — REST routes, JWT auth, Lucyd docs
├── game-logic/ # Game state machine and scoring engine
├── mqtt-bridge/ # Bidirectional relay MQTT <-> WebSocket
├── screen-hub/ # Bidirectional relay Frontend apps <-> Backend
└── shared/ # Types, events, DTOs shared across crates
docker compose up --build -d # Full stack
docker compose up -d mosquitto # Broker only (for MQTT Explorer)| Service | Port | Description |
|---|---|---|
| API | 8080 | REST + WS (/ws/bridge, /health) |
| Mosquitto | 1883 | MQTT broker (anonymous) |
| Nginx | 80 | Reverse proxy |
| Lucyd | 8080 | Interactive API docs (/docs) |
rustup default stable # Rust 1.89+
cargo build # Build workspace
cargo test # 173 tests (api + game-logic + screen-hub + mqtt-bridge + shared)Documentation of the codebase:
cargo doc --open| Variable | Required | Default | Description |
|---|---|---|---|
SCREEN_JWT_SECRET |
yes | — | JWT signing secret (min 32 chars) |
DATABASE_URL |
no | sqlite:///data/flipper.db |
SQLite database path |
API_PORT |
no | 8080 |
HTTP listen port |
ALLOWED_ORIGINS |
no | http://localhost:3000 |
Comma-separated CORS allowed origins |
Admin routes (/api/v1/admin/…) require a Authorization: Bearer <token> header where the token is a JWT signed with SCREEN_JWT_SECRET and carrying role: "admin".
Generate a token with the built-in CLI command:
# Local
SCREEN_JWT_SECRET='your-secret' cargo run -p api -- generate-admin-token
# Docker (secret is already injected via the container env)
docker compose run --rm api app generate-admin-token
# Or inside a running container
docker exec <container_name> app generate-admin-token# Inside the running container
docker exec backend-api-1 app generate-admin-token# Or with a fresh container (env vars injected from compose)
docker compose run --rm api app generate-admin-tokenOutput: ADMIN_TOKEN=eyJ… — copy the value and use it as the Bearer token.
The token has no expiry. Keep it secret; rotate by regenerating with a new
SCREEN_JWT_SECRET.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Liveness probe |
POST |
/api/v1/game/start |
Start a new game session ({ character_id: u8 }) |
GET |
/api/v1/game/state |
Current game state (404 if no game running) |
POST |
/api/v1/game/end |
Force-end current game and persist the final score |
GET |
/api/v1/scores |
Top-10 all-time leaderboard (sorted by score desc) |
POST |
/api/v1/scores |
Debug: attempt to insert a score into the leaderboard |
GET |
/docs |
Interactive OpenAPI docs (Lucyd UI) |
Scores are stored in SQLite (scores table). The board is capped at 10 entries:
- If fewer than 10 entries exist, every score is inserted.
- If the board is full, the score must be strictly greater than the current minimum to be accepted; the minimum entry is then evicted atomically.
- The leaderboard is automatically broadcast to
back_screenvia aLeaderboardUpdateWebSocket envelope at the end of every game.
Installation
cargo install cargo-llvm-cov
Run the tests + report in the terminal
cargo llvm-cov
HTML report + Tab of the coverage
cargo llvm-cov --html
(Last tested 30/06/2026: ~82.6% code coverage)
| Dep | Role |
|---|---|
| Rust edition 2024 / resolver 3 | Toolchain |
| Axum 0.8 | HTTP + WebSocket server |
| rumqttc 0.25 | Async MQTT client |
| tokio-tungstenite 0.26 | WebSocket client (bridge) |
| lucyd ≥ 0.1.9 | Interactive OpenAPI docs UI (/docs) |
| sqlx 0.8 | Async SQLite driver with migrations |
| jsonwebtoken 9 | JWT encoding / validation |
| tower-http 0.6 | CORS + request tracing middleware |
| thiserror 2 | Ergonomic error types |
| schemars 0.8 | JSON Schema generation |
| tracing + tracing-subscriber | Structured JSON logging |
| cargo-chef | Docker layer caching |
All frontend screens (front_screen, back_screen, dmd_screen) connect to the backend via WebSocket. Every message exchanged is a ScreenEnvelope serialized as JSON.
{
"from": "front_screen",
"to": { "kind": "screen", "id": "back_screen" },
"event_type": "game_state_update",
"payload": { "score": 42000, "combo": 3 }
}| Field | Type | Description |
|---|---|---|
from |
ScreenId |
Sender — front_screen, back_screen, or dmd_screen |
to |
ScreenTarget |
{"kind":"screen","id":"<id>"} or {"kind":"broadcast"} |
event_type |
string |
Free-form event name (e.g. game_state_update) |
payload |
any JSON |
Arbitrary data, no fixed schema (intentionally flexible) |
event_type |
Direction | Description |
|---|---|---|
game_state_update |
server → screen | Current game state snapshot |
game_over |
server → screen | Game ended |
boss_defeated |
server → screen | A boss was defeated |
leaderboard_update |
server → back_screen | Top-10 leaderboard payload, broadcast after each game |
Screen WS client
│ JSON text frame (ScreenEnvelope)
▼
WS handler ──deserialise──► ScreenRouter
│
Interceptor chain
(validate / enrich / swallow)
│
ScreenRegistry
(mpsc per screen, cap 128)
│
┌──────────┴──────────┐
target screen all others
(unicast) (broadcast)
Key behaviours:
- A screen can only have one active connection — duplicate connections are rejected.
- Broadcast delivers to every connected screen except the sender.
- If a screen's channel is full (128 messages), the message is dropped and a warning is logged.
- When the WS connection closes, the
ScreenGuardauto-unregisters the screen from the registry. - Interceptors run before dispatch and can mutate or swallow any message.
Images are automatically built and pushed to ghcr.io/flipgame-hetic/backend on every push to main or dev, and on each GitHub release. The latest tag always tracks main; branches and releases get their own tags (branch name, semver, short SHA). Build cache is stored in the registry itself (buildcache tag) to speed up subsequent runs.
pinball/<device_id>/<subtopic>
Inbound: input/button, input/under_plunger, input/plunger, input/gyro, telemetry, events, status
Outbound: ball/hit, game/state, cmd