Dynamic A* Algorithm Visualization

70RzmWf

Roblox provides a robust PathfindingService, which is designed to simplify the process of navigating complex environments by automatically calculating the optimal path between two points, considering obstacles and dynamic changes in the environment. This service is highly efficient and leverages a system similar to the A* algorithm to determine paths. By using PathfindingService, developers can quickly implement pathfinding capabilities without needing to manually code the underlying logic, making it an excellent choice for straightforward navigation tasks in games and simulations.

In contrast, developing a custom pathfinding system, as demonstrated in our script, offers deeper insights and flexibility that can be particularly valuable for advanced users or those with specific needs. By implementing the A* algorithm manually, developers gain a thorough understanding of heuristic-based search methods and how to optimize pathfinding for their unique requirements. Our custom system, which dynamically updates the path every second, not only showcases the mechanics of A* but also demonstrates how to create a more interactive and engaging experience. This approach allows for fine-tuning and customization that might not be as easily achievable with the built-in service.

While Roblox’s PathfindingService is perfect for quickly setting up navigation with minimal effort, our custom A* algorithm provides a foundation for deeper learning and greater customization. This custom approach can be adapted to various applications beyond gaming, such as robotics and logistics, where precise control over the pathfinding logic is necessary. By comparing both methods, developers can choose the appropriate tool for their specific project needs, whether they prioritize ease of implementation or the flexibility and control offered by a custom solution.

-- Define the positions of nodes
local nodes = {
    A = Vector3.new(0, 0, 0),
    B = Vector3.new(-5, 0, -5),
    C = Vector3.new(5, 0, 0),
    D = Vector3.new(-5, 0, -10),
    E = Vector3.new(10, 0, -10),
}

-- Define the edges between nodes with weights
local edges = {
    { from = "A", to = "B", weight = 1 },
    { from = "A", to = "C", weight = 5 },
    { from = "B", to = "D", weight = 7 },
    { from = "C", to = "E", weight = 8 },
    { from = "D", to = "E", weight = 10 },
}

-- Convert the edge list into an adjacency list for easier neighbor lookup
local graph = {}
for _, edge in ipairs(edges) do
    graph[edge.from] = graph[edge.from] or {}
    graph[edge.from][edge.to] = edge.weight
    graph[edge.to] = graph[edge.to] or {}
    graph[edge.to][edge.from] = edge.weight
end

-- Heuristic function for A* (using Euclidean distance)
local function heuristic(a, b)
    return (nodes[a] - nodes[b]).Magnitude
end

-- Implementation of the A* algorithm
local function aStar(startNode, goalNode)
    local openSet = { startNode }
    local cameFrom = {}
    local gScore = {}
    local fScore = {}

    -- Initialize scores for all nodes
    for node, _ in pairs(nodes) do
        gScore[node] = math.huge
        fScore[node] = math.huge
    end

    gScore[startNode] = 0
    fScore[startNode] = heuristic(startNode, goalNode)

    -- Main A* algorithm loop
    while #openSet > 0 do
        table.sort(openSet, function(a, b) return fScore[a] < fScore[b] end)
        local current = table.remove(openSet, 1)

        -- If the goal is reached, reconstruct the path
        if current == goalNode then
            local path = {}
            while cameFrom[current] do
                table.insert(path, 1, current)
                current = cameFrom[current]
            end
            table.insert(path, 1, startNode)
            return path
        end

        -- Evaluate neighbors of the current node
        for neighbor, weight in pairs(graph[current]) do
            local tentative_gScore = gScore[current] + weight
            if tentative_gScore < gScore[neighbor] then
                cameFrom[neighbor] = current
                gScore[neighbor] = tentative_gScore
                fScore[neighbor] = gScore[neighbor] + heuristic(neighbor, goalNode)

                if not table.find(openSet, neighbor) then
                    table.insert(openSet, neighbor)
                end
            end
        end
    end

    return nil
end

-- Function to create a node in the workspace
local function createNode(name, position)
    local part = Instance.new("Part")
    part.Name = name
    part.Size = Vector3.new(1, 1, 1)
    part.Position = position
    part.Anchored = true
    part.Shape = Enum.PartType.Ball
    part.BrickColor = BrickColor.new("Bright yellow")
    part.CanCollide = false
    part.Parent = workspace

    local billboard = Instance.new("BillboardGui", part)
    billboard.Size = UDim2.new(0, 100, 0, 50)
    billboard.StudsOffset = Vector3.new(0, 2, 0)
    billboard.AlwaysOnTop = true

    local textLabel = Instance.new("TextLabel", billboard)
    textLabel.Text = name
    textLabel.Size = UDim2.new(1, 0, 1, 0)
    textLabel.TextScaled = true
    textLabel.BackgroundTransparency = 1
    textLabel.TextColor3 = Color3.new(0, 0, 0)
