No description
  • Rust 97%
  • Just 1.9%
  • Nix 1.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Bradley Nelson 145295ba12
All checks were successful
ci / test (push) Successful in 1m22s
ci / viewer-app (push) Successful in 5m46s
CI: cover crates/viewer-app in a job of its own
`crates/viewer-app` is excluded from the workspace, so `cargo test/clippy
--workspace` never reached it — it was the one package in the tree that could
rot unnoticed, and it had in fact accumulated three clippy failures and zero
tests. Adding it as a workspace member is not the fix: Cargo honours
`[profile]` only at a workspace root, so membership would mean moving its
`opt-level` overrides (needed to make Bevy usable in dev builds) onto
sim-core and the determinism suites.

So it gets a second CI job instead, parallel to `test`, mirroring `just
viewer-check` locally:

- Bevy features trimmed to ["3d", "ui"]. Dropping `audio` removes the
  `alsa-sys` build-time system dep for sounds this app never plays, and `2d`
  is sprite rendering it never uses: 539 -> 510 crates.
- 10 GPU-free unit tests covering the coordinate fit, both debug-ABI
  decoders, the playback cursor, and the HUD formatters. No window, GPU
  adapter, or display server is needed; the tests never construct an `App`.
- `parse_fixture` is gone. It duplicated `harness`'s own name table and had
  drifted two variants behind, so `drift` and `recon` could not be opened at
  all. The app now defers to `Fixture::from_name` and derives its usage
  string from `Fixture::ALL`, and a test simulates every fixture through the
  real downstream path to prove each one yields renderable frames.
- Fixed the three pre-existing `unnecessary_cast` failures the new clippy
  gate surfaced.

Verified in a clean rust:bookworm container (the CI image): fmt, clippy
-D warnings, and the tests all pass with pkg-config, libudev-dev,
libwayland-dev, and libxkbcommon-dev installed. Those are build-time
pkg-config probes, not runtime needs; Vulkan is genuinely dlopen'd, so the
job needs no GPU.

Nothing here is upstream of the sim: viewer-app reads replays and never
feeds back (§5), so no hash moves.
2026-08-18 15:33:25 -06:00
.forgejo/workflows CI: cover crates/viewer-app in a job of its own 2026-08-18 15:33:25 -06:00
configs Add the bot debug side-channel and the Bevy viewer front-end (§9.5, §13 M4) 2026-08-18 15:32:09 -06:00
crates CI: cover crates/viewer-app in a job of its own 2026-08-18 15:33:25 -06:00
examples Add the bot debug side-channel and the Bevy viewer front-end (§9.5, §13 M4) 2026-08-18 15:32:09 -06:00
.envrc Add the bot debug side-channel and the Bevy viewer front-end (§9.5, §13 M4) 2026-08-18 15:32:09 -06:00
.gitignore Add the bot debug side-channel and the Bevy viewer front-end (§9.5, §13 M4) 2026-08-18 15:32:09 -06:00
Cargo.lock Add the bot debug side-channel and the Bevy viewer front-end (§9.5, §13 M4) 2026-08-18 15:32:09 -06:00
Cargo.toml Add the bot debug side-channel and the Bevy viewer front-end (§9.5, §13 M4) 2026-08-18 15:32:09 -06:00
CLAUDE.md CI: cover crates/viewer-app in a job of its own 2026-08-18 15:33:25 -06:00
devenv.nix Add the bot debug side-channel and the Bevy viewer front-end (§9.5, §13 M4) 2026-08-18 15:32:09 -06:00
flake.lock Initialize Rust project with devenv and just 2026-07-22 01:24:41 -06:00
flake.nix Initialize Rust project with devenv and just 2026-07-22 01:24:41 -06:00
justfile CI: cover crates/viewer-app in a job of its own 2026-08-18 15:33:25 -06:00
README.md CI: cover crates/viewer-app in a job of its own 2026-08-18 15:33:25 -06:00

BattleTime

A programming game where competitors submit WebAssembly modules that pilot ships in a 3D Newtonian space-combat simulation. The simulation is bit-deterministic across platforms; matches are distributed as small replays and are independently verifiable by re-simulation.

This repository implements the design in spec/spec.md. Section references below (§) point into that document.

Design pillars

  1. Bit-exact determinism across OS, libc, and CPU. The load-bearing property.
  2. Distributed agency — one WASM instance per entity, coordinating over a light-lagged, lossy channel.
  3. Partial, delayed information — sensor-limited, light-speed propagation.
  4. Compute is a resource — efficient bots think more often; efficiency never buys raw power.
  5. The viewer is downstream — rendering never feeds back into the sim.

