Help optimizing a square highlighter system

Hello.

I made a simple client script to highlight adjacent squares on a grid for a mini-game that I’m creating. The problem is, it ends up repeating over 2500 times, causing the client script to lag very badly due to the exponential amount of operations for each highlight or “infection” to each square.

SimpleBoardClicker.rbxm (7.1 KB)
(put in StarterGui)

Here is the code if anyone takes a look:

local TweenService = game:GetService("TweenService")

local Infecting = false
local Infected = {}

local Operations = 0

function GetAdjacent(Position)
	local X = Position.X
	local Y = Position.Y
	
	local Adjacent = {}
	
	local function ShareX(Tile)
		if X == Tile.AbsolutePosition.X then
			return true
		end
		
		return false
	end
	
	local function ShareY(Tile)
		if Y == Tile.AbsolutePosition.Y then
			return true
		end
		
		return false
	end

	for _, Tile in next, script.Parent:GetChildren() do
		if Tile:IsA("TextButton") then
			if Tile.AbsolutePosition.X == X - 26 and ShareY(Tile) and not Infected[tonumber(Tile.Name)] then
				Adjacent.Left = Tile
			end
			
			if Tile.AbsolutePosition.X == X + 26 and ShareY(Tile) and not Infected[tonumber(Tile.Name)] then
				Adjacent.Right = Tile
			end
			
			if Tile.AbsolutePosition.Y == Y - 26 and ShareX(Tile) and not Infected[tonumber(Tile.Name)] then
				Adjacent.Above = Tile
			end
			
			if Tile.AbsolutePosition.Y == Y + 26 and ShareX(Tile) and not Infected[tonumber(Tile.Name)] then
				Adjacent.Below = Tile
			end
		end
	end
	
	return Adjacent
end

function Infect(Number)
	spawn(function()
		if not Infected[Number] then 
			Operations = Operations + 1
			Number = tonumber(Number)
			Infected[Number] = true
			
			local Tile = script.Parent:FindFirstChild(tostring(Number))
			local Adjacent = {}
			
			TweenService:Create(Tile, TweenInfo.new(1), {BackgroundColor3 = Color3.new(0.5, 1, 0.5)}):Play()
			
			for _, Tile in next, GetAdjacent({X = Tile.AbsolutePosition.X, Y = Tile.AbsolutePosition.Y}) do
				Infect(Tile.Name)
			end
		end
	end)
end

for _, Tile in next, script.Parent:GetChildren() do
	if Tile:IsA("TextButton") then
		Tile.MouseButton1Down:Connect(function()
			Infect(Tile.Name)
		end)
	end
end

spawn(function()
	while wait() do
		print("Successful operations:", Operations)
	end
end)

Any help is appreciated! Thank you

1 Like

The main issue is that you perform all your calculations directly on instances. Any kind of a floodfill algorithm will be a hog for your CPU, causing heavy performance drops, but adding indexing to that takes it to a different level.

What I’d do is ditch the OOP design altogether and initialize all properties into separate row-major order arrays, just like you already do with the Infected array. This will avoid the costly lookups on instances.

Another thing I’d add are occasional pauses when the frame time is above 1/45 of a second.

local st = tick()
--code in a loop or something
if (tick()-st)>1/45 then
	heartbeat:Wait()
	st = tick()
end

Don’t forget to use table.create(arrsize) whenever possible as it avoids resizing the array every time you add a new element.

4 Likes

I think what noonisback means is to store all the tiles in a way that you can look up tiles at any coordinate instead of having to search for tiles with a given coordinate every time.

I usually use a 2D table, so that’s how I’ll do it in these examples.

So your GetAdjecent function could be simplified to
function GetAdjacent(Position)
	--Make sure Position has already been divided by tileSize, or change this function to divide the x and y coords 
	local x, y = Position.X, Position.Y
	local Adjacent = {
		Tiles[x - 1][y],
		Tiles[x + 1][y],
		Tiles[x][y - 1],
		Tiles[x][y + 1],
	}
	
	return Adjacent
end

Just dictionary lookups, no searching through every tile in the game.

Of course you'll need to set up the Tiles table, which you can do at the start of the game like this:
local tileSize = 26
local Tiles = {}

for _, Tile in next, script.Parent:GetChildren() do
	if Tile:IsA("TextButton") then
		local tileX = Tile.AbsolutePosition.X / tileSize
		local tileY = Tile.AbsolutePosition.Y / tileSize
		
		--Setup row, if it hasn't already been set up
		Tiles[tileX] = Tiles[tileX] or {}
		--Set col, overwriting if it has already been set (i.e. if some other Tile has the same coordinate, maybe throw an error instead?)
		Tiles[tileX][tileY] = Tile
	end
end

A few other issues with your script:

Calling GetAdjecent like this:

GetAdjacent({X = Tile.AbsolutePosition.X, Y = Tile.AbsolutePosition.Y})

