Battlezone Online is a real-time multiplayer tank game built with Node.js, WebSockets, and Three.js.
Two public test servers, both usually up:
- https://bz.rikers.org — development, running whatever is current
- https://orin-bzo.rikers.org — the
linux/arm64Docker image, updated nightly
Neither is a stable deployment, so expect either to be restarting or ahead of the latest release.
There are two supported ways to run the game:
- Docker image from GitHub Container Registry
- Source release tarball or git checkout
For most users, Docker is the best install and update path.
Each tagged release publishes:
- a GitHub release with notes generated from CHANGELOG.md
- a source tarball
- a versioned Ubuntu 26.04 image at
ghcr.io/timriker/bzo:<version>-ubuntu26.04 - a moving
ubuntu26.04tag ghcr.io/timriker/bzo:<version>andghcr.io/timriker/bzo:latest, both using Ubuntu 26.04
Every published image contains linux/amd64 and linux/arm64 variants. Release
tags use stable vX.Y.Z SemVer only; prerelease and build-metadata tags are not
published. Ubuntu 26.04 images use the pinned Node.js 24.19.0 runtime.
Docker images are built on Ubuntu 26.04 with pinned Node.js 24.19.0.
Runtime compatibility is validated in CI on Node.js 18.19.1 and 24.19.0.
Use compose.yml:
docker compose up -dThis starts the server on port 3000 and stores runtime config in ./data/server.json.
On first start, the server copies example-server.json to the configured runtime path if no config exists.
Naming update: this project now uses compose.yml, server.json, and
example-server.json only.
Then open:
http://localhost:3000
The image is multi-arch (linux/amd64 and linux/arm64), so Docker will pull the
correct variant for your host by default.
If you need to force an architecture, set platform in compose:
services:
bzo:
image: ghcr.io/timriker/bzo:latest
platform: linux/amd64 # or linux/arm64
volumes:
- ./data:/datadocker run -d \
--name bzo \
-p 3000:3000 \
-v bzo-data:/data \
ghcr.io/timriker/bzo:latestThe image defaults to SERVER_CONFIG_PATH=/data/server.json.
To force a specific architecture when running directly:
docker run -d \
--name bzo \
--platform linux/amd64 \
-p 3000:3000 \
-v bzo-data:/data \
ghcr.io/timriker/bzo:latestUse --platform linux/arm64 on ARM hosts if you want to pin that explicitly.
- Persist server settings and runtime config by mounting
/data(already done incompose.yml). SERVER_CONFIG_PATHdefaults to/data/server.json.- The container runs as UID/GID
1000:1000; for bind mounts, ensure the host./datadirectory is writable by that user (for examplechown -R 1000:1000 ./data).
Built-in maps ship inside the image at /app/maps.
Runtime map uploads and operator-managed custom maps are stored in a writable
runtime maps directory that defaults to $(dirname $SERVER_CONFIG_PATH)/maps.
With the default Docker settings, this is /data/maps, which is already
persisted by the existing ./data:/data volume.
No extra volume is required for operator uploads to persist across restarts.
If you want to override the runtime map directory, set MAPS_PATH:
services:
bzo:
image: ghcr.io/timriker/bzo:latest
environment:
SERVER_CONFIG_PATH: /data/server.json
MAPS_PATH: /data/maps
volumes:
- ./data:/dataYou can still provide static read-only maps in the image path, but uploaded maps should go to the runtime directory.
- Node.js 18.19.1 or Node.js 24.19.0
- npm
npm installIf server.json does not exist, the server will create it from example-server.json on first start.
Production:
npm startDevelopment:
npm run devThen open:
http://localhost:3000
Runtime configuration lives in server.json by default.
You can override the path with:
SERVER_CONFIG_PATH=/path/to/server.json npm startSee example-server.json for the supported shape.
There is no built-in self-update path for source installs.
To update, download a newer release or pull newer source, then run:
npm installDocker is the recommended update path.
Manual update:
docker compose pull
docker compose up -dor:
docker pull ghcr.io/timriker/bzo:latestIf you want automatic container updates, use your preferred container update manager. That is not built into the game itself.
- Human-readable history is kept in CHANGELOG.md
- Tagged GitHub releases use the matching changelog section as release notes
W/SorUp/Down— move forward/backwardA/DorLeft/Right— turn left/rightEnter— shootTab— jumpQ— self-destructP— pause/resumeN— open chat (or click theSendbutton)Enter— send chat (while chat input is focused, or clickSendagain)Esc— exit chat input or mouse mode1/2/3/4/5— switch chat tab (All/Chat/Server/Misc/Debug)[/]— previous/next chat tab.— reply to last direct-message sender,— message nemesis targetPage Up/Page Down— scroll chat historyEnd— jump chat to newest messageM— toggle mouse movementC— cycle camera modeO— toggle operator panelF— toggle fullscreenI— toggle debug HUD=or+orNumpad +— zoom radar out (increase range)-orNumpad -— zoom radar in (decrease range)\— reset radar zoom to the default medium range (0.5x shot-distance)Settings -> Radar: ...— cycle Short/Medium/Long radar presetsB— toggle nearby voice microphone/or?— show/hide help panel
The VR mode uses native WebXR and requires a browser and headset that support
immersive-vr. For local validation, open the game at http://localhost:3000.
For remote access, terminate TLS at the reverse proxy and open the game over
https://; the client automatically uses wss:// for its WebSocket connection
when the page is served over HTTPS.
A headset browser launching the installed app tries to enter VR with no 2D
landing page. xr-launch.js asks for the session before the rest of the client
loads, and keeps asking on each signal that could carry the user activation an
immersive session needs -- window load, focus, page show, visibility change, and
the Launch Handler -- with the renderer picking up whichever session results.
Where none of them lands, and everywhere else, VR Mode starts from the button.
A saved name joins immediately, and without one the XR menu opens on a Join screen carrying the same name, team, and tank choices as the 2D entry dialog, so nothing waits on a screen the player cannot see.
Typing in XR uses the headset's own system keyboard, raised when the Name or MOTD row takes focus. Quest Browser 26.1 and later provide one; a headset that does not marks those rows Desktop only, and the player can still join under the name the server assigns. Each time the keyboard opens it starts a fresh edit, so the first key replaces the whole field rather than appending to it.
The one-tap VR button beside the settings gear is shown only on a device with a
headset, because Chrome on Android reports immersive-vr support on any phone
through Cardboard; VR Mode stays in the Settings menu there.
If the deployment sets a restrictive Permissions-Policy header, allow
xr-spatial-tracking=(self). The Node.js server does not terminate TLS itself,
so HTTPS and the corresponding WebSocket proxy configuration are deployment
responsibilities.
Use the WebXR validation checklist when checking a new browser, headset, or deployment. WebGPU rendering is outside the scope of this checklist.
npm run checkThis runs syntax and lint checks.
CI also runs these checks on pushes and pull requests.
Prepare a release locally:
npm run release:prepare -- 1.0.1That updates:
package.jsonpackage-lock.jsonCHANGELOG.md
Then edit the new changelog section so it contains the real user-visible changes.
Validate locally:
npm run check
npm run release:check -- v1.0.1
npm run release:check:increment -- v1.0.1Then commit, tag, and push:
git add package.json package-lock.json CHANGELOG.md
git commit -m "Release v1.0.1"
git tag v1.0.1
git push
git push origin v1.0.1The release workflow will:
- verify that the stable tag is newer than the previous release and points to
main - install dependencies and run lint, validation, audit, and CodeQL checks
- fail if
package.jsondoes not match the pushed tag - fail if CHANGELOG.md does not contain a matching non-placeholder section
- build and smoke-test Ubuntu 26.04 images with pinned Node.js
24.19.0forlinux/amd64andlinux/arm64 - promote the verified versioned and moving Docker tags to GHCR
- publish a GitHub release and attach a source tarball
This project is licensed under the GNU Affero General Public License v3.0.
Network users can access the source code from the running app via /source, or directly at: