Multiplayer
Every Rally game is multiplayer. While people play, the game runs on a game server that Rally hosts, and on each player’s device. The server is in charge: it runs your Scripts, simulates physics, keeps score and saves data. Each device runs your LocalScripts, draws the world, plays sound and reads input.
The rule that keeps games fair: the server decides, devices ask. Anything a device sends could come from a modified copy of your game, so the server checks it.
What players see
Section titled “What players see”The server holds the real copy of the game tree. Each player’s device gets its own copy, kept in sync by replication.
| Where | Who gets it |
|---|---|
| Workspace, Lighting, ReplicatedStorage, StarterGui, StarterPlayer | Every player. |
| Players | Every player: each Player, with its attributes and its leaderstats. |
A player’s PlayerGui |
Only that player. |
| ServerScriptService, ServerStorage | Nobody. They stay on the server. |
| Scripts (wherever they are) and ModuleScripts | Nobody. A LocalScript’s code, and the code of the modules it imports, does go to players. |
A few properties stay on the server too, such as a player’s respawnLocation.
When changes arrive
Section titled “When changes arrive”- On joining, a player gets everything they can see in one go.
- After that, the server sends what changed 20 times a second: new objects, changed properties and attributes, moved and destroyed objects. Only the properties that changed are sent, and setting a property to the value it already has sends nothing.
- On a device, objects the server creates later arrive over time. Wait for them with
waitForChildinstead of assuming they exist. See Instances and the tree.
Parts that move on the server are smoothed between updates on each device, so motion looks fluid.
Changes on a device stay on that device
Section titled “Changes on a device stay on that device”A LocalScript can change anything in its own copy of the tree: move parts, recolor them, delete them. Those changes never reach the server or other players. That’s what makes LocalScripts right for UI, the camera and effects, and why a modified device can’t change your world. If the server later changes the same property, the server’s value replaces the local one.
A device only ever sends the server two things:
- its own character’s movement, which the server checks;
- remote event and remote function calls, which your Scripts check.
Characters and movement
Section titled “Characters and movement”Each player moves their own character on their device, so movement feels instant even with lag. Characters are the only thing a device moves: every other moving object, including every unanchored part, is simulated by the server, and no device ever owns a part’s physics.
The device sends the character’s position 20 times a second, and the server checks every update:
- Sideways it can’t have gone further than
walkSpeedallows for the time since the last update, with 50% to spare plus 2 studs. - Upward it can’t have risen more than
jumpPower(an upward speed) allows for that time, with the same allowance. Falling is always allowed.
A move that fails the check is rejected, and the player is put back where the server last accepted them.
From the server, change characters through their properties and methods:
walkSpeed,jumpPower,health,maxHealthandcolorgo to the owner’s device and take effect there.teleport(position)(or assigningposition) moves the character. Its owner’s device is told to move it there, and moves still on their way from the old spot are ignored.velocityandstateare reported by the owner’s device, so they’re read-only.
More in Players and characters.
Remote events
Section titled “Remote events”A RemoteEvent carries one-way messages between the server and devices. Put it where both sides
can see it, usually ReplicatedStorage: in Studio, Insert > Events > RemoteEvent puts it
there. Give it a name that says what it’s for.
| Member | Side | What it does |
|---|---|---|
fireServer(...args) |
LocalScript | Sends a message to the server. |
onServerEvent |
Script | Fires with the sending player first, then the arguments. Rally adds the player; the device can’t fake it. |
fireClient(player, ...args) |
Script | Sends a message to one player’s device. |
fireAllClients(...args) |
Script | Sends a message to every player’s device. |
onClientEvent |
LocalScript | Fires with the arguments the server sent. |
This pair of scripts lets players press C to cheer. The server allows one cheer every 3
seconds and tells everyone. Add a RemoteEvent named Cheer to ReplicatedStorage:
import { Color3, PlayerGui, RemoteEvent, ReplicatedStorage, ScreenGui, task, TextLabel, UserInputService, Vector2, waitForChild, waitForLocalPlayer,} from '@rally/sdk';
task.spawn(async () => { const me = await waitForLocalPlayer(); const gui = await waitForChild(me, 'PlayerGui', PlayerGui); const cheer = await waitForChild(ReplicatedStorage, 'Cheer', RemoteEvent);
const screen = new ScreenGui(); screen.name = 'Cheers'; const banner = new TextLabel(); banner.anchorPoint = new Vector2(0.5, 0); banner.positionScale = new Vector2(0.5, 0); banner.position = new Vector2(0, 80); banner.size = new Vector2(420, 48); banner.backgroundTransparency = 1; banner.textColor = Color3.fromHex('#ffd23f'); banner.textSize = 28; banner.fontWeight = 'bold'; banner.text = ''; banner.parent = screen; screen.parent = gui;
// Ask the server. UserInputService.inputBegan.connect((input, gameProcessed) => { if (!gameProcessed && input.key === 'KeyC') cheer.fireServer(); });
// Hear back from it. let hide: (() => void) | null = null; cheer.onClientEvent.connect((name) => { if (typeof name !== 'string') return; banner.text = `${name} cheers!`; hide?.(); hide = task.delay(2, () => { banner.text = ''; }); });});import { getChild, type Player, Players, RemoteEvent, ReplicatedStorage, task } from '@rally/sdk';
const cheer = getChild(ReplicatedStorage, 'Cheer', RemoteEvent);const lastCheer = new Map<Player, number>();
cheer.onServerEvent.connect((player) => { const now = task.time(); if (now - (lastCheer.get(player) ?? -Infinity) < 3) return; // one cheer every 3 seconds lastCheer.set(player, now); cheer.fireAllClients(player.displayName);});
Players.playerRemoving.connect((player) => lastCheer.delete(player));fireClient and fireAllClients only reach players who can see the RemoteEvent. Firing one in
ServerStorage reaches nobody, and fireClient warns about it in Output.
Request and response
Section titled “Request and response”A RemoteFunction lets a device ask the server a question and wait for the answer.
- On the server, set
onServerInvoketo a function. It gets the calling player first, then the arguments, and whatever it returns goes back to the device. It can beasync. Each RemoteFunction has one handler. - On the device,
await remote.invokeServer(...args)returns the answer.
The device’s call fails (the promise rejects) when:
- the handler throws: the device gets the error message, and Output shows a warning;
- the server has no handler:
no onServerInvoke on the server; - the handler takes longer than 5 seconds:
the server took too long; - the player is sending calls too fast:
rate limited(see Rate limits).
Only devices call invokeServer. To ask a device something from the server, fire a RemoteEvent
and have the device answer with another one.
This shop sells two upgrades for coins. Add a RemoteFunction named Buy to ReplicatedStorage. The
list of items lives in a ModuleScript both sides use:
export interface ShopItem { id: string; name: string; price: number;}
export const ITEMS: ShopItem[] = [ { id: 'speed', name: 'Speed boots', price: 50 }, { id: 'jump', name: 'Spring shoes', price: 80 },];The server owns the coins and the rules. It answers every request with a result the device can show:
import { type Character, createLeaderstats, getChild, type NumberValue, type Player, Players, RemoteFunction, ReplicatedStorage,} from '@rally/sdk';import { ITEMS } from '@game/ReplicatedStorage/ShopItems';
const buy = getChild(ReplicatedStorage, 'Buy', RemoteFunction);
interface Shopper { coins: NumberValue; owned: Set<string>;}const shoppers = new Map<Player, Shopper>();
/** Gives a character the upgrades its player owns. */function apply(character: Character, owned: Set<string>): void { if (owned.has('speed')) character.walkSpeed = 24; if (owned.has('jump')) character.jumpPower = 70;}
Players.playerAdded.connect((player) => { const shopper: Shopper = { coins: createLeaderstats(player, { Coins: 100 }).Coins, owned: new Set() }; shoppers.set(player, shopper); player.characterAdded.connect((character) => apply(character, shopper.owned));});Players.playerRemoving.connect((player) => shoppers.delete(player));
buy.onServerInvoke = (player, itemId) => { const shopper = shoppers.get(player); const item = ITEMS.find((i) => i.id === itemId); // only ids on the list are accepted if (!shopper || !item) return { ok: false, message: 'That item isn’t for sale.' }; if (shopper.owned.has(item.id)) return { ok: false, message: `You already have ${item.name}.` }; if (shopper.coins.value < item.price) return { ok: false, message: 'Not enough coins.' }; shopper.coins.value -= item.price; shopper.owned.add(item.id); if (player.character) apply(player.character, shopper.owned); return { ok: true, message: `You bought ${item.name}!` };};The LocalScript makes a button per item and shows the server’s answer:
import { Color3, PlayerGui, RemoteFunction, ReplicatedStorage, ScreenGui, task, TextButton, TextLabel, Vector2, waitForChild, waitForLocalPlayer,} from '@rally/sdk';import { ITEMS } from '@game/ReplicatedStorage/ShopItems';
task.spawn(async () => { const me = await waitForLocalPlayer(); const gui = await waitForChild(me, 'PlayerGui', PlayerGui); const buy = await waitForChild(ReplicatedStorage, 'Buy', RemoteFunction);
const screen = new ScreenGui(); screen.name = 'Shop'; const status = new TextLabel(); status.position = new Vector2(16, 16 + ITEMS.length * 52); status.size = new Vector2(260, 32); status.backgroundTransparency = 1; status.textColor = Color3.white; status.textAlign = 'left'; status.text = ''; status.parent = screen;
ITEMS.forEach((item, i) => { const button = new TextButton(); button.text = `${item.name} — ${item.price} coins`; button.position = new Vector2(16, 16 + i * 52); button.size = new Vector2(260, 44); button.cornerRadius = 10; button.parent = screen; button.activated.connect(async () => { try { const result = (await buy.invokeServer(item.id)) as { ok: boolean; message: string }; status.text = result.message; } catch { status.text = 'The shop is busy. Try again.'; } }); }); screen.parent = gui;});The device’s copy of the coins is only for display. Even if a modified device changed it, the server checks its own value before selling anything.
What you can send
Section titled “What you can send”Remote event arguments, remote function arguments and return values can be:
| Type | Arrives as |
|---|---|
Numbers, strings, booleans, null |
The same value. |
| Arrays and plain objects | Copies, with their contents converted by these same rules. |
Vector3, Vector2, Color3 |
The same kind of value. |
| An instance | The same object on the other side, or null if the receiver can’t see it, such as something in ServerStorage or a part a LocalScript made. |
undefined, NaN, Infinity, functions |
null. |
Other objects (a Map, a Set, a class) |
A plain object with the object’s own fields. A Map or Set arrives empty: send an array instead. |
Object keys that start with $ are dropped.
Size limits keep a device from flooding the server:
| Limit | Value |
|---|---|
| Nesting depth | 12 levels of arrays and objects. |
| Values per message | 5,000, counting every value inside arrays and objects. |
| Strings | Cut to 50,000 characters. |
| One message from a device | 64 KB. A device that sends more is disconnected. |
Sending arguments that are too deep or have too many values throws
remote arguments are too large on the side that sends them. If a device sends them anyway, the
server drops the message.
Rate limits
Section titled “Rate limits”- Remote calls: each player can make 60 remote event and remote function calls per second,
together. Calls over the limit are dropped (remote function calls fail with
rate limited), and Output shows a warning the first time:Ann sent too many remote calls; extra calls are dropped. - Everything a device sends: movement updates and remote calls together are limited to 150 messages a second per player, with short bursts up to 300. Messages over that are dropped.
These limits protect the server. Your game still needs its own limits on what makes sense, like one shot every half second, as in the examples on this page.
Validate everything
Section titled “Validate everything”For every onServerEvent and onServerInvoke, assume the arguments are wrong until you’ve checked
them:
- Check types and ranges.
typeof amount === 'number' && Number.isFinite(amount), then clamp it.target instanceof BasePart. Strings: check the length, or look them up in a list of the values you accept. - Check that it’s allowed now. Is the character alive? Is the round running? Does the player own the item? Is the target close enough to the server’s position of the character?
- Rate-limit per player with a cooldown.
- Decide the outcome on the server. Accept intentions (“throw”, “buy the hat”), never results (“I hit Bob for 50”, “I have 999 coins”).
This throw ability for a 3D game follows all four. The device only sends where the player is
looking; the server checks it, picks the speed, spawns the ball and decides who it hits. Add a
RemoteEvent named Throw to ReplicatedStorage:
import { RemoteEvent, ReplicatedStorage, task, UserInputService, waitForChild, workspace } from '@rally/sdk';
task.spawn(async () => { const throwBall = await waitForChild(ReplicatedStorage, 'Throw', RemoteEvent); UserInputService.inputBegan.connect((input, gameProcessed) => { if (gameProcessed || input.key !== 'KeyF') return; const camera = workspace.currentCamera; if (camera) throwBall.fireServer(camera.lookAt.sub(camera.position)); // where we're looking });});import { Character, Color3, getChild, Part, type Player, Players, RemoteEvent, ReplicatedStorage, task, Vector3, workspace,} from '@rally/sdk';
const throwBall = getChild(ReplicatedStorage, 'Throw', RemoteEvent);const COOLDOWN = 0.5;const SPEED = 70;const lastThrow = new Map<Player, number>();
function isUsableVector(v: unknown): v is Vector3 { return ( v instanceof Vector3 && Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z) && v.magnitude > 0.001 );}
throwBall.onServerEvent.connect((player, aim) => { // 1. Types and ranges. if (!isUsableVector(aim)) return; // 2. Allowed now? const character = player.character; if (!character || character.health <= 0) return; // 3. Rate limit. const now = task.time(); if (now - (lastThrow.get(player) ?? -Infinity) < COOLDOWN) return; lastThrow.set(player, now); // 4. The server decides the rest. const direction = aim.unit; const ball = new Part(); ball.shape = 'sphere'; ball.size = new Vector3(1.5, 1.5, 1.5); ball.color = Color3.fromHex('#ff6b35'); ball.anchored = false; ball.position = character.position.add(new Vector3(0, 1.5, 0)).add(direction.mul(3)); ball.velocity = direction.mul(SPEED); const hit = new Set<Character>(); ball.touched.connect((other) => { if (!(other instanceof Character) || other === character || hit.has(other)) return; hit.add(other); other.takeDamage(20); }); ball.parent = workspace; task.delay(3, () => ball.destroy());});
Players.playerRemoving.connect((player) => lastThrow.delete(player));It’s fine to repeat a check on the device to avoid sending pointless requests, such as hiding a button while it’s cooling down, as long as the server checks too.
Patterns
Section titled “Patterns”Shared state: attributes and values
Section titled “Shared state: attributes and values”For anything that is true right now, like the round phase, a timer or who’s winning, store it in the tree where everyone can see it: attributes on a Folder in ReplicatedStorage, or Value objects. Players who join late get the current value automatically. A RemoteEvent fired before someone joined is never seen by them.
The server runs the rounds and writes the state:
import { Folder, Players, ReplicatedStorage, task } from '@rally/sdk';
const state = new Folder();state.name = 'GameState';state.setAttribute('phase', 'waiting');state.setAttribute('timeLeft', 0);state.parent = ReplicatedStorage;
async function countdown(phase: string, seconds: number): Promise<void> { state.setAttribute('phase', phase); for (let t = seconds; t > 0; t--) { state.setAttribute('timeLeft', t); await task.wait(1); }}
task.spawn(async () => { for (;;) { while (Players.getPlayers().length === 0) { state.setAttribute('phase', 'waiting'); await task.wait(1); } await countdown('intermission', 10); await countdown('round', 60); }});Every device shows it:
import { Color3, Folder, PlayerGui, ReplicatedStorage, ScreenGui, task, TextLabel, Vector2, waitForChild, waitForLocalPlayer,} from '@rally/sdk';
task.spawn(async () => { const me = await waitForLocalPlayer(); const gui = await waitForChild(me, 'PlayerGui', PlayerGui); const state = await waitForChild(ReplicatedStorage, 'GameState', Folder);
const screen = new ScreenGui(); screen.name = 'RoundTimer'; const label = new TextLabel(); label.anchorPoint = new Vector2(0.5, 0); label.positionScale = new Vector2(0.5, 0); label.position = new Vector2(0, 16); label.size = new Vector2(240, 40); label.backgroundColor = Color3.fromHex('#10141f'); label.backgroundTransparency = 0.25; label.cornerRadius = 10; label.textColor = Color3.white; label.parent = screen; screen.parent = gui;
const show = () => { const phase = state.getAttribute('phase'); const timeLeft = state.getAttribute('timeLeft'); label.text = phase === 'round' ? `Round: ${timeLeft}s` : phase === 'intermission' ? `Next round in ${timeLeft}s` : 'Waiting for players'; }; state.attributeChanged.connect(show); show();});Moments: remote events
Section titled “Moments: remote events”For things that happen, like an explosion, a cheer or a “you found a secret!” message, fire a RemoteEvent. It’s gone once it’s delivered, which is right for moments.
Per-player state on the server
Section titled “Per-player state on the server”Keep each player’s server-side state in a Map keyed by the Player: add the entry in
Players.playerAdded, delete it in Players.playerRemoving, as the examples on this page do. To
keep data between visits, see Data stores.
Anti-cheat basics
Section titled “Anti-cheat basics”What Rally does for you:
- Characters can’t move sideways faster than their
walkSpeedallows, or rise faster than theirjumpPowerallows, beyond the allowance above. - Remote calls are rate-limited and size-limited, and a device can’t refer to objects it can’t see.
- Players never receive ServerScriptService, ServerStorage or your Scripts’ code.
What’s up to you:
- The server doesn’t check movement against walls, and it doesn’t make characters fall. Within the speed limits, a modified device could pass through a thin wall, hang in the air or keep climbing. Don’t let reaching a place be the only proof of anything valuable: check that checkpoints were reached in order, and that it took a believable amount of time.
- Every remote is a door into your server. Validate what comes through, as above.
- Anything a LocalScript imports is sent to players. Keep secrets and server-only rules in ServerStorage or ServerScriptService modules: LocalScripts can’t import those.
- Removing a player:
player.kick(message)disconnects them with your message. Lag can make honest players send odd requests, so prefer ignoring a bad request to kicking for it.
Test with several players
Section titled “Test with several players”Use Test on a real game server with 2, 3 or 4 players to see your game the way players will: each test player gets their own view, and the server runs separately. Play runs everything in your browser as one player, without the server’s movement checks, rate limits or message limits. See Test your game.