← All projects
Physics Sandbox

Fling Things and People

A replica of a physics sandbox where you pick up players and objects and throw them, built from nothing in two days as a trial piece, and built as though somebody would have to maintain it.

Challenge

Replicate the hard parts of a physics sandbox, grab anything, throw anyone, land badly, in two days, without the result being a demo that falls apart the moment two players touch the same crate.

Solution

Decide once where every piece of authority lives. The client simulates the hold because it is the only machine with no latency to the player's aim; the server owns the claim, the launch and the recovery, because those are the parts another player can be hurt by.

  • Roblox
  • Luau
  • Physics
  • Network Ownership
  • UI
A crate held in the air on the end of a yellow grab beam, seen from behind the player
A ragdolled Roblox character mid-flight above a road, the grab beam still trailing behind them
A blurred first-person view mid-fling, with NPC chat lines reading 'Let me go!' and 'Take this!'
The in-game inventory panel showing toy thumbnails in a grid, a spawn preview and Spawn and Clear Toys buttons
The whole thing running, grab, throw, ragdoll, toys, NPCs (sped up)1/5
Fling Things and People media

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.

A grab splits across two machines. The grabbing client raycasts, welds and drags the target every frame. The server claims the assembly, hands physics ownership to that client, replicates the beam to everyone else, and, for a thrown player, applies the launch itself before handing ownership to the victim. GRABBING CLIENT Aim raywhat is under the cursor Weld + dragAlignPosition, per frame asks ownership granted, only now can the client move it SERVER, AUTHORITATIVE Reach check Claim assembly Ragdoll victim Hand ownership Replicate beam a throw re-enters here, the launch is applied by whoever owns the rig, which is never the thrower
The client is trusted with how the hold feels. It is trusted with nothing else.

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:

v  =  clamp ⁣(750m+15,  50,  100)v \;=\; \mathrm{clamp}\!\left(\frac{750}{m} + 15,\; 50,\; 100\right)

with mm 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:

trecover  =  clamp(1.0+0.035vpeak,  0.8,  5.0)t_{\text{recover}} \;=\; \mathrm{clamp}\bigl(1.0 + 0.035\,v_{\text{peak}},\; 0.8,\; 5.0\bigr)

where vpeakv_{\text{peak}} 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

LibraryWhat it does here
ProfileStoreSession-locked player data behind quests, dailies, codes, stats and the economy
SeamReactive state for the Main UI, the one store every panel reads
ProgressionUtilsQuest progression maths
CmdrAdmin console, allow-listed, with six custom commands
Leg Controller V1.2Motor6D 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.

What it shipped

  • Grab and throw for any unanchored assembly, crates, toys, players, NPCs, through one pipeline
  • Server-applied launches, because whoever owns a part decides its velocity
  • Ragdoll recovery timed from a measured landing rather than a fixed wait
  • Air control unlocked only above a real launch speed, so a toss is not a glider
  • Bots that grab and throw through a 100-line adapter, with nothing edited inside the NPC kit
  • ProfileStore persistence behind quests, dailies, codes, stats and an economy
  • One client store feeding seven UI panels, none of which read each other
  • Cmdr admin console behind an allow-list, with six custom commands
  • Two days, start to finish

More projects