Skip to content

About

Asteroids written in Aver, compiled to WebAssembly and played in the browser. A port of bradcypert/asteroids-demos.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Asteroids, in Aver, in the browser

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.

the game

Playing 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.

Building it

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.

How it is put together

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.

Deploying

./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.

How it is checked

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.

Where this differs from the reference

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.

A toolchain bug worth knowing about

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.

About

Asteroids written in Aver, compiled to WebAssembly and played in the browser. A port of bradcypert/asteroids-demos.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages