irtio.json
irtio init writes irtio.json at your project root. The following example shows common options:
{
"project": "p_a5da84132e8d6c2e",
"name": "dive",
"static": { "dir": "dist", "label": "dive" },
"client": "src/main.ts",
"queues": { "5v5": { "size": 10 } },
"boards": { "highScore": { "direction": "higher" } },
"origins": ["https://dive.example.com"]
} init writes name, and static when you choose static hosting. Your first irtio deploy adds project. client, queues, boards, origins, audienceRole, and preRelease are optional settings you add by hand.
The rest of a project lives on the server, not in this file: region, JWT signing secrets, deployment
versions, limits. There is no port, no endpoint and no secret in irtio.json, which is why it is
safe to commit.
Properties
All keys are optional in the file. Commands that require a project must resolve one from the file, schema, or command options.
| Property | Type | When omitted |
|---|---|---|
project | string | See project resolution |
name | string | See name |
client | string | No declared browser entry; see client |
static | string or { dir, label? } | No configured static deployment; see static |
queues | Queue map | Keep project queues; see queues |
boards | Board map | Keep project boards; see boards |
origins | string[] | Keep managed origins; see origins |
audienceRole | string or null | Keep the project’s audience setting; see audienceRole |
preRelease | boolean | Keep the project’s migration policy; see preRelease |
Where it is read from
Every command reads ./irtio.json, relative to the directory you ran it in. There is no search up
the tree and no global config file, so run commands from the project root. --room path/to/room.ts changes which room file gets bundled, not where the config is read from.
-c <file> (or --config <file>) reads a different file instead. See a second project file.
| Command | Reads |
|---|---|
dev | project, client |
deploy | project, client, name, queues, boards, origins, audienceRole, preRelease |
deploy --static | project, static, name, queues, boards, origins, audienceRole, preRelease |
migrate create | project |
rollback | project |
logs | project |
rooms, rooms saves, rooms restore | project |
keys jwt-secret, keys jwt-mint | project |
init | project and name, to reuse what is already there |
simulate, login, whoami | nothing |
project
Your project id: the public key that a client presents when it joins, and the project a deploy lands
in. It is p_ followed by 16 hex characters, and irt.io assigns it. init does not write one. Your
first irtio deploy creates the project, gets its id, and writes it into this file as project.
You cannot choose an id: a deploy with an id that is not a project on your account stops rather
than creating one, and the id of a deleted project is never used again.
Resolution order differs by command:
| Command | Order |
|---|---|
deploy | --project, then irtio.json, then the project in defineSchema(...) |
deploy --static, migrate create, rollback, logs, rooms, keys | --project, then irtio.json |
dev | irtio.json, then dev |
With no id from any of those sources, deploy and deploy --static create the project and write
its id here. The other commands that talk to the server refuse by name rather than picking one.
irtio dev is the exception. With no irtio.json at all it runs under the project id dev, which
is also what a browser client falls back to against a localhost endpoint. That pairing is what lets init scaffold a project you can run before you have ever logged in.
The project id is public. It is domain locked by the origin allow list, it is not a credential, and
it belongs in your client bundle and in version control. The credential a project has is its JWT
signing secret, which irtio keys jwt-secret prints once and never writes to disk.
name
The project’s display name: what the dashboard lists it as, and what a deploy registers when it creates the project. It is a label. Nothing resolves against it, two projects may share one, and a rename does not move a deployment, a room, or the project id.
init writes the directory name. --name overrides it:
npx irtio init --name "Dive Co-op" | Command | Order |
|---|---|
deploy, deploy --static | --name, then the project file, then the directory name |
Nothing is asked. On the deploy that creates the project, this is the name it is registered
under. On any deploy after that, a name that no longer matches the registered one is a rename,
applied before anything is uploaded and printed:
renamed project p_a5da84132e8d6c2e: dive -> Dive Co-op The directory fallback renames nothing. A project file with no name keeps whatever name the
project was created with.
You can also create the project first, from Projects, New project on the dashboard, which takes
the name and prints the irtio.json to paste back.
The same id also lives in irtio/schema.ts
joinRoom takes the key from schema.project, or from an explicit key option. The CLI prefers irtio.json. The schema init writes imports irtio.json and reads project from it, so the two
always agree:
import irtio from '../irtio.json' with { type: 'json' };
export const schema = defineSchema(
{ /* ... */ },
{ project: (irtio as { project?: string }).project },
); Your client bundle picks the id up when it is built, so build your page after the first deploy has
written it. A schema from an older init has the id as a string literal instead. If you change the
id in one place and not the other, irtio deploy pushes to one project while your players join a
different one, and the failure shows up as a join refusal rather than a deploy error.
static
What irtio deploy --static uploads. Two shapes:
"static": "dist"
"static": { "dir": "dist", "label": "my-game" } dir is the built client directory. There is no build step in deploy --static, so run your own
build first. label is the site’s subdomain (my-game.irt-serve.com). The label decides the URL
players have, and a deploy that forgets it publishes the next version somewhere else.
Point dir at your build output, not the project directory: everything in it is public. .env* files, .git and node_modules are never uploaded, and other hidden files are skipped unless you
pass --include-dotfiles. See what it skips.
irtio dev also serves dir on its own port when it holds an index.html. Pass --no-serve to
turn that off. See irtio dev.
With static set, irtio deploy ships the site along with the room, room first. --no-static deploys the room alone, and --static deploys the site alone:
npx irtio deploy # room, then site
npx irtio deploy --no-static # room only
npx irtio deploy --static # site only A project with no room file and a static key is a valid project: a page that joins someone else’s
room, or a site that is not a game yet. irtio deploy there ships the site and says so instead of
reporting a missing room file.
--static <dir> and --label <label> win over the file when you pass them. With neither the flag
nor the key, the deploy names both options rather than guessing:
irtio deploy: no directory to upload — pass --static <dir> or add "static" to irtio.json client
An optional path to your client entry, relative to the project root.
It exists for one check: room code must never ship to the browser. Your room file holds server
logic, and in a physics room it holds the exact rules a cheater would like to read. When client is
set, irtio dev and irtio deploy walk that entry’s import graph with esbuild and warn if the
graph reaches the room file.
irtio: WARNING: the client entry src/main.ts imports the room file irtio/room.ts — room code must
never ship to clients; move shared code (e.g. the physics world builder) into its own module both
sides import The walk stays local. Every bare specifier (@irtio/client, react, @dimforge/rapier3d-compat)
is marked external, so it never descends into node_modules and only your own files are considered.
A shared module that both the client and the room import is fine. Only the room entry itself is
flagged.
It is a warning, not a refusal. The check also never fails a build: if the client entry has a syntax error, or the path names nothing, the walk gives up and reports no import, so an unrelated bug in your client cannot block a deploy.
Without client, nothing checks this. It costs one line and it is worth adding, particularly for a
physics room, where the world builder is genuinely shared and the temptation to import the room
alongside it is real.
queues
Optional: this project’s quick-match queues, declared next to the room code that consumes them.
"queues": {
"5v5": { "size": 10 },
"ranked": { "size": 10, "skill": true },
"casual": { "size": 2 }
} Declaring the key makes this file the source of truth: every deploy (room or static) compares
it with the project’s queues and syncs when they differ, printing synced queues from the project file: .... A dashboard edit survives until the next deploy, then
the file wins. An empty object clears the project back to the implicit default queue of two.
Leave the key out to keep managing queues from the dashboard or the projects API; a deploy then
never touches them. The rules are the same either way: at most 32 queues, whole sizes of at least
2, and the server refuses a bad sync with the same message the dashboard would see. irtio.json and
the project PATCH endpoint accept identical queue declarations.
Team queues
A queue entry declares size or teams, never both. Declaring both is refused.
"queues": {
"5v5": { "teams": [ { "size": 5, "count": 2 } ] },
"royale": { "teams": [ { "minSize": 1, "maxSize": 3, "fill": true } ] }
} teams is a list of team shapes: { "size"?, "minSize"?, "maxSize"?, "count"?, "fill"? }. See Teams for what each field does and the limits on team size and
count.
boards
Optional: this project’s leaderboards, declared next to the room code that writes to them.
"boards": {
"highScore": { "direction": "higher" },
"fastestLap": { "direction": "lower", "period": "weekly", "keep": 4 },
"regional": { "direction": "higher", "buckets": true }
} | Property | Type | Default | Meaning |
|---|---|---|---|
direction | 'higher' or 'lower' | Required | Which score ranks first |
period | 'daily', 'weekly', 'monthly', or 'season:<name>' | None | Separate scores by period |
buckets | boolean | false | Separate scores into named buckets |
keep | number | 12 | Number of past periods to retain; see period retention |
Declaring the key makes this file the source of truth: every deploy (room or static) compares it
with the project’s boards and syncs when they differ, printing synced boards from the project file: .... A dashboard edit survives until the next deploy, then the
file wins.
A board you remove from a declared map is deleted on the next deploy, along with the scores in it. An empty object removes every board. If you want to keep a board that is not in the file, leave the key out: a deploy then never touches boards, and you manage them from the dashboard or the projects API instead.
origins
Optional: the origins a browser may connect this project’s rooms from.
"origins": ["https://dive.example.com", "https://staging.dive.example.com"] Each entry is a bare origin: a scheme, a host, and a port if the site uses one. No path and no
trailing slash, because the server compares it with the browser’s Origin header exactly as written. ["*"] allows every origin. localhost is always allowed, so [] means “localhost only” rather than
“nothing can connect”.
Declaring the key makes this file the source of truth: every deploy replaces the project’s allow
list with this one and prints synced allowed origins from the project file: .... Order does not
matter, so reordering the array is not a change. Declaring the key also means a first deploy does not
ask you for a production origin, because the file has already answered.
If this project uses static hosting, include the site’s own origin in the list. deploy --static adds that origin for you, on the <label>.irt-serve.com URL it prints, but the next room deploy
replaces the whole list with the one in this file and removes it again. The hosted page then fails
the origin check until you run deploy --static once more, so a project with a static site normally
declares both its own domain and the irt-serve.com one:
"origins": ["https://dive.example.com", "https://dive.irt-serve.com"] Leave the key out to keep managing origins from the dashboard or the projects API; a deploy then
never touches them, apart from the origin a first deploy asks for and the site URL that deploy --static adds.
audienceRole
Set a role name to enable viewers in relay rooms. Names are 1–32 characters using letters, digits, _, -, ., or :. Each deploy synchronizes the value; null disables the audience and omitting
the key preserves the stored setting. Audience joins require a room-scoped credential.
See audience rooms.
preRelease
Optional: skip migrations while a project’s schema is still moving and no player state is worth keeping.
"preRelease": true Declaring the key makes this file the source of truth, synced on every deploy the same way queues is: false turns it back off, and leaving the key out leaves the stored setting alone.
With it on, a breaking schema change deploys without --allow-breaking and without a migration
file. Instead of migrating, a breaking deploy defaults to --strategy migrate, which closes every
room on an older version — clients are disconnected and reconnect into a fresh room on the new
version — and a hibernated room’s snapshot is discarded at wake rather than migrated, so the room
starts cold. See schema changes and migrations.
Turn it off once players have progress worth keeping: the state it discards is real, including saves for the rooms it restarts.
Limits that live on the project
Limits are not in irtio.json. They belong to the project and are read at boot, so changing one
takes effect the next time your project starts rather than at your next build.
connectionsPerIpPerMin
New connections allowed from a single client address, per minute. The default is 120, refilling at 2 per second, and it is checked as the socket is accepted, before any frames. It stops one address opening sockets faster than a game ever would, and 120 is generous for real players: a browser tab opens one. The address is the player’s own, not the platform’s router in front of your server, so one address using up its allowance never refuses anybody else. An IPv6 address is counted by its /64, the block one home connection is given, so a client cannot dodge the limit by cycling through the addresses it owns.
It is not generous for a load run. 200 bots from one machine share one address, so a run of more
than about 120 clients from a single host starts seeing E_RATE_LIMITED with the message too many connections from this address. That is the limit working, not a bug, and raising it for
the run is the intended answer.
Set it on the project:
curl -X PATCH https://irt.io/v1/projects/$PROJECT
-H "Authorization: Bearer $IRTIO_TOKEN"
-H 'content-type: application/json'
-d '{"name":"dive","connectionsPerIpPerMin":4000}' name is required by the route and must be the project’s current name. Send "connectionsPerIpPerMin": null to go back to the default. The value reaches your project as the
environment variable IRT_CONNECTIONS_PER_IP_PER_MIN at its next start, and the first start that
carries a value the project did not have before is a cold boot, because the environment is part of
what the saved image is keyed on. Rooms come back from their own snapshots as usual. Only the boot
is slower.
Locally there is nothing to set: irtio dev and startDev({ limits: { connectionsPerIpPerMin } }) take the limit in code, and a local run never crosses a real network anyway.
If you run your own server, IRT_CONNECTIONS_PER_IP_PER_MIN is the same knob with no project record
in the way. A value of 0 is refused at boot rather than silently refusing every connection.
vmMemMib
How much memory your project’s server gets, in MiB. Leave it unset and irtio sizes it: 128 MiB normally, and 256 MiB for a project whose schema declares physics, because a physics room needs space for the physics engine and for compiling it.
Set it when automatic sizing is not enough. The signal is a project that dies rather than a room that misbehaves: rooms drop, the project restarts, and its log ends without an error because the process was killed rather than throwing. A busy physics room with a few hundred connections is the case this was added for.
curl -X PATCH https://irt.io/v1/projects/$PROJECT
-H "Authorization: Bearer $IRTIO_TOKEN"
-H 'content-type: application/json'
-d '{"name":"arena","vmMemMib":1024}' name is required by the route and must be the project’s current name, the same as above. The
accepted range is 128 to 4096 MiB, whole numbers only. Send "vmMemMib": null to go back to
automatic sizing. When it is set it overrides automatic sizing outright, including the physics size,
so 1024 means 1024 for a physics project too.
It applies the next time your project starts, not immediately. A project running right now keeps the size it booted with, because a server cannot be resized underneath a live room. Changing the value also invalidates that project’s saved images, so the next start is a cold boot rather than a resume: a memory snapshot cannot be restored into a machine of a different size. Rooms come back from their own snapshots as usual. Only that project is affected, and only once per change.
Validation
| Case | What happens |
|---|---|
| File absent | Fine. dev uses dev; other commands fall back to their next source |
| Not valid JSON | Hard error, naming the file: irtio deploy: /path/irtio.json is not valid JSON |
project is not a non-empty string | dev and deploy refuse by name. logs, rooms, rollback, keys and deploy --static treat it as absent and fall through to their “no project id” error |
name is not a non-empty string | Every command that reads the file refuses by name |
static is not a directory string or an object with a non-empty dir | deploy --static refuses by name |
client is not a non-empty string | dev and deploy refuse by name |
boards is not an object, or a board has no direction of higher or lower | Every command that reads the file refuses by name, naming the board |
origins is not an array of distinct non-empty strings | Every command that reads the file refuses by name |
An origins entry is not a bare origin or * | deploy refuses with the server’s message, naming the entry |
preRelease is not true or false | Every command that reads the file refuses by name |
-c names a file that does not exist | Hard error: irtio deploy: no config file at /path/prod.irtio.json. A file you named explicitly is never silently swapped for irtio.json |
| Unknown keys | Ignored. Nothing validates the file as a whole, so a typo like "projet" reads as an absent project |
A second project file
-c <file> (or --config <file>) points a command at a different project file. Every command that
reads one takes it: dev, deploy, deploy --static, migrate create, rollback, logs, rooms and keys.
That is what makes one checkout deploy to more than one project:
// test.irtio.json
{ "project": "p_11112222aaaabbbb", "name": "dive (test)", "static": { "dir": "dist", "label": "dive-test" } } // prod.irtio.json
{ "project": "p_71530c9707fe43ec", "name": "dive", "static": { "dir": "dist", "label": "dive" } } npx irtio deploy -c test.irtio.json
npx irtio deploy --static -c test.irtio.json
npx irtio logs -c prod.irtio.json --follow The path is relative to the directory you run the command in, so -c envs/prod.json works too. The
name *.irtio.json is a convention, not a requirement.
A file named with -c must exist. Falling back to irtio.json when prod.irtio.json is missing
would deploy the wrong project on a typo, so that is a hard error instead.
init has no -c: it always writes irtio.json. Copy that file to make the second one, and change
the id and name in the copy. A second project needs its own project id, and there is no provisioning
step to get one: it is just a public, unique string, so pick your own. Use the same p_ plus 16 hex
characters init generates, and the first deploy registers the project under it. The dashboard
can mint one for you too, but nothing requires it.
The client bundle is unaffected. joinRoom reads schema.project (or an explicit key), and
nothing in the browser reads a project file, so pointing a build at the test project means passing
that key to joinRoom yourself.
Re-running init
init never overwrites an existing file, irtio.json included, and it lists what it kept:
kept irtio.json (already there — nothing was overwritten) Before writing anything it reads the existing irtio.json and reuses the project and name it
finds, so re-running init in a project that has already been deployed cannot orphan that
deployment, and cannot rename it either. --name does not override an
existing file: nothing is overwritten, so edit the file to change the name, and the next deploy
applies it.
A malformed irtio.json is skipped like any other existing file. init does not repair it, and a
newly scaffolded irtio/schema.ts reads its id from that file, so fix the JSON first if you are
re-scaffolding on top of a broken file.