Skip to content

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.

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.

  1. 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.
  2. Rally’s play page opens your client in a frame, from your game’s own web address.
  3. 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.
  4. 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()

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:

  1. On Rally, open Settings > Web games (rally.place/app/settings#web-games).

  2. 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.

  3. 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.

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)
rally.json
{
"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" }
}
}

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.

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.

Your game’s page only runs scripts that are files in your client folder:

  • No inline <script> blocks and no inline event handlers such as onclick="…". Put all code in .js files.
  • 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() or new Function(). WebAssembly works, and so do workers started from your own files or from blob: URLs.

Styles, fonts, images, audio and video may also come from any https:// address, and inline styles work.

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.

  • Opening pop-ups or new tabs, or moving Rally’s page to another address.
  • alert(), confirm() and prompt().
  • 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.

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.
  • 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.

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.

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 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.

client/src/top-bar.ts
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);

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();
});

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.

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.

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 websocket transport.
  • Connect as soon as the player has a ticket. A server that nobody connects to stops taking players after a minute.

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.

  • 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.
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.

  • 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.

Two packages connect your game to Rally:

Terminal window
npm install @rallyplace/web-sdk # in your client
npm 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.

@rallyplace/web-sdk runs in your client. It gets the player and their ticket from Rally’s play page:

client/src/main.ts
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.id stays 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.
  • serverUrl can 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.

@rallyplace/server-sdk runs in your server program, on Node.js. It checks join tickets and saves data:

server/src/index.ts
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.

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:

server/src/scores.ts
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.

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 EdDSA algorithm, and check the signature.
  • Check that iss is RALLY_ISSUER, aud is RALLY_AUDIENCE and exp hasn’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.

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 maxPlayers in rally.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.

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

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.

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 with guest_.
  • 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.
  1. Ask for access in Settings > Web games, and install the CLI: npm install -g @rallyplace/cli, then rally login.

  2. 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.

  3. In your client, when rally.isEmbedded() is true, call rally.ready() first thing, then use the player and ticket it returns instead of your own sign-in, and connect to serverUrl. 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.

  4. In your server, listen on PORT and HOST, give each player one WebSocket connection, check every ticket with verifyTicket(), and use userId as the player’s id. Save data with data if you need it, and not for guests.

  5. Build your server into a folder that runs on its own with your server.command, dependencies included.

  6. Write rally.json, then run rally create once and rally publish.

  7. 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.