<roblox version="4">
  <Item class="ReplicatedStorage" referent="0">
    <Properties>
      <string name="Name">ReplicatedStorage</string>
    </Properties>
    <Item class="Folder" referent="1">
      <Properties>
        <string name="Name">Shared</string>
      </Properties>
      <Item class="ModuleScript" referent="2">
        <Properties>
          <string name="Name">Cooldown</string>
          <string name="Source"><![CDATA[--!strict
-- Timestamp bag. The server owns the real one; the client keeps a mirror so
-- the HUD can tick smoothly without a remote every frame.

local Cooldown = {}
Cooldown.__index = Cooldown

export type Class = typeof(setmetatable(
	{} :: {
		expiry: { [string]: number },
		charges: { [string]: { count: number, max: number, nextAt: number, interval: number } },
	},
	Cooldown
))

function Cooldown.new(): Class
	return setmetatable({ expiry = {}, charges = {} }, Cooldown) :: any
end

function Cooldown.start(self: Class, key: string, duration: number)
	if duration <= 0 then
		self.expiry[key] = nil
		return
	end
	self.expiry[key] = os.clock() + duration
end

function Cooldown.remaining(self: Class, key: string): number
	local at = self.expiry[key]
	if not at then
		return 0
	end
	return math.max(0, at - os.clock())
end

function Cooldown.ready(self: Class, key: string): boolean
	return Cooldown.remaining(self, key) <= 0
end

function Cooldown.clear(self: Class, key: string)
	self.expiry[key] = nil
end

-- Charge-based resources (dash). Refills one charge every `interval` seconds
-- rather than gating the whole thing behind a single timer.
function Cooldown.defineCharges(self: Class, key: string, max: number, interval: number)
	self.charges[key] = { count = max, max = max, nextAt = 0, interval = interval }
end

local function refill(entry, now: number)
	if entry.count >= entry.max then
		entry.nextAt = 0
		return
	end
	if entry.nextAt == 0 then
		entry.nextAt = now + entry.interval
		return
	end
	while now >= entry.nextAt and entry.count < entry.max do
		entry.count += 1
		entry.nextAt = if entry.count >= entry.max then 0 else entry.nextAt + entry.interval
	end
end

function Cooldown.consumeCharge(self: Class, key: string): boolean
	local entry = self.charges[key]
	if not entry then
		return false
	end

	refill(entry, os.clock())
	if entry.count <= 0 then
		return false
	end

	entry.count -= 1
	if entry.nextAt == 0 then
		entry.nextAt = os.clock() + entry.interval
	end
	return true
end

function Cooldown.chargeCount(self: Class, key: string): number
	local entry = self.charges[key]
	if not entry then
		return 0
	end
	refill(entry, os.clock())
	return entry.count
end

-- 0..1 progress toward the next charge, for the HUD pips.
function Cooldown.chargeProgress(self: Class, key: string): number
	local entry = self.charges[key]
	if not entry then
		return 1
	end
	refill(entry, os.clock())
	if entry.count >= entry.max or entry.nextAt == 0 then
		return 1
	end
	local left = entry.nextAt - os.clock()
	return math.clamp(1 - (left / entry.interval), 0, 1)
end

return Cooldown
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="3">
        <Properties>
          <string name="Name">Hitbox</string>
          <string name="Source"><![CDATA[--!strict
-- Spatial queries for move hitboxes. Returns character models, never parts, so
-- callers never have to think about which limb they clipped.

local Workspace = game:GetService("Workspace")

local Hitbox = {}

local function collect(parts: { BasePart }, ignore: Model?): { Model }
	local seen: { [Model]: boolean } = {}
	local out: { Model } = {}

	for _, part in parts do
		local model = part:FindFirstAncestorOfClass("Model")
		if not model or model == ignore or seen[model] then
			continue
		end
		if not model:FindFirstChildOfClass("Humanoid") then
			continue
		end
		seen[model] = true
		table.insert(out, model)
	end

	return out
end

local function params(ignore: Model?): OverlapParams
	local p = OverlapParams.new()
	p.FilterType = Enum.RaycastFilterType.Exclude
	p.FilterDescendantsInstances = if ignore then { ignore } else {}
	p.MaxParts = 64
	return p
end

--[[
	origin  CFrame of the caster's HumanoidRootPart
	spec    the move's `hitbox` table
	ignore  the caster's model, so you never hit yourself
]]
function Hitbox.query(origin: CFrame, spec, ignore: Model?): { Model }
	local shape = spec.shape

	if shape == "Box" then
		local center = origin * CFrame.new(0, 0, -(spec.offset or 0))
		return collect(Workspace:GetPartBoundsInBox(center, spec.size, params(ignore)), ignore)
	elseif shape == "Sphere" then
		local center = (origin * CFrame.new(0, 0, -(spec.offset or 0))).Position
		return collect(Workspace:GetPartBoundsInRadius(center, spec.radius, params(ignore)), ignore)
	elseif shape == "Cone" then
		-- Query the enclosing sphere, then drop anything outside the arc. The
		-- sphere is cheap and the arc test is a dot product, so this beats
		-- trying to express a cone as boxes.
		local center = origin.Position
		local candidates =
			collect(Workspace:GetPartBoundsInRadius(center, spec.range, params(ignore)), ignore)

		local forward = origin.LookVector
		local cosLimit = math.cos(math.rad(spec.angle * 0.5))
		local halfHeight = (spec.height or 12) * 0.5

		local out: { Model } = {}
		for _, model in candidates do
			local root = model:FindFirstChild("HumanoidRootPart")
			if not root or not root:IsA("BasePart") then
				continue
			end

			local delta = root.Position - center
			if math.abs(delta.Y) > halfHeight then
				continue
			end

			local flat = Vector3.new(delta.X, 0, delta.Z)
			if flat.Magnitude < 0.05 then
				table.insert(out, model)
				continue
			end

			local flatForward = Vector3.new(forward.X, 0, forward.Z).Unit
			if flat.Unit:Dot(flatForward) >= cosLimit then
				table.insert(out, model)
			end
		end
		return out
	end

	return {}
end

-- Nearest character model in front of the caster, used by Teleport and Grab.
function Hitbox.nearestTarget(origin: CFrame, range: number, ignore: Model?): Model?
	local candidates =
		collect(Workspace:GetPartBoundsInRadius(origin.Position, range, params(ignore)), ignore)

	local best: Model? = nil
	local bestDistance = math.huge

	for _, model in candidates do
		local root = model:FindFirstChild("HumanoidRootPart")
		if not root or not root:IsA("BasePart") then
			continue
		end
		local humanoid = model:FindFirstChildOfClass("Humanoid")
		if not humanoid or humanoid.Health <= 0 then
			continue
		end

		local distance = (root.Position - origin.Position).Magnitude
		if distance < bestDistance then
			bestDistance = distance
			best = model
		end
	end

	return best
end

-- Resolve a knockback spec into a world-space impulse direction.
function Hitbox.knockbackVector(spec, casterCFrame: CFrame, targetPosition: Vector3): Vector3
	local mode = spec.mode
	local power = spec.power or 0
	local lift = spec.lift or 0

	-- "Up" and "Down" are purely vertical, so they contribute no horizontal
	-- direction at all — `lift` carries the sign. Everything unrecognised
	-- falls back to "Forward".
	local direction: Vector3
	if mode == "Up" or mode == "Down" then
		direction = Vector3.zero
	elseif mode == "Backward" then
		direction = -casterCFrame.LookVector
	elseif mode == "Away" then
		local flat = targetPosition - casterCFrame.Position
		flat = Vector3.new(flat.X, 0, flat.Z)
		direction = if flat.Magnitude > 0.05 then flat.Unit else casterCFrame.LookVector
	else
		direction = casterCFrame.LookVector
	end

	return Vector3.new(direction.X * power, lift, direction.Z * power)
end

return Hitbox
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="4">
        <Properties>
          <string name="Name">Net</string>
          <string name="Source"><![CDATA[--!strict
-- Remote plumbing. The server builds the folder, the client waits on it.
-- Deliberately few remotes: one channel in, one state channel out, one
-- cosmetic channel out.

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

local FOLDER_NAME = "SBGRemotes"

local REMOTE_NAMES = {
	-- client -> server. One envelope for every input; see CombatService.
	"Action",
	-- server -> owning client. Authoritative snapshot the HUD renders from.
	"State",
	-- server -> all clients. Purely cosmetic cues (hit sparks, move started).
	"Effect",
	-- client -> server. Character pick.
	"SelectCharacter",
}

local Net = {}

local cache: { [string]: RemoteEvent } = {}

local function container(): Folder
	if RunService:IsServer() then
		local folder = ReplicatedStorage:FindFirstChild(FOLDER_NAME)
		if not folder then
			folder = Instance.new("Folder")
			folder.Name = FOLDER_NAME
			folder.Parent = ReplicatedStorage
		end
		return folder :: Folder
	end
	return ReplicatedStorage:WaitForChild(FOLDER_NAME) :: Folder
end

function Net.get(name: string): RemoteEvent
	local existing = cache[name]
	if existing then
		return existing
	end

	local parent = container()
	local remote: RemoteEvent

	if RunService:IsServer() then
		local found = parent:FindFirstChild(name)
		if found then
			remote = found :: RemoteEvent
		else
			remote = Instance.new("RemoteEvent")
			remote.Name = name
			remote.Parent = parent
		end
	else
		remote = parent:WaitForChild(name) :: RemoteEvent
	end

	cache[name] = remote
	return remote
end

-- Server-only: make every remote exist before any client asks for one.
function Net.init()
	assert(RunService:IsServer(), "Net.init is server-only")
	for _, name in REMOTE_NAMES do
		Net.get(name)
	end
end

return Net
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="5">
        <Properties>
          <string name="Name">Powers</string>
          <string name="Source"><![CDATA[--!strict
--[[
	Power registry.

	Every move is plain data — the server reads these fields and drives the
	whole thing generically, so adding a character means adding a module here
	and nothing else.

	CHARACTER SCHEMA
	  id, name, title, blurb   display
	  category      "Hero" | "Element", used to group the roster panel
	  element       "Fire" | "Water" | ... — elements only, shown as a tag
	  color         accent used across the HUD for this pick
	  maxHealth, walkSpeed
	  moves         exactly four, one per slot

	MOVE SCHEMA
	  slot          1..4, which HUD key it binds to
	  id            unique string, used on the wire
	  name          display name
	  kind          "Strike" | "Barrage" | "Charge" | "Counter" | "Dash"
	                | "Projectile" | "Teleport" | "Grab" | "Barrier" | "Sustained"
	  description   HUD tooltip text
	  cooldown      seconds before it can be used again
	  damage        per hit (for Charge, the floor — see maxDamage)

	  windup        seconds after the input before anything can connect
	  active        seconds the hitbox is live (single-hit kinds)
	  endlag        seconds locked after the hitbox closes
	  stun          seconds the victim cannot act
	  ragdoll       seconds the victim is ragdolled (overrides stun)

	  hitbox        { shape = "Box",    size = Vector3, offset = number }
	                { shape = "Sphere", radius = number, offset = number }
	                { shape = "Cone",   range = number, angle = degrees, height = number }
	  knockback     { mode = "Forward"|"Backward"|"Up"|"Down"|"Away", power, lift }

	  hits          Barrage: number of ticks
	  hitInterval   Barrage/Sustained: seconds between ticks
	  finisher      Barrage: { damage, knockback } applied by the last tick only
	  charge        Charge: { min, max } hold seconds; damage lerps to maxDamage
	  counter       Counter: { window, reflectMultiplier, minimumDamage, ragdoll, knockback }
	  dash          Dash: { distance, speed, lift?, passThrough? }
	  projectile    Projectile: { count, spread, speed, lifetime, size, gravity, explode }
	  teleport      Teleport: { range, behindOffset, iframes }
	  grab          Grab: { range, holdDuration, slamDamage }
	  barrier       Barrier: { duration, absorb, reflectFraction, popKnockback, popRadius }
	  sustained     Sustained: { duration, tickInterval }
	  burn          damage-over-time rider: { damagePerTick, ticks, tickInterval }
	  slow          movement rider: { multiplier, duration } — a stronger slow
	                overrides a weaker one, a weaker one only extends the timer
	  pull          drags targets in during charge: { radius, power }

	  blockBreak    true  -> shreds block meter instead of being chipped
	  uncounterable true  -> Counter moves cannot catch it
	  pierce        true  -> hits every target in the volume, not just the first
	  rooted        true  -> caster cannot move for the duration
	  moveSpeedMultiplier  caster speed scale while the move runs (rooted = false)
	  ultimate      true  -> gets the highlighted HUD slot
]]

local Universal = require(script.Universal)

local ORDER = {
	-- Heroes
	"Saitama",
	"Garou",
	"Genos",
	"Sonic",
	"Tatsumaki",
	"AtomicSamurai",
	-- Elements
	"Blaze",
	"Tide",
	"Gale",
	"Volt",
	"Terra",
	"Frost",
}

local Powers = {}

Powers.Universal = Universal
Powers.Order = ORDER

-- id -> character definition
Powers.Characters = {}

-- "Saitama/SeriousPunch" -> move definition, built once so lookups on the hot
-- path never walk the roster.
local moveIndex = {}

for _, id in ORDER do
	local character = require(script[id])
	Powers.Characters[id] = character

	for _, move in character.moves do
		moveIndex[`{id}/{move.id}`] = move
	end
end

function Powers.getCharacter(characterId: string)
	return Powers.Characters[characterId]
end

function Powers.getMove(characterId: string, moveId: string)
	return moveIndex[`{characterId}/{moveId}`]
end

function Powers.getMoveBySlot(characterId: string, slot: number)
	local character = Powers.Characters[characterId]
	if not character then
		return nil
	end
	for _, move in character.moves do
		if move.slot == slot then
			return move
		end
	end
	return nil
end

function Powers.isValidCharacter(characterId: string): boolean
	return Powers.Characters[characterId] ~= nil
end

Powers.DefaultCharacter = ORDER[1]

return Powers
]]></string>
        </Properties>
        <Item class="ModuleScript" referent="6">
          <Properties>
            <string name="Name">AtomicSamurai</string>
            <string name="Source"><![CDATA[--!strict

return {
	id = "AtomicSamurai",
	name = "Atomic Samurai",
	title = "S-Class Rank 4",
	category = "Hero",
	color = Color3.fromRGB(212, 84, 100),
	accent = Color3.fromRGB(236, 236, 244),
	maxHealth = 98,
	walkSpeed = 17,
	blurb = "Every move is a clean angle. Tight windows, no wasted motion, nothing forgiving about it.",

	moves = {
		{
			slot = 1,
			id = "AtomicSlash",
			name = "Atomic Slash",
			kind = "Strike",
			animation = "Slash",
			description = "One horizontal cut with more reach than it looks like it has.",
			cooldown = 9,
			damage = 20,
			windup = 0.24,
			active = 0.2,
			endlag = 0.38,
			stun = 0.5,
			ragdoll = 1.5,
			knockback = { mode = "Forward", power = 84, lift = 24 },
			hitbox = { shape = "Box", size = Vector3.new(14, 7, 12), offset = 6 },
			rooted = false,
		},

		{
			slot = 2,
			id = "AtomicBladeRush",
			name = "Atomic Blade Rush",
			kind = "Dash",
			animation = "Lunge",
			description = "Steps through them. The cut lands after you have already passed.",
			cooldown = 13,
			damage = 26,
			dash = { distance = 40, speed = 130, passThrough = true },
			windup = 0.14,
			active = 0.22,
			endlag = 0.46,
			ragdoll = 2.2,
			knockback = { mode = "Backward", power = 96, lift = 22 },
			hitbox = { shape = "Box", size = Vector3.new(8, 7, 10), offset = 0 },
			blockBreak = true,
			rooted = false,
		},

		{
			slot = 3,
			id = "AtomicSwordCounter",
			name = "Atomic Sword Counter",
			kind = "Counter",
			animation = "Stance",
			description = "Parry window. Catches a hit and answers with a cut that ragdolls.",
			cooldown = 15,
			damage = 0,
			counter = {
				window = 0.6,
				reflectMultiplier = 1.4,
				minimumDamage = 18,
				ragdoll = 2.4,
				knockback = { mode = "Forward", power = 110, lift = 34 },
			},
			windup = 0.08,
			endlag = 0.4,
			whiffCooldown = 15,
			rooted = true,
		},

		{
			slot = 4,
			id = "AtomicHundredSlashes",
			name = "Atomic Hundred Slashes",
			kind = "Barrage",
			animation = "Flurry",
			description = "A hundred cuts in the time it takes to blink. Nothing survives the full string.",
			cooldown = 35,
			damage = 3.5,
			hits = 20,
			hitInterval = 0.05,
			windup = 0.35,
			endlag = 0.85,
			stun = 0.18,
			ragdoll = 3.4,
			finisher = {
				damage = 20,
				knockback = { mode = "Forward", power = 150, lift = 48 },
			},
			knockback = { mode = "Forward", power = 4, lift = 4 },
			hitbox = { shape = "Sphere", radius = 11, offset = 3 },
			blockBreak = true,
			uncounterable = true,
			rooted = true,
			ultimate = true,
		},
	},
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="7">
          <Properties>
            <string name="Name">Blaze</string>
            <string name="Source"><![CDATA[--!strict

return {
	id = "Blaze",
	name = "Blaze",
	title = "Living Ember",
	element = "Fire",
	category = "Element",
	color = Color3.fromRGB(238, 96, 44),
	accent = Color3.fromRGB(255, 196, 74),
	maxHealth = 92,
	walkSpeed = 17,
	blurb = "Nothing hits hard on its own — the burn stacks do the killing. Win the long trade.",

	moves = {
		{
			slot = 1,
			id = "CinderPalm",
			name = "Cinder Palm",
			kind = "Strike",
			animation = "Palm",
			description = "Open palm that leaves them smouldering. Cheap, and the burn pays it back.",
			cooldown = 8,
			damage = 13,
			windup = 0.2,
			active = 0.16,
			endlag = 0.34,
			stun = 0.4,
			burn = { damagePerTick = 2, ticks = 5, tickInterval = 0.6 },
			knockback = { mode = "Forward", power = 58, lift = 18 },
			hitbox = { shape = "Box", size = Vector3.new(7, 7, 8), offset = 4 },
			rooted = false,
		},

		{
			slot = 2,
			id = "Fireball",
			name = "Fireball",
			kind = "Projectile",
			animation = "Throw",
			description = "Lobbed ball of flame that bursts on contact and cooks everyone nearby.",
			cooldown = 11,
			damage = 16,
			projectile = {
				count = 1,
				spread = 0,
				speed = 115,
				lifetime = 3,
				size = Vector3.new(2.6, 2.6, 2.6),
				gravity = 18,
				explode = { radius = 11, damage = 9 },
			},
			burn = { damagePerTick = 2.5, ticks = 5, tickInterval = 0.55 },
			windup = 0.26,
			endlag = 0.4,
			stun = 0.35,
			knockback = { mode = "Away", power = 62, lift = 26 },
			rooted = false,
			moveSpeedMultiplier = 0.7,
		},

		{
			slot = 3,
			id = "FlameDash",
			name = "Flame Dash",
			kind = "Dash",
			animation = "Lunge",
			description = "Rips through everything in the lane and sets all of it alight.",
			cooldown = 14,
			damage = 15,
			dash = { distance = 36, speed = 118, passThrough = true },
			burn = { damagePerTick = 3, ticks = 4, tickInterval = 0.5 },
			windup = 0.14,
			active = 0.2,
			endlag = 0.42,
			stun = 0.5,
			knockback = { mode = "Forward", power = 60, lift = 22 },
			hitbox = { shape = "Box", size = Vector3.new(8, 7, 9), offset = 0 },
			pierce = true,
			rooted = false,
		},

		{
			slot = 4,
			id = "Eruption",
			name = "Eruption",
			kind = "Charge",
			animation = "GroundSlam",
			chargeAnimation = "ChargeUp",
			description = "Opens the ground underneath them. Full charge burns for the length of a fight.",
			cooldown = 36,
			damage = 30,
			maxDamage = 66,
			charge = { min = 0.7, max = 2.1 },
			windup = 0.3,
			active = 0.3,
			endlag = 0.95,
			ragdoll = 3.4,
			burn = { damagePerTick = 4, ticks = 8, tickInterval = 0.5 },
			knockback = { mode = "Up", power = 34, lift = 140 },
			hitbox = { shape = "Sphere", radius = 20, offset = 0 },
			blockBreak = true,
			pierce = true,
			rooted = true,
			ultimate = true,
		},
	},
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="8">
          <Properties>
            <string name="Name">Frost</string>
            <string name="Source"><![CDATA[--!strict

return {
	id = "Frost",
	name = "Frost",
	title = "Still Winter",
	element = "Ice",
	category = "Element",
	color = Color3.fromRGB(140, 208, 244),
	accent = Color3.fromRGB(230, 246, 255),
	maxHealth = 94,
	walkSpeed = 16,
	blurb = "Takes their movement away first and the fight second. Nothing escapes once it is stacked.",

	moves = {
		{
			slot = 1,
			id = "FrostStrike",
			name = "Frost Strike",
			kind = "Strike",
			animation = "Straight",
			description = "Cracks across them and locks their legs up for a couple of seconds.",
			cooldown = 8,
			damage = 14,
			windup = 0.22,
			active = 0.16,
			endlag = 0.32,
			stun = 0.4,
			slow = { multiplier = 0.6, duration = 2.5 },
			knockback = { mode = "Forward", power = 62, lift = 18 },
			hitbox = { shape = "Box", size = Vector3.new(7, 7, 9), offset = 4.5 },
			rooted = false,
		},

		{
			slot = 2,
			id = "IcicleVolley",
			name = "Icicle Volley",
			kind = "Projectile",
			animation = "Cast",
			description = "A spread of shards. Each one that lands chills them further.",
			cooldown = 11,
			damage = 8,
			projectile = {
				count = 4,
				spread = 11,
				speed = 130,
				lifetime = 2.5,
				size = Vector3.new(0.8, 0.8, 3),
				gravity = 12,
				explode = { radius = 6, damage = 4 },
			},
			slow = { multiplier = 0.7, duration = 2 },
			windup = 0.22,
			endlag = 0.38,
			stun = 0.3,
			knockback = { mode = "Away", power = 44, lift = 18 },
			rooted = false,
			moveSpeedMultiplier = 0.65,
		},

		{
			slot = 3,
			id = "FrozenCounter",
			name = "Frozen Counter",
			kind = "Counter",
			animation = "Stance",
			description = "Catch a hit on the ice and it comes back with their legs frozen under them.",
			cooldown = 16,
			damage = 0,
			counter = {
				window = 0.7,
				reflectMultiplier = 1.5,
				minimumDamage = 16,
				ragdoll = 2.2,
				knockback = { mode = "Forward", power = 100, lift = 30 },
			},
			windup = 0.1,
			endlag = 0.38,
			whiffCooldown = 16,
			rooted = true,
		},

		{
			slot = 4,
			id = "AbsoluteZero",
			name = "Absolute Zero",
			kind = "Charge",
			animation = "GroundSlam",
			chargeAnimation = "ChargeUp",
			description = "Freezes the whole area solid. Long ragdoll, and they crawl once they are up.",
			cooldown = 35,
			damage = 27,
			maxDamage = 62,
			charge = { min = 0.8, max = 2.2 },
			windup = 0.3,
			active = 0.32,
			endlag = 0.95,
			ragdoll = 4.2,
			slow = { multiplier = 0.4, duration = 4 },
			knockback = { mode = "Up", power = 20, lift = 96 },
			hitbox = { shape = "Sphere", radius = 23, offset = 0 },
			blockBreak = true,
			pierce = true,
			rooted = true,
			ultimate = true,
		},
	},
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="9">
          <Properties>
            <string name="Name">Gale</string>
            <string name="Source"><![CDATA[--!strict

return {
	id = "Gale",
	name = "Gale",
	title = "Open Sky",
	element = "Wind",
	category = "Element",
	color = Color3.fromRGB(150, 224, 196),
	accent = Color3.fromRGB(228, 248, 236),
	maxHealth = 88,
	walkSpeed = 19,
	blurb = "Launches them up and keeps them there. Juggle or lose — there is no plan B.",

	moves = {
		{
			slot = 1,
			id = "GaleSlash",
			name = "Gale Slash",
			kind = "Strike",
			animation = "Slash",
			description = "A blade of compressed air with far more reach than it should have.",
			cooldown = 7,
			damage = 14,
			windup = 0.2,
			active = 0.18,
			endlag = 0.3,
			stun = 0.42,
			knockback = { mode = "Forward", power = 72, lift = 20 },
			hitbox = { shape = "Box", size = Vector3.new(10, 7, 22), offset = 11 },
			rooted = false,
		},

		{
			slot = 2,
			id = "Updraft",
			name = "Updraft",
			kind = "Strike",
			animation = "Scoop",
			description = "Pops everyone around you straight up. The setup for everything else.",
			cooldown = 10,
			damage = 11,
			windup = 0.24,
			active = 0.22,
			endlag = 0.36,
			ragdoll = 1.5,
			knockback = { mode = "Up", power = 14, lift = 128 },
			hitbox = { shape = "Sphere", radius = 13, offset = 0 },
			pierce = true,
			rooted = false,
		},

		{
			slot = 3,
			id = "AirStep",
			name = "Air Step",
			kind = "Teleport",
			animation = "Lunge",
			description = "Rides the draft in behind whoever is closest. No damage — position is the point.",
			cooldown = 13,
			damage = 0,
			teleport = { range = 70, behindOffset = 5, iframes = 0.35 },
			windup = 0.12,
			endlag = 0.18,
			rooted = false,
		},

		{
			slot = 4,
			id = "Tempest",
			name = "Tempest",
			kind = "Charge",
			animation = "Cast",
			chargeAnimation = "ChargeUp",
			description = "The whole field goes up. Everything that comes down is ragdolled.",
			cooldown = 33,
			damage = 26,
			maxDamage = 60,
			charge = { min = 0.6, max = 2 },
			windup = 0.26,
			active = 0.34,
			endlag = 0.9,
			ragdoll = 4,
			knockback = { mode = "Up", power = 40, lift = 190 },
			hitbox = { shape = "Sphere", radius = 26, offset = 0 },
			blockBreak = true,
			pierce = true,
			rooted = true,
			ultimate = true,
		},
	},
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="10">
          <Properties>
            <string name="Name">Garou</string>
            <string name="Source"><![CDATA[--!strict

return {
	id = "Garou",
	name = "Garou",
	title = "Hero Hunter",
	category = "Hero",
	color = Color3.fromRGB(126, 132, 148),
	accent = Color3.fromRGB(88, 172, 214),
	maxHealth = 105,
	walkSpeed = 17,
	blurb = "Reads what you throw and gives it back. Rewards patience, punishes button mashing.",

	moves = {
		{
			slot = 1,
			id = "WhirlwindIronCuttingFist",
			name = "Whirlwind Iron Cutting Fist",
			kind = "Strike",
			animation = "SpinSlash",
			description = "Spinning slash that throws a wind blade well past arm's reach.",
			cooldown = 10,
			damage = 19,
			windup = 0.28,
			active = 0.22,
			endlag = 0.42,
			stun = 0.5,
			ragdoll = 1.4,
			knockback = { mode = "Forward", power = 88, lift = 22 },
			hitbox = { shape = "Box", size = Vector3.new(9, 7, 20), offset = 10 },
			rooted = false,
		},

		{
			slot = 2,
			id = "WaterStreamRockSmashingFist",
			name = "Water Stream Rock Smashing Fist",
			kind = "Counter",
			animation = "Stance",
			description = "Stance. Anything that lands during the window gets redirected back, harder.",
			cooldown = 16,
			-- No damage of its own; it returns what it caught.
			damage = 0,
			counter = {
				window = 0.8,
				reflectMultiplier = 1.75,
				-- Floor so countering a chip hit still means something.
				minimumDamage = 14,
				ragdoll = 2.6,
				knockback = { mode = "Forward", power = 120, lift = 30 },
			},
			windup = 0.1,
			endlag = 0.35,
			-- Whiffing the read costs you the full cooldown anyway.
			whiffCooldown = 16,
			rooted = true,
		},

		{
			slot = 3,
			id = "FlowingWaterCrushingRockFist",
			name = "Flowing Water Crushing Rock Fist",
			kind = "Dash",
			animation = "Palm",
			description = "Closes the gap and drives a palm through their guard.",
			cooldown = 14,
			damage = 24,
			dash = { distance = 34, speed = 96 },
			windup = 0.16,
			active = 0.2,
			endlag = 0.5,
			ragdoll = 2,
			knockback = { mode = "Forward", power = 104, lift = 18 },
			hitbox = { shape = "Box", size = Vector3.new(7, 7, 8), offset = 4 },
			blockBreak = true,
			rooted = false,
		},

		{
			slot = 4,
			id = "FatalCounter",
			name = "Fatal Counter",
			kind = "Barrage",
			animation = "Flurry",
			description = "Grabs, then unloads. Long lock-in, so miss it and you are wide open.",
			cooldown = 34,
			damage = 5,
			hits = 9,
			hitInterval = 0.11,
			windup = 0.4,
			endlag = 0.9,
			stun = 0.25,
			ragdoll = 3.2,
			finisher = {
				damage = 22,
				knockback = { mode = "Down", power = 140, lift = -80 },
			},
			knockback = { mode = "Forward", power = 6, lift = 6 },
			hitbox = { shape = "Box", size = Vector3.new(6, 7, 7), offset = 3.5 },
			blockBreak = true,
			uncounterable = true,
			rooted = true,
			ultimate = true,
		},
	},
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="11">
          <Properties>
            <string name="Name">Genos</string>
            <string name="Source"><![CDATA[--!strict

return {
	id = "Genos",
	name = "Genos",
	title = "Demon Cyborg",
	category = "Hero",
	color = Color3.fromRGB(228, 116, 48),
	accent = Color3.fromRGB(255, 196, 84),
	maxHealth = 95,
	walkSpeed = 16,
	blurb = "Fights at range and pins you down. Squishy up close, brutal if you let him breathe.",

	moves = {
		{
			slot = 1,
			id = "Incinerate",
			name = "Incinerate",
			kind = "Sustained",
			animation = "Cast",
			description = "Sweeping flame cone. Ticks while held and stacks a burn.",
			cooldown = 13,
			damage = 3,
			-- Ticks for `duration` seconds instead of a single active frame.
			sustained = { duration = 1.4, tickInterval = 0.12 },
			burn = { damagePerTick = 1.5, ticks = 5, tickInterval = 0.5 },
			windup = 0.22,
			endlag = 0.4,
			stun = 0.15,
			knockback = { mode = "Forward", power = 4, lift = 2 },
			hitbox = { shape = "Cone", range = 30, angle = 42, height = 10 },
			-- Can turn to track while the flame is up, but slowly.
			rooted = false,
			moveSpeedMultiplier = 0.35,
		},

		{
			slot = 2,
			id = "MachineGunBlows",
			name = "Machine Gun Blows",
			kind = "Barrage",
			animation = "Flurry",
			description = "Rapid piston punches. Fast enough to beat most startups.",
			cooldown = 15,
			damage = 3.5,
			hits = 14,
			hitInterval = 0.07,
			windup = 0.18,
			endlag = 0.5,
			stun = 0.18,
			ragdoll = 1.2,
			finisher = {
				damage = 9,
				knockback = { mode = "Forward", power = 88, lift = 30 },
			},
			knockback = { mode = "Forward", power = 5, lift = 3 },
			hitbox = { shape = "Box", size = Vector3.new(6, 7, 8), offset = 4 },
			blockBreak = true,
			rooted = true,
		},

		{
			slot = 3,
			id = "JetDriveArrow",
			name = "Jet Drive Arrow",
			kind = "Dash",
			animation = "Uppercut",
			description = "Boosters fire into a rising uppercut. Launches them straight up for a follow-up.",
			cooldown = 17,
			damage = 21,
			dash = { distance = 24, speed = 84, lift = 22 },
			windup = 0.2,
			active = 0.24,
			endlag = 0.55,
			ragdoll = 2.2,
			knockback = { mode = "Up", power = 26, lift = 132 },
			hitbox = { shape = "Box", size = Vector3.new(7, 9, 8), offset = 4 },
			rooted = false,
		},

		{
			slot = 4,
			id = "IncinerationCannon",
			name = "Incineration Cannon",
			kind = "Charge",
			animation = "Cast",
			chargeAnimation = "ChargeUp",
			description = "Charges both arms into one beam. Goes through everything in a line.",
			cooldown = 38,
			damage = 38,
			maxDamage = 82,
			charge = { min = 0.7, max = 2 },
			windup = 0.3,
			active = 0.3,
			endlag = 1,
			ragdoll = 3.6,
			burn = { damagePerTick = 3, ticks = 6, tickInterval = 0.5 },
			knockback = { mode = "Forward", power = 180, lift = 40 },
			hitbox = { shape = "Box", size = Vector3.new(9, 9, 60), offset = 30 },
			blockBreak = true,
			-- Pierces: hits every target in the line, not just the first.
			pierce = true,
			rooted = true,
			ultimate = true,
		},
	},
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="12">
          <Properties>
            <string name="Name">Saitama</string>
            <string name="Source"><![CDATA[--!strict

return {
	id = "Saitama",
	name = "Saitama",
	title = "Caped Baldy",
	category = "Hero",
	color = Color3.fromRGB(240, 196, 62),
	accent = Color3.fromRGB(196, 60, 52),
	maxHealth = 100,
	walkSpeed = 16,
	blurb = "Slow to start, ends everything. No mobility, no defence, absurd payoff.",

	moves = {
		{
			slot = 1,
			id = "NormalPunch",
			name = "Normal Punch",
			kind = "Strike",
			animation = "Straight",
			description = "One straight punch. Ragdolls anything it touches and sends it a long way.",
			cooldown = 12,
			damage = 22,
			windup = 0.32,
			active = 0.18,
			endlag = 0.5,
			stun = 0,
			ragdoll = 2.4,
			knockback = { mode = "Forward", power = 150, lift = 34 },
			hitbox = { shape = "Box", size = Vector3.new(8, 7, 9), offset = 4.5 },
			blockBreak = false,
			-- Locks you in place while it plays out; that is the cost.
			rooted = true,
		},

		{
			slot = 2,
			id = "ConsecutiveNormalPunches",
			name = "Consecutive Normal Punches",
			kind = "Barrage",
			animation = "Flurry",
			description = "A wall of punches. Chews through block meter, then dumps them on the floor.",
			cooldown = 18,
			damage = 4,
			hits = 12,
			hitInterval = 0.09,
			windup = 0.3,
			endlag = 0.65,
			stun = 0.22,
			ragdoll = 1.8,
			-- Applied by the final hit only.
			finisher = {
				damage = 10,
				knockback = { mode = "Forward", power = 96, lift = 40 },
			},
			knockback = { mode = "Forward", power = 8, lift = 4 },
			hitbox = { shape = "Box", size = Vector3.new(7, 7, 8), offset = 4 },
			blockBreak = true,
			rooted = true,
		},

		{
			slot = 3,
			id = "SeriousTableFlip",
			name = "Serious Table Flip",
			kind = "Strike",
			animation = "Scoop",
			description = "Rips up the ground in an arc. Hits everyone in front, no aim required.",
			cooldown = 22,
			damage = 34,
			windup = 0.55,
			active = 0.3,
			endlag = 0.8,
			ragdoll = 3,
			knockback = { mode = "Up", power = 40, lift = 110 },
			hitbox = { shape = "Cone", range = 26, angle = 85, height = 14 },
			blockBreak = true,
			rooted = true,
		},

		{
			slot = 4,
			id = "SeriousPunch",
			name = "Serious Punch",
			kind = "Charge",
			animation = "Straight",
			chargeAnimation = "ChargeUp",
			description = "Hold to wind up. Full charge one-shots most health bars and ignores block entirely.",
			cooldown = 40,
			-- Damage scales between these across the charge window.
			damage = 45,
			maxDamage = 100,
			charge = { min = 0.6, max = 2.4 },
			windup = 0.25,
			active = 0.25,
			endlag = 1.1,
			ragdoll = 4.5,
			knockback = { mode = "Forward", power = 260, lift = 60 },
			hitbox = { shape = "Box", size = Vector3.new(12, 10, 34), offset = 17 },
			blockBreak = true,
			-- Cannot be parried, countered, or i-framed out of at full charge.
			uncounterable = true,
			rooted = true,
			ultimate = true,
		},
	},
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="13">
          <Properties>
            <string name="Name">Sonic</string>
            <string name="Source"><![CDATA[--!strict

return {
	id = "Sonic",
	name = "Speed-o'-Sound Sonic",
	title = "Ninja",
	category = "Hero",
	color = Color3.fromRGB(64, 72, 96),
	accent = Color3.fromRGB(120, 220, 200),
	maxHealth = 90,
	walkSpeed = 19,
	blurb = "Fastest thing in the lobby. Paper-thin, but you have to catch him first.",

	moves = {
		{
			slot = 1,
			id = "WindBladeKick",
			name = "Wind Blade Kick",
			kind = "Dash",
			animation = "Kick",
			description = "Blink forward into a spinning kick. Short cooldown, good for chaining.",
			cooldown = 8,
			damage = 16,
			dash = { distance = 30, speed = 120 },
			windup = 0.12,
			active = 0.16,
			endlag = 0.3,
			stun = 0.45,
			ragdoll = 1.2,
			knockback = { mode = "Forward", power = 74, lift = 26 },
			hitbox = { shape = "Box", size = Vector3.new(7, 7, 8), offset = 4 },
			rooted = false,
		},

		{
			slot = 2,
			id = "ExplodingShuriken",
			name = "Exploding Shuriken",
			kind = "Projectile",
			animation = "Throw",
			description = "Throws a fan of shuriken that detonate on contact.",
			cooldown = 12,
			damage = 11,
			projectile = {
				count = 3,
				spread = 9,
				speed = 140,
				lifetime = 2.5,
				size = Vector3.new(1.2, 1.2, 1.2),
				gravity = 0,
				explode = { radius = 8, damage = 7 },
			},
			windup = 0.2,
			endlag = 0.35,
			stun = 0.3,
			knockback = { mode = "Away", power = 52, lift = 24 },
			rooted = false,
		},

		{
			slot = 3,
			id = "ShadowStep",
			name = "Shadow Step",
			kind = "Teleport",
			animation = "Lunge",
			description = "Vanishes and reappears behind the nearest target. No damage — it sets one up.",
			cooldown = 15,
			damage = 0,
			teleport = { range = 60, behindOffset = 5, iframes = 0.3 },
			windup = 0.14,
			endlag = 0.2,
			rooted = false,
		},

		{
			slot = 4,
			id = "WindBladeBarrage",
			name = "Ninja Art: Wind Blade Barrage",
			kind = "Barrage",
			animation = "Flurry",
			description = "Too many cuts to count, then one that puts them through the floor.",
			cooldown = 32,
			damage = 4,
			hits = 16,
			hitInterval = 0.06,
			windup = 0.3,
			endlag = 0.8,
			stun = 0.2,
			ragdoll = 3,
			finisher = {
				damage = 16,
				knockback = { mode = "Down", power = 120, lift = -90 },
			},
			knockback = { mode = "Forward", power = 5, lift = 5 },
			hitbox = { shape = "Sphere", radius = 9, offset = 3 },
			blockBreak = true,
			uncounterable = true,
			rooted = true,
			ultimate = true,
		},
	},
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="14">
          <Properties>
            <string name="Name">Tatsumaki</string>
            <string name="Source"><![CDATA[--!strict

return {
	id = "Tatsumaki",
	name = "Tatsumaki",
	title = "Tornado of Terror",
	category = "Hero",
	color = Color3.fromRGB(96, 208, 148),
	accent = Color3.fromRGB(180, 128, 232),
	maxHealth = 88,
	walkSpeed = 16,
	blurb = "Never touches you and never has to. Controls the whole space, folds under pressure.",

	moves = {
		{
			slot = 1,
			id = "TelekineticGrab",
			name = "Telekinetic Grab",
			kind = "Grab",
			animation = "Grab",
			description = "Lifts them off the ground and slams them down wherever you are looking.",
			cooldown = 14,
			damage = 20,
			grab = { range = 45, holdDuration = 1.1, slamDamage = 14 },
			windup = 0.25,
			endlag = 0.5,
			ragdoll = 2.6,
			knockback = { mode = "Down", power = 90, lift = -70 },
			rooted = true,
		},

		{
			slot = 2,
			id = "RubbleToss",
			name = "Rubble Toss",
			kind = "Projectile",
			animation = "Cast",
			description = "Tears up debris and throws it in a volley. Cheap, spammable, keeps them honest.",
			cooldown = 10,
			damage = 9,
			projectile = {
				count = 5,
				spread = 14,
				speed = 105,
				lifetime = 3,
				size = Vector3.new(2.4, 2.4, 2.4),
				gravity = 40,
				explode = { radius = 6, damage = 4 },
			},
			windup = 0.3,
			endlag = 0.45,
			stun = 0.28,
			knockback = { mode = "Away", power = 46, lift = 20 },
			rooted = false,
			moveSpeedMultiplier = 0.6,
		},

		{
			slot = 3,
			id = "PsychicBarrier",
			name = "Psychic Barrier",
			kind = "Barrier",
			animation = "Focus",
			description = "A shell that eats a fixed amount of damage before popping.",
			cooldown = 20,
			damage = 0,
			barrier = {
				duration = 5,
				absorb = 45,
				-- Reflects a slice of whatever it absorbs back at the attacker.
				reflectFraction = 0.25,
				popKnockback = { mode = "Away", power = 70, lift = 30 },
				popRadius = 14,
			},
			windup = 0.2,
			endlag = 0.25,
			rooted = false,
		},

		{
			slot = 4,
			id = "GravityCrush",
			name = "Gravity Crush",
			kind = "Charge",
			animation = "Cast",
			chargeAnimation = "Focus",
			description = "Pulls everything nearby to a point, then crushes it. Ignores block.",
			cooldown = 36,
			damage = 32,
			maxDamage = 72,
			charge = { min = 0.8, max = 2.2 },
			-- The pull runs during the charge, so charging longer drags them in further.
			pull = { radius = 40, power = 55 },
			windup = 0.25,
			active = 0.35,
			endlag = 1,
			ragdoll = 3.8,
			knockback = { mode = "Up", power = 30, lift = 150 },
			hitbox = { shape = "Sphere", radius = 22, offset = 0 },
			blockBreak = true,
			pierce = true,
			rooted = true,
			ultimate = true,
		},
	},
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="15">
          <Properties>
            <string name="Name">Terra</string>
            <string name="Source"><![CDATA[--!strict

return {
	id = "Terra",
	name = "Terra",
	title = "Immovable",
	element = "Earth",
	category = "Element",
	color = Color3.fromRGB(168, 130, 86),
	accent = Color3.fromRGB(112, 92, 68),
	maxHealth = 125,
	walkSpeed = 14,
	blurb = "Slowest thing here and the hardest to shift. Every move breaks guard.",

	moves = {
		{
			slot = 1,
			id = "StoneFist",
			name = "Stone Fist",
			kind = "Strike",
			animation = "Hook",
			description = "Sheathes the arm in rock. Goes through a held block like it is not there.",
			cooldown = 10,
			damage = 24,
			windup = 0.34,
			active = 0.2,
			endlag = 0.45,
			ragdoll = 1.8,
			knockback = { mode = "Forward", power = 106, lift = 26 },
			hitbox = { shape = "Box", size = Vector3.new(8, 7, 8), offset = 4 },
			blockBreak = true,
			rooted = false,
		},

		{
			slot = 2,
			id = "BoulderToss",
			name = "Boulder Toss",
			kind = "Projectile",
			animation = "Overhead",
			description = "Tears a slab out of the ground and throws it. Arcs hard, hits harder.",
			cooldown = 13,
			damage = 22,
			projectile = {
				count = 1,
				spread = 0,
				speed = 88,
				lifetime = 3.5,
				size = Vector3.new(4, 4, 4),
				gravity = 55,
				explode = { radius = 13, damage = 11 },
			},
			windup = 0.38,
			endlag = 0.5,
			ragdoll = 1.6,
			knockback = { mode = "Away", power = 92, lift = 40 },
			rooted = true,
		},

		{
			slot = 3,
			id = "StoneSkin",
			name = "Stone Skin",
			kind = "Barrier",
			animation = "Focus",
			description = "Plates over. Soaks more than anything else in the game, gives nothing back.",
			cooldown = 22,
			damage = 0,
			barrier = {
				duration = 8,
				absorb = 90,
				reflectFraction = 0,
				popKnockback = { mode = "Away", power = 60, lift = 26 },
				popRadius = 12,
			},
			windup = 0.3,
			endlag = 0.3,
			rooted = false,
		},

		{
			slot = 4,
			id = "Fissure",
			name = "Fissure",
			kind = "Charge",
			animation = "GroundSlam",
			chargeAnimation = "ChargeUp",
			description = "Splits the ground in a wedge. Everything standing in it goes up.",
			cooldown = 37,
			damage = 34,
			maxDamage = 74,
			charge = { min = 0.9, max = 2.4 },
			windup = 0.36,
			active = 0.34,
			endlag = 1.05,
			ragdoll = 3.6,
			knockback = { mode = "Up", power = 44, lift = 145 },
			hitbox = { shape = "Cone", range = 34, angle = 70, height = 16 },
			blockBreak = true,
			pierce = true,
			rooted = true,
			ultimate = true,
		},
	},
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="16">
          <Properties>
            <string name="Name">Tide</string>
            <string name="Source"><![CDATA[--!strict

return {
	id = "Tide",
	name = "Tide",
	title = "Deep Current",
	element = "Water",
	category = "Element",
	color = Color3.fromRGB(64, 148, 226),
	accent = Color3.fromRGB(140, 226, 240),
	maxHealth = 100,
	walkSpeed = 16,
	blurb = "Pushes, slows, and outlasts. Never wins a race — decides where the fight happens.",

	moves = {
		{
			slot = 1,
			id = "TorrentPalm",
			name = "Torrent Palm",
			kind = "Strike",
			animation = "Palm",
			description = "A pressurised jet that shoves them straight off you.",
			cooldown = 8,
			damage = 15,
			windup = 0.22,
			active = 0.18,
			endlag = 0.32,
			stun = 0.45,
			knockback = { mode = "Away", power = 118, lift = 26 },
			hitbox = { shape = "Box", size = Vector3.new(7, 7, 12), offset = 6 },
			rooted = false,
		},

		{
			slot = 2,
			id = "WaveCrash",
			name = "Wave Crash",
			kind = "Sustained",
			animation = "Cast",
			description = "A rolling wall of water. Ticks, pushes, and leaves them wading.",
			cooldown = 15,
			damage = 3.5,
			sustained = { duration = 1.5, tickInterval = 0.15 },
			slow = { multiplier = 0.55, duration = 2.5 },
			windup = 0.28,
			endlag = 0.45,
			stun = 0.12,
			knockback = { mode = "Away", power = 22, lift = 6 },
			hitbox = { shape = "Cone", range = 28, angle = 60, height = 12 },
			pierce = true,
			rooted = false,
			moveSpeedMultiplier = 0.4,
		},

		{
			slot = 3,
			id = "TideGuard",
			name = "Tide Guard",
			kind = "Barrier",
			animation = "Focus",
			description = "A shell of moving water. Eats damage and spits a third of it back.",
			cooldown = 19,
			damage = 0,
			barrier = {
				duration = 6,
				absorb = 55,
				reflectFraction = 0.33,
				popKnockback = { mode = "Away", power = 88, lift = 34 },
				popRadius = 16,
			},
			windup = 0.2,
			endlag = 0.25,
			rooted = false,
		},

		{
			slot = 4,
			id = "Maelstrom",
			name = "Maelstrom",
			kind = "Charge",
			animation = "Cast",
			chargeAnimation = "Focus",
			description = "Drags everything into the middle and closes it. Nothing walks out of the pull.",
			cooldown = 34,
			damage = 28,
			maxDamage = 64,
			charge = { min = 0.8, max = 2.2 },
			pull = { radius = 38, power = 62 },
			windup = 0.28,
			active = 0.32,
			endlag = 0.9,
			ragdoll = 3.2,
			slow = { multiplier = 0.5, duration = 3 },
			knockback = { mode = "Up", power = 26, lift = 120 },
			hitbox = { shape = "Sphere", radius = 21, offset = 0 },
			blockBreak = true,
			pierce = true,
			rooted = true,
			ultimate = true,
		},
	},
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="17">
          <Properties>
            <string name="Name">Universal</string>
            <string name="Source"><![CDATA[--!strict
-- Moves every character shares, regardless of who they picked.
-- These occupy their own HUD row so they never collide with the four
-- character slots.

return {
	M1 = {
		id = "M1",
		name = "Punch",
		bind = Enum.UserInputType.MouseButton1,
		kind = "Combo",
		icon = "M1",
		description = "Four-hit punch string. The finisher launches instead of stunning.",

		-- Each entry is one link in the string. Dropping the chain (by waiting
		-- longer than `comboWindow`) sends you back to link 1.
		comboWindow = 1.1,
		chain = {
			-- Alternating hands, then a hook, then a committed straight.
			{ damage = 6, windup = 0.08, active = 0.12, endlag = 0.2, stun = 0.35, animation = "Jab" },
			{
				damage = 6,
				windup = 0.08,
				active = 0.12,
				endlag = 0.2,
				stun = 0.35,
				animation = "Jab",
				mirror = true,
			},
			{ damage = 7, windup = 0.09, active = 0.12, endlag = 0.22, stun = 0.4, animation = "Hook" },
			{
				damage = 12,
				windup = 0.14,
				active = 0.16,
				endlag = 0.45,
				ragdoll = 1.6,
				animation = "Straight",
				knockback = { mode = "Forward", power = 78, lift = 26 },
			},
		},

		hitbox = { shape = "Box", size = Vector3.new(6, 6, 7), offset = 3.5 },
		cooldown = 0,
	},

	Block = {
		id = "Block",
		name = "Block",
		bind = Enum.KeyCode.F,
		kind = "Hold",
		icon = "F",
		description = "Hold to soak damage. Chip still lands, and the meter breaks if you eat too much.",

		maxMeter = 100,
		-- Meter drained per point of incoming damage while holding.
		meterPerDamage = 1.6,
		-- Fraction of incoming damage that still gets through a held block.
		chipMultiplier = 0.18,
		-- Seconds of full stun when the meter empties.
		breakStun = 2.5,
		regenPerSecond = 14,
		regenDelay = 1.25,
		cooldown = 0,
	},

	Dash = {
		id = "Dash",
		name = "Dash",
		bind = Enum.KeyCode.Q,
		kind = "Movement",
		icon = "Q",
		description = "Burst in your input direction. Brief i-frames on startup.",

		charges = 2,
		rechargePerCharge = 3.2,
		distance = 26,
		duration = 0.24,
		iframes = 0.14,
		cooldown = 0,
	},

	Sprint = {
		id = "Sprint",
		name = "Sprint",
		bind = Enum.KeyCode.LeftShift,
		kind = "Hold",
		icon = "LSHIFT",
		description = "Run. Drains stamina, and stamina is shared with dash.",

		walkSpeed = 16,
		sprintSpeed = 30,
		staminaPerSecond = 9,
		cooldown = 0,
	},

	Getup = {
		id = "Getup",
		name = "Get Up",
		bind = Enum.KeyCode.Space,
		kind = "Recovery",
		icon = "SPACE",
		description = "Mash while ragdolled to stand up early.",

		-- Seconds shaved off the remaining ragdoll per press.
		reductionPerPress = 0.18,
		-- Presses inside this window past the first are ignored, so holding
		-- the key down does nothing.
		pressCooldown = 0.09,
		-- Fraction of the ragdoll you must always eat, no matter the mashing.
		floor = 0.35,
		cooldown = 0,
	},

	Stamina = {
		max = 100,
		regenPerSecond = 12,
		regenDelay = 0.8,
		dashCost = 18,
	},
}
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="18">
          <Properties>
            <string name="Name">Volt</string>
            <string name="Source"><![CDATA[--!strict

return {
	id = "Volt",
	name = "Volt",
	title = "Split Second",
	element = "Lightning",
	category = "Element",
	color = Color3.fromRGB(246, 220, 68),
	accent = Color3.fromRGB(168, 148, 248),
	maxHealth = 86,
	walkSpeed = 20,
	blurb = "Shortest cooldowns in the roster and the health bar to match. Never stop moving.",

	moves = {
		{
			slot = 1,
			id = "BoltJab",
			name = "Bolt Jab",
			kind = "Strike",
			animation = "Jab",
			description = "Barely a windup. Beats almost anything if you see it coming.",
			cooldown = 5,
			damage = 11,
			windup = 0.1,
			active = 0.12,
			endlag = 0.22,
			stun = 0.38,
			knockback = { mode = "Forward", power = 54, lift = 16 },
			hitbox = { shape = "Box", size = Vector3.new(6, 7, 9), offset = 4.5 },
			rooted = false,
		},

		{
			slot = 2,
			id = "ChainLightning",
			name = "Chain Lightning",
			kind = "Projectile",
			animation = "Cast",
			description = "An arc that does not stop at the first thing it hits.",
			cooldown = 10,
			damage = 13,
			projectile = {
				count = 2,
				spread = 6,
				speed = 190,
				lifetime = 2,
				size = Vector3.new(1, 1, 3.5),
				gravity = 0,
				explode = { radius = 9, damage = 8 },
			},
			windup = 0.16,
			endlag = 0.3,
			stun = 0.4,
			knockback = { mode = "Forward", power = 48, lift = 18 },
			pierce = true,
			rooted = false,
		},

		{
			slot = 3,
			id = "FlashStep",
			name = "Flash Step",
			kind = "Teleport",
			animation = "Lunge",
			description = "Gone and back before the animation finishes. Long i-frames on the landing.",
			cooldown = 11,
			damage = 0,
			teleport = { range = 80, behindOffset = 4.5, iframes = 0.45 },
			windup = 0.08,
			endlag = 0.14,
			rooted = false,
		},

		{
			slot = 4,
			id = "Thunderclap",
			name = "Thunderclap",
			kind = "Charge",
			animation = "Straight",
			chargeAnimation = "ChargeUp",
			description = "One line, straight through. Goes past the first target and keeps going.",
			cooldown = 31,
			damage = 30,
			maxDamage = 70,
			charge = { min = 0.5, max = 1.8 },
			windup = 0.2,
			active = 0.24,
			endlag = 0.85,
			ragdoll = 3,
			knockback = { mode = "Forward", power = 200, lift = 44 },
			hitbox = { shape = "Box", size = Vector3.new(8, 8, 55), offset = 27 },
			blockBreak = true,
			pierce = true,
			rooted = true,
			ultimate = true,
		},
	},
}
]]></string>
          </Properties>
        </Item>
      </Item>
    </Item>
  </Item>
  <Item class="ServerScriptService" referent="19">
    <Properties>
      <string name="Name">ServerScriptService</string>
    </Properties>
    <Item class="Script" referent="20">
      <Properties>
        <string name="Name">Server</string>
        <token name="RunContext">0</token>
        <string name="Source"><![CDATA[--!strict

local CombatService = require(script.CombatService)
local TrainingDummies = require(script.TrainingDummies)

CombatService.start()
TrainingDummies.start()
]]></string>
      </Properties>
      <Item class="ModuleScript" referent="21">
        <Properties>
          <string name="Name">CombatService</string>
          <string name="Source"><![CDATA[--!strict
-- Server authority for every power. The client only ever asks; nothing here
-- trusts a number that came off the wire.

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

local Shared = ReplicatedStorage:WaitForChild("Shared")
local Powers = require(Shared.Powers)
local Cooldown = require(Shared.Cooldown)
local Hitbox = require(Shared.Hitbox)
local Net = require(Shared.Net)

local Ragdoll = require(script.Parent.Ragdoll)
local MoveRunner = require(script.Parent.MoveRunner)

local Universal = Powers.Universal
local StaminaCfg = Universal.Stamina

local CombatService = {}

local fighters: { [Player]: any } = {}
local ragdollUntil: { [Model]: number } = {}

--------------------------------------------------------------------------------
-- fighter lifecycle
--------------------------------------------------------------------------------

local function newFighter(player: Player)
	local cooldowns = Cooldown.new()
	Cooldown.defineCharges(cooldowns, "Dash", Universal.Dash.charges, Universal.Dash.rechargePerCharge)

	return {
		player = player,
		characterId = Powers.DefaultCharacter,

		cooldowns = cooldowns,

		block = {
			meter = Universal.Block.maxMeter,
			holding = false,
			lastDrainAt = 0,
			broken = false,
		},

		stamina = {
			value = StaminaCfg.max,
			lastUseAt = 0,
			sprinting = false,
		},

		stunUntil = 0,
		iframesUntil = 0,
		slowUntil = 0,
		slowMultiplier = 1,

		busy = false,
		activeMove = nil,
		comboIndex = 0,
		comboExpiresAt = 0,

		counter = nil,
		barrier = nil,
		charging = nil,
		chargeReleased = false,
	}
end

function CombatService.getFighter(player: Player)
	return fighters[player]
end

local function fighterForModel(model: Model)
	local player = Players:GetPlayerFromCharacter(model)
	return if player then fighters[player] else nil
end

--------------------------------------------------------------------------------
-- status
--------------------------------------------------------------------------------

local function isRagdolled(model: Model): boolean
	local at = ragdollUntil[model]
	return at ~= nil and os.clock() < at
end

-- True when the fighter has lost control and any in-flight move should abort.
function CombatService.interrupted(fighter): boolean
	local character = fighter.player.Character
	if not character or not character.Parent then
		return true
	end

	local humanoid = character:FindFirstChildOfClass("Humanoid")
	if not humanoid or humanoid.Health <= 0 then
		return true
	end

	return os.clock() < fighter.stunUntil or isRagdolled(character)
end

local function applyRagdoll(model: Model, duration: number, victim)
	local now = os.clock()
	local until_ = math.max(ragdollUntil[model] or 0, now + duration)
	ragdollUntil[model] = until_

	-- Mashing get-up can only ever shave off (1 - floor) of the knockdown.
	if victim then
		victim.ragdollFloor = math.max(victim.ragdollFloor or 0, now + duration * Universal.Getup.floor)
	end

	Ragdoll.enable(model)

	task.delay(duration, function()
		-- Getup mashing and later hits both move the deadline, so re-check
		-- rather than assuming this timer owns the recovery.
		local current = ragdollUntil[model]
		if not current then
			return
		end
		local left = current - os.clock()
		if left > 0.02 then
			task.delay(left, function()
				if ragdollUntil[model] and os.clock() >= ragdollUntil[model] then
					ragdollUntil[model] = nil
					Ragdoll.disable(model)
				end
			end)
			return
		end
		ragdollUntil[model] = nil
		Ragdoll.disable(model)
	end)
end

-- Bumped per slow so a later, weaker slow expiring cannot clear a stronger one
-- that landed after it.
local slowTokens: { [Model]: number } = {}

local function applySlow(model: Model, victim, spec)
	-- Only players have a base walk speed worth scaling; dummies stand still.
	if not victim then
		return
	end

	local now = os.clock()
	-- A stronger slow always wins; an equal-or-weaker one only extends time.
	if now < victim.slowUntil and victim.slowMultiplier < spec.multiplier then
		victim.slowUntil = math.max(victim.slowUntil, now + spec.duration)
		return
	end

	victim.slowMultiplier = spec.multiplier
	victim.slowUntil = math.max(victim.slowUntil, now + spec.duration)

	local token = (slowTokens[model] or 0) + 1
	slowTokens[model] = token

	task.delay(spec.duration, function()
		if slowTokens[model] ~= token then
			return
		end
		if os.clock() >= victim.slowUntil then
			victim.slowUntil = 0
			victim.slowMultiplier = 1
		end
	end)
end

local function applyBurn(attacker, model: Model, burn)
	task.spawn(function()
		for _ = 1, burn.ticks do
			task.wait(burn.tickInterval)
			local humanoid = model:FindFirstChildOfClass("Humanoid")
			if not humanoid or humanoid.Health <= 0 then
				return
			end
			humanoid:TakeDamage(burn.damagePerTick)
			Net.get("Effect"):FireAllClients("Burn", {
				target = model,
				source = attacker.player,
				characterId = attacker.characterId,
			})
		end
	end)
end

--------------------------------------------------------------------------------
-- the one place damage happens
--------------------------------------------------------------------------------

--[[
	Returns true if the hit connected in a way the attacker should see as a hit.
	Blocks, counters, barriers, and i-frames all return false.
]]
function CombatService.applyHit(
	attacker,
	targetModel: Model,
	move,
	damage: number,
	knockback,
	isFinisher,
	opts
)
	local humanoid = targetModel:FindFirstChildOfClass("Humanoid")
	if not humanoid or humanoid.Health <= 0 then
		return false
	end

	local attackerCharacter = attacker.player.Character
	if not attackerCharacter then
		return false
	end
	local attackerRoot = attackerCharacter:FindFirstChild("HumanoidRootPart")
	if not attackerRoot or not attackerRoot:IsA("BasePart") then
		return false
	end

	local targetRoot = targetModel:FindFirstChild("HumanoidRootPart")
	local defender = fighterForModel(targetModel)
	local effects = Net.get("Effect")
	local now = os.clock()

	-- i-frames
	if defender and now < defender.iframesUntil and not move.uncounterable then
		effects:FireAllClients("Dodge", { target = targetModel })
		return false
	end

	-- counter stance: send it back at the attacker
	if defender and defender.counter and now < defender.counter.expiresAt and not move.uncounterable then
		local counterMove = defender.counter.move
		local spec = counterMove.counter
		defender.counter.triggered = true

		local reflected = math.max(damage * spec.reflectMultiplier, spec.minimumDamage)
		local attackerHumanoid = attackerCharacter:FindFirstChildOfClass("Humanoid")
		if attackerHumanoid then
			attackerHumanoid:TakeDamage(reflected)
		end

		if targetRoot and targetRoot:IsA("BasePart") then
			attackerRoot.AssemblyLinearVelocity =
				Hitbox.knockbackVector(spec.knockback, targetRoot.CFrame, attackerRoot.Position)
		end
		applyRagdoll(attackerCharacter, spec.ragdoll, attacker)

		effects:FireAllClients("Counter", {
			by = defender.player,
			against = attacker.player,
			move = counterMove.id,
			damage = reflected,
		})
		return false
	end

	-- psychic barrier: soak, optionally kick some back
	if defender and defender.barrier and now < defender.barrier.expiresAt then
		local barrier = defender.barrier
		local spec = barrier.move.barrier
		barrier.remaining -= damage

		if spec.reflectFraction and spec.reflectFraction > 0 then
			local attackerHumanoid = attackerCharacter:FindFirstChildOfClass("Humanoid")
			if attackerHumanoid then
				attackerHumanoid:TakeDamage(damage * spec.reflectFraction)
			end
		end

		if barrier.remaining <= 0 then
			defender.barrier = nil
			effects:FireAllClients("BarrierPop", { user = defender.player })

			if targetRoot and targetRoot:IsA("BasePart") then
				local blown = Hitbox.query(
					targetRoot.CFrame,
					{ shape = "Sphere", radius = spec.popRadius, offset = 0 },
					targetModel
				)
				for _, model in blown do
					local root = model:FindFirstChild("HumanoidRootPart")
					if root and root:IsA("BasePart") then
						root.AssemblyLinearVelocity =
							Hitbox.knockbackVector(spec.popKnockback, targetRoot.CFrame, root.Position)
					end
				end
			end
		else
			effects:FireAllClients("BarrierHit", { user = defender.player, remaining = barrier.remaining })
		end
		return false
	end

	-- block
	local finalDamage = damage
	local blocked = false

	if defender and defender.block.holding and not defender.block.broken then
		local blockCfg = Universal.Block
		local drain = damage * blockCfg.meterPerDamage
		if move.blockBreak then
			-- Block-break moves shred the meter instead of being chipped down.
			drain *= 2.5
		else
			finalDamage = damage * blockCfg.chipMultiplier
			blocked = true
		end

		defender.block.meter -= drain
		defender.block.lastDrainAt = now

		if defender.block.meter <= 0 then
			defender.block.meter = 0
			defender.block.broken = true
			defender.block.holding = false
			defender.stunUntil = now + blockCfg.breakStun
			blocked = false
			finalDamage = damage
			effects:FireAllClients("BlockBreak", { target = targetModel })
		elseif blocked then
			effects:FireAllClients("Blocked", { target = targetModel })
		end
	end

	humanoid:TakeDamage(finalDamage)

	if blocked then
		return false
	end

	-- knockback
	if knockback and targetRoot and targetRoot:IsA("BasePart") then
		targetRoot.AssemblyLinearVelocity =
			Hitbox.knockbackVector(knockback, attackerRoot.CFrame, targetRoot.Position)
	end

	-- stun vs ragdoll: a ragdoll supersedes a stun, and barrages only ragdoll
	-- on the finisher so the victim stays in the string.
	local stunOnly = opts and opts.stunOnly
	if stunOnly then
		if defender then
			defender.stunUntil = math.max(defender.stunUntil, now + stunOnly)
		end
	elseif move.ragdoll and (isFinisher or move.kind ~= "Barrage") then
		applyRagdoll(targetModel, move.ragdoll, defender)
		if defender then
			defender.busy = false
			defender.charging = nil
			defender.counter = nil
		end
	elseif move.stun then
		if defender then
			defender.stunUntil = math.max(defender.stunUntil, now + move.stun)
		end
	end

	if move.burn then
		applyBurn(attacker, targetModel, move.burn)
	end

	if move.slow then
		applySlow(targetModel, defender, move.slow)
	end

	effects:FireAllClients("Damage", {
		target = targetModel,
		source = attacker.player,
		amount = finalDamage,
		move = move.id,
	})

	return true
end

--------------------------------------------------------------------------------
-- input handling
--------------------------------------------------------------------------------

local function canAct(fighter): boolean
	local character = fighter.player.Character
	if not character then
		return false
	end
	local humanoid = character:FindFirstChildOfClass("Humanoid")
	if not humanoid or humanoid.Health <= 0 then
		return false
	end
	if os.clock() < fighter.stunUntil or isRagdolled(character) then
		return false
	end
	return true
end

local function runMove(fighter, move)
	fighter.busy = true
	fighter.activeMove = move.id

	local ok, result = pcall(MoveRunner.run, fighter, move)
	if not ok then
		warn(`[SBG] move {move.id} errored: {result}`)
	end

	fighter.busy = false
	fighter.activeMove = nil

	-- Releases any held animation pose (charge, counter stance, barrier).
	Net.get("Effect"):FireAllClients("MoveEnd", { user = fighter.player })

	-- Counter reports whether it caught anything; tryMove uses that to pick
	-- which cooldown to charge.
	return if ok then result else nil
end

local function tryMove(fighter, slot: number)
	if not canAct(fighter) or fighter.busy then
		return
	end

	local move = Powers.getMoveBySlot(fighter.characterId, slot)
	if not move then
		return
	end

	if not Cooldown.ready(fighter.cooldowns, move.id) then
		return
	end

	-- Charge moves start their cooldown on release, but every other kind
	-- commits the moment it starts so nothing can be double-fired.
	Cooldown.start(fighter.cooldowns, move.id, move.cooldown)

	if move.kind == "Charge" then
		fighter.chargeReleased = false
		fighter.charging = { move = move, startedAt = os.clock() }
	end

	task.spawn(function()
		local result = runMove(fighter, move)
		fighter.charging = nil

		-- A whiffed counter pays a different (usually equal) price.
		if move.kind == "Counter" and result == false and move.whiffCooldown then
			Cooldown.start(fighter.cooldowns, move.id, move.whiffCooldown)
		end
	end)
end

local function tryM1(fighter)
	if not canAct(fighter) or fighter.busy then
		return
	end

	local m1 = Universal.M1
	local now = os.clock()

	if now > fighter.comboExpiresAt then
		fighter.comboIndex = 0
	end
	fighter.comboIndex = (fighter.comboIndex % #m1.chain) + 1
	fighter.comboExpiresAt = now + m1.comboWindow

	local link = m1.chain[fighter.comboIndex]
	-- Synthesise a one-off move definition for this link so applyHit and the
	-- HUD see the same shape as any other move.
	local move = {
		id = m1.id,
		kind = "Strike",
		damage = link.damage,
		windup = link.windup,
		active = link.active,
		endlag = link.endlag,
		stun = link.stun,
		ragdoll = link.ragdoll,
		knockback = link.knockback,
		hitbox = m1.hitbox,
		rooted = false,
		moveSpeedMultiplier = 0.6,
	}

	task.spawn(runMove, fighter, move)
end

local function tryDash(fighter, direction: Vector3?)
	if not canAct(fighter) then
		return
	end

	local dashCfg = Universal.Dash
	if fighter.stamina.value < StaminaCfg.dashCost then
		return
	end
	if not Cooldown.consumeCharge(fighter.cooldowns, "Dash") then
		return
	end

	fighter.stamina.value -= StaminaCfg.dashCost
	fighter.stamina.lastUseAt = os.clock()

	local character = fighter.player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	if not root or not root:IsA("BasePart") then
		return
	end

	local aim = direction
	if not aim or aim.Magnitude < 0.05 then
		aim = root.CFrame.LookVector
	else
		aim = aim.Unit
	end

	fighter.iframesUntil = os.clock() + dashCfg.iframes
	Net.get("Effect"):FireAllClients("Dash", { user = fighter.player })

	task.spawn(function()
		local speed = dashCfg.distance / dashCfg.duration
		local elapsed = 0
		while elapsed < dashCfg.duration do
			local currentRoot = character and character:FindFirstChild("HumanoidRootPart")
			if not currentRoot or not currentRoot:IsA("BasePart") then
				break
			end
			currentRoot.AssemblyLinearVelocity = (aim :: Vector3) * speed + Vector3.new(0, 6, 0)
			elapsed += task.wait()
		end
	end)
end

local function tryGetup(fighter)
	local character = fighter.player.Character
	if not character or not isRagdolled(character) then
		return
	end

	local getupCfg = Universal.Getup
	local now = os.clock()
	if now - (fighter.lastGetupAt or 0) < getupCfg.pressCooldown then
		return
	end
	fighter.lastGetupAt = now

	local until_ = ragdollUntil[character]
	if not until_ then
		return
	end

	-- Never let mashing skip more than (1 - floor) of the original knockdown.
	local floorAt = fighter.ragdollFloor or 0
	ragdollUntil[character] = math.max(floorAt, until_ - getupCfg.reductionPerPress)
end

local function setBlocking(fighter, holding: boolean)
	if holding and (not canAct(fighter) or fighter.block.broken) then
		return
	end
	fighter.block.holding = holding
	if not holding then
		fighter.block.lastDrainAt = os.clock()
	end
end

local function setSprinting(fighter, sprinting: boolean)
	fighter.stamina.sprinting = sprinting

	-- WalkSpeed itself is applied by step(), which reconciles sprint, stamina,
	-- and slow together.
end

--------------------------------------------------------------------------------
-- regen + state replication
--------------------------------------------------------------------------------

local function snapshot(fighter)
	local character = fighter.player.Character
	local humanoid = character and character:FindFirstChildOfClass("Humanoid")
	local definition = Powers.getCharacter(fighter.characterId)

	local cooldowns = {}
	for _, move in definition.moves do
		cooldowns[move.id] = Cooldown.remaining(fighter.cooldowns, move.id)
	end

	local now = os.clock()
	local charging = nil
	if fighter.charging then
		local move = fighter.charging.move
		local held = now - fighter.charging.startedAt
		charging = {
			moveId = move.id,
			ratio = math.clamp(
				(held - move.charge.min) / math.max(move.charge.max - move.charge.min, 0.001),
				0,
				1
			),
		}
	end

	return {
		characterId = fighter.characterId,
		health = if humanoid then humanoid.Health else 0,
		maxHealth = if humanoid then humanoid.MaxHealth else definition.maxHealth,
		block = {
			meter = fighter.block.meter,
			max = Universal.Block.maxMeter,
			holding = fighter.block.holding,
			broken = fighter.block.broken,
		},
		stamina = { value = fighter.stamina.value, max = StaminaCfg.max },
		dash = {
			count = Cooldown.chargeCount(fighter.cooldowns, "Dash"),
			max = Universal.Dash.charges,
			progress = Cooldown.chargeProgress(fighter.cooldowns, "Dash"),
		},
		cooldowns = cooldowns,
		stun = math.max(0, fighter.stunUntil - now),
		ragdoll = if character and ragdollUntil[character]
			then math.max(0, ragdollUntil[character] - now)
			else 0,
		busy = fighter.busy,
		charging = charging,
		barrier = if fighter.barrier and now < fighter.barrier.expiresAt
			then fighter.barrier.remaining
			else nil,
	}
end

local function step(dt: number)
	local now = os.clock()
	local blockCfg = Universal.Block
	local sprintCfg = Universal.Sprint

	for player, fighter in fighters do
		-- block meter
		if not fighter.block.holding and now - fighter.block.lastDrainAt > blockCfg.regenDelay then
			fighter.block.meter =
				math.min(blockCfg.maxMeter, fighter.block.meter + blockCfg.regenPerSecond * dt)
			if fighter.block.broken and fighter.block.meter >= blockCfg.maxMeter * 0.5 then
				fighter.block.broken = false
			end
		end

		-- stamina
		local character = player.Character
		local humanoid = character and character:FindFirstChildOfClass("Humanoid")
		local moving = humanoid ~= nil and humanoid.MoveDirection.Magnitude > 0.1

		if fighter.stamina.sprinting and moving and fighter.stamina.value > 0 then
			fighter.stamina.value = math.max(0, fighter.stamina.value - sprintCfg.staminaPerSecond * dt)
			fighter.stamina.lastUseAt = now
		elseif now - fighter.stamina.lastUseAt > StaminaCfg.regenDelay then
			fighter.stamina.value =
				math.min(StaminaCfg.max, fighter.stamina.value + StaminaCfg.regenPerSecond * dt)
		end

		-- One place owns WalkSpeed whenever a move is not deliberately holding
		-- the player still. Sprinting, running dry, and Frost's slow all just
		-- feed into this, instead of each poking WalkSpeed and fighting.
		if humanoid and not fighter.busy then
			local target = Powers.getCharacter(fighter.characterId).walkSpeed
			if fighter.stamina.sprinting and fighter.stamina.value > 1 then
				target = sprintCfg.sprintSpeed
			end
			if now < fighter.slowUntil then
				target *= fighter.slowMultiplier
			end
			if math.abs(humanoid.WalkSpeed - target) > 0.01 then
				humanoid.WalkSpeed = target
			end
		end
	end
end

--------------------------------------------------------------------------------
-- wiring
--------------------------------------------------------------------------------

local ACTIONS = {
	Move = function(fighter, payload)
		local slot = tonumber(payload and payload.slot)
		if slot and slot >= 1 and slot <= 4 then
			tryMove(fighter, slot)
		end
	end,

	MoveRelease = function(fighter, payload)
		local slot = tonumber(payload and payload.slot)
		if fighter.charging and slot and fighter.charging.move.slot == slot then
			fighter.chargeReleased = true
		end
	end,

	M1 = function(fighter)
		tryM1(fighter)
	end,

	Block = function(fighter, payload)
		setBlocking(fighter, payload and payload.holding == true)
	end,

	Dash = function(fighter, payload)
		local direction = nil
		if payload and typeof(payload.direction) == "Vector3" then
			direction = payload.direction
		end
		tryDash(fighter, direction)
	end,

	Sprint = function(fighter, payload)
		setSprinting(fighter, payload and payload.holding == true)
	end,

	Getup = function(fighter)
		tryGetup(fighter)
	end,
}

local function onCharacterAdded(player: Player, character: Model)
	local fighter = fighters[player]
	if not fighter then
		return
	end

	-- Reset everything a fresh body should not inherit.
	fighter.busy = false
	fighter.activeMove = nil
	fighter.counter = nil
	fighter.barrier = nil
	fighter.charging = nil
	fighter.stunUntil = 0
	fighter.iframesUntil = 0
	fighter.slowUntil = 0
	fighter.slowMultiplier = 1
	fighter.block.meter = Universal.Block.maxMeter
	fighter.block.broken = false
	fighter.block.holding = false
	fighter.stamina.value = StaminaCfg.max
	ragdollUntil[character] = nil

	local definition = Powers.getCharacter(fighter.characterId)
	local humanoid = character:WaitForChild("Humanoid") :: Humanoid
	humanoid.MaxHealth = definition.maxHealth
	humanoid.Health = definition.maxHealth
	humanoid.WalkSpeed = definition.walkSpeed

	humanoid.Died:Connect(function()
		ragdollUntil[character] = nil
		fighter.busy = false
	end)
end

function CombatService.start()
	Net.init()
	MoveRunner.init(CombatService)

	local function bind(player: Player)
		fighters[player] = newFighter(player)
		player.CharacterAdded:Connect(function(character)
			onCharacterAdded(player, character)
		end)
		if player.Character then
			onCharacterAdded(player, player.Character)
		end
	end

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

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

	Net.get("Action").OnServerEvent:Connect(function(player, action, payload)
		local fighter = fighters[player]
		if not fighter or typeof(action) ~= "string" then
			return
		end
		local handler = ACTIONS[action]
		if handler then
			handler(fighter, payload)
		end
	end)

	Net.get("SelectCharacter").OnServerEvent:Connect(function(player, characterId)
		local fighter = fighters[player]
		if not fighter or typeof(characterId) ~= "string" then
			return
		end
		if not Powers.isValidCharacter(characterId) or fighter.busy then
			return
		end

		fighter.characterId = characterId
		fighter.cooldowns = Cooldown.new()
		Cooldown.defineCharges(
			fighter.cooldowns,
			"Dash",
			Universal.Dash.charges,
			Universal.Dash.rechargePerCharge
		)

		local character = player.Character
		local humanoid = character and character:FindFirstChildOfClass("Humanoid")
		if humanoid then
			local definition = Powers.getCharacter(characterId)
			humanoid.MaxHealth = definition.maxHealth
			humanoid.Health = definition.maxHealth
			humanoid.WalkSpeed = definition.walkSpeed
		end
	end)

	RunService.Heartbeat:Connect(step)

	-- Replicate at a fixed rate; the HUD ticks cooldowns locally between frames.
	task.spawn(function()
		local state = Net.get("State")
		while true do
			for player, fighter in fighters do
				if player.Parent then
					state:FireClient(player, snapshot(fighter))
				end
			end
			task.wait(1 / 12)
		end
	end)
end

return CombatService
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="22">
        <Properties>
          <string name="Name">MoveRunner</string>
          <string name="Source"><![CDATA[--!strict
-- Executes a move from its data definition. Every branch here maps to one
-- `kind` in the power schema; nothing is hardcoded per character.

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

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

local MoveRunner = {}

-- Injected by CombatService to break the require cycle.
local combat

function MoveRunner.init(combatService)
	combat = combatService
end

local function emit(name: string, payload)
	Net.get("Effect"):FireAllClients(name, payload)
end

-- How long the move actually takes end to end. Sent with the start event so the
-- client can stretch the animation clip to match, which keeps the pose in sync
-- with the hitbox no matter how the timings get tuned.
local function moveDuration(move): number
	local total = (move.windup or 0) + (move.endlag or 0)

	if move.kind == "Barrage" then
		total += (move.hits or 1) * (move.hitInterval or 0.1)
	elseif move.kind == "Sustained" then
		total += move.sustained.duration
	else
		total += move.active or 0.15
	end

	return total
end

local function startPayload(fighter, move)
	return {
		user = fighter.player,
		characterId = fighter.characterId,
		move = move.id,
		animation = move.animation,
		mirror = move.mirror,
		duration = moveDuration(move),
	}
end

local function rootOf(fighter): BasePart?
	local character = fighter.player.Character
	if not character then
		return nil
	end
	local root = character:FindFirstChild("HumanoidRootPart")
	return if root and root:IsA("BasePart") then root else nil
end

local function humanoidOf(fighter): Humanoid?
	local character = fighter.player.Character
	if not character then
		return nil
	end
	return character:FindFirstChildOfClass("Humanoid")
end

-- Holds the caster still (or slows them) for the duration of a move, then puts
-- their speed back exactly as it was.
local function lock(fighter, move)
	local humanoid = humanoidOf(fighter)
	if not humanoid then
		return function() end
	end

	local originalWalk = humanoid.WalkSpeed
	local originalJump = humanoid.JumpPower
	local originalAuto = humanoid.AutoRotate

	if move.rooted then
		humanoid.WalkSpeed = 0
		humanoid.JumpPower = 0
	elseif move.moveSpeedMultiplier then
		humanoid.WalkSpeed = originalWalk * move.moveSpeedMultiplier
	end

	return function()
		if humanoid.Parent then
			humanoid.WalkSpeed = originalWalk
			humanoid.JumpPower = originalJump
			humanoid.AutoRotate = originalAuto
		end
	end
end

local function strike(fighter, move, damageScale: number?)
	local root = rootOf(fighter)
	if not root then
		return
	end

	local scale = damageScale or 1
	local hits = Hitbox.query(root.CFrame, move.hitbox, fighter.player.Character)
	local landed = 0

	for _, target in hits do
		if not move.pierce and landed >= 1 then
			break
		end
		if combat.applyHit(fighter, target, move, move.damage * scale, move.knockback) then
			landed += 1
		end
	end

	if landed > 0 then
		emit("Hit", { position = root.Position, characterId = fighter.characterId, move = move.id })
	end
	return landed
end

local KINDS = {}

function KINDS.Strike(fighter, move)
	local release = lock(fighter, move)
	emit("MoveStart", startPayload(fighter, move))

	task.wait(move.windup)
	if combat.interrupted(fighter) then
		release()
		return
	end

	local elapsed = 0
	local active = move.active or 0.15
	-- Sample across the active window so fast movers cannot walk through it.
	while elapsed < active do
		if (strike(fighter, move) or 0) > 0 then
			break
		end
		elapsed += task.wait()
	end

	task.wait(move.endlag or 0)
	release()
end

function KINDS.Barrage(fighter, move)
	local release = lock(fighter, move)
	emit("MoveStart", startPayload(fighter, move))

	task.wait(move.windup)

	for index = 1, move.hits do
		if combat.interrupted(fighter) then
			break
		end

		local isLast = index == move.hits
		local root = rootOf(fighter)
		if not root then
			break
		end

		local damage = move.damage
		local knockback = move.knockback
		if isLast and move.finisher then
			damage = move.finisher.damage
			knockback = move.finisher.knockback
		end

		local targets = Hitbox.query(root.CFrame, move.hitbox, fighter.player.Character)
		for _, target in targets do
			combat.applyHit(fighter, target, move, damage, knockback, isLast)
			if not move.pierce then
				break
			end
		end

		emit("Hit", {
			position = root.Position,
			characterId = fighter.characterId,
			move = move.id,
			tick = index,
			-- The last tick of a barrage is the finisher, so it gets the full
			-- impact treatment instead of another small tick pop.
			heavy = isLast,
		})
		task.wait(move.hitInterval)
	end

	task.wait(move.endlag or 0)
	release()
end

function KINDS.Charge(fighter, move)
	local release = lock(fighter, move)
	-- The charge pose is held until release, so it needs no duration.
	emit("ChargeStart", {
		user = fighter.player,
		characterId = fighter.characterId,
		move = move.id,
		animation = move.chargeAnimation or "ChargeUp",
	})

	local startedAt = os.clock()
	local minHold, maxHold = move.charge.min, move.charge.max

	-- Poll for release. `chargeReleased` is set by CombatService when the
	-- client sends the key-up, and we hard-stop at maxHold either way.
	while true do
		local held = os.clock() - startedAt
		if held >= maxHold then
			break
		end
		if fighter.chargeReleased and held >= minHold then
			break
		end
		if combat.interrupted(fighter) then
			fighter.chargeReleased = false
			release()
			emit("ChargeCancel", { user = fighter.player, characterId = fighter.characterId, move = move.id })
			return
		end

		-- Gravity Crush and friends drag targets in while charging.
		if move.pull then
			local root = rootOf(fighter)
			if root then
				local nearby = Hitbox.query(
					root.CFrame,
					{ shape = "Sphere", radius = move.pull.radius, offset = 0 },
					fighter.player.Character
				)
				for _, target in nearby do
					local targetRoot = target:FindFirstChild("HumanoidRootPart")
					if targetRoot and targetRoot:IsA("BasePart") then
						local toCaster = root.Position - targetRoot.Position
						if toCaster.Magnitude > 6 then
							targetRoot.AssemblyLinearVelocity = toCaster.Unit * move.pull.power
						end
					end
				end
			end
		end

		task.wait()
	end

	local held = math.clamp(os.clock() - startedAt, minHold, maxHold)
	local ratio = (held - minHold) / math.max(maxHold - minHold, 0.001)
	local damage = move.damage + (move.maxDamage - move.damage) * ratio

	fighter.chargeReleased = false
	local releasePayload = startPayload(fighter, move)
	releasePayload.ratio = ratio
	emit("ChargeRelease", releasePayload)

	task.wait(move.windup)
	if not combat.interrupted(fighter) then
		local root = rootOf(fighter)
		if root then
			local targets = Hitbox.query(root.CFrame, move.hitbox, fighter.player.Character)
			for _, target in targets do
				combat.applyHit(fighter, target, move, damage, move.knockback, true)
				if not move.pierce then
					break
				end
			end
			emit("Hit", {
				position = root.Position,
				characterId = fighter.characterId,
				move = move.id,
				heavy = true,
			})
		end
	end

	task.wait(move.endlag or 0)
	release()
end

function KINDS.Counter(fighter, move)
	local release = lock(fighter, move)
	-- Held until the window closes or something lands on it.
	emit("CounterStance", {
		user = fighter.player,
		characterId = fighter.characterId,
		move = move.id,
		animation = move.animation,
	})

	fighter.counter = {
		move = move,
		expiresAt = os.clock() + move.counter.window,
		triggered = false,
	}

	local deadline = os.clock() + move.counter.window
	while os.clock() < deadline do
		if fighter.counter and fighter.counter.triggered then
			break
		end
		task.wait()
	end

	local triggered = fighter.counter ~= nil and fighter.counter.triggered
	fighter.counter = nil

	if not triggered then
		emit("CounterWhiff", { user = fighter.player, characterId = fighter.characterId, move = move.id })
	end

	task.wait(move.endlag or 0)
	release()
	return triggered
end

function KINDS.Dash(fighter, move)
	local release = lock(fighter, move)
	emit("MoveStart", startPayload(fighter, move))

	task.wait(move.windup)
	if combat.interrupted(fighter) then
		release()
		return
	end

	local root = rootOf(fighter)
	if not root then
		release()
		return
	end

	local direction = root.CFrame.LookVector
	local travelTime = move.dash.distance / move.dash.speed
	local velocity = direction * move.dash.speed + Vector3.new(0, move.dash.lift or 0, 0)

	local elapsed = 0
	local alreadyHit: { [Model]: boolean } = {}

	while elapsed < travelTime do
		local currentRoot = rootOf(fighter)
		if not currentRoot or combat.interrupted(fighter) then
			break
		end
		currentRoot.AssemblyLinearVelocity = velocity

		local targets = Hitbox.query(currentRoot.CFrame, move.hitbox, fighter.player.Character)
		for _, target in targets do
			if alreadyHit[target] then
				continue
			end
			alreadyHit[target] = true
			combat.applyHit(fighter, target, move, move.damage, move.knockback, true)
			emit("Hit", {
				position = currentRoot.Position,
				characterId = fighter.characterId,
				move = move.id,
			})

			-- Blade Rush cuts through and keeps going; everything else stops.
			if not move.dash.passThrough then
				elapsed = travelTime
			end
		end

		elapsed += task.wait()
	end

	task.wait(move.endlag or 0)
	release()
end

function KINDS.Projectile(fighter, move)
	local release = lock(fighter, move)
	emit("MoveStart", startPayload(fighter, move))

	task.wait(move.windup)

	local root = rootOf(fighter)
	if not root or combat.interrupted(fighter) then
		release()
		return
	end

	local spec = move.projectile
	local origin = root.CFrame

	-- Projectiles wear the caster's colours.
	local definition = Powers.getCharacter(fighter.characterId)
	local primary = if definition then definition.color else Color3.fromRGB(240, 240, 255)
	local secondary = if definition then definition.accent or primary else primary

	for index = 1, spec.count do
		-- Fan the volley evenly around the aim line.
		local offset = 0
		if spec.count > 1 then
			offset = ((index - 1) / (spec.count - 1) - 0.5) * spec.spread * 2
		end
		local aim = origin * CFrame.Angles(0, math.rad(-offset), 0)

		task.spawn(function()
			local part = Instance.new("Part")
			part.Name = `{move.id}_Projectile`
			part.Size = spec.size
			part.CFrame = aim * CFrame.new(0, 1, -3)
			part.CanCollide = false
			part.CanQuery = false
			part.CanTouch = false
			part.CastShadow = false
			part.Anchored = true
			part.Material = Enum.Material.Neon
			part.Color = primary
			part.Parent = workspace
			Debris:AddItem(part, spec.lifetime + 0.1)

			-- Dressed here rather than broadcast, so a five-shard volley costs
			-- one replicated part each instead of five extra events.
			local light = Instance.new("PointLight")
			light.Color = primary
			light.Range = 14
			light.Brightness = 3
			light.Parent = part

			local a0 = Instance.new("Attachment")
			a0.Position = Vector3.new(0, 0, spec.size.Z * 0.5)
			a0.Parent = part
			local a1 = Instance.new("Attachment")
			a1.Position = Vector3.new(0, 0, -spec.size.Z * 0.5)
			a1.Parent = part

			local trail = Instance.new("Trail")
			trail.Attachment0 = a0
			trail.Attachment1 = a1
			trail.Color = ColorSequence.new(primary, secondary)
			trail.Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 0.1),
				NumberSequenceKeypoint.new(1, 1),
			})
			trail.Lifetime = 0.4
			trail.LightEmission = 1
			trail.LightInfluence = 0
			trail.WidthScale = NumberSequence.new(1.6, 0)
			trail.FaceCamera = true
			trail.Parent = part

			local velocity = aim.LookVector * spec.speed
			local elapsed = 0

			while elapsed < spec.lifetime do
				local dt = RunService.Heartbeat:Wait()
				elapsed += dt

				if spec.gravity and spec.gravity > 0 then
					velocity -= Vector3.new(0, spec.gravity * dt, 0)
				end
				part.CFrame = CFrame.new(part.Position + velocity * dt, part.Position + velocity)

				local struck = Hitbox.query(
					part.CFrame,
					{ shape = "Sphere", radius = 3, offset = 0 },
					fighter.player.Character
				)
				if #struck > 0 then
					combat.applyHit(fighter, struck[1], move, move.damage, move.knockback, true)

					if spec.explode then
						local splash = Hitbox.query(
							part.CFrame,
							{ shape = "Sphere", radius = spec.explode.radius, offset = 0 },
							fighter.player.Character
						)
						for _, extra in splash do
							if extra ~= struck[1] then
								combat.applyHit(fighter, extra, move, spec.explode.damage, move.knockback)
							end
						end
					end

					emit("Explode", {
						position = part.Position,
						characterId = fighter.characterId,
						move = move.id,
					})
					part:Destroy()
					return
				end
			end

			part:Destroy()
		end)

		task.wait(0.05)
	end

	task.wait(move.endlag or 0)
	release()
end

function KINDS.Teleport(fighter, move)
	local release = lock(fighter, move)
	emit("MoveStart", startPayload(fighter, move))

	task.wait(move.windup)

	local root = rootOf(fighter)
	if not root then
		release()
		return
	end

	local target = Hitbox.nearestTarget(root.CFrame, move.teleport.range, fighter.player.Character)
	if target then
		local targetRoot = target:FindFirstChild("HumanoidRootPart")
		if targetRoot and targetRoot:IsA("BasePart") then
			emit("Teleport", {
				user = fighter.player,
				characterId = fighter.characterId,
				from = root.Position,
				to = targetRoot.Position,
				move = move.id,
			})
			-- Land behind them, facing their back.
			local behind = targetRoot.CFrame * CFrame.new(0, 0, move.teleport.behindOffset)
			root.CFrame = CFrame.new(behind.Position, targetRoot.Position)
			fighter.iframesUntil = os.clock() + move.teleport.iframes
		end
	end

	task.wait(move.endlag or 0)
	release()
end

function KINDS.Grab(fighter, move)
	local release = lock(fighter, move)
	emit("MoveStart", startPayload(fighter, move))

	task.wait(move.windup)

	local root = rootOf(fighter)
	if not root or combat.interrupted(fighter) then
		release()
		return
	end

	local target = Hitbox.nearestTarget(root.CFrame, move.grab.range, fighter.player.Character)
	if not target then
		emit("GrabWhiff", { user = fighter.player, characterId = fighter.characterId, move = move.id })
		task.wait(move.endlag or 0)
		release()
		return
	end

	local targetRoot = target:FindFirstChild("HumanoidRootPart")
	if not targetRoot or not targetRoot:IsA("BasePart") then
		release()
		return
	end

	combat.applyHit(fighter, target, move, move.damage, nil, false, { stunOnly = move.grab.holdDuration })
	emit(
		"Grab",
		{ user = fighter.player, characterId = fighter.characterId, target = target, move = move.id }
	)

	-- Suspend them in front of the caster for the hold.
	local heldUntil = os.clock() + move.grab.holdDuration
	while os.clock() < heldUntil do
		local currentRoot = rootOf(fighter)
		if not currentRoot or combat.interrupted(fighter) then
			break
		end
		targetRoot.AssemblyLinearVelocity = Vector3.zero
		targetRoot.CFrame = currentRoot.CFrame * CFrame.new(0, 6, -12)
		task.wait()
	end

	combat.applyHit(fighter, target, move, move.grab.slamDamage, move.knockback, true)
	emit("Slam", {
		position = targetRoot.Position,
		characterId = fighter.characterId,
		move = move.id,
	})

	task.wait(move.endlag or 0)
	release()
end

function KINDS.Barrier(fighter, move)
	local release = lock(fighter, move)

	fighter.barrier = {
		move = move,
		remaining = move.barrier.absorb,
		expiresAt = os.clock() + move.barrier.duration,
	}
	emit("BarrierUp", {
		user = fighter.player,
		characterId = fighter.characterId,
		move = move.id,
		animation = move.animation,
	})

	task.wait(move.endlag or 0)
	release()

	-- Let it lapse on its own; applyHit pops it early if it runs out.
	task.delay(move.barrier.duration, function()
		if fighter.barrier and fighter.barrier.move == move then
			fighter.barrier = nil
			emit("BarrierDown", { user = fighter.player, characterId = fighter.characterId, move = move.id })
		end
	end)
end

function KINDS.Sustained(fighter, move)
	local release = lock(fighter, move)
	emit("MoveStart", startPayload(fighter, move))

	task.wait(move.windup)

	local deadline = os.clock() + move.sustained.duration
	while os.clock() < deadline do
		if combat.interrupted(fighter) then
			break
		end

		local root = rootOf(fighter)
		if not root then
			break
		end

		local targets = Hitbox.query(root.CFrame, move.hitbox, fighter.player.Character)
		for _, target in targets do
			combat.applyHit(fighter, target, move, move.damage, move.knockback)
		end

		emit("Sustain", {
			user = fighter.player,
			characterId = fighter.characterId,
			move = move.id,
			position = root.Position,
		})
		task.wait(move.sustained.tickInterval)
	end

	task.wait(move.endlag or 0)
	release()
end

function MoveRunner.run(fighter, move)
	local handler = KINDS[move.kind]
	if not handler then
		warn(`[SBG] no handler for move kind "{move.kind}" ({move.id})`)
		return
	end
	return handler(fighter, move)
end

return MoveRunner
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="23">
        <Properties>
          <string name="Name">Ragdoll</string>
          <string name="Source"><![CDATA[--!strict
-- Swaps the character's Motor6Ds for BallSocketConstraints so knockback
-- actually throws a body around instead of sliding a statue.

local Ragdoll = {}

local FOLDER_NAME = "RagdollJoints"

local NO_COLLIDE = {
	Head = true,
	HumanoidRootPart = true,
}

local function jointsOf(character: Model): { Motor6D }
	local out: { Motor6D } = {}
	for _, descendant in character:GetDescendants() do
		if descendant:IsA("Motor6D") and descendant.Name ~= "RootJoint" then
			table.insert(out, descendant)
		end
	end
	return out
end

local function buildSocket(motor: Motor6D, store: Folder)
	local part0, part1 = motor.Part0, motor.Part1
	if not part0 or not part1 then
		return
	end

	local a0 = Instance.new("Attachment")
	a0.Name = `{motor.Name}_A0`
	a0.CFrame = motor.C0
	a0.Parent = part0

	local a1 = Instance.new("Attachment")
	a1.Name = `{motor.Name}_A1`
	a1.CFrame = motor.C1
	a1.Parent = part1

	local socket = Instance.new("BallSocketConstraint")
	socket.Name = motor.Name
	socket.Attachment0 = a0
	socket.Attachment1 = a1
	socket.LimitsEnabled = true
	socket.TwistLimitsEnabled = true
	socket.UpperAngle = 45
	socket.TwistLowerAngle = -45
	socket.TwistUpperAngle = 45

	-- Keep a pointer back to the motor so disable() can flip it on again.
	local link = Instance.new("ObjectValue")
	link.Name = "Motor"
	link.Value = motor
	link.Parent = socket

	-- Lives in the store, not on the limb, so disable() can find every socket
	-- in one place.
	socket.Parent = store

	motor.Enabled = false
end

function Ragdoll.isRagdolled(character: Model): boolean
	return character:FindFirstChild(FOLDER_NAME) ~= nil
end

function Ragdoll.enable(character: Model)
	if Ragdoll.isRagdolled(character) then
		return
	end

	local humanoid = character:FindFirstChildOfClass("Humanoid")
	if not humanoid or humanoid.Health <= 0 then
		return
	end

	local store = Instance.new("Folder")
	store.Name = FOLDER_NAME
	store.Parent = character

	humanoid:ChangeState(Enum.HumanoidStateType.Physics)
	humanoid.PlatformStand = true
	humanoid.AutoRotate = false

	for _, motor in jointsOf(character) do
		buildSocket(motor, store)
	end

	for _, part in character:GetChildren() do
		if part:IsA("BasePart") and NO_COLLIDE[part.Name] then
			part.CanCollide = false
		end
	end
end

function Ragdoll.disable(character: Model)
	local store = character:FindFirstChild(FOLDER_NAME)
	if not store then
		return
	end

	for _, socket in store:GetChildren() do
		local link = socket:FindFirstChild("Motor")
		if link and link:IsA("ObjectValue") and link.Value then
			local motor = link.Value :: Motor6D
			motor.Enabled = true
		end
	end

	-- Attachments live on the limbs, not in the store, so sweep them separately.
	for _, descendant in character:GetDescendants() do
		if
			descendant:IsA("Attachment") and (descendant.Name:match("_A0$") or descendant.Name:match("_A1$"))
		then
			descendant:Destroy()
		end
	end

	store:Destroy()

	local humanoid = character:FindFirstChildOfClass("Humanoid")
	if humanoid then
		humanoid.PlatformStand = false
		humanoid.AutoRotate = true
		humanoid:ChangeState(Enum.HumanoidStateType.GettingUp)
	end

	for _, part in character:GetChildren() do
		if part:IsA("BasePart") and NO_COLLIDE[part.Name] then
			part.CanCollide = part.Name ~= "HumanoidRootPart"
		end
	end
end

return Ragdoll
]]></string>
        </Properties>
      </Item>
      <Item class="ModuleScript" referent="24">
        <Properties>
          <string name="Name">TrainingDummies</string>
          <string name="Source"><![CDATA[--!strict
-- Something to hit. Builds R6 rigs in code rather than importing a model, so
-- the project still needs no asset IDs.
--
-- The combat code needs nothing special from these: anything that is a Model
-- with a Humanoid and a HumanoidRootPart is a valid target, and the Motor6Ds
-- built here are what Ragdoll swaps for BallSocketConstraints.

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

local Ragdoll = require(script.Parent.Ragdoll)

local TrainingDummies = {}

local CONFIG = {
	count = 3,
	-- Laid out in an arc this far in front of the spawn point.
	distance = 24,
	spacing = 14,

	health = 250,
	respawnDelay = 3.5,

	-- Heals back up if left alone, so you can keep testing without waiting
	-- for a death.
	regenDelay = 6,
	regenPerSecond = 30,

	-- Anything that falls below this is considered lost and gets rebuilt.
	voidY = -80,
}

-- Medium stone grey — the colour Studio's Rig Builder gives a default dummy.
local DUMMY_GREY = Color3.fromRGB(163, 162, 165)

-- Ships with the client, so this stays free of marketplace asset IDs.
local FACE_TEXTURE = "rbxasset://textures/face.png"

local dummies: { any } = {}

--------------------------------------------------------------------------------
-- rig construction
--------------------------------------------------------------------------------

local HALF_PI = math.pi / 2

local function makePart(name: string, size: Vector3): BasePart
	local part = Instance.new("Part")
	part.Name = name
	part.Size = size
	part.Color = DUMMY_GREY
	part.Material = Enum.Material.Plastic
	part.TopSurface = Enum.SurfaceType.Smooth
	part.BottomSurface = Enum.SurfaceType.Smooth
	return part
end

local function joint(name: string, part0: BasePart, part1: BasePart, c0: CFrame, c1: CFrame)
	local motor = Instance.new("Motor6D")
	motor.Name = name
	motor.Part0 = part0
	motor.Part1 = part1
	motor.C0 = c0
	motor.C1 = c1
	motor.Parent = part0
	return motor
end

local function buildRig(): Model
	local model = Instance.new("Model")
	model.Name = "Dummy"

	local root = makePart("HumanoidRootPart", Vector3.new(2, 2, 1))
	root.Transparency = 1
	root.CanCollide = false
	root.Parent = model

	local torso = makePart("Torso", Vector3.new(2, 2, 1))
	torso.Parent = model

	local head = makePart("Head", Vector3.new(2, 1, 1))
	head.Parent = model

	-- The 1.25 scale head mesh and the face decal are what make it read as a
	-- Roblox dummy rather than a stack of bricks.
	local headMesh = Instance.new("SpecialMesh")
	headMesh.MeshType = Enum.MeshType.Head
	headMesh.Scale = Vector3.new(1.25, 1.25, 1.25)
	headMesh.Parent = head

	local face = Instance.new("Decal")
	face.Name = "face"
	face.Texture = FACE_TEXTURE
	face.Face = Enum.NormalId.Front
	face.Parent = head

	local leftArm = makePart("Left Arm", Vector3.new(1, 2, 1))
	leftArm.Parent = model
	local rightArm = makePart("Right Arm", Vector3.new(1, 2, 1))
	rightArm.Parent = model
	local leftLeg = makePart("Left Leg", Vector3.new(1, 2, 1))
	leftLeg.Parent = model
	local rightLeg = makePart("Right Leg", Vector3.new(1, 2, 1))
	rightLeg.Parent = model

	-- Standard R6 joint offsets.
	joint(
		"RootJoint",
		root,
		torso,
		CFrame.new(0, 0, 0) * CFrame.Angles(-HALF_PI, 0, math.pi),
		CFrame.new(0, 0, 0) * CFrame.Angles(-HALF_PI, 0, math.pi)
	)
	joint(
		"Neck",
		torso,
		head,
		CFrame.new(0, 1, 0) * CFrame.Angles(-HALF_PI, 0, math.pi),
		CFrame.new(0, -0.5, 0) * CFrame.Angles(-HALF_PI, 0, math.pi)
	)
	joint(
		"Right Shoulder",
		torso,
		rightArm,
		CFrame.new(1, 0.5, 0) * CFrame.Angles(0, HALF_PI, 0),
		CFrame.new(-0.5, 0.5, 0) * CFrame.Angles(0, HALF_PI, 0)
	)
	joint(
		"Left Shoulder",
		torso,
		leftArm,
		CFrame.new(-1, 0.5, 0) * CFrame.Angles(0, -HALF_PI, 0),
		CFrame.new(0.5, 0.5, 0) * CFrame.Angles(0, -HALF_PI, 0)
	)
	joint(
		"Right Hip",
		torso,
		rightLeg,
		CFrame.new(1, -1, 0) * CFrame.Angles(0, HALF_PI, 0),
		CFrame.new(0.5, 1, 0) * CFrame.Angles(0, HALF_PI, 0)
	)
	joint(
		"Left Hip",
		torso,
		leftLeg,
		CFrame.new(-1, -1, 0) * CFrame.Angles(0, -HALF_PI, 0),
		CFrame.new(-0.5, 1, 0) * CFrame.Angles(0, -HALF_PI, 0)
	)

	-- A real Rig Builder dummy carries one of these; anything that recolours
	-- characters expects to find it.
	local bodyColors = Instance.new("BodyColors")
	bodyColors.HeadColor3 = DUMMY_GREY
	bodyColors.TorsoColor3 = DUMMY_GREY
	bodyColors.LeftArmColor3 = DUMMY_GREY
	bodyColors.RightArmColor3 = DUMMY_GREY
	bodyColors.LeftLegColor3 = DUMMY_GREY
	bodyColors.RightLegColor3 = DUMMY_GREY
	bodyColors.Parent = model

	local humanoid = Instance.new("Humanoid")
	humanoid.RigType = Enum.HumanoidRigType.R6
	humanoid.MaxHealth = CONFIG.health
	humanoid.Health = CONFIG.health
	-- Dummies never chase; they are a punching bag, not an opponent.
	humanoid.WalkSpeed = 0
	humanoid.JumpPower = 0
	humanoid.AutoRotate = false
	-- Let Ragdoll own the joints on death instead of shattering them.
	humanoid.BreakJointsOnDeath = false
	humanoid.DisplayDistanceType = Enum.HumanoidDisplayDistanceType.None
	humanoid.Parent = model

	model.PrimaryPart = root
	return model
end

--------------------------------------------------------------------------------
-- health bar
--------------------------------------------------------------------------------

local function buildHealthBar(model: Model)
	local head = model:FindFirstChild("Head")
	if not head or not head:IsA("BasePart") then
		return nil
	end

	local fill = Instance.new("Frame")
	fill.Name = "Fill"
	fill.Size = UDim2.fromScale(1, 1)
	fill.BackgroundColor3 = Color3.fromRGB(228, 74, 70)
	fill.BorderSizePixel = 0

	local fillCorner = Instance.new("UICorner")
	fillCorner.CornerRadius = UDim.new(0, 3)
	fillCorner.Parent = fill

	local track = Instance.new("Frame")
	track.Name = "Track"
	track.AnchorPoint = Vector2.new(0.5, 1)
	track.Position = UDim2.fromScale(0.5, 1)
	track.Size = UDim2.new(1, 0, 0, 8)
	track.BackgroundColor3 = Color3.fromRGB(10, 11, 15)
	track.BackgroundTransparency = 0.25
	track.BorderSizePixel = 0
	track.ClipsDescendants = true
	fill.Parent = track

	local trackCorner = Instance.new("UICorner")
	trackCorner.CornerRadius = UDim.new(0, 4)
	trackCorner.Parent = track

	local label = Instance.new("TextLabel")
	label.Name = "Label"
	label.Size = UDim2.new(1, 0, 0, 16)
	label.BackgroundTransparency = 1
	label.Font = Enum.Font.GothamBlack
	label.Text = "TRAINING DUMMY"
	label.TextColor3 = Color3.fromRGB(238, 240, 247)
	label.TextSize = 12
	label.TextStrokeTransparency = 0.5

	local billboard = Instance.new("BillboardGui")
	billboard.Name = "DummyHealth"
	billboard.Size = UDim2.fromOffset(120, 30)
	billboard.StudsOffsetWorldSpace = Vector3.new(0, 2.4, 0)
	billboard.AlwaysOnTop = true
	billboard.MaxDistance = 220

	label.Parent = billboard
	track.Parent = billboard
	billboard.Parent = head

	return fill
end

--------------------------------------------------------------------------------
-- lifecycle
--------------------------------------------------------------------------------

local function groundBelow(position: Vector3): Vector3
	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude

	-- Ignore the other dummies, or they stack on each other's heads.
	local container = Workspace:FindFirstChild("Dummies")
	params.FilterDescendantsInstances = if container then { container } else {}

	local result = Workspace:Raycast(position + Vector3.new(0, 60, 0), Vector3.new(0, -400, 0), params)
	if result then
		return result.Position + Vector3.new(0, 3.5, 0)
	end
	return position
end

local function anchorPoint(): CFrame
	local spawnLocation = Workspace:FindFirstChildOfClass("SpawnLocation")
	if spawnLocation then
		return spawnLocation.CFrame
	end
	return CFrame.new(0, 0, 0)
end

local function placementFor(index: number): CFrame
	local origin = anchorPoint()
	-- Spread them left-to-right across the front of the spawn.
	local lateral = (index - (CONFIG.count + 1) / 2) * CONFIG.spacing
	local target = origin * CFrame.new(lateral, 0, -CONFIG.distance)
	local grounded = groundBelow(target.Position)

	-- Face back toward the spawn so you always hit them from the front.
	return CFrame.new(grounded, Vector3.new(origin.Position.X, grounded.Y, origin.Position.Z))
end

local function despawn(entry)
	if entry.model and entry.model.Parent then
		entry.model:Destroy()
	end
	entry.model = nil
	entry.humanoid = nil
	entry.fill = nil
end

local function spawnDummy(entry)
	despawn(entry)

	local model = buildRig()
	local placement = placementFor(entry.index)
	model.Parent = entry.container
	model:PivotTo(placement)

	entry.model = model
	entry.humanoid = model:FindFirstChildOfClass("Humanoid")
	entry.fill = buildHealthBar(model)
	entry.lastDamagedAt = 0
	entry.dead = false

	local humanoid = entry.humanoid
	if not humanoid then
		return
	end

	humanoid.HealthChanged:Connect(function(health)
		if health < entry.lastHealth then
			entry.lastDamagedAt = os.clock()
		end
		entry.lastHealth = health
	end)
	entry.lastHealth = humanoid.Health

	humanoid.Died:Connect(function()
		if entry.dead then
			return
		end
		entry.dead = true

		-- Flop, sit there a moment so the knockback reads, then rebuild.
		Ragdoll.enable(model)
		task.delay(CONFIG.respawnDelay, function()
			spawnDummy(entry)
		end)
	end)
end

local function step(dt: number)
	local now = os.clock()

	for _, entry in dummies do
		local humanoid = entry.humanoid
		local model = entry.model
		if not humanoid or not model or not model.Parent or entry.dead then
			continue
		end

		-- Fell off the world.
		local root = model:FindFirstChild("HumanoidRootPart")
		if root and root:IsA("BasePart") and root.Position.Y < CONFIG.voidY then
			spawnDummy(entry)
			continue
		end

		if humanoid.Health > 0 and humanoid.Health < humanoid.MaxHealth then
			if now - entry.lastDamagedAt > CONFIG.regenDelay then
				humanoid.Health = math.min(humanoid.MaxHealth, humanoid.Health + CONFIG.regenPerSecond * dt)
				entry.lastHealth = humanoid.Health
			end
		end

		if entry.fill then
			entry.fill.Size = UDim2.fromScale(math.clamp(humanoid.Health / humanoid.MaxHealth, 0, 1), 1)
		end
	end
end

function TrainingDummies.start()
	local container = Workspace:FindFirstChild("Dummies")
	if not container then
		container = Instance.new("Folder")
		container.Name = "Dummies"
		container.Parent = Workspace
	end

	for index = 1, CONFIG.count do
		local entry = {
			index = index,
			container = container,
			model = nil,
			humanoid = nil,
			fill = nil,
			lastDamagedAt = 0,
			lastHealth = CONFIG.health,
			dead = false,
		}
		table.insert(dummies, entry)
		spawnDummy(entry)
	end

	RunService.Heartbeat:Connect(step)

	-- The spawn point may not exist until the first player arrives; if the
	-- dummies were placed at the origin, put them where they belong now.
	Players.PlayerAdded:Connect(function()
		task.delay(1, function()
			for _, entry in dummies do
				if entry.model and not entry.dead then
					entry.model:PivotTo(placementFor(entry.index))
				end
			end
		end)
	end)
end

return TrainingDummies
]]></string>
        </Properties>
      </Item>
    </Item>
  </Item>
  <Item class="StarterPlayer" referent="25">
    <Properties>
      <string name="Name">StarterPlayer</string>
    </Properties>
    <Item class="StarterPlayerScripts" referent="26">
      <Properties>
        <string name="Name">StarterPlayerScripts</string>
      </Properties>
      <Item class="LocalScript" referent="27">
        <Properties>
          <string name="Name">Client</string>
          <string name="Source"><![CDATA[--!strict

local PowerController = require(script.PowerController)

PowerController.start()
]]></string>
        </Properties>
        <Item class="ModuleScript" referent="28">
          <Properties>
            <string name="Name">Animation</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Client-side procedural animation.

	No asset IDs and no uploaded KeyframeSequences — poses are written straight
	to Motor6D.Transform. That is the same channel Roblox's Animator writes to,
	so the bind runs at Character priority + 1: the default walk/idle animations
	apply first each frame and our pose lands on top of them.

	Everyone animates everyone. The server already broadcasts move events to all
	clients, so each client plays the same clip on the same character locally.
	Nothing about the pose is replicated, which keeps this free.
]]

local RunService = game:GetService("RunService")

local Rig = require(script.Rig)
local Track = require(script.Track)

local Animation = {}

type Entry = {
	rig: Rig.Class,
	track: Track.Class?,
	fading: { Track.Class },
}

local entries: { [Model]: Entry } = {}

local function entryFor(character: Model): Entry?
	local existing = entries[character]
	if existing then
		if existing.rig.character.Parent then
			return existing
		end
		entries[character] = nil
	end

	local rig = Rig.bind(character)
	if not rig then
		return nil
	end

	local entry: Entry = { rig = rig, track = nil, fading = {} }
	entries[character] = entry
	return entry
end

function Animation.play(character: Model?, clipName: string?, opts)
	if not character or not clipName then
		return
	end

	local entry = entryFor(character)
	if not entry then
		return
	end

	local track = Track.new(clipName, opts)
	if not track then
		return
	end

	-- Let the outgoing clip blend away instead of popping.
	if entry.track then
		Track.stop(entry.track)
		table.insert(entry.fading, entry.track)
	end
	entry.track = track
end

function Animation.stop(character: Model?)
	if not character then
		return
	end
	local entry = entries[character]
	if not entry or not entry.track then
		return
	end
	Track.stop(entry.track)
	table.insert(entry.fading, entry.track)
	entry.track = nil
end

-- Drop everything immediately — used when a character ragdolls or dies, since
-- its Motor6Ds are being replaced by constraints.
function Animation.clear(character: Model?)
	if not character then
		return
	end
	entries[character] = nil
end

local function update(dt: number)
	for character, entry in entries do
		if not character.Parent then
			entries[character] = nil
			continue
		end

		if not Rig.isPosable(entry.rig) then
			-- Ragdolled or dead: stop writing, and rebind next time so the
			-- restored Motor6Ds are picked up fresh.
			entries[character] = nil
			continue
		end

		-- Fading clips apply first so the active one wins where they overlap.
		for index = #entry.fading, 1, -1 do
			local track = entry.fading[index]
			local pose, weight = Track.sample(track, dt)
			if track.finished then
				table.remove(entry.fading, index)
			else
				Rig.apply(entry.rig, pose, weight)
			end
		end

		local track = entry.track
		if track then
			local pose, weight = Track.sample(track, dt)
			Rig.apply(entry.rig, pose, weight)
			if track.finished then
				entry.track = nil
			end
		end

		if not entry.track and #entry.fading == 0 then
			entries[character] = nil
		end
	end
end

function Animation.start()
	-- After Character priority, so this overrides the default animate script
	-- rather than being overwritten by it.
	RunService:BindToRenderStep("SBGPose", Enum.RenderPriority.Character.Value + 1, update)
end

return Animation
]]></string>
          </Properties>
          <Item class="ModuleScript" referent="29">
            <Properties>
              <string name="Name">Clips</string>
              <string name="Source"><![CDATA[--!strict
--[[
	The motion library. Every move in the roster names one of these.

	Keyframe times are normalised 0..1 and stretched to fit whatever the move's
	real duration is, so a clip stays in sync with the hitbox no matter how the
	windup/active/endlag numbers get tuned.

	A pose only lists the joints it actually drives. Anything it leaves out is
	left to the default animations — which is why the punches touch arms and
	torso but not legs: you keep walking normally while throwing them. Clips
	that commit the whole body (slams, lunges, kicks) do pose the legs.

	`hold = true` means the clip settles on its last keyframe and stays there
	until something stops it — used for charges, counter stances, and barriers.
]]

local function ang(x: number, y: number, z: number): CFrame
	return CFrame.Angles(math.rad(x), math.rad(y), math.rad(z))
end

local Clips = {}

--------------------------------------------------------------------------------
-- strikes
--------------------------------------------------------------------------------

Clips.Jab = {
	duration = 0.34,
	keys = {
		{ t = 0, pose = { RightArm = ang(-25, 0, -12), LeftArm = ang(-25, 0, 12), Torso = ang(0, 10, 0) } },
		{
			t = 0.34,
			pose = { RightArm = ang(-95, 0, -4), LeftArm = ang(-30, 0, 14), Torso = ang(0, -18, 0) },
		},
		{
			t = 0.55,
			pose = { RightArm = ang(-92, 0, -4), LeftArm = ang(-30, 0, 14), Torso = ang(0, -16, 0) },
		},
		{ t = 1, pose = { RightArm = ang(-25, 0, -12), LeftArm = ang(-25, 0, 12), Torso = ang(0, 10, 0) } },
	},
}

Clips.Straight = {
	duration = 0.5,
	keys = {
		{ t = 0, pose = { RightArm = ang(12, 0, -22), LeftArm = ang(-32, 0, 16), Torso = ang(0, 30, 0) } },
		{
			t = 0.4,
			pose = { RightArm = ang(-102, 0, 0), LeftArm = ang(-14, 0, 22), Torso = ang(-6, -32, 0) },
		},
		{
			t = 0.62,
			pose = { RightArm = ang(-98, 0, 0), LeftArm = ang(-14, 0, 22), Torso = ang(-4, -28, 0) },
		},
		{ t = 1, pose = { RightArm = ang(-20, 0, -14), LeftArm = ang(-24, 0, 14), Torso = ang(0, 8, 0) } },
	},
}

Clips.Palm = {
	duration = 0.44,
	keys = {
		{ t = 0, pose = { RightArm = ang(-42, 0, -28), LeftArm = ang(-38, 0, 20), Torso = ang(0, 22, 0) } },
		{
			t = 0.38,
			pose = { RightArm = ang(-92, 0, 12), LeftArm = ang(-46, 0, 26), Torso = ang(0, -16, 0) },
		},
		{
			t = 0.66,
			pose = { RightArm = ang(-90, 0, 12), LeftArm = ang(-46, 0, 26), Torso = ang(0, -14, 0) },
		},
		{ t = 1, pose = { RightArm = ang(-30, 0, -18), LeftArm = ang(-28, 0, 16), Torso = ang(0, 8, 0) } },
	},
}

Clips.Uppercut = {
	duration = 0.5,
	keys = {
		{ t = 0, pose = { RightArm = ang(38, 0, -16), LeftArm = ang(-30, 0, 18), Torso = ang(14, 16, 0) } },
		{
			t = 0.42,
			pose = { RightArm = ang(-152, 0, -8), LeftArm = ang(-20, 0, 20), Torso = ang(-16, -14, 0) },
		},
		{
			t = 0.66,
			pose = { RightArm = ang(-148, 0, -8), LeftArm = ang(-20, 0, 20), Torso = ang(-14, -12, 0) },
		},
		{ t = 1, pose = { RightArm = ang(-22, 0, -14), LeftArm = ang(-24, 0, 14), Torso = ang(0, 6, 0) } },
	},
}

Clips.Hook = {
	duration = 0.48,
	keys = {
		{ t = 0, pose = { RightArm = ang(-32, 0, -72), LeftArm = ang(-34, 0, 18), Torso = ang(0, 42, 0) } },
		{ t = 0.4, pose = { RightArm = ang(-82, 0, 56), LeftArm = ang(-24, 0, 22), Torso = ang(0, -40, 0) } },
		{
			t = 0.64,
			pose = { RightArm = ang(-80, 0, 52), LeftArm = ang(-24, 0, 22), Torso = ang(0, -36, 0) },
		},
		{ t = 1, pose = { RightArm = ang(-24, 0, -14), LeftArm = ang(-24, 0, 14), Torso = ang(0, 8, 0) } },
	},
}

Clips.Slash = {
	duration = 0.46,
	keys = {
		{
			t = 0,
			pose = { RightArm = ang(-152, 0, -46), LeftArm = ang(-38, 0, 22), Torso = ang(-10, 36, 0) },
		},
		{
			t = 0.36,
			pose = { RightArm = ang(-42, 0, 52), LeftArm = ang(-30, 0, 26), Torso = ang(10, -34, 0) },
		},
		{
			t = 0.62,
			pose = { RightArm = ang(-40, 0, 48), LeftArm = ang(-30, 0, 26), Torso = ang(8, -30, 0) },
		},
		{ t = 1, pose = { RightArm = ang(-26, 0, -16), LeftArm = ang(-26, 0, 16), Torso = ang(0, 8, 0) } },
	},
}

--------------------------------------------------------------------------------
-- whole-body commitments
--------------------------------------------------------------------------------

Clips.Overhead = {
	duration = 0.6,
	keys = {
		{
			t = 0,
			pose = {
				RightArm = ang(-172, 0, -12),
				LeftArm = ang(-172, 0, 12),
				Torso = ang(-20, 0, 0),
				RightLeg = ang(-14, 0, 0),
			},
		},
		{
			t = 0.44,
			pose = {
				RightArm = ang(-34, 0, -8),
				LeftArm = ang(-34, 0, 8),
				Torso = ang(26, 0, 0),
				RightLeg = ang(16, 0, 0),
			},
		},
		{
			t = 0.68,
			pose = {
				RightArm = ang(-32, 0, -8),
				LeftArm = ang(-32, 0, 8),
				Torso = ang(22, 0, 0),
				RightLeg = ang(12, 0, 0),
			},
		},
		{ t = 1, pose = { RightArm = ang(-20, 0, -12), LeftArm = ang(-20, 0, 12), Torso = ang(0, 0, 0) } },
	},
}

Clips.GroundSlam = {
	duration = 0.66,
	keys = {
		{
			t = 0,
			pose = {
				RightArm = ang(-162, 0, -26),
				LeftArm = ang(-162, 0, 26),
				Torso = ang(-16, 0, 0),
				Head = ang(-12, 0, 0),
			},
		},
		{
			t = 0.4,
			pose = {
				RightArm = ang(-8, 0, -8),
				LeftArm = ang(-8, 0, 8),
				Torso = ang(36, 0, 0),
				Head = ang(16, 0, 0),
				RightLeg = ang(24, 0, 0),
				LeftLeg = ang(24, 0, 0),
			},
		},
		{
			t = 0.62,
			pose = {
				RightArm = ang(-6, 0, -8),
				LeftArm = ang(-6, 0, 8),
				Torso = ang(32, 0, 0),
				Head = ang(14, 0, 0),
				RightLeg = ang(20, 0, 0),
				LeftLeg = ang(20, 0, 0),
			},
		},
		{ t = 1, pose = { RightArm = ang(-18, 0, -12), LeftArm = ang(-18, 0, 12), Torso = ang(0, 0, 0) } },
	},
}

Clips.Scoop = {
	duration = 0.62,
	keys = {
		{
			t = 0,
			pose = {
				RightArm = ang(22, 0, -16),
				LeftArm = ang(22, 0, 16),
				Torso = ang(32, 0, 0),
				RightLeg = ang(20, 0, 0),
				LeftLeg = ang(20, 0, 0),
			},
		},
		{
			t = 0.48,
			pose = {
				RightArm = ang(-166, 0, -16),
				LeftArm = ang(-166, 0, 16),
				Torso = ang(-24, 0, 0),
				Head = ang(-16, 0, 0),
				RightLeg = ang(-8, 0, 0),
				LeftLeg = ang(-8, 0, 0),
			},
		},
		{ t = 1, pose = { RightArm = ang(-20, 0, -12), LeftArm = ang(-20, 0, 12), Torso = ang(0, 0, 0) } },
	},
}

Clips.SpinSlash = {
	duration = 0.58,
	keys = {
		-- Yaw is broken into four steps because a CFrame lerp always takes the
		-- shortest path; a single 360 key would just sit still.
		{ t = 0, pose = { RightArm = ang(-88, 0, -78), LeftArm = ang(-40, 0, 30), Torso = ang(0, -40, 0) } },
		{
			t = 0.25,
			pose = { RightArm = ang(-88, 0, -84), LeftArm = ang(-40, 0, 30), Torso = ang(0, 50, 0) },
		},
		{
			t = 0.5,
			pose = { RightArm = ang(-88, 0, -84), LeftArm = ang(-40, 0, 30), Torso = ang(0, 140, 0) },
		},
		{
			t = 0.75,
			pose = { RightArm = ang(-88, 0, -84), LeftArm = ang(-40, 0, 30), Torso = ang(0, 230, 0) },
		},
		{
			t = 0.9,
			pose = { RightArm = ang(-70, 0, -50), LeftArm = ang(-36, 0, 24), Torso = ang(0, 320, 0) },
		},
		{ t = 1, pose = { RightArm = ang(-24, 0, -14), LeftArm = ang(-24, 0, 14), Torso = ang(0, 360, 0) } },
	},
}

Clips.Lunge = {
	duration = 0.4,
	keys = {
		{
			t = 0,
			pose = {
				RightArm = ang(-56, 0, -26),
				LeftArm = ang(18, 0, 22),
				Torso = ang(-26, 0, 0),
				RightLeg = ang(-42, 0, 0),
				LeftLeg = ang(26, 0, 0),
			},
		},
		{
			t = 0.55,
			pose = {
				RightArm = ang(-58, 0, -24),
				LeftArm = ang(20, 0, 20),
				Torso = ang(-24, 0, 0),
				RightLeg = ang(-38, 0, 0),
				LeftLeg = ang(24, 0, 0),
			},
		},
		{ t = 1, pose = { RightArm = ang(-20, 0, -14), LeftArm = ang(-20, 0, 14), Torso = ang(0, 0, 0) } },
	},
}

Clips.Kick = {
	duration = 0.5,
	keys = {
		{
			t = 0,
			pose = {
				RightArm = ang(-40, 0, -56),
				LeftArm = ang(-40, 0, 56),
				Torso = ang(0, 26, 0),
				RightLeg = ang(22, 0, 0),
			},
		},
		{
			t = 0.4,
			pose = {
				RightArm = ang(-50, 0, -70),
				LeftArm = ang(-50, 0, 70),
				Torso = ang(0, -32, 12),
				RightLeg = ang(-98, 0, -26),
			},
		},
		{
			t = 0.64,
			pose = {
				RightArm = ang(-48, 0, -66),
				LeftArm = ang(-48, 0, 66),
				Torso = ang(0, -28, 10),
				RightLeg = ang(-92, 0, -22),
			},
		},
		{ t = 1, pose = { RightArm = ang(-22, 0, -14), LeftArm = ang(-22, 0, 14), Torso = ang(0, 0, 0) } },
	},
}

--------------------------------------------------------------------------------
-- ranged
--------------------------------------------------------------------------------

Clips.Cast = {
	duration = 0.44,
	keys = {
		{ t = 0, pose = { RightArm = ang(-40, 0, -22), LeftArm = ang(-40, 0, 22), Torso = ang(6, 0, 0) } },
		{
			t = 0.34,
			pose = {
				RightArm = ang(-90, 0, -8),
				LeftArm = ang(-90, 0, 8),
				Torso = ang(-10, 0, 0),
				Head = ang(-6, 0, 0),
			},
		},
		{ t = 0.78, pose = { RightArm = ang(-88, 0, -8), LeftArm = ang(-88, 0, 8), Torso = ang(-8, 0, 0) } },
		{ t = 1, pose = { RightArm = ang(-22, 0, -14), LeftArm = ang(-22, 0, 14), Torso = ang(0, 0, 0) } },
	},
}

Clips.Throw = {
	duration = 0.46,
	keys = {
		{ t = 0, pose = { RightArm = ang(-168, 0, -32), LeftArm = ang(-52, 0, 30), Torso = ang(-8, 34, 0) } },
		{ t = 0.38, pose = { RightArm = ang(-72, 0, 8), LeftArm = ang(-28, 0, 20), Torso = ang(6, -28, 0) } },
		{ t = 0.62, pose = { RightArm = ang(-68, 0, 8), LeftArm = ang(-28, 0, 20), Torso = ang(4, -24, 0) } },
		{ t = 1, pose = { RightArm = ang(-22, 0, -14), LeftArm = ang(-22, 0, 14), Torso = ang(0, 6, 0) } },
	},
}

--------------------------------------------------------------------------------
-- looping and held
--------------------------------------------------------------------------------

Clips.Flurry = {
	duration = 0.18,
	loop = true,
	keys = {
		{ t = 0, pose = { RightArm = ang(-96, 0, -6), LeftArm = ang(-26, 0, 16), Torso = ang(0, -14, 0) } },
		{ t = 0.5, pose = { RightArm = ang(-26, 0, -16), LeftArm = ang(-96, 0, 6), Torso = ang(0, 14, 0) } },
		{ t = 1, pose = { RightArm = ang(-96, 0, -6), LeftArm = ang(-26, 0, 16), Torso = ang(0, -14, 0) } },
	},
}

Clips.Stance = {
	duration = 0.26,
	hold = true,
	keys = {
		{ t = 0, pose = { RightArm = ang(-24, 0, -14), LeftArm = ang(-24, 0, 14), Torso = ang(0, 0, 0) } },
		{
			t = 1,
			pose = {
				RightArm = ang(-72, 0, 46),
				LeftArm = ang(-62, 0, -28),
				Torso = ang(4, 28, 0),
				Head = ang(0, -18, 0),
			},
		},
	},
}

Clips.ChargeUp = {
	duration = 0.32,
	hold = true,
	keys = {
		{ t = 0, pose = { RightArm = ang(-22, 0, -14), LeftArm = ang(-22, 0, 14), Torso = ang(0, 0, 0) } },
		{
			t = 1,
			pose = {
				RightArm = ang(34, 0, -34),
				LeftArm = ang(34, 0, 34),
				Torso = ang(14, 0, 0),
				Head = ang(-10, 0, 0),
				RightLeg = ang(16, 0, 0),
				LeftLeg = ang(-10, 0, 0),
			},
		},
	},
}

Clips.Focus = {
	duration = 0.36,
	hold = true,
	keys = {
		{ t = 0, pose = { RightArm = ang(-22, 0, -14), LeftArm = ang(-22, 0, 14), Torso = ang(0, 0, 0) } },
		{
			t = 1,
			pose = {
				RightArm = ang(-46, 0, -58),
				LeftArm = ang(-46, 0, 58),
				Torso = ang(-12, 0, 0),
				Head = ang(-14, 0, 0),
			},
		},
	},
}

Clips.Grab = {
	duration = 0.3,
	hold = true,
	keys = {
		{ t = 0, pose = { RightArm = ang(-30, 0, -18), LeftArm = ang(-28, 0, 16), Torso = ang(0, 12, 0) } },
		{
			t = 1,
			pose = {
				RightArm = ang(-96, 0, -6),
				LeftArm = ang(-34, 0, 18),
				Torso = ang(-4, -12, 0),
				Head = ang(-6, 0, 0),
			},
		},
	},
}

return Clips
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="30">
            <Properties>
              <string name="Name">Rig</string>
              <string name="Source"><![CDATA[--!strict
--[[
	Binds a character's Motor6Ds to logical limb names so a clip can be authored
	once and play on either rig type.

	Poses are written to `Motor6D.Transform`, which is the same channel the
	Animator writes to. That is deliberate: it means a pose is a delta from the
	rig's rest position rather than an absolute CFrame, so the same numbers mean
	the same thing on an R6 and an R15 body. It also means we never touch C0/C1
	and never have to restore them.

	R15 is best-effort. Its limbs are split at the elbow and knee, so a clip that
	only sets `RightArm` moves the upper arm and leaves the forearm inline —
	readable, but not as expressive as the R6 version. Clips can set
	`RightForearm` / `LeftForearm`, which R6 silently ignores.
]]

local Rig = {}
Rig.__index = Rig

-- logical name -> Motor6D name, per rig
local R6 = {
	Torso = "RootJoint",
	Head = "Neck",
	RightArm = "Right Shoulder",
	LeftArm = "Left Shoulder",
	RightLeg = "Right Hip",
	LeftLeg = "Left Hip",
}

local R15 = {
	Torso = "Waist",
	Head = "Neck",
	RightArm = "RightShoulder",
	LeftArm = "LeftShoulder",
	RightForearm = "RightElbow",
	LeftForearm = "LeftElbow",
	RightLeg = "RightHip",
	LeftLeg = "LeftHip",
}

-- Mirroring a clip swaps sides and flips the two axes that carry handedness.
local MIRROR_PAIRS = {
	RightArm = "LeftArm",
	LeftArm = "RightArm",
	RightForearm = "LeftForearm",
	LeftForearm = "RightForearm",
	RightLeg = "LeftLeg",
	LeftLeg = "RightLeg",
	Torso = "Torso",
	Head = "Head",
}

export type Class = typeof(setmetatable(
	{} :: {
		character: Model,
		motors: { [string]: Motor6D },
		rigType: string,
	},
	Rig
))

local function findMotor(character: Model, name: string): Motor6D?
	for _, descendant in character:GetDescendants() do
		if descendant:IsA("Motor6D") and descendant.Name == name then
			return descendant
		end
	end
	return nil
end

function Rig.bind(character: Model): Class?
	local humanoid = character:FindFirstChildOfClass("Humanoid")
	if not humanoid then
		return nil
	end

	local isR6 = humanoid.RigType == Enum.HumanoidRigType.R6
	local map = if isR6 then R6 else R15

	local motors: { [string]: Motor6D } = {}
	for logical, motorName in map do
		local motor = findMotor(character, motorName)
		if motor then
			motors[logical] = motor
		end
	end

	if not next(motors) then
		return nil
	end

	return setmetatable({
		character = character,
		motors = motors,
		rigType = if isR6 then "R6" else "R15",
	}, Rig) :: any
end

function Rig.mirrorPose(pose: { [string]: CFrame }): { [string]: CFrame }
	local out = {}
	for logical, cf in pose do
		local target = MIRROR_PAIRS[logical] or logical
		local x, y, z = cf:ToEulerAnglesXYZ()
		local px, py, pz = cf.Position.X, cf.Position.Y, cf.Position.Z
		-- Flip yaw and roll, keep pitch; mirror sideways translation.
		out[target] = CFrame.new(-px, py, pz) * CFrame.Angles(x, -y, -z)
	end
	return out
end

function Rig.isAlive(self: Class): boolean
	if not self.character.Parent then
		return false
	end
	local humanoid = self.character:FindFirstChildOfClass("Humanoid")
	return humanoid ~= nil and humanoid.Health > 0
end

-- Ragdolled characters have their Motor6Ds disabled outright, so posing them
-- would fight the physics.
function Rig.isPosable(self: Class): boolean
	return Rig.isAlive(self) and self.character:FindFirstChild("RagdollJoints") == nil
end

function Rig.apply(self: Class, pose: { [string]: CFrame }, weight: number)
	if weight <= 0 then
		return
	end

	for logical, motor in self.motors do
		local target = pose[logical]
		if not target then
			continue
		end
		if not motor.Parent then
			continue
		end

		-- Blend from wherever the default animations left the joint this frame,
		-- so a partial-weight pose layers on top of the walk cycle instead of
		-- snapping over it.
		motor.Transform = if weight >= 1
			then target
			else motor.Transform:Lerp(target, math.clamp(weight, 0, 1))
	end
end

return Rig
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="31">
            <Properties>
              <string name="Name">Track</string>
              <string name="Source"><![CDATA[--!strict
--[[
	One playing clip.

	A keyframe only lists the joints it moves. Before playback the clip is
	normalised so every key carries the same joint set, and any joint a key did
	not mention is filled with identity — that is, the rig's rest pose. So
	GroundSlam, whose first key says nothing about legs, starts with legs at
	rest and bends them into the slam rather than snapping to it. Joints no key
	in the clip mentions at all are never written, which is what leaves the walk
	cycle running under an arms-only punch.
]]

local Clips = require(script.Parent.Clips)
local Rig = require(script.Parent.Rig)

local Track = {}
Track.__index = Track

local normalised: { [string]: any } = {}

-- Build the union of joints across a clip's keys once, then backfill.
local function normalise(clipName: string)
	local cached = normalised[clipName]
	if cached then
		return cached
	end

	local clip = Clips[clipName]
	if not clip then
		return nil
	end

	local joints: { [string]: boolean } = {}
	for _, key in clip.keys do
		for logical in key.pose do
			joints[logical] = true
		end
	end

	local keys = {}
	for index, key in clip.keys do
		local pose = {}
		for logical in joints do
			pose[logical] = key.pose[logical] or CFrame.identity
		end
		keys[index] = { t = key.t, pose = pose }
	end

	local built = {
		duration = clip.duration,
		loop = clip.loop == true,
		hold = clip.hold == true,
		keys = keys,
	}
	normalised[clipName] = built
	return built
end

export type Class = typeof(setmetatable({} :: any, Track))

--[[
	opts.duration  stretch the clip to this many seconds (looping and held
	               clips ignore it and run at their authored rate)
	opts.mirror    swap left/right
	opts.fadeIn    seconds to blend in, default 0.08
	opts.fadeOut   seconds to blend out, default 0.12
]]
function Track.new(clipName: string, opts): Class?
	local clip = normalise(clipName)
	if not clip then
		warn(`[SBG] unknown animation clip "{clipName}"`)
		return nil
	end

	opts = opts or {}

	local length = clip.duration
	if opts.duration and not clip.loop and not clip.hold then
		length = math.clamp(opts.duration, 0.12, 4)
	end

	local keys = clip.keys
	if opts.mirror then
		keys = {}
		for index, key in clip.keys do
			keys[index] = { t = key.t, pose = Rig.mirrorPose(key.pose) }
		end
	end

	return setmetatable({
		clip = clip,
		keys = keys,
		length = length,
		elapsed = 0,
		fadeIn = opts.fadeIn or 0.08,
		fadeOut = opts.fadeOut or 0.12,
		stopping = false,
		stopElapsed = 0,
		finished = false,
	}, Track) :: any
end

function Track.stop(self: Class)
	if self.stopping then
		return
	end
	self.stopping = true
	self.stopElapsed = 0
end

local function poseAt(keys, t: number)
	local previous = keys[1]
	for index = 2, #keys do
		local key = keys[index]
		if t <= key.t then
			local span = key.t - previous.t
			local alpha = if span > 0.0001 then (t - previous.t) / span else 1

			local blended = {}
			for logical, from in previous.pose do
				local to = key.pose[logical] or from
				blended[logical] = from:Lerp(to, alpha)
			end
			return blended
		end
		previous = key
	end
	return previous.pose
end

-- Returns pose, weight. `finished` on self tells the manager to drop it.
function Track.sample(self: Class, dt: number)
	self.elapsed += dt

	local raw = self.elapsed / self.length
	local t: number
	if self.clip.loop then
		t = raw % 1
	elseif self.clip.hold then
		t = math.min(raw, 1)
	else
		t = math.min(raw, 1)
		if raw >= 1 and not self.stopping then
			Track.stop(self)
		end
	end

	local weight = math.clamp(self.elapsed / math.max(self.fadeIn, 0.0001), 0, 1)

	if self.stopping then
		self.stopElapsed += dt
		local out = 1 - math.clamp(self.stopElapsed / math.max(self.fadeOut, 0.0001), 0, 1)
		weight = math.min(weight, out)
		if out <= 0 then
			self.finished = true
		end
	end

	return poseAt(self.keys, t), weight
end

return Track
]]></string>
            </Properties>
          </Item>
        </Item>
        <Item class="ModuleScript" referent="32">
          <Properties>
            <string name="Name">Effects</string>
            <string name="Source"><![CDATA[--!strict
--[[
	Turns the server's combat broadcasts into things you can see.

	Most of a move's look is derived rather than authored: the kind decides the
	shape of the effect (strikes get limb trails, sustained moves get a cone,
	charges get a converging aura) and the power's own colour and element decide
	how it is painted. That means a new character is fully dressed the moment
	its data module exists.

	On top of that, every ultimate gets a signature release in SIGNATURE below,
	because those are the moves people actually remember.
]]

local Debris = game:GetService("Debris")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local Workspace = game:GetService("Workspace")

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

local Palette = require(script.Palette)
local Vfx = require(script.Vfx)

local Effects = {}

local LocalPlayer = Players.LocalPlayer

-- Per-player teardown callbacks for anything that runs for the length of a move.
local running: { [Player]: { () -> () } } = {}
local barriers: { [Player]: RBXScriptConnection } = {}

local function characterOf(player): Model?
	return if typeof(player) == "Instance" and player:IsA("Player") then player.Character else nil
end

local function rootOf(player): BasePart?
	local character = characterOf(player)
	if not character then
		return nil
	end
	local root = character:FindFirstChild("HumanoidRootPart")
	return if root and root:IsA("BasePart") then root else nil
end

local function track(player: Player, stop: () -> ())
	local list = running[player]
	if not list then
		list = {}
		running[player] = list
	end
	table.insert(list, stop)
end

local function clearRunning(player)
	local list = running[player]
	if not list then
		return
	end
	running[player] = nil
	for _, stop in list do
		pcall(stop)
	end
end

-- Shake falls off with distance so someone else's ultimate across the map does
-- not rattle your screen.
local function shakeFrom(position: Vector3, magnitude: number, duration: number)
	local root = rootOf(LocalPlayer)
	if not root then
		return
	end
	local distance = (root.Position - position).Magnitude
	local falloff = math.clamp(1 - distance / 120, 0, 1)
	if falloff <= 0.02 then
		return
	end
	Vfx.shake(magnitude * falloff, duration)
end

--------------------------------------------------------------------------------
-- signature ultimate releases
--------------------------------------------------------------------------------

local SIGNATURE = {}

local function lanceFor(payload, paint, length: number, width: number)
	local root = rootOf(payload.user)
	if not root then
		return
	end
	Vfx.beam(root.Position, root.CFrame.LookVector, paint, { length = length, width = width })
	Vfx.ring(root.CFrame * CFrame.Angles(math.pi / 2, 0, 0), paint, { radius = 10, duration = 0.35 })
	shakeFrom(root.Position, 1.4, 0.4)
end

local function eruptionFor(payload, paint, count: number, spread: number)
	local root = rootOf(payload.user)
	if not root then
		return
	end

	-- Pillars march outward in a ragged ring rather than all at once.
	for index = 1, count do
		local angle = (index / count) * math.pi * 2
		local distance = spread * (0.45 + (index % 3) * 0.28)
		local offset = Vector3.new(math.cos(angle) * distance, -2.5, math.sin(angle) * distance)
		task.delay(index * 0.035, function()
			Vfx.pillar(root.Position + offset, paint, { height = 18 + (index % 4) * 5, width = 3.5 })
		end)
	end

	Vfx.ring(CFrame.new(root.Position - Vector3.new(0, 2.5, 0)), paint, { radius = spread, duration = 0.5 })
	Vfx.burst(root.Position, paint, { count = 40, scale = 1.6 })
	shakeFrom(root.Position, 2, 0.55)
end

local function implosionFor(payload, paint, radius: number)
	local root = rootOf(payload.user)
	if not root then
		return
	end
	Vfx.dome(root.Position, paint, { radius = radius, duration = 0.45 })
	Vfx.ring(CFrame.new(root.Position - Vector3.new(0, 2.5, 0)), paint, { radius = radius, duration = 0.5 })
	Vfx.burst(root.Position, paint, { count = 46, scale = 1.5 })
	shakeFrom(root.Position, 1.8, 0.5)
end

function SIGNATURE.SeriousPunch(payload, paint)
	local root = rootOf(payload.user)
	if not root then
		return
	end
	-- Three rings staggered down the punch line reads as the air itself moving.
	for index = 0, 2 do
		task.delay(index * 0.05, function()
			local at = root.CFrame * CFrame.new(0, 0, -8 - index * 12)
			Vfx.ring(at * CFrame.Angles(math.pi / 2, 0, 0), paint, {
				radius = 12 + index * 6,
				duration = 0.45,
				thickness = 1.1,
			})
		end)
	end
	Vfx.beam(root.Position, root.CFrame.LookVector, paint, { length = 46, width = 7, duration = 0.35 })
	Vfx.burst(root.Position + root.CFrame.LookVector * 5, paint, { count = 50, scale = 1.8 })
	shakeFrom(root.Position, 3, 0.6)
end

function SIGNATURE.IncinerationCannon(payload, paint)
	lanceFor(payload, paint, 62, 8)
end

function SIGNATURE.Thunderclap(payload, paint)
	lanceFor(payload, paint, 58, 6)
	-- Forked branches off the main bolt.
	local root = rootOf(payload.user)
	if root then
		for index = 1, 5 do
			local lateral = (math.random() - 0.5) * 26
			local along = 12 + math.random() * 34
			local at = root.CFrame * CFrame.new(lateral, math.random() * 6 - 2, -along)
			task.delay(index * 0.02, function()
				Vfx.beam(
					at.Position,
					Vector3.new(0, -1, 0),
					paint,
					{ length = 14, width = 1.4, duration = 0.25 }
				)
			end)
		end
	end
end

function SIGNATURE.GravityCrush(payload, paint)
	implosionFor(payload, paint, 22)
end

function SIGNATURE.Maelstrom(payload, paint)
	implosionFor(payload, paint, 21)
end

function SIGNATURE.Eruption(payload, paint)
	eruptionFor(payload, paint, 10, 18)
end

function SIGNATURE.Fissure(payload, paint)
	eruptionFor(payload, paint, 8, 22)
end

function SIGNATURE.AbsoluteZero(payload, paint)
	eruptionFor(payload, paint, 12, 20)
end

function SIGNATURE.Tempest(payload, paint)
	local root = rootOf(payload.user)
	if not root then
		return
	end
	-- Vortex: a tall column plus rings climbing it.
	Vfx.pillar(root.Position - Vector3.new(0, 2, 0), paint, { height = 40, width = 9, duration = 0.7 })
	for index = 0, 3 do
		task.delay(index * 0.06, function()
			local at = CFrame.new(root.Position + Vector3.new(0, index * 7 - 2, 0))
			Vfx.ring(at, paint, { radius = 20 - index * 3, duration = 0.5 })
		end)
	end
	Vfx.burst(root.Position, paint, { count = 44, scale = 1.6 })
	shakeFrom(root.Position, 1.9, 0.55)
end

--------------------------------------------------------------------------------
-- generic per-kind dressing
--------------------------------------------------------------------------------

local function startMoveVisuals(payload)
	local paint = Palette.get(payload.characterId)
	local character = characterOf(payload.user)
	if not character then
		return
	end

	local move = if payload.characterId and payload.move
		then Powers.getMove(payload.characterId, payload.move)
		else nil
	local kind = if move then move.kind else "Strike"

	if kind == "Sustained" then
		track(payload.user, Vfx.cone(character, paint, { rate = 110, angle = 24 }))
		return
	end

	if kind == "Barrage" then
		-- Both arms, since a barrage alternates.
		track(payload.user, Vfx.trail(character, paint, { limb = "Right Arm" }))
		track(payload.user, Vfx.trail(character, paint, { limb = "Left Arm" }))
		return
	end

	if kind == "Dash" then
		track(payload.user, Vfx.trail(character, paint, { limb = "Right Arm", lifetime = 0.4 }))
		track(payload.user, Vfx.trail(character, paint, { limb = "Torso", lifetime = 0.4 }))
		Vfx.afterimage(character, paint, { duration = 0.3 })
		return
	end

	if kind == "Projectile" then
		local root = rootOf(payload.user)
		if root then
			Vfx.burst(root.Position + root.CFrame.LookVector * 3 + Vector3.new(0, 1, 0), paint, {
				count = 12,
				scale = 0.7,
			})
		end
		return
	end

	if kind == "Grab" then
		local stop = Vfx.aura(character, paint, { rate = 30, scale = 0.8 })
		track(payload.user, stop)
		return
	end

	-- Strike and anything unclassified.
	track(payload.user, Vfx.trail(character, paint, { limb = "Right Arm" }))
	if move and move.ultimate then
		local stop = Vfx.aura(character, paint, { rate = 60 })
		track(payload.user, stop)
	end
end

--------------------------------------------------------------------------------
-- handlers
--------------------------------------------------------------------------------

local HANDLERS = {}

function HANDLERS.MoveStart(payload)
	clearRunning(payload.user)
	startMoveVisuals(payload)
end

function HANDLERS.MoveEnd(payload)
	clearRunning(payload.user)
end

function HANDLERS.Hit(payload)
	if not payload.position then
		return
	end
	local paint = Palette.get(payload.characterId)
	local heavy = payload.heavy == true

	Vfx.burst(payload.position, paint, {
		count = if heavy then 34 else 14,
		scale = if heavy then 1.5 else 0.8,
	})

	if heavy then
		Vfx.ring(CFrame.new(payload.position) * CFrame.Angles(math.pi / 2, 0, 0), paint, {
			radius = 12,
			duration = 0.35,
		})
	end

	-- Barrage ticks fire constantly; a full shake on each would be nauseating.
	local magnitude = if heavy then 1.6 elseif payload.tick then 0.18 else 0.5
	shakeFrom(payload.position, magnitude, if heavy then 0.4 else 0.16)
end

function HANDLERS.ChargeStart(payload)
	clearRunning(payload.user)

	local character = characterOf(payload.user)
	if not character then
		return
	end
	local paint = Palette.get(payload.characterId)

	track(payload.user, Vfx.aura(character, paint, { rate = 70, converge = true, lightRange = 18 }))

	-- Rings pulsing inward mark the wind-up.
	local pulsing = true
	track(payload.user, function()
		pulsing = false
	end)

	task.spawn(function()
		while pulsing do
			local root = character:FindFirstChild("HumanoidRootPart")
			if not root or not root:IsA("BasePart") then
				break
			end
			Vfx.ring(CFrame.new(root.Position - Vector3.new(0, 2.5, 0)), paint, {
				radius = 9,
				duration = 0.5,
				thickness = 0.5,
				height = 0.8,
			})
			task.wait(0.35)
		end
	end)
end

function HANDLERS.ChargeRelease(payload)
	clearRunning(payload.user)

	local paint = Palette.get(payload.characterId)
	local signature = SIGNATURE[payload.move]

	if signature then
		signature(payload, paint)
	else
		local root = rootOf(payload.user)
		if root then
			Vfx.ring(root.CFrame * CFrame.Angles(math.pi / 2, 0, 0), paint, { radius = 16, duration = 0.4 })
			Vfx.burst(root.Position, paint, { count = 34, scale = 1.4 })
			shakeFrom(root.Position, 1.4, 0.4)
		end
	end
end

function HANDLERS.ChargeCancel(payload)
	clearRunning(payload.user)
end

function HANDLERS.CounterStance(payload)
	clearRunning(payload.user)
	local character = characterOf(payload.user)
	if character then
		track(payload.user, Vfx.aura(character, Palette.get(payload.characterId), { rate = 22, scale = 0.6 }))
	end
end

function HANDLERS.Counter(payload)
	clearRunning(payload.by)

	local root = rootOf(payload.by)
	if not root then
		return
	end

	local paint = Palette.get(payload.characterId)
	Vfx.flash(root.Position, Color3.fromRGB(255, 255, 255), 34, 0.25)
	Vfx.burst(root.Position, paint, { count = 38, scale = 1.3 })
	Vfx.ring(root.CFrame * CFrame.Angles(math.pi / 2, 0, 0), paint, { radius = 13, duration = 0.35 })
	shakeFrom(root.Position, 2, 0.4)
end

function HANDLERS.CounterWhiff(payload)
	clearRunning(payload.user)
end

function HANDLERS.Dash(payload)
	local character = characterOf(payload.user)
	if not character then
		return
	end
	local paint = Palette.get(payload.characterId)
	Vfx.afterimage(character, paint, { duration = 0.28 })

	local root = character:FindFirstChild("HumanoidRootPart")
	if root and root:IsA("BasePart") then
		Vfx.burst(root.Position - Vector3.new(0, 2.4, 0), paint, { count = 10, scale = 0.6, light = false })
	end
end

function HANDLERS.Teleport(payload)
	local paint = Palette.get(payload.characterId)
	local character = characterOf(payload.user)
	if character then
		Vfx.afterimage(character, paint, { duration = 0.4 })
	end
	if payload.from then
		Vfx.burst(payload.from, paint, { count = 20, scale = 1 })
	end
	if payload.to then
		Vfx.burst(payload.to, paint, { count = 20, scale = 1 })
	end
end

function HANDLERS.Explode(payload)
	if not payload.position then
		return
	end
	local paint = Palette.get(payload.characterId)
	Vfx.burst(payload.position, paint, { count = 26, scale = 1.3 })
	Vfx.ring(CFrame.new(payload.position) * CFrame.Angles(math.pi / 2, 0, 0), paint, {
		radius = 9,
		duration = 0.3,
	})
	shakeFrom(payload.position, 0.6, 0.2)
end

function HANDLERS.Slam(payload)
	if not payload.position then
		return
	end
	local paint = Palette.get(payload.characterId)
	Vfx.ring(CFrame.new(payload.position), paint, { radius = 16, duration = 0.4 })
	Vfx.burst(payload.position, paint, { count = 30, scale = 1.4 })
	shakeFrom(payload.position, 1.5, 0.35)
end

function HANDLERS.Sustain(payload)
	-- The cone emitter is already running; this just adds a pulse of light.
	if payload.position then
		local paint = Palette.get(payload.characterId)
		Vfx.flash(payload.position, paint.primary, 10, 0.12)
	end
end

function HANDLERS.BlockBreak(payload)
	local character = payload.target
	if typeof(character) ~= "Instance" then
		return
	end
	local root = character:FindFirstChild("HumanoidRootPart")
	if not root or not root:IsA("BasePart") then
		return
	end

	local white = { primary = Color3.fromRGB(255, 236, 180), secondary = Color3.fromRGB(255, 160, 90) }
	white.texture = Palette.Texture.sparkle
	white.style = "snap"
	white.light = 16

	Vfx.burst(root.Position, white, { count = 34, scale = 1.3 })
	Vfx.ring(root.CFrame * CFrame.Angles(math.pi / 2, 0, 0), white, { radius = 11, duration = 0.35 })
	shakeFrom(root.Position, 1.4, 0.35)
end

function HANDLERS.Burn(payload)
	local target = payload.target
	if typeof(target) ~= "Instance" then
		return
	end
	local root = target:FindFirstChild("HumanoidRootPart")
	if not root or not root:IsA("BasePart") then
		return
	end

	-- One small puff per damage tick, so a burn reads as an ongoing thing
	-- rather than a single hit.
	Vfx.burst(root.Position + Vector3.new(0, 0.5, 0), Palette.get(payload.characterId), {
		count = 6,
		scale = 0.55,
		light = false,
	})
end

function HANDLERS.GrabWhiff(payload)
	clearRunning(payload.user)
end

function HANDLERS.Blocked(payload)
	local character = payload.target
	if typeof(character) ~= "Instance" then
		return
	end
	local root = character:FindFirstChild("HumanoidRootPart")
	if root and root:IsA("BasePart") then
		Vfx.flash(root.Position + Vector3.new(0, 1, 0), Color3.fromRGB(150, 200, 255), 10, 0.16)
	end
end

--------------------------------------------------------------------------------
-- barrier bubble
--------------------------------------------------------------------------------

local function destroyBarrier(player)
	local connection = barriers[player]
	if connection then
		connection:Disconnect()
		barriers[player] = nil
	end
end

function HANDLERS.BarrierUp(payload)
	destroyBarrier(payload.user)

	local character = characterOf(payload.user)
	local root = if character then character:FindFirstChild("HumanoidRootPart") else nil
	if not root or not root:IsA("BasePart") then
		return
	end

	local paint = Palette.get(payload.characterId)

	local shell = Instance.new("Part")
	shell.Name = "BarrierShell"
	shell.Shape = Enum.PartType.Ball
	shell.Size = Vector3.new(11, 11, 11)
	shell.Color = paint.primary
	shell.Material = Enum.Material.ForceField
	shell.Transparency = 0.35
	shell.Anchored = true
	shell.CanCollide = false
	shell.CanQuery = false
	shell.CanTouch = false
	shell.CastShadow = false
	shell.Parent = Workspace:FindFirstChild("ClientVfx") or Workspace

	-- Anchored and re-placed each frame: welding a local part to a replicated
	-- one gets jittery, and this stays exact.
	local pulse = 0
	barriers[payload.user] = RunService.RenderStepped:Connect(function(dt)
		if not shell.Parent or not root.Parent then
			destroyBarrier(payload.user)
			shell:Destroy()
			return
		end
		pulse += dt
		local scale = 11 + math.sin(pulse * 5) * 0.4
		shell.Size = Vector3.new(scale, scale, scale)
		shell.CFrame = CFrame.new(root.Position)
	end)

	track(payload.user, function()
		destroyBarrier(payload.user)
		if shell.Parent then
			TweenService:Create(shell, TweenInfo.new(0.25), { Transparency = 1 }):Play()
			Debris:AddItem(shell, 0.3)
		end
	end)
end

function HANDLERS.BarrierHit(payload)
	local root = rootOf(payload.user)
	if root then
		Vfx.flash(root.Position, Palette.get(payload.characterId).primary, 16, 0.18)
	end
end

local function popBarrier(payload)
	local root = rootOf(payload.user)
	local paint = Palette.get(payload.characterId)
	clearRunning(payload.user)
	destroyBarrier(payload.user)

	if root then
		Vfx.dome(root.Position, paint, { radius = 14, duration = 0.35 })
		Vfx.burst(root.Position, paint, { count = 30, scale = 1.2 })
		shakeFrom(root.Position, 1.1, 0.3)
	end
end

HANDLERS.BarrierPop = popBarrier
HANDLERS.BarrierDown = popBarrier

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

function Effects.start()
	Vfx.start()

	Net.get("Effect").OnClientEvent:Connect(function(name, payload)
		local handler = HANDLERS[name]
		if not handler or typeof(payload) ~= "table" then
			return
		end
		local ok, err = pcall(handler, payload)
		if not ok then
			warn(`[SBG] vfx handler {name} errored: {err}`)
		end
	end)

	Players.PlayerRemoving:Connect(function(player)
		clearRunning(player)
		destroyBarrier(player)
	end)
end

return Effects
]]></string>
          </Properties>
          <Item class="ModuleScript" referent="33">
            <Properties>
              <string name="Name">Palette</string>
              <string name="Source"><![CDATA[--!strict
--[[
	Visual identity per power.

	Colours come from the power data itself, so a new character is coloured
	correctly the moment its module exists. Only the particle style is defined
	here, keyed by element.

	Every texture ships with the Roblox client (they are the ones behind the
	legacy Fire/Smoke/Sparkles objects), so this stays free of asset IDs like
	everything else in the project.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")

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

local TEXTURE = {
	smoke = "rbxasset://textures/particles/smoke_main.dds",
	fire = "rbxasset://textures/particles/fire_main.dds",
	sparkle = "rbxasset://textures/particles/sparkles_main.dds",
}

-- style: how the element wants to move.
--   drift   upward/lazy (fire, smoke)
--   spray   fast and directional (water, wind)
--   snap    short-lived and jittery (lightning, ice)
--   heavy   slow and falling (earth)
local ELEMENT = {
	Fire = { texture = TEXTURE.fire, style = "drift", light = 14 },
	Water = { texture = TEXTURE.smoke, style = "spray", light = 8 },
	Wind = { texture = TEXTURE.smoke, style = "spray", light = 6 },
	Lightning = { texture = TEXTURE.sparkle, style = "snap", light = 18 },
	Earth = { texture = TEXTURE.smoke, style = "heavy", light = 4 },
	Ice = { texture = TEXTURE.sparkle, style = "snap", light = 10 },
}

local HERO_DEFAULT = { texture = TEXTURE.smoke, style = "drift", light = 10 }

local Palette = {}

Palette.Texture = TEXTURE

local cache: { [string]: any } = {}

function Palette.get(characterId: string?)
	if not characterId then
		return {
			primary = Color3.fromRGB(238, 240, 247),
			secondary = Color3.fromRGB(160, 168, 186),
			texture = TEXTURE.smoke,
			style = "drift",
			light = 10,
		}
	end

	local existing = cache[characterId]
	if existing then
		return existing
	end

	local definition = Powers.getCharacter(characterId)
	if not definition then
		return Palette.get(nil)
	end

	local style = if definition.element then ELEMENT[definition.element] else nil
	style = style or HERO_DEFAULT

	local built = {
		primary = definition.color,
		secondary = definition.accent or definition.color,
		texture = style.texture,
		style = style.style,
		light = style.light,
	}
	cache[characterId] = built
	return built
end

-- Two-stop sequence from the power's own colours, used by most emitters.
function Palette.sequence(paint): ColorSequence
	return ColorSequence.new(paint.primary, paint.secondary)
end

return Palette
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="34">
            <Properties>
              <string name="Name">Vfx</string>
              <string name="Source"><![CDATA[--!strict
--[[
	Visual primitives. Everything here is built from parts, particle emitters
	and lights at runtime — no meshes, no uploaded textures, no asset IDs.

	All of it is client-local: nothing is parented anywhere that replicates, so
	a screen full of effects costs the server nothing.

	Shockwave rings are built out of segments rather than a mesh, because a ring
	is the one shape you cannot make from Roblox's primitives.
]]

local Debris = game:GetService("Debris")
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local Workspace = game:GetService("Workspace")

local Vfx = {}

local FOLDER_NAME = "ClientVfx"

local function container(): Folder
	local existing = Workspace:FindFirstChild(FOLDER_NAME)
	if existing and existing:IsA("Folder") then
		return existing
	end
	local folder = Instance.new("Folder")
	folder.Name = FOLDER_NAME
	folder.Parent = Workspace
	return folder
end

-- Every cosmetic part wants the same dozen properties off.
local function decor(size: Vector3, color: Color3): BasePart
	local part = Instance.new("Part")
	part.Size = size
	part.Color = color
	part.Material = Enum.Material.Neon
	part.Anchored = true
	part.CanCollide = false
	part.CanQuery = false
	part.CanTouch = false
	part.CastShadow = false
	part.Massless = true
	part.TopSurface = Enum.SurfaceType.Smooth
	part.BottomSurface = Enum.SurfaceType.Smooth
	return part
end

local function tweenOut(instance: Instance, duration: number, goal)
	TweenService
		:Create(instance, TweenInfo.new(duration, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), goal)
		:Play()
	Debris:AddItem(instance, duration + 0.1)
end

--------------------------------------------------------------------------------
-- particles
--------------------------------------------------------------------------------

local STYLE_TUNING = {
	drift = { speed = NumberRange.new(6, 16), accel = Vector3.new(0, 14, 0), spread = 180, life = 0.7 },
	spray = { speed = NumberRange.new(18, 34), accel = Vector3.new(0, -8, 0), spread = 60, life = 0.5 },
	snap = { speed = NumberRange.new(22, 46), accel = Vector3.new(0, 0, 0), spread = 180, life = 0.28 },
	heavy = { speed = NumberRange.new(8, 18), accel = Vector3.new(0, -46, 0), spread = 120, life = 0.9 },
}

local function configureEmitter(emitter: ParticleEmitter, paint, scale: number)
	local tuning = STYLE_TUNING[paint.style] or STYLE_TUNING.drift

	emitter.Texture = paint.texture
	emitter.Color = ColorSequence.new(paint.primary, paint.secondary)
	emitter.LightEmission = 0.75
	emitter.LightInfluence = 0
	emitter.Size = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 0.6 * scale),
		NumberSequenceKeypoint.new(0.35, 2.4 * scale),
		NumberSequenceKeypoint.new(1, 0),
	})
	emitter.Transparency = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 0.15),
		NumberSequenceKeypoint.new(0.7, 0.45),
		NumberSequenceKeypoint.new(1, 1),
	})
	emitter.Lifetime = NumberRange.new(tuning.life * 0.6, tuning.life)
	emitter.Speed = tuning.speed
	emitter.SpreadAngle = Vector2.new(tuning.spread, tuning.spread)
	emitter.Acceleration = tuning.accel
	emitter.Rotation = NumberRange.new(-180, 180)
	emitter.RotSpeed = NumberRange.new(-200, 200)
	emitter.Enabled = false
end

-- A one-shot puff of particles at a point.
function Vfx.burst(position: Vector3, paint, opts)
	opts = opts or {}
	local count = opts.count or 24
	local scale = opts.scale or 1

	local anchor = decor(Vector3.new(0.2, 0.2, 0.2), paint.primary)
	anchor.Transparency = 1
	anchor.CFrame = CFrame.new(position)
	anchor.Parent = container()

	local emitter = Instance.new("ParticleEmitter")
	configureEmitter(emitter, paint, scale)
	emitter.Parent = anchor
	emitter:Emit(count)

	if opts.light ~= false then
		Vfx.flash(position, paint.primary, (opts.lightRange or paint.light) * scale, 0.22)
	end

	Debris:AddItem(anchor, 1.6)
end

-- Emitter that follows a character until you stop it.
function Vfx.aura(character: Model, paint, opts)
	local root = character:FindFirstChild("HumanoidRootPart")
	if not root or not root:IsA("BasePart") then
		return function() end
	end

	opts = opts or {}

	local emitter = Instance.new("ParticleEmitter")
	configureEmitter(emitter, paint, opts.scale or 1)
	emitter.Rate = opts.rate or 45
	emitter.Enabled = true

	-- Converging auras pull inward instead of spraying out; used by charges.
	if opts.converge then
		emitter.Speed = NumberRange.new(-26, -14)
		emitter.Acceleration = Vector3.zero
		emitter.SpreadAngle = Vector2.new(180, 180)
	end

	emitter.Parent = root

	local light = Instance.new("PointLight")
	light.Color = paint.primary
	light.Range = opts.lightRange or 12
	light.Brightness = 2
	light.Parent = root

	local stopped = false
	return function()
		if stopped then
			return
		end
		stopped = true
		emitter.Enabled = false
		light:Destroy()
		Debris:AddItem(emitter, 1.2)
	end,
		emitter,
		light
end

-- Forward-facing spray, for flamethrowers and wave attacks. NormalId.Front is
-- the face the character's LookVector points out of, so parenting to the root
-- aims it without any per-frame work.
function Vfx.cone(character: Model, paint, opts)
	local root = character:FindFirstChild("HumanoidRootPart")
	if not root or not root:IsA("BasePart") then
		return function() end
	end

	opts = opts or {}

	local emitter = Instance.new("ParticleEmitter")
	configureEmitter(emitter, paint, opts.scale or 1.6)
	emitter.EmissionDirection = Enum.NormalId.Front
	emitter.SpreadAngle = Vector2.new(opts.angle or 26, opts.angle or 26)
	emitter.Speed = NumberRange.new(opts.speed or 42, (opts.speed or 42) * 1.4)
	emitter.Lifetime = NumberRange.new(0.35, 0.6)
	emitter.Acceleration = Vector3.zero
	emitter.Rate = opts.rate or 90
	emitter.Enabled = true
	emitter.Parent = root

	local light = Instance.new("PointLight")
	light.Color = paint.primary
	light.Range = 16
	light.Brightness = 3
	light.Parent = root

	local stopped = false
	return function()
		if stopped then
			return
		end
		stopped = true
		emitter.Enabled = false
		light:Destroy()
		Debris:AddItem(emitter, 1)
	end
end

--------------------------------------------------------------------------------
-- geometry
--------------------------------------------------------------------------------

--[[
	Expanding shockwave ring, built from `segments` thin boxes arranged in a
	circle. Each segment is re-placed every frame as the radius grows, and the
	whole thing fades as it goes.
]]
function Vfx.ring(cframe: CFrame, paint, opts)
	opts = opts or {}
	local segments = opts.segments or 18
	local finalRadius = opts.radius or 18
	local duration = opts.duration or 0.45
	local thickness = opts.thickness or 0.7
	local height = opts.height or 1.4

	local parent = container()
	local parts: { BasePart } = {}
	local arc = (2 * math.pi * finalRadius) / segments

	for index = 1, segments do
		local part = decor(Vector3.new(thickness, height, arc * 1.15), paint.primary)
		part.Transparency = 0.15
		part.Parent = parent
		parts[index] = part
	end

	local elapsed = 0
	local connection
	connection = RunService.RenderStepped:Connect(function(dt)
		elapsed += dt
		local alpha = math.min(elapsed / duration, 1)
		-- Ease out so it snaps wide immediately then settles.
		local eased = 1 - (1 - alpha) ^ 3
		local radius = finalRadius * eased

		for index, part in parts do
			local angle = (index / segments) * math.pi * 2
			local offset = CFrame.Angles(0, angle, 0) * CFrame.new(0, 0, -radius)
			part.CFrame = cframe * offset
			part.Transparency = 0.15 + 0.85 * alpha
			part.Size = Vector3.new(thickness * (1 - alpha * 0.6), height * (1 - alpha * 0.5), arc * 1.15)
		end

		if alpha >= 1 then
			connection:Disconnect()
			for _, part in parts do
				part:Destroy()
			end
		end
	end)
end

-- Expanding hollow-looking sphere. Used for implosions and barrier pops.
function Vfx.dome(position: Vector3, paint, opts)
	opts = opts or {}
	local radius = opts.radius or 16
	local duration = opts.duration or 0.4

	local ball = decor(Vector3.new(1, 1, 1), paint.primary)
	ball.Shape = Enum.PartType.Ball
	ball.Transparency = 0.45
	ball.CFrame = CFrame.new(position)
	ball.Parent = container()

	tweenOut(ball, duration, {
		Size = Vector3.new(radius, radius, radius) * 2,
		Transparency = 1,
	})
end

-- Column of light punching up out of the ground.
function Vfx.pillar(position: Vector3, paint, opts)
	opts = opts or {}
	local height = opts.height or 26
	local width = opts.width or 4
	local duration = opts.duration or 0.5

	local part = decor(Vector3.new(width, 1, width), paint.primary)
	part.Transparency = 0.25
	part.CFrame = CFrame.new(position)
	part.Parent = container()

	TweenService
		:Create(part, TweenInfo.new(duration * 0.35, Enum.EasingStyle.Back, Enum.EasingDirection.Out), {
			Size = Vector3.new(width, height, width),
			CFrame = CFrame.new(position + Vector3.new(0, height / 2, 0)),
		})
		:Play()

	task.delay(duration * 0.35, function()
		tweenOut(
			part,
			duration * 0.65,
			{ Transparency = 1, Size = Vector3.new(width * 0.2, height, width * 0.2) }
		)
	end)
end

-- Straight lance of light. Grows from the caster along `direction`.
function Vfx.beam(origin: Vector3, direction: Vector3, paint, opts)
	opts = opts or {}
	local length = opts.length or 55
	local width = opts.width or 5
	local duration = opts.duration or 0.42

	local unit = if direction.Magnitude > 0.01 then direction.Unit else Vector3.new(0, 0, -1)
	local part = decor(Vector3.new(width, width, 1), paint.primary)
	part.Transparency = 0.1
	part.CFrame = CFrame.new(origin + unit * 2, origin + unit * 4)
	part.Parent = container()

	TweenService
		:Create(part, TweenInfo.new(duration * 0.25, Enum.EasingStyle.Quint, Enum.EasingDirection.Out), {
			Size = Vector3.new(width, width, length),
			CFrame = CFrame.new(origin + unit * (length / 2), origin + unit * length),
		})
		:Play()

	task.delay(duration * 0.25, function()
		tweenOut(part, duration * 0.75, { Transparency = 1, Size = Vector3.new(0.4, 0.4, length) })
	end)

	Vfx.flash(origin + unit * 3, paint.primary, 22, 0.3)
end

-- Streak left behind a moving limb.
function Vfx.trail(character: Model, paint, opts)
	opts = opts or {}
	local limbName = opts.limb or "Right Arm"
	local limb = character:FindFirstChild(limbName)
		or character:FindFirstChild("RightHand")
		or character:FindFirstChild("RightLowerArm")
	if not limb or not limb:IsA("BasePart") then
		return function() end
	end

	local a0 = Instance.new("Attachment")
	a0.Position = Vector3.new(0, -0.9, 0)
	a0.Parent = limb

	local a1 = Instance.new("Attachment")
	a1.Position = Vector3.new(0, 0.9, 0)
	a1.Parent = limb

	local trail = Instance.new("Trail")
	trail.Attachment0 = a0
	trail.Attachment1 = a1
	trail.Color = ColorSequence.new(paint.primary, paint.secondary)
	trail.Transparency = NumberSequence.new({
		NumberSequenceKeypoint.new(0, 0.15),
		NumberSequenceKeypoint.new(1, 1),
	})
	trail.Lifetime = opts.lifetime or 0.28
	trail.LightEmission = 1
	trail.LightInfluence = 0
	trail.FaceCamera = true
	trail.Parent = limb

	local stopped = false
	return function()
		if stopped then
			return
		end
		stopped = true
		trail.Enabled = false
		Debris:AddItem(trail, 0.5)
		Debris:AddItem(a0, 0.5)
		Debris:AddItem(a1, 0.5)
	end
end

function Vfx.flash(position: Vector3, color: Color3, range: number, duration: number)
	local anchor = decor(Vector3.new(0.2, 0.2, 0.2), color)
	anchor.Transparency = 1
	anchor.CFrame = CFrame.new(position)
	anchor.Parent = container()

	local light = Instance.new("PointLight")
	light.Color = color
	light.Range = range
	light.Brightness = 5
	light.Parent = anchor

	tweenOut(light, duration, { Brightness = 0, Range = range * 0.3 })
	Debris:AddItem(anchor, duration + 0.15)
end

-- Ghost copy of a character that fades in place. Used by teleports and dashes.
function Vfx.afterimage(character: Model, paint, opts)
	opts = opts or {}
	local duration = opts.duration or 0.35
	local parent = container()

	for _, part in character:GetChildren() do
		if not part:IsA("BasePart") or part.Transparency >= 1 then
			continue
		end

		local ghost = part:Clone()
		ghost:ClearAllChildren()
		ghost.Anchored = true
		ghost.CanCollide = false
		ghost.CanQuery = false
		ghost.CanTouch = false
		ghost.CastShadow = false
		ghost.Material = Enum.Material.Neon
		ghost.Color = paint.primary
		ghost.Transparency = 0.55
		ghost.Parent = parent

		tweenOut(ghost, duration, { Transparency = 1 })
	end
end

--------------------------------------------------------------------------------
-- camera shake
--------------------------------------------------------------------------------

local shakes: { { magnitude: number, remaining: number, duration: number } } = {}

function Vfx.shake(magnitude: number, duration: number)
	table.insert(shakes, { magnitude = magnitude, remaining = duration, duration = duration })
end

local function updateShake(dt: number)
	if #shakes == 0 then
		return
	end

	local camera = Workspace.CurrentCamera
	if not camera then
		table.clear(shakes)
		return
	end

	local total = 0
	for index = #shakes, 1, -1 do
		local entry = shakes[index]
		entry.remaining -= dt
		if entry.remaining <= 0 then
			table.remove(shakes, index)
		else
			-- Falls off over the life of the shake so it settles instead of
			-- cutting out.
			total += entry.magnitude * (entry.remaining / entry.duration)
		end
	end

	if total <= 0 then
		return
	end

	total = math.min(total, 3)
	local offset = Vector3.new(
		(math.random() - 0.5) * total,
		(math.random() - 0.5) * total,
		(math.random() - 0.5) * total * 0.4
	)
	camera.CFrame = camera.CFrame
		* CFrame.new(offset)
		* CFrame.Angles(0, 0, (math.random() - 0.5) * total * 0.02)
end

function Vfx.start()
	-- After the camera has been positioned for this frame, so the offset is not
	-- immediately overwritten.
	RunService:BindToRenderStep("SBGShake", Enum.RenderPriority.Camera.Value + 1, updateShake)
end

return Vfx
]]></string>
            </Properties>
          </Item>
        </Item>
        <Item class="ModuleScript" referent="35">
          <Properties>
            <string name="Name">PowerController</string>
            <string name="Source"><![CDATA[--!strict
-- Input -> server, and server state -> HUD. Holds no authority; it only
-- smooths the 12 Hz snapshot into something that ticks every frame.

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

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

local UI = require(script.Parent.UI)
local DamageNumbers = require(script.Parent.UI.DamageNumbers)
local Animation = require(script.Parent.Animation)
local Effects = require(script.Parent.Effects)

local Universal = Powers.Universal
local LocalPlayer = Players.LocalPlayer

local PowerController = {}

local SLOT_KEYS = {
	[Enum.KeyCode.One] = 1,
	[Enum.KeyCode.Two] = 2,
	[Enum.KeyCode.Three] = 3,
	[Enum.KeyCode.Four] = 4,
}

local state = {
	characterId = Powers.DefaultCharacter,
	snapshot = nil,
	snapshotAt = 0,
}

local hud

local function send(action: string, payload)
	Net.get("Action"):FireServer(action, payload)
end

--------------------------------------------------------------------------------
-- snapshot smoothing
--------------------------------------------------------------------------------

-- Timers arrive as remaining-seconds at snapshot time, so subtracting the age
-- of the snapshot gives a countdown that ticks every frame instead of 12/sec.
local function derive()
	local snapshot = state.snapshot
	if not snapshot then
		return nil
	end

	local age = os.clock() - state.snapshotAt

	local cooldowns = {}
	for id, remaining in snapshot.cooldowns do
		cooldowns[id] = math.max(0, remaining - age)
	end

	local charging = nil
	if snapshot.charging then
		local move = Powers.getMove(snapshot.characterId, snapshot.charging.moveId)
		local ratio = snapshot.charging.ratio
		if move and move.charge then
			ratio = math.clamp(ratio + age / math.max(move.charge.max - move.charge.min, 0.001), 0, 1)
		end
		charging = { moveId = snapshot.charging.moveId, ratio = ratio }
	end

	return {
		characterId = snapshot.characterId,
		health = snapshot.health,
		maxHealth = snapshot.maxHealth,
		block = snapshot.block,
		stamina = snapshot.stamina,
		dash = snapshot.dash,
		cooldowns = cooldowns,
		stun = math.max(0, snapshot.stun - age),
		ragdoll = math.max(0, snapshot.ragdoll - age),
		busy = snapshot.busy,
		charging = charging,
		barrier = snapshot.barrier,
	}
end

--------------------------------------------------------------------------------
-- input
--------------------------------------------------------------------------------

local function dashDirection(): Vector3
	local character = LocalPlayer.Character
	local humanoid = character and character:FindFirstChildOfClass("Humanoid")

	if humanoid and humanoid.MoveDirection.Magnitude > 0.1 then
		return humanoid.MoveDirection
	end

	local camera = workspace.CurrentCamera
	if camera then
		local look = camera.CFrame.LookVector
		return Vector3.new(look.X, 0, look.Z)
	end
	return Vector3.zero
end

local function isRagdolled(): boolean
	local derived = derive()
	return derived ~= nil and derived.ragdoll > 0
end

local function onInputBegan(input: InputObject, gameProcessed: boolean)
	if input.KeyCode == Enum.KeyCode.Tab and not gameProcessed then
		hud:toggleRoster()
		return
	end

	if gameProcessed or hud:isRosterOpen() then
		return
	end

	local slot = SLOT_KEYS[input.KeyCode]
	if slot then
		send("Move", { slot = slot })
		hud:flashSlot(slot)
		return
	end

	if input.UserInputType == Enum.UserInputType.MouseButton1 then
		send("M1", nil)
	elseif input.KeyCode == Universal.Block.bind then
		send("Block", { holding = true })
	elseif input.KeyCode == Universal.Dash.bind then
		send("Dash", { direction = dashDirection() })
	elseif input.KeyCode == Universal.Sprint.bind then
		send("Sprint", { holding = true })
	elseif input.KeyCode == Universal.Getup.bind and isRagdolled() then
		send("Getup", nil)
	end
end

local function onInputEnded(input: InputObject, gameProcessed: boolean)
	if gameProcessed then
		return
	end

	local slot = SLOT_KEYS[input.KeyCode]
	if slot then
		-- Charge moves fire on release; everything else ignores this.
		send("MoveRelease", { slot = slot })
		return
	end

	if input.KeyCode == Universal.Block.bind then
		send("Block", { holding = false })
	elseif input.KeyCode == Universal.Sprint.bind then
		send("Sprint", { holding = false })
	end
end

--------------------------------------------------------------------------------
-- cosmetic events
--------------------------------------------------------------------------------

local EFFECTS = {}

--------------------------------------------------------------------------------
-- animation
--
-- Every client plays every character's clips locally off these broadcasts, so
-- nothing about the pose has to replicate.
--------------------------------------------------------------------------------

local function characterOf(player): Model?
	return if typeof(player) == "Instance" and player:IsA("Player") then player.Character else nil
end

local function playMove(payload)
	Animation.play(characterOf(payload.user), payload.animation, {
		duration = payload.duration,
		mirror = payload.mirror,
	})
end

EFFECTS.MoveStart = playMove
EFFECTS.ChargeRelease = playMove

function EFFECTS.ChargeStart(payload)
	Animation.play(characterOf(payload.user), payload.animation)
end

function EFFECTS.CounterStance(payload)
	Animation.play(characterOf(payload.user), payload.animation)
end

function EFFECTS.BarrierUp(payload)
	Animation.play(characterOf(payload.user), payload.animation)
end

function EFFECTS.MoveEnd(payload)
	Animation.stop(characterOf(payload.user))
end

function EFFECTS.ChargeCancel(payload)
	Animation.stop(characterOf(payload.user))
end

function EFFECTS.BarrierDown(payload)
	Animation.stop(characterOf(payload.user))
end

function EFFECTS.Damage(payload)
	local mine = payload.source == LocalPlayer

	if mine then
		hud:showHitmarker()
	end
	if payload.target == LocalPlayer.Character then
		hud:showDamage()
	end

	-- Show the number for anything you dealt, and for anything dealt to you.
	if payload.target and (mine or payload.target == LocalPlayer.Character) then
		DamageNumbers.show(payload.target, payload.amount or 0, mine)
	end
end

function EFFECTS.Counter(payload)
	if payload.by == LocalPlayer then
		hud:showBanner("counter!", Color3.fromRGB(120, 220, 200))
	elseif payload.against == LocalPlayer then
		hud:showBanner("countered", Color3.fromRGB(228, 74, 70))
		hud:showDamage()
	end
end

function EFFECTS.BlockBreak(payload)
	if payload.target == LocalPlayer.Character then
		hud:showBanner("guard broken", Color3.fromRGB(255, 108, 78))
		hud:showDamage()
	end
end

function EFFECTS.BarrierPop(payload)
	if payload.user == LocalPlayer then
		hud:showBanner("barrier shattered", Color3.fromRGB(180, 128, 232))
	end
end

function EFFECTS.Dodge(payload)
	if payload.target == LocalPlayer.Character then
		hud:showBanner("dodged", Color3.fromRGB(140, 148, 167))
	end
end

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

function PowerController.start()
	Animation.start()
	Effects.start()

	hud = UI.new(function(characterId)
		state.characterId = characterId
		hud:setCharacter(characterId)
		Net.get("SelectCharacter"):FireServer(characterId)
	end)

	hud:setCharacter(state.characterId)

	Net.get("State").OnClientEvent:Connect(function(snapshot)
		if snapshot.characterId ~= state.characterId then
			state.characterId = snapshot.characterId
			hud:setCharacter(snapshot.characterId)
		end
		state.snapshot = snapshot
		state.snapshotAt = os.clock()
	end)

	Net.get("Effect").OnClientEvent:Connect(function(name, payload)
		local handler = EFFECTS[name]
		if handler and payload then
			handler(payload)
		end
	end)

	UserInputService.InputBegan:Connect(onInputBegan)
	UserInputService.InputEnded:Connect(onInputEnded)

	RunService.RenderStepped:Connect(function()
		local derived = derive()
		if derived then
			hud:update(derived)
		end
	end)
end

return PowerController
]]></string>
          </Properties>
        </Item>
        <Item class="ModuleScript" referent="36">
          <Properties>
            <string name="Name">UI</string>
            <string name="Source"><![CDATA[--!strict
-- Assembles the HUD and exposes the handful of calls the controller needs.

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

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

local Create = require(script.Create)
local Theme = require(script.Theme)
local MoveBar = require(script.MoveBar)
local Meters = require(script.Meters)
local CharacterSelect = require(script.CharacterSelect)

local UI = {}
UI.__index = UI

function UI.new(onPickCharacter)
	local self = setmetatable({}, UI)

	local playerGui = Players.LocalPlayer:WaitForChild("PlayerGui")

	local screen = Create("ScreenGui", {
		Name = "SBGHud",
		ResetOnSpawn = false,
		IgnoreGuiInset = true,
		ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
		Parent = playerGui,
	})
	self.screen = screen

	-- Red vignette on taking damage.
	self.vignette = Create("Frame", {
		Name = "Vignette",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Theme.health,
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		ZIndex = 2,
		Parent = screen,
	})

	-- Centre-screen hit confirm.
	self.hitmarker = Create("TextLabel", {
		Name = "Hitmarker",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(40, 40),
		BackgroundTransparency = 1,
		Font = Theme.fontHeavy,
		Text = "✕",
		TextColor3 = Theme.text,
		TextSize = 26,
		TextTransparency = 1,
		ZIndex = 30,
		Parent = screen,
	})

	self.banner = Create("TextLabel", {
		Name = "Banner",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.34),
		Size = UDim2.fromOffset(520, 30),
		BackgroundTransparency = 1,
		Font = Theme.fontHeavy,
		Text = "",
		TextColor3 = Theme.text,
		TextSize = 22,
		TextTransparency = 1,
		ZIndex = 30,
		Parent = screen,
	})

	self.meters = Meters.new(screen)
	self.moveBar = MoveBar.new(screen)
	self.roster = CharacterSelect.new(screen, onPickCharacter)

	self:buildPowersButton(screen)

	return self
end

-- Bottom-left button that opens the roster. Tab still works; this is just the
-- click-driven way in.
function UI.buildPowersButton(self, screen: Instance)
	local buttonStroke = Create("UIStroke", { Color = Theme.stroke, Thickness = 1.5 })

	local swatch = Create("Frame", {
		Name = "Swatch",
		AnchorPoint = Vector2.new(0, 0.5),
		Position = UDim2.new(0, 10, 0.5, 0),
		Size = UDim2.fromOffset(6, 26),
		BackgroundColor3 = Theme.ultimate,
		BorderSizePixel = 0,
		Create("UICorner", { CornerRadius = UDim.new(0, 3) }),
	})
	self.powersSwatch = swatch

	local title = Create("TextLabel", {
		Name = "Title",
		AnchorPoint = Vector2.new(0, 0),
		Position = UDim2.new(0, 26, 0, 9),
		Size = UDim2.new(1, -34, 0, 14),
		BackgroundTransparency = 1,
		Font = Theme.fontHeavy,
		Text = "POWERS",
		TextColor3 = Theme.text,
		TextSize = 13,
		TextXAlignment = Enum.TextXAlignment.Left,
	})

	local subtitle = Create("TextLabel", {
		Name = "Current",
		AnchorPoint = Vector2.new(0, 0),
		Position = UDim2.new(0, 26, 0, 24),
		Size = UDim2.new(1, -34, 0, 12),
		BackgroundTransparency = 1,
		Font = Theme.fontBold,
		Text = "",
		TextColor3 = Theme.textFaint,
		TextSize = 10,
		TextTruncate = Enum.TextTruncate.AtEnd,
		TextXAlignment = Enum.TextXAlignment.Left,
	})
	self.powersSubtitle = subtitle

	local button = Create("TextButton", {
		Name = "PowersButton",
		AnchorPoint = Vector2.new(0, 1),
		Position = UDim2.new(0, 16, 1, -16),
		Size = UDim2.fromOffset(184, 46),
		BackgroundColor3 = Theme.panel,
		BackgroundTransparency = 0.08,
		BorderSizePixel = 0,
		AutoButtonColor = false,
		Text = "",
		ZIndex = 10,
		Create("UICorner", { CornerRadius = UDim.new(0, 8) }),
		buttonStroke,
		swatch,
		title,
		subtitle,
		Create("TextLabel", {
			Name = "Hint",
			AnchorPoint = Vector2.new(1, 0.5),
			Position = UDim2.new(1, -10, 0.5, 0),
			Size = UDim2.fromOffset(30, 14),
			BackgroundTransparency = 1,
			Font = Theme.fontBold,
			Text = "TAB",
			TextColor3 = Theme.textFaint,
			TextSize = 9,
			TextXAlignment = Enum.TextXAlignment.Right,
		}),
		Parent = screen,
	})

	button.MouseEnter:Connect(function()
		buttonStroke.Color = Theme.strokeHot
		TweenService:Create(button, TweenInfo.new(0.12), { BackgroundColor3 = Theme.panelRaised }):Play()
	end)
	button.MouseLeave:Connect(function()
		buttonStroke.Color = Theme.stroke
		TweenService:Create(button, TweenInfo.new(0.12), { BackgroundColor3 = Theme.panel }):Play()
	end)
	button.Activated:Connect(function()
		self.roster:toggle()
	end)

	self.powersButton = button
end

function UI.setCharacter(self, characterId: string)
	self.moveBar:setCharacter(characterId)
	self.meters:setCharacter(characterId)
	self.roster:setSelected(characterId)

	local definition = Powers.getCharacter(characterId)
	if definition then
		self.powersSubtitle.Text = string.upper(definition.name)
		self.powersSwatch.BackgroundColor3 = definition.color
	end
end

function UI.update(self, state)
	self.moveBar:update(state)
	self.meters:update(state)
end

function UI.flashSlot(self, slot: number)
	self.moveBar:flash(slot)
end

function UI.toggleRoster(self): boolean
	return self.roster:toggle()
end

function UI.isRosterOpen(self): boolean
	return self.roster.root.Visible
end

function UI.showHitmarker(self)
	self.hitmarker.TextTransparency = 0
	self.hitmarker.Size = UDim2.fromOffset(52, 52)
	TweenService:Create(self.hitmarker, TweenInfo.new(0.22), {
		TextTransparency = 1,
		Size = UDim2.fromOffset(40, 40),
	}):Play()
end

function UI.showDamage(self)
	self.vignette.BackgroundTransparency = 0.72
	TweenService:Create(self.vignette, TweenInfo.new(0.35), { BackgroundTransparency = 1 }):Play()
end

function UI.showBanner(self, text: string, color: Color3?)
	self.banner.Text = string.upper(text)
	self.banner.TextColor3 = color or Theme.text
	self.banner.TextTransparency = 0
	TweenService:Create(self.banner, TweenInfo.new(0.9, Enum.EasingStyle.Quad), { TextTransparency = 1 })
		:Play()
end

return UI
]]></string>
          </Properties>
          <Item class="ModuleScript" referent="37">
            <Properties>
              <string name="Name">CharacterSelect</string>
              <string name="Source"><![CDATA[--!strict
-- Roster panel. Toggled with Tab; picking a card swaps which four powers the
-- move bar is bound to.

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

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

local Create = require(script.Parent.Create)
local Theme = require(script.Parent.Theme)

local CharacterSelect = {}
CharacterSelect.__index = CharacterSelect

local function corner(radius: number)
	return Create("UICorner", { CornerRadius = UDim.new(0, radius) })
end

local function buildMoveLine(move, order: number)
	return Create("Frame", {
		Name = `Move{order}`,
		Size = UDim2.new(1, 0, 0, 14),
		BackgroundTransparency = 1,
		LayoutOrder = order,
		Create("TextLabel", {
			Name = "Slot",
			Size = UDim2.fromOffset(14, 14),
			BackgroundTransparency = 1,
			Font = Theme.fontHeavy,
			Text = tostring(move.slot),
			TextColor3 = if move.ultimate then Theme.ultimate else Theme.textFaint,
			TextSize = 10,
			TextXAlignment = Enum.TextXAlignment.Left,
		}),
		Create("TextLabel", {
			Name = "Name",
			Position = UDim2.fromOffset(16, 0),
			Size = UDim2.new(1, -16, 1, 0),
			BackgroundTransparency = 1,
			Font = Theme.fontBody,
			Text = move.name,
			TextColor3 = if move.ultimate then Theme.text else Theme.textDim,
			TextSize = 11,
			TextTruncate = Enum.TextTruncate.AtEnd,
			TextXAlignment = Enum.TextXAlignment.Left,
		}),
	})
end

local function buildCard(definition, order: number, onPick)
	local cardStroke = Create("UIStroke", { Color = Theme.stroke, Thickness = 1.5 })

	local moveList = Create("Frame", {
		Name = "Moves",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -10),
		Size = UDim2.new(1, -24, 0, 62),
		BackgroundTransparency = 1,
		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 2),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	for index, move in definition.moves do
		buildMoveLine(move, index).Parent = moveList
	end

	local button = Create("TextButton", {
		Name = definition.id,
		Size = UDim2.fromOffset(264, 206),
		BackgroundColor3 = Theme.panel,
		BorderSizePixel = 0,
		AutoButtonColor = false,
		Text = "",
		LayoutOrder = order,
		ClipsDescendants = true,
		corner(10),
		cardStroke,

		-- Accent stripe so each card reads as its character at a glance.
		Create("Frame", {
			Name = "Accent",
			Size = UDim2.new(1, 0, 0, 3),
			BackgroundColor3 = definition.color,
			BorderSizePixel = 0,
		}),

		Create("TextLabel", {
			Name = "Name",
			Position = UDim2.fromOffset(12, 12),
			Size = UDim2.new(1, -24, 0, 18),
			BackgroundTransparency = 1,
			Font = Theme.fontHeavy,
			Text = string.upper(definition.name),
			TextColor3 = Theme.text,
			TextSize = 15,
			TextTruncate = Enum.TextTruncate.AtEnd,
			TextXAlignment = Enum.TextXAlignment.Left,
		}),

		Create("TextLabel", {
			Name = "Title",
			Position = UDim2.fromOffset(12, 30),
			Size = UDim2.new(1, -78, 0, 13),
			BackgroundTransparency = 1,
			Font = Theme.fontBold,
			Text = string.upper(definition.title),
			TextColor3 = definition.color,
			TextSize = 10,
			TextTruncate = Enum.TextTruncate.AtEnd,
			TextXAlignment = Enum.TextXAlignment.Left,
		}),

		-- Element name for the elemental picks, "HERO" for the rest.
		Create("TextLabel", {
			Name = "Tag",
			AnchorPoint = Vector2.new(1, 0),
			Position = UDim2.new(1, -12, 0, 12),
			Size = UDim2.fromOffset(58, 16),
			BackgroundColor3 = definition.color,
			BackgroundTransparency = 0.82,
			Font = Theme.fontHeavy,
			Text = string.upper(definition.element or definition.category),
			TextColor3 = definition.color,
			TextSize = 9,
			corner(4),
		}),

		Create("TextLabel", {
			Name = "Blurb",
			Position = UDim2.fromOffset(12, 48),
			Size = UDim2.new(1, -24, 0, 32),
			BackgroundTransparency = 1,
			Font = Theme.fontBody,
			Text = definition.blurb,
			TextColor3 = Theme.textFaint,
			TextSize = 10,
			TextWrapped = true,
			TextXAlignment = Enum.TextXAlignment.Left,
			TextYAlignment = Enum.TextYAlignment.Top,
		}),

		Create("TextLabel", {
			Name = "Stats",
			Position = UDim2.fromOffset(12, 82),
			Size = UDim2.new(1, -24, 0, 12),
			BackgroundTransparency = 1,
			Font = Theme.fontBold,
			Text = `{definition.maxHealth} HP   ·   {definition.walkSpeed} SPD`,
			TextColor3 = Theme.textFaint,
			TextSize = 9,
			TextXAlignment = Enum.TextXAlignment.Left,
		}),

		moveList,
	})

	button.MouseEnter:Connect(function()
		TweenService:Create(button, TweenInfo.new(0.12), { BackgroundColor3 = Theme.panelRaised }):Play()
	end)
	button.MouseLeave:Connect(function()
		TweenService:Create(button, TweenInfo.new(0.12), { BackgroundColor3 = Theme.panel }):Play()
	end)
	button.Activated:Connect(function()
		onPick(definition.id)
	end)

	return { button = button, stroke = cardStroke, definition = definition }
end

function CharacterSelect.new(parent: Instance, onPick)
	local self = setmetatable({}, CharacterSelect)

	self.cards = {}
	self.selected = nil

	-- Twelve cards do not fit in one screen of panel, so the grid scrolls and
	-- sizes its own canvas from the layout.
	local grid = Create("ScrollingFrame", {
		Name = "Grid",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, 72),
		Size = UDim2.new(1, -48, 1, -96),
		BackgroundTransparency = 1,
		BorderSizePixel = 0,
		CanvasSize = UDim2.new(),
		AutomaticCanvasSize = Enum.AutomaticSize.Y,
		ScrollBarThickness = 5,
		ScrollBarImageColor3 = Theme.strokeHot,
		ScrollBarImageTransparency = 0.35,
		ScrollingDirection = Enum.ScrollingDirection.Y,
		Create("UIGridLayout", {
			CellSize = UDim2.fromOffset(264, 206),
			CellPadding = UDim2.fromOffset(12, 12),
			HorizontalAlignment = Enum.HorizontalAlignment.Center,
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
		Create("UIPadding", { PaddingBottom = UDim.new(0, 12) }),
	})

	for order, id in Powers.Order do
		local card = buildCard(Powers.getCharacter(id), order, function(picked)
			onPick(picked)
			self:setSelected(picked)
			self:setVisible(false)
		end)
		card.button.Parent = grid
		self.cards[id] = card
	end

	local closeStroke = Create("UIStroke", { Color = Theme.stroke, Thickness = 1.5 })
	local closeButton = Create("TextButton", {
		Name = "Close",
		AnchorPoint = Vector2.new(1, 0),
		Position = UDim2.new(1, -24, 0, 20),
		Size = UDim2.fromOffset(30, 30),
		BackgroundColor3 = Theme.panelRaised,
		BorderSizePixel = 0,
		AutoButtonColor = false,
		Font = Theme.fontHeavy,
		Text = "✕",
		TextColor3 = Theme.textDim,
		TextSize = 15,
		corner(8),
		closeStroke,
	})

	closeButton.MouseEnter:Connect(function()
		closeStroke.Color = Theme.strokeHot
		closeButton.TextColor3 = Theme.text
	end)
	closeButton.MouseLeave:Connect(function()
		closeStroke.Color = Theme.stroke
		closeButton.TextColor3 = Theme.textDim
	end)
	closeButton.Activated:Connect(function()
		self:setVisible(false)
	end)

	local panel = Create("Frame", {
		Name = "Panel",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.5),
		Size = UDim2.fromOffset(864, 530),
		BackgroundColor3 = Theme.panel,
		BorderSizePixel = 0,
		corner(14),
		Create("UIStroke", { Color = Theme.stroke, Thickness = 2 }),
		Create("TextLabel", {
			Name = "Heading",
			Position = UDim2.fromOffset(28, 22),
			Size = UDim2.new(1, -56, 0, 22),
			BackgroundTransparency = 1,
			Font = Theme.fontHeavy,
			Text = "SELECT YOUR POWER",
			TextColor3 = Theme.text,
			TextSize = 19,
			TextXAlignment = Enum.TextXAlignment.Left,
		}),
		Create("TextLabel", {
			Name = "Hint",
			AnchorPoint = Vector2.new(1, 0),
			Position = UDim2.new(1, -68, 0, 26),
			Size = UDim2.fromOffset(200, 16),
			BackgroundTransparency = 1,
			Font = Theme.fontBold,
			Text = "CLICK A POWER TO EQUIP IT",
			TextColor3 = Theme.textFaint,
			TextSize = 11,
			TextXAlignment = Enum.TextXAlignment.Right,
		}),
		closeButton,
		grid,
	})

	self.root = Create("Frame", {
		Name = "CharacterSelect",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = Theme.backdrop,
		BackgroundTransparency = 0.25,
		BorderSizePixel = 0,
		Visible = false,
		ZIndex = 40,
		panel,
		Parent = parent,
	})

	return self
end

function CharacterSelect.setSelected(self, characterId: string)
	self.selected = characterId
	for id, card in self.cards do
		local picked = id == characterId
		card.stroke.Color = if picked then card.definition.color else Theme.stroke
		card.stroke.Thickness = if picked then 2.5 else 1.5
	end
end

function CharacterSelect.setVisible(self, visible: boolean)
	self.root.Visible = visible
end

function CharacterSelect.toggle(self)
	self.root.Visible = not self.root.Visible
	return self.root.Visible
end

return CharacterSelect
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="38">
            <Properties>
              <string name="Name">Create</string>
              <string name="Source"><![CDATA[--!strict
-- Minimal instance builder. Hash keys are properties, array entries are
-- children, so a whole widget reads as one nested literal.

local function Create(className: string, props): any
	local instance = Instance.new(className)

	local children = {}

	for key, value in props do
		if typeof(key) == "number" then
			table.insert(children, value)
		elseif key ~= "Parent" then
			(instance :: any)[key] = value
		end
	end

	for _, child in children do
		child.Parent = instance
	end

	-- Parent last so the instance is fully built before it replicates.
	if props.Parent then
		instance.Parent = props.Parent
	end

	return instance
end

return Create
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="39">
            <Properties>
              <string name="Name">DamageNumbers</string>
              <string name="Source"><![CDATA[--!strict
-- Floating damage numbers over whatever just got hit. Purely client-side —
-- the server already broadcasts the amount with each Damage effect.

local TweenService = game:GetService("TweenService")
local Debris = game:GetService("Debris")

local Create = require(script.Parent.Create)
local Theme = require(script.Parent.Theme)

local DamageNumbers = {}

local LIFETIME = 0.85

function DamageNumbers.show(targetModel: Model, amount: number, mine: boolean)
	if amount <= 0 then
		return
	end

	local head = targetModel:FindFirstChild("Head") or targetModel:FindFirstChild("HumanoidRootPart")
	if not head or not head:IsA("BasePart") then
		return
	end

	-- Scatter so a 20-tick barrage does not stack into one illegible blob.
	local drift = Vector3.new((math.random() - 0.5) * 2.6, 1.6, (math.random() - 0.5) * 2.6)

	local label = Create("TextLabel", {
		Name = "Amount",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		Font = Theme.fontHeavy,
		Text = tostring(math.floor(amount + 0.5)),
		-- Your own damage pops white; damage you are taking reads red.
		TextColor3 = if mine then Theme.text else Theme.health,
		TextSize = if amount >= 30 then 30 else 21,
		TextStrokeTransparency = 0.35,
	})

	local billboard = Create("BillboardGui", {
		Name = "DamageNumber",
		Size = UDim2.fromOffset(90, 40),
		StudsOffsetWorldSpace = drift,
		AlwaysOnTop = true,
		MaxDistance = 250,
		label,
		Parent = head,
	})

	Debris:AddItem(billboard, LIFETIME + 0.1)

	local info = TweenInfo.new(LIFETIME, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
	TweenService:Create(billboard, info, {
		StudsOffsetWorldSpace = drift + Vector3.new(0, 3.4, 0),
	}):Play()
	TweenService:Create(label, info, {
		TextTransparency = 1,
		TextStrokeTransparency = 1,
	}):Play()
end

return DamageNumbers
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="40">
            <Properties>
              <string name="Name">Meters</string>
              <string name="Source"><![CDATA[--!strict
-- Health / block / stamina readouts, plus the two full-screen prompts
-- (charge meter and get-up mash).

local ReplicatedStorage = game:GetService("ReplicatedStorage")

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

local Create = require(script.Parent.Create)
local Theme = require(script.Parent.Theme)

local Meters = {}
Meters.__index = Meters

local function corner(radius: number)
	return Create("UICorner", { CornerRadius = UDim.new(0, radius) })
end

local function buildBar(name: string, height: number, color: Color3, order: number)
	local fill = Create("Frame", {
		Name = "Fill",
		Size = UDim2.fromScale(1, 1),
		BackgroundColor3 = color,
		BorderSizePixel = 0,
		corner(math.floor(height / 2)),
	})

	local text = Create("TextLabel", {
		Name = "Value",
		AnchorPoint = Vector2.new(1, 0.5),
		Position = UDim2.new(1, -6, 0.5, 0),
		Size = UDim2.fromOffset(70, height),
		BackgroundTransparency = 1,
		Font = Theme.fontHeavy,
		Text = "",
		TextColor3 = Theme.text,
		TextSize = math.clamp(height - 4, 9, 14),
		TextXAlignment = Enum.TextXAlignment.Right,
		ZIndex = 3,
	})

	local track = Create("Frame", {
		Name = name,
		Size = UDim2.new(1, 0, 0, height),
		BackgroundColor3 = Theme.backdrop,
		BackgroundTransparency = 0.25,
		BorderSizePixel = 0,
		LayoutOrder = order,
		ClipsDescendants = true,
		corner(math.floor(height / 2)),
		Create("UIStroke", { Color = Theme.stroke, Thickness = 1, Transparency = 0.4 }),
		fill,
		text,
	})

	return { track = track, fill = fill, text = text }
end

function Meters.new(parent: Instance)
	local self = setmetatable({}, Meters)

	self.health = buildBar("Health", 16, Theme.health, 1)
	self.block = buildBar("Block", 8, Theme.block, 2)
	self.stamina = buildBar("Stamina", 8, Theme.stamina, 3)

	-- The two thin bars carry no number; the labels would just be noise.
	self.block.text.Visible = false
	self.stamina.text.Visible = false

	self.nameLabel = Create("TextLabel", {
		Name = "CharacterName",
		Size = UDim2.new(1, 0, 0, 15),
		BackgroundTransparency = 1,
		Font = Theme.fontHeavy,
		Text = "",
		TextColor3 = Theme.text,
		TextSize = 13,
		TextXAlignment = Enum.TextXAlignment.Left,
		LayoutOrder = 0,
	})

	local stack = Create("Frame", {
		Name = "Stack",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -132),
		Size = UDim2.fromOffset(330, 60),
		BackgroundTransparency = 1,
		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Vertical,
			Padding = UDim.new(0, 4),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
		self.nameLabel,
		self.health.track,
		self.block.track,
		self.stamina.track,
	})

	--------------------------------------------------------------------------
	-- charge meter
	--------------------------------------------------------------------------

	self.chargeFill = Create("Frame", {
		Name = "Fill",
		Size = UDim2.fromScale(0, 1),
		BackgroundColor3 = Theme.charge,
		BorderSizePixel = 0,
		corner(4),
	})

	self.chargeLabel = Create("TextLabel", {
		Name = "Label",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 0, -4),
		Size = UDim2.new(1, 0, 0, 16),
		BackgroundTransparency = 1,
		Font = Theme.fontHeavy,
		Text = "",
		TextColor3 = Theme.charge,
		TextSize = 13,
	})

	self.chargeBar = Create("Frame", {
		Name = "ChargeMeter",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.62),
		Size = UDim2.fromOffset(240, 9),
		BackgroundColor3 = Theme.backdrop,
		BackgroundTransparency = 0.2,
		BorderSizePixel = 0,
		Visible = false,
		ClipsDescendants = false,
		corner(4),
		Create("UIStroke", { Color = Theme.charge, Thickness = 1.5, Transparency = 0.3 }),
		self.chargeFill,
		self.chargeLabel,
	})

	--------------------------------------------------------------------------
	-- knockdown prompt
	--------------------------------------------------------------------------

	self.getupLabel = Create("TextLabel", {
		Name = "GetUpPrompt",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.7),
		Size = UDim2.fromOffset(300, 26),
		BackgroundTransparency = 1,
		Font = Theme.fontHeavy,
		Text = "MASH [SPACE] TO GET UP",
		TextColor3 = Theme.text,
		TextSize = 17,
		Visible = false,
	})

	self.stunLabel = Create("TextLabel", {
		Name = "StunPrompt",
		AnchorPoint = Vector2.new(0.5, 0.5),
		Position = UDim2.fromScale(0.5, 0.66),
		Size = UDim2.fromOffset(300, 22),
		BackgroundTransparency = 1,
		Font = Theme.fontHeavy,
		Text = "STUNNED",
		TextColor3 = Theme.healthLow,
		TextSize = 15,
		Visible = false,
	})

	self.root = Create("Frame", {
		Name = "Meters",
		Size = UDim2.fromScale(1, 1),
		BackgroundTransparency = 1,
		stack,
		self.chargeBar,
		self.getupLabel,
		self.stunLabel,
		Parent = parent,
	})

	return self
end

function Meters.setCharacter(self, characterId: string)
	local definition = Powers.getCharacter(characterId)
	if not definition then
		return
	end
	self.nameLabel.Text = `{string.upper(definition.name)}  ·  {string.upper(definition.title)}`
	self.nameLabel.TextColor3 = definition.color
end

function Meters.update(self, state)
	-- health
	local maxHealth = math.max(state.maxHealth or 1, 1)
	local ratio = math.clamp((state.health or 0) / maxHealth, 0, 1)
	self.health.fill.Size = UDim2.fromScale(ratio, 1)
	self.health.fill.BackgroundColor3 = if ratio <= 0.3 then Theme.healthLow else Theme.health
	self.health.text.Text = `{math.ceil(state.health or 0)} / {math.floor(maxHealth)}`

	-- block
	if state.block then
		local blockRatio = math.clamp(state.block.meter / math.max(state.block.max, 1), 0, 1)
		self.block.fill.Size = UDim2.fromScale(blockRatio, 1)
		self.block.fill.BackgroundColor3 = if state.block.broken then Theme.blockBroken else Theme.block
	end

	-- stamina
	if state.stamina then
		local staminaRatio = math.clamp(state.stamina.value / math.max(state.stamina.max, 1), 0, 1)
		self.stamina.fill.Size = UDim2.fromScale(staminaRatio, 1)
	end

	-- charge
	if state.charging then
		self.chargeBar.Visible = true
		self.chargeFill.Size = UDim2.fromScale(state.charging.ratio, 1)
		self.chargeLabel.Text = if state.charging.ratio >= 1
			then "MAX CHARGE — RELEASE"
			else `CHARGING  {math.floor(state.charging.ratio * 100)}%`
	else
		self.chargeBar.Visible = false
	end

	-- knockdown / stun
	self.getupLabel.Visible = (state.ragdoll or 0) > 0
	self.stunLabel.Visible = (state.ragdoll or 0) <= 0 and (state.stun or 0) > 0
end

return Meters
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="41">
            <Properties>
              <string name="Name">MoveBar</string>
              <string name="Source"><![CDATA[--!strict
-- Bottom-centre ability bar: four character slots on top of a row of the
-- universal binds.

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

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

local Create = require(script.Parent.Create)
local Theme = require(script.Parent.Theme)

local Universal = Powers.Universal

local MoveBar = {}
MoveBar.__index = MoveBar

local SLOT_KEYS = { "1", "2", "3", "4" }

local function corner(radius: number)
	return Create("UICorner", { CornerRadius = UDim.new(0, radius) })
end

local function stroke(color: Color3, thickness: number, transparency: number?)
	return Create("UIStroke", {
		Color = color,
		Thickness = thickness,
		Transparency = transparency or 0,
		ApplyStrokeMode = Enum.ApplyStrokeMode.Border,
	})
end

--------------------------------------------------------------------------------
-- one ability slot
--------------------------------------------------------------------------------

local function buildSlot(index: number, onHover)
	local cooldownFill = Create("Frame", {
		Name = "CooldownFill",
		AnchorPoint = Vector2.new(0, 1),
		Position = UDim2.fromScale(0, 1),
		Size = UDim2.fromScale(1, 0),
		BackgroundColor3 = Color3.fromRGB(0, 0, 0),
		BackgroundTransparency = 0.35,
		BorderSizePixel = 0,
		ZIndex = 3,
	})

	local cooldownText = Create("TextLabel", {
		Name = "CooldownText",
		BackgroundTransparency = 1,
		Size = UDim2.fromScale(1, 1),
		Font = Theme.fontHeavy,
		Text = "",
		TextColor3 = Theme.text,
		TextSize = 22,
		ZIndex = 5,
	})

	local keyBadge = Create("TextLabel", {
		Name = "Key",
		AnchorPoint = Vector2.new(0, 0),
		Position = UDim2.new(0, 5, 0, 4),
		Size = UDim2.fromOffset(18, 18),
		BackgroundTransparency = 1,
		Font = Theme.fontHeavy,
		Text = SLOT_KEYS[index],
		TextColor3 = Theme.textDim,
		TextSize = 15,
		TextXAlignment = Enum.TextXAlignment.Left,
		ZIndex = 4,
	})

	local nameLabel = Create("TextLabel", {
		Name = "MoveName",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -5),
		Size = UDim2.new(1, -8, 0, 24),
		BackgroundTransparency = 1,
		Font = Theme.fontBold,
		Text = "",
		TextColor3 = Theme.text,
		TextSize = 10,
		TextWrapped = true,
		TextYAlignment = Enum.TextYAlignment.Bottom,
		ZIndex = 4,
	})

	local glyph = Create("TextLabel", {
		Name = "Glyph",
		AnchorPoint = Vector2.new(0.5, 0),
		Position = UDim2.new(0.5, 0, 0, 16),
		Size = UDim2.new(1, 0, 0, 28),
		BackgroundTransparency = 1,
		Font = Theme.fontHeavy,
		Text = "",
		TextColor3 = Theme.text,
		TextSize = 24,
		ZIndex = 4,
	})

	local slotStroke = stroke(Theme.stroke, 1.5)

	local frame = Create("Frame", {
		Name = `Slot{index}`,
		Size = UDim2.fromOffset(Theme.slotSize, Theme.slotSize),
		BackgroundColor3 = Theme.panel,
		BorderSizePixel = 0,
		LayoutOrder = index,
		ClipsDescendants = true,
		corner(10),
		slotStroke,
		Create("UIGradient", {
			Color = ColorSequence.new(Color3.fromRGB(255, 255, 255), Color3.fromRGB(190, 195, 210)),
			Rotation = 90,
			Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, 0.86),
				NumberSequenceKeypoint.new(1, 0.97),
			}),
		}),
		glyph,
		keyBadge,
		nameLabel,
		cooldownFill,
		cooldownText,
	})

	frame.MouseEnter:Connect(function()
		onHover(index, true)
	end)
	frame.MouseLeave:Connect(function()
		onHover(index, false)
	end)

	return {
		frame = frame,
		stroke = slotStroke,
		glyph = glyph,
		name = nameLabel,
		key = keyBadge,
		fill = cooldownFill,
		text = cooldownText,
		move = nil,
		flashing = false,
	}
end

--------------------------------------------------------------------------------
-- universal bind pills
--------------------------------------------------------------------------------

local function buildPill(id: string, glyph: string, label: string, order: number)
	local body = Create("TextLabel", {
		Name = "Label",
		AnchorPoint = Vector2.new(0, 0.5),
		Position = UDim2.new(0, 30, 0.5, 0),
		Size = UDim2.new(1, -36, 1, 0),
		BackgroundTransparency = 1,
		Font = Theme.fontBold,
		Text = label,
		TextColor3 = Theme.textDim,
		TextSize = 11,
		TextXAlignment = Enum.TextXAlignment.Left,
	})

	local badge = Create("TextLabel", {
		Name = "Glyph",
		AnchorPoint = Vector2.new(0, 0.5),
		Position = UDim2.new(0, 5, 0.5, 0),
		Size = UDim2.fromOffset(22, 16),
		BackgroundColor3 = Theme.panelRaised,
		Font = Theme.fontHeavy,
		Text = glyph,
		TextColor3 = Theme.text,
		TextSize = 10,
		corner(4),
	})

	-- Dash draws its charges as pips along the bottom edge.
	local pips = Create("Frame", {
		Name = "Pips",
		AnchorPoint = Vector2.new(1, 0.5),
		Position = UDim2.new(1, -6, 0.5, 0),
		Size = UDim2.fromOffset(26, 6),
		BackgroundTransparency = 1,
		Visible = false,
		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			HorizontalAlignment = Enum.HorizontalAlignment.Right,
			VerticalAlignment = Enum.VerticalAlignment.Center,
			Padding = UDim.new(0, 3),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	local pillStroke = stroke(Theme.stroke, 1, 0.35)

	local frame = Create("Frame", {
		Name = id,
		Size = UDim2.fromOffset(112, 26),
		BackgroundColor3 = Theme.panel,
		BackgroundTransparency = 0.15,
		BorderSizePixel = 0,
		LayoutOrder = order,
		corner(6),
		pillStroke,
		badge,
		body,
		pips,
	})

	return { frame = frame, stroke = pillStroke, label = body, badge = badge, pips = pips, pipParts = {} }
end

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

function MoveBar.new(parent: Instance)
	local self = setmetatable({}, MoveBar)

	self.slots = {}
	self.pills = {}
	self.hovered = nil

	local tooltip = Create("Frame", {
		Name = "Tooltip",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -136),
		Size = UDim2.fromOffset(360, 62),
		BackgroundColor3 = Theme.panel,
		BackgroundTransparency = 0.08,
		BorderSizePixel = 0,
		Visible = false,
		ZIndex = 20,
		corner(8),
		stroke(Theme.stroke, 1.5),
		Create("TextLabel", {
			Name = "Title",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, 8),
			Size = UDim2.new(1, -20, 0, 16),
			BackgroundTransparency = 1,
			Font = Theme.fontHeavy,
			Text = "",
			TextColor3 = Theme.text,
			TextSize = 14,
			ZIndex = 21,
		}),
		Create("TextLabel", {
			Name = "Body",
			AnchorPoint = Vector2.new(0.5, 0),
			Position = UDim2.new(0.5, 0, 0, 26),
			Size = UDim2.new(1, -24, 0, 30),
			BackgroundTransparency = 1,
			Font = Theme.fontBody,
			Text = "",
			TextColor3 = Theme.textDim,
			TextSize = 11,
			TextWrapped = true,
			TextYAlignment = Enum.TextYAlignment.Top,
			ZIndex = 21,
		}),
	})
	self.tooltip = tooltip

	local function onHover(index: number, entered: boolean)
		self.hovered = if entered then index else nil
		local entry = self.slots[index]
		if not entered or not entry or not entry.move then
			tooltip.Visible = false
			return
		end
		tooltip.Title.Text = string.upper(entry.move.name)
		tooltip.Body.Text = entry.move.description
		tooltip.Visible = true
	end

	local slotRow = Create("Frame", {
		Name = "Slots",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -16),
		Size = UDim2.fromOffset(Theme.slotSize * 4 + Theme.slotGap * 3, Theme.slotSize),
		BackgroundTransparency = 1,
		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			HorizontalAlignment = Enum.HorizontalAlignment.Center,
			Padding = UDim.new(0, Theme.slotGap),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	for index = 1, 4 do
		local slot = buildSlot(index, onHover)
		slot.frame.Parent = slotRow
		self.slots[index] = slot
	end

	local pillRow = Create("Frame", {
		Name = "Universal",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.new(0.5, 0, 1, -98),
		Size = UDim2.fromOffset(112 * 4 + 18, 26),
		BackgroundTransparency = 1,
		Create("UIListLayout", {
			FillDirection = Enum.FillDirection.Horizontal,
			HorizontalAlignment = Enum.HorizontalAlignment.Center,
			Padding = UDim.new(0, 6),
			SortOrder = Enum.SortOrder.LayoutOrder,
		}),
	})

	local pillSpecs = {
		{ id = "M1", glyph = "M1", label = string.upper(Universal.M1.name) },
		{ id = "Block", glyph = "F", label = string.upper(Universal.Block.name) },
		{ id = "Dash", glyph = "Q", label = string.upper(Universal.Dash.name) },
		{ id = "Sprint", glyph = "⇧", label = string.upper(Universal.Sprint.name) },
	}

	for order, spec in pillSpecs do
		local pill = buildPill(spec.id, spec.glyph, spec.label, order)
		pill.frame.Parent = pillRow
		self.pills[spec.id] = pill
	end

	-- Dash pips, one per charge.
	local dashPill = self.pills.Dash
	dashPill.pips.Visible = true
	for i = 1, Universal.Dash.charges do
		local pip = Create("Frame", {
			Name = `Pip{i}`,
			Size = UDim2.fromOffset(6, 6),
			BackgroundColor3 = Theme.stamina,
			BorderSizePixel = 0,
			LayoutOrder = i,
			corner(3),
			Parent = dashPill.pips,
		})
		table.insert(dashPill.pipParts, pip)
	end

	local root = Create("Frame", {
		Name = "MoveBar",
		AnchorPoint = Vector2.new(0.5, 1),
		Position = UDim2.fromScale(0.5, 1),
		Size = UDim2.new(1, 0, 0, 190),
		BackgroundTransparency = 1,
		tooltip,
		pillRow,
		slotRow,
		Parent = parent,
	})
	self.root = root

	return self
end

function MoveBar.setCharacter(self, characterId: string)
	local definition = Powers.getCharacter(characterId)
	if not definition then
		return
	end

	self.accent = definition.color

	for index = 1, 4 do
		local slot = self.slots[index]
		local move = Powers.getMoveBySlot(characterId, index)
		slot.move = move

		if move then
			slot.name.Text = string.upper(move.name)
			slot.glyph.Text = string.sub(move.name, 1, 1)
			slot.glyph.TextColor3 = if move.ultimate then Theme.ultimate else definition.color
			slot.stroke.Color = if move.ultimate then Theme.ultimate else Theme.stroke
			slot.stroke.Thickness = if move.ultimate then 2 else 1.5
		else
			slot.name.Text = ""
			slot.glyph.Text = ""
		end
	end
end

local function formatCooldown(seconds: number): string
	if seconds <= 0 then
		return ""
	end
	if seconds >= 10 then
		return tostring(math.ceil(seconds))
	end
	return string.format("%.1f", seconds)
end

function MoveBar.update(self, state)
	for index = 1, 4 do
		local slot = self.slots[index]
		local move = slot.move
		if not move then
			continue
		end

		local remaining = (state.cooldowns and state.cooldowns[move.id]) or 0
		local ratio = if move.cooldown > 0 then math.clamp(remaining / move.cooldown, 0, 1) else 0

		slot.fill.Size = UDim2.fromScale(1, ratio)
		slot.text.Text = formatCooldown(remaining)
		slot.name.TextColor3 = if remaining > 0 then Theme.cooling else Theme.ready
		slot.key.TextColor3 = if remaining > 0 then Theme.textFaint else Theme.textDim

		-- Highlight whichever move is mid-charge.
		local isCharging = state.charging ~= nil and state.charging.moveId == move.id
		if isCharging then
			slot.stroke.Color = Theme.charge
			slot.stroke.Thickness = 2 + state.charging.ratio * 2
		elseif move.ultimate then
			slot.stroke.Color = Theme.ultimate
			slot.stroke.Thickness = 2
		else
			slot.stroke.Color = if remaining > 0 then Theme.stroke else Theme.strokeHot
			slot.stroke.Thickness = 1.5
		end
	end

	-- Block pill reflects meter state.
	local blockPill = self.pills.Block
	if state.block then
		if state.block.broken then
			blockPill.stroke.Color = Theme.blockBroken
			blockPill.label.TextColor3 = Theme.blockBroken
			blockPill.label.Text = "BROKEN"
		else
			blockPill.stroke.Color = if state.block.holding then Theme.block else Theme.stroke
			blockPill.label.TextColor3 = if state.block.holding then Theme.block else Theme.textDim
			blockPill.label.Text = string.upper(Universal.Block.name)
		end
	end

	-- Dash pips.
	if state.dash then
		local dashPill = self.pills.Dash
		for i, pip in dashPill.pipParts do
			local filled = i <= state.dash.count
			pip.BackgroundColor3 = if filled then Theme.stamina else Theme.panelRaised
			pip.BackgroundTransparency = if filled then 0 else 0.25
		end
		dashPill.stroke.Color = if state.dash.count > 0 then Theme.stroke else Theme.textFaint
	end

	-- Sprint pill dims when stamina is gone.
	local sprintPill = self.pills.Sprint
	if state.stamina then
		local empty = state.stamina.value <= 1
		sprintPill.label.TextColor3 = if empty then Theme.textFaint else Theme.textDim
	end
end

-- Quick pop when a move fires, so inputs feel acknowledged even on lag.
function MoveBar.flash(self, slotIndex: number)
	local slot = self.slots[slotIndex]
	if not slot then
		return
	end

	local original = UDim2.fromOffset(Theme.slotSize, Theme.slotSize)
	slot.frame.Size = UDim2.fromOffset(Theme.slotSize + 6, Theme.slotSize + 6)
	TweenService:Create(slot.frame, TweenInfo.new(0.16, Enum.EasingStyle.Quad), { Size = original }):Play()
end

return MoveBar
]]></string>
            </Properties>
          </Item>
          <Item class="ModuleScript" referent="42">
            <Properties>
              <string name="Name">Theme</string>
              <string name="Source"><![CDATA[--!strict

return {
	backdrop = Color3.fromRGB(10, 11, 15),
	panel = Color3.fromRGB(19, 21, 28),
	panelRaised = Color3.fromRGB(28, 31, 41),
	stroke = Color3.fromRGB(54, 59, 74),
	strokeHot = Color3.fromRGB(120, 130, 156),

	text = Color3.fromRGB(238, 240, 247),
	textDim = Color3.fromRGB(140, 148, 167),
	textFaint = Color3.fromRGB(92, 99, 116),

	health = Color3.fromRGB(228, 74, 70),
	healthLow = Color3.fromRGB(255, 108, 78),
	block = Color3.fromRGB(84, 162, 234),
	blockBroken = Color3.fromRGB(120, 126, 142),
	stamina = Color3.fromRGB(238, 196, 62),
	charge = Color3.fromRGB(255, 142, 48),
	ultimate = Color3.fromRGB(255, 152, 56),

	ready = Color3.fromRGB(236, 238, 245),
	cooling = Color3.fromRGB(96, 103, 122),

	fontHeavy = Enum.Font.GothamBlack,
	fontBold = Enum.Font.GothamBold,
	fontBody = Enum.Font.GothamMedium,

	slotSize = 74,
	slotGap = 8,
}
]]></string>
            </Properties>
          </Item>
        </Item>
      </Item>
    </Item>
  </Item>
</roblox>