Publishing as a Discord Activity

A Discord Activity is your game in an iframe inside Discord, started from a voice channel. Everyone who joins is already in a call together, which makes it the easiest place a co-op game ever lands: there is no lobby to build, no invite to send, and no code to read out.

Three things differ from serving the same game on your own page, and @irtio/discord is those three things.

npm install @irtio/discord @discord/embedded-app-sdk

@discord/embedded-app-sdk is Discord’s own SDK. It is an optional peer dependency here, so you install it and choose its version; the adapter imports it when it needs it and says so plainly when it is not there.

The whole integration

import { joinRoom } from '@irtio/client';
import { connectDiscordActivity, discordRoomId, inDiscordActivity } from '@irtio/discord';

import { schema } from './irtio/schema.js';

const room = inDiscordActivity()
  ? await joinDiscord()
  : await joinRoom(schema);

async function joinDiscord() {
  const activity = await connectDiscordActivity({
    clientId: DISCORD_APPLICATION_ID,
    project: schema.project,
  });
  return joinRoom(schema, {
    room: discordRoomId(activity),
    name: activity.user.global_name || activity.user.username,
  });
}

inDiscordActivity() reads the query parameters Discord puts on the iframe, so one build runs both inside Discord and on your own site. The complete example is examples/discord-activity.

Setting up the application

  1. Create an application at discord.com/developers/applications. Its Application ID is what you pass as clientId. It is public: it is the first label of the origin your Activity is served from.
  2. Reset the Client Secret under OAuth2 and store both against your irtio project.
  3. Under Activities, enable Activities and set the root URL mapping to where your game is served from.
  4. Add the two irtio URL mappings, described below.
  5. Add https://<application-id>.discordsays.com to your project’s allowed origins, on the dashboard under API keys. Without it the iframe cannot join a room while your own site keeps working, which is the failure that looks like a bug in the adapter.

Storing the client secret

Discord’s sign-in has a leg your game cannot run. The page gets a one-time authorization code, and turning that code into an access token requires the application’s client secret. A secret in a browser bundle is not a secret, so Discord’s own guide puts this step on your server.

An irtio game has no server, so irtio holds the pair and runs the step for you:

curl -X PATCH https://irt.io/v1/projects/$PROJECT_ID 
  -H "authorization: Bearer $IRT_TOKEN" 
  -H 'content-type: application/json' 
  -d '{"name":"My game","discordClientId":"...","discordClientSecret":"..."}'

name is required by the route, so send the project’s current name alongside. Send null for either field to clear it.

FieldRead back asNotes
discordClientIditselfPublic. Returned by GET /v1/projects/:id like any other setting.
discordClientSecretdiscordSecretSet: trueWrite-only. No route returns the value.

connectDiscordActivity posts the code to POST /v1/discord/token, which exchanges it and hands back the access token. That route answers the same refusal for every failure, including a project with no Discord application configured, so it cannot be used to find out which games ship an Activity. When you are setting this up, the answer that tells you whether the secret arrived is discordSecretSet on your own PATCH.

URL mappings

Everything an Activity requests goes through Discord’s proxy on https://<application-id>.discordsays.com. Anything not mapped is blocked with nothing in the console to explain it. WebSockets are proxied, which is what makes an irtio room reachable at all.

Add these under Activities > URL Mappings, in this order:

PrefixTargetCarries
/irt<region>.irt.io, for example eu.irt.iothe room connection, joinRoom and joinRelay
/irt-apiirt.iocontrol: the token exchange, quick match, leaderboards

The order is not cosmetic. Discord builds its matcher from the target string without escaping it, so irt.io also matches eu.irt.io, and the first mapping that matches wins. With the rows the other way round, every room connection is rewritten onto the control prefix and refused.

connectDiscordActivity applies the matching pair at runtime through the SDK’s patchUrlMappings, which replaces fetch, the WebSocket constructor and XMLHttpRequest.prototype.open. Your game keeps asking for wss://eu.irt.io and the patched constructor sends it through the proxy, so nothing in your joinRoom call is Discord-shaped and the same line works on your own page.

To apply the mappings yourself, earlier in your boot, call patchIrtioUrls() and pass patchUrls: false to connectDiscordActivity. Whichever way, they must be applied before the first connection: a socket opened first goes to the unproxied host and is dropped.

import { irtioUrlMappings, patchIrtioUrls } from '@irtio/discord';

await patchIrtioUrls({ region: 'us' });
irtioUrlMappings({ region: 'us' }); // the same rows, to check against the portal's table

One room per Activity instance

Discord already decides who is playing together. Everyone who joins one launch shares an instance id, and discordRoomId(activity) turns it into a room code:

const room = await joinRoom(schema, { room: discordRoomId(activity) });

Every player computes the same code, so the first to arrive creates the room by joining it and the rest land in it. Nothing has to be sent, and there is no host. Pass { type: 'arena' } to address a declared room type.

Voice

@irtio/discord never engages irtio’s voice, and there is no option to make it.

Players launch an Activity from a voice channel, so they are already in a call with exactly the people in the room, on hardware Discord has already asked permission for and with mute and volume controls they already know. Adding a second voice path gives them two calls at once, two echo-cancellation paths, and audio egress for a channel nobody listens to.

irtio voice is opt-in per joinRoom, so this is a convention rather than a restriction: the adapter simply never passes voice options. If you have a reason to want irtio voice inside an Activity, pass the options to joinRoom yourself.

Local development

Discord has no localhost story. The iframe is served through Discord’s proxy, and the proxy cannot reach your machine, so development runs through a tunnel:

npx irtio dev                                              # the room, on :7070
npx esbuild main.ts --bundle --format=esm --outfile=main.js
npx serve .                                                # the page, on :3000
cloudflared tunnel --url http://localhost:3000             # a public https address

Put the address cloudflared prints into your root URL mapping in the Developer Portal, then start the Activity from a voice channel. irtio dev keeps serving the room throughout; what the tunnel carries is the page. Each code change needs the bundle rebuilt and the iframe reloaded, and the tunnel address changes whenever you restart cloudflared, which means editing the mapping again.

Test the parts that are not Discord on your own page first. inDiscordActivity() is false there, your game falls back to ?room=, and the tunnel loop is then only for the Discord-specific parts.

Limits

  • Nothing here can be tested without Discord. The SDK talks to the host client over an RPC channel that exists only inside the iframe, the proxy is Discord’s, and the authorization code comes from a consent flow. The adapter’s own tests cover the sequence, the mapping rules and the room-code derivation against a fake SDK. The first real check of an integration is a manual pass in a voice channel.
  • Discovery is gated. Anyone with the link can start your Activity while it is unpublished, but appearing in Discord’s App Directory requires passing their review, which covers verification of your application and their own content and quality rules. Plan for it as a submission with a wait, not a deploy.
  • In-app purchases are out of scope here. Discord’s Activity monetization runs through their own SKU and entitlement APIs on the SDK; nothing in @irtio/discord wraps it, and irtio has no view of it.
  • WebRTC does not cross the proxy, which is the other half of the voice convention above. Discord proxies WebSockets, which is what an irtio room uses.
  • The default scope is identify alone, which is an id, a name and an avatar. Pass scopes if your game genuinely calls Discord’s HTTP API for more; every extra scope is a consent prompt.

Next: publishing on CrazyGames for the other portal with a first-class multiplayer surface, and co-op games for the shape of game an Activity suits.