No description
  • Rust 94.3%
  • Python 3.9%
  • Fluent 1.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Oriol Gómez 127977eb81 package.py runs from the game's folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CHcpZGXQzQvrkTWuiyPpk
2026-10-14 14:14:30 +02:00
crates Music starts at 50%; learning a sound skips the confirm sound 2026-10-14 14:14:30 +02:00
steamaudio root 2026-10-01 07:22:00 +02:00
tools package.py runs from the game's folder 2026-10-14 14:14:30 +02:00
.DS_Store root 2026-10-01 07:22:00 +02:00
.gitignore Ignore dist/ build output 2026-10-01 08:17:06 +02:00
binaural_right.f32 root 2026-10-01 07:22:00 +02:00
Cargo.toml root 2026-10-01 07:22:00 +02:00
decoded.f32 root 2026-10-01 07:22:00 +02:00
hrtf_ahead.f32 root 2026-10-01 07:22:00 +02:00
hrtf_right.f32 root 2026-10-01 07:22:00 +02:00
pstatus.md package.py runs from the game's folder 2026-10-14 14:14:30 +02:00
README.md new features 2026-10-04 10:15:24 +02:00
rendered.f32 root 2026-10-01 07:22:00 +02:00
tone.f32 root 2026-10-01 07:22:00 +02:00

audiogame-engine

An audio-first game engine for blind-accessible games, in Rust. No visual UI: the engine opens a bare window for keyboard focus and everything the player perceives is sound and speech.

concern how
speech Prism through the prismer crate: a running screen reader (NVDA, JAWS, VoiceOver, Orca…) first, native TTS otherwise
spatial audio Steam Audio HRTF through audionimbus; built-in two-ear fallback
mixer own mixer on cpal: buses, sample-accurate scheduling, filters, limiter, music crossfades, sequencer
decoding symphonia (wav, ogg/vorbis, flac, mp3)
window/input winit + gilrs
i18n Fluent (.ftl files)
persistence JSON in the platform data dir
world grids, walls and occlusion (parry2d), A* pathfinding (pathfinding), autowalk, beacons, sonar, scanner
physics rapier2d (feature physics), contacts with point and impact speed for sounds
maps / synth Tiled .tmx (feature tiled), fundsp sounds into the library (feature synth)
multiplayer audiogame-net: WebRTC data channels (matchbox_socket) with STUN/TURN (coturn), host-authoritative star

Platforms: Windows, macOS, Linux today (everything used supports Android/iOS for later).

Workspace

crates/audiogame       the engine
crates/audiogame-net   multiplayer (optional, separate crate)
steamaudio/            Steam Audio runtime libraries: windows/, macos/, linux/

Building

Requirements: Rust stable, CMake (Prism is C++23, built by prism-sys), a C++ compiler (MSVC 2022 / clang 17+ / gcc 14+), and libclang for the Steam Audio bindings (winget install LLVM.LLVM on Windows, Xcode command line tools on macOS; found automatically, or set LIBCLANG_PATH).

cargo build
cargo run -p audiogame --example demo             # spoken menu + orbiting HRTF tone
cargo run -p audiogame --example demo -- --headless

The engine's build.rs links Steam Audio from steamaudio/<os> (Steam Audio 4.8.1, x64 on Windows and Linux, universal on macOS) and copies that runtime library and the freshly built prism.dll next to the built binaries (target/<profile>/), so an exe also runs outside cargo run and never picks up a stale Prism from the system PATH. When shipping, put both next to the exe.

Environment variables read at start-up: AUDIOGAME_ASSETS (assets dir), AUDIOGAME_LANG (forces the locale; a launcher sets it), AUDIOGAME_DATA_DIR (save location).

Using the engine from another project:

[dependencies]
audiogame = { git = "https://github.com/ogomez92/audiogame-engine" }   # or path = "../audiogame-engine/crates/audiogame"

and copy the [env] block from .cargo/config.toml (or export the two variables). Features: steam-audio (default), prism (default), world (default), physics, tiled, synth. Disable the first two for a pure-Rust build without native dependencies; turn the others on per game: audiogame = { workspace = true, features = ["physics"] }.

A game in 40 lines

use audiogame::prelude::*;

struct Game { ship: VoiceHandle, x: f32 }

