Quick actions

cmd+k|ctrl+k

Navigation

Languages

ParkourSystem_v2

Snippet info

Language

Lua

Visibility

public

Author

panickyhospital950

Created

2026-04-04T00:58:40.241418Z

Updated

2026-04-04T00:58:40.241418Z

--[[
    ██████╗  █████╗ ██████╗ ██╗  ██╗ ██████╗ ██╗   ██╗██████╗ 
    ██╔══██╗██╔══██╗██╔══██╗██║ ██╔╝██╔═══██╗██║   ██║██╔══██╗
    ██████╔╝███████║██████╔╝█████╔╝ ██║   ██║██║   ██║██████╔╝
    ██╔═══╝ ██╔══██║██╔══██╗██╔═██╗ ██║   ██║██║   ██║██╔══██╗
    ██║     ██║  ██║██║  ██║██║  ██╗╚██████╔╝╚██████╔╝██║  ██║
    ╚═╝     ╚═╝  ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝ ╚═════╝  ╚═════╝ ╚═╝  ╚═╝
    
    Advanced Parkour System v2.0
    - Wall Run (Left/Right detection + gravity curve)
    - Slide (Speed burst + hitbox shrink + camera tilt)
    - Vault (Obstacle detection + lerp over)
    - Custom procedural animations (no imports)
    - Minecraft-style HUD (blocky stamina bar + move indicator)
    
    Keybinds:
      [LEFT SHIFT] while moving  = Slide
      [SPACE] near wall + moving = Wall Run
      [SPACE] near low obstacle  = Vault
]]

-- // Services
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local TweenService = game:GetService("TweenService")

local Player = Players.LocalPlayer
local Camera = workspace.CurrentCamera

-- // Wait for character
local Character = Player.Character or Player.CharacterAdded:Wait()
local Humanoid = Character:WaitForChild("Humanoid")
local RootPart = Character:WaitForChild("HumanoidRootPart")
local Animator = Humanoid:WaitForChild("Animator")

-- // Respawn handler
Player.CharacterAdded:Connect(function(char)
    Character = char
    Humanoid = char:WaitForChild("Humanoid")
    RootPart = char:WaitForChild("HumanoidRootPart")
    Animator = Humanoid:WaitForChild("Animator")
end)

-- // Config
local CONFIG = {
    -- Wall Run
    WallRunSpeed        = 28,
    WallRunDuration     = 1.4,
    WallRunGravity      = 0.35,
    WallRunRayDist      = 3.2,
    WallRunCooldown     = 0.5,
    WallRunMinSpeed     = 8,
    WallRunCameraTilt   = 15,

    -- Slide
    SlideSpeed          = 38,
    SlideDuration       = 1.0,
    SlideCooldown       = 0.6,
    SlideHitboxScale    = 0.5,
    SlideCameraTilt     = -8,
    SlideMinSpeed       = 10,
    SlideFriction       = 0.92,

    -- Vault
    VaultRayDist        = 4.5,
    VaultMaxHeight      = 7.5,
    VaultMinHeight      = 1.5,
    VaultSpeed          = 0.3,
    VaultCooldown       = 0.4,

    -- Stamina
    MaxStamina          = 100,
    StaminaRegen        = 18,
    WallRunStaminaCost  = 30,
    SlideStaminaCost    = 15,
    VaultStaminaCost    = 10,

    -- General
    DefaultWalkSpeed    = 16,
    SprintSpeed         = 24,
}

-- // State
local State = {
    isWallRunning   = false,
    wallRunSide     = nil, -- "Left" or "Right"
    wallRunTime     = 0,
    wallRunCooldown = 0,

    isSliding       = false,
    slideTime       = 0,
    slideCooldown   = 0,
    slideDir        = nil,

    isVaulting      = false,
    vaultCooldown   = 0,

    stamina         = CONFIG.MaxStamina,
    isSprinting     = false,
    shiftHeld       = false,
    spaceHeld       = false,
}

