Poppy Playtime Grab pack
Key RequiredUniversal

Poppy Playtime Grab pack

Universal ScriptEducationalistEducationalist 2.5K views 1.8K executesUpdated Feb 20, 2026
5.0/ 5(1 votes)
10100% Positive
Lua Script•981 lines (34379 chars)
-- Grab Pack Script (LocalScript in StarterCharacterScripts)
-- Refined: Launch & Grab only, improved hand contact detection

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

local player = Players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local humanoid = character:WaitForChild("Humanoid")
local humanoidRootPart = character:WaitForChild("HumanoidRootPart")
local camera = workspace.CurrentCamera

-- ============================================
-- CONFIGURATION
-- ============================================
local Config = {
    -- Hand movement
    HandMaxLength   = 120,
    HandSpeed       = 90,
    RetractSpeed    = 95,
    HandStickRadius = 3.5,      -- how close hand must be to stick to a surface

    -- Launch
    LaunchForce         = 145,
    LaunchVerticalBoost = 32,
    LaunchDuration      = 0.30,

    -- String visuals
    StringRadius    = 0.07,
    StringPulseSpeed= 3,

    -- Hand visuals
    HandSize        = Vector3.new(1.2, 1.2, 1.2),
    WristSize       = Vector3.new(0.5, 0.5, 0.5),

    -- Colors
    BlueColor       = Color3.fromRGB(0,   120, 255),
    RedColor        = Color3.fromRGB(220,  30,  30),
    StringBlueColor = Color3.fromRGB(0,    80, 200),
    StringRedColor  = Color3.fromRGB(180,  20,  20),

    -- Timing
    ShootCooldown     = 0.20,
    DoubleClickWindow = 0.40,

    -- Grab
    MaxGrabbableSize  = 80,
    GrabWeldStiffness = 5000,   -- how firmly held objects are kept in place

    -- Backpack bob
    BobSpeed  = 2.5,
    BobAmount = 0.04,
}

-- ============================================
-- STATE
-- ============================================
local GrabPack = {
    equipped  = false,
    bobTimer  = 0,

    hands = {
        blue = {
            active       = false,
            extended     = false,
            grabbed      = false,
            grabTarget   = nil,
            grabOffset   = CFrame.new(),   -- offset from grabbed part
            grabWeld     = nil,
            grabNormal   = Vector3.new(0,1,0),
            position     = Vector3.new(),
            direction    = Vector3.new(),
            wrapPoints   = {},
            model        = nil,
            string       = {},
            returning    = false,
            clickCount   = 0,
            clickTimer   = 0,
            lastShotTime = 0,
            pulseTimer   = 0,
            -- identity
            color        = Color3.fromRGB(0, 120, 255),
            stringColor  = Color3.fromRGB(0,  80, 200),
            side         = "Left",
        },
        red = {
            active       = false,
            extended     = false,
            grabbed      = false,
            grabTarget   = nil,
            grabOffset   = CFrame.new(),
            grabWeld     = nil,
            grabNormal   = Vector3.new(0,1,0),
            position     = Vector3.new(),
            direction    = Vector3.new(),
            wrapPoints   = {},
            model        = nil,
            string       = {},
            returning    = false,
            clickCount   = 0,
            clickTimer   = 0,
            lastShotTime = 0,
            pulseTimer   = 0,
            color        = Color3.fromRGB(220, 30, 30),
            stringColor  = Color3.fromRGB(180, 20, 20),
            side         = "Right",
        },
    },

    backpackModel = nil,
    gui           = nil,
}

-- ============================================
-- UTILITY
-- ============================================
local function createPart(parent, size, color, transparency, anchored, cancollide, material)
    local p = Instance.new("Part")
    p.Size             = size         or Vector3.new(1,1,1)
    p.Color            = color        or Color3.new(1,1,1)
    p.Transparency     = transparency or 0
    p.Anchored         = anchored ~= nil and anchored or true
    p.CanCollide       = cancollide   ~= nil and cancollide or false
    p.Material         = material     or Enum.Material.SmoothPlastic
    p.CastShadow       = false
    p.Parent           = parent
    return p
end

local function playSound(id, pos, vol, pitch)
    local p = Instance.new("Part")
    p.Anchored = true; p.CanCollide = false
    p.Transparency = 1; p.Size = Vector3.new(0.1,0.1,0.1)
    p.Position = pos or Vector3.new()
    p.Parent   = workspace
    local s    = Instance.new("Sound", p)
    s.SoundId  = id or ""; s.Volume = vol or 0.6
    s.PlaybackSpeed    = pitch or 1
    s.RollOffMaxDistance = 70
    s:Play()
    Debris:AddItem(p, 4)
