Help with smooth tumbling and rolling projectile physics

I’ve been trying to make smooth grenades for a while and I’ve run into a bit of a problem. The two main methods I have found from my research is setting the assemblylinearvelocitay or using applyimpulse() or use math to move the projectile. However these both have issues that I will explain down below

AssemblyLinearVelocity and ApplyImpulse() will look unsmooth if I don’t set the network owner to the client, and if it do, it will look unsmooth for other clients and open up the possibility for exploitation. I also cannot fire all clients to simulate grenade because I still do need the final position on server and that final position might be really off from the clients reported final position.

Using math and kinematics looks promising, however I am completely clueless on how to do the tumbling and rolling part. What I have done in the past is to use a ball and socket constraint for and invisible projectile and linking it to a cosmetic projectile to simulate tumbling and rolling, however when it lands, it does this sort of snapping because the ball and socket constraint may not be rotated in such a way that makes the projectile roll or otherwise rest on the ground.

Any help would be appreciated, thanks!

1 Like

Hey! You’re basically running into the class tradeoff between the server authority vs smooth physics and yeah all the issues you mentioned are real. What you could possibly do is :

Make the server handle physics
Client handle visuals

Instead of trying to make the actual physics object look perfect, you separate it into Server Projectile and Client Cosmetic

  1. Server = Physics Only (no visuals)
  • Use ApplyImpulse() / AssemblyLinearVelocity
  • Keep the network ownership on server
grenade:SetNetworkOwner(nil) -- server owns it
grenade:ApplyImpulse(direction * force)
  1. Client = Smooth Visual
  • Create a visual grenade
  • Continuously lerp to server grenade position
  • Add angular velocity for tumbling
RunService.RenderStepped:Connect(function(dt)
    local targetCF = serverGrenade.CFrame
    
 
    visualGrenade.CFrame = visualGrenade.CFrame:Lerp(targetCF, 0.2)
    
    visualGrenade.CFrame *= CFrame.Angles(
        math.rad(720 * dt),
        math.rad(360 * dt),
        math.rad(180 * dt)
    )
end)

Your constraint method snaps cus the orientation isn’t aligned with velocity or surface normal

So instead when the velocity is low

if velocity.Magnitude < 2 then

    local normal = hit.Normal
    visualGrenade.CFrame = CFrame.fromMatrix(
        position,
        normal:Cross(Vector3.new(0,1,0)),
        normal
    )
end

Lemme know if it helped or fixed your issue.

Heya, I want to recommend you Vetra, It handles tumbling aswell, full 6DOF if thats what you want!

I did a bit of reading into Vetra, and its an amazing module that fits my needs, but I am a bit confused on how to do the cosmetic client replication, if you know more about this module, I would love to know.

Hello, For bullet replication! This is how I do it.

Shared
-- BounceGunBehaviors (ModuleScript)
-- Place in ReplicatedStorage so both server and client can require it.
--
-- Both sides MUST require this and call Register() in identical order.
-- VetraNet transmits only the 2-byte hash — if the order diverges, every
-- fire request will be rejected as RejectedUnknownBehavior.

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Vetra            = require(ReplicatedStorage.Vetra)
local BehaviorRegistry = require(ReplicatedStorage.Vetra.VetraNet.Transport.BehaviorRegistry)

-- ─── Shared RaycastParams ────────────────────────────────────────────────────
-- No FilterDescendantsInstances here — the server and each client fill in
-- their own character / bullet container at fire time via the Behavior's
-- RaycastParams field. Leave it open so the registry entry is reusable.

local RaycastFilter = RaycastParams.new()
RaycastFilter.FilterType = Enum.RaycastFilterType.Exclude

-- ─── Behavior Definitions ────────────────────────────────────────────────────

local BounceBehavior = {
	MaxDistance             = 6000,
	MaxSpeed                = 500,      -- required by BehaviorRegistry for speed validation
	MaxBounces              = 30,
	Restitution             = 0.98,

	Gravity                 = Vector3.new(0, -20, 0),
	RaycastParams           = RaycastFilter,
	HighFidelitySegmentSize = 0,
	
	CanBounceFunction       = function() return true end,
}

