Appearance
Players and Characters
Written: 2026-10-14
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
Track joins and leaves
Players owns every player object. 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 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 controls one Character model. Character holds a Humanoid, body parts, and scripts. The 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 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 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 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.
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 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 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
endIt 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.
Exercises
- Greet every current and future player.
- Print health and root position on every spawn.
- Build display-only leaderstats on the server.
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.
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.
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 and animate through 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)
endAnimation 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.