Optimized XP System in Roblox Without Using While Loops

Hello guys, is there any way to make an optimized system?

I’m building a +1 Speed Escape Obby, and I’m wondering if there’s a better way to avoid using a while loop for XP gain.

Basically, I want to give XP only when the player is running and when Activity == nil (because if the attribute exists, it means the player is on a treadmill and should instead get XP passively while AFK).

Is there a more efficient or event-based way to handle this instead of constantly checking inside a loop?

function LevelServiceServer:SetupCharacter(player: Player, character: Model)
	local humanoid: Humanoid = character:WaitForChild("Humanoid")

	self.ApplySpeedModifier(player)

	local isRunning = false
	local alive = true

	humanoid.Running:Connect(function(speed)
		isRunning = speed > 0
	end)

	humanoid.Died:Connect(function()
		alive = false
	end)

	character.AncestryChanged:Connect(function(_, parent)
		if not parent then
			alive = false
		end
	end)

	task.spawn(function()
		while alive do
			local activity = player:GetAttribute("Activity")

			if isRunning and humanoid.Health > 0 and not activity then
				print("working")
				self.AddXp(player, xpPerSecond, true)
			elseif activity then
				print("not working")
			end

			task.wait(1)
		end
	end)
end
1 Like

Use the stuff you already have. humanoid.Running fires when the player starts or stops moving, and GetAttributeChangedSignal("Activity") fires when the treadmill activity changes. Have both call one evaluate() function.

For the ticker (instead of using loops), use something like task.delay() and have it schedule itself every second. Before giving XP, do one quick check to make sure the conditions are still valid. If not, stop and do not schedule another tick.

function LevelServiceServer:SetupCharacter(player: Player, character: Model)
    local humanoid: Humanoid = character:WaitForChild("Humanoid")
    self.ApplySpeedModifier(player)

    local isRunning = false
    local isDead = false
    local xpThread = nil -- handle to the pending task.delay()

    -- re-queue itself every second while conditions are true
    local function scheduleXp()
        if xpThread then return end
        xpThread = task.delay(1, function()
            xpThread = nil
            if isDead or not isRunning or player:GetAttribute("Activity") then return end
            self.AddXp(player, xpPerSecond, true)
            scheduleXp()
        end)
    end

    -- cancel the pending tick if one alraedy exists
    local function stopXp()
        if xpThread then
            task.cancel(xpThread)
            xpThread = nil
        end
    end

    -- this gets called by every event, single place that decides if the ticker runs
    local function evaluate()
        if isDead then stopXp(); return end
        if isRunning and not player:GetAttribute("Activity") then
            scheduleXp()
        else
            stopXp()
        end
    end

    humanoid.Running:Connect(function(speed)
        isRunning = speed > 0
        evaluate()
    end)

    humanoid.Died:Connect(function()
        isDead = true
        evaluate()
    end)

    character.AncestryChanged:Connect(function(_, parent)
        if not parent then
            isDead = true
            evaluate()
        end
    end)

    player:GetAttributeChangedSignal("Activity"):Connect(evaluate)
end
1 Like

Fixed script:
function LevelServiceServer:SetupCharacter(player: Player, character: Model)
local humanoid: Humanoid = character:WaitForChild(“Humanoid”)

self.ApplySpeedModifier(player)

local isRunning = false
local xpThread = nil

local function stopXp()
	if xpThread then
		task.cancel(xpThread)
		xpThread = nil
	end
end

local function startXp()
	if xpThread then return end -- already running
	xpThread = task.spawn(function()
		while true do
			task.wait(1)
			-- re-check inside the tick in case state changed mid-wait
			if not isRunning or humanoid.Health <= 0 or player:GetAttribute("Activity") then
				stopXp()
				return
			end
			self.AddXp(player, xpPerSecond, true)
		end
	end)
end

local function evaluateState()
	local activity = player:GetAttribute("Activity")
	if isRunning and humanoid.Health > 0 and not activity then
		startXp()
	else
		stopXp()
	end
end

humanoid.Running:Connect(function(speed)
	isRunning = speed > 0
	evaluateState()
end)

player:GetAttributeChangedSignal("Activity"):Connect(evaluateState)

humanoid.Died:Connect(function()
	isRunning = false
	stopXp()
end)

character.AncestryChanged:Connect(function(_, parent)
	if not parent then
		isRunning = false
		stopXp()
	end
end)

end

evaluateState() is called by every relevant event instead of checked every second. The XP thread only spawns when the player is actually running with no Activity, and gets cancelled the moment any condition breaks.
task.cancel() cleanly kills the thread — no alive flag needed, no waiting for the next loop iteration to notice the state changed.
startXp() guard — if xpThread then return end prevents double-spawning if two events fire in the same frame (e.g. speed changes while Activity also clears).
Re-check inside the tick — the task.wait(1) means up to 1 second can pass before the thread notices a mid-wait state change. The guard at the top of the tick catches that edge case and exits cleanly rather than granting a phantom XP tick.
GetAttributeChangedSignal(“Activity”) replaces polling the attribute every second, so treadmill transitions are instant instead of delayed by up to 1 second.

1 Like