{"id":"vbaumel1337/central","name":"central","scope":"vbaumel1337","platform":"roblox","description":"Server-authoritative hitbox, raycast, and shapecast querying with client-side latency compensation","version":"0.2.0","latest":"0.2.0","versions":["0.1.0","0.1.1","0.1.2","0.1.3","0.1.4","0.1.5","0.2.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{"sleitnick/observers":{"version":"^0.4.0","alias":"Observers"}},"integrity":"8ad0f48491f2644200f5976c19b430412b8c69b8b7fe383efa324c4b478dc060","likes":0,"downloads":0,"install":"forest install vbaumel1337/central","url":"https://forest.dev/p/roblox/vbaumel1337/central","files":"https://api.forest.dev/ai/package/roblox/vbaumel1337/central/files","readme":"# Central\n\nServer-authoritative hitbox, raycast, and shapecast querying for Roblox, with\nbuilt-in client latency compensation.\n\nCentral keeps a short rolling history of tagged hitbox parts on the server so\nthat raycasts/shapecasts issued against a player are resolved against where\nthat player actually saw the world, not just the current frame. It measures\neach player's perceived replication delay automatically (see\n[How Character Latency Is Measured](#how-character-latency-is-measured)) and\nrewinds hitbox history to match. Queries are resolved against an AABB tree\nper historical frame, using the [Bolt](https://github.com/unityjaeger/Bolt)\nlibrary for the collision-detection math.\n\n## Installation\n\nAdd to your `wally.toml`:\n\n```toml\n[dependencies]\nCentral = \"vbaumel1337/central@^0.2.0\"\n```\n\nThen `wally install`.\n\n## Examples\n\nA runnable example place is included at\n[`examples/Server Authoritative Hitboxes Demo.rbxl`](<examples/Server Authoritative Hitboxes Demo.rbxl>),\nand playable live on Roblox:\n[Server-Authoritative Hitboxes Demo](https://www.roblox.com/games/92062322926252/Server-Authoritative-Hitboxes-Demo).\n\n**Commands**: F fires a projectile, E fires a laser gun, Q does a super jump.\n(This game can lag/error occasionally, since Roblox's instance streaming is\nbuggy right now.)\n\n## Usage\n\nCentral is a single module required from both the server and the client; it\ngates its own behavior based on `RunService`. Call `Central.Start()` once,\nearly, on both realms, before using any of the functions below. Calling it\ntwice, or from the wrong realm, warns and no-ops.\n\n```lua\nlocal Central = require(ReplicatedStorage.Packages.Central)\nCentral.Start()\n```\n\n### Queries\n\nEvery query takes the `player` it's being cast on behalf of (used to\nlag-compensate against, and to resolve that player's own hitboxes live) and\nan optional `querySettings`. On the client these are a plain pass-through to\ntheir Roblox counterpart, no compensation happens. On the server, each call\nruns a normal **live** query against the world right now plus a\n**historical** query against that player's rewound hitbox history, and\nmerges them: the cast functions return whichever hit is closer, the overlap\nfunctions union both result sets.\n\n```lua\nCentral.Raycast(player, origin, direction, raycastParams?, querySettings?)          -- workspace:Raycast               -> distance, instance, position, normal\nCentral.Shapecast(player, part, direction, raycastParams?, querySettings?)          -- workspace:Shapecast             -> distance, instance, position, normal\nCentral.SimpleShapecast(player, part, direction, raycastParams?, querySettings?)    -- workspace:Shapecast, cheaper    -> distance, instance\nCentral.GetBoundsInRadius(player, position, radius, overlapParams?, querySettings?) -- workspace:GetPartBoundsInRadius -> {BasePart}\nCentral.GetPartBoundsInBox(player, cframe, size, overlapParams?, querySettings?)    -- workspace:GetPartBoundsInBox    -> {BasePart}\nCentral.GetPartsInPart(player, part, overlapParams?, querySettings?)                -- workspace:GetPartsInPart        -> {BasePart}\n```\n\nOnly these `RaycastParams`/`OverlapParams` properties are honored:\n`CollisionGroup`, `RespectCanCollide`, `ExcludeInstances`,\n`IncludeInstances`, and (overlap only) `MaxParts`.\n\n`querySettings` (server only): `{ frameRange: number?, check: ((BasePart) -> boolean)? }`\n- `frameRange`: extra frames around the player's latency-resolved frame to\n  search. Defaults to `Settings.RAYCAST_FRAME_RANGE` for `Raycast`,\n  `Settings.COLLISION_FRAME_RANGE` for the rest.\n- `check`: a `(part: BasePart) -> boolean` filter for candidate hits. On\n  `Raycast`/`Shapecast`/`SimpleShapecast` it also makes the live cast pierce\n  through failing parts instead of stopping on them.\n\n### Collision group registration (server only)\n\nCentral keeps hitbox parts and queries in two separate, always mutually\nnon-collidable, families of `PhysicsService` collision group: a **hitbox**\ngroup (for the parts) never collides with a **query** group (for the\ncasts). That's what lets a live query skip right past a hitbox part instead\nof hitting its current position; the historical pass is what checks\nhitboxes.\n\n```lua\nCentral.AddCollisionGroup(name)    -- registers a hitbox group, non-collidable with every query group\nCentral.RemoveCollisionGroup(name) -- reverses that; leaves the PhysicsService group registered\nCentral.AddQueryGroup(name)        -- registers a query group, non-collidable with every hitbox group\nCentral.RemoveQueryGroup(name)     -- reverses that; leaves the PhysicsService group registered\n```\n\nA tagged hitbox part or query whose `CollisionGroup` isn't a registered\nhitbox/query group is silently forced onto\n`Settings.DEFAULT_HITBOX_COLLISIONGROUP` / `Settings.DEFAULT_HITBOX_QUERY_GROUP`.\n`Settings.INITIAL_COLLISION_GROUPS` / `Settings.INITIAL_QUERY_GROUPS`\n(default: just those two) are registered automatically by `Central.Start()`.\n\n### Creating a hitbox\n\nTag a `BasePart` with `Settings.HITBOX_TAG` (`\"CompensatedHitbox\"` by\ndefault) to have the server start recording its `CFrame`, size, and\n`CanCollide` every frame. Set the attribute and collision group *before*\nadding the tag. Central only reads them once, at the moment the tag is\nadded:\n\n```lua\n-- server\npart:SetAttribute(Settings.OWNER_ATTRIBUTE, player.Name) -- optional\npart.CollisionGroup = \"EnemyHitbox\" -- must already be registered\npart:AddTag(Settings.HITBOX_TAG)\n```\n\n- **Owner** (`Settings.OWNER_ATTRIBUTE`): the player who already sees this\n  part in the right place on their own screen (e.g. their own character).\n  Set to that player's `Name`, their own queries check it live instead of\n  rewinding it. Everyone else still gets it lag-compensated.\n- Each recorded frame is just a `CFrame`/size/`CanCollide` snapshot, not a\n  simulated body, the history has no physics of its own. The live part\n  still behaves normally in `workspace` under Roblox's own physics.\n\n### Debug hitbox visualization (server only)\n\nNo-ops unless `Settings.DEBUG_MODE` is `true`; when on, every query also\ndraws its ray/shape and hit via the vendored Bolt visualizer.\n\n```lua\nCentral.ShowHitboxes(player, owner)         -- draw owner's hitboxes at player's rewound frame, i.e. what Central resolves player's queries against\nCentral.HideHitboxes(player, owner)         -- stop that draw\nCentral.ShowAllPlayerHitboxes(player)       -- like ShowHitboxes, for every other player + \"Server\"-owned hitboxes, recomputed live\nCentral.RemoveAllPlayerHitboxes(player)     -- stop that draw\n```\n\n`owner` is a player's `Name`, or `\"Server\"` for hitboxes with no owner\nattribute. All four clean up automatically on `Players.PlayerRemoving`.\n\n## Getting synced client/server results under `BindToSimulation`\n\nFor gameplay code like shooting, bind the *same* function to\n`RunService:BindToSimulation` on both the client and server, and call\nCentral's query functions from inside it:\n\n```lua\nRunService:BindToSimulation(function(delta)\n    local origin, direction = getAimRay()\n    local distance, instance, position = Central.Raycast(player, origin, direction)\n\n    if instance and isServer then\n        applyDamage(instance)\n    end\nend, Settings.StepFrequency, Settings.HitboxStepPriority + 100)\n```\n\nBoth sides run off the same input for a step; the client sees it instantly,\nthe server after a network delay. On the client, `Central.Raycast` is a\nplain live raycast. On the server it rewinds to the frame matching the\nfiring player's own latency, so both sides usually land on the same hit\n(not always, jitter and latency variance keep it approximate).\n\n- Only apply real effects (damage, destroying a part) `if isServer`. The\n  client's call is just local feedback.\n- Widen `querySettings.frameRange` if jitter is causing disagreements.\n- A hitbox owned by the querying player is checked live instead of\n  historically, on both realms.\n- Something that moves every step (e.g. a projectile shapecast each frame)\n  should use a registered **query** group, not a hitbox group, so it\n  doesn't physically collide with hitboxes and all its hit detection goes\n  through Central.\n- Your binding's step priority must be **higher** than\n  `Settings.HitboxStepPriority`. Central records that step's hitbox frame\n  at that priority, so a lower/equal-priority binding queries last step's\n  data instead of the current one.\n\n## How Character Latency Is Measured\n\nInstead of raw network ping, Central measures perceived replication delay\ndirectly. 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*.\n\nEach cycle, the client records the predicted dummy's current angle, waits\nfor the unpredicted dummy to sweep to that same angle, and reports the time\nthat took over a `RemoteEvent`, that's how far behind the player's\nreplicated view is running, including Roblox's own interpolation buffering.\nThe 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\ninto `[0, Settings.MAX_LATENCY]`, and averages the last\n`Settings.LATENCY_SAMPLE_WINDOW` samples into `Settings.LATENCY_ATTRIBUTE`,\nthe number hitbox history gets rewound by.\n\n**The measurement is client-reported.** The server clamps and sanity-checks\nit but doesn't independently verify it, so a modified client can influence\nhow far back its own shots are rewound, bounded by `MAX_LATENCY`. Treat\n`MAX_LATENCY` as the security-relevant knob if that matters for your game.\n\n## Performance\n\nMeasured server-side in Studio: a 3×3×3 grid of anchored hitbox parts\n(7 studs apart), all moved and all 60 history frames populated every frame,\nat default settings (`FRAME_CAP = 60`, frame ranges of `1`,\n`PREFER_NEAREST_FRAME = true`). Timed against `HistoricalHitboxes` directly,\nlag-compensation cost alone; `Central.Raycast`/friends add a live\n`workspace` cast on top. Absolute numbers will move with hardware; the\nscaling behavior is the part worth trusting.\n\n**Closest-hit queries are flat in hitbox count**: 16× the hitboxes costs\nthe same, because the tree prunes to the closest hit during traversal:\n\n| hitboxes | `Raycast` | `Shapecast` | `SimpleShapecast` | Overlap (per part) |\n|---|---|---|---|---|\n| 50 | 0.94 µs | 1.35 µs | 1.23 µs | 50.3 µs (1.05 µs) |\n| 400 | 1.01 µs | 1.51 µs | 1.40 µs | 70.8 µs (1.11 µs) |\n| 800 | 1.03 µs | 1.42 µs | 1.37 µs | 70.1 µs (1.10 µs) |\n\nWhat actually moves the cost: **whether the cast connects** (400 hitboxes).\nA clean hit bounds the search so everything farther is skipped; a grazing\nshapecast never establishes that bound, so every candidate gets tested\n(~10× a direct hit):\n\n| | `Raycast` | `Shapecast` | `SimpleShapecast` |\n|---|---|---|---|\n| direct hit | 0.97 µs | 1.54 µs | 1.39 µs |\n| grazing contact | 1.28 µs | 14.65 µs | 14.20 µs |\n| empty space | 0.76 µs | 1.02 µs | 0.92 µs |\n\nAnd for overlap queries, **which shape you query with**: a hitbox is\nalways a box, so the test is box × your shape; box/sphere/capsule get a\ndedicated routine 1.5–5× faster than the GJK fallback everything else uses:\n\n| query shape | routine | per-call (overlapping/separated) |\n|---|---|---|\n| box | `box_box` | 0.14 / 0.07 µs |\n| sphere | `box_sphere` | 0.13 / 0.13 µs |\n| capsule | `box_capsule` | 0.24 / 0.19 µs |\n| cylinder, wedge, corner wedge, ellipsoid | GJK fallback | 0.5–0.8 µs |\n\n`GetPartBoundsInBox`/`GetBoundsInRadius` always stay on a dedicated routine;\n`GetPartsInPart` derives the shape from the part you hand it, so a\nCylinder/Wedge/mesh part drops to GJK, approximate with the other two on a\nhot path if the exact silhouette doesn't matter. Overlap cost overall tracks\nhow many hitboxes fall *inside* the query volume, not how many exist.\n\n**Per frame**: median of 600 timed frames, each covering `UpdateFrame` plus\nthe stated number of queries, measured in place (so it includes the cache\npressure a query pays right after `UpdateFrame` just walked the whole tree;\na query timed alone in a tight loop looks 1.5–2× cheaper than this).\n\n`UpdateFrame` runs once per simulation step regardless of querying, and is\nroughly linear in hitbox count:\n\n| hitboxes | per frame | per hitbox |\n|---|---|---|\n| 50 | 69 µs | 1.38 µs |\n| 100 | 156 µs | 1.56 µs |\n| 200 | 364 µs | 1.82 µs |\n| 400 | 778 µs | 1.95 µs |\n| 800 | 1730 µs | 2.16 µs |\n\nWhole frame with raycasts, as a share of one 60 Hz frame (16667 µs):\n\n| hitboxes | 0 casts | 1 | 5 | 10 | 25 | 50 | 100 |\n|---|---|---|---|---|---|---|---|\n| 50 | 0.4% | 0.4% | 0.5% | 0.5% | 0.6% | 0.8% | 1.3% |\n| 100 | 0.9% | 1.0% | 1.1% | 1.2% | 1.3% | 1.6% | 1.9% |\n| 200 | 2.2% | 2.3% | 2.3% | 2.3% | 2.5% | 2.8% | 3.3% |\n| 400 | 4.7% | 5.0% | 5.1% | 5.0% | 5.2% | 5.5% | 6.1% |\n| 800 | 10.4% | 10.8% | 11.6% | 11.0% | 11.7% | 11.7% | 13.2% |\n\nShapecasts land within noise of those raycast figures at every count; the\nper-cast difference is small enough that `UpdateFrame` dominates either way:\n\n| hitboxes | 0 casts | 1 | 5 | 10 | 25 | 50 | 100 |\n|---|---|---|---|---|---|---|---|\n| 50 | 0.4% | 0.4% | 0.5% | 0.5% | 0.7% | 0.9% | 1.5% |\n| 100 | 1.0% | 1.0% | 1.1% | 1.1% | 1.3% | 1.6% | 2.1% |\n| 200 | 2.1% | 2.3% | 2.3% | 2.3% | 2.5% | 2.8% | 3.4% |\n| 400 | 4.7% | 4.9% | 4.9% | 5.1% | 5.1% | 5.4% | 6.0% |\n| 800 | 11.1% | 11.1% | 11.2% | 11.7% | 11.1% | 11.9% | 12.8% |\n\nIf you need to cut cost, reduce how many parts carry `Settings.HITBOX_TAG`,\nadding query volume is comparatively cheap. (One caveat on the medians: a\nhistorical tree occasionally rebuilds (`TREE_REBUILD_CHECK_INTERVAL`) and\nthat frame spikes; rare and staggered across trees, so it shows up in the\ntail, not the median.)\n\n## Settings\n\nEvery tunable lives on the table at `lib/Settings.luau`\n(`Central.Settings`/`CentralServer.Settings` are the same table). Several\nfields are captured into locals the moment the package is first required,\nso editing `lib/Settings.luau` directly, not mutating `Central.Settings` at\nruntime, is the safe way to change a default.\n\n| Setting | Default | What it controls |\n|---|---|---|\n| `DEBUG_MODE` | `false` | Draws rays/hitboxes for every query, and gates the `Show`/`Hide`Hitboxes functions. Costs performance, leave off outside debugging. |\n| `DEBUG_LIFETIME` | `1.5` | Seconds a debug draw stays visible before clearing. |\n| `StepFrequency` | `Hz60` | How often Central's `BindToSimulation` loop records a hitbox frame and recomputes rewound indices. |\n| `HitboxStepPriority` | `1000` | Priority Central's internal binding runs at; your own query-calling bindings need a higher number. |\n| `AUTO_ADD_CHARACTERS` | `true` | Auto-tags every part of a spawning player's character as an owned hitbox. |\n| `DEFAULT_HITBOX_QUERY_GROUP` | `\"HitboxQuery\"` | Fallback query group for an unregistered `CollisionGroup`. |\n| `INITIAL_QUERY_GROUPS` | `{DEFAULT_HITBOX_QUERY_GROUP}` | Query groups auto-registered by `Central.Start()`. |\n| `DEFAULT_HITBOX_COLLISIONGROUP` | `\"HitboxCollison\"` | Fallback hitbox group for an unregistered `CollisionGroup`. |\n| `INITIAL_COLLISION_GROUPS` | `{DEFAULT_HITBOX_COLLISIONGROUP}` | Hitbox groups auto-registered by `Central.Start()`. |\n| `HITBOX_TAG` | `\"CompensatedHitbox\"` | `CollectionService` tag marking a part as lag-compensated. |\n| `OWNER_ATTRIBUTE` | `\"HitboxOwner\"` | Attribute holding a hitbox's owning player's `Name`. |\n| `LATENCY_ATTRIBUTE` | `\"PartLatency\"` | Attribute Central writes each player's averaged latency to. |\n| `FRAME_CAP` | `60` | Size of the history ring buffer; bounds how far back Central can rewind (60 @ 60 Hz ≈ 1s). |\n| `RAYCAST_FRAME_RANGE` | `1` | D","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/vbaumel1337/roblox/central/0.2.0/readme"}