impl Screen for Game {
    fn name(&self) -> &str { "game" }
    fn on_enter(&mut self, ctx: &mut Ctx) {
        ctx.say(ctx.t("game-start"));
        let d = ctx.audio.directions();
        ctx.audio.set_listener(0.0, 0.0, 0.0, d.north);
    }
    fn update(&mut self, ctx: &mut Ctx) -> Transition {
        let input = ctx.input.game();              // x forward/back, y strafe, rotate
        self.x += input.y * 5.0 * ctx.dt;
        self.ship.set_position(self.x, 0.0, 0.0);
        if ctx.input.pressed(Key::Space) {
            ctx.audio.play("laser", PlayOptions::new().position(self.x, 0.0).pitch_jitter(1.0));
        }
        if ctx.input.ui().pause { return Transition::push(PauseScreen::new(ctx)); }
        Transition::Stay
    }
}

fn main() {
    let config = AppConfig::new("mygame", "My Game").assets(audiogame::dev_assets!());
    App::new(config, |ctx| Box::new(
        MenuScreen::new(ctx.t("menu-title"), vec![
            MenuItem::new("start", ctx.t("menu-start")),
            MenuItem::new("quit", ctx.t("menu-quit")),
        ]).root().on_activate(|ctx, id| match id {
            "start" => { let ship = ctx.audio.play_loop("engine", PlayOptions::new().position(0.0, 0.0)).unwrap(); Transition::push(Game { ship, x: 0.0 }) }
            "quit" => Transition::Quit,
            _ => Transition::Stay,
        })
    )).run();
}

