A tick accurate combat engine for Paper. It takes over hit detection, reach validation, knockback and damage scaling, keeps a rolling history of every entity position so hits can be checked against where the attacker actually saw the target, and ships a small packet level anticheat on top.
Built for Minecraft 26.2, Java 25, Kotlin.
Custom hit detection. Every swing is re traced server side with a slab method ray against the target hitbox rather than trusting the client. No allocation per hit, the ray and the box are both reused.
Reach validation with rewind. Positions for the last N ticks live in one flat DoubleArray.
When a hit comes in the target is rewound by the attacker's ping before the reach is measured, so a
player on 200ms is not punished for their own latency.
Combat state machine. Every tracked entity sits in IDLE, ENGAGED or RECOVERING, with tag
and invulnerability deadlines held in IntArrays. Other code can watch transitions without polling.
Knockback engine. Configurable horizontal, vertical, sprint bonus, enchant scaling, friction and air multiplier. Knockback is computed on the hit and applied on the next tick, because vanilla overwrites velocity set during the damage event.
Knockback sync. Predicts whether the victim's client has already landed by the time the velocity packet arrives, and uses grounded knockback values instead of airborne ones when it has. This is what stops the classic "high ping players fly further" desync.
Attack cooldown. Either LEGACY (1.8 style, always full damage) or SCALED (vanilla
0.2 + charge squared times 0.8), with a configurable recharge period.
Damage modifiers. An ordered pipeline. Built ins are charge scaling, criticals, reach falloff and a cap. Any of them can veto the hit and stop the chain.
Anticheat. Reach, click rate, swingless attacks and rotation checks, with violation levels that decay on a half life and permission gated alerts.
The whole engine is built on slot indexed flat arrays instead of per player objects. Every tracked
entity gets an Int slot, and all state lives in parallel primitive arrays indexed by it. The per
tick loop is a bare while over DoubleArray and IntArray with no map lookups, no iterators and
no boxing. Vec, Aabb, RayHit and DamageContext are all reused rather than allocated per hit.
Position history is frame major, so writing a whole tick touches one contiguous run of memory.
- Paper
26.2or newer - Java 25
- packetevents
2.13.0or newer, optional
Without packetevents the plugin still runs. Knockback sync falls back to the server reported ping instead of real round trip measurement, and the packet level checks are switched off. The console says which mode you are in on startup.
./gradlew buildThe jar lands in build/libs/CombatEngine-1.0.jar. packetevents is compileOnly so it is not
bundled, install it separately.
Root command is /combat, aliased to /ce.
/combat statusengine tick, tracked slots, accepted and rejected swings, average reach/combat modifierswhat is in the damage pipeline and in what order/combat knockbackthe active knockback profile/combat syncknockback sync state, corrections applied and skipped, tracked pings/combat checksanticheat state and flag counts per check/combat meyour own slot, state, combat tag, attack charge, ping and violation level/combat reloadreload the config and restart the timers
combat.useread engine status, everyone by defaultcombat.adminreload, op by defaultcombat.alertsreceive anticheat alerts, op by default
The engine records every tracked entity's position and hitbox size once per tick into a ring buffer
sized by engine.history-ticks. When a hit arrives the attacker's ping is turned into a tick count
(ping / 50, capped by reach.rewind-cap-ticks), the victim's box is pulled from that frame, and a
ray is cast from the attacker's eye along their look vector.
If the ray misses the box entirely and reach.reject is on, the hit is cancelled. Otherwise the
distance along the ray becomes the reach, which then feeds both the falloff modifier and the
anticheat reach check.
The tape is checked before use. If the requested frame was overwritten or never recorded, the engine falls back to the victim's live hitbox instead of guessing.
When a player is hit while airborne, the server sends a velocity packet. If that player is a fraction of a block above the ground, their client may well have landed before the packet arrives, and the knockback then reads as far stronger than it should.
Knockback sync estimates the ground distance from four hitbox corners, works out how long the client
needs to reach its apex and fall back (a per tick simulation using vanilla gravity 0.08, drag
0.98 and terminal velocity 3.92), and compares that against the measured ping. If the client is
predicted to be grounded already, the vertical and horizontal knockback are replaced with the
grounded values from the sync config block.
Ping comes from real ping and pong packets through packetevents rather than Player#getPing, which
is smoothed and lags behind.
Hits older than sync.max-hit-age-ms are ignored, and a knockback vector that somehow points back at
the attacker is left alone on the horizontal.
This is ported from the knockbacksync module of the Core plugin. Same constants, same behaviour, but it now lives in the engine and shares its config and ping tracking instead of standing on its own.
Four checks, all off a single packetevents listener except reach.
reach runs on the main thread and reuses the measurement the engine already made against the
rewound hitbox. Flags anything past anticheat.reach.max.
hitrate counts attack packets in a rolling second and flags past max-cps.
swing flags an attack packet with no arm animation inside window-ms, which is a common
killaura signature.
rotation flags a pitch outside the legal range, or a yaw jump larger than max-yaw-step in a
single packet.
Every flag adds to a violation level that halves every vl-half-life-seconds, so a burst of noise
fades instead of accumulating forever. Alerts go to the console and to anyone holding
anticheat.alert-permission.
Packet listeners run off the main thread, so they only ever touch atomics and concurrent maps. None of them read the engine's slot arrays, which is why reach is measured on the main thread and not in the listener.
plugins/CombatEngine/config.yml. Everything is clamped on load, so a bad value cannot take the
server down.
cooldown.mode accepts SCALED or LEGACY. 1.8 and OLD also map to legacy.
engine:
history-ticks: 20
initial-slots: 64
track-mobs: true
reach:
limit: 3.0
tolerance: 0.15
hitbox-padding: 0.1
rewind: true
rewind-cap-ticks: 10
reject: true
cooldown:
mode: SCALED
period-ticks: 12
scale-damage: true
knockback:
enabled: true
horizontal: 0.4
vertical: 0.4
friction: 0.5
sync:
enabled: true
ping-offset: 0
max-hit-age-ms: 50
vertical-default: 0.36080000519752503
horizontal-default: 0.36080000519752503
anticheat:
enabled: true
vl-half-life-seconds: 30
reach:
max: 3.1
hitrate:
max-cps: 16./gradlew test63 tests covering the ray and box maths, the position tape including wraparound and resize, slot allocation and reuse, the state machine transitions, the cooldown curve, knockback direction and caps, the damage pipeline ordering and veto, the fall simulation, and violation decay.
- Knockback lands one tick after the hit. That is deliberate. Vanilla applies its own knockback after the damage event returns, so anything set during the event gets overwritten.
- Mobs are tracked lazily the first time they are hit, and released when they die or stop being valid. Players are tracked on join.
- There are no comments in the source. Naming carries it.
engine.history-ticksis the memory knob. Each frame costs five doubles per tracked entity, so 20 ticks and 200 entities is about 160KB.
MIT. See LICENSE.
Written by Am4er.