M
ScriptKeyless
mRTS WayPoint - Tactical Route Planner
MEDIEVAL RTS [⚔️BIGGER ARMIES]Desktop compatibleDelta
Description
Allows you to plan waypoints for troops, improving movements, enabling the ability to do unit/army patrols. This is under testing as of this post and currently runs on Delta, might not work on other exec
GAMEMEDIEVAL RTS [⚔️BIGGER ARMIES]
UPDATEDOct 17, 2026
ACCESSKeyless
PLATFORMDesktop
Script source Luau
-- [[ BloxScripts Risk Notice ]]
-- This script has not been verified by BloxScripts.fun. Deal with caution.
--
-- Stay safe:
-- • Never log in on unofficial Roblox sites or lookalike domains.
-- • Real Roblox links use roblox.com (check the .com ending).
-- • Treat fake Roblox login / "claim reward" pages as phishing.
-- [[ End BloxScripts Risk Notice ]]
-- Medieval RTS | Tactical Route Planner
-- Client-side waypoint planner, uses SendUnitGoals like regular RTS movement.
-- Mark points on the world, capture selected units, then run the route.
local Players = game:GetService("Players")
local UserInputService = game:GetService("UserInputService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local player = Players.LocalPlayer
local playerGui = player:WaitForChild("PlayerGui")
local unitFolder = workspace:WaitForChild("Game"):WaitForChild("PlayerFolder")
:WaitForChild(player.Name):WaitForChild("Units")
local sendGoals = ReplicatedStorage:WaitForChild("SendUnitGoals")
local COMMAND_GAP = 1.5 -- minimum gap between server calls
local MANUAL_PAUSE = 1.5 -- hold automatic orders after a world click
local ARRIVE_RADIUS = 10 -- studs from a waypoint to count as arrived
local RETRY_AFTER = 8 -- seconds until reissuing an unfinished goal
local MAX_RETRIES = 4 -- additional sends per waypoint, per unit
local MAX_POINTS = 12
local RECHECK_INTERVAL = 0.3
-- Stop a previous copy of THIS script without breaking unrelated UIs.
local oldSwitch = playerGui:FindFirstChild("RoutePlannerActive")
if oldSwitch then
oldSwitch.Value = false
oldSwitch:Destroy()
end
local switch = Instance.new("BoolValue")
switch.Name = "RoutePlannerActive"
switch.Value = true
switch.Parent = playerGui
local oldGui = playerGui:FindFirstChild("MedievalRoutePlanner")
if oldGui then oldGui:Destroy() end
local oldMarkers = workspace:FindFirstChild("LocalRoutePlannerMarkers")
if oldMarkers then oldMarkers:Destroy() end
local gui = Instance.new("ScreenGui")
gui.Name = "MedievalRoutePlanner"
gui.ResetOnSpawn = false
gui.DisplayOrder = 500
gui.Parent = playerGui
local markerFolder = Instance.new("Folder")
markerFolder.Name = "LocalRoutePlannerMarkers"
markerFolder.Parent = workspace
local function styleCorner(parent, radius)
local c = Instance.new("UICorner")
c.CornerRadius = UDim.new(0, radius or 8)
c.Parent = parent
end
local function textLabel(parent, text, y, height, color, size)
local l = Instance.new("TextLabel")
l.BackgroundTransparency = 1
l.Position = UDim2.fromOffset(12, y)
l.Size = UDim2.new(1, -24, 0, height)
l.Font = Enum.Font.Gotham
l.TextSize = size or 12
l.TextColor3 = color or Color3.fromRGB(205, 215, 226)
l.TextXAlignment = Enum.TextXAlignment.Left
l.TextTruncate = Enum.TextTruncate.AtEnd
l.Text = text
l.Parent = parent
return l
end
local function button(parent, text, x, y, width, height, color)
local b = Instance.new("TextButton")
b.Position = UDim2.fromOffset(x, y)
b.Size = UDim2.fromOffset(width, height)
b.BackgroundColor3 = color
b.BorderSizePixel = 0
b.Text = text
b.Font = Enum.Font.GothamBold
b.TextColor3 = Color3.new(1, 1, 1)
b.TextSize = 11
b.Parent = parent
styleCorner(b, 7)
return b
end
local panel = Instance.new("Frame")
panel.Name = "Panel"
panel.Size = UDim2.fromOffset(268, 248)
panel.Position = UDim2.new(0, 15, 0.36, 0)
panel.BackgroundColor3 = Color3.fromRGB(22, 28, 38)
panel.BorderSizePixel = 0
panel.ClipsDescendants = true
panel.Parent = gui
styleCorner(panel, 10)
local border = Instance.new("UIStroke")
border.Color = Color3.fromRGB(67, 102, 111)
border.Thickness = 1
border.Parent = panel
local header = Instance.new("Frame")
header.Name = "DragHandle"
header.BackgroundTransparency = 1
header.Size = UDim2.new(1, -39, 0, 36)
header.Parent = panel
local title = textLabel(header, "◆ TACTICAL ROUTE PLANNER", 8, 20,
Color3.fromRGB(99, 230, 191), 13)
title.Font = Enum.Font.GothamBold
local minimize = button(panel, "−", 234, 7, 27, 25, Color3.fromRGB(51, 63, 76))
minimize.TextSize = 18
local separator = Instance.new("Frame")
separator.Size = UDim2.new(1, -20, 0, 1)
separator.Position = UDim2.fromOffset(10, 40)
separator.BorderSizePixel = 0
separator.BackgroundColor3 = Color3.fromRGB(58, 70, 83)
separator.Parent = panel
local unitText = textLabel(panel, "UNITS: 0 captured", 47, 19)
local pointText = textLabel(panel, "WAYPOINTS: 0 / 12", 69, 19)
local progressText = textLabel(panel, "PROGRESS: —", 91, 19)
local statusText = textLabel(panel, "READY • add waypoints on the map", 117, 31,
Color3.fromRGB(122, 228, 183), 11)
statusText.TextWrapped = true
statusText.TextTruncate = Enum.TextTruncate.None
local captureBtn = button(panel, "CAPTURE UNITS", 10, 155, 121, 33,
Color3.fromRGB(44, 115, 130))
local addBtn = button(panel, "+ WAYPOINT", 137, 155, 121, 33,
Color3.fromRGB(42, 116, 88))
local goBtn = button(panel, "RUN ROUTE", 10, 195, 79, 34,
Color3.fromRGB(45, 143, 101))
local pauseBtn = button(panel, "PAUSE", 95, 195, 79, 34,
Color3.fromRGB(158, 110, 45))
local clearBtn = button(panel, "CLEAR", 180, 195, 78, 34,
Color3.fromRGB(147, 59, 67))
local collapsed = false
local armed = false
local running = false
local paused = false
local busy = false
local currentStep = 1
local waypoints = {}
local captured = {}
local unitState = {}
local lastGlobalSend = -math.huge
local lastManual = -math.huge
local connections = {}
local function setStatus(message)
statusText.Text = message
end
local function getPosition(unit)
if not unit or not unit.Parent then return nil end
local hitbox = unit:FindFirstChild("Hitbox", true)
if hitbox and hitbox:IsA("BasePart") then
return hitbox.Position
end
return nil
end
local function refreshLabels()
local n = 0
for unit in captured do
if getPosition(unit) then
n += 1
else
captured[unit] = nil
unitState[unit] = nil
end
end
unitText.Text = "UNITS: " .. n .. " captured"
pointText.Text = "WAYPOINTS: " .. #waypoints .. " / " .. MAX_POINTS
if running then
progressText.Text = "PROGRESS: " .. currentStep .. " / " .. #waypoints
else
progressText.Text = "PROGRESS: —"
end
return n
end
local function addMarker(position, number)
local base = Instance.new("Part")
base.Name = "Waypoint_" .. number
base.Shape = Enum.PartType.Cylinder
base.Size = Vector3.new(0.3, 5, 5)
base.Material = Enum.Material.Neon
base.Color = Color3.fromRGB(83, 220, 174)
base.Transparency = 0.3
base.Anchored = true
base.CanCollide = false
base.CanTouch = false
base.CanQuery = false
base.CFrame = CFrame.new(position + Vector3.new(0, 0.25, 0))
* CFrame.Angles(0, 0, math.rad(90))
base.Parent = markerFolder
local label = Instance.new("BillboardGui")
label.Size = UDim2.fromOffset(48, 30)
label.StudsOffsetWorldSpace = Vector3.new(0, 4, 0)
label.AlwaysOnTop = true
label.Adornee = base
label.Parent = base
local numberText = Instance.new("TextLabel")
numberText.BackgroundColor3 = Color3.fromRGB(19, 37, 42)
numberText.BackgroundTransparency = 0.1
numberText.Size = UDim2.fromScale(1, 1)
numberText.Font = Enum.Font.GothamBold
numberText.TextSize = 18
numberText.TextColor3 = Color3.fromRGB(129, 247, 208)
numberText.Text = tostring(number)
numberText.Parent = label
styleCorner(numberText, 8)
end
local function clickedInPanel(point)
local pos = panel.AbsolutePosition
local size = panel.AbsoluteSize
return point.X >= pos.X and point.X <= pos.X + size.X
and point.Y >= pos.Y and point.Y <= pos.Y + size.Y
end
local function raycastWorld(screenPos)
local camera = workspace.CurrentCamera
if not camera then return nil end
local ray = camera:ViewportPointToRay(screenPos.X, screenPos.Y)
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
local exclusions = {markerFolder, workspace.Game.PlayerFolder}
if player.Character then table.insert(exclusions, player.Character) end
params.FilterDescendantsInstances = exclusions
local result = workspace:Raycast(ray.Origin, ray.Direction * 4000, params)
return result and result.Position or nil
end
local function resetOrders()
unitState = {}
currentStep = 1
lastGlobalSend = -math.huge
end
local function endRoute(message)
running = false
paused = false
pauseBtn.Text = "PAUSE"
refreshLabels()
setStatus(message)
end
local function onWorldClick(input, processed)
if not switch.Value then return end
local kind = input.UserInputType
if kind ~= Enum.UserInputType.Touch
and kind ~= Enum.UserInputType.MouseButton1
and kind ~= Enum.UserInputType.MouseButton2 then
return
end
if clickedInPanel(input.Position) then return end
-- Only mouse-left or a touch can add a waypoint; any manual world
-- interaction pauses auto-orders briefly to avoid command collisions.
lastManual = os.clock()
if not armed then return end
if kind == Enum.UserInputType.MouseButton2 then return end
-- The game may mark world clicks processed; still allow waypoint placement.
local worldPoint = raycastWorld(input.Position)
if not worldPoint then
setStatus("No map surface detected — tap terrain or water")
return
end
if #waypoints >= MAX_POINTS then
armed = false
addBtn.Text = "+ WAYPOINT"
setStatus("Waypoint limit reached (12)")
return
end
table.insert(waypoints, worldPoint)
addMarker(worldPoint, #waypoints)
armed = false
addBtn.Text = "+ WAYPOINT"
refreshLabels()
setStatus("Waypoint " .. #waypoints .. " saved — add another or run")
end
table.insert(connections, UserInputService.InputEnded:Connect(onWorldClick))
-- Drag from header (not from buttons), works with mouse and touch.
local dragging = false
local dragStart
local startPosition
local dragInput
table.insert(connections, header.InputBegan:Connect(function(input)
if input.UserInputType == Enum.UserInputType.MouseButton1
or input.UserInputType == Enum.UserInputType.Touch then
dragging = true
dragStart = input.Position
startPosition = panel.Position
local release
release = input.Changed:Connect(function()
if input.UserInputState == Enum.UserInputState.End then
dragging = false
release:Disconnect()
end
end)
end
end))
table.insert(connections, header.InputChanged:Connect(function(input)
if input.UserInputType == Enum.UserInputType.MouseMovement
or input.UserInputType == Enum.UserInputType.Touch then
dragInput = input
end
end))
table.insert(connections, UserInputService.InputChanged:Connect(function(input)
if dragging and input == dragInput then
local delta = input.Position - dragStart
panel.Position = UDim2.new(
startPosition.X.Scale, startPosition.X.Offset + delta.X,
startPosition.Y.Scale, startPosition.Y.Offset + delta.Y
)
end
end))
table.insert(connections, minimize.MouseButton1Click:Connect(function()
collapsed = not collapsed
panel.Size = UDim2.fromOffset(268, collapsed and 40 or 248)
minimize.Text = collapsed and "+" or "−"
end))
table.insert(connections, captureBtn.MouseButton1Click:Connect(function()
if running then
setStatus("Pause or clear the current route before recapturing")
return
end
captured = {}
for _, unit in unitFolder:GetChildren() do
if unit:IsA("Model")
and unit:FindFirstChild("SelectionPart", true)
and getPosition(unit) then
captured[unit] = true
end
end
local count = refreshLabels()
if count == 0 then
setStatus("No selected units detected — select in-game first")
else
setStatus(count .. " unit(s) captured for the route")
end
end))
table.insert(connections, addBtn.MouseButton1Click:Connect(function()
if running then
setStatus("Finish or clear the current run before adding points")
return
end
if #waypoints >= MAX_POINTS then
setStatus("Waypoint limit reached (12)")
return
end
armed = not armed
addBtn.Text = armed and "TAP MAP..." or "+ WAYPOINT"
setStatus(armed and "Tap a point on the game world" or "Waypoint placement canceled")
end))
table.insert(connections, goBtn.MouseButton1Click:Connect(function()
if running then
setStatus("Route already running")
return
end
if #waypoints == 0 then
setStatus("Add at least one waypoint first")
return
end
if refreshLabels() == 0 then
setStatus("Select units in-game, then CAPTURE UNITS")
return
end
armed = false
addBtn.Text = "+ WAYPOINT"
resetOrders()
running = true
paused = false
pauseBtn.Text = "PAUSE"
refreshLabels()
setStatus("Running route • sending one unit order at a time")
end))
table.insert(connections, pauseBtn.MouseButton1Click:Connect(function()
if not running then
setStatus("No route is running")
return
end
paused = not paused
pauseBtn.Text = paused and "RESUME" or "PAUSE"
setStatus(paused and "Paused • existing unit moves may continue"
or "Resumed • checking unit progress")
end))
table.insert(connections, clearBtn.MouseButton1Click:Connect(function()
running = false
paused = false
armed = false
addBtn.Text = "+ WAYPOINT"
pauseBtn.Text = "PAUSE"
table.clear(waypoints)
table.clear(captured)
resetOrders()
markerFolder:ClearAllChildren()
refreshLabels()
setStatus("Cleared • select units and add a new route")
end))
local function sendMove(unit, destination, info)
if busy then return end
local now = os.clock()
if now - lastGlobalSend < COMMAND_GAP then return end
if now - lastManual < MANUAL_PAUSE then return end
if info.lastSend and now - info.lastSend < RETRY_AFTER then return end
if info.tries >= MAX_RETRIES + 1 then return end
busy = true
lastGlobalSend = now
info.lastSend = now
info.tries += 1
setStatus("Ordering " .. unit.Name .. " → point " .. currentStep
.. " (attempt " .. info.tries .. ")")
task.spawn(function()
local ok, response = pcall(function()
return sendGoals:InvokeServer({{unit, destination}}, false)
end)
if not ok then
warn("[ROUTE PLANNER] Movement error:", response)
setStatus("Movement error • retry scheduled")
elseif response == false then
setStatus("Server rejected movement • retry scheduled")
end
busy = false
end)
end
local function tickRoute()
if not running or paused or busy then return end
if currentStep > #waypoints then
endRoute("Route completed ✓")
return
end
local destination = waypoints[currentStep]
local total = 0
local reached = 0
local pendingUnit
local pendingState
for unit in captured do
local position = getPosition(unit)
if not position then
captured[unit] = nil
unitState[unit] = nil
else
total += 1
local state = unitState[unit]
if not state or state.step ~= currentStep then
state = {step = currentStep, tries = 0, lastSend = nil}
unitState[unit] = state
end
if (position - destination).Magnitude <= ARRIVE_RADIUS then
reached += 1
elseif state.tries >= MAX_RETRIES + 1
and os.clock() - (state.lastSend or 0) >= RETRY_AFTER then
-- Mark this unit as skipped, so a stuck unit doesn't trap
-- the whole group at this waypoint forever.
state.skipped = true
elseif not state.skipped and (not pendingUnit
or (state.lastSend or 0) < (pendingState.lastSend or 0)) then
pendingUnit = unit
pendingState = state
end
if state.skipped then reached += 1 end
end
end
if total == 0 then
endRoute("No captured units remain")
return
end
progressText.Text = "PROGRESS: " .. currentStep .. "/" .. #waypoints
.. " • " .. reached .. "/" .. total .. " done"
if reached >= total then
if currentStep < #waypoints then
currentStep += 1
setStatus("Reached waypoint — heading to point " .. currentStep)
else
endRoute("Route finished • " .. total .. " units processed")
end
return
end
if pendingUnit then
sendMove(pendingUnit, destination, pendingState)
elseif os.clock() - lastGlobalSend > RETRY_AFTER then
setStatus("Checking last unit arrivals...")
end
end
task.spawn(function()
while switch.Parent and switch.Value and gui.Parent do
local ok, err = pcall(tickRoute)
if not ok then warn("[ROUTE PLANNER]", err) end
task.wait(RECHECK_INTERVAL)
end
for _, connection in connections do
connection:Disconnect()
end
if gui.Parent then gui:Destroy() end
if markerFolder.Parent then markerFolder:Destroy() end
if switch.Parent then switch:Destroy() end
end)
print("[ROUTE PLANNER] Ready • capture units, add map waypoints, then run")Review the source before using it. Executions count successful copies, capped at 10 per user and network per script in 24 hours.
Community discussion
0Start the conversation.


Your community, in one place