physics based tail animator
Key RequiredUniversal

physics based tail animator

Universal Scriptkoboldpawskoboldpaws 5K views 5.4K executesUpdated Jan 17, 2026
3.3/ 5(18 votes)
12667% Positive
Lua Script•498 lines (12532 chars)
--[[

    tail physics
    version 4.2

    developed by @zephyrr skidded off @r3dtuxedo

]]--

local config = {
    stiffness = 96,
    damping = 9,
    linearamp = Vector3.new(6.4, 2.5333333, 6.4),
    angularamp = Vector3.new(0, 18, 0),
    wagenabled = true,
    wagdrop = 0.2,
    wagsway = 0.4,
    wagroll = 0.5,
    wagblendin = 0.008,
    wagblendout = 0.02,
    wagspeed = 1,
    timescale = 1,
    windenabled = false,
    gravityenabled = false,
    customwind = Vector3.zero,
    customforce = Vector3.zero,
    maxdistance = 900,
    maxtails = 5,
    movethreshold = 15,
    includednames = {} :: { string },
}

local run = game:GetService("RunService")
local players = game:GetService("Players")

local player = players.LocalPlayer
local char = player.Character or player.CharacterAdded:Wait()
local cam = workspace.CurrentCamera

local sin = math.sin
local cos = math.cos
local min = math.min
local max = math.max
local insert = table.insert
local find = table.find
local remove = table.remove
local sort = table.sort

local timestep = 1 / 120
local invtimestep = 120

local tails = {} :: { {
    root: BasePart,
    prevroot: CFrame,
    prevdelta: CFrame,
    weld: Weld,
    origcf: CFrame,
    prevweld: CFrame,
    curweld: CFrame,
    angle: Vector3,
    angvel: Vector3,
    wagblend: number,
    seed: number,
    pivot: CFrame,
    invpivot: CFrame,
    char: Model,
    invscale: number,
    dist: number,
    rendering: boolean,
} }

local tracked = {} :: { [Model]: boolean }
local wagtime = 0
local windtime = 0
local accum = 0

local winddir = Vector3.zero
local windalpha = 0
local windalpha04 = 0
local windalpha006 = 0
local haswind = false

local gravforce = Vector3.zero
local linearforce = Vector3.zero
local haslinear = false

local function springcalc(pos: Vector3, vel: Vector3, stiff: number, damp: number): (Vector3, Vector3)
    local accel = stiff * pos + damp * vel
    local newvel = vel - accel * timestep
    return pos + newvel * timestep, newvel
end

local function vectoangles(v: Vector3): CFrame
    return CFrame.fromEulerAngles(v.X, v.Y, v.Z)
end

local function lerp(a: number, b: number, t: number): number
    return a + (b - a) * t
end

local function updatewind()
    local windforce = config.windenabled and (workspace.GlobalWind + config.customwind) or config.customwind

    if windforce.Magnitude > 0 then
        winddir = windforce.Unit
    else
        winddir = Vector3.zero
    end

    windalpha = windforce.Magnitude * 0.5
    windalpha04 = windalpha * 0.4
    windalpha006 = windalpha * windalpha * 0.06
    haswind = windalpha > 0
end

local function updatelinear()
    linearforce = config.gravityenabled and (gravforce + config.customforce) or config.customforce
    haslinear = linearforce.Magnitude > 0
end

local function updategravity()
    gravforce = Vector3.new(0, max((196.2 - workspace.Gravity) * 0.004, -1))
    updatelinear()
end

updatewind()
updategravity()

workspace:GetPropertyChangedSignal("GlobalWind"):Connect(updatewind)
workspace:GetPropertyChangedSignal("Gravity"):Connect(updategravity)

local function getwagoffset(tail): Vector3
    local blend = tail.wagblend

    if tail.angvel.Magnitude > 0.3 then
        blend = blend < 0.01 and 0 or blend * (1 - config.wagblendout)
    else
        blend = blend > 0.99 and 1 or lerp(blend, 1, config.wagblendin)
    end

    tail.wagblend = blend

    if blend <= 0 then
        return Vector3.zero
    end

    local wt = wagtime + tail.seed
    local ft = wt + sin(wt) * sin(wt * 0.22727272727272727) * 0.9

    return Vector3.new(
        (sin(ft * 2) + 0.8) * config.wagdrop * blend,
        cos(ft) * config.wagsway * blend,
        sin(ft) * -config.wagroll * blend
    )
end

local function updateinterp(tail, alpha: number)
    local scale = tail.invscale * tail.char:GetScale()
    local cf = tail.prevweld:Lerp(tail.curweld, alpha)

    if config.wagenabled then
        cf = cf * (cf:ToObjectSpace(tail.pivot * vectoangles(getwagoffset(tail)) * tail.invpivot)):Inverse()
    end

    tail.weld.C0 = cf.Rotation + cf.Position * scale
end

local function resettail(tail)
    tail.prevroot = tail.root.CFrame
    tail.prevdelta = CFrame.identity
    tail.prevweld = tail.origcf
    tail.curweld = tail.origcf
    tail.angle = Vector3.zero
    tail.angvel = Vector3.zero
    tail.wagblend = 0
    tail.rendering = true
