Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/brockmartin/roblox-game-skill/llms.txt

Use this file to discover all available pages before exploring further.

Animation and visual effects are the polish layer that makes a Roblox game feel alive. Responsive hit flashes, particle bursts, camera shake, and well-timed sounds transform functional systems into satisfying experiences. Most VFX code runs client-side — LocalScripts handle particles, tweens, camera effects, and sound playback, while the server fires RemoteEvents to tell clients when to trigger effects after confirming game state.

Character Animation

Loading and Playing Animations

Every Humanoid has an Animator child. The Animator plays, blends, and prioritizes animation tracks on the character rig.
local character = player.Character or player.CharacterAdded:Wait()
local humanoid = character:WaitForChild("Humanoid")
local animator = humanoid:FindFirstChildOfClass("Animator")
    or humanoid:WaitForChild("Animator")

-- 1. Create an Animation instance with the asset ID
local slashAnim = Instance.new("Animation")
slashAnim.AnimationId = "rbxassetid://123456789"

-- 2. Load it through the Animator (returns an AnimationTrack)
local slashTrack = animator:LoadAnimation(slashAnim)

-- 3. Play / Stop
slashTrack:Play()
slashTrack:Play(0.2)        -- 0.2s fade-in
slashTrack:Stop(0.3)        -- 0.3s fade-out

-- 4. Adjust at runtime
slashTrack:AdjustSpeed(1.5) -- 1.5× playback speed
slashTrack:AdjustWeight(0.8) -- 80% blend weight
Preload animations at character spawn rather than inside hot paths like attack handlers. LoadAnimation may yield on first use. Store the returned AnimationTrack in a variable and call Play() later.

Animation Priorities

Priorities determine which animation wins when multiple tracks affect the same joints. Higher priority overrides lower.
PriorityUse Case
Enum.AnimationPriority.IdleBreathing, idle sway
Enum.AnimationPriority.MovementWalk, run, jump, fall
Enum.AnimationPriority.ActionAttack, interact, emote
Enum.AnimationPriority.Action2–4Escalating override tiers
Enum.AnimationPriority.CoreInternal Roblox (avoid overriding)
slashTrack.Priority = Enum.AnimationPriority.Action

Animation Markers

Animation events fire logic at exact frames defined in the Animation Editor. Use them to synchronize hitboxes, sounds, and particles with the visual motion:
slashTrack:GetMarkerReachedSignal("HitFrame"):Connect(function(paramValue: string)
    -- Spawn hitbox, play sound, emit particles at the exact swing frame
    print("Hit frame reached!", paramValue)
end)

Cross-Fading Between Animations

local walkTrack = animator:LoadAnimation(walkAnim)
local runTrack  = animator:LoadAnimation(runAnim)

walkTrack:Play(0.2)

-- Later, cross-fade to run
walkTrack:Stop(0.3)
runTrack:Play(0.3)

AnimationController for Non-Humanoid Rigs

Use AnimationController for props, doors, creatures with custom rigs, cutscene actors — anything without a Humanoid.
local model = workspace.DragonNPC
local animController = Instance.new("AnimationController")
animController.Parent = model

local flyAnim = Instance.new("Animation")
flyAnim.AnimationId = "rbxassetid://987654321"

local flyTrack = animController:LoadAnimation(flyAnim)
flyTrack.Looped = true
flyTrack:Play()
The model needs Motor6D joints connecting its parts, just like a character rig. The root part should be the PrimaryPart of the model.

Particle Effects

ParticleEmitter Core Properties

PropertyTypeDescription
RatenumberParticles/second (0 = manual Emit())
LifetimeNumberRangeHow long each particle lives
SpeedNumberRangeInitial velocity (studs/second)
SpreadAngleVector2Cone spread in X and Y (degrees)
SizeNumberSequenceSize over particle lifetime
ColorColorSequenceColor over particle lifetime
TransparencyNumberSequenceTransparency over particle lifetime
AccelerationVector3Constant force (gravity = Vector3.new(0, -10, 0))
LightEmissionnumber0–1, additive blending (1 = fully glowy)
DragnumberAir resistance (0 = none)
OrientationEnum.ParticleOrientationFacingCamera, VelocityParallel, etc.

NumberSequence and ColorSequence

-- Size: start at 1, peak at 2 at midlife, shrink to 0
local sizeSeq = NumberSequence.new({
    NumberSequenceKeypoint.new(0, 1),
    NumberSequenceKeypoint.new(0.5, 2),
    NumberSequenceKeypoint.new(1, 0),
})

