Two days
The brief was a trial: build something new, now, showing the hard systems from a
physics sandbox where
players pick each other up and throw each other. Unpaid,
and judged on whether it looked like a game rather than a script.
Two days is not enough time to be clever. It is enough time to be careful about exactly one thing, and the thing worth being careful about here is authority, who is allowed to decide what, and what happens when two machines disagree. Everything else on this page follows from that one decision.
Where authority lives
A grab looks like one action and is really three, each of which wants to live somewhere different.
The hold belongs to the grabbing
client. It is the only machine with no
latency between the
player’s aim and the held object, and a hold that lags
behind the crosshair feels broken no matter how correct it is. So the client
welds to the target and drags it with an AlignPosition.
For that to move anything, the client must own the target’s physics assembly,
and the claim on that ownership belongs to the
server, because two players
grabbing one crate is a dispute, and disputes are not settled on a client.
The launch belongs to the server too, for a reason that took a rewrite to learn: whoever owns a part decides its velocity. A throw applied on the grabber’s machine is silently overwritten the instant ownership moves back to the victim. The client can ask; only the owner can throw.
Reach, re-grab cooldown, hold distance and the theft rule are all decided on the server. Grabbing something somebody else is holding is legal, whoever grabbed last wins, and the previous holder’s client is told to drop its beam, so two beams never fight over one assembly.
Two things that only physics teaches you
A ragdolled character is not one assembly. Every limb simulates independently, so handing over “the character” means walking it part by part. Handing over the root alone produces a target whose torso follows the beam while its arms lag a frame behind:
-- A ragdolled rig is no longer one assembly -- every limb simulates on its own -- so
-- ownership has to be handed over part by part rather than through the root alone.
local function giveCharacterOwnership(character, player)
for _, part in ipairs(character:GetDescendants()) do
if part:IsA("BasePart") and not part.Anchored then
-- Ask first rather than pcalling blind: a refusal here means the grabber cannot
-- move the victim at all, and that is worth seeing in the log instead of
-- silently producing a target that hangs in mid-air.
local canSet, reason = part:CanSetNetworkOwnership()
if not canSet then
warn(("GrabService: cannot set network owner of %s (%s)"):format(part:GetFullName(), tostring(reason)))
elseif player then
part:SetNetworkOwner(player)
else
part:SetNetworkOwnershipAuto()
end
end
end
end
And ownership is not a thing you set once. Grabbing a walking NPC worked for about a second and then went dead in the player’s hands, because the pathfinder inside the NPC kit re-asserts server ownership on a heartbeat while a bot is moving. The fix is not to change the kit, it is to notice, and take it back:
-- Hold on to physics ownership for as long as the grab lasts.
--
-- Handing it over once at grab time is not enough: the SmartNPCs pathfinder re-asserts
-- server ownership on a heartbeat while a bot is walking, so a bot that was mid-route
-- when it got picked up took its own body back within a second and the grab went dead in
-- the player's hands. This puts it back, every pass, and says so.
task.spawn(function()
while true do
task.wait(0.2)
for player, grab in pairs(activeGrabs) do
local character = grab.victimCharacter
if character and character.Parent and not RagdollService.IsFlinging(character) then
local root = character:FindFirstChild("HumanoidRootPart") or character.PrimaryPart
local ok, owner = pcall(function()
return root and root:GetNetworkOwner()
end)
if ok and owner ~= player then
giveCharacterOwnership(character, player)
end
end
end
end
end)
Throw strength is mass-relative, so a crate and a person of the same weight leave your hands at the same speed:
with the summed mass of every unanchored part. The clamp is what stops a one-stud pebble from breaking the sound barrier and a piano from refusing to move.
One owner for the ragdoll
Three things put a character on the floor, being held, being dropped, being
thrown, and all three want a different recovery. Rather than three systems
each setting a Ragdoll attribute, there is one service with three entry points
and a rule that nothing else writes that attribute.
The interesting part is when you get up. A fixed timer is wrong in both directions: a long fling ends mid-air, a short one leaves you lying on the floor for no reason. So recovery is measured, not assumed. The character is watched until it is genuinely at rest on something, and only then does the clock start:
where is the fastest the assembly actually travelled, not the
speed it was launched at, a throw into a wall and a throw across the
map are
different landings and should read as different landings.
Landing is AssemblyLinearVelocity under 12 studs/s with ground within 4.5
studs, held for a quarter second so a bounce does not end the ragdoll early,
with a 0.35 s grace at the start (you are still standing on the floor for the
first frames of a throw) and a 20 s ceiling for landing somewhere the ground
check cannot see, deep water, a moving platform, the void under the map.
The launch itself is the part that had to move to the server:
-- The launch is applied here rather than by the thrower's client because whoever owns a
-- part decides its velocity: a client-side throw is simply overwritten the moment physics
-- ownership moves. So the server claims the rig, launches it, and hands it to the victim a
-- moment later -- late enough for the throw to stick, early enough to steer the arc.
function RagdollService.Fling(character, direction, launchSpeed)
local generation = beginRagdoll(character, "Fling")
local state = getState(character)
state.launchSpeed = launchSpeed
setAirControl(character, launchSpeed)
local velocity = direction.Unit * launchSpeed
setCharacterOwnership(character, nil)
for _, part in ipairs(character:GetDescendants()) do
if part:IsA("BasePart") and not part.Anchored then
part.AssemblyLinearVelocity = velocity
end
end
local victim = Players:GetPlayerFromCharacter(character)
if victim then
task.delay(Config.LAUNCH_OWNERSHIP_HANDOVER, function()
if state.generation == generation and character.Parent and victim.Parent then
setCharacterOwnership(character, victim)
end
end)
end
...
end
LAUNCH_OWNERSHIP_HANDOVER is 0.15 s: long enough for the velocity to take,
short enough that the victim’s own steering responds without a round trip.
generation is the small thing that keeps all of this honest. Every ragdoll
bumps a counter, and every landing watcher captures the value it started with.
Re-grabbed mid-flight, killed, respawned, the old watcher sees a number that no
longer matches and returns, instead of standing a character up out of the grab
it is currently in.
Being flung is also the only way to steer. Air control unlocks above a launch speed of 45 studs/s, which is the difference between “somebody tossed you” and “somebody threw you”: a gentle toss gives you nothing, a real fling gives you 55 studs/s² of sideways acceleration against a gravity of 100, enough to change where you land, not enough to fly. It fades as the flight ends, so the landing still reads as a crash rather than a controlled stop. Above 70 studs/s the impact takes the screen with it.
Movement is one owner too
The same rule, one layer up. Sprint used to be set by the sprint script and reset to base speed by the crouch script on the very next frame, so holding Shift did nothing at all. That is not a bug you fix with a flag; it is a bug you fix by deciding who owns the number.
-- The single owner of this character's WalkSpeed and JumpPower.
--
-- That sole ownership is the point. Sprinting used to be set here and reset to the base
-- speed by BobbingAndCrouch on the very next PreRender, so holding Shift did nothing at
-- all. Crouch now publishes the "Crouching" attribute and this script decides the number.
local wantSprint = sprintHeld and moving and grounded and not crouching and not Humanoid.Sit
local rampTime = if wantSprint then RAMP_UP_TIME else RAMP_DOWN_TIME
ramp = math.clamp(ramp + (if wantSprint then delta / rampTime else -delta / rampTime), 0, 1)
local targetSpeed = if crouching then CROUCH_SPEED else BASE_SPEED + (SPRINT_SPEED - BASE_SPEED) * ramp
Crouch publishes an attribute and gets out of the way. Sprint is a ramp rather than a toggle, 0.9 s up, 0.35 s down, because losing speed should feel immediate and gaining it should not, and the field of view and the speed lines ride the same scalar, so the screen only reacts once you are genuinely moving. Below a third of the way up the ramp the effects are not drawn at all.
Around that: head bob, a first-person body, leg IK through a Motor6D leg controller, free-look on a held key, seat handling, and impact sounds on any part that hits something hard enough to deserve one.
Teaching the NPCs to grab
The place also runs my Smart NPCs kit, and the bots needed to join in, grab players, throw them, react to being grabbed themselves.
A bot has no client. The entire player-side grab, weld a part, drag it with an
AlignPosition, replicate the beam, assumes a machine that is holding the
crosshair, and a bot does not have one. So the bot grab is the server-side
equivalent, and it says so:
--[[
BotGrabAction
The player-facing grab is simulated on the grabbing CLIENT: it welds a part to the
target and drags it with an AlignPosition, and the server only hands over physics
ownership and replicates the beam. A bot has no client, so none of that machinery
applies. This is the server-side equivalent: ragdoll the victim, hold them off the
bot's hand with an AlignPosition the server owns, then hand them to RagdollService
to be flung. The visible result is the same, the authority is not.
]]
-- How far a bot can reach, measured root to root. Deliberately shorter than a player's
-- 20-stud beam: a bot has to actually walk up to you.
local MAX_REACH = 12
Everything downstream of the hold is shared. A thrown bot and a thrown player
both go through RagdollService.Fling, so recovery, air control and the impact
effect are defined once.
The adapter is the whole integration
The kit’s seam for “an action a player could also perform” is
BotActorActionRouter: register a capability, and the language model driving
the bots is automatically told it exists and may name it. The entire
fling-specific integration is one file that registers three of them.
-- A name from the model is untrusted text. It only ever selects an existing character;
-- it never becomes an instance path or a position.
local function resolveTarget(name: any): Model?
if type(name) ~= "string" or name == "" then
return nil
end
local player = Players:FindFirstChild(name)
if player and player:IsA("Player") then
return player.Character
end
if director and director.GetBots then
for _, bot in ipairs(director.GetBots()) do
if bot.Name == name then
return bot
end
end
end
return nil
end
router.Register("GrabTarget", {
aliases = { "grab", "pickup", "pick up", "grabtarget", "snatch" },
description = "pick up a named player or NPC and throw them a moment later",
execute = function(actor: Model, entry: { [string]: any }): (boolean, string)
local target = resolveTarget(entry.target or entry.n or entry.t)
if not target then
return false, "no such live target to grab"
end
return BotGrabAction.Grab(actor, target)
end,
})
Three registrations, GrabTarget, ThrowTarget, DropTarget, and the bots
can be asked, in plain language, to pick somebody up. Nothing inside the kit was
edited to make that work, which is the property worth protecting: it lives in
the game’s folder, not the kit’s, so a place with no grab system simply never
registers them and the model is told they do not exist.
The reverse direction is one line in the grab service. A grabbed bot has attributes set on its rig, which its own behaviour tree already watches, so being picked up becomes something a bot can react to, struggle, complain, and remember who did it, without the grab system knowing anything about behaviour.
Two days of that integration, in total, was: one action
module, one registry
file, and one bug where the tag being watched was the kit’s source-file tag
rather than the tag a live rig carries, so the reaction branch never ran.
How it is organised
Grouped by responsibility, not by type. Hover a folder to see what it owns.
ServerScriptService
Systems
MainBootstrap
Grab
GrabService
GrabControl
GrabProbe
BotGrabAction
Character
RagdollService
OutOfBoundsService
RigMotor6DService
Toys
ToyService
ToyCatalog
ToyModels
ToySpace
Data
PlayerData
ProfileTemplate
MainSlice
GameTypes
Net
MainNet
RateLimiter
Economy
Economy
EconomyRules
Quests
QuestService
QuestRules
QuestPool
Daily
Codes
Stats
Admin
Settings
Lifecycle
Bots
BotGrabActionRegistry
BotGrabBehavior
BotStandaloneBootstrap
StarterPlayer
StarterCharacterScripts
Grab
GrabInputController
GrabbedStateController
Ragdoll
RagdollController
RagdollAirControl
Character
MovementController
BobbingAndCrouch
FirstPersonBody
StopVelocity
StarterPlayerScripts
Grab
GrabBeamAndAim
AimRaycaster
Character
LegIKClient
HRPSounds
RagdollScreenEffect
Two rules do most of the work here.
Rules are pure, services are not. QuestRules, EconomyRules,
DailyRules, StatsRules, CodeRules, ToySpace and RateLimiter are
arithmetic with no reads and no state, time is a parameter rather than a call
to os.clock, so a caller cannot disagree with them about the clock. The
service next door does the reading, the writing and the announcing.
--[[
RateLimiter -- pure throttling arithmetic. Time is a parameter so it is testable
and callers cannot disagree about the clock. Knows nothing about players.
]]
function RateLimiter.New(intervalSeconds: number): Limiter
local lastCall: { [any]: number } = {}
local function Allow(key: any, now: number?): boolean
local timestamp = now or os.clock()
local previous = lastCall[key]
if previous ~= nil and timestamp - previous < intervalSeconds then
return false
end
lastCall[key] = timestamp
return true
end
return { Allow = Allow }
end
A module says what it does not do. Every header on the server says which
neighbour owns the thing it deliberately did not do, MainNet “contains no
game rules: it never decides whether a purchase is legal, only who may ask and
how often”; ToyService “never moves coins itself (Economy does) and never
reads the catalog directly”. That sentence is worth more than the description of
what the module does, because it is the one a future change is about to
violate.
Placement carries the same intent. A controller that must die with the character
lives in StarterCharacterScripts; one that must survive a respawn lives in
StarterPlayerScripts. Nothing is in either folder by accident.
What it costs to run
The grab loop is hot, so the
modules in it are compiled accordingly, --!native
and --!optimize 2 on the character, grab, ragdoll and UI modules, and typed
throughout, which is what makes native compilation worth anything.
Past that, the optimisations are mostly about not doing work:
The inventory reconciles rather than rebuilds. It renders 220 rows, and a state change touches only the ones that changed:
--[[
ListRenderer -- a keyed reconciler for template-cloned rows.
Creates rows for new keys, REUSES rows for surviving keys, destroys rows for keys
that vanished. Reuse is the whole point: the inventory renders 220 rows and must
not rebuild them on every state change.
Owns no state beyond its row map, knows nothing about what a row means, never
reads MainStore.
]]
One store, seven panels. MainStore holds Seam state hydrated from a single
server slice and exposes the action calls. Every panel reads from it and no panel
reads from another, so opening the store cannot desynchronise the inventory. It
also decides nothing: whether a purchase is allowed is always the server’s
answer, never the client’s guess.
Remotes are throttled centrally. Every Main UI remote goes through MainNet,
which rate-limits per player per call name at four calls a second and answers
"TooFast" rather than dropping silently. The client is told the status and
nothing else.
Spawned toys have a budget in points, not a count. A player gets 100 points of world presence; a campfire costs more than a die. It is the cheapest possible answer to “somebody spawned four hundred crates”, and it is nine lines of pure arithmetic with the counting done elsewhere.
Housekeeping runs on its own clock. Ownership reclaim at 0.2 s, stale-grab
sweeps and bot-hold expiry at 0.25 s. None of it belongs on RenderStepped, and
putting it there is how a physics sandbox quietly becomes a slideshow.
Effects are gated on the thing that causes them. Speed lines and the field of view shift are computed from the sprint ramp and skipped below a third of it; the ragdoll screen effect only fires above 70 studs/s of impact. There is also a low-quality mode that strips them wholesale.
What it is built on
| Library | What it does here |
|---|---|
| ProfileStore | Session-locked player data behind quests, dailies, codes, stats and the economy |
| Seam | Reactive state for the Main UI, the one store every panel reads |
| ProgressionUtils | Quest progression maths |
| Cmdr | Admin console, allow-listed, with six custom commands |
| Leg Controller V1.2 | Motor6D leg IK, on players and bots alike |
The NPC layer is my own Smart NPCs kit, dropped in unmodified. Everything else, grab, ragdoll, movement, toys, economy, quests, dailies, codes, stats, settings, admin commands, the UI stack, is written for this place.
What it is not
Two days is two days. The art is placeholder, the map is a toolbox town, and the monetisation is gamepass hooks rather than a shop worth the name. The bots are carrying an NPC system built long before this trial existed, and it would be dishonest to count that as part of the two days.
What the two days bought is the part that would otherwise have to be unpicked later: one place where physics authority is decided, one owner for the ragdoll, one owner for movement, rules that are pure and services that are not, and an NPC integration that touches nothing it does not own.







