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.
- Copy
templates/workerinto your project and runnpm installthere. - In the Cloudflare dashboard, create a TURN key under Realtime. It gives you a key ID and an API token.
- Set
ALLOWED_ORIGINSinwrangler.jsoncto your game's address. - Add the secrets and deploy:
npx wrangler secret put TURN_KEY_IDnpx wrangler secret put TURN_KEY_API_TOKENnpx wrangler deploy- 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.
| Variable | Meaning | In the template |
|---|---|---|
ALLOWED_ORIGINS | Comma-separated origins allowed to join, such as https://mygame.example | http://localhost:4710 |
MAX_PLAYERS | Players per room. One more gets room-full | 8 |
TURN_TTL_SECONDS | How long TURN credentials last | 14400 |
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:
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 relay | GB per hour | Cost per hour after the free tier | Room-hours in the free tier |
|---|---|---|---|
| 4 players | 0.29 | $0.014 | About 3,470 |
| 8 players | 1.34 | $0.067 | About 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.error | Check |
|---|---|
connection-failed | The 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-server | Both secrets are set, then redeploy |
room-full | MAX_PLAYERS |