- Rust 94.3%
- Python 3.9%
- Fluent 1.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014CHcpZGXQzQvrkTWuiyPpk |
||
| crates | ||
| steamaudio | ||
| tools | ||
| .DS_Store | ||
| .gitignore | ||
| binaural_right.f32 | ||
| Cargo.toml | ||
| decoded.f32 | ||
| hrtf_ahead.f32 | ||
| hrtf_right.f32 | ||
| pstatus.md | ||
| README.md | ||
| rendered.f32 | ||
| tone.f32 | ||
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.Ctxfields: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 (anyMenuItem), then Done and Cancel (engine strings). Enter in a field, on Done, or Ctrl+Enter anywhere submits; anErris 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,Stayjust 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).panon a mono sample followsPanLaw::EqualPower(default: constant power, each ear at 0.707 in the centre) orPanLaw::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, andaudio.occlude_voices(|voice, listener, source| Some(amount))decides per voice (Noneleaves 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)onPlayOptionsfor 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/westyaws 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 }viaAppConfig::spatialoraudio.set_spatial. Changingmodemoves playing voices too (loops included), except voices that asked for their own mode withSpatialOptions::mode.- The built-in binaural model (used for
Binaural, and forHrtfwithout 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,StereoorBinaurallateralise 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 framefor (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 aWallKind(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), andmove_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 exactlyfromto exactlyto),.simplify(&walls, radius)/.simplify_on_grid(&grid, blocked)(string pulling),.length(),.legs(y_down, eight) -> Vec<Leg>, anddescribe_legs(&ctx.i18n, &legs, "distance-steps", 1.0, 2)gives "2 steps east, then 3 steps south".WaypointGraphfor free-form maps:add(p),connect_visible(&walls, max_distance, radius),find_path(&walls, from, to, radius).guide:Follower::new(path)walks it: each framelet s = follower.step(pos, speed, ctx.dt)givess.delta(apply it, e.g. throughmove_circle) ands.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")(featuretiled) returnsTiledWorld { grid, walls, objects, properties }: solid tiles (propertysolidor classwall, or a layer namedwalls/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