Understanding Script Contexts

There’s some confusion about how scripts behave when they’re deleted, so I figured I’d explain it in one giant post and forget about it.

This assumes that you understand coroutines. If you don’t, go read up on that now.

Scripts, Events, and Threads

So, when a Script or LocalScript is deleted, how does it just stop working?

When a script runs for the first time, the game engine creates an initial ‘thread’ from the script’s main body. Before this thread starts, the game engine assigns it a ‘script context’, which ties it to the script that it was created from. As it creates event hooks ( event:Connect(function(...) ), they are also assigned to the script context, so that when they are later fired, new threads are created and assigned to the script context. Any event hooks created in those threads are bound to the script context and so on.

Ultimately, all the threads and event hooks are bound to the same script context, so that when the script is later deleted, the game engine breaks all the event hooks and doesn’t resume yielding threads, stopping the entire script.

Let’s take this script for example:

ScriptContext

-- game.ServerScriptService.ScriptA

wait(3)
local player = game.Players:GetPlayers()[1]
local humanoid = player.Character.Humanoid
humanoid:GetPropertyChangedSignal("Health"):Connect(function()
	wait(0.2)
	print(player.Name .. "'s Health: " .. humanoid.Health)
end)
wait(2)
humanoid.Health = humanoid.Health / 2

After 3 seconds, this starts listening to an event, and then after another 2 seconds, takes away half your health, but what happens specifically:

  1. The initial thread is started for the entire script and is assigned to a script context.
  2. The thread yields at wait(3), allowing other stuff to run.
  3. After the thread resumes, the event is connected, so the event connection is also assigned to the script context.
  4. The thread again yields at wait(2).
  5. The player’s Health is halved, immediatly triggering the event hook and spawning a brand new thread, which is assigned to the same script context as the event hook.
  6. The new thread yields at wait(0.2).
  7. The inital thread immediately ends. The game engine isn’t tracking it anymore.
  8. The new thread resumes after 0.2 seconds and then prints the player’s health.
  9. The new thread ends and is no longer tracked.
  10. The event hook is fired repeatedly over the next several seconds as another script slowly heals the player, spawning a new thread each time.

So, what happens if another script deletes this script after 5.1 seconds from the start?

-- game.ServerScriptService.ScriptB
wait(5.1)
script.Parent.ScriptA:Destroy()

The event hook is already created and the initial thread is done; it’s not running anymore, and the event thread is 0.1 seconds into the wait(0.2) command.

So when the script is deleted, the script context is destroyed, and so the associated event hook is broken and won’t fire anymore. The task scheduler managing wait(0.2) sees the thread belongs to the dead context, and therefore never resumes it. The script never reaches a print statement, so it doesn’t print anything.

ModuleScript Confusion

The behavior is great for those older games where scripted bricks get blown up and later deleted, or weapons get dropped into the void as they’re animating, but when ModuleScripts are used, it becomes a mess, because things break when a deleted script originally called a function in a ModuleScript to start a mechanism which serves multiple other scripts.

Let’s say for example there is a Script that starts a loop in a ModuleScript and then deletes itself:

-- game.ServerScriptService.Script

local module = require(script.Parent.ModuleScript)
module.Start()
wait(4)
script:Destroy()

-- game.ServerScriptService.ModuleScript

local module = {}

module.IsRunning = false
module.Counter = 0

function module.Start()
	if not module.IsRunning then
		module.IsRunning = true
		spawn(function(...)
			while true do
				module.Counter = module.Counter + 1
				print(module.Counter)
				wait(1)
			end
		end)
	end
end

return module

It would seem that once started, the loop should run forever because it is physically located in the ModuleScript’s Start function.

Nope! That’s not what happens. Instead, the loop increments module.Counter to 4 and then stops.

This is because the thread running module.Start() belongs to the regular Script’s context, so the game engine associates the spawn loop with that script instead of the ModuleScript. This means when that regular script is deleted, the ModuleScript’s loop stops.

To work around this, you can hook into a BindableEvent before returning the module and fire it from the triggering script so the infinite loop runs independent of the triggering script.

Alternatively, you can run module.Start() from a script that never gets destroyed.

Indestructible ModuleScripts

Similar to regular Scripts, a ModuleScript’s context begins at its main script body, and ends once the module returns. Anything done during that time is associated with the ModuleScript.

However, unlike regular Scripts, a ModuleScript only runs when require is called on it, and once running, cannot be stopped by conventional means. Specifically, the game engine doesn’t destroy the context when ModuleScript:Destroy() is done, and there is no Disabled property to toggle. This means the ModuleScript’s behaviors can be made indestructible!

This behavior can be used to run the ModuleScript from nil, but unless you create self-destruct mechanisms, the only way to stop the ModuleScript is to stop the game itself.

Here is an example:

local module = {}

module.Counter = 0

spawn(function(...)
	-- This runs forever, because it's started under the ModuleScript's context!
	while true do
		module.Counter = module.Counter + 1
		print(module.Counter)
		wait(1)
	end
end)

return module
14 Likes

Yeah, I use this behavior for anti-exploit LocalScripts that I place into ReplicatedFirst, as basically 99% of all crappy exploits cannot access it and stop it once I delete the script instance itself.

Edit two: oh yeah, this behavior should be the same for BaseScripts.

script:Destroy()

while true do
      -- Code goes here. It will never cease running for some reason; even if the script instance is destroyed.
      break 
end
4 Likes

I don’t think it would have any effect and now I am curious to see if an exploiter can get through that hack.

I feel like it’d just be similar to parenting a script to nil or something? (I am not implying :Destroy() is only setting a parent to nil)

The Destroy function completely wipes the trace of the BaseScript instance. Parenting it to nil wouldn’t do much compared to that, as they can still access it in nil.

The only way they can really bypass this is by using exploits that can run code before ReplicatedFirst, but I think there’s really only one exploit that can do that. So, doing this basically stops 99% of exploiters from even tampering with said scripts.

1 Like