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
- Add to your weapon: Open your weapon prefab and select
ChainLightningBehaviorin the Shooting Behavior dropdown - Configure settings: Adjust chain range, damage, and visual settings to taste

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