Forest Logo
search
package_2

central

By @vbaumel1337

Roblox

Mirrored

Central

Server-authoritative hitbox, raycast, and shapecast querying for Roblox, with built-in client latency compensation.

Central keeps a short rolling history of tagged hitbox parts on the server so that raycasts/shapecasts issued against a player are resolved against where that player actually saw the world, not just the current frame. It measures each player's perceived replication delay automatically (see How Character Latency Is Measured) and rewinds hitbox history to match. Queries are resolved against an AABB tree per historical frame, using the Bolt library for the collision-detection math.

Installation

Add to your wally.toml:

[dependencies]
Central = "vbaumel1337/central@^0.2.0"

Then wally install.

Examples

A runnable example place is included at examples/Server Authoritative Hitboxes Demo.rbxl, and playable live on Roblox: Server-Authoritative Hitboxes Demo.

Commands: F fires a projectile, E fires a laser gun, Q does a super jump. (This game can lag/error occasionally, since Roblox's instance streaming is buggy right now.)

Usage

Central is a single module required from both the server and the client; it gates its own behavior based on RunService. Call Central.Start() once, early, on both realms, before using any of the functions below. Calling it twice, or from the wrong realm, warns and no-ops.

local Central = require(ReplicatedStorage.Packages.Central)
Central.Start()

Queries

Every query takes the player it's being cast on behalf of (used to lag-compensate against, and to resolve that player's own hitboxes live) and an optional querySettings. On the client these are a plain pass-through to their Roblox counterpart, no compensation happens. On the server, each call runs a normal live query against the world right now plus a historical query against that player's rewound hitbox history, and merges them: the cast functions return whichever hit is closer, the overlap functions union both result sets.

Central.Raycast(player, origin, direction, raycastParams?, querySettings?)          -- workspace:Raycast               -> distance, instance, position, normal
Central.Shapecast(player, part, direction, raycastParams?, querySettings?)          -- workspace:Shapecast             -> distance, instance, position, normal
Central.SimpleShapecast(player, part, direction, raycastParams?, querySettings?)    -- workspace:Shapecast, cheaper    -> distance, instance
Central.GetBoundsInRadius(player, position, radius, overlapParams?, querySettings?) -- workspace:GetPartBoundsInRadius -> {BasePart}
Central.GetPartBoundsInBox(player, cframe, size, overlapParams?, querySettings?)    -- workspace:GetPartBoundsInBox    -> {BasePart}
Central.GetPartsInPart(player, part, overlapParams?, querySettings?)                -- workspace:GetPartsInPart        -> {BasePart}

Only these RaycastParams/OverlapParams properties are honored: CollisionGroup, RespectCanCollide, ExcludeInstances, IncludeInstances, and (overlap only) MaxParts.

querySettings (server only): { frameRange: number?, check: ((BasePart) -> boolean)? }

  • frameRange: extra frames around the player's latency-resolved frame to search. Defaults to Settings.RAYCAST_FRAME_RANGE for Raycast, Settings.COLLISION_FRAME_RANGE for the rest.
  • check: a (part: BasePart) -> boolean filter for candidate hits. On Raycast/Shapecast/SimpleShapecast it also makes the live cast pierce through failing parts instead of stopping on them.

Collision group registration (server only)

Central keeps hitbox parts and queries in two separate, always mutually non-collidable, families of PhysicsService collision group: a hitbox group (for the parts) never collides with a query group (for the casts). That's what lets a live query skip right past a hitbox part instead of hitting its current position; the historical pass is what checks hitboxes.

Central.AddCollisionGroup(name)    -- registers a hitbox group, non-collidable with every query group
Central.RemoveCollisionGroup(name) -- reverses that; leaves the PhysicsService group registered
Central.AddQueryGroup(name)        -- registers a query group, non-collidable with every hitbox group
Central.RemoveQueryGroup(name)     -- reverses that; leaves the PhysicsService group registered

A tagged hitbox part or query whose CollisionGroup isn't a registered hitbox/query group is silently forced onto Settings.DEFAULT_HITBOX_COLLISIONGROUP / Settings.DEFAULT_HITBOX_QUERY_GROUP. Settings.INITIAL_COLLISION_GROUPS / Settings.INITIAL_QUERY_GROUPS (default: just those two) are registered automatically by Central.Start().

