A 3D physics hack-and-slash party brawler that runs in the browser. 2–8 players join with a room code, smack each other off a floating arena with knockback-scaling melee, and the arena shrinks tile by tile until someone wins. Four sub-islands float off the diagonals — jump or dash the gap to grab weapon pickups (they outlast the main platform, but not forever).
- WASD move (camera-relative) · mouse aim camera (click to lock)
- Space jump / double-jump · Shift dash
- LMB / J light attack · RMB / K heavy attack (in the air: ground slam)
- Keyboard-only mode (you're asked on first join whether you play with a mouse or a trackpad; change it anytime from the menu): arrow keys aim the camera, J / K attack, C snaps the camera behind you, and while you run the camera gently settles behind your movement — no pointer lock, no mouse needed anywhere (menus and lobby work with Tab/arrows + Enter)
- Touch devices get on-screen controls: a floating thumbstick (bottom-left) to move, drag anywhere else to aim, and jump / light / heavy / dash buttons (bottom-right)
- No health bars: every hit raises your launch meter (%) — knockback scales with it. Fall off the arena and you're out. Last robot standing wins the round; first to 3 rounds takes the match.
- Outer tile rings flash red and fall as the round goes on. Don't linger.
- Weapon pickups drop on the sub-islands every few seconds (timed ~8s buffs,
grabbing a new one replaces the old):
- Hammer (orange) — your melee hits ~2.5× harder
- Anchor (teal) — incoming knockback reduced to ~35%
- Gun (yellow) — light attack fires knockback bullets
- Bomb (red) — light attack lobs an arcing AoE explosive (self-damage is real)
The core trick: the gameplay simulation is written once, in Rust, and runs in
two places — natively inside the authoritative server, and compiled to WASM
in every browser as the client's prediction engine. Physics is Rapier3D (with
enhanced-determinism), which ships first-class on both targets, so client
prediction actually matches server results.
Browser (TypeScript + Three.js + sim.wasm) Server (Rust: axum/tokio)
┌───────────────────────────────────┐ ┌───────────────────────────┐
│ input @60Hz ──seq-numbered────────┼─WebSocket──▶ │ per-room tokio task │
│ local player: predicted via │ (binary, │ 60 Hz authoritative sim │
│ sim.wasm, rewind+replay on │ postcard) │ (Rapier3D native) │
│ server correction │ ◀────────────┼ 20 Hz quantized snapshots│
│ remote players: interpolated │ │ + events │
│ ~120ms behind, fed into the │ │ hits/rounds all decided │
│ prediction world as kinematic │ │ server-side │
│ proxies for collisions │ │ │
└───────────────────────────────────┘ └───────────────────────────┘
| Piece | Language | Why |
|---|---|---|
crates/sim |
Rust | Single source of truth: character controller, combat, shrinking arena. Compiles natively and to wasm32. |
crates/protocol |
Rust | Wire types + quantization, postcard-encoded. Shared by server and (via WASM) the browser — TypeScript never touches byte layouts. |
crates/sim-wasm |
Rust→WASM | wasm-bindgen surface: prediction sim + message encode/decode for the client. |
crates/gameserver |
Rust | axum/tokio: room registry (DashMap), WebSocket plumbing, one 60 Hz task per room, serves the client build. |
client/ |
TypeScript | Three.js rendering, glTF characters (CC0 RobotExpressive), interpolation/reconciliation glue, HTML/CSS UI, WebAudio-synthesized SFX. |
Netcode details:
- Server-authoritative: all hits, knockback and deaths are decided by the server; clients only predict their own movement.
- Prediction + reconciliation: the client keeps a ring buffer of inputs; each snapshot carries the last-applied input seq plus a precise local state. On divergence the client restores server state and replays pending inputs through the same Rust code the server ran.
- Snapshots are postcard-encoded and quantized (i16 fixed-point positions, byte yaw) at 20 Hz; remote players render ~120 ms in the past, interpolated.
- Arena shrink is free: the schedule is a pure function of the round tick, so both sides compute identical tile states with zero bytes on the wire.
Every match records itself automatically — on your side of the wire. The client keeps the server's snapshot stream (a few hundred KB per match), not a video, and re-renders it in-engine, Fortnite-style. Open Replays from the menu, or hit WATCH REPLAY right after a match:
- Timeline: scrub with markers for KOs, big hits, pickups and bomb blasts,
round chips, 0.25×–4× speed, frame stepping (
,/.), previous/next KO (p/n), Space to pause. - Cameras (
1/2/3): follow any player with the game's orbit camera, free-fly anywhere (WASD + Space/Shift, scroll = speed), or player view — exact for the player who recorded (their camera is part of the recording), reconstructed from facing for everyone else. - Export video: frame-perfect MP4 with game audio, rendered offline (usually faster than real time — the tab can stay in the background). Follow-cam or player-cam presets, 720p/1080p, 30/60 fps, standard/high quality, whole match or a single round.
- Library: every match you record is kept in browser storage until you
delete it (pin favorites to find them fast), and any replay travels as a
.szrfile — save it, send it, import it on another machine. Replays are tied to the game build that recorded them; the viewer warns when they differ.
The server never records anything — snapshots are already byte-identical for every client, so your copy of the match is the match.
The game takes the whole screen. Starting a match goes fullscreen on its own, and the button top-right of the menu (or Settings → Display) toggles it any time; the choice is remembered. On phones, going fullscreen also locks to landscape, which is the orientation the on-screen thumbstick and action buttons are laid out for. Esc leaves fullscreen as usual.
SmashZone is also installable: "Install" / "Add to Home Screen" gives it its own
icon and launches it with no browser chrome at all — the manifest asks for
display: fullscreen. The menu shows an INSTALL APP button whenever the
browser reports the game is installable. On iPhone that's the only route to a
chrome-free game, because Safari there has no Fullscreen API.
A service worker precaches the whole bundle — engine, arena model, fonts, every sound, and the ffmpeg core the exporter needs — so after one visit the menu, settings and the entire replay library work with no network at all. Only playing together needs the server.
Updates take care of themselves. There is no version constant anywhere to
bump: client/build/pwa.ts names the worker's cache after a content hash of
everything in the build, so pushing a change and redeploying is the whole
release process. Open tabs check for a new build on load, when refocused, and
every 15 minutes. A waiting update is applied silently while you're on the
menu, and never mid-match — if you're playing, it shows a small "new version
ready" toast and swaps the moment you're back on the menu.
# prerequisites: rust (+ wasm32 target), wasm-pack, bun 1.2+
# 1. build the wasm sim (rerun after touching crates/sim*, crates/protocol)
cd client && bun install && bun run wasm
# 2. build the client bundle (the server embeds client/dist at compile time
# and re-embeds automatically when it changes)
cd client && bun run build
# 3. run the server — play at http://localhost:8080
cargo run -p gameserver
# or iterate on the client with hot reload at http://localhost:5173
# (proxies /api and /ws to :8080)
cd client && bun run devRust tests cover the deterministic sim, protocol round-trips, room/netcode lifecycle, and bot AI (including tier winrate proofs):
cargo testBrowser integration tests drive the real game (compiled server + embedded client) headlessly with Playwright, covering menu/lobby/match flow, movement, combat, arena shrink, pickups, HUD, spectating, reconnection, replays, and the PWA (install surface + booting with the network cut):
cd client && bun run test:e2e # builds wasm + client + server, runs the suiteUseful knobs: SKIP_BUILD=1 reuses existing builds; E2E_BASE_URL=http://localhost:8080
runs the specs against an already-running server (e.g. a single spec via
bun test e2e/specs/05-combat.spec.ts --timeout 120000); PW_CHROMIUM=/path/to/chrome
overrides the browser binary (otherwise the suite uses Playwright's installed
Chromium — bunx playwright install chromium once if you have none).
The release binary is the whole deployment. crates/gameserver/build.rs
embeds client/dist (HTML, JS, CSS, WASM sim, character model) into the
executable at compile time, so shipping is: build, copy one file, run.
# build the frontend, then the self-contained server binary
cd client && bun install && bun run wasm && bun run build && cd ..
cargo build --release -p gameserver
# ship it — no other files needed on the server
scp target/release/gameserver you@host:
ssh you@host ./gameserver # BIND_ADDR=0.0.0.0:8080 by default(Release builds fail with instructions if client/dist is missing; debug
builds only warn, so cargo test works without a frontend build.)
Or as a container:
docker build -t smashzone .
docker run -p 8080:8080 smashzoneTwo stamps come out of a build, and neither is edited by hand:
- Service-worker cache version — a content hash over every file that
ships, computed in
client/build/pwa.tsand substituted intodist/sw.js. It changes if and only if the bytes change, in every build path (local, CI, Docker, Fly), which is what makes installed clients update themselves. Nothing configures it; there is nothing to pass. BUILD_ID— the human-readable stamp written into.szrreplay headers (postcard wire bytes are only guaranteed decodable by the build that wrote them, so the viewer warns on a mismatch).client/build/buildid.tsresolves it from$BUILD_ID, else the working tree's git SHA, else"dev". Docker copies no.git, so pass it there if you want replays stamped with the commit:docker build --build-arg BUILD_ID=$(git rev-parse --short HEAD) .
Tuning values (speeds, impulses, tick rates, shrink schedule) live in shared/constants.json, read by both Rust and TypeScript at build time.