The correct way to do CharacterAdded and PlayerAdded

The CharacterAdded and PlayerAdded scripting events are a beginner’s trap!

You are almost guaranteed to run into issues the first time using them. I’ve seen this issue pop up for new programmers in my experience. This tutorial will help you understand the problem.


TL;DR please don’t do this:

-- naive method
game.Players.PlayerAdded:Connect(function(player)
  player.CharacterAdded:Connect(function(character)
    print("character loaded!", character) 
    -- YOUR CODE HERE
  end
end

Do this instead:

-- proper method
local function newCharacter(character)
  print("character loaded!", character)
  -- YOUR CODE HERE
end

local function newPlayer(player)
  if player.Character then
    task.defer(newCharacter, player.Character)
  end
  player.CharacterAdded:Connect(newCharacter)
end

for _,player in game.Players:GetPlayers() do
  task.defer(newPlayer, player)
end
game.Players.PlayerAdded:Connect(newPlayer)

“Why shouldn’t I do the naive method?”

Issue: binding .added events without checking for existing children

By doing the naive method, you will eventually* get code that doesn’t run in Studio when you connect CharacterAdded/PlayerAdded, and it will happens randomly.


Race conditions (in this context) are an issue in Roblox where certain code blocks fire out of order or in a random order.

You want to set your character’s WalkSpeed to 32 when they first spawn in. You create a server script in ServerScriptService.

game.Players.PlayerAdded:Connect(function(player)
  player.CharacterAdded:Connect(function(character)
    character.Humanoid.WalkSpeed = 32
  end
end

In Roblox studio, we can’t guarantee that this script loads before the player spawns in*. Sometimes the game spawns you in first, THEN the script runs. Other times, the script runs first, THEN you spawn in. In the example, you will only get WalkSpeed = 32 IF the script runs before you join the game.

If the player joined the game before we do :Connect, PlayerAdded will not trigger. Same with CharacterAdded, if the player spawned BEFORE we call :Connect, it won’t trigger.

We MUST call PlayerAdded:Connect() before the first players join the game.
We MUST call CharacterAdded:Connect() before the first character respawns.

This is normally easy to do for a published game, since servers often need to boot up before letting players in. You will rarely see this issue in-game (although it can still happen).

Studio Testing does not have this luxury.
We have no control over which gets to go first. The Roblox engine decides it.

Therefore, the best we can do is account for the already existing players/characters that spawned in before the script runs.

Hence:

if player.Character then -- account for an already spawned character
	handleCharacter()
end
player.CharacterAdded:Connect(handleCharacter)

OR

for _,player in game.Players:GetPlayers() do -- loop through all existing players
	handlePlayer(player)
end
game.Players.PlayerAdded:Connect(handlePlayer)

This guarantees that the function will ALWAYS bind to the player/character regardless of ordering, join time, or yielding.


* Correction pointed out by @Judgy_Oreo:

Correction + Author's Notes

Correction: You will not face this issue if your script has no yields prior to the event binds. For example, the naive code above works perfectly by itself.

However, in real game projects with varying dependencies on open sourced code, roblox async APIs, streaming enabled, :WaitForChild(), InvokeServer/Client, varying programmer styles, and other yielding causes are bound to show up in the code prior to character binding. Worse yet, the script becomes a ticking time bomb for future problems when eventually a yielding setup is used. Without proper planning and heavy enforcement of a specific code style, we cannot unintentionally avoid this issue. Therefore, I think it is safer to implement this setup than to not do it.


Apply first, connect last

Why can’t I do:

player.CharacterAdded:Connect(handleCharacter) -- inversion, connect first, loop after
if player.Character then
	handleCharacter()
end

I have not seen this cause issue in practice. I believe this is bad habit though. There may or may not be a scenario where CharacterAdded might trigger simultaneously along with player.Character, leading to a double trigger, which could be bad. In my years of experience I’ve only been using connect-last and can’t vouch if connect-first is valid and unlikely to cause bugs. TL;DR you can do it but at your own risk.

if player.Character then -- my preferred ordering
	handleCharacter()
end
player.CharacterAdded:Connect(handleCharacter) -- connect-last

“Why do you use task.defer()?”

Consider this scenario:

local function newPlayer(player) -- this function yields!
  task.wait(5)
end

for _,v in game.Players:GetPlayers() do
  newPlayer(v)
end
game.Players.PlayerAdded:Connect(newPlayer)

If your binded function has a wait(), it will yield the loop, stalling the PlayerAdded event. If anyone joins the game during that for loop, they won’t be “caught” by the function.

We MUST perform the for loop quickly and bind PlayerAdded:Connect(), otherwise, we have a blind spot of at least 5 seconds in this example!


Error handling/disconnecting events

This tutorial’s scope does not in include disconnecting events. Rarely do I ever disconnect Character/Player binds. For me, these binds are usually part of scripts that run once in runtime. You will have to figure out a disconnecting system.

Errors will show the correct stack trace so they don’t need additional work.


“Ok, I won’t do the thing! Do you have a module to do this automatically?”

Yes, I wrote this for PlayerAdded and CharacterAdded since they are the most common use cases.

--[[
	SAMPLE CODE:
	
	local CharacterBind = require(...)

	CharacterBind:BindPlayer(function(player: Player)
		-- this code is guaranteed to run once for all players, current or future
		print("procesing player", player)
		task.wait(1) -- simulate yielding
		print("procesing done", player)
	end)

	CharacterBind:BindCharacter(function(character: Model, player: Player)
		-- this code is guaranteed to run once for every character, current or future
		-- respawning will also trigger this code on the new character
		print("procesing character", character, "belonging to player", player)
		task.wait(1) -- simulate yielding
		print("procesing done", character, "belonging to player", player)
	end)
--]]

local module = {}

function module:BindPlayer(playerFunction)
	-- runs playerFunction on all players, including ones that join in the future
	for _,player in game:GetService("Players"):GetPlayers() do
		task.defer(playerFunction, player)
	end
	game:GetService("Players").PlayerAdded:Connect(playerFunction)
end

function module:BindCharacter(characterFunction)
	-- runs playerFunction on all characters, including ones that join/respawn in the future
	module:BindPlayer(function(player)
		if player.Character then
			task.defer(characterFunction, player.Character, player)
		end
		player.CharacterAdded:Connect(function(character)
			characterFunction(character, player)
		end)
	end)
end

return module
10 Likes

If the Script is static (i.e. it always exists in the same place and isn’t dynamically :Clone’d), this could only be an issue if you yielded at the top of the Script. Otherwise, I’m pretty sure Roblox runs all of your Scripts as the Server itself loads, before the fake Studio Client can connect. I don’t think I’ve ever had a race condition like this happen to me, even with a very slow computer.

1 Like

Yes, you are correct. I verified it on my end.

I have updated the page to include your correction. I feel this tutorial is still important because of the unpredictability of scripts. Since requires() and :WaitForChild() often show up at the top of scripts and :Connect/event blocks tend to be at the bottom of scripts, there is often a yield component before events are binded. The exception is if you have a very specialized async promise setup. See notes.

P.S. I could’ve sworn it used to be unpredictable XD. I’ve been on this platform for so long I don’t remember if that’s always been there or not.

1 Like

Here’s another interesting factoid, assuming the Instance you are trying to find with :WaitForChild is static and also not :Clone’d or moved around, and (if you’re on the Client) it’s not in the Workspace (assuming it can be Streamed out with Instance Streaming), then you don’t need to use :WaitForChild. All Instances that fit that description are guaranteed to exist before any Scripts run.

It’s something I see beginners do a lot, so if anyone’s reading this thread with a habit of using :WaitForChild on everything, just-in-case, hopefully you know now.

1 Like