-- Color: orange to red
local colorSeq = ColorSequence.new({
    ColorSequenceKeypoint.new(0, Color3.fromRGB(255, 170, 0)),
    ColorSequenceKeypoint.new(1, Color3.fromRGB(200, 30, 0)),
})

-- Transparency: fade in, hold, then fade out
local transSeq = NumberSequence.new({
    NumberSequenceKeypoint.new(0, 1),
    NumberSequenceKeypoint.new(0.1, 0),
    NumberSequenceKeypoint.new(0.8, 0),
    NumberSequenceKeypoint.new(1, 1),
})

Effect Recipes

local fire = Instance.new("ParticleEmitter")
fire.Rate = 80
fire.Lifetime = NumberRange.new(0.4, 0.8)
fire.Speed = NumberRange.new(3, 6)
fire.SpreadAngle = Vector2.new(15, 15)
fire.Size = NumberSequence.new({
    NumberSequenceKeypoint.new(0, 0.5),
    NumberSequenceKeypoint.new(0.3, 1.5),
    NumberSequenceKeypoint.new(1, 0),
})
fire.Color = ColorSequence.new({
    ColorSequenceKeypoint.new(0, Color3.fromRGB(255, 220, 50)),
    ColorSequenceKeypoint.new(0.4, Color3.fromRGB(255, 100, 0)),
    ColorSequenceKeypoint.new(1, Color3.fromRGB(100, 20, 0)),
})
fire.Transparency = NumberSequence.new({
    NumberSequenceKeypoint.new(0, 0.3),
    NumberSequenceKeypoint.new(1, 1),
})
fire.LightEmission = 1
fire.Acceleration = Vector3.new(0, 4, 0)
fire.Texture = "rbxasset://textures/particles/fire_main.dds"
fire.Parent = somePart

Beams and Trails

Beam

A Beam renders a textured ribbon between two Attachment instances. Use it for lasers, lightning, tethers, and energy connections.
local att0 = Instance.new("Attachment")
att0.Parent = partA

local att1 = Instance.new("Attachment")
att1.Parent = partB

local beam = Instance.new("Beam")
beam.Attachment0 = att0
beam.Attachment1 = att1
beam.Width0 = 0.5
beam.Width1 = 0.5
beam.Color = ColorSequence.new(Color3.fromRGB(0, 150, 255))
beam.Transparency = NumberSequence.new({
    NumberSequenceKeypoint.new(0, 0),
    NumberSequenceKeypoint.new(1, 0.5),
})
beam.LightEmission = 1
beam.FaceCamera = true
beam.Segments = 20         -- more segments = smoother curves
beam.CurveSize0 = 2
beam.CurveSize1 = -2
beam.TextureSpeed = 1      -- scrolling texture
beam.Parent = partA

Trail

A Trail renders a ribbon behind a moving part. Requires two Attachment instances defining the width axis.
local part = workspace.Sword.Blade

local att0 = Instance.new("Attachment")
att0.Position = Vector3.new(0, 0, -2)  -- base of blade
att0.Parent = part

local att1 = Instance.new("Attachment")
att1.Position = Vector3.new(0, 0, 2)   -- tip of blade
att1.Parent = part

local trail = Instance.new("Trail")
trail.Attachment0 = att0
trail.Attachment1 = att1
trail.Lifetime = 0.3
trail.MinLength = 0.05
trail.FaceCamera = true
trail.Color = ColorSequence.new({
    ColorSequenceKeypoint.new(0, Color3.fromRGB(255, 255, 255)),
    ColorSequenceKeypoint.new(1, Color3.fromRGB(100, 100, 255)),
})
trail.Transparency = NumberSequence.new({
    NumberSequenceKeypoint.new(0, 0),
    NumberSequenceKeypoint.new(1, 1),
})
trail.LightEmission = 0.8
trail.Parent = part
Use CaseApproach
Laser beamsBeam between gun barrel and hit-point attachment
Sword trailsTrail on blade, Lifetime = 0.2–0.4 s
LightningBeam with many Segments, randomize CurveSize0/1 each frame
Arcane tetherBeam with high CurveSize and scrolling texture

TweenService for VFX

TweenService interpolates any numeric or color property over time. It is the backbone of procedural visual feedback and is more efficient than manually setting properties each RenderStepped.

Common Easing Styles

