Deadlock zipline
10/07/2026
I’ve been playing a bit too much Deadlock lately and the zipline mechanic seemed like a fun weekend project. Originally I wanted to add it to a procedural geometry library I’ve been building, generating the mesh myself from Unity Splines.
Unfortunately, Unity’s Spline package already ships with mesh extrusion built in, so there wasn’t much point reinventing it. Instead I focused on the other parts of the mechanic: visuals, aiming, and character movement.
Building the zipline
The spline itself is straightforward. I use Unity’s SplineExtrude component to generate a tube mesh along the spline, it saves the generated mesh automatically so we can remove the component afterwards as at runtime I only need the mesh itself and the SplineContainer component, unless I decide to edit the spline later.
To give it a look closer to Deadlock’s I created a simple unlit transparent shader graph that randomly varies the cable thickness using a few sine waves. The vertices are also displaced along a random direction, giving the cable a jagged, unstable silhouette. The color also blends towards white as the cable thickens, adding a bit more variation to the effect.
void NoiseThickness_float(float _in, float time, out float _out)
{
float s1 = ((sin(time * 1.2 + _in * 5.) * 0.5) + 0.5) * 0.01;
float s2 = ((sin(time * 0.8 + _in * 2.) * 0.5) + 0.5) * 0.02;
_out = 0.01 + s1 + s2;
}
Finding the attachment point
The next problem is figuring out which point on the spline the player is actually aiming at.
I originally expected to implement a closest-point-on-spline search myself, but Unity’s Spline package already exposes exactly that functionality. So I created a PlayerZiplineController component that grabs all the ziplines in the world on Awake and in the Update loop casts a ray from the center of the screen, and loops through the splines querying for its nearest point and use that as the potential attachment location. One thing worth mentioning is that Unity’s Spline API operates entirely in the spline’s local space, so all inputs and outputs need to be transformed appropriately.
Ray worldCamRay = _playerCamera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f));
float closestDist = float.MaxValue;
Vector3 zipClosestWorldPos = Vector3.zero;
float zipClosestT = -1f;
ZiplineController bestZipline = null;
for (int i = 0; i < _ziplines.Length; i++)
{
ZiplineController zipline = _ziplines[i];
Transform ziplineTransform = zipline.transform;
Ray localCamRay = new Ray(ziplineTransform.InverseTransformPoint(worldCamRay.origin), ziplineTransform.InverseTransformDirection(worldCamRay.direction));
SplineUtility.GetNearestPoint(zipline.ZipSpline, localCamRay, out float3 nearest, out float t );
// ...
And it works just like that, you can see in the image below a red line which is the worldCamRay and a little blue sphere at the attachment point.
Aiming arrow
To visualize the grab, I stretch a camera-facing quad between the player’s hips and the attachment point.
private void TransformPlayerToZiplineQuad(Vector3 zipPosWorld)
{
Vector3 playerToZip = (zipPosWorld - _playerToZipQuadStartT.position);
_playerToZipQuadT.position = _playerToZipQuadStartT.position + playerToZip * 0.5f;
_playerToZipQuadT.localScale = new Vector3(1f, playerToZip.magnitude, 1f);
Vector3 up = playerToZip.normalized; // up needs to be this so top and bot of quad aligns properly with desired positions
Vector3 toCamera = (_playerCamera.transform.position - _playerToZipQuadT.position).normalized; // we want the quad to face the camera
Vector3 right = Vector3.Cross(up, toCamera).normalized;
if (right.sqrMagnitude < 0.0001f)
right = Vector3.Cross(up, _playerCamera.transform.up).normalized; // fallback axis
Vector3 forward = -Vector3.Cross(right, up).normalized; // quad normal, as camera-facing as possible given the up constraint
_playerToZipQuadT.rotation = Quaternion.LookRotation(forward, up);
}
And for the shader I’m mixing two scrolling SDFs (dash line and arrows), likely this should be done with textures, but it’s much more fun to code than to use photoshop.
void ArrowSDF_float(float2 _uv, float uvxUnscaled, float scrollSpeed, out float _mask)
{
float2 p = _uv;
p *= 3.;
p.x += scrollSpeed; // move the arrows
// repeat arrows
float s = 1.8;
p.x = p.x - s * round(p.x / s);
p.x += 0.6;
p.y = abs(p.y);
// size scale
float m = lerp(1., 0.3, smoothstep(0., 0.8, uvxUnscaled));
p /= m;
float2 q1 = p;
float d1 = sdParallelogram(q1, 0.2, 0.6, 0.4) * m;
float2 q2 = p;
q2.x -= 0.55;
float d2 = sdParallelogram(q2, 0.1, 0.6, 0.4) * m;
float2 q3 = p;
q3.x -= 1.;
float d3 = sdParallelogram(q3, 0.1, 0.6, 0.4) * m;
float dist = min(d1, min(d2, d3));
_mask = smoothstep(0.02, 0., dist);
}
void DashLine_float(float2 _uv, float scrollSpeed, out float _mask)
{
float2 p = _uv;
p *= 3.;
p.x += scrollSpeed;
float s = 2.;
p.x = p.x - s * round(p.x / s);
_mask = smoothstep(0.02, 0., sdBox(p, float2(0.5, 0.1)));
}
The dash and arrow SDFs are masked together, scrolled along the quad over time, and finally tinted using the active zipline’s color.
Annoying little problem
Because the zipline shader displaces vertices, the point returned by the spline no longer lines up with the rendered mesh. Visually, the player appears to grab thin air.
The fix I implemented was simple, pass the grab position to the shader and smoothly fade the displacement and thickness towards zero near that point by multiplying the smoothstep output node with the thickness and displacement. This ensures the rendered cable always lines up with the actual attachment location and becomes thinner so the character’s model hand lines up better with the zipline making it seem like he’s actually grabbing something.
Improving the aiming system
Technically the GetNearestPoint function always returns the closest point on the spline, which is not really desirable from a gameplay perspective. So, to improve this I added a few simple filters on possible grab points to ensure the player is close enough and roughly looking at the grab point. Grab distance and dot product have hysteresis to prevent flickering when moving around the thresholds. A physics cast should probably be the next addition, to avoid grabbing ziplines through walls. One thing of note, I believe we could achieve an even more responsive system if the GetNearestPoint function was a custom implementation. Instead of returning a single closest point, it could return several nearby candidates. Deadlock appears to do something similar, allowing the system to snap to a nearby valid point rather than failing because the player happened to aim a little too far along the cable.
private bool CanPlayerGrabZipline(Vector3 grabPosWorld, float grabDist, float grabDot, bool wasGrabbableLastFrame)
{
// check if player within range to grab the zipline
if ((grabDist > _playerToZipMinRange && !wasGrabbableLastFrame) ||
(grabDist > _playerToZipMinRange + _playerToZipDeadzoneRange && wasGrabbableLastFrame))
return false;
// check if player is aiming in the general direction of the grab position
if ((grabDot < _playerToZipMinDot && !wasGrabbableLastFrame) ||
(grabDot < _playerToZipMinDot - _playerToZipDeadzoneDot && wasGrabbableLastFrame))
return false;
// check if zipline grab pos is visible on screen
Vector3 viewPos = _playerCamera.WorldToViewportPoint(grabPosWorld);
return viewPos.x > 0f && viewPos.x < 1f && viewPos.y > 0f && viewPos.y < 1f;
}
Movement
Originally I only wanted to prototype the visuals, but, once I could aim at the cables, it felt wrong not to actually ride them.
Rather than writing a controller from scratch, I extended Unity’s sample ThirdPersonController with a new movement state. A trimmed Mixamo animation was enough for the prototype and the movement itself simply samples the spline over time, updating the player’s position and orientation until reaching the end or jumping off. Also, at this point would probably be a good idea to refactor the character controller into a proper state machine based architecture rather than having a massive script with way too many booleans, fortunately it wasn’t at the point yet where adding this simple feature was too much of a pain.
The core movement code looks like this:
private void GrabZipline()
{
if (_input.grabZipline && _grabZiplineTimeoutDelta <= 0.0f && _ziplineController.CanGrabZipline && !_isMovingToZipline && !_isOnZipline)
{
_isMovingToZipline = true;
_ziplineTargetPosition = _ziplineController.ZiplineGrabPosition;
_currentZiplineT = _ziplineController.ZiplineGrabT;
Vector3 up = Vector3.up; // always alined vertically with world up
Vector3 tangent = _ziplineController.EvaluateZiplineSplineTangentWorld(_currentZiplineT);
// decide direction to move
Vector3 playerFacingDir = _playerCamera.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0f)).direction;
_ziplineMoveDir = Vector3.Dot(playerFacingDir, tangent) >= 0f ? 1f : -1f;
tangent *= _ziplineMoveDir;
Vector3 right = Vector3.Cross(up, tangent).normalized;
Vector3 forward = Vector3.Cross(right, up).normalized;
_ziplineTargetRotation = Quaternion.LookRotation(forward, up);
_toZiplineMoveTime = (_ziplineTargetPosition - _ziplineGrabT.position).magnitude / ToZiplineSpeed;
_ziplineController.EnableZiplineTargeting(false); // if _isMovingToZipline / _isOnZipline we don't want PlayerZiplineController aiming system to be working
if (_hasAnimator)
{
_animator.SetBool(_animIDGrabZipline, true);
_animator.SetBool(_animIDFreeFall, false);
_animator.SetBool(_animIDJump, false);
}
}
if (_isMovingToZipline)
{
Vector3 handToZipline = _ziplineTargetPosition - _ziplineGrabT.position;
float currentMoveDist = handToZipline.magnitude;
if (currentMoveDist < 0.01f) // reached target position on the zipline
{
_isMovingToZipline = false;
_isOnZipline = true;
_animator.speed = 1f;
}
else
{
// could compute time and make actual rotation guarantee it lines up on arrival, but this works fine
transform.rotation = Quaternion.Lerp(transform.rotation, _ziplineTargetRotation, 2f * Time.deltaTime);
// smoothstep velocity when distance is very close so character doesnt jitter around target position
float finalVelocity = math.smoothstep(-0.2f, 0.5f, currentMoveDist) * ToZiplineSpeed;
// move the player
_controller.Move(handToZipline.normalized * (finalVelocity * Time.deltaTime));
// scale speed of the animations by the time it takes to travel to the zipline so animation feels more consistent
// note _toZiplineMoveTime isn't 100% accurate due to the smoothstep above but that's ok, just a small drift
// changing _animator.speed wouldn't be ideal if we had different animation layers for example but for this use case is fine
var currentState = _animator.GetCurrentAnimatorStateInfo(0);
bool alreadyInAir = currentState.shortNameHash == _animInAirHash; // if already in air lets halve the approach anim duration
float approachDuration = ZiplineApproachChainDuration * (alreadyInAir ? 0.5f : 1f);
_animator.speed = approachDuration / _toZiplineMoveTime;
}
}
if (_isOnZipline)
{
// step on zipline
float stepT = (Time.deltaTime * OnZiplineSpeed) / _ziplineController.GetZiplineLength();
_currentZiplineT += stepT * _ziplineMoveDir;
// sample info we need
_ziplineController.EvaluateZiplineSplineWorld(_currentZiplineT, out Vector3 worldPos, out Vector3 worldTan, out Vector3 worldUp);
_ziplineController.UpdateZiplineGrabPositionWorld(worldPos);
// compute rotation
Vector3 up = Vector3.up;
worldTan *= _ziplineMoveDir;
Vector3 right = Vector3.Cross(up, worldTan).normalized;
Vector3 forward = Vector3.Cross(right, up).normalized;
_ziplineTargetRotation = Quaternion.LookRotation(forward, up);
transform.rotation = Quaternion.Lerp(transform.rotation, _ziplineTargetRotation, 2f * Time.deltaTime);
// move the player
_controller.Move((worldPos - _ziplineGrabT.position));
// exit when reaching the end of the zipline
if (_currentZiplineT >= 0.99f && _ziplineMoveDir == 1f ||
_currentZiplineT <= 0.01f && _ziplineMoveDir == -1f)
{
_isOnZipline = false;
_isMovingToZipline = false;
// small delay before reactivating aiming system and ability to jump to zipline again
_grabZiplineTimeoutDelta = ZiplineTimeout;
if (_hasAnimator)
{
_animator.SetBool(_animIDGrabZipline, false);
_animator.SetBool(_animIDFreeFall, true);
_animator.SetBool(_animIDJump, false);
}
}
else if (_input.jump) // can also exit by jumping off
{
_isOnZipline = false;
_isMovingToZipline = false;
// the square root of H * -2 * G = how much velocity needed to reach desired height
_verticalVelocity = Mathf.Sqrt(JumpHeight * -2f * Gravity);
// update animator if using character
if (_hasAnimator)
{
_animator.SetBool(_animIDGrabZipline, false);
_animator.SetBool(_animIDFreeFall, false);
_animator.SetBool(_animIDJump, true);
}
}
}
Closing thoughts
While it’s obviously nowhere near as polished as Deadlock’s implementation, especially when it comes to the character’s movement and animation, this feels pretty alright for a weekend demo. Besides the improvements mentioned throughout the article, the biggest thing I’d like to revisit is the cost of repeatedly querying the spline for its nearest point. I haven’t profiled it yet, so before optimizing anything I’d want to take some measurements first. A few ideas worth testing would be:
- performing a very coarse nearest-point search before running the expensive version
- using trigger volumes around each zipline to avoid unnecessary queries when the player is very far
- throttling the search to every few frames and interpolating the attachment point in between updates
- seeing if the algorithm itself can be parallelized or parallelizing the queries if there are loads of ziplines on the scene
Whether any of these are actually worthwhile depends entirely on the profiler.