# 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 {#deploy}

You need a Cloudflare account.

1. Copy [`templates/worker`](https://github.com/debuffjp/earshot/tree/main/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
```

5. Pass the Worker's address to your game as `wss://earshot-voice..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 {#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 {#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](./voice#your-duties) says more.

## Costs {#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](https://developers.cloudflare.com/realtime/sfu/platform/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 {#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` |