Tutorial · Game systems

How to make an admin panel in Roblox Studio

An admin panel lets your moderators deal with problem players without typing commands: pick a player, pick a reason, press Kick or Ban. The hard part is not the buttons, it is making sure nobody else can press them. This guide builds a panel with ranks, where every check runs on the server, bans are saved so they last, and a ban removes the player from every server of your game at once. Prefer chat commands? See admin commands with TextChatCommand.

Updated 25 October 20268 min readIntermediateLuau

In short

Put the ranks and every action in a server Script. Clients talk to it through one RemoteFunction, and its OnServerInvoke checks the caller's rank by user ID before it does anything. Save bans in a data store with an end time and check them in Players.PlayerAdded. Publish each new ban on a MessagingService topic so every running server kicks that player right away. The panel itself is a LocalScript that only builds for staff.

  • Never trust the panel: a cheater can fire your RemoteFunction with anything, so the server checks the rank, the action and every argument.
  • Staff can only act on players below their own rank, so a moderator cannot ban the owner.
  • Roblox also has a built-in ban API, Players:BanAsync, that blocks alt accounts too. When to use it.

Get the script

Admin panel server script with ranks and bans

One server Script holds the ranks, kicks, saves timed and permanent bans in a data store and uses MessagingService to remove a banned player from every server.

