Publishing as a Discord Activity
Run your game inside a Discord Activity with @irtio/discord. The adapter handles authorization,
URL mappings, and a shared room code for each Activity instance. The examples assume an existing irtio game.
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.
Connect the game
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.
Store the application credentials on your irtio project to use the hosted token exchange:
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.
Every Activity request reaches irtio through Discord’s proxy, so irtio sees one address for all your
players. When the exchange succeeds, the route also returns a short-lived ticket naming the Discord
user. connectDiscordActivity attaches it to the identity calls @irtio/client makes, so their
rate limits count each player separately rather than everyone behind the proxy together. You do not
handle the ticket yourself. See the limits page.
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
Use the Discord voice channel players joined with the Activity. Discord’s proxy supports WebSockets but does not carry WebRTC, so use Discord’s mute and volume controls for the call.
Local development
Expose your local game page through an HTTPS tunnel so Discord’s proxy can reach it:
npx irtio dev # the room, on :7070
npx esbuild main.ts --bundle --splitting --format=esm --outdir=.
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.
Test and publish
Test authorization and URL mappings inside a Discord voice channel. These flows depend on the Discord host and cannot be verified on a standalone page.
App Directory discovery requires Discord’s review. Request it before your planned launch. Use Discord’s SKU and entitlement APIs directly for Activity purchases.
The default authorization scope is identify, for the player’s id, name, and avatar. Request
additional scopes only for the Discord APIs your game uses.
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.