StyleFeel
LinearConstant speed, mechanical
QuadGentle acceleration/deceleration
SineSmooth, organic
BackOvershoots then settles
BounceBounces at the end
ElasticSpringy overshoot
ExponentialVery sharp acceleration

Core VFX Tween Patterns

local TweenService = game:GetService("TweenService")

-- Flash on hit: turn white then revert
local function flashPart(part: BasePart, originalColor: Color3)
    part.Color = Color3.new(1, 1, 1)  -- instant white
    TweenService:Create(part, TweenInfo.new(0.3, Enum.EasingStyle.Quad), {
        Color = originalColor,
    }):Play()
end

-- Pulse effect: scale up then back
local function pulse(part: BasePart)
    local originalSize = part.Size
    local tweenGrow = TweenService:Create(part,
        TweenInfo.new(0.15, Enum.EasingStyle.Back, Enum.EasingDirection.Out), {
        Size = originalSize * 1.3,
    })
    local tweenShrink = TweenService:Create(part,
        TweenInfo.new(0.2, Enum.EasingStyle.Quad, Enum.EasingDirection.In), {
        Size = originalSize,
    })
    tweenGrow:Play()
    tweenGrow.Completed:Connect(function()
        tweenShrink:Play()
    end)
end

-- Grow and fade out (explosion ring)
local function expandAndFade(part: BasePart)
    local tween = TweenService:Create(part,
        TweenInfo.new(0.6, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
        Size = part.Size * 5,
        Transparency = 1,
    })
    tween:Play()
    tween.Completed:Connect(function()
        part:Destroy()
    end)
end

Lighting Effects

Dynamic Lights

-- PointLight: omnidirectional — good for torches, explosions
local pointLight = Instance.new("PointLight")
pointLight.Brightness = 2
pointLight.Color = Color3.fromRGB(255, 180, 50)
pointLight.Range = 20
pointLight.Shadows = true
pointLight.Parent = torchPart

-- SpotLight: directional cone — good for flashlights
local spotLight = Instance.new("SpotLight")
spotLight.Brightness = 3
spotLight.Color = Color3.new(1, 1, 1)
spotLight.Range = 40
spotLight.Angle = 30
spotLight.Face = Enum.NormalId.Front
spotLight.Parent = flashlightPart

Post-Processing Effects

All post-processing objects go in Lighting or Camera.
-- Bloom
local bloom = Instance.new("BloomEffect")
bloom.Intensity = 0.8
bloom.Size = 24
bloom.Threshold = 1.2
bloom.Parent = game.Lighting

-- ColorCorrection
local cc = Instance.new("ColorCorrectionEffect")
cc.Brightness = 0.05
cc.Contrast = 0.1
cc.Saturation = 0.15
cc.TintColor = Color3.new(1, 0.95, 0.9)  -- warm tint
cc.Parent = game.Lighting

-- Atmosphere
local atmo = Instance.new("Atmosphere")
atmo.Density = 0.3
atmo.Color = Color3.fromRGB(200, 210, 230)
atmo.Glare = 0.2
atmo.Haze = 1.5
atmo.Parent = game.Lighting

-- DepthOfField
local dof = Instance.new("DepthOfFieldEffect")
dof.FarIntensity = 0.3
dof.FocusDistance = 30
dof.InFocusRadius = 20
dof.NearIntensity = 0.2
dof.Parent = game.Lighting

Mood Presets via Global Lighting

local lighting = game.Lighting

-- Daytime bright
lighting.ClockTime = 14
lighting.Brightness = 2
lighting.Ambient = Color3.fromRGB(140, 140, 140)

-- Nighttime spooky
lighting.ClockTime = 0
lighting.Brightness = 0.5
lighting.Ambient = Color3.fromRGB(20, 20, 40)
lighting.FogEnd = 200
lighting.FogColor = Color3.fromRGB(15, 15, 30)

Sound Design

Positional Audio

Parent a Sound to a Part in the workspace. The engine automatically applies 3D rolloff.
local sound = Instance.new("Sound")
sound.SoundId = "rbxassetid://111222333"
sound.Volume = 0.8
sound.PlaybackSpeed = 1
sound.Looped = false
sound.RollOffMode = Enum.RollOffMode.InverseTapered
sound.RollOffMaxDistance = 100
sound.RollOffMinDistance = 10
sound.Parent = somePart
sound:Play()

SoundGroup for Volume Categories

local SoundService = game:GetService("SoundService")

