Synchronizing client visual effects across players

I’m working on a Postal 2–style urination mechanic in Roblox.
The effect looks great when it’s simulated on the client, but I also want other players to see it.

Here’s where the problem starts:

  • Server-only simulation → looks bad (not smooth, laggy).
  • Client + Server mix → messy, and I don’t want to hack around hiding server effects from the local player.
  • Each client simulating locally → inconsistent results, doesn’t sync well.

So I’m stuck on how to replicate this mechanic properly.


What I’ve Tried

  • Full client simulation (works visually, but no replication).
  • Full server simulation (replicates, but looks awful).
  • Hybrid approach (server + client) → results in duplicated effects and visual glitches.
  • Client-only simulation per player → not synced between clients.

Goal

I want:

  • Local player → smooth animation (client-driven).
  • Other players → able to see the process replicated consistently.

Basically, a way to replicate beams/droplets/puddles without ruining the smoothness.


Code Example

local RS = game:GetService("RunService")
local Players = game:GetService("Players")
local Debris = game:GetService("Debris")
local TweenService = game:GetService("TweenService")

local RE = game.ReplicatedStorage.PeeEvent
local player = Players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local hrp = character:WaitForChild("HumanoidRootPart")
local mouse = player:GetMouse()

local SPRAY_SPEED = 50
local LIFETIME = 2
local RATE = 0.03
local GRAVITY = Vector3.new(0, -100, 0)

local spraying = false
local lastAttachment = nil

local rootAttachment = Instance.new("Attachment")
rootAttachment.Position = Vector3.new(0, -1.3, 0)
rootAttachment.Parent = hrp


local puddleMap = {}


local function getPuddleAtPoint(pos)
	for k, puddle in pairs(puddleMap) do
		if puddle and puddle.Parent then

			local relative = puddle.CFrame:PointToObjectSpace(pos)
			local half = puddle.Size / 2

			if math.abs(relative.X) <= half.X and math.abs(relative.Z) <= half.Z then

				return k, puddle
			end
		end
	end
	return nil, nil
end

