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

  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.

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.

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.

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:

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

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.