local sfxGroup = Instance.new("SoundGroup")
sfxGroup.Name = "SFX"
sfxGroup.Volume = 1
sfxGroup.Parent = SoundService

local musicGroup = Instance.new("SoundGroup")
musicGroup.Name = "Music"
musicGroup.Volume = 0.5
musicGroup.Parent = SoundService

hitSound.SoundGroup = sfxGroup
bgm.SoundGroup = musicGroup

-- Player adjusts SFX volume in settings:
sfxGroup.Volume = 0.3  -- all SFX sounds update instantly

Syncing Sounds with Animation Markers

walkTrack:GetMarkerReachedSignal("Footstep"):Connect(function()
    local footstep = Instance.new("Sound")
    footstep.SoundId = "rbxassetid://112233445"
    footstep.Volume = 0.5
    footstep.PlaybackSpeed = 0.9 + math.random() * 0.2  -- slight pitch variation
    footstep.Parent = character.HumanoidRootPart
    footstep:Play()
    footstep.Ended:Connect(function()
        footstep:Destroy()
    end)
end)

Camera Effects

Camera Shake

local camera = workspace.CurrentCamera
local RunService = game:GetService("RunService")

local function shakeCamera(intensity: number, duration: number)
    local elapsed = 0
    local connection: RBXScriptConnection

    connection = RunService.RenderStepped:Connect(function(dt: number)
        elapsed += dt
        if elapsed >= duration then
            connection:Disconnect()
            return
        end

        local progress = 1 - (elapsed / duration)  -- decay over time
        local shakeX = (math.random() - 0.5) * 2 * intensity * progress
        local shakeY = (math.random() - 0.5) * 2 * intensity * progress

        camera.CFrame = camera.CFrame * CFrame.new(shakeX, shakeY, 0)
    end)
end

-- Moderate shake for 0.3 seconds
shakeCamera(0.5, 0.3)

FOV Zoom

local TweenService = game:GetService("TweenService")

local function zoomCamera(targetFOV: number, duration: number)
    local tween = TweenService:Create(camera,
        TweenInfo.new(duration, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
        FieldOfView = targetFOV,
    })
    tween:Play()
    return tween
end

-- Dramatic zoom in, then back to normal
local zoomIn = zoomCamera(40, 0.5)
zoomIn.Completed:Connect(function()
    task.wait(1)
    zoomCamera(70, 0.8)
end)

Cutscene Waypoint System

type CutsceneWaypoint = {
    cframe: CFrame,
    duration: number,
    easingStyle: Enum.EasingStyle?,
    holdTime: number?,
}

local function playCutscene(waypoints: { CutsceneWaypoint })
    camera.CameraType = Enum.CameraType.Scriptable

    for _, waypoint in waypoints do
        local style = waypoint.easingStyle or Enum.EasingStyle.Quad
        local info = TweenInfo.new(waypoint.duration, style, Enum.EasingDirection.InOut)

        local tween = TweenService:Create(camera, info, { CFrame = waypoint.cframe })
        tween:Play()
        tween.Completed:Wait()

        if waypoint.holdTime and waypoint.holdTime > 0 then
            task.wait(waypoint.holdTime)
        end
    end

    -- Return control to player
    camera.CameraType = Enum.CameraType.Custom
end

Complete Hit Effect System

A production-ready client-side module combining white flash, particle burst, hit sound, and camera shake into a single reusable call:
--[[
    HitEffectSystem — combines visual and audio feedback for combat hits.
    Run CLIENT-SIDE only (LocalScript or module required from a LocalScript).
]]

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

local HitEffectSystem = {}

local DEFAULT_CONFIG = {
    -- Flash
    flashColor = Color3.new(1, 1, 1),
    flashDuration = 0.15,
    flashRevertDuration = 0.25,

    -- Particles
    particleBurstCount = 20,
    particleLifetime = NumberRange.new(0.2, 0.5),
    particleSpeed = NumberRange.new(8, 15),
    particleSize = NumberSequence.new({
        NumberSequenceKeypoint.new(0, 0.5),
        NumberSequenceKeypoint.new(1, 0),
    }),
    particleColor = ColorSequence.new({
        ColorSequenceKeypoint.new(0, Color3.new(1, 1, 1)),
        ColorSequenceKeypoint.new(1, Color3.fromRGB(255, 200, 50)),
    }),

    -- Sound
    hitSoundId = "rbxassetid://123456789",
    hitSoundVolume = 0.8,
    hitSoundPitchVariation = 0.15,

    -- Camera shake
    shakeIntensity = 0.4,
    shakeDuration = 0.2,
}