end

local function tweenTransparency(part, target, dur)
    TweenService:Create(part,
        TweenInfo.new(dur or 0.12, Enum.EasingStyle.Quad),
        {Transparency = target}
    ):Play()
end

-- Apply a short-lived BodyVelocity on the root part
local function applyRootVelocity(velocity, duration)
    local old = humanoidRootPart:FindFirstChild("GrabPackBV")
    if old then old:Destroy() end

    local bv = Instance.new("BodyVelocity")
    bv.Name      = "GrabPackBV"
    bv.Velocity  = velocity
    bv.MaxForce  = Vector3.new(1e5, 1e5, 1e5)
    bv.P         = 1e4
    bv.Parent    = humanoidRootPart
    Debris:AddItem(bv, duration or 0.3)
end

local function clearRootVelocity()
    local bv = humanoidRootPart:FindFirstChild("GrabPackBV")
    if bv then bv:Destroy() end
end

-- ============================================
-- STRING ORIGIN (wrist position)
-- ============================================
local function getStringOrigin(handData)
    local armName = handData.side == "Left" and "LeftHand" or "RightHand"
    local arm = character:FindFirstChild(armName)
    if arm then return arm.Position end
    return humanoidRootPart.Position
        + Vector3.new(handData.side == "Left" and -1.5 or 1.5, 0, 0)
end

-- ============================================
-- RAYCAST PARAMS (exclude character)
-- ============================================
local function makeRayParams(extraExcludes)
    local p = RaycastParams.new()
    local excludes = {character}
    if extraExcludes then
        for _, v in ipairs(extraExcludes) do
            table.insert(excludes, v)
        end
    end
    p.FilterDescendantsInstances = excludes
    p.FilterType = Enum.RaycastFilterType.Exclude
    return p
end

-- ============================================
-- BACKPACK MODEL
-- ============================================
local function buildBackpack()
    local folder = Instance.new("Folder")
    folder.Name   = "GrabPackModel"
    folder.Parent = workspace

    local body       = createPart(folder, Vector3.new(1.8, 2.0, 0.65), Color3.fromRGB(28, 28, 33))
    local trim       = createPart(folder, Vector3.new(1.85, 2.05, 0.1), Color3.fromRGB(55, 55, 65))
    local strap1     = createPart(folder, Vector3.new(0.15, 1.8, 0.1),  Color3.fromRGB(45, 45, 45))
    local strap2     = createPart(folder, Vector3.new(0.15, 1.8, 0.1),  Color3.fromRGB(45, 45, 45))

    local blueHousing = createPart(folder, Vector3.new(0.55,0.55,0.55), Config.BlueColor)
    blueHousing.Shape    = Enum.PartType.Ball
    blueHousing.Material = Enum.Material.Neon

    local redHousing  = createPart(folder, Vector3.new(0.55,0.55,0.55), Config.RedColor)
    redHousing.Shape    = Enum.PartType.Ball
    redHousing.Material = Enum.Material.Neon

    local lightBlue = createPart(folder, Vector3.new(0.15,0.15,0.15), Config.BlueColor)
    lightBlue.Material = Enum.Material.Neon; lightBlue.Shape = Enum.PartType.Ball

    local lightRed  = createPart(folder, Vector3.new(0.15,0.15,0.15), Config.RedColor)
    lightRed.Material  = Enum.Material.Neon; lightRed.Shape  = Enum.PartType.Ball

    GrabPack.backpackModel = {
        folder = folder, body = body, trim = trim,
        strap1 = strap1, strap2 = strap2,
        blueHousing = blueHousing, redHousing = redHousing,
        lightBlue = lightBlue, lightRed = lightRed,
    }
end

local function updateBackpackPosition()
    if not GrabPack.backpackModel then return end
    local torso = character:FindFirstChild("UpperTorso") or character:FindFirstChild("Torso")
    if not torso then return end

    GrabPack.bobTimer = GrabPack.bobTimer + (1/60) * Config.BobSpeed
    local bob = math.sin(GrabPack.bobTimer) * Config.BobAmount
    local cf  = torso.CFrame * CFrame.new(0, bob, 0.78)
    local m   = GrabPack.backpackModel

    m.body.CFrame        = cf
    m.trim.CFrame        = cf * CFrame.new(0, 0, 0.3)
    m.strap1.CFrame      = cf * CFrame.new(-0.6, 0, -0.1)
    m.strap2.CFrame      = cf * CFrame.new( 0.6, 0, -0.1)
    m.blueHousing.CFrame = cf * CFrame.new(-0.72, 0.32, -0.2)
    m.redHousing.CFrame  = cf * CFrame.new( 0.72, 0.32, -0.2)
    m.lightBlue.CFrame   = cf * CFrame.new(-0.5, -0.7, -0.28)
    m.lightRed.CFrame    = cf * CFrame.new( 0.5, -0.7, -0.28)

    -- Pulse indicator lights
    local t = tick()
    local blueActive = GrabPack.hands.blue.active
    local redActive  = GrabPack.hands.red.active
    m.lightBlue.Transparency = blueActive and (0.2 + math.abs(math.sin(t*4))*0.4) or 0.6
    m.lightRed.Transparency  = redActive  and (0.2 + math.abs(math.sin(t*4))*0.4) or 0.6
