A "cozy" multiplayer 2D pinball game in the browser. Each player has their own pinball board with a shared deep-space behind it connecting all players.
See workflow.md for how documentation and tasks are organized.
See docs/ for architecture, goals, and decisions. New here? Start with docs/onboarding.md.
After cloning, enable the pre-commit hook that auto-formats code:
git config core.hooksPath .githooksThis runs cargo fmt on Rust files and prettier on TypeScript/JS files whenever you commit.
cd server
cargo run --releaseServer listens on ws://localhost:9001/ws.
Bot configuration:
BOT_COUNT=5 cargo run --release # 5 bots (default 3)
BOT_COUNT=0 cargo run --release # no botscd client
npm install
npm run devOpen the URL shown by Vite (typically http://localhost:5173).
cd client_bevy
cargo run --releaseBy default this client connects to ws://127.0.0.1:9001/ws.
Set PINBALL_WS_URL to override.
| Key | Action |
|---|---|
| Left arrow | Left flipper |
| Right arrow | Right flipper |
| Space (hold) | Charge launcher |
| Space (release) | Launch ball |
Touch: tap left/right side for flippers, bottom-right for launcher.
| Command | Description |
|---|---|
npm run dev |
Start dev server |
npm run build |
Typecheck + production build |
npm test |
Run tests |
npm run test:verbose |
Run tests with verbose output |
npm run typecheck |
Type-check without building |
npm run format |
Prettier |
npm run preview |
Preview production build locally |
| Command | Description |
|---|---|
cargo run --release |
Run server |
cargo test |
Run tests |
cd server
cargo run --release --bin loadtest -- --clients 200 --duration 30cd client
npm outdated # check what's outdated
npx npm-check-updates -u # bump package.json to latest versions
npm install # install new versions
npx vitest run # verify tests passcd server
cargo install cargo-outdated cargo-edit # one-time setup
cargo outdated # check what's outdated
cargo upgrade # bump Cargo.toml to latest versions
cargo update # update Cargo.lock
cargo test # verify tests passAfter upgrading, run all tests on both sides before committing. Physics engine upgrades (Rapier) can change simulation behavior — check physics tests carefully.
Balls in deep space orbit on a unit sphere. The server broadcasts snapshots at 10 Hz, but clients render at 60 fps. To bridge this gap both clients use snapshot interpolation with a small jitter buffer — the standard technique from networked games (see Valve's Source Multiplayer Networking and Glenn Fiedler's Snapshot Interpolation).
How it works:
- Keep a short ring buffer of timestamped snapshots (
serverTime) - Estimate client/server time offset and render at
nowServer - interpolationDelay - Find the two snapshots around that render time and slerp between them (
t ∈ [0, 1]) - If render time is newer than latest snapshot, do short capped extrapolation as fallback
Why this matters: using local receive-time gaps (recv deltas) as the interpolation timeline can still stutter on the internet because packet arrival jitter changes t from frame to frame. Using serverTime as the timeline makes motion stable even when packets arrive unevenly.
Current tuning:
interpolationDelay = 0.2s(200 ms)maxExtrapolation = 0.2s- snapshot buffer length:
8
Tuning guide:
- If motion still jitters on unstable WAN: increase
interpolationDelay(e.g.0.20 -> 0.25). - If motion feels too delayed: decrease
interpolationDelayin small steps (e.g.0.20 -> 0.18) and re-test on real internet. - Keep
maxExtrapolationat or belowinterpolationDelay; larger extrapolation increases visible drift when packets are late. - If snapshots arrive in bursts, increase buffer length slightly (e.g.
8 -> 10); too large buffers add latency without helping much. - Re-test both clients after tuning (TypeScript + Bevy) to keep behavior aligned.
Maintenance note:
- Keep interpolation constants synchronized between
client/src/shared/ServerConnection.tsandclient_bevy/src/shared/net_state.rs. - Specifically keep these in sync: interpolation delay, max extrapolation, snapshot buffer size, epsilon, and offset smoothing factor.
Because the balls move on a sphere, slerp gives the correct great-circle arc between positions.
- Both clients (TypeScript + Bevy) tune Rapier contacts to avoid "stuck ball" wedges near flippers/guide walls:
- lower collider friction on
ball/flipper/wall - friction combine rule set to
Min - ball sleeping disabled
- lower collider friction on
- Escape from board top uses a dedicated escape-slot sensor (event-driven), not per-frame position polling.
- Server capture color preserves owner color even if the original owner is no longer in the active player list (cached owner color fallback; no hardcoded white fallback).
The repo provides Containerfiles for server and client. Deployment depends on your infrastructure.
Internet -> Traefik (HTTPS) -> pinball-server (9001, WebSocket at /ws)
-> pinball-web (80, Nginx static)
Note: the standalone deploy/compose.yml maps Traefik to host ports 8080/8443 instead of 80/443. Adjust or use a reverse proxy in front if you need standard ports.
server/Containerfile— multi-stage Rust build, produces a minimal server imageclient/Containerfile— multi-stage Node build, produces an Nginx static imagedeploy/compose.yml— standalone example with its own Traefik (for fresh setups)
If you already have a Traefik reverse proxy managing multiple services, add the pinball services to your existing docker-compose.yml:
pinball_web:
container_name: pinball_web
build:
context: /path/to/Pinball2DMulti/client
dockerfile: Containerfile
image: pinball_web:local
restart: unless-stopped
expose: ["80"]
networks: [web]
pinball_server:
container_name: pinball_server
build:
context: /path/to/Pinball2DMulti
dockerfile: server/Containerfile
image: pinball_server:local
restart: unless-stopped
expose: ["9001"]
networks: [web]Then add Traefik dynamic config to route /ws to pinball_server:9001 and everything else to pinball_web:80.
From the server, in your compose directory (not deploy/):
# 1. Pull latest code
cd /path/to/Pinball2DMulti && git pull --rebase
# 2. Rebuild and restart (from your compose directory)
cd /path/to/your/compose
podman-compose build pinball_web pinball_server
podman-compose up -d --force-recreate pinball_web pinball_server
podman image prune -fImportant: Only rebuild and recreate the pinball services — not your entire stack. Using --force-recreate ensures the new images are actually used. podman image prune -f removes the large intermediate build images (~1-2 GB each).
If you don't have an existing Traefik, use the included deploy/compose.yml:
# 1. Create environment file
cp deploy/.env.example deploy/.env
# Edit deploy/.env with your LE_EMAIL and PINBALL_HOST
# 2. Prepare Let's Encrypt storage
mkdir -p deploy/letsencrypt
touch deploy/letsencrypt/acme.json
chmod 600 deploy/letsencrypt/acme.json
# 3. Enable Podman socket (rootless)
systemctl --user enable --now podman.socket
# 4. Build and start
cd deploy
podman-compose build
podman-compose up -dTo update:
cd /path/to/Pinball2DMulti && git pull --rebase
cd deploy && podman-compose build && podman-compose down && podman-compose up -d && podman image prune -fNote: The standalone setup uses down/up which restarts all services including Traefik. The .env file must exist with valid PINBALL_HOST and LE_EMAIL values — without it, Traefik labels will be empty and routing will fail.
| Command | Description |
|---|---|
podman-compose ps |
Show running containers |
podman-compose logs -f pinball-server |
Follow server logs |
podman-compose logs -f pinball-web |
Follow web logs |
podman-compose build --no-cache <service> |
Full rebuild |
podman image prune -f |
Remove dangling build images |
- WebSocket fails: Check browser dev tools Network/WS tab, check
podman-compose logs pinball-server - Empty
Host()in Traefik logs:.envfile missing orPINBALL_HOSTnot set (standalone setup only) - Old code still running after deploy: Container not recreated — use
--force-recreateordown/up - Large dangling images after build: Run
podman image prune -fto clean multi-stage build layers