NPC Kit Add-on: Leash Script

Eskil recently approached me about a limitation with the NPC Kit, where enemies can be farmed in exploits, such as luring them outside of their intended fighting area. In our discussion I realized two things. That first, this problem has been solved before in games. And second, I can solve this for the NPC Kit without even modifying it.

Below is a script that can be added as a child of a trigger. The trigger is positioned and scaled to determine the valid play area for the NPCs at that location. In the video, the trigger is setup as a Sphere and scaled to match the stone ring. When an NPC exits the trigger the leash script sends them back home. Any damage done to them while they are retreating is blocked. The optional flag LEASH_HEALS can also be enabled, causing attacks to heal instead of damaging, while NPCs are in this retreating state.

NPC Leash Zone Hierarchy

What this script also demonstrates is how NPCs can be manipulated in various ways, without the need to modify the AI logic or combat directly. Custom gameplay can be added as auxiliary scripts, modifying damage, spawning/unspawning, loot dropping, movement as well as all kinds of level design constraints can be built into the kit's pluggable areas. That said, I realize it's not a very clean API. With this script I hope to illuminate a bit of what's possible.

	Leash Zone
	by: standardcombo
	Placed under a trigger that defines the zone. When NPCs exit the trigger/zone
	they are sent back to patrol or back to their spawn point.
	While returning to their patrol/spawn the NPCs ignore attacks. Attack damage
	is reduced to zero, but can also be set to heal the NPC if it's attacked
	while leashed.

local TRIGGER = script.parent
local LEASH_DURATION = script:GetCustomProperty("Duration") or 7
local LEASH_HEALS = script:GetCustomProperty("LeashHeals") or false

local leashedNPCs = {}

function FindAiScript(obj)
	if not obj.FindTemplateRoot then return end
	local templateRoot = obj:FindTemplateRoot()
	if (templateRoot == nil) then
		templateRoot = obj.parent
	if templateRoot then
		-- Team mismatch exit condition
		local team = templateRoot:GetCustomProperty("Team") or 0
		if (team ~= 0 and ~= 0 and team ~= then
			return nil
		-- Search for AI script
		local scripts = templateRoot:FindDescendantsByType("Script")
		for _,s in ipairs(scripts) do
			if s.context.SetObjective then
				return s
	return nil

function OnNPCDestroyed(obj)
	leashedNPCs[obj] = nil

function OnEndOverlap(trigger, obj)
	local aiScript = FindAiScript(obj)
	if aiScript then
		aiScript.context.SetTemporaryVisionRadius(0, LEASH_DURATION)
		aiScript.context.SetTemporaryHearingRadius(0, LEASH_DURATION)
		if not leashedNPCs[aiScript] then
			leashedNPCs[aiScript] = true
		if Object.IsValid(aiScript) then
			leashedNPCs[aiScript] = nil

function OnGoingToTakeDamage(attackData)
	if not Object.IsValid(attackData.object) then return end
	local aiScript = FindAiScript(attackData.object)
	if aiScript and leashedNPCs[aiScript] then
		if LEASH_HEALS then
			attackData.damage.amount = -attackData.damage.amount
			attackData.damage.amount = 0
Events.Connect("CombatWrapAPI.GoingToTakeDamage", OnGoingToTakeDamage)
1 Like