Running a floodfill with Actors

I am making a ‘color grab’ type game, and I am needing to floodfill a grid to find an enclosed area. Such as…

I am the pink area, and I mark off a section of unclaimed grid (light pink line) and once that line re-connects back to the area I own, I need a way to detect the enclosed area, so I can fill it in, as claimed by me.

If I floodfill on every neighbor of each point on the line (ignoring other player colors) , I assume I will get results of valid, meaning I only encountered my own territory (color) or I encountered the line. Or invalid meaning I hit an edge.

This method seems to work, but is very laggy with large maps, of 15,000 squares.
So I thought of using actors, and google gave me an example of some script to use to divide the task up into quadrants, however, I am lost as to how the different sections will understand they are connected to the whole.

Here is the script from the AI
Main Script

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GridModule = require(ReplicatedStorage.GridModule)

local Grid = GridModule.new(100, 100) -- Example 100x100 grid

-- Create actors for different sections of the grid
local Actors = {}
for i = 1, 4 do -- Example: 4 actors for 4 quadrants
    local actor = Instance.new("Actor")
    actor.Name = "FloodActor_" .. i
    actor.Parent = workspace.ActorsFolder
    table.insert(Actors, actor)

    -- A script inside each actor will contain the flood fill logic
    local actorScript = Instance.new("Script")
    actorScript.Source = ReplicatedStorage.FloodFillActorScript:GetSource()
    actorScript.Parent = actor
end

function initiateFloodFill(x, y, newColor)
    -- Determine which actor to start the fill on
    local startActor = Actors[Grid:getActorIndexForPoint(x, y)]
    startActor:SendMessage("StartFill", {
        x = x,
        y = y,
        newColor = newColor
    })
end

initiateFloodFill(50, 50, Color3.new(0, 1, 0))

Actor Script

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GridModule = require(ReplicatedStorage.GridModule)

local Actor = script.Parent
local Grid = GridModule.new(100, 100) -- A new, local copy of the grid data

local function parallelFloodFill(startPos, newColor)
    task.desynchronize()
    -- Non-recursive, queue-based flood fill logic
    local queue = {startPos}
    while #queue > 0 do
        local pos = table.remove(queue, 1)
        local x, y = pos.x, pos.y

        -- Check boundaries and color conditions
        if Grid:isValid(x, y) and Grid:isSameColor(x, y) then
            -- Safely update local data in parallel
            Grid:setColor(x, y, newColor)

            -- Check neighbors
            for _, neighbor in ipairs(Grid:getNeighbors(x, y)) do
                -- If neighbor is in a different actor's territory, message that actor
                if not Actor:isMyTerritory(neighbor) then
                    local otherActor = Actor:getActorForPoint(neighbor)
                    otherActor:SendMessage("ContinueFill", {
                        x = neighbor.x,
                        y = neighbor.y,
                        newColor = newColor
                    })
                else
                    table.insert(queue, neighbor)
                end
            end
        end
    end

    task.synchronize()
    -- At this point, the main thread can safely modify `DataModel` objects
    -- to reflect the changes made in parallel.
end

Actor:BindToMessage("StartFill", function(message)
    parallelFloodFill(message.startPos, message.newColor)
end)

There is a section in the script where it says…
" – At this point, the main thread can safely modify DataModel objects
– to reflect the changes made in parallel."
So I assume this is where I would put something to modify a global data set to reflect the actor’s changes.

However, what would happen if…

In this image, Actor1 shaded in green and Actor2 shaded in yellow, will both scan their respective areas of the map, but how will they know the area (with a double Red Arrow) is connected, so that Actor2 doesnt disregard this space, thinking it is not enclosed. And the same for Actor 1, without the edges touching the space I own, how would it understand where the boundaries are?

Maybe I don’t really get ‘actors’ or how to use them, or maybe there is a simpler way to go about this.

This has had me stumped for about a week now, so any help or advice is greatly appreciated.

1 Like

