Best approach to code slow projectiles?

Im trying to code a tool that is a “Dead Rat” and when activated it casts a projectile - the dead rat - at a target.

image

Usually what i do for fast projectiles is making it all client based, passing only the origin and direction to the other clients for them to recreate the projectile themselves, in this case though the projectile - a big rat - move slower and im having trouble to make it look nice on the other clients.

I tried many things like:

  • Having a projectile preloaded on server with network ownership given to the plr
  • Full server projectile
  • Full client projectile

and honestly each one of my tries shows a big problem.

What do you guys think its the better way for handling this situation?

What exactly has been your issue with the “full client projectile” method?

For all of my projectile cases, I would usually:

  1. Client sends event to say they’ve fired a shot in X direction from Y position

  2. Server validates the client’s request

  3. Server replicates that to other client(s) assuming they would realistically be able to interact with and/or see the projectile

  4. The other clients would then:

    Note: If your issue is that they move at different speeds for different clients, it may be that you’re not correctly addressing time step issues - learn more about that here

    • Instantiate the projectile on their client - if you really want to, you could adjust the starting position of the projectile based on the ping on both the receiving client and the original client
    • While the projectile meets XYZ conditions, e.g. hasn’t hit anything yet, will then move the projectile via its CFrame property
    • This client will check to see if it hits anything, if it does, it will exit the movement and/or do any effect(s) that aren’t replicated, e.g. smoke effect when hitting a wall
  5. In the meantime, if the original client sends a request to the server to damage another client after its projectile hit something:

    • Validate what the client is suggesting it hit i.e. was it possible for that player to hit the other player at that point in time knowing that it sent the last projectile from Y position towards X direction at Z time?
    • If it’s valid, do whatever effects that projectile is meant to do; and replicate any effect(s) that the other client(s) need to see

The problem would be, since the projectile speed is some 40*deltaTime (slow), its hard to garantee that the projectile hits the target realistically based on ping. In the last step you described, when the projectile hits something, the ping time might make the projectile on the third clients teleport far and go through characters or through any moving parts.

Also i make the rat bounce off the wall and that movement after this event is organic from roblox’s default physics - changing projectile style in the middle of the proccess causes lag too.

Do you know any good way to reduce lag on GRANADES for exemple? Might look more with what i want then gun bullets.

Looks like the best solution indeed for now compared to the alternatives, but who knows someone knows something…

Sadly this will always be the case due to the nature of networked solutions. The best you can do is just try to make it appear as close as possible.

That’s why you’re doing the collision checks on each client and accounting for the latency of each client during the request. The thing that will appear most jarring for players will be what they can actually see, so if you’re waiting for the server/some other client to tell you that the projectile hit something there’s going to be significant latency.

Here’s a working example solution:

Example Server Code
-- SERVER
local PlayerService = game:GetService('Players')
local ReplicatedStorage = game:GetService('ReplicatedStorage')

-- const
local MAX_CHARACTER_DISTANCE = 50

-- build remotes
local networking = Instance.new('Folder')
networking.Name = 'NetworkingFolder'
networking.Parent = ReplicatedStorage

local fireRemote = Instance.new('RemoteEvent')
fireRemote.Name = 'FireActionRemote'
fireRemote.Parent = networking

local replRemote = Instance.new('RemoteEvent')
replRemote.Name = 'FireReplicationRemote'
replRemote.Parent = networking

-- utils
local function validateParams(types, ...)
  for i = 1, #types do
    local value = select(i, ...)
    local desiredType = types[i]
    if typeof(value) ~= desiredType then
      return false
    end
  end

  return true
end

local function tryGetCharacterRootPart(player)
  local t = typeof(player)
  if t ~= 'Instance' or not player:IsA('Player') or not player:IsDescendantOf(PlayerService) then
    return false, nil
  end

  local character = player.Character
  if not character or not character:IsDescendantOf(workspace) then
    return false, nil
  end

  local humanoid = character:FindFirstChildOfClass('Humanoid')
  if not humanoid or humanoid:GetState() == Enum.HumanoidStateType.Dead or humanoid.Health <= 0 then
    return false, nil
  end

  local rootPart = humanoid.RootPart
  if not rootPart then
    return false, nil
  end

  return true, rootPart
end

-- handle incoming event(s)
fireRemote.OnServerEvent:Connect(function (player, position, direction)
  -- confirm that the player's request body is valid
  local hasValidParams = validateParams({ 'Vector3', 'Vector3' }, position, direction)
  if not hasValidParams then
    return false
  end

  -- check to see if the character is alive and whether it has a root
  -- part that we can query
  local isValid, rootPart = tryGetCharacterRootPart(player)
  if not isValid then
    return false
  end

  -- TODO:
  --   Do some checks here to det. whether
  --   the player should actually be able to fire
  --   this weapon e.g. cooldown check(s) etc


  -- det. whether the character is within range of the
  -- position that the player alleges they fired from
  local origin = rootPart.Position
  local distance = (position - origin).Magnitude
  if distance > MAX_CHARACTER_DISTANCE then
    return false
  end

  -- det. whether the direction is a unit vector
  -- and whether it's not NaN
  local length = direction.Magnitude
  if length > 1 or length ~= length then
    return false
  end

  -- TODO:
  --    Filter this players array such that
  --    it only contains players that are valid
  --    and that would realistically be able to see
  --    this weapon being fired
  local players = PlayerService:GetPlayers()
  local ping = player:GetNetworkPing()
  for i = 1, #players do
    local client = players[i]
    if client == player then
      continue
    end

    local weaponDetails = nil -- +/- send any information about the weapon here
    local processedTime = workspace:GetServerTimeNow()
    replRemote:FireClient(client, player, ping, processedTime, position, direction, weaponDetails)
  end
end)

