Error catalogue

Every failure irtio names has a stable E_* code. The 47 protocol codes also have stable numeric codes that will never be renumbered, so a client and a server built months apart still agree on what went wrong.

Where you see an error depends on which side raised it:

  • Fatal server errors reject the joinRoom or joinRelay promise if the room was never joined, or close the connection and fire room.on('error', ...) with fatal: true.
  • Non-fatal server errors fire room.on('error', ...) with fatal: false and leave you connected.
  • RPC errors reject the promise room.call.<name>(...) returned.
  • Room-side errors reject a promise inside your room file and never reach a client.
  • Client-local errors never touch the wire at all.

Branching on a code

There is no error class to import. Connection errors arrive as a plain object on the error event, with exactly three fields:

interface RoomError {
  readonly code: string;    // 'E_ROOM_FULL', or 'E_INTERNAL' for a local failure
  readonly message: string;
  readonly fatal: boolean;  // fatal errors close the room
}

So the connection branch is a switch on e.code:

// main.ts
room.on('error', (e) => {
  switch (e.code) {
    case 'E_STARTING':
      showSpinner('waking the server');
      break;
    case 'E_ROOM_FULL':
      showMessage('this room is full');
      break;
    case 'E_SLOW_CONSUMER':
      // the client reconnects on its own; nothing to do but say so
      showMessage('reconnecting');
      break;
    default:
      if (e.fatal) showDisconnected(e.message);
  }
});

RPC failures are different, and the difference matters. room.call.<name>() rejects with a plain Error that carries no code property; read its message. When your handler refused the call with ctx.deny(reason), the message is deny: <reason>, which is the normal way a room says no. When irtio refused the call, or your handler threw, the message starts with the code and a colon:

try {
  await room.call.play({ card });
} catch (err) {
  const message = err instanceof Error ? err.message : String(err);
  if (message.startsWith('deny: ')) {
    showToast(message.slice('deny: '.length));   // your room's own reason: 'not your turn'
  } else if (message.startsWith('E_RPC_BAD_PARAMS')) {
    console.error('client and server schemas disagree', message);
  } else {
    console.error(message);
  }
}

A fatal error that arrives before the join completes rejects joinRoom with an Error whose message is the code, a colon, and the server’s message, so the same startsWith check works there.

Connection and identity

CodeNumberFatalWhat it means and what to do
E_PROTOCOL_VERSION1yesYour @irtio/client and the deployed server disagree about the wire protocol. Update the client, or redeploy the room, so both are on the same major version
E_ORIGIN2yesThe page’s origin is not in the project’s origin list. Register it. localhost is always allowed, so this only bites on a deployed page, usually the first time you open a preview URL
E_AUTH3yesThe project key was rejected, or no HELLO arrived in time. Check that project in irtio/schema.ts matches a project that exists on the control plane you are pointing at. On localhost with no irtio init, the client uses the key dev, which only irtio dev accepts
E_SCHEMA_MISMATCH4yesThe schema your client was built with is not the schema the room is running. The hash covers every field, type, order, role and RPC signature. Rebuild the client against the schema you deployed, or deploy the schema your client has. If you ship the page with irtio deploy (--static, or static in irtio.json), end the client build with irtio stamp so the deploy refuses a stale page instead of putting it live
E_RESUME_EXPIRED8noYour reconnection window (reconnectGraceMs, 30 seconds by default) closed while you were away. The client rejoins fresh on its own. room.me changes, so re-read anything you cached from it. Raise reconnectGraceMs if your players routinely background the tab

E_SCHEMA_MISMATCH is the error that catches “I changed a field and only redeployed one side”, which is exactly what it is for.

Tokens (JWT auth)

Raised only for joins that present a token. A join with no token can never hit these. See Authentication for the claims and setup these codes are checking.

