Skip to content

Instantly share code, notes, and snippets.

@drickmortey
Created April 3, 2026 19:08
Show Gist options
  • Select an option

  • Save drickmortey/f5c84bdeea208547a8a7f5bc8c8f5dc0 to your computer and use it in GitHub Desktop.

Select an option

Save drickmortey/f5c84bdeea208547a8a7f5bc8c8f5dc0 to your computer and use it in GitHub Desktop.
--!strict
math.randomseed(os.time()) --for good luck
--//services// these are abbreviated but yardy know
local P = game:GetService("Players")
local TS = game:GetService("TweenService")
local SS = game:GetService("ServerStorage")
local RS = game:GetService("ReplicatedStorage"); local genUI = RS.GenerateUI; local aliasAdded = RS.AliasAdded; local runCommand = RS.RunCommand
local commandEvents = RS.CommandEvents; local push = commandEvents.Push
--generateUI prompts the player to create the command list, aliasAdded gives the player an updated list of aliases when new ones are added, runCommand requests a command be run (when execute button is pressed on client)
--what "push" does can be done on the server but it has a lot of latency from time command is executed -> effect taking place due to network ownership stuff. so the client does it instead to make it smooth.
---------------------------------------------
--//cache// (not much gains, but the code runs multiple times so it counts. also less stuff to write). but the main reason I decided to do this was to show how much API I used from roblox
local clamp = math.clamp
local random = math.random
local spn = task.spawn
local del = task.delay
local vcreate = vector.create :: (x: number, y: number, z: number) -> Vector3 --to silence the type solver on a "type mismatch" that wasn't entirely true
local v3new = Vector3.new
local toRadian = math.rad
--//config//
local defaultPrefix = ";" --default prefix from the og admin abuse game
local startingRank = 0 --starting off as 0 makes you a regular player without privileges
local exemptions = require(script.Exemptions) :: {[string]: number} --indexed by usernames, pointing at a userid value of that player for people who shouldn't be server banned and whatever (like above and beyond privileges)
local TESTING = false --however that stuff should happen if we're testing
----------------------------------------------------
type PassIn = {
onCD: onCD?,--[playerName]: debounce (boolean). contains list of players who are on cooldown
cooldownSeconds: number?, --for how much time the command is rendered unusable for that specific player
aliases: {string}?, --extra names for a command,
aliasesProxy: proxy?, --must be empty, acts as an "AliasAdded" event listener that fires a function whenever new aliases are added and redirects the alias to the actual alias table
Once: boolean?, --some commands don't need targets and shouldn't run multiple times if there's "multiple targets" (you'll see what I mean)
notPanelExecutable: boolean?, --some commands can't be executed by the admin panel
seniorPrecedence: boolean?, --some commands shouldn't be run on higher ranked staff
}
type secondaryPassIn = {isServerBanned: boolean?, fromPanel: boolean?} --this provides intellisense for the secondary pass in which is in the parameters of the command's function
type proxy = {[string]: cmdFunc} --the proxy is supposed to be empty, but this silences the typechecker when you try to add something to it (it will complain that this is supposed to be empty). it will remain empty anyway because __newindex's callback redirects the new element to the actual table that is supposed to receive the element while also doing other stuff.
type params = { --params is a table so that adding new values is easier and I don't have to add the addition to every command's parameters manually (makes it painless)
Character: Model?, targetPlayer: Player, words: words, msg: string, sender: Player, secondaryPassIn: {any} & secondaryPassIn
}
type cmdFunc = (params)-> () --every command's function takes in the parameters a table with type "params" and doesn't return anything
type onCD = {[string]: boolean} --onCD is a table that is indexed with player names pointing to a boolean value (debounce)
--//types//
type cmd = { --this is the command object (which is a table, too)
PassIn: PassIn , --contains some modifiers for how the command is executed
Rank: number, --minimum rank required to execute the command
func: cmdFunc, --you run this function to "execute" the command, assuming that the parameters are in a table called params of type "params"
Signature: string--this describes to the player on the admin panel how to execute the command using the chat (like ;kill target(s))
--it works by fetching the prefix (plr:GetAttribute("Prefix")), the command's name (it's sent in generateUI) and then using the signature (also sent in generateUI) to construct a string that fully describes how to execute the command (what each word represents)
} & methods
type methods = {--these methods are stored in cmdClass and are inherited by every command object
Execute: (self: cmd, params: params)->(), --this simply calls the command object's function
SetCooldown: (self: cmd, setFor: Player)->(), --this makes the command unusable for a period of time for a specific player
HasCooldown: (self: cmd, checkFor: Player)->boolean, --this specifies how long that period is
CanExecute: (self: cmd, checkFor: Player)-> boolean,--this returns a true or false on whether or not a specific player can execute a command (no cooldown and rank requirement is met)
GetSignature: (self: cmd, cmdName: string, sig: string?)->string, --this assembles the signature of the command as a string. it can't access it's own name as it's a key in a table (there's no "value.key" or "value.parent"), the name can be acquired while looping through cmdList (for key, v where key is the name)
InitialiseAliasProxy: (self: cmd, cmdName:string)->(), --this defines a proxy in the command object and a __newindex callback for it and also adds an alias table if there isn't one. the reason why I used a proxy is to also fire a remote every time a new alias is added instead of only adding the alias (aliases[i] = v)
AddAlias: (self: cmd,alias: string)->(), --this does two things: adds a key (the alias) into cmdList that equals to self (the command object), thus acting as an extra name for that command. it also adds the alias in the original command's alias table and has a remote fired to all clients to update their aliases on screen via the proxy
Precedence: (self: cmd, sender: Player, reciever: Player)->boolean, --this returns a boolean on whether or not the receiver is more important than the sender (in that case it doesn't run the command)
ExecuteOnPlayerList: (self: cmd, insert: params,targets: {Player?})->() --this executes the command object on a list of players
} --I couldn't intersect "methods" with "cmd" during declaration (cmd & methods) because that would annotate the type which would necessitate the type solver to fact check the truthiness of this union throughout all objects with this type
--which would throw type errors because it doesn't know that the methods won't there at compile-time, they're inherited. so this has to be typecasted later which does not change the workflow but guides the type system better and removes pointless warns
type words = {[number]: string} --contains separated words the player has said, e.g. words[1] is the first word in their message
type cmdClass = { --will contain methods that the objects will inherit
__index: cmdClass, --its type is the cmdClass because it's supposed to look in there when a nil index is accessed. explained further, it's just included here so the typechecker can shut up about it
} & methods --include the methods inside this type alongside __index
--not putting this inside the table is important, because then it'll define methods as a table of type "methods" inside cmdClass
--but we know that's not true, so this basically merges everything inside type "methods" into cmdClass to show it contains them alongside whatever cmdClass has (__index)
----------------------------------------------------
--//variables//
local cmdList = {} --doesn't have anything for now, but needs to be here so the helper functions can access the table
--can't give this any definitive type right now, or else it'll give a "can't add this because it's not in the type definition" type error on the commands that will be added to this later
local cmdNames = {} --this contains the original commands because cmdList is diluted with aliases
--//reusable code//
---this is a weird workaround for a type error where the type system didn't trust that the command object is of type cmd&methods because the methods are inherited using __index, which the type system cannot infer
--this "rephrases" the exact same thing, except it's in a function that is generically typed, it trusts us this way. this isn't a runtime issue either way, it only gets rid of the type error.
--if we didn't do this, self (the command object) is inferred only as "cmd" without the methods it inherits from the class, and we'd have to do self = self :: cmd&methods at the start of each method to get proper intellisense.
local function typedSetmetatable<T>(obj: T, meta: {}): T --T is a convention, nothing specific
return setmetatable(obj, meta)
end
local function onVIP(target: Player) --is the command is being run on a player it shouldn't run on
return (exemptions[target.Name] and TESTING)
--if the target is in the exemptions list, "true" will be in its position.
--then the "and" part triggers when the first operand is true, checking if TESTING is false. only returning true in
--place of both operands if TESTING is also true.
end
--- returns the humanoid or nil
local function hum(char: Model?, funcName: string?): Humanoid?
local h: Humanoid? = char and char:FindFirstChildWhichIsA("Humanoid") or nil
--if character exists then find a humanoid in it, or give back nil if not.
if not h then warn("Bad character passed into "..(funcName or "command").." function:", char) end --helps debugging. future me: this helped me TWICE to track down the exact issue when writing this script.
return h
end
local plrList = {} :: {[string]:Player} --contains a list of the names of the players in the server
--any key is a player's name and its value is the player
--this saves us from performing :GetPlayers() in most cases, which is the main performance gain.
--another benefit is that to query a player in the table, we don't have to loop over it. we can simply try to index their name and get a meaningful gain.
local serverBanned = {} :: {[string]: Player} --contains a list of people banned from the server
local loopkilled = {} :: {[string]: Player} --a list of people who are currently being loopkilled
--cool thing about indexers is that userdata like player as indexers are almost always faster than strings for accesses, while strings only become comparable to their speed after being used once (after that they're cached in an intern pool)
--- return a table of targets (players) or nil by querying a string
local function getTarget(sender: Player, str: string?): {[string]: Player}? | {[number]: Player}? --an array or dictionary of players or nil will be returned
if not str then return nil end --will default to the last target if return value is nil
local keywordQuery = str:lower() --store case insensitive version when checking for if the message was keyword (str will be used for case-sensitive player name finding)
local target: {Player}? = nil --it's nil for now. it will be returned if none of the checks trigger anything, returning nil (which will make the outside logic default to the last target)
if keywordQuery == "me" then --this saves them the hassle of typing their username
return {sender} --even singular values need to be wrapped in a table so it works with the system
elseif keywordQuery == "all" then
return plrList --all players including the sender
elseif keywordQuery == "others" then--everyone else (not including them)
local players = plrList
players[sender.Name] = nil --even if the sender isn't in the table, setting nil to nil is allowed
return players --didn't perform all of that on "target" because it has a different indexer than plrList (which is a dictionary)
end --looks like the target is none of these keywords
--so lets search by player name
for name, player in plrList do --it's a dictionary so the index is the name (string)
--string.find looks for a pattern, which allows us to search by partial name
--remember that str is the pattern (which we assume is part of a player's name)
if name:find(str) and player then --string.find() returns numbers but luau considers that true in logic operations
return {player} --also need to make sure the player exists
end
end
return target --function still needs to return something even if it didn't find anything (nil)
end
---determine if the player is trying to run a command by querying their first word
local function isCommand(word1:string): string?
--for tables, the length operator # returns the amount of elements in the array part. for strings, it returns the "amount of characters"
local toExecute = string.lower(word1:sub(2, #word1)) --the text after the prefix should be the name of the command
--lowercase for case insensitivity obviously
--the prefix is the first character, or word1:sub(1,1)
if not cmdList[toExecute] then print("not a command") return nil end --not in the list of commands, so return nil
--in case you were wondering, each key in cmdList must be in lowercase (not a design flaw, only avoiding unnecessary checks)
return toExecute --instead of having to find out what the command was again, just return that if it really was a command (will still pass boolean logic checks because non-nil and non-false types are truthy)
end
local function rankToString(rank: number): string
--this is basically an inefficient switch statement
if rank == 0 then
return "Non-staff"
elseif rank == 1 then
return "Tester"
elseif rank == 2 then
return "Moderator"
elseif rank == 3 then
return "Admin"
elseif rank == 4 then
return "Senior Admin"
elseif rank == 5 then
return "Owner"
else
warn("Invalid rank: "..rank) --only way we'll ever reach this part is if startingrank was negative or greater than 5, doesn't break anything, still
return "N/A" --not available
end
end
---does all the changes that comes with a change in the rank number
local function setRank(plr: Player, setTo: number)
setTo = clamp(setTo, 0, 5) --ranks outside of these are invalid
spn(function() --put this task under a separate thread to avoid halting the rest of the code
local leaderstats = plr:WaitForChild("leaderstats", 0.1) --find their leaderstats which contains their rank value
if not leaderstats then return end
local rank = leaderstats:WaitForChild("Rank", 0.1) ::StringValue? --waitforchild returns instance, not stringvalue but I know better
if not rank then return end
rank.Value = rankToString(setTo) --change the rank in the leaderstats to the new one we want to set
plr:SetAttribute("Rank", setTo) --also update the attribute so the other parts of the script are on the same page
end)
end
---check if a given substring is found in a player's name
local function partialFind(match: string): Player?
match = match:lower() --no need to redundantly convert match to lowercase everytime the loop runs
for _, plr in plrList do
--plr.Name is what we're looking in, since its in lowercase "match" is converted to lowercase
if string.find(plr.Name, match) then return plr end
end
return nil--nothing found
end
------------------------------------------------------------------------------------
--this will contain the methods for the objects, which use : to insert "self" as the first argument
local commands:cmdClass = {} ::cmdClass --construct a table with type cmdClass. I'm casting the type to the expression too, so luau can shut up about an empty table not having everything I specified (yardy know why)
--this will act as our class (in the form of a metatable, which is the closest thing lua/luau has to a "class")
commands.__index = commands --this is a metamethod, it defines "fallback" behaviour for otherwise erroneous operations on a table (like tbl + tbl)
--this is the workflow:
--[[
program tries to index a nil key in the object (which is also a table)
before erroring, it checks if the object has a metamethod __index or if it has a metatable that defines a behaviour for __index
you'd normally have to "tie" a function to __index, but since its use is so common for inheritance, there's a default
behaviour built into the language itself for when you do tbl.__index = OtherTbl which makes the key we're trying to index
be searched for in the OtherTbl, which is usually the metatable itself as you can see up there. now objects made using this
class will inherit functions and relevant data from said class (which is like a container).
]]
--inherited methods:
function commands:HasCooldown(checkFor: Player): boolean --this is a predicate
local onCD = self.PassIn.onCD
if not onCD then return false end
return (onCD[checkFor.Name] ~= nil) and true or false --query if there's an index with the player's name which holds a truthy value and return true if yes, false if no (if command doesn't have onCD, it returns false meaning no cooldown)
end
function commands:SetCooldown(setFor: Player)
--onCD defines if a command has a cooldown (0.1 seconds by default)
--cooldownSeconds optionally changes that value
local onCD = self.PassIn.onCD--what's currently inside onCD of this command at this exact moment
if not onCD then return end
if self:HasCooldown(setFor) then return end --don't set CD if already on cooldown, or if onCD doesn't exist in the first place (commands that don't have cooldown)
onCD[setFor.Name] = true --no cooldown, so it's time to set one
del((self.PassIn.cooldownSeconds or 0.1), function() --spawns a new thread that sleeps for a set amount of time before running
onCD[setFor.Name] = nil --remove the player's entry after timer amount of seconds has elapsed
end) --also 0.1 as default cooldown if a command doesn't have it explicitly declared
end
function commands:CanExecute(checkFor: Player)--this is a predicate
if self:HasCooldown(checkFor) then return false end
--no cooldown :true:
local plrRank: number = checkFor:GetAttribute("Rank") or 0 --in case..
if self.Rank > plrRank then return false end
--authorised :true:
return true --passed all checks :true:
end
---runs the command
function commands:Execute(...) --... (varargs) is a table that contains a variable amount of parameters in no specific order
self.func(... :: params) --they get passed in here. they are supposed to be the function parameters so their type is params
end
---constructs a command's signature which is text that shows how to execute the command in a readable string
function commands:GetSignature(cmdName:string)
local sig = self.Signature --the signature is specified during command creation, stored in the command object (self)
local split = sig:split(" ") --the signature is always split with a single space (as a separator for words)
local assembled = cmdName --"assembled" is the whole signature, it gets "assembled" in steps and starts off with the command name.
--note that the prefix isn't present in "assembled", that is handled separately on the client
for i, word in split do --for each word in the signature
if word == "" then break end--if a command has no signature, its given "" in that parameter. but it's not a word so avoid it
assembled = assembled.." ["..word.."]" --add it to assembled with a space and square brackets around it
end
return assembled --give back the constructed signature
end
function commands:InitialiseAliasProxy(cmdName: string)
--a proxy is an empty table that acts as a delegate to another table. instead of adding elements to the other table,
--you add it to its proxy which as a metatable set to it that defines a function that runs on __newindex
--this effectively acts as an ElementAdded "event" for the other table because every index is new, which is perfect for the AliasAdded event defined at the top.
--the purpose of this is to fire AliasAdded every time a new alias is added and also formally add an alias to the other table (the aliases table)
if not self.PassIn.aliases then --aliases should at least be an empty table
self.PassIn.aliases = {} :: {string}
end
local aliases = self.PassIn.aliases :: {string}
local proxy = self.PassIn.aliasesProxy
setmetatable(proxy, { --the proxy needs a metatable to define a behaviour for __newindex
--__newindex can be directly put inside proxy, but by convention it should stay empty
__newindex = function(_, alias, value)
aliasAdded:FireAllClients(cmdName, alias)--update every client's visuals
aliases[alias] = alias--add the alias into the command's aliases table, indexed with the alias
end})
end
function commands:AddAlias(alias: string)
local proxy = self.PassIn.aliasesProxy :: proxy
cmdList[alias] = self --the alias refers to this command object, so it runs the same command
proxy[alias] = self.func --this formally adds the alias to the alias table and fires a remote to update the client side
end
---check if the target is more important than the sender
function commands:Precedence(sender: Player, reciever: Player)
if not self.PassIn.seniorPrecedence then return false end --ensure the setting is turned on
--this still allows players to execute commands on themselves because n < n = false
return (sender:GetAttribute("Rank") or 0) < (reciever:GetAttribute("Rank") or 0) --compare and see if the sender has a higher rank than the reciever, return as a boolean. default the ranks to 0 (non-staff) if not available
end
function commands:ExecuteOnPlayerList(params: params, targets: {Player?})
local sender = params.sender; if not sender then return end--might not exist
local ran = false --flag for if command has run at least once during this execution
local once = self.PassIn.Once
for _, target in targets do
if ran and once then return end--this indeed makes sure that the command runs once
if not target or self:Precedence(sender, target) then continue end--target exists and reciever isn't more important than sender
params.targetPlayer = target--these two variables are what changes each loop iteration (to resemble that of the NEW target)
params.Character = target.Character
self:Execute(params)
ran = true --has run at least once
end
end
---Constructor for new commands, uses generic types. Running a command assumes that the "sender" is authorised. not put under "commands" to make commands.new as per convention, but I didn't want the commands to inherit the constructor
--as that'd make something like cmdList.kill.new true and inherited, which is stupid nonetheless. my first solution was to define a function for __index and return nil if the key being accessed was "new", but that overhead could have
--been entirely avoided by just not letting the objects inherit the constructor.
local function new(PassIn: PassIn, Rank: number, func: cmdFunc, signature: string):cmd & methods --returns a command object that inherits methods defined in "methods"
local obj = { --obj is a table of type cmd & methods (will be typed later)
PassIn = PassIn :: PassIn,
Rank = Rank :: number,
func = func :: (params)->(),
Signature = signature :: string
}
obj.PassIn.aliasesProxy = {} --must at least be an empty table
local o = typedSetmetatable(obj, commands) :: cmd & methods --even if it looks like the object doesn't have any methods, typecasting makes the type solver trust me, especially because this is a callback (the methods are inherited, typecasting without the function has a type error even if it is fundamentally doing the same thing both ways)
return o --we can assign the object to a variable now
end
--// COMMANDS (all of them are listed here for the sake of simplicity, but its better to put them in a module)
---Kills a character by setting humanoid health to 0
cmdList.kill = new({aliases = {"k", "destroy", "kobe"}, onCD = {}}, 2,function(params: params)
local hum: Humanoid? = hum(params.Character, "Kill")
if not hum then return end --if the character's humanoid exists
hum.Health = 0 --this doesn't kill if the character hasn't had some extra time
--has to wait till the character has "properly" loaded, otherwise setting health to 0 doesn't kill and simply injures them
--this isn't usually a problem here, only in things like "loopkill"
end, "target(s)")
---interpolates speed to a character (cooler this way)
cmdList.speed = new({aliases = {}}, 1, function(params: params)
--word2 exists, it can be turned into a number, otherwise word2 is nil
local word2 = params.words[2] and tonumber(params.words[2]) or nil
--initialise a variable for word3 for now
local word3
--this is a neat way to handle the command from panel and chat
--the way both send messages is different because:
--panel can choose multiple targets without "all" or "others". does the message become ";kill p1, p2, p3"? that makes word2 and word3 lose certain meaning
--using chat, you can either do single target or use the keywords, it makes word2 certain that it defines the targets and the other words don't
--however, word2 will ONLY exist if the command was from chat. the panel only defines word3 and onwards, we can use this little fact to our advantage
--it doesn't define word1 because it's chosen from the panel command list, and it doesn't define word2 because of the earlier problem. it simply stores the targeted individuals into a table and sends it, no word2
--we consider word3 as our speed amount
word3 = params.secondaryPassIn.fromPanel and word2 or word3
local speed = tonumber(word3) --might be nil, putting it in a new variable for readability
if type(speed) ~= "number" then return end --need it to exactly be a number
local hum: Humanoid? = hum(params.Character, "Speed")--second argument is the command name so I can pinpoint which command is having "quirky behaviour" when the function warns
if not hum then return end
TS:Create(hum, TweenInfo.new(1), {["WalkSpeed"] = speed}):Play()
--not storing this in a variable saves memory so the tween can be safely trashed since no references point to it
--it's a minor thing but good to know
end, "target(s) amount")
cmdList.teleport = new({aliases = {"tp", "instanttransmission"}},2, function(params: params)
if not params.Character then return end --not valid if no character to teleport
local word3 = params.words[3]
if not word3 then return end --nowhere to teleport. note that word3 is considered the destination here because this command works in two modes
--1: teleport target (word2) to a character (word3)
--2: use provided coordinates on word2
--first see if word3 is a player (mode 1)
word3 = word3:lower() --this makes it easier to target players who have capital letters in their name, but may also catch players whose name has the same pattern but in a different casing in the crossfire instead of the actual target
local plr = partialFind(word3)
--I think it's fine for the convenience, especially in something as "harmless" as teleport
--search for character to teleport to if word3 was a player
if plr then
local char: Model? = plr.Character
if not char then return end --had to use a nested if here, "if not char then return end" would have prematurely ended the function (it would need to check if word3 was coordinates which it wouldn't be able to do if it returned)
params.Character:PivotTo(char:GetPivot()) --teleport to specified player's character
--a model's position and orientation is set with pivots, which specifies "its center of rotation and position" and relative to it, cframes are applied. the pivot itself is a cframe, so we can get the pivot of another model and apply that cframe to the model we want to teleport
return --disallow the code below from running. it's pointless to check for coordinates when this if triggers, because coordinates don't contain letters and it'd be invalid anyway
end
--mode2 (looking for coordinates)
local msg = params.msg --we can't use word3 for coordinates as they might be spaced out, like: 50, 50,20
--substitute everything that DOESN'T match this pattern with "", meaning to remove that part
msg = msg:gsub("[^%d,]", "") --strings are immutable (can't be mutated), so we have to take the newly formed string and set it as the message
--[^] means anything NOT inside this set (the "not" being denoted by the caret ^, otherwise it would have been to remove "everything that is inside this set")
--%d and , are inside the set so it will spare commas and digits (what the %d directive refers to)
if not msg:find(",", 1, true) then return end --quick check to save performance of checking for coordinates and executing them in case "admin" made a typo and didn't type the correct name (accidental comma at beginning as the first character, which would pass the first check)
--this works because roblox usernames can't contain commas, and the "admin" would need to intentionally add commas if they wanted to insert coordinates instead of player name
--this is cheap because plain search is set to true and no directives and such patterns are being used (negligible overhead)
--check for coordinates
local coords = string.split(msg, ",") --seperate vector3 axes by comma
local numericalCoords = {} :: {X: number, Y: number, Z:number}--basically a vector3 minus all its features
for i, coord in coords do
local n = tonumber(coord)
if not n then return end --discard because a coordinate might accidentally be a number: 50,t,15 and we need all coordinates to be a number
if i == 1 then numericalCoords.X = n --add the coordinates into the table in proper order, xyz
elseif i == 2 then numericalCoords.Y = n
elseif i == 3 then numericalCoords.Z = n end
end
params.Character:PivotTo(CFrame.new( --this does NOT take a vector3 since pivots are cframes and this requires another pivot
numericalCoords.X,
numericalCoords.Y,
numericalCoords.Z
))
end, "target(s) player/coordinates")
cmdList.bring = new({aliases = {"sendtoprincipalsoffice"}}, 2, function(params: params)
local toBring = params.Character
print(toBring)
local sender = params.sender
if not toBring then warn("target character", toBring , "is not available") return end
local char = sender.Character --tobring will be teleported to sender's character
if not sender or not char then warn("Player", sender, "or their character", char, "not available.") return end
local hrp = char:FindFirstChild("HumanoidRootPart")
if not hrp then return end
--once everything is confirmed
local senderCF = char:GetPivot() --get sender's cframe
toBring:PivotTo(senderCF + senderCF.LookVector * 3 ) --set the target's cframe to that cframe
--but put it 3 studs ahead of sender's cframe (the lookvector) which is a unit vector (meaning its value is 1, but it contains direction too) equaling to one stud where the sender is looking
end, "target(s)")
---changes the prefix. player can't set someone else's prefix
cmdList.prefix = new({notPanelExecutable = true, Once = true},0, function(params: params)
local sender = params.sender
local words = params.words
if not sender then warn("Couldn't set prefix due to unavailable player: ", sender) return end --can't do anything if there's no character
local setTo = words[2] and words[2] or defaultPrefix --if word2 is a truthy value (exists), use that. else default to ;
setTo = setTo:sub(1,1) --this extracts the first character of the prefix they're trying to set because the system relies on the first character as the prefix, I'm intentionally avoiding multi-character prefixes for simplicity and to avoid technical quirks
sender:SetAttribute("Prefix", setTo) --set the prefix attribute, which allows this prefix to be used for commands
spn(function() --let a separate thread do this part so there's concurrency
local leaderstats = sender:WaitForChild("leaderstats") ::Folder
local prefix = leaderstats:WaitForChild("Prefix") ::StringValue
prefix.Value = setTo --update the visuals on the leaderboard
end)
end, "setPrefixAs")
---your admin system not complete if it don't got a forcefield command
cmdList.ff = new({aliases = {"forcefield", "plotarmor"}}, 2, function(params: params)
local char = params.Character; if not char then return end --there needs to be a character to put the forcefield on
local forcefield = Instance.new("ForceField") --create a forcefield in memory, don't worry I didn't use the second argument of instance.new
forcefield.Parent = char --set the parent to the character so it recieves the forcefield
end, "target(s)")
cmdList.explode = new({aliases = {"downloadfreeminecraft", "lactoseintolerant"},onCD = {}, cooldownSeconds = 2}, 0, function(params: params)
local char = params.Character; if not char then return end --this means that they're probably respawning right now
local hrp = char:FindFirstChild("HumanoidRootPart") ::BasePart? --findfirstchild returns a value of type "Instance?", but we all know that humanoidrootpart is a basepart
if not hrp then return end --in case the last check didn't rule out any outliers
local Yvariance: number = random(-1., 0.) --put here separately for readability. they are floating points so the value isn't always a whole number
local origin = hrp.Position - vcreate(0,Yvariance,0) --subtract with a vector that has a random Y value to induce variation in raycast distance. vector.create is better for computations.
--doesn't include orientation (not a cframe) so it's always shooting down (not relative to the hrp)
local maxRayLength = 12
local direction = vcreate(0,-maxRayLength, 0) -- go 12 studs straight down
local params = RaycastParams.new() --define some rules for the raycast
params.FilterType = Enum.RaycastFilterType.Exclude --ignore anything inside filterdescendantsinstances
params.FilterDescendantsInstances = char:GetDescendants() --returns an array so no need to wrap it in a table constructor {}
--doing GetDescendants instead of GetChildren because a handle inside a tool or accessory might block the raycast and bug the whole vfx
local result = workspace:Raycast(origin, direction, params) --send the ray
if result then --if it hit anything then
local dist = result.Distance
local scaleFactor = clamp(dist/5, 0, 2)^1.5 --this makes a multiplier where 0 is the minimum and 2 is the maximum distance, to use for scaling crater size depending on distance. I have added the exponentiation for easing
local clone = SS.Crater:Clone()
local s = clone.Size --for readability
--as an offset
local min = 0.8
local max = 5
-- adding min at the beginning makes sure the value has a linear increase to it because of how multiplication can be with small values
-- multiplying max by the scale allows it to change depending on raycast distance
local scale = min + (max * scaleFactor)
clone.Size = Vector3.new( --vector3 is more suitable for size and related API
s.X * scale,
s.Y, --this stays constant
s.Z * scale
) --however, since the crater part is rotated 90 degrees to keep its flat side up, this ends up increasing the height instead
--which is what I want, so the crater looks closer to the player if they're in the air
clone.Position = result.Position --put the crater where the ray hit
local fadeTimer = clamp(2*dist, 1, 10) --simple way of variating crater duration based on raycast distance (to make it go away faster on bigger distance)
clone.Parent = workspace --make it physically appear
local tween = TS:Create(clone, TweenInfo.new(fadeTimer), {["Transparency"] = 1})
tween:Play()
tween.Completed:Once(function()
clone:Destroy()
end)
end
local explosion = Instance.new("Explosion") --make an explosion that isn't set off yet (in memory)
explosion.DestroyJointRadiusPercent = 1 --always destroy joints
explosion.Position = hrp.Position --explode where the hrp is
explosion.Parent = workspace --parent to workspace and set off the explosion
end, "target(s)")
---give a command another name
cmdList.alias = new({aliases = {"addalias","nickname"}, Once = true, notPanelExecutable = true}, 0, function(params: params)
--this isn't panel executable because it wouldn't notify other people about any new aliases unless they explicitly checked, makes the logic simpler too
local words = params.words
local targetCmd = words[2] --word2 is the command to add the alias to
local newAlias = words[3] --word3 is the alias we want to set
if not targetCmd or not newAlias then return end --both must exist
--both words exist, but we need to know if targetCmd is a command. also no duplicate aliases
local dupeAlias = cmdList[newAlias] --is the new alias already inside the command list? (aliases go to the command list with their command object being exactly of the original command)
local command = cmdList[targetCmd] --targetCmd is a string but we don't know if it's a command, if targetCmd is in the command list then "command" will refer to its object
if not command or dupeAlias then return end--if the target isn't a valid command or if there's a duplicate
--it's safe now
command:AddAlias(newAlias)--easy to add the alias
end, "CommandName NewAlias")
cmdList.kick = new({seniorPrecedence = true}, 3, function(params: params)
local isServerBanned = params.secondaryPassIn.isServerBanned ~= nil --the serverban command uses this one for the kicking part, secondaryPassIn allows "communication" between the two
if onVIP(params.targetPlayer) then return end --don't kick "exempt" players
local msg = params.msg
local kickMsg --set later
local word3 = params.words[3]
local word2 = params.words[2]
local word1 = params.words[1]
if params.secondaryPassIn.fromPanel and msg then --if the command is being run at the request of the panel
--msg will look something like: ;kick Stop harassing people please
msg = word1 and msg:gsub(word1, "") or msg --remove the first word, don't if it doesn't exist
kickMsg = msg
elseif not params.secondaryPassIn.fromPanel then --if it's being executed at the request of a chat message
--msg will look similar to: ;kick Target Stop doing that weird thing
msg = word1 and msg:gsub(word1, "") or msg--remove first word if it exists
msg = word2 and msg:gsub(word2, "") or msg--remove second word if it exists
kickMsg = msg
end
local sender = params.sender and params.sender.Name or "N/A" --maybe the sender doesn't exist
--kickmsg should be a truthy value when the if statement runs, in that case leave it as-is
--if that's not the case, then if the person is serverbanned give this string, else give that string
kickMsg = kickMsg and kickMsg or (isServerBanned and "You have been server banned by "..sender or "You have been kicked by "..sender)
params.targetPlayer:Kick(kickMsg)
end, "target(s)")
cmdList.serverban = new({seniorPrecedence = true, aliases = {"servban"}}, 3, function(params: params)
if onVIP(params.targetPlayer) then return end
local player = params.targetPlayer
serverBanned[player.Name] = player --we check if a player is in this table when someone joins
local kick = cmdList.kick :: cmd&methods
params.secondaryPassIn.isServerBanned = true --let the kick command know this person is server banned
kick:Execute(params) --remove them from the game
end, "target(s)")
cmdList.rank = new({seniorPrecedence = true}, 4, function(params: params)
local targetPlayer = params.targetPlayer
local word2 = params.words[2]
local word3 = params.words[3]
word3 = tonumber(word3) --the rank to set
if not word3 then word3 = tonumber(word2) end --try word2 as the rank
if not word3 then return end --no rank provided..
setRank(targetPlayer, word3)
end, "target(s) RankNumber(1-5)")
cmdList.fling = new({seniorPrecedence = true, onCD = {}}, 2, function(params: params)
local char = params.Character
if not char then return end
local hrp = char:FindFirstChild("HumanoidRootPart") :: Part
local hum = char:FindFirstChild("Humanoid") :: Humanoid
if not hrp or not hum then return end
local force = 500 --intensity of the fling
local angular = force/15 --how strongly to rotate
--some random direction
local direction = v3new(
random(-1,1),
random(0,1),
random(-1,1)).Unit --normalise so the direction is preserved and the magnitude doesn't interfere with our computations
--applying this 4 times proves effective
for i = 1, 4 do
hum.Sit = true --"stun"
hrp:SetNetworkOwner(nil) --server needs network ownership of this to make velocity changes as the player has that by default
hrp:ApplyImpulse(direction*force) --multiplying by force preserves direction but increases the "intensity"
hrp.AssemblyAngularVelocity = v3new(random(-angular, angular), --make them spin
random(-angular, angular),
random(-angular, angular))
task.wait()
end
hrp:SetNetworkOwner(params.targetPlayer)--revert ownership
end, "target(s)")
cmdList.push = new({seniorPrecedence = true, onCD = {}}, 2, function(params: params)
local char = params.Character
if not char then return end
local hrp = char:FindFirstChild("HumanoidRootPart") :: Part
local hum = char:FindFirstChild("Humanoid") :: Humanoid
if not hrp or not hum then return end
local force = 1500
local upwardness = v3new(0,0.15,0) --this is just to reduce friction by having the character be in the air for a moment
hrp:SetNetworkOwner(params.targetPlayer) --if ownership is with the server
push:FireClient(params.targetPlayer, force, upwardness)
end, "target(s)")
cmdList.trip = new({cooldownSeconds = 1, onCD = {}}, 2, function(params: params)
local char = params.Character
if not char then return end
local hrp = char:WaitForChild("HumanoidRootPart") :: Part
local hum = char:WaitForChild("Humanoid") :: Humanoid
local tiltAngle = toRadian(220) --takes degrees. converting to radians is importat because cframe.angles expects radians
--math relating to circles and trigonometry works cleaner with radians
hrp.CFrame *= CFrame.Angles(tiltAngle, 0, 0) --multiplication meaningfully applies the transform (second operand relative to first operand). basically it rotates the character to a point where they lose their footing and fall down
hum.Sit = true --this makes sure they can't get up for a good while or recover quickly
end, "target(s)")
cmdList.clearserverbans = new({aliases = {"clearservbans", "clearbans"}, Once = true}, 2, function(params: params)
serverBanned = {} --instead of looping through every value in the table and setting it to nil, just redefine the table as an empty table
end, "")
--this one isn't panel executable because the target isn't in the server to begin with so it's pretty pointless
cmdList.unserverban = new({aliases = {"clearservban", "clearban", "unservban"}, Once = true, notPanelExecutable = true}, 2, function(params: params)
local toUnban = params.words[2]; if not toUnban then return end
serverBanned[toUnban] = nil --now they can join the server
end, "target")
cmdList.loopkill = new({aliases = {"returntozero", "requiemda"},seniorPrecedence = true}, 2, function(params: params)
local player = params.targetPlayer; if not player then return end
if loopkilled[player.Name] == player then loopkilled[player.Name] = nil return end --toggle
local char = params.Character; if not char then return end
local humanoid = hum(char, "loopkill") if not humanoid then return end
local conn: RBXScriptConnection
loopkilled[player.Name] = player
conn = player.CharacterAdded:Connect(function(char)
task.wait(0.25) --setting health to 0 right after character is added doesn't kill them
humanoid = hum(char, "loopkill")
if not humanoid or not loopkilled[player.Name] then loopkilled[player.Name] = nil;conn:Disconnect() return end
--notice that checking for "if not player then disconnect()" isn't worth it because "player" never changes, it points towards the same object in memory even if the player leaves the game.
humanoid.Health = 0
end)
humanoid.Health = 0 --initially kill them
end, "target(s)")
cmdList.unloop = new({aliases = {"repentance", "freefromcycleoflifeanddeath"}, Once = true}, 2, function(params: params)
loopkilled = {}--clear the table, next time their connection fires, it'll disconnect since they're not in here
end, "")
--evaluate the original commands
for key, command in cmdList::{[string]:cmd&methods} do
--remember when I said "can't give cmdlist a definitive type"?
--it doesn't matter after every command is added, it won't complain anymore.
local obj = command :: cmd & methods
--key is also the command's name
cmdNames[key] = {Signature = obj:GetSignature(key), Rank = obj.Rank, Aliases = obj.PassIn.aliases, notPanelExecutable = obj.PassIn.notPanelExecutable}
--cmdNames is a table that isn't diluted with aliases and only contains original commands
end
--do alias stuff for the original commands. cmdNames doesn't have the full command object so cmdList is used
for key, command in cmdList::{[string]:cmd&methods} do
if not cmdNames[key] then continue end --only access the original command
--you can add "aliases" to an alias, which still points towards the original command
command:InitialiseAliasProxy(key)--key refers to the command's name
local aliases = command.PassIn.aliases
if not aliases then aliases = {}; continue end --some commands don't have this defined, so skip them
for _, alias in aliases do --go through the command's aliases
cmdList[alias] = command --create a "pointer" towards the same command (not really)
end
end
P.PlayerAdded:Connect(function(plr: Player)
if serverBanned[plr.Name] then plr:Kick("You are banned from this server. Please join another one, or wait until an administrator clears your name.") end
local character = plr.Character or plr.CharacterAdded:Wait() --we can afford a wait here
plrList[plr.Name] = plr --anything but nil to represent a key in the table (because non-existent keys are already nil.. we'd be un-existing a key that we want to "exist" in that case). boolean is the best choice because it takes exactly one bit in memory
plr:SetAttribute("Prefix", defaultPrefix)
plr:SetAttribute("Rank", startingRank)
local leaderstats = Instance.new("Folder")
leaderstats.Name = "leaderstats" --name exactly leaderstats to put it on the leaderboard
leaderstats.Parent = plr --tie it to this player
local rank = Instance.new("StringValue")
rank.Name = "Rank"
rank.Value = rankToString(plr:GetAttribute("Rank"))
rank.Parent = leaderstats
local prefix = Instance.new("StringValue")
prefix.Name = "Prefix"
prefix.Value = plr:GetAttribute("Prefix")
prefix.Parent = leaderstats
genUI:FireClient(plr, cmdNames) --send the original commands with their aliases and other information
plr.CharacterAdded:Connect(function(newChar) --update our character reference with the latest one
character = newChar
end)
local lastTarget: {Player} = {plr} -- if no target is provided, it defaults to the last target. assumes the last target is the player initially
--needs to be in a table because the targets are looped over no matter the amount
plr.Chatted:Connect(function(msg: string)
local words = {} :: {[number]: string}
local i = 1
for word in msg:gmatch("%S+") do --gmatch returns an iterator function that satisfies the for loop, iterating for each instance of the pattern matched. but no "i" variable so it has to be outside the loop
words[i] = word--add the word in proper order from 1 onwards
i += 1--have to manually increase the "index" for every iteration
end
if #words == 0 then return end --nothing proper was said
--this optimisation doesn't run the "heavy" code ahead if the first character isn't the prefix
--matches the player's prefix if it's a string, else it uses the default one
if words[1]:sub(1,1) ~= (type(plr:GetAttribute("Prefix")) == "string" and plr:GetAttribute("Prefix") or defaultPrefix) then return end
words[1] = words[1]:lower()--make the command case insensitive. like ;KIll -> ;kill
local toExecute = isCommand(words[1], plr)--returns the command's name or nil
if not toExecute then return end
local word2: string? = words[2] --word2 might not exist if the message is something like ;explode
local command = cmdList[toExecute] :: cmd & methods --guaranteed to exist
if not command:CanExecute(plr) then return end--not enough rank or cooldown
command:SetCooldown(plr)
local targetTbl = getTarget(plr, word2) or lastTarget
--note: I typecast {Player} to the expression "targetTbl" because beyond the above check, targetTbl is {[string]: Player} | {Player}. the type solver doesn't know that, so I have to guide it.
lastTarget = targetTbl :: {Player} --remember this new target
local insert = {words = words, sender = plr, msg = msg, secondaryPassIn = {}} :: params --this is pretty much all we need to make any command in existence (the character is added later)
local targets = targetTbl :: {Player?}
command:ExecuteOnPlayerList(insert, targets)
end)
end)
P.PlayerRemoving:Connect(function(plr: Player)
plrList[plr.Name] = nil
--if player for some reason wasn't in the table in the first place, this index would have been nil.
--setting nil to nil isn't illegal.
end)
runCommand.OnServerEvent:Connect(function(sender, targets: {[string]: Player}?, wordList: {[string|number]:string}?, toExecute: string?)
if type(wordList) ~= "table" or type(targets) ~= "table" or type(toExecute) ~= "string" then return end --make sure that we're safe and someone isn't giving us the wrong data
local command = cmdList[toExecute] :: cmd&methods --can't use isCommand here as that's for querying if player is trying to run a command using their message
if not command then warn(toExecute.." not a command") return end
if not command:CanExecute(sender) then warn("Not allowed to execute command "..toExecute) return end
local prefix = sender:GetAttribute("Prefix") or defaultPrefix
local construct = prefix..toExecute --put together the player's original message, starting with the first word. toExecute isn't the command object here
wordList[1] = construct --also the first word
for i, word in wordList do
--when the table comes through the remote, all of its numeric indexes are converted to strings because of the serialisation
local numeric_i = tonumber(i)
if type(numeric_i) ~= "number" then return end --only numbers
wordList[numeric_i] = word
construct = construct.." "..word --add this to the message
end
local insert = {sender = sender, words = wordList, msg = construct, secondaryPassIn = {fromPanel = true}} :: params
for i, v in targets do
local player = P:FindFirstChild(i) :: Player?--is the target in the server
targets[i] = nil --set their position into the table as nil for now
if not player then continue end
targets[i] = player --put them back because they're valid
end
for i, v in insert.words do
if type(v) ~= "string" then warn("words are invalid:", wordList) return end
end --make sure everything is the correct type
command:ExecuteOnPlayerList(insert, targets :: {Player?}) --this simply silences the typechecker since the function type says targets is an array (but it works for both dictionaries and arrays, so it doesn't matter)
end)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment