- Rust 86%
- JavaScript 10.4%
- Shell 1.7%
- HTML 0.9%
- Dockerfile 0.5%
- Other 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| .github/actions/setup-web-build | ||
| assets/players | ||
| docs | ||
| scripts | ||
| src | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
Ryggattack
Ryggattack is a code-first, local multiplayer arena game built with Rust and Bevy. The current proof of concept focuses on the core idea: get behind another player and land a shot in their back.
Current proof of concept
- Up to four local players on WASD, the arrow keys, and gamepads
- A join lobby where each device takes a seat, and bots fill the rest
- A new procedurally generated railway map on every launch
- Guaranteed connectivity, no dead ends, and additional random loops
- Automatic movement with junction choices
- Cart collisions reverse both players along their current rails
- Menus driven by mouse, keyboard, or gamepad, and an in-game pause dialog
- Low-poly 3D arena generated entirely in code, set in a forest that grows back at the start of every round
- Missiles that explode on impact and blow apart the trees around them
- Rear-hit detection, scoring, 60-second rounds, and restart
- No external assets or visual editor
Run it
On NixOS or any system with Nix and flakes enabled, run the game directly in the project environment:
nix develop 'path:.' --command cargo run
The explicit path:. also works when the directory has not been initialized as
a Git repository yet.
Alternatively, enter the environment and keep it open for development:
nix develop 'path:.'
cargo run
On another Linux distribution, install the current stable Rust toolchain and
Bevy's Linux system dependencies, then run cargo run. Bevy 0.19 needs Rust
1.95 or newer; older toolchains stop with an is not supported by the following packages error before compiling anything.
The first build compiles Bevy and can take several minutes. Later builds are incremental and much faster.
Running cargo run outside the Nix environment can fail with an
XKBNotFound/libxkbcommon error because Bevy loads that Linux library at
runtime.
Run it in a browser
The game also builds to WebAssembly and runs in any browser with WebGL 2.
The web build needs an extra Rust target and the wasm-bindgen CLI, so use a
rustup-managed toolchain rather than the Nix shell:
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.128
The CLI version has to match the wasm-bindgen crate pinned in Cargo.lock.
scripts/build-web.sh compares the two and prints the exact cargo install
command when they drift apart, so start there if the page loads to a blank
canvas after a dependency bump.
Build the bundle and serve it:
scripts/build-web.sh
python3 -m http.server 8080 --directory dist/web
Then open http://127.0.0.1:8080/. The page has to come from a web server;
opening dist/web/index.html from disk does not work because browsers refuse
to load ES modules and WebAssembly over file://.
scripts/build-web.sh --debug builds the unoptimized profile. It compiles
faster after a source change but produces a far larger download and a much
lower frame rate, so it is only worth it while iterating.
What the build produces
Everything lands in dist/web/, which is ignored by Git:
| File | Contents |
|---|---|
index.html |
Page shell and canvas, copied from web/index.html |
ryggattack.js |
JavaScript glue generated by wasm-bindgen |
ryggattack_bg.wasm |
The game itself |
assets/ |
Copy of the repository's assets/ directory |
Any static host will do — upload dist/web/ as it is. Serve .wasm with the
application/wasm content type and turn on compression: the release bundle is
roughly 39 MB uncompressed and 12 MB gzipped, because it carries all of Bevy.
A release build also runs wasm-opt -Oz over the module when Binaryen is
installed, which takes about a quarter off the uncompressed size and close to
nothing off the compressed one. It is optional because it is the one tool here
that cargo does not install; the build says which of the two it produced.
The playable build
Every push to main builds dist/web/ and stores it as the ryggattack-web
Forgejo Actions artifact. Pull requests build the same bundle but do not
publish an artifact. Deployment of the main-branch artifact can be connected
to the homelab separately.
The published container image
Pushing a v* tag builds the bundle and publishes an image that serves it:
docker run --rm -p 8080:8080 git.modig.online/fabianmodig/ryggattack/web:v0.1.0
The image is nginx on port 8080 with dist/web/ as its document root, the
module and the glue stored pre-compressed, and .wasm answered as
application/wasm. latest follows finished releases, so a pre-release such
as v0.2.0-rc1 publishes under its own name and moves nothing else.
.forgejo/workflows/release.yml builds the image, starts it and checks that it
really serves the page and the module, and pushes only then. Starting the
workflow by hand publishes the branch name as the tag, which is a way to try a
build without releasing it.
The same image builds locally, from the repository root:
scripts/build-web.sh
docker build -f web/Dockerfile -t ryggattack-web .
docker run --rm -p 8080:8080 ryggattack-web
web/Dockerfile packages a bundle that already exists; it does not compile
one, so scripts/build-web.sh has to run first.
How the web build differs
- The game renders into the
#ryggattack-canvaselement fromweb/index.htmland follows the size of the window. - That canvas has to hold keyboard focus. The page focuses it on load and on every pointer press; a game embedded in another page needs to do the same.
- The main menu has no EXIT button and Escape does not quit there, because a page cannot close itself. Escape still opens the in-game pause dialog.
- Gamepads arrive through the browser's Gamepad API, so a pad only shows up in the lobby after a button on it is pressed with the page focused.
- A browser without hardware acceleration falls back to software rendering. Bevy logs a warning about that in the developer console and the game runs, but slowly.
Controls
Every seat steers the same way; only the buttons differ.
| Action | WASD seat | Arrow seat | Gamepad seat |
|---|---|---|---|
| Choose direction at the next junction | W A S D | Arrow keys | D-pad or left stick |
| Fire | Space | Right Ctrl | A / Cross, or right trigger |
| Action | Keyboard | Gamepad |
|---|---|---|
| Move the menu highlight | Arrow keys or WASD | D-pad or left stick |
| Select the highlighted button | Enter or Space | A / Cross, or Start |
| Back / cancel | Escape | B / Circle |
| Pause / resume | Escape | Start to pause, B or A on RESUME to resume |
| Restart after a round | R | Start |
Every menu can equally be clicked with the mouse, driven with the keyboard, or driven with a gamepad; the three share one highlight, so hovering a button with the mouse also moves the keyboard and gamepad selection there.
START on the main menu opens the join lobby. Press up on a keyboard scheme or a gamepad to take a seat and down to leave it; seats are handed out in join order. Any seat still empty when the round begins is played by a bot, so one player against three bots still works. At least one player must join before the round can start.
Up and down are taken by joining and leaving in the lobby, so its two buttons sit side by side and the highlight moves left and right there instead of up and down. START ROUND starts out highlighted, so Enter, A, or Start begins the round as before, and Escape or B goes back to the main menu. The seating is kept when you restart a round or return to the menu.
If only one of the two keyboard seats is taken, that player can steer with WASD and the arrow keys interchangeably. Claiming both seats splits them into two independent players.
During a game, Escape or the gamepad Start button opens a dialog with Resume and Return to Menu options.
The carts move automatically and can only change direction at intersections. Hold a direction while approaching a junction to choose that branch. The route is selected on entry, and the cart follows it smoothly through the junction. Carts cannot make a 180-degree turn. The white marker shows the front and the red panel is the vulnerable rear target. A hit only scores when the missile is travelling in approximately the same direction as the target—the attacker is behind them. Scored-on players respawn on their starting rail.
Missiles explode on whatever they meet first: a cart, a wall, or one of the trees that stand between the rails. A blast wrecks the scenery around it, and each wrecked tree goes up in a smaller blast of its own a moment later, so a shot into a thicket clears a patch of forest. Low growth such as bushes and ferns is flown over rather than struck, but still goes up in the blast. The forest grows back when a round starts or restarts.
Source layout
The code is grouped by responsibility. Start with src/main.rs to see how the
application is configured and the order in which gameplay systems run.
| File | Responsibility |
|---|---|
src/main.rs |
Application setup and system scheduling |
src/game.rs |
Game states, round timer, start, and restart |
src/scene.rs |
Assemble the camera, lighting, ground, tracks, forest, carts, and HUD |
src/scenery.rs |
Where every tree, bush, and rock stands, and how the forest grows back |
src/explosions.rs |
Blasts, the wreckage they make of the scenery, and the chain reaction |
src/players/mod.rs |
Player state, device and bot input, movement, and collisions |
src/players/roster.rs |
Which device holds each of the four seats |
src/players/visuals.rs |
Cart and rider models |
src/combat.rs |
Missiles, what they strike, rear hits, and scoring |
src/ui/mod.rs |
Main menu, pause dialog, and score display |
src/ui/lobby.rs |
The join lobby and its seat cards |
src/ui/nav.rs |
One reading of the menu keys, sticks, and buttons |
src/tracks/map.rs |
Generate the connected rail network and grid coordinates |
src/tracks/path.rs |
Junction routes, smooth movement, and reversing carts |
src/tracks/render.rs |
Rail, track-bed, and sleeper meshes |
The web build is driven from outside src/: web/index.html is the page
shell that hosts the canvas, scripts/build-web.sh compiles the WebAssembly
bundle into dist/web/, and web/Dockerfile with web/nginx.conf packages
that bundle into the image a tag push publishes.
src/tracks/mod.rs exposes the track functions and types used by the rest of
the game. Tuning constants live with the feature they control. Tests live
alongside the code they check; run them with cargo test inside the development
environment. Use cargo fmt to format changes.
Near-term roadmap
- Improve movement, hit feedback, bot behavior, and round presentation.
- Add original character and arena art.
- Shrink the download; 39 MB is a lot to ask of a first visit.
- Validate Android and iOS packaging early.