How to make a stealth system

i’m switching to Knit! I’m sorry for everyone who learnt AGF for this post :((
next thing: making the bars in the enemies’ position, and making them the smaller the more distant they are
THIS IS A WORK IN PROGRESS! POST LAST EDITED: 24/04/2021 PROJECT LAST EDITED: 11/05/2021
Henlo, fellow humans! Zamdie here, and I’ll guide you through this tutorial! ☆*:.。.o(≧▽≦)o.。.:*☆

We’ll be creating a stealth system, and for that, I’ve compiled everything you need in order to fry the pink jelly inside your head! :brain: :fire:

Let’s start, shall we? \(^▽^)/

0.1: Things our system will contain:


  • Enemy detection (enemy will detect the player);
  • Enemy paths (enemies will patrol areas);
  • Enemy takedown (players will be able to neutralize/kill enemies);
  • Enemy reactions (enemies will try to kill the player once they’re detected);

0.2: Stuff you need to know before trying to understand this tutorial


I won’t be explaining these things in the tutorial

1: Before we start


I’m going to use AeroGameFramework because it makes everything easier (─‿‿─). You don’t have to use it, but I’ll be explaining stuff on it (don’t worry, I’ll include sections to explain what to do if you aren’t using AGF (ノ_<。)ヾ(^▽^) ). I’ll be using Visual Studio Code with Rojo aswell, with the Roblox LSP extension which is god tier IMO ଘ(੭ˊ꒳​ˋ)੭✧ . advanced intellisense, something Roblox’s editor lacks. One GetService or WaitForChild and Studio refuses to give you intellisense ヾ(`ヘ´)ノ゙

2: Setting up


I’m gonna make it OOP. Why, you ask :thinking:? Because we can have one controller script and a module to handle all the logic. (´• ω •`)

So, let’s create one controller called StealthController in Client > Controllers:
image
and a class module called Enemy under Shared:
image
Then, create a ScreenGui named DetectionUIS in StaterGui:
image
and design a detection bar. Put it in ReplicatedStorage. I’ve set it up like this:
image
However, depending on the location of the actual bar (the part that you want to go up and down) you might want to change the code a bit.

Without AGF

Create a ModuleScript under ReplicatedStorage named Enemy:
image
and a LocalScript under StarterPlayer > StarterPlayerScripts named StealthController:
image

First, let’s set-up our Enemy class.

-- Enemy
-- zamd157
-- December 19, 2020
-- Last edited: April 24, 2021


-- Basic OOP setup
local Enemy = {}
Enemy.__index = Enemy


-- Services
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PathfindingService = game:GetService("PathfindingService")
local TweenService = game:GetService("TweenService")


-- Objects
local detectionBar = ReplicatedStorage:WaitForChild("detectionBar")


-- Constants
local RAYCAST_PARAMS: RaycastParams = RaycastParams.new()
	RAYCAST_PARAMS.FilterType = Enum.RaycastFilterType.Blacklist
local POSITION_TWEEN_INFO: TweenInfo = TweenInfo.new(
    0.3,
    Enum.EasingStyle.Quint,
    Enum.EasingDirection.In
)


-- Modules
local Signal


-- Module initialization
function Enemy:Init()
	-- Assign Signal
	Signal = self.Shared.Signal
end

return Enemy
Without AGF

Copy the source of the Signal module from Nevermore engine’s GitHub, paste it in a ModuleScript inside ReplicatedStorage, and name it Signal.
image
In the Enemy class, remove the :Init() method, and require the Signal module on the variable declaration:

-- With AGF
local Signal -- Will be assigned under the AGF :Init() method

-- Without AGF
local Signal = require(game:GetService("ReplicatedStorage"):WaitForChild("Signal"))

Let’s make our class constructor now!

-- Constructor
function Enemy.new(enemyModel: Model)

	-- References to stuff inside the enemy model
	local animator = Instance.new("Animator"); animator.Parent = enemyModel.Humanoid;
	--local animationsFolder = enemyModel.animations

	-- Grab the local player, it'll be nil in methods called from server
	local plr: Player? = (RunService:IsClient()) and game:GetService("Players").LocalPlayer or nil
	
	-- Create our object!
	local self = setmetatable({
		-- Properties
		-- Reference to the enemies' model
		enemy = enemyModel,
		-- Events
		OnDetected = Signal.new(),
		-- Variables to store info
		detected = false,
		detecting = false,
		distance = 0,
		detectionMeter = 0,
		-- Player, this'll be nil in methods to be called from server
		player = plr,
		-- The frame that is going to be the detection bar, it'll be nil if the local player is
		detectionBar = (plr ~= nil) and detectionBar:Clone() or nil,

		combatSettings = {
			damage = 25,
			attackCooldown = 3,
			range = 20,
		},

		--[[animations = {
		
			fire = animator:LoadAnimation(animationsFolder.fire),
			reload = animator:LoadAnimation(animationsFolder.reload),
			walk = animator:LoadAnimation(animationsFolder.walk),
	
		},]]

		detectionSettings = {
			FOV = 0.5,
			maxDistance = 50,
			minDetection = 0,
			maxDetection = 100,
			DISTANCE_CONSTANT = 20
		}
	}, Enemy)

	-- Parent the detection bar to the detection ui's ScreenGui if it isn't nil
	if (self.detectionBar) then
		plr:WaitForChild("PlayerGui")
		plr.PlayerGui:WaitForChild("DetectionUIS")
		self.detectionBar.Parent = plr.PlayerGui.DetectionUIS
	end

	return self
end

Cool! Let’s set-up the controller now:

-- Stealth Controller
-- zamd157
-- December 19, 2020
-- Last edited: April 24, 2021


-- Controller
local StealthController = {}


-- Roblox Services
local RunService = game:GetService("RunService")


-- Modules
local Enemy


-- Table to store our enemies
local enemies = {}

-- The folder with the models of our enemies
local enemyFolder = workspace:WaitForChild("enemies"):GetChildren()

-- Types
-- this is poggers men!
type Enemy = {
    -- Properties
    enemy: Model,
    OnDetected: RBXScriptSignal,
    detected: boolean,
    detecting: boolean,
    distance: number,
    detectionMeter: number,
    player: Player?,
    combatSettings: {
        damage: number,
        attackCooldown: number,
        range: number
    },
    animations: {
        fire: AnimationTrack,
        reload: AnimationTrack,
        walk: AnimationTrack
    },
    detectionSettings: {
        FOV: number,
        maxDistance: number,
        minDetection: number,
        maxDetection: number,
        DISTANCE_CONSTANT: number
    },

    -- Objects
    detectionBar: Frame?,

    -- Methods
    new: (Model) -> Enemy,
    DoChecks: () -> nil,
    MoveToNextPoint: () -> nil,

    -- Private methods
    _behindObstacle: () -> boolean,
    _withinFOV: () -> boolean,
    _withinDistance: () -> boolean,
    _updateGUI: () -> nil,
    Init: () -> nil
}


-- Controller initialization
function StealthController:Init()
    -- Assign enemy
    Enemy = self.Shared.Enemy
end

return StealthController
Without AGF

Do as before, assign the ModuleScript named Enemy under ReplicatedStorage:

-- With AGF
local Enemy -- Will be assigned under the AGF :Init() method

-- Without AGF
local Enemy = require(game:GetService("ReplicatedStorage"):WaitForChild("Enemy"))

Great! Now that we have the Enemy class set-up, let’s code our core controller logic:

function StealthController:Start()

    -- Loop through the enemies's models
    for enemyIndex: number, enemy: Model in ipairs(enemyFolder) do
        -- Create an instance of the Enemy class
        local newEnemy = Enemy.new(enemy)
        -- Create a key for our new Enemy object
        enemies[enemy.Name .. enemyIndex] = newEnemy
        -- Connect the .OnDetected event
        local onDetected: RBXScriptConnection
		onDetected = newEnemy.OnDetected:Connect(function()
			-- Here is where you're going to get creative
			-- You could make this assault-based like Payday 2,
			-- or perhaps a "i-saw-you-so-i-attack-you" system
			-- like Thief, Assassin's Creed, Dishonored, etc.
			-- I'm going with the second option

			-- We'll code this later, for now, a print statement does the job
            print("Detected!")
            onDetected:Disconnect()
        end)
    end
	
end
Without AGF

This is a bit trickier to convert to non-AGF scripts.
Think of the AGF methods like this:

  • :Init() - Variable declarations for things inside the framework;
  • :Start() - Normal body of the script;

So as the Enemy module’s :Init() method, to convert the variable declaration, simply put it at the top of the script:

-- With AGF
local Enemy -- Will be assigned under the AGF :Init() method

-- Without AGF
-- Simply assign it on the variable declaration
local Enemy = require(game:GetService("ReplicatedStorage"):WaitForChild("Enemy"))

For the :Start() method, simply put everything in it on the top scope of the script, remove the method, the table and the return statement:

-- Loop through the enemies's models
for enemyIndex: number, enemy: Model in ipairs(enemyFolder) do
	-- Create an instance of the Enemy class
	local newEnemy = Enemy.new(enemy)
	-- Create a key for our new Enemy object
	enemies[enemy.Name .. enemyIndex] = newEnemy
	-- Connect the .OnDetected event
	local onDetected: RBXScriptConnection
	onDetected = newEnemy.OnDetected:Connect(function()
		-- Here is where you're going to get creative
		-- You could make this assault-based like Payday 2,
		-- or perhaps a "i-saw-you-so-i-attack-you" system
		-- like Thief, Assassin's Creed, Dishonored, etc.
		-- I'm going with the second option

		-- We'll code this later, for now, a print statement does the job
		print("Detected!")
		onDetected:Disconnect()
	end)
end
Full non-AGF controller (for now)
-- Stealth Controller
-- zamd157
-- December 19, 2020
-- Last edited: April 24, 2021


-- Controller
local StealthController = {}


-- Roblox Services
local RunService = game:GetService("RunService")


-- Modules
local Enemy = require(game:GetService("ReplicatedStorage"):WaitForChild("Enemy"))


-- Table to store our enemies
local enemies = {}

-- The folder with the models of our enemies
local enemyFolder = workspace:WaitForChild("enemies"):GetChildren()

-- Types
-- this is poggers men!
type Enemy = {
    -- Properties
    enemy: Model,
    OnDetected: RBXScriptSignal,
    detected: boolean,
    detecting: boolean,
    distance: number,
    detectionMeter: number,
    player: Player?,
    combatSettings: {
        damage: number,
        attackCooldown: number,
        range: number
    },
    animations: {
        fire: AnimationTrack,
        reload: AnimationTrack,
        walk: AnimationTrack
    },
    detectionSettings: {
        FOV: number,
        maxDistance: number,
        minDetection: number,
        maxDetection: number,
        DISTANCE_CONSTANT: number
    },

    -- Objects
    detectionBar: Frame?,

    -- Methods
    new: (Model) -> Enemy,
    DoChecks: () -> nil,
    MoveToNextPoint: () -> nil,

    -- Private methods
    _behindObstacle: () -> boolean,
    _withinFOV: () -> boolean,
    _withinDistance: () -> boolean,
    _updateGUI: () -> nil,
    Init: () -> nil
}


-- Loop through the enemies's models
for enemyIndex: number, enemy: Model in ipairs(enemyFolder) do
	-- Create an instance of the Enemy class
	local newEnemy = Enemy.new(enemy)
	-- Create a key for our new Enemy object
	enemies[enemy.Name .. enemyIndex] = newEnemy
	-- Connect the .OnDetected event
	local onDetected: RBXScriptConnection
	onDetected = newEnemy.OnDetected:Connect(function()
		print("Detected!")
		onDetected:Disconnect()
	end)
end

3: Detection System


Alright, first, let’s plan out what we’re going to do.

  1. Check if player is within detection distance;
  2. Check if player is within enemy’s FOV;
  3. Check if player is behind something;

If these conditions are met, the enemy is going to raise their detection up, depending on the detection increment which is based on the distance from the player.
So, let’s get to the good stuff :sunglasses:

First, create a method called DoChecks() inside your Enemy module:

-- Do our checks
function Enemy:DoChecks()

end

Alright! Let’s make some logic functions now!

Starting with our less expensive check, we’ll use the player:DistanceFromCharacter() method, then compare it with our maxDistance setting:

function Enemy:_withinDistance()
	-- If player's character doesn't exist, return false
	if (not self.player.Character) then return false end

	-- Grab le distance
	self.distance = self.player:DistanceFromCharacter(self.enemy.Head.Position)
	
	-- If distance is smaller than the max distance, return true, else, false
	return (self.distance <= self.detectionSettings.maxDistance)
end

Aight’, with our second check, we’ll use dot product to determine if our player is within FOV of the enemy. Check out this awesome tutorial by okeanskiy REMEMBER TO PUT @ BEFORE IT OK ZAMDIE? about it, this topic also explains it very well. Check them out for explanation! because my brain can’t process that information… yet

function Enemy:_withinFOV()
	-- If player's character doesn't exist, return false
	if (not self.player.Character) then return false end
		
	-- Get le unit of the player's head to the enemy's head
	local npcToCharacter = (
		self.player.Character.Head.Position - self.enemy.Head.Position
	).Unit
	-- Get le LookVector of the head of the enemy's CFrame
	local npcLook = self.enemy.Head.CFrame.LookVector
	-- Grab le dot product
	local dotProduct = npcToCharacter:Dot(npcLook)
	
	-- If the dot product is bigger than the FOV, returns true, else, false
	return (dotProduct > self.detectionSettings.FOV)
end

For our last check which will make weak computing devices die , we’ll raycast from the enemy’s head to the player’s head, to check if there is something between them.

function Enemy:_behindObstacle()
	-- If player's character doesn't exist, return false
	if (not self.player.Character) then return false end
	
	-- Raycast!!!!!!!11
	-- Please note that if we fire the ray from the head to the head,
	-- it will be only detected if the head is exposed. So if the
	-- torso and legs are showing, but the head isn't, the player's
	-- not going to get detected.
	local raycastResult = workspace:Raycast(
		self.enemy.Head.Position, -- Origin
		(self.player.Character.Head.Position - self.enemy.Head.Position), -- Direction
		RAYCAST_PARAMS -- Raycast params
	)
	
	-- If ray hits, we do stuff lol
	if (raycastResult) then

		-- This checks if the parent is a model(character).
		-- If it isn't, it's the handle of an accessory, so it sets to the parent of the parent
		local char = (raycastResult.Instance.Parent:IsA("Model"))
		and raycastResult.Instance.Parent -- If it's a limb
		or raycastResult.Instance.Parent.Parent -- If it's a Handle of an accessory

		-- If it is actually a character
		if (char:FindFirstChild("Humanoid")) then
			
			-- Returns true if the character hit is of our player's (because it can hit other players or enemies), else, returns false
			return (char.Name == self.player.Name)
		end
	end
	
	-- Returns false if ray didn't hit anything, or didn't hit a character
	return false
end

Phew! That was quite a bit of code, oh boy did I not know what was coming… let’s put our functions in our method and do magic!

-- Do our checks
function Enemy:DoChecks()
	-- Fire our functions, if they are detected, set detecting to true
	if (self:_withinDistance()) then
		if (self:_withinFOV()) then
			if (self:_behindObstacle()) then
				self.detecting = true
			end
		end
	end

	-- Call the function to update the detection bar
	self:_updateGUI()
	-- Set detecting to false again
	self.detecting = false
end

Oh, so you mere mortal thought we were done with the hard part? Nope, we’re just starting.
You saw I called a method called _updateGUI(). Yes, you definitely saw. We’re doing the detection bar next. :cold_face:

4: Coding the detection bar


Ladies and gentlemen, fasten your seatbelts brains because we’re going to experience a turbulence.
Really.

I’ve explained everything in comments, good luck, my friend!
(I’m too lazy to explain everything now, here above the script, I’ll explain it in detail when I release it as a tutorial!)

function Enemy:_updateGUI()
	-- If detecting, do all the stuff below
	if (self.detecting) then
		-- Here we add the current detection to a constant divided by the distance
		-- I saw this on the devforum, however I can't find the topic again
		-- Basically, saying our constant is 20, it works like this:
		-- 20 / 5 (5 is very close) = 4
		-- 20 / 50 (50 is very far) = 0,4
		-- So, the meter is gonna go up slower when the value is low, and the bigger, the faster
		self.detectionMeter = math.clamp(
			(self.detectionMeter + 
				(self.detectionSettings.DISTANCE_CONSTANT / self.distance)
			),
			self.detectionSettings.minDetection,
			self.detectionSettings.maxDetection
		)
		
		-- If the bar isn't showing up, make it show
		if (not self.detectionBar.Visible) then
			self.detectionBar.Visible = true
		end

		-- If the detection is the max detection, fire the OnDetected event and set detected to true
		if (self.detectionMeter == self.detectionSettings.maxDetection) then
			self.OnDetected:Fire()
			self.detected = true
			self.detectionBar.Visible = false
		else
			-- Else, we resize the detection bar to go up
			-- Btw, Udim2.fromScale is Udim2.new but without the offset values
			-- Basically, the code below is the same as Udim2.new(1, 0, mathstuff, 0)
			self.detectionBar.bar.Size = UDim2.fromScale(
				1,
				-(self.detectionMeter / self.detectionSettings.maxDetection)
			)

			-- TODO; POSITION THE GUI IN THE DIRECTION OF THE ENEMY
		end

	else
		-- If not detecting, do the stuff below

		-- If the bar is not visible, return (as we won't need to do more stuff)
		if (not self.detectionBar.Visible) then return end
		
		-- If the bar isn't the smallest size, we make it go down, else, it's completely down
		-- and we make it not visible
		if (self.detectionBar.bar.Size.Y.Scale ~= 0) then
			
			-- This math is the same we do when detecting, 
			-- except we subtract it and divide the distance by the distance constant
			-- (before we added it, and divided the distance constant by the distance)
			-- This'll make so the closer you are, the slower the bar goes down
			self.detectionMeter = math.clamp(
				(self.detectionMeter - 
					(self.distance / self.detectionSettings.DISTANCE_CONSTANT)
				),
				self.detectionSettings.minDetection,
				self.detectionSettings.maxDetection
			)
			
			-- The resizing is the same
			self.detectionBar.bar.Size = UDim2.fromScale(
				1,
				-(self.detectionMeter / self.detectionSettings.maxDetection)
			)
		else
			-- I spent like 2 hours figuring why this else wasn't running
			-- It was because I forgot to add .bar and it was checking the
			-- Size of the detectionBar (the background of the bar)

			-- Make the detection bar not visible anymore
			self.detectionBar.Visible = false
		end
	end
end

Hey, you did it! You did a nice job! :sunglasses:
One more small step, slap a nice loop in our StealthController to call the :DoChecks() method!

function StealthController:Start()

    -- Loop through the enemies's models
    for enemyIndex: number, enemy: Model in ipairs(enemyFolder) do
        -- Create an instance of the Enemy class
        local newEnemy = Enemy.new(enemy)
        -- Create a key for our new Enemy object
        enemies[enemy.Name .. enemyIndex] = newEnemy
        -- Connect the .OnDetected event
        local onDetected: RBXScriptConnection
		onDetected = newEnemy.OnDetected:Connect(function()
            print("Detected!")
            onDetected:Disconnect()
        end)
    end

    -- Function to be run every heartbeat
    local function heartBeat(_: number)

        -- Loops through our enemies
        for _: string, enemy: Enemy in pairs(enemies) do
            -- If enemy has been detected, skip the iteration to not call the method
            if (enemy.detected) then continue end
            enemy:DoChecks()
        end
    end

    RunService.Heartbeat:Connect(heartBeat)
end

Take a small break and appreciate what we’ve done so far:


I swear the rest of the tutorial is going to be easier!

5: Guard routes - pathfinding


First things first, create a service named EnemyService under Server:
image
Now make a folder called waypoints in Workspace, and then folders with the names of the enemies and the number.
image
Now that’s done, let’s code the plate we’re going to work with:

-- Enemy Service
-- zamd157
-- December 19, 2020
-- Last edited: April 24, 2021


-- Service
local EnemyService = {Client = {}}


-- Roblox services
local RunService = game:GetService("RunService")


-- Modules
local Enemy


-- Table to store our enemies
local enemies = {}

-- The folder with the models of our enemies
local enemyFolder = workspace:WaitForChild("enemies"):GetChildren()

-- Folder with the enemies's waypoints
local waypoints = workspace:WaitForChild("waypoints")

-- Types
type Enemy = {
    -- Properties
    enemy: Model,
    OnDetected: RBXScriptSignal,
    detected: boolean,
    detecting: boolean,
    distance: number,
    detectionMeter: number,
    player: Player?,
    combatSettings: {
        damage: number,
        attackCooldown: number,
        range: number
    },
    animations: {
        fire: AnimationTrack,
        reload: AnimationTrack,
        walk: AnimationTrack
    },
    detectionSettings: {
        FOV: number,
        maxDistance: number,
        minDetection: number,
        maxDetection: number,
        DISTANCE_CONSTANT: number
    },

    -- Objects
    detectionBar: Frame?,

    -- Methods
    new: (Model) -> Enemy,
    DoChecks: (Enemy) -> nil,
    MoveToNextPoint: (Enemy) -> nil,

    -- Private methods
    _behindObstacle: (Enemy) -> boolean,
    _withinFOV: (Enemy) -> boolean,
    _withinDistance: (Enemy) -> boolean,
    _updateGUI: (Enemy) -> nil,
    Init: (Enemy) -> nil
}

type Array<T> = {[number] : T}

-- Local util functions

-- You honestly don't have to use this Heartbeat based wait,
-- I'm just using it because why not? 0.1 second accuracy is nice!
local function wait(seconds: number)
    seconds = seconds or (1/60)
    local deltaTime = 0
    
    while (deltaTime < seconds) do
        deltaTime = deltaTime + RunService.Heartbeat:Wait()
    end

    return deltaTime
end


function EnemyService:Init()
    Enemy = self.Shared.Enemy
end

return EnemyService

Next, let’s make a new method in the Enemy class called :MoveToNextPoint(). In it, we’re going to handle pathfinding:

-- Enemy route pathfinding
function Enemy:MoveToNextPoint()
	local path = self.path

	-- Part to go to
	local waypointPart = path.waypoints[path.waypoint]
	-- Event when the route is blocked
	local routeBlocked: RBXScriptConnection
	-- Tween to have a nice transition when the character stays in the waypoint part
	local positionTween = TweenService:Create(
		self.enemy.PrimaryPart,
		POSITION_TWEEN_INFO,
		{
			-- In the part's position, looking at the direction the part is looking
			CFrame = CFrame.new(
				waypointPart.Position,
				waypointPart.CFrame.LookVector
			)
		}
	)

	-- Create the path
	path.route = PathfindingService:CreatePath()

	-- Compute the path
	path.route:ComputeAsync(
		self.enemy.PrimaryPart.Position, -- Start position
		waypointPart.Position -- End position
	)

	-- If the path status isn't success, we have to make 'em stop
	if (path.route.Status ~= Enum.PathStatus.Success) then
		self.enemy.Humanoid:MoveTo(self.enemy.PrimaryPart.Position)
	end

	-- Connect the Blocked event, so it calls itself again aka. recursive
	routeBlocked = path.route.Blocked:Connect(function()
		routeBlocked:Disconnect()
		self:MoveToNextPoint()
	end)
	
	-- Loop through the waypoints
	for _, waypoint: PathWaypoint in pairs(path.route:GetWaypoints()) do
		self.enemy.Humanoid:MoveTo(waypoint.Position)
		self.enemy.Humanoid.MoveToFinished:Wait()
	end

	-- Since the loop yields, once the enemy reaches the position it plays the position tween
	positionTween:Play()
	-- Cleanup
	routeBlocked:Disconnect()

	-- If waypoint is the last waypoint, set it to 0 (because next instruction is to add 1)
	-- Else, just sets it to the waypoint
	path.waypoint = (path.waypoint == path.maxWaypoints) and 0 or path.waypoint
	path.waypoint += 1
end

This, of course, isn’t the best way to do it (I never really messed with pathfinding). However, if it works, don’t touch it! spoke like a true programmer right there :sob:
Next, we’ll put the logic that controls the method on our EnemyService:

function EnemyService:Start()

    -- Loop through the enemies's models to create new Enemy objects
    for enemyIndex: number, enemy: Model in ipairs(enemyFolder) do
        -- Create an instance of the Enemy class
        local newEnemy = Enemy.new(enemy)
        local enemyName = enemy.Name .. enemyIndex
        -- Create a key for our new Enemy object
        enemies[enemyName] = newEnemy
        -- Inject pathfinding properties
        newEnemy.path = {
            waypoints = waypoints[enemyName]:GetChildren();
            maxWaypoints = #waypoints[enemyName]:GetChildren();
            waypoint = 1;
        }

        -- Set network ownership of the limbs to the server, so pathfinding isn't laggy
        -- Coroutine'd, because why not?
        coroutine.wrap(function()
            for _, part: BasePart in ipairs(enemy:GetChildren() :: Array<BasePart>) do
                if (not part:IsA("BasePart")) then continue end
                part:SetNetworkOwner(nil)
            end
        end)()
    end
    
    -- Infinite loop 0_0
	-- This is just a joke, if you're disturbed by this and you're copy pasting from the tutorial itself just change it to true (in the files I put true)
	-- Why did I even say that? Do what you want!
    while (not ("" == 0)) do
        -- Coroutine'd or else they'd have to wait for the previous enemy to move
        coroutine.wrap(function()
            for _: string, enemy: Enemy in pairs(enemies) do
                enemy:MoveToNextPoint()
            end
        end)()
        
        wait(10)
    end
end

And with that, let’s appreciate our creation again:

I swear I’m not that bad at stealth games to get caught like that :pensive:

6: Adding reactions


24 Likes