<roblox version="4">
  <Item class="Lighting" referent="0">
    <Properties>
      <string name="Name">Lighting</string>
      <Color3 name="Ambient">
        <R>26</R>
        <G>28</G>
        <B>38</B>
      </Color3>
      <float name="Brightness">1.2</float>
      <float name="ClockTime">16.5</float>
      <float name="EnvironmentDiffuseScale">0.3</float>
      <float name="EnvironmentSpecularScale">0.2</float>
      <float name="ExposureCompensation">-0.5</float>
      <Color3 name="FogColor">
        <R>26</R>
        <G>30</G>
        <B>44</B>
      </Color3>
      <float name="FogEnd">2200</float>
      <float name="FogStart">260</float>
      <float name="GeographicLatitude">12</float>
      <bool name="GlobalShadows">true</bool>
      <Color3 name="OutdoorAmbient">
        <R>48</R>
        <G>52</G>
        <B>68</B>
      </Color3>
      <float name="ShadowSoftness">0.5</float>
      <token name="Technology">3</token>
    </Properties>
  </Item>
  <Item class="Players" referent="1">
    <Properties>
      <string name="Name">Players</string>
      <float name="RespawnTime">3</float>
    </Properties>
  </Item>
  <Item class="ReplicatedStorage" referent="2">
    <Properties>
      <string name="Name">ReplicatedStorage</string>
    </Properties>
    <Item class="Folder" referent="3">
      <Properties>
        <string name="Name">Shared</string>
      </Properties>
      <Item class="ModuleScript" referent="4">
        <Properties>
          <string name="Name">Challenges</string>
          <string name="Source"><![CDATA[--!strict
--[[
	Challenges
	----------------------------------------------------------------------------
	The three challenge courses, modelled on what the "+1 Speed" obby games
	actually ship: Spacings, Conveyors and Vanishing.

	Each course is a straight track elevated above the arena floor. Miss a jump
	and you land on the ground and walk back -- no death, no respawn, no lost
	progress, which is what keeps the loop tight.

	Difficulty is expressed as the WalkSpeed you need to clear it, and the payout
	scales with it:

	    Spacings   Easy    1 trophy    ~30 studs/s   (about 125 Speed)
	    Conveyors  Medium  5 trophies  ~60 studs/s   (about 1,560 Speed)
	    Vanishing  Hard    10 trophies ~95 studs/s   (about 8,750 Speed)

	So the courses are the reason to keep walking: Speed is not the goal, it is
	the key that opens the next course.

	Courses sit in the gaps *between* the running lanes, starting past the rank
	pedestal ring, so nothing overlaps and each one is visible from spawn.
]]

local Types = require(script.Parent.Types)

local Challenges = {}

--- Physics constant: how far a default Humanoid jumps horizontally per stud/s of
--- WalkSpeed, at Config.Movement.JumpHeight and the default gravity.
--- 2 * sqrt(2h/g) seconds of airtime -- used to sanity check gap widths.
Challenges.JUMP_AIRTIME = 0.589

Challenges.Difficulties = {
	Easy = { Name = "Easy", Color = Color3.fromRGB(76, 217, 123) },
	Medium = { Name = "Medium", Color = Color3.fromRGB(255, 176, 32) },
	Hard = { Name = "Hard", Color = Color3.fromRGB(255, 77, 109) },
} :: { [string]: { Name: string, Color: Color3 } }

Challenges.List = {
	{
		Id = "Spacings",
		Name = "Spacings",
		Difficulty = "Easy" :: Types.Difficulty,
		Trophies = 1,
		Description = "Jump the gaps. They get wider.",
		RecommendedWalkSpeed = 30,
		--- Which gap between running lanes to sit in, and how far out to start.
		GapIndex = 1,
		StartRadius = 320,
		Height = 20,
		Width = 26,
		Color = Color3.fromRGB(76, 217, 123),
		Kind = "Spacings" :: Types.ChallengeKind,
		Params = {
			Platforms = 9,
			PlatformDepth = 14,
			--- Gap widths interpolate from first to last across the course.
			GapFirst = 6,
			GapLast = 17,
		},
	},
	{
		Id = "Conveyors",
		Name = "Conveyors",
		Difficulty = "Medium" :: Types.Difficulty,
		Trophies = 5,
		Description = "The belts push back, and the doors do not wait.",
		RecommendedWalkSpeed = 60,
		GapIndex = 4,
		StartRadius = 320,
		Height = 20,
		Width = 26,
		Color = Color3.fromRGB(255, 176, 32),
		Kind = "Conveyors" :: Types.ChallengeKind,
		Params = {
			Segments = 6,
			SegmentLength = 34,
			--- Studs/second the belt pushes you back toward the start.
			BeltSpeed = 34,
			--- Timed doors between segments. These are what actually enforce the
			--- speed requirement: anchored-part conveyor physics is inconsistent
			--- across Roblox versions, so the course must still be hard if the
			--- belts end up doing nothing.
			DoorCycle = 2.6,
			DoorOpenFraction = 0.5,
		},
	},
	{
		Id = "Vanishing",
		Name = "Vanishing",
		Difficulty = "Hard" :: Types.Difficulty,
		Trophies = 10,
		Description = "The floor is only there for a moment.",
		RecommendedWalkSpeed = 95,
		GapIndex = 7,
		StartRadius = 320,
		Height = 20,
		Width = 26,
		Color = Color3.fromRGB(255, 77, 109),
		Kind = "Vanishing" :: Types.ChallengeKind,
		Params = {
			Rows = 11,
			RowDepth = 12,
			RowSpacing = 22,
			--- A row is solid for `VisibleFraction` of every `Cycle` seconds, and
			--- each row is offset so the solid stretch travels along the course.
			Cycle = 2,
			--- Each row's window is offset by RowSpacing / RecommendedWalkSpeed, so
			--- the solid stretch travels along the course at exactly the speed the
			--- sign advertises. ChallengeService derives that; there is no second
			--- number here to drift out of sync with it.
			VisibleFraction = 0.45,
		},
	},
} :: { Types.ChallengeDefinition }

local byId: { [string]: Types.ChallengeDefinition } = {}

for index, definition in Challenges.List do
	definition.Order = index
	byId[definition.Id] = definition
end

Challenges.ById = byId
Challenges.Count = #Challenges.List

function Challenges.get(id: string): Types.ChallengeDefinition?
	return byId[id]
end

function Challenges.getDifficulty(definition: Types.ChallengeDefinition)
	return Challenges.Difficulties[definition.Difficulty] or Challenges.Difficulties.Easy
end

--- Total forward length of a course, from the start pad to the finish pad.
--- Shared so the world builder and the server's "you cannot have finished that
--- fast" check derive it from the same numbers.
function Challenges.getCourseLength(definition: Types.ChallengeDefinition): number
	local params = definition.Params

	if definition.Kind == "Spacings" then
		local platforms = params.Platforms or 0
		local gaps = math.max(platforms - 1, 0)
		local averageGap = ((params.GapFirst or 0) + (params.GapLast or 0)) * 0.5
		return platforms * (params.PlatformDepth or 0) + gaps * averageGap
	elseif definition.Kind == "Conveyors" then
		return (params.Segments or 0) * (params.SegmentLength or 0)
	elseif definition.Kind == "Vanishing" then
		return (params.Rows or 0) * (params.RowSpacing or 0)
	end

	return 0
end

--- The fastest a run could physically be completed, used to reject a "finish"
--- that never involved actually running the course.
function Challenges.getMinimumRunTime(definition: Types.ChallengeDefinition, maxWalkSpeed: number): number
	local length = Challenges.getCourseLength(definition)
	if maxWalkSpeed <= 0 then
		return 0
	end
	-- 0.6 is generous headroom: jumping covers ground slightly faster than
	-- walking, and we would rather miss a cheater than reject a real run.
	return length / maxWalkSpeed * 0.6
end

--- Widest gap on a Spacings course, and the WalkSpeed needed to clear it.
function Challenges.getHardestJump(definition: Types.ChallengeDefinition): (number, number)
	local widest = math.max(definition.Params.GapFirst or 0, definition.Params.GapLast or 0)
	return widest, widest / Challenges.JUMP_AIRTIME
end

return Challenges
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="5">
        <Properties>
          <string name="Name">Config</string>
          <string name="Source"><![CDATA[--!strict
--[[
	Config
	----------------------------------------------------------------------------
	Every tunable value in Walk for Speed lives here. Nothing else in the code
	base hard-codes a balance number.

	The table is deep-frozen on require: a write attempt throws instead of
	silently changing balance for one server only. Change values here and
	re-sync -- never at runtime.

	Data-heavy definitions live in their own files:
	  * Shared/Ranks.lua       -- the rank ladder and its trophy requirements
	  * Shared/Challenges.lua  -- the three challenge courses
	  * Shared/Cosmetics.lua   -- trail and title definitions
]]

local RunService = game:GetService("RunService")

local Util = require(script.Parent.Util)

local Config = {}

Config.Game = {
	Name = "Walk for Speed",
	Version = "1.0.0",
	--- Bumping this invalidates nothing by itself; it is stamped onto saves so
	--- support can tell which build wrote a profile.
	BuildTag = "wfs-1.0.0",
}

--------------------------------------------------------------------------------
-- Movement & rewards
--------------------------------------------------------------------------------

Config.Movement = {
	--- WalkSpeed a player with 0 Speed runs at.
	BaseWalkSpeed = 16,
	--- Hard ceiling. The curve below is clamped to this, so raising Speed past
	--- the cap keeps earning trophies but stops making you faster.
	MaxWalkSpeed = 300,
	JumpHeight = 8.5,

	--- Horizontal studs in one step. A step is the *only* unit of progress in
	--- this game: every step is one payout, so the Steps counter and the Speed
	--- counter move together and a player can always see where their Speed came
	--- from. At the base walk speed that is a "+1" every half second.
	StudsPerStep = 8,
	--- Speed granted per step, before multipliers.
	SpeedPerStep = 1,

	--- How often the server samples every character. 10 Hz is enough for
	--- accurate distance while costing far less than a per-frame loop.
	SampleInterval = 0.1,
	--- Movement slower than this (studs/second) is treated as drift and ignored,
	--- which is what stops standing-still jitter from paying out.
	MinSpeedToCount = 2,
	--- Only reward movement while the character is on the ground, so jumping,
	--- falling and being flung earn nothing.
	RequireGrounded = true,
	--- Safety valve: never grant more than this many steps from one sample.
	MaxStepsPerSample = 25,
}

--- WalkSpeed = Base + bonus(Speed), where bonus is linear at first and then
--- follows a power curve so early progress feels fast and late progress stays
--- controllable. Tuned against the 8-stud step: 16 studs/s at zero, 30 by ~125
--- Speed (Spacings opens), 60 by ~1.5k (Conveyors), 95 by ~8.8k (Vanishing),
--- and the 300 cap around 180k.
Config.SpeedCurve = {
	--- Studs of WalkSpeed per Speed point while below `LinearUntil`.
	LinearStudsPerPoint = 0.112,
	LinearUntil = 188,
	--- Exponent applied to Speed above `LinearUntil` (0.5 == square root).
	Exponent = 0.5,
	Coefficient = 0.6223,
	--- Each rebirth scales the whole bonus by this much.
	RebirthWalkSpeedBonus = 0.1,
}

--------------------------------------------------------------------------------
-- Progression
--------------------------------------------------------------------------------

Config.Progression = {
	--- Set false to hide the rebirth button and reject the remote. The data,
	--- the multiplier maths and the server API stay live either way, so turning
	--- it back on is a one-line change rather than a feature build.
	RebirthEnabled = true,
	--- Additive multiplier per rebirth (0.25 == +25% Speed earned per reward).
	RebirthSpeedBonus = 0.25,
	--- Rebirth cost curve: Base * Growth ^ rebirths, rounded to a whole number.
	RebirthBaseCost = 78000,
	RebirthCostGrowth = 2.25,
	--- Rebirthing keeps trophies and cosmetics; these fields are what resets.
	RebirthResets = { "Speed" },
	--- Ceiling on stacked timed boosts, so a bug can never hand out infinity.
	MaxBoosts = 8,
	MaxBoostMultiplier = 10,
}

--------------------------------------------------------------------------------
-- Persistence
--------------------------------------------------------------------------------

Config.Data = {
	StoreName = "WalkForSpeed_PlayerData",
	--- Bump the scope to wipe live data (e.g. after a balance reset).
	Scope = "v1",

	AutosaveInterval = 120,
	--- Autosaves are spread across this window so 100 players do not all write
	--- in the same second and blow the DataStore budget.
	AutosaveJitter = 30,

	MaxRetries = 5,
	RetryBaseDelay = 1,
	RetryMaxDelay = 12,

	--- A session lock older than this is considered abandoned (server crash) and
	--- may be taken over.
	SessionLockTimeout = 900,
	--- How long a fresh load will keep waiting for another server to release a
	--- live lock before taking it over.
	LockWaitTime = 12,

	--- Studio has no DataStore access unless API services are enabled, so we run
	--- against an in-memory stand-in instead of failing every request.
	UseMockStoreInStudio = true,
	--- BindToClose gets ~30s; leave headroom for the final writes to land.
	ShutdownTimeout = 25,
}

--- How often the server pushes state to the owning client.
Config.Replication = {
	--- Numeric stats stream on an UnreliableRemoteEvent at this interval.
	StatInterval = 0.2,
	--- Structural changes (trophies, cosmetics, settings) go out reliably and
	--- immediately, so they are not listed here.
	AttributeInterval = 0.5,
}

--------------------------------------------------------------------------------
-- Anti-cheat
--------------------------------------------------------------------------------

Config.AntiCheat = {
	--- A sample is rejected when the player covered more ground than
	--- authoritative WalkSpeed * dt * Tolerance + Slack. Tolerance absorbs
	--- legitimate physics (slopes, conveyor-free downhill runs, lag spikes).
	SpeedTolerance = 1.45,
	DistanceSlack = 6,

	--- Distance is credited at no more than the player's authoritative WalkSpeed
	--- allows. Movement above that is banked (up to this many studs) and paid
	--- back on later samples where they moved less than they could have. That
	--- makes a laggy player whole without ever letting a speed exploiter earn
	--- faster than an honest one.
	CreditCatchUpBank = 45,

	--- Displacement beyond this in one sample is treated as a teleport: it is
	--- silently ignored and the tracker resyncs, with no violation recorded.
	--- Respawns, seats and future teleporters all land here.
	TeleportDistance = 120,

	--- Violations decay at this rate per second, so occasional lag never
	--- accumulates into a punishment.
	ViolationDecay = 0.35,
	ViolationsBeforeFlag = 12,
	ViolationsBeforeAction = 40,
	--- Off by default: a false positive that kicks a paying player costs more
	--- than an exploiter who earns nothing (rewards are already rejected).
	KickOnExploit = false,

	--- Consecutive airborne samples while still covering ground horizontally
	--- before we call it flight. At a 0.1s sample rate this is 3.5 seconds,
	--- comfortably longer than any jump arc the default Humanoid can produce.
	AirborneSamplesBeforeFlag = 35,

	--- Server-side WalkSpeed reconciliation interval.
	WalkSpeedCheckInterval = 2,
	--- Tolerance before the server rewrites a character's WalkSpeed.
	WalkSpeedEpsilon = 0.05,

	--- Token-bucket limits for client->server remotes: `rate` requests/second
	--- with a burst allowance of `burst`.
	RemoteLimits = {
		Default = { rate = 5, burst = 10 },
		RequestProfile = { rate = 1, burst = 3 },
		EquipCosmetic = { rate = 4, burst = 8 },
		UpdateSetting = { rate = 6, burst = 12 },
		Rebirth = { rate = 0.5, burst = 2 },
	},
}

--------------------------------------------------------------------------------
-- Ranks and challenges (definitions live in Shared/Ranks.lua and
-- Shared/Challenges.lua)
--------------------------------------------------------------------------------

Config.Ranks = {
	--- Ranks at this ladder position or higher are announced to the whole server
	--- when someone unlocks them.
	AnnounceFromOrder = 6,
	--- Auto-equip the title and trail a rank grants if the player has nothing
	--- equipped yet.
	AutoEquipFirstReward = true,
}

Config.Challenges = {
	--- A finish only counts if the player crossed the start pad first, so the
	--- pads are the whole anti-skip mechanism. This is how long a start is
	--- remembered before it expires.
	RunTimeout = 240,
	--- Minimum seconds between two payouts on the same course, so standing on
	--- the finish pad cannot farm it.
	AwardCooldown = 2,
	--- Touch debounce per pad, per player.
	TouchDebounce = 0.35,
	--- Courses at or above this trophy value are announced server-wide.
	AnnounceFromTrophies = 10,
	--- How often the vanishing/door timers are stepped. 20 Hz is smooth enough
	--- for a 2 second cycle and costs almost nothing.
	TimerInterval = 0.05,
}

--------------------------------------------------------------------------------
-- Interface
--------------------------------------------------------------------------------

Config.UI = {
	--- Global multiplier on every tween/animation duration. 0.5 == twice as
	--- fast. Players can scale this further in the settings panel.
	AnimationSpeed = 1,
	--- Stiffness of the exponential smoothing used for counting numbers up.
	NumberSmoothing = 9,
	--- Stiffness for progress bar fills.
	BarSmoothing = 7,

	FloatingTextLifetime = 1.15,
	FloatingTextRise = 4.5,
	--- Reward popups are merged while one is still on screen, so a player at 300
	--- WalkSpeed sees "+6 Speed" instead of six overlapping labels.
	FloatingTextMergeWindow = 0.25,
	MaxFloatingTexts = 8,

	NotificationDuration = 4.5,
	MaxNotifications = 4,

	--- UI scales relative to this viewport size.
	ReferenceResolution = Vector2.new(1280, 720),
	MinScale = 0.75,
	MaxScale = 1.2,
	--- Below this viewport width the HUD switches to its compact layout.
	CompactWidth = 700,

	Sounds = {
		Enabled = true,
		--- rbxasset:// paths ship with the client, so there is nothing to upload
		--- and nothing that can 404 in production.
		Click = "rbxasset://sounds/electronicpingshort.wav",
		Reward = "rbxasset://sounds/electronicpingshort.wav",
		Trophy = "rbxasset://sounds/switch3.wav",
	},
}

Config.Camera = {
	BaseFieldOfView = 70,
	MaxFieldOfView = 92,
	--- WalkSpeed at which the FOV kick reaches its maximum.
	FieldOfViewFullSpeed = 220,
	--- Exponential smoothing stiffness; low values feel floaty, high values snap.
	Smoothing = 3.5,
	--- Zoom limits scale up with speed so fast players can see further ahead.
	BaseMaxZoom = 40,
	FastMaxZoom = 64,
}

--------------------------------------------------------------------------------
-- World
--------------------------------------------------------------------------------

Config.Map = {
	--- Square arena, centred on the origin. Big enough that a 300 WalkSpeed
	--- player still needs ~14 seconds to cross it.
	Size = 4096,
	TileSize = 512,
	--- Height of the invisible-topped boundary wall.
	WallHeight = 120,
	SpawnRadius = 90,
	--- Number of radial running lanes leaving the spawn plaza.
	LaneCount = 8,
	LaneWidth = 60,
	--- Ramps and arches scattered along each lane.
	PropsPerLane = 6,
	--- Pedestals showcasing each trophy, ringed around spawn.
	ShowcaseRadius = 140,

	--- MapService generates the whole world, so anything else in Workspace is
	--- competing with it -- most importantly the template Baseplate, a light grey
	--- plane sitting at exactly the height of our ground tiles. Set false if you
	--- are dropping this into a place that already has geometry you want to keep.
	ClearForeignWorld = true,

	--- Applied to Lighting on every server start by MapService, which also wipes
	--- any post-processing the place happens to carry. The project file sets the
	--- same values for the Studio edit view, but these are the ones that decide
	--- what a player actually sees -- so this is the block to turn when the game
	--- is too bright or too dark.
	Lighting = {
		ClockTime = 16.5,
		Brightness = 1.2,
		Ambient = Color3.fromRGB(26, 28, 38),
		OutdoorAmbient = Color3.fromRGB(48, 52, 68),
		--- The single strongest dial in the whole look. Negative is darker;
		--- every stop is a halving.
		ExposureCompensation = -0.5,
		EnvironmentDiffuseScale = 0.3,
		EnvironmentSpecularScale = 0.2,
		ShadowSoftness = 0.5,
		GeographicLatitude = 12,

		Bloom = { Intensity = 0.15, Size = 10, Threshold = 2.5 },
		Atmosphere = {
			Density = 0.3,
			Offset = 0.05,
			Haze = 1,
			Glare = 0,
			Color = Color3.fromRGB(118, 128, 156),
			Decay = Color3.fromRGB(50, 56, 78),
		},
		ColorCorrection = {
			Brightness = -0.06,
			Contrast = 0.2,
			Saturation = 0.1,
			TintColor = Color3.fromRGB(240, 240, 250),
		},
	},

	--- Muted but readable: a late-afternoon arena lit by the sun, not by its own
	--- markings. `Accent` is a painted colour, not an emissive one -- the only
	--- things that glow are the safe ring, the rank crystals, and the challenge
	--- pads, doors and vanishing floor.
	Palette = {
		Ground = Color3.fromRGB(44, 49, 66),
		GroundAlt = Color3.fromRGB(52, 58, 77),
		Lane = Color3.fromRGB(72, 84, 118),
		Accent = Color3.fromRGB(96, 168, 255),
		Plaza = Color3.fromRGB(38, 43, 58),
		Wall = Color3.fromRGB(28, 32, 45),
	},
}

--------------------------------------------------------------------------------
-- Diagnostics
--------------------------------------------------------------------------------

Config.Debug = {
	--- Extra print output from services.
	Verbose = false,
	--- Runs the pure-logic self test on server start. Studio only by default.
	RunSelfTests = RunService:IsStudio(),
}

return Util.deepFreeze(Config)
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="6">
        <Properties>
          <string name="Name">Cosmetics</string>
          <string name="Source"><![CDATA[--!strict
--[[
	Cosmetics
	----------------------------------------------------------------------------
	Definitions for the rewards trophies hand out: overhead titles and character
	trails. Purely data + lookup helpers, so both the server (ownership, equip
	validation) and the client (rendering) read from the same source.

	Trails are described declaratively and built with a real Trail instance at
	runtime, which keeps them asset-free (nothing to upload) and cheap.
]]

local Types = require(script.Parent.Types)

local Cosmetics = {}

local function gradient(...: Color3): ColorSequence
	local colors = { ... }
	local keypoints = table.create(#colors)

	for index, color in colors do
		local time = #colors == 1 and 0 or (index - 1) / (#colors - 1)
		table.insert(keypoints, ColorSequenceKeypoint.new(time, color))
	end

	return ColorSequence.new(keypoints)
end

local FADE_OUT = NumberSequence.new {
	NumberSequenceKeypoint.new(0, 0.15),
	NumberSequenceKeypoint.new(0.6, 0.5),
	NumberSequenceKeypoint.new(1, 1),
}

local TAPER = NumberSequence.new {
	NumberSequenceKeypoint.new(0, 1),
	NumberSequenceKeypoint.new(1, 0.1),
}

Cosmetics.Trails = {
	Ember = {
		Id = "Ember",
		Name = "Ember",
		Color = gradient(Color3.fromRGB(255, 176, 32), Color3.fromRGB(255, 84, 24)),
		Transparency = FADE_OUT,
		Lifetime = 0.45,
		WidthScale = TAPER,
		LightEmission = 0.7,
	},
	Frost = {
		Id = "Frost",
		Name = "Frost",
		Color = gradient(Color3.fromRGB(196, 246, 255), Color3.fromRGB(76, 155, 255)),
		Transparency = FADE_OUT,
		Lifetime = 0.5,
		WidthScale = TAPER,
		LightEmission = 0.6,
	},
	Solar = {
		Id = "Solar",
		Name = "Solar Flare",
		Color = gradient(Color3.fromRGB(255, 244, 168), Color3.fromRGB(255, 201, 60), Color3.fromRGB(255, 122, 0)),
		Transparency = FADE_OUT,
		Lifetime = 0.55,
		WidthScale = TAPER,
		LightEmission = 0.9,
	},
	Prism = {
		Id = "Prism",
		Name = "Prism",
		Color = gradient(
			Color3.fromRGB(255, 108, 160),
			Color3.fromRGB(176, 97, 255),
			Color3.fromRGB(76, 200, 255),
			Color3.fromRGB(96, 232, 160)
		),
		Transparency = FADE_OUT,
		Lifetime = 0.6,
		WidthScale = TAPER,
		LightEmission = 0.85,
	},
	Aurora = {
		Id = "Aurora",
		Name = "Aurora",
		Color = gradient(Color3.fromRGB(127, 231, 255), Color3.fromRGB(96, 232, 190), Color3.fromRGB(176, 97, 255)),
		Transparency = FADE_OUT,
		Lifetime = 0.7,
		WidthScale = TAPER,
		LightEmission = 1,
	},
	Voidfire = {
		Id = "Voidfire",
		Name = "Voidfire",
		Color = gradient(Color3.fromRGB(255, 77, 109), Color3.fromRGB(120, 26, 92), Color3.fromRGB(24, 12, 40)),
		Transparency = FADE_OUT,
		Lifetime = 0.75,
		WidthScale = TAPER,
		LightEmission = 1,
	},
	Starfall = {
		Id = "Starfall",
		Name = "Starfall",
		Color = gradient(Color3.fromRGB(255, 255, 255), Color3.fromRGB(56, 232, 224), Color3.fromRGB(120, 140, 255)),
		Transparency = NumberSequence.new {
			NumberSequenceKeypoint.new(0, 0),
			NumberSequenceKeypoint.new(0.5, 0.35),
			NumberSequenceKeypoint.new(1, 1),
		},
		Lifetime = 0.9,
		WidthScale = TAPER,
		LightEmission = 1,
	},
} :: { [string]: Types.TrailDefinition }

Cosmetics.Titles = {
	Jogger = { Id = "Jogger", Name = "Jogger", Color = Color3.fromRGB(205, 127, 50) },
	Sprinter = { Id = "Sprinter", Name = "Sprinter", Color = Color3.fromRGB(216, 222, 233) },
	Speedster = { Id = "Speedster", Name = "Speedster", Color = Color3.fromRGB(255, 201, 60) },
	Blur = { Id = "Blur", Name = "Blur", Color = Color3.fromRGB(176, 97, 255) },
	Supersonic = { Id = "Supersonic", Name = "Supersonic", Color = Color3.fromRGB(127, 231, 255) },
	MythicRunner = { Id = "MythicRunner", Name = "Mythic Runner", Color = Color3.fromRGB(255, 77, 109) },
	Celestial = { Id = "Celestial", Name = "Celestial", Color = Color3.fromRGB(56, 232, 224) },
} :: { [string]: Types.TitleDefinition }

function Cosmetics.getTrail(id: string?): Types.TrailDefinition?
	return id and Cosmetics.Trails[id] or nil
end

function Cosmetics.getTitle(id: string?): Types.TitleDefinition?
	return id and Cosmetics.Titles[id] or nil
end

--- Applies a trail definition onto a Trail instance. Shared so the character
--- trail and the trophy showcase preview can never drift apart.
function Cosmetics.applyTrail(trail: Trail, definition: Types.TrailDefinition)
	trail.Color = definition.Color
	trail.Transparency = definition.Transparency
	trail.Lifetime = definition.Lifetime
	trail.LightEmission = definition.LightEmission
	trail.WidthScale = definition.WidthScale or NumberSequence.new(1)
	trail.MinLength = 0.2
	trail.FaceCamera = true
end

return Cosmetics
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="7">
        <Properties>
          <string name="Name">Format</string>
          <string name="Source"><![CDATA[--!strict
--[[
	Format
	----------------------------------------------------------------------------
	Display helpers. Kept shared so a number never renders one way in the HUD
	and another way in a notification.
]]

local Format = {}

local SUFFIXES = { "K", "M", "B", "T", "Qa", "Qi", "Sx", "Sp", "Oc", "No", "Dc" }

--- 1234 -> "1,234"
function Format.comma(value: number): string
	local whole = math.floor(math.abs(value))
	local text = tostring(whole)
	local formatted = text

	while true do
		local replaced: number
		formatted, replaced = formatted:gsub("^(-?%d+)(%d%d%d)", "%1,%2")
		if replaced == 0 then
			break
		end
	end

	return (value < 0 and "-" or "") .. formatted
end

--- 1234 -> "1.23K". Keeps three significant digits like most simulators do.
function Format.abbreviate(value: number): string
	local sign = value < 0 and "-" or ""
	local absolute = math.abs(value)

	if absolute < 1000 then
		-- Below 1000 we show whole numbers; fractions read as noise in a HUD.
		return sign .. tostring(math.floor(absolute))
	end

	local index = math.floor(math.log(absolute, 1000))
	index = math.clamp(index, 1, #SUFFIXES)

	local scaled = absolute / 1000 ^ index

	-- Floating point can leave us just under the next suffix (999,999 would
	-- otherwise render as "1000K" instead of "1M").
	if scaled >= 1000 and index < #SUFFIXES then
		index += 1
		scaled /= 1000
	end

	local decimals = scaled < 10 and 2 or (scaled < 100 and 1 or 0)

	-- Trim trailing zeros so we get "1.5K" rather than "1.50K".
	local text = string.format("%." .. decimals .. "f", scaled)
	if text:find("%.") then
		text = text:gsub("0+$", ""):gsub("%.$", "")
	end

	return sign .. text .. SUFFIXES[index]
end

--- Studs -> a human readable distance.
function Format.distance(studs: number): string
	if studs < 1000 then
		return string.format("%d studs", math.floor(studs))
	end
	return Format.abbreviate(studs) .. " studs"
end

--- Seconds -> "1h 04m" / "12m 03s" / "45s"
function Format.duration(seconds: number): string
	seconds = math.max(0, math.floor(seconds))

	local hours = math.floor(seconds / 3600)
	local minutes = math.floor(seconds % 3600 / 60)
	local secs = seconds % 60

	if hours > 0 then
		return string.format("%dh %02dm", hours, minutes)
	elseif minutes > 0 then
		return string.format("%dm %02ds", minutes, secs)
	end
	return string.format("%ds", secs)
end

--- 1.25 -> "x1.25" (drops the decimals when they are all zero)
function Format.multiplier(value: number): string
	local text = string.format("%.2f", value)
	text = text:gsub("0+$", ""):gsub("%.$", "")
	return "x" .. text
end

--- 0.37 -> "37%"
function Format.percent(alpha: number): string
	return string.format("%d%%", math.floor(alpha * 100 + 0.5))
end

return Format
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="8">
        <Properties>
          <string name="Name">Net</string>
          <string name="Source"><![CDATA[--!strict
--[[
	Net
	----------------------------------------------------------------------------
	Declares every remote in one table and wraps them so that:

	  * The server creates them, the client only ever looks them up. There is no
	    "create if missing" path a client could race.
	  * Every client -> server remote passes through a token bucket before the
	    handler runs, so a spamming exploiter is dropped at the door.
	  * Argument validation helpers live next to the transport, which makes
	    "never trust the client" the easy thing to do at each call site.

	Directions are declared, not implied: firing a ClientToServer remote from the
	server (or vice versa) throws immediately in Studio instead of silently doing
	nothing in production.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local Config = require(script.Parent.Config)
local Signal = require(script.Parent.Signal)

local IS_SERVER = RunService:IsServer()
local FOLDER_NAME = "Remotes"
local WAIT_TIMEOUT = 30

type Direction = "ServerToClient" | "ClientToServer"

type RemoteSpec = {
	Class: "RemoteEvent" | "UnreliableRemoteEvent" | "RemoteFunction",
	Direction: Direction,
	--- Key into Config.AntiCheat.RemoteLimits; ClientToServer remotes only.
	Limit: string?,
}

local Net = {}

--- Fires (player, remoteName) whenever a client exceeds its budget.
--- AntiCheatService subscribes to this; Net itself never punishes.
Net.RateLimited = Signal.new()

Net.Specs = {
	--- Full authoritative state. Sent on join and whenever something structural
	--- changes (trophies, cosmetics, settings, rebirth).
	ProfileSync = { Class = "RemoteEvent", Direction = "ServerToClient" },
	--- High frequency numeric stats. Unreliable on purpose: dropping a packet
	--- costs nothing because the next one carries the absolute value, and it
	--- keeps the reliable channel free for things that matter.
	StatStream = { Class = "UnreliableRemoteEvent", Direction = "ServerToClient" },
	--- One reward payout, for the floating "+N" popup.
	RewardGranted = { Class = "RemoteEvent", Direction = "ServerToClient" },
	RankUnlocked = { Class = "RemoteEvent", Direction = "ServerToClient" },
	--- A challenge course was cleared: id, trophies paid, run time, best time.
	ChallengeCompleted = { Class = "RemoteEvent", Direction = "ServerToClient" },
	--- Entering / leaving a course start pad, so the HUD can show what it needs.
	ChallengeState = { Class = "RemoteEvent", Direction = "ServerToClient" },
	Notify = { Class = "RemoteEvent", Direction = "ServerToClient" },

	RequestProfile = { Class = "RemoteFunction", Direction = "ClientToServer", Limit = "RequestProfile" },
	EquipCosmetic = { Class = "RemoteEvent", Direction = "ClientToServer", Limit = "EquipCosmetic" },
	UpdateSetting = { Class = "RemoteEvent", Direction = "ClientToServer", Limit = "UpdateSetting" },
	Rebirth = { Class = "RemoteEvent", Direction = "ClientToServer", Limit = "Rebirth" },
} :: { [string]: RemoteSpec }

local folder: Folder? = nil
local cache: { [string]: Instance } = {}

--------------------------------------------------------------------------------
-- Rate limiting
--------------------------------------------------------------------------------

type Bucket = { tokens: number, updated: number }

local buckets: { [Player]: { [string]: Bucket } } = {}

local function getLimit(name: string)
	local spec = Net.Specs[name]
	local limits = Config.AntiCheat.RemoteLimits
	return (spec and spec.Limit and limits[spec.Limit]) or limits.Default
end

--- Classic token bucket: `rate` tokens refill per second up to `burst`.
local function consume(player: Player, name: string): boolean
	local playerBuckets = buckets[player]
	if not playerBuckets then
		playerBuckets = {}
		buckets[player] = playerBuckets
	end

	local limit = getLimit(name)
	local now = os.clock()
	local bucket = playerBuckets[name]

	if not bucket then
		bucket = { tokens = limit.burst, updated = now }
		playerBuckets[name] = bucket
	else
		bucket.tokens = math.min(limit.burst, bucket.tokens + (now - bucket.updated) * limit.rate)
		bucket.updated = now
	end

	if bucket.tokens < 1 then
		return false
	end

	bucket.tokens -= 1
	return true
end

if IS_SERVER then
	Players.PlayerRemoving:Connect(function(player)
		buckets[player] = nil
	end)
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

--- Server only. Creates the remote folder and every declared remote.
function Net.init(): Folder
	assert(IS_SERVER, "Net.init may only be called on the server")

	if folder then
		return folder
	end

	local created = Instance.new("Folder")
	created.Name = FOLDER_NAME

	for name, spec in Net.Specs do
		local remote = Instance.new(spec.Class)
		remote.Name = name
		remote.Parent = created
		cache[name] = remote
	end

	created.Parent = ReplicatedStorage
	folder = created
	return created
end

local function getFolder(): Folder
	if folder then
		return folder
	end

	local found = ReplicatedStorage:WaitForChild(FOLDER_NAME, WAIT_TIMEOUT)
	assert(found, "Remotes folder never replicated -- is the server bootstrap running?")

	folder = found :: Folder
	return folder :: Folder
end

--- Looks up a remote by name, waiting for replication on the client.
function Net.get(name: string): Instance
	local cached = cache[name]
	if cached then
		return cached
	end

	local spec = Net.Specs[name]
	assert(spec, `Unknown remote "{name}"`)

	local remote = getFolder():WaitForChild(name, WAIT_TIMEOUT)
	assert(remote, `Remote "{name}" never replicated`)

	cache[name] = remote
	return remote
end

local function expectDirection(name: string, direction: Direction)
	local spec = Net.Specs[name]
	assert(spec, `Unknown remote "{name}"`)
	assert(spec.Direction == direction, `Remote "{name}" is {spec.Direction}, not {direction}`)
end

--------------------------------------------------------------------------------
-- Server API
--------------------------------------------------------------------------------

function Net.fireClient(name: string, player: Player, ...: any)
	expectDirection(name, "ServerToClient")
	local remote = Net.get(name) :: any
	remote:FireClient(player, ...)
end

function Net.fireAllClients(name: string, ...: any)
	expectDirection(name, "ServerToClient")
	local remote = Net.get(name) :: any
	remote:FireAllClients(...)
end

--- Connects a rate-limited handler. Requests over budget are dropped and
--- reported through `Net.RateLimited`.
function Net.onServerEvent(name: string, handler: (player: Player, ...any) -> ()): RBXScriptConnection
	expectDirection(name, "ClientToServer")
	local remote = Net.get(name) :: RemoteEvent

	return remote.OnServerEvent:Connect(function(player, ...)
		if not consume(player, name) then
			Net.RateLimited:Fire(player, name)
			return
		end
		handler(player, ...)
	end)
end

--- Same, for RemoteFunctions. A throttled invoke returns nil rather than
--- erroring, so a laggy client degrades instead of breaking.
function Net.onServerInvoke(name: string, handler: (player: Player, ...any) -> any)
	expectDirection(name, "ClientToServer")
	local remote = Net.get(name) :: RemoteFunction

	remote.OnServerInvoke = function(player, ...)
		if not consume(player, name) then
			Net.RateLimited:Fire(player, name)
			return nil
		end
		return handler(player, ...)
	end
end

--------------------------------------------------------------------------------
-- Client API
--------------------------------------------------------------------------------

function Net.onClientEvent(name: string, handler: (...any) -> ()): RBXScriptConnection
	expectDirection(name, "ServerToClient")
	local remote = Net.get(name) :: any
	return remote.OnClientEvent:Connect(handler)
end

function Net.fireServer(name: string, ...: any)
	expectDirection(name, "ClientToServer")
	local remote = Net.get(name) :: RemoteEvent
	remote:FireServer(...)
end

function Net.invokeServer(name: string, ...: any): any
	expectDirection(name, "ClientToServer")
	local remote = Net.get(name) :: RemoteFunction
	return remote:InvokeServer(...)
end

--------------------------------------------------------------------------------
-- Argument guards
--------------------------------------------------------------------------------

--- Returns the value only when it is a string within the length bound.
--- Anything else (including nil, tables and 4 MB of junk) yields nil.
function Net.readString(value: any, maxLength: number?): string?
	if type(value) ~= "string" then
		return nil
	end
	if #value > (maxLength or 64) then
		return nil
	end
	return value
end

function Net.readBoolean(value: any): boolean?
	if type(value) ~= "boolean" then
		return nil
	end
	return value
end

function Net.readNumber(value: any, min: number, max: number): number?
	if type(value) ~= "number" or value ~= value then
		return nil
	end
	if value < min or value > max then
		return nil
	end
	return value
end

return Net
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="9">
        <Properties>
          <string name="Name">Progression</string>
          <string name="Source"><![CDATA[--!strict
--[[
	Progression
	----------------------------------------------------------------------------
	Pure functions over a profile: how much Speed a reward is worth, what a
	rebirth costs, which cosmetics are owned.

	Nothing here touches instances or services, which is what lets the client
	display "Speed per reward: 1.4" using exactly the number the server will
	grant, and lets the self test exercise the maths without a running game.
]]

local Config = require(script.Parent.Config)
local Ranks = require(script.Parent.Ranks)
local Types = require(script.Parent.Types)

local Progression = {}

--- Sum of every permanent and timed bonus. Ranks contribute additively, timed
--- boosts multiply on top.
function Progression.getSpeedMultiplier(profile: Types.Profile, now: number?): number
	local multiplier = 1

	multiplier += Config.Progression.RebirthSpeedBonus * math.max(profile.Rebirths, 0)

	for id in profile.Ranks do
		local definition = Ranks.get(id)
		local bonus = definition and definition.Rewards.SpeedMultiplier
		if bonus then
			multiplier += bonus
		end
	end

	local timestamp = now or os.time()
	for _, boost in profile.Boosts do
		if boost.ExpiresAt == 0 or boost.ExpiresAt > timestamp then
			multiplier *= math.clamp(boost.Multiplier, 0, Config.Progression.MaxBoostMultiplier)
		end
	end

	return multiplier
end

--- Speed actually granted for one step, rounded so the HUD never shows a
--- fractional stat. This is why Steps and Speed can drift apart late on: with a
--- big enough multiplier one step is worth more than one Speed.
function Progression.getSpeedPerStep(profile: Types.Profile, now: number?): number
	local raw = Config.Movement.SpeedPerStep * Progression.getSpeedMultiplier(profile, now)
	return math.max(1, math.floor(raw + 0.5))
end

--- Exact (unrounded) value, for the "x1.35" multiplier readout.
function Progression.getRawSpeedPerStep(profile: Types.Profile, now: number?): number
	return Config.Movement.SpeedPerStep * Progression.getSpeedMultiplier(profile, now)
end

function Progression.getRebirthCost(rebirths: number): number
	local progression = Config.Progression
	local cost = progression.RebirthBaseCost * progression.RebirthCostGrowth ^ math.max(rebirths, 0)
	return math.floor(cost + 0.5)
end

function Progression.canRebirth(profile: Types.Profile): (boolean, number)
	local cost = Progression.getRebirthCost(profile.Rebirths)
	return profile.Speed >= cost, cost
end

--- Applies a rebirth in place. Caller is responsible for having checked
--- `canRebirth` first; this returns false rather than trusting it.
function Progression.applyRebirth(profile: Types.Profile): boolean
	local allowed = Progression.canRebirth(profile)
	if not allowed then
		return false
	end

	for _, key in Config.Progression.RebirthResets do
		if key == "Speed" then
			profile.Speed = 0
		elseif key == "Coins" then
			profile.Coins = 0
		end
	end

	profile.Rebirths += 1
	return true
end

--- Drops expired boosts. Returns true when the list changed.
function Progression.pruneBoosts(profile: Types.Profile, now: number?): boolean
	local timestamp = now or os.time()
	local changed = false

	for index = #profile.Boosts, 1, -1 do
		local boost = profile.Boosts[index]
		if boost.ExpiresAt ~= 0 and boost.ExpiresAt <= timestamp then
			table.remove(profile.Boosts, index)
			changed = true
		end
	end

	return changed
end

--- Cosmetics unlocked by a rank, flattened for the award path.
function Progression.collectRewards(definition: Types.RankDefinition): Types.RankRewards
	return definition.Rewards
end

function Progression.ownsTitle(profile: Types.Profile, id: string): boolean
	return profile.Titles[id] == true
end

function Progression.ownsTrail(profile: Types.Profile, id: string): boolean
	return profile.Trails[id] == true
end

return Progression
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="10">
        <Properties>
          <string name="Name">Ranks</string>
          <string name="Source"><![CDATA[--!strict
--[[
	Ranks
	----------------------------------------------------------------------------
	The rank ladder. Ranks are unlocked with **trophies**, and trophies come from
	completing challenge courses -- which is how the "+1" games this is modelled
	on actually work: speed is what you accumulate by playing, trophies are what
	you earn by proving you can use it.

	    Speed      -> earned by walking, makes you faster
	    Trophies   -> earned by clearing challenge courses (1 / 5 / 10 each)
	    Rank       -> unlocked by total trophies, pays out coins + cosmetics

	This is the file to edit when tuning the ladder. Everything else (the HUD
	rank card, the rank window, the world pedestals, the award check, the
	notification) is driven from this table.

	Adding a tier is a one-entry change: keep `Requirement` ascending and give it
	a stable, never-reused `Id` (ids are what gets written to the DataStore).
]]

local Types = require(script.Parent.Types)

local Ranks = {}

--- Rarity drives border colour, glow strength and the label on the showcase.
export type RarityStyle = { Name: string, Color: Color3, Glow: number }

Ranks.Rarities = {
	Common = { Name = "Common", Color = Color3.fromRGB(154, 165, 177), Glow = 0.15 },
	Uncommon = { Name = "Uncommon", Color = Color3.fromRGB(76, 217, 123), Glow = 0.25 },
	Rare = { Name = "Rare", Color = Color3.fromRGB(76, 155, 255), Glow = 0.4 },
	Epic = { Name = "Epic", Color = Color3.fromRGB(176, 97, 255), Glow = 0.55 },
	Legendary = { Name = "Legendary", Color = Color3.fromRGB(255, 176, 32), Glow = 0.7 },
	Mythic = { Name = "Mythic", Color = Color3.fromRGB(255, 77, 109), Glow = 0.85 },
	Celestial = { Name = "Celestial", Color = Color3.fromRGB(56, 232, 224), Glow = 1 },
} :: { [string]: RarityStyle }

--- Ordered ascending by Requirement (total trophies). `Order` is filled in below.
--- Pacing note: one lap of all three courses pays 16 trophies, so Celestial is
--- roughly 30 clean laps.
Ranks.List = {
	{
		Id = "Bronze",
		Name = "Bronze",
		Rarity = "Common" :: Types.Rarity,
		Requirement = 1,
		Color = Color3.fromRGB(205, 127, 50),
		Accent = Color3.fromRGB(120, 70, 26),
		Description = "Your first trophy. Everyone starts here.",
		Rewards = { Coins = 250, Title = "Jogger", Trail = "Ember" },
		Order = 0,
	},
	{
		Id = "Silver",
		Name = "Silver",
		Rarity = "Uncommon" :: Types.Rarity,
		Requirement = 10,
		Color = Color3.fromRGB(216, 222, 233),
		Accent = Color3.fromRGB(130, 140, 155),
		Description = "Ten trophies. The gaps are starting to look small.",
		Rewards = { Coins = 750, Title = "Sprinter", Trail = "Frost", SpeedMultiplier = 0.05 },
		Order = 0,
	},
	{
		Id = "Gold",
		Name = "Gold",
		Rarity = "Rare" :: Types.Rarity,
		Requirement = 25,
		Color = Color3.fromRGB(255, 201, 60),
		Accent = Color3.fromRGB(176, 122, 12),
		Description = "You have cleared the Conveyors more than once.",
		Rewards = { Coins = 2500, Title = "Speedster", Trail = "Solar", SpeedMultiplier = 0.1 },
		Order = 0,
	},
	{
		Id = "Platinum",
		Name = "Platinum",
		Rarity = "Epic" :: Types.Rarity,
		Requirement = 50,
		Color = Color3.fromRGB(207, 233, 245),
		Accent = Color3.fromRGB(118, 156, 176),
		Description = "Fifty trophies. The Vanishing does not scare you.",
		Rewards = { Coins = 6000, Title = "Blur", Trail = "Prism", SpeedMultiplier = 0.15 },
		Order = 0,
	},
	{
		Id = "Diamond",
		Name = "Diamond",
		Rarity = "Legendary" :: Types.Rarity,
		Requirement = 100,
		Color = Color3.fromRGB(127, 231, 255),
		Accent = Color3.fromRGB(40, 132, 176),
		Description = "One hundred trophies. Unbreakable.",
		Rewards = { Coins = 15000, Title = "Supersonic", Trail = "Aurora", SpeedMultiplier = 0.25 },
		Order = 0,
	},
	{
		Id = "Mythic",
		Name = "Mythic",
		Rarity = "Mythic" :: Types.Rarity,
		Requirement = 250,
		Color = Color3.fromRGB(255, 77, 109),
		Accent = Color3.fromRGB(104, 20, 60),
		Description = "A rumour with footsteps.",
		Rewards = { Coins = 60000, Title = "MythicRunner", Trail = "Voidfire", SpeedMultiplier = 0.5 },
		Order = 0,
	},
	{
		Id = "Celestial",
		Name = "Celestial",
		Rarity = "Celestial" :: Types.Rarity,
		Requirement = 500,
		Color = Color3.fromRGB(56, 232, 224),
		Accent = Color3.fromRGB(24, 96, 130),
		Description = "You outran the horizon.",
		Rewards = { Coins = 200000, Title = "Celestial", Trail = "Starfall", SpeedMultiplier = 1 },
		Order = 0,
	},
} :: { Types.RankDefinition }

local byId: { [string]: Types.RankDefinition } = {}

for index, definition in Ranks.List do
	definition.Order = index
	byId[definition.Id] = definition
end

Ranks.ById = byId
Ranks.Count = #Ranks.List

function Ranks.get(id: string): Types.RankDefinition?
	return byId[id]
end

function Ranks.getRarity(definition: Types.RankDefinition)
	return Ranks.Rarities[definition.Rarity] or Ranks.Rarities.Common
end

--- Highest rank the given trophy total qualifies for, or nil below the first.
function Ranks.getCurrent(trophies: number): Types.RankDefinition?
	local current: Types.RankDefinition? = nil
	for _, definition in Ranks.List do
		if trophies >= definition.Requirement then
			current = definition
		else
			break
		end
	end
	return current
end

--- Next rank above the given trophy total, or nil once the ladder is complete.
function Ranks.getNext(trophies: number): Types.RankDefinition?
	for _, definition in Ranks.List do
		if trophies < definition.Requirement then
			return definition
		end
	end
	return nil
end

--- Highest rank actually *unlocked*. This -- not `getCurrent` -- is what the HUD
--- and the nametags display, so a rebirth (or any future trophy spend) can never
--- take a rank away that was already earned.
function Ranks.getHighestOwned(owned: { [string]: number }): Types.RankDefinition?
	local highest: Types.RankDefinition? = nil
	for _, definition in Ranks.List do
		if owned[definition.Id] ~= nil then
			highest = definition
		end
	end
	return highest
end

--- First rung of the ladder the player has not unlocked yet.
function Ranks.getNextUnowned(owned: { [string]: number }): Types.RankDefinition?
	for _, definition in Ranks.List do
		if owned[definition.Id] == nil then
			return definition
		end
	end
	return nil
end

--- Progress along the ladder as the player experiences it: from the highest rank
--- they hold toward the first one they do not.
--- Returns (alpha, earned, needed, current, upcoming).
function Ranks.getLadderProgress(
	trophies: number,
	owned: { [string]: number }
): (number, number, number, Types.RankDefinition?, Types.RankDefinition?)
	local current = Ranks.getHighestOwned(owned)
	local upcoming = Ranks.getNextUnowned(owned)

	local floor = current and current.Requirement or 0
	if not upcoming then
		return 1, trophies, trophies, current, nil
	end

	local span = math.max(upcoming.Requirement - floor, 1)
	local earned = math.clamp(trophies - floor, 0, span)
	return earned / span, earned, span, current, upcoming
end

--- Every rank the trophy total qualifies for but that has not been credited yet.
--- Runs on load as well as on award, so lowering a requirement retroactively
--- grants it and there is no second code path that could disagree.
function Ranks.getMissing(trophies: number, owned: { [string]: number }): { Types.RankDefinition }
	local missing = {}
	for _, definition in Ranks.List do
		if trophies >= definition.Requirement and owned[definition.Id] == nil then
			table.insert(missing, definition)
		end
	end
	return missing
end

return Ranks
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="11">
        <Properties>
          <string name="Name">Schema</string>
          <string name="Source"><![CDATA[--!strict
--[[
	Schema
	----------------------------------------------------------------------------
	The shape of a saved profile, plus migration and sanitisation.

	Rules for changing this file:
	  * Adding a field: add it to `template()`. Existing saves pick it up
	    automatically via Util.reconcile -- no migration needed.
	  * Changing the meaning of a field: bump VERSION and add a migration step.
	  * Never rename or reuse a key.

	`sanitize` runs on every load and before every save. It is the last line of
	defence against a corrupt save, a hand-edited value or a bug elsewhere in the
	code writing nonsense into a profile.
]]

local Challenges = require(script.Parent.Challenges)
local Config = require(script.Parent.Config)
local Cosmetics = require(script.Parent.Cosmetics)
local Ranks = require(script.Parent.Ranks)
local Types = require(script.Parent.Types)
local Util = require(script.Parent.Util)

local Schema = {}

--- v2: trophies became a challenge currency and the old speed-milestone trophy
--- set became the rank ladder.
Schema.VERSION = 2

--- Largest value any counter is allowed to hold. Well below 2^53 so arithmetic
--- stays exact and JSON round-trips cleanly.
local MAX_STAT = 1e15

local function template(): Types.Profile
	return {
		Version = Schema.VERSION,

		Speed = 0,
		TotalSpeedEarned = 0,
		Distance = 0,
		Steps = 0,
		Rebirths = 0,
		Coins = 0,
		Trophies = 0,

		Ranks = {},
		Challenges = {},

		Titles = {},
		Trails = {},
		EquippedTitle = "",
		EquippedTrail = "",
		Boosts = {},

		Settings = {
			AnimationSpeed = Config.UI.AnimationSpeed,
			ShowEffects = true,
			ShowNametags = true,
			CompactHud = false,
		},

		Stats = {
			Joins = 0,
			PlayTime = 0,
			Created = 0,
			LastSeen = 0,
		},
	}
end

Schema.template = template

--- A brand new profile.
function Schema.default(): Types.Profile
	local profile = template()
	profile.Stats.Created = os.time()
	profile.Stats.LastSeen = os.time()
	return profile
end

local function sanitizeNumber(value: any, fallback: number, min: number, max: number): number
	if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then
		return fallback
	end
	return math.clamp(math.floor(value), min, max)
end

local function sanitizeFloat(value: any, fallback: number, min: number, max: number): number
	if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then
		return fallback
	end
	return math.clamp(value, min, max)
end

--- Clamps every field into a legal range and drops anything unrecognised.
--- Always returns a table safe to hand to gameplay code.
function Schema.sanitize(input: any): Types.Profile
	local profile: Types.Profile = Util.reconcile(type(input) == "table" and input or {}, template())

	profile.Version = Schema.VERSION
	profile.Speed = sanitizeNumber(profile.Speed, 0, 0, MAX_STAT)
	profile.TotalSpeedEarned = sanitizeNumber(profile.TotalSpeedEarned, profile.Speed, 0, MAX_STAT)
	profile.Distance = sanitizeFloat(profile.Distance, 0, 0, MAX_STAT)
	profile.Steps = sanitizeNumber(profile.Steps, 0, 0, MAX_STAT)
	profile.Rebirths = sanitizeNumber(profile.Rebirths, 0, 0, 1e6)
	profile.Coins = sanitizeNumber(profile.Coins, 0, 0, MAX_STAT)
	profile.Trophies = sanitizeNumber(profile.Trophies, 0, 0, MAX_STAT)

	-- Ranks: keep known ids only, value is the unix timestamp unlocked.
	local ranks: { [string]: number } = {}
	if type(profile.Ranks) == "table" then
		for id, unlockedAt in profile.Ranks do
			if type(id) == "string" and Ranks.get(id) then
				ranks[id] = sanitizeNumber(unlockedAt, 0, 0, MAX_STAT)
			end
		end
	end
	profile.Ranks = ranks

	-- Challenge records: known courses only.
	local records: { [string]: Types.ChallengeRecord } = {}
	if type(profile.Challenges) == "table" then
		for id, record in profile.Challenges do
			if type(id) == "string" and type(record) == "table" and Challenges.get(id) then
				records[id] = {
					Completions = sanitizeNumber(record.Completions, 0, 0, MAX_STAT),
					BestTime = sanitizeFloat(record.BestTime, 0, 0, 86400),
				}
			end
		end
	end
	profile.Challenges = records

	local function sanitizeOwnership(source: any, exists: (string) -> boolean): { [string]: boolean }
		local owned: { [string]: boolean } = {}
		if type(source) == "table" then
			for id, value in source do
				if type(id) == "string" and value == true and exists(id) then
					owned[id] = true
				end
			end
		end
		return owned
	end

	profile.Titles = sanitizeOwnership(profile.Titles, function(id)
		return Cosmetics.Titles[id] ~= nil
	end)
	profile.Trails = sanitizeOwnership(profile.Trails, function(id)
		return Cosmetics.Trails[id] ~= nil
	end)

	-- Equipping something you do not own is not an error, it just unequips.
	if type(profile.EquippedTitle) ~= "string" or not profile.Titles[profile.EquippedTitle] then
		profile.EquippedTitle = ""
	end
	if type(profile.EquippedTrail) ~= "string" or not profile.Trails[profile.EquippedTrail] then
		profile.EquippedTrail = ""
	end

	local boosts: { Types.Boost } = {}
	if type(profile.Boosts) == "table" then
		for _, boost in profile.Boosts do
			if type(boost) == "table" and type(boost.Id) == "string" and #boosts < Config.Progression.MaxBoosts then
				table.insert(boosts, {
					Id = boost.Id,
					Multiplier = sanitizeFloat(boost.Multiplier, 1, 0, Config.Progression.MaxBoostMultiplier),
					ExpiresAt = sanitizeNumber(boost.ExpiresAt, 0, 0, MAX_STAT),
				})
			end
		end
	end
	profile.Boosts = boosts

	local settings = profile.Settings
	settings.AnimationSpeed = sanitizeFloat(settings.AnimationSpeed, Config.UI.AnimationSpeed, 0.25, 3)
	settings.ShowEffects = settings.ShowEffects ~= false
	settings.ShowNametags = settings.ShowNametags ~= false
	settings.CompactHud = settings.CompactHud == true

	local stats = profile.Stats
	stats.Joins = sanitizeNumber(stats.Joins, 0, 0, MAX_STAT)
	stats.PlayTime = sanitizeNumber(stats.PlayTime, 0, 0, MAX_STAT)
	stats.Created = sanitizeNumber(stats.Created, os.time(), 0, MAX_STAT)
	stats.LastSeen = sanitizeNumber(stats.LastSeen, os.time(), 0, MAX_STAT)

	-- TotalSpeedEarned can never be behind current Speed; a rebirth lowers Speed
	-- but the lifetime total only ever grows.
	if profile.TotalSpeedEarned < profile.Speed then
		profile.TotalSpeedEarned = profile.Speed
	end

	return profile
end

--- Walks a stored profile up to the current VERSION. Each step is responsible
--- only for its own hop, so the chain stays readable as versions accumulate.
local migrations: { [number]: (any) -> any } = {
	--- v1 -> v2. In v1, `Trophies` was a dictionary of milestone ids unlocked by
	--- Speed. Those milestones are now the rank ladder, and `Trophies` is the
	--- challenge currency, so the old set moves across and the counter starts at
	--- zero -- nobody loses a badge they earned.
	[1] = function(profile)
		local legacy = profile.Trophies

		if type(legacy) == "table" then
			local ranks = type(profile.Ranks) == "table" and profile.Ranks or {}
			for id, unlockedAt in legacy do
				if type(id) == "string" and ranks[id] == nil then
					ranks[id] = unlockedAt
				end
			end
			profile.Ranks = ranks
		end

		profile.Trophies = 0
		profile.Version = 2
		return profile
	end,
}

function Schema.migrate(input: any): Types.Profile
	local profile = type(input) == "table" and input or {}
	local version = type(profile.Version) == "number" and profile.Version or Schema.VERSION

	while version < Schema.VERSION do
		local step = migrations[version]
		if not step then
			-- No explicit migration: reconcile handles purely additive changes.
			break
		end
		profile = step(profile)
		version += 1
	end

	return Schema.sanitize(profile)
end

--- Everything the owning client is allowed to see. Today that is the whole
--- profile; keeping the funnel explicit means adding a server-only field later
--- cannot leak by accident.
function Schema.toClientPayload(profile: Types.Profile): { [string]: any }
	return {
		Speed = profile.Speed,
		TotalSpeedEarned = profile.TotalSpeedEarned,
		Distance = profile.Distance,
		Steps = profile.Steps,
		Rebirths = profile.Rebirths,
		Coins = profile.Coins,
		Trophies = profile.Trophies,
		Ranks = Util.deepCopy(profile.Ranks),
		Challenges = Util.deepCopy(profile.Challenges),
		Titles = Util.deepCopy(profile.Titles),
		Trails = Util.deepCopy(profile.Trails),
		EquippedTitle = profile.EquippedTitle,
		EquippedTrail = profile.EquippedTrail,
		Boosts = Util.deepCopy(profile.Boosts),
		Settings = Util.deepCopy(profile.Settings),
	}
end

return Schema
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="12">
        <Properties>
          <string name="Name">Signal</string>
          <string name="Source"><![CDATA[--!strict
--[[
	Signal
	----------------------------------------------------------------------------
	A tiny dependency-free event object. Used for service-to-service
	communication so nothing has to poll or reach into another service's state.

	Handlers run on their own coroutine (via task.spawn) which means a handler
	that yields or errors can never stall the firing service.
]]

local Signal = {}
Signal.__index = Signal

export type Connection = {
	Connected: boolean,
	Disconnect: (self: Connection) -> (),
}

type ConnectionInternal = {
	Connected: boolean,
	_fn: (...any) -> (),
	_signal: any,
	Disconnect: (self: any) -> (),
}

local Connection = {}
Connection.__index = Connection

function Connection.Disconnect(self: ConnectionInternal)
	if not self.Connected then
		return
	end
	self.Connected = false

	local connections = self._signal._connections
	local index = table.find(connections, self)
	if index then
		table.remove(connections, index)
	end
end

export type Signal<T...> = {
	Connect: (self: Signal<T...>, fn: (T...) -> ()) -> Connection,
	Once: (self: Signal<T...>, fn: (T...) -> ()) -> Connection,
	Fire: (self: Signal<T...>, T...) -> (),
	Wait: (self: Signal<T...>) -> T...,
	DisconnectAll: (self: Signal<T...>) -> (),
	Destroy: (self: Signal<T...>) -> (),
}

function Signal.new<T...>(): Signal<T...>
	return (setmetatable({ _connections = {} }, Signal) :: any) :: Signal<T...>
end

function Signal:Connect(fn: (...any) -> ()): Connection
	local connection = setmetatable({
		Connected = true,
		_fn = fn,
		_signal = self,
	}, Connection)

	table.insert(self._connections, connection)
	return (connection :: any) :: Connection
end

function Signal:Once(fn: (...any) -> ()): Connection
	local connection: Connection
	connection = self:Connect(function(...)
		connection:Disconnect()
		fn(...)
	end)
	return connection
end

function Signal:Fire(...: any)
	-- Iterate a copy: a handler is allowed to disconnect itself or others.
	local connections = table.clone(self._connections)
	for _, connection in connections do
		if connection.Connected then
			task.spawn(connection._fn, ...)
		end
	end
end

function Signal:Wait(): ...any
	local thread = coroutine.running()
	local connection: Connection
	connection = self:Connect(function(...)
		connection:Disconnect()
		task.spawn(thread, ...)
	end)
	return coroutine.yield()
end

function Signal:DisconnectAll()
	for _, connection in self._connections do
		connection.Connected = false
	end
	table.clear(self._connections)
end

function Signal:Destroy()
	self:DisconnectAll()
end

return Signal
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="13">
        <Properties>
          <string name="Name">SpeedCurve</string>
          <string name="Source"><![CDATA[--!strict
--[[
	SpeedCurve
	----------------------------------------------------------------------------
	Converts the Speed stat into an actual Humanoid.WalkSpeed.

	Shared on purpose: the server uses it as the authoritative value (and the
	anti-cheat budget), and the client uses it to preview what the next reward is
	worth. One function, one answer, no drift.

	The curve is linear while Speed is small (so the first minute feels great)
	and then follows Speed^0.5 (so a player with 50x the Speed is ~7x the bonus
	instead of 50x, which would be unplayable).
]]

local Config = require(script.Parent.Config)

local SpeedCurve = {}

--- WalkSpeed added on top of the base, before the cap is applied.
function SpeedCurve.bonus(speed: number, rebirths: number?): number
	local curve = Config.SpeedCurve
	local points = math.max(speed, 0)

	local linearPoints = math.min(points, curve.LinearUntil)
	local bonus = linearPoints * curve.LinearStudsPerPoint

	if points > curve.LinearUntil then
		bonus += (points - curve.LinearUntil) ^ curve.Exponent * curve.Coefficient
	end

	local count = math.max(rebirths or 0, 0)
	if count > 0 then
		bonus *= 1 + curve.RebirthWalkSpeedBonus * count
	end

	return bonus
end

--- The authoritative WalkSpeed for a given Speed stat.
function SpeedCurve.walkSpeed(speed: number, rebirths: number?): number
	local movement = Config.Movement
	local total = movement.BaseWalkSpeed + SpeedCurve.bonus(speed, rebirths)
	return math.clamp(total, movement.BaseWalkSpeed, movement.MaxWalkSpeed)
end

--- True once extra Speed no longer converts into WalkSpeed.
function SpeedCurve.isCapped(speed: number, rebirths: number?): boolean
	return SpeedCurve.walkSpeed(speed, rebirths) >= Config.Movement.MaxWalkSpeed
end

--- 0..1 position between the base and the maximum WalkSpeed. Drives the camera
--- FOV kick and the speed lines.
function SpeedCurve.intensity(walkSpeed: number): number
	local movement = Config.Movement
	local span = movement.MaxWalkSpeed - movement.BaseWalkSpeed
	if span <= 0 then
		return 0
	end
	return math.clamp((walkSpeed - movement.BaseWalkSpeed) / span, 0, 1)
end

return SpeedCurve
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="14">
        <Properties>
          <string name="Name">Types</string>
          <string name="Source"><![CDATA[--!strict
--[[
	Types
	----------------------------------------------------------------------------
	Every shared data shape in one place so the server, the client and the save
	layer all agree on what a profile looks like.

	Nothing here has a runtime cost: the module returns an empty table and only
	exports types.
]]

export type Rarity = "Common" | "Uncommon" | "Rare" | "Epic" | "Legendary" | "Mythic" | "Celestial"

export type Difficulty = "Easy" | "Medium" | "Hard"

export type ChallengeKind = "Spacings" | "Conveyors" | "Vanishing"

--- What unlocking a rank hands the player. Every field is optional so new
--- reward kinds can be added without touching existing definitions.
export type RankRewards = {
	Coins: number?,
	Title: string?,
	Trail: string?,
	--- Additive bonus to the speed multiplier, e.g. 0.1 == +10% speed per reward.
	SpeedMultiplier: number?,
}

export type RankDefinition = {
	Id: string,
	Name: string,
	Rarity: Rarity,
	--- Total trophies required to unlock.
	Requirement: number,
	--- Colour of the badge body itself.
	Color: Color3,
	--- Secondary colour used for the base/plinth and glow.
	Accent: Color3,
	Description: string,
	Rewards: RankRewards,
	--- 1-based ladder position, assigned automatically.
	Order: number,
}

--- Geometry and rules for one challenge course. `Params` is deliberately loose:
--- each `Kind` reads the fields it needs, which keeps adding a fourth course
--- from widening a shared type nobody else uses.
export type ChallengeDefinition = {
	Id: string,
	Name: string,
	Difficulty: Difficulty,
	--- Trophies paid out per completion.
	Trophies: number,
	Description: string,
	RecommendedWalkSpeed: number,
	--- Which gap between running lanes the course sits in.
	GapIndex: number,
	StartRadius: number,
	Height: number,
	Width: number,
	Color: Color3,
	Kind: ChallengeKind,
	Params: { [string]: number },
	Order: number,
}

--- Per-player record of a course. Best time is in seconds; 0 means unset.
export type ChallengeRecord = {
	Completions: number,
	BestTime: number,
}

export type TrailDefinition = {
	Id: string,
	Name: string,
	Color: ColorSequence,
	Transparency: NumberSequence,
	Lifetime: number,
	WidthScale: NumberSequence?,
	LightEmission: number,
}

export type TitleDefinition = {
	Id: string,
	Name: string,
	Color: Color3,
}

--- Client-side preferences. Stored on the profile so they follow the player.
export type PlayerSettings = {
	AnimationSpeed: number,
	ShowEffects: boolean,
	ShowNametags: boolean,
	CompactHud: boolean,
}

export type ProfileStats = {
	Joins: number,
	PlayTime: number,
	Created: number,
	LastSeen: number,
}

--- A timed multiplier boost. Kept as a plain array so gamepasses/potions can
--- push entries later without a schema migration.
export type Boost = {
	Id: string,
	Multiplier: number,
	--- `os.time()` at which the boost stops applying. 0 == permanent.
	ExpiresAt: number,
}

export type Profile = {
	Version: number,

	Speed: number,
	TotalSpeedEarned: number,
	Distance: number,
	Steps: number,
	Rebirths: number,
	Coins: number,
	--- The challenge currency. Earned 1 / 5 / 10 at a time from courses.
	Trophies: number,

	--- Rank id -> unix timestamp it was unlocked at.
	Ranks: { [string]: number },
	--- Challenge id -> completions and best time.
	Challenges: { [string]: ChallengeRecord },

	--- Cosmetic id -> owned.
	Titles: { [string]: boolean },
	Trails: { [string]: boolean },
	EquippedTitle: string,
	EquippedTrail: string,
	Boosts: { Boost },
	Settings: PlayerSettings,
	Stats: ProfileStats,
}

--- Keys that are replicated to the owning client as plain numbers.
export type NumericKey = "Speed" | "TotalSpeedEarned" | "Distance" | "Steps" | "Rebirths" | "Coins" | "Trophies"

export type SessionLock = {
	JobId: string,
	PlaceId: number,
	At: number,
}

--- What actually lands in the DataStore. The profile is nested so we can store
--- metadata beside it without polluting gameplay data.
export type SaveEnvelope = {
	Schema: number,
	Data: Profile,
	Lock: SessionLock?,
	SavedAt: number,
}

export type LoadResult = {
	Profile: Profile,
	--- False when the datastore was unreachable and we handed back a fresh
	--- profile that must never be written back over real data.
	Persistent: boolean,
	IsNew: boolean,
}

return {}
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="15">
        <Properties>
          <string name="Name">Util</string>
          <string name="Source"><![CDATA[--!strict
--[[
	Util
	----------------------------------------------------------------------------
	Small, side-effect free helpers shared by every layer of the game.
]]

local Util = {}

--- Clamp with a friendlier name than math.clamp for readability at call sites.
function Util.clamp(value: number, min: number, max: number): number
	return math.clamp(value, min, max)
end

function Util.lerp(a: number, b: number, alpha: number): number
	return a + (b - a) * alpha
end

--- Framerate independent exponential smoothing.
--- `stiffness` is roughly "how many e-foldings per second".
function Util.damp(current: number, target: number, stiffness: number, deltaTime: number): number
	local alpha = 1 - math.exp(-stiffness * deltaTime)
	return current + (target - current) * alpha
end

--- Maps `value` from one range to another, clamped to the output range.
function Util.mapClamped(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number
	if inMax == inMin then
		return outMin
	end
	local alpha = math.clamp((value - inMin) / (inMax - inMin), 0, 1)
	return outMin + (outMax - outMin) * alpha
end

function Util.round(value: number, places: number?): number
	local factor = 10 ^ (places or 0)
	return math.floor(value * factor + 0.5) / factor
end

--- Recursive copy. Used to hand out profile snapshots without leaking the
--- authoritative table to callers.
function Util.deepCopy<T>(source: T): T
	if type(source) ~= "table" then
		return source
	end

	local result = {}
	for key, value in source :: any do
		result[key] = Util.deepCopy(value)
	end
	return (result :: any) :: T
end

--- Fills in any key missing from `target` using `template`, recursively.
--- This is how old saves pick up newly added fields without a bespoke migration.
function Util.reconcile<T>(target: { [any]: any }, template: T): T
	local result = {}

	for key, templateValue in template :: any do
		local targetValue = target[key]

		if type(templateValue) == "table" then
			if type(targetValue) == "table" then
				result[key] = Util.reconcile(targetValue, templateValue)
			else
				result[key] = Util.deepCopy(templateValue)
			end
		elseif targetValue ~= nil and type(targetValue) == type(templateValue) then
			result[key] = targetValue
		else
			result[key] = templateValue
		end
	end

	-- Preserve keys the template does not know about (dictionaries of ids such
	-- as owned trophies, titles and trails).
	for key, value in target do
		if result[key] == nil then
			result[key] = value
		end
	end

	return (result :: any) :: T
end

--- Recursively freezes a table so accidental writes fail loudly instead of
--- silently changing balance at runtime.
function Util.deepFreeze<T>(value: T): T
	if type(value) ~= "table" then
		return value
	end
	for _, item in value :: any do
		Util.deepFreeze(item)
	end
	if not table.isfrozen(value :: any) then
		table.freeze(value :: any)
	end
	return value
end

--- Number of entries in a dictionary.
function Util.count(dictionary: { [any]: any }): number
	local total = 0
	for _ in dictionary do
		total += 1
	end
	return total
end

--- Retries `fn` with exponential backoff and jitter.
--- Returns `ok, result` where `result` is the error message on failure.
function Util.retry<T>(
	fn: () -> T,
	attempts: number,
	baseDelay: number,
	maxDelay: number,
	onFailure: ((attempt: number, err: any) -> ())?
): (boolean, any)
	local lastError: any = nil

	for attempt = 1, attempts do
		local ok, result = pcall(fn)
		if ok then
			return true, result
		end

		lastError = result
		if onFailure then
			onFailure(attempt, result)
		end

		if attempt < attempts then
			-- Deterministic backoff plus a little jitter so a wave of failing
			-- servers does not retry in lockstep.
			local delay = math.min(baseDelay * 2 ^ (attempt - 1), maxDelay)
			task.wait(delay * (0.75 + math.random() * 0.5))
		end
	end

	return false, lastError
end

return Util
]]></string>
        </Properties>
      </Item>
    </Item>
  </Item>
  <Item class="ServerScriptService" referent="16">
    <Properties>
      <string name="Name">ServerScriptService</string>
    </Properties>
    <Item class="Script" referent="17">
      <Properties>
        <string name="Name">Server</string>
        <token name="RunContext">0</token>
        <string name="Source"><![CDATA[--!strict
--[[
	Walk for Speed -- server bootstrap
	----------------------------------------------------------------------------
	The single entry point on the server. Every service is a plain module with an
	`Init` method; this script decides the order they start in and nothing else.

	Order matters in exactly two places:

	  1. Net.init() must run before anything looks a remote up.
	  2. PlayerDataService starts last, because it is what begins loading
	     players. Everything that reacts to a profile (trophies, walk speed,
	     cosmetics) subscribes to its signals first, so no player can slip
	     through while a listener is still being wired up.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local Net = require(Shared.Net)

local Services = script:WaitForChild("Services")

local startedAt = os.clock()

-- Remotes exist before any service (or client) can ask for one.
Net.init()

local SaveService = require(Services.SaveService)
local PlayerDataService = require(Services.PlayerDataService)
local AntiCheatService = require(Services.AntiCheatService)
local RankService = require(Services.RankService)
local ChallengeService = require(Services.ChallengeService)
local CosmeticService = require(Services.CosmeticService)
local MovementRewardService = require(Services.MovementRewardService)
local PlayerCommandService = require(Services.PlayerCommandService)
local MapService = require(Services.MapService)

SaveService:Init()
MapService:Init()
ChallengeService:Init()
AntiCheatService:Init()

RankService:Init()
CosmeticService:Init()
MovementRewardService:Init()
PlayerCommandService:Init()

-- Last: this is what starts loading players.
PlayerDataService:Init()

if Config.Debug.RunSelfTests then
	local SelfTest = require(script:WaitForChild("SelfTest"))
	local _, failed = SelfTest.run()
	if failed > 0 then
		warn("[WalkForSpeed] Self test failures above -- balance logic is not behaving as configured.")
	end
end

print(
	string.format(
		"[WalkForSpeed] %s (%s) ready in %.0f ms.",
		Config.Game.Name,
		Config.Game.Version,
		(os.clock() - startedAt) * 1000
	)
)
]]></string>
      </Properties>
      <Item class="ModuleScript" referent="18">
        <Properties>
          <string name="Name">SelfTest</string>
          <string name="Source"><![CDATA[--!strict
--[[
	SelfTest
	----------------------------------------------------------------------------
	Assertions over the pure logic that balance depends on: number formatting,
	the speed curve, the rank ladder, the challenge courses, multipliers and save
	sanitisation (including the v1 -> v2 migration).

	None of it touches instances or DataStores, so it runs in a few milliseconds
	on server start (Studio only by default -- see Config.Debug.RunSelfTests) and
	catches the class of mistake that is otherwise invisible until a player has
	already been paid the wrong amount.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Challenges = require(Shared.Challenges)
local Config = require(Shared.Config)
local Format = require(Shared.Format)
local Progression = require(Shared.Progression)
local Ranks = require(Shared.Ranks)
local Schema = require(Shared.Schema)
local SpeedCurve = require(Shared.SpeedCurve)
local Util = require(Shared.Util)

local SelfTest = {}

local passed = 0
local failures: { string } = {}

local function check(name: string, condition: boolean, detail: string?)
	if condition then
		passed += 1
	else
		table.insert(failures, if detail then `{name} -- {detail}` else name)
	end
end

local function checkEqual(name: string, actual: any, expected: any)
	check(name, actual == expected, `expected {tostring(expected)}, got {tostring(actual)}`)
end

local function checkClose(name: string, actual: number, expected: number, tolerance: number?)
	local epsilon = tolerance or 1e-6
	check(name, math.abs(actual - expected) <= epsilon, `expected ~{expected}, got {actual}`)
end

--------------------------------------------------------------------------------

local function testFormat()
	checkEqual("Format.comma small", Format.comma(999), "999")
	checkEqual("Format.comma thousands", Format.comma(1234567), "1,234,567")

	checkEqual("Format.abbreviate under 1K", Format.abbreviate(999), "999")
	checkEqual("Format.abbreviate exact 1K", Format.abbreviate(1000), "1K")
	checkEqual("Format.abbreviate 1.5K", Format.abbreviate(1500), "1.5K")
	checkEqual("Format.abbreviate rollover", Format.abbreviate(999999), "1M")
	checkEqual("Format.abbreviate millions", Format.abbreviate(1234567), "1.23M")

	checkEqual("Format.multiplier trims", Format.multiplier(1.5), "x1.5")
	checkEqual("Format.multiplier whole", Format.multiplier(2), "x2")
	checkEqual("Format.duration minutes", Format.duration(125), "2m 05s")
end

local function testSpeedCurve()
	local movement = Config.Movement
	checkClose("Zero speed is base WalkSpeed", SpeedCurve.walkSpeed(0), movement.BaseWalkSpeed)
	check("Negative speed clamps to base", SpeedCurve.walkSpeed(-500) == movement.BaseWalkSpeed)

	local previous = -1
	local monotonic = true
	for speed = 0, 200000, 977 do
		local value = SpeedCurve.walkSpeed(speed)
		if value < previous then
			monotonic = false
			break
		end
		previous = value
	end
	check("WalkSpeed never decreases as Speed rises", monotonic)

	check("WalkSpeed respects the cap", SpeedCurve.walkSpeed(1e9) <= movement.MaxWalkSpeed)
	check("Huge speed reaches the cap", SpeedCurve.isCapped(1e9))
	check("A fresh player is not capped", not SpeedCurve.isCapped(0))

	check("Rebirths make you faster", SpeedCurve.walkSpeed(1000, 1) > SpeedCurve.walkSpeed(1000, 0))

	checkClose("Intensity at base is 0", SpeedCurve.intensity(movement.BaseWalkSpeed), 0)
	checkClose("Intensity at max is 1", SpeedCurve.intensity(movement.MaxWalkSpeed), 1)
end

local function testRanks()
	check("Ladder is not empty", #Ranks.List > 0)

	local ascending = true
	for index = 2, #Ranks.List do
		if Ranks.List[index].Requirement <= Ranks.List[index - 1].Requirement then
			ascending = false
			break
		end
	end
	check("Requirements ascend", ascending)

	local orderedIds = true
	for index, definition in Ranks.List do
		if definition.Order ~= index then
			orderedIds = false
		end
		if not Ranks.Rarities[definition.Rarity] then
			check(`Rarity exists for {definition.Id}`, false)
		end
	end
	check("Order matches ladder position", orderedIds)

	local first = Ranks.List[1]
	checkEqual("No rank below the first requirement", Ranks.getCurrent(first.Requirement - 1), nil)
	checkEqual("First rank unlocks at exactly its requirement", Ranks.getCurrent(first.Requirement), first)
	checkEqual("Next from zero is the first rank", Ranks.getNext(0), first)
	checkEqual("Nothing left after the last rank", Ranks.getNext(1e12), nil)

	local missing = Ranks.getMissing(first.Requirement, {})
	checkEqual("Exactly one rank missing at the first requirement", #missing, 1)

	local owned = { [first.Id] = os.time() }
	checkEqual("Unlocked ranks are not re-awarded", #Ranks.getMissing(first.Requirement, owned), 0)

	-- Ownership-based ladder position (what the HUD and nametags display).
	checkEqual("Nothing owned means no current rank", Ranks.getHighestOwned {}, nil)
	checkEqual("Highest owned is reported", Ranks.getHighestOwned(owned), first)
	checkEqual("Next unowned from nothing is the first", Ranks.getNextUnowned {}, first)
	checkEqual("Next unowned skips what is owned", Ranks.getNextUnowned(owned), Ranks.List[2])

	local everything: { [string]: number } = {}
	for _, definition in Ranks.List do
		everything[definition.Id] = 1
	end
	checkEqual("Nothing left when the ladder is complete", Ranks.getNextUnowned(everything), nil)

	local freshAlpha, freshEarned, freshNeeded, freshCurrent, freshNext = Ranks.getLadderProgress(0, {})
	checkEqual("Fresh player has no rank", freshCurrent, nil)
	checkEqual("Fresh player is climbing toward the first", freshNext, first)
	checkEqual("Fresh progress is zero", freshAlpha, 0)
	checkEqual("Fresh earned is zero", freshEarned, 0)
	checkEqual("Fresh target is the first requirement", freshNeeded, first.Requirement)

	-- A rank already held must survive the trophy total dropping below it.
	local _, _, _, afterSpend = Ranks.getLadderProgress(0, owned)
	checkEqual("A held rank is never taken away", afterSpend, first)

	local completeAlpha = Ranks.getLadderProgress(1e9, everything)
	checkEqual("A complete ladder reads as full", completeAlpha, 1)
end

local function testChallenges()
	check("There is at least one course", #Challenges.List > 0)

	local seenGaps: { [number]: boolean } = {}
	local seenIds: { [string]: boolean } = {}

	for index, definition in Challenges.List do
		checkEqual(`{definition.Id} order`, definition.Order, index)
		check(`{definition.Id} pays trophies`, definition.Trophies > 0)
		check(`{definition.Id} has a difficulty`, Challenges.Difficulties[definition.Difficulty] ~= nil)

		check(`{definition.Id} has a unique id`, not seenIds[definition.Id])
		seenIds[definition.Id] = true

		-- Two courses in the same lane gap would build on top of each other.
		check(`{definition.Id} has a unique lane gap`, not seenGaps[definition.GapIndex])
		seenGaps[definition.GapIndex] = true

		check(
			`{definition.Id} fits between the lanes`,
			definition.GapIndex >= 1 and definition.GapIndex <= Config.Map.LaneCount
		)

		local length = Challenges.getCourseLength(definition)
		check(`{definition.Id} has length`, length > 0, `got {length}`)

		local minimum = Challenges.getMinimumRunTime(definition, Config.Movement.MaxWalkSpeed)
		check(`{definition.Id} minimum run time is positive`, minimum > 0)
		check(
			`{definition.Id} minimum run time is beatable`,
			minimum < length / definition.RecommendedWalkSpeed,
			"a legitimate run at the recommended speed must not be rejected"
		)

		-- The course must sit past the rank pedestals and inside the walls.
		check(`{definition.Id} starts past the showcase`, definition.StartRadius > Config.Map.ShowcaseRadius)
		check(`{definition.Id} ends inside the arena`, definition.StartRadius + length + 80 < Config.Map.Size * 0.5)

		-- You must not be able to jump onto the course from the ground: the
		-- start pad is the only way in, and that is the whole anti-skip design.
		check(`{definition.Id} is above jump height`, definition.Height > Config.Movement.JumpHeight * 1.5)
	end

	-- Difficulty must actually cost more speed and pay more trophies.
	local previousSpeed, previousTrophies = 0, 0
	local scaling = true
	for _, definition in Challenges.List do
		if definition.RecommendedWalkSpeed <= previousSpeed or definition.Trophies <= previousTrophies then
			scaling = false
		end
		previousSpeed = definition.RecommendedWalkSpeed
		previousTrophies = definition.Trophies
	end
	check("Harder courses need more speed and pay more", scaling)

	-- The widest Spacings gap has to be clearable at the speed it advertises.
	local spacings = Challenges.get("Spacings")
	if spacings then
		local widest, needed = Challenges.getHardestJump(spacings)
		check(
			`Widest gap is clearable at the advertised speed`,
			needed <= spacings.RecommendedWalkSpeed,
			`{widest} studs needs {math.ceil(needed)} studs/s, course advertises {spacings.RecommendedWalkSpeed}`
		)
	end

	-- The courses have to connect to the ladder, or trophies go nowhere.
	local perLap = 0
	for _, definition in Challenges.List do
		perLap += definition.Trophies
	end
	check("A full lap pays something", perLap > 0)
	check("The first rank is one lap or less away", Ranks.List[1].Requirement <= perLap)
end

local function testProgression()
	local profile = Schema.default()
	checkClose("Fresh multiplier is 1", Progression.getSpeedMultiplier(profile), 1)
	checkEqual(
		"A fresh step is worth the configured Speed",
		Progression.getSpeedPerStep(profile),
		Config.Movement.SpeedPerStep
	)
	-- The whole point of the redesign: one step, one Speed, no hidden divisor.
	checkEqual("A step and a Speed point are the same event", Config.Movement.SpeedPerStep, 1)

	local bonusRank = nil
	for _, definition in Ranks.List do
		if definition.Rewards.SpeedMultiplier then
			bonusRank = definition
			break
		end
	end

	if bonusRank then
		profile.Ranks[bonusRank.Id] = os.time()
		checkClose(
			"Rank multipliers apply",
			Progression.getSpeedMultiplier(profile),
			1 + (bonusRank.Rewards.SpeedMultiplier :: number)
		)
	end

	local rebirthProfile = Schema.default()
	checkEqual("Rebirth cost at zero", Progression.getRebirthCost(0), Config.Progression.RebirthBaseCost)
	check("Rebirth cost grows", Progression.getRebirthCost(1) > Progression.getRebirthCost(0))

	local allowed, cost = Progression.canRebirth(rebirthProfile)
	check("Cannot rebirth with no Speed", not allowed)

	rebirthProfile.Speed = cost
	check("Can rebirth at exactly the cost", (Progression.canRebirth(rebirthProfile)))
	check("Rebirth applies", Progression.applyRebirth(rebirthProfile))
	checkEqual("Rebirth resets Speed", rebirthProfile.Speed, 0)
	checkEqual("Rebirth increments the counter", rebirthProfile.Rebirths, 1)

	local boosted = Schema.default()
	table.insert(boosted.Boosts, { Id = "Test", Multiplier = 2, ExpiresAt = 0 })
	checkClose("Permanent boosts multiply", Progression.getSpeedMultiplier(boosted), 2)

	local expired = Schema.default()
	table.insert(expired.Boosts, { Id = "Old", Multiplier = 5, ExpiresAt = 100 })
	checkClose("Expired boosts do not apply", Progression.getSpeedMultiplier(expired, 200), 1)
	check("Pruning removes expired boosts", Progression.pruneBoosts(expired, 200))
	checkEqual("Boost list is empty after pruning", #expired.Boosts, 0)
end

local function testSchema()
	local clean = Schema.sanitize(nil)
	checkEqual("Sanitising nil yields a default profile", clean.Speed, 0)
	checkEqual("A fresh profile has no trophies", clean.Trophies, 0)

	local hostile = Schema.sanitize {
		Speed = -50,
		Trophies = -12,
		Distance = 0 / 0,
		Steps = math.huge,
		Coins = "lots",
		Ranks = { NotARealRank = 1, [1] = 2 },
		Challenges = {
			NotARealCourse = { Completions = 5, BestTime = 1 },
			Spacings = { Completions = 3, BestTime = 9 },
		},
		Titles = { NotARealTitle = true },
		Trails = { Ember = true },
		EquippedTrail = "Aurora",
		EquippedTitle = "Speedster",
		Boosts = { { Id = "X", Multiplier = 1e9, ExpiresAt = 0 } },
		Settings = { AnimationSpeed = 1e6, ShowEffects = "yes" },
	}

	checkEqual("Negative Speed clamps to zero", hostile.Speed, 0)
	checkEqual("Negative trophies clamp to zero", hostile.Trophies, 0)
	checkEqual("NaN distance falls back", hostile.Distance, 0)
	check("Infinite steps are rejected", hostile.Steps < math.huge)
	checkEqual("Non-numeric coins fall back", hostile.Coins, 0)
	checkEqual("Unknown ranks are dropped", Util.count(hostile.Ranks), 0)
	checkEqual("Unknown courses are dropped", Util.count(hostile.Challenges), 1)
	checkEqual("Known course records survive", hostile.Challenges.Spacings.Completions, 3)
	checkEqual("Unknown titles are dropped", Util.count(hostile.Titles), 0)
	checkEqual("Known trails survive", hostile.Trails.Ember, true)
	checkEqual("Equipping an unowned trail unequips", hostile.EquippedTrail, "")
	checkEqual("Equipping an unowned title unequips", hostile.EquippedTitle, "")
	check("Boost multipliers are capped", hostile.Boosts[1].Multiplier <= Config.Progression.MaxBoostMultiplier)
	check("Animation speed is clamped", hostile.Settings.AnimationSpeed <= 3)
	checkEqual("Bad booleans fall back to the default", hostile.Settings.ShowEffects, true)

	local carried = Schema.sanitize { Speed = 400, TotalSpeedEarned = 10 }
	check("Lifetime total is never behind current Speed", carried.TotalSpeedEarned >= carried.Speed)

	-- v1 stored Trophies as a dictionary of speed milestones. Those become
	-- ranks, and the trophy counter starts at zero.
	local legacy = Schema.migrate {
		Version = 1,
		Speed = 2600,
		Trophies = { Bronze = 1700000000, Silver = 1700000001, NotARealRank = 5 },
	}
	checkEqual("Migration stamps the current version", legacy.Version, Schema.VERSION)
	checkEqual("Migration preserves Speed", legacy.Speed, 2600)
	checkEqual("Migration moves milestones to ranks", Util.count(legacy.Ranks), 2)
	check("Migration keeps a known rank", legacy.Ranks.Bronze ~= nil)
	checkEqual("Migration drops an unknown rank", legacy.Ranks.NotARealRank, nil)
	checkEqual("Migration resets the trophy counter", legacy.Trophies, 0)

	local current = Schema.migrate { Version = Schema.VERSION, Speed = 42, Trophies = 7 }
	checkEqual("A current profile is untouched", current.Speed, 42)
	checkEqual("A current trophy count survives", current.Trophies, 7)

	local payload = Schema.toClientPayload(Schema.default())
	checkEqual("Client payload has no Version leak", payload.Version, nil)
	check("Client payload carries settings", payload.Settings ~= nil)
	check("Client payload carries ranks", payload.Ranks ~= nil)
	check("Client payload carries challenge records", payload.Challenges ~= nil)
end

local function testUtil()
	local merged = Util.reconcile({ a = 1, extra = "keep" }, { a = 0, b = 2 })
	checkEqual("Reconcile keeps existing values", merged.a, 1)
	checkEqual("Reconcile fills missing values", merged.b, 2)
	checkEqual("Reconcile preserves unknown keys", merged.extra, "keep")

	local nested = Util.reconcile({ inner = { x = 5 } }, { inner = { x = 0, y = 1 } })
	checkEqual("Reconcile recurses", nested.inner.y, 1)
	checkEqual("Reconcile keeps nested values", nested.inner.x, 5)

	local original = { list = { 1, 2, 3 } }
	local copy = Util.deepCopy(original)
	copy.list[1] = 99
	checkEqual("deepCopy does not alias", original.list[1], 1)

	checkClose("damp converges", Util.damp(0, 10, 100, 1), 10, 0.01)
	checkEqual("mapClamped clamps high", Util.mapClamped(50, 0, 10, 0, 1), 1)
	checkEqual("mapClamped clamps low", Util.mapClamped(-50, 0, 10, 0, 1), 0)
end

--------------------------------------------------------------------------------

--- Returns (passed, failed). Also prints a summary.
function SelfTest.run(): (number, number)
	passed = 0
	table.clear(failures)

	local suites = {
		{ "Format", testFormat },
		{ "SpeedCurve", testSpeedCurve },
		{ "Ranks", testRanks },
		{ "Challenges", testChallenges },
		{ "Progression", testProgression },
		{ "Schema", testSchema },
		{ "Util", testUtil },
	}

	for _, suite in suites do
		local name = suite[1] :: string
		local ok, err = pcall(suite[2] :: () -> ())
		if not ok then
			table.insert(failures, `{name} suite errored -- {err}`)
		end
	end

	if #failures == 0 then
		print(`[SelfTest] {passed} checks passed.`)
	else
		warn(`[SelfTest] {passed} passed, {#failures} FAILED:`)
		for _, failure in failures do
			warn(`  - {failure}`)
		end
	end

	return passed, #failures
end

return SelfTest
]]></string>
        </Properties>
      </Item>
      <Item class="Folder" referent="19">
        <Properties>
          <string name="Name">Services</string>
        </Properties>
        <Item class="ModuleScript" referent="20">
          <Properties>
            <string name="Name">AntiCheatService</string>
            <string name="Source"><![CDATA[--!strict
--[[
	AntiCheatService
	----------------------------------------------------------------------------
	Bookkeeping for suspicious behaviour. It does not decide what is suspicious
	-- MovementRewardService does that, because it is the code that already knows
	what a legal displacement looks like -- this service just keeps score.

	Design notes:

	  * Scores decay over time. A player on a bad connection accumulates a
	    handful of displacement rejections an hour; a teleport exploiter
	    accumulates dozens a second. Only the second one crosses a threshold.
	  * Rejection is the primary defence and it is silent: the reward simply
	    does not happen. Kicking is opt-in (Config.AntiCheat.KickOnExploit)
	    because a false positive costs more than an exploiter who earns nothing.
	  * Rate-limit trips from Net feed in here too, so one player hammering
	    remotes and one player teleporting share a single reputation number.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local Net = require(Shared.Net)
local Signal = require(Shared.Signal)

local AntiCheatService = {}

--- (player, kind, score) -- fired once when a player crosses the flag threshold.
AntiCheatService.Flagged = Signal.new()

type Record = {
	score: number,
	updated: number,
	flagged: boolean,
	counts: { [string]: number },
}

local records: { [Player]: Record } = {}

local function getRecord(player: Player): Record
	local record = records[player]
	if not record then
		record = { score = 0, updated = os.clock(), flagged = false, counts = {} }
		records[player] = record
	end
	return record
end

local function decay(record: Record, now: number)
	local elapsed = now - record.updated
	record.updated = now
	if elapsed > 0 then
		record.score = math.max(0, record.score - elapsed * Config.AntiCheat.ViolationDecay)
	end
end

--- Records one violation. `weight` lets a blatant teleport count for more than
--- a marginal displacement overrun.
function AntiCheatService:Report(player: Player, kind: string, weight: number?)
	if not player.Parent then
		return
	end

	local record = getRecord(player)
	local now = os.clock()
	decay(record, now)

	record.score += weight or 1
	record.counts[kind] = (record.counts[kind] or 0) + 1

	local settings = Config.AntiCheat

	if not record.flagged and record.score >= settings.ViolationsBeforeFlag then
		record.flagged = true
		warn(`[AntiCheat] {player.Name} ({player.UserId}) flagged: {kind} x{record.counts[kind]}`)
		AntiCheatService.Flagged:Fire(player, kind, record.score)
	end

	if record.score >= settings.ViolationsBeforeAction then
		record.score = 0
		record.flagged = false

		if settings.KickOnExploit then
			player:Kick("Kicked for abnormal movement. If this is a mistake, please rejoin.")
		else
			warn(`[AntiCheat] {player.Name} passed the action threshold via {kind}; rewards stay rejected.`)
		end
	end
end

--- Current (decayed) suspicion score. Useful for admin tooling and dashboards.
function AntiCheatService:GetScore(player: Player): number
	local record = records[player]
	if not record then
		return 0
	end
	decay(record, os.clock())
	return record.score
end

function AntiCheatService:GetReport(player: Player): { [string]: number }
	local record = records[player]
	return record and table.clone(record.counts) or {}
end

function AntiCheatService:Init()
	-- A client that blows through its remote budget is the same kind of problem
	-- as one that teleports, so it lands on the same reputation score.
	Net.RateLimited:Connect(function(player: Player, remoteName: string)
		AntiCheatService:Report(player, `RateLimit:{remoteName}`, 2)
	end)

	Players.PlayerRemoving:Connect(function(player)
		records[player] = nil
	end)
end

return AntiCheatService
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="21">
          <Properties>
            <string name="Name">ChallengeService</string>
            <string name="Source"><![CDATA[--!strict
--[[
	ChallengeService
	----------------------------------------------------------------------------
	Builds the three challenge courses and pays out trophies for clearing them.

	This is the half of the loop that gives Speed a point: Speed is what you
	accumulate by walking, trophies are what you earn by proving you can use it,
	and ranks are bought with trophies.

	    Spacings   1 trophy    jump the widening gaps
	    Conveyors  5 trophies  out-run the belts, beat the timed doors
	    Vanishing  10 trophies ride the wave of disappearing floor

	Award rules, all server-side:
	  * A finish only counts if the player crossed the *start* pad first. The pads
	    are the whole anti-skip mechanism -- you cannot drop onto the finish.
	  * A run faster than physically possible for the course length is rejected
	    and reported, not paid.
	  * A start older than `RunTimeout` has expired; walk it again.
	  * There is a per-course cooldown, so standing on the finish pad while the
	    Touched event machine-guns cannot farm it.

	Courses are re-runnable on purpose. Farming the course *is* the loop.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Challenges = require(Shared.Challenges)
local Config = require(Shared.Config)
local Format = require(Shared.Format)
local Net = require(Shared.Net)
local Signal = require(Shared.Signal)
local Types = require(Shared.Types)

local AntiCheatService = require(script.Parent.AntiCheatService)
local PlayerDataService = require(script.Parent.PlayerDataService)

local ChallengeService = {}

--- (player, definition, runTime)
ChallengeService.Completed = Signal.new()

type TimedPart = {
	part: BasePart,
	--- Seconds of offset into the cycle.
	phase: number,
	cycle: number,
	solidFraction: number,
	--- Cached so we only write properties on a transition.
	solid: boolean,
	solidTransparency: number,
	ghostTransparency: number,
}

type Course = {
	definition: Types.ChallengeDefinition,
	model: Model,
	length: number,
	minimumRunTime: number,
	timed: { TimedPart },
}

local courses: { [string]: Course } = {}
local runs: { [Player]: { [string]: number } } = {}
local lastTouch: { [Player]: { [string]: number } } = {}
local lastAward: { [Player]: { [string]: number } } = {}
local timerAccumulator = 0

--------------------------------------------------------------------------------
-- Geometry helpers
--------------------------------------------------------------------------------

type PieceOptions = {
	Name: string,
	Parent: Instance,
	--- Forward offset along the course, in studs from the start of the body.
	Forward: number,
	--- Size as (depth along the course, height, width across the course).
	Size: Vector3,
	Height: number?,
	Color: Color3,
	Material: Enum.Material?,
	Transparency: number?,
	Collide: boolean?,
	Lateral: number?,
}

local function piece(base: CFrame, options: PieceOptions): BasePart
	local part = Instance.new("Part")
	part.Name = options.Name
	part.Anchored = true
	part.Size = options.Size
	part.CFrame = base * CFrame.new(options.Forward, options.Height or 0, options.Lateral or 0)
	part.Color = options.Color
	part.Material = options.Material or Enum.Material.SmoothPlastic
	part.Transparency = options.Transparency or 0
	part.CanCollide = options.Collide ~= false
	part.CanTouch = false
	-- Anything you can stand on stays queryable; pure decoration does not.
	part.CanQuery = part.CanCollide
	part.TopSurface = Enum.SurfaceType.Smooth
	part.BottomSurface = Enum.SurfaceType.Smooth
	part.Parent = options.Parent
	return part
end

local function label(parent: BasePart, text: string, subtext: string, color: Color3, offset: number)
	local billboard = Instance.new("BillboardGui")
	billboard.Name = "Sign"
	billboard.Size = UDim2.fromOffset(300, 96)
	billboard.StudsOffsetWorldSpace = Vector3.new(0, offset, 0)
	billboard.AlwaysOnTop = false
	billboard.LightInfluence = 0
	billboard.MaxDistance = 420
	billboard.Parent = parent

	local title = Instance.new("TextLabel")
	title.BackgroundTransparency = 1
	title.Size = UDim2.new(1, 0, 0.55, 0)
	title.Text = text
	title.TextColor3 = color
	title.TextScaled = true
	title.FontFace = Font.fromEnum(Enum.Font.FredokaOne)
	title.Parent = billboard

	local detail = Instance.new("TextLabel")
	detail.BackgroundTransparency = 1
	detail.Position = UDim2.fromScale(0, 0.55)
	detail.Size = UDim2.new(1, 0, 0.45, 0)
	detail.Text = subtext
	detail.TextColor3 = Color3.fromRGB(226, 232, 240)
	detail.TextScaled = true
	detail.FontFace = Font.fromEnum(Enum.Font.GothamMedium)
	detail.Parent = billboard
end

--------------------------------------------------------------------------------
-- Course builders
--------------------------------------------------------------------------------

--- Timed parts are phased by *minus* how long a runner at the recommended speed
--- takes to reach them, which makes the solid window travel along the course at
--- exactly that speed. Keep up and the floor is under you and the doors are
--- open the whole way; fall behind and it is not, which is the difficulty.
local function addTimed(
	course: Course,
	part: BasePart,
	forward: number,
	cycle: number,
	solidFraction: number,
	solidTransparency: number?
)
	local speed = math.max(course.definition.RecommendedWalkSpeed, 1)

	table.insert(course.timed, {
		part = part,
		phase = -forward / speed,
		cycle = cycle,
		solidFraction = solidFraction,
		solid = true,
		solidTransparency = solidTransparency or 0,
		ghostTransparency = 0.82,
	})
end

local function buildSpacings(course: Course, base: CFrame, bodyStart: number): number
	local definition = course.definition
	local params = definition.Params
	local platforms = math.floor(params.Platforms)
	local depth = params.PlatformDepth

	local forward = bodyStart

	for index = 1, platforms do
		piece(base, {
			Name = `Platform_{index}`,
			Parent = course.model,
			Forward = forward + depth * 0.5,
			Height = definition.Height,
			Size = Vector3.new(depth, 2, definition.Width),
			Color = definition.Color,
			Material = Enum.Material.Metal,
		})

		-- Marks the lip you jump from, rather than lighting the whole platform.
		for _, end_ in { -1, 1 } do
			piece(base, {
				Name = `PlatformEdge_{index}_{end_}`,
				Parent = course.model,
				Forward = forward + depth * 0.5 + end_ * (depth * 0.5 - 0.75),
				Height = definition.Height + 1.1,
				Size = Vector3.new(1.5, 0.3, definition.Width),
				Color = definition.Color,
				Material = Enum.Material.SmoothPlastic,
				Collide = false,
			})
		end

		forward += depth

		if index < platforms then
			local alpha = (index - 1) / math.max(platforms - 2, 1)
			forward += params.GapFirst + (params.GapLast - params.GapFirst) * alpha
		end
	end

	return forward
end

local function buildConveyors(course: Course, base: CFrame, bodyStart: number): number
	local definition = course.definition
	local params = definition.Params
	local segments = math.floor(params.Segments)
	local length = params.SegmentLength

	local forward = bodyStart

	for index = 1, segments do
		local belt = piece(base, {
			Name = `Belt_{index}`,
			Parent = course.model,
			Forward = forward + length * 0.5,
			Height = definition.Height,
			Size = Vector3.new(length, 2, definition.Width),
			Color = definition.Color,
			Material = Enum.Material.DiamondPlate,
		})

		-- Anchored-part conveyor physics is inconsistent across Roblox versions,
		-- so the belts are flavour and the doors below are what actually enforce
		-- the speed requirement. If the belts do push, the course is harder; if
		-- they do not, it is still gated.
		belt.AssemblyLinearVelocity = base.RightVector * -params.BeltSpeed

		for _, side in { -1, 1 } do
			piece(base, {
				Name = `Rail_{index}_{side}`,
				Parent = course.model,
				Forward = forward + length * 0.5,
				Height = definition.Height + 1.5,
				Lateral = side * definition.Width * 0.5,
				Size = Vector3.new(length, 1, 0.6),
				Color = definition.Color,
				Material = Enum.Material.Metal,
				Collide = false,
			})
		end

		forward += length

		if index < segments then
			local door = piece(base, {
				Name = `Door_{index}`,
				Parent = course.model,
				Forward = forward,
				Height = definition.Height + 7,
				Size = Vector3.new(1.5, 12, definition.Width),
				Color = Color3.fromRGB(255, 92, 108),
				Material = Enum.Material.Neon,
			})
			addTimed(course, door, forward - bodyStart, params.DoorCycle, 1 - params.DoorOpenFraction)
		end
	end

	return forward
end

local function buildVanishing(course: Course, base: CFrame, bodyStart: number): number
	local definition = course.definition
	local params = definition.Params
	local rows = math.floor(params.Rows)

	local forward = bodyStart

	for index = 1, rows do
		local row = piece(base, {
			Name = `Row_{index}`,
			Parent = course.model,
			Forward = forward + params.RowDepth * 0.5,
			Height = definition.Height,
			Size = Vector3.new(params.RowDepth, 2, definition.Width),
			Color = definition.Color,
			Material = Enum.Material.Neon,
			-- Eleven 12x26 slabs of Neon is a wall of light. Solid rows stay
			-- part-transparent so the course reads as glowing floor, not as a
			-- lamp you are standing inside.
			Transparency = 0.3,
		})

		-- The first row never vanishes, so there is always somewhere to stand
		-- while you time your run.
		if index > 1 then
			addTimed(course, row, forward - bodyStart, params.Cycle, params.VisibleFraction, 0.3)
		end

		forward += params.RowSpacing
	end

	return forward - (params.RowSpacing - params.RowDepth)
end

--------------------------------------------------------------------------------
-- Assembly
--------------------------------------------------------------------------------

local function buildStairs(course: Course, base: CFrame)
	local definition = course.definition
	local steps = math.max(math.ceil(definition.Height / 1.5), 1)
	local rise = definition.Height / steps

	-- The last step must land flush against the start pad (which begins at
	-- forward 0), or there is a hole in the approach at 20 studs up.
	for index = 1, steps do
		piece(base, {
			Name = `Step_{index}`,
			Parent = course.model,
			Forward = -4 * (steps - index) - 2,
			Height = rise * index - 1,
			Size = Vector3.new(4.5, 2, definition.Width),
			Color = Config.Map.Palette.Lane,
			Material = Enum.Material.Metal,
		})
	end
end

local function buildCourse(definition: Types.ChallengeDefinition, parent: Instance): Course
	local angle = (definition.GapIndex - 0.5) * (math.pi * 2 / Config.Map.LaneCount)
	local direction = Vector3.new(math.cos(angle), 0, math.sin(angle))

	-- Local +X runs away from spawn, local +Z is lateral. Every builder works in
	-- these coordinates, so none of them has to think about world angles.
	local base = CFrame.new(direction * definition.StartRadius) * CFrame.Angles(0, -angle, 0)

	local model = Instance.new("Model")
	model.Name = definition.Id

	local course: Course = {
		definition = definition,
		model = model,
		length = Challenges.getCourseLength(definition),
		minimumRunTime = Challenges.getMinimumRunTime(definition, Config.Movement.MaxWalkSpeed),
		timed = {},
	}

	buildStairs(course, base)

	local difficulty = Challenges.getDifficulty(definition)

	local startPad = piece(base, {
		Name = "StartPad",
		Parent = model,
		Forward = 12,
		Height = definition.Height,
		Size = Vector3.new(24, 2, definition.Width),
		Color = Color3.fromRGB(76, 217, 123),
		Material = Enum.Material.Neon,
		Transparency = 0.2,
	})
	startPad.CanTouch = true

	label(
		startPad,
		definition.Name,
		`{difficulty.Name}  ·  +{definition.Trophies} {definition.Trophies == 1 and "Trophy" or "Trophies"}`
			.. `  ·  needs {definition.RecommendedWalkSpeed} studs/s`,
		difficulty.Color,
		9
	)

	local bodyStart = 30
	local bodyEnd = bodyStart

	if definition.Kind == "Spacings" then
		bodyEnd = buildSpacings(course, base, bodyStart)
	elseif definition.Kind == "Conveyors" then
		bodyEnd = buildConveyors(course, base, bodyStart)
	elseif definition.Kind == "Vanishing" then
		bodyEnd = buildVanishing(course, base, bodyStart)
	end

	local finishPad = piece(base, {
		Name = "FinishPad",
		Parent = model,
		Forward = bodyEnd + 14,
		Height = definition.Height,
		Size = Vector3.new(28, 2, definition.Width),
		Color = Color3.fromRGB(255, 201, 60),
		Material = Enum.Material.Neon,
		Transparency = 0.2,
	})
	finishPad.CanTouch = true

	label(finishPad, "FINISH", `+{definition.Trophies}`, Color3.fromRGB(255, 201, 60), 9)

	-- Measure the real distance the builder laid down rather than trusting the
	-- estimate, so the "you cannot have run that fast" check stays honest if a
	-- course is retuned.
	course.length = bodyEnd - bodyStart
	course.minimumRunTime = course.length / Config.Movement.MaxWalkSpeed * 0.6

	model.PrimaryPart = startPad
	model.Parent = parent

	return course
end

--------------------------------------------------------------------------------
-- Runs
--------------------------------------------------------------------------------

local function tableFor(store: { [Player]: { [string]: number } }, player: Player): { [string]: number }
	local existing = store[player]
	if not existing then
		existing = {}
		store[player] = existing
	end
	return existing
end

local function debounced(player: Player, key: string): boolean
	local touches = tableFor(lastTouch, player)
	local now = os.clock()

	if now - (touches[key] or 0) < Config.Challenges.TouchDebounce then
		return true
	end

	touches[key] = now
	return false
end

local function onStart(player: Player, course: Course)
	local id = course.definition.Id
	if debounced(player, `start:{id}`) then
		return
	end

	tableFor(runs, player)[id] = os.clock()
	Net.fireClient("ChallengeState", player, { Id = id, Active = true })
end

local function onFinish(player: Player, course: Course)
	local definition = course.definition
	local id = definition.Id

	if debounced(player, `finish:{id}`) then
		return
	end

	local playerRuns = tableFor(runs, player)
	local startedAt = playerRuns[id]

	if not startedAt then
		Net.fireClient("Notify", player, {
			Title = "Start at the green pad",
			Body = `{definition.Name} only counts if you run it from the start.`,
			Kind = "Warning",
		})
		return
	end

	local elapsed = os.clock() - startedAt

	if elapsed > Config.Challenges.RunTimeout then
		playerRuns[id] = nil
		Net.fireClient("ChallengeState", player, { Id = id, Active = false })
		Net.fireClient("Notify", player, {
			Title = "Run expired",
			Body = "That took a while -- start again from the green pad.",
			Kind = "Warning",
		})
		return
	end

	if elapsed < course.minimumRunTime then
		-- Nothing can cross the course this fast, so something moved the
		-- character. No payout, and it goes on the record.
		AntiCheatService:Report(player, `Challenge:{id}`, 3)
		return
	end

	local awards = tableFor(lastAward, player)
	local now = os.clock()
	if now - (awards[id] or 0) < Config.Challenges.AwardCooldown then
		return
	end
	awards[id] = now

	playerRuns[id] = nil

	PlayerDataService:AddStat(player, "Trophies", definition.Trophies)
	local completions, bestTime, isBest = PlayerDataService:RecordChallenge(player, id, elapsed)

	Net.fireClient("ChallengeState", player, { Id = id, Active = false })
	Net.fireClient("ChallengeCompleted", player, {
		Id = id,
		Trophies = definition.Trophies,
		Time = elapsed,
		BestTime = bestTime,
		PersonalBest = isBest,
		Completions = completions,
	})

	if definition.Trophies >= Config.Challenges.AnnounceFromTrophies then
		local announcement = {
			Title = `{definition.Name} cleared!`,
			Body = `{player.DisplayName} took {Format.duration(elapsed)} for +{definition.Trophies} trophies.`,
			Kind = "Success",
		}
		for _, other in Players:GetPlayers() do
			if other ~= player then
				Net.fireClient("Notify", other, announcement)
			end
		end
	end

	ChallengeService.Completed:Fire(player, definition, elapsed)
end

local function bindPad(course: Course, pad: BasePart, handler: (Player, Course) -> ())
	pad.Touched:Connect(function(hit)
		local character = hit.Parent
		if not character then
			return
		end

		local player = Players:GetPlayerFromCharacter(character)
		if not player or not PlayerDataService:IsLoaded(player) then
			return
		end

		handler(player, course)
	end)
end

--------------------------------------------------------------------------------
-- Timers
--------------------------------------------------------------------------------

local function stepTimers()
	local now = workspace:GetServerTimeNow()

	for _, course in courses do
		for _, entry in course.timed do
			local position = (now + entry.phase) % entry.cycle
			local solid = position < entry.cycle * entry.solidFraction

			if solid ~= entry.solid then
				entry.solid = solid
				entry.part.CanCollide = solid
				entry.part.Transparency = if solid then entry.solidTransparency else entry.ghostTransparency
			end
		end
	end
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

function ChallengeService:GetCourse(id: string): Course?
	return courses[id]
end

function ChallengeService:Init()
	local existing = workspace:FindFirstChild("Challenges")
	if existing then
		existing:Destroy()
	end

	local folder = Instance.new("Folder")
	folder.Name = "Challenges"

	for _, definition in Challenges.List do
		local course = buildCourse(definition, folder)
		courses[definition.Id] = course

		local startPad = course.model:FindFirstChild("StartPad") :: BasePart?
		local finishPad = course.model:FindFirstChild("FinishPad") :: BasePart?
		if startPad then
			bindPad(course, startPad, onStart)
		end
		if finishPad then
			bindPad(course, finishPad, onFinish)
		end
	end

	folder.Parent = workspace

	RunService.PostSimulation:Connect(function(deltaTime)
		timerAccumulator += deltaTime
		if timerAccumulator < Config.Challenges.TimerInterval then
			return
		end
		timerAccumulator = 0
		stepTimers()
	end)

	Players.PlayerRemoving:Connect(function(player)
		runs[player] = nil
		lastTouch[player] = nil
		lastAward[player] = nil
	end)

	if Config.Debug.Verbose then
		for id, course in courses do
			print(`[ChallengeService] {id}: {math.floor(course.length)} studs, min {course.minimumRunTime}s`)
		end
	end
end

return ChallengeService
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="22">
          <Properties>
            <string name="Name">CosmeticService</string>
            <string name="Source"><![CDATA[--!strict
--[[
	CosmeticService
	----------------------------------------------------------------------------
	Renders the trail a player has equipped, on the server, so every client sees
	the same thing and no client can grant itself one.

	The equipped id arrives as a player attribute (published by
	PlayerDataService), which means this service needs no remotes of its own and
	reacts to changes with a single property-changed signal per player.

	Titles are rendered client-side by NametagController, because whether you
	see other players' nametags is a local preference.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Cosmetics = require(Shared.Cosmetics)

local CosmeticService = {}

local TRAIL_NAME = "EquippedTrail"

local function buildTrail(root: BasePart): Trail
	local top = Instance.new("Attachment")
	top.Name = "TrailTop"
	top.Position = Vector3.new(0, 1.1, 0)
	top.Parent = root

	local bottom = Instance.new("Attachment")
	bottom.Name = "TrailBottom"
	bottom.Position = Vector3.new(0, -1.1, 0)
	bottom.Parent = root

	local trail = Instance.new("Trail")
	trail.Name = TRAIL_NAME
	trail.Attachment0 = top
	trail.Attachment1 = bottom
	trail.Enabled = false
	trail.Parent = root

	return trail
end

local function refresh(player: Player)
	local character = player.Character
	if not character then
		return
	end

	local root = character:FindFirstChild("HumanoidRootPart") :: BasePart?
	if not root then
		return
	end

	local trail = root:FindFirstChild(TRAIL_NAME) :: Trail?
	if not trail then
		trail = buildTrail(root)
	end
	assert(trail, "trail")

	local id = player:GetAttribute("Trail")
	local definition = if type(id) == "string" then Cosmetics.getTrail(id) else nil

	if not definition then
		trail.Enabled = false
		return
	end

	Cosmetics.applyTrail(trail, definition)
	trail.Enabled = true
end

local function onCharacterAdded(player: Player, character: Model)
	if not character:FindFirstChild("HumanoidRootPart") then
		character:WaitForChild("HumanoidRootPart", 10)
	end
	if player.Character ~= character then
		return
	end
	refresh(player)
end

function CosmeticService:Init()
	local function bind(player: Player)
		player:GetAttributeChangedSignal("Trail"):Connect(function()
			refresh(player)
		end)

		player.CharacterAdded:Connect(function(character)
			task.spawn(onCharacterAdded, player, character)
		end)

		if player.Character then
			task.spawn(onCharacterAdded, player, player.Character)
		end
	end

	Players.PlayerAdded:Connect(bind)
	for _, player in Players:GetPlayers() do
		bind(player)
	end
end

return CosmeticService
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="23">
          <Properties>
            <string name="Name">MapService</string>
            <string name="Source"><![CDATA[--!strict
--[[
	MapService
	----------------------------------------------------------------------------
	Builds the arena at runtime.

	Generating the world in code rather than shipping a .rbxlx keeps the map in
	version control as readable text, lets the layout follow Config.Map (change
	`Size` and everything scales), and keeps the build a single replication burst
	because nothing is parented into Workspace until it is finished.

	Art direction rule: **Neon means something.** It is reserved for the safe-zone
	ring and the rank crystals here, and for the challenge pads, doors and
	vanishing floor in ChallengeService -- things a player has to read at a
	glance. Everything else is painted: real materials, real colours, lit by the
	sun. When every edge glows, none of them do.

	Layout:
	  * A square ground plane split into tiles, so content streaming has
	    something sensible to stream.
	  * A raised spawn plaza at the origin: flat, enclosed by a marker ring, with
	    nothing to fall off. Marked Persistent so it is always loaded.
	  * Radial running lanes leaving the plaza, each with ramps and arches to
	    give high-speed movement something to read against.
	  * A ring of pedestals, one per rank, showing the ladder in world space.
	  * Three challenge courses, built separately by ChallengeService, sitting in
	    the gaps between lanes past the pedestal ring.
	  * Boundary walls so a 300 WalkSpeed player cannot leave the map.
]]

local Lighting = game:GetService("Lighting")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local Format = require(Shared.Format)
local Ranks = require(Shared.Ranks)

local MapService = {}

local MAP = Config.Map
local PALETTE = MAP.Palette

--------------------------------------------------------------------------------
-- Builders
--------------------------------------------------------------------------------

type PartOptions = {
	Size: Vector3,
	CFrame: CFrame,
	Color: Color3,
	Material: Enum.Material?,
	Transparency: number?,
	--- Decoration defaults to collision- and query-free, which keeps physics and
	--- raycasts cheap on a map with a few hundred parts.
	Decoration: boolean?,
	Shape: Enum.PartType?,
	Class: string?,
	Name: string?,
	Parent: Instance?,
}

local function makePart(options: PartOptions): BasePart
	local part = Instance.new((options.Class or "Part") :: any) :: BasePart
	part.Name = options.Name or "Piece"
	part.Anchored = true
	part.Size = options.Size
	part.CFrame = options.CFrame
	part.Color = options.Color
	part.Material = options.Material or Enum.Material.SmoothPlastic
	part.Transparency = options.Transparency or 0
	part.TopSurface = Enum.SurfaceType.Smooth
	part.BottomSurface = Enum.SurfaceType.Smooth

	if options.Shape and part:IsA("Part") then
		part.Shape = options.Shape
	end

	if options.Decoration then
		part.CanCollide = false
		part.CanTouch = false
		part.CanQuery = false
		part.CastShadow = false
	else
		part.CanTouch = false
	end

	part.Parent = options.Parent
	return part
end

local function buildGround(parent: Instance)
	local folder = Instance.new("Folder")
	folder.Name = "Ground"

	local tiles = math.max(1, math.floor(MAP.Size / MAP.TileSize))
	local offset = (tiles - 1) * MAP.TileSize * 0.5

	for x = 0, tiles - 1 do
		for z = 0, tiles - 1 do
			local checker = (x + z) % 2 == 0
			makePart {
				Name = `Tile_{x}_{z}`,
				Size = Vector3.new(MAP.TileSize, 8, MAP.TileSize),
				CFrame = CFrame.new(x * MAP.TileSize - offset, -4, z * MAP.TileSize - offset),
				Color = checker and PALETTE.Ground or PALETTE.GroundAlt,
				Material = Enum.Material.Concrete,
				Parent = folder,
			}
		end
	end

	folder.Parent = parent
end

local function buildWalls(parent: Instance)
	local folder = Instance.new("Folder")
	folder.Name = "Bounds"

	local half = MAP.Size * 0.5
	local thickness = 12

	for index, direction in
		{ Vector3.new(1, 0, 0), Vector3.new(-1, 0, 0), Vector3.new(0, 0, 1), Vector3.new(0, 0, -1) }
	do
		local along = Vector3.new(direction.Z, 0, direction.X)
		local size = Vector3.new(
			math.abs(along.X) * MAP.Size + math.abs(direction.X) * thickness,
			MAP.WallHeight,
			math.abs(along.Z) * MAP.Size + math.abs(direction.Z) * thickness
		)

		local wall = makePart {
			Name = `Wall_{index}`,
			Size = size,
			CFrame = CFrame.new(direction * (half + thickness * 0.5) + Vector3.new(0, MAP.WallHeight * 0.5, 0)),
			Color = PALETTE.Wall,
			Material = Enum.Material.Metal,
			Parent = folder,
		}
		wall.Reflectance = 0.05

		-- Glowing cap so the boundary reads from a distance at speed.
		makePart {
			Name = `WallTrim_{index}`,
			Size = Vector3.new(size.X, 1.2, size.Z),
			CFrame = CFrame.new(direction * (half + thickness * 0.5) + Vector3.new(0, MAP.WallHeight, 0)),
			Color = PALETTE.Accent,
			Material = Enum.Material.Metal,
			Decoration = true,
			Parent = folder,
		}
	end

	folder.Parent = parent
end

local function buildSpawn(parent: Instance)
	local plaza = Instance.new("Model")
	plaza.Name = "SpawnPlaza"

	-- Always loaded: a player must never land on unstreamed geometry.
	plaza.ModelStreamingMode = Enum.ModelStreamingMode.Persistent

	-- The plaza top sits 1.5 studs above the ground: high enough to read as a
	-- raised platform, low enough that a Humanoid steps onto it without a ramp.
	-- Nothing on the plaza is ever more than one step from spawn height, which
	-- is what makes it a genuinely safe area rather than one you can fall off.
	makePart {
		Name = "Platform",
		Class = "Part",
		Shape = Enum.PartType.Cylinder,
		Size = Vector3.new(6, MAP.SpawnRadius * 2, MAP.SpawnRadius * 2),
		CFrame = CFrame.new(0, -1.5, 0) * CFrame.Angles(0, 0, math.rad(90)),
		Color = PALETTE.Plaza,
		Material = Enum.Material.Concrete,
		Parent = plaza,
	}

	-- Marker ring: the visual edge of the safe area.
	--
	-- Built from segments, NOT from a Cylinder part. A Cylinder is a solid disc,
	-- so the "ring" was a 172 stud wide sheet of Neon lying at the player's feet
	-- -- which is an area light the size of the plaza, and it whited out the
	-- whole screen. Neon is for thin accents only; nothing emissive in this map
	-- is allowed to be a large surface.
	local ringSegments = 48
	local ringRadius = MAP.SpawnRadius - 4
	local ringChord = 2 * ringRadius * math.sin(math.pi / ringSegments) + 0.4

	for index = 1, ringSegments do
		local angle = (index - 0.5) / ringSegments * math.pi * 2
		local direction = Vector3.new(math.cos(angle), 0, math.sin(angle))

		makePart {
			Name = `SafeRing_{index}`,
			Size = Vector3.new(1.4, 0.4, ringChord),
			CFrame = CFrame.new(direction * ringRadius + Vector3.new(0, 1.5, 0)) * CFrame.Angles(0, -angle, 0),
			Color = PALETTE.Accent,
			Material = Enum.Material.Neon,
			Transparency = 0.15,
			Decoration = true,
			Parent = plaza,
		}
	end

	-- Decorative skirts where each lane meets the plaza. Non-collidable on
	-- purpose: the 1.5 stud lip is already walkable, and a stray collidable
	-- wedge in a running lane is the last thing a speed game needs.
	for index = 1, MAP.LaneCount do
		local angle = (index - 1) / MAP.LaneCount * math.pi * 2
		local direction = Vector3.new(math.cos(angle), 0, math.sin(angle))

		-- A line across the lane mouth, not a slab of it.
		makePart {
			Name = `LaneSkirt_{index}`,
			Size = Vector3.new(MAP.LaneWidth * 0.9, 0.4, 2),
			CFrame = CFrame.new(direction * (MAP.SpawnRadius + 6) + Vector3.new(0, 0.25, 0))
				* CFrame.Angles(0, -angle, 0),
			Color = PALETTE.Accent,
			Material = Enum.Material.SmoothPlastic,
			Decoration = true,
			Parent = plaza,
		}
	end

	-- Decorative pillars framing the plaza.
	local pillarCount = 16
	for index = 1, pillarCount do
		local angle = (index - 1) / pillarCount * math.pi * 2
		local direction = Vector3.new(math.cos(angle), 0, math.sin(angle))

		makePart {
			Name = `Pillar_{index}`,
			Size = Vector3.new(5, 26, 5),
			CFrame = CFrame.new(direction * (MAP.SpawnRadius - 10) + Vector3.new(0, 16, 0)),
			Color = PALETTE.Wall,
			Material = Enum.Material.Slate,
			Decoration = true,
			Parent = plaza,
		}
		makePart {
			Name = `PillarCap_{index}`,
			Size = Vector3.new(5.4, 1.6, 5.4),
			CFrame = CFrame.new(direction * (MAP.SpawnRadius - 10) + Vector3.new(0, 28.5, 0)),
			Color = PALETTE.Accent,
			Material = Enum.Material.Metal,
			Decoration = true,
			Parent = plaza,
		}
	end

	local spawnLocation = Instance.new("SpawnLocation")
	spawnLocation.Name = "Spawn"
	spawnLocation.Anchored = true
	spawnLocation.Size = Vector3.new(24, 1, 24)
	-- Flush with the plaza surface: no lip to catch on, no drop to fall from.
	spawnLocation.CFrame = CFrame.new(0, 1, 0)
	-- Not Neon: this is the surface you spawn standing on, so it is the last
	-- thing that should be emissive. The ring around it does the glowing.
	spawnLocation.Color = PALETTE.Lane
	spawnLocation.Material = Enum.Material.Metal
	spawnLocation.Transparency = 0
	spawnLocation.TopSurface = Enum.SurfaceType.Smooth
	spawnLocation.CanCollide = true
	spawnLocation.CanTouch = false
	-- No combat in this game, so no spawn forcefield to sit through.
	spawnLocation.Duration = 0
	spawnLocation.Neutral = true
	spawnLocation.Parent = plaza

	plaza.PrimaryPart = spawnLocation
	plaza.Parent = parent
end

local function buildShowcase(parent: Instance)
	local folder = Instance.new("Folder")
	folder.Name = "RankShowcase"

	local count = #Ranks.List
	local laneStep = math.pi * 2 / MAP.LaneCount

	for index, definition in Ranks.List do
		-- Sit in the gaps *between* the running lanes, so a pedestal is never an
		-- obstacle in the middle of a sprint. Falls back to an even ring if the
		-- ladder ever grows past the lane count.
		local angle = if count <= MAP.LaneCount then (index - 0.5) * laneStep else (index - 1) / count * math.pi * 2
		local direction = Vector3.new(math.cos(angle), 0, math.sin(angle))
		local base = direction * MAP.ShowcaseRadius

		makePart {
			Name = `Pedestal_{definition.Id}`,
			Size = Vector3.new(12, 8, 12),
			CFrame = CFrame.new(base + Vector3.new(0, 4, 0)),
			Color = PALETTE.Wall,
			Material = Enum.Material.Slate,
			Parent = folder,
		}

		local crystal = makePart {
			Name = `Crystal_{definition.Id}`,
			Size = Vector3.new(6, 10, 6),
			CFrame = CFrame.new(base + Vector3.new(0, 13, 0)) * CFrame.Angles(0, math.rad(45), 0),
			Color = definition.Color,
			Material = Enum.Material.Neon,
			Decoration = true,
			Parent = folder,
		}
		crystal.Transparency = 0.15

		local light = Instance.new("PointLight")
		light.Color = definition.Color
		light.Brightness = 0.9
		light.Range = 14
		light.Shadows = false
		light.Parent = crystal

		local billboard = Instance.new("BillboardGui")
		billboard.Name = "Label"
		billboard.Size = UDim2.fromOffset(240, 68)
		billboard.StudsOffsetWorldSpace = Vector3.new(0, 9, 0)
		billboard.AlwaysOnTop = false
		billboard.LightInfluence = 0
		billboard.MaxDistance = 220
		billboard.Parent = crystal

		local title = Instance.new("TextLabel")
		title.BackgroundTransparency = 1
		title.Size = UDim2.new(1, 0, 0.55, 0)
		title.Text = definition.Name
		title.TextColor3 = definition.Color
		title.TextScaled = true
		title.FontFace = Font.fromEnum(Enum.Font.FredokaOne)
		title.Parent = billboard

		local requirement = Instance.new("TextLabel")
		requirement.BackgroundTransparency = 1
		requirement.Position = UDim2.fromScale(0, 0.55)
		requirement.Size = UDim2.new(1, 0, 0.45, 0)
		requirement.Text =
			`{Format.comma(definition.Requirement)} {definition.Requirement == 1 and "trophy" or "trophies"}`
		requirement.TextColor3 = Color3.fromRGB(226, 232, 240)
		requirement.TextScaled = true
		requirement.FontFace = Font.fromEnum(Enum.Font.GothamMedium)
		requirement.Parent = billboard
	end

	folder.Parent = parent
end

local function buildLanes(parent: Instance)
	local folder = Instance.new("Folder")
	folder.Name = "Lanes"

	local half = MAP.Size * 0.5
	local laneLength = half - MAP.SpawnRadius - 40
	local laneCentre = MAP.SpawnRadius + 20 + laneLength * 0.5

	for index = 1, MAP.LaneCount do
		local angle = (index - 1) / MAP.LaneCount * math.pi * 2
		local direction = Vector3.new(math.cos(angle), 0, math.sin(angle))
		local rotation = CFrame.Angles(0, -angle, 0)

		makePart {
			Name = `Lane_{index}`,
			Size = Vector3.new(laneLength, 0.4, MAP.LaneWidth),
			CFrame = CFrame.new(direction * laneCentre + Vector3.new(0, 0.3, 0)) * rotation,
			Color = PALETTE.Lane,
			Material = Enum.Material.SmoothPlastic,
			Decoration = true,
			Parent = folder,
		}

		for _, side in { -1, 1 } do
			makePart {
				Name = `LaneEdge_{index}_{side}`,
				Size = Vector3.new(laneLength, 0.6, 1.6),
				CFrame = CFrame.new(direction * laneCentre + Vector3.new(0, 0.5, 0))
					* rotation
					* CFrame.new(0, 0, side * MAP.LaneWidth * 0.5),
				Color = PALETTE.Accent,
				Material = Enum.Material.SmoothPlastic,
				Decoration = true,
				Parent = folder,
			}
		end

		-- Props along the lane: alternating step platforms and arches. The
		-- platform is a 1.5 stud lip -- something to read speed against and to
		-- jump from, never something that stops a run.
		for prop = 1, MAP.PropsPerLane do
			local alpha = prop / (MAP.PropsPerLane + 1)
			local distance = MAP.SpawnRadius + 40 + alpha * laneLength
			local origin = direction * distance

			if prop % 2 == 1 then
				makePart {
					Name = `Platform_{index}_{prop}`,
					Size = Vector3.new(28, 1.5, MAP.LaneWidth * 0.7),
					CFrame = CFrame.new(origin + Vector3.new(0, 0.75, 0)) * rotation,
					Color = PALETTE.Lane,
					Material = Enum.Material.Metal,
					Parent = folder,
				}
				-- An edge line, not a glowing lid: the platform top is 28x42, and
				-- 24 of those across the map is a lot of emissive area.
				for _, end_ in { -1, 1 } do
					makePart {
						Name = `PlatformGlow_{index}_{prop}_{end_}`,
						Size = Vector3.new(2, 0.3, MAP.LaneWidth * 0.7 + 0.4),
						CFrame = CFrame.new(origin + Vector3.new(0, 1.55, 0))
							* rotation
							* CFrame.new(end_ * 13, 0, 0),
						Color = PALETTE.Accent,
						Material = Enum.Material.SmoothPlastic,
						Decoration = true,
						Parent = folder,
					}
				end
			else
				for _, side in { -1, 1 } do
					makePart {
						Name = `ArchLeg_{index}_{prop}_{side}`,
						Size = Vector3.new(3, 30, 3),
						CFrame = CFrame.new(origin + Vector3.new(0, 15, 0))
							* rotation
							* CFrame.new(0, 0, side * MAP.LaneWidth * 0.45),
						Color = PALETTE.Wall,
						Material = Enum.Material.Metal,
						Decoration = true,
						Parent = folder,
					}
				end

				makePart {
					Name = `ArchTop_{index}_{prop}`,
					Size = Vector3.new(3, 2.5, MAP.LaneWidth * 0.9 + 3),
					CFrame = CFrame.new(origin + Vector3.new(0, 30, 0)) * rotation,
					Color = PALETTE.Accent,
					Material = Enum.Material.Metal,
					Decoration = true,
					Parent = folder,
				}
			end
		end
	end

	folder.Parent = parent
end

--- Post-processing classes this service owns outright. Anything of these types
--- already in Lighting is destroyed and rebuilt from Config, because "create it
--- only if one is missing" means a stray effect left in the place -- a bright
--- Bloom, a SunRays, a leftover ColorCorrection -- silently wins forever and no
--- amount of retuning Config has any effect.
local MANAGED_EFFECTS = {
	"Atmosphere",
	"BloomEffect",
	"ColorCorrectionEffect",
	"SunRaysEffect",
	"BlurEffect",
	"DepthOfFieldEffect",
}

local function applyAtmosphere(): number
	local settings = Config.Map.Lighting
	local removed = 0

	for _, child in Lighting:GetChildren() do
		for _, className in MANAGED_EFFECTS do
			if child:IsA(className) then
				child:Destroy()
				removed += 1
				break
			end
		end
	end

	-- Applied here rather than trusting the place file: Rojo sets these on the
	-- edit DataModel, but a saved place, a template or a hand-edit in Studio can
	-- all override them. This runs on every server start.
	Lighting.ClockTime = settings.ClockTime
	Lighting.Brightness = settings.Brightness
	Lighting.Ambient = settings.Ambient
	Lighting.OutdoorAmbient = settings.OutdoorAmbient
	Lighting.ExposureCompensation = settings.ExposureCompensation
	Lighting.EnvironmentDiffuseScale = settings.EnvironmentDiffuseScale
	Lighting.EnvironmentSpecularScale = settings.EnvironmentSpecularScale
	Lighting.ShadowSoftness = settings.ShadowSoftness
	Lighting.GeographicLatitude = settings.GeographicLatitude
	Lighting.GlobalShadows = true

	local atmosphere = Instance.new("Atmosphere")
	atmosphere.Density = settings.Atmosphere.Density
	atmosphere.Offset = settings.Atmosphere.Offset
	atmosphere.Haze = settings.Atmosphere.Haze
	atmosphere.Glare = settings.Atmosphere.Glare
	atmosphere.Color = settings.Atmosphere.Color
	atmosphere.Decay = settings.Atmosphere.Decay
	atmosphere.Parent = Lighting

	-- Bloom multiplies every emissive surface in view. It is the difference
	-- between "the markings glow" and "the screen is white".
	local bloom = Instance.new("BloomEffect")
	bloom.Intensity = settings.Bloom.Intensity
	bloom.Size = settings.Bloom.Size
	bloom.Threshold = settings.Bloom.Threshold
	bloom.Parent = Lighting

	local correction = Instance.new("ColorCorrectionEffect")
	correction.Brightness = settings.ColorCorrection.Brightness
	correction.Contrast = settings.ColorCorrection.Contrast
	correction.Saturation = settings.ColorCorrection.Saturation
	correction.TintColor = settings.ColorCorrection.TintColor
	correction.Parent = Lighting

	return removed
end

--- Anything in Workspace that is not ours and would fight the arena: the
--- template Baseplate (a light grey 512x512 plane sitting at exactly the height
--- of our ground tiles), stray spawns, leftover terrain.
local function clearForeignWorld(): { string }
	local removed = {}

	if not Config.Map.ClearForeignWorld then
		return removed
	end

	for _, child in workspace:GetChildren() do
		local isBaseplate = child:IsA("BasePart") and child.Name == "Baseplate"
		local isStraySpawn = child:IsA("SpawnLocation")

		if isBaseplate or isStraySpawn then
			table.insert(removed, `{child.ClassName}:{child.Name}`)
			child:Destroy()
		end
	end

	local terrain = workspace.Terrain
	if terrain then
		-- MaxExtents is a Region3int16: Min/Max, no Width. Empty terrain reports
		-- a zero-volume region.
		local extents = terrain.MaxExtents
		local span = extents.Max - extents.Min
		if span.X ~= 0 or span.Y ~= 0 or span.Z ~= 0 then
			table.insert(removed, "Terrain")
			terrain:Clear()
		end
	end

	return removed
end

--------------------------------------------------------------------------------
-- Entry point
--------------------------------------------------------------------------------

function MapService:Init()
	local existing = workspace:FindFirstChild("Map")
	if existing then
		existing:Destroy()
	end

	local cleared = clearForeignWorld()

	-- A Folder rather than a Model: folders are transparent to content
	-- streaming, which keeps the spawn plaza's Persistent setting meaningful.
	local map = Instance.new("Folder")
	map.Name = "Map"

	buildGround(map)
	buildWalls(map)
	buildLanes(map)
	buildShowcase(map)
	buildSpawn(map)

	map.Parent = workspace

	local removedEffects = applyAtmosphere()

	-- Always printed, not gated behind Verbose: if the arena ever looks wrong,
	-- this line is the first thing worth reading.
	print(
		string.format(
			"[MapService] %d instances | cleared %d foreign effect(s)%s | exposure %.2f, brightness %.2f",
			#map:GetDescendants(),
			removedEffects,
			#cleared > 0 and (" | removed " .. table.concat(cleared, ", ")) or "",
			Lighting.ExposureCompensation,
			Lighting.Brightness
		)
	)
end

return MapService
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="24">
          <Properties>
            <string name="Name">MockStore</string>
            <string name="Source"><![CDATA[--!strict
--[[
	MockStore
	----------------------------------------------------------------------------
	An in-memory stand-in for a GlobalDataStore.

	Studio has no DataStore access unless "Enable Studio Access to API Services"
	is ticked, and a team member playtesting should never be able to touch live
	player data anyway. Rather than let every request fail (and every code path
	that depends on a load silently degrade), we swap in this object, which
	implements the same surface with the same yielding behaviour.

	It deliberately deep-copies on the way in and out, exactly like the real
	service does across the network boundary, so a bug that relies on holding a
	reference to a stored table fails in Studio too.
]]

local Util = require(game:GetService("ReplicatedStorage").Shared.Util)

local MockStore = {}
MockStore.__index = MockStore

export type MockStore = typeof(setmetatable(
	{} :: {
		_data: { [string]: any },
		_keyInfo: { [string]: any },
		_latency: number,
	},
	MockStore
))

--- `latency` fakes a round trip so ordering bugs surface in Studio, not live.
function MockStore.new(latency: number?): MockStore
	return setmetatable({
		_data = {},
		_keyInfo = {},
		_latency = latency or 0.05,
	}, MockStore) :: any
end

function MockStore:_yield()
	if self._latency > 0 then
		task.wait(self._latency)
	end
end

function MockStore:GetAsync(key: string): any
	self:_yield()
	return Util.deepCopy(self._data[key])
end

function MockStore:SetAsync(key: string, value: any, userIds: { number }?): string
	self:_yield()
	self._data[key] = Util.deepCopy(value)
	self._keyInfo[key] = { UserIds = userIds }
	return key
end

function MockStore:UpdateAsync(key: string, transform: (any, any) -> ...any): ...any
	self:_yield()

	local current = Util.deepCopy(self._data[key])
	local newValue, userIds = transform(current, self._keyInfo[key])

	if newValue == nil then
		-- Matches the real service: returning nil cancels the write.
		return nil
	end

	self._data[key] = Util.deepCopy(newValue)
	self._keyInfo[key] = { UserIds = userIds }
	return Util.deepCopy(newValue)
end

function MockStore:RemoveAsync(key: string): any
	self:_yield()
	local previous = self._data[key]
	self._data[key] = nil
	self._keyInfo[key] = nil
	return Util.deepCopy(previous)
end

return MockStore
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="25">
          <Properties>
            <string name="Name">MovementRewardService</string>
            <string name="Source"><![CDATA[--!strict
--[[
	MovementRewardService
	----------------------------------------------------------------------------
	The core loop: measure how far each player actually walked and pay them for
	it. Everything here runs on the server; the client is never asked how far it
	moved and never sends a reward request.

	One step is the only unit of progress: every Config.Movement.StudsPerStep
	studs is one step, and one step is one payout. Steps and Speed are therefore
	the same event counted twice, which is what keeps the two HUD numbers in
	agreement instead of drifting apart for no visible reason.

	How a sample is judged (every Config.Movement.SampleInterval seconds):

	  1. Displacement is measured on the XZ plane only, so falling, jumping and
	     riding a lift are worth nothing.
	  2. Anything beyond TeleportDistance is a teleport (respawn, seat, future
	     teleporters). The tracker resyncs and the sample is dropped with no
	     violation -- teleporting is legal, it just is not walking.
	  3. Anything beyond `authoritative WalkSpeed * dt * tolerance + slack` is
	     impossible for that player's stats. Dropped, and reported to
	     AntiCheatService. Note the budget comes from the *profile*, not from
	     Humanoid.WalkSpeed, so an exploiter writing WalkSpeed on their client
	     changes nothing about what they can earn.
	  3b. What survives is then *credited* at no more than the authoritative
	     WalkSpeed allows, with the surplus banked and repaid on slower samples.
	     Rejection alone is not enough: it leaves a window between "legal speed"
	     and "obviously impossible" that a modest speed hack could farm. Clamping
	     closes it, and the bank means a player whose position updates arrive
	     bunched together still gets paid for every stud they really walked.
	  4. Airborne samples are dropped (Config.Movement.RequireGrounded), and a
	     long unbroken airborne streak while still covering ground is reported
	     as flight.
	  5. Movement slower than MinSpeedToCount is drift and is dropped, which is
	     what stops a character resting on a slope from earning.

	One RunService.PostSimulation connection drives every player. There is no
	per-player loop, no per-frame work and nothing that scales worse than O(n).
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local Net = require(Shared.Net)
local Progression = require(Shared.Progression)
local SpeedCurve = require(Shared.SpeedCurve)

local AntiCheatService = require(script.Parent.AntiCheatService)
local PlayerDataService = require(script.Parent.PlayerDataService)

local MovementRewardService = {}

type Tracker = {
	player: Player,
	character: Model,
	humanoid: Humanoid,
	root: BasePart,

	lastPosition: Vector3,
	stepAccumulator: number,
	--- Studs earned but not yet credited, held back by the rate clamp.
	catchUpBank: number,
	airborneStreak: number,
	nextWalkSpeedCheck: number,
	connections: { RBXScriptConnection },
}

local trackers: { [Player]: Tracker } = {}
local sampleAccumulator = 0

--------------------------------------------------------------------------------
-- WalkSpeed authority
--------------------------------------------------------------------------------

--- The WalkSpeed a player is entitled to, derived from saved data only.
local function authoritativeWalkSpeed(player: Player): number
	local profile = PlayerDataService:Get(player)
	if not profile then
		return Config.Movement.BaseWalkSpeed
	end
	return SpeedCurve.walkSpeed(profile.Speed, profile.Rebirths)
end

local function applyWalkSpeed(player: Player)
	local tracker = trackers[player]
	if not tracker then
		return
	end

	local humanoid = tracker.humanoid
	if humanoid.Parent == nil or humanoid.Health <= 0 then
		return
	end

	humanoid.WalkSpeed = authoritativeWalkSpeed(player)
end

--- Exposed so admin tools and the rebirth path can force a refresh.
function MovementRewardService:RefreshWalkSpeed(player: Player)
	applyWalkSpeed(player)
end

--------------------------------------------------------------------------------
-- Character tracking
--------------------------------------------------------------------------------

local function untrack(player: Player)
	local tracker = trackers[player]
	if not tracker then
		return
	end

	for _, connection in tracker.connections do
		connection:Disconnect()
	end
	trackers[player] = nil
end

local function onCharacterAdded(player: Player, character: Model)
	untrack(player)

	local humanoid = character:WaitForChild("Humanoid", 10) :: Humanoid?
	local root = character:WaitForChild("HumanoidRootPart", 10) :: BasePart?
	if not humanoid or not root then
		return
	end

	-- The character may have been swapped out again while we waited.
	if player.Character ~= character or character.Parent == nil then
		return
	end

	humanoid.UseJumpPower = false
	humanoid.JumpHeight = Config.Movement.JumpHeight
	humanoid.WalkSpeed = authoritativeWalkSpeed(player)

	local tracker: Tracker = {
		player = player,
		character = character,
		humanoid = humanoid,
		root = root,

		lastPosition = root.Position,
		stepAccumulator = 0,
		catchUpBank = 0,
		airborneStreak = 0,
		-- Reconcile on the very first sample: the profile may have finished
		-- loading in the moment between spawn and now.
		nextWalkSpeedCheck = 0,
		connections = {},
	}

	table.insert(
		tracker.connections,
		humanoid.Died:Connect(function()
			untrack(player)
		end)
	)

	-- Seats teleport the root part; resync instead of counting the jump.
	table.insert(
		tracker.connections,
		humanoid:GetPropertyChangedSignal("SeatPart"):Connect(function()
			local live = trackers[player]
			if live then
				live.lastPosition = live.root.Position
			end
		end)
	)

	trackers[player] = tracker
end

--------------------------------------------------------------------------------
-- Sampling
--------------------------------------------------------------------------------

local function sample(tracker: Tracker, elapsed: number, now: number)
	local player = tracker.player
	local root = tracker.root
	local humanoid = tracker.humanoid

	if root.Parent == nil or humanoid.Parent == nil or humanoid.Health <= 0 then
		return
	end

	local profile = PlayerDataService:Get(player)
	if not profile then
		return
	end

	-- Keep the client honest about its own WalkSpeed. This does not stop a local
	-- exploit (nothing can), it just guarantees the server's copy is correct so
	-- physics replication and the reward budget agree.
	local authoritative = SpeedCurve.walkSpeed(profile.Speed, profile.Rebirths)

	if now >= tracker.nextWalkSpeedCheck then
		tracker.nextWalkSpeedCheck = now + Config.AntiCheat.WalkSpeedCheckInterval
		if math.abs(humanoid.WalkSpeed - authoritative) > Config.AntiCheat.WalkSpeedEpsilon then
			humanoid.WalkSpeed = authoritative
		end
	end

	local position = root.Position
	local offset = position - tracker.lastPosition
	tracker.lastPosition = position

	local distance = Vector3.new(offset.X, 0, offset.Z).Magnitude
	if distance <= 0 then
		tracker.airborneStreak = 0
		return
	end

	local settings = Config.AntiCheat

	-- (2) Teleport: legal, but not walking.
	if distance > settings.TeleportDistance then
		return
	end

	-- (3) Faster than this player could possibly be.
	local allowance = authoritative * elapsed * settings.SpeedTolerance + settings.DistanceSlack
	if distance > allowance then
		AntiCheatService:Report(player, "Displacement", 1)
		return
	end

	-- (4) Airborne.
	if humanoid.SeatPart ~= nil then
		tracker.airborneStreak = 0
		return
	end

	if Config.Movement.RequireGrounded and humanoid.FloorMaterial == Enum.Material.Air then
		if distance > Config.Movement.MinSpeedToCount * elapsed then
			tracker.airborneStreak += 1
			if tracker.airborneStreak == settings.AirborneSamplesBeforeFlag then
				AntiCheatService:Report(player, "Flight", 4)
			end
		else
			tracker.airborneStreak = 0
		end
		return
	end

	tracker.airborneStreak = 0

	-- (5) Drift.
	if distance < Config.Movement.MinSpeedToCount * elapsed then
		return
	end

	--------------------------------------------------------------------------
	-- Legitimate movement from here down.
	--------------------------------------------------------------------------

	-- (3b) Credit at the honest rate, banking any surplus.
	local legitimate = authoritative * elapsed
	local credited = distance

	if credited > legitimate then
		local room = math.max(Config.AntiCheat.CreditCatchUpBank - tracker.catchUpBank, 0)
		tracker.catchUpBank += math.min(credited - legitimate, room)
		credited = legitimate
	elseif tracker.catchUpBank > 0 then
		local repay = math.min(tracker.catchUpBank, legitimate - credited)
		tracker.catchUpBank -= repay
		credited += repay
	end

	if credited <= 0 then
		return
	end

	PlayerDataService:AddStat(player, "Distance", credited)

	-- One step, one payout.
	tracker.stepAccumulator += credited

	local stride = Config.Movement.StudsPerStep
	if tracker.stepAccumulator < stride then
		return
	end

	local steps = math.floor(tracker.stepAccumulator / stride)
	if steps > Config.Movement.MaxStepsPerSample then
		-- Cannot happen given the displacement check above, but a balance change
		-- that lowers StudsPerStep should degrade, not explode.
		steps = Config.Movement.MaxStepsPerSample
		tracker.stepAccumulator = 0
	else
		tracker.stepAccumulator -= steps * stride
	end

	PlayerDataService:AddStat(player, "Steps", steps)

	local gained = steps * Progression.getSpeedPerStep(profile)
	local total = PlayerDataService:AddSpeed(player, gained)

	Net.fireClient("RewardGranted", player, {
		Amount = gained,
		Steps = steps,
		Speed = total,
	})
end

local function step(deltaTime: number)
	sampleAccumulator += deltaTime
	local interval = Config.Movement.SampleInterval
	if sampleAccumulator < interval then
		return
	end

	local elapsed = sampleAccumulator
	sampleAccumulator = 0

	local now = os.clock()
	for _, tracker in trackers do
		sample(tracker, elapsed, now)
	end
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

function MovementRewardService:Init()
	local function bind(player: Player)
		player.CharacterAdded:Connect(function(character)
			task.spawn(onCharacterAdded, player, character)
		end)

		if player.Character then
			task.spawn(onCharacterAdded, player, player.Character)
		end
	end

	Players.PlayerAdded:Connect(bind)
	for _, player in Players:GetPlayers() do
		bind(player)
	end

	Players.PlayerRemoving:Connect(untrack)

	-- Speed changed: give the player their new WalkSpeed straight away rather
	-- than waiting for the next reconciliation tick.
	PlayerDataService.SpeedChanged:Connect(function(player: Player)
		applyWalkSpeed(player)
	end)

	-- Rebirths and boosts change the curve without changing Speed.
	PlayerDataService.StructureChanged:Connect(function(player: Player)
		applyWalkSpeed(player)
	end)

	PlayerDataService.ProfileLoaded:Connect(function(player: Player)
		applyWalkSpeed(player)
	end)

	RunService.PostSimulation:Connect(step)
end

return MovementRewardService
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="26">
          <Properties>
            <string name="Name">PlayerCommandService</string>
            <string name="Source"><![CDATA[--!strict
--[[
	PlayerCommandService
	----------------------------------------------------------------------------
	Every client -> server request in the game lands here, and every one of them
	is validated against the server's own copy of the profile before anything
	happens.

	The pattern for each handler is deliberately identical:
	    read + type-check the arguments -> look up the live profile ->
	    check the player is allowed -> mutate -> resync.

	Rate limiting happens one layer down in Net, so a handler never has to think
	about spam.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local Cosmetics = require(Shared.Cosmetics)
local Net = require(Shared.Net)
local Progression = require(Shared.Progression)
local Schema = require(Shared.Schema)

local PlayerDataService = require(script.Parent.PlayerDataService)

local PlayerCommandService = {}

--- Settings the client is allowed to change, with their validators.
local SETTING_VALIDATORS: { [string]: (any) -> any } = {
	AnimationSpeed = function(value)
		return Net.readNumber(value, 0.25, 3)
	end,
	ShowEffects = function(value)
		return Net.readBoolean(value)
	end,
	ShowNametags = function(value)
		return Net.readBoolean(value)
	end,
	CompactHud = function(value)
		return Net.readBoolean(value)
	end,
}

local function notify(player: Player, title: string, body: string, kind: string?)
	Net.fireClient("Notify", player, { Title = title, Body = body, Kind = kind or "Info" })
end

local function onEquipCosmetic(player: Player, kind: any, id: any)
	local kindName = Net.readString(kind, 16)
	local cosmeticId = Net.readString(id, 32)
	if not kindName or cosmeticId == nil then
		return
	end

	local profile = PlayerDataService:Get(player)
	if not profile then
		return
	end

	-- An empty id means "unequip", which is always allowed.
	if kindName == "Title" then
		if cosmeticId ~= "" then
			if not Cosmetics.getTitle(cosmeticId) or not Progression.ownsTitle(profile, cosmeticId) then
				return
			end
		end
		profile.EquippedTitle = cosmeticId
	elseif kindName == "Trail" then
		if cosmeticId ~= "" then
			if not Cosmetics.getTrail(cosmeticId) or not Progression.ownsTrail(profile, cosmeticId) then
				return
			end
		end
		profile.EquippedTrail = cosmeticId
	else
		return
	end

	PlayerDataService:MarkStructural(player)
end

local function onUpdateSetting(player: Player, key: any, value: any)
	local settingName = Net.readString(key, 32)
	if not settingName then
		return
	end

	local validator = SETTING_VALIDATORS[settingName]
	if not validator then
		return
	end

	local clean = validator(value)
	if clean == nil then
		return
	end

	local profile = PlayerDataService:Get(player)
	if not profile then
		return
	end
	(profile.Settings :: any)[settingName] = clean
	PlayerDataService:MarkStructural(player)
end

local function onRebirth(player: Player)
	if not Config.Progression.RebirthEnabled then
		return
	end

	local profile = PlayerDataService:Get(player)
	if not profile then
		return
	end

	local allowed, cost = Progression.canRebirth(profile)
	if not allowed then
		notify(player, "Not yet", `Rebirth needs {cost} Speed.`, "Warning")
		return
	end

	local ok, message = PlayerDataService:PerformRebirth(player)
	if ok then
		notify(player, "Rebirth!", `{message} -- your Speed multiplier grew.`, "Success")
	else
		notify(player, "Rebirth failed", message, "Warning")
	end
end

local function onRequestProfile(player: Player): { [string]: any }?
	local profile = PlayerDataService:Get(player)
	if not profile then
		return nil
	end
	return Schema.toClientPayload(profile)
end

function PlayerCommandService:Init()
	Net.onServerEvent("EquipCosmetic", onEquipCosmetic)
	Net.onServerEvent("UpdateSetting", onUpdateSetting)
	Net.onServerEvent("Rebirth", onRebirth)
	Net.onServerInvoke("RequestProfile", onRequestProfile)

	if Config.Debug.Verbose then
		print("[PlayerCommandService] Remote handlers bound.")
	end
end

return PlayerCommandService
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="27">
          <Properties>
            <string name="Name">PlayerDataService</string>
            <string name="Source"><![CDATA[--!strict
--[[
	PlayerDataService
	----------------------------------------------------------------------------
	Owns the in-memory profile for every player in the server and is the only
	place gameplay code is allowed to mutate one.

	Responsibilities:
	  * Load on join / save + release on leave, with the "player left while we
	    were loading" race handled explicitly.
	  * Batched replication. Numeric stats stream to the owner at 5 Hz over an
	    unreliable remote; structural changes (trophies, cosmetics, settings) go
	    out reliably and immediately.
	  * Public state (Speed, title, trail, current trophy) is published as player
	    attributes, which replicate to everyone for free -- that is what drives
	    nametags and other players' trails with zero extra remotes.
	  * Staggered autosaves and a BindToClose flush.

	Other services react through the signals at the top rather than polling.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local Net = require(Shared.Net)
local Progression = require(Shared.Progression)
local Schema = require(Shared.Schema)
local Ranks = require(Shared.Ranks)
local Signal = require(Shared.Signal)
local Types = require(Shared.Types)

local SaveService = require(script.Parent.SaveService)

local PlayerDataService = {}

--- (player, profile) -- profile is live; mutate only through this service.
PlayerDataService.ProfileLoaded = Signal.new()
--- (player, profile) -- fires before the final save, while data is still valid.
PlayerDataService.ProfileReleasing = Signal.new()
--- (player, speed) -- the Speed stat changed for any reason.
PlayerDataService.SpeedChanged = Signal.new()
--- (player, trophies) -- the trophy count changed. Drives the rank ladder.
PlayerDataService.TrophiesChanged = Signal.new()
--- (player) -- something non-numeric changed and clients have been resynced.
PlayerDataService.StructureChanged = Signal.new()

type Session = {
	player: Player,
	userId: number,
	profile: Types.Profile,
	persistent: boolean,

	--- Numeric keys awaiting their next stream packet.
	dirtyStats: { [string]: boolean },
	structuralDirty: boolean,
	attributesDirty: boolean,

	statSequence: number,
	nextStatPush: number,
	nextAttributePush: number,
	nextAutosave: number,

	playStamp: number,
	saving: boolean,
	leaderstats: { [string]: IntValue },
}

local sessions: { [Player]: Session } = {}
local loading: { [Player]: boolean } = {}
local shuttingDown = false

local NUMERIC_KEYS = { "Speed", "TotalSpeedEarned", "Distance", "Steps", "Rebirths", "Coins", "Trophies" }

--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------

local function buildLeaderstats(player: Player): { [string]: IntValue }
	local folder = Instance.new("Folder")
	folder.Name = "leaderstats"

	local values: { [string]: IntValue } = {}
	for _, name in { "Trophies", "Speed", "Rebirths" } do
		local value = Instance.new("IntValue")
		value.Name = name
		value.Value = 0
		value.Parent = folder
		values[name] = value
	end

	folder.Parent = player
	return values
end

--- Publishes the parts of a profile everyone is allowed to see.
local function pushAttributes(session: Session)
	local profile = session.profile
	local player = session.player
	-- Highest *unlocked*, not highest the current trophy count qualifies for:
	-- a future trophy spend must never take a rank badge away.
	local current = Ranks.getHighestOwned(profile.Ranks)

	player:SetAttribute("Speed", profile.Speed)
	player:SetAttribute("Trophies", profile.Trophies)
	player:SetAttribute("Rebirths", profile.Rebirths)
	player:SetAttribute("RankId", current and current.Id or "")
	player:SetAttribute("Title", profile.EquippedTitle)
	player:SetAttribute("Trail", profile.EquippedTrail)

	local stats = session.leaderstats
	stats.Trophies.Value = math.clamp(math.floor(profile.Trophies), 0, 2 ^ 31 - 1)
	stats.Speed.Value = math.clamp(math.floor(profile.Speed), 0, 2 ^ 31 - 1)
	stats.Rebirths.Value = math.clamp(profile.Rebirths, 0, 2 ^ 31 - 1)
end

local function pushStats(session: Session)
	local payload: { [string]: any } = { seq = session.statSequence }
	session.statSequence += 1

	for key in session.dirtyStats do
		payload[key] = session.profile[key :: any]
	end
	table.clear(session.dirtyStats)

	Net.fireClient("StatStream", session.player, payload)
end

local function pushProfile(session: Session)
	session.structuralDirty = false
	Net.fireClient("ProfileSync", session.player, Schema.toClientPayload(session.profile))
end

--------------------------------------------------------------------------------
-- Public API
--------------------------------------------------------------------------------

function PlayerDataService:Get(player: Player): Types.Profile?
	local session = sessions[player]
	return session and session.profile or nil
end

function PlayerDataService:IsLoaded(player: Player): boolean
	return sessions[player] ~= nil
end

function PlayerDataService:GetSessionCount(): number
	local total = 0
	for _ in sessions do
		total += 1
	end
	return total
end

--- Adds to a numeric stat. Negative amounts are allowed (spending coins) but
--- the result is floored at zero.
function PlayerDataService:AddStat(player: Player, key: Types.NumericKey, amount: number): number?
	local session = sessions[player]
	if not session then
		return nil
	end
	if amount == 0 or amount ~= amount then
		return (session.profile :: any)[key]
	end

	local profile = session.profile :: any
	local updated = math.max((profile[key] or 0) + amount, 0)
	profile[key] = updated
	session.dirtyStats[key] = true

	if key == "Speed" then
		session.attributesDirty = true
		PlayerDataService.SpeedChanged:Fire(player, updated)
	elseif key == "Trophies" then
		session.attributesDirty = true
		PlayerDataService.TrophiesChanged:Fire(player, updated)
	elseif key == "Rebirths" then
		session.attributesDirty = true
	end

	return updated
end

--- The reward path: Speed and the lifetime total move together.
function PlayerDataService:AddSpeed(player: Player, amount: number): number?
	local session = sessions[player]
	if not session then
		return nil
	end
	if amount <= 0 then
		return session.profile.Speed
	end

	local profile = session.profile
	profile.TotalSpeedEarned += amount
	session.dirtyStats.TotalSpeedEarned = true

	return PlayerDataService:AddStat(player, "Speed", amount)
end

--- Records a challenge completion: bumps the counter and keeps the best time.
--- Returns (completions, bestTime, isPersonalBest).
function PlayerDataService:RecordChallenge(player: Player, id: string, runTime: number): (number, number, boolean)
	local session = sessions[player]
	if not session then
		return 0, 0, false
	end

	local record = session.profile.Challenges[id]
	if not record then
		record = { Completions = 0, BestTime = 0 }
		session.profile.Challenges[id] = record
	end

	record.Completions += 1

	local best = record.BestTime <= 0 or runTime < record.BestTime
	if best then
		record.BestTime = runTime
	end

	PlayerDataService:MarkStructural(player)
	return record.Completions, record.BestTime, best
end

--- Marks non-numeric state as changed. The full profile is resynced on the next
--- flush (within ~200 ms) rather than once per mutation, so awarding a trophy
--- with four rewards costs one packet.
function PlayerDataService:MarkStructural(player: Player)
	local session = sessions[player]
	if session then
		session.structuralDirty = true
		session.attributesDirty = true
	end
end

--- Runs `mutator` against the live profile and flags a resync. Returns whatever
--- the mutator returned, so callers can report success.
function PlayerDataService:Update<T>(player: Player, mutator: (Types.Profile) -> T): T?
	local session = sessions[player]
	if not session then
		return nil
	end

	local result = mutator(session.profile)
	PlayerDataService:MarkStructural(player)
	return result
end

function PlayerDataService:PerformRebirth(player: Player): (boolean, string)
	local session = sessions[player]
	if not session then
		return false, "Profile not loaded"
	end

	local profile = session.profile
	local allowed, cost = Progression.canRebirth(profile)
	if not allowed then
		return false, `Requires {cost} Speed`
	end

	if not Progression.applyRebirth(profile) then
		return false, "Rebirth failed"
	end

	for _, key in NUMERIC_KEYS do
		session.dirtyStats[key] = true
	end
	session.structuralDirty = true
	session.attributesDirty = true

	PlayerDataService.SpeedChanged:Fire(player, profile.Speed)
	return true, `Rebirth {profile.Rebirths}`
end

--------------------------------------------------------------------------------
-- Saving
--------------------------------------------------------------------------------

local function accruePlayTime(session: Session)
	local now = os.clock()
	session.profile.Stats.PlayTime += now - session.playStamp
	session.playStamp = now
	session.profile.Stats.LastSeen = os.time()
end

local function saveSession(session: Session, release: boolean): boolean
	if not session.persistent then
		return false
	end

	if session.saving then
		if not release then
			return false
		end
		-- A release save must not be skipped: wait out the autosave in flight.
		local deadline = os.clock() + 10
		while session.saving and os.clock() < deadline do
			task.wait(0.1)
		end
	end

	session.saving = true
	accruePlayTime(session)

	local ok = SaveService:SaveAsync(session.userId, session.profile, release)

	session.saving = false
	session.nextAutosave = os.clock() + Config.Data.AutosaveInterval + math.random() * Config.Data.AutosaveJitter
	return ok
end

--- Forces a save for one player. Exposed for admin tooling and the self test.
function PlayerDataService:SaveNow(player: Player, release: boolean?): boolean
	local session = sessions[player]
	if not session then
		return false
	end
	return saveSession(session, release == true)
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

local function onPlayerAdded(player: Player)
	if sessions[player] or loading[player] then
		return
	end
	loading[player] = true

	local result = SaveService:LoadAsync(player.UserId)
	loading[player] = nil

	if player.Parent == nil then
		-- They left mid-load. Drop the lock so their next server can have it.
		if result.Persistent then
			task.spawn(function()
				SaveService:ReleaseAsync(player.UserId)
			end)
		end
		return
	end

	local profile = result.Profile
	profile.Stats.Joins += 1
	if result.IsNew then
		profile.Stats.Created = os.time()
	end

	local now = os.clock()
	local session: Session = {
		player = player,
		userId = player.UserId,
		profile = profile,
		persistent = result.Persistent,

		dirtyStats = {},
		structuralDirty = false,
		attributesDirty = true,

		statSequence = 1,
		nextStatPush = now + Config.Replication.StatInterval,
		nextAttributePush = now,
		-- Stagger the first autosave so a full server does not write in lockstep.
		nextAutosave = now + Config.Data.AutosaveInterval + math.random() * Config.Data.AutosaveJitter,

		playStamp = now,
		saving = false,
		leaderstats = buildLeaderstats(player),
	}

	sessions[player] = session
	pushAttributes(session)
	pushProfile(session)

	PlayerDataService.ProfileLoaded:Fire(player, profile)

	if not result.Persistent then
		Net.fireClient("Notify", player, {
			Title = "Offline mode",
			Body = "Your progress cannot be saved right now.",
			Kind = "Warning",
		})
	end
end

local function onPlayerRemoving(player: Player)
	local session = sessions[player]
	sessions[player] = nil

	if not session then
		-- Still loading: onPlayerAdded sees the nil Parent and releases the lock.
		return
	end

	PlayerDataService.ProfileReleasing:Fire(player, session.profile)
	-- Deliberately synchronous: Roblox waits on PlayerRemoving handlers, which
	-- is exactly the window we need for the final write to land.
	saveSession(session, true)
end

--------------------------------------------------------------------------------
-- Flush loop
--------------------------------------------------------------------------------

local function step()
	local now = os.clock()

	for player, session in sessions do
		if session.structuralDirty then
			pushProfile(session)
			PlayerDataService.StructureChanged:Fire(player)
		end

		if now >= session.nextStatPush then
			session.nextStatPush = now + Config.Replication.StatInterval
			if next(session.dirtyStats) ~= nil then
				pushStats(session)
			end
		end

		if session.attributesDirty and now >= session.nextAttributePush then
			session.attributesDirty = false
			session.nextAttributePush = now + Config.Replication.AttributeInterval
			pushAttributes(session)
		end

		if not shuttingDown and now >= session.nextAutosave and not session.saving then
			-- Push the next slot forward immediately so a slow save cannot queue
			-- a second one behind it.
			session.nextAutosave = now + Config.Data.AutosaveInterval + math.random() * Config.Data.AutosaveJitter
			task.spawn(saveSession, session, false)
		end
	end
end

function PlayerDataService:Init()
	Players.PlayerAdded:Connect(function(player)
		task.spawn(onPlayerAdded, player)
	end)
	Players.PlayerRemoving:Connect(onPlayerRemoving)

	-- Anyone who joined while the server was still booting.
	for _, player in Players:GetPlayers() do
		task.spawn(onPlayerAdded, player)
	end

	RunService.PostSimulation:Connect(step)

	game:BindToClose(function()
		shuttingDown = true

		local pending = 0
		local finished = 0

		for _, session in sessions do
			pending += 1
			task.spawn(function()
				saveSession(session, true)
				finished += 1
			end)
		end

		if pending == 0 then
			return
		end

		local deadline = os.clock() + Config.Data.ShutdownTimeout
		while finished < pending and os.clock() < deadline do
			task.wait(0.1)
		end

		if finished < pending then
			warn(`[PlayerDataService] Shutdown timed out with {pending - finished} save(s) unfinished.`)
		end
	end)
end

return PlayerDataService
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="28">
          <Properties>
            <string name="Name">RankService</string>
            <string name="Source"><![CDATA[--!strict
--[[
	RankService
	----------------------------------------------------------------------------
	Unlocks ranks when the trophy count crosses a requirement, and hands out what
	they grant: coins, a title, a trail and a permanent multiplier bonus.

	Trophies come from ChallengeService. This service does not care where they
	came from -- it only ever compares the ladder against what the profile
	already holds, via `Ranks.getMissing`. That has two useful consequences:

	  * Lowering a requirement in Ranks.lua retroactively grants it to everyone
	    on their next join. Raising one never revokes anything.
	  * There is no separate "check on load" and "check on gain" code path that
	    could disagree.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local Net = require(Shared.Net)
local Ranks = require(Shared.Ranks)
local Signal = require(Shared.Signal)
local Types = require(Shared.Types)

local PlayerDataService = require(script.Parent.PlayerDataService)

local RankService = {}

--- (player, definition)
RankService.Unlocked = Signal.new()

local function grantRewards(player: Player, profile: Types.Profile, definition: Types.RankDefinition)
	local rewards = definition.Rewards

	if rewards.Coins then
		PlayerDataService:AddStat(player, "Coins", rewards.Coins)
	end

	if rewards.Title then
		profile.Titles[rewards.Title] = true
		if Config.Ranks.AutoEquipFirstReward and profile.EquippedTitle == "" then
			profile.EquippedTitle = rewards.Title
		end
	end

	if rewards.Trail then
		profile.Trails[rewards.Trail] = true
		if Config.Ranks.AutoEquipFirstReward and profile.EquippedTrail == "" then
			profile.EquippedTrail = rewards.Trail
		end
	end

	-- SpeedMultiplier needs no bookkeeping: Progression reads it straight off
	-- the unlocked-rank list, so holding the rank *is* holding the bonus.
end

local function unlock(player: Player, profile: Types.Profile, definition: Types.RankDefinition)
	if profile.Ranks[definition.Id] ~= nil then
		return
	end

	profile.Ranks[definition.Id] = os.time()
	grantRewards(player, profile, definition)
	PlayerDataService:MarkStructural(player)

	Net.fireClient("RankUnlocked", player, {
		Id = definition.Id,
		Order = definition.Order,
		UnlockedAt = profile.Ranks[definition.Id],
	})

	if definition.Order >= Config.Ranks.AnnounceFromOrder then
		-- Everyone except the earner: they already got the personal popup, and
		-- two toasts for one unlock reads as a bug.
		local announcement = {
			Title = `{definition.Name} rank!`,
			Body = `{player.DisplayName} reached {definition.Requirement} trophies.`,
			Kind = "Rank",
			RankId = definition.Id,
		}
		for _, other in Players:GetPlayers() do
			if other ~= player then
				Net.fireClient("Notify", other, announcement)
			end
		end
	end

	RankService.Unlocked:Fire(player, definition)
end

--- Grants every rank the player now qualifies for. Cheap enough to call on
--- every trophy change: the common case is one comparison.
function RankService:Evaluate(player: Player)
	local profile = PlayerDataService:Get(player)
	if not profile then
		return
	end

	local missing = Ranks.getMissing(profile.Trophies, profile.Ranks)
	for _, definition in missing do
		unlock(player, profile, definition)
	end
end

--- Read-only summary. Exposed for admin tooling and future leaderboards.
function RankService:GetProgress(player: Player): { [string]: any }?
	local profile = PlayerDataService:Get(player)
	if not profile then
		return nil
	end

	local alpha, earned, needed, current, upcoming = Ranks.getLadderProgress(profile.Trophies, profile.Ranks)

	return {
		CurrentId = current and current.Id or nil,
		NextId = upcoming and upcoming.Id or nil,
		Alpha = alpha,
		Earned = earned,
		Needed = needed,
	}
end

function RankService:Init()
	PlayerDataService.ProfileLoaded:Connect(function(player: Player)
		RankService:Evaluate(player)
	end)

	PlayerDataService.TrophiesChanged:Connect(function(player: Player, trophies: number)
		-- Fast path: the highest rank this trophy count qualifies for is almost
		-- always one the player already holds, and checking that costs a short
		-- loop and one table lookup instead of building a list.
		local current = Ranks.getCurrent(trophies)
		if not current then
			return
		end

		local profile = PlayerDataService:Get(player)
		if not profile or profile.Ranks[current.Id] ~= nil then
			return
		end

		RankService:Evaluate(player)
	end)
end

return RankService
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="29">
          <Properties>
            <string name="Name">SaveService</string>
            <string name="Source"><![CDATA[--!strict
--[[
	SaveService
	----------------------------------------------------------------------------
	The only module in the game that talks to DataStoreService.

	Guarantees it provides:

	  * Session locking. A profile carries the JobId of the server that owns it.
	    A second server refuses to load it until the lock is released or goes
	    stale, which is what stops the classic "join two servers, duplicate your
	    currency" exploit.
	  * Never clobber. Saves go through UpdateAsync and abort if the lock changed
	    hands, so a zombie server coming back from a network stall cannot
	    overwrite newer data.
	  * Retry with backoff and jitter, and request-budget awareness so a busy
	    server queues rather than burning throttle.
	  * Graceful degradation. If the datastore is unreachable the player still
	    gets a playable session, flagged non-persistent so it is never written
	    back over their real save.
]]

local DataStoreService = game:GetService("DataStoreService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local Schema = require(Shared.Schema)
local Types = require(Shared.Types)
local Util = require(Shared.Util)

local MockStore = require(script.Parent.MockStore)

local SaveService = {}

local store: any = nil
local usingMock = false
local jobId = ""

--- Set when the live datastore has failed hard. Sessions opened while degraded
--- are non-persistent and are never written back.
SaveService.Degraded = false

local function log(message: string, ...: any)
	if Config.Debug.Verbose then
		print(`[SaveService] {string.format(message, ...)}`)
	end
end

local function keyFor(userId: number): string
	return `Player_{userId}`
end

--- Waits until at least one request of the given type is available.
--- Returns false if the wait times out, in which case the caller should retry
--- later rather than fire a request that is guaranteed to throttle.
local function waitForBudget(requestType: Enum.DataStoreRequestType, timeout: number): boolean
	if usingMock then
		return true
	end

	local deadline = os.clock() + timeout
	while DataStoreService:GetRequestBudgetForRequestType(requestType) < 1 do
		if os.clock() >= deadline then
			return false
		end
		task.wait(0.4)
	end
	return true
end

function SaveService:Init()
	jobId = if game.JobId ~= "" then game.JobId else `studio-{game.PlaceId}-{os.time()}`

	if RunService:IsStudio() and Config.Data.UseMockStoreInStudio then
		usingMock = true
		store = MockStore.new()
		warn("[SaveService] Studio detected -- using the in-memory mock store. Live data is untouched.")
		return
	end

	local ok, result = pcall(function()
		return DataStoreService:GetDataStore(Config.Data.StoreName, Config.Data.Scope)
	end)

	if ok then
		store = result
	else
		usingMock = true
		store = MockStore.new(0)
		SaveService.Degraded = true
		warn(`[SaveService] GetDataStore failed ({result}) -- running non-persistent.`)
	end
end

--------------------------------------------------------------------------------
-- Loading
--------------------------------------------------------------------------------

type Envelope = {
	Schema: number,
	Data: any,
	Lock: Types.SessionLock?,
	SavedAt: number,
	Build: string?,
}

local function isLockLive(lock: Types.SessionLock?): boolean
	if not lock then
		return false
	end
	if lock.JobId == jobId then
		return false
	end
	return os.time() - lock.At < Config.Data.SessionLockTimeout
end

--- Attempts to take ownership of a key. Returns the stored envelope on success
--- and nil when another live server still holds the lock.
local function acquire(userId: number): Envelope?
	local key = keyFor(userId)

	local ok, result = Util.retry(
		function()
			if not waitForBudget(Enum.DataStoreRequestType.UpdateAsync, 10) then
				error("UpdateAsync budget exhausted")
			end

			return store:UpdateAsync(key, function(current: any): ...any
				local envelope: Envelope = if type(current) == "table"
					then current
					else { Schema = Schema.VERSION, Data = nil, Lock = nil, SavedAt = 0 }

				if isLockLive(envelope.Lock) then
					-- Cancel the write; the caller will wait and try again.
					return nil
				end

				envelope.Lock = { JobId = jobId, PlaceId = game.PlaceId, At = os.time() }
				return envelope, { userId }
			end)
		end,
		Config.Data.MaxRetries,
		Config.Data.RetryBaseDelay,
		Config.Data.RetryMaxDelay,
		function(attempt, err)
			warn(`[SaveService] Load attempt {attempt} for {userId} failed: {err}`)
		end
	)

	if not ok then
		return nil
	end

	return result
end

--- Loads (and locks) a profile. Always returns something playable.
function SaveService:LoadAsync(userId: number): Types.LoadResult
	assert(store, "SaveService:Init was never called")

	local deadline = os.clock() + Config.Data.LockWaitTime
	local envelope: Envelope? = nil
	local lockedOut = false

	repeat
		envelope = acquire(userId)
		if envelope then
			break
		end

		-- Either the datastore errored (retry already exhausted) or another live
		-- server holds the lock. Both cases are worth one short wait.
		lockedOut = true
		task.wait(1.5)
	until os.clock() >= deadline

	if not envelope then
		warn(`[SaveService] Could not acquire a session for {userId}; running non-persistent.`)
		return {
			Profile = Schema.default(),
			Persistent = false,
			IsNew = true,
		}
	end

	if lockedOut then
		log("Recovered a stale session lock for %d", userId)
	end

	local isNew = envelope.Data == nil
	local profile = Schema.migrate(envelope.Data)

	return {
		Profile = profile,
		Persistent = true,
		IsNew = isNew,
	}
end

--------------------------------------------------------------------------------
-- Saving
--------------------------------------------------------------------------------

--- Writes a profile. When `release` is true the session lock is dropped, which
--- is what lets the player rejoin another server immediately.
--- Returns false if the write failed or the lock had been taken by someone else.
function SaveService:SaveAsync(userId: number, profile: Types.Profile, release: boolean): boolean
	assert(store, "SaveService:Init was never called")

	local key = keyFor(userId)
	local payload = Schema.sanitize(profile)
	local lostLock = false

	local ok, result = Util.retry(
		function()
			if not waitForBudget(Enum.DataStoreRequestType.UpdateAsync, 10) then
				error("UpdateAsync budget exhausted")
			end

			return store:UpdateAsync(key, function(current: any): ...any
				local envelope: Envelope = if type(current) == "table"
					then current
					else { Schema = Schema.VERSION, Data = nil, Lock = nil, SavedAt = 0 }

				local lock = envelope.Lock
				if lock and lock.JobId ~= jobId and os.time() - lock.At < Config.Data.SessionLockTimeout then
					-- Someone else owns this session now. Abort rather than
					-- overwrite whatever they have been doing.
					lostLock = true
					return nil
				end

				envelope.Schema = Schema.VERSION
				envelope.Data = payload
				envelope.SavedAt = os.time()
				envelope.Build = Config.Game.BuildTag
				envelope.Lock = if release then nil else { JobId = jobId, PlaceId = game.PlaceId, At = os.time() }

				return envelope, { userId }
			end)
		end,
		Config.Data.MaxRetries,
		Config.Data.RetryBaseDelay,
		Config.Data.RetryMaxDelay,
		function(attempt, err)
			warn(`[SaveService] Save attempt {attempt} for {userId} failed: {err}`)
		end
	)

	if lostLock then
		warn(`[SaveService] Session lock for {userId} was taken by another server; save skipped.`)
		return false
	end

	if not ok then
		warn(`[SaveService] Save for {userId} failed permanently: {result}`)
		return false
	end

	log("Saved %d (release=%s)", userId, tostring(release))
	return true
end

--- Drops the lock without writing gameplay data. Used when a player leaves
--- before their profile finished loading.
function SaveService:ReleaseAsync(userId: number): boolean
	assert(store, "SaveService:Init was never called")

	local ok = Util.retry(function()
		return store:UpdateAsync(keyFor(userId), function(current: any): ...any
			if type(current) ~= "table" then
				return nil
			end
			local lock = current.Lock
			if lock and lock.JobId ~= jobId then
				return nil
			end
			current.Lock = nil
			return current, { userId }
		end)
	end, 3, Config.Data.RetryBaseDelay, Config.Data.RetryMaxDelay)

	return ok
end

function SaveService:IsMock(): boolean
	return usingMock
end

return SaveService
]]></string>
          </Properties>
        </Item>
      </Item>
    </Item>
  </Item>
  <Item class="SoundService" referent="30">
    <Properties>
      <string name="Name">SoundService</string>
      <bool name="RespectFilteringEnabled">true</bool>
    </Properties>
  </Item>
  <Item class="StarterGui" referent="31">
    <Properties>
      <string name="Name">StarterGui</string>
      <bool name="ResetPlayerGuiOnSpawn">false</bool>
    </Properties>
    <Item class="ScreenGui" referent="32">
      <Properties>
        <string name="Name">HUD</string>
        <int name="DisplayOrder">10</int>
        <token name="ScreenInsets">1</token>
        <bool name="ResetOnSpawn">false</bool>
        <token name="ScreenInsets">2</token>
        <token name="ZIndexBehavior">1</token>
      </Properties>
    </Item>
    <Item class="ScreenGui" referent="33">
      <Properties>
        <string name="Name">Notifications</string>
        <int name="DisplayOrder">30</int>
        <token name="ScreenInsets">1</token>
        <bool name="ResetOnSpawn">false</bool>
        <token name="ScreenInsets">2</token>
        <token name="ZIndexBehavior">1</token>
      </Properties>
    </Item>
    <Item class="ScreenGui" referent="34">
      <Properties>
        <string name="Name">RanksUI</string>
        <int name="DisplayOrder">20</int>
        <bool name="Enabled">false</bool>
        <token name="ScreenInsets">1</token>
        <bool name="ResetOnSpawn">false</bool>
        <token name="ScreenInsets">2</token>
        <token name="ZIndexBehavior">1</token>
      </Properties>
    </Item>
  </Item>
  <Item class="StarterPlayer" referent="35">
    <Properties>
      <string name="Name">StarterPlayer</string>
      <float name="CameraMaxZoomDistance">64</float>
      <float name="CameraMinZoomDistance">8</float>
      <bool name="EnableMouseLockOption">true</bool>
    </Properties>
    <Item class="StarterCharacterScripts" referent="36">
      <Properties>
        <string name="Name">StarterCharacterScripts</string>
      </Properties>
    </Item>
    <Item class="StarterPlayerScripts" referent="37">
      <Properties>
        <string name="Name">StarterPlayerScripts</string>
      </Properties>
      <Item class="LocalScript" referent="38">
        <Properties>
          <string name="Name">Client</string>
          <string name="Source"><![CDATA[--!nonstrict
--[[
	Walk for Speed -- client bootstrap
	----------------------------------------------------------------------------
	Wires the replication channels into ClientState, then starts the controllers.

	The order is: state first, then anything that renders it. Controllers only
	ever read ClientState and react to its signals, so none of them can race a
	profile that has not arrived yet -- they simply render the default until it
	does.

	The initial RequestProfile invoke covers the one case ProfileSync cannot: a
	client that finished loading after the server had already sent its snapshot.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local Net = require(Shared.Net)

local ClientState = require(script.ClientState)

local Controllers = script:WaitForChild("Controllers")
local CameraController = require(Controllers.CameraController)
local EffectsController = require(Controllers.EffectsController)
local NametagController = require(Controllers.NametagController)
local UIController = require(Controllers.UIController)

local startedAt = os.clock()

-- Replication in.
Net.onClientEvent("ProfileSync", ClientState.applySnapshot)
Net.onClientEvent("StatStream", ClientState.applyStats)

-- Controllers out.
UIController.start()
EffectsController.start()
CameraController.start()
NametagController.start()

-- Backstop for a late-loading client: ask once for the current profile.
task.spawn(function()
	if ClientState.loaded then
		return
	end

	local ok, payload = pcall(function()
		return Net.invokeServer("RequestProfile")
	end)

	if ok and payload and not ClientState.loaded then
		ClientState.applySnapshot(payload)
	elseif not ok then
		warn(`[WalkForSpeed] RequestProfile failed: {payload}`)
	end
end)

if Config.Debug.Verbose then
	print(string.format("[WalkForSpeed] Client ready in %.0f ms.", (os.clock() - startedAt) * 1000))
end
]]></string>
        </Properties>
        <Item class="ModuleScript" referent="39">
          <Properties>
            <string name="Name">ClientState</string>
            <string name="Source"><![CDATA[--!strict
--[[
	ClientState
	----------------------------------------------------------------------------
	The client's read-only mirror of the server profile.

	Two channels feed it:
	  * ProfileSync -- reliable, full snapshot. Authoritative.
	  * StatStream  -- unreliable, numbers only, ~5 Hz. Carries a sequence number
	    so a packet that arrives out of order is discarded rather than briefly
	    rewinding the HUD.

	Nothing here is ever sent back to the server as a source of truth; it exists
	so the interface has something local to render and interpolate toward.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Progression = require(Shared.Progression)
local Schema = require(Shared.Schema)
local Signal = require(Shared.Signal)
local Types = require(Shared.Types)

local ClientState = {}

--- Fires after any change, with the profile mirror.
ClientState.Changed = Signal.new()
--- Fires the first time a full profile lands.
ClientState.Ready = Signal.new()

ClientState.profile = Schema.default()
ClientState.loaded = false

local lastSequence = 0

local NUMERIC_KEYS: { Types.NumericKey } = {
	"Speed",
	"TotalSpeedEarned",
	"Distance",
	"Steps",
	"Rebirths",
	"Coins",
	"Trophies",
}

--- Applies a full snapshot from ProfileSync.
function ClientState.applySnapshot(payload: { [string]: any }?)
	if type(payload) ~= "table" then
		return
	end

	local profile = ClientState.profile :: any

	for _, key in NUMERIC_KEYS do
		local value = payload[key]
		if type(value) == "number" then
			profile[key] = value
		end
	end

	for _, key in { "Ranks", "Challenges", "Titles", "Trails", "Boosts", "Settings" } do
		if type(payload[key]) == "table" then
			profile[key] = payload[key]
		end
	end

	if type(payload.EquippedTitle) == "string" then
		profile.EquippedTitle = payload.EquippedTitle
	end
	if type(payload.EquippedTrail) == "string" then
		profile.EquippedTrail = payload.EquippedTrail
	end

	local wasLoaded = ClientState.loaded
	ClientState.loaded = true

	ClientState.Changed:Fire(ClientState.profile)
	if not wasLoaded then
		ClientState.Ready:Fire(ClientState.profile)
	end
end

--- Applies a numeric delta packet from StatStream.
function ClientState.applyStats(payload: { [string]: any }?)
	if type(payload) ~= "table" then
		return
	end

	local sequence = payload.seq
	if type(sequence) == "number" then
		if sequence <= lastSequence then
			-- Out of order on an unreliable channel: the newer packet already
			-- carried the absolute value, so this one has nothing to add.
			return
		end
		lastSequence = sequence
	end

	local profile = ClientState.profile :: any
	local changed = false

	for _, key in NUMERIC_KEYS do
		local value = payload[key]
		if type(value) == "number" then
			profile[key] = value
			changed = true
		end
	end

	if changed then
		ClientState.Changed:Fire(ClientState.profile)
	end
end

--------------------------------------------------------------------------------
-- Derived values (thin wrappers so UI code reads well)
--------------------------------------------------------------------------------

function ClientState.getSpeedPerStep(): number
	return Progression.getSpeedPerStep(ClientState.profile)
end

function ClientState.getMultiplier(): number
	return Progression.getSpeedMultiplier(ClientState.profile)
end

function ClientState.ownsTitle(id: string): boolean
	return ClientState.profile.Titles[id] == true
end

function ClientState.ownsTrail(id: string): boolean
	return ClientState.profile.Trails[id] == true
end

function ClientState.hasRank(id: string): boolean
	return ClientState.profile.Ranks[id] ~= nil
end

function ClientState.countRanks(): number
	local total = 0
	for _ in ClientState.profile.Ranks do
		total += 1
	end
	return total
end

function ClientState.getChallenge(id: string): Types.ChallengeRecord?
	return ClientState.profile.Challenges[id]
end

return ClientState
]]></string>
          </Properties>
        </Item>
        <Item class="Folder" referent="40">
          <Properties>
            <string name="Name">Controllers</string>
          </Properties>
          <Item class="ModuleScript" referent="41">
            <Properties>
              <string name="Name">CameraController</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	CameraController
	----------------------------------------------------------------------------
	Keeps the camera readable at 300 studs per second.

	Two things happen as WalkSpeed rises:
	  * Field of view widens, which is what makes speed *feel* like speed.
	  * The zoom-out limit increases, so a fast player can pull back far enough
	    to see what they are running into.

	Both are eased with framerate-independent damping rather than tweens, so a
	speed change mid-tween never snaps and a frame spike never overshoots. The
	default camera scripts stay in charge of everything else -- there is no
	custom camera here to fight with them.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local SpeedCurve = require(Shared.SpeedCurve)
local Util = require(Shared.Util)

local Client = script.Parent.Parent
local ClientState = require(Client.ClientState)

local player = Players.LocalPlayer

local CameraController = {}

local humanoid: Humanoid? = nil
local currentFieldOfView = Config.Camera.BaseFieldOfView
local currentZoom = Config.Camera.BaseMaxZoom

local function targetIntensity(): number
	local live = humanoid
	if not live or live.Parent == nil or live.Health <= 0 then
		return 0
	end

	if ClientState.profile.Settings.ShowEffects == false then
		return 0
	end

	local settings = Config.Camera
	local span = settings.FieldOfViewFullSpeed - Config.Movement.BaseWalkSpeed
	if span <= 0 then
		return 0
	end

	local alpha = math.clamp((live.WalkSpeed - Config.Movement.BaseWalkSpeed) / span, 0, 1)

	-- Only kick in once the player is actually moving, so standing still at high
	-- Speed does not sit at a permanently wide FOV.
	local moving = math.clamp(live.MoveDirection.Magnitude, 0, 1)
	return alpha * moving
end

local function step(deltaTime: number)
	local camera = workspace.CurrentCamera
	if not camera then
		return
	end

	local settings = Config.Camera
	local intensity = targetIntensity()

	local targetFieldOfView = settings.BaseFieldOfView
		+ (settings.MaxFieldOfView - settings.BaseFieldOfView) * intensity
	currentFieldOfView = Util.damp(currentFieldOfView, targetFieldOfView, settings.Smoothing, deltaTime)
	camera.FieldOfView = currentFieldOfView

	local targetZoom = settings.BaseMaxZoom + (settings.FastMaxZoom - settings.BaseMaxZoom) * intensity
	currentZoom = Util.damp(currentZoom, targetZoom, settings.Smoothing, deltaTime)

	-- Only write when it actually moved: this is a replicated player property,
	-- not a local camera field, so a per-frame write is real traffic.
	if math.abs(player.CameraMaxZoomDistance - currentZoom) > 0.25 then
		player.CameraMaxZoomDistance = currentZoom
	end
end

local function onCharacterAdded(character: Model)
	humanoid = character:WaitForChild("Humanoid", 10) :: Humanoid?
end

function CameraController.start()
	-- Only used for the ceiling; the player still controls their own zoom.
	player.CameraMinZoomDistance = math.min(player.CameraMinZoomDistance, Config.Camera.BaseMaxZoom)

	if player.Character then
		task.spawn(onCharacterAdded, player.Character)
	end
	player.CharacterAdded:Connect(function(character)
		task.spawn(onCharacterAdded, character)
	end)

	RunService.PreRender:Connect(step)

	-- Sanity: a capped player should be at the widest FOV the config allows.
	if Config.Debug.Verbose then
		print(
			`[CameraController] FOV {Config.Camera.BaseFieldOfView}..{Config.Camera.MaxFieldOfView}`,
			`at up to {Config.Movement.MaxWalkSpeed} studs/s (curve caps at {SpeedCurve.walkSpeed(1e9)})`
		)
	end
end

return CameraController
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="42">
            <Properties>
              <string name="Name">EffectsController</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	EffectsController
	----------------------------------------------------------------------------
	Local feedback for movement: the floating "+N Speed" popups and the speed
	particles that build as you get faster.

	The popup is the genre's signature: a big "+1" with the running total right
	underneath it, so every payout answers "and what am I on now?".

	Reward popups are merged inside a short window. At 300 WalkSpeed a player
	crosses the reward distance several times a second, and one label per reward
	would be an unreadable stack; merging turns that into a single "+6" that
	actually tells them something.

	All of it is cosmetic and local, so it honours the player's "movement
	effects" setting and costs the server nothing.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local Format = require(Shared.Format)
local Net = require(Shared.Net)
local SpeedCurve = require(Shared.SpeedCurve)

local Client = script.Parent.Parent
local ClientState = require(Client.ClientState)
local FloatingText = require(Client.UI.FloatingText)
local Sound = require(Client.UI.Sound)
local Theme = require(Client.UI.Theme)

local player = Players.LocalPlayer

local EffectsController = {}

local root: BasePart? = nil
local humanoid: Humanoid? = nil
local emitter: ParticleEmitter? = nil

local pendingAmount = 0
local pendingTotal = 0
local pendingUntil = 0
local nextParticleUpdate = 0

local function effectsEnabled(): boolean
	return ClientState.profile.Settings.ShowEffects ~= false
end

--------------------------------------------------------------------------------
-- Speed particles
--------------------------------------------------------------------------------

local function buildEmitter(part: BasePart): ParticleEmitter
	local attachment = Instance.new("Attachment")
	attachment.Name = "SpeedFX"
	attachment.Position = Vector3.new(0, -1.4, 0)
	attachment.Parent = part

	-- No custom texture: the built-in particle sprite ships with the client, so
	-- there is nothing to upload and nothing that can fail to load.
	local particles = Instance.new("ParticleEmitter")
	particles.Name = "SpeedParticles"
	particles.Color = ColorSequence.new(Theme.Color.Speed, Theme.Color.Accent)
	particles.LightEmission = 0.8
	particles.Size = NumberSequence.new {
		NumberSequenceKeypoint.new(0, 0.8),
		NumberSequenceKeypoint.new(1, 0),
	}
	particles.Transparency = NumberSequence.new {
		NumberSequenceKeypoint.new(0, 0.25),
		NumberSequenceKeypoint.new(1, 1),
	}
	particles.Lifetime = NumberRange.new(0.25, 0.45)
	particles.Speed = NumberRange.new(2, 6)
	particles.SpreadAngle = Vector2.new(18, 18)
	particles.Acceleration = Vector3.new(0, 6, 0)
	particles.Rate = 0
	particles.Drag = 2
	particles.Parent = attachment

	return particles
end

local function updateParticles()
	local liveEmitter = emitter
	local liveHumanoid = humanoid
	if not liveEmitter or not liveHumanoid or liveEmitter.Parent == nil then
		return
	end

	if not effectsEnabled() then
		liveEmitter.Rate = 0
		return
	end

	local intensity = SpeedCurve.intensity(liveHumanoid.WalkSpeed)
	local moving = liveHumanoid.MoveDirection.Magnitude > 0.1

	-- Below a third of the range the effect is noise; above it, ramp hard so
	-- getting fast feels like it changed something.
	local ramp = math.clamp((intensity - 0.3) / 0.7, 0, 1)
	liveEmitter.Rate = if moving then ramp * ramp * 60 else 0
end

--------------------------------------------------------------------------------
-- Reward popups
--------------------------------------------------------------------------------

local function flushRewards()
	if pendingAmount <= 0 then
		return
	end

	local amount = pendingAmount
	local total = pendingTotal
	pendingAmount = 0

	local part = root
	if not part or part.Parent == nil or not effectsEnabled() then
		return
	end

	-- "+1" on top, "24 Speed" underneath.
	FloatingText.spawn(part, `+{Format.abbreviate(amount)}`, `{Format.abbreviate(total)} Speed`, Theme.Color.Speed)

	-- Pitch rises with the size of the payout, so a big merged burst sounds
	-- different from a single reward.
	Sound.play("Reward", 1 + math.min(amount, 12) * 0.02, 0.2)
end

--- Clearing a course is the big payout, so it gets its own popup in trophy gold
--- rather than being merged into the Speed stream.
local function onChallengeCompleted(payload)
	if type(payload) ~= "table" then
		return
	end

	local part = root
	if not part or part.Parent == nil or not effectsEnabled() then
		return
	end

	local trophies = tonumber(payload.Trophies) or 0
	if trophies <= 0 then
		return
	end

	FloatingText.spawn(part, `+{trophies}`, if trophies == 1 then "Trophy" else "Trophies", Theme.Color.Coin)
end

local function onReward(payload)
	if type(payload) ~= "table" then
		return
	end

	local amount = tonumber(payload.Amount) or 0
	if amount <= 0 then
		return
	end

	pendingAmount += amount
	pendingTotal = tonumber(payload.Speed) or (pendingTotal + amount)

	local now = os.clock()
	if pendingUntil <= now then
		pendingUntil = now + Config.UI.FloatingTextMergeWindow
	end
end

--------------------------------------------------------------------------------
-- Character tracking
--------------------------------------------------------------------------------

local function onCharacterAdded(character: Model)
	root = character:WaitForChild("HumanoidRootPart", 10) :: BasePart?
	humanoid = character:WaitForChild("Humanoid", 10) :: Humanoid?
	emitter = nil

	if root and player.Character == character then
		emitter = buildEmitter(root)
	end
end

local function step()
	local now = os.clock()

	if pendingAmount > 0 and now >= pendingUntil then
		flushRewards()
	end

	if now >= nextParticleUpdate then
		-- 10 Hz is plenty for a particle rate and keeps this loop off the
		-- per-frame budget.
		nextParticleUpdate = now + 0.1
		updateParticles()
	end
end

function EffectsController.start()
	if player.Character then
		task.spawn(onCharacterAdded, player.Character)
	end
	player.CharacterAdded:Connect(function(character)
		task.spawn(onCharacterAdded, character)
	end)

	Net.onClientEvent("RewardGranted", onReward)
	Net.onClientEvent("ChallengeCompleted", onChallengeCompleted)
	RunService.PostSimulation:Connect(step)
end

return EffectsController
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="43">
            <Properties>
              <string name="Name">NametagController</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	NametagController
	----------------------------------------------------------------------------
	Overhead nametags: display name, equipped title in its own colour, and live
	trophy count tinted by the player's current rank.

	Everything it renders comes from player attributes, which the server
	publishes once and Roblox replicates to every client automatically. No
	remotes, no polling, and it keeps working for players who join later.

	Rendered locally so "show nametags" can be a per-player preference.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Cosmetics = require(Shared.Cosmetics)
local Format = require(Shared.Format)
local Ranks = require(Shared.Ranks)

local Client = script.Parent.Parent
local ClientState = require(Client.ClientState)
local New = require(Client.UI.New)
local Theme = require(Client.UI.Theme)

local localPlayer = Players.LocalPlayer

local NametagController = {}

local container: ScreenGui? = nil
local tags: { [Player]: any } = {}

local function getContainer(): ScreenGui
	if container and container.Parent then
		return container
	end

	local gui = New("ScreenGui") {
		Name = "Nametags",
		Parent = localPlayer:WaitForChild("PlayerGui"),
		ResetOnSpawn = false,
		DisplayOrder = 1,
		IgnoreGuiInset = true,
	}

	container = gui
	return gui
end

local function buildTag(player: Player)
	local billboard = New("BillboardGui") {
		Name = `Tag_{player.UserId}`,
		Parent = getContainer(),
		Size = UDim2.fromOffset(220, 58),
		StudsOffsetWorldSpace = Vector3.new(0, 2.6, 0),
		AlwaysOnTop = false,
		LightInfluence = 0,
		MaxDistance = 160,
		Enabled = false,
	}

	local title = New("TextLabel") {
		Name = "Title",
		Parent = billboard,
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 16),
		Text = "",
		TextColor3 = Theme.Color.TextMuted,
		FontFace = Theme.Font.Bold,
		TextScaled = true,
		Visible = false,
	}

	local name = New("TextLabel") {
		Name = "Name",
		Parent = billboard,
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(0, 16),
		Size = UDim2.new(1, 0, 0, 24),
		Text = player.DisplayName,
		TextColor3 = Theme.Color.Text,
		FontFace = Theme.Font.Display,
		TextScaled = true,
	}
	New.stroke(Theme.Color.Backdrop, 2, 0.2).Parent = name

	local speed = New("TextLabel") {
		Name = "Speed",
		Parent = billboard,
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(0, 40),
		Size = UDim2.new(1, 0, 0, 16),
		Text = "0 Speed",
		TextColor3 = Theme.Color.Speed,
		FontFace = Theme.Font.Bold,
		TextScaled = true,
	}

	return { billboard = billboard, title = title, name = name, speed = speed, connections = {} }
end

local function renderTag(player: Player)
	local tag = tags[player]
	if not tag then
		return
	end

	local trophies = tonumber(player:GetAttribute("Trophies")) or 0
	local rankId = player:GetAttribute("RankId")
	local titleId = player:GetAttribute("Title")

	local definition = if type(rankId) == "string" and rankId ~= "" then Ranks.get(rankId) else nil
	local rarity = if definition then Ranks.getRarity(definition) else nil

	local rankName = if definition then `{definition.Name}  ·  ` else ""
	tag.speed.Text = `{rankName}{Format.comma(trophies)} 🏆`
	tag.speed.TextColor3 = if rarity then rarity.Color else Theme.Color.TextMuted

	local titleDefinition = if type(titleId) == "string" and titleId ~= "" then Cosmetics.getTitle(titleId) else nil
	if titleDefinition then
		tag.title.Visible = true
		tag.title.Text = titleDefinition.Name
		tag.title.TextColor3 = titleDefinition.Color
	else
		tag.title.Visible = false
	end
end

local function attachTo(player: Player, character: Model)
	local tag = tags[player]
	if not tag then
		return
	end

	local head = character:WaitForChild("Head", 10)
	if not head or tags[player] ~= tag then
		return
	end

	tag.billboard.Adornee = head
	tag.billboard.Enabled = ClientState.profile.Settings.ShowNametags ~= false
	renderTag(player)
end

local function addPlayer(player: Player)
	if tags[player] then
		return
	end

	local tag = buildTag(player)
	tags[player] = tag

	for _, attribute in { "Trophies", "RankId", "Title" } do
		table.insert(
			tag.connections,
			player:GetAttributeChangedSignal(attribute):Connect(function()
				renderTag(player)
			end)
		)
	end

	table.insert(
		tag.connections,
		player.CharacterAdded:Connect(function(character)
			task.spawn(attachTo, player, character)
		end)
	)

	if player.Character then
		task.spawn(attachTo, player, player.Character)
	end
end

local function removePlayer(player: Player)
	local tag = tags[player]
	if not tag then
		return
	end
	tags[player] = nil

	for _, connection in tag.connections do
		connection:Disconnect()
	end
	tag.billboard:Destroy()
end

local function setVisible(visible: boolean)
	for _, tag in tags do
		tag.billboard.Enabled = visible and tag.billboard.Adornee ~= nil
	end
end

function NametagController.start()
	Players.PlayerAdded:Connect(addPlayer)
	Players.PlayerRemoving:Connect(removePlayer)

	for _, player in Players:GetPlayers() do
		addPlayer(player)
	end

	local lastVisible: boolean? = nil
	ClientState.Changed:Connect(function(profile)
		local visible = profile.Settings.ShowNametags ~= false
		if visible ~= lastVisible then
			lastVisible = visible
			setVisible(visible)
		end
	end)
end

return NametagController
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="44">
            <Properties>
              <string name="Name">UIController</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	UIController
	----------------------------------------------------------------------------
	Owns every screen: builds them, feeds them state, and routes their callbacks
	back to the server.

	It is also where the three input methods are reconciled:

	  * Mouse/touch -- buttons handle themselves.
	  * Gamepad -- ContextActionService opens and closes windows, and
	    GuiService.SelectedObject is moved to the right control whenever a window
	    opens or closes so the D-pad always has somewhere sensible to start.
	  * Screen size -- one UIScale per layer, plus a compact layout below a
	    configured width, rather than shrinking a desktop layout until it is
	    unreadable on a phone.
]]

local ContextActionService = game:GetService("ContextActionService")
local GuiService = game:GetService("GuiService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local UserInputService = game:GetService("UserInputService")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Challenges = require(Shared.Challenges)
local Config = require(Shared.Config)
local Net = require(Shared.Net)
local Ranks = require(Shared.Ranks)

local Client = script.Parent.Parent
local ClientState = require(Client.ClientState)
local UI = Client.UI
local HUD = require(UI.HUD)
local Motion = require(UI.Motion)
local New = require(UI.New)
local Notifications = require(UI.Notifications)
local RankWindow = require(UI.RankWindow)
local SettingsWindow = require(UI.SettingsWindow)
local Sound = require(UI.Sound)

local player = Players.LocalPlayer

local UIController = {}

local hud
local rankWindow
local settingsWindow
local notifications
local playerGui: PlayerGui

--------------------------------------------------------------------------------
-- Screen setup
--------------------------------------------------------------------------------

local function createScreen(name: string, displayOrder: number, enabled: boolean): ScreenGui
	return New("ScreenGui") {
		Name = name,
		Parent = playerGui,
		ResetOnSpawn = false,
		IgnoreGuiInset = true,
		DisplayOrder = displayOrder,
		ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
		ScreenInsets = Enum.ScreenInsets.CoreUISafeInsets,
		Enabled = enabled,
	}
end

--- StarterGui ships three of the four ScreenGuis. This waits for one of those,
--- and falls back to building it if a client somehow starts without it, so a
--- missing container degrades to a working interface rather than an error.
local function resolveScreen(name: string, displayOrder: number, enabled: boolean): ScreenGui
	local existing = playerGui:FindFirstChild(name) or playerGui:WaitForChild(name, 10)
	if existing and existing:IsA("ScreenGui") then
		return existing
	end

	warn(`[WalkForSpeed] {name} was missing from StarterGui; building it at runtime.`)
	return createScreen(name, displayOrder, enabled)
end

--------------------------------------------------------------------------------
-- Responsive layout
--------------------------------------------------------------------------------

local function computeScale(viewport: Vector2): number
	local reference = Config.UI.ReferenceResolution
	local scale = math.min(viewport.X / reference.X, viewport.Y / reference.Y)
	return math.clamp(scale, Config.UI.MinScale, Config.UI.MaxScale)
end

local function applyLayout()
	local camera = workspace.CurrentCamera
	if not camera then
		return
	end

	local viewport = camera.ViewportSize
	local scale = computeScale(viewport)

	hud:setScale(scale)
	rankWindow:setScale(scale)
	settingsWindow:setScale(scale)
	notifications:setScale(scale)

	local narrow = viewport.X < Config.UI.CompactWidth or viewport.Y < 460
	local compact = narrow or ClientState.profile.Settings.CompactHud == true

	hud:setCompact(compact)
	rankWindow:setCompact(compact)
end

--------------------------------------------------------------------------------
-- Gamepad
--------------------------------------------------------------------------------

local function usingGamepad(): boolean
	local last = UserInputService:GetLastInputType()
	return last == Enum.UserInputType.Gamepad1
		or last == Enum.UserInputType.Gamepad2
		or last == Enum.UserInputType.Gamepad3
		or last == Enum.UserInputType.Gamepad4
end

local function focus(target: GuiObject?)
	if not usingGamepad() then
		GuiService.SelectedObject = nil
		return
	end
	GuiService.SelectedObject = target
end

local function anyWindowOpen(): boolean
	return rankWindow:isOpen() or settingsWindow:isOpen()
end

local function closeWindows()
	rankWindow:setOpen(false)
	settingsWindow:setOpen(false)
end

local function toggleRanks()
	local opening = not rankWindow:isOpen()
	settingsWindow:setOpen(false)
	rankWindow:setOpen(opening)
	focus(if opening then rankWindow:getPrimarySelection() else hud:getPrimarySelection())
end

local function toggleSettings()
	local opening = not settingsWindow:isOpen()
	rankWindow:setOpen(false)
	settingsWindow:setOpen(opening)
	focus(if opening then settingsWindow:getPrimarySelection() else hud:getPrimarySelection())
end

local function bindActions()
	ContextActionService:BindAction("WFS_Ranks", function(_, state)
		if state == Enum.UserInputState.Begin then
			toggleRanks()
		end
		return Enum.ContextActionResult.Sink
	end, false, Enum.KeyCode.T, Enum.KeyCode.ButtonY)

	ContextActionService:BindAction("WFS_Back", function(_, state)
		if state ~= Enum.UserInputState.Begin then
			return Enum.ContextActionResult.Pass
		end
		if not anyWindowOpen() then
			return Enum.ContextActionResult.Pass
		end
		closeWindows()
		focus(hud:getPrimarySelection())
		return Enum.ContextActionResult.Sink
	end, false, Enum.KeyCode.ButtonB)
end

--------------------------------------------------------------------------------
-- State rendering
--------------------------------------------------------------------------------

local lastAnimationSpeed = nil
local lastCompact = nil

local function render(profile)
	hud:render(profile)
	rankWindow:render(profile)
	settingsWindow:render(profile)

	local animationSpeed = profile.Settings.AnimationSpeed
	if animationSpeed ~= lastAnimationSpeed then
		lastAnimationSpeed = animationSpeed
		Motion.speed = animationSpeed
	end

	if profile.Settings.CompactHud ~= lastCompact then
		lastCompact = profile.Settings.CompactHud
		applyLayout()
	end
end

--------------------------------------------------------------------------------
-- Lifecycle
--------------------------------------------------------------------------------

function UIController.start()
	playerGui = player:WaitForChild("PlayerGui")

	local hudGui = resolveScreen("HUD", 10, true)
	local ranksGui = resolveScreen("RanksUI", 20, false)
	local notificationGui = resolveScreen("Notifications", 30, true)
	-- Settings is not in StarterGui, so it is created rather than waited for.
	local settingsGui = createScreen("SettingsUI", 25, false)

	hud = HUD.new(hudGui)
	rankWindow = RankWindow.new(ranksGui)
	settingsWindow = SettingsWindow.new(settingsGui)
	notifications = Notifications.new(notificationGui)

	UIController.hud = hud
	UIController.notifications = notifications

	------------------------------------------------------------------
	-- HUD -> server
	------------------------------------------------------------------

	hud:on("Ranks", toggleRanks)
	hud:on("Settings", toggleSettings)
	hud:on("Rebirth", function()
		Net.fireServer("Rebirth")
	end)

	rankWindow:on("Equip", function(kind: string, id: string)
		Net.fireServer("EquipCosmetic", kind, id)
	end)

	settingsWindow:on("Changed", function(key: string, value: any)
		Net.fireServer("UpdateSetting", key, value)

		-- Apply locally straight away so the control feels instant; the server
		-- echo confirms it a moment later.
		if key == "AnimationSpeed" then
			Motion.speed = value
		elseif key == "ShowEffects" then
			ClientState.profile.Settings.ShowEffects = value
			ClientState.Changed:Fire(ClientState.profile)
		elseif key == "ShowNametags" then
			ClientState.profile.Settings.ShowNametags = value
			ClientState.Changed:Fire(ClientState.profile)
		elseif key == "CompactHud" then
			ClientState.profile.Settings.CompactHud = value
			applyLayout()
		end
	end)

	------------------------------------------------------------------
	-- Server -> UI
	------------------------------------------------------------------

	Net.onClientEvent("Notify", function(payload)
		notifications:push(payload)
	end)

	Net.onClientEvent("RankUnlocked", function(payload)
		local definition = payload and Ranks.get(payload.Id)
		if not definition then
			return
		end

		notifications:push {
			Title = `{definition.Name} rank unlocked!`,
			Body = definition.Description,
			Kind = "Rank",
			RankId = definition.Id,
		}

		Sound.play("Trophy", 1, 0.45)
		hud:pulseTrophies()
	end)

	Net.onClientEvent("RewardGranted", function(payload)
		if type(payload) == "table" and payload.Amount then
			hud:pulse()
		end
	end)

	-- A course run started or ended. Only the banner needs to know.
	Net.onClientEvent("ChallengeState", function(payload)
		if type(payload) ~= "table" then
			return
		end
		hud:setChallenge(if payload.Active then payload.Id else nil)
	end)

	Net.onClientEvent("ChallengeCompleted", function(payload)
		local definition = payload and Challenges.get(payload.Id)
		if not definition then
			return
		end

		hud:setChallenge(nil)
		hud:pulseTrophies()
		Sound.play("Trophy", 1.1, 0.4)

		local time = string.format("%.1fs", payload.Time or 0)
		local body = if payload.PersonalBest
			then `New best: {time}  ·  +{payload.Trophies} trophies`
			else `{time}  ·  +{payload.Trophies} trophies`

		notifications:push {
			Title = `{definition.Name} cleared!`,
			Body = body,
			Kind = "Success",
		}
	end)

	------------------------------------------------------------------
	-- Layout + input
	------------------------------------------------------------------

	ClientState.Changed:Connect(render)
	render(ClientState.profile)

	applyLayout()
	local camera = workspace.CurrentCamera
	if camera then
		camera:GetPropertyChangedSignal("ViewportSize"):Connect(applyLayout)
	end
	workspace:GetPropertyChangedSignal("CurrentCamera"):Connect(function()
		local newCamera = workspace.CurrentCamera
		if newCamera then
			newCamera:GetPropertyChangedSignal("ViewportSize"):Connect(applyLayout)
			applyLayout()
		end
	end)

	bindActions()

	UserInputService.LastInputTypeChanged:Connect(function()
		if not usingGamepad() then
			GuiService.SelectedObject = nil
		elseif anyWindowOpen() then
			focus(
				if rankWindow:isOpen() then rankWindow:getPrimarySelection() else settingsWindow:getPrimarySelection()
			)
		end
	end)

	rankWindow:on("Closed", function()
		focus(hud:getPrimarySelection())
	end)
	settingsWindow:on("Closed", function()
		focus(hud:getPrimarySelection())
	end)
end

return UIController
]]></string>
            </Properties>
          </Item>
        </Item>
        <Item class="Folder" referent="45">
          <Properties>
            <string name="Name">UI</string>
          </Properties>
          <Item class="ModuleScript" referent="46">
            <Properties>
              <string name="Name">Components</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Components
	----------------------------------------------------------------------------
	The shared vocabulary of the interface: panels, stat rows, progress bars,
	buttons, toggles and segmented pickers.

	Each builder returns a table with the instance plus explicit setters, e.g.
	`row.set(1234)`. The caller keeps the handle and pushes values in; there is
	no diffing layer and no hidden subscription, which makes the data flow easy
	to follow from the remote all the way to the pixel.

	Interaction states are wired for all three input methods at once: hover
	(mouse), press (touch) and selection (gamepad) drive the same visual, so
	controller users get the same feedback without a second code path.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local Format = require(Shared.Format)

local Motion = require(script.Parent.Motion)
local New = require(script.Parent.New)
local Sound = require(script.Parent.Sound)
local Theme = require(script.Parent.Theme)

local Components = {}

--------------------------------------------------------------------------------
-- Surfaces
--------------------------------------------------------------------------------

function Components.panel(props: { [string]: any }?): Frame
	local settings = props or {}

	local panel = New("Frame") {
		Name = settings.Name or "Panel",
		Parent = settings.Parent,
		Size = settings.Size or UDim2.fromScale(1, 1),
		Position = settings.Position,
		AnchorPoint = settings.AnchorPoint,
		LayoutOrder = settings.LayoutOrder,
		BackgroundColor3 = settings.Color or Theme.Color.Panel,
		BackgroundTransparency = settings.Transparency or 0,
		BorderSizePixel = 0,

		New.corner(settings.Radius or Theme.Radius.Large),
		New.stroke(settings.StrokeColor or Theme.Color.StrokeSoft, 1, settings.StrokeTransparency or 0.2),
	}

	if settings.Gradient ~= false then
		New.gradient(Theme.PanelGradient, settings.GradientRotation or 90, NumberSequence.new(0, 0.06)).Parent = panel
	end

	return panel
end

function Components.label(props: { [string]: any }): TextLabel
	return New("TextLabel") {
		Name = props.Name or "Label",
		Parent = props.Parent,
		BackgroundTransparency = 1,
		Size = props.Size or UDim2.new(1, 0, 0, 20),
		Position = props.Position,
		AnchorPoint = props.AnchorPoint,
		LayoutOrder = props.LayoutOrder,
		Text = props.Text or "",
		TextColor3 = props.Color or Theme.Color.Text,
		TextTransparency = props.Transparency or 0,
		FontFace = props.Font or Theme.Font.Medium,
		TextSize = props.TextSize or 16,
		TextScaled = props.TextScaled or false,
		TextXAlignment = props.XAlign or Enum.TextXAlignment.Left,
		TextYAlignment = props.YAlign or Enum.TextYAlignment.Center,
		TextWrapped = props.Wrapped or false,
		RichText = props.RichText or false,
		ZIndex = props.ZIndex or 1,
	}
end

--------------------------------------------------------------------------------
-- Stats
--------------------------------------------------------------------------------

export type StatRow = {
	Instance: Frame,
	set: (value: number) -> (),
	setText: (text: string) -> (),
}

--- A labelled number that counts toward its new value instead of snapping.
function Components.statRow(props: { [string]: any }): StatRow
	local formatter = props.Formatter or Format.abbreviate

	local row = New("Frame") {
		Name = props.Name or "StatRow",
		Parent = props.Parent,
		BackgroundTransparency = 1,
		Size = props.Size or UDim2.new(1, 0, 0, 22),
		LayoutOrder = props.LayoutOrder,
	}

	New("Frame") {
		Name = "Dot",
		Parent = row,
		AnchorPoint = Vector2.new(0, 0.5),
		Position = UDim2.new(0, 0, 0.5, 0),
		Size = UDim2.fromOffset(6, 6),
		BackgroundColor3 = props.Color or Theme.Color.Accent,

		New.corner(Theme.Radius.Pill),
	}

	Components.label {
		Parent = row,
		Name = "Title",
		Text = props.Title or "",
		Color = Theme.Color.TextMuted,
		Font = Theme.Font.Medium,
		TextSize = props.TextSize or 15,
		Position = UDim2.fromOffset(14, 0),
		Size = UDim2.new(1, -14, 1, 0),
	}

	local value = Components.label {
		Parent = row,
		Name = "Value",
		Text = formatter(0),
		Color = props.Color or Theme.Color.Text,
		Font = Theme.Font.Bold,
		TextSize = props.TextSize or 15,
		Size = UDim2.new(1, 0, 1, 0),
		XAlign = Enum.TextXAlignment.Right,
	}

	local counter = Motion.numberLabel(value, function(current)
		return formatter(current)
	end)

	return {
		Instance = row,
		set = function(target: number)
			counter:set(target)
		end,
		setText = function(text: string)
			counter:snap(0)
			value.Text = text
		end,
	}
end

export type ProgressBar = {
	Instance: Frame,
	Fill: Frame,
	setAlpha: (alpha: number) -> (),
	setColor: (color: ColorSequence) -> (),
}

function Components.progressBar(props: { [string]: any }?): ProgressBar
	local settings = props or {}

	local track = New("Frame") {
		Name = settings.Name or "ProgressBar",
		Parent = settings.Parent,
		Size = settings.Size or UDim2.new(1, 0, 0, 14),
		Position = settings.Position,
		AnchorPoint = settings.AnchorPoint,
		LayoutOrder = settings.LayoutOrder,
		BackgroundColor3 = Theme.Color.PanelSunken,
		BorderSizePixel = 0,
		ClipsDescendants = true,

		New.corner(Theme.Radius.Pill),
		New.stroke(Theme.Color.StrokeSoft, 1, 0.4),
	}

	local fill = New("Frame") {
		Name = "Fill",
		Parent = track,
		Size = UDim2.fromScale(0, 1),
		BackgroundColor3 = Color3.new(1, 1, 1),
		BorderSizePixel = 0,

		New.corner(Theme.Radius.Pill),
	}

	local gradient = New.gradient(settings.Gradient or Theme.SpeedGradient, 0)
	gradient.Parent = fill

	local counter = Motion.barFill(fill)

	return {
		Instance = track,
		Fill = fill,
		setAlpha = function(alpha: number)
			counter:set(math.clamp(alpha, 0, 1))
		end,
		setColor = function(color: ColorSequence)
			gradient.Color = color
		end,
	}
end

--------------------------------------------------------------------------------
-- Controls
--------------------------------------------------------------------------------

export type Button = {
	Instance: TextButton,
	setText: (text: string) -> (),
	setEnabled: (enabled: boolean) -> (),
	setColor: (color: Color3) -> (),
}

--- Wires hover, press and gamepad-selection feedback onto one button.
local function attachInteraction(button: GuiButton, stroke: UIStroke, getColor: () -> Color3)
	local hovering = false
	local pressed = false

	local function refresh()
		local base = getColor()
		local target = base
		if pressed then
			target = Theme.darken(base, 0.18)
		elseif hovering then
			target = Theme.lighten(base, 0.14)
		end

		Motion.tween(button, { BackgroundColor3 = target }, 0.14)
		Motion.tween(stroke, { Transparency = hovering and 0 or 0.45, Thickness = hovering and 2 or 1 }, 0.14)
	end

	button.MouseEnter:Connect(function()
		hovering = true
		refresh()
	end)
	button.MouseLeave:Connect(function()
		hovering = false
		pressed = false
		refresh()
	end)

	-- Gamepad focus uses the same visual as a mouse hover.
	button.SelectionGained:Connect(function()
		hovering = true
		refresh()
	end)
	button.SelectionLost:Connect(function()
		hovering = false
		refresh()
	end)

	button.MouseButton1Down:Connect(function()
		pressed = true
		refresh()
	end)
	button.MouseButton1Up:Connect(function()
		pressed = false
		refresh()
	end)

	button.TouchTap:Connect(function()
		Motion.tween(stroke, { Thickness = 3 }, 0.08)
		Motion.tween(stroke, { Thickness = 1 }, 0.25)
	end)

	refresh()
end

function Components.button(props: { [string]: any }): Button
	local color = props.Color or Theme.Color.Accent
	local enabled = true

	local button = New("TextButton") {
		Name = props.Name or "Button",
		Parent = props.Parent,
		Size = props.Size or UDim2.new(1, 0, 0, 40),
		Position = props.Position,
		AnchorPoint = props.AnchorPoint,
		LayoutOrder = props.LayoutOrder,
		BackgroundColor3 = color,
		BorderSizePixel = 0,
		AutoButtonColor = false,
		Text = props.Text or "",
		TextColor3 = props.TextColor or Theme.Color.Text,
		FontFace = props.Font or Theme.Font.Bold,
		TextSize = props.TextSize or 17,
		TextScaled = props.TextScaled or false,
		-- Gamepad: every button is reachable by the D-pad.
		Selectable = true,

		New.corner(props.Radius or Theme.Radius.Medium),
	}

	local stroke = New.stroke(Theme.lighten(color, 0.4), 1, 0.45)
	stroke.Parent = button

	if props.Gradient ~= false then
		New.gradient(Theme.PanelGradient, 90, NumberSequence.new(0, 0.12)).Parent = button
	end

	local function currentColor(): Color3
		return if enabled then color else Theme.Color.Locked
	end

	attachInteraction(button, stroke, currentColor)

	button.Activated:Connect(function()
		if not enabled then
			return
		end
		Sound.play("Click", 1, 0.25)
		if props.OnClick then
			props.OnClick()
		end
	end)

	-- Both setters are idempotent. They are called from render paths that run
	-- several times a second, and starting a tween per call would mean the
	-- button never settles and the tween pool never drains.
	return {
		Instance = button,
		setText = function(text: string)
			if button.Text ~= text then
				button.Text = text
			end
		end,
		setEnabled = function(value: boolean)
			if enabled == value then
				return
			end
			enabled = value
			button.Selectable = value
			button.TextTransparency = value and 0 or 0.35
			Motion.tween(button, { BackgroundColor3 = currentColor() }, 0.16)
		end,
		setColor = function(value: Color3)
			if color == value then
				return
			end
			color = value
			Motion.tween(button, { BackgroundColor3 = currentColor() }, 0.16)
		end,
	}
end

export type Toggle = {
	Instance: Frame,
	set: (value: boolean) -> (),
	get: () -> boolean,
}

function Components.toggle(props: { [string]: any }): Toggle
	local value = props.Value == true

	local row = New("Frame") {
		Name = props.Name or "Toggle",
		Parent = props.Parent,
		BackgroundTransparency = 1,
		Size = props.Size or UDim2.new(1, 0, 0, 38),
		LayoutOrder = props.LayoutOrder,
	}

	Components.label {
		Parent = row,
		Name = "Title",
		Text = props.Title or "",
		Color = Theme.Color.Text,
		Font = Theme.Font.Medium,
		TextSize = 16,
		Size = UDim2.new(1, -70, 1, 0),
	}

	local switch = New("TextButton") {
		Name = "Switch",
		Parent = row,
		AnchorPoint = Vector2.new(1, 0.5),
		Position = UDim2.new(1, 0, 0.5, 0),
		Size = UDim2.fromOffset(58, 28),
		BackgroundColor3 = Theme.Color.PanelSunken,
		BorderSizePixel = 0,
		AutoButtonColor = false,
		Text = "",
		Selectable = true,

		New.corner(Theme.Radius.Pill),
	}

	local stroke = New.stroke(Theme.Color.Stroke, 1, 0.4)
	stroke.Parent = switch

	local knob = New("Frame") {
		Name = "Knob",
		Parent = switch,
		AnchorPoint = Vector2.new(0, 0.5),
		Position = UDim2.new(0, 3, 0.5, 0),
		Size = UDim2.fromOffset(22, 22),
		BackgroundColor3 = Theme.Color.TextMuted,
		BorderSizePixel = 0,

		New.corner(Theme.Radius.Pill),
	}

	local function render(animated: boolean)
		local position = if value then UDim2.new(1, -25, 0.5, 0) else UDim2.new(0, 3, 0.5, 0)
		local knobColor = if value then Theme.Color.Text else Theme.Color.TextMuted
		local trackColor = if value then Theme.Color.Success else Theme.Color.PanelSunken

		if animated then
			Motion.tween(knob, { Position = position, BackgroundColor3 = knobColor }, 0.2, Enum.EasingStyle.Back)
			Motion.tween(switch, { BackgroundColor3 = trackColor }, 0.2)
		else
			knob.Position = position
			knob.BackgroundColor3 = knobColor
			switch.BackgroundColor3 = trackColor
		end
	end

	switch.Activated:Connect(function()
		value = not value
		render(true)
		Sound.play("Click", value and 1.1 or 0.9, 0.25)
		if props.OnChanged then
			props.OnChanged(value)
		end
	end)

	attachInteraction(switch, stroke, function()
		return if value then Theme.Color.Success else Theme.Color.PanelSunken
	end)

	render(false)

	return {
		Instance = row,
		set = function(newValue: boolean)
			if value == newValue then
				return
			end
			value = newValue
			render(true)
		end,
		get = function(): boolean
			return value
		end,
	}
end

export type Segmented = {
	Instance: Frame,
	set: (value: any) -> (),
}

--- A row of mutually exclusive options. Preferred over a drag slider because it
--- works identically with a mouse, a thumb and a D-pad.
function Components.segmented(props: { [string]: any }): Segmented
	local options = props.Options or {}
	local selected = props.Value

	local row = New("Frame") {
		Name = props.Name or "Segmented",
		Parent = props.Parent,
		BackgroundTransparency = 1,
		Size = props.Size or UDim2.new(1, 0, 0, 58),
		LayoutOrder = props.LayoutOrder,
	}

	Components.label {
		Parent = row,
		Name = "Title",
		Text = props.Title or "",
		Color = Theme.Color.Text,
		Font = Theme.Font.Medium,
		TextSize = 16,
		Size = UDim2.new(1, 0, 0, 20),
	}

	local strip = New("Frame") {
		Name = "Options",
		Parent = row,
		Position = UDim2.new(0, 0, 0, 24),
		Size = UDim2.new(1, 0, 0, 32),
		BackgroundTransparency = 1,

		New.list {
			FillDirection = Enum.FillDirection.Horizontal,
			Padding = UDim.new(0, 6),
			HorizontalAlignment = Enum.HorizontalAlignment.Left,
		},
	}

	local buttons: { [any]: any } = {}

	local function render()
		for value, entry in buttons do
			local active = value == selected
			entry.setColor(if active then Theme.Color.Accent else Theme.Color.PanelSunken)
		end
	end

	for index, option in options do
		local entry
		entry = Components.button {
			Parent = strip,
			Name = tostring(option.Value),
			Text = option.Label,
			TextSize = 14,
			LayoutOrder = index,
			Size = UDim2.new(0, 74, 1, 0),
			Radius = Theme.Radius.Small,
			Color = Theme.Color.PanelSunken,
			OnClick = function()
				selected = option.Value
				render()
				if props.OnChanged then
					props.OnChanged(option.Value)
				end
			end,
		}
		buttons[option.Value] = entry
	end

	render()

	return {
		Instance = row,
		set = function(value: any)
			if selected == value then
				return
			end
			selected = value
			render()
		end,
	}
end

return Components
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="47">
            <Properties>
              <string name="Name">FloatingText</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	FloatingText
	----------------------------------------------------------------------------
	The popup that sells the whole genre:

	        +1          <- big, bright, the thing you earned
	      24 Speed      <- underneath, the number it just became

	That second line is the point. "+1" on its own is a particle; "+1" with your
	running total under it is feedback, and it is why these games feel like they
	are always giving you something.

	Pooled: BillboardGuis are created once and reused, because a player at high
	WalkSpeed earns several rewards a second and allocating a Gui per reward is
	how a simulator ends up with a stutter every time someone gets fast.

	Adornee-based rather than parented into the character, so a respawn cannot
	orphan a live animation.
]]

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)

local Motion = require(script.Parent.Motion)
local New = require(script.Parent.New)
local Theme = require(script.Parent.Theme)

local FloatingText = {}

local pool: { BillboardGui } = {}
local active = 0
local container: ScreenGui? = nil

local function getContainer(): ScreenGui
	if container and container.Parent then
		return container
	end

	local playerGui = Players.LocalPlayer:WaitForChild("PlayerGui")
	local gui = New("ScreenGui") {
		Name = "FloatingText",
		Parent = playerGui,
		ResetOnSpawn = false,
		DisplayOrder = 5,
		IgnoreGuiInset = true,
	}

	container = gui
	return gui
end

local function build(): BillboardGui
	local billboard = New("BillboardGui") {
		Name = "Float",
		Parent = getContainer(),
		Size = UDim2.fromOffset(190, 74),
		AlwaysOnTop = true,
		LightInfluence = 0,
		MaxDistance = 120,
		Enabled = false,
	}

	local gain = New("TextLabel") {
		Name = "Gain",
		Parent = billboard,
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0.62, 0),
		Text = "",
		TextColor3 = Theme.Color.Speed,
		FontFace = Theme.Font.Display,
		TextScaled = true,
	}
	New.stroke(Theme.Color.Backdrop, 2.5, 0.1).Parent = gain

	local total = New("TextLabel") {
		Name = "Total",
		Parent = billboard,
		BackgroundTransparency = 1,
		Position = UDim2.fromScale(0, 0.6),
		Size = UDim2.new(1, 0, 0.36, 0),
		Text = "",
		TextColor3 = Theme.Color.Text,
		FontFace = Theme.Font.Bold,
		TextScaled = true,
	}
	New.stroke(Theme.Color.Backdrop, 2, 0.2).Parent = total

	return billboard
end

local function acquire(): BillboardGui?
	if active >= Config.UI.MaxFloatingTexts then
		return nil
	end

	local billboard = table.remove(pool)
	if not billboard then
		billboard = build()
	end

	active += 1
	return billboard
end

local function release(billboard: BillboardGui)
	billboard.Enabled = false
	billboard.Adornee = nil
	active -= 1
	table.insert(pool, billboard)
end

local function fade(label: TextLabel, lifetime: number)
	label.TextTransparency = 0
	Motion.tween(label, { TextTransparency = 1 }, lifetime, Enum.EasingStyle.Quad, Enum.EasingDirection.In)

	local stroke = label:FindFirstChildOfClass("UIStroke")
	if stroke then
		stroke.Transparency = 0.1
		Motion.tween(stroke, { Transparency = 1 }, lifetime, Enum.EasingStyle.Quad, Enum.EasingDirection.In)
	end
end

--- Spawns one popup above `adornee`.
--- `gain` is the headline ("+1"), `total` the line underneath ("24 Speed").
function FloatingText.spawn(adornee: BasePart, gain: string, total: string?, color: Color3?)
	local billboard = acquire()
	if not billboard then
		return
	end

	local gainLabel = billboard:FindFirstChild("Gain") :: TextLabel
	local totalLabel = billboard:FindFirstChild("Total") :: TextLabel

	gainLabel.Text = gain
	gainLabel.TextColor3 = color or Theme.Color.Speed

	totalLabel.Text = total or ""
	totalLabel.Visible = total ~= nil and total ~= ""

	-- A little horizontal scatter so a burst reads as several rewards rather
	-- than one label flickering.
	local scatter = (math.random() - 0.5) * 2.4
	billboard.StudsOffsetWorldSpace = Vector3.new(scatter, 2.6, 0)
	billboard.Adornee = adornee
	billboard.Enabled = true

	local lifetime = Config.UI.FloatingTextLifetime

	Motion.tween(
		billboard,
		{ StudsOffsetWorldSpace = Vector3.new(scatter * 1.6, 2.6 + Config.UI.FloatingTextRise, 0) },
		lifetime,
		Enum.EasingStyle.Quad,
		Enum.EasingDirection.Out
	)

	fade(gainLabel, lifetime)
	fade(totalLabel, lifetime)

	task.delay(Motion.duration(lifetime) + 0.05, function()
		release(billboard)
	end)
end

return FloatingText
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="48">
            <Properties>
              <string name="Name">HUD</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	HUD
	----------------------------------------------------------------------------
	The always-on interface, laid out the way the "+1" games lay theirs out:

	  * Top left  -- the two numbers that matter. SPEED is what ticks up while you
	    walk; TROPHIES is what you spend on ranks. Everything else is a small row
	    underneath.
	  * Top centre -- the rank card: badge, name, and a bar filling toward the
	    next rank in trophies.
	  * Bottom centre -- the challenge banner, which only appears while a course
	    run is live and counts the seconds.
	  * Right -- the buttons.

	Everything animates through Motion, so numbers count up, the progress bar
	eases and the whole HUD honours the player's animation-speed setting. Nothing
	polls: `HUD:render(profile)` is called when ClientState changes and does the
	minimum work needed to reflect it.

	Compact mode (narrow screens / phones) drops the secondary stat rows and
	shrinks the cards rather than scaling everything down until it is unreadable.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Challenges = require(Shared.Challenges)
local Config = require(Shared.Config)
local Format = require(Shared.Format)
local Progression = require(Shared.Progression)
local Ranks = require(Shared.Ranks)
local SpeedCurve = require(Shared.SpeedCurve)

local Components = require(script.Parent.Components)
local Motion = require(script.Parent.Motion)
local New = require(script.Parent.New)
local RankIcon = require(script.Parent.RankIcon)
local Theme = require(script.Parent.Theme)

local HUD = {}
HUD.__index = HUD

local PANEL_WIDTH = 272
local PANEL_WIDTH_COMPACT = 212
local PANEL_HEIGHT = 292
local PANEL_HEIGHT_COMPACT = 208

function HUD.new(parent: Instance)
	local self = setmetatable({}, HUD)

	self.compact = false
	self.currentRankId = nil
	self.nextRankId = nil
	self.callbacks = {}
	-- Buttons that exist, in creation order. Built as a list rather than named
	-- fields so an optional button (rebirth) cannot leave a hole in it.
	self.buttons = {}

	self.challengeId = nil
	self.challengeStartedAt = nil
	self.challengeTick = 0

	self.root = New("Frame") {
		Name = "HUDRoot",
		Parent = parent,
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
	}

	self.scale = Instance.new("UIScale")
	self.scale.Parent = self.root

	self:_buildStats()
	self:_buildRankCard()
	self:_buildChallengeBanner()
	self:_buildButtons()

	RunService.PostSimulation:Connect(function(deltaTime)
		self:_stepChallengeTimer(deltaTime)
	end)

	return self
end

--------------------------------------------------------------------------------
-- Construction
--------------------------------------------------------------------------------

function HUD:_buildStats()
	local panel = Components.panel {
		Parent = self.root,
		Name = "Stats",
		Position = UDim2.fromOffset(16, 16),
		Size = UDim2.fromOffset(PANEL_WIDTH, PANEL_HEIGHT),
	}
	self.statsPanel = panel

	New.padding(14).Parent = panel
	New.list({ Padding = UDim.new(0, 6) }).Parent = panel

	Components.label {
		Parent = panel,
		Name = "Brand",
		Text = string.upper(Config.Game.Name),
		Color = Theme.Color.Accent,
		Font = Theme.Font.Bold,
		TextSize = 12,
		Size = UDim2.new(1, 0, 0, 14),
		LayoutOrder = 1,
	}

	local speedRow = New("Frame") {
		Name = "SpeedRow",
		Parent = panel,
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 52),
		LayoutOrder = 2,
	}

	self.speedLabel = Components.label {
		Parent = speedRow,
		Name = "SpeedValue",
		Text = "0",
		Color = Theme.Color.Speed,
		Font = Theme.Font.Display,
		TextSize = 44,
		Size = UDim2.new(1, -46, 1, 0),
		YAlign = Enum.TextYAlignment.Center,
	}

	Components.label {
		Parent = speedRow,
		Name = "SpeedCaption",
		Text = "SPEED",
		Color = Theme.Color.TextDim,
		Font = Theme.Font.Bold,
		TextSize = 12,
		AnchorPoint = Vector2.new(1, 1),
		Position = UDim2.new(1, 0, 1, -6),
		Size = UDim2.fromOffset(60, 14),
		XAlign = Enum.TextXAlignment.Right,
	}

	self.speedCounter = Motion.numberLabel(self.speedLabel, Format.abbreviate)

	self.walkSpeedLabel = Components.label {
		Parent = panel,
		Name = "WalkSpeed",
		Text = "",
		Color = Theme.Color.TextMuted,
		Font = Theme.Font.Medium,
		TextSize = 13,
		Size = UDim2.new(1, 0, 0, 16),
		LayoutOrder = 3,
	}

	-- The second headline number. Trophies are the currency ranks are bought
	-- with, so it gets its own tile rather than being buried in the stat list.
	local trophyRow = New("Frame") {
		Name = "TrophyRow",
		Parent = panel,
		BackgroundColor3 = Theme.Color.PanelSunken,
		BackgroundTransparency = 0.35,
		BorderSizePixel = 0,
		Size = UDim2.new(1, 0, 0, 34),
		LayoutOrder = 4,

		New.corner(Theme.Radius.Small),
		New.padding(8),
	}

	Components.label {
		Parent = trophyRow,
		Name = "TrophyCaption",
		Text = "TROPHIES",
		Color = Theme.Color.TextDim,
		Font = Theme.Font.Bold,
		TextSize = 12,
		Size = UDim2.new(0.5, 0, 1, 0),
	}

	self.trophyLabel = Components.label {
		Parent = trophyRow,
		Name = "TrophyValue",
		Text = "0",
		Color = Theme.Color.Coin,
		Font = Theme.Font.Display,
		TextSize = 22,
		Size = UDim2.new(1, 0, 1, 0),
		XAlign = Enum.TextXAlignment.Right,
	}

	self.trophyCounter = Motion.numberLabel(self.trophyLabel, Format.comma)

	New("Frame") {
		Name = "Divider",
		Parent = panel,
		BackgroundColor3 = Theme.Color.StrokeSoft,
		BackgroundTransparency = 0.35,
		BorderSizePixel = 0,
		Size = UDim2.new(1, 0, 0, 1),
		LayoutOrder = 5,
	}

	self.rows = {}

	local function row(name: string, title: string, color: Color3, order: number, formatter: ((number) -> string)?)
		self.rows[name] = Components.statRow {
			Parent = panel,
			Name = name,
			Title = title,
			Color = color,
			LayoutOrder = order,
			Formatter = formatter,
		}
	end

	row("Steps", "Steps taken", Theme.Color.AccentDeep, 6, Format.comma)
	row("Reward", "Speed per step", Theme.Color.Speed, 7, function(value)
		return `+{Format.abbreviate(value)}`
	end)
	row("Distance", "Distance walked", Theme.Color.Accent, 8, Format.distance)
	row("Coins", "Coins", Theme.Color.Coin, 9)
	row("Rebirths", "Rebirths", Theme.Color.Warning, 10, Format.comma)
end

function HUD:_buildRankCard()
	local card = Components.panel {
		Parent = self.root,
		Name = "RankCard",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, 16),
		Size = UDim2.fromOffset(430, 96),
	}
	self.rankCard = card

	New.padding(12).Parent = card

	self.currentIconHolder = New("Frame") {
		Name = "CurrentIcon",
		Parent = card,
		BackgroundTransparency = 1,
		Size = UDim2.fromOffset(68, 68),
		Position = UDim2.fromOffset(0, 2),
	}

	self.nextIconHolder = New("Frame") {
		Name = "NextIcon",
		Parent = card,
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.new(1, 0, 0, 8),
		Size = UDim2.fromOffset(56, 56),
	}

	local middle = New("Frame") {
		Name = "Middle",
		Parent = card,
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(78, 0),
		Size = UDim2.new(1, -78 - 64, 1, 0),
	}

	self.rankName = Components.label {
		Parent = middle,
		Name = "RankName",
		Text = "Unranked",
		Color = Theme.Color.Text,
		Font = Theme.Font.Display,
		TextSize = 20,
		Size = UDim2.new(1, 0, 0, 24),
	}

	self.rankRarity = Components.label {
		Parent = middle,
		Name = "Rarity",
		Text = "Clear a challenge to earn your first trophy",
		Color = Theme.Color.TextMuted,
		Font = Theme.Font.Medium,
		TextSize = 13,
		Position = UDim2.fromOffset(0, 24),
		Size = UDim2.new(1, 0, 0, 16),
	}

	self.rankBar = Components.progressBar {
		Parent = middle,
		Position = UDim2.new(0, 0, 0, 44),
		Size = UDim2.new(1, 0, 0, 12),
	}

	self.rankProgressLabel = Components.label {
		Parent = middle,
		Name = "Progress",
		Text = "0 / 0",
		Color = Theme.Color.TextMuted,
		Font = Theme.Font.Bold,
		TextSize = 12,
		Position = UDim2.new(0, 0, 0, 58),
		Size = UDim2.new(1, 0, 0, 14),
		XAlign = Enum.TextXAlignment.Right,
	}
end

function HUD:_buildChallengeBanner()
	local banner = Components.panel {
		Parent = self.root,
		Name = "ChallengeBanner",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -22),
		Size = UDim2.fromOffset(330, 58),
		Color = Theme.Color.PanelRaised,
		StrokeColor = Theme.Color.Success,
		StrokeTransparency = 0.1,
	}
	banner.Visible = false
	self.challengeBanner = banner

	New.padding(10).Parent = banner

	self.challengeName = Components.label {
		Parent = banner,
		Name = "Name",
		Text = "",
		Color = Theme.Color.Text,
		Font = Theme.Font.Display,
		TextSize = 18,
		Size = UDim2.new(0.62, 0, 0, 22),
	}

	self.challengeReward = Components.label {
		Parent = banner,
		Name = "Reward",
		Text = "",
		Color = Theme.Color.Coin,
		Font = Theme.Font.Bold,
		TextSize = 13,
		Position = UDim2.fromOffset(0, 22),
		Size = UDim2.new(0.62, 0, 0, 16),
	}

	self.challengeTimer = Components.label {
		Parent = banner,
		Name = "Timer",
		Text = "0.0s",
		Color = Theme.Color.Success,
		Font = Theme.Font.Display,
		TextSize = 26,
		Size = UDim2.new(1, 0, 1, 0),
		XAlign = Enum.TextXAlignment.Right,
	}
end

function HUD:_buildButtons()
	local column = New("Frame") {
		Name = "Buttons",
		Parent = self.root,
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(1, 0.5),
		Position = UDim2.new(1, -16, 0.5, 0),
		Size = UDim2.fromOffset(158, 160),

		New.list {
			Padding = UDim.new(0, 8),
			VerticalAlignment = Enum.VerticalAlignment.Center,
			HorizontalAlignment = Enum.HorizontalAlignment.Right,
		},
	}
	self.buttonColumn = column

	self.rankButton = Components.button {
		Parent = column,
		Name = "RanksButton",
		Text = "Ranks",
		LayoutOrder = 1,
		Size = UDim2.fromOffset(158, 46),
		Color = Theme.Color.Accent,
		OnClick = function()
			self:_emit("Ranks")
		end,
	}
	table.insert(self.buttons, self.rankButton)

	if Config.Progression.RebirthEnabled then
		self.rebirthButton = Components.button {
			Parent = column,
			Name = "RebirthButton",
			Text = "Rebirth",
			LayoutOrder = 2,
			Size = UDim2.fromOffset(158, 46),
			Color = Theme.Color.AccentDeep,
			TextSize = 15,
			OnClick = function()
				self:_emit("Rebirth")
			end,
		}
		table.insert(self.buttons, self.rebirthButton)
	end

	self.settingsButton = Components.button {
		Parent = column,
		Name = "SettingsButton",
		Text = "Settings",
		LayoutOrder = 3,
		Size = UDim2.fromOffset(158, 46),
		Color = Theme.Color.PanelRaised,
		OnClick = function()
			self:_emit("Settings")
		end,
	}
	table.insert(self.buttons, self.settingsButton)
end

--------------------------------------------------------------------------------
-- Rendering
--------------------------------------------------------------------------------

local function swapIcon(holder: Frame, definition, locked: boolean)
	holder:ClearAllChildren()
	if not definition then
		return nil
	end

	local icon = RankIcon.create(definition, { Locked = locked, Size = UDim2.fromScale(1, 1) })
	icon.Parent = holder
	return icon
end

function HUD:render(profile)
	self.speedCounter:set(profile.Speed)
	self.trophyCounter:set(profile.Trophies)

	local walkSpeed = SpeedCurve.walkSpeed(profile.Speed, profile.Rebirths)
	local capped = SpeedCurve.isCapped(profile.Speed, profile.Rebirths)
	local walkText = if capped
		then string.format("%d studs/s (max)", math.floor(walkSpeed))
		else string.format("%d studs/s", math.floor(walkSpeed))
	if self.walkSpeedLabel.Text ~= walkText then
		self.walkSpeedLabel.Text = walkText
	end

	self.rows.Reward.set(Progression.getSpeedPerStep(profile))
	self.rows.Distance.set(profile.Distance)
	self.rows.Steps.set(profile.Steps)
	self.rows.Coins.set(profile.Coins)
	self.rows.Rebirths.set(profile.Rebirths)

	if self.rebirthButton then
		local allowed, cost = Progression.canRebirth(profile)
		self.rebirthButton.setEnabled(allowed)
		self.rebirthButton.setText(if allowed then "Rebirth!" else `Rebirth @ {Format.abbreviate(cost)}`)
	end

	self:_renderRank(profile)
end

function HUD:_renderRank(profile)
	local alpha, earned, needed, current, upcoming = Ranks.getLadderProgress(profile.Trophies, profile.Ranks)

	local currentId = current and current.Id or nil
	if currentId ~= self.currentRankId then
		self.currentRankId = currentId
		swapIcon(self.currentIconHolder, current, false)

		if current then
			local rarity = Ranks.getRarity(current)
			self.rankName.Text = `{current.Name} Rank`
			self.rankRarity.Text = rarity.Name
			self.rankRarity.TextColor3 = rarity.Color
			self.rankBar.setColor(ColorSequence.new(rarity.Color, Theme.lighten(rarity.Color, 0.35)))
		else
			self.rankName.Text = "Unranked"
			self.rankRarity.Text = "Clear a challenge to earn your first trophy"
			self.rankRarity.TextColor3 = Theme.Color.TextMuted
			self.rankBar.setColor(Theme.SpeedGradient)
		end
	end

	local nextId = upcoming and upcoming.Id or nil
	if nextId ~= self.nextRankId then
		self.nextRankId = nextId
		swapIcon(self.nextIconHolder, upcoming, true)
	end

	self.rankBar.setAlpha(alpha)

	local progressText = if upcoming
		then `{Format.comma(earned)} / {Format.comma(needed)} trophies to {upcoming.Name}`
		else "Every rank unlocked"
	if self.rankProgressLabel.Text ~= progressText then
		self.rankProgressLabel.Text = progressText
	end
end

--- Flashes the speed number when a reward lands.
function HUD:pulse()
	Motion.pop(self.speedLabel, 1.12, 0.4)
end

--- Flashes the trophy number when a course pays out.
function HUD:pulseTrophies()
	Motion.pop(self.trophyLabel, 1.18, 0.45)
end

--------------------------------------------------------------------------------
-- Challenge banner
--------------------------------------------------------------------------------

--- Shows or hides the live run banner. `id` is a challenge id, or nil to clear.
function HUD:setChallenge(id: string?)
	local definition = if id then Challenges.get(id) else nil

	if not definition then
		self.challengeId = nil
		self.challengeStartedAt = nil
		if self.challengeBanner.Visible then
			self.challengeBanner.Visible = false
		end
		return
	end

	local difficulty = Challenges.getDifficulty(definition)

	self.challengeId = definition.Id
	self.challengeStartedAt = os.clock()
	self.challengeName.Text = definition.Name
	self.challengeReward.Text =
		`{difficulty.Name}  ·  +{definition.Trophies} {definition.Trophies == 1 and "trophy" or "trophies"}`
	self.challengeTimer.Text = "0.0s"
	self.challengeTimer.TextColor3 = difficulty.Color
	self.challengeBanner.Visible = true
end

function HUD:_stepChallengeTimer(deltaTime: number)
	if not self.challengeStartedAt then
		return
	end

	-- 10 Hz: one decimal place cannot show more than that anyway.
	self.challengeTick += deltaTime
	if self.challengeTick < 0.1 then
		return
	end
	self.challengeTick = 0

	local elapsed = os.clock() - self.challengeStartedAt

	-- The server expires a run at the same point. Clearing the banner locally
	-- means a player who wandered off is not left staring at a dead timer.
	if elapsed > Config.Challenges.RunTimeout then
		self:setChallenge(nil)
		return
	end

	self.challengeTimer.Text = string.format("%.1fs", elapsed)
end

--------------------------------------------------------------------------------
-- Layout
--------------------------------------------------------------------------------

function HUD:setScale(scale: number)
	self.scale.Scale = scale
end

function HUD:setCompact(compact: boolean)
	if self.compact == compact then
		return
	end
	self.compact = compact

	self.statsPanel.Size = UDim2.fromOffset(
		if compact then PANEL_WIDTH_COMPACT else PANEL_WIDTH,
		if compact then PANEL_HEIGHT_COMPACT else PANEL_HEIGHT
	)

	for _, name in { "Steps", "Coins", "Rebirths" } do
		self.rows[name].Instance.Visible = not compact
	end

	self.rankCard.Size = if compact then UDim2.fromOffset(330, 88) else UDim2.fromOffset(430, 96)
	self.rankProgressLabel.Visible = not compact

	self.challengeBanner.Size = if compact then UDim2.fromOffset(268, 52) else UDim2.fromOffset(330, 58)

	local buttonWidth = if compact then 122 else 158
	for _, handle in self.buttons do
		handle.Instance.Size = UDim2.fromOffset(buttonWidth, if compact then 40 else 46)
	end
	self.buttonColumn.Size = UDim2.fromOffset(buttonWidth, 160)
end

function HUD:setVisible(visible: boolean)
	self.root.Visible = visible
end

--------------------------------------------------------------------------------
-- Events
--------------------------------------------------------------------------------

function HUD:on(name: string, callback: () -> ())
	self.callbacks[name] = callback
end

function HUD:_emit(name: string)
	local callback = self.callbacks[name]
	if callback then
		callback()
	end
end

--- Gamepad navigation entry point: the first thing the D-pad should land on.
function HUD:getPrimarySelection(): GuiObject
	return self.rankButton.Instance
end

return HUD
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="49">
            <Properties>
              <string name="Name">Motion</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Motion
	----------------------------------------------------------------------------
	Every animation in the game goes through here, which buys three things:

	  * One global speed dial. Config.UI.AnimationSpeed sets the baseline and the
	    player's own setting scales it further, so "make the UI snappier" is a
	    number, not a hunt through twenty TweenInfos.
	  * Counters that ease toward a target instead of restarting a tween. When
	    Speed ticks up three times a second a restarted tween never lands;
	    framerate-independent smoothing always converges and always looks smooth.
	  * A single PreRender connection driving all of them, rather than one
	    connection (or one tween object) per label.
]]

local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local Util = require(Shared.Util)

local Motion = {}

--- Player-controlled multiplier. Higher == faster animations.
Motion.speed = 1

local EASE = Enum.EasingStyle.Quart
local DIRECTION = Enum.EasingDirection.Out

--- Turns a design-time duration into a real one.
function Motion.duration(seconds: number): number
	local scale = Config.UI.AnimationSpeed * math.max(Motion.speed, 0.05)
	return math.max(seconds / scale, 0.01)
end

function Motion.info(seconds: number, style: Enum.EasingStyle?, direction: Enum.EasingDirection?): TweenInfo
	return TweenInfo.new(Motion.duration(seconds), style or EASE, direction or DIRECTION)
end

--- Fire-and-forget tween. Returns the tween so callers can wait on it.
function Motion.tween(
	instance: Instance,
	properties: { [string]: any },
	seconds: number?,
	style: Enum.EasingStyle?,
	direction: Enum.EasingDirection?
): Tween
	local tween = TweenService:Create(instance, Motion.info(seconds or 0.25, style, direction), properties)
	tween:Play()
	return tween
end

--- A short overshoot, for "something good just happened".
function Motion.pop(instance: GuiObject, scale: number?, seconds: number?)
	local base = instance:GetAttribute("MotionBaseSize")
	if base == nil then
		base = instance.Size
		instance:SetAttribute("MotionBaseSize", base)
	end

	local amount = scale or 1.08
	local grown =
		UDim2.new(base.X.Scale * amount, base.X.Offset * amount, base.Y.Scale * amount, base.Y.Offset * amount)

	instance.Size = grown
	Motion.tween(instance, { Size = base }, seconds or 0.35, Enum.EasingStyle.Back, Enum.EasingDirection.Out)
end

--------------------------------------------------------------------------------
-- Counters
--------------------------------------------------------------------------------

export type Counter = {
	set: (self: Counter, target: number) -> (),
	snap: (self: Counter, value: number) -> (),
	get: (self: Counter) -> number,
	destroy: (self: Counter) -> (),
}

type CounterState = {
	current: number,
	target: number,
	stiffness: number,
	epsilon: number,
	apply: (number) -> (),
	alive: boolean,
	dirty: boolean,
}

local counters: { CounterState } = {}
local connection: RBXScriptConnection? = nil

local function step(deltaTime: number)
	local index = 1
	while index <= #counters do
		local state = counters[index]

		if not state.alive then
			-- Swap-remove: order does not matter and it keeps removal O(1).
			counters[index] = counters[#counters]
			counters[#counters] = nil
			continue
		end

		if state.dirty then
			state.current = Util.damp(state.current, state.target, state.stiffness * Motion.speed, deltaTime)

			if math.abs(state.target - state.current) <= state.epsilon then
				state.current = state.target
				state.dirty = false
			end

			state.apply(state.current)
		end

		index += 1
	end

	if #counters == 0 and connection then
		connection:Disconnect()
		connection = nil
	end
end

local function ensureRunning()
	if not connection then
		connection = RunService.PreRender:Connect(step)
	end
end

--- Creates a value that eases toward whatever you `set` it to.
--- `apply` receives the smoothed value every frame while it is moving.
function Motion.counter(
	apply: (number) -> (),
	options: { stiffness: number?, epsilon: number?, start: number? }?
): Counter
	local settings = options or {}

	local state: CounterState = {
		current = settings.start or 0,
		target = settings.start or 0,
		stiffness = settings.stiffness or Config.UI.NumberSmoothing,
		epsilon = settings.epsilon or 0.5,
		apply = apply,
		alive = true,
		dirty = false,
	}

	table.insert(counters, state)
	ensureRunning()
	apply(state.current)

	local counter = {}

	function counter:set(target: number)
		if state.target == target then
			return
		end
		state.target = target
		state.dirty = true
		ensureRunning()
	end

	function counter:snap(value: number)
		state.current = value
		state.target = value
		state.dirty = false
		state.apply(value)
	end

	function counter:get(): number
		return state.current
	end

	function counter:destroy()
		state.alive = false
	end

	return counter
end

--- Convenience wrapper: a counter that writes a formatted number into a label.
function Motion.numberLabel(label: TextLabel, formatter: (number) -> string, stiffness: number?): Counter
	return Motion.counter(function(value)
		label.Text = formatter(value)
	end, { stiffness = stiffness or Config.UI.NumberSmoothing, epsilon = 0.5 })
end

--- Convenience wrapper: a counter that drives a progress bar fill (0..1).
function Motion.barFill(fill: GuiObject, stiffness: number?): Counter
	return Motion.counter(function(alpha)
		fill.Size = UDim2.fromScale(math.clamp(alpha, 0, 1), 1)
	end, { stiffness = stiffness or Config.UI.BarSmoothing, epsilon = 0.001 })
end

return Motion
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="50">
            <Properties>
              <string name="Name">New</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	New
	----------------------------------------------------------------------------
	A ~40 line declarative instance builder, so UI code reads as a tree instead
	of 200 lines of `local x = Instance.new(...)`.

		local frame = New "Frame" {
			Name = "Card",
			Size = UDim2.fromOffset(220, 90),
			BackgroundColor3 = Theme.Color.Panel,

			New.corner(Theme.Radius.Medium),
			New.stroke(Theme.Color.Stroke),
		}

	String keys are properties, array entries are children, and Parent is always
	applied last so a tree is only ever reparented once.

	Deliberately not a reactive framework: this game has a handful of screens and
	a state mirror that already fires signals, so a builder plus explicit setter
	functions is less machinery for the same result.
]]

local DEFAULT_LIST_PADDING = 8

local New = {}

local function build(className: string)
	return function(props: { [any]: any }): Instance
		local instance = Instance.new(className)
		local parent = props.Parent

		for key, value in props do
			if type(key) == "number" then
				if typeof(value) == "Instance" then
					value.Parent = instance
				end
			elseif key ~= "Parent" then
				instance[key] = value
			end
		end

		instance.Parent = parent
		return instance
	end
end

setmetatable(New, {
	__call = function(_, className: string)
		return build(className)
	end,
})

--------------------------------------------------------------------------------
-- Modifiers
--------------------------------------------------------------------------------

function New.corner(radius: UDim): UICorner
	local corner = Instance.new("UICorner")
	corner.CornerRadius = radius
	return corner
end

function New.stroke(color: Color3, thickness: number?, transparency: number?): UIStroke
	local stroke = Instance.new("UIStroke")
	stroke.Color = color
	stroke.Thickness = thickness or 1
	stroke.Transparency = transparency or 0
	stroke.ApplyStrokeMode = Enum.ApplyStrokeMode.Border
	return stroke
end

function New.gradient(color: ColorSequence, rotation: number?, transparency: NumberSequence?): UIGradient
	local gradient = Instance.new("UIGradient")
	gradient.Color = color
	gradient.Rotation = rotation or 90
	if transparency then
		gradient.Transparency = transparency
	end
	return gradient
end

function New.padding(all: number?, overrides: { [string]: number }?): UIPadding
	local padding = Instance.new("UIPadding")
	local value = all or 0
	padding.PaddingTop = UDim.new(0, value)
	padding.PaddingBottom = UDim.new(0, value)
	padding.PaddingLeft = UDim.new(0, value)
	padding.PaddingRight = UDim.new(0, value)

	if overrides then
		for side, amount in overrides do
			padding[`Padding{side}`] = UDim.new(0, amount)
		end
	end

	return padding
end

function New.list(props: { [string]: any }?): UIListLayout
	local layout = Instance.new("UIListLayout")
	layout.SortOrder = Enum.SortOrder.LayoutOrder
	layout.FillDirection = Enum.FillDirection.Vertical
	layout.Padding = UDim.new(0, DEFAULT_LIST_PADDING)

	if props then
		for key, value in props do
			layout[key] = value
		end
	end

	return layout
end

function New.grid(cellSize: UDim2, cellPadding: UDim2, props: { [string]: any }?): UIGridLayout
	local layout = Instance.new("UIGridLayout")
	layout.CellSize = cellSize
	layout.CellPadding = cellPadding
	layout.SortOrder = Enum.SortOrder.LayoutOrder
	layout.HorizontalAlignment = Enum.HorizontalAlignment.Center

	if props then
		for key, value in props do
			layout[key] = value
		end
	end

	return layout
end

function New.aspect(ratio: number): UIAspectRatioConstraint
	local constraint = Instance.new("UIAspectRatioConstraint")
	constraint.AspectRatio = ratio
	return constraint
end

function New.sizeLimit(max: Vector2, min: Vector2?): UISizeConstraint
	local constraint = Instance.new("UISizeConstraint")
	constraint.MaxSize = max
	if min then
		constraint.MinSize = min
	end
	return constraint
end

function New.textLimit(max: number, min: number?): UITextSizeConstraint
	local constraint = Instance.new("UITextSizeConstraint")
	constraint.MaxTextSize = max
	constraint.MinTextSize = min or 1
	return constraint
end

return New
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="51">
            <Properties>
              <string name="Name">Notifications</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Notifications
	----------------------------------------------------------------------------
	The toast stack in the top right.

	Positions are tweened by hand rather than delegated to a UIListLayout,
	because the interesting part of a toast stack is the shuffle: when the top
	card expires the ones below it slide up, and that reads as intentional only
	if it is animated.

	Rank notifications carry the real rank badge, in its own colours -- the same
	procedural icon the rank window uses.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)
local Ranks = require(Shared.Ranks)

local Components = require(script.Parent.Components)
local Motion = require(script.Parent.Motion)
local New = require(script.Parent.New)
local Sound = require(script.Parent.Sound)
local Theme = require(script.Parent.Theme)
local RankIcon = require(script.Parent.RankIcon)

local Notifications = {}
Notifications.__index = Notifications

local CARD_WIDTH = 312
local CARD_HEIGHT = 74
local CARD_GAP = 8

local KIND_COLORS = {
	Info = Theme.Color.Accent,
	Success = Theme.Color.Success,
	Warning = Theme.Color.Warning,
	Trophy = Theme.Color.Coin,
	Rank = Theme.Color.Coin,
}

function Notifications.new(screenGui: ScreenGui)
	local self = setmetatable({}, Notifications)

	self.gui = screenGui
	self.cards = {}

	self.root = New("Frame") {
		Name = "Toasts",
		Parent = screenGui,
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.new(1, -16, 0, 16),
		Size = UDim2.fromOffset(CARD_WIDTH, 400),
	}

	self.scale = Instance.new("UIScale")
	self.scale.Parent = self.root

	return self
end

function Notifications:_reflow()
	for index, card in self.cards do
		local target = UDim2.fromOffset(0, (index - 1) * (CARD_HEIGHT + CARD_GAP))
		if card.frame.Position ~= target then
			Motion.tween(card.frame, { Position = target }, 0.24)
		end
	end
end

function Notifications:_dismiss(card)
	if card.dismissed then
		return
	end
	card.dismissed = true

	local index = table.find(self.cards, card)
	if index then
		table.remove(self.cards, index)
	end

	Motion.tween(card.frame, { Position = card.frame.Position + UDim2.fromOffset(CARD_WIDTH + 40, 0) }, 0.22)

	for _, descendant in card.frame:GetDescendants() do
		if descendant:IsA("TextLabel") then
			Motion.tween(descendant, { TextTransparency = 1 }, 0.2)
		end
	end
	Motion.tween(card.frame, { BackgroundTransparency = 1 }, 0.2)

	task.delay(Motion.duration(0.3), function()
		card.frame:Destroy()
	end)

	self:_reflow()
end

--- payload: { Title, Body, Kind, RankId }
function Notifications:push(payload)
	if type(payload) ~= "table" then
		return
	end

	local kind = payload.Kind or "Info"
	local accent = KIND_COLORS[kind] or Theme.Color.Accent

	local definition = if payload.RankId then Ranks.get(payload.RankId) else nil
	if definition then
		accent = Ranks.getRarity(definition).Color
	end

	local frame = Components.panel {
		Parent = self.root,
		Name = "Toast",
		Size = UDim2.fromOffset(CARD_WIDTH, CARD_HEIGHT),
		Position = UDim2.fromOffset(CARD_WIDTH + 40, #self.cards * (CARD_HEIGHT + CARD_GAP)),
		Color = Theme.Color.PanelRaised,
		StrokeColor = accent,
		StrokeTransparency = 0.25,
	}

	New("Frame") {
		Name = "Accent",
		Parent = frame,
		BackgroundColor3 = accent,
		BorderSizePixel = 0,
		Position = UDim2.fromOffset(0, 10),
		Size = UDim2.new(0, 4, 1, -20),

		New.corner(Theme.Radius.Pill),
	}

	local textLeft = 16

	if definition then
		local holder = New("Frame") {
			Name = "Icon",
			Parent = frame,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0, 0.5),
			Position = UDim2.new(0, 14, 0.5, 0),
			Size = UDim2.fromOffset(54, 54),
		}
		local icon = RankIcon.create(definition, { Size = UDim2.fromScale(1, 1) })
		icon.Parent = holder
		textLeft = 78
	end

	Components.label {
		Parent = frame,
		Name = "Title",
		Text = payload.Title or "",
		Color = Theme.Color.Text,
		Font = Theme.Font.Bold,
		TextSize = 16,
		Position = UDim2.fromOffset(textLeft, 14),
		Size = UDim2.new(1, -textLeft - 14, 0, 20),
	}

	Components.label {
		Parent = frame,
		Name = "Body",
		Text = payload.Body or "",
		Color = Theme.Color.TextMuted,
		Font = Theme.Font.Medium,
		TextSize = 13,
		Position = UDim2.fromOffset(textLeft, 36),
		Size = UDim2.new(1, -textLeft - 14, 0, 26),
		Wrapped = true,
		YAlign = Enum.TextYAlignment.Top,
	}

	local card = { frame = frame, dismissed = false }
	table.insert(self.cards, card)

	-- Oldest first out, so a burst of notifications never grows off screen.
	while #self.cards > Config.UI.MaxNotifications do
		self:_dismiss(self.cards[1])
	end

	self:_reflow()

	Sound.play(if definition then "Trophy" else "Click", 1, 0.3)

	task.delay(Config.UI.NotificationDuration, function()
		self:_dismiss(card)
	end)
end

function Notifications:setScale(scale: number)
	self.scale.Scale = scale
end

return Notifications
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="52">
            <Properties>
              <string name="Name">RankIcon</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	RankIcon
	----------------------------------------------------------------------------
	Draws a rank badge -- a trophy cup -- out of frames, corners and gradients.
	No image assets anywhere.

	Why: uploading seven icons means seven asset ids that have to be moderated,
	can 404, and cannot be recoloured. Built from primitives, a new rank tier is
	a data entry in Ranks.lua and its badge exists immediately, in its own
	colours, at every size.

	    +-----+     rim
	    | cup |     body, gradient-shaded, rounded at the bottom
	     \___/
	      | |      stem
	     =====     base
	    #######    plinth
]]

local New = require(script.Parent.New)
local Theme = require(script.Parent.Theme)

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local Ranks = require(Shared.Ranks)
local Types = require(Shared.Types)

local RankIcon = {}

export type Options = {
	Locked: boolean?,
	--- Draws the rarity glow behind the trophy. Off for dense grids.
	Glow: boolean?,
	LayoutOrder: number?,
	Size: UDim2?,
	Position: UDim2?,
	AnchorPoint: Vector2?,
	Name: string?,
}

--- Builds the icon. The returned frame is square via an aspect constraint, so a
--- caller only has to control one dimension.
function RankIcon.create(definition: Types.RankDefinition, options: Options?): Frame
	local settings = options or {}
	local locked = settings.Locked == true

	local rarity = Ranks.getRarity(definition)
	local body = if locked then Theme.Color.Locked else definition.Color
	local accent = if locked then Theme.darken(Theme.Color.Locked, 0.25) else definition.Accent
	local glowColor = if locked then Theme.Color.Locked else rarity.Color

	local root = New("Frame") {
		Name = settings.Name or `Rank_{definition.Id}`,
		BackgroundTransparency = 1,
		Size = settings.Size or UDim2.fromScale(1, 1),
		Position = settings.Position,
		AnchorPoint = settings.AnchorPoint,
		LayoutOrder = settings.LayoutOrder or definition.Order,

		New.aspect(1),
	}

	if settings.Glow ~= false then
		New("Frame") {
			Name = "Glow",
			Parent = root,
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.fromScale(0.5, 0.52),
			Size = UDim2.fromScale(0.92, 0.92),
			BackgroundColor3 = glowColor,
			BackgroundTransparency = 1 - 0.16 * (locked and 0.3 or rarity.Glow),
			ZIndex = 0,

			New.corner(Theme.Radius.Pill),
		}
	end

	-- Handles first, so the cup draws over them.
	for _, side in { -1, 1 } do
		New("Frame") {
			Name = if side < 0 then "HandleLeft" else "HandleRight",
			Parent = root,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.fromScale(0.5 + side * 0.31, 0.16),
			Size = UDim2.fromScale(0.16, 0.3),
			BackgroundColor3 = accent,
			ZIndex = 1,

			New.corner(Theme.Radius.Pill),
		}
	end

	New("Frame") {
		Name = "Cup",
		Parent = root,
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.fromScale(0.5, 0.1),
		Size = UDim2.fromScale(0.54, 0.42),
		BackgroundColor3 = body,
		ZIndex = 2,

		New.corner(UDim.new(0.3, 0)),
		New.gradient(Theme.shine(body), 90),
	}

	New("Frame") {
		Name = "Rim",
		Parent = root,
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.fromScale(0.5, 0.08),
		Size = UDim2.fromScale(0.62, 0.09),
		BackgroundColor3 = Theme.lighten(body, 0.2),
		ZIndex = 3,

		New.corner(Theme.Radius.Pill),
	}

	New("Frame") {
		Name = "Stem",
		Parent = root,
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromScale(0.13, 0.16),
		BackgroundColor3 = accent,
		ZIndex = 2,
	}

	New("Frame") {
		Name = "Base",
		Parent = root,
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.fromScale(0.5, 0.65),
		Size = UDim2.fromScale(0.34, 0.09),
		BackgroundColor3 = accent,
		ZIndex = 2,

		New.corner(Theme.Radius.Small),
	}

	New("Frame") {
		Name = "Plinth",
		Parent = root,
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.fromScale(0.5, 0.73),
		Size = UDim2.fromScale(0.56, 0.11),
		BackgroundColor3 = if locked then Theme.Color.Locked else Theme.darken(accent, 0.25),
		ZIndex = 2,

		New.corner(Theme.Radius.Small),
		New.gradient(Theme.shine(accent), 90),
	}

	if locked then
		New("TextLabel") {
			Name = "LockedMark",
			Parent = root,
			BackgroundTransparency = 1,
			Position = UDim2.fromScale(0.5, 0.32),
			AnchorPoint = Vector2.new(0.5, 0.5),
			Size = UDim2.fromScale(0.5, 0.3),
			Text = "?",
			TextColor3 = Theme.Color.TextDim,
			TextScaled = true,
			FontFace = Theme.Font.Display,
			ZIndex = 4,
		}
	end

	return root
end

return RankIcon
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="53">
            <Properties>
              <string name="Name">RankWindow</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	RankWindow
	----------------------------------------------------------------------------
	Two tabs behind one button.

	RANKS -- the ladder. Every rank as a tile: unlocked ones in full colour with
	their rarity glow, locked ones greyed with a "?" so the ladder still reads as
	a goal rather than a blank. Selecting one fills the showcase on the right:
	badge, rarity, the trophy cost, and the rewards it grants, with working equip
	buttons for the title and trail.

	CHALLENGES -- the three courses. What each one pays, how fast you need to be,
	how many times you have cleared it and your best time. This is the answer to
	"what do I do with all this Speed?", so it lives one click from the HUD.

	Both tabs are built once on startup and shown/hidden rather than rebuilt on
	open: ten tiles is cheap to keep resident and it makes opening instant.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Challenges = require(Shared.Challenges)
local Cosmetics = require(Shared.Cosmetics)
local Format = require(Shared.Format)
local Ranks = require(Shared.Ranks)
local SpeedCurve = require(Shared.SpeedCurve)

local Components = require(script.Parent.Components)
local Motion = require(script.Parent.Motion)
local New = require(script.Parent.New)
local RankIcon = require(script.Parent.RankIcon)
local Sound = require(script.Parent.Sound)
local Theme = require(script.Parent.Theme)

local RankWindow = {}
RankWindow.__index = RankWindow

function RankWindow.new(screenGui: ScreenGui)
	local self = setmetatable({}, RankWindow)

	self.gui = screenGui
	self.open = false
	self.compact = false
	self.tab = "Ranks"
	self.selectedId = Ranks.List[1].Id
	self.tiles = {}
	self.challengeCards = {}
	self.callbacks = {}
	-- Signature of the last render. ClientState fires several times a second as
	-- Speed ticks up, and none of that changes this window.
	self.renderKey = nil

	self.backdrop = New("TextButton") {
		Name = "Backdrop",
		Parent = screenGui,
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Theme.Color.Backdrop,
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		AutoButtonColor = false,
		Text = "",
		Modal = true,
	}

	self.backdrop.Activated:Connect(function()
		self:setOpen(false)
	end)

	self.scale = Instance.new("UIScale")
	self.scale.Parent = screenGui

	self.panel = Components.panel {
		Parent = screenGui,
		Name = "Window",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromScale(0.78, 0.74),
		Color = Theme.Color.Panel,
	}

	New.sizeLimit(Vector2.new(980, 620), Vector2.new(280, 300)).Parent = self.panel
	New.padding(16).Parent = self.panel

	self:_buildHeader()
	self:_buildRanksTab()
	self:_buildChallengesTab()
	self:_populateRanks()
	self:_populateChallenges()
	self:_applyTab()

	screenGui.Enabled = false
	return self
end

--------------------------------------------------------------------------------
-- Header and tabs
--------------------------------------------------------------------------------

function RankWindow:_buildHeader()
	local header = New("Frame") {
		Name = "Header",
		Parent = self.panel,
		BackgroundTransparency = 1,
		Size = UDim2.new(1, 0, 0, 44),
	}

	self.titleLabel = Components.label {
		Parent = header,
		Name = "Title",
		Text = "RANKS",
		Color = Theme.Color.Text,
		Font = Theme.Font.Display,
		TextSize = 28,
		Size = UDim2.new(1, -120, 0, 30),
	}

	self.countLabel = Components.label {
		Parent = header,
		Name = "Count",
		Text = "0 trophies",
		Color = Theme.Color.TextMuted,
		Font = Theme.Font.Medium,
		TextSize = 14,
		Position = UDim2.fromOffset(0, 28),
		Size = UDim2.new(1, -120, 0, 16),
	}

	Components.button {
		Parent = header,
		Name = "Close",
		Text = "Close",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.new(1, 0, 0, 0),
		Size = UDim2.fromOffset(96, 38),
		Color = Theme.Color.PanelRaised,
		TextSize = 15,
		OnClick = function()
			self:setOpen(false)
		end,
	}

	local tabs = New("Frame") {
		Name = "Tabs",
		Parent = self.panel,
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(0, 50),
		Size = UDim2.new(1, 0, 0, 34),

		New.list {
			FillDirection = Enum.FillDirection.Horizontal,
			Padding = UDim.new(0, 8),
		},
	}

	self.tabButtons = {}

	for index, name in { "Ranks", "Challenges" } do
		self.tabButtons[name] = Components.button {
			Parent = tabs,
			Name = `Tab{name}`,
			Text = string.upper(name),
			LayoutOrder = index,
			Size = UDim2.fromOffset(148, 34),
			TextSize = 14,
			Radius = Theme.Radius.Small,
			Color = Theme.Color.PanelSunken,
			OnClick = function()
				self:setTab(name)
			end,
		}
	end
end

function RankWindow:setTab(name: string)
	if self.tab == name then
		return
	end
	self.tab = name
	Sound.play("Click", 1.05, 0.22)
	self:_applyTab()

	if self.profile then
		self:render(self.profile, true)
	end
end

function RankWindow:_applyTab()
	local ranksActive = self.tab == "Ranks"

	self.ranksBody.Visible = ranksActive
	self.challengesBody.Visible = not ranksActive

	self.tabButtons.Ranks.setColor(if ranksActive then Theme.Color.Accent else Theme.Color.PanelSunken)
	self.tabButtons.Challenges.setColor(if ranksActive then Theme.Color.PanelSunken else Theme.Color.Accent)

	self.titleLabel.Text = if ranksActive then "RANKS" else "CHALLENGES"
end

--------------------------------------------------------------------------------
-- Ranks tab
--------------------------------------------------------------------------------

function RankWindow:_buildRanksTab()
	self.ranksBody = New("Frame") {
		Name = "RanksBody",
		Parent = self.panel,
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(0, 92),
		Size = UDim2.new(1, 0, 1, -92),

		New.list {
			FillDirection = Enum.FillDirection.Horizontal,
			Padding = UDim.new(0, 12),
		},
	}

	self.grid = New("ScrollingFrame") {
		Name = "Ladder",
		Parent = self.ranksBody,
		BackgroundColor3 = Theme.Color.PanelSunken,
		BackgroundTransparency = 0.35,
		BorderSizePixel = 0,
		LayoutOrder = 1,
		Size = UDim2.new(0.56, -6, 1, 0),
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		ScrollBarThickness = 5,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		ScrollingDirection = Enum.ScrollingDirection.Y,
		ElasticBehavior = Enum.ElasticBehavior.WhenScrollable,

		New.corner(Theme.Radius.Medium),
		New.padding(12),
		New.grid(UDim2.fromOffset(124, 158), UDim2.fromOffset(10, 10), {
			HorizontalAlignment = Enum.HorizontalAlignment.Left,
		}),
	}

	self.showcase = Components.panel {
		Parent = self.ranksBody,
		Name = "Showcase",
		LayoutOrder = 2,
		Size = UDim2.new(0.44, -6, 1, 0),
		Color = Theme.Color.PanelSunken,
	}

	New.padding(16).Parent = self.showcase

	self.showcaseIcon = New("Frame") {
		Name = "Icon",
		Parent = self.showcase,
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, 0),
		Size = UDim2.fromOffset(132, 132),
	}

	self.showcaseName = Components.label {
		Parent = self.showcase,
		Name = "Name",
		Text = "",
		Color = Theme.Color.Text,
		Font = Theme.Font.Display,
		TextSize = 24,
		Position = UDim2.fromOffset(0, 140),
		Size = UDim2.new(1, 0, 0, 28),
		XAlign = Enum.TextXAlignment.Center,
	}

	self.showcaseRarity = Components.label {
		Parent = self.showcase,
		Name = "Rarity",
		Text = "",
		Color = Theme.Color.TextMuted,
		Font = Theme.Font.Bold,
		TextSize = 13,
		Position = UDim2.fromOffset(0, 168),
		Size = UDim2.new(1, 0, 0, 16),
		XAlign = Enum.TextXAlignment.Center,
	}

	self.showcaseDescription = Components.label {
		Parent = self.showcase,
		Name = "Description",
		Text = "",
		Color = Theme.Color.TextMuted,
		Font = Theme.Font.Regular,
		TextSize = 14,
		Position = UDim2.fromOffset(0, 190),
		Size = UDim2.new(1, 0, 0, 36),
		XAlign = Enum.TextXAlignment.Center,
		Wrapped = true,
	}

	self.rewardList = New("Frame") {
		Name = "Rewards",
		Parent = self.showcase,
		BackgroundTransparency = 1,
		Position = UDim2.fromOffset(0, 232),
		Size = UDim2.new(1, 0, 0, 96),

		New.list { Padding = UDim.new(0, 4) },
	}

	local actions = New("Frame") {
		Name = "Actions",
		Parent = self.showcase,
		BackgroundTransparency = 1,
		AnchorPoint = Vector2.new(0, 1),
		Position = UDim2.new(0, 0, 1, 0),
		Size = UDim2.new(1, 0, 0, 88),

		New.list { Padding = UDim.new(0, 8) },
	}

	self.equipTitleButton = Components.button {
		Parent = actions,
		Name = "EquipTitle",
		Text = "Equip title",
		LayoutOrder = 1,
		Size = UDim2.new(1, 0, 0, 38),
		TextSize = 15,
		OnClick = function()
			self:_equip("Title")
		end,
	}

	self.equipTrailButton = Components.button {
		Parent = actions,
		Name = "EquipTrail",
		Text = "Equip trail",
		LayoutOrder = 2,
		Size = UDim2.new(1, 0, 0, 38),
		TextSize = 15,
		Color = Theme.Color.AccentDeep,
		OnClick = function()
			self:_equip("Trail")
		end,
	}
end

function RankWindow:_populateRanks()
	for _, definition in Ranks.List do
		local rarity = Ranks.getRarity(definition)

		local tile = New("TextButton") {
			Name = definition.Id,
			Parent = self.grid,
			BackgroundColor3 = Theme.Color.Panel,
			BorderSizePixel = 0,
			AutoButtonColor = false,
			Text = "",
			LayoutOrder = definition.Order,
			Selectable = true,

			New.corner(Theme.Radius.Medium),
		}

		local stroke = New.stroke(rarity.Color, 1, 0.6)
		stroke.Parent = tile

		local iconHolder = New("Frame") {
			Name = "IconHolder",
			Parent = tile,
			BackgroundTransparency = 1,
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, 8),
			Size = UDim2.fromOffset(92, 92),
		}

		local name = Components.label {
			Parent = tile,
			Name = "Name",
			Text = definition.Name,
			Color = Theme.Color.TextMuted,
			Font = Theme.Font.Bold,
			TextSize = 13,
			Position = UDim2.fromOffset(4, 104),
			Size = UDim2.new(1, -8, 0, 16),
			XAlign = Enum.TextXAlignment.Center,
		}

		local requirement = Components.label {
			Parent = tile,
			Name = "Requirement",
			Text = `{Format.comma(definition.Requirement)} 🏆`,
			Color = Theme.Color.TextDim,
			Font = Theme.Font.Medium,
			TextSize = 12,
			Position = UDim2.fromOffset(4, 122),
			Size = UDim2.new(1, -8, 0, 14),
			XAlign = Enum.TextXAlignment.Center,
		}

		tile.Activated:Connect(function()
			Sound.play("Click", 1, 0.2)
			self:select(definition.Id)
		end)

		self.tiles[definition.Id] = {
			button = tile,
			stroke = stroke,
			iconHolder = iconHolder,
			name = name,
			requirement = requirement,
			owned = nil,
		}
	end
end

--------------------------------------------------------------------------------
-- Challenges tab
--------------------------------------------------------------------------------

function RankWindow:_buildChallengesTab()
	self.challengesBody = New("ScrollingFrame") {
		Name = "ChallengesBody",
		Parent = self.panel,
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		Position = UDim2.fromOffset(0, 92),
		Size = UDim2.new(1, 0, 1, -92),
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		ScrollBarThickness = 5,
		ScrollBarImageColor3 = Theme.Color.Stroke,
		ScrollingDirection = Enum.ScrollingDirection.Y,

		New.list { Padding = UDim.new(0, 10) },
	}
end

function RankWindow:_populateChallenges()
	for _, definition in Challenges.List do
		local difficulty = Challenges.getDifficulty(definition)

		local card = Components.panel {
			Parent = self.challengesBody,
			Name = definition.Id,
			LayoutOrder = definition.Order,
			Size = UDim2.new(1, -8, 0, 118),
			Color = Theme.Color.PanelSunken,
			StrokeColor = difficulty.Color,
			StrokeTransparency = 0.45,
		}

		New.padding(14).Parent = card

		-- A colour-coded spine so the three courses are distinguishable at a
		-- glance, matching the pad colours out in the world.
		New("Frame") {
			Name = "Spine",
			Parent = card,
			BackgroundColor3 = difficulty.Color,
			BorderSizePixel = 0,
			Size = UDim2.new(0, 4, 1, 0),

			New.corner(Theme.Radius.Pill),
		}

		Components.label {
			Parent = card,
			Name = "Name",
			Text = definition.Name,
			Color = Theme.Color.Text,
			Font = Theme.Font.Display,
			TextSize = 22,
			Position = UDim2.fromOffset(16, 0),
			Size = UDim2.new(1, -16, 0, 26),
		}

		Components.label {
			Parent = card,
			Name = "Difficulty",
			Text = `{string.upper(difficulty.Name)}  ·  +{definition.Trophies} 🏆`,
			Color = difficulty.Color,
			Font = Theme.Font.Bold,
			TextSize = 13,
			Position = UDim2.fromOffset(16, 26),
			Size = UDim2.new(1, -16, 0, 16),
		}

		Components.label {
			Parent = card,
			Name = "Description",
			Text = definition.Description,
			Color = Theme.Color.TextMuted,
			Font = Theme.Font.Regular,
			TextSize = 14,
			Position = UDim2.fromOffset(16, 46),
			Size = UDim2.new(1, -16, 0, 18),
		}

		local requirement = Components.label {
			Parent = card,
			Name = "Requirement",
			Text = "",
			Color = Theme.Color.TextMuted,
			Font = Theme.Font.Medium,
			TextSize = 13,
			Position = UDim2.fromOffset(16, 70),
			Size = UDim2.new(1, -16, 0, 16),
		}

		local record = Components.label {
			Parent = card,
			Name = "Record",
			Text = "",
			Color = Theme.Color.TextDim,
			Font = Theme.Font.Medium,
			TextSize = 13,
			Position = UDim2.fromOffset(16, 88),
			Size = UDim2.new(1, -16, 0, 16),
		}

		local status = Components.label {
			Parent = card,
			Name = "Status",
			Text = "",
			Color = Theme.Color.TextMuted,
			Font = Theme.Font.Bold,
			TextSize = 14,
			AnchorPoint = Vector2.new(1, 0),
			Position = UDim2.new(1, 0, 0, 4),
			Size = UDim2.fromOffset(150, 22),
			XAlign = Enum.TextXAlignment.Right,
		}

		self.challengeCards[definition.Id] = {
			card = card,
			requirement = requirement,
			record = record,
			status = status,
		}
	end
end

function RankWindow:_renderChallenges(profile)
	local walkSpeed = SpeedCurve.walkSpeed(profile.Speed, profile.Rebirths)

	for _, definition in Challenges.List do
		local entry = self.challengeCards[definition.Id]
		local record = profile.Challenges[definition.Id]

		local ready = walkSpeed >= definition.RecommendedWalkSpeed
		entry.requirement.Text =
			`Needs about {definition.RecommendedWalkSpeed} studs/s  ·  you are at {math.floor(walkSpeed)}`
		entry.requirement.TextColor3 = if ready then Theme.Color.Success else Theme.Color.Warning

		if record and record.Completions > 0 then
			entry.record.Text =
				`Cleared {Format.comma(record.Completions)}x  ·  best {string.format("%.1fs", record.BestTime)}`
		else
			entry.record.Text = "Not cleared yet"
		end

		entry.status.Text = if ready then "READY" else "TOO SLOW"
		entry.status.TextColor3 = if ready then Theme.Color.Success else Theme.Color.Warning
	end
end

--------------------------------------------------------------------------------
-- Rendering
--------------------------------------------------------------------------------

local function rewardLines(definition, ownedTitle: boolean, ownedTrail: boolean): { { text: string, color: Color3 } }
	local lines = {}
	local rewards = definition.Rewards

	if rewards.Coins then
		table.insert(lines, { text = `{Format.comma(rewards.Coins)} coins`, color = Theme.Color.Coin })
	end
	if rewards.SpeedMultiplier then
		table.insert(lines, {
			text = `+{math.floor(rewards.SpeedMultiplier * 100 + 0.5)}% Speed per reward`,
			color = Theme.Color.Speed,
		})
	end
	if rewards.Title then
		local title = Cosmetics.getTitle(rewards.Title)
		table.insert(lines, {
			text = `Title: {title and title.Name or rewards.Title}`,
			color = if ownedTitle then Theme.Color.Text else Theme.Color.TextDim,
		})
	end
	if rewards.Trail then
		local trail = Cosmetics.getTrail(rewards.Trail)
		table.insert(lines, {
			text = `Trail: {trail and trail.Name or rewards.Trail}`,
			color = if ownedTrail then Theme.Color.Text else Theme.Color.TextDim,
		})
	end

	return lines
end

function RankWindow:select(id: string)
	if self.selectedId == id then
		return
	end
	self.selectedId = id
	if self.profile then
		self:render(self.profile, true)
	end
end

--- What this window depends on. Speed is in it only because the challenges tab
--- shows whether you are fast enough, and that is bucketed to a whole stud.
local function renderSignature(profile, selectedId: string, tab: string): string
	local ranked = 0
	for _ in profile.Ranks do
		ranked += 1
	end

	local cleared = 0
	for _, record in profile.Challenges do
		cleared += record.Completions
	end

	local titleCount = 0
	for _ in profile.Titles do
		titleCount += 1
	end

	local trailCount = 0
	for _ in profile.Trails do
		trailCount += 1
	end

	local walkSpeed = math.floor(SpeedCurve.walkSpeed(profile.Speed, profile.Rebirths))

	return `{tab}|{selectedId}|{profile.Trophies}|{ranked}|{cleared}|{titleCount}|{trailCount}`
		.. `|{profile.EquippedTitle}|{profile.EquippedTrail}|{walkSpeed}`
end

function RankWindow:render(profile, force: boolean?)
	self.profile = profile

	local signature = renderSignature(profile, self.selectedId, self.tab)
	if signature == self.renderKey and not force then
		return
	end
	self.renderKey = signature

	local unlocked = 0
	for _, definition in Ranks.List do
		local tile = self.tiles[definition.Id]
		local hasIt = profile.Ranks[definition.Id] ~= nil
		if hasIt then
			unlocked += 1
		end

		if tile.owned ~= hasIt then
			tile.owned = hasIt
			tile.iconHolder:ClearAllChildren()
			local icon = RankIcon.create(definition, { Locked = not hasIt, Size = UDim2.fromScale(1, 1) })
			icon.Parent = tile.iconHolder

			tile.name.TextColor3 = if hasIt then Theme.Color.Text else Theme.Color.TextDim
			tile.stroke.Transparency = if hasIt then 0.15 else 0.7
		end

		local selected = definition.Id == self.selectedId
		tile.button.BackgroundColor3 = if selected then Theme.Color.PanelRaised else Theme.Color.Panel
		tile.stroke.Thickness = if selected then 2 else 1
	end

	self.countLabel.Text = `{Format.comma(profile.Trophies)} trophies  ·  {unlocked} / {#Ranks.List} ranks`

	self:_renderShowcase(profile)
	self:_renderChallenges(profile)
end

function RankWindow:_renderShowcase(profile)
	local definition = Ranks.get(self.selectedId)
	if not definition then
		return
	end

	local rarity = Ranks.getRarity(definition)
	local hasIt = profile.Ranks[definition.Id] ~= nil

	self.showcaseIcon:ClearAllChildren()
	local icon = RankIcon.create(definition, { Locked = not hasIt, Size = UDim2.fromScale(1, 1) })
	icon.Parent = self.showcaseIcon

	self.showcaseName.Text = `{definition.Name} Rank`
	self.showcaseName.TextColor3 = if hasIt then Theme.Color.Text else Theme.Color.TextMuted

	self.showcaseRarity.Text = if hasIt
		then `{string.upper(rarity.Name)}  ·  UNLOCKED`
		else `{string.upper(rarity.Name)}  ·  {Format.comma(definition.Requirement)} TROPHIES`
	self.showcaseRarity.TextColor3 = rarity.Color

	self.showcaseDescription.Text = definition.Description

	local ownedTitle = definition.Rewards.Title ~= nil and profile.Titles[definition.Rewards.Title] == true
	local ownedTrail = definition.Rewards.Trail ~= nil and profile.Trails[definition.Rewards.Trail] == true

	self.rewardList:ClearAllChildren()
	New.list({ Padding = UDim.new(0, 4) }).Parent = self.rewardList

	for index, line in rewardLines(definition, ownedTitle, ownedTrail) do
		Components.label {
			Parent = self.rewardList,
			Name = `Reward{index}`,
			Text = `•  {line.text}`,
			Color = line.color,
			Font = Theme.Font.Medium,
			TextSize = 14,
			Size = UDim2.new(1, 0, 0, 18),
			LayoutOrder = index,
		}
	end

	local titleId = definition.Rewards.Title
	if titleId then
		local equipped = profile.EquippedTitle == titleId
		self.equipTitleButton.Instance.Visible = true
		self.equipTitleButton.setEnabled(ownedTitle and not equipped)
		self.equipTitleButton.setText(if equipped then "Title equipped" else "Equip title")
	else
		self.equipTitleButton.Instance.Visible = false
	end

	local trailId = definition.Rewards.Trail
	if trailId then
		local equipped = profile.EquippedTrail == trailId
		self.equipTrailButton.Instance.Visible = true
		self.equipTrailButton.setEnabled(ownedTrail and not equipped)
		self.equipTrailButton.setText(if equipped then "Trail equipped" else "Equip trail")
	else
		self.equipTrailButton.Instance.Visible = false
	end
end

function RankWindow:_equip(kind: string)
	local definition = Ranks.get(self.selectedId)
	if not definition then
		return
	end

	local id = if kind == "Title" then definition.Rewards.Title else definition.Rewards.Trail
	if not id then
		return
	end

	local callback = self.callbacks.Equip
	if callback then
		callback(kind, id)
	end
end

--------------------------------------------------------------------------------
-- Window behaviour
--------------------------------------------------------------------------------

function RankWindow:setOpen(open: boolean)
	if self.open == open then
		return
	end
	self.open = open

	if open then
		self.gui.Enabled = true
		self.panel.Position = UDim2.fromScale(0.5, 0.56)
		Motion.tween(self.backdrop, { BackgroundTransparency = 0.45 }, 0.22)
		Motion.tween(self.panel, { Position = UDim2.fromScale(0.5, 0.5) }, 0.3, Enum.EasingStyle.Back)
		Sound.play("Click", 1.15, 0.3)

		local callback = self.callbacks.Opened
		if callback then
			callback()
		end
	else
		Motion.tween(self.backdrop, { BackgroundTransparency = 1 }, 0.2)
		local slide = Motion.tween(self.panel, { Position = UDim2.fromScale(0.5, 0.56) }, 0.2)
		slide.Completed:Once(function()
			if not self.open then
				self.gui.Enabled = false
			end
		end)

		local callback = self.callbacks.Closed
		if callback then
			callback()
		end
	end
end

function RankWindow:isOpen(): boolean
	return self.open
end

function RankWindow:setScale(scale: number)
	self.scale.Scale = scale
end

function RankWindow:setCompact(compact: boolean)
	if self.compact == compact then
		return
	end
	self.compact = compact

	local layout = self.ranksBody:FindFirstChildOfClass("UIListLayout")
	if layout then
		layout.FillDirection = if compact then Enum.FillDirection.Vertical else Enum.FillDirection.Horizontal
	end

	if compact then
		self.panel.Size = UDim2.fromScale(0.94, 0.86)
		self.grid.Size = UDim2.new(1, 0, 0.52, -6)
		self.showcase.Size = UDim2.new(1, 0, 0.48, -6)
	else
		self.panel.Size = UDim2.fromScale(0.78, 0.74)
		self.grid.Size = UDim2.new(0.56, -6, 1, 0)
		self.showcase.Size = UDim2.new(0.44, -6, 1, 0)
	end
end

function RankWindow:on(name: string, callback)
	self.callbacks[name] = callback
end

--- Where gamepad focus should land when the window opens.
function RankWindow:getPrimarySelection(): GuiObject
	if self.tab ~= "Ranks" then
		return self.tabButtons.Challenges.Instance
	end
	local tile = self.tiles[self.selectedId]
	return tile and tile.button or self.panel
end

return RankWindow
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="54">
            <Properties>
              <string name="Name">SettingsWindow</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	SettingsWindow
	----------------------------------------------------------------------------
	Player preferences. Each control reports through the "Changed" callback; the
	controller sends it to the server, the server validates and stores it on the
	profile, and it comes back down on the next sync. Settings therefore follow
	the player between servers and devices for free.

	The animation-speed control is a segmented picker rather than a drag slider
	on purpose: identical behaviour with a mouse, a thumb and a D-pad.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)

local Components = require(script.Parent.Components)
local Motion = require(script.Parent.Motion)
local New = require(script.Parent.New)
local Sound = require(script.Parent.Sound)
local Theme = require(script.Parent.Theme)

local SettingsWindow = {}
SettingsWindow.__index = SettingsWindow

local SPEED_OPTIONS = {
	{ Label = "Calm", Value = 0.5 },
	{ Label = "Normal", Value = 1 },
	{ Label = "Quick", Value = 1.5 },
	{ Label = "Instant", Value = 2.5 },
}

function SettingsWindow.new(screenGui: ScreenGui)
	local self = setmetatable({}, SettingsWindow)

	self.gui = screenGui
	self.open = false
	self.callbacks = {}
	self.suppress = false
	self.renderKey = nil

	self.backdrop = New("TextButton") {
		Name = "Backdrop",
		Parent = screenGui,
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Theme.Color.Backdrop,
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		AutoButtonColor = false,
		Text = "",
		Modal = true,
	}
	self.backdrop.Activated:Connect(function()
		self:setOpen(false)
	end)

	self.scale = Instance.new("UIScale")
	self.scale.Parent = screenGui

	self.panel = Components.panel {
		Parent = screenGui,
		Name = "Window",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(420, 400),
	}

	New.sizeLimit(Vector2.new(460, 440), Vector2.new(260, 320)).Parent = self.panel
	New.padding(18).Parent = self.panel
	New.list({ Padding = UDim.new(0, 10) }).Parent = self.panel

	Components.label {
		Parent = self.panel,
		Name = "Title",
		Text = "SETTINGS",
		Color = Theme.Color.Text,
		Font = Theme.Font.Display,
		TextSize = 26,
		Size = UDim2.new(1, 0, 0, 30),
		LayoutOrder = 1,
	}

	self.effectsToggle = Components.toggle {
		Parent = self.panel,
		Title = "Movement effects",
		LayoutOrder = 2,
		OnChanged = function(value)
			self:_emit("ShowEffects", value)
		end,
	}

	self.nametagToggle = Components.toggle {
		Parent = self.panel,
		Title = "Player nametags",
		LayoutOrder = 3,
		OnChanged = function(value)
			self:_emit("ShowNametags", value)
		end,
	}

	self.compactToggle = Components.toggle {
		Parent = self.panel,
		Title = "Compact HUD",
		LayoutOrder = 4,
		OnChanged = function(value)
			self:_emit("CompactHud", value)
		end,
	}

	self.animationPicker = Components.segmented {
		Parent = self.panel,
		Title = "Interface animation speed",
		LayoutOrder = 5,
		Options = SPEED_OPTIONS,
		Value = 1,
		OnChanged = function(value)
			self:_emit("AnimationSpeed", value)
		end,
	}

	Components.button {
		Parent = self.panel,
		Name = "Close",
		Text = "Done",
		LayoutOrder = 6,
		Size = UDim2.new(1, 0, 0, 42),
		OnClick = function()
			self:setOpen(false)
		end,
	}

	Components.label {
		Parent = self.panel,
		Name = "Version",
		Text = `{Config.Game.Name} v{Config.Game.Version}`,
		Color = Theme.Color.TextDim,
		Font = Theme.Font.Regular,
		TextSize = 12,
		Size = UDim2.new(1, 0, 0, 14),
		XAlign = Enum.TextXAlignment.Center,
		LayoutOrder = 7,
	}

	screenGui.Enabled = false
	return self
end

--- Chooses the nearest option so a stored value that is not on the list (or a
--- config change) still lands somewhere sensible.
local function nearestSpeedOption(value: number): number
	local best = SPEED_OPTIONS[1].Value
	local bestDistance = math.huge

	for _, option in SPEED_OPTIONS do
		local distance = math.abs(option.Value - value)
		if distance < bestDistance then
			bestDistance = distance
			best = option.Value
		end
	end

	return best
end

function SettingsWindow:render(profile)
	local settings = profile.Settings
	local signature = `{tostring(settings.ShowEffects)}|{tostring(settings.ShowNametags)}`
		.. `|{tostring(settings.CompactHud)}|{settings.AnimationSpeed}`
	if signature == self.renderKey then
		return
	end
	self.renderKey = signature

	-- Rendering server state must not echo back as a change event.
	self.suppress = true

	self.effectsToggle.set(settings.ShowEffects)
	self.nametagToggle.set(settings.ShowNametags)
	self.compactToggle.set(settings.CompactHud)
	self.animationPicker.set(nearestSpeedOption(settings.AnimationSpeed))

	self.suppress = false
end

function SettingsWindow:setOpen(open: boolean)
	if self.open == open then
		return
	end
	self.open = open

	if open then
		self.gui.Enabled = true
		self.panel.Position = UDim2.fromScale(0.5, 0.56)
		Motion.tween(self.backdrop, { BackgroundTransparency = 0.45 }, 0.22)
		Motion.tween(self.panel, { Position = UDim2.fromScale(0.5, 0.5) }, 0.3, Enum.EasingStyle.Back)
		Sound.play("Click", 1.15, 0.3)
	else
		Motion.tween(self.backdrop, { BackgroundTransparency = 1 }, 0.2)
		local slide = Motion.tween(self.panel, { Position = UDim2.fromScale(0.5, 0.56) }, 0.2)
		slide.Completed:Once(function()
			if not self.open then
				self.gui.Enabled = false
			end
		end)
	end

	local callback = self.callbacks[if open then "Opened" else "Closed"]
	if callback then
		callback()
	end
end

function SettingsWindow:isOpen(): boolean
	return self.open
end

function SettingsWindow:setScale(scale: number)
	self.scale.Scale = scale
end

function SettingsWindow:on(name: string, callback)
	self.callbacks[name] = callback
end

function SettingsWindow:getPrimarySelection(): GuiObject
	return self.effectsToggle.Instance:FindFirstChild("Switch") or self.panel
end

function SettingsWindow:_emit(key: string, value: any)
	if self.suppress then
		return
	end
	local callback = self.callbacks.Changed
	if callback then
		callback(key, value)
	end
end

return SettingsWindow
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="55">
            <Properties>
              <string name="Name">Sound</string>
              <string name="Source"><![CDATA[--!nonstrict
--[[
	Sound
	----------------------------------------------------------------------------
	UI audio. Uses rbxasset:// paths that ship inside the Roblox client, so there
	is nothing to upload and nothing that can fail to load in production.

	Sounds are pooled per id and rate-limited: a player mashing a button, or
	earning six rewards in one payout, gets one clean sound rather than a stack
	of overlapping ones.
]]

local SoundService = game:GetService("SoundService")

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Shared = ReplicatedStorage:WaitForChild("Shared")
local Config = require(Shared.Config)

local Sound = {}

Sound.enabled = Config.UI.Sounds.Enabled

local container: Folder? = nil
local pool: { [string]: Sound } = {}
local lastPlayed: { [string]: number } = {}

local MIN_INTERVAL = 0.06

local function getContainer(): Folder
	if container then
		return container
	end
	local folder = Instance.new("Folder")
	folder.Name = "WalkForSpeedUISounds"
	folder.Parent = SoundService
	container = folder
	return folder
end

local function get(assetId: string, volume: number): Sound
	local existing = pool[assetId]
	if existing then
		return existing
	end

	local sound = Instance.new("Sound")
	sound.SoundId = assetId
	sound.Volume = volume
	sound.RollOffMode = Enum.RollOffMode.Linear
	sound.Parent = getContainer()

	pool[assetId] = sound
	return sound
end

--- Plays a themed sound. `pitch` lets callers stack a rising tone for combos.
function Sound.play(name: "Click" | "Reward" | "Trophy", pitch: number?, volume: number?)
	if not Sound.enabled then
		return
	end

	local assetId = Config.UI.Sounds[name]
	if not assetId or assetId == "" then
		return
	end

	local now = os.clock()
	if now - (lastPlayed[name] or 0) < MIN_INTERVAL then
		return
	end
	lastPlayed[name] = now

	local sound = get(assetId, volume or 0.35)
	sound.Volume = volume or 0.35
	sound.PlaybackSpeed = pitch or 1
	sound:Play()
end

return Sound
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="56">
            <Properties>
              <string name="Name">Theme</string>
              <string name="Source"><![CDATA[--!strict
--[[
	Theme
	----------------------------------------------------------------------------
	Colours, fonts, radii and spacing. Every UI module reads from here, so
	restyling the whole game is one file.

	No image assets anywhere in the interface: panels, buttons and even the
	trophy icons are built from frames, corners, strokes and gradients. That
	means nothing to upload, nothing that can fail to load, and the whole UI
	recolours from this table.
]]

local Theme = {}

Theme.Color = {
	Backdrop = Color3.fromRGB(10, 12, 20),
	Panel = Color3.fromRGB(24, 28, 42),
	PanelRaised = Color3.fromRGB(32, 37, 55),
	PanelSunken = Color3.fromRGB(18, 21, 32),
	Stroke = Color3.fromRGB(58, 66, 92),
	StrokeSoft = Color3.fromRGB(44, 50, 70),

	Text = Color3.fromRGB(238, 242, 250),
	TextMuted = Color3.fromRGB(148, 158, 182),
	TextDim = Color3.fromRGB(96, 104, 126),

	Accent = Color3.fromRGB(76, 155, 255),
	AccentDeep = Color3.fromRGB(112, 96, 255),
	Speed = Color3.fromRGB(96, 232, 190),
	Coin = Color3.fromRGB(255, 201, 60),
	Success = Color3.fromRGB(76, 217, 123),
	Warning = Color3.fromRGB(255, 176, 32),
	Danger = Color3.fromRGB(255, 92, 108),
	Locked = Color3.fromRGB(62, 69, 90),
}

Theme.Font = {
	Display = Font.fromEnum(Enum.Font.FredokaOne),
	Bold = Font.fromEnum(Enum.Font.GothamBold),
	Medium = Font.fromEnum(Enum.Font.GothamMedium),
	Regular = Font.fromEnum(Enum.Font.Gotham),
}

Theme.Radius = {
	Small = UDim.new(0, 8),
	Medium = UDim.new(0, 14),
	Large = UDim.new(0, 20),
	Pill = UDim.new(1, 0),
}

Theme.Pad = {
	Tight = 6,
	Small = 10,
	Medium = 16,
	Large = 24,
}

--- Vertical sheen used on raised panels.
Theme.PanelGradient = ColorSequence.new {
	ColorSequenceKeypoint.new(0, Color3.fromRGB(255, 255, 255)),
	ColorSequenceKeypoint.new(1, Color3.fromRGB(196, 204, 226)),
}

Theme.AccentGradient = ColorSequence.new {
	ColorSequenceKeypoint.new(0, Theme.Color.Accent),
	ColorSequenceKeypoint.new(1, Theme.Color.AccentDeep),
}

Theme.SpeedGradient = ColorSequence.new {
	ColorSequenceKeypoint.new(0, Theme.Color.Speed),
	ColorSequenceKeypoint.new(1, Theme.Color.Accent),
}

--- Mixes toward white, for hover states and highlights.
function Theme.lighten(color: Color3, alpha: number): Color3
	return color:Lerp(Color3.new(1, 1, 1), alpha)
end

function Theme.darken(color: Color3, alpha: number): Color3
	return color:Lerp(Color3.new(0, 0, 0), alpha)
end

--- Two-stop gradient from a single colour, for trophy bodies and rarity glows.
function Theme.shine(color: Color3): ColorSequence
	return ColorSequence.new {
		ColorSequenceKeypoint.new(0, Theme.lighten(color, 0.35)),
		ColorSequenceKeypoint.new(0.55, color),
		ColorSequenceKeypoint.new(1, Theme.darken(color, 0.3)),
	}
end

return Theme
]]></string>
            </Properties>
          </Item>
        </Item>
      </Item>
    </Item>
  </Item>
  <Item class="Workspace" referent="57">
    <Properties>
      <string name="Name">Workspace</string>
      <float name="Gravity">196.2</float>
      <bool name="NeedsPivotMigration">false</bool>
      <bool name="StreamingEnabled">true</bool>
      <token name="StreamingIntegrityMode">3</token>
      <int name="StreamingMinRadius">256</int>
      <int name="StreamingTargetRadius">1024</int>
    </Properties>
  </Item>
</roblox>