local function spawnPuddle(hitPos, hitNormal)
	local key, puddle = getPuddleAtPoint(hitPos)

	if puddle then

		if puddle.Size.X < 6 then
			local newSize = puddle.Size + Vector3.new(0.2, 0, 0.2)
			if newSize.X > 6 then
				newSize = Vector3.new(6, puddle.Size.Y, 6)
			end
			TweenService:Create(
				puddle,
				TweenInfo.new(0.2, Enum.EasingStyle.Sine, Enum.EasingDirection.Out),
				{Size = newSize}
			):Play()
		end
	else

		local newKey = tostring(tick() .. "_" .. math.random(1000,9999))


		local puddlePart = Instance.new("Part")
		puddlePart.Size = Vector3.new(0.5, 0.01, 0.5)
		puddlePart.Anchored = true
		puddlePart.CanCollide = false
		puddlePart.Color = Color3.fromRGB(255, 220, 0)
		puddlePart.Material = Enum.Material.SmoothPlastic
		puddlePart.Transparency = 1
		puddlePart.CanTouch = false
		puddlePart.CastShadow = false
		puddlePart.CollisionGroup = "Ignore"

		local Decal = game.ReplicatedStorage.Decal:Clone()
		Decal.Parent = puddlePart

		puddlePart.CFrame = CFrame.lookAt(hitPos, hitPos + hitNormal) * CFrame.Angles(math.rad(90), 0, 0)
		puddlePart.Parent = workspace.Puddles


		puddlePart.Size = Vector3.new(0.01, 0.01, 0.01)
		TweenService:Create(
			puddlePart,
			TweenInfo.new(0.1, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
			{Size = Vector3.new(0.5, 0.01, 0.5)}
		):Play()


		local up = Vector3.new(0,1,0)
		local isVertical = hitNormal:Dot(up) < math.cos(math.rad(75))


		local extra = math.random(1, 3)
		for i = 1, extra do
			local offset
			if isVertical then

				offset = Vector3.new(0, -(i * 0.6), 0)
			else

				offset = Vector3.new(math.random(-30,30)/30, 1, math.random(-30,30)/30)
			end

			local rayParams = RaycastParams.new()
			rayParams.FilterType = Enum.RaycastFilterType.Blacklist
			rayParams.FilterDescendantsInstances = {puddlePart, character}

			local result = workspace:Raycast(
				puddlePart.Position + offset,
				isVertical and hitNormal * 0.1 or Vector3.new(0,-5,0),
				rayParams
			)

			if result then
				if not isVertical then

					if result.Normal:Dot(up) < math.cos(math.rad(60)) then
						continue
					end
				end

				local small = puddlePart:Clone()
				small.Size = Vector3.new(0.01,0.01,0.01)
				small.CFrame = CFrame.lookAt(result.Position, result.Position + result.Normal) * CFrame.Angles(math.rad(90), 0, 0)
				small.Parent = workspace.Puddles


				TweenService:Create(
					small,
					TweenInfo.new(0.15, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
					{Size = Vector3.new(0.2, 0.01, 0.2)}
				):Play()

				Debris:AddItem(small, 5)
			end
		end


		TweenService:Create(
			puddlePart,
			TweenInfo.new(3600),
			{Transparency = 1}
		):Play()

		task.delay(3601, function()
			if puddlePart and puddlePart.Parent then
				puddlePart:Destroy()
				puddleMap[newKey] = nil
			end
		end)

		puddleMap[newKey] = puddlePart
	end
end







local function spawnDroplet(direction)
	local part = Instance.new("Part")
	part.Size = Vector3.new(0.15, 0.15, 0.15)
	part.Shape = Enum.PartType.Ball
	part.Color = Color3.fromRGB(255, 220, 0)
	part.Material = Enum.Material.Neon
	part.Anchored = false
	part.CanCollide = false
	part.CanTouch = false
	part.CollisionGroup = "Ignore"
	part.Position = hrp.Position - Vector3.new(0, 1.3, 0)
	part.Transparency = 1
	part.Parent = workspace

	local dropAttachment = Instance.new("Attachment")
	dropAttachment.Parent = part

	local beam = Instance.new("Beam")
	beam.Color = ColorSequence.new(Color3.fromRGB(255, 220, 0))
	beam.Width0, beam.Width1 = 1,1
	beam.LightEmission = 1
	beam.LightInfluence = 1
	beam.Texture = "http://www.roblox.com/asset/?id=5210472205"
	beam.TextureSpeed = 0
	beam.Transparency = NumberSequence.new(0.75)
	beam.FaceCamera = true
script.Pee.Volume = 1.5
	script.Pee.Playing = true
	
	if lastAttachment then
		beam.Attachment0 = lastAttachment
		beam.Attachment1 = dropAttachment
	end
	beam.Parent = part

	lastAttachment = dropAttachment
	Debris:AddItem(part, LIFETIME)
	Debris:AddItem(beam, LIFETIME)

	local velocity = direction * SPRAY_SPEED
	local lastPos = part.Position
	RE:FireServer(hrp.Position - Vector3.new(0, 1.3, 0),direction)
	local conn
	conn = RS.RenderStepped:Connect(function(dt)
		if not part.Parent then conn:Disconnect() return end

		local newPos = lastPos + velocity * dt
		local ray = RaycastParams.new()
		ray.FilterDescendantsInstances = {character, part}
		ray.FilterType = Enum.RaycastFilterType.Blacklist

		local result = workspace:Raycast(lastPos, (newPos - lastPos), ray)
		if result and result.Instance.CollisionGroup ~= "Ignore" then
			spawnPuddle(result.Position, result.Normal)
			part:Destroy()
			conn:Disconnect()
			return
		end

		velocity += GRAVITY * dt
		part.Position = newPos
		lastPos = newPos
	end)
end


RS.RenderStepped:Connect(function()
	if spraying then
		local targetPos = mouse.Hit.p
		local dir = (targetPos - hrp.Position).Unit
		spawnDroplet(dir)
		task.wait(RATE)
	end
end)


function RandomVoiceLine()
	script.Folder:FindFirstChild("Voice line".. math.random(1,5)):Play()
end
local UIS = game:GetService("UserInputService")
local Zipped = false
UIS.InputBegan:Connect(function(INPUT)
	if INPUT.KeyCode == Enum.KeyCode.R then
		script.Zipp:Play()
		Zipped = not Zipped
		if Zipped == false then
			spraying = false 
			lastAttachment = nil 
			task.spawn(function()
				for i = 0, 15 do
					task.wait(0.01)
					script.Pee.Volume -= 0.1
				end
				script.Pee.Playing = false
				script.Pee.TimePosition = 0

			end)
		end
	end
end)
mouse.Button1Down:Connect(function() 
	if Zipped == true then
	RandomVoiceLine() 
	spraying = true 
	end
end)
mouse.Button1Up:Connect(function() 

	spraying = false 
	lastAttachment = nil 
task.spawn(function()
	for i = 0, 15 do
		task.wait(0.01)
		script.Pee.Volume -= 0.1
	end
		script.Pee.Playing = false
	script.Pee.TimePosition = 0

end)
end)

1 Like

Instead of using math.random() for getting random values, you should use Random.new() with a seed to recreate the same state on all the clients.

On the server.

local seed = DateTime.now().UnixTimestamp -- This can be any number, i'm using Unix Timestamp as an example

effectsRemote:FireAllClients({
    Seed = seed, -- Send seed to all clients
    Data = ... -- Send other info u wanna send
})

On the client

effectsRemote.OnClientEvent:Connect(function(args: {[string]: any})
    local randomInstance = Random.new(args.Seed)
    local randomNumber = randomInstance:NextNumber(0, 100) -- Should produce the same results on all clients
end)
1 Like

Thanks, but I already found a solution by myself. Here is the code in case someone else needs it, lol.

local RS = game:GetService("RunService")
local Players = game:GetService("Players")
local Debris = game:GetService("Debris")
local TweenService = game:GetService("TweenService")

local RE = game.ReplicatedStorage.PeeEvent
local player = Players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local hrp = character:WaitForChild("HumanoidRootPart")
local mouse = player:GetMouse()

local SPRAY_SPEED = 50
local LIFETIME = 2
local RATE = 0.03
local GRAVITY = Vector3.new(0, -100, 0)

local spraying = false


local rootAttachment = Instance.new("Attachment")
rootAttachment.Position = Vector3.new(0, -1.3, 0)
rootAttachment.Parent = hrp

local activeStreams = {}
local puddleMap = {}

local lastAttachments = {}
local function getPuddleAtPoint(pos)
	for k, puddle in pairs(puddleMap) do
		if puddle and puddle.Parent then

			local relative = puddle.CFrame:PointToObjectSpace(pos)
			local half = puddle.Size / 2

			if math.abs(relative.X) <= half.X and math.abs(relative.Z) <= half.Z then

				return k, puddle
			end
		end
	end
	return nil, nil
end

local function spawnPuddle(hitPos, hitNormal)
	local key, puddle = getPuddleAtPoint(hitPos)

	if puddle then

		if puddle.Size.X < 6 then
			local newSize = puddle.Size + Vector3.new(0.2, 0, 0.2)
			if newSize.X > 6 then
				newSize = Vector3.new(6, puddle.Size.Y, 6)
			end
			TweenService:Create(
				puddle,
				TweenInfo.new(0.2, Enum.EasingStyle.Sine, Enum.EasingDirection.Out),
				{Size = newSize}
			):Play()
		end
	else

		local newKey = tostring(tick() .. "_" .. math.random(1000,9999))


		local puddlePart = Instance.new("Part")
		puddlePart.Size = Vector3.new(0.5, 0.01, 0.5)
		puddlePart.Anchored = true
		puddlePart.CanCollide = false
		puddlePart.Color = Color3.fromRGB(255, 220, 0)
		puddlePart.Material = Enum.Material.SmoothPlastic
		puddlePart.Transparency = 1
		puddlePart.CanTouch = false
		puddlePart.CastShadow = false
		puddlePart.CollisionGroup = "Ignore"

		local Decal = game.ReplicatedStorage.Decal:Clone()
		Decal.Parent = puddlePart

		puddlePart.CFrame = CFrame.lookAt(hitPos, hitPos + hitNormal) * CFrame.Angles(math.rad(90), 0, 0)
		puddlePart.Parent = workspace.Puddles


		puddlePart.Size = Vector3.new(0.01, 0.01, 0.01)
		TweenService:Create(
			puddlePart,
			TweenInfo.new(0.1, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
			{Size = Vector3.new(0.5, 0.01, 0.5)}
		):Play()


		local up = Vector3.new(0,1,0)
		local isVertical = hitNormal:Dot(up) < math.cos(math.rad(75))


		local extra = math.random(1, 3)
		for i = 1, extra do
			local offset
			if isVertical then

				offset = Vector3.new(0, -(i * 0.6), 0)
			else

				offset = Vector3.new(math.random(-30,30)/30, 1, math.random(-30,30)/30)
			end

			local rayParams = RaycastParams.new()
			rayParams.FilterType = Enum.RaycastFilterType.Blacklist
			rayParams.FilterDescendantsInstances = {puddlePart, character}

			local result = workspace:Raycast(
				puddlePart.Position + offset,
				isVertical and hitNormal * 0.1 or Vector3.new(0,-5,0),
				rayParams
			)

			if result then
				if not isVertical then

					if result.Normal:Dot(up) < math.cos(math.rad(60)) then
						continue
					end
				end

				local small = puddlePart:Clone()
				small.Size = Vector3.new(0.01,0.01,0.01)
				small.CFrame = CFrame.lookAt(result.Position, result.Position + result.Normal) * CFrame.Angles(math.rad(90), 0, 0)
				small.Parent = workspace.Puddles


				TweenService:Create(
					small,
					TweenInfo.new(0.15, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
					{Size = Vector3.new(0.2, 0.01, 0.2)}
				):Play()

				Debris:AddItem(small, 5)
			end
		end


		TweenService:Create(
			puddlePart,
			TweenInfo.new(3600),
			{Transparency = 1}
		):Play()

		task.delay(3601, function()
			if puddlePart and puddlePart.Parent then
				puddlePart:Destroy()
				puddleMap[newKey] = nil
			end
		end)

		puddleMap[newKey] = puddlePart
	end
end







local function spawnDroplet(direction, rootpart)
	local part = Instance.new("Part")
	part.Size = Vector3.new(0.15, 0.15, 0.15)
	part.Shape = Enum.PartType.Ball
	part.Color = Color3.fromRGB(255, 220, 0)
	part.Material = Enum.Material.Neon
	part.Anchored = false
	part.CanCollide = false
	part.CanTouch = false
	part.CollisionGroup = "Ignore"
	part.Position = rootpart.Position - Vector3.new(0, 1.3, 0)
	part.Transparency = 1
	part.Parent = workspace

	local dropAttachment = Instance.new("Attachment")
	dropAttachment.Parent = part

	local beam = Instance.new("Beam")
	beam.Color = ColorSequence.new(Color3.fromRGB(255, 220, 0))
	beam.Width0, beam.Width1 = 1, 1
	beam.LightEmission = 1
	beam.LightInfluence = 1
	beam.Texture = "http://www.roblox.com/asset/?id=5210472205"
	beam.TextureSpeed = 0
	beam.Transparency = NumberSequence.new(0.75)
	beam.FaceCamera = true

	if lastAttachments[rootpart] then
		beam.Attachment0 = lastAttachments[rootpart]
		beam.Attachment1 = dropAttachment
	end

	beam.Parent = part

	lastAttachments[rootpart] = dropAttachment
	Debris:AddItem(part, LIFETIME)
	Debris:AddItem(beam, LIFETIME)


	local velocity = direction * SPRAY_SPEED
	local lastPos = part.Position

	local conn
	conn = RS.RenderStepped:Connect(function(dt)
		if not part.Parent then conn:Disconnect() return end

		local newPos = lastPos + velocity * dt
		local ray = RaycastParams.new()
		ray.FilterDescendantsInstances = {rootpart.Parent, part}
		ray.FilterType = Enum.RaycastFilterType.Blacklist

		local result = workspace:Raycast(lastPos, (newPos - lastPos), ray)
		if result and result.Instance.CollisionGroup ~= "Ignore" then
			spawnPuddle(result.Position, result.Normal)
			part:Destroy()
			conn:Disconnect()
			return
		end

		velocity += GRAVITY * dt
		part.Position = newPos
		lastPos = newPos
	end)
end


RS.RenderStepped:Connect(function()
	if spraying then
		local targetPos = mouse.Hit.p
		local dir = (targetPos - hrp.Position).Unit
		RE:FireServer(hrp, targetPos)
		spawnDroplet(dir, hrp)
		task.wait(RATE)
	end
end)
local UIS = game:GetService("UserInputService")
UIS.InputBegan:Connect(function(INPUT)
	if INPUT.KeyCode == Enum.KeyCode.R then
		Zipped = not Zipped
		if not Zipped then
			spraying = false
			lastAttachments[hrp] = nil
			task.spawn(function()
				for i = 0, 15 do
					task.wait(0.01)
					script.Pee.Volume -= 0.1
				end
				script.Pee.Playing = false
				script.Pee.TimePosition = 0
			end)
		end
	end
end)
local function RandomVoiceLine()
	local Sounds = script.Folder:GetChildren()
	Sounds[math.random(1,5)]:Play()
end
mouse.Button1Down:Connect(function()
	script.Pee.Playing = true
	script.Pee.TimePosition = 0
	script.Pee.Volume = 1.5
	if Zipped then
		RandomVoiceLine()
		spraying = true
	end
end)

mouse.Button1Up:Connect(function()
	spraying = false
	
	lastAttachments[hrp] = nil
	RE:FireServer(hrp, nil)
	task.spawn(function()
		for i = 0, 15 do
			task.wait(0.01)
			script.Pee.Volume -= 0.1
		end

		script.Pee.Playing = false
		script.Pee.TimePosition = 0
	end)
end)


RE.OnClientEvent:Connect(function(RootPart, Direction)
	if not RootPart then return end

	if Direction then

		if activeStreams[RootPart] then
			activeStreams[RootPart]:Disconnect()
		end

		activeStreams[RootPart] = RS.RenderStepped:Connect(function(dt)
			if not RootPart.Parent then
				activeStreams[RootPart]:Disconnect()
				activeStreams[RootPart] = nil
				return
			end
			spawnDroplet(Direction, RootPart)
			task.wait(RATE)
		end)
	else

		if activeStreams[RootPart] then
			activeStreams[RootPart]:Disconnect()
			activeStreams[RootPart] = nil
		end
	end
end)
1 Like

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.