Assets live in assets/sounds/** (names are relative paths without extension: assets/sounds/ui/confirm.wav is "ui/confirm") and assets/locales/<lang>.ftl.

API tour

App and Ctx (audiogame::app)

  • AppConfig::new(name, title) + builders: .assets(path), .fps(60), .spatial(SpatialConfig), .setting(SettingDef), .output(Output), .speak_title(bool), .start_sound("ui/start"), .lazy_sounds(true) (register the sounds directory and decode each file on first use: large sound sets start instantly).
  • App::new(config, |ctx| first_screen).run() opens the window and runs.
  • Ctx fields: audio, speech, input, i18n, settings, storage, timers, dt, time, frame, assets, focused.
  • ctx.t(key), ctx.t_args(key, &[("name", v.into())]), t!(ctx, "key", name = v).
  • ctx.say(text) interrupts (assertive); ctx.announce(text) queues (polite).
  • ctx.play("name"), ctx.play_ui("ui/confirm").
  • ctx.insert(T), ctx.state::<T>(), ctx.state_mut::<T>() — typed shared state between screens.
  • ctx.timers.later(secs, |ctx| …), .every(secs, …), .cancel(id).
  • ctx.apply_settings(), ctx.save_settings(), ctx.quit().

Screens (audiogame::screen)

trait Screen { fn update(&mut self, ctx) -> Transition; on_enter; on_exit; on_resume; on_pause; on_focus_lost } and Transition::{Stay, Push, Pop, Replace, PopN(n), PopNThen(n, screen), Reset, Quit} with helpers Transition::push(screen), Transition::pop_then(2, results) etc. Only the top of the stack updates.

Ready-made (audiogame::screens): MenuScreen (.root(), .polite() to queue the title after a game-over line, .on_enter to relabel), TextScreen (help/about; TextScreen::from_keys(ctx, "help-title", "help") reads help-1, help-2, …; from_key_fn for other patterns), LearnSoundsScreen (entries may .with_description(..); timers scheduled by a demo are cancelled when it stops), PauseScreen::new(ctx, |ctx| Transition::PopN(2)), SettingsScreen::new(ctx), LanguageScreen::new(ctx), HighscoresScreen::new(ctx, &scores) / with_formatter.

  • MenuScreen::keyed(ctx, "menu-title", &[("start", "menu-start"), …]) builds buttons from message keys and relabels them on enter/resume; .hotkey(Key::KeyT, |ctx| …) for hidden shortcuts; .on_exit(..).
  • PauseScreen: Escape, Resume or the pause button resume; .resume_keys(&[Key::KeyP]), .hotkey(..), .speak_title(false), .on_enter/.on_exit (duck and restore music), .item(..).
  • GameOverScreen::new(ctx, title, score): title and .line(..)s, then a form as a menu: the name as an edit field (typed straight into), Save, and the game's .item(MenuItem, handler) / .lead_item(..). Saves into .board(key, capacity) (default "highscores", 10) with .extra(json). Variants: .only_if_ranked(), .save_on_enter(), .hide_form_after_save(), .focus_after_save(id), .empty_name("Player"), .default_name(..), .max_len(n) / .field(TextField), .name_label, .save_label, .name_required(..), .saved_message(|ctx, rank| …), .on_saved(..), .after_save(|ctx, rank| Transition) (leave once saved), .no_entry() (results only), .on_enter, .on_back.
  • ListenerTestScreen::new("test/tick"): a tick round the listener, 1/2/3 switch binaural/HRTF/stereo.
  • FormScreen::new(title, fields, |ctx, menu| -> Result<Transition, String>): the fields (any MenuItem), then Done and Cancel (engine strings). Enter in a field, on Done, or Ctrl+Enter anywhere submits; an Err is spoken and the form stays. .on_button(|ctx, id, menu| …) for buttons among the fields (pickers), .on_resume(|ctx, menu| …) to take back what a pushed screen chose.
  • ContextMenu::new(title, items, |ctx, id| -> Transition): what can be done with something. The choice's result replaces the menu (a pushed screen takes its place, Stay just closes it); Escape closes it. ContextMenu::requested(ctx) is true on the Applications key or Shift+F10.
  • IntroScreen::new("logo-en", |ctx| Box::new(main_menu(ctx))): .skip_sound(..), .preload(names).

Menus (audiogame::menu)

Menu::new(title, Vec<MenuItem>); each frame menu.update(ctx) -> Option<MenuEvent> (Activate(id), Left(id), Right(id), Changed(id), Back, Focus(id)). Up/Down wrap, Home/End, digits, first-letter jump. ui/move, ui/confirm, ui/back play if present.

Every item has a Role, and is spoken as label, role, value, position: "Start game, button, 1 of 5". Turn parts off with menu.speak_roles / menu.speak_position.

item role the player the game gets
MenuItem::new(id, label) button Enter/Space activates Activate
MenuItem::selector(id, label, options, i) selector Left/Right pick an option Changed; menu.selected(id)
MenuItem::edit(id, label, TextField::new("").max_len(20)) edit field types straight in (no Enter first), Left/Right/Home/End move the caret, Backspace/Delete erase, Enter activates Changed per edit; menu.text(id), menu.set_text
MenuItem::new(..).role(Role::Slider).with_value("80%") slider Left/Right Left/Right; set the value with menu.set_value
.role(Role::Tab) tab Left/Right switch view Left/Right
MenuItem::number(id, label, NumberField::new(v, min, max, step).decimals(2).unit("ms")) number Left/Right step (Shift: ten steps), Home/End min/max, digits/./- type a value confirmed by Enter, an arrow or leaving (Escape drops it) Changed per change; menu.number(id), menu.set_number
MenuItem::checkbox(id, label, checked) check box Enter/Space tick and untick ("checked", "not checked") Changed; menu.checked(id), menu.set_checked
.role(Role::Text) (none) a line of information

.with_value("80%"), .with_sound("ui/back"), .disabled() ("…, unavailable"). TextField (.max_len, .allowed("ABC"), .uppercase()) is the one text editor: menu edit fields use it, and TextInput::new(prompt, field) wraps it for a lone prompt between screens. Screens that draw their own lists can sound the same with menu::describe(&ctx.i18n, label, role, value, Some((index, total))).

Audio (audiogame::audio)

  • audio.play(name, PlayOptions) -> Option<VoiceHandle>; play_loop; osc(Wave, hz, opts); noise(color, opts).
  • pan on a mono sample follows PanLaw::EqualPower (default: constant power, each ear at 0.707 in the centre) or PanLaw::Balance (the near ear at full level, the far one scaled by 1 - |pan|, like stereo samples and like DirectSound/BGT panning). Stereo samples are always balanced.
  • Samples are resampled with 4-point cubic (Catmull-Rom) interpolation: a 44.1 kHz file on a 48 kHz mixer keeps its highs (within 0.5 dB at 10 kHz).
  • PlayOptions::new() builders: .gain .pitch(semitones) .detune(cents) .rate .looping() .offset .duration .delay .when(audio_time) .fade_in .fade_out .pan(-1..1) .pan_law(PanLaw::Balance) .filter(Filter::lowpass(800.0)) .position(x, y) .position3(x, y, z) .gain_model(GainModel) .spatial(SpatialOptions) .behind_detune(cents) .bus(Bus::UI) .throttle(ms) .max_instances(n) .jitter(pitch, gain).
  • VoiceHandle: is_playing() is_ended() stop() stop_fade(s) set_gain(v, ramp) set_pitch set_rate set_detune set_pan set_position(x, y, z) set_filter set_filter_ramp(filter, ramp) set_tremolo(rate, depth, ramp) set_loop set_frequency set_paused(bool) set_occlusion(amount, ramp) position() elapsed(now).
  • Occlusion: .occlusion(0..1) / set_occlusion(amount, ramp) muffles a voice as if behind walls (lowpass toward 450 Hz and about -14 dB at 1, a stage of its own so the voice's filter is untouched; at 0 the voice is untouched, and it never clicks either way). audio.occlude(|listener, source| walls.occlusion(listener.xy(), source.xy()), 0.08) once a frame sets it on every playing spatial voice from the world's walls; voices played with .occludable(false) (UI cues, calibration ticks) are skipped, and audio.occlude_voices(|voice, listener, source| Some(amount)) decides per voice (None leaves it).
  • set_rate(rate, ramp), set_pitch(semitones, ramp), set_gain(value, ramp): every setter that glides takes the ramp in seconds last. A paused voice keeps its place and renders nothing until resumed (ramp the gain down first to avoid a click); stopping a paused voice ends it at once, whatever the fade.
  • .tremolo(rate_hz, depth) on PlayOptions for creaks/wobbles; audio.stop_named(name, fade) stops every instance of a sample; audio.set_bus_filter(bus, Some(Filter::lowpass(800.0))) muffles a whole bus.
  • Listener: audio.set_listener(x, y, z, yaw), set_listener_position, set_listener_yaw, audio.directions() (north/south/east/west yaws for the configured handedness), audio.relative(pos) (forward/right/distance/azimuth), yaw_to(from, to).
  • SpatialConfig { confusion_radius, meters_per_unit, mode: Hrtf|Binaural|Stereo, muffle_min_cutoff, y_down } via AppConfig::spatial or audio.set_spatial. Changing mode moves playing voices too (loops included), except voices that asked for their own mode with SpatialOptions::mode.
  • The built-in binaural model (used for Binaural, and for Hrtf without Steam Audio) gives each ear a delay, a head-shadow lowpass and a level drop (about -7 dB fully to the other side, so bass-heavy sounds lateralise too), and an elevation cue: a pinna-like notch that climbs with the source and a brighter top end above, duller below. Steam Audio's measured HRTF, like a real head, barely separates low frequencies by level; for bass-heavy cues that must read clearly left/right, Stereo or Binaural lateralise more strongly.
  • GainModel::exponential(min, max), linear, inverse(ref, power) (.with_gains(min_gain, _) sets its floor), normalize(gain); default exponential(1, 100).
  • Buses: Bus::SFX/MUSIC/UI/VOICE/MASTER, Bus::named("env"); audio.set_volume(bus, v, ramp), mute, duck(bus, amount, ramp) -> DuckGuard (releases on drop), stop_all(fade), stop_bus.
  • Music: audio.music().play("theme", fade), .play_with(name, MusicOptions), .stop(fade), .duck(amount, ramp), .set_gain, .set_pitch, .is_playing(), .current_name().
  • Clock: audio.now() seconds on the audio clock; schedule cues with .when(t).
  • Sequencer::new(interval); seq.start(audio.now()); each frame for (when, step) in seq.poll(audio.now()) { … }.
  • Library: audio.has(name), audio.duration(name), audio.library().names(); library().register_dir(dir) / register_file(name, path) (decoded on first use), preload(names) (decode on a background thread), is_decoded(name), evict(name) (free the samples, keep the name), remove(name).

Coordinates: x/y horizontal, z up; y_down: true (default) means +y is screen-south like 2D tile games. yaw is a math angle, so facing north with y_down is -π/2; use audio.directions().north.

Speech (audiogame::speech)

speech.say(text) (interrupt), speech.announce(text) (queue), speech.stop(), set_rate, set_volume, set_enabled, backend_name(), transcript() (tests). voice_state() asks the backend whether voice and rate can be set from the game (standalone TTS, not a screen reader), for its voices and current rate; set_voice(index) picks one.

Input (audiogame::input)

input.key(Key::KeyW) held, input.pressed(Key::Space) this frame, input.released, input.text() typed chars, input.shift()/ctrl()/alt(). input.ui() → UiInput { up, down, left, right, back, confirm, pause, start, enter, space, tab, home, end, focus } (newly pressed only). input.game() → GameInput { x, y, rotate } sustained. input.mappings to rebind. input.press_age(key) gives the seconds between the key event and the frame start (rhythm games). input.pressed_or_repeated(key) is also true on OS auto-repeat (menus that scroll while an arrow is held); input.keys_pressed() lists this frame's presses. input.consume() marks this frame's keys as handled (nothing after sees them pressed or typed); MenuScreen hotkeys do it for their key, so a shortcut on Space or a digit does not also activate or jump in the menu. Gamepad: standard indices (0 A, 1 B, 9 Start, 12–15 d-pad). input.rumble(strong, weak, seconds) vibrates every gamepad that can (heavy and light motors, 0…1; a new request replaces the last); stop_rumble(). Register SettingDef::bool("rumble", true) to give players the engine's "Gamepad vibration" setting (settings-rumble); input.rumble_enabled follows it. Every request is logged (input.rumble_log(), Harness::rumbles(), with played: false when vibration was off) so tests can check them without a pad.

i18n (audiogame::i18n)

Fluent files; locale-name = English in each. Keys are kebab-case (menu-start), arguments { $score }. i18n.available(), set_locale, system_locale().

The engine translates its own strings (English, Spanish, Portuguese: crates/audiogame/locales/, built into the library): menu roles (role-button, role-edit, role-selector, role-slider, role-tab), menu-position, menu-unavailable, text-*, settings-*, language-*, highscores-*, pause-*. A game translates only its own text and window-title; defining an engine key in the game's .ftl overrides it. Lookup order: game locale, engine strings in that language, game fallback, engine English. i18n::builtin_keys() lists the engine's keys for string checks.

Settings, storage, high scores

SettingDef::range(key, min, max, step, default), ::bool, ::choice; settings.get_f32/get_bool/get_str/set/step. Engine defaults: volume-master, volume-sfx, volume-music, volume-ui, volume-voice, language. Re-registering a key (e.g. AppConfig::setting(SettingDef::range("volume-music", 0.0, 1.0, 0.1, 0.5))) overrides its default; any volume-<bus> setting drives that bus; settings.move_before("volume-env", "volume-music") orders the settings screen. Storage: get::<T>(key), set(key, &value) (writes immediately). Highscores::load(&storage, "highscores", 10) / load_default(&storage), qualifies, worth_saving(score) (positive and qualifies), rank_for(score), insert, save. HighscoreEntry::new(name, score).with_extra(json), entry.extra_i64("level", 1).

Listening check

cargo run -p audiogame --example spatial_demo speaks each direction, plays clicks there and circles a tone around you in HRTF, binaural and stereo mode, then plain left/centre/right panning. tests/spatial.rs measures the same things on the rendered signal.

Testing (audiogame::testing)

let mut h = Harness::new(config, |ctx| Box::new(menu(ctx)));  // no window, no device, deterministic
h.frames(10); h.key(Key::ArrowDown); h.key(Key::Enter); h.seconds(2.0);
h.assert_spoken("Get ready");

Each harness gets its own temp data dir (AppConfig::data_dir also works), speech is muted but transcribed, audio renders into the void at the game's frame rate. To listen to a stretch of play, ctx.audio.start_recording() keeps what the frames render until ctx.audio.take_recording() hands it back (interleaved stereo at ctx.audio.sample_rate()). h.focus(false) simulates alt-tab.

Record and replay (audiogame::replay): run any game with AUDIOGAME_RECORD=session.json and play; each frame's dt and key events are saved with the random seed when it closes. Harness::replay(config, first, &Recording::load(path)?) plays it back headless frame for frame (exact as long as the game draws from fastrand/math::rng and keeps to ctx.dt/ctx.time): a bug found by hand becomes a regression test.

Scanner (audiogame::scanner)

What is around the player, nearest first: name, distance, direction, and a ping where the item is.

let items = doors.iter().map(|d| ScanItem::new(d.id, ctx.t("door"), d.pos).category(ctx.t("doors")).sound("door_hum"));
if let Some(ScanEvent::Selected(id)) = self.scanner.update(ctx, items) { /* ... */ }
self.scanner.selected()   // e.g. to autowalk there