------------------------------------------------------------------------
-- MINECRAFT-STYLE HUD
------------------------------------------------------------------------
local function CreateHUD()
    -- Destroy old GUI if re-running
    local old = Player.PlayerGui:FindFirstChild("ParkourHUD")
    if old then old:Destroy() end

    local ScreenGui = Instance.new("ScreenGui")
    ScreenGui.Name = "ParkourHUD"
    ScreenGui.ResetOnSpawn = false
    ScreenGui.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
    ScreenGui.Parent = Player.PlayerGui

    -- Main container (bottom center)
    local Container = Instance.new("Frame")
    Container.Name = "Container"
    Container.Size = UDim2.new(0, 280, 0, 64)
    Container.Position = UDim2.new(0.5, -140, 1, -90)
    Container.BackgroundColor3 = Color3.fromRGB(30, 30, 30)
    Container.BorderSizePixel = 0
    Container.Parent = ScreenGui

    -- Blocky border (MC style - 3px dark outline)
    local Border = Instance.new("UIStroke")
    Border.Thickness = 3
    Border.Color = Color3.fromRGB(12, 12, 12)
    Border.Parent = Container

    -- Inner border highlight
    local InnerBorder = Instance.new("Frame")
    InnerBorder.Name = "InnerBorder"
    InnerBorder.Size = UDim2.new(1, -6, 1, -6)
    InnerBorder.Position = UDim2.new(0, 3, 0, 3)
    InnerBorder.BackgroundColor3 = Color3.fromRGB(48, 48, 48)
    InnerBorder.BorderSizePixel = 0
    InnerBorder.Parent = Container

    local InnerStroke = Instance.new("UIStroke")
    InnerStroke.Thickness = 1
    InnerStroke.Color = Color3.fromRGB(60, 60, 60)
    InnerStroke.Parent = InnerBorder

    -- Stamina label (pixely look using Code font)
    local StaminaLabel = Instance.new("TextLabel")
    StaminaLabel.Name = "StaminaLabel"
    StaminaLabel.Size = UDim2.new(0, 70, 0, 16)
    StaminaLabel.Position = UDim2.new(0, 10, 0, 6)
    StaminaLabel.BackgroundTransparency = 1
    StaminaLabel.Text = "STAMINA"
    StaminaLabel.TextColor3 = Color3.fromRGB(180, 180, 180)
    StaminaLabel.TextSize = 11
    StaminaLabel.Font = Enum.Font.Code
    StaminaLabel.TextXAlignment = Enum.TextXAlignment.Left
    StaminaLabel.Parent = InnerBorder

    -- Stamina bar background
    local StaminaBG = Instance.new("Frame")
    StaminaBG.Name = "StaminaBG"
    StaminaBG.Size = UDim2.new(1, -20, 0, 14)
    StaminaBG.Position = UDim2.new(0, 10, 0, 22)
    StaminaBG.BackgroundColor3 = Color3.fromRGB(20, 20, 20)
    StaminaBG.BorderSizePixel = 0
    StaminaBG.Parent = InnerBorder

    local BarStroke = Instance.new("UIStroke")
    BarStroke.Thickness = 2
    BarStroke.Color = Color3.fromRGB(10, 10, 10)
    BarStroke.Parent = StaminaBG

    -- Stamina bar fill
    local StaminaFill = Instance.new("Frame")
    StaminaFill.Name = "StaminaFill"
    StaminaFill.Size = UDim2.new(1, 0, 1, 0)
    StaminaFill.BackgroundColor3 = Color3.fromRGB(50, 205, 50)
    StaminaFill.BorderSizePixel = 0
    StaminaFill.Parent = StaminaBG

    -- Pixelated shine on stamina bar (MC hearts style)
    local Shine = Instance.new("Frame")
    Shine.Name = "Shine"
    Shine.Size = UDim2.new(1, 0, 0.35, 0)
    Shine.BackgroundColor3 = Color3.fromRGB(255, 255, 255)
    Shine.BackgroundTransparency = 0.8
    Shine.BorderSizePixel = 0
    Shine.Parent = StaminaFill

    -- Move indicator label
    local MoveLabel = Instance.new("TextLabel")
    MoveLabel.Name = "MoveLabel"
    MoveLabel.Size = UDim2.new(1, -20, 0, 18)
    MoveLabel.Position = UDim2.new(0, 10, 0, 38)
    MoveLabel.BackgroundTransparency = 1
    MoveLabel.Text = "[ IDLE ]"
    MoveLabel.TextColor3 = Color3.fromRGB(255, 255, 85)
    MoveLabel.TextSize = 13
    MoveLabel.Font = Enum.Font.Code
    MoveLabel.TextXAlignment = Enum.TextXAlignment.Center
    MoveLabel.Parent = InnerBorder

    -- Crosshair (MC style - small + in center)
    local Crosshair = Instance.new("TextLabel")
    Crosshair.Name = "Crosshair"
    Crosshair.Size = UDim2.new(0, 20, 0, 20)
    Crosshair.Position = UDim2.new(0.5, -10, 0.5, -10)
    Crosshair.BackgroundTransparency = 1
    Crosshair.Text = "+"
    Crosshair.TextColor3 = Color3.fromRGB(255, 255, 255)
    Crosshair.TextSize = 22
    Crosshair.Font = Enum.Font.Code
    Crosshair.TextStrokeTransparency = 0.5
    Crosshair.TextStrokeColor3 = Color3.fromRGB(0, 0, 0)
    Crosshair.Parent = ScreenGui

    -- Keybind hint (top left, MC-style tooltip)
    local HintFrame = Instance.new("Frame")
    HintFrame.Name = "HintFrame"
    HintFrame.Size = UDim2.new(0, 220, 0, 80)
    HintFrame.Position = UDim2.new(0, 12, 0, 12)
    HintFrame.BackgroundColor3 = Color3.fromRGB(20, 10, 30)
    HintFrame.BackgroundTransparency = 0.25
    HintFrame.BorderSizePixel = 0
    HintFrame.Parent = ScreenGui

    local HintStroke = Instance.new("UIStroke")
    HintStroke.Thickness = 2
    HintStroke.Color = Color3.fromRGB(80, 40, 120)
    HintStroke.Parent = HintFrame

    local HintText = Instance.new("TextLabel")
    HintText.Size = UDim2.new(1, -12, 1, -8)
    HintText.Position = UDim2.new(0, 6, 0, 4)
    HintText.BackgroundTransparency = 1
    HintText.RichText = true
    HintText.Text = '<font color="#FFFF55">[SHIFT]</font> Slide\n<font color="#FFFF55">[SPACE]</font> Wall Run / Vault\n<font color="#FFFF55">[W+SHIFT]</font> Sprint'
    HintText.TextColor3 = Color3.fromRGB(200, 200, 200)
    HintText.TextSize = 12
    HintText.Font = Enum.Font.Code
    HintText.TextXAlignment = Enum.TextXAlignment.Left
    HintText.TextYAlignment = Enum.TextYAlignment.Top
    HintText.Parent = HintFrame

    -- Fade hint after 8 seconds
    task.delay(8, function()
        local tw = TweenService:Create(HintFrame, TweenInfo.new(1.5), {BackgroundTransparency = 1})
        local tw2 = TweenService:Create(HintStroke, TweenInfo.new(1.5), {Transparency = 1})
        local tw3 = TweenService:Create(HintText, TweenInfo.new(1.5), {TextTransparency = 1})
        tw:Play() tw2:Play() tw3:Play()
    end)

    return {
        StaminaFill = StaminaFill,
        MoveLabel = MoveLabel,
    }
