anthonysaade.dev

Building Timestop: Freezing a Roblox Fight Convincingly

How the Timestop ability in Bard’s Fighting Arena works: a server-owned timestop state, a Glass mesh that freezes the screen, animations that ease to a stop, and a color grade.

By · · 3 min read · Intermediate

Timestop is an ability in Bard’s Fighting Arena that freezes the fight around the player who uses it. Most of the effect is presentation: making a frozen moment feel frozen.

Getting timestopped.
Using Timestop.

A freeze mesh in front of the camera

The freeze effect comes from a mesh with the Glass material, placed half a stud in front of the camera and sized to cover the whole view. It relies on an engine bug: looking through a Glass mesh “freezes” the screen, so everything behind it stops updating and holds perfectly still.

The code below keeps it filling the screen at any field of view or aspect ratio:

	local FreezePart do
		local Distance = 0.5
		local FreezePartOffset = CFrame.new(0, 0, -Distance)
		FreezePart = function(GarbageCollector)
			local FrozenPart = Instance.fromExisting(script.FreezePart) do
				local Viewport, Height = Camera.ViewportSize, 2*math.tan(math.rad(Camera.FieldOfView)/2)*Distance
				FrozenPart.Size = Vector3.new(Height*(Viewport.X/Viewport.Y), Height, 0)
				FrozenPart.CFrame = Camera.CFrame*FreezePartOffset
				FrozenPart.Parent = workspace.Visuals
			end
			GarbageCollector[#GarbageCollector+1] = FrozenPart
		end
	end

The height comes from basic camera math: at distance d, a vertical field of view fov covers 2 · tan(fov / 2) · d studs. Multiplying by the viewport’s aspect ratio gives the width.

Easing animations to a stop

Cutting animations to speed zero looks like a bug. Instead, each playing track slows down along a cubic ease-out curve:

	local SlowDownAnimation do
		local Cubic, Out = Enum.EasingStyle.Cubic, Enum.EasingDirection.Out
		SlowDownAnimation = function(AnimationTrack : AnimationTrack)
			local Speed = math.floor(AnimationTrack.Speed*1000)/1000
			local PreviousSpeed = Speed
			local Elapsed = 0; repeat
				local DeltaTime = task.wait()
				local CurrentSpeed = Elapsed == 0 and Speed or math.floor(AnimationTrack.Speed*1000)/1000
				if PreviousSpeed ~= CurrentSpeed then
					break
				end
				Elapsed += DeltaTime
				local NewSpeed = Speed*(1-TweenService:GetValue(Elapsed, Cubic, Out))
				if NewSpeed <= 0.01 or AnimationTrack.TimePosition+AnimationTrack.Speed*DeltaTime*2 > AnimationTrack.Length then
					AnimationTrack:AdjustSpeed(0)
					break
				else
					AnimationTrack:AdjustSpeed(NewSpeed)
					PreviousSpeed = math.floor(NewSpeed*1000)/1000
				end
			until not AnimationTrack.IsPlaying or AnimationTrack.Speed <= 0 do
				if PreviousSpeed == math.floor(AnimationTrack.Speed*1000)/1000 then
					AnimationTrack:AdjustSpeed(Speed)
				end
			end
		end
	end

It samples TweenService:GetValue without creating a tween. If something else changes the track’s speed partway through, the loop notices the mismatch and stops interfering.

The color grade

The whole scene shifts to full grayscale with raised contrast over half a second, then eases back over a quarter second:

	local VolumeTInfo, VolumeProp = TweenInfo.new(0.25), {Volume = 0}
	local ColorCorrectionTInfo, ColorCorrectionProp = TweenInfo.new(0.5, Enum.EasingStyle.Exponential), {Brightness = 0.1, Contrast = 1, Saturation = -1, TintColor = Color3.fromRGB(227, 227, 227)}
	local ColorCorrectionTInfo2, ColorCorrectionProp2 = TweenInfo.new(0.25, Enum.EasingStyle.Sine), {Brightness = 0, Contrast = 0, Saturation = 0, TintColor = Color3.fromRGB(255, 255, 255)}

Keeping it in sync

Everything that matters about a timestop is decided on the server. When someone uses it, the server creates an ObjectValue on their character that describes the timestop, and schedules it to destroy itself when the duration runs out:

		local IsTimestopped = Instance.new("ObjectValue") do
			IsTimestopped.Name = "Timestopped"
			IsTimestopped.Value = Character
			if Timeskip then
				IsTimestopped:SetAttribute("Timeskip", true)
			end
			IsTimestopped:SetAttribute("Duration", Length)
			IsTimestopped:SetAttribute("CreatedTime", Globals.Time+(os.clock()-Globals.TimeElapsed))
			IsTimestopped:SetAttribute("Resistant", true)
			IsTimestopped:SetAttribute("GUID", HttpService:GenerateGUID(false))
			IsTimestopped.Parent = Character.States
			task.delay(Length or 2, game.Destroy, IsTimestopped)
		end

Because it is a plain instance, replication delivers it to every client with no extra remote calls. Clients can read who stopped time, how long it lasts, and when it started. The GUID tells separate timestops apart, and destroying the instance is the single signal that time is moving again.

Who gets frozen

A bubble grows out from the user to the timestop’s range. Every frame, the server asks which hurtboxes are inside it:

		local SpacialQuery = task.spawn(function()
			while true do
				local CurrentVictims = table.clone(TimestopProfiles)
				for Index, Hurtbox in next, workspace:GetPartsInPart(TimestopBubble.PrimaryPart, Globals.HitboxOParams) do
					local Attachment : Weld = Hurtbox:FindFirstChild("Weld")
					local Parent = Attachment and Attachment.Part1
					local Entity : Globals.CharacterType = Parent and Parent.Parent
					if Entity and Entity.Parent == workspace.Entities and Entity ~= Character then
						CurrentVictims[Entity] = nil
						if TimestopProfiles[Entity] then
							continue
						end
						StunVictim(TimestopProfiles, IsTimestopped, Entity)
					end
				end
				for Victim, Profile in next, CurrentVictims do
					UnstunVictim(TimestopProfiles, Victim, Profile)
				end
				table.clear(CurrentVictims); CurrentVictims = nil
				
				task.wait()
			end
		end)

Anyone who enters the bubble is frozen, and anyone who leaves is released. Freezing a character on the server means:

Releasing a character reverses each step using the saved speeds, then cleans up everything in one pass.

The TimestopFrozen state also reaches the client-side character sync from my Parallel Luau post. That loop skips frozen characters entirely, so their poses do not drift while time is stopped.