Script in ServerScriptService named AdminServer
-- Script in ServerScriptService named AdminServer.
-- Ranks, server-side checks, kick, timed bans saved in a DataStore,
-- and MessagingService so a ban removes the player from every server at once.
local DataStoreService = game:GetService("DataStoreService")
local MessagingService = game:GetService("MessagingService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

-- Ranks by user ID. 1 = Moderator, 2 = Admin, 3 = Owner. Everyone else is 0.
local RANKS: { [number]: number } = {
	[123456] = 3,
}
local RANK_NAMES = { [0] = "Player", [1] = "Moderator", [2] = "Admin", [3] = "Owner" }
-- The lowest rank that may use each action
local MIN_RANK: { [string]: number } = {
	kick = 1,
	ban = 2,
	unban = 2,
}
-- Fixed reasons: the banned player sees one of these. Typed text shown to another
-- player would have to go through Roblox's text filter first, so the panel never sends any.
local REASONS = { "Exploiting", "Harassing players", "Spamming", "Breaking the game rules" }
local MAX_BAN_MINUTES = 60 * 24 * 365
local BAN_TOPIC = "AdminBans"
local bans = DataStoreService:GetDataStore("AdminBans_v1")

local remote = Instance.new("RemoteFunction")
remote.Name = "AdminRemote"
remote.Parent = ReplicatedStorage

local function rankOf(userId: number): number
	-- In a Studio playtest of a place you own yourself, you are the Owner
	if RunService:IsStudio() and game.CreatorType == Enum.CreatorType.User and userId == game.CreatorId then
		return 3
	end
	return RANKS[userId] or 0
end

local function findPlayer(text: string): Player?
	local lower = string.lower(text)
	if lower == "" then
		return nil
	end
	for _, player in Players:GetPlayers() do
		if string.sub(string.lower(player.Name), 1, #lower) == lower
			or string.sub(string.lower(player.DisplayName), 1, #lower) == lower
		then
			return player
		end
	end
	return nil
end

local function banMessage(reason: string, untilTime: number): string
	if untilTime < 0 then
		return "You are banned: " .. reason
	end
	local minutes = math.max(1, math.ceil((untilTime - os.time()) / 60))
	return "You are banned for " .. minutes .. " more minutes: " .. reason
end

-- Returns the active ban for a user ID, if any
local function getBan(userId: number): (string?, number?)
	local ok, data = pcall(function()
		return bans:GetAsync(tostring(userId))
	end)
	if not ok or type(data) ~= "table" then
		return nil, nil
	end
	local untilTime = tonumber(data.untilTime) or 0
	if untilTime >= 0 and untilTime <= os.time() then
		return nil, nil -- it ran out
	end
	return tostring(data.reason or "No reason given"), untilTime
end

-- Every server listens for bans made on other servers
local subscribed, subscribeErr = pcall(function()
	MessagingService:SubscribeAsync(BAN_TOPIC, function(message)
		local data = message.Data
		if type(data) ~= "table" then
			return
		end
		local target = Players:GetPlayerByUserId(tonumber(data.userId) or 0)
		if target then
			target:Kick(banMessage(tostring(data.reason), tonumber(data.untilTime) or -1))
		end
	end)
end)
if not subscribed then
	warn("AdminServer: cross-server bans are off:", subscribeErr)
end

type Request = { action: string?, target: string?, reason: string?, minutes: number? }

local actions: { [string]: (caller: Player, request: Request) -> string } = {
	kick = function(caller, request)
		local target = findPlayer(request.target or "")
		if not target then
			return "No player in this server matches that name."
		end
		if rankOf(target.UserId) >= rankOf(caller.UserId) then
			return "You can only kick players below your rank."
		end
		target:Kick("Kicked by a moderator: " .. (request.reason or REASONS[#REASONS]))
		return "Kicked " .. target.Name .. "."
	end,
	ban = function(caller, request)
		-- the target can be a name in this server or a user ID for someone offline
		local target = findPlayer(request.target or "")
		local userId = if target then target.UserId else tonumber(request.target)
		if not userId then
			return "Type the name of a player in this server, or a user ID."
		end
		if rankOf(userId) >= rankOf(caller.UserId) then
			return "You can only ban players below your rank."
		end
		local minutes = math.floor(tonumber(request.minutes) or 0)
		local untilTime = if minutes > 0 then os.time() + math.min(minutes, MAX_BAN_MINUTES) * 60 else -1
		local reason = request.reason or REASONS[#REASONS]
		local ok, err = pcall(function()
			bans:SetAsync(tostring(userId), { untilTime = untilTime, reason = reason, by = caller.UserId })
		end)
		if not ok then
			return "The ban was not saved: " .. tostring(err)
		end
		if target then
			target:Kick(banMessage(reason, untilTime))
		end
		pcall(function()
			MessagingService:PublishAsync(BAN_TOPIC, { userId = userId, reason = reason, untilTime = untilTime })
		end)
		return "Banned " .. userId .. (if untilTime < 0 then " for good." else " for " .. minutes .. " minutes.")
	end,
	unban = function(_caller, request)
		local userId = tonumber(request.target)
		if not userId then
			return "Unban takes a user ID."
		end
		local ok, err = pcall(function()
			bans:RemoveAsync(tostring(userId))
		end)
		return if ok then "Unbanned " .. userId .. "." else "Unban failed: " .. tostring(err)
	end,
}

-- The only way in from clients. Every check happens here, never in the panel.
remote.OnServerInvoke = function(caller: Player, request: any): any
	local rank = rankOf(caller.UserId)
	if type(request) ~= "table" then
		-- the panel asks once on join: which rank am I, and which reasons can I pick
		return { rank = rank, rankName = RANK_NAMES[rank], reasons = if rank > 0 then REASONS else {} }
	end
	local action = request.action
	local handler = if type(action) == "string" then actions[action] else nil
	if not handler or type(action) ~= "string" then
		return { ok = false, message = "Unknown action." }
	end
	if rank < (MIN_RANK[action] or math.huge) then
		return { ok = false, message = "Your rank cannot do that." }
	end
	local clean: Request = {
		target = if type(request.target) == "string" then string.sub(request.target, 1, 40) else nil,
		reason = if table.find(REASONS, request.reason) then request.reason else nil,
		minutes = if type(request.minutes) == "number" then request.minutes else nil,
	}
	local message = handler(caller, clean)
	print(("[Admin] %s (%d) %s %s: %s"):format(caller.Name, caller.UserId, action, clean.target or "", message))
	return { ok = true, message = message }
end

-- Banned players are removed as soon as they join
Players.PlayerAdded:Connect(function(player: Player)
	local reason, untilTime = getBan(player.UserId)
	if reason and untilTime then
		player:Kick(banMessage(reason, untilTime))
	end
end)

RoCode builds it in the place you have open in Roblox Studio, on Windows or macOS with a desktop browser; on a phone the button sends the prompt and the link to your computer. Free to start, with free credits every day. More Roblox Studio scripts

Where to put it

Explorer: the server script and the panel
ServerScriptService
  AdminServer Script new
StarterPlayer
  StarterPlayerScripts
    AdminPanel LocalScript new

Settings you can change

RANKSIn the script
User IDs and their rank: 1 Moderator, 2 Admin, 3 Owner. Replace 123456 with your own ID. Default: [123456] = 3.
MIN_RANKIn the script
The lowest rank allowed to kick, ban and unban. By default moderators can kick and admins can ban.

Add the AdminPanel LocalScript from Step 2 for the on-screen panel. To test bans in Studio, turn on Enable Studio Access to API Services (File, then Experience Settings, then Security).

What a cheater can and cannot doThe server checks the caller's rank and the target's rank on every request, and only accepts fixed reasons, so a cheater who fires the remote gets "Your rank cannot do that."

Type-checked with luau-lsp strict against Roblox's API definitions on 25 October 2026. Not playtested in Studio yet: press Play and watch the Output window before you publish.

Prompt for RoCode

Add an admin panel to my game: ranks by user ID (moderator, admin, owner), a panel only staff can see with a player name box, a reason picker and Kick, Ban and Unban buttons, every check on the server, timed and permanent bans saved in a DataStore, and MessagingService so a ban kicks the player from every server.

On this page
  1. Get the script
  2. How it works
  3. 1. Server script
  4. 2. The panel
  5. 3. Across servers
  6. 4. BanAsync
  7. Common errors
  8. With RoCode
  9. Questions
  10. Sources

How it fits together

Two scripts and one remote. The panel asks, the server decides.

The pieces of the admin panel
PieceRuns onJob
AdminServerServerRanks, the rank checks, kick, ban, unban, the ban check on join and the cross-server listener
AdminRemoteReplicatedStorageA RemoteFunction the server creates. The panel calls it and gets a message back
AdminPanelEach staff member's screenAsks the server for its rank on join, and builds the panel only when the rank is 1 or more
Data store AdminBans_v1RobloxOne entry per banned user ID: when the ban ends, the reason and who banned them
Topic AdminBansRobloxA MessagingService channel every server listens on, so a ban reaches players in other servers
Ranks and what they can do
RankNumberCan
Moderator1Kick
Admin2Kick, ban, unban
Owner3Everything, and act on admins

Step 1: The server script

  1. Insert a Script in ServerScriptService named AdminServer and paste the code below.
  2. Put your user ID in RANKS with rank 3, and add your staff with 1 or 2. Your user ID is the number in your profile link, roblox.com/users/123456/profile.
Script in ServerScriptService named AdminServer
-- Script in ServerScriptService named AdminServer.
-- Ranks, server-side checks, kick, timed bans saved in a DataStore,
-- and MessagingService so a ban removes the player from every server at once.
local DataStoreService = game:GetService("DataStoreService")
local MessagingService = game:GetService("MessagingService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

-- Ranks by user ID. 1 = Moderator, 2 = Admin, 3 = Owner. Everyone else is 0.
local RANKS: { [number]: number } = {
	[123456] = 3,
}
local RANK_NAMES = { [0] = "Player", [1] = "Moderator", [2] = "Admin", [3] = "Owner" }
-- The lowest rank that may use each action
local MIN_RANK: { [string]: number } = {
	kick = 1,
	ban = 2,
	unban = 2,
}
-- Fixed reasons: the banned player sees one of these. Typed text shown to another
-- player would have to go through Roblox's text filter first, so the panel never sends any.
local REASONS = { "Exploiting", "Harassing players", "Spamming", "Breaking the game rules" }
local MAX_BAN_MINUTES = 60 * 24 * 365
local BAN_TOPIC = "AdminBans"
local bans = DataStoreService:GetDataStore("AdminBans_v1")

local remote = Instance.new("RemoteFunction")
remote.Name = "AdminRemote"
remote.Parent = ReplicatedStorage

local function rankOf(userId: number): number
	-- In a Studio playtest of a place you own yourself, you are the Owner
	if RunService:IsStudio() and game.CreatorType == Enum.CreatorType.User and userId == game.CreatorId then
		return 3
	end
	return RANKS[userId] or 0
end

local function findPlayer(text: string): Player?
	local lower = string.lower(text)
	if lower == "" then
		return nil
	end
	for _, player in Players:GetPlayers() do
		if string.sub(string.lower(player.Name), 1, #lower) == lower
			or string.sub(string.lower(player.DisplayName), 1, #lower) == lower
		then
			return player
		end
	end
	return nil
end

local function banMessage(reason: string, untilTime: number): string
	if untilTime < 0 then
		return "You are banned: " .. reason
	end
	local minutes = math.max(1, math.ceil((untilTime - os.time()) / 60))
	return "You are banned for " .. minutes .. " more minutes: " .. reason
end

-- Returns the active ban for a user ID, if any
local function getBan(userId: number): (string?, number?)
	local ok, data = pcall(function()
		return bans:GetAsync(tostring(userId))
	end)
	if not ok or type(data) ~= "table" then
		return nil, nil
	end
	local untilTime = tonumber(data.untilTime) or 0
	if untilTime >= 0 and untilTime <= os.time() then
		return nil, nil -- it ran out
	end
	return tostring(data.reason or "No reason given"), untilTime
end

-- Every server listens for bans made on other servers
local subscribed, subscribeErr = pcall(function()
	MessagingService:SubscribeAsync(BAN_TOPIC, function(message)
		local data = message.Data
		if type(data) ~= "table" then
			return
		end
		local target = Players:GetPlayerByUserId(tonumber(data.userId) or 0)
		if target then
			target:Kick(banMessage(tostring(data.reason), tonumber(data.untilTime) or -1))
		end
	end)
end)
if not subscribed then
	warn("AdminServer: cross-server bans are off:", subscribeErr)
end

type Request = { action: string?, target: string?, reason: string?, minutes: number? }

local actions: { [string]: (caller: Player, request: Request) -> string } = {
	kick = function(caller, request)
		local target = findPlayer(request.target or "")
		if not target then
			return "No player in this server matches that name."
		end
		if rankOf(target.UserId) >= rankOf(caller.UserId) then
			return "You can only kick players below your rank."
		end
		target:Kick("Kicked by a moderator: " .. (request.reason or REASONS[#REASONS]))
		return "Kicked " .. target.Name .. "."
	end,
	ban = function(caller, request)
		-- the target can be a name in this server or a user ID for someone offline
		local target = findPlayer(request.target or "")
		local userId = if target then target.UserId else tonumber(request.target)
		if not userId then
			return "Type the name of a player in this server, or a user ID."
		end
		if rankOf(userId) >= rankOf(caller.UserId) then
			return "You can only ban players below your rank."
		end
		local minutes = math.floor(tonumber(request.minutes) or 0)
		local untilTime = if minutes > 0 then os.time() + math.min(minutes, MAX_BAN_MINUTES) * 60 else -1
		local reason = request.reason or REASONS[#REASONS]
		local ok, err = pcall(function()
			bans:SetAsync(tostring(userId), { untilTime = untilTime, reason = reason, by = caller.UserId })
		end)
		if not ok then
			return "The ban was not saved: " .. tostring(err)
		end
		if target then
			target:Kick(banMessage(reason, untilTime))
		end
		pcall(function()
			MessagingService:PublishAsync(BAN_TOPIC, { userId = userId, reason = reason, untilTime = untilTime })
		end)
		return "Banned " .. userId .. (if untilTime < 0 then " for good." else " for " .. minutes .. " minutes.")
	end,
	unban = function(_caller, request)
		local userId = tonumber(request.target)
		if not userId then
			return "Unban takes a user ID."
		end
		local ok, err = pcall(function()
			bans:RemoveAsync(tostring(userId))
		end)
		return if ok then "Unbanned " .. userId .. "." else "Unban failed: " .. tostring(err)
	end,
}

-- The only way in from clients. Every check happens here, never in the panel.
remote.OnServerInvoke = function(caller: Player, request: any): any
	local rank = rankOf(caller.UserId)
	if type(request) ~= "table" then
		-- the panel asks once on join: which rank am I, and which reasons can I pick
		return { rank = rank, rankName = RANK_NAMES[rank], reasons = if rank > 0 then REASONS else {} }
	end
	local action = request.action
	local handler = if type(action) == "string" then actions[action] else nil
	if not handler or type(action) ~= "string" then
		return { ok = false, message = "Unknown action." }
	end
	if rank < (MIN_RANK[action] or math.huge) then
		return { ok = false, message = "Your rank cannot do that." }
	end
	local clean: Request = {
		target = if type(request.target) == "string" then string.sub(request.target, 1, 40) else nil,
		reason = if table.find(REASONS, request.reason) then request.reason else nil,
		minutes = if type(request.minutes) == "number" then request.minutes else nil,
	}
	local message = handler(caller, clean)
	print(("[Admin] %s (%d) %s %s: %s"):format(caller.Name, caller.UserId, action, clean.target or "", message))
	return { ok = true, message = message }
end

-- Banned players are removed as soon as they join
Players.PlayerAdded:Connect(function(player: Player)
	local reason, untilTime = getBan(player.UserId)
	if reason and untilTime then
		player:Kick(banMessage(reason, untilTime))
	end
end)

OnServerInvoke is the only door in. It looks up the caller's rank, refuses actions the rank does not allow, and copies only the fields it expects from the request: a target of at most 40 characters, a reason from the fixed list and a number of minutes. The handlers then check the target's rank against the caller's.

A ban stores untilTime, a Unix time from os.time(), or -1 for a permanent ban. getBan treats a ban whose time has passed as gone, so timed bans end by themselves.

Step 2: The panel

  1. Insert a LocalScript in StarterPlayer, then StarterPlayerScripts, named AdminPanel, and paste the code below.
  2. Press Play in a place you own. In Studio you count as the Owner, so an Admin button appears at the top right.
LocalScript in StarterPlayerScripts named AdminPanel
-- LocalScript in StarterPlayer > StarterPlayerScripts named AdminPanel.
-- Builds the panel only for players with a rank. It sends requests; the server decides.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local player = Players.LocalPlayer
local remote = ReplicatedStorage:WaitForChild("AdminRemote") :: RemoteFunction

local ok, info = pcall(function()
	return remote:InvokeServer(nil)
end)
if not ok or type(info) ~= "table" or (tonumber(info.rank) or 0) < 1 then
	return -- not staff: no panel at all
end
local reasons: { string } = if type(info.reasons) == "table" then info.reasons else { "Breaking the game rules" }

local gui = Instance.new("ScreenGui")
gui.Name = "AdminPanel"
gui.ResetOnSpawn = false
gui.Parent = player:WaitForChild("PlayerGui")

local function corner(parent: Instance, radius: number)
	local c = Instance.new("UICorner")
	c.CornerRadius = UDim.new(0, radius)
	c.Parent = parent
end

local function label(parent: Instance, text: string, y: number): TextLabel
	local l = Instance.new("TextLabel")
	l.BackgroundTransparency = 1
	l.Position = UDim2.fromOffset(16, y)
	l.Size = UDim2.new(1, -32, 0, 18)
	l.Font = Enum.Font.GothamMedium
	l.TextSize = 14
	l.TextColor3 = Color3.fromRGB(170, 175, 190)
	l.TextXAlignment = Enum.TextXAlignment.Left
	l.Text = text
	l.Parent = parent
	return l
end

local function box(parent: Instance, placeholder: string, y: number): TextBox
	local b = Instance.new("TextBox")
	b.Position = UDim2.fromOffset(16, y)
	b.Size = UDim2.new(1, -32, 0, 36)
	b.BackgroundColor3 = Color3.fromRGB(32, 34, 44)
	b.TextColor3 = Color3.new(1, 1, 1)
	b.PlaceholderText = placeholder
	b.PlaceholderColor3 = Color3.fromRGB(120, 125, 140)
	b.Font = Enum.Font.Gotham
	b.TextSize = 16
	b.Text = ""
	b.ClearTextOnFocus = false
	b.Parent = parent
	corner(b, 8)
	return b
end

local function button(parent: Instance, text: string, color: Color3, x: number, y: number, width: number): TextButton
	local b = Instance.new("TextButton")
	b.Position = UDim2.new(x, if x > 0 then 4 else 16, 0, y)
	b.Size = UDim2.new(width, -20, 0, 38)
	b.BackgroundColor3 = color
	b.TextColor3 = Color3.new(1, 1, 1)
	b.Font = Enum.Font.GothamBold
	b.TextSize = 16
	b.Text = text
	b.AutoButtonColor = true
	b.Parent = parent
	corner(b, 8)
	return b
end

-- The toggle button, top right of the screen
local toggle = button(gui, "Admin", Color3.fromRGB(90, 70, 200), 0, 0, 0)
toggle.AnchorPoint = Vector2.new(1, 0)
toggle.Position = UDim2.new(1, -16, 0, 60)
toggle.Size = UDim2.fromOffset(90, 36)

local panel = Instance.new("Frame")
panel.Name = "Panel"
panel.AnchorPoint = Vector2.new(1, 0)
panel.Position = UDim2.new(1, -16, 0, 104)
panel.Size = UDim2.fromOffset(320, 372)
panel.BackgroundColor3 = Color3.fromRGB(20, 21, 28)
panel.Visible = false
panel.Parent = gui
corner(panel, 12)

local title = label(panel, "Admin panel  ·  " .. tostring(info.rankName), 12)
title.TextColor3 = Color3.new(1, 1, 1)
title.Font = Enum.Font.GothamBold
title.TextSize = 16

label(panel, "Player name, or a user ID to ban or unban", 40)
local targetBox = box(panel, "Name or user ID", 60)
label(panel, "Ban length in minutes (empty = permanent)", 104)
local minutesBox = box(panel, "60", 124)

label(panel, "Reason the player sees", 168)
local reasonIndex = 1
local reasonButton = button(panel, reasons[1], Color3.fromRGB(45, 48, 62), 0, 188, 1)

local kickButton = button(panel, "Kick", Color3.fromRGB(220, 140, 40), 0, 240, 0.5)
local banButton = button(panel, "Ban", Color3.fromRGB(210, 60, 70), 0.5, 240, 0.5)
local unbanButton = button(panel, "Unban user ID", Color3.fromRGB(60, 150, 90), 0, 286, 1)

local status = label(panel, "", 336)
status.TextWrapped = true
status.Size = UDim2.new(1, -32, 0, 30)

toggle.Activated:Connect(function()
	panel.Visible = not panel.Visible
end)

reasonButton.Activated:Connect(function()
	reasonIndex = reasonIndex % #reasons + 1
	reasonButton.Text = reasons[reasonIndex]
end)

local busy = false
local function send(action: string)
	if busy then
		return
	end
	busy = true
	status.Text = "Working..."
	local sent, result = pcall(function()
		return remote:InvokeServer({
			action = action,
			target = targetBox.Text,
			reason = reasons[reasonIndex],
			minutes = tonumber(minutesBox.Text),
		})
	end)
	status.Text = if sent and type(result) == "table" then tostring(result.message) else "No answer from the server."
	busy = false
end

kickButton.Activated:Connect(function()
	send("kick")
end)
banButton.Activated:Connect(function()
	send("ban")
end)
unbanButton.Activated:Connect(function()
	send("unban")
end)

Players without a rank never get the panel, but that is only tidiness: the server would refuse them anyway. The panel builds its frame in code, so there is nothing to set up in StarterGui. To restyle it, change the colours in box and button, or build your own frame and keep the send function.

Step 3: Bans across servers

A popular game runs many servers at once. Without help, a ban only removes the player from the server your admin is in; they would be kicked the next time they join, from the data store, but could keep playing in another server until then.

The fix is MessagingService. Every server subscribes to the AdminBans topic when it starts. When an admin bans someone, the server saves the ban, then publishes the user ID, reason and end time. Every server gets the message, usually within seconds, and kicks that player if they are there.

  • If MessagingService is not available, for example in a Studio test of a place that was never published, the script warns "cross-server bans are off" and the rest still works.
  • Messages can arrive late or not at all, so the data store stays the source of truth: the join check catches anyone a message missed.

Step 4: Roblox's built-in ban API

Roblox has its own ban API: Players:BanAsync, UnbanAsync and GetBanHistoryAsync. Turn it on with the BanningEnabled property of Players, in Studio. Roblox removes a banned player from every server itself, and by default it also bans their suspected alt accounts.

Your own bans or BanAsync
This guide's data store bansPlayers:BanAsync
Removes from every serverYes, with MessagingServiceYes, by Roblox
Alt accountsNoYes, unless ExcludeAltAccounts is true
Works in a Studio testYes, with API access onRuns, but bans made in Studio do not apply to the live game
Message the player seesYour fixed reasonsDisplayReason, up to 400 characters, filtered by Roblox
Where you manage bansYour panelYour scripts, or Roblox's Open Cloud API

To switch, replace the SetAsync and publish in the ban handler with a call like Players:BanAsync({ UserIds = { userId }, Duration = 3600, DisplayReason = reason, PrivateReason = "banned by " .. caller.UserId }), where Duration is in seconds and -1 means permanent. It can only be called from a server script.

Common errors and fixes

If the panel does not show or a button does nothing, check these.

The Admin button never appears

Your user ID is not in RANKS, or the place is owned by a group (the Studio Owner shortcut only works for a place owned by your own account). Check the ID and look for errors in the Output.

"The ban was not saved" in the status line

Data stores are not reachable. In Studio, publish the place and turn on Enable Studio Access to API Services.

"You can only kick players below your rank"

Working as intended: staff cannot act on someone of the same rank or higher. Give the owner rank 3.

Banning by name says no player matches

Names only work for players in your server. For someone offline, type their user ID instead.

A banned player still plays in another server

MessagingService did not deliver, or the place was not published. They are removed the next time they join, from the saved ban.

Build it with RoCode

If you would rather describe the panel than wire it up by hand, RoCode can build it in the place you have open. RoCode is an independent AI agent for Roblox Studio, not made by Roblox: you describe the change in a web chat, and its Studio plugin creates the scripts and GUI in your Explorer.

You type

Add an admin panel for me (owner, user ID 111) and two moderators (222, 333): moderators can kick, I can ban for a set time or for good, every check on the server, bans saved and applied across all servers.

RoCode does
  • Writes the server script and the panel with your ranks and the actions you ask for
  • Can read your place first and work with an admin script you already have
  • Is told to compile-check each script with Check Script, and sets undo points around each batch of changes

You still test it: with a second player for the rank checks, and in a published place for saving and cross-server bans. How RoCode connects to Studio.

Questions

How do I make an admin panel in Roblox?

Keep the ranks and actions in a server Script behind one RemoteFunction, check the caller's rank there on every request, and build the panel in a LocalScript that only shows for staff. Steps 1 and 2 have both scripts.

How do I ban someone from my Roblox game for good?

Save a ban with an end time of -1 in a data store and kick anyone with a saved ban when they join, as this guide does. Or use Players:BanAsync with Duration set to -1, which also bans their alt accounts.

Do I need HD Admin or Adonis?

No. Those admin systems add many commands and a panel, but they are large code you did not write. A short panel of your own does exactly what you need, and you know every line that can kick a player.

Can exploiters use my admin panel?

Not if every check is on the server. A cheater can fire the RemoteFunction, but OnServerInvoke checks their rank by user ID and refuses.

How does a ban work across servers?

The server that bans publishes the user ID on a MessagingService topic. Every other server listens on that topic and kicks the player if they are there.

Sources and further reading

How the code was checked: every script on this page passes Luau's strict type checker against Roblox's API definitions (luau-lsp, 25 October 2026). A type check catches misspelt APIs and wrong types, not game logic, so playtest in Studio before you publish.

Try RoCode on your own place.

Free every day, no card required. Install the plugin, describe the feature, and review what lands in Studio.