Consequences
Bans cheaters through Players:BanAsync, escalates each ban's length from the account's ban history, and reports every result per user id on the Bus.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .AntiExploit .Consequences - Since
- v0.9.0
- Source
- AntiExploit/Consequences.luau
Consequences is the penalty half of anti-exploit. The Movement Monitor and
Rewind detect, a game’s hook handler decides, and Consequences carries out the decision through the
Roblox ban API. Each repeat offense earns a longer ban, counted from the account’s ban history. ban() never yields: it
checks the engine’s limits at the call, runs the HTTP work on its own thread, and reports each user id’s result on the
Bus, so a violation hook or a Scheduler job can call it directly. Forensics
keeps the movement evidence for the same flags.
Why a ban and not Kick
Section titled “Why a ban and not Kick”Player:Kick() removes the player from one server. They can rejoin at once, on another server or on another account,
and nothing records that it happened. Players:BanAsync evicts the player from every server in the ban’s scope
immediately and refuses their joins until the ban ends. By default it also covers the accounts Roblox suspects are the
same person’s alts, and it writes an entry to the account’s ban history, which is what makes escalation possible.
Kick still has a place as the fallback when a ban fails (see Usage), because it at least removes the cheater from this server.
How it works
Section titled “How it works”The call
Section titled “The call”bans:ban(targets, options) runs these steps before anything yields:
DisplayReasonmust be a string of 1 to 400 characters (UTF-8 characters, or bytes when the string is not valid UTF-8). Anything else raises an error at the caller’s line.Duration, when given, must be-1(permanent) or a finite whole number of seconds of at least 1. Anything else raises.- After
detach(), the call returns here and does nothing. targetsis a Player, a user id, or an array of either. Duplicate ids collapse to one. An id the API would reject (anything that is not a whole number of at least 1) never reaches the API and publishesAntiExploit.BanFailed(id, "InvalidUserId")instead. Players in a Studio local server test have negative ids and land here. The engine reference calls mixing valid and invalid ids in oneUnbanAsynccall undefined behavior, and Consequences keeps them out of both APIs.- An id whose ban is already in flight is skipped. A violation hook that fires again while the HTTP call runs cannot escalate the same account twice. Once the call finishes, the id can be banned again.
- The private note is built:
PrivateReason, orDisplayReasonwhen none is given, prefixed with[Violation, N strikes]whenViolationis set (Strikesoptional), and cut to 1,000 characters at a character boundary so it stays valid UTF-8. - The remaining ids go to a spawned thread with a copy of the options.
Escalation from ban history
Section titled “Escalation from ban history”When the call has no Duration, the spawned thread reads each id’s ban history with Players:GetBanHistoryAsync, up to
20 pages, and counts the entries with Ban = true. An unban writes its own entry and does not lower the count. The
length is:
Escalation[min(PriorBans + 1, #Escalation)]With the default { 3600, 86400, -1 }, a first ban lasts 1 hour, a second 1 day, and every later one is permanent: the
last step repeats. A history read that fails logs a warning and uses the first step. Histories are read one id at a time
before any ban call is made.
An explicit Duration skips the history read entirely. The record’s PriorBans is then nil, as it is in Studio and
after a failed read.
attach validates Escalation: it must hold at least one step, and every step must be -1 or a whole number of
seconds of at least 1. A bad table raises at attach, before any ban runs.
The API limits at the call
Section titled “The API limits at the call”Consequences enforces the engine’s documented limits itself, so a bad argument fails loudly in the caller’s code instead of as an HTTP error on another thread:
| Limit | Value | What Consequences does |
|---|---|---|
User ids per BanAsync or UnbanAsync call |
50 | Splits the ids into chunks of 50. |
DisplayReason |
1 to 400 characters | Raises at the call. |
PrivateReason |
1,000 characters | Cuts the note at a character boundary. |
Duration |
whole seconds, or -1 for permanent |
Raises at the call. 0 and other negatives are invalid. |
| User ids | whole numbers of at least 1 | Keeps invalid ids out of the call and reports InvalidUserId. |
| Ban history pages | 20 per id (a Consequences cap) | Stops reading after 20 pages. |
Batching and per-id results
Section titled “Batching and per-id results”Ids that earned the same duration share BanAsync calls, 50 per call. The engine makes one HTTP request per id and, when
some fail, raises one error that joins the failures and names each failed id (failure for UserId 2: ...). Consequences
parses that message: every id it names publishes AntiExploit.BanFailed(userId, message), and every other id in the
chunk publishes AntiExploit.Banned(userId, duration, record). A message that names no id (banning disabled, a bad
configuration) failed the whole call, so every id in the chunk gets BanFailed.
The record is { DisplayReason, PrivateReason, PriorBans?, Production }. PrivateReason is the note as sent, prefix
and cut included. Production is false in Studio, where the ban never reaches the live game.
Alt accounts, device blocks and scope
Section titled “Alt accounts, device blocks and scope”The attach config sets three BanAsync flags for every call:
ExcludeAltAccounts(defaultfalse). The engine’s default is to extend a ban to suspected alternate accounts, and Consequences keeps it. Settrueto ban only the named account.DeviceBlock(defaultfalse) setsApplyDeviceBlock, which blocks the device for 24 hours. OnlyUnbanAsynclifts it; an unban from the Creator Hub or Open Cloud does not.ApplyToUniverse(defaulttrue).falsebans from this place only, though the engine reference notes that a ban on the start place keeps the player out of the whole experience in practice.
Studio and production
Section titled “Studio and production”Production defaults to not RunService:IsStudio(). In Studio:
Players.BanningEnabledmust be ticked in the Players properties. Scripts cannot set it, and while it is off every ban and unban fails and publishesBanFailedorUnbanFailed.- Bans run but never apply to the live game, and
record.Productionis false. - Ban history does not exist, so
history()returnsnil, "NotProduction", escalation always uses the first step, andPriorBansis nil.
Unbanning
Section titled “Unbanning”bans:unban(userIds, applyToUniverse?) takes a user id or an array of them and never yields. Invalid ids publish
AntiExploit.UnbanFailed(id, "InvalidUserId"). The rest go to UnbanAsync in chunks of 50 on a spawned thread, with
per-id results parsed the same way: AntiExploit.Unbanned(userId) or AntiExploit.UnbanFailed(userId, message).
An unban lifts only bans with the same scope. A universe unban (applyToUniverse true, the default taken from the attach
config) leaves a place ban in force, and the reverse. An unban also lifts the account’s device block.
Detaching
Section titled “Detaching”bans:detach() stops publishing. Calls already in flight finish their HTTP work but publish nothing, and later ban and
unban calls return without doing anything (a ban with bad arguments still raises).
A moderation service turns anti-exploit strikes into bans and keeps a fallback for when the ban API is unavailable:
--!strict-- ModerationService: turns anti-exploit strikes into escalating bans.
local Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerScriptService = game:GetService("ServerScriptService")
local Consequences = require(ServerScriptService.ChloeKernelServer.AntiExploit.Consequences)local Logger = require(ReplicatedStorage.ChloeKernel.Logger)
local Log = Logger.scope("ModerationService")
local BanStrikes = 5
local ModerationService = { Name = "ModerationService",}
function ModerationService.init(self: any, kernel: any) self.Kernel = kernel self.Bans = Consequences.attach(kernel) -- 1 hour, then 1 day, then permanent self.Cleanups = {}end
function ModerationService.start(self: any) local Kernel = self.Kernel table.insert( self.Cleanups, Kernel.Hooks:on("AntiExploit.MovementViolation", function(context) if context.Strikes >= BanStrikes then -- Never yields: the ban runs on its own thread self.Bans:ban(context.Player, { DisplayReason = "Banned for exploiting.", -- shown when the player tries to join Violation = context.Type, -- kept in the private note with the strike count Strikes = context.Strikes, }) end return nil end) )
-- BanningEnabled off or the ban service down: at least remove the player from this server local Failed = Kernel.Bus:subscribe("AntiExploit.BanFailed", function(_topic, userId, reason) Log.warn(`ban for {tostring(userId)} failed: {reason}`) if type(userId) == "number" and userId % 1 == 0 then local Player = Players:GetPlayerByUserId(userId) if Player then Player:Kick("Removed for exploiting.") end end end) table.insert(self.Cleanups, function() Failed:disconnect() end)
local Banned = Kernel.Bus:subscribe("AntiExploit.Banned", function(_topic, userId, duration, record) Log.info(`banned {userId} for {duration} s (prior bans {tostring(record.PriorBans)}): {record.PrivateReason}`) end) table.insert(self.Cleanups, function() Banned:disconnect() end)end
function ModerationService.stop(self: any) for _, Cleanup in self.Cleanups do Cleanup() end table.clear(self.Cleanups) self.Bans:detach()end
return ModerationServiceA moderator action usually knows the length it wants. An explicit Duration skips the history lookup:
local Moderation = kernel:getService("ModerationService")
Moderation.Bans:ban(TargetUserId, { DisplayReason = "Banned for 7 days for harassment.", PrivateReason = `Moderator {Moderator.UserId}, report 4412`, Duration = 7 * 86400,})
Moderation.Bans:unban(TargetUserId) -- lifts a universe ban and its device blockbans:history(userId) yields and returns the raw history entries (Ban, Duration, DisplayReason, PrivateReason,
StartTime, PlaceId) for a moderation tool. Call it from a thread that may yield, never from a hook or a Scheduler
job.
| Member | Description |
|---|---|
Consequences.attach(kernel, config?) |
Returns the ban controller. Raises when Escalation is empty or holds a step that is not -1 or whole seconds of at least 1. |
bans:ban(targets, options) |
Bans a Player, a user id, or an array of either. Never yields. Raises on a bad DisplayReason or Duration; reports every id on the Bus. |
bans:unban(userIds, applyToUniverse?) |
Lifts bans and device blocks for a user id or an array of them. Never yields. The scope defaults to the attach config’s ApplyToUniverse. |
bans:history(userId) |
Yields. Every ban and unban on record for the id, or nil, reason ("InvalidUserId", "NotProduction" or the engine’s error). Reads up to 20 pages. |
bans:detach() |
Stops all publishing. Calls in flight finish silently; later calls do nothing. |
attach config:
| Option | Default | Description |
|---|---|---|
Escalation |
{ 3600, 86400, -1 } |
Ban seconds by prior ban count. -1 is permanent, and the last step repeats. |
ExcludeAltAccounts |
false |
true bans only the named account. The default also bans suspected alts. |
DeviceBlock |
false |
Applies a 24-hour device block that only UnbanAsync lifts. |
ApplyToUniverse |
true |
false bans from this place only. Also the default scope for unban. |
Production |
not RunService:IsStudio() |
Whether ban history exists. false skips history reads and marks records. |
Players |
the Players service | A double for specs. |
ban options:
| Option | Description |
|---|---|
DisplayReason |
Required. Shown to the player when they try to join, with the time left. 1 to 400 characters; the engine text-filters it. |
PrivateReason |
Kept in the ban history and never sent to the client. Defaults to DisplayReason. Cut to 1,000 characters. |
Duration |
Seconds, or -1 for permanent. Overrides escalation and skips the history read. |
Violation |
Written at the front of the private note as [Violation, N strikes]. |
Strikes |
The strike count for that prefix. |
Hooks & bus topics
Section titled “Hooks & bus topics”| Kind | Name | Payload | Notes |
|---|---|---|---|
| Bus (published) | AntiExploit.Banned |
userId, duration, record |
One per id after its BanAsync call succeeded. duration is seconds or -1; record is { DisplayReason, PrivateReason, PriorBans?, Production }. |
| Bus (published) | AntiExploit.BanFailed |
userId, reason |
reason is "InvalidUserId" or the engine’s error message for that call. |
| Bus (published) | AntiExploit.Unbanned |
userId |
One per id after its UnbanAsync call succeeded. |
| Bus (published) | AntiExploit.UnbanFailed |
userId, reason |
reason is "InvalidUserId" or the engine’s error message. |
Consequences consumes no topics and defines no hooks. The game connects detection to it, usually from a handler on the
AntiExploit.MovementViolation hook or a subscriber to the Weapon.ShotRejected Bus topic.