Page Down / Page Up next and previous, Home repeats (re-measured), End nearest, Ctrl+Page Down / Up change category (alphabetical, then "Everything"); all in ScannerKeys. "Door, 4.2 meters, ahead left": Bearing::Relative (default, from the listener's facing), Compass ("north east") or Clock ("2 o'clock"); .unit("distance-steps", scale), .range(r), .ping(false) (then play your own ping on ScanEvent::Selected), set_origin(Some(p)) to measure from somewhere other than the listener, and ScanItem::text(..) to speak the game's own wording for an item ("alpha bumper, upper left") while the list stays sorted by distance. Helpers: scanner::direction_key(azimuth), compass_key(v, y_down), distance_text(i18n, value, unit) (decimals written the locale's way: "1,5 metros"), number_text(locale, value).

World (audiogame::world, feature world, default)

  • Grid<T>: Grid::from_rows(&["#####", "#..D#"], |c| c), from_fn, get/set/contains, center(cell) / cell_at(pos) (cell_size, 1 by default), neighbours(cell, diagonal), line(a, b), line_of_sight(a, b, blocked), find(pred). Compass::of(v, y_down) names a direction.
  • Walls: segment, rect, rotated_rect, circle, polygon, polyline, Walls::from_grid(&grid, |t| solid, kind) (solid cells merged into as few boxes as rows allow, so a 2×2 table is one box); each with a WallKind (solid() muffles 0.7, glass() 0.15, .occlusion(x), .blocks(false) for sound-only, .tag(n)); set_enabled(id, open) for doors. Queries: raycast(origin, dir, max) -> Option<Hit { distance, point, normal, wall, tag }>, line_of_sight(a, b), occlusion(listener, source) (summed over walls crossed, capped at 1; walls containing either end do not count), nearest(point, max), overlaps_circle, sweep_clear(a, b, radius), and move_circle(pos, radius, delta) -> Move { position, hit } which slides along walls and cannot tunnel.
  • nav: grid_path(&grid, start, goal, Moves::Four | Eight, |cell, tile| Some(cost)) (A*, diagonals never cut a blocked corner); Path::on_grid(&grid, from, to, moves, cost) (from exactly from to exactly to), .simplify(&walls, radius) / .simplify_on_grid(&grid, blocked) (string pulling), .length(), .legs(y_down, eight) -> Vec<Leg>, and describe_legs(&ctx.i18n, &legs, "distance-steps", 1.0, 2) gives "2 steps east, then 3 steps south". WaypointGraph for free-form maps: add(p), connect_visible(&walls, max_distance, radius), find_path(&walls, from, to, radius).
  • guide: Follower::new(path) walks it: each frame let s = follower.step(pos, speed, ctx.dt) gives s.delta (apply it, e.g. through move_circle) and s.event (Waypoint(i) / Arrived); target(), remaining(pos). Beacon::new(Some("beacon")) pings from a target, faster (interval_near/far) and higher (pitch_near) as the player closes in: beacon.update(&ctx.audio, ctx.time, player, follower.target()); .phase(seconds) delays the first ping so beacons made together do not tick in unison.
  • sonar: Sonar::four(range) / eight / front, .sound(name); scan(&walls, origin, yaw, y_down) -> Vec<Echo>, ping(&audio, &echoes) (one echo per hit, 70 ms apart, higher when nearer), Sonar::describe(&i18n, &echo, "distance-meters", 1.0) gives "wall, 2 meters, ahead"; Sonar::describe_as(&i18n, &echo, "sofa", "north", unit, scale) uses the game's own name for what was hit (hit.tag) and its own direction.
  • world::tiled::load("level.tmx") (feature tiled) returns TiledWorld { grid, walls, objects, properties }: solid tiles (property solid or class wall, or a layer named walls/collision) become walls, objects on such a layer too; every other object is listed with name, class, centre (in tiles) and properties.

Engine strings for all of this: dir-* (relative, compass, clock), distance-meters, distance-steps, scanner-*, nav-*, sonar-*.

Physics (audiogame::physics, feature physics)

rapier2d stepped at a fixed 120 Hz, reporting each new contact ready to be sounded:

let mut phys = Physics::new(Vec2::new(0.0, 9.8));          // y_down: +y is down; zero for top-down
phys.add_walls(&walls, Material::wall());                  // the same geometry the audio queries use
let ball = phys.add_ball(pos, 0.3, Material::rubber().tag(BALL));
for c in phys.step(ctx.dt) {                               // contacts that began this frame
    ctx.audio.play("bounce", PlayOptions::new().at(c.point).gain(c.intensity(12.0)));
}

Contact { a, b, tag_a, tag_b, point, normal, speed, sensor }: speed is the closing speed just before the hit; other_tag(BALL), involves(tag). Bodies: add_ball, add_box, add_dynamic(pos, angle, ColliderBuilder, m), add_kinematic (+ set_next_pose: flippers, paddles), statics (add_static_segment/box/ball, add_static(ColliderBuilder)), add_sensor_ball (pickups). Material presets rubber(), steel(), wall() with .restitution .friction .density .damping(lin, ang) .ccd .fixed_rotation .tag. position/velocity/angle/set_velocity/apply_impulse/ add_force/set_position/remove/raycast (a force lasts through the step call it precedes, then is cleared: add it every frame); touching(a, b) and touching_any(c) for contacts that persist (scraping, resting); set_collider_position (drifting sensors), set_ball_radius, set_restitution, set_friction on live colliders; Material::combine(CombineRule::Min) so e.g. a wall's low bounce wins. single_step() runs one fixed step and returns its contacts, for games with their own step loop. phys.world is rapier's PhysicsWorld for everything else (physics::rapier2d re-exported).

Synth (audiogame::synth, feature synth)

synth::render(seconds, &mut graph) renders a fundsp graph (re-exported as synth::fundsp) into a Sample; ctx.audio.library().insert("zap", sample) and it plays like any file.

Multiplayer (audiogame-net)

let cfg = NetConfig::gomsen("bumper");          // shared servers; or NetConfig::new(url, app).ice_minter(..)
let mut s = Session::host(cfg, "Alice");        // or Session::join(cfg, "Bob", "KQ7M")
// per frame
let (events, msgs) = s.poll_messages::<MyMessage>();
s.broadcast(&snapshot, false);                  // unreliable
s.send_host(&input, false);
s.start();                                      // host: clients (and later joiners) get NetEvent::Started

NetConfig::gomsen(app) uses the shared servers: signalling at wss://signal.gomsen.com (DEFAULT_SIGNAL_URL; the AUDIOGAME_SIGNAL_URL environment variable overrides it for every game, e.g. ws://127.0.0.1:3536 for a local matchbox_server, which also skips TURN) and short-lived TURN credentials from https://turn.gomsen.com/ice?app=<app>. app namespaces the rooms (<signal>/<app>-<CODE>).

Peers are audiogame_net::PeerId. Hellos carry the host flag, so in a mesh of any size clients know which peer is the host (host_id, send_host, "host left" closes). LobbyScreen hands the session to the host on Enter and to every client when the host starts; a client joining a game already under way goes straight in. tests/live.rs (ignored) checks this over real WebRTC.

Signalling: matchbox_server behind TLS — live at wss://signal.gomsen.com (matchbox_server 0.14 behind Caddy on the gomsen VPS, systemd matchbox-signal.service; rooms live in memory, no auth; a Caddy reload drops open signalling sockets). The mesh is full, so keep rooms small (2–8 players). ICE: static STUN/TURN entries or a minter endpoint returning { "iceServers": [...] } with short-lived coturn credentials (use-auth-secret), exactly like the web games did.

Layout of a game crate

games/mygame/
  Cargo.toml          audiogame = { workspace = true }
  pstatus.md          porting status
  assets/sounds/**    (synced from the web build)
  assets/locales/en.ftl, es.ftl
  src/main.rs         AppConfig + first screen
  src/state.rs        shared game state (ctx.insert)
  src/screens/*.rs    menu, ready, game, pause, gameover, learn…
  src/content/*.rs    the simulation
  tests/smoke.rs      Harness-driven smoke test