end

local function restoretail(tail)
    tail.rendering = false
    local scale = tail.invscale * tail.char:GetScale()
    tail.weld.C0 = tail.origcf.Rotation + tail.origcf.Position * scale
end

local function updatetail(tail, doweld: boolean, alpha: number)
    local rootcf = tail.root.CFrame
    local delta = tail.prevroot:ToObjectSpace(rootcf)

    if delta.Position.Magnitude > config.movethreshold then
        delta = delta.Rotation
    end

    local accel = tail.prevdelta:ToObjectSpace(delta)
    local force = accel.Position * config.linearamp

    if haswind then
        local s4 = sin(windtime * 4)
        local unstable = sin(windtime * 2 * s4) * windalpha04
        local stable = sin(windtime * 2) * s4
        local windforce = winddir * windalpha006 * (1 + (stable + unstable) * 0.4)
        force -= rootcf:VectorToObjectSpace(windforce)
    end

    if haslinear then
        force -= rootcf:VectorToObjectSpace(linearforce)
    end

    local pivot = tail.pivot
    local disp = pivot.Position - tail.prevweld.Position
    local torque = disp:Cross(force)

    local x, y, z = accel:ToEulerAnglesXYZ()
    local angaccel = Vector3.new(x, y, z) * config.angularamp

    local newangle, newangvel = springcalc(tail.angle, tail.angvel, config.stiffness, config.damping)
    tail.angle = newangle

    local scale = tail.invscale * tail.char:GetScale()
    tail.angvel = newangvel + torque / scale - angaccel

    local newcur = pivot * vectoangles(newangle) * tail.invpivot
    local newprev = tail.curweld

    if doweld then
        local cf = newprev:Lerp(newcur, alpha)

        if config.wagenabled then
            cf = cf * (cf:ToObjectSpace(pivot * vectoangles(getwagoffset(tail)) * tail.invpivot)):Inverse()
        end

        tail.weld.C0 = cf.Rotation + cf.Position * scale
    end

    tail.prevroot = rootcf
    tail.prevdelta = delta
    tail.prevweld = newprev
    tail.curweld = newcur
end

local function getpivot(root: BasePart): CFrame?
    local waistrig = root:FindFirstChild("WaistRigAttachment")
    if waistrig and waistrig:IsA("Attachment") then
        return waistrig.CFrame * CFrame.new(0, 0, root.Size.Z * 0.5)
    end

    local waistback = root:FindFirstChild("WaistBackAttachment")
    if waistback and waistback:IsA("Attachment") then
        return waistback.CFrame * CFrame.new(0, 0.3, 0)
    end

    return nil
end

local function nametoseed(name: string): number
    local seed = 0

    for i = 1, #name do
        seed += string.byte(name, i) ^ 2
    end

    return seed
end

local function removetail(tail)
    local idx = find(tails, tail)
    if idx then
        remove(tails, idx)
    end
end

local function setupweld(characterModel: Model, weld: Weld, tailpart: BasePart, custompivot: CFrame | BasePart?)
    local flipped = false
    local root = weld.Part0

    if not root then
        warn("weld missing part0")
        return
    end

    if root == tailpart then
        root = weld.Part1
        if not root then
            warn("weld missing part1")
            return
        end

        flipped = true
    end

    local pivot: CFrame?
    if custompivot then
        pivot = typeof(custompivot) == "CFrame" and custompivot or custompivot.CFrame
    else
        pivot = getpivot(root)
    end

    if not pivot then
        warn("couldnt find attachment")
        return
    end

    local origcf: CFrame
    if flipped then
        origcf = weld.C1 * weld.C0:Inverse()
        weld.Part0, weld.Part1 = root, tailpart
    else
        origcf = weld.C0 * weld.C1:Inverse()
    end

    weld.C0 = origcf
    weld.C1 = CFrame.identity

    local localpivot = origcf:ToObjectSpace(CFrame.new(pivot.Position))
    local seed = nametoseed(characterModel.Name)

    local plr = players:GetPlayerFromCharacter(characterModel)
    if plr then
        seed += plr.UserId
    end

    local tail = {
        root = root,
        prevroot = root.CFrame,
        prevdelta = CFrame.identity,
        weld = weld,
        origcf = origcf,
        prevweld = origcf,
        curweld = origcf,
        angle = Vector3.zero,
        angvel = Vector3.zero,
        wagblend = 0,
        seed = seed,
        pivot = origcf * localpivot,
        invpivot = localpivot:Inverse(),
        char = characterModel,
        invscale = 1 / characterModel:GetScale(),
        dist = 0,
        rendering = false,
    }

    insert(tails, tail)

    weld.AncestryChanged:Connect(function()
        if not weld.Parent then
            removetail(tail)
        end
    end)
end

local function setupaccessory(characterModel: Model, accessory: Accessory)
    local handle = accessory:FindFirstChild("Handle")
    if not handle or not handle:IsA("BasePart") then
        accessory.ChildAdded:Once(function()
            setupaccessory(characterModel, accessory)
        end)
        return
    end

    local weld = handle:FindFirstChild("AccessoryWeld")
    if weld and weld:IsA("Weld") then
        setupweld(characterModel, weld, handle, nil)
    end
