I challenged myself to script a basic ladder system for a FPS I’m making because Roblox’s climbing is dubious at best and this is the result of it.
Source code
--[[ Variables ]]--
-- Services --
local CollectionService = game:GetService("CollectionService");
local Debris = game:GetService("Debris")
local Players = game:GetService("Players");
local RunService = game:GetService("RunService");
local UserInputService = game:GetService("UserInputService");
-- User --
local plr = Players.LocalPlayer;
local char = plr.Character or plr.CharacterAdded:Wait();
local cam = workspace.CurrentCamera;
local hum = char:WaitForChild("Humanoid");
local hrp = char:WaitForChild("HumanoidRootPart");
local climbingMover : AlignPosition;
local climbingAtt1;
local isClimbing = false;
local currentLadder;
local currentLadderDismountPoints = {};
local characterMass = hrp.AssemblyMass;
local characterWalkspeed = hum.WalkSpeed;
local gravity = workspace.Gravity;
-- Constants --
local CLIMB_SPEED_MULTIPLIER = 1; --// Climb speed is this number times your walkspeed
local DISMOUNT_POINT_DOT_THRESHOLD = 0.9; --// The minimum dot product required to use a dismount point.
local DISMOUNT_MAXIMUM_DISTANCE = 8; --// How far the player can dismount, in studs.
local LINE_OF_SIGHT_RAYCAST_PARAMS = RaycastParams.new();
LINE_OF_SIGHT_RAYCAST_PARAMS.FilterDescendantsInstances = {char};
LINE_OF_SIGHT_RAYCAST_PARAMS.FilterType = Enum.RaycastFilterType.Exclude;
LINE_OF_SIGHT_RAYCAST_PARAMS.RespectCanCollide = true;
local CHARACTER_HEIGHT = Vector3.new(0, 2.5, 0);
--[[ Functions ]]--
local clamp = math.clamp;
local sign = math.sign;
local vector3New = Vector3.new;
function cleanup()
--// Unlikely to happen, but better safe than sorry.
for _, v in next, CollectionService:GetTagged("sourceLadderCleanup") do
v:Destroy();
end
end
function startClimbingLadder(ladderPart)
isClimbing = true;
currentLadder = ladderPart.Parent;
currentLadderDismountPoints = {};
for _, v in next, currentLadder:GetDescendants() do
if v:HasTag("sourceLadderDismountPoint") then
table.insert(currentLadderDismountPoints, v);
end
end
climbingAtt1 = Instance.new("Attachment");
climbingAtt1.Parent = ladderPart;
climbingAtt1.WorldPosition = vector3New(climbingAtt1.WorldPosition.X, hrp.Position.Y, climbingAtt1.WorldPosition.Z);
climbingAtt1:AddTag("sourceLadderCleanup");
climbingMover.Enabled = true;
climbingMover.Attachment1 = climbingAtt1;
onPhysicsUpdate();
end
function stopClimbingLadder()
if not isClimbing then
return;
end
isClimbing = false;
climbingMover.Enabled = false;
hrp:ApplyImpulse(cam.CFrame.LookVector * 5 * characterMass);
if climbingAtt1 then
climbingAtt1:Destroy();
climbingAtt1 = nil;
end
end
function determineLadderMoveDirection()
local camCF = cam.CFrame;
local moveDirection = hum.MoveDirection;
local camPitch = camCF:ToOrientation();
local camLookDirection = sign(camPitch);
local relativeMoveDirection = camCF:VectorToObjectSpace(moveDirection);
local speedMultiplier = moveDirection.Magnitude;
return vector3New(0, -sign(relativeMoveDirection.Z) * camLookDirection, 0) * speedMultiplier;
end
function clampVector3Y(vector, minY, maxY)
local y = clamp(vector.Y, minY, maxY);
return vector3New(vector.X, y, vector.Z);
end
function calculateLadderBounds()
local top = currentLadder.LadderTop;
local bottom = currentLadder.LadderBottom;
local min = bottom.Position - vector3New(0, bottom.Size.Y / 2, 0);
local max = top.Position + vector3New(0, top.Size.Y / 2, 0);
return min.Y, max.Y;
end
function hasLineOfSightWith(point)
local result = workspace:Raycast(hrp.Position, (point - hrp.Position), LINE_OF_SIGHT_RAYCAST_PARAMS);
if not result or result.Distance <= 0.1 then
return true;
end
end
function isMovingTowardsLadder(ladder)
local ladderToHrp = ((ladder.LadderTop.Position - hrp.Position) * vector3New(1, 0, 1)).Unit;
local dot = hum.MoveDirection.Unit:Dot(ladderToHrp);
return dot >= 0.25;
end
function checkForLadderDismountPoint()
local highestDot = DISMOUNT_POINT_DOT_THRESHOLD;
local bestPoint = nil;
for _, dismountPoint in next, currentLadderDismountPoints do
local pointToHrp = (dismountPoint.WorldPosition - hrp.Position);
local dist = pointToHrp.Magnitude;
local dot = (pointToHrp * vector3New(1, 0, 1)).Unit:Dot( hum.MoveDirection.Unit);
local hasLineOfSightWithPoint = hasLineOfSightWith(dismountPoint.WorldPosition + CHARACTER_HEIGHT);
if dist <= DISMOUNT_MAXIMUM_DISTANCE and dot > highestDot and hasLineOfSightWithPoint then
bestPoint = dismountPoint;
highestDot = dot;
end
end
return bestPoint;
end
function dismountToPoint(point)
local dismountMover = Instance.new("AlignPosition");
dismountMover.Name = "SourceLadderDismountMover"
dismountMover.Enabled = true;
dismountMover.Mode = Enum.PositionAlignmentMode.OneAttachment;
dismountMover.ApplyAtCenterOfMass = true;
dismountMover.Position = point.WorldPosition + CHARACTER_HEIGHT;
dismountMover.MaxForce = characterWalkspeed * characterMass * gravity;
dismountMover.MaxVelocity = 1e9;
dismountMover.Responsiveness = 100;
dismountMover.Attachment0 = climbingMover.Attachment0;
dismountMover.Parent = char;
Debris:AddItem(dismountMover, 1);
local start = os.clock();
while os.clock() - start < 1 and (hrp.Position - dismountMover.Position).Magnitude > 1 do
task.wait();
end
dismountMover:Destroy();
end
function updateClimbing(dt)
if not isClimbing then
return;
end
local maxVerticalForce = characterMass * gravity * characterWalkspeed * CLIMB_SPEED_MULTIPLIER;
local targetPosition = determineLadderMoveDirection() * dt * characterWalkspeed;
climbingMover.MaxAxesForce = vector3New(1e9, maxVerticalForce, 1e9);
climbingAtt1.Position += targetPosition;
climbingAtt1.WorldPosition = clampVector3Y(climbingAtt1.WorldPosition, calculateLadderBounds());
local dismountPoint = checkForLadderDismountPoint();
if dismountPoint then
stopClimbingLadder();
dismountToPoint(dismountPoint);
end
end
function makeConstraints()
local att0 = Instance.new("Attachment");
att0.Parent = hrp;
climbingMover = Instance.new("AlignPosition");
climbingMover.Name = "SourceLadderClimbingMover"
climbingMover.Enabled = false;
climbingMover.ApplyAtCenterOfMass = true;
climbingMover.ForceLimitMode = Enum.ForceLimitMode.PerAxis;
climbingMover.ForceRelativeTo = Enum.ActuatorRelativeTo.World;
climbingMover.Responsiveness = 100;
climbingMover.Attachment0 = att0;
climbingMover.Parent = char;
end
function onDeath()
stopClimbingLadder();
RunService:UnbindFromRenderStep("sourceLadderUpdate");
end
function onPhysicsUpdate()
characterWalkspeed = hum.WalkSpeed;
characterMass = hrp.AssemblyMass;
gravity = workspace.Gravity;
end
function onRootPartTouched(hit)
if not hit.Parent:HasTag("sourceLadder") or isClimbing then
return;
end
if isMovingTowardsLadder(hit.Parent) then
startClimbingLadder(hit);
end
end
function onSpawn()
cleanup();
makeConstraints();
hum.Died:Connect(onDeath);
workspace:GetPropertyChangedSignal("Gravity"):Connect(onPhysicsUpdate);
hrp:GetPropertyChangedSignal("AssemblyMass"):Connect(onPhysicsUpdate);
hum:GetPropertyChangedSignal("WalkSpeed"):Connect(onPhysicsUpdate);
hrp.Touched:Connect(onRootPartTouched);
UserInputService.JumpRequest:Connect(stopClimbingLadder);
RunService:BindToRenderStep("sourceLadderUpdate", Enum.RenderPriority.Character.Value + 1, updateClimbing);
end
onSpawn();
Description
Source-like Ladders is a basic implementation of the snappy ladder physics the Source engine has. This script does not replace or change the built-in character climbing. What it does is clamp your character’s position between two vertical points and allows you to move up/down depending on your camera’s direction.
To dismount a ladder, you can either reach a dismount point, or jump at any time.
To re-mount a ladder, move towards it like you did the first time.
Setup & Usage
- Create a model or folder.
- Using the explorer window, give it a tag named “sourceLadder”
- In the folder/model, create a part named “LadderBottom” and another part named “LadderTop”
- Ensure the parts have the property “CanTouch” set to true, and “CanCollide” set to false.
- Climb your cool and awesome source ladder
To create a dismount point, create an attachment with the tag “sourceLadderDismountPoint” and put it in any part in your ladder.
How does it work?
When your character’s root part touches a part whose parent has the “sourceLadder” tag, it will begin to climb the ladder from that position.
For dismount points, when you’re within range, have a clear line of right with it, and moving in its direction, your character will dismount the ladder and be smoothly positioned to that point.
Closing Remarks
This is one of few public resources I’ve ever posted, and probably my most ambitious.
Given that this was a script written in one sitting, its bound to have some flaws to it and is kinda bare bone. As it stands now though, it works pretty well from my testing.
Currently planned features include:
- Smart, automatic dismounting without dismount points
- Diagonal ladder support
If you have any suggestions or feedback, feel free to post it here. I’ll be posting updates to this given that it’s going into one of my own works.