Please don’t use AI for something like this. It’s bound to get something wrong, especially with this sort of complexity.

First off, Script.Source is not able to be modified, yet you copy a script and try to do ReplicatedStorage.FloodFillActorScript:GetSource()… I do not believe GetSource() is a method that exists, either. You will have to clone the FloodFillActorScript directly; I believe Instance.fromExisting works, but if not, Instance:Clone() is just fine.

Secondly, I would suggest changing Actor:BindToMessage() to Actor:BindMessageToParallel(). It runs the callback desynchronized to begin with, so you have no need to call task.desynchronize() within your parallelFloodFill() function. I would also suggest not synchronizing within the actor… Your main script can act somewhat like a mutex and synchronize like this:

-- Send message in parallel
task.desynchronize()
startActor:SendMessage("StartFill", ...)

task.synchronize()
-- Apply changes in the Workspace or wherever
task.defer(ApplyFill)

It can’t. You’re creating a local copy of the grid data for every single actor. This is bad since you need to replicate changes to the grid across all of your Actors, which… kind of negates the purpose of having actors for this, because now you’re updating everything in parallel tenfold, plus the added cost of more memory usage for essentially zero gain.

I suggest looking into SharedTables. Since your grid is 2D, you can have a simple 1D array of grid points that store the color, maybe as a number (you can do (r << 16) | (g << 8) | b) so that color comparisons are faster too, and updating the SharedTable isn’t as costly (complex data structures aren’t very good).
Also, shifting the queue via table.remove is not great for performance; it has to move all of the elements above 1 down so that it stays in order. Alongside this, you don’t need ipairs to iterate loops in Luau anymore, you can just do for _, neighbor in Grid:GetNeigbors(x, y) do.

Correct me if I’m wrong, but to check if a cycle closes, the only squares that really matter are the perimeters of each player cycle(and perhaps also a static perimeter of the world bounds or static objects). The problem can be really optimized if you keep track of each player perimeter, since you will only have to check these squares for closed loops.

For example to check if pink is about to make a closed cycle, all you need is the perimeter of their own area and the perimeter of the magenta(opponent) area. After you detect a closed loop, you paint it according to the player that closes it(in this case pink).

Another check you can do to optimize even more, is figure out a way to tell which loops you have already painted by encoding the loop information in an array. You could also store which player owns which subloops so in case an event happens, like them leaving or losing, you can clear them up faster without scanning over the entire map.

Basically you have no reason to care for the inside area of each player cycle or empty space when trying to do any of this.

1 Like

I dont think actors are going to make your life easier here.

They are great if youve already got a well-defined parallel job, but in your case your splitting up one continuous region into chunks that still need to “talk” to each other. That means you either duplicate the grid in every actor (heavy, as Starveldt mentioned) or you spend a lot of time passing messages around just to keep them in sync.

(Either way the gains are pretty small compared to the complexity)


As @Nyrion mentioned, you dont actually need to scan the whole grid! The only cells that matter are the boundary cells of a players trail and existing territory. If you store those perimeters as they change, detecting whether a loop has closed is just a matter of checking whether the new trail intersects its own perimeter or reconnects to owned land!!

OR

Instead of floodfilling, you can treat each empty cell as belonging to a “region set.” When a player draws a line that cuts across, you split/merge sets. This lets you tell which regions are still connected to the world edge (“unbounded”) vs which ones are enclosed. The enclosed ones are the ones you can safely recolor. Union–find is extremely efficient compared to BFS floodfills on large grids!!


Also you should use SharedTables, these are better than cloning your grid everywhere.

Store your grid as a flat 1D array of integers, one per cell (maybe 0 = empty, 1 = pink, and so on). Each actor can safely read/write from the same SharedTable. That way you dont have to re-merge results!!


If you want to get inspired look into algorithms for cycle detection in planar graphs its basically the same problem your facing just in math terms!


Even if you stick with floodfill thingi you can speed it up:

