Player identity
Set identity: true to give players an id that survives reconnects and returning to the same browser. Players can link another device or claim their account with email or a sign-in provider.
// main.ts
const room = await joinRoom(schema, { identity: true }); What the player gets
The first time a browser runs that line, irtio mints an identity and the browser stores it. There is no interruption, nothing to fill in and nothing to remember.
Because it lives in the browser, it has the limits of a thing that lives in a browser. Clearing site data is a new player. A private window is a new player. A different browser is a different player, until the player links it.
Say so in your UI if progress matters. “Your scores are saved on this browser, and you can add another device” is honest.
One identity, several connections
A durable identity can have several connections at once. Two tabs using the same stored credential
join as separate clients with one playerId.
Model the session separately from the player. Keep what belongs to the human on the player row, and keep what belongs to a connection in its own collection keyed by client id:
// irtio/schema.ts
players: entity({ name: str(32), score: u32 }), // keyed by playerId
sessions: entity({ playerId: str(64), seat: u8 }), // keyed by ctx.clientId // irtio/room.ts
onJoin(state, ctx) {
state.sessions.add(ctx.clientId, { playerId: ctx.playerId, seat: nextSeat(state) });
},
onLeave(state, ctx) {
state.sessions.remove(ctx.clientId);
}, A single clientId on the player row is overwritten by a second connection. Use the session
collection to route replies to each connection.
Accounts, and linking a second device
What the browser holds is a device credential. What it belongs to is an account, and the account is the player. Every device on one account gets the same player id in a given project, so a score posted from a phone and a score posted from a laptop land on the same row.
An account starts with one device and picks up more by a short code the player carries from one screen to the other:
// on the device that already has the account
const { code, expiresInMs } = await identity.linkCode();
show(code); // eight characters, good for ten minutes, usable once
// on the new device
await identity.redeemLinkCode(typedCode);
// this browser is now that account, with its own credential identity here is the Identity object. joinRoom({ identity: true }) builds one for you, or you
can construct one yourself and pass it in when you want to call these before a join.
The code is eight characters from an alphabet with no letters a person misreads, and it is not case sensitive, so a player can read it out loud. Its length is not what keeps it safe. It expires in ten minutes, it dies the first time it is used, and the redeem endpoint is rate limited hard enough that guessing is not a strategy. Show it, do not store it, and let it expire.
Redeeming gives the new device its own credential rather than a copy of the first one. That is what makes the devices independent: losing a phone is one device to revoke, not a reason to start again.
Warn the player before you redeem
Redeeming replaces what this browser was. If the browser already had an account of its own, and this was that account’s only device, that account and everything saved under it are deleted.
The alternative is worse. An account whose last credential was revoked is one nobody can ever sign in to, whose scores and saved games sit there unreachable by the player and undeletable by anyone.
So say so first. Something like:
Signing in with a code will replace the progress saved in this browser with the progress from your other device. The progress here cannot be recovered afterwards.
An account with other devices on it is not deleted. The player just loses this one device from it.
const { account, self, devices } = await identity.devices();
// devices: [{ id, createdAt, lastSeen }, ...]; `self` is the one you are looking at
await identity.revokeDevice(someOtherDeviceId); An account’s last device cannot be revoked. That request is refused with 409 E_LAST_DEVICE,
because it would leave exactly the orphan described above. To remove the last device, delete the
account with identity.deleteAccount().
There is still no password. A player who would rather not carry a code across the room can add an email address or a sign-in provider to the account instead, and reach it from a new device that way. The next section covers it. It renames nobody, because player ids derive from the account and a claimed account keeps its id.
Claiming an account
Claiming is how a player gets onto their account from a device it has never seen, without carrying a code across the room. They give an address, open the link that arrives, and that device is on the account. Nothing is renamed when they do: player ids derive from the account, and a claimed account keeps its id, so every board row and every saved game stays exactly where it was.
There is still no password anywhere in this. An address is a way of reaching an account, not a secret that opens it.
Adding an email address
Two calls. The first asks for a link. Make it only after this device has exchanged an assertion for
the project (which any join does); a device that has never joined the project is refused with 403 E_CLAIM_NOT_PLAYED.
await fetch(`https://irt.io/v1/account/claim/email?project=${project}`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-irt-identity': deviceCredential },
body: JSON.stringify({ email, returnUrl: 'https://mygame.example/account' }),
});
// 200 { ok: true, expiresInMs }, the same whatever the address is
// 403 E_CLAIM_NOT_PLAYED: this device's account has not joined the project
// 429 E_RATE_LIMITED: a claim limit, including the platform's daily ceiling (see below) The 200 is the same whatever the address is. Already claimed by this player, claimed by somebody
else, never seen: one answer, because an answer that varied would tell a stranger holding an address
whether that person plays your game.
The second call finishes it, and it is the page in returnUrl that makes it, with the token from
the link’s fragment:
await fetch(`https://irt.io/v1/account/claim/verify?project=${project}`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-irt-identity': deviceCredential },
body: JSON.stringify({ token }),
});
// { account, email, linked, retired } linked: false means the address was free and is now this account’s. linked: true means the
address already belonged to an account and this device has joined it — from the next join on,
this browser is that player, with that player’s scores.
Where the link is allowed to land
A device credential lives in one origin’s localStorage. A page on irt.io cannot read the
credential your game stored on mygame.example, so it could not finish a claim for it.
That is what returnUrl is for: name the page in your own game that should handle the link, and it
will be the one that opens. Two rules decide whether it is honoured, and both matter:
- It has to be on an origin you have registered for the project (the same list the room join
checks), or the request is refused by name. A project that has registered no origins may name a
localhostURL, which is what local development is. - If the address already belongs to another account, the link first opens a confirmation page on
irt.io. It leads with the origin of your
returnUrl, shows your project’s name as what the game calls itself (with its project id), and says that the device which asked will join the player’s account, with their scores and saved data, and be able to manage their other devices. Only when the player chooses Confirm there does irt.io send them on to your page with the token. If they choose This wasn’t me, the link is deleted. A link that can move a device onto another player’s account must not reach a page the requesting game wrote until the owner of the mailbox has said yes on a page it did not write; without that step, a game’s developer could have the platform mail one of their players a link that hands their account to the developer’s own device.
The second rule is invisible from the outside: the request answers the same way either way. For you, it means a player adding a second device from your game by email sees one extra page on irt.io before your page opens. Your page handles the token exactly as it would without it.
Opening the confirmation page confirms nothing; only the button does, so a mail scanner that fetches
the link cannot confirm for the player. The confirmation lasts as long as the link (20 minutes), and verify still requires the device that asked. A token sent straight to your origin for an address
that was free when you asked will not move your device onto an account that claimed the address in
the meantime: verify refuses it like any other bad link.
The link that reaches your page carries ?project= in the query and the token in the fragment,
whether it came straight from the email or through the confirmation page. Your page needs both: the
project goes on the verify request, and the fragment is where the token is because a fragment is
never sent to a server.
Leave returnUrl out and the link opens irt.io’s own page, which works when the player’s credential
is on irt.io and tells them plainly what to do when it is not.
Email links
The email identifies the destination host and project. Your UI should explain that the player requested a link and must open it on the same device.
What a link is, and what it is not
- It works once and lasts twenty minutes.
- It has to be opened on the device that asked for it. The link alone is not a credential: the page that finishes the claim presents the device credential too, so a forwarded or intercepted link attaches nobody’s device to anything. A link presented by the wrong device is spent on the attempt and refused.
- Every failure is one failure. Expired, already used, never issued, wrong device: one status, one message. The difference between them is exactly what somebody holding a guessed link wants to know.
- Only its hash is stored. A dump of the platform’s database is not a set of working links.
The claim limits
Before any limit applies, the device’s account must have joined the project it names, by exchanging
an assertion for it. Otherwise the request is refused with 403 E_CLAIM_NOT_PLAYED. That refusal is
about the device asking, never about the address.
After that, the route is limited five ways, and the one that matters is per address: three in a burst and six an hour to any one mailbox, no matter how many accounts, machines or projects the requests come from. Being over that limit produces the same answer as being under it, and no mail.
The others answer 429 and say which one they were:
- Per account: three in a burst, ten an hour.
- Per project, in two tiers. A project less than seven days old, or one that has never been deployed, gets ten in a burst and ten an hour. An established project gets sixty in a burst and six hundred an hour.
- Per address of the machine asking: thirty in a burst, sixty an hour, because one address is a classroom as often as it is one browser.
- A platform-wide daily ceiling on claim mail, across every project. Once it is reached, the
request answers
429withretry-afterset to the next UTC midnight.
Adding a sign-in provider
Google and Discord, when the platform has them configured. The shape is three legs, and the reason is that a browser navigating to Google cannot carry a header:
POST /v1/account/oauth/google/startwith the device credential answers a URL.- Send the player there. They come back to your completion page with a token in the fragment.
POST /v1/account/oauth/completewith that token and the device credential applies the link.
The third leg is the interesting one. The provider’s callback does not link anything by itself: it leaves a pending link that only a device on the account that started the flow can claim. A sign-in completed in somebody else’s browser is useless to them.
The semantics match the email: an unlinked provider identity binds to this account, and a known one brings this device onto the account that holds it.
When no provider is configured, all four routes answer E_OAUTH_DISABLED by name rather than
failing somewhere further in.
Player data
Your room receives a project-specific player id. Claiming an account keeps that id stable. The identity assertion does not expose the player’s email, provider, display name, or account id to your game.
When a device joins another account
A device that claims an address or a provider belonging to another account leaves the account it
was on. If that account had no other devices, it is deleted along with everything saved under it,
and the answer says retired: true.
That is the same rule the link code follows and for the same reason: an account whose last credential walked away is one the player can never reach and nobody can delete. Warn the player before you start a claim on a device that has progress on it, the way the link code section above does:
Adding this address will replace the progress saved in this browser with the progress from your other device. The progress here cannot be recovered afterwards.
An account with other devices on it is not deleted; it just loses this one.
Reading and removing a claim
// GET /v1/account?project=… with the device credential
// { account, self, email, providers: [{ provider, displayName, linkedAt }], devices } DELETE /v1/account/email and DELETE /v1/account/providers/:provider remove them. Both are
allowed even when they are the only claim on the account: the account is still reachable from the
devices it already holds, and the answer says so. Refusing would leave a player with an address they
can never take back.
Deleting an account
A player can delete everything:
await identity.deleteAccount(); That removes the account, every device credential on it, any claimed address or provider, and every leaderboard row and player-KV row keyed on that player, in every project they played rather than only the one they asked from. It cannot be undone and irtio does not keep a copy. The browser’s credential is forgotten with it, so the next join is a new player.
As a project owner you can also remove one player’s row from one board, which is the shape a privacy request usually arrives in:
irtio leaderboard delete scores --player irt:9tW2rQ... --yes What your room sees
An irt: player id:
// irtio/room.ts
onJoin(state, ctx) {
// ctx.playerId is something like 'irt:9tW2rQ...', stable for this player, in this project
void ctx.room.kv.get(ctx.playerId, 'progress');
} That id is per project. The same human playing two of your games has two different ids, and the two games cannot tell they are the same person. This is not configurable. An identity that games could compare notes on would be a tracking identifier.
Linking devices does not change that. An account spans devices; a player id still does not span projects. Two games holding assertions for one account see two unrelated hashes, and neither can derive the other.
Within a project it is stable, which is what makes it useful. It is the right key for player storage and it is what leaderboards rank. It is not a key for one connection: see one identity, several connections for why ctx.clientId is
what a per-session row belongs under.
Shared origins
The device credential is stored in localStorage for your page’s origin, and any script on that
origin can read it and write it.
Some hosts serve many games from one origin. A portal such as itch.io’s html-classic.itch.zone,
or the CrazyGames games.crazygames.com iframe, can put your game and other developers’ games on
the same origin. Another game there can read the credential and act as the player: join any
project as them, link its own device, and claim or delete the account. It can also write its own
credential into the same key, so the player ends up playing under an account that game controls.
Choose based on where your game runs:
- On your own domain, or on an
irtio deploy --staticsite,identity: trueis fine as it is. - On a shared origin where progress matters, use your own auth with a JWT (see authentication), or host the game on your own origin.
- If your game is fine with a new player on every page load, keep the credential out of shared
storage by passing an
Identitywithstorage: null:
// main.ts (client)
import { Identity, joinRoom } from '@irtio/client';
const identity = new Identity({ project: 'p_your_project_id', storage: null });
const room = await joinRoom(schema, { identity }); project must be the project your join uses. That is the schema’s project, or the key you pass
to joinRoom if you pass one. If they differ, the room refuses the join.
With storage: null the credential lasts only as long as the page. Every page load mints a new
credential and creates a new account. Minting is rate limited per network address, so many players
behind one network, such as a classroom or a LAN, can hit the limit. To carry progress across a
reload, the player has to redeem a link code or open a claim link again on every load, and
redeeming replaces the fresh account. See warn the player before you redeem.
Credential handling
Room code receives a per-project player ID. The long-lived device credential stays between the browser and the identity service. It is stored in the page’s origin, so any script there can read it. See Shared origins.
To test identity: true from a localhost page, use a deployed project and room endpoint. See testing identity locally.
Using it with a token you already have
identity and token are mutually exclusive. A join asserts one identity, and passing both would
ask the server to pick. If you already run your own auth, keep using your JWT.
The HTTP endpoints
If you are not using @irtio/client, the two calls are:
POST /v1/identity {"project":"<id>"} -> {"identity":"…","id":"pid_…","account":"acc_…"}
POST /v1/identity/assertion {"identity":"…","project":"<id>"} -> {"assertion":"…","playerId":"irt:…"} Mint once and store the result. Exchange before every connect, including reconnects, because an
assertion that outlives its exchange is refused with E_TOKEN_EXPIRED. Both endpoints are rate
limited, and the mint one tightly, because a browser should call it exactly once, ever. See the limits page.
The account routes take the device credential in an x-irt-identity header and a ?project= in the
query, which is used only to account for the rate limit:
POST /v1/account/link-code?project=<id> -> {"code":"ABCD1234","expiresInMs":600000}
POST /v1/account/link?project=<id> {"code":"…"} -> {"identity":"…","id":"pid_…","account":"acc_…"}
GET /v1/account/devices?project=<id> -> {"account":"acc_…","self":"pid_…","devices":[…]}
DELETE /v1/account/devices/<id>?project=<id> -> {"ok":true}
DELETE /v1/account?project=<id> -> 204 Never put the credential in a URL. It is long-lived, and a query string ends up in access logs, Referer headers and browser history.
Every way a link code can be wrong answers the same 401 with the same message: expired, already used, never minted, or mistyped into something that is not a code at all. The difference between those cases is exactly what somebody guessing codes would want to learn.
Related
- Player storage for what
ctx.playerIdkeys. - Leaderboards for what a durable id ranks.