end

local HUD = CreateHUD()

------------------------------------------------------------------------
-- CUSTOM PROCEDURAL ANIMATIONS (no imported anims)
------------------------------------------------------------------------
local AnimationModule = {}

-- Create a KeyframeSequence programmatically, register as animation
local function BuildAnimation(name, length, looped, keyframes)
    local kfs = Instance.new("KeyframeSequence")
    kfs.Name = name
    kfs.Loop = looped

    for _, kfData in ipairs(keyframes) do
        local kf = Instance.new("Keyframe")
        kf.Time = kfData.Time

        for _, poseData in ipairs(kfData.Poses) do
            local pose = Instance.new("Pose")
            pose.Name = poseData.Name
            pose.CFrame = poseData.CFrame
            pose.EasingStyle = poseData.EasingStyle or Enum.PoseEasingStyle.Linear
            pose.EasingDirection = poseData.EasingDirection or Enum.PoseEasingDirection.InOut

            -- Handle sub-poses (limb hierarchy)
            if poseData.SubPoses then
                for _, subData in ipairs(poseData.SubPoses) do
                    local subPose = Instance.new("Pose")
                    subPose.Name = subData.Name
                    subPose.CFrame = subData.CFrame
                    subPose.EasingStyle = subData.EasingStyle or Enum.PoseEasingStyle.Linear
                    subPose.Parent = pose
                end
            end

            pose.Parent = kf
        end

        kf.Parent = kfs
    end

    -- Register with animator
    local animId = Animator:LoadAnimation(
        (function()
            local anim = Instance.new("Animation")
            local reg = game:GetService("KeyframeSequenceProvider"):RegisterKeyframeSequence(kfs)
            anim.AnimationId = reg
            return anim
        end)()
    )

    return animId