Example Client Code
-- CLIENT
local RunService = game:GetService('RunService')
local PlayerService = game:GetService('Players')
local UserInputService = game:GetService('UserInputService')
local ReplicatedStorage = game:GetService('ReplicatedStorage')

-- const
local UPD_FREQUENCY = 1 / 60  -- i.e. update at 60fps
local DEBOUNCE_TIME = 0.5     -- e.g. some cooldown time
local BULLET_VELOCITY = 120   -- i.e. some bullet speed, could be taken from a weapon variable
local BULLET_LOSS_DELAY = 1   -- i.e. how long until we remove the bullet in seconds

-- deps
local player = PlayerService.LocalPlayer
local networkContainer = ReplicatedStorage:WaitForChild('NetworkingFolder')

local fireRemote = networkContainer:WaitForChild('FireActionRemote')
local replRemote = networkContainer:WaitForChild('FireReplicationRemote')

-- utils
local function fireBulletFrom(client, position, direction, timeOffset)
  timeOffset = timeOffset or 0

  -- e.g. can be used to det. whether we should fire 'HitCharacter' events etc
  local isLocalPlayer = client == player

  -- var
  local startTime = os.clock() - timeOffset
  local bulletGravity = Vector3.yAxis*-workspace.Gravity -- or from some weapon variable

  local initialVelocity = direction*BULLET_VELOCITY
  local previousPosition = position
  previousPosition += initialVelocity*timeOffset + 0.5*bulletGravity*math.pow(timeOffset, 2)

  local rayParams = RaycastParams.new()
  rayParams.FilterType = Enum.RaycastFilterType.Exclude
  rayParams.FilterDescendantsInstances = { client.Character }

  local bullet = Instance.new('Part')
  bullet.Size = Vector3.one + Vector3.zAxis
  bullet.Shape = Enum.PartType.Block
  bullet.Position = previousPosition
  bullet.Anchored = true
  bullet.CanTouch = false
  bullet.CanQuery = false
  bullet.CanCollide = false
  bullet.BrickColor = BrickColor.Random()
  bullet.Parent = workspace

  -- bullet runtime
  local lastUpdate, connection = 0, nil
  connection = RunService.Stepped:Connect(function (gt, dt)
    lastUpdate += dt

    -- constrain our timestep, see: https://gafferongames.com/post/fix_your_timestep/
    if lastUpdate < UPD_FREQUENCY then
      return
    end
    lastUpdate = math.fmod(lastUpdate, UPD_FREQUENCY)

    local now = os.clock()
    local elapsedTime = now - startTime
    local displacement = initialVelocity*elapsedTime + 0.5*bulletGravity*math.pow(elapsedTime, 2)
    local desiredPosition = position + displacement

    local result = workspace:Raycast(previousPosition, desiredPosition - previousPosition, rayParams)
    local instance = result and result.Instance
    if instance then
      print('Hit:', instance, '@', result.Position)

      -- exit our bullet runtime
      if connection and connection.Connected then
        connection:Disconnect()
      end

      -- update the desired position based on our intersection
      desiredPosition = result.Position

      -- either (a) let the bullet sit there for a while if we hit something anchored; or (b) remove it immediately if we hit something unanchored
      if instance.Anchored then
        bullet.CFrame = CFrame.new(desiredPosition, desiredPosition + displacement.Unit)
        task.delay(BULLET_LOSS_DELAY, function ()
          pcall(bullet.Destroy, bullet)
        end)
      else
        pcall(bullet.Destroy, bullet)
      end

      if isLocalPlayer then
        -- TODO:
        --    send some event to the server to say we hit XYZ
        --    assuming it has a humanoid etc etc

      end

      -- TODO:
      --    Do some particle effect now that we hit something

      return
    end

    bullet.CFrame = CFrame.new(desiredPosition, desiredPosition + displacement.Unit)
  end)
end

-- handle incoming events
replRemote.OnClientEvent:Connect(function (otherClient, ping, processedTime, position, direction, weaponDetails)
  local elapsed = workspace:GetServerTimeNow() - processedTime
  elapsed += ping

  -- fire the weapon from that client accounting for the delay
  fireBulletFrom(otherClient, position, direction, elapsed)
end)

-- some example fire method
local lastFired
UserInputService.InputBegan:Connect(function (input, gameProcessed)
  if gameProcessed then
    return
  end

  local inputType = input.UserInputType
  if inputType ~= Enum.UserInputType.MouseButton1 then
    return
  end

  if lastFired and lastFired < DEBOUNCE_TIME then
    return
  end
  lastFired = os.clock()

  local character = player.Character
  local rootPart = character and character.PrimaryPart
  if not rootPart then
    return
  end

  local origin = rootPart.CFrame
  local position = origin.Position
  local direction = origin.LookVector

  -- tell the server, and other clients, that we're firing our weapon
  fireRemote:FireServer(position, direction)

  -- start firing the weapon for our own client
  fireBulletFrom(player, position, direction, 0)
end)

Bounces can be handled on each client still, but if you’re worried about desync of moving dynamic that the bullets can bounce off then you could simulate it on the server and replicate those changes to the client so that they can sync the positions over time.

Similarly, assuming you aren’t bouncing off dynamic objects, you could always compute the projectile path in advance and then check for any intersections with players along that path when each client plays the path. For example, see this reference here.