Workspace layout (§2)

Crate Role Spec
sim-types Frozen repr(C), little-endian, versioned ABI structs §2, §3.3
sim-core no_std deterministic kernel: (State, [BotOutput]) -> State §3, §4, §5, §6, §7, §12
comms Unified light-lagged propagation queue (sensors + radio, one mechanism) §8.1
debug-draw Downstream debug-overlay / log data model bots emit (lines/points/shapes/text/logs) — never hashed §9.5
replay Sparse, re-simulatable replay: manifest + per-decision command log; keyframes + seek; debug overlays §9.1, §9.2
wasm-validate Feature-posture validation + mutable-global snapshot rewriter §11
runtime wasmtime host: persistent instances, fuel=cycle metering, snapshots §7, §9.3
harness Determinism harness: per-tick BLAKE3 hashing, scripted-bot driver, fixtures, forensics §3.4, §9.4, §9.5, §13 M0
scenario TOML match config (ruleset overrides + fleet) and a unified driver mixing WASM and scripted pilots §2, §7
cli (game) Developer flow: run, test, bench, replay, inspect, abi-header §2
ladder Tournament runner, round-robin scheduling, Elo ratings §13 M5
viewer Downstream match viewer: frame extraction + text renderer (reads replays; never feeds back) §13 M4
viewer-app Bevy/wgpu 3D front-end over viewer (excluded workspace; run via just viewer, gated by just viewer-check) §13 M4
bot-sdk-rust Ergonomic Rust wrapper over the bot ABI §2
bot-abi C header + byte-layout spec for other languages §2

Milestone status (§13)

  • M0 — determinism harness first (per-tick hashing, self-comparing CI checks, fixture matches), then physics + heat + reaction mass + cycles + command programs with scripted in-process bots. Done.
  • M1 — WASM host: persistent instances, cycle (fuel) metering, validation + instrumentation, game inspect. Done.
  • M2 — replay format (sparse, cycles_consumed mandatory), keyframe snapshots, forensic bundle, seek. Done.
  • M3 — comms/sensor propagation queue, resolution tiers, weapon families (kinetic/beam/missile), missiles-as-agents. Done (parameters provisional, §14).
  • M4 — viewer. Downstream frame extraction + a text renderer live in the lean viewer crate; the Bevy/wgpu 3D front-end is crates/viewer-app (its own workspace so the GPU deps stay out of every --workspace command, and so it keeps its own dev profile). It has its own CI job and its own local gate, just viewer-check. Run it with just viewer <fixture|replay> [ticks]. Done.
  • M5 — tournament runner, scheduling, ratings. Done.

Determinism

sim-core is no_std, allocation-light, zero-I/O. It uses vendored libm (no std transcendentals), BTreeMap never HashMap, fixed accumulation and collision-resolution order, fixed-step (1000 Hz) semi-implicit Euler integration, and no wall-clock or entropy (§3.2). Per-tick state is hashed with BLAKE3 over a canonical little-endian serialization (§3.4).

crates/harness/tests/golden.rs pins per-tick hashes at ticks 1/100/10000/end for every fixture. It is the one pinned check — a deliberate physics change requires re-blessing it — so it is #[ignore]d and not run in CI; run it on demand with just determinism. CI instead relies on the self-comparing checks below, which never need a re-bless.

Determinism testing system

Alongside the pinned goldens, several self-comparing checks assert determinism without any frozen constants — a legitimate physics/ruleset change never forces a re-bless, because every run is only ever compared to another run of the same code (just determinism-all). Every comparison uses the 32-byte chain_digest — the running BLAKE3 over every per-tick state hash — so a divergence at any single tick fails, even a transient one that later reconverges (unlike comparing only final/checkpoint hashes):

  • Parallel N-way (tests/parallel.rs) — launches many (fixture, seed) simulations on their own threads concurrently; all replicas of a case must agree, distinct cases must differ. Running them at once is what would surface any shared-mutable-state regression a sequential loop hides.
  • Fuzzer (tests/fuzz.rs) — a deterministic generator (src/scenarios.rs) turns a seed into a full randomized match (varied fleet size, positions, velocities, masses, orientations, pilots), and BT_FUZZ_N (default 200) of them are each run twice and required to match. Widening the input space is how edge cases surface that the five fixtures never reach; the generator uses only correctly-rounded IEEE ops so a seed yields a bit-identical start on every platform. Hunt harder with BT_FUZZ_N=5000 just determinism-all.
  • Cross-environment (tests/digest.rs) — an emitter prints the digest report (every fixture + BT_FUZZ_N scenarios) from whatever the tree computes. just determinism-cross runs it natively and again elsewhere in a QEMU-emulated Docker container, then diffs. One recipe, several axes — verified bit-identical on linux/arm64 (other ISA), linux/s390x (big-endian — stresses the little-endian marshalling), and musl/Alpine (other OS userland). If a target diverges, just determinism-locate <label> re-runs it emitting the full per-tick chain in both environments and prints the first divergent tick. (The cross tool assumes rustup and does not work with the nix toolchain, so the recipe drives Docker+QEMU directly; a one-time docker run --privileged --rm tonistiigi/binfmt --install all registers the emulators.)

