Players and characters
Every person in a server has a Player object in the Players service, and — while they’re
alive in the world — a Character: the body they move around. This page covers both, from joining
to respawning.
Players joining and leaving
Section titled “Players joining and leaving”When someone joins, the server:
- creates a
Playerfor them, named after their display name, with aPlayerGuiholding copies of everything in StarterGui; - puts the player in Players and fires
Players.playerAdded; - spawns their character (unless you’ve turned that off) and fires
player.characterAdded.
When they leave, Players.playerRemoving fires while the player and their character still exist
— the moment to save their progress — and then both are removed.
Keep per-player state in a Map keyed by player, filled in playerAdded and emptied in
playerRemoving:
import { type Character, Color3, type Player, Players, print, task } from '@rally/sdk';
interface PlayerState { joinedAt: number; deaths: number;}const states = new Map<Player, PlayerState>();
function onCharacter(player: Player, character: Character): void { character.walkSpeed = 20; character.color = Color3.fromHex('#ffb703'); character.died.connect(() => { const state = states.get(player); if (state) state.deaths += 1; });}
function onPlayer(player: Player): void { states.set(player, { joinedAt: task.time(), deaths: 0 }); print(`${player.displayName} joined (${Players.getPlayers().length} here)`); if (player.character) onCharacter(player, player.character); player.characterAdded.connect((character) => onCharacter(player, character));}
// Players already here when this script started, then everyone who joins later.for (const player of Players.getPlayers()) onPlayer(player);Players.playerAdded.connect(onPlayer);
Players.playerRemoving.connect((player) => { const state = states.get(player); print(`${player.displayName} left after ${state?.deaths ?? 0} deaths`); states.delete(player);});Server scripts start before anyone can join, so on the server the loop over getPlayers() usually
finds nobody — but it costs nothing and makes the code safe wherever it runs. On a player’s device
it matters: there, playerAdded only fires for players who join after you.
The Players service also has getPlayers(), getPlayerByUserId(userId),
getPlayerFromCharacter(character) and, on a player’s device, localPlayer. See
Services.
The Player object
Section titled “The Player object”| Member | What it is |
|---|---|
userId |
Their Rally account id, as a string. It never changes — use it to save data. Read-only. |
displayName |
The name shown for them. Read-only. The player’s name is the same. |
guest |
true for someone playing without a Rally account. Read-only. See Guests. |
character |
Their current Character, or null while they have none. Read-only. |
teamColor |
The body color their characters spawn with. |
respawnLocation |
A part they always spawn on top of (a SpawnLocation or any other part), or null. Server only — see Checkpoints. |
playerGui |
Their PlayerGui, which holds their on-screen UI. |
characterAdded |
Fires with the new character each time one spawns. |
characterRemoving |
Fires with the old character just before it’s replaced. |
loadCharacter() |
Server: spawns them a new character now, replacing any current one. |
kick(message?) |
Server: removes them from the game, showing them message. |
Guests
Section titled “Guests”People without a Rally account can play your game once it’s published as unlisted or public: they choose Play as guest on its play link. A guest is a player like any other, with a few differences:
guestistrue, on the server and on every player’s device, so you can show them a note such as “Sign in to keep your progress”.- Their
userIdstarts withguest_and their name looks likeGuest1234. Both are throwaway. - Nothing is saved for them. Data stored under their
userIdlasts only while the server runs. See Keys. - They can’t use the chat, and they never see your server’s output.
Characters
Section titled “Characters”A character is the player’s body: a capsule 5 studs tall and 1 stud in radius
(Character.height and Character.radius). Other players see a name tag over it, with a health
bar when it’s hurt.
| Member | What it is |
|---|---|
position |
The center of the body. Setting it teleports the character. |
rotation |
Which way it faces, in degrees; y is the turn. |
velocity |
How fast it’s moving. Read-only. |
state |
'idle', 'running', 'jumping', 'falling' or 'dead'. Read-only. |
walkSpeed |
Studs per second. Default 16, from 0 to 200. |
jumpPower |
Upward speed when jumping. Default 50, from 0 to 400. |
health, maxHealth |
Default 100 each. |
color |
Body color. |
player |
The Player it belongs to. Read-only. |
teleport(position) |
Moves it there instantly. |
takeDamage(amount) |
Lowers its health by amount, never below 0. |
kill() |
Sets its health to 0. |
died |
Fires when its health reaches 0. |
Who moves a character
Section titled “Who moves a character”To feel responsive, each player’s device moves their own character the moment they press a key,
and tells the server where it went. The server checks every move: the distance covered sideways
must fit the character’s walkSpeed, and the distance up must fit its jumpPower, with a little
slack; falling is always allowed. A move that doesn’t fit is rejected, and the player is put back
where they were.
That has a few consequences for your scripts:
- Change speed, jumping and health on the server. The player’s device picks up the new values. Values a LocalScript sets stay on that device, and the server keeps checking against its own.
- Move characters from the server with
teleport(or by settingposition). It’s an instant jump — calling it every step won’t make smooth movement. velocityandstateare read-only. They come from the player’s device.
Spawning
Section titled “Spawning”When a character spawns, the game puts it on top of a SpawnLocation in the Workspace — one picked
at random from those whose enabled property is on, spread out a little so characters don’t land
inside each other. With no SpawnLocation at all, characters appear near the center of the world.
Every new character gets the defaults in StarterPlayer — characterWalkSpeed,
characterJumpPower and characterMaxHealth, with full health — and the player’s teamColor as
its body color. To give characters something else, change them in characterAdded.
After a character dies, the player gets a new one after Players.respawnTime seconds (3 by
default).
Checkpoints
Section titled “Checkpoints”Set a player’s respawnLocation to a part and they always spawn on top of it, instead of at a
random SpawnLocation. It has to be in the Workspace, and a SpawnLocation counts only while it’s
enabled; set it back to null for random spawns again. A Checkpoint behavior inside a part does
this for you. Here, touching a checkpoint makes it the player’s spawn point:
import { Character, Folder, getChild, Players, SpawnLocation, workspace } from '@rally/sdk';
// Every checkpoint is an enabled SpawnLocation in Workspace/Checkpoints.const checkpoints = getChild(workspace, 'Checkpoints', Folder);const start = getChild(checkpoints, 'Start', SpawnLocation);
// New players start at the start, not at a random checkpoint.Players.playerAdded.connect((player) => { player.respawnLocation = start;});
// Touching a checkpoint makes it where you respawn.for (const checkpoint of checkpoints.getChildren()) { if (!(checkpoint instanceof SpawnLocation)) continue; checkpoint.touched.connect((other) => { if (!(other instanceof Character)) return; const player = Players.getPlayerFromCharacter(other); if (player) player.respawnLocation = checkpoint; });}You can also move a character yourself once it has spawned — to a position that isn’t a SpawnLocation, say. Wait one step first, so your move comes after the spawn:
import { Players, task, Vector3 } from '@rally/sdk';
// Every character spawns as usual, then gets dropped from high above its spawn point.Players.playerAdded.connect((player) => { player.characterAdded.connect(async (character) => { await task.wait(); // let the spawn finish first, so this move wins character.teleport(character.position.add(new Vector3(0, 60, 0))); });});Spawning on your own schedule
Section titled “Spawning on your own schedule”Round-based games often keep everyone out of the world until a round starts. Set
Players.characterAutoLoads to false: characters then only spawn when you call
player.loadCharacter(), and characters that die stay down until you do.
import { getChild, Part, Players, task, Vector3, workspace } from '@rally/sdk';
// Nobody spawns until a round starts.Players.characterAutoLoads = false;
const arena = getChild(workspace, 'Arena', Part);
task.spawn(async () => { for (;;) { await task.wait(10); // intermission: everyone waits as a spectator for (const player of Players.getPlayers()) { player.loadCharacter(); } await task.wait(); // let the spawns finish, then move everyone into the arena for (const player of Players.getPlayers()) { player.character?.teleport(arena.position.add(new Vector3(0, 5, 0))); } await task.wait(60); // the round for (const player of Players.getPlayers()) player.character?.kill(); }});Falling out of the world
Section titled “Falling out of the world”A character that falls below the Workspace’s fallenPartsDestroyHeight (-500 by default) dies, and
respawns as usual. Raise it for maps over a void, so falling players don’t wait long.
Health and damage
Section titled “Health and damage”takeDamage(amount)lowers health, never below 0, and does nothing to a character that’s already dead.kill()sets health to 0.- You can also set
healthandmaxHealthdirectly.healthisn’t capped atmaxHealth, so cap it yourself when you heal. - When health reaches 0, the character’s
statebecomes'dead',diedfires, and the player respawns afterrespawnTime.
Characters don’t heal on their own. This script adds lava that hurts, and slow healing:
import { Character, getChild, Part, Players, task, workspace } from '@rally/sdk';
// Lava hurts 20 per touch; standing still in it doesn't, so it's a "step off quickly" hazard.const lava = getChild(workspace, 'Lava', Part);lava.touched.connect((other) => { if (other instanceof Character) other.takeDamage(20);});
// Characters don't heal on their own. Heal everyone 2 health per second.task.spawn(async () => { for (;;) { await task.wait(1); for (const player of Players.getPlayers()) { const c = player.character; if (c && c.health > 0 && c.health < c.maxHealth) { c.health = Math.min(c.maxHealth, c.health + 2); } } }});Speed and jumping
Section titled “Speed and jumping”walkSpeed and jumpPower are yours to change, from the server. A speed pad:
import { Character, getChild, Part, StarterPlayer, task, workspace } from '@rally/sdk';
const pad = getChild(workspace, 'SpeedPad', Part);pad.touched.connect((other) => { if (!(other instanceof Character) || other.health <= 0) return; other.walkSpeed = 40; task.delay(3, () => { if (!other.destroyed) other.walkSpeed = StarterPlayer.characterWalkSpeed; });});To freeze a player in place — during a countdown, say — set both walkSpeed and jumpPower to 0,
and put them back afterwards.
There’s no built-in team system: you build teams from a few pieces. Record each player’s team in an
attribute (it replicates, so every device can read it), color their characters with teamColor,
and spawn each team on its own pad with respawnLocation:
import { Color3, Folder, getChild, type Player, Players, SpawnLocation, workspace } from '@rally/sdk';
const spawns = getChild(workspace, 'TeamSpawns', Folder);const TEAMS = { Red: { color: Color3.fromHex('#ff4d4d'), spawn: getChild(spawns, 'Red', SpawnLocation) }, Blue: { color: Color3.fromHex('#4d7cff'), spawn: getChild(spawns, 'Blue', SpawnLocation) },};type Team = keyof typeof TEAMS;
function teamOf(player: Player): Team | undefined { const team = player.getAttribute('team'); return team === 'Red' || team === 'Blue' ? team : undefined;}
function count(team: Team): number { return Players.getPlayers().filter((p) => teamOf(p) === team).length;}
// playerAdded runs before the first character spawns, so all of this applies to it.Players.playerAdded.connect((player) => { const team: Team = count('Red') <= count('Blue') ? 'Red' : 'Blue'; player.setAttribute('team', team); // attributes replicate: every device can read it player.teamColor = TEAMS[team].color; // the body color of the characters they spawn with player.respawnLocation = TEAMS[team].spawn; // always spawn on the team's pad});
/** No friendly fire: call this before one player damages another. */function canHurt(attacker: Player, victim: Player): boolean { return teamOf(attacker) !== teamOf(victim);}canHurt is there for your damage code: check it before one player hurts another. To show teams in
the player list, add a StringValue called Team to the player’s leaderstats.
The player list and leaderstats
Section titled “The player list and leaderstats”Every game shows a list of the players in the server, in the top-right corner. To add columns to it,
give a player a Folder named leaderstats holding NumberValue, StringValue or BoolValue
objects: each value’s name becomes a column. The list is sorted by the first column, highest first,
when it holds numbers.
createLeaderstats builds the folder for you, with one NumberValue per entry, and returns the
values by name:
import { createLeaderstats, NumberValue, Players, task } from '@rally/sdk';
// Two columns in the player list: seconds survived (the list is sorted by it) and deaths.Players.playerAdded.connect((player) => { const stats = createLeaderstats(player, { Survived: 0, Deaths: 0 }); player.characterAdded.connect((character) => { stats.Survived.value = 0; character.died.connect(() => { stats.Deaths.value += 1; }); });});
// Once a second, count up for everyone who's alive.task.spawn(async () => { for (;;) { await task.wait(1); for (const player of Players.getPlayers()) { const character = player.character; const survived = player.findFirstChild('leaderstats')?.findFirstChild('Survived'); if (character && character.health > 0 && survived instanceof NumberValue) { survived.value += 1; } } }});leaderstats replicates to every player, so a LocalScript can read anyone’s stats — to show them in
your own UI, for example. Change them only on the server. To keep stats between visits, save them
with data stores, and see
Co-op game with a leaderboard for an all-time board.
Removing a player
Section titled “Removing a player”player.kick(message) removes a player from the game. They see You were removed from the game
with your message. Kicking is server-only.
On a player’s device
Section titled “On a player’s device”In a LocalScript, Players.localPlayer is the player whose device it is. waitForLocalPlayer()
resolves with it, typed as a Player. Their character comes and goes as they die and respawn, so
handle the current one and every later one:
import { type Character, print, task, waitForLocalPlayer } from '@rally/sdk';
task.spawn(async () => { const me = await waitForLocalPlayer(); const onCharacter = (character: Character) => { print(`Spawned with ${character.health} health`); character.getPropertyChangedSignal('health').connect(() => { if (character.health < 30) print('Careful, low health!'); }); }; if (me.character) onCharacter(me.character); me.characterAdded.connect(onCharacter);});Changes the server makes to your character — health, speed, color — arrive like any other change, and their change events fire on your device. Your own device decides where your character is, so moves the server makes arrive as teleports.
Next steps
Section titled “Next steps”- Input and camera — what players press, and what they see.
- Multiplayer — let players’ actions reach the server safely.
- Data stores — keep progress between visits.