CodeNumberFatalWhat it means and what to do
E_TOKEN_EXPIRED21yesThe token’s exp is in the past (or its iat is in the future), beyond the 60-second clock leeway. Also what a reconnect gets when a session outlives its token. Mint a fresh token on your server, and pass token as a function so every reconnect fetches a new one
E_TOKEN_INVALID22yesThe signature does not verify against any of the issuer’s live secrets. Check the server is signing with the secret irtio keys jwt-secret printed, and that a rotation was finished (irtio keys jwt-secret retire) rather than left half-done
E_TOKEN_WRONG_PROJECT23yesThe token’s aud claim is not this project’s id. Set aud to the project id in irtio.json
E_TOKEN_WRONG_ROOM24yesThe token carries a roomId claim and the join is for a different room (creating a new room counts). Mint the token for the room actually being joined, or omit roomId to make it good for any room in the project
E_TOKEN_MALFORMED25yesNot a JWT, or missing a required claim — iss, aud, sub and exp are all required. Sign with the documented recipe; irtio keys jwt-mint produces a known-good token to compare against
E_TOKEN_BAD_ISSUER26yesThe token’s iss claim names an issuer this project has no signing secret for. irtio keys jwt-secret --issuer <label> mints for a named issuer; the default label is main
E_TOKEN_BAD_ALG27yesThe token’s header asks for an algorithm the issuer’s configuration does not allow. The verifier trusts its config, never the token’s header, so alg: "none" dies here by design. Sign with HS256, the one algorithm accepted this milestone

Two of these are worth reading as configuration problems rather than attacks: E_TOKEN_BAD_ISSUER right after minting usually means the tenant has not been placed again yet, and E_AUTH on a token join means the project has no secret at all (irtio keys jwt-secret is the fix, and the error says so).

Rooms

CodeNumberFatalWhat it means and what to do
E_ROOM_NOT_FOUND5yesNo such room, or a room id with characters that cannot be one (ids are 1 to 32 of A-Z a-z 0-9 - _). Usually a truncated or hand-edited share link. Also sent when the join set mustExist and the room does not exist, or mustExist was set and no room id was given. Do not construct ids yourself: let joinRoom create one and read room.link
E_ROOM_FULL6yesThe room is at maxClients, 64 by default. Raise it in irtio/room.ts, or send the player to a new room by joining with no ?room=
E_ROOM_CLOSED7yesThe room called room.close(), was reset by a code reload, or could not recover from repeated failures. Join a new room. If you did not call close(), check irtio logs
E_KICKED16yesYour room called room.kick(clientId, reason). The reason is yours, so make it useful
E_ADMISSION45yesThe room refused the join before it ever had a seat: either your onAdmit called ctx.deny(reason), or the joining subject is inside a room.ban cooldown. Distinct from E_KICKED, which ends a session that was already in the room. The message is the reason your room gave
E_EJECTED46yesA relay participant ejected this peer. Its verified credential subject is excluded until the room ends. An anonymous viewer can obtain a fresh credential; see moderation
E_TYPE_AT_CAPACITY30yesThis room type is already running as many rooms as it declared it would. The tenant’s VM memory was sized from the declared per-room heap times that number, so the next room is one the machine was never built to hold. The message names the type, the limit and the field to raise
E_AUDIENCE_FULL43yesThe room’s audience is full. Separate from E_ROOM_FULL on purpose: the audience cap and the participant cap are different numbers over different populations, so a viewer refused here is not being told the streamer’s own seats are taken
E_AUDIENCE_SEND44noAn audience peer sent chat, addressed 'all', or exceeded its input budget. Viewers may address a client id or role. The message is refused and the connection stays open

A room that throws in tick three times in a row restarts from its last snapshot. A room that restarts too many times in a minute is closed with E_INTERNAL.

RPCs

CodeNumberWhat it means and what to do
E_RPC_UNKNOWN11The room has no implementation for that RPC name or id. Normally impossible to compile, so it means client and server were built from different rpc.ts files. Rebuild both
E_RPC_REJECTED12You called a client-direction RPC from the client. Client-direction RPCs are what the server calls on you; declare it with server(...) if the client should call it
E_RPC_TIMEOUT13No reply in time: 10 seconds for a client’s call to the server, 5 seconds for room.call(clientId) in the other direction. A server-to-client call rejects on disconnect, so a timeout usually means the client’s implementation never returned. Returning a promise is fine; never resolving it is not
E_RPC_BAD_PARAMS14The parameters did not match the declared shape: a missing field, a wrong type, a string or list over its declared maximum. Types normally prevent this, so it means skew or a hand-built call