Two more reach platforms Docker can't:

  • WASM / browser (examples/determinism-wasm/) — the same digest_report (fixtures + fuzz scenarios) compiled to wasm32. just determinism-wasm runs it under Node (V8) and diffs against native; just wasm-serve hosts a self-contained page so opening it in any browser on macOS, Windows, or a phone prints that machine's digests with no toolchain (?fuzz=N to widen). Verified bit-identical to native x86_64.
  • Native anywhere — just determinism-native prints this machine's digests. The emitter is pure portable Rust (std + BLAKE3, no platform code), so on a real macOS/Windows box it is one command; compare its DIGEST lines to any other machine's. (macOS can't be virtualized on non-Apple hardware, so native macOS needs a real or cloud Mac.)

What runs in CI: only the fast self-comparing checks — determinism.rs (run-to-run) and parallel.rs (concurrent N-way) — via cargo test --workspace. The pinned golden.rs, the fuzzer, the Docker+QEMU cross-environment checks, and the WASM harness are all #[ignore]d or just-only and run on demand. (CI runs a single Linux environment and never compares hashes across environments — that cross-platform equality check lives in just determinism-cross.) A second CI job builds, lints, and tests crates/viewer-app, which --workspace cannot reach; it is downstream-only and touches no hash.

Quick start

# Run everything
cargo test --workspace

# Run a fixture match, write a replay, then verify it re-simulates
cargo run -p cli -- run --fixture duel --out /tmp/duel.btrp
cargo run -p cli -- replay /tmp/duel.btrp --verify

# Determinism checkpoints, throughput, and the C ABI header
cargo run -p cli -- test
cargo run -p cli -- bench --fixture tumble --ticks 200000
cargo run -p cli -- abi-header --out battletime_bot.h

# Inspect a module's validation + instrumentation diff (§11.4)
cargo run -p cli -- inspect path/to/bot.wasm

# Run a real WASM bot in a live duel (see examples/seeker-bot)
just build-example-bot
cargo run -p cli -- run --config configs/wasm-duel.toml

Match configs (§2)

run and bench accept a TOML match config that fully specifies the ruleset and fleet — any mix of WASM modules and built-in scripted bots — instead of a fixed fixture. See configs/ for examples.

# Bench a full match (WASM-vs-WASM); throughput excludes compile/setup time
cargo run --release -p cli -- bench --config configs/wasm-duel.toml --ticks 500000

# Play a mixed WASM-vs-scripted match and record a verifiable replay
cargo run -p cli -- run --config configs/mixed-duel.toml --out /tmp/mixed.btrp

# Override any ruleset field from the CLI, repeatably; --seed wins over the config
cargo run -p cli -- bench --config configs/wasm-duel.toml \
    --set ruleset.max_thrust_accel=8000 --set ruleset.tick_cap=20000 --seed 12

A config is [ruleset] (any subset of the tunable Ruleset scalars, §14) plus a list of [[player]] tables; each player is a ship piloted by exactly one of wasm = "path" or builtin = "<name>" (idle, constant-thrust, pulse, spinner, gunner, pinger) with a args = { ... } table. Relative wasm paths resolve against the config file's directory.

Example bot

examples/seeker-bot/ is a complete WASM bot written against bot-sdk-rust: a sensor-cued point-defense gunner with a bump allocator and persistent static mut state. It compiles to a ~7 KB wasm32-unknown-unknown cdylib, is validated

  • instrumented on load like any submission (§11), and is run through the full sim-core simulation by runtime's WasmDriver. The end-to-end tests live in crates/runtime/tests/end_to_end.rs (a full deterministic WASM-vs-WASM match). The committed crates/runtime/tests/fixtures/seeker.wasm is rebuilt with just build-example-bot.

Fixtures: drift, tumble, duel, salvo, recon — each pins a subsystem's hash (§13).

License

MIT OR Apache-2.0.