A small, extensible 1v1 netplay fighting game in Rust.
- Networking:
web-transport(QUIC / WebTransport) —web-transport-quinnon native,web-transport-wasmin the browser. - Rendering:
wgpu(instanced 2D sprites). - GUI:
eguiviaeframe(native window + web canvas). - Server: authoritative, embedded into the native client (host a match from the game itself) and also available as a standalone binary.
The same client binary runs natively and on the web. The server is native-only (browsers can't host QUIC), but a browser can join a native host.
crates/core bf-core shared simulation, wire protocol, asset registry (no platform deps)
crates/server bf-server authoritative WebTransport server (native)
crates/client bf-client eframe + wgpu + egui client (native + wasm); embeds the server
assets/ characters/, stages/, registry.ron ← all content, embedded at build time
web/ index.html + Trunk.toml for the browser build
tools/ gen_art.py regenerates the placeholder sprites
cargo run -p bf-client --bin bagfighters- Pick a name and character, click Host a match. The game starts the embedded server and connects to it locally. The connecting screen shows a certificate hash — the other player needs it.
- Player 2 runs the same binary (or the web build), enters
https://<your-ip>:4433, pastes the certificate hash, and clicks Join.
Two native clients on one machine: run it twice; host in one, join
https://127.0.0.1:4433 (with the shown hash) in the other.
Set Local players to 2 in the menu, give each player a character and a control scheme (a connected gamepad is picked for player 2 automatically), then Host a match. Both fighters are driven from this one machine over the embedded server — no second client, no network needed. One connection simply drives two fighter slots.
cargo run -p bf-server # binds [::]:4433, prints the cert hash
cargo run -p bf-server 0.0.0.0:5000Requires a browser with WebGPU and WebTransport (recent Chrome/Edge).
cargo install trunk
cd web
trunk serve # http://127.0.0.1:8080Only the client compiles to wasm — the server is native-only (it pulls in quinn/rustls, which don't build for
wasm32). Trunk builds justbf-clientvia thehrefinweb/index.html, so don't run a workspace-widecargo build --target wasm32-unknown-unknown; scope it to the client:cargo build -p bf-client --lib --target wasm32-unknown-unknown.
The page opens to the same menu. There's no Host button on web (browsers can't host); enter a native host's URL + cert hash and Join.
WebTransport requires the server's certificate to be pinned by SHA-256 and to be valid for under 14 days — the server mints exactly such a self-signed cert at startup and shows its hash. Native clients pin the same way, so there is one code path for both platforms.
Each local player picks one scheme in the menu, so two people can share a keyboard or grab gamepads for couch co-op.
| Action | Keyboard (WASD) | Keyboard (Arrows) | Gamepad |
|---|---|---|---|
| Move | A / D |
← / → |
left stick / d-pad |
| Jump | W / Space |
↑ |
A (south) / up |
| Attack | J |
. |
X / Y |
| Block | K |
/ |
B / shoulders / triggers |
Gamepads are read via gilrs on native; the
web build supports the keyboard schemes only.
Content is data-driven and embedded at build time (so it works identically on
web). After editing assets/, rebuild.
A new character — drop a folder of PNGs in assets/characters/<id>/ and add
a CharacterDef to assets/registry.ron:
CharacterDef(
id: "ninja",
name: "Ninja",
dir: "characters/ninja",
sprite_size: (96.0, 160.0),
animations: {
"idle": AnimClip(frames: ["idle_0.png", "idle_1.png"], fps: 3.0, looping: true),
"walk": AnimClip(frames: ["walk_0.png", "walk_1.png"], fps: 10.0, looping: true),
"attack": AnimClip(frames: ["attack_0.png"], fps: 18.0, looping: false),
// jump / hurt fall back to idle if omitted
},
stats: CombatStats(max_hp: 90.0, walk_speed: 320.0, attack_damage: 7.0),
)Recognized clip names: idle, walk, jump, attack, hurt (missing ones
fall back to idle). Every CombatStats field has a default, so specify only
what you want to change.
A new stage — drop one background PNG in assets/stages/<id>/ and add a
StageDef (id, name, sprite, width, height, floor_y).
The placeholder art is generated by python3 tools/gen_art.py (needs Pillow).
The server steps one authoritative World at a fixed 60 Hz and broadcasts a
Snapshot datagram each tick. Clients send their buttons as datagrams and
render the latest snapshot directly (server-authoritative, no prediction —
simple and robust on a LAN). The handshake (Hello/Welcome) uses a reliable
bidirectional stream; the realtime loop uses unreliable datagrams. The whole
simulation lives in bf-core and is pure, leaving room for client-side
prediction or rollback later.