Source-like Ladders v1.0

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.

Model link

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

  1. Create a model or folder.
  2. Using the explorer window, give it a tag named “sourceLadder”
  3. In the folder/model, create a part named “LadderBottom” and another part named “LadderTop”
  4. Ensure the parts have the property “CanTouch” set to true, and “CanCollide” set to false.
  5. 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.

17 Likes