To refuse an action, call ctx.deny(reason) in the handler. The caller’s promise rejects with the message deny: <reason>, and the room carries on:

// irtio/room.ts
rpc: {
  start(state, _params, ctx) {
    if (ctx.role !== 'host') ctx.deny('host only');
    state.match.phase = 'playing';
  },
}

The client catches an Error whose message is deny: host only. A handler that throws instead is treated as a bug: the caller gets E_INTERNAL: rpc start, and the thrown message goes only to irtio logs. See RPCs.

The client-side timeout message is rpc <name> timed out, with no code prefix. A call attempted while the socket is not open fails fast with its own message rather than waiting out the full timeout.

Writes and ownership

CodeNumberWhat it means and what to do
E_NOT_OWNER10Someone tried to write an instance they do not own. Normally you never see this — writing a non-owned instance is a compile error and a warn-once no-op on the client — so it shows up when hand-writing frames, or after ownership changed underneath you. await room.requestOwnership(entity, id) first, and re-read the instance after a grant: object identity is not preserved across an ownership change
E_WRITE_REJECTED15Your room’s validate refused the write outright, or the value did not fit its declared type (a str(24) over 24 UTF-8 bytes, a number outside a u8). If this is your own player moving legitimately, validate is too strict or the field too narrow

Note the difference from a correction: a validate handler that returns prev, or a clamped object, answers with a CORRECT carrying the server’s current values instead of either of these errors, and you see it on the client as room.on('correct', ...). See ownership.

Load and lifecycle

