Skip to content
← All posts
4 min read

Running an anticheat on Folia: why most checks quietly stop working

Folia's regionised threading forbids reading world state off the owning region thread, which is exactly what movement, scaffold and reach checks do. Here is the failure mode, the approach that does not work, and the one that does.

foliadetection engineering

If you moved a server to Folia and kept your anticheat running, it is worth checking what it is still actually doing. There is a good chance a large share of its checks are inert, and a reasonable chance nothing in your console told you.

What Folia changes

Paper runs the world on one main thread. Folia splits the world into independently-ticking regions, each owned by its own thread, and enforces that ownership: reading or writing a block outside the region you are currently executing on throws.

Anticheat detection, meanwhile, mostly runs on the netty thread, the thread packets arrive on. That is deliberate and correct. It is why packet-level checks can respond within the same tick the packet arrived, instead of a tick later.

On Paper those two facts coexist because reading a block off-thread is merely unsafe, and in practice works. On Folia it is enforced. So the moment a check on the netty thread asks "is there a block under this player", Folia raises an error.

Which checks need block reads? Essentially all the interesting ones:

  • Movement / fly / speed: the physics simulation needs the blocks the player is standing on and colliding with.
  • Scaffold and tower detection: needs the world state around the placement.
  • Reach and hitbox raytracing: needs to trace through world geometry.

Packet-timing checks (timer, CPS, ping consistency, protocol validation) need no world reads and keep working fine. That is why an anticheat on Folia can look healthy (alerts still appear, from the subset of checks that never touched the world) while fly and scaffold go completely undetected.

How to check yours: get on a test server, run a scaffold or a basic fly cheat, and see whether the check that should catch it fires. Do not infer from "the plugin loaded without errors".

The approach that does not work

The intuitive fix is to stop doing detection on the netty thread. Hold the packet, hand it to the region thread that owns the player, and re-run the check there where block reads are legal.

We built that. It failed a live soak, badly enough to be worth documenting so nobody repeats it.

Re-injecting packets into the pipeline so they run again on the region thread amplifies the packet stream: the re-injected packets are themselves observed, and the volume compounds. We measured over 258,000 packets per second on a modest player count. Players timed out. Commands and respawn broke. The approach is a dead end, not a tuning problem: you are fighting the pipeline's own feedback loop.

The approach that works: snapshot the world instead

Invert it. Do not move the check to the world's thread. Bring a copy of the world to the check's thread.

A task on each region thread keeps a small, per-player snapshot of the chunks around that player fresh: a window of a few chunks, refreshed every few ticks. Checks stay on the netty thread, run exactly the same code they run on Paper, and their block reads are served from the snapshot instead of from the live world.

The trade-off is honest and bounded: the snapshot is up to one refresh period stale. For a player moving through blocks that were placed several ticks ago (the overwhelmingly common case), it is identical to a live read. For a block placed and stood on within the same refresh window, it is not.

That staleness is precisely why this should be rolled out in two stages. Run it alert-only first, where a stale read can produce a wrong alert but can never rubber-band or ban anybody, and confirm on your own traffic that the alerts are real. Only then enable enforcement, where the check sets back and bans the way it does on Paper.

One implementation detail worth passing on, because it cost us a player-facing bug: never route a block read through the snapshot from inside the collision-shape resolver. The snapshot's substitute block gets asked for its type, which re-enters the shape cache, which throws a recursive-update error and faults the player out. Off-region, the shape drill has to resolve from block data without reading a block at all.

What still cannot work

Two categories resist snapshotting entirely, and any vendor claiming otherwise is worth a follow-up question:

  • Checks that call live server internals, such as reading a block's break speed. That is a live NMS call, not a data read; there is nothing to snapshot.
  • Checks that emulate a live interaction, such as full interaction raytracing, for the same reason.

These stay off on Folia. That is a real gap, and the honest thing is to name it rather than let a feature matrix imply full parity.

What to ask your vendor

If you run Folia, or plan to:

  1. Which specific checks run on Folia, and which are disabled?
  2. Does the plugin tell me when a check is inactive, or does it just not fire?
  3. Is Folia support one jar, or a separate build I have to track?
  4. Is the Paper behaviour byte-for-byte unchanged by the Folia work?

That last one matters more than it sounds. Folia support that refactors shared detection code puts your Paper servers' accuracy at risk to fix a platform they do not run on. The right shape is every Folia branch gated behind a runtime platform check, so Paper executes the identical path it always did.