end

-- WALL RUN ANIMATION (body tilts sideways, legs cycling)
local function CreateWallRunAnim(side)
    local tiltAngle = side == "Right" and math.rad(-35) or math.rad(35)
    local armReach = side == "Right" and math.rad(-60) or math.rad(60)

    return BuildAnimation("WallRun_" .. side, 0.6, true, {
        {
            Time = 0,
            Poses = {
                {
                    Name = "HumanoidRootPart",
                    CFrame = CFrame.Angles(0, 0, tiltAngle * 0.3),
                    SubPoses = {
                        {
                            Name = "LowerTorso",
                            CFrame = CFrame.Angles(math.rad(10), 0, 0),
                        }
                    }
                },
                {
                    Name = "LeftUpperLeg",
                    CFrame = CFrame.Angles(math.rad(-45), 0, 0),
                },
                {
                    Name = "RightUpperLeg",
                    CFrame = CFrame.Angles(math.rad(35), 0, 0),
                },
                {
                    Name = "LeftUpperArm",
                    CFrame = CFrame.Angles(math.rad(20), 0, math.rad(-15)),
                },
                {
                    Name = "RightUpperArm",
                    CFrame = CFrame.Angles(armReach, 0, math.rad(15)),
                },
            }
        },
        {
            Time = 0.3,
            Poses = {
                {
                    Name = "HumanoidRootPart",
                    CFrame = CFrame.Angles(0, 0, tiltAngle * 0.3),
                },
                {
                    Name = "LeftUpperLeg",
                    CFrame = CFrame.Angles(math.rad(35), 0, 0),
                },
                {
                    Name = "RightUpperLeg",
                    CFrame = CFrame.Angles(math.rad(-45), 0, 0),
                },
                {
                    Name = "LeftUpperArm",
                    CFrame = CFrame.Angles(math.rad(-30), 0, math.rad(-15)),
                },
                {
                    Name = "RightUpperArm",
                    CFrame = CFrame.Angles(math.rad(10), 0, math.rad(15)),
                },
            }
        },
    })
end

-- SLIDE ANIMATION (crouched, one leg forward, arms back)
local SlideAnim = BuildAnimation("Slide", 0.4, false, {
    {
        Time = 0,
        Poses = {
            {
                Name = "HumanoidRootPart",
                CFrame = CFrame.Angles(math.rad(-5), 0, 0),
                SubPoses = {
                    {
                        Name = "LowerTorso",
                        CFrame = CFrame.Angles(math.rad(-25), 0, 0),
                    }
                }
            },
            {
                Name = "LeftUpperLeg",
                CFrame = CFrame.Angles(math.rad(70), 0, 0),
            },
            {
                Name = "RightUpperLeg",
                CFrame = CFrame.Angles(math.rad(15), 0, math.rad(10)),
            },
            {
                Name = "LeftUpperArm",
                CFrame = CFrame.Angles(math.rad(30), 0, math.rad(-25)),
            },
            {
                Name = "RightUpperArm",
                CFrame = CFrame.Angles(math.rad(30), 0, math.rad(25)),
            },
            {
                Name = "UpperTorso",
                CFrame = CFrame.Angles(math.rad(-30), 0, 0),
            },
            {
                Name = "Head",
                CFrame = CFrame.Angles(math.rad(20), 0, 0),
            },
        }
    },
})

-- VAULT ANIMATION (hands reach forward, body lifts up and over)
local VaultAnim = BuildAnimation("Vault", 0.35, false, {
    {
        Time = 0,
        Poses = {
            {
                Name = "HumanoidRootPart",
                CFrame = CFrame.Angles(math.rad(15), 0, 0),
            },
            {
                Name = "LeftUpperArm",
                CFrame = CFrame.Angles(math.rad(-120), 0, math.rad(-10)),
            },
            {
                Name = "RightUpperArm",
                CFrame = CFrame.Angles(math.rad(-120), 0, math.rad(10)),
            },
            {
                Name = "LeftUpperLeg",
                CFrame = CFrame.Angles(math.rad(-40), 0, 0),
            },
            {
                Name = "RightUpperLeg",
                CFrame = CFrame.Angles(math.rad(-40), 0, 0),
            },
        }
    },
    {
        Time = 0.2,
        Poses = {
            {
                Name = "HumanoidRootPart",
                CFrame = CFrame.Angles(math.rad(-20), 0, 0),
            },
            {
                Name = "LeftUpperArm",
                CFrame = CFrame.Angles(math.rad(40), 0, math.rad(-20)),
            },
            {
                Name = "RightUpperArm",
                CFrame = CFrame.Angles(math.rad(40), 0, math.rad(20)),
            },
            {
                Name = "LeftUpperLeg",
                CFrame = CFrame.Angles(math.rad(50), 0, 0),
            },
            {
                Name = "RightUpperLeg",
                CFrame = CFrame.Angles(math.rad(50), 0, 0),
            },
        }
    },
})

-- Pre-build wall run anims
local WallRunLeftAnim = CreateWallRunAnim("Left")
local WallRunRightAnim = CreateWallRunAnim("Right")

local currentAnim = nil

local function PlayAnim(track)
    if currentAnim and currentAnim.IsPlaying then
        currentAnim:Stop(0.15)
    end
    currentAnim = track
    track:Play(0.15)
end

local function StopAnim()
    if currentAnim and currentAnim.IsPlaying then
        currentAnim:Stop(0.2)
        currentAnim = nil
    end
end

------------------------------------------------------------------------
-- RAYCAST HELPERS
------------------------------------------------------------------------
local RayParams = RaycastParams.new()
RayParams.FilterType = Enum.RaycastFilterType.Exclude

local function UpdateRayFilter()
    RayParams.FilterDescendantsInstances = {Character}
end
UpdateRayFilter()
Player.CharacterAdded:Connect(function()
    task.wait(0.5)
    UpdateRayFilter()
end)

local function CastRay(origin, direction)
    return workspace:Raycast(origin, direction, RayParams)
end

-- Check for wall on a specific side
local function CheckWall(side)
    if not RootPart or not RootPart.Parent then return nil end
    local dir = side == "Right" and RootPart.CFrame.RightVector or -RootPart.CFrame.RightVector
    local result = CastRay(RootPart.Position, dir * CONFIG.WallRunRayDist)
    if result and result.Instance and result.Instance.CanCollide then
        -- Verify it's a roughly vertical surface
        local dot = math.abs(result.Normal:Dot(Vector3.new(0, 1, 0)))
        if dot < 0.3 then
            return result
        end
    end
    return nil
end

-- Check for vaultable obstacle ahead
local function CheckVault()
    if not RootPart or not RootPart.Parent then return nil, nil end
    local lookDir = RootPart.CFrame.LookVector

    -- Cast forward at waist height
    local waistResult = CastRay(
        RootPart.Position + Vector3.new(0, -1, 0),
        lookDir * CONFIG.VaultRayDist
    )

    if not waistResult or not waistResult.Instance or not waistResult.Instance.CanCollide then
        return nil, nil
    end

    -- Cast downward from above the obstacle to find the top
    local topOrigin = waistResult.Position + lookDir * 1.5 + Vector3.new(0, CONFIG.VaultMaxHeight, 0)
    local topResult = CastRay(topOrigin, Vector3.new(0, -CONFIG.VaultMaxHeight * 2, 0))

    if topResult then
        local obstacleHeight = topResult.Position.Y - (RootPart.Position.Y - 3)
        if obstacleHeight >= CONFIG.VaultMinHeight and obstacleHeight <= CONFIG.VaultMaxHeight then
            return waistResult, topResult
        end
    end

    return nil, nil
end

-- Check if character is grounded
local function IsGrounded()
    if not RootPart or not RootPart.Parent then return false end
    local result = CastRay(RootPart.Position, Vector3.new(0, -3.5, 0))
    return result ~= nil
end

-- Get movement direction
local function GetMoveDirection()
    if not Humanoid then return Vector3.zero end
    return Humanoid.MoveDirection
end

local function IsMoving()
    return GetMoveDirection().Magnitude > 0.1
end

------------------------------------------------------------------------
-- CAMERA TILT
------------------------------------------------------------------------
local currentTilt = 0
local targetTilt = 0

local function SetCameraTilt(degrees)
    targetTilt = math.rad(degrees)
end

------------------------------------------------------------------------
-- WALL RUN SYSTEM
------------------------------------------------------------------------
local wallRunBV = nil

local function StartWallRun(side, wallResult)
    if State.isWallRunning or State.isSliding or State.isVaulting then return end
    if State.stamina < CONFIG.WallRunStaminaCost * 0.3 then return end
    if tick() - State.wallRunCooldown < CONFIG.WallRunCooldown then return end

    State.isWallRunning = true
    State.wallRunSide = side
    State.wallRunTime = 0

    -- Calculate wall-parallel direction (upward + forward along wall)
    local wallNormal = wallResult.Normal
    local wallForward = wallNormal:Cross(Vector3.new(0, 1, 0)).Unit
    if RootPart.CFrame.LookVector:Dot(wallForward) < 0 then
        wallForward = -wallForward
    end

    -- Create BodyVelocity for wall run movement
    wallRunBV = Instance.new("BodyVelocity")
    wallRunBV.MaxForce = Vector3.new(50000, 50000, 50000)
    wallRunBV.P = 10000
    wallRunBV.Parent = RootPart

    -- Play animation
    local animTrack = side == "Right" and WallRunRightAnim or WallRunLeftAnim
    PlayAnim(animTrack)

    -- Camera tilt
    SetCameraTilt(side == "Right" and CONFIG.WallRunCameraTilt or -CONFIG.WallRunCameraTilt)

    -- Wall run update loop
    local startTick = tick()
    local conn
    conn = RunService.Heartbeat:Connect(function(dt)
        if not State.isWallRunning then
            if conn then conn:Disconnect() end
            return
        end

        State.wallRunTime = tick() - startTick
        State.stamina = math.max(0, State.stamina - CONFIG.WallRunStaminaCost * dt)

        -- Gravity curve: starts going up, gradually arcs down
        local t = State.wallRunTime / CONFIG.WallRunDuration
        local upForce = CONFIG.WallRunSpeed * (1 - t * CONFIG.WallRunGravity * 2.5)
        local forwardForce = CONFIG.WallRunSpeed * (1 - t * 0.3)

        if wallRunBV and wallRunBV.Parent then
            wallRunBV.Velocity = wallForward * forwardForce + Vector3.new(0, math.max(upForce, -10), 0)
        end

        -- Re-check wall
        local newWall = CheckWall(side)

        -- End conditions
        if State.wallRunTime >= CONFIG.WallRunDuration
            or State.stamina <= 0
            or not newWall
            or IsGrounded() and State.wallRunTime > 0.2
            or not State.spaceHeld then
            StopWallRun()
            if conn then conn:Disconnect() end
        end
    end)
end

function StopWallRun()
    if not State.isWallRunning then return end

    State.isWallRunning = false
    State.wallRunCooldown = tick()

    if wallRunBV then
        wallRunBV:Destroy()
        wallRunBV = nil
    end

    -- Small upward boost on release (wall jump)
    if RootPart and RootPart.Parent then
        local jumpBV = Instance.new("BodyVelocity")
        jumpBV.MaxForce = Vector3.new(30000, 30000, 30000)
        jumpBV.Velocity = RootPart.CFrame.LookVector * 18 + Vector3.new(0, 28, 0)
        jumpBV.Parent = RootPart
        game:GetService("Debris"):AddItem(jumpBV, 0.15)
    end

    SetCameraTilt(0)
    StopAnim()
end

------------------------------------------------------------------------
-- SLIDE SYSTEM
------------------------------------------------------------------------
local slideBV = nil
local originalHipHeight = nil

local function StartSlide()
    if State.isSliding or State.isWallRunning or State.isVaulting then return end
    if not IsGrounded() or not IsMoving() then return end
    if State.stamina < CONFIG.SlideStaminaCost * 0.5 then return end
    if tick() - State.slideCooldown < CONFIG.SlideCooldown then return end

    local moveDir = GetMoveDirection()
    if moveDir.Magnitude < 0.1 then return end

    -- Check minimum speed for slide
    if RootPart.AssemblyLinearVelocity.Magnitude < CONFIG.SlideMinSpeed then return end

    State.isSliding = true
    State.slideTime = 0
    State.slideDir = moveDir.Unit
    State.stamina = math.max(0, State.stamina - CONFIG.SlideStaminaCost)

    -- Shrink hitbox
    originalHipHeight = Humanoid.HipHeight
    Humanoid.HipHeight = originalHipHeight * CONFIG.SlideHitboxScale

    -- Slide velocity
    slideBV = Instance.new("BodyVelocity")
    slideBV.MaxForce = Vector3.new(40000, 0, 40000)
    slideBV.P = 8000
    slideBV.Velocity = State.slideDir * CONFIG.SlideSpeed
    slideBV.Parent = RootPart

    -- Animation + camera
    PlayAnim(SlideAnim)
    SetCameraTilt(CONFIG.SlideCameraTilt)

    local startTick = tick()
    local conn
    conn = RunService.Heartbeat:Connect(function(dt)
        if not State.isSliding then
            if conn then conn:Disconnect() end
            return
        end

        State.slideTime = tick() - startTick

        -- Apply friction
        if slideBV and slideBV.Parent then
            local speed = slideBV.Velocity.Magnitude * CONFIG.SlideFriction
            slideBV.Velocity = State.slideDir * speed
        end

        -- End conditions
        if State.slideTime >= CONFIG.SlideDuration or not State.shiftHeld then
            StopSlide()
            if conn then conn:Disconnect() end
        end
    end)
end

function StopSlide()
    if not State.isSliding then return end

    State.isSliding = false
    State.slideCooldown = tick()

    if slideBV then
        slideBV:Destroy()
        slideBV = nil
    end

    if originalHipHeight and Humanoid then
        Humanoid.HipHeight = originalHipHeight
        originalHipHeight = nil
    end

    SetCameraTilt(0)
    StopAnim()
end

------------------------------------------------------------------------
-- VAULT SYSTEM
------------------------------------------------------------------------
local function StartVault()
    if State.isVaulting or State.isWallRunning or State.isSliding then return end
    if not IsMoving() then return end
    if State.stamina < CONFIG.VaultStaminaCost then return end
    if tick() - State.vaultCooldown < CONFIG.VaultCooldown then return end

    local waistHit, topHit = CheckVault()
    if not waistHit or not topHit then return end

    State.isVaulting = true
    State.stamina = math.max(0, State.stamina - CONFIG.VaultStaminaCost)

    -- Play vault animation
    PlayAnim(VaultAnim)

    -- Calculate vault path
    local startPos = RootPart.Position
    local topPos = topHit.Position + Vector3.new(0, 3.5, 0)
    local endPos = topPos + RootPart.CFrame.LookVector * 4

    -- Disable default physics during vault
    local bg = Instance.new("BodyGyro")
    bg.MaxTorque = Vector3.new(100000, 100000, 100000)
    bg.CFrame = RootPart.CFrame
    bg.Parent = RootPart

    local bv = Instance.new("BodyVelocity")
    bv.MaxForce = Vector3.new(100000, 100000, 100000)
    bv.P = 15000
    bv.Parent = RootPart

    -- Smooth vault lerp
    local elapsed = 0
    local duration = CONFIG.VaultSpeed
    local conn
    conn = RunService.Heartbeat:Connect(function(dt)
        if not State.isVaulting then
            if conn then conn:Disconnect() end
            return
        end

        elapsed = elapsed + dt
        local alpha = math.min(elapsed / duration, 1)

        -- Bezier curve: start -> top -> end
        local t = alpha
        local p0 = startPos
        local p1 = topPos + Vector3.new(0, 2, 0) -- control point (arc peak)
        local p2 = endPos

        local pos = (1 - t)^2 * p0 + 2 * (1 - t) * t * p1 + t^2 * p2

        if bv and bv.Parent and RootPart and RootPart.Parent then
            bv.Velocity = (pos - RootPart.Position) * 30
        end

        if alpha >= 1 then
            State.isVaulting = false
            State.vaultCooldown = tick()
            if bv then bv:Destroy() end
            if bg then bg:Destroy() end
            StopAnim()
            if conn then conn:Disconnect() end
        end
    end)
end

------------------------------------------------------------------------
-- INPUT HANDLING
------------------------------------------------------------------------
UserInputService.InputBegan:Connect(function(input, gpe)
    if gpe then return end

    if input.KeyCode == Enum.KeyCode.LeftShift then
        State.shiftHeld = true

        if IsMoving() and IsGrounded() then
            -- If already moving fast, slide; otherwise sprint
            if RootPart and RootPart.AssemblyLinearVelocity.Magnitude >= CONFIG.SlideMinSpeed then
                StartSlide()
            else
                State.isSprinting = true
                Humanoid.WalkSpeed = CONFIG.SprintSpeed
            end
        else
            State.isSprinting = true
            Humanoid.WalkSpeed = CONFIG.SprintSpeed
        end
    end

    if input.KeyCode == Enum.KeyCode.Space then
        State.spaceHeld = true

        if not IsGrounded() and not State.isWallRunning then
            -- Try wall run
            local rightWall = CheckWall("Right")
            local leftWall = CheckWall("Left")

            if rightWall and IsMoving() then
                StartWallRun("Right", rightWall)
            elseif leftWall and IsMoving() then
                StartWallRun("Left", leftWall)
            end
        elseif IsGrounded() and IsMoving() then
            -- Try vault
            StartVault()
        end
    end
end)

UserInputService.InputEnded:Connect(function(input, gpe)
    if input.KeyCode == Enum.KeyCode.LeftShift then
        State.shiftHeld = false
        State.isSprinting = false
        if not State.isSliding and not State.isWallRunning then
            Humanoid.WalkSpeed = CONFIG.DefaultWalkSpeed
        end
    end

    if input.KeyCode == Enum.KeyCode.Space then
        State.spaceHeld = false
    end
end)

------------------------------------------------------------------------
-- MAIN UPDATE LOOP (Stamina, HUD, Camera Tilt)
------------------------------------------------------------------------
RunService.RenderStepped:Connect(function(dt)
    if not Character or not Character.Parent then return end
    if not Humanoid or Humanoid.Health <= 0 then return end

    -- Stamina regen (when not doing parkour moves)
    if not State.isWallRunning and not State.isSliding and not State.isVaulting then
        State.stamina = math.min(CONFIG.MaxStamina, State.stamina + CONFIG.StaminaRegen * dt)
    end

    -- Walk speed reset guard
    if not State.isSliding and not State.isWallRunning and not State.isVaulting then
        if State.isSprinting then
            Humanoid.WalkSpeed = CONFIG.SprintSpeed
        else
            Humanoid.WalkSpeed = CONFIG.DefaultWalkSpeed
        end
    end

    -- Update HUD
    if HUD.StaminaFill and HUD.StaminaFill.Parent then
        local pct = State.stamina / CONFIG.MaxStamina
        HUD.StaminaFill.Size = UDim2.new(math.max(pct, 0), 0, 1, 0)

        -- Color shift: green -> yellow -> red
        if pct > 0.5 then
            HUD.StaminaFill.BackgroundColor3 = Color3.fromRGB(50, 205, 50)
        elseif pct > 0.25 then
            HUD.StaminaFill.BackgroundColor3 = Color3.fromRGB(255, 200, 40)
        else
            HUD.StaminaFill.BackgroundColor3 = Color3.fromRGB(220, 50, 50)
        end
    end

    -- Move label
    if HUD.MoveLabel and HUD.MoveLabel.Parent then
        local label = "[ IDLE ]"
        local color = Color3.fromRGB(150, 150, 150)

        if State.isWallRunning then
            label = ">> WALL RUN [" .. (State.wallRunSide or "?") .. "] <<"
            color = Color3.fromRGB(85, 255, 255)
        elseif State.isSliding then
            label = ">> SLIDE <<"
            color = Color3.fromRGB(255, 170, 50)
        elseif State.isVaulting then
            label = ">> VAULT <<"
            color = Color3.fromRGB(170, 85, 255)
        elseif State.isSprinting and IsMoving() then
            label = ">> SPRINT <<"
            color = Color3.fromRGB(255, 255, 85)
        elseif IsMoving() then
            label = "[ MOVING ]"
            color = Color3.fromRGB(200, 200, 200)
        end

        HUD.MoveLabel.Text = label
        HUD.MoveLabel.TextColor3 = color
    end

    -- Smooth camera tilt
    currentTilt = currentTilt + (targetTilt - currentTilt) * math.min(dt * 12, 1)
    Camera.CFrame = Camera.CFrame * CFrame.Angles(0, 0, currentTilt)
end)

------------------------------------------------------------------------
-- CLEANUP ON DEATH
------------------------------------------------------------------------
Humanoid.Died:Connect(function()
    StopWallRun()
    StopSlide()
    State.isVaulting = false
    StopAnim()
    SetCameraTilt(0)
end)

print("[PARKOUR] System loaded! | SHIFT=Slide/Sprint | SPACE=WallRun/Vault")
INFO