-- ─── Registry ────────────────────────────────────────────────────────────────

local Registry = BehaviorRegistry.new()
Registry:Register("BounceGun", BounceBehavior)

return Registry
Client 
-- ─── Vetra ───────────────────────────────────────────────────────────────────
local Vetra    = require(ReplicatedStorage.Vetra)
local VetraNet = Vetra.VetraNet

-- ─── Shared BehaviorRegistry ─────────────────────────────────────────────────
-- Must register behaviors in the exact same order as the server.
local Registry = require(ReplicatedStorage.BounceGunBehaviors)

local ClientSolver = Vetra.newParallel({ ShardCount = 4 })

local Net = VetraNet.new(ClientSolver, Registry)

-- Inject the cosmetic provider into the registered behavior so VetraNet
-- uses it when spawning local cosmetics.
local BounceBehavior                   = Registry:Get(Registry:GetHash("BounceGun"))
BounceBehavior.CosmeticBulletProvider  = createCosmeticBullet
BounceBehavior.CosmeticBulletContainer = BulletContainer

-- This fire! and replicate to all :)
Net:Fire(origin, direction.Unit, CONFIG.BulletSpeed, "BounceGun")
Server
-- ─── Services ────────────────────────────────────────────────────────────────
local ReplicatedStorage = game:GetService("ReplicatedStorage")

-- ─── Vetra ───────────────────────────────────────────────────────────────────
local Vetra    = require(ReplicatedStorage.Vetra)
local VetraNet = Vetra.VetraNet

-- ─── Shared BehaviorRegistry ─────────────────────────────────────────────────
-- Must be required BEFORE VetraNet.Server.new() so behaviors are registered
-- before the first fire request can arrive from a client.
local Registry = require(ReplicatedStorage.BounceGunBehaviors)

-- ─── Parallel Solver ─────────────────────────────────────────────────────────
-- newParallel distributes bullet simulation across Roblox Actors.
-- ShardCount = 4 is a sensible default; tune upward if you have many
-- concurrent bullets (>200) and your server has more cores available.
-- Falls back to serial automatically if Actor construction fails.
local Solver = Vetra.newParallel({
	ShardCount = 4,
	ActorParent = script.Parent
})

-- ─── Server Network ───────────────────────────────────────────────────────────
local Net = VetraNet.new(Solver, Registry, {
	MaxOriginTolerance     = 20,   -- studs; bounce gun fires forward so be generous
	MaxConcurrentPerPlayer = 30,   -- 30 bullets in flight per player
	TokensPerSecond        = 8,    -- sustained fire rate (shots/sec)
	BurstLimit             = 15,   -- allows initial burst before throttling
	ReplicateState         = true, -- send per-frame position updates to clients
})

-- ─── Hit Handler ─────────────────────────────────────────────────────────────
-- OnValidatedHit fires only after the server confirms the hit is legitimate.
-- Apply damage, sound effects, etc. here.
Net.OnValidatedHit:Connect(function(Player, Context, Result, Velocity, _Force)
	if not Result or not Result.Instance then return end

	local hitPart = Result.Instance
	local model   = hitPart:FindFirstAncestorOfClass("Model")
	local hum     = model and model:FindFirstChildOfClass("Humanoid")
	if hum and hum.Health > 0 then
		-- Scale damage with remaining speed — faster bullet = more damage
		local speed  = Velocity.Magnitude
		local damage = math.clamp(speed * 0.1, 5, 40)
		hum:TakeDamage(damage)
	end
end)

Do note that the hash order must be same, so you can sort them first.
But the behavior between client and server doesnt have to be same, this means that client cosmetic can be different with server.
Depends on you!

1 Like

Alright I tried replicating it and it looks incredibly smooth, but I’m still wondering about how to do the tumbling mid flight, the roll once its on the ground, and how to disable the deletion upon reaching low enough speed.
https://gyazo.com/9306bce7e3eaa683a67d3b75e504b57e