Skip to content

Earshot 0.2: voice chat that hears your walls ​

Earshot makes sound in a 2D web game follow the map: a friend behind a wall sounds muffled, a cave echoes, and a shout wakes the monster only if it carries that far. It has no runtime dependencies and works with any engine that can give it a Web Audio context. 0.2 adds voice chat, with rooms joined by invite code on a small Worker you deploy to your own Cloudflare account.

bash
npm i @debuffjp/earshot
Where the sound isWhat the player hearsDefault filter and gain
Clear line, under 2 tilesFull volumeNone
Behind a thin wallMuffled, words survive2000 Hz, -6 dB
Behind a solid wallDuller and quieter1200 Hz, -12 dB
Behind two or more wallsMostly tone700 Hz, -18 dB
12 tiles or moreSilenceSound is off

These defaults were tuned by ear. We measured what they do to the signal too.

Give it your map ​

You tell Earshot where the listener is and what stands between two points, then call ear.update() from your game loop. Everything else happens in the audio graph.

ts
import { createListener, wallsOnGrid } from "@debuffjp/earshot";

const ear = createListener(ctx, {
  tile: 16,
  pos: () => player,
  world: {
    wallsBetween: (a, b) => wallsOnGrid(tiles, a, b),
    reverbAt: (p) => (p.y > 20 ? "cave" : null),
  },
});

const radio = ear.source(radioNode, { pos: () => radioPosition });

// In your game loop. 10 to 20 times a second is enough.
ear.update();

Any AudioNode can be a source, so sound effects, music from a jukebox in the corner and player voices all follow the same rules.

Your voice as a controller ​

createLoudness turns the mic into levels from 0 to 3, measured against the room's own noise. Audio goes to an analyser and no further: it isn't recorded, sent or played back. Combine a level with the walls and you get a stealth rule in three lines:

ts
const walls = wallsOnGrid(tiles, player, monster);
const mix = computeMix(player, monster, walls, DEFAULT_PARAMS, DEFAULT_MATERIALS);
if (loud.level >= 2 && heardDb(loud.level, mix) >= 6) monster.wake();

Every level can also come from a key through press(), so players without a mic get the same game.

Voice chat in your world ​

New in 0.2, ear.voice() puts a remote player's stream through those same walls and rooms. If you already have an RTCPeerConnection, that's all you need. If you don't, joinVoiceRoom() connects players for you:

ts
const joined = await joinVoiceRoom({
  url: "wss://earshot-voice.you.workers.dev",
  code: createRoomCode(),
  mic,
  listener: ear,
  ipPolicy: "relay",
  pos: (peer) => players.get(peer) ?? player, // Until their first position arrives.
  onMessage: (peer, position) => players.set(peer, position),
});

Two decisions stay with you, on purpose. The mic can't open until your canUseVoice function allows it, which is where consent and flags such as a portal's disableChat belong. And ipPolicy has no default: "relay" hides IP addresses from other players and bills voice as TURN traffic, while "direct" costs less and lets players see each other's addresses. Live voice between players can bring legal duties, especially where children may play, so read the voice chat docs before you ship it.

What the server costs ​

We don't host anything. The Worker template runs on your account. It passes connection messages between players, enforces the player cap and issues TURN credentials, and it has a canJoin function for your login or ban checks. Anyone with a room code can join until you add those checks.

Relay is the expensive setting, so here's our calculation for its worst case: everyone talks constantly, at 32 kbps Opus with packet overhead, which is about 24 MB per stream per hour. Cloudflare bills TURN on outgoing traffic at $0.05 per GB after 1,000 GB free each month (pricing). Everyone connects to everyone, so a room of n players carries n x (n - 1) streams: 12 for 4 players, 56 for 8.

Room, all on relayGB per hourCost per hour after the free tierRoom-hours in the free tier
4 players0.29$0.014About 3,470
8 players1.34$0.067About 740

Real rooms cost less, because rooms stop sending to players well out of range and push-to-talk sends silence while the key is up, which is smaller but still traffic. The table covers TURN only: Workers and Durable Objects usage for the room server is billed separately.

Start with the Earshot docs, then put one wall between your player and a radio.