A port of bradcypert/asteroids-demos
to Aver, compiled to WebAssembly and played in
a browser. The reference implementations are raylib programs in nine languages;
they share one set of constants and one drawing routine, so c/main.c serves as
the specification and this port follows it.
python3 -m http.server -d web 8000
then open http://localhost:8000/. Or open web/asteroids.html directly from
the filesystem — it is the same game with the wasm inlined, so it needs no
server.
| key | action |
|---|---|
A / ← |
turn left |
D / → |
turn right |
W / ↑ |
thrust |
Space |
fire (hold to fire repeatedly) |
Enter |
restart, once the game is over |
Add ?seed=12345 to the URL to replay a specific game. The simulation is
deterministic, so the same seed and the same keystrokes always produce the same
game, on any display.
Requires an aver built with the wasm feature. The default build has no wasm
backend and aver compile --target wasm-gc exits 2:
cargo install --path <your aver checkout> --features wasm --force
Then:
./build.sh
which audits the sources, runs the headless simulation, compiles the wasm, runs the harness against it, and emits the single-file build. The compiled artefacts are committed, so the game runs from a fresh clone without any of this.
The whole game is Aver. The host is an adapter with no game logic in it: it
supplies six drawing primitives, collects the keyboard into booleans, and drives
the clock. Every decision about how the game looks or behaves — the ship's
proportions, the palette, the HUD layout, the blink rate, the physics — is in
src/.
src/Canvas.av the host contract: six drawing operations, no bodies
src/Tuning.av every constant, one per function, mirroring the #define block
src/Geometry.av vectors, screen wrapping, circle overlap
src/Rng.av the seeded generator
src/Model.av the domain types and the per-size tables
src/Entities.av what happens to one ship, bullet or asteroid
src/Sim.av what happens to the whole world in one tick
src/Render.av what the world looks like
src/Decisions.av why it is built this way, as checked syntax
src/main.av entry point for the browser build
src/smoke.av entry point for a headless run
web/host.js the adapter
test/headless.mjs assertions against the compiled wasm
src/aver.toml lives beside the modules rather than at the project root because
aver discovers it from the module root, and module resolution is flat: every
.av file of a program must sit in one directory. For the same reason the
decisions are src/Decisions.av rather than decisions/architecture.av.
Read the reasoning behind the design without reading the code:
aver context src/Decisions.av --module-root src --decisions-only
and the vocabulary in CONTEXT.md.
./deploy.sh <bucket> [cloudfront-distribution-id]
or BUCKET=... DISTRIBUTION_ID=... ./deploy.sh. Useful switches, all
environment variables: DRY_RUN=1 to change nothing, PRUNE=1 to delete
bucket objects that are no longer in web/, WAIT=1 to block until the
CloudFront invalidation finishes.
It refuses to publish a stale build — if any .av file is newer than
web/asteroids.wasm, it tells you to run ./build.sh rather than shipping
artefacts that no longer match the sources.
The cache policy is public, max-age=0, s-maxage=31536000, must-revalidate.
That is deliberate: asteroids.wasm and host.js have stable filenames with
no content hash, so a long browser max-age would strand people on an old
build after a redeploy, and a CloudFront invalidation clears the edge but never
a browser cache. Browsers therefore revalidate on each load — a conditional GET
answered with a 304 — while CloudFront holds the object for a year and is
emptied explicitly on deploy. If the filenames ever gain a content hash, switch
to max-age=31536000, immutable and drop the invalidation.
asteroids.wasm is uploaded separately with an explicit
Content-Type: application/wasm. Strictly it does not need to be correct here,
because host.js reads the module with fetch().arrayBuffer() and
WebAssembly.compile rather than instantiateStreaming, which is the call that
enforces the type — but it is right, and it costs one line.
Enable compression on the CloudFront behaviour. It matters more than anything else in this section: the wasm goes 24 KB to 9 KB and the single-file build 43 KB to 17 KB. S3 does not compress on its own.
The bucket and distribution are assumed to exist; the script publishes, it does
not create infrastructure. The setup it expects is a private bucket with Block
Public Access left on, reached through CloudFront with Origin Access Control
and index.html as the default root object. Plain S3 static website hosting
with a public bucket works too and needs no CloudFront, but that endpoint is
HTTP-only and cannot compress. The game does not itself require HTTPS —
WebAssembly needs no secure context, and nothing here uses SharedArrayBuffer or
browser storage.
If you want the simplest possible hosting, web/asteroids.html is entirely
self-contained — wasm base64-inlined, host inline, zero network requests — so
uploading that one object as index.html is a complete deployment on its own.
aver audit src --module-root src # 375 cases, 0 warnings
aver run src/smoke.av --module-root src # headless simulation (VM)
aver run src/smoke.av --module-root src --wasm-gc # and on the shipped backend
node test/headless.mjs # 33 assertions on the wasm
node test/capture.mjs # screenshot capture page
The three cover different failures. audit covers the simulation, including
laws for screen wrapping, overlap symmetry, generator bounds and the lives
floor. headless.mjs is the only check that exercises the real seam — the
hex-encoded capability imports, the wasm-gc reference held across frames, the
string bridge, and the standard float imports — by instantiating the compiled
module with web/host.js's own import wiring and asserting on the draw calls.
The capture page renders deterministic frames for screenshotting in headless
Chromium.
Late waves are harder. The reference has fixed arrays of 32 bullets and 64 asteroids and silently drops spawns when they are full; around wave 13 split fragments stop appearing. This port has no ceiling, so it keeps spawning them.
Overlapping hits resolve differently. A bullet touching two asteroids at once destroys whichever comes first in list order, rather than by array slot. Not observable in play.
The generator is not C's. Asteroid headings and speeds come from a seeded
LCG carried in the world rather than rand(), so no seed reproduces a
particular C run. Both draw from the same distributions.
Everything else follows c/main.c: the constants, the statement order within a
frame, raylib's exact palette (GRAY is #828282, YELLOW is #FDF900,
RED is #E62937), text drawn from its top-left corner, and bullets that die
at the screen edge instead of wrapping.
Filed upstream as jasisz/aver#1240,
with a 33-line reproduction kept here at test/repro/aver-issue-1240.av.
On aver 0.30.0-dev's VM, a record update that sets a scalar field from one
binding's field and a list field from a call whose arguments are fields of
two different bindings evaluates one of those bindings to Unit:
Type error [line 27]: field access on non-record value (Unit)
Swapping the two fields in the update avoids it, and so does binding the inner
call to a name first. --wasm-gc is unaffected.
Three functions here hit it naturally — spawnAsteroid, tickPlaying and
launchBullet all add an entity while advancing a timer or the generator in the
same update. Each now binds the inner call to a name first, with a comment
saying why, so the workaround is not tidied away by a later reader.
Two things made it expensive to find, both worth knowing:
verify does not catch it. verify spawnAsteroid passes with Sim as the
entry module; the failure only appears once another module calls it. The whole
audit was green while aver run src/smoke.av was aborting.
The diagnostic names the update line, not the binding that became Unit, so
it points at the consumer rather than the cause. That made it look like a
cross-module dispatch problem for far longer than it should have.
Separately, aver verify --wasm-gc reports spurious failures on this project:
it decodes one value per case and cannot yet render compound values, so every
case returning a record is reported as a mismatch against
<... wasm-gc compound-value repr is a follow-up>. The default VM verify is the
authoritative one.
