---
url: /roblox-fundamentals/players-and-characters.md
description: Handle joining players, characters, respawn, and display-only leaderstats.
---

# Players and Characters

*Written: {{ $frontmatter.date }}*

## Learning objectives

* You can track joins with PlayerAdded and leaves with PlayerRemoving.
* You can cover pre-joined players with a loop.
* You can read the character model, Humanoid, and HumanoidRootPart.
* You can handle respawn with CharacterAdded.
* You can treat leaderstats as display-only and guard LocalPlayer.

## Prerequisites

* [Services and Events](./services-and-events.md)
* [Task and Async](./task-and-async.md)
* [DataModel and Instances](./datamodel-and-instances.md)

## Track joins and leaves

[Players](https://create.roblox.com/docs/en-us/reference/engine/classes/Players) owns every player object. [PlayerAdded](https://create.roblox.com/docs/en-us/reference/engine/classes/Players#PlayerAdded) fires when someone joins. PlayerRemoving fires right before someone leaves.

Connect both events at the top of your server script. Then loop over current players. Scripts can start after players have joined, so the loop covers anyone who arrived early. The [services](https://create.roblox.com/docs/en-us/scripting/services) guide uses this same connect-plus-loop shape.

**Script in ServerScriptService**

```luau
--!strict
local Players = game:GetService("Players")

local function hello(player: Player): ()
	print(player.Name)
end

local function goodbye(player: Player): ()
	print(player.Name)
end

for _, player in Players:GetPlayers() do
	hello(player)
end
Players.PlayerAdded:Connect(hello)
Players.PlayerRemoving:Connect(goodbye)
```

Define join and leave handlers. Greet current players. Connect future joins and leaves. Expected behavior: Output prints every current name once, then prints each join and leave as it happens.

## Read the character

Each [Player](https://create.roblox.com/docs/en-us/reference/engine/classes/Player) controls one Character model. [Character](https://create.roblox.com/docs/en-us/reference/engine/classes/Player#Character) holds a [Humanoid](https://create.roblox.com/docs/en-us/reference/engine/classes/Humanoid), body parts, and scripts. The [characters](https://create.roblox.com/docs/en-us/characters) guide describes characters as interactive models you control or meet in game.

HumanoidRootPart is the positioning root. Measure distance from it. Move the character through it. [Humanoid](https://create.roblox.com/docs/en-us/reference/engine/classes/Humanoid) carries health and state. It gives the model character behavior.

**Script in ServerScriptService**

```luau
--!strict
local Players = game:GetService("Players")

local function inspect(player: Player): ()
	local character = player.Character
	if not character then
		return
	end
	local humanoid = character:FindFirstChildOfClass("Humanoid")
	local root = character:FindFirstChild("HumanoidRootPart")
	if humanoid and root and root:IsA("BasePart") then
		print(humanoid.Health)
		print(root.Position)
	end
end

for _, player in Players:GetPlayers() do
	inspect(player)
end
Players.PlayerAdded:Connect(inspect)
```

Guard every step. Read Character, which can be nil. Find the Humanoid by class and the root by name. Check the root type before reading Position. Expected behavior: Output prints health and position for each spawned player, and skips players with no character.

## Handle respawn

Characters come and go. [CharacterAdded](https://create.roblox.com/docs/en-us/reference/engine/classes/Player#CharacterAdded) fires on every spawn and respawn. Connect it once per player. Do your per-life setup inside it.

**Script in ServerScriptService**

```luau
--!strict
local Players = game:GetService("Players")

local function onCharacter(character: Model): ()
	local humanoid = character:WaitForChild("Humanoid")
	print(humanoid.Name)
end

local function onPlayer(player: Player): ()
	player.CharacterAdded:Connect(onCharacter)
	local current = player.Character
	if current then
		onCharacter(current)
	end
end

for _, player in Players:GetPlayers() do
	onPlayer(player)
end
Players.PlayerAdded:Connect(onPlayer)
```

Two handlers nest here. The outer handler runs per player. The inner handler runs per life. Also handle a character that already exists. Expected behavior: Output prints `Humanoid` on every spawn, including respawns after death.

## Leaderstats are display-only

Leaderstats is a Folder named exactly `leaderstats` under each player. It holds value objects like IntValue. Roblox shows them in the player list, per the [leaderboards](https://create.roblox.com/docs/en-us/players/leaderboards) guide. That display is its only power: it mirrors numbers, it never owns them.

Keep the real numbers on the server. Copy them into leaderstats for view. Never let the client write them or trust them back. A later chapter enforces this with validation in [Server Authority](./server-authority.md).

**Script in ServerScriptService**

```luau
--!strict
local Players = game:GetService("Players")

local function setup(player: Player): ()
	local stats = Instance.new("Folder")
	stats.Name = "leaderstats"
	local coins = Instance.new("IntValue")
	coins.Name = "Coins"
	coins.Value = 0
	coins.Parent = stats
	stats.Parent = player
	print(stats.Name)
end

for _, player in Players:GetPlayers() do
	setup(player)
end
Players.PlayerAdded:Connect(setup)
```

Build display data parent-last. Create the folder and the value. Parent the value to the folder, then the folder to the player. Expected behavior: every player gains a `leaderstats` folder with a `Coins` value set to zero.

## Guard LocalPlayer

`LocalPlayer` exists only on the client. It is nil on the server. Any code shared across the [client-server](https://create.roblox.com/docs/en-us/projects/client-server) boundary must guard it.

Read it once in client code. Check for nil before you use it. This guard lets the same ModuleScript load safely on both sides, as the [modules](https://create.roblox.com/docs/en-us/scripting/module) guide requires for shared code.

**LocalScript in StarterPlayerScripts**

```luau
--!strict
local Players = game:GetService("Players")

local player = Players.LocalPlayer
if not player then
	return
end
print(player.Name)
```

Read the local player. Return early on nil. Print only when it exists. Expected behavior: each client prints its own name, and the server path never throws.

## Common mistakes

**Mistake 1: missing pre-joined players.**

Wrong example below. It connects but never loops.

**Script in ServerScriptService**

```luau
--!strict
local Players = game:GetService("Players")

Players.PlayerAdded:Connect(function(player: Player): ()
	print(player.Name)
end)
```

It skips anyone who joined before the connection. Studio restarts and late script starts both cause this. Fix it with a GetPlayers loop plus the connection, as shown above.

**Mistake 2: assuming Character exists.**

Wrong example below. It indexes Character directly.

**Script in ServerScriptService**

```luau
--!strict
local Players = game:GetService("Players")

Players.PlayerAdded:Connect(function(player: Player): ()
	local root = player.Character:FindFirstChild("HumanoidRootPart")
	print(root)
end)
```

It throws because Character is nil before spawn. Fix it by nil-guarding Character and waiting for spawn through CharacterAdded.

**Mistake 3: trusting leaderstats from the client.**

Wrong example below. It lets the client set the display value.

**LocalScript in StarterPlayerScripts**

```luau
--!strict
local Players = game:GetService("Players")

local player = Players.LocalPlayer
if player then
	local stats = player:FindFirstChild("leaderstats")
	if stats then
		local coins = stats:FindFirstChild("Coins")
		if coins and coins:IsA("IntValue") then
			coins.Value = 999
		end
	end
end
```

It looks easy but it grants fake wealth. The client owns that change locally and the server must ignore it. Fix it by writing leaderstats only from a server Script and validating every purchase there, as you learn in [Server Authority](./server-authority.md).

## Exercises

1. Greet every current and future player.
2. Print health and root position on every spawn.
3. Build display-only leaderstats on the server.

::: details Solution 1
**Script in ServerScriptService**

```luau
--!strict
local Players = game:GetService("Players")

local function greet(player: Player): ()
	print(player.Name)
end

for _, player in Players:GetPlayers() do
	greet(player)
end
Players.PlayerAdded:Connect(greet)
```

Expected behavior: Output prints all current names, then each newcomer.
:::

::: details Solution 2
**Script in ServerScriptService**

```luau
--!strict
local Players = game:GetService("Players")

local function track(character: Model): ()
	local humanoid = character:WaitForChild("Humanoid")
	if humanoid:IsA("Humanoid") then
		print(humanoid.Health)
	end
	local root = character:WaitForChild("HumanoidRootPart")
	print(root.Name)
end

local function watch(player: Player): ()
	player.CharacterAdded:Connect(track)
end

for _, player in Players:GetPlayers() do
	watch(player)
end
Players.PlayerAdded:Connect(watch)
```

Expected behavior: Output prints health and root name on every spawn.
:::

::: details Solution 3
**Script in ServerScriptService**

```luau
--!strict
local Players = game:GetService("Players")

local function display(player: Player): ()
	local stats = Instance.new("Folder")
	stats.Name = "leaderstats"
	local gems = Instance.new("IntValue")
	gems.Name = "Gems"
	gems.Value = 0
	gems.Parent = stats
	stats.Parent = player
end

for _, player in Players:GetPlayers() do
	display(player)
end
Players.PlayerAdded:Connect(display)
```

Expected behavior: every player shows a `Gems` score of zero in the player list.
:::

## NPC movement and character animation

NPCs need movement and animation too. NPCs move with [PathfindingService](https://create.roblox.com/docs/reference/engine/classes/PathfindingService) and animate through [Animator](https://create.roblox.com/docs/reference/engine/classes/Animator). Both live on the server; the client only renders the result.

**Pathfinding** builds a `Path` with `CreatePath` agent parameters, computes it with `ComputeAsync`, then walks `GetWaypoints`. Handle `Blocked` by recomputing, and never use the deprecated `ComputeRawPathAsync`/`ComputeSmoothPathAsync`, use `FindPathAsync` for one-shot queries. Tune `AgentRadius`, `AgentHeight`, `AgentCanJump`, and `Costs` per NPC size.

```luau
--!strict
local PathfindingService = game:GetService("PathfindingService")

local path = PathfindingService:CreatePath({
	AgentRadius = 2,
	AgentHeight = 5,
	AgentCanJump = false,
})
local ok, err = pcall(function()
	path:ComputeAsync(Vector3.new(0, 0, 0), Vector3.new(50, 0, 50))
end)
if ok and path.Status == Enum.PathStatus.Success then
	for _, waypoint in path:GetWaypoints() do
		print(waypoint.Position)
	end
else
	print(err)
end
```

**Animation** plays through the `Animator` inside the character's `Humanoid` (or `AnimationController`). Load with `animator:LoadAnimation(animation)` once, every call creates a new track, then `Play`/`Stop` the returned `AnimationTrack`. For player characters, load on the client that owns the character to replicate; for NPCs, load on the server. Never call the deprecated `Humanoid:LoadAnimation`; always go through the `Animator`.

```luau
--!strict
local humanoid = script.Parent:WaitForChild("Humanoid") :: Humanoid
local animator = humanoid:WaitForChild("Animator") :: Animator
local animation = Instance.new("Animation")
animation.AnimationId = "rbxassetid://507765644"
animation.Parent = script
local track = animator:LoadAnimation(animation)
track:Play()
```

## Summary

Greet joins and leaves. Cover early joins with a loop. Read characters with guards. Respawn through CharacterAdded. Display leaderstats without trusting them. Move NPCs with PathfindingService and animate through Animator. Next, cross the boundary in [Remotes and Networking](./remotes-and-networking.md).