end

-- Function to create an edge between two nodes in the workspace
local function createEdge(fromPos, toPos, weight)
    local part = Instance.new("Part")
    part.Size = Vector3.new(0.2, 0.2, (fromPos - toPos).Magnitude)
    part.CFrame = CFrame.lookAt((fromPos + toPos) / 2, toPos)
    part.Anchored = true
    part.BrickColor = BrickColor.new("Really black")
    part.CanCollide = false
    part.Parent = workspace

    local billboard = Instance.new("BillboardGui", part)
    billboard.Size = UDim2.new(0, 50, 0, 25)
    billboard.StudsOffset = Vector3.new(0, 2, 0)
    billboard.AlwaysOnTop = true

    local textLabel = Instance.new("TextLabel", billboard)
    textLabel.Text = tostring(weight)
    textLabel.Size = UDim2.new(1, 0, 1, 0)
    textLabel.TextScaled = true
    textLabel.BackgroundTransparency = 1
    textLabel.TextColor3 = Color3.new(1, 1, 1)
end

-- Function to clear previous path visualization
local function clearPreviousPath()
    for _, part in ipairs(workspace:GetChildren()) do
        if part.Name == "PathPart" then
            part:Destroy()
        end
    end
end

-- Function to visualize the path found by the A* algorithm
local function visualizePath(path)
    clearPreviousPath()
    local totalWeight = 0
    for i = 1, #path - 1 do
        local fromPos = nodes[path[i]]
        local toPos = nodes[path[i + 1]]
        local part = Instance.new("Part")
        part.Name = "PathPart"
        part.Size = Vector3.new(0.2, 0.2, (fromPos - toPos).Magnitude)
        part.CFrame = CFrame.lookAt((fromPos + toPos) / 2, toPos)
        part.Anchored = true
        part.BrickColor = BrickColor.new("Bright green")
        part.CanCollide = false
        part.Parent = workspace
        
        totalWeight = totalWeight + graph[path[i]][path[i + 1]]
    end
    print("Path from " .. path[1] .. " to " .. path[#path] .. " with total weight: " .. totalWeight)
end

-- Create nodes and edges in the workspace
for name, position in pairs(nodes) do
    createNode(name, position)
end

for _, edge in pairs(edges) do
    createEdge(nodes[edge.from], nodes[edge.to], edge.weight)
end

-- Function to choose a random node from the nodes list
local function chooseRandomNode()
    local keys = {}
    for key in pairs(nodes) do
        table.insert(keys, key)
    end
    return keys[math.random(1, #keys)]
end

-- Function to update the path every second
local function updatePath()
    while true do
        local startNode = chooseRandomNode()
        local goalNode = chooseRandomNode()
        while startNode == goalNode do
            goalNode = chooseRandomNode()
        end
        
        local path = aStar(startNode, goalNode)
        if path then
            visualizePath(path)
        else
            print("No path found")
        end
        
        wait(1) -- Update path every second
    end
end

-- Start updating paths
spawn(updatePath)

You may notice multiple parts being selected at once in the included image. This is due to the path chosen extending over multiple pieces. If you watch the output and compare it to the highlighted part, you will notice the chosen path with weight.

1 Like

I’d recommend turning this into a Module and upload it onto the Creator Marketplace.

A* algorithms can vary significantly in their implementation details, despite sharing the same foundational principles of using heuristics to find the shortest path between nodes. These differences arise from factors such as the choice of heuristic function, the data structures used for open and closed sets, and the specific optimizations made to improve performance or suit particular use cases. For instance, some implementations may prioritize speed by using simpler heuristics, while others might focus on accuracy and completeness, handling a wider range of scenarios at the cost of computational efficiency. These variations mean that an A* algorithm designed for one context, such as navigating a static grid, might not be directly applicable to a dynamic 3D environment with obstacles that change over time. Consequently, a universal solution in a modulescript would be challenging to achieve, as it would need to accommodate these diverse requirements and optimizations, making it less efficient or effective for any specific application.

I certainly would not use the provided script in production, as it was only intended to be an example.

Typically you would use A* for routing for example in a waypoint system

Slight modification to the provided algorithm can return something like this.