Sentinel
Anti-cheat, anti-bot and anti-xray for Paper — and a framework you can build on.
Sentinel ships 18 detections, a bot guard with captcha, plugin-side ore obfuscation and mining analytics. Every part of it is reachable through a public API, so you can add your own checks, reroute punishments into your own moderation system, or use Sentinel purely as a detection engine behind your own plugin.
- No external dependencies — Paper API only
- No database, no web calls, no telemetry
- Java 25, Paper 26.3 (
paper-plugin.yml, Brigadier commands)
Install
- Drop
sentinel.jarintoplugins/. - Start the server.
config.yml,messages.ymlandverified.ymlare created inplugins/Sentinel/. - Give your staff
sentinel.alertsandsentinel.admin.
Commands
All of them require sentinel.admin. /sn is an alias of /sentinel.
| Command | Effect |
|---|---|
/sentinel |
Version, check count, profile count, current TPS |
/sentinel reload |
Reload config.yml and messages.yml |
/sentinel menu |
Open the admin GUI |
/sentinel checks |
List every registered check with its state |
/sentinel check <name> on|off |
Toggle one check until the next reload |
/sentinel alerts [on|off] |
Toggle your own alert messages |
/sentinel verbose [on|off] |
Also show the detail line of every flag |
/sentinel violations <player> |
Current violation level per check |
/sentinel clear <player> |
Reset a player's violations |
/sentinel xray top |
Mining-analytics suspect list, highest score first |
/sentinel xray report <player> |
Full mining breakdown for one player |
/sentinel xray reveal <player> |
Send the real blocks (debugging) |
/sentinel xray hide <player> |
Re-hide everything around the player |
/sentinel bot status |
Bot guard state, blocked connections, lockdown |
/sentinel bot lockdown |
Toggle lockdown (refuse new connections) |
/sentinel bot verify <player> |
Mark a player as verified for good |
/sentinel bot captcha <player> |
Start a captcha for a player |
Permissions
| Node | Default | Meaning |
|---|---|---|
sentinel.admin |
op | Use /sentinel |
sentinel.alerts |
op | Receive flag alerts |
sentinel.bypass |
false | Skip every check |
sentinel.bypass.xray |
false | See real blocks, no mining analytics |
sentinel.bypass.captcha |
false | Never asked to verify |
sentinel.bypass.bot |
false | Skip the behaviour profiler |
How a detection becomes a punishment
- A check sees something impossible and calls
flag, which raises that player's violation level (vl) for that check. SentinelFlagEventfires. Cancel it and the violation is rolled back — nothing is logged, alerted or punished.- Staff with
sentinel.alertsget a message; the flag is written toviolations.log. - The punishment ladder runs. The highest rule whose
violationsthreshold the vl has reached is executed once. - Violations decay by
decay-per-secondfor every second without a new flag, so one bad reading never accumulates into a ban.
Every check is exempted while the player is in a state the server cannot model cleanly: right after joining, teleporting, respawning, taking knockback, leaving a vehicle, changing world, while gliding, riptiding, in creative mode, and whenever the server is lagging or the player's ping is over the limit.
Configuration
general
| Key | Default | Meaning |
|---|---|---|
prefix |
gradient | MiniMessage prefix in front of every plugin message |
debug |
false |
Reserved for verbose logging |
alerts
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
Master switch for staff alerts |
console |
true |
Also log flags to the console |
cooldown-millis |
1500 |
Minimum gap between two alerts for the same player and check |
enabled-by-default |
true |
Staff get alerts without typing /sentinel alerts on |
format |
— | Alert line; placeholders <player> <check> <vl> <category> <ping> <tps> |
verbose-format |
— | Second line in verbose mode; placeholder <details> |
The alert line carries a hover with the full detail text and a click that runs
/sentinel violations <player>.
logging
| Key | Default | Meaning |
|---|---|---|
file |
true |
Append flags to a log file |
file-name |
violations.log |
File inside plugins/Sentinel/ |
keep-days |
14 |
Delete the log once it is older than this |
performance
| Key | Default | Meaning |
|---|---|---|
min-tps |
17.0 |
Below this the server counts as lagging and movement checks pause |
max-ping |
400 |
Players above this ping are exempt |
lag-grace-millis |
2500 |
How long the lag exemption lasts after a spike |
chunk-scans-per-tick |
2 |
Ore index builds per tick (raise to warm up faster, costs CPU) |
exemptions
creative, flying and operator are switches. The *-millis keys set how
long the matching exemption lasts after the event: join, teleport,
respawn, velocity, vehicle, world-change, damage, slime.
Set operator: true if your ops should never be flagged.
punishments
punishments:
reset-violations: true
ladder:
- violations: 15.0
action: alert
- violations: 40.0
action: command
value: "kick <player> Sentinel: suspicious behaviour (<check>)"
- violations: 80.0
action: command
value: "ban <player> Sentinel: unfair advantage (<check>)"
Built-in actions: alert, command (console command in value), kick
(kick message in value), none. Placeholders in value: <player>,
<uuid>, <check>, <category>, <vl>, <details>.
reset-violations: true zeroes the check's vl after a punishment other than
alert, so the same ladder step does not fire twice in a row.
Plugins can register their own action ids — see API.
checks
Every check has the same three keys plus its own settings block:
| Key | Meaning |
|---|---|
enabled |
Run this check at all |
decay-per-second |
How fast its violation level drains |
max-violations |
Ceiling, so one check cannot dominate the ladder |
Most settings blocks contain a buffer: how many consecutive bad readings
are needed before the check flags. Raising a buffer or a tolerance is the right
way to deal with a false positive; disabling the check is the last resort.
| Check | Category | What it looks at |
|---|---|---|
speed |
movement | Horizontal distance per move against a model built from sprint state, walk speed, speed/slowness potions, ice, soul speed and sprint-jumps |
flight |
movement | Airborne without descending, with no liquid, ladder, web, bounce block or ground below |
nofall |
movement | Landing after a long fall while the client reports no fall distance |
step |
movement | Ground-to-ground height gain above max-step (0.65 passes slabs and stairs) |
jesus |
movement | Moving on a liquid surface with air at the feet and nothing solid below |
timer |
movement | Movement packet rate against the 20 Hz tick rate, measured over sample-size moves |
invalid-motion |
movement | Non-finite or absurd deltas and impossible pitch; cancels the move |
reach |
combat | Distance from the eye to the victim's bounding box |
killaura |
combat | Attack angle outside the view cone, several victims inside one window, attacking while blocking and sprinting |
autoclicker |
combat | Clicks per second and the standard deviation of click intervals |
aim |
combat | Pitch snaps during a fight and pitch deltas that never vary |
fastbreak |
interaction | Break time against Block#getBreakSpeed for the tool actually held |
nuker |
interaction | Blocks broken per second and how far apart they are |
fastplace |
interaction | Blocks placed per second |
scaffold |
interaction | Placing below yourself while moving and looking away from the block |
inventory |
interaction | Inventory clicks while sprinting, airborne or moving |
chat-spam |
chat | Message interval and repeated messages |
command-spam |
chat | Command interval |
bot
connection — refuses connections before they reach the login stage.
| Key | Default | Meaning |
|---|---|---|
max-per-address |
3 |
Connections per address inside the window |
window-seconds |
60 |
Length of that window |
max-joins-per-second |
6 |
Server-wide rate; exceeding it triggers lockdown |
lockdown-seconds |
30 |
How long lockdown refuses every new connection |
max-online-per-address |
2 |
Simultaneous players per address |
names — min-length, max-length, max-digit-ratio,
min-entropy/max-entropy (Shannon entropy over the name — random strings
score high, words score low) and blocked-patterns, a list of Java regexes.
Use single quotes in YAML so backslashes survive.
activity — a client that has not moved or rotated within
require-seconds is kicked.
behaviour — straight-line-threshold and straight-line-samples catch
paths with no lateral deviation at all; zero-rotation-samples catches moving
without ever turning the head. Each signal is worth 30 points;
alert-score notifies staff, kick-score kicks.
captcha — mode: chat sends a code to type, mode: gui opens a menu and
asks for one named item. only-unverified: true asks each account once and
remembers it in verified.yml. freeze blocks movement and commands,
blindness applies the effect for the duration. Wrong answers count against
max-attempts, silence against timeout-seconds; both end in a kick.
xray
obfuscation — Sentinel replaces every block in hidden-blocks with
stone, deepslate or netherrack in the packets the client receives, and sends the
real block once the player is within reveal-radius. hysteresis keeps blocks
visible a little past that radius so walking along the edge does not flicker.
update-interval-ticks is how often the near window is recomputed,
max-blocks-per-update caps one update, and the three replacement-* keys
choose the filler per block family.
Paper has a server-side anti-xray (
anticheat.anti-xrayinpaper-world-defaults.yml) that is cheaper than any plugin can be, because it runs inside the chunk serializer. If you enable Paper's engine-mode 2 or 3, setxray.obfuscation.enabled: falseand keep only the analytics below. Run Sentinel's obfuscation when you cannot touch the server config, or when you want per-permission control (sentinel.bypass.xray).
analytics — scores mining behaviour instead of hiding anything, which
catches the players who found their ores legitimately-looking but far too often.
Four weighted signals over a rolling window:
| Signal | Weight | Meaning |
|---|---|---|
ore-ratio |
40 | Actual ore rate against expected-ore-ratio per material |
tunnel |
25 | Share of breaks that continue a straight 1×1 tunnel |
beeline |
20 | Share of ores reached right after a direction change |
depth |
15 | Share of breaks at or below deep-y |
A player is listed as a suspect once the score reaches suspect-score and at
least min-samples blocks were seen. alert-cooldown-minutes throttles the
alert, not the scoring. Analytics never punishes on its own — it produces a list
for staff and a SentinelXrayEvent for your own plugin.
Files
| File | Content |
|---|---|
config.yml |
Everything above |
messages.yml |
Every player-facing string, MiniMessage |
verified.yml |
UUIDs that passed the captcha |
violations.log |
One line per flag |
For developers
Sentinel is built to be used as a detection engine by other plugins: register your own checks, read per-player state, replace the punishment layer, or listen for flags and feed them into your own moderation backend.
Sentinel API
API version 1.0.0 — everything under org.sentinel.api is the public
surface and follows semantic versioning. Everything outside it is internal and
may change without notice.
Contents
- Setup
- Entry point
- Writing your own check
- Reading player state
- Violations
- Exemptions
- Punishment actions
- Events
- Bot guard and captcha
- X-ray
- Using Sentinel as a framework
- Version history
Setup
Sentinel has no repository publication, so depend on the jar directly. The build produces a sources jar next to it.
dependencies {
compileOnly(files("libs/sentinel.jar"))
}
# paper-plugin.yml
dependencies:
server:
Sentinel:
load: BEFORE
required: true
Use required: false plus SentinelProvider.available() if Sentinel is
optional for your plugin.
Entry point
SentinelApi sentinel = SentinelProvider.get();
SentinelProvider.get() throws IllegalStateException while Sentinel is not
loaded; find() returns an Optional and available() a boolean. Sentinel is
also registered with the Bukkit services manager:
RegisteredServiceProvider<SentinelApi> registration =
Bukkit.getServicesManager().getRegistration(SentinelApi.class);
SentinelApi gives you every subsystem:
| Method | Returns |
|---|---|
version() |
Plugin version |
checks() |
CheckRegistry |
violations() |
ViolationService |
exemptions() |
ExemptionService |
punishments() |
PunishmentRegistry |
bots() |
BotService |
xray() |
XrayService |
profile(Player) / profile(UUID) / profiles() |
PlayerProfile |
serverHealthy() |
false while the server is lagging |
reload() |
Reload the configuration |
Writing your own check
Extend Check, override the hooks you need, call flag when something is
wrong. You never touch violation levels, alerts, logging or punishments — that
is handled for you.
public final class GlideCheck extends Check {
public GlideCheck() {
super("glide", CheckCategory.MOVEMENT, "Falls slower than gravity allows");
}
@Override
public void onMove(final MoveContext context) {
if (context.onGround() || context.profile().airTicks() < 10) {
return;
}
final double expected = -0.08D * context.profile().airTicks();
final double tolerance = settings().getDouble("tolerance", 0.05D);
if (context.vertical() > expected + tolerance) {
if (buffer(context.profile(), 1.0D, 5.0D) > 3.0D) {
flag(context.profile(), "dy " + context.vertical() + " expected " + expected);
resetBuffer(context.profile());
}
} else {
buffer(context.profile(), -1.0D, 5.0D);
}
}
}
Register it with your own plugin as the owner, and clean up on disable:
@Override
public void onEnable() {
SentinelProvider.get().checks().register(this, new GlideCheck());
}
@Override
public void onDisable() {
SentinelProvider.find().ifPresent(api -> api.checks().unregisterAll(this));
}
Hooks
| Hook | Fired when |
|---|---|
onRegister() / onUnregister() |
The check is added or removed |
onJoin(profile) / onQuit(profile) |
A player joins or leaves |
onMove(MoveContext) |
The player's position changed |
onRotate(profile, yawDelta, pitchDelta) |
The player's rotation changed |
onAttack(AttackContext) |
The player damaged an entity |
onSwing(profile) |
The player swung their arm |
onBlockDamage / onBlockBreak / onBlockPlace (BlockContext) |
Block interaction |
onInventoryClick(profile) |
A click in any inventory |
onChat(profile, message) |
A chat message (on the main thread) |
onCommand(profile, command) |
A command, including the slash |
onTick(profile) |
Every 5 ticks, per online player |
All hooks run on the main thread. MoveContext, AttackContext and
BlockContext carry cancel() so a check can block the action outright;
MoveContext exposes from, to, deltaX/Y/Z, horizontal(), vertical(),
onGround() and wasOnGround(), AttackContext the victim(), reach() and
angle(), BlockContext the block(), action() and distance().
Reporting
| Method | Effect |
|---|---|
flag(profile, details) |
One violation |
flag(profile, amount, details) |
Weighted violation |
reward(profile, amount) |
Lower the violation level after a clean reading |
Per-player state
buffer(profile), buffer(profile, delta, max) and resetBuffer(profile) give
you a clamped counter per check and player. For anything richer use
state(profile, key, Supplier), which lazily creates and caches your own
object:
private static final class Samples {
private final double[] values = new double[32];
private int index;
}
final Samples samples = state(context.profile(), "samples", Samples::new);
clearState(profile) drops everything your check stored for that player. All
of it lives in profile.storage() and disappears when the player leaves.
Configuration
settings() returns a CheckSettings backed by checks.<name> in
config.yml. A check that is not in the file still works — it gets
enabled: true, decay-per-second: 1.0 and max-violations: 120.0. Admins can
add a block for it at any time:
checks:
glide:
enabled: true
decay-per-second: 0.5
max-violations: 100.0
settings:
tolerance: 0.05
CheckSettings reads getBoolean, getInt, getLong, getDouble,
getString, getStringList and getDoubleMap, each with a fallback, and
enabled(boolean) toggles the check at runtime.
Reading player state
PlayerProfile is a live, read-mostly view of one online player.
PlayerProfile profile = SentinelProvider.get().profile(player);
profile.ping();
profile.airTicks();
profile.lastHorizontal();
profile.clicksPerSecond();
profile.recentTargets();
profile.totalViolations();
profile.violationSnapshot(); // check name -> vl
profile.mining().score(); // 0..100
profile.botScore(); // 0..100
profile.verified();
profile.exempt(ExemptType.TELEPORT);
profile.storage(); // your own scratch space
mining() returns MiningStats: totalBlocks(), totalOres(), ores() per
material, oreRatio(), tunnelRatio(), beelineRatio(), deepRatio(),
score() and reset().
Violations
ViolationService violations = SentinelProvider.get().violations();
violations.violations(profile, "speed");
violations.total(profile);
violations.snapshot(profile);
violations.add(profile, "my-check", 2.0, "reason"); // runs alerts + ladder
violations.subtract(profile, "speed", 5.0);
violations.clear(profile);
violations.recent(profile); // last 30 flags
violations.recent(); // last 200 server-wide
add with an unregistered check name still works — it is logged under the
custom category. That is the shortest path if your detection logic lives
entirely in your own plugin and you only want Sentinel's violation
bookkeeping, alerts and punishment ladder.
A Violation is a record: player, playerName, check, category,
amount, total, details, timestamp, ping, tps.
Exemptions
ExemptionService exemptions = SentinelProvider.get().exemptions();
exemptions.exempt(profile, ExemptType.LAG);
exemptions.exemptAny(profile, ExemptType.JOIN, ExemptType.TELEPORT);
exemptions.apply(profile, ExemptType.MANUAL, 5000L); // exempt for 5 seconds
exemptions.clear(profile, ExemptType.MANUAL);
exemptions.active(profile);
Use ExemptType.MANUAL for your own effects — a custom dash ability, a lobby
launch pad, a cutscene — and the built-in movement checks will ignore the
player for that window.
Punishment actions
Register an id and use it in the ladder. This is how you route Sentinel into your own moderation system without touching its code.
SentinelProvider.get().punishments().register("mybans", context -> {
MyBanService.ban(context.profile().uuid(),
"Sentinel: " + context.violation().check(),
context.violation().details());
});
punishments:
ladder:
- violations: 60.0
action: mybans
value: "7d"
PunishmentContext carries the profile(), the violation(), the
threshold() that was crossed and the value() string from the config.
ids() lists all registered actions, unregister(id) removes one, and
execute(id, context) runs one directly.
Overwriting a built-in id (alert, command, kick, none) replaces it.
Events
All events are Bukkit events. Four of them are cancellable, and cancelling is the clean way to take control.
| Event | Cancelling it |
|---|---|
SentinelFlagEvent |
Rolls the violation back; no alert, no log, no punishment |
SentinelPunishEvent |
Stops that punishment action from running |
SentinelBotEvent |
Lets the connection or player through |
SentinelXrayEvent |
Suppresses the suspect alert (the suspect is still listed) |
SentinelCaptchaEvent |
Not cancellable — reports STARTED, PASSED, FAILED, TIMEOUT |
@EventHandler
public void onFlag(final SentinelFlagEvent event) {
if (event.profile().player().getWorld().getName().equals("minigames")) {
event.setCancelled(true);
return;
}
MyWebhook.post(event.violation());
}
SentinelBotEvent fires asynchronously during pre-login for connection and
name signals, and synchronously for behaviour kicks. It exposes the
playerName(), the address(), the signal() and a mutable kickReason().
Bot guard and captcha
BotService bots = SentinelProvider.get().bots();
bots.lockdown(); // currently refusing connections?
bots.lockdown(60_000L); // refuse for a minute
bots.blockedConnections();
bots.pendingCaptchas();
bots.verified(uuid);
bots.verify(uuid); // also completes a running captcha
bots.unverify(uuid);
bots.requestCaptcha(player);
bots.verifying(player);
bots.score(profile);
bots.signals(profile); // Set<BotSignal>
BotSignal values: CONNECTION_RATE, ADDRESS_LIMIT, LOCKDOWN,
NAME_PATTERN, NAME_ENTROPY, NAME_LENGTH, NAME_DIGITS, NO_ACTIVITY,
STRAIGHT_LINE, ZERO_ROTATION, CAPTCHA_TIMEOUT, CAPTCHA_FAILED.
To replace the captcha with your own, listen for
SentinelCaptchaEvent.Result.STARTED, run your flow, and call
bots.verify(uuid) when the player passes.
X-ray
XrayService xray = SentinelProvider.get().xray();
xray.obfuscationEnabled();
xray.hiddenBlocks();
xray.hideAll(player);
xray.revealAll(player);
xray.hiddenCount(player);
xray.invalidate(chunk); // after changing blocks yourself
xray.analyticsEnabled();
xray.score(profile);
xray.suspects(); // List<XraySuspect>, best score first
xray.clearSuspect(uuid);
Call invalidate(chunk) whenever your plugin changes blocks in a way Sentinel
cannot see (world edits, schematic pastes, custom generation), otherwise the ore
index for that chunk stays stale until it unloads.
XraySuspect is a record: player, playerName, score, blocks, ores,
detectedAt.
Using Sentinel as a framework
Three ways to build on it, from least to most invasive.
1. Detection engine, your reaction. Leave the checks on, set the whole
ladder to action: none or drop it, and listen to SentinelFlagEvent. Sentinel
does the detecting, decaying and logging; your plugin decides what happens.
punishments:
ladder: []
2. Your checks, Sentinel's plumbing. Register your own Check subclasses.
You get exemptions, violation decay, the buffer helpers, per-check config,
alerts with hover and click, the log file, the admin GUI entry, the ladder and
/sentinel check <name> on|off — for free, for a class with one method.
3. Your moderation backend. Register a PunishmentAction and point the
ladder at it. Sentinel never calls ban or kick itself in that case; your
code decides, and SentinelPunishEvent still lets a third plugin veto it.
Migration notes if you are moving an existing anti-cheat into Sentinel:
- One check class per detection, named in
kebab-case; the name is the config key, the command argument and the violation key, so pick it once and keep it. - Replace your own tick counters with
profile.airTicks(),profile.groundTicks(),profile.lastHorizontal()andprofile.clicksPerSecond()instead of tracking movement twice. - Replace your own false-positive guards with the
ExemptTypechecks before you port the detection logic itself — most of what looks like a detection bug is a missing exemption. - Anything you stored in a
Map<UUID, Something>becomesstate(profile, key, Something::new)and gets cleaned up on quit for you. - Keep your thresholds in
settings()rather than as constants so servers can tune them without a rebuild.
Version history
1.0.0
Initial API.
SentinelApi,SentinelProviderCheck,CheckCategory,CheckRegistry,CheckSettings,MoveContext,AttackContext,BlockContextPlayerProfile,MiningStatsViolation,ViolationServiceExemptType,ExemptionServicePunishmentAction,PunishmentContext,PunishmentRegistryBotService,BotSignalXrayService,XraySuspectSentinelFlagEvent,SentinelPunishEvent,SentinelBotEvent,SentinelXrayEvent,SentinelCaptchaEvent
Information
Pinned Versions
- R26.3
Pages
Members
1Owner