• Rust 86%
  • JavaScript 10.4%
  • Shell 1.7%
  • HTML 0.9%
  • Dockerfile 0.5%
  • Other 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Fabian Modig c7e78f6e64
All checks were successful
CI / cargo test (push) Successful in 3m34s
CI / web build (push) Successful in 5m20s
ci: fix Forgejo validation failures
2026-10-05 17:57:11 +02:00
.forgejo/workflows ci: fix Forgejo validation failures 2026-10-05 17:57:11 +02:00
.github/actions/setup-web-build ci: migrate workflows to Forgejo Actions 2026-10-05 17:13:27 +02:00
assets/players First commit with working game 2026-09-10 20:45:22 +02:00
docs Perf report §8: clippy row states the 6 style warnings 2026-09-25 23:14:12 +02:00
scripts Drop the low-memory web-dev build profile and build-web.sh --profile 2026-09-25 23:23:35 +02:00
src Fix render scale below 100% stopping all rendering: no GPU copy when the scene image resizes 2026-09-25 22:29:54 +02:00
web Publish a container image of the web build on tag pushes 2026-09-11 06:35:43 +00:00
.dockerignore Publish a container image of the web build on tag pushes 2026-09-11 06:35:43 +00:00
.gitignore Add a WebAssembly build and document how to run it 2026-09-10 21:21:28 +00:00
Cargo.lock Backdrop marker is Clone for batch spawning; lockfile 2026-09-25 17:52:26 +02:00
Cargo.toml Drop the low-memory web-dev build profile and build-web.sh --profile 2026-09-25 23:23:35 +02:00
flake.lock First commit with working game 2026-09-10 20:45:22 +02:00
flake.nix Change name to ryggattack 2026-09-10 20:51:15 +02:00
README.md ci: migrate workflows to Forgejo Actions 2026-10-05 17:13:27 +02:00

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-canvas element from web/index.html and 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

  1. Improve movement, hit feedback, bot behavior, and round presentation.
  2. Add original character and arena art.
  3. Shrink the download; 39 MB is a lot to ask of a first visit.
  4. Validate Android and iOS packaging early.