Skip to content

Self-hosting ​

joinVoiceRoom() needs a small Cloudflare Worker. You deploy it to your own account and you pay for it: we host nothing. The Worker passes connection messages between players, enforces the player cap and issues TURN credentials. Voice travels between players or through TURN, never through the Worker.

Deploy ​

You need a Cloudflare account.

  1. Copy templates/worker into your project and run npm install there.
  2. In the Cloudflare dashboard, create a TURN key under Realtime. It gives you a key ID and an API token.
  3. Set ALLOWED_ORIGINS in wrangler.jsonc to your game's address.
  4. Add the secrets and deploy:
bash
npx wrangler secret put TURN_KEY_ID
bash
npx wrangler secret put TURN_KEY_API_TOKEN
bash
npx wrangler deploy
  1. Pass the Worker's address to your game as wss://earshot-voice.<your-subdomain>.workers.dev.

Test from two different networks. Set a usage alert in Cloudflare too.

Without both secrets the Worker offers only a public STUN server. ipPolicy: "relay" then fails with no-turn-server, and "direct" can still connect players where their networks allow it.

Settings ​

These are the vars in wrangler.jsonc. All values are strings.

VariableMeaningIn the template
ALLOWED_ORIGINSComma-separated origins allowed to join, such as https://mygame.examplehttp://localhost:4710
MAX_PLAYERSPlayers per room. One more gets room-full8
TURN_TTL_SECONDSHow long TURN credentials last14400

An origin must match exactly: scheme, host and any port, with no trailing slash. Add local and production origins together while you develop.

The Durable Object binding and migration that hold each room are already in the file.

canJoin ​

Edit canJoin in src/index.js before you ship. As shipped, anyone with a room code can join, and it does nothing about bans or age. Return false to refuse the connection:

js
async function canJoin(request, env, { room, peer }) {
  return isLoggedIn(request.headers.get("Cookie"), env); // Your own login check.
}

isLoggedIn stands for your own code. joinVoiceRoom() builds the connection URL itself, so read what you need from the request's headers. A refused player sees connection-failed.

  • The origin check stops other websites, not custom clients. Anyone determined can get TURN credentials from an open Worker, so require a login if your costs matter.
  • There is no stranger matching. Room codes are for friends, so keep it that way unless you can moderate.
  • You are responsible for privacy notices and for laws on children and online safety where your players are. This isn't legal advice. Voice says more.

Costs ​

Relay is the expensive setting. This is the worst case: everyone relayed and talking 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), as of 2026-11-04. Check current prices before relying on these. 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.

Troubleshooting ​

joined.errorCheck
connection-failedThe URL, then ALLOWED_ORIGINS against your page's exact origin, then canJoin. A room code from createRoomCode() always passes the Worker's format check; your own must be 16 to 64 letters, digits, - or _
no-turn-serverBoth secrets are set, then redeploy
room-fullMAX_PLAYERS