CodeNumberFatalWhat it means and what to do
E_RATE_LIMITED9sometimesTwo unrelated limits share this code, so read the message, not just the code. rate limited is too many frames from one connection or project key (240 frames/s per connection, burst 2×; 600 HELLOs per client address per minute, and 6,000 per minute for the whole project): batch your writes instead of forcing them. Owned writes are already batched once per animation frame, so calling room.flush() in a loop defeats that, and an RPC per input event should be coalesced. too many connections from this address is a separate per-address connection cap (an IPv6 address counts by its /64), checked as the socket is accepted, before any frames: a token bucket, default connectionsPerIpPerMin: 120 (120 tokens, refilled 2/s), so a burst of more than 120 new connections from one address is refused. This one is per-project configuration: PATCH /v1/projects/:id with connectionsPerIpPerMin, which reaches the tenant as IRT_CONNECTIONS_PER_IP_PER_MIN at its next start (see irtio.json). A load run of more than about 120 clients from one machine needs it raised for the run, or the clients spread across addresses. Locally, startDev({ limits: { connectionsPerIpPerMin: … } }) sets it in code
E_SLOW_CONSUMER20yesThe server dropped a client that was not draining its stream, rather than growing an unbounded queue for it. The client reconnects for a fresh snapshot on its own. The join snapshot does not count against the budget until it has been sent, so large room state alone does not cause this. If it recurs, the room produces more per tick than the connection can carry: lower tickRate, narrow field types, or use role visibility so each client receives less
E_STARTING19noNot really an error. Your project’s server was asleep and is waking. The client surfaces it as room.status === 'starting' and keeps waiting. Show a spinner; the lobby element’s connection dot already does
E_TENANT_WAKING47yesYour project’s machine was not up when this join arrived: it is booting behind a fresh deploy, it is being restarted, it is at its memory limit and is putting idle rooms to sleep to make room, or the address the router had cached named a machine that had just gone away. Nothing is broken and the join usually succeeds seconds later, so @irtio/client treats it as retriable — it keeps joinRoom() pending, shows room.status === 'starting', and re-dials three times (2s, 4s, 8s) before failing for real. Distinct from E_STARTING, which the router sends on a connection it is holding open for you; this one ends the attempt and asks you back. Also sent to a mustExist join to a relay room while the relay store cannot be read, so a store outage is never reported as a room that does not exist. A client older than this code sees an unknown fatal error and stops, which is what every version did before it existed
E_PLACEMENT33yesYour project’s region has no server with room for another machine. The message names the region. The socket closes with code 1013 (try again later) and the client does not retry this join on its own, because nothing a player does can fix it: the operator adds a server to the region or raises a server’s VM cap. Until then, joins for a project whose machine is already up keep working; only projects that need a machine started are refused
E_USAGE_CAP28yesYour project reached a usage cap for the current billing period, and this join is new work. On the free tier a cap is a hard wall: new joins, new rooms and new writes are refused, while players already connected keep playing to the end of their session. Read the message for which meter it was. Add a card on the billing page to turn the wall into an alert, wait for the period to reset on the 1st, or raise the ceiling if you set one yourself
E_ASSERTION_UNVERIFIABLE29yesYou joined with identity: true, and the server holding your room has not been handed the platform verification key yet. This is a delivery gap, not a bad credential: keys reach a tenant the moment its machine comes up, so the fix is to retry in a second or two. It is a distinct code from E_TOKEN_INVALID precisely so you can tell “your token is wrong” from “this server is not ready to check it”. If it persists past a few seconds, the box is not talking to the control plane, which is ours to fix
E_ROOM_DELETED34yesSomebody with a rooms API credential deleted this room, and its stored state is gone. Not a kick and not a normal close: the room you were in no longer exists, so reconnecting would start an empty one rather than rejoining. The client stops for good rather than auto-resuming, which is what keeps a deleted room deleted instead of having the players who were in it immediately recreate it. The socket closes with code 4291. If this was not you, someone on your team ran irtio rooms delete --force or a script holding one of your project’s API keys did; irtio logs --room <id> names which
E_IDLE_TIMEOUT35noThe box dropped this client because the socket stopped answering. The server pings every 20 seconds and drops a session that has missed two pongs, so about a minute of silence ends it. Nothing was lost and the room is still there: reconnect. It has its own code so that an idle drop is distinguishable from a lost network or a crashed box, which a bare 1006 close is not. The socket closes with code 4408
E_EGRESS_WALL31yesThe project has used its egress allowance for the period and the box stopped forwarding. Distinct from E_USAGE_CAP, which is the polite refusal of new work at a cap evaluated up to a minute ago; this is the box stopping traffic locally the moment its leased budget ran out. Add a card, or wait for the period to reset. The socket closes with code 4290
E_TIER_LIMIT32yesA free-tier shape limit: the relay client cap, or the concurrent awake-rooms cap. The message names which limit and which tier. Adding a card raises it
E_ROOM_MOVED41noThe room was placed on another machine, and this one stood it down. Routine on a healthy platform and nothing is wrong: look the room up again and reconnect, which lands you on the successor. The successor wakes from the room’s last save; changes since that save can be lost. The socket closes with code 4292
E_ROOM_UNSAVABLE42yesThe machine holding this room can reach neither the control plane nor storage, so it stopped the room rather than keep taking input it could not save. Final: the client does not reconnect, because retrying against the one machine that has just proven it can reach nothing produces a reconnect storm and no room. Look the room up again later. The socket closes with code 4293

| E_STATE_CAP | 36 | no | A relay room with state is at its 512 KB state cap, and this write would have grown it. The write was refused; nothing was dropped, evicted or truncated, every other client is unaffected, and the room keeps serving. Read the room’s state size before you reach it: state that is the current situation fits the cap, and state that is a world or a history does not. A move log, a chat history or a growing canvas belongs in storage rather than in synced state; a world that streams by interest needs a room file | | E_RECORD_CAP | 37 | no | The collection named in the message is at its 1024-record cap, so the add was refused. Remove records you no longer need, or deploy a room file if the game genuinely holds more than a thousand of something | | E_WRITE_RATE | 38 | no | You sent more than 35 writes in a second on a relay room with state. The extra writes were not applied and the connection stayed open. One write per tick plus a buffer is the budget; batch your changes instead of writing per input event, and let room.flush() run once per frame rather than in a loop |

Faults

CodeNumberFatalWhat it means and what to do
E_INTERNAL17sometimesSomething inside irtio failed. Deliberately vague on the wire; the detail is in irtio logs at the same moment. An RPC handler that throws reaches its caller as E_INTERNAL: rpc <name>. The client also uses this code locally for a frame it could not decode or apply, always non-fatal, because the stream may recover
E_BAD_FRAME18yesA frame that could not be decoded, or arrived out of order (a write before the session was joined). You should never see this from @irtio/client. It means a hand-written client (for example a HELLO whose name or role is over 32 UTF-8 bytes), a proxy mangling binary frames, or genuine corruption

