Note: Intermediate scripting knowledge is a prerequisite to understand some of the things going on within this tutorial.
I’ve seen various admin commands tutorials but none that explain how to create a modular system. Hard coding something like an admin commands system is bad practice. Scripters should learn to be flexible and write dynamic code.
What is a “modular system”?
This can be defined as a system with parts that are interchangeable and easily edited. In this tutorial I’ll show you how to create a modular admins command system. It will consist of a controller script → command modules. An example of the layout of this system:
Each of these module scripts returns a table with data for their command. An example of one of these modules:
return {
Name = "Commands", -- The name of the command
MinimumRank = 0, -- The minimum rank required to use the command
Usages = {"cmds", "commands", "help"}, -- Various usages for the command
ParamFormat = {}, -- Param format, I'll explain this later
Callback = function(player) -- Callback function to actually excecute this command
-- We'll write the code for this function later
return true; -- Return true/false if the command successfully executed
end
}
The actual admin commands script will only call the callback of these commands based on what the player inputs into chat. E.g. if the player types !cmds
then the code above is executed.
Creating the admin commands script
Let’s first create the basic structure of the script.
-----// SERVICES //-----
local PLAYERS = game:GetService("Players");
-----// VARIABLES //-----
local commands = {};
-----// CODE //-----
-- Insert each of the tables the modules return into the commands variable
for _, commandInstance in pairs(script:GetChildren()) do
local cmd = require(commandInstance);
commands[cmd.Name] = cmd; -- We'll index the commands by their name
end
PLAYERS.PlayerAdded:Connect(function(player)
player.Chatted:Connect(function(message)
end)
end)
So we now have access to the message sent by the player. Let’s create a settings variable that contains some internal stuff:
local SETTINGS = {
Prefix = "!", -- Define our prefix for commands
Ranks = { -- Table containing users and their ranks
[game.CreatorId] = 99, -- Game creator will be placed at a rank of 99
["AstrealDev"] = 3
}
}
Now most people who make admins commands split their message string by the length of their commands/prefixes. A way more effective way to do this is to use string.gmatch()
and split the string by white spaces. This will break the command into various parts. For example (within your chatted event):
-- Let's pretend the player just typed:
-- !speed me 30
local arguments = {}; -- This table will contain the split string
for value in string.gmatch(message, "%S+") do
table.insert(arguments, value);
end
-- The arguments table is now:
-- {"!speed", "me", "30"}
Now that we have a split table we can check to see if the first argument starts with the prefix defined in the Settings table.
if string.sub(arguments[1], 1, string.len(SETTINGS.Prefix)) ~= SETTINGS.Prefix then return end
-- Any code below this line won't be run if the first argument doesn't start with the prefix
-- All the code above does is check if the start of the first argument == prefix
Now we know that the player is trying to execute a command but how do we get that command? Well we can use string.sub()
to get all the text after the prefix in the first argument.
arguments[1] = string.sub(arguments[1], string.len(SETTINGS.Prefix) + 1);
-- Here we replace the first argument and set it to itself minus the prefix
It’s important here to check and see if that command actually exists. If it doesn’t then just return out of the Chatted
event.
EDIT: Check out @kingerman88’s comment they explain a much more efficient process for detecting aliases. The way this is done is by creating a dictionary is defined once at the start of the script and then can be indexed based on command aliases. This saves significant resources rather than looping through each command every time a command is run.
local commandNames = {}
for _,cmd in ipairs(commands) do
for _, usage in pairs(cmd.Usages) do
commandNames[usage:lower()] = cmd
end
end
Inefficient/Old Code
local command;
for _, cmd in pairs(commands) do
-- Loop the usages to check if any of the usages == arguments[1]
for _, usage in pairs(cmd.Usages) do
if usage:lower() == arguments[1]:lower() then
command = cmd;
break;
end
end
end
-- If command is nil then just return out of the Chatted event
if not command then return end
Back within the Chatted event we can check if the command exists via the first argument.
local command = commandNames[arguments[1]:lower()]
if not command then return end -- Return out of the Chatted event if the command is nil
The next check we have to do is to check if the player is above the required rank for this command. We can create a function for this to minimize the code in our Chatted
event.
local function GetRank(plr)
if SETTINGS.Ranks[plr.UserId] then return SETTINGS.Ranks[plr.UserId] end
if SETTINGS.Ranks[plr.Name] then return SETTINGS.Ranks[plr.Name] end
return 0; -- If we haven't returned anything by now then the player isn't in the ranks table so return 0
end
We can implement the rank check:
if GetRank(player) < command.MinimumRank then return end
The final check we have to do is the parameter check. Some commands might have a parameter table that looks like so:
ParamFormat = {"Player", "Number"}; -- E.g. for !walkspeed me 30
This means that after the command name is used to execute it the player must also pass a Player
and Number
. We have to ensure that these are valid and to do so we can use the following code:
if #arguments - 1 < #command.ParamFormat then return end
local argsToPass = {}; -- This table will be passed to the command's callback function based on the params they requested
-- Loop through adding the values to the argsToPass table
for index, paramType in pairs(command.ParamFormat) do
-- Command is asking for a player
if paramType:lower() == "player" then
argsToPass[index] = GetPlayer(player, arguments[index + 1]); -- We'll create this function in a bit
elseif paramType:lower() == "number" then
argsToPass[index] = tonumber(arguments[index + 1]);
elseif paramType:lower() == "string" then
argsToPass[index] = arguments[index + 1];
end
end
You’ll notice that we call a function called GetPlayer()
when attempting to get the player from a string. This function will attempt to grab a player from a string including various shorthands (me, all, others, etc.). It will return the player(s) in a table. Here’s how the function looks.
local function GetPlayer(plr, str)
if str:lower() == "me" then -- Player is executing on themselves
return { plr };
elseif str:lower() == "all" then
return PLAYERS:GetPlayers();
elseif str:lower() == "others" then
local toReturn = {};
for _, loopedPlayer in pairs(PLAYERS:GetPlayers()) do
if loopedPlayer.UserId ~= plr.UserId then
table.insert(toReturn, loopedPlayer);
end
end
return toReturn;
else -- The executor is probably trying to get a player via their UserId or Name
local byUserId = PLAYERS:GetPlayerByUserId(tonumber(str));
local byName = PLAYERS:FindFirstChild(str);
if byUserId then return { byUserId } end
if byName then return { byName } end
end
return {}; -- Don't know what the player is so return an empty table
end
Now that we’ve generated our argsToPass
table we can execute the command.
local success, errorMessage = command.Callback(player, unpack(argsToPass));
-- First pass in the executor
-- Next unpack the arguments so they appear as a tuple
-- This will make it much easier to handle them within the callback
if not success then
-- Write a custom error if the command fails to execute
warn("Command " .. command.Name .. " failed to execute due to " .. (errorMessage or "?"));
end
Example command
Here’s an example walkspeed command:
return {
Name = "WalkSpeed",
MinimumRank = 2, -- Minimum rank of 2 for this command
Usages = {"speed", "walkspeed", "setspeed"},
ParamFormat = {"Player", "Number"},
Callback = function(executor, players, newSpeed)
for _, plr in pairs(players) do -- Dont forget the Player param will return a table
plr.Character.Humanoid.WalkSpeed = newSpeed;
end
return true;
end
}
Final thoughts & code
As you can see this modular system is significantly nicer and more user-friendly than something containing a bunch of hard coded commands. Let me know if you enjoyed the tutorial or if there’s something you feel I should add/change.
Final Code
-----// SERVICES //-----
local PLAYERS = game:GetService("Players");
-----// VARIABLES //-----
local commands = {};
local SETTINGS = {
Prefix = "!",
Ranks = {
[game.CreatorId] = 99,
["AstrealDev"] = 3
}
}
-----// FUNCTIONS //-----
local function GetRank(plr)
if SETTINGS.Ranks[plr.UserId] then return SETTINGS.Ranks[plr.UserId] end
if SETTINGS.Ranks[plr.Name] then return SETTINGS.Ranks[plr.Name] end
return 0;
end
local function GetPlayer(plr, str)
if str:lower() == "me" then
return { plr };
elseif str:lower() == "all" then
return PLAYERS:GetPlayers();
elseif str:lower() == "others" then
local toReturn = {};
for _, loopedPlayer in pairs(PLAYERS:GetPlayers()) do
if loopedPlayer.UserId ~= plr.UserId then
table.insert(toReturn, loopedPlayer);
end
end
return toReturn;
else
local byUserId = PLAYERS:GetPlayerByUserId(tonumber(str));
local byName = PLAYERS:FindFirstChild(str);
if byUserId then return { byUserId } end
if byName then return { byName } end
end
return {};
end
-----// CODE //-----
for _, commandInstance in pairs(script:GetChildren()) do
local cmd = require(commandInstance);
commands[cmd.Name] = cmd;
end
local commandNames = {}
for _,cmd in ipairs(commands) do
for _, usage in pairs(cmd.Usages) do
commandNames[usage:lower()] = cmd
end
end
PLAYERS.PlayerAdded:Connect(function(player)
player.Chatted:Connect(function(message)
local arguments = {};
for value in string.gmatch(message, "%S+") do
table.insert(arguments, value);
end
if string.sub(arguments[1], 1, string.len(SETTINGS.Prefix)) ~= SETTINGS.Prefix then return end
arguments[1] = string.sub(arguments[1], string.len(SETTINGS.Prefix) + 1);
local command = commandNames[arguments[1]:lower()];
if not command then return end
if GetRank(player) < command.MinimumRank then return end
if #arguments - 1 < #command.ParamFormat then return end
local argsToPass = {};
for index, paramType in pairs(command.ParamFormat) do
if paramType:lower() == "player" then
argsToPass[index] = GetPlayer(player, arguments[index + 1]);
elseif paramType:lower() == "number" then
argsToPass[index] = tonumber(arguments[index + 1]);
elseif paramType:lower() == "string" then
argsToPass[index] = arguments[index + 1];
end
end
local success, errorMessage = command.Callback(player, unpack(argsToPass));
if not success then
warn("Command " .. command.Name .. " failed to execute due to " .. (errorMessage or "?"));
end
end)
end)