Creating a hitbox

Tag a BasePart with Settings.HITBOX_TAG ("CompensatedHitbox" by default) to have the server start recording its CFrame, size, and CanCollide every frame. Set the attribute and collision group before adding the tag. Central only reads them once, at the moment the tag is added:

-- server
part:SetAttribute(Settings.OWNER_ATTRIBUTE, player.Name) -- optional
part.CollisionGroup = "EnemyHitbox" -- must already be registered
part:AddTag(Settings.HITBOX_TAG)
  • Owner (Settings.OWNER_ATTRIBUTE): the player who already sees this part in the right place on their own screen (e.g. their own character). Set to that player's Name, their own queries check it live instead of rewinding it. Everyone else still gets it lag-compensated.
  • Each recorded frame is just a CFrame/size/CanCollide snapshot, not a simulated body, the history has no physics of its own. The live part still behaves normally in workspace under Roblox's own physics.

Debug hitbox visualization (server only)

No-ops unless Settings.DEBUG_MODE is true; when on, every query also draws its ray/shape and hit via the vendored Bolt visualizer.

Central.ShowHitboxes(player, owner)         -- draw owner's hitboxes at player's rewound frame, i.e. what Central resolves player's queries against
Central.HideHitboxes(player, owner)         -- stop that draw
Central.ShowAllPlayerHitboxes(player)       -- like ShowHitboxes, for every other player + "Server"-owned hitboxes, recomputed live
Central.RemoveAllPlayerHitboxes(player)     -- stop that draw

owner is a player's Name, or "Server" for hitboxes with no owner attribute. All four clean up automatically on Players.PlayerRemoving.

Getting synced client/server results under BindToSimulation

For gameplay code like shooting, bind the same function to RunService:BindToSimulation on both the client and server, and call Central's query functions from inside it:

RunService:BindToSimulation(function(delta)
    local origin, direction = getAimRay()
    local distance, instance, position = Central.Raycast(player, origin, direction)

    if instance and isServer then
        applyDamage(instance)
    end
end, Settings.StepFrequency, Settings.HitboxStepPriority + 100)

Both sides run off the same input for a step; the client sees it instantly, the server after a network delay. On the client, Central.Raycast is a plain live raycast. On the server it rewinds to the frame matching the firing player's own latency, so both sides usually land on the same hit (not always, jitter and latency variance keep it approximate).

  • Only apply real effects (damage, destroying a part) if isServer. The client's call is just local feedback.
  • Widen querySettings.frameRange if jitter is causing disagreements.
  • A hitbox owned by the querying player is checked live instead of historically, on both realms.
  • Something that moves every step (e.g. a projectile shapecast each frame) should use a registered query group, not a hitbox group, so it doesn't physically collide with hitboxes and all its hit detection goes through Central.
  • Your binding's step priority must be higher than Settings.HitboxStepPriority. Central records that step's hitbox frame at that priority, so a lower/equal-priority binding queries last step's data instead of the current one.

How Character Latency Is Measured

Instead of raw network ping, Central measures perceived replication delay directly. Two dummies, far away from the playing area (make sure to set Settings.LATENCY_DUMMY_HIDE_OFFSET to a value that ensures that, but not too far to be affected by floating point), spin around a circle. In the server, they share the exact same positions and velocities. However, in the client, one of them is set to Enum.PredictionMode.On, and the other Enum.PredictionMode.Off. Roblox has to know the part interpolation delay to use it on the prediction, so the time the unpredicted dummy is behind the predicted dummy, is the part interpolation delay.

Each cycle, the client records the predicted dummy's current angle, waits for the unpredicted dummy to sweep to that same angle, and reports the time that took over a RemoteEvent, that's how far behind the player's replicated view is running, including Roblox's own interpolation buffering. The server discards invalid samples, adds Settings.LATENCY_OFFSET (change this value if you feel the measures are a bit off, by default its 0), clamps into [0, Settings.MAX_LATENCY], and averages the last Settings.LATENCY_SAMPLE_WINDOW samples into Settings.LATENCY_ATTRIBUTE, the number hitbox history gets rewound by.