Player storage

These never reach a client. They reject the promise room.kv.get, set or delete returned, inside your room code, and the room keeps running. A limit is something to handle, not something to fall over on. They have no numeric code because nothing about them crosses the client socket.

CodeWhat it means and what to do
E_KV_VALUE_TOO_LARGEA value is over 16 KiB of UTF-8. Store less, or store a reference (an object key, an id) instead of the thing itself
E_KV_TOO_MANY_KEYSOne player may hold 128 distinct keys per project. Consolidate into one JSON value, or delete what you no longer read. Overwriting a key the player already has is never refused by this limit, so a player at the cap can still shrink their own data
E_KV_BAD_KEYThe key is empty, over 256 bytes, contains control characters, or is exactly . or ... Those two are path segments to every URL parser, so they cannot travel as a key. Dots inside a longer key (../save, a..b), / and @ are legal, because a key is never used as a storage path
E_KV_BAD_PLAYERSame rules, for the player id, including a player id of exactly . or ... Usually an empty ctx.playerId reaching room.kv, which means the room called it before a join
E_KV_PROJECT_FULLThe whole project hit its row cap of one million. This is a conversation rather than a code change
E_KV_FORBIDDENA request tried to reach another project’s rows. Unreachable in normal operation, since the project comes from the tenant’s own credential and never from the request. Seeing it means something between the room and the gateway is misconfigured. Also answered to a write for a player who has deleted their account: nothing may be stored under their id again
E_USAGE_CAPThe project is at its storage cap, so this write was refused (HTTP 507 from the storage gateway). Nothing is deleted and reads still serve, so a room can read what it saved and room.kv.delete still works. Delete some saved data, raise the ceiling, or add a card
E_RATE_LIMITEDThe project as a whole went over its player-storage rate: 12,000 reads a minute with a burst of 3,000, or 6,000 writes and deletes a minute with a burst of 2,000. Counted across every room of the project, so a busy game that saves on every change can reach it. Write back on a timer or at match end instead, and wait before retrying, since the allowance refills over a minute. See player storage
E_KV_UNAVAILABLEEither the control plane could not be reached, or this tenant has no player storage behind it at all. irtio dev is the second case, since there is no control plane there. Expect it locally and handle the rejection; in production it is an outage, and retrying later is reasonable
E_HOST_TIMEOUTThe host never answered a room.save() or room.kv call at all, within 10 seconds. This one is ours, not yours. Seeing it instead of E_KV_UNAVAILABLE means the failure was below the layer that names its own errors

Leaderboards

These never reach a client either. They reject the promise room.leaderboard.submit returned, inside your room code, and the room keeps running. There is no client-side submit at all, so there is no client-side failure to handle.

