Web games
Most Rally games are built in Rally Studio with Rally’s engine. A web game is a game you made for the browser with your own engine, framework and tools. It brings its own client (the HTML, JavaScript and assets that players’ browsers load) and its own server program. Rally hosts both: it serves your client, starts your server as players arrive, tells your game who each player is, and gives the game a page, a play link and chat.
You publish web games from your computer with the rally command-line tool.
Web games and Studio games
Section titled “Web games and Studio games”| Studio game | Web game | |
|---|---|---|
| Built with | Rally Studio and Rally’s game API | Your own engine and tools |
| Code | TypeScript scripts in the game’s tree | Your client build and your server program |
| Published with | Publish in Studio | rally publish |
| Who can publish | Anyone with a Rally account | People Rally staff have approved |
| Visibility | Private, unlisted or public: your choice | Private or unlisted; Rally staff decide what’s listed |
| Players per server | 1 to 100 | 1 to 1,000 |
| Saved data | Data stores | The server SDK’s data |
| Players without an account | Can play as guests | Can play as guests |
If you’re starting a game from scratch, Rally Studio gives you servers, multiplayer and saved data without writing a server. Web games are for games that already exist, or that need an engine of their own.
How a web game runs
Section titled “How a web game runs”- A player presses Play. Rally finds a server running your game’s current version that has room, the fullest first so players end up together, or starts a new one. It gives the player a join ticket for that server.
- Rally’s play page opens your client in a frame, from your game’s own web address.
- Your client asks Rally who the player is with the web SDK, and gets their name and id, the ticket and the server’s address.
- Your client connects to your server and sends the ticket. Your server checks it with the server SDK and lets the player in.
Rally's play page ──── opens ────▶ your client (your game's own address) │ │ rally.ready(): player, ticket, server address │ places the player ▼ └──────────────────────▶ your server on Rally ◀── WebSocket + ticket checks the ticket with verifyTicket()Ask for access
Section titled “Ask for access”A web game runs your own code, on Rally’s servers and in players’ browsers, so publishing web games needs approval from Rally staff. You ask once:
-
On Rally, open Settings > Web games (rally.place/app/settings#web-games).
-
Under What do you want to build?, describe the game, what it’s made with, and whether it has a server, in 20 to 1,000 characters. You can add a Link to your game or past work, such as a demo, a store page or a repository.
-
Choose Ask for access. Rally staff approve or deny your request, and you get an email either way. Settings > Web games shows where it stands too.
- While your request waits, you can change it with Edit request.
- If staff deny it, you see their note, and you can ask again a day later.
- Staff can revoke access later. Then you can’t create web games or publish new versions. Games you’ve already published keep running unless staff take them down. Revoked access comes back only through Rally staff.
Until you’re approved, rally create and rally publish stop with a message and a link to
Settings > Web games. rally whoami shows where your request stands.
Project layout
Section titled “Project layout”A web game is a folder with a rally.json in it, plus whatever your game needs to build. What
Rally publishes is two folders your build produces: the client and the server.
moon-miners/├─ rally.json├─ package.json├─ client/ your client's source├─ server/ your server's source└─ dist/ ├─ client/ the client build: index.html, bundled scripts, assets (client.dir) └─ server/ the server, ready to run, with its dependencies (server.dir){ "name": "Moon Miners", "runtime": "web", "description": "Dig for ore on the moon with up to 16 friends.", "maxPlayers": 16, "build": "npm run build", "client": { "dir": "dist/client", "entry": "index.html", "permissions": ["pointer-lock", "fullscreen"] }, "server": { "dir": "dist/server", "command": ["node", "index.mjs"], "env": { "TICK_RATE": "30" } }}rally.json reference
Section titled “rally.json reference”Paths are relative to the project folder.
| Field | Required | What it is |
|---|---|---|
name |
Yes | The game’s name, 1 to 60 characters. rally create uses it. |
runtime |
Yes | Always "web". |
description |
No | Shown on the game’s page, up to 1,000 characters. rally create uses it. |
dimension |
No | "3d" (the default) or "2d": the badge on the game’s page. rally create uses it. |
maxPlayers |
No | Players per server, 1 to 1,000. The default is 20. Every publish applies it. |
build |
No | A shell command that builds both folders, such as "npm run build". rally publish runs it in the project folder first, unless you pass --skip-build. |
client.dir |
Yes | The folder your client build writes. Everything in it is published, except source maps and .DS_Store files. |
client.entry |
No | The page Rally opens, at the top of client.dir. The default is index.html. |
client.permissions |
No | Browser features your game needs: any of "pointer-lock", "fullscreen", "microphone" and "camera". See Permissions. |
server.dir |
Yes | The folder with your server program and everything it needs to run, dependencies included. It’s packed whole. |
server.command |
Yes | The command that starts your server, as a list: the program, then its arguments, like ["node", "index.mjs"]. It runs inside server.dir, without a shell. |
server.env |
No | Settings for your server, as names and text values. They’re stored with the version as plain text, so don’t put secrets here. |
game |
Written by rally create |
The game’s id. rally publish publishes to it. |
api |
Written by rally create |
The Rally address the game is on. |
Client requirements
Section titled “Client requirements”Your client is a folder of files. Rally serves them from your game’s own web address, which is
different for every game, and opens your entry page in a frame on Rally’s play page, with
?rally=1 added to its address. Everything in the client folder is public: anyone who has your
game’s address can download it, so keep secrets on your server.
Bundle your code
Section titled “Bundle your code”Your game’s page only runs scripts that are files in your client folder:
- No inline
<script>blocks and no inline event handlers such asonclick="…". Put all code in.jsfiles. - No import maps. Bundle your modules with your build tool instead.
- No scripts from other sites, such as a library from a CDN. Bundle libraries into your code.
- No
eval()ornew Function(). WebAssembly works, and so do workers started from your own files or fromblob:URLs.
Styles, fonts, images, audio and video may also come from any https:// address, and inline
styles work.
Connections
Section titled “Connections”Your client can only connect, with fetch, XMLHttpRequest or WebSockets, to its own address and
to your game’s servers on Rally. Requests to other sites, such as an outside API or analytics, are
blocked. Frames inside your game can only show your own files.
Other things the frame doesn’t allow
Section titled “Other things the frame doesn’t allow”- Opening pop-ups or new tabs, or moving Rally’s page to another address.
alert(),confirm()andprompt().- Downloading files.
- Submitting a form to an address. Handle forms in your code instead.
localStorage and IndexedDB work, and belong to your game’s own address.
Permissions
Section titled “Permissions”A web game gets autoplay of sound and video without asking. List anything else it needs in
client.permissions:
| Permission | Lets your game |
|---|---|
pointer-lock |
Lock the mouse pointer, for mouse look. Without it, requestPointerLock() fails. |
fullscreen |
Make its own elements full screen. The Rally menu always has a Full screen button. |
microphone |
Ask the player to use their microphone. |
camera |
Ask the player to use their camera. |
Paths and caching
Section titled “Paths and caching”- Your game’s address with no path (
/) serves the entry page. - A path that isn’t one of your files and has no file extension, such as
/lobby, also serves the entry page, so a client that handles its own routes works. - A missing path with an extension, such as
/missing.png, is a “not found” error.
Files whose names end in a content hash are cached by browsers for a year: a name that ends with
- or ., then at least 8 letters, digits or underscores, before the extension, like
index-B7xk29Qa.js or main.3f9c2e1d.css. Build tools such as Vite name files this way. Every
other file is checked with Rally on each load, so a new version shows up right away. Make sure only
files that never change have names like that: menu-background.png matches the pattern, so a new
image with the same name would stay cached.
Say hello to Rally
Section titled “Say hello to Rally”Call the web SDK’s rally.ready() as soon as your page loads, before loading anything big. It’s how
your game gets its player and ticket, and it’s how Rally knows your game is running: once it’s
called, the SDK tells Rally every second that the game is still responding. If Rally hasn’t heard
from your game 30 seconds after loading it, or stops hearing from it for 5 seconds (for example,
because a loop blocks the page), the player sees This game isn’t responding, with buttons to
leave or keep waiting.
Keyboard focus
Section titled “Keyboard focus”When your game opens, the keyboard belongs to Rally’s page, not to your game: keys don’t reach your game until the player clicks it. Until then, Tab opens the Rally menu and Enter opens the chat. Start with something to click, such as a Click to play screen, before you need the keyboard.
Once the player has clicked your game, every key goes to your game, Tab and Enter included. Players open the Rally menu (chat, a link to their server, full screen, report and leave) with the button in the top-left corner.
Rally’s buttons
Section titled “Rally’s buttons”Rally draws three round buttons over the top-left corner of your game, in this order: Back (back to Rally, with the game still running in the mini player), the Rally menu and the chat. They’re always in the same place, so you can design around them:
- Each button is 36 pixels across. The first is 8 pixels from the top and left edges of your game, and they’re 6 pixels apart.
- Together they fill a zone 52 pixels tall, from the top edge, and 136 pixels wide, from the left edge: the last button’s right edge plus 8 pixels.
- Keep your own buttons, text and HUD out of that zone. Or give your game a top bar that’s 52 pixels tall and start its content 136 pixels from the left, so Rally’s buttons sit inline with yours.
Your game’s page always fits inside the screen’s safe area: on a phone with a notch or rounded corners, Rally keeps the frame clear of them and fills the space around it with its own dark background. The zone is measured from your page’s top-left corner, so you don’t need to add safe-area insets of your own.
Rally tells your game where the zone is, so you don’t have to hard-code it: rally.overlay()
returns { x, y, width, height } in CSS pixels from the top-left corner of your page, today
{ x: 0, y: 0, width: 136, height: 52 }, and rally.onOverlay(fn) calls you when it changes.
While no buttons are over your game, the zone is all zeros.
import { rally, type RallyOverlay } from '@rallyplace/web-sdk';
const bar = document.querySelector<HTMLElement>('#top-bar')!;
function place(zone: RallyOverlay) { // Start the bar's content after Rally's buttons, at their height. bar.style.paddingLeft = `${Math.max(zone.width, 12)}px`; bar.style.minHeight = `${zone.height || 40}px`;}
const zone = rally.overlay();if (zone) place(zone);rally.onOverlay(place);The mini player
Section titled “The mini player”Players can leave your game’s page without leaving your game: Back (or Minimize (keep playing) in the Rally menu) takes them back to where they were on Rally, and your game keeps running in a small window in a corner of the page, the mini player. It’s 4:3, from 220 to 380 pixels wide. Your page isn’t reloaded and the player stays connected to your server. Expand brings it back full screen, and Close leaves the game.
In the mini player, Rally’s buttons, menu and chat are hidden, and the keyboard usually belongs to
the page around it. Your game can still be clicked. rally.isPip() is true while it’s in the
mini player, and rally.onPip(fn) tells you when that changes: pause, or show a simpler view.
rally.onPip((pip) => { document.body.classList.toggle('mini', pip); if (pip) pauseMenu.open();});Server requirements
Section titled “Server requirements”Rally runs your server program on its own servers, one copy for each game server, behind a front door that passes HTTP and WebSocket traffic through to your program.
Start and listen on PORT
Section titled “Start and listen on PORT”Rally runs server.command inside your unpacked server folder, on Linux, with Node.js available as
node. Anything else your program needs must be in the server folder: its dependencies (or one
bundled file), native modules built for Linux, and any other programs.
Your program must listen for HTTP and WebSocket connections on the port in the PORT environment
variable, on the address in HOST. Listen as soon as you can: the first player waits for a new
server for at most 30 seconds.
One WebSocket for each player
Section titled “One WebSocket for each player”Rally counts a server’s players by its open WebSocket connections: each one counts as one player. That’s how it knows which servers are full, and which are empty and can stop.
- Give each player one WebSocket connection to your server, and keep it open while they play.
- HTTP requests don’t count. A server that only answers HTTP, or a client that falls back to HTTP
polling, looks empty to Rally, and an empty server is shut down. With socket.io, use only the
websockettransport. - Connect as soon as the player has a ticket. A server that nobody connects to stops taking players after a minute.
Check every player’s ticket
Section titled “Check every player’s ticket”Treat a connection as a player only after its ticket checks out with
verifyTicket(). The ticket says who the player is, and that Rally sent them to this
game. Use the player’s id from the ticket, never one the client sends.
What Rally takes care of
Section titled “What Rally takes care of”- Certificates. Your program speaks plain HTTP and WebSocket on
PORT. Players reach it at the server address Rally gives their client. - CORS. Rally answers preflight requests from your game’s own address and adds the CORS headers its requests need, so your server needs no CORS setup for its own client.
- Paths. Your program receives request paths as your client wrote them after the server address.
Don’t use
GET /health, paths under/_rally/or paths that start with/m/for your own routes: Rally answers those itself.
What your program gets
Section titled “What your program gets”| Variable | What it is |
|---|---|
PORT |
The port to listen on. |
HOST |
The address to listen on. |
NODE_ENV |
production, unless server.env sets it. |
HOME, TMPDIR |
A private folder your program may write to. |
RALLY_JWKS_URL, RALLY_ISSUER, RALLY_AUDIENCE |
What you need to check tickets with your own JWT library. See Tickets without the SDK. |
It also gets everything in server.env, and a few more RALLY_ variables that the server SDK
reads for you: use rallyEnv() for the game, server and version ids and the players per server.
It gets nothing else from Rally’s machine, and anything it writes to disk is lost when the server
stops.
How long a server runs
Section titled “How long a server runs”- A server starts when a player needs one and every running server of the current version is full (or there isn’t one).
- When no player has been connected for 60 seconds, Rally stops sending players to the server. It shuts down about a minute later if it’s still empty.
- A server runs for at most 24 hours.
- When a server stops, your program gets
SIGTERM. Don’t count on time to save then: save what matters as it happens. - If your program exits or crashes, the server stops and its players are disconnected. The next player gets a new server.
The SDKs
Section titled “The SDKs”Two packages connect your game to Rally:
npm install @rallyplace/web-sdk # in your clientnpm install @rallyplace/server-sdk # in your server (Node.js)Both are optional: they do the work described here, and you can do it yourself instead. The server SDK has no dependencies.
Web SDK
Section titled “Web SDK”@rallyplace/web-sdk runs in your client. It gets the player and their ticket from Rally’s play
page:
import { rally } from '@rallyplace/web-sdk';import { loadAssets, showTitle, startGame, startOnMyOwn } from './game';
async function main() { // Opened outside Rally, such as on your own computer: run the way you always have. if (!rally.isEmbedded()) return startOnMyOwn();
// First thing: Rally shows "This game isn't responding" until the game says hello. const session = await rally.ready(); showTitle(`Welcome, ${session.user.name}`); await loadAssets(); connect(session.serverUrl, session.ticket);}
function connect(serverUrl: string, ticket: string) { const socket = new WebSocket(serverUrl); socket.addEventListener('open', () => socket.send(JSON.stringify({ ticket }))); socket.addEventListener('close', async () => { // Tickets last about a minute: ask for a fresh one to reconnect. const fresh = await rally.refreshTicket(); connect(fresh.serverUrl, fresh.ticket); }); startGame(socket);}
void main();| Function | What it does |
|---|---|
rally.isEmbedded() |
true when the page is running inside Rally’s play page. |
rally.ready({ timeoutMs }) |
Tells Rally the game has loaded, and resolves with the session: user (id and name), ticket, serverUrl, gameId and gameName. Calling it again returns the same session. It rejects outside Rally, or if Rally doesn’t answer within timeoutMs (30 seconds by default). |
rally.refreshTicket(timeoutMs) |
Resolves with a new ticket and serverUrl, for reconnecting or for a connection that starts more than a minute after ready(). The server can be a different one: connect to the serverUrl it returns. It times out after 20 seconds by default. |
rally.currentSession() |
The session from ready(), with the latest ticket, or null before ready() resolves. |
rally.overlay() |
Where Rally’s buttons are over your game: { x, y, width, height } in CSS pixels from your page’s top-left corner, all zeros while there are none, or null before Rally has said. |
rally.onOverlay(fn) |
Calls fn with the new zone each time it changes. Returns a function that stops listening. |
rally.isPip() |
true while your game is in Rally’s mini player. |
rally.onPip(fn) |
Calls fn with true or false each time the mini player opens or closes. Returns a function that stops listening. |
rally.httpUrl(serverUrl) |
Turns ws:// into http:// and wss:// into https://, for your server’s HTTP routes. |
Each function is also exported on its own, as in import { ready } from '@rallyplace/web-sdk'.
user.idstays the same every time a player plays. Use it as their id in your game.- A ticket is good for about a minute after the player pressed Play. If your game loads for
longer than that before it connects, call
refreshTicket()first. serverUrlcan include a path. Add your own paths after it, as in`${rally.httpUrl(serverUrl)}/matchmake`, and never replace it. Your server receives the request without that part.
Server SDK
Section titled “Server SDK”@rallyplace/server-sdk runs in your server program, on Node.js. It checks join tickets and saves
data:
import { createServer } from 'node:http';import { env } from 'node:process';import { TicketError, verifyTicket } from '@rallyplace/server-sdk';import { WebSocketServer } from 'ws';import { addPlayer } from './game';
const http = createServer((_req, res) => res.end('ok'));const sockets = new WebSocketServer({ server: http });
sockets.on('connection', (socket) => { // The client's first message carries its join ticket. socket.once('message', async (raw) => { try { const { ticket } = JSON.parse(String(raw)) as { ticket: string }; const player = await verifyTicket(ticket); // { userId, name, gameId, serverId, owner, expiresAt } addPlayer(socket, player); } catch (err) { if (err instanceof TicketError) console.warn(`refused a join: ${err.message}`); socket.close(); } });});
// On Rally, PORT and HOST say where to listen. On your own computer, use your usual port.http.listen(Number(env.PORT ?? 2567), env.HOST);verifyTicket(ticket) checks that Rally signed the ticket for this game and this server, and that
it hasn’t expired. It returns who joined, or throws a TicketError whose message says why. Log
that message, but don’t show it to players.
| What it returns | |
|---|---|
userId |
The player’s id, the same as user.id in the web SDK. Use it as the key for their saved data. |
name |
The player’s name. |
gameId, serverId |
The game, and the server Rally placed them on. |
owner |
true when it’s you, the game’s owner: you can show yourself debug tools. |
expiresAt |
When the ticket expires, in seconds since 1970. |
The rest of the package:
| Export | What it does |
|---|---|
onRally() |
true when Rally started this program. On your own computer it’s false: skip ticket checks there, or give players made-up ids. |
rallyEnv() |
Rally’s settings for this server: gameId, serverId, versionId, maxPlayers and more. Throws when the program isn’t running on Rally. |
data |
Saved data for your game. See below. |
createData() |
Makes another saved data client, for example with your own fetch. |
TicketError, DataError |
The errors verifyTicket() and data throw. A DataError has the HTTP status. |
Saved data
Section titled “Saved data”data saves JSON values for your game. They’re shared by all its servers and versions, and kept
after servers stop. Each value lives in a store under a key, usually a player’s id:
import { data, type RallyPlayer } from '@rallyplace/server-sdk';
export async function onWin(player: RallyPlayer) { await data.increment('Wins', player.userId); // adds 1 and returns the new total await data.update<{ coins: number }>('Profile', player.userId, (profile) => ({ coins: (profile?.coins ?? 0) + 10, }));}
export const leaderboard = () => data.top('Wins', 10); // [{ key, value }], highest first| Method | What it does |
|---|---|
get(store, key) |
The saved value, or undefined if there isn’t one. |
set(store, key, value) |
Saves a JSON value. |
increment(store, key, delta) |
Adds delta (1 by default) to a number, counting a missing one as 0, and returns the result. Safe when several servers do it at once. |
update(store, key, fn) |
Reads the value, passes it to fn and saves what fn returns, without losing another server’s change in between. Return undefined to leave the value as it is. |
remove(store, key) |
Deletes the value. |
top(store, limit, { ascending }) |
The highest numbers in a store, as { key, value } rows: 10 by default and up to 100. Pass ascending: true for the lowest, such as best times. |
Store names and keys are 1 to 50 characters, and a value can be up to 64 KB of JSON. Saved data
only works on Rally: on your own computer, data throws a DataError.
Tickets without the SDK
Section titled “Tickets without the SDK”A join ticket is a standard JWT, so a server written in any language can check it with a JWT library that supports EdDSA (Ed25519) and key sets:
- Get Rally’s public keys from the key set at the address in
RALLY_JWKS_URL, and keep them for a few minutes rather than fetching them for every player. - Accept only the
EdDSAalgorithm, and check the signature. - Check that
issisRALLY_ISSUER,audisRALLY_AUDIENCEandexphasn’t passed.
| Claim | What it is |
|---|---|
sub |
The player’s id. |
name |
The player’s name. |
gid, vid, iid |
The game, version and server the player was placed on. |
owner |
true for the game’s owner. |
guest |
true for someone playing without an account. See Guests. |
iat, exp |
When the ticket was made, and when it expires: 60 seconds later. |
Publishing and versions
Section titled “Publishing and versions”Run rally create once to create the game, then rally publish whenever you want players to get
your changes. See the CLI reference for both.
- Each publish makes a new numbered version, and it’s live right away. Your game’s address serves the new client at once, and new servers run the new version.
- Servers that are already running keep the version they started with until their players leave. Players get the new version the next time they join.
- Each publish sets players per server from
maxPlayersinrally.json. - Only the game’s owner can publish.
- Files are matched by their contents, so a publish uploads only files Rally doesn’t have yet.
- To roll back, open the game’s page on Rally and choose Make current next to an older version under Versions.
You change a web game in your own project and publish it with the CLI. Rally Studio and Build live are for Studio games.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Files in the client | 5,000 |
| One client file | 64 MB |
| All client files together | 200 MB |
| The server folder, packed and compressed | 150 MB |
| Players per server | 1 to 1,000 (20 by default) |
server.command |
1 to 32 items |
server.env |
Up to 64 variables. Names are 1 to 64 letters, digits and _, not starting with a digit; values are up to 2,000 characters. Rally sets PORT, HOST, HOSTNAME, PATH, NODE_OPTIONS and names that start with RALLY_, LD_ or DYLD_, so you can’t. |
| A join ticket | Good for 60 seconds |
| Waiting for a new server to start | 30 seconds |
| An empty server | Stops taking players after 60 seconds, and shuts down about a minute later |
| Longest a server runs | 24 hours |
| Saved data: store names and keys | 1 to 50 characters |
| Saved data: one value | 64 KB of JSON |
| Saved data: requests per game, all servers together | 6,000 a minute |
| Creating web games | 20 an hour |
| Publishing | 30 an hour |
Visibility, listing and guests
Section titled “Visibility, listing and guests”A new web game is unlisted: once it’s published, anyone with its link can play it, but it isn’t
listed in Games. Share the play link that rally publish prints, or the game’s page.
On the game’s page, you can switch it between Unlisted and Private, where only you and your collaborators can play it. Making a web game Public, listed in Games for everyone, is up to Rally staff.
Guests
Section titled “Guests”People without a Rally account can play unlisted and public web games as guests. Opening the game’s play link, they choose Play as guest.
- A guest gets a name like
Guest1234, and an id that starts withguest_. - Their ticket has
guest: true. - Guests can’t use the chat, and Rally keeps nothing about them. Don’t save a guest’s progress under their id.
Port an existing game
Section titled “Port an existing game”-
Ask for access in Settings > Web games, and install the CLI:
npm install -g @rallyplace/cli, thenrally login. -
Make your client build a folder of plain files: every script bundled into files, no inline scripts, no import maps and no scripts from other sites. Remove calls to outside APIs and analytics.
-
In your client, when
rally.isEmbedded()is true, callrally.ready()first thing, then use the player and ticket it returns instead of your own sign-in, and connect toserverUrl. Show a Click to play screen before you need the keyboard, and keep your own UI out of the top-left zone where Rally’s buttons are. -
In your server, listen on
PORTandHOST, give each player one WebSocket connection, check every ticket withverifyTicket(), and useuserIdas the player’s id. Save data withdataif you need it, and not for guests. -
Build your server into a folder that runs on its own with your
server.command, dependencies included. -
Write
rally.json, then runrally createonce andrally publish. -
Open the play link, click into the game and play. Then try it with a friend, and as a guest in a private browser window.