Use a queue with two indices (head and tail) instead of table.remove to avoid constantly shifting arrays. Skip empty space until you hit the perimeter and try implementing limit fills by the bounding box of the new trail, so you never touch cells outside the trails min/max X and Y.

Hope this helps in any kind of way!!!

1 Like

This actually helps a lot actually.
I do have a few questions if you dont mind.

How would you make a SharedTable, so that actors can read and write to it?

Also, is there any example you have on the part you said "Instead of floodfilling, you can treat each empty cell as belonging to a “region set.”
I’m not quite sure what you mean by this.

Thanks for the info. It really gets me thinking of different strategies.

SharedTables are built into Roblox for exactly this kind of thing. You can think of them as a special dictionary/array that lives in one place in memory, but both the main thread and any actors can read and write to it without duplicating the data…


Basic usage:

local SharedTableRegistry = game:GetService("SharedTableRegistry")

-- create or fetch a table
local gridTable = SharedTableRegistry:GetSharedTable("Grid")

-- fill it with initial data
for i = 1, 15000 do
    gridTable[i] = 0 -- 0 = empty
end

-- Actor or another script
local gridTable = SharedTableRegistry:GetSharedTable("Grid")
gridTable[42] = 1 -- claim a cell
print(gridTable[42]) -- all scripts/actors see the update

The limitations are:

  • Keys must be strings or numbers
  • Values must be simple types (numbers, strings, booleans, Vector3, Color3)
  • No nested tables!!!

Thats why people usually flatten a 2D grid into a 1D array (index = y * width + x)


On the “region set” idea:

Instead of floodfilling from scratch you keep track of which empty cells belong to the same open region. A union–find (also called disjoint-set) is a data structure that’s good at this. Each cell has a “parent” and you can quickly merge two sets if they connect!

For your game:

  • At start, every empty cell that touches the border is in the “outside” region
  • When players draw a line, you union the cells they cut through into a “blocked” set
  • Any region of empty cells that is no longer connected to the “outside” region is enclosed, so you can recolor it

That way, instead of running a big BFS/DFS floodfill, you just check connectivity.


– starting from a point, it fills connected areas step by step… (which we dont want)

rough sketch:

local parent = {}

local function find(i)
    if parent[i] ~= i then
        parent[i] = find(parent[i])
    end
    return parent[i]
end

local function union(a, b)
    local ra, rb = find(a), find(b)
    if ra ~= rb then
        parent[rb] = ra
    end
end

-- example setup
for i = 1, 15000 do
    parent[i] = i
end

-- connect neighbors
union(5, 6)
union(6, 7)

print(find(5) == find(7)) -- true

Youd adapt that to your 2D grid (flatten to indices). If a region is still connected to the “outside set” its not enclosed. If its disconnected, you fill it!


Its definitely more complex than plain floodfill, but it scales better and avoids checking the same 15k cells over and over!!!

Wow, Ive been coding on Roblox since 2015, and never once have I heard of SharedTables. I had no idea it was something buit into Roblox o.o

If nothing else, this will give me plenty of information to try for new solutions.
Thanks so very much.

There is one thing, that I can’t seem to wrap my head around with this.
Suppose this is my arena

I have the Region in dark blue, and the dotted Region is touching the border, so it is its own region (Region A) that is considered ‘empty’

These regions are comprised of cells that have parents that point to the root of their region.

So… If I were to mark off a section, as such…

How would I go about determining if the area inside the closed off path is its own new region?
Currently, just as the path is closed (has touched my own territory again) the enclosed area still thinks it is part of Region A, because all of its cells still parent to Region A

If I don’t know which side of my path is enclosed or not, I assume I would need to look at all cells that border along my path. I just cant wrap my head around, how I should scan. Do I just scan all cells again? That might be 15000 cells. How do the cells inside the enclosed area, know they are now part of a new region? Do I scan around the path? Do I scan just the region I own, plus the path region? How do I ‘update’?

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.