local emitterCache: { [BasePart]: ParticleEmitter } = {}

local function getOrCreateEmitter(part: BasePart, config: typeof(DEFAULT_CONFIG)): ParticleEmitter
    if emitterCache[part] then return emitterCache[part] end

    local emitter = Instance.new("ParticleEmitter")
    emitter.Name = "HitBurst"
    emitter.Rate = 0  -- manual emission only
    emitter.Lifetime = config.particleLifetime
    emitter.Speed = config.particleSpeed
    emitter.SpreadAngle = Vector2.new(180, 180)
    emitter.Size = config.particleSize
    emitter.Color = config.particleColor
    emitter.LightEmission = 1
    emitter.Drag = 5
    emitter.RotSpeed = NumberRange.new(-180, 180)
    emitter.Parent = part
    emitterCache[part] = emitter

    part.Destroying:Connect(function()
        emitterCache[part] = nil
    end)

    return emitter
end

local function flashPart(part: BasePart, config: typeof(DEFAULT_CONFIG))
    local originalColor = part.Color
    part.Color = config.flashColor
    TweenService:Create(part,
        TweenInfo.new(config.flashRevertDuration, Enum.EasingStyle.Quad, Enum.EasingDirection.Out),
        { Color = originalColor }
    ):Play()
end

local function emitParticleBurst(part: BasePart, config: typeof(DEFAULT_CONFIG))
    getOrCreateEmitter(part, config):Emit(config.particleBurstCount)
end

local function playHitSound(part: BasePart, config: typeof(DEFAULT_CONFIG))
    local sound = Instance.new("Sound")
    sound.SoundId = config.hitSoundId
    sound.Volume = config.hitSoundVolume
    sound.PlaybackSpeed = 1 + (math.random() - 0.5) * 2 * config.hitSoundPitchVariation
    sound.RollOffMaxDistance = 80
    sound.RollOffMinDistance = 5
    sound.Parent = part
    sound:Play()
    sound.Ended:Connect(function() sound:Destroy() end)
end

local function shakeCamera(config: typeof(DEFAULT_CONFIG))
    local camera = workspace.CurrentCamera
    if not camera then return end

    local elapsed = 0
    local connection: RBXScriptConnection

    connection = RunService.RenderStepped:Connect(function(dt: number)
        elapsed += dt
        if elapsed >= config.shakeDuration then
            connection:Disconnect()
            return
        end
        local decay = 1 - (elapsed / config.shakeDuration)
        local offsetX = (math.random() - 0.5) * 2 * config.shakeIntensity * decay
        local offsetY = (math.random() - 0.5) * 2 * config.shakeIntensity * decay
        camera.CFrame = camera.CFrame * CFrame.new(offsetX, offsetY, 0)
    end)
end

--[[
    Main entry point. Trigger the full hit effect on a target part.
    @param targetPart  The BasePart that was hit.
    @param overrides   Optional table to override DEFAULT_CONFIG values.
]]
function HitEffectSystem.play(targetPart: BasePart, overrides: { [string]: any }?)
    local config = table.clone(DEFAULT_CONFIG)
    if overrides then
        for key, value in overrides do
            (config :: any)[key] = value
        end
    end

    flashPart(targetPart, config)
    emitParticleBurst(targetPart, config)
    playHitSound(targetPart, config)
    shakeCamera(config)
end

function HitEffectSystem.cleanup()
    for part, emitter in emitterCache do
        emitter:Destroy()
    end
    table.clear(emitterCache)
end

return HitEffectSystem

--[[
    USAGE (in a LocalScript):

    local HitEffectSystem = require(script.Parent.HitEffectSystem)

    hitRemote.OnClientEvent:Connect(function(targetPart: BasePart, isCritical: boolean)
        if isCritical then
            HitEffectSystem.play(targetPart, {
                flashColor = Color3.fromRGB(255, 50, 50),
                particleBurstCount = 40,
                shakeIntensity = 0.8,
                shakeDuration = 0.4,
            })
        else
            HitEffectSystem.play(targetPart)
        end
    end)
]]

Performance Budgets

Particles

Max ~200 particles per emitter. Aim for fewer than 20–30 active emitters visible at once. Use 64×64 or 128×128 textures — never high-res images on particles.