end

local function istail(accessory: Accessory): boolean
    local acctype = accessory.AccessoryType
    local accname = accessory.Name:lower()

    if acctype == Enum.AccessoryType.Back or acctype == Enum.AccessoryType.Waist then
        if accname:find("tail", 1, true) then
            return true
        end

        for i = 1, #config.includednames do
            if accessory.Name == config.includednames[i] then
                return true
            end
        end
    end

    return false
end

local function setupchar(characterModel: Model)
    if tracked[characterModel] then
        return
    end

    tracked[characterModel] = true
    print("setting up " .. characterModel.Name)

    characterModel.ChildAdded:Connect(function(child)
        if child:IsA("Accessory") and istail(child) then
            print("found new tail " .. child.Name)
            setupaccessory(characterModel, child)
        end
    end)

    local count = 0
    for _, child in characterModel:GetChildren() do
        if child:IsA("Accessory") and istail(child) then
            print("found tail " .. child.Name)
            setupaccessory(characterModel, child)
            count += 1
        end
    end

    if count > 0 then
        print("setup " .. count .. " tails")
    else
        print("no tails found")
    end

    characterModel:GetPropertyChangedSignal("Parent"):Connect(function()
        if not characterModel.Parent then
            tracked[characterModel] = nil
        end
    end)
end

setupchar(char)

player.CharacterAdded:Connect(function(newchar)
    char = newchar
    tails = {}
    tracked = {}
    wagtime = 0
    windtime = 0
    accum = 0

    print("resetting physics")
    task.wait(0.5)
    setupchar(newchar)
end)

local function sortbydist(a, b): boolean
    return a.dist < b.dist
end

run.PreRender:Connect(function(dt: number)
    dt = min(dt, 0.1) * config.timescale
    wagtime += dt * config.wagspeed
    windtime += dt
    accum += dt

    local steps = 0
    while accum >= timestep do
        accum -= timestep
        steps += 1
    end

    local campos = cam.CFrame.Position
    local rendering = {}

    for _, tail in tails do
        local dist = (tail.root.CFrame.Position - campos).Magnitude

        if dist <= config.maxdistance then
            tail.dist = dist
            insert(rendering, tail)
        elseif tail.rendering then
            restoretail(tail)
        end
    end

    sort(rendering, sortbydist)

    local interp = steps == 0
    local alpha = accum * invtimestep
    local updated = 0

    for _, tail in rendering do
        if updated >= config.maxtails then
            if tail.rendering then
                restoretail(tail)
            end
            continue
        end

        if interp then
            if tail.rendering then
                updateinterp(tail, alpha)
            end
        else
            if not tail.rendering then
                resettail(tail)
            end

            for i = 1, steps do
                updatetail(tail, i == steps, alpha)
            end
        end

        updated += 1
    end
end)

print("tracking " .. #tails .. " tails")

Features & Information

works with any ugc with the name "tail" in it based on tuxphysicstails by @r3dtuxedo
#animator#visual#furry

Community Comments (20)

leeisleeisJan 17, 2026

fe or not?

leeisleeisJan 17, 2026

how does this work

Replying to @leeis
koboldpawskoboldpawsJan 17, 2026

@leeis its visual and it uses spring physics and movement detection

now this, this is epic

Ay question, does this work on uhh hat reanim or

Replying to @Enotfromrainworld12
koboldpawskoboldpawsJan 25, 2026

@Enotfromrainworld12 no this is purely visual

coolguytokencoolguytokenFeb 2, 2026(edited)

can you make the script restart when i die or res*? (btw, peak script)

Replying to @coolguytoken
koboldpawskoboldpawsFeb 4, 2026

@coolguytoken i just pushed an update that fixes the res* bs. lmk if theres any other bugs

Replying to @koboldpaws
coolguytokencoolguytokenFeb 5, 2026

@zephyrr thank you ^_^

SkoopSkoopFeb 18, 2026(edited)

You should make one that loads an animation from the catalog that makes the waist part move if a tail is attached to the waist part on the players avatar, that way it can be FE. R15 only tho

waruwaruMar 11, 2026

woaaahhhh

gulubobgulubobApr 19, 2026

as a furry (protogen) this has to be one of the best scripts ever [im aware its client sided]

Replying to @gulubob
koboldpawskoboldpawsApr 19, 2026

❤️

6ixl6ixlApr 19, 2026

What the actual *

Tester666Tester666Apr 19, 2026

Can I make one for ear or head accessory??

Tester666Tester666Apr 19, 2026

U*

DracksnowwDracksnowwApr 19, 2026

É visual só?

RosevelttRosevelttApr 19, 2026

can others see it aswell?

joeyspennerjoeyspennerApr 21, 2026

Is this client sided or server sided?

Replying to @joeyspenner
santaisantaiJun 4, 2026

@joeyspenner cilentside. I wish I had bobux so I can make avatars, but I'm stuck with a Greenman avatar from wtd

More Scripts for Universal Scripts