CodeWhat it means and what to do
E_LB_NOT_IN_ROOMYou submitted a score for a player who is not a client of this room. Pass ctx.playerId, not an id you built yourself. This check runs outside your tenant, so it is a real boundary rather than a lint: it is what makes a board server-authoritative rather than room-authoritative
E_LB_BAD_SCOREScores are whole numbers, in the safe-integer range. A float, a NaN, or a string is refused. If you are tracking a time, submit milliseconds and make the board lower
E_LB_BAD_BOARDA board name is 1 to 64 characters of a-z 0-9 . _ -, starting with a letter or digit. It is a public URL segment, which is why the rules are tight
E_LB_BAD_PLAYERThe player id is empty, over 256 bytes, or contains control characters — the same rules player storage applies. Also answered to a submit for a player who has deleted their account
E_LB_PROJECT_FULLThe project hit its row cap of one million board rows. A conversation rather than a code change
E_LB_UNAVAILABLEEither the control plane could not be reached, or this tenant has no leaderboards behind it at all. irtio dev is the second case, since there is no control plane there. Expect it locally and handle the rejection; in production it is an outage, and a lost score is better than a wedged room, which is why the call rejects rather than retrying forever
E_LB_BAD_CURSORThe ?cursor= you passed was not made for this board, direction, period and bucket. A cursor names a position in one ranking, so one from last week or from another cohort is refused rather than paged. Start again from the first page
E_LB_BAD_PERIODThe ?period= you passed is not shaped like a period key. The four shapes are a day (2026-09-03), an ISO week (2026-W36), a month (2026-09) and a season name
E_LB_BAD_BUCKETA bucket name is 1 to 64 characters of a-z 0-9 . _ -, starting with a letter or digit. That is the rule board names follow, and for the same reason: it is a public parameter and it lands in a primary key
E_LB_BUCKET_REQUIREDThis board declares buckets: true, so a submit has to name one and a read has to pass ?bucket=. There is deliberately no merged read across buckets: keep an unbucketed board beside the bucketed one if you want both views
E_LB_NO_BUCKETSYou named a bucket on a board that declares none. Refused rather than ignored, because a bucket that was quietly dropped would put a cohort’s scores on the wrong ranking. Set buckets: true on the board, or leave the bucket out
E_CHAT_TOO_LONGOne chat line is at most 512 bytes of UTF-8. The message names the cap and the size you sent. Refused whole and never trimmed: a trimmed line puts words in a player’s mouth. The connection stays open
E_CHAT_RATEYou are sending chat faster than one line a second (five in a burst). The line is not sent, the connection stays open, and you are told once rather than once per refused line

Quick-match and identity (HTTP)

These come back from the control plane over HTTPS, before any socket exists, so they arrive as a JSON body with a code and a message rather than on the error event.

CodeStatusWhat it means and what to do
E_NO_MATCH200Not an HTTP error at all: POST /match answered {"status":"timeout"} and the client SDK turned it into this. The queue did not fill before the deadline. The body says how many were waiting, which is the useful part: 1 of 2 means nobody else came. Queue again, or offer a room code instead
E_QUEUE_UNKNOWN404No queue by that name on this project. A project that has configured no queues has exactly one, default, of two players; once it configures any, default is no longer implicit. Set them with PATCH /v1/projects/:id
E_MATCH_DUPLICATE409This identity queued again while an earlier request was still waiting, so the earlier one was retired to make room for the newer one. The usual cause is a page reload. Nothing is wrong; the surviving request is the one to wait on
E_IDENTITY_INVALID401The identity token presented is not one this control plane knows. Deliberately not distinguished from expired or revoked. Mint a new one; the SDK does this for you once, automatically
E_RATE_LIMITED429Too many requests to a public control-plane surface. The message says whether it was your address or the project as a whole, and retry-after says how long to wait. The limits page has the numbers
E_PARTY_UNKNOWN404The party code is not valid. The same answer for a code that never existed, one that has expired, one from another game, and a live one you are not a member of, so a code cannot be guessed at by comparing the answers. Ask for a new code
E_PARTY_FULL409The party already holds everyone it was made for. You reach this only holding a live code, so it names the reason. Make a bigger party, or wait for someone to leave
E_PARTY_TOO_BIG400The party has more members than the queue holds. The message names both numbers. Pick a bigger queue, or split the party
E_PARTY_BAD_SIZE400A party size has to be a whole number from 2 to 64, the largest queue a project can declare
E_PRESENCE_TOO_MANY400A presence query names at most 64 player ids. Refused rather than answered short, so a partial answer is never mistaken for the whole one. Split it and ask twice
E_PRESENCE_NO_SUBJECTS400A presence query has to name at least one player id in subjects
E_PRESENCE_BAD_WAIT400wait has to be a number of milliseconds. A wait longer than the maximum is clamped rather than refused; this is for a value that is not a number at all
E_PRESENCE_TOO_MANY_WATCHES429This address already has four presence watches open. One watch covers 64 ids, so one is usually enough. Stop a watch before starting another
E_PRESENCE_WATCHES_BUSY503The control plane is holding every presence watch it will hold, or this project is holding its share of them (64). Not your doing: read without wait, which costs one query, and try a watch again shortly
E_LAST_DEVICE409identity.revokeDevice() (DELETE /v1/account/devices/:id) named the account’s only device. Revoking it would leave an account nobody can reach or delete. To remove the last device, delete the account with identity.deleteAccount()