The measurement is client-reported. The server clamps and sanity-checks it but doesn't independently verify it, so a modified client can influence how far back its own shots are rewound, bounded by MAX_LATENCY. Treat MAX_LATENCY as the security-relevant knob if that matters for your game.

Performance

Measured server-side in Studio: a 3×3×3 grid of anchored hitbox parts (7 studs apart), all moved and all 60 history frames populated every frame, at default settings (FRAME_CAP = 60, frame ranges of 1, PREFER_NEAREST_FRAME = true). Timed against HistoricalHitboxes directly, lag-compensation cost alone; Central.Raycast/friends add a live workspace cast on top. Absolute numbers will move with hardware; the scaling behavior is the part worth trusting.

Closest-hit queries are flat in hitbox count: 16× the hitboxes costs the same, because the tree prunes to the closest hit during traversal:

hitboxesRaycastShapecastSimpleShapecastOverlap (per part)
500.94 µs1.35 µs1.23 µs50.3 µs (1.05 µs)
4001.01 µs1.51 µs1.40 µs70.8 µs (1.11 µs)
8001.03 µs1.42 µs1.37 µs70.1 µs (1.10 µs)

What actually moves the cost: whether the cast connects (400 hitboxes). A clean hit bounds the search so everything farther is skipped; a grazing shapecast never establishes that bound, so every candidate gets tested (~10× a direct hit):

RaycastShapecastSimpleShapecast
direct hit0.97 µs1.54 µs1.39 µs
grazing contact1.28 µs14.65 µs14.20 µs
empty space0.76 µs1.02 µs0.92 µs

And for overlap queries, which shape you query with: a hitbox is always a box, so the test is box × your shape; box/sphere/capsule get a dedicated routine 1.5–5× faster than the GJK fallback everything else uses:

query shaperoutineper-call (overlapping/separated)
boxbox_box0.14 / 0.07 µs
spherebox_sphere0.13 / 0.13 µs
capsulebox_capsule0.24 / 0.19 µs
cylinder, wedge, corner wedge, ellipsoidGJK fallback0.5–0.8 µs

GetPartBoundsInBox/GetBoundsInRadius always stay on a dedicated routine; GetPartsInPart derives the shape from the part you hand it, so a Cylinder/Wedge/mesh part drops to GJK, approximate with the other two on a hot path if the exact silhouette doesn't matter. Overlap cost overall tracks how many hitboxes fall inside the query volume, not how many exist.

Per frame: median of 600 timed frames, each covering UpdateFrame plus the stated number of queries, measured in place (so it includes the cache pressure a query pays right after UpdateFrame just walked the whole tree; a query timed alone in a tight loop looks 1.5–2× cheaper than this).

UpdateFrame runs once per simulation step regardless of querying, and is roughly linear in hitbox count:

hitboxesper frameper hitbox
5069 µs1.38 µs
100156 µs1.56 µs
200364 µs1.82 µs
400778 µs1.95 µs
8001730 µs2.16 µs

Whole frame with raycasts, as a share of one 60 Hz frame (16667 µs):

hitboxes0 casts15102550100
500.4%0.4%0.5%0.5%0.6%0.8%1.3%
1000.9%1.0%1.1%1.2%1.3%1.6%1.9%
2002.2%2.3%2.3%2.3%2.5%2.8%3.3%
4004.7%5.0%5.1%5.0%5.2%5.5%6.1%
80010.4%10.8%11.6%11.0%11.7%11.7%13.2%

Shapecasts land within noise of those raycast figures at every count; the per-cast difference is small enough that UpdateFrame dominates either way:

hitboxes0 casts15102550100
500.4%0.4%0.5%0.5%0.7%0.9%1.5%
1001.0%1.0%1.1%1.1%1.3%1.6%2.1%
2002.1%2.3%2.3%2.3%2.5%2.8%3.4%
4004.7%4.9%4.9%5.1%5.1%5.4%6.0%
80011.1%11.1%11.2%11.7%11.1%11.9%12.8%

If you need to cut cost, reduce how many parts carry Settings.HITBOX_TAG, adding query volume is comparatively cheap. (One caveat on the medians: a historical tree occasionally rebuilds (TREE_REBUILD_CHECK_INTERVAL) and that frame spikes; rare and staggered across trees, so it shows up in the tail, not the median.)

