Welcome to the Hangar Open Beta. Please report any issue you encounter on GitHub!
Avatar for iAyms

Sentinel Anti-Cheat

Report SentinelAC?

R

1.0.0

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

  1. Drop sentinel.jar into plugins/.
  2. Start the server. config.yml, messages.yml and verified.yml are created in plugins/Sentinel/.
  3. Give your staff sentinel.alerts and sentinel.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

  1. A check sees something impossible and calls flag, which raises that player's violation level (vl) for that check.
  2. SentinelFlagEvent fires. Cancel it and the violation is rolled back — nothing is logged, alerted or punished.
  3. Staff with sentinel.alerts get a message; the flag is written to violations.log.
  4. The punishment ladder runs. The highest rule whose violations threshold the vl has reached is executed once.
  5. Violations decay by decay-per-second for 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-xray in paper-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, set xray.obfuscation.enabled: false and 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

  1. Setup
  2. Entry point
  3. Writing your own check
  4. Reading player state
  5. Violations
  6. Exemptions
  7. Punishment actions
  8. Events
  9. Bot guard and captcha
  10. X-ray
  11. Using Sentinel as a framework
  12. 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() and profile.clicksPerSecond() instead of tracking movement twice.
  • Replace your own false-positive guards with the ExemptType checks 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> becomes state(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, SentinelProvider
  • Check, CheckCategory, CheckRegistry, CheckSettings, MoveContext, AttackContext, BlockContext
  • PlayerProfile, MiningStats
  • Violation, ViolationService
  • ExemptType, ExemptionService
  • PunishmentAction, PunishmentContext, PunishmentRegistry
  • BotService, BotSignal
  • XrayService, XraySuspect
  • SentinelFlagEvent, SentinelPunishEvent, SentinelBotEvent, SentinelXrayEvent, SentinelCaptchaEvent

Information

Published
October 7, 2026
Author
0Downloads

Platforms

Paper
Paper
26.3