It seems like there’s no good reason to do it like that. Just pass the X coord as the first parameter and the Y coord as the second. It’s never difficult to remember which order X and Y go in, it’s literally the alphabet.

GetAdjacent(Tile.AbsolutePosition.X, Tile.AbsolutePosition.Y)

for _, Tile in next, script.Parent:GetChildren() do
	if Tile:IsA("TextButton") then
		Tile.MouseButton1Down:Connect(function()
			Infect(Tile.Name)
		end)
	end
end
Setting up listeners for every tile is probably a bad idea. At least, now that you can just look up tiles at any coordinate you don't *need* to. Instead you can do
function getTileScreenSpace(screenX, screenY)
	local tileX, tileY = math.floor(screenX/tileSize + 0.5), math.floor(screenY/tileSize + 0.5)
	local Tile
	if Tiles[tileX] then
		return Tiles[tileX][tileY]
	end
end

game:GetService("UserInputService").InputBegan:Connect(function(input)
	if input.UserInputType == Enum.UserInputType.MouseButton1 then
		local mouseX, mouseY = input.Position.X, input.Position.Y
		local Tile = getTileScreenSpace(mouseX, mouseY)

		if Tile then
			Infect(Tile.Name)
		end
	end
end)
1 Like

I made a short floodfill function that does practically what you want.

@ThanksRoBama Yes, but also no. What I meant is that accessing data from instances is relatively slow, unlike with arrays. In other words, replace everything with arrays and it will be a lot faster. Same goes for multi dimensional arrays as accessing an element inside an array inside an array is practically 2x slower than a row major order.

In a 100x100 array, accessing multidimensional arrays would be [x][y], while for row majors it would be [y*100+x+1], given your position starts with 0 and ends with 99.

local confSizeX = 100
local confSizeY = 100
local confMaxSpread = 10 --max steps to make

local confNodeCount = confSizeX*confSizeY

local arrNode = table.create(confNodeCount)
local arrMarked = table.create(confNodeCount,false)

local function mark(px,py,id)
	print("Node "..px.." "..py.." marked.")
	--do whatever you want here
end

local function MarkNodeStart(px0,py0,maxspread)
	if arrMarked[py0*confSizeX+px0+1] then
		print("ERROR: Node already marked.")
		return
	end
	arrMarked[py0*confSizeX+px0+1] = true
	local maxspread = maxspread or confMaxSpread

	local countLast = 1
	local listLastX = {px0}
	local listLastY = {py0}

	local countSpread = 0

	local st = tick()
	while listLastX[1] do
		local newLastX = table.create(countLast*2)
		local newLastY = table.create(countLast*2)
		local newCountLast = 0
		for i=1,countLast do
			local px0 = listLastX[i]
			local py0 = listLastY[i]
			local id0 = py0*confSizeX+px0+1

			if px0~=0 then --left (negative)
				local id1 = id0-1
				if not arrMarked[id1] then
					arrMarked[id1] = true
					newCountLast = newCountLast+1
					newLastX[newCountLast] = px0-1
					newLastY[newCountLast] = py0
					mark(px0-1,py0,id1)
				end
			end
			if px0~=confSizeX-1 then --right (positive)
				local id1 = id0+1
				if not arrMarked[id1] then
					arrMarked[id1] = true
					newCountLast = newCountLast+1
					newLastX[newCountLast] = px0+1
					newLastY[newCountLast] = py0
					mark(px0+1,py0,id1)
				end
			end

			if py0~=0 then --up (negative)
				local id1 = id0-confSizeX
				if not arrMarked[id1] then
					arrMarked[id1] = true
					newCountLast = newCountLast+1
					newLastX[newCountLast] = px0
					newLastY[newCountLast] = py0-1
					mark(px0,py0-1,id1)
				end
			end
			if py0~=confSizeY-1 then --down (positive)
				local id1 = id0+confSizeX
				if not arrMarked[id1] then
					arrMarked[id1] = true
					newCountLast = newCountLast+1
					newLastX[newCountLast] = px0
					newLastY[newCountLast] = py0+1
					mark(px0,py0+1,id1)
				end
			end
		end
		countSpread = countSpread+1
		if countSpread == maxspread then
			print("Reached max spread")
			break
		end
		listLastX = newLastX
		listLastY = newLastY
		countLast = newCountLast

		if (st-tick())>1/45 then
			--heartbeat:Wait()
			st = tick()
		end
	end
	print("Done")

	--console visualizer because I don't have Roblox rn
	local str = ""
	for y=0,confSizeY-1 do
		for x=0,confSizeX-1 do
			local id = y*confSizeX+x+1
			str = str..(arrMarked[id] and "#" or "_")
		end
		str = str.."\n"
	end
	print(str)
end

--a small test
MarkNodeStart(10,10,50)

Instances are slower to access because a function has to be fired which returns the value in question. IDK how Luau handles this, but it’s still slower than a pure array.

1 Like