Settings

Every tunable lives on the table at lib/Settings.luau (Central.Settings/CentralServer.Settings are the same table). Several fields are captured into locals the moment the package is first required, so editing lib/Settings.luau directly, not mutating Central.Settings at runtime, is the safe way to change a default.

SettingDefaultWhat it controls
DEBUG_MODEfalseDraws rays/hitboxes for every query, and gates the Show/HideHitboxes functions. Costs performance, leave off outside debugging.
DEBUG_LIFETIME1.5Seconds a debug draw stays visible before clearing.
StepFrequencyHz60How often Central's BindToSimulation loop records a hitbox frame and recomputes rewound indices.
HitboxStepPriority1000Priority Central's internal binding runs at; your own query-calling bindings need a higher number.
AUTO_ADD_CHARACTERStrueAuto-tags every part of a spawning player's character as an owned hitbox.
DEFAULT_HITBOX_QUERY_GROUP"HitboxQuery"Fallback query group for an unregistered CollisionGroup.
INITIAL_QUERY_GROUPS{DEFAULT_HITBOX_QUERY_GROUP}Query groups auto-registered by Central.Start().
DEFAULT_HITBOX_COLLISIONGROUP"HitboxCollison"Fallback hitbox group for an unregistered CollisionGroup.
INITIAL_COLLISION_GROUPS{DEFAULT_HITBOX_COLLISIONGROUP}Hitbox groups auto-registered by Central.Start().
HITBOX_TAG"CompensatedHitbox"CollectionService tag marking a part as lag-compensated.
OWNER_ATTRIBUTE"HitboxOwner"Attribute holding a hitbox's owning player's Name.
LATENCY_ATTRIBUTE"PartLatency"Attribute Central writes each player's averaged latency to.
FRAME_CAP60Size of the history ring buffer; bounds how far back Central can rewind (60 @ 60 Hz ≈ 1s).
RAYCAST_FRAME_RANGE1Default querySettings.frameRange for Raycast.
COLLISION_FRAME_RANGE1Default querySettings.frameRange for Shapecast/SimpleShapecast/overlap.
PREFER_NEAREST_FRAMEtrueTie-break when several frames in range produce a hit: true = nearest frame wins regardless of distance; false = spatially closest wins, ties to nearer frame.
TREE_REBUILD_CHECK_INTERVAL10Seconds between balance checks on each historical AABB tree.
TREE_REBUILD_CHECK_JITTER0.2Fraction of the interval used to randomize each tree's next check, so they don't all come due together.
LATENCY_OFFSET0Seconds added to each raw latency sample before clamp/average; bias compensation earlier/later.
LATENCY_SAMPLE_WINDOW5How many recent client reports are averaged into LATENCY_ATTRIBUTE.
MAX_LATENCY1Upper clamp (s) on a reported latency sample; also bounds how far a modified client can push its own rewind.
LATENCY_DUMMY_HIDE_OFFSETVector3.new(0, 13337, 0)Where the latency measurement rig is parked, off-map.
LATENCY_ORBIT_RADIUS25Radius (studs) of the circle the two latency dummies walk.
LATENCY_ANGLE_EPSILONmath.rad(0.1)How close the delayed dummy must get to the predicted dummy's recorded angle to count as caught up.
LATENCY_MEASURE_TIMEOUT1Seconds before an unconverged measurement cycle is abandoned.
LATENCY_MEASURE_PAUSE0.5Seconds between measurement cycles.
RAYCAST_MARGIN1e-4Slack added to the live-hit distance before the historical query runs, so a flush hitbox still registers.
GJK_TOLERANCE1e-4Convergence tolerance for the GJK shapecast/intersection routines.
AABB_PADDING1Padding (studs) around each hitbox's bounding box in the AABB tree, giving queries slack before a partial rebuild is needed.

Third-party code

lib/bolt and lib/visualizer.luau from unityjaeger/Bolt

Observers (sleitnick/observers), pulled in as a regular Wally dependency

Package Details

Install command (Click to copy)


Version

0.2.0

License

MIT

check_circle

Safe for commercial use

Automated license review — not legal advice.