Modular Admin Commands

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:
image
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)
18 Likes

This is a really well thought out tutorial! Although I do have some feedback.

First

Every time you execute the code, you are using this loop. That is a little inefficient, as you could just use a dictionary which you store all the command aliases in.

local commandNames = {}
for _,cmd in ipairs(commands) do
    for _, usage in pairs(cmd.Usages) do
        commandNames[usage:lower()] = cmd
    end
end
--
-- we can call it much easier
command = commandNames[arguments[1]:lower()]

Regarding the example command, if a player’s character isn’t loaded, it will cause an error for everyone.

    for _, plr in pairs(players) do -- Dont forget the Player param will return a table
      plr.Character.Humanoid.WalkSpeed = newSpeed;
    end

Since this is more of an intermediate tutorial, highly suggest that you show group support. Most people would probably figure that out, but it’s never bad to include an example!


The only other thing is that you should try figuring out a way to do multi line commands, since most main admin command modules support this. Might I suggest looping through all the values from the gmatch and organizing them to each command :wink:

3 Likes

Hey thanks for the feedback! Your dictionary version of detecting command aliases is definitely much more efficient I’ll update the tutorial now. The overall tutorial was meant to be pretty basic simply showcasing some intermediate scripting. People who decide to follow it should be more than capable of implementing group support by editing the GetRank() function. Same goes for multiline commands but thank you for the suggestions I appreciate it.

2 Likes

This is an awesome tutorial! I will definitely try this out when I can. If you wanted to make this more advanced, you could implement a datastore to save admins and have commands to add them to the table.

1 Like

Hello, thank you for the tutorial! If you don’t mind me asking, how did you changed Roblox Studio UIs?

73bb756e3c22e68d995fe468d907e34289b3ce72

oh well I recommend you look at vanilla, that is what you want

2 Likes

Has anyone tried using this “Modular” chat command module:

The actual key to being a skilled programmer is the ability to understand and modify other people’s code. Your work is excellent.

May I know why it doesn’t work for me?