docs.unity3d.com
Search Results for

    Show / Hide Table of Contents

    Chain Lightning Weapon

    This tutorial shows how to create a chain lightning weapon that fires an arc of electricity jumping between multiple enemies.

    Prerequisites

    Complete the Creating a Custom Weapon guide first.


    Part 1: The Interface

    To create any custom shooting behavior, you implement the IShootingBehavior interface:

    public interface IShootingBehavior
    {
        void Shoot(ShootingContext context);              // Fire the weapon
        bool CanShoot();                                  // Check if ready to fire
        void UpdateShooting(Vector3 direction, float dt); // Update for continuous fire
        void StopShooting();                              // Clean up when done
    }
    

    The ShootingContext gives you everything you need:

    Field What it does
    origin Where the shot starts
    direction Where the player is aiming
    muzzle Transform for visual effects
    damage Base damage value
    owner The shooting player's GameObject
    ownerClientId Network ID for hit attribution
    hitMask Layers to detect hits on
    Weapon Reference for playing effects

    Part 2: How It Works (Pseudo-Code)

    Here's the logic for chain lightning in plain terms:

    SHOOT:
        1. Clear previous chain data
        2. Get the starting point (muzzle position)
        
        3. FIND FIRST TARGET:
           - Spherecast from muzzle in aim direction
           - If hit something → add to chain, apply damage
           - If missed → just draw lightning to max range
        
        4. CHAIN TO MORE TARGETS (repeat up to maxChains times):
           - Look for nearby enemies around current hit point
           - Skip enemies already hit
           - Skip non-hittable objects (walls, props)
           - Pick the best target (closest + most aligned)
           - Apply reduced damage (70% of previous)
           - Add to chain
        
        5. SHOW VISUALS (via network RPC so all players see it):
           - Create animated LineRenderer arcs between chain points
    
    CAN SHOOT:
        - Always return true (weapon is always ready)
    
    STOP SHOOTING:
        - Destroy any active lightning arcs
    

    The key parts are:

    • Target Finding: Use spherecasting for the first target, sphere overlap for chaining
    • Damage Decay: Each chain does less damage than the last
    • Network Sync: Visuals are sent to all clients via RPC
    • Valid Targets: Only chain to objects with ShooterHitProcessor

    Part 3: Complete Script

    Create a new script called ChainLightningBehavior.cs:

    using UnityEngine;
    using Unity.Netcode;
    using Blocks.Gameplay.Core;
    using Blocks.Gameplay.Shooter;
    using System.Collections.Generic;
    
    public class ChainLightningBehavior : NetworkBehaviour, IShootingBehavior
    {
        [Header("Chain Lightning Settings")]
        [SerializeField] private float initialRange = 30f;
        [SerializeField] private float chainRange = 15f;
        [SerializeField] private int maxChains = 4;
        [SerializeField] private float chainAngle = 90f;
        [SerializeField] private float targetAcquisitionRadius = 0.5f;
    
        [Header("Damage Settings")]
        [SerializeField] private float baseDamage = 6f;
        [SerializeField] private float damageDecayPerChain = 0.7f;
        [SerializeField] private float hitForce = 50f;
    
        [Header("Arc Visual Settings")]
        [SerializeField] private Color lightningColor = new Color(0.4f, 0.8f, 1f, 1f);
        [SerializeField] private float arcWidthStart = 0.02f;
        [SerializeField] private float arcWidthEnd = 0.04f;
        [SerializeField] private float arcDuration = 0.15f;
    
        [Header("Arc Animation Settings")]
        [SerializeField] private int arcSegments = 12;
        [SerializeField] private float arcAmplitude = 0.3f;
        [SerializeField] private float arcFrequency = 8f;
        [SerializeField] private float arcAnimationSpeed = 10f;
        [SerializeField] private float arcJitter = 0.2f;
    
        [Header("Advanced Visual Settings")]
        [SerializeField] private Material arcMaterial;
        [SerializeField] private bool emitPointLights = true;
        [SerializeField] private float pointLightIntensity = 2f;
        [SerializeField] private float pointLightRange = 5f;
    
        // Internal state
        private readonly List<Vector3> m_ChainPoints = new List<Vector3>();
        private readonly List<GameObject> m_HitTargets = new List<GameObject>();
        private readonly List<LineRenderer> m_ActiveArcs = new List<LineRenderer>();
        private readonly List<Light> m_ActiveLights = new List<Light>();
        private Material m_DefaultMaterial;
        private float m_ArcTimeOffset;
    
        static readonly int k_SrcBlend = Shader.PropertyToID("_SrcBlend");
        static readonly int k_DstBlend = Shader.PropertyToID("_DstBlend");
    
        private void Awake()
        {
            if (arcMaterial == null)
            {
                m_DefaultMaterial = new Material(Shader.Find("Sprites/Default"));
                m_DefaultMaterial.SetInt(k_SrcBlend, (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
                m_DefaultMaterial.SetInt(k_DstBlend, (int)UnityEngine.Rendering.BlendMode.One);
            }
        }
    
        private void Update()
        {
            m_ArcTimeOffset += Time.deltaTime * arcAnimationSpeed;
    
            for (int i = m_ActiveArcs.Count - 1; i >= 0; i--)
            {
                var arc = m_ActiveArcs[i];
                if (arc == null)
                {
                    m_ActiveArcs.RemoveAt(i);
                    continue;
                }
    
                if (arcAnimationSpeed > 0 && arc.positionCount > 2)
                {
                    AnimateArc(arc, arc.GetPosition(0), arc.GetPosition(arc.positionCount - 1));
                }
            }
        }
    
        public override void OnDestroy()
        {
            base.OnDestroy();
            if (m_DefaultMaterial != null)
            {
                Destroy(m_DefaultMaterial);
            }
        }
    
        // ═══════════════════════════════════════════════════════════════
        // IShootingBehavior Implementation
        // ═══════════════════════════════════════════════════════════════
    
        public void Shoot(ShootingContext context)
        {
            m_ChainPoints.Clear();
            m_HitTargets.Clear();
    
            Vector3 origin = context.muzzle != null ? context.muzzle.position : context.origin;
            Vector3 direction = ApplySpread(context.direction, context.currentSpread);
    
            m_ChainPoints.Add(origin);
    
            // Find initial target
            bool hitInitialTarget = FindInitialTarget(origin, direction, context,
                out Vector3 hitPoint, out IHittable hittable, out GameObject hitObject);
    
            if (!hitInitialTarget)
            {
                // No target - shoot to max range
                m_ChainPoints.Add(origin + direction * initialRange);
            }
            else
            {
                m_ChainPoints.Add(hitPoint);
                m_HitTargets.Add(hitObject);
    
                if (hittable != null)
                    ApplyDamage(hittable, hitPoint, direction, baseDamage, context);
    
                // Chain to additional targets
                float currentDamage = baseDamage;
                Vector3 currentPoint = hitPoint;
                Vector3 currentDirection = direction;
    
                for (int chain = 0; chain < maxChains; chain++)
                {
                    currentDamage *= damageDecayPerChain;
    
                    if (!FindNextChainTarget(currentPoint, currentDirection, context,
                        out Vector3 nextHitPoint, out IHittable nextHittable, out GameObject nextHitObject))
                        break;
    
                    m_ChainPoints.Add(nextHitPoint);
                    m_HitTargets.Add(nextHitObject);
    
                    if (nextHittable != null)
                    {
                        Vector3 chainDirection = (nextHitPoint - currentPoint).normalized;
                        ApplyDamage(nextHittable, nextHitPoint, chainDirection, currentDamage, context);
                    }
    
                    currentDirection = (nextHitPoint - currentPoint).normalized;
                    currentPoint = nextHitPoint;
                }
            }
    
            // Sync visuals to all clients
            PlayChainLightningVisualsRpc(m_ChainPoints.ToArray(), emitPointLights);
            context.OnAmmoConsumed?.Invoke(1);
    
            if (m_ChainPoints.Count > 1)
            {
                context.OnHitPointCalculated?.Invoke(m_ChainPoints[m_ChainPoints.Count - 1], Vector3.up);
            }
        }
    
        public bool CanShoot() => true;
    
        public void UpdateShooting(Vector3 updatedDirection, float deltaTime) { }
    
        public void StopShooting()
        {
            foreach (var arc in m_ActiveArcs)
            {
                if (arc != null) Destroy(arc.gameObject);
            }
            m_ActiveArcs.Clear();
    
            foreach (var light in m_ActiveLights)
            {
                if (light != null) Destroy(light.gameObject);
            }
            m_ActiveLights.Clear();
        }
    
        // ═══════════════════════════════════════════════════════════════
        // Target Finding
        // ═══════════════════════════════════════════════════════════════
    
        private bool FindInitialTarget(Vector3 origin, Vector3 direction, ShootingContext context,
            out Vector3 hitPoint, out IHittable hittable, out GameObject hitObject)
        {
            hitPoint = Vector3.zero;
            hittable = null;
            hitObject = null;
    
            if (Physics.SphereCast(origin, targetAcquisitionRadius, direction, out RaycastHit hit,
                initialRange, context.hitMask))
            {
                // Skip self
                if (hit.collider.transform.root == context.owner.transform.root)
                {
                    if (Physics.Raycast(origin, direction, out hit, initialRange, context.hitMask))
                    {
                        if (hit.collider.transform.root == context.owner.transform.root)
                            return false;
                    }
                    else return false;
                }
    
                hitPoint = hit.point;
                hitObject = hit.collider.gameObject;
                hittable = hit.collider.GetComponentInParent<IHittable>();
    
                // Play impact effect
                NetworkObjectReference parentRef = default;
                var parentNetObj = hit.collider.GetComponentInParent<NetworkObject>();
                if (parentNetObj != null) parentRef = parentNetObj;
                context.Weapon?.PlayImpactEffect(hit.point, hit.normal, baseDamage, parentRef);
    
                return true;
            }
    
            return false;
        }
    
        private bool FindNextChainTarget(Vector3 fromPoint, Vector3 previousDirection, ShootingContext context,
            out Vector3 hitPoint, out IHittable hittable, out GameObject hitObject)
        {
            hitPoint = Vector3.zero;
            hittable = null;
            hitObject = null;
    
            // Find all potential targets in range
            Collider[] colliders = Physics.OverlapSphere(fromPoint, chainRange, context.hitMask);
    
            float bestScore = float.MinValue;
            Collider bestTarget = null;
    
            foreach (var collider in colliders)
            {
                // Skip owner
                if (collider.transform.root == context.owner.transform.root)
                    continue;
    
                // Skip already hit targets
                bool alreadyHit = false;
                foreach (var hitTarget in m_HitTargets)
                {
                    if (collider.transform.root == hitTarget.transform.root)
                    {
                        alreadyHit = true;
                        break;
                    }
                }
                if (alreadyHit) continue;
    
                // Only chain to valid hit targets
                var targetHitProcessor = collider.GetComponentInParent<ShooterHitProcessor>();
                if (targetHitProcessor == null)
                    continue;
    
                // Calculate direction and score
                Vector3 targetPoint = collider.ClosestPoint(fromPoint);
                Vector3 toTarget = targetPoint - fromPoint;
                float distance = toTarget.magnitude;
    
                if (distance < 0.1f || distance > chainRange)
                    continue;
    
                Vector3 directionToTarget = toTarget.normalized;
                float angle = Vector3.Angle(previousDirection, directionToTarget);
    
                if (angle > chainAngle)
                    continue;
    
                // Check line of sight
                if (Physics.Raycast(fromPoint, directionToTarget, out RaycastHit losHit, distance, context.hitMask))
                {
                    if (losHit.collider.transform.root != collider.transform.root)
                        continue;
                }
    
                // Score: prefer closer and more aligned
                float score = (1f - distance / chainRange) * 0.4f + (1f - angle / chainAngle) * 0.6f;
    
                if (score > bestScore)
                {
                    bestScore = score;
                    bestTarget = collider;
                }
            }
    
            if (bestTarget != null)
            {
                hitPoint = bestTarget.ClosestPoint(fromPoint);
                hitObject = bestTarget.gameObject;
                hittable = bestTarget.GetComponentInParent<IHittable>();
    
                NetworkObjectReference parentRef = default;
                var hitProcessor = bestTarget.GetComponentInParent<ShooterHitProcessor>();
                if (hitProcessor != null && hitProcessor.NetworkObject != null)
                    parentRef = hitProcessor.NetworkObject;
    
                Vector3 hitNormal = (fromPoint - hitPoint).normalized;
                float damage = baseDamage * Mathf.Pow(damageDecayPerChain, m_HitTargets.Count);
                context.Weapon?.PlayImpactEffect(hitPoint, hitNormal, damage, parentRef);
    
                return true;
            }
    
            return false;
        }
    
        // ═══════════════════════════════════════════════════════════════
        // Damage
        // ═══════════════════════════════════════════════════════════════
    
        private void ApplyDamage(IHittable hittable, Vector3 hitPoint, Vector3 direction,
            float damage, ShootingContext context)
        {
            var hitInfo = new HitInfo
            {
                amount = damage,
                hitPoint = hitPoint,
                hitNormal = -direction,
                attackerId = context.ownerClientId,
                impactForce = direction * hitForce
            };
    
            hittable.OnHit(hitInfo);
            context.OnTargetHit?.Invoke(
                hittable as MonoBehaviour != null ? (hittable as MonoBehaviour).gameObject : null,
                hitInfo);
        }
    
        // ═══════════════════════════════════════════════════════════════
        // Visuals (synced via RPC)
        // ═══════════════════════════════════════════════════════════════
    
        [Rpc(SendTo.Everyone)]
        private void PlayChainLightningVisualsRpc(Vector3[] chainPoints, bool createPointLights)
        {
            m_ChainPoints.Clear();
            m_ChainPoints.AddRange(chainPoints);
    
            CreateLightningArcs();
    
            if (createPointLights)
                CreatePointLights();
        }
    
        private void CreateLightningArcs()
        {
            if (m_ChainPoints.Count < 2) return;
    
            for (int i = 0; i < m_ChainPoints.Count - 1; i++)
            {
                LineRenderer arc = CreateArcRenderer();
                SetupArcPositions(arc, m_ChainPoints[i], m_ChainPoints[i + 1]);
                m_ActiveArcs.Add(arc);
                Destroy(arc.gameObject, arcDuration);
            }
        }
    
        private LineRenderer CreateArcRenderer()
        {
            GameObject arcObject = new GameObject("LightningArc");
            LineRenderer arc = arcObject.AddComponent<LineRenderer>();
    
            arc.material = arcMaterial != null ? arcMaterial : m_DefaultMaterial;
            arc.startColor = lightningColor;
            arc.endColor = lightningColor * 0.8f;
            arc.startWidth = arcWidthStart;
            arc.endWidth = arcWidthEnd;
            arc.numCapVertices = 4;
            arc.numCornerVertices = 4;
            arc.useWorldSpace = true;
            arc.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
            arc.receiveShadows = false;
    
            return arc;
        }
    
        private void SetupArcPositions(LineRenderer arc, Vector3 start, Vector3 end)
        {
            arc.positionCount = arcSegments;
    
            Vector3 direction = (end - start).normalized;
    
            Vector3 up = Vector3.Cross(direction, Vector3.right);
            if (up.magnitude < 0.001f)
                up = Vector3.Cross(direction, Vector3.forward);
            up.Normalize();
    
            Vector3 right = Vector3.Cross(direction, up).normalized;
            Vector3[] positions = new Vector3[arcSegments];
    
            for (int i = 0; i < arcSegments; i++)
            {
                float t = i / (float)(arcSegments - 1);
                Vector3 basePosition = Vector3.Lerp(start, end, t);
    
                if (i == 0 || i == arcSegments - 1)
                {
                    positions[i] = basePosition;
                    continue;
                }
    
                float noiseOffset = m_ArcTimeOffset + i * 0.7f;
                float displacement = Mathf.Sin(t * arcFrequency * Mathf.PI * 2f + noiseOffset) * arcAmplitude;
                displacement += Random.Range(-arcJitter, arcJitter);
    
                float secondaryDisplacement = Mathf.Sin(t * arcFrequency * 2f * Mathf.PI + noiseOffset * 1.5f)
                    * arcAmplitude * 0.3f;
    
                Vector3 offset = up * displacement + right * secondaryDisplacement;
                float taperFactor = Mathf.Sin(t * Mathf.PI);
                offset *= taperFactor;
    
                positions[i] = basePosition + offset;
            }
    
            arc.SetPositions(positions);
        }
    
        private void AnimateArc(LineRenderer arc, Vector3 start, Vector3 end)
        {
            if (arc == null || arc.positionCount < 2) return;
            SetupArcPositions(arc, start, end);
        }
    
        private void CreatePointLights()
        {
            foreach (var light in m_ActiveLights)
            {
                if (light != null) Destroy(light.gameObject);
            }
            m_ActiveLights.Clear();
    
            for (int i = 1; i < m_ChainPoints.Count; i++)
            {
                GameObject lightObj = new GameObject("ChainLight");
                lightObj.transform.position = m_ChainPoints[i];
    
                Light pointLight = lightObj.AddComponent<Light>();
                pointLight.type = LightType.Point;
                pointLight.color = lightningColor;
                pointLight.intensity = pointLightIntensity;
                pointLight.range = pointLightRange;
                pointLight.shadows = LightShadows.None;
    
                m_ActiveLights.Add(pointLight);
                Destroy(lightObj, arcDuration);
            }
        }
    
        private Vector3 ApplySpread(Vector3 direction, float spreadAngle)
        {
            if (spreadAngle <= 0) return direction;
    
            Vector2 randomCircle = Random.insideUnitCircle;
            Quaternion spreadRotation = Quaternion.Euler(
                randomCircle.y * spreadAngle,
                randomCircle.x * spreadAngle, 0);
            return spreadRotation * direction;
        }
    }
    
    

    Setup

    1. Add to your weapon: Open your weapon prefab and select ChainLightningBehavior in the Shooting Behavior dropdown
    2. Configure settings: Adjust chain range, damage, and visual settings to taste

    Chain Lightning Inspector

    Note

    Lightning only chains to objects with a ShooterHitProcessor component, preventing connections to walls or non-damageable props.


    Result

    Chain Lightning in Action

    In This Article
    Back to top
    Copyright © 2026 Unity Technologies — Trademarks and terms of use
    • Legal
    • Privacy Policy
    • Cookie Policy
    • Do Not Sell or Share My Personal Information
    • Your Privacy Choices (Cookie Settings)