Skip to content

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.

When someone joins, the server:

  1. creates a Player for them, named after their display name, with a PlayerGui holding copies of everything in StarterGui;
  2. puts the player in Players and fires Players.playerAdded;
  3. 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:

ServerScriptService/PlayerSetup.server.ts
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.

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.

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:

  • guest is true, 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 userId starts with guest_ and their name looks like Guest1234. Both are throwaway.
  • Nothing is saved for them. Data stored under their userId lasts only while the server runs. See Keys.
  • They can’t use the chat, and they never see your server’s output.

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.

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 setting position). It’s an instant jump — calling it every step won’t make smooth movement.
  • velocity and state are read-only. They come from the player’s device.

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

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:

ServerScriptService/Checkpoints.server.ts
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:

ServerScriptService/SkyDrop.server.ts
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)));
});
});

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.

ServerScriptService/Rounds.server.ts
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();
}
});

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.

  • 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 health and maxHealth directly. health isn’t capped at maxHealth, so cap it yourself when you heal.
  • When health reaches 0, the character’s state becomes 'dead', died fires, and the player respawns after respawnTime.

Characters don’t heal on their own. This script adds lava that hurts, and slow healing:

ServerScriptService/Health.server.ts
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);
}
}
}
});

walkSpeed and jumpPower are yours to change, from the server. A speed pad:

ServerScriptService/SpeedPad.server.ts
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:

ServerScriptService/Teams.server.ts
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.

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:

ServerScriptService/Leaderboard.server.ts
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.

player.kick(message) removes a player from the game. They see You were removed from the game with your message. Kicking is server-only.

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:

StarterPlayer/StarterPlayerScripts/MyCharacter.client.ts
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.