Beams & Trails

Keep Segments at 10–30. Very high segment counts cost draw calls. Destroy beams when no longer needed.

Tweens

Hundreds of simultaneous tweens are fine. Cancel and destroy tweens on cleanup to prevent leaks.

Sounds

Limit simultaneous playing sounds to ~20–30. Always destroy one-shot sounds via the Ended event.

Disable Effects on Low-End Devices

local function applyQualitySettings()
    local level = settings().Rendering.QualityLevel

    if level <= 3 then
        -- Disable post-processing on low-quality settings
        for _, effect in game.Lighting:GetChildren() do
            if effect:IsA("PostEffect") then
                effect.Enabled = false
            end
        end
        -- Optionally reduce particle Rate values
        -- Disable Shadows on dynamic lights
    end
end

VFX Object Pooling

Avoid creating and destroying particle emitters every frame. Pre-create a pool and enable/disable or reposition them:
local VFXPool = {}
VFXPool.__index = VFXPool

function VFXPool.new(template: Instance, poolSize: number)
    local self = setmetatable({}, VFXPool)
    self._available = {}

    for i = 1, poolSize do
        local clone = template:Clone()
        clone.Parent = workspace.VFXFolder
        for _, emitter in clone:GetDescendants() do
            if emitter:IsA("ParticleEmitter") then
                emitter.Enabled = false
            end
        end
        table.insert(self._available, clone)
    end

    return self
end

function VFXPool:get(): Instance?
    return table.remove(self._available)
end

function VFXPool:release(obj: Instance)
    for _, emitter in obj:GetDescendants() do
        if emitter:IsA("ParticleEmitter") then
            emitter.Enabled = false
        end
    end
    table.insert(self._available, obj)
end

Common Mistakes

Never set Camera.CameraType to Scriptable without restoring it. Players will lose camera control permanently. Always restore Enum.CameraType.Custom after cutscenes and camera effects complete.
-- BAD: creates a new emitter every Heartbeat, accumulates forever
RunService.Heartbeat:Connect(function()
    local emitter = Instance.new("ParticleEmitter")
    emitter.Rate = 500
    emitter.Parent = somePart
end)

-- GOOD: reuse a single emitter, burst manually
local emitter = Instance.new("ParticleEmitter")
emitter.Rate = 0  -- manual emission only
emitter.Parent = somePart

local function burstParticles(count: number)
    emitter:Emit(count)
end
-- BAD: loading in the attack handler may yield on first call
local function onAttack()
    local anim = Instance.new("Animation")
    anim.AnimationId = "rbxassetid://123456789"
    local track = animator:LoadAnimation(anim)  -- may yield
    track:Play()
end

-- GOOD: preload at character spawn
local attackTrack: AnimationTrack

local function onCharacterAdded(character: Model)
    local anim = Instance.new("Animation")
    anim.AnimationId = "rbxassetid://123456789"
    local animator = character:WaitForChild("Humanoid"):WaitForChild("Animator")
    attackTrack = animator:LoadAnimation(anim)
    attackTrack.Priority = Enum.AnimationPriority.Action
end

local function onAttack()
    if attackTrack then attackTrack:Play() end
end
If the RenderStepped connection in a camera shake function is never disconnected, the camera jitters indefinitely. Always store the connection reference and call Disconnect() when the duration expires or the effect is cancelled.
-- BAD: creates a new Sound every hit, never destroyed — memory leak
sound:Play()

-- GOOD: destroy after playback ends
sound.Ended:Connect(function()
    sound:Destroy()
end)
sound:Play()
Shadows = true on dynamic lights is expensive. For most dynamic lights (fire, explosions, torches at distance), use Shadows = false and only enable shadows for the one or two key lights in a scene.

Sound, Animation, and VFX Sync Checklist

  • Use MarkerReachedSignal to trigger sounds and hitboxes at exact animation frames
  • Play impact sounds at the moment of collision, not when the swing starts
  • Match PlaybackSpeed to any animation speed multipliers
  • Use TweenService rather than setting properties each RenderStepped
  • Destroy one-shot sounds via Ended, never via task.delay estimates
  • Pool VFX objects rather than creating/destroying per event

  • Combat Systems — hit effect triggers, animation markers for hitboxes, damage number display
  • GUI Systems — TweenService patterns for UI transitions mirror the VFX patterns here
  • Inventory Systems — equipping items that carry particle aura effects

Build docs developers (and LLMs) love