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
- 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. - Reset the Client Secret under OAuth2 and store both against your irtio project.
- Under Activities, enable Activities and set the root URL mapping to where your game is served from.
- Add the two irtio URL mappings, described below.
- Add
https://<application-id>.discordsays.comto 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.
| Field | Read back as | Notes |
|---|---|---|
discordClientId | itself | Public. Returned by GET /v1/projects/:id like any other setting. |
discordClientSecret | discordSecretSet: true | Write-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:
| Prefix | Target | Carries |
|---|---|---|
/irt | <region>.irt.io, for example eu.irt.io | the room connection, joinRoom and joinRelay |
/irt-api | irt.io | control: 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/discordwraps 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
identifyalone, which is an id, a name and an avatar. Passscopesif 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.