Client-local

E_CONNECT_FAILED is not a protocol code. The connection failed or closed before the room was joined, so the server never got to name a reason. joinRoom and joinRelay reject with a message in one of these two shapes:

E_CONNECT_FAILED: cannot reach <url>: <reason> (the room was never joined)
E_CONNECT_FAILED: <url> closed the connection before the room was joined (code 1006)

Check the URL in the message. If it names your deployment, the tenant could not start, and irtio logs will say why. If it is a localhost URL, either irtio dev is not running, or you are in Node and expected production, in which case set IRT_URL.

A client that resolved its endpoint from the localhost default (step 3 of endpoint resolution) does not fail on 7070 alone: it probes 7171, 7272, 7373 and 7474 as well, because irtio dev binds those in turn when it cannot take 7070. The message names every port tried:

E_CONNECT_FAILED: cannot reach ws://localhost:7474: ... — no irtio dev server answered on any of
7070, 7171, 7272, 7373, 7474.

irtio dev prints the port it bound. Pass it as the url option, set window.IRT_URL = 'ws://localhost:<port>' before your bundle runs, or start the dev server with irtio dev --port 7070 to pin the port and fail loudly when it is unavailable.

A close before the join deliberately does not trigger reconnection. A join that never succeeded should fail rather than retry forever.

Deploy

MessageWhat it means and what to do
not logged indeploy, logs, rooms, whoami and migrate create need credentials. Run irtio login
E_BREAKING_SCHEMA / deploy refused: N breaking changesYour schema changed in a way existing snapshots cannot be read under. Every change is listed with its reason. Run irtio migrate create <name>, write the transform, then irtio deploy --allow-breaking. Additive changes (a new field with .default(...) or .opt) need none of this
--allow-breaking needs a migration for v<n>You passed --allow-breaking with no irtio/migrations/<n>_*.ts. Deploy only attaches the file numbered for the version it is creating. If irtio migrate create guessed a different number (it does when it cannot reach the control plane, and says so), rename the file; the refusal names any migration file no deployment has used
irtio dev: no room file foundNo --room, and none of irtio/room.ts, irtio/room.js, room.ts exists. Run irtio init, or pass --room path/to/room.ts
<path>/irtio.json is not valid JSONThe file did not parse. See the irtio.json reference for its keys
no config file at <path>-c/--config named a file that is not there. It is never silently swapped for irtio.json, because that would deploy the wrong project on a typo
E_USAGE_CAPThe project is at its storage cap, so the deploy’s bundle or static upload was refused (HTTP 507). Existing deployments keep serving and a rollback still works, because neither writes anything new. irtio usage shows where the project stands; delete unused saves, raise the ceiling, or add a card
no directory to upload — pass --static <dir> or add "static" to irtio.jsondeploy --static had no directory from either source. See static
E_CONFLICT / a static activate for this project is already runningAnother irtio deploy --static for the same project was putting its site live at the same moment, usually two CI jobs or two terminals (HTTP 409). This deploy uploaded its files but did not put them live. Wait for the other deploy to finish, then run yours again
HOLE bot <n> cheated and drew 0 correctionsNot a failure, and an important warning. irtio simulate --cheat (or --cheat-bot <n>) sent deliberately illegal writes and your room took all of them. Add rules to validate

See deploying and simulated players.

WebSocket close codes

A few refusals also get their own close code, so a client can act on onclose before it has parsed a frame. Everything else closes with 1008 (policy) or 1011 (internal error).

Close codeErrorReconnect?
1013E_PLACEMENTNo. The region has no server with room for another machine, and nothing the client does changes that
4290E_EGRESS_WALLNo. The project is out of data-out budget for the period
4291E_ROOM_DELETEDNo. Reconnecting would create an empty room rather than rejoin the deleted one
4292E_ROOM_MOVEDYes. Look the room up again; the lookup lands you on the machine now serving it
4293E_ROOM_UNSAVABLENo. The machine can reach neither irtio nor storage, so retrying against it produces nothing
4408E_IDLE_TIMEOUTYes. The socket stopped answering keepalives; nothing was lost