end

-- ============================================
-- HAND MODEL
-- ============================================
local function setHandVisible(handData, visible)
    if not handData.model then return end
    local t = visible and 0 or 1
    tweenTransparency(handData.model.palm,    t, 0.10)
    tweenTransparency(handData.model.wrist,   t, 0.10)
    tweenTransparency(handData.model.thumb,   t, 0.10)
    tweenTransparency(handData.model.forearm, t, 0.10)
    for _, f in ipairs(handData.model.fingers)  do tweenTransparency(f, t, 0.10) end
    for _, k in ipairs(handData.model.knuckles) do tweenTransparency(k, t, 0.10) end
    handData.model.visible = visible
    if handData.model.light then
        handData.model.light.Enabled = visible
    end
end

local function buildHand(handData)
    local folder = Instance.new("Folder")
    folder.Name   = handData.side .. "Hand"
    folder.Parent = workspace

    local palm    = createPart(folder, Config.HandSize, handData.color, 1)
    palm.Shape    = Enum.PartType.Ball

    local fingers = {}
    for _ = 1, 4 do
        table.insert(fingers,
            createPart(folder, Vector3.new(0.18, 0.52, 0.18), handData.color, 1))
    end

    local thumb   = createPart(folder, Vector3.new(0.18, 0.42, 0.18), handData.color, 1)
    local wrist   = createPart(folder, Config.WristSize, handData.color, 1)
    local forearm = createPart(folder, Vector3.new(0.38, 0.38, 0.9),   handData.color, 1)

    local knuckles = {}
    for _ = 1, 4 do
        local k = createPart(folder, Vector3.new(0.14,0.14,0.14), handData.color, 1)
        k.Shape = Enum.PartType.Ball
        table.insert(knuckles, k)
    end

    local core = createPart(folder, Vector3.new(0.5,0.5,0.5), handData.color, 1,
        true, false, Enum.Material.Neon)
    core.Shape = Enum.PartType.Ball

    local light = Instance.new("PointLight")
    light.Color      = handData.color
    light.Brightness = 2.5
    light.Range      = 10
    light.Enabled    = false
    light.Parent     = palm

    -- Selection box to highlight grabbed objects
    local selBox = Instance.new("SelectionBox")
    selBox.LineThickness       = 0.05
    selBox.Color3              = handData.color
    selBox.SurfaceTransparency = 0.8
    selBox.SurfaceColor3       = handData.color
    selBox.Visible             = false
    selBox.Parent              = folder

    handData.model = {
        folder   = folder,
        palm     = palm,
        fingers  = fingers,
        thumb    = thumb,
        wrist    = wrist,
        forearm  = forearm,
        knuckles = knuckles,
        core     = core,
        light    = light,
        selBox   = selBox,
        visible  = false,
    }

    setHandVisible(handData, false)
end

local function updateHandModel(handData, dt)
    if not handData.model then return end
    local pos = handData.position
    local dir = handData.direction
    if dir.Magnitude < 0.01 then dir = Vector3.new(0,0,-1) end

    local cf = CFrame.new(pos, pos + dir)

    handData.model.palm.CFrame    = cf
    handData.model.wrist.CFrame   = cf * CFrame.new(0, 0,  0.55)
    handData.model.forearm.CFrame = cf * CFrame.new(0, 0,  1.0)
    handData.model.core.CFrame    = cf

    -- Finger curl
    local curl = handData.grabbed and 0.8 or 0.3
    local fingerOffsets = {
        CFrame.new(-0.35, 0.40, 0.2) * CFrame.Angles(curl, 0, 0),
        CFrame.new(-0.12, 0.45, 0.2) * CFrame.Angles(curl, 0, 0),
        CFrame.new( 0.12, 0.45, 0.2) * CFrame.Angles(curl, 0, 0),
        CFrame.new( 0.35, 0.40, 0.2) * CFrame.Angles(curl, 0, 0),
    }
    for i, f in ipairs(handData.model.fingers) do
        f.CFrame = cf * fingerOffsets[i]
    end
    handData.model.thumb.CFrame = cf * CFrame.new(-0.5, 0.1, 0.3) * CFrame.Angles(0, 0, 0.8)

    local knuckleOffsets = {
        CFrame.new(-0.35, 0.52, 0.05),
        CFrame.new(-0.12, 0.57, 0.05),
        CFrame.new( 0.12, 0.57, 0.05),
        CFrame.new( 0.35, 0.52, 0.05),
    }
    for i, k in ipairs(handData.model.knuckles) do
        k.CFrame = cf * knuckleOffsets[i]
    end

    -- Neon core pulse
    handData.pulseTimer = (handData.pulseTimer or 0) + dt * Config.StringPulseSpeed
    local pulse = math.abs(math.sin(handData.pulseTimer))
    if handData.grabbed then
        handData.model.core.Color        = handData.color
        handData.model.core.Transparency = 0.3 + pulse * 0.4
        handData.model.light.Brightness  = 2   + pulse * 2
    else
        handData.model.core.Color        = handData.color
        handData.model.core.Transparency = 1
        handData.model.light.Brightness  = 2.5
    end

    -- Highlight grabbed object
    if handData.grabbed and handData.grabTarget then
        handData.model.selBox.Adornee = handData.grabTarget
        handData.model.selBox.Visible = true
    else
        handData.model.selBox.Visible = false
    end
end

-- ============================================
-- STRING SYSTEM
-- ============================================
local function clearString(handData)
    for _, seg in ipairs(handData.string) do
        if seg and seg.Parent then seg:Destroy() end
    end
    handData.string = {}
end

local function updateString(handData, dt)
    clearString(handData)
    if not handData.active then return end

    local folder = handData.model and handData.model.folder or workspace
    local origin = getStringOrigin(handData)

    local points = {origin}
    for _, wp in ipairs(handData.wrapPoints) do
        table.insert(points, wp.position)
    end
    table.insert(points, handData.position)

    handData.pulseTimer = (handData.pulseTimer or 0) + (dt or 0) * Config.StringPulseSpeed
    local pulseAlpha = 0.5 + math.abs(math.sin(handData.pulseTimer)) * 0.5
    local pulseColor = handData.stringColor:Lerp(Color3.new(1,1,1), pulseAlpha * 0.18)

    for i = 1, #points - 1 do
        local p1, p2 = points[i], points[i+1]
        local dist   = (p2 - p1).Magnitude
        if dist < 0.01 then continue end

        local seg    = Instance.new("Part")
        seg.Size     = Vector3.new(Config.StringRadius*2, Config.StringRadius*2, dist)
        seg.CFrame   = CFrame.new((p1+p2)/2, p2)
        seg.Color    = pulseColor
        seg.Anchored = true; seg.CanCollide = false
        seg.Material = Enum.Material.Neon; seg.CastShadow = false
        seg.Parent   = folder
        table.insert(handData.string, seg)
    end
end

-- ============================================
-- WRAP / UNWRAP
-- ============================================
local function checkWrap(handData, oldPos, newPos)
    local params = makeRayParams(handData.model and {handData.model.folder} or nil)
    local dir    = newPos - oldPos
    if dir.Magnitude < 0.01 then return false, nil end

    local result = workspace:Raycast(oldPos, dir, params)
    if result then
        local wrapPos = result.Position + result.Normal * 0.05
        table.insert(handData.wrapPoints, {
            position = wrapPos,
            normal   = result.Normal,
            instance = result.Instance,
        })
        return true, result.Position
    end
    return false, nil
end

local function checkUnwrap(handData)
    if #handData.wrapPoints == 0 then return end
    local origin   = getStringOrigin(handData)
    local lastWrap = handData.wrapPoints[#handData.wrapPoints]
    local prevPos  = #handData.wrapPoints > 1
        and handData.wrapPoints[#handData.wrapPoints - 1].position
        or  origin

    if (handData.position - prevPos).Magnitude
     < (handData.position - lastWrap.position).Magnitude * 0.5 then
        table.remove(handData.wrapPoints, #handData.wrapPoints)
    end
end

-- ============================================
-- AIM  (multi-cast for reliable hit detection)
-- ============================================
local function getAimTarget(handData)
    local params = makeRayParams(handData.model and {handData.model.folder} or nil)

    local unitRay = camera:ScreenPointToRay(
        camera.ViewportSize.X / 2,
        camera.ViewportSize.Y / 2
    )

    -- Primary centre ray
    local result = workspace:Raycast(
        unitRay.Origin,
        unitRay.Direction * Config.HandMaxLength,
        params
    )

    if result then
        return result.Position + result.Normal * 0.08, result.Instance, result.Normal
    end

    -- Secondary spread rays for better surface contact
    local spread = 0.06
    local offsets = {
        Vector3.new( spread, 0, 0), Vector3.new(-spread, 0, 0),
        Vector3.new(0,  spread, 0), Vector3.new(0, -spread, 0),
        Vector3.new( spread,  spread, 0), Vector3.new(-spread, -spread, 0),
    }
    for _, off in ipairs(offsets) do
        local tweakedDir = (unitRay.Direction + off).Unit * Config.HandMaxLength
        local r2 = workspace:Raycast(unitRay.Origin, tweakedDir, params)
        if r2 then
            return r2.Position + r2.Normal * 0.08, r2.Instance, r2.Normal
        end
    end

    -- Nothing hit — return max range point
    return unitRay.Origin + unitRay.Direction * Config.HandMaxLength, nil, nil
end

-- ============================================
-- GRAB  (attach hand to any BasePart)
-- ============================================
local function attemptGrab(handData)
    local inst = handData.targetInstance
    if not inst then return false end
    if not inst:IsA("BasePart") then return false end
    if inst:IsDescendantOf(character) then return false end

    local sz = inst.Size
    if sz.X > Config.MaxGrabbableSize
    or sz.Y > Config.MaxGrabbableSize
    or sz.Z > Config.MaxGrabbableSize then
        return false
    end

    handData.grabbed    = true
    handData.grabTarget = inst
    handData.grabNormal = handData.targetNormal or Vector3.new(0,1,0)

    -- Store offset so we can track unanchored parts
    handData.grabOffset = inst.CFrame:ToObjectSpace(CFrame.new(handData.position))

    playSound("rbxassetid://9120386436", handData.position, 0.7, 1)
    return true
end

-- ============================================
-- RETRACT
-- ============================================
local function retractHand(handData)
    -- Release grabbed object
    if handData.grabWeld then
        handData.grabWeld:Destroy()
        handData.grabWeld = nil
    end

    handData.extended   = false
    handData.grabbed    = false
    handData.grabTarget = nil
    handData.returning  = true

    if handData.model then
        handData.model.selBox.Visible = false
    end

    playSound("rbxassetid://9120386436", handData.position, 0.45, 1.1)
end

-- ============================================
-- LAUNCH  (propel player toward grabbed point)
-- ============================================
local function launchToHand(handData)
    if not handData.grabbed then return end
    local target = handData.position
    local dir    = (target - humanoidRootPart.Position)
    if dir.Magnitude < 0.1 then return end

    local launchDir = (dir.Unit
        + Vector3.new(0, Config.LaunchVerticalBoost / Config.LaunchForce, 0)).Unit

    applyRootVelocity(launchDir * Config.LaunchForce, Config.LaunchDuration)

    playSound("rbxassetid://9120386436", humanoidRootPart.Position, 0.8, 0.95)

    -- Auto-retract after launch
    task.delay(Config.LaunchDuration + 0.08, function()
        retractHand(handData)
    end)

    handData.clickCount = 0
end

-- ============================================
-- SHOOT
-- ============================================
local function shootHand(handData)
    local now = tick()
    if now - (handData.lastShotTime or 0) < Config.ShootCooldown then return end

    -- Already active: double-tap = launch, single tap = retract
    if handData.active then
        handData.clickCount = handData.clickCount + 1
        handData.clickTimer = 0

        if handData.clickCount >= 2 and handData.grabbed then
            launchToHand(handData)
        elseif not handData.grabbed then
            retractHand(handData)
        end
        return
    end

    -- Fire new shot
    handData.active       = true
    handData.extended     = false
    handData.grabbed      = false
    handData.returning    = false
    handData.wrapPoints   = {}
    handData.clickCount   = 1
    handData.clickTimer   = 0
    handData.lastShotTime = now

    local targetPos, targetInst, targetNormal = getAimTarget(handData)
    handData.targetPos      = targetPos
    handData.targetInstance = targetInst
    handData.targetNormal   = targetNormal or Vector3.new(0, 1, 0)

    local origin           = getStringOrigin(handData)
    handData.position      = origin
    handData.direction     = (targetPos - origin).Unit
    handData.distance      = 0

    setHandVisible(handData, true)
    playSound("rbxassetid://9120386436", origin, 0.7, 1 + math.random() * 0.1)
end

-- ============================================
-- HAND MOVEMENT UPDATE
-- ============================================
local function updateHandMovement(handData, dt)
    if not handData.active then return end

    local origin = getStringOrigin(handData)

    -- ── Retracting ───────────────────────────────
    if handData.returning then
        local target   = #handData.wrapPoints > 0
            and handData.wrapPoints[#handData.wrapPoints].position
            or  origin

        local moveDir  = target - handData.position
        local moveDist = moveDir.Magnitude

        if moveDist < 0.5 then
            if #handData.wrapPoints > 0 then
                table.remove(handData.wrapPoints, #handData.wrapPoints)
            else
                -- Hand fully retracted
                handData.active     = false
                handData.extended   = false
                handData.returning  = false
                handData.grabbed    = false
                handData.clickCount = 0
                handData.wrapPoints = {}
                setHandVisible(handData, false)
                clearString(handData)
            end
        else
            local step         = math.min(Config.RetractSpeed * dt, moveDist)
            handData.position  = handData.position + moveDir.Unit * step
            handData.direction = moveDir.Unit
        end

    -- ── Holding a grabbed part ────────────────────
    elseif handData.grabbed then
        if handData.grabTarget and handData.grabTarget.Parent then
            -- Track unanchored parts
            if not handData.grabTarget.Anchored then
                local worldGrabPos = handData.grabTarget.CFrame
                    * handData.grabOffset
                handData.position = worldGrabPos.Position
            end
            checkUnwrap(handData)
        else
            -- Part disappeared
            retractHand(handData)
        end

    -- ── Extending ────────────────────────────────
    else
        local targetPos = handData.targetPos
        local moveDir   = targetPos - handData.position
        local moveDist  = moveDir.Magnitude

        local oldPos = handData.position
        local step   = math.min(Config.HandSpeed * dt, moveDist)
        local newPos = handData.position
            + (moveDist > 0.001 and moveDir.Unit * step or Vector3.new())

        local wrapped, wrapPos = checkWrap(handData, oldPos, newPos)
        handData.position = wrapped and (wrapPos or newPos) or newPos
        if moveDist > 0.001 then
            handData.direction = moveDir.Unit
        end

        -- Arrived at target?
        if moveDist <= Config.HandStickRadius then
            handData.extended  = true
            handData.position  = targetPos

            if not attemptGrab(handData) then
                -- Nothing to grab – retract after a moment
                task.delay(0.5, function()
                    if handData.active and not handData.grabbed then
                        retractHand(handData)
                    end
                end)
            end
        end

        -- Max range reached
        if (handData.position - origin).Magnitude >= Config.HandMaxLength then
            handData.extended = true
            if not handData.grabbed then
                task.delay(0.5, function()
                    if handData.active and not handData.grabbed then
                        retractHand(handData)
                    end
                end)
            end
        end
    end
end

-- ============================================
-- CLICK TIMER
-- ============================================
local function updateClickTimers(dt)
    for _, h in pairs(GrabPack.hands) do
        if h.clickTimer < Config.DoubleClickWindow then
            h.clickTimer = h.clickTimer + dt
        end
    end
end

-- ============================================
-- GUI
-- ============================================
local function buildGUI()
    local sg = Instance.new("ScreenGui")
    sg.Name             = "GrabPackGUI"
    sg.ResetOnSpawn     = false
    sg.IgnoreGuiInset   = true
    sg.Parent           = player.PlayerGui

    -- Panel
    local panel = Instance.new("Frame", sg)
    panel.Size                   = UDim2.new(0, 255, 0, 90)
    panel.Position               = UDim2.new(0.5, -127, 1, -112)
    panel.BackgroundColor3       = Color3.fromRGB(12, 12, 12)
    panel.BackgroundTransparency = 0.22
    panel.BorderSizePixel        = 0
    Instance.new("UICorner", panel).CornerRadius = UDim.new(0, 14)
    local stroke = Instance.new("UIStroke", panel)
    stroke.Color = Color3.fromRGB(80,80,90); stroke.Thickness = 1.2; stroke.Transparency = 0.4

    local function makeBtn(color, x, label, keyText)
        local btn = Instance.new("TextButton", panel)
        btn.Size = UDim2.new(0, 108, 0, 68); btn.Position = UDim2.new(0, x, 0, 11)
        btn.BackgroundColor3 = color; btn.Text = ""; btn.BorderSizePixel = 0
        Instance.new("UICorner", btn).CornerRadius = UDim.new(0, 10)

        local title = Instance.new("TextLabel", btn)
        title.Size = UDim2.new(1,0,0.42,0); title.Position = UDim2.new(0,0,0.04,0)
        title.BackgroundTransparency = 1; title.Text = label
        title.TextColor3 = Color3.new(1,1,1); title.TextScaled = true
        title.Font = Enum.Font.GothamBold

        local status = Instance.new("TextLabel", btn)
        status.Name = "Status"; status.Size = UDim2.new(1,0,0.36,0)
        status.Position = UDim2.new(0,0,0.46,0)
        status.BackgroundTransparency = 1; status.Text = "READY"
        status.TextColor3 = Color3.new(1,1,1); status.TextScaled = true
        status.Font = Enum.Font.Gotham

        local coolBar = Instance.new("Frame", btn)
        coolBar.Name = "CooldownBar"; coolBar.Size = UDim2.new(0,0,0,3)
        coolBar.Position = UDim2.new(0,0,1,-3)
        coolBar.BackgroundColor3 = Color3.new(1,1,1); coolBar.BorderSizePixel = 0
        coolBar.BackgroundTransparency = 0.3
        Instance.new("UICorner", coolBar).CornerRadius = UDim.new(0,2)

        local keyLbl = Instance.new("TextLabel", panel)
        keyLbl.Size = UDim2.new(0,108,0,14); keyLbl.Position = UDim2.new(0,x,1,-18)
        keyLbl.BackgroundTransparency = 1; keyLbl.Text = keyText
        keyLbl.TextColor3 = color:Lerp(Color3.new(1,1,1),0.4)
        keyLbl.TextScaled = true; keyLbl.Font = Enum.Font.GothamBold

        return btn, status, coolBar
    end

    local blueBtn, blueStatus, blueCool = makeBtn(Config.BlueColor, 10,  "✋ BLUE", "[Q] Grab / Launch")
    local redBtn,  redStatus,  redCool  = makeBtn(Config.RedColor,  133, "✋ RED",  "[E] Grab / Launch")

    -- Crosshair
    local ch = Instance.new("Frame", sg)
    ch.Name = "Crosshair"; ch.Size = UDim2.new(0,26,0,26)
    ch.Position = UDim2.new(0.5,-13,0.5,-13); ch.BackgroundTransparency = 1

    local function chBar(sz, pos)
        local f = Instance.new("Frame", ch)
        f.Size = sz; f.Position = pos
        f.BackgroundColor3 = Color3.new(1,1,1); f.BorderSizePixel = 0
        return f
    end
    chBar(UDim2.new(1,0,0,2), UDim2.new(0,0,0.5,-1))
    chBar(UDim2.new(0,2,1,0), UDim2.new(0.5,-1,0,0))

    local chDot = Instance.new("Frame", ch)
    chDot.Size = UDim2.new(0,4,0,4); chDot.Position = UDim2.new(0.5,-2,0.5,-2)
    chDot.BackgroundColor3 = Color3.new(1,1,1); chDot.BorderSizePixel = 0
    Instance.new("UICorner", chDot).CornerRadius = UDim.new(1,0)

    local chRing = Instance.new("Frame", ch)
    chRing.Name = "Ring"; chRing.Size = UDim2.new(0,20,0,20)
    chRing.Position = UDim2.new(0.5,-10,0.5,-10)
    chRing.BackgroundTransparency = 1; chRing.BorderSizePixel = 0
    Instance.new("UICorner", chRing).CornerRadius = UDim.new(1,0)
    local rs = Instance.new("UIStroke", chRing)
    rs.Thickness = 1.5; rs.Color = Color3.new(1,1,1); rs.Transparency = 0.4

    -- Instructions
    local instr = Instance.new("TextLabel", sg)
    instr.Size = UDim2.new(0,320,0,20); instr.Position = UDim2.new(0.5,-160,1,-130)
    instr.BackgroundTransparency = 1
    instr.Text = "Q/E = Shoot  |  R = Retract  |  Double-tap Q/E = Launch"
    instr.TextColor3 = Color3.fromRGB(200,200,200); instr.TextScaled = true
    instr.Font = Enum.Font.Gotham

    GrabPack.gui = {
        screen     = sg,
        panel      = panel,
        blueBtn    = blueBtn,
        redBtn     = redBtn,
        blueStatus = blueStatus,
        redStatus  = redStatus,
        blueCool   = blueCool,
        redCool    = redCool,
        chRing     = chRing,
    }
end

local function updateGUI(dt)
    if not GrabPack.gui then return end
    local now = tick()
    local gui = GrabPack.gui

    local function updateBtn(h, btn, statusLbl, coolBar)
        if h.grabbed then
            statusLbl.Text = "✅ GRABBED"; btn.BackgroundTransparency = 0
        elseif h.extended then
            statusLbl.Text = "→ LOCKED";  btn.BackgroundTransparency = 0.1
        elseif h.active then
            statusLbl.Text = "⟶ FLYING";  btn.BackgroundTransparency = 0.15
        else
            statusLbl.Text = "READY";     btn.BackgroundTransparency = 0
        end

        local elapsed = math.clamp((now - (h.lastShotTime or 0)) / Config.ShootCooldown, 0, 1)
        TweenService:Create(coolBar,
            TweenInfo.new(0.05, Enum.EasingStyle.Linear),
            {Size = UDim2.new(elapsed, 0, 0, 3)}
        ):Play()
    end

    updateBtn(GrabPack.hands.blue, gui.blueBtn, gui.blueStatus, gui.blueCool)
    updateBtn(GrabPack.hands.red,  gui.redBtn,  gui.redStatus,  gui.redCool)

    -- Crosshair ring colour
    local rs = gui.chRing:FindFirstChildOfClass("UIStroke")
    if rs then
        if GrabPack.hands.blue.grabbed then
            rs.Color = Config.BlueColor
        elseif GrabPack.hands.red.grabbed then
            rs.Color = Config.RedColor
        elseif GrabPack.hands.blue.active then
            rs.Color = Config.BlueColor:Lerp(Color3.new(1,1,1), 0.35)
        elseif GrabPack.hands.red.active then
            rs.Color = Config.RedColor:Lerp(Color3.new(1,1,1), 0.35)
        else
            rs.Color = Color3.new(1,1,1)
        end
    end
end

-- ============================================
-- INPUT
-- ============================================
local function connectInput()
    local gui = GrabPack.gui

    gui.blueBtn.MouseButton1Click:Connect(function()
        shootHand(GrabPack.hands.blue)
    end)
    gui.redBtn.MouseButton1Click:Connect(function()
        shootHand(GrabPack.hands.red)
    end)

    UserInputService.InputBegan:Connect(function(input, processed)
        if processed then return end
        local kc = input.KeyCode

        if kc == Enum.KeyCode.Q then
            shootHand(GrabPack.hands.blue)

        elseif kc == Enum.KeyCode.E then
            shootHand(GrabPack.hands.red)

        elseif kc == Enum.KeyCode.R then
            retractHand(GrabPack.hands.blue)
            retractHand(GrabPack.hands.red)
        end
    end)
end

-- ============================================
-- INITIALIZE
-- ============================================
local function initialize()
    buildBackpack()
    buildHand(GrabPack.hands.blue)
    buildHand(GrabPack.hands.red)
    buildGUI()
    connectInput()
    GrabPack.equipped = true

    print("[GrabPack] Initialized")
    print("  Q         – Shoot Blue hand")
    print("  E         – Shoot Red hand")
    print("  R         – Retract both hands")
    print("  Q/E × 2   – Launch toward grabbed point")
end

-- ============================================
-- MAIN LOOP
-- ============================================
RunService.RenderStepped:Connect(function(dt)
    if not GrabPack.equipped then return end
    if not character or not character.Parent then return end

    updateBackpackPosition()

    for _, h in pairs(GrabPack.hands) do
        updateHandMovement(h, dt)
        if h.active then
            updateHandModel(h, dt)
            updateString(h, dt)
        end
    end

    updateClickTimers(dt)
    updateGUI(dt)
end)

-- ============================================
-- CLEANUP
-- ============================================
player.CharacterRemoving:Connect(function()
    GrabPack.equipped = false
    clearRootVelocity()

    if GrabPack.backpackModel then
        GrabPack.backpackModel.folder:Destroy()
    end
    for _, h in pairs(GrabPack.hands) do
        clearString(h)
        if h.model then h.model.folder:Destroy() end
    end
    if GrabPack.gui then GrabPack.gui.screen:Destroy() end
end)

initialize()

Features & Information

Controls: InputAction Q or Blue ButtonShoot/control blue hand E or Red ButtonShoot/control red handPress once (extended) Retract handPress twice (grabbed) Launch to target
#Made by Educationalist

Community Comments (4)

WuzRERBornWuzRERBornFeb 20, 2026

u made this with claude ai lol

Replying to @WuzRERBorn
EducationalistEducationalistFeb 20, 2026

@WuzRERBorn It is a demonstration not made with AI it points out what features I have made in the script so far.

yoitsman123yoitsman123Mar 24, 2026

is this server sided or client sided

Replying to @yoitsman123
EducationalistEducationalistMay 22, 2026

@yoitsman123 Client side

More Scripts for Universal Scripts