Skip to content

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.

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.

bans:ban(targets, options) runs these steps before anything yields:

  1. DisplayReason must 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.
  2. Duration, when given, must be -1 (permanent) or a finite whole number of seconds of at least 1. Anything else raises.
  3. After detach(), the call returns here and does nothing.
  4. targets is 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 publishes AntiExploit.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 one UnbanAsync call undefined behavior, and Consequences keeps them out of both APIs.
  5. 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.
  6. The private note is built: PrivateReason, or DisplayReason when none is given, prefixed with [Violation, N strikes] when Violation is set (Strikes optional), and cut to 1,000 characters at a character boundary so it stays valid UTF-8.
  7. The remaining ids go to a spawned thread with a copy of the options.

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.

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.

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.

The attach config sets three BanAsync flags for every call:

  • ExcludeAltAccounts (default false). The engine’s default is to extend a ban to suspected alternate accounts, and Consequences keeps it. Set true to ban only the named account.
  • DeviceBlock (default false) sets ApplyDeviceBlock, which blocks the device for 24 hours. Only UnbanAsync lifts it; an unban from the Creator Hub or Open Cloud does not.
  • ApplyToUniverse (default true). false bans 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.

Production defaults to not RunService:IsStudio(). In Studio:

  • Players.BanningEnabled must be ticked in the Players properties. Scripts cannot set it, and while it is off every ban and unban fails and publishes BanFailed or UnbanFailed.
  • Bans run but never apply to the live game, and record.Production is false.
  • Ban history does not exist, so history() returns nil, "NotProduction", escalation always uses the first step, and PriorBans is nil.

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.

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:

src/Server/Game/Services/ModerationService.luau
--!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 ModerationService

A 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 block

bans: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.
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.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.