docs.unity3d.com
Search Results for

    Show / Hide Table of Contents

    Interactable Spawning Weapon

    This tutorial shows how to create a weapon that spawns networked interactable objects. We'll build a teleport pad launcher—shoot projectiles that place teleport pads on surfaces, letting players teleport between two linked pads.

    Prerequisites

    Complete the Creating a Custom Weapon guide first.


    Part 1: The Interfaces

    We need two interfaces: one for projectile behavior, one for interaction effects.

    IProjectileEffect

    Defines what happens when a projectile is launched, flies, and hits something:

    public interface IProjectileEffect
    {
        void Initialize(ModularProjectile projectile);  // Called once on Awake
        void Setup(GameObject owner, IWeapon weapon, ShootingContext context);  // Called before launch
        void OnLaunch();  // Projectile spawned
        void ProcessUpdate();  // Every frame
        void Cleanup();  // On despawn
        void ContactEvent(...);  // On collision
    }
    

    IInteractionEffect

    Defines a single action when a player interacts with something:

    public interface IInteractionEffect
    {
        int Priority { get; }  // Higher = executes first
        
        IEnumerator ApplyEffect(GameObject interactor, GameObject interactable);
        void CancelEffect(GameObject interactor);
    }
    

    Part 2: How It Works (Pseudo-Code)

    We're creating two scripts that work together:

    SpawnInteractableEffect (Projectile)

    SETUP:
        - Store the shooter's info
        - Ignore collisions between projectile and shooter
    
    ON COLLISION (ContactEvent):
        - If already spawned something → skip
        - If hit the shooter → skip
        
        - Calculate spawn position (contact point + offset along surface normal)
        - Calculate rotation (align to surface if enabled)
        - Instantiate the teleport pad prefab
        - Tell the pad who owns it (shooter's client ID)
        - Spawn it on the network
        - Disable this projectile
    

    TeleportPadEffect (Interactable)

    ON SPAWN:
        - Register this pad in the player's pad list
        - If player has 3+ pads → despawn the oldest one
        - If player has exactly 2 pads → link them together
    
    ON INTERACT (ApplyEffect):
        - If interactor is NOT the pad owner → reject
        - If no partner pad exists → reject
        
        - Disable player movement
        - Play start VFX/SFX + camera shake
        - Wait for delay
        - Teleport player to partner pad position
        - Play end VFX/SFX + camera shake
        - Re-enable movement
    
    ON DESPAWN:
        - Remove from player's pad list
        - Update partner references
    

    Part 3: Complete Scripts

    SpawnInteractableEffect.cs

    using System;
    using UnityEngine;
    using Unity.Netcode;
    using Blocks.Gameplay.Shooter;
    using Unity.Netcode.Components;
    
    public class SpawnInteractableEffect : NetworkBehaviour, IProjectileEffect
    {
        [Header("Spawn Settings")]
        [SerializeField] private NetworkObject interactablePrefab;
        [SerializeField] private bool alignToSurface = true;
        [SerializeField] private float spawnOffset = 0.1f;
    
        [Header("Collision Settings")]
        [SerializeField] private Collider[] colliders;
    
        [Header("Lifetime Settings")]
        [SerializeField] private bool deferredDespawn = true;
        [SerializeField] private int deferredDespawnTicks = 2;
    
        private GameObject m_Owner;
        private ModularProjectile m_Projectile;
        private ShootingContext m_ShootingContext;
        private bool m_HasSpawned;
    
        public bool IsDeferredDespawnEnabled => deferredDespawn;
        public int DeferredDespawnTicks => deferredDespawnTicks;
    
        public event Action<ModularProjectile> OnEffectComplete;
    
        // ═══════════════════════════════════════════════════════════════
        // IProjectileEffect Implementation
        // ═══════════════════════════════════════════════════════════════
    
        public void Initialize(ModularProjectile projectile)
        {
            m_Projectile = projectile;
        }
    
        public void Setup(GameObject owner, IWeapon sourceWeapon, ShootingContext context)
        {
            m_Owner = owner;
            m_ShootingContext = context;
            m_HasSpawned = false;
            IgnoreCollision(owner, gameObject, true);
        }
    
        public void OnLaunch()
        {
            m_HasSpawned = false;
            EnableColliders(true);
        }
    
        public void ProcessUpdate()
        {
            // No time-based logic for this effect
        }
    
        public void Cleanup()
        {
            if (m_Owner != null)
            {
                IgnoreCollision(m_Owner, gameObject, false);
            }
        }
    
        public ContactEventHandlerInfo GetContactEventHandlerInfo()
        {
            return new ContactEventHandlerInfo
            {
                ProvideNonRigidBodyContactEvents = true,
                HasContactEventPriority = HasAuthority
            };
        }
    
        public Rigidbody GetRigidbody()
        {
            return m_Projectile != null ? m_Projectile.GetComponent<Rigidbody>() : null;
        }
    
        // ═══════════════════════════════════════════════════════════════
        // Collision Handling
        // ═══════════════════════════════════════════════════════════════
    
        public void ContactEvent(ulong eventId, Vector3 averageNormal, Rigidbody collidingBody,
            Vector3 contactPoint, bool hasCollisionStay = false, Vector3 averagedCollisionStayNormal = default)
        {
            if (!IsSpawned || m_HasSpawned || !HasAuthority) return;
            if (collidingBody != null && collidingBody.gameObject == m_Owner) return;
    
            SpawnInteractable(contactPoint, averageNormal);
        }
    
        // ═══════════════════════════════════════════════════════════════
        // Spawning
        // ═══════════════════════════════════════════════════════════════
    
        private void SpawnInteractable(Vector3 contactPoint, Vector3 surfaceNormal)
        {
            if (!HasAuthority || m_HasSpawned || interactablePrefab == null) return;
    
            m_HasSpawned = true;
    
            // Calculate spawn position and rotation
            Vector3 spawnPosition = contactPoint + (surfaceNormal * spawnOffset);
            Quaternion spawnRotation = alignToSurface
                ? Quaternion.LookRotation(Vector3.ProjectOnPlane(transform.forward, surfaceNormal), surfaceNormal)
                : Quaternion.identity;
    
            // Instantiate and spawn the network object
            NetworkObject spawnedObject = Instantiate(interactablePrefab, spawnPosition, spawnRotation);
    
            // Initialize TeleportPadEffect if present
            if (spawnedObject.TryGetComponent<TeleportPadEffect>(out var teleportPad))
            {
                teleportPad.InitializeOwner(m_ShootingContext.ownerClientId);
            }
    
            spawnedObject.SpawnWithOwnership(m_ShootingContext.ownerClientId);
    
            // Notify all clients to disable the projectile visually
            DisableProjectileRpc();
        }
    
        [Rpc(SendTo.Everyone)]
        private void DisableProjectileRpc()
        {
            EnableColliders(false);
    
            var rb = GetRigidbody();
            if (rb != null)
            {
                if (!rb.isKinematic)
                {
                    rb.linearVelocity = Vector3.zero;
                    rb.angularVelocity = Vector3.zero;
                    rb.isKinematic = true;
                }
            }
    
            if (HasAuthority)
            {
                OnEffectComplete?.Invoke(m_Projectile);
            }
        }
    
        // ═══════════════════════════════════════════════════════════════
        // Helpers
        // ═══════════════════════════════════════════════════════════════
    
        private void EnableColliders(bool enable)
        {
            if (colliders == null) return;
            foreach (var col in colliders)
            {
                if (col != null) col.enabled = enable;
            }
        }
    
        private void IgnoreCollision(GameObject objectA, GameObject objectB, bool shouldIgnore)
        {
            if (objectA == null || objectB == null) return;
    
            var rootA = objectA.transform.root.gameObject;
            var rootB = objectB.transform.root.gameObject;
    
            var collidersA = rootA.GetComponentsInChildren<Collider>();
            var collidersB = rootB.GetComponentsInChildren<Collider>();
    
            foreach (var colliderA in collidersA)
            {
                foreach (var colliderB in collidersB)
                {
                    Physics.IgnoreCollision(colliderA, colliderB, shouldIgnore);
                }
            }
        }
    }
    
    

    TeleportPadEffect.cs

    using UnityEngine;
    using Unity.Netcode;
    using UnityEngine.VFX;
    using Unity.Cinemachine;
    using System.Collections;
    using Blocks.Gameplay.Core;
    using System.Collections.Generic;
    
    public class TeleportPadEffect : NetworkBehaviour, IInteractionEffect
    {
        [Header("Effect Settings")]
        [SerializeField] private int priority = 10;
    
        [Header("Teleport Start Effects")]
        [SerializeField] private GameObject teleportStartVFX;
        [SerializeField] private SoundDef teleportStartSfx;
        [SerializeField] private float teleportDelay = 0.5f;
    
        [Header("Teleport End Effects")]
        [SerializeField] private GameObject teleportEndVFX;
        [SerializeField] private SoundDef teleportEndSfx;
    
        [Header("VFX & Audio Settings")]
        [SerializeField] private float vfxDuration = 2.0f;
        [SerializeField] private float sfxVolume = 1.0f;
    
        [Header("Movement Control")]
        [SerializeField] private bool disableMovementDuringTeleport = true;
    
        [Header("Pad Pairing")]
        [SerializeField] private int maxPadsPerPlayer = 2;
        [SerializeField] private float teleportHeightOffset = 1.0f;
    
        [Header("Access Control")]
        [SerializeField] private bool allowAllPlayers;
    
        [Header("Cooldown")]
        [SerializeField] private float teleportCooldown = 1.5f;
    
        // Network variables for pad state
        private readonly NetworkVariable<ulong> m_PadOwnerClientId =
            new NetworkVariable<ulong>(ulong.MaxValue, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
        private readonly NetworkVariable<NetworkObjectReference> m_PartnerPad =
            new NetworkVariable<NetworkObjectReference>(default, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
    
        // Pending owner ID for post-spawn initialization
        private ulong m_PendingOwnerClientId = ulong.MaxValue;
    
        // Static tracking of pads per player (authority-side)
        private static readonly Dictionary<ulong, List<TeleportPadEffect>> k_PlayerPads =
            new Dictionary<ulong, List<TeleportPadEffect>>();
    
        // Cooldown tracking per player
        private readonly Dictionary<ulong, float> m_LastTeleportTimes = new Dictionary<ulong, float>();
    
        public int Priority => priority;
    
        public TeleportPadEffect PartnerPad
        {
            get
            {
                if (m_PartnerPad.Value.TryGet(out NetworkObject netObj))
                {
                    return netObj.GetComponent<TeleportPadEffect>();
                }
                return null;
            }
        }
    
        // ═══════════════════════════════════════════════════════════════
        // Network Lifecycle
        // ═══════════════════════════════════════════════════════════════
    
        public override void OnNetworkSpawn()
        {
            base.OnNetworkSpawn();
    
            if (HasAuthority && m_PendingOwnerClientId != ulong.MaxValue)
            {
                m_PadOwnerClientId.Value = m_PendingOwnerClientId;
                RegisterPad();
            }
        }
    
        public override void OnNetworkDespawn()
        {
            if (HasAuthority)
            {
                UnregisterPad();
            }
    
            base.OnNetworkDespawn();
        }
    
        public void InitializeOwner(ulong ownerClientId)
        {
            m_PendingOwnerClientId = ownerClientId;
        }
    
        // ═══════════════════════════════════════════════════════════════
        // IInteractionEffect Implementation
        // ═══════════════════════════════════════════════════════════════
    
        public IEnumerator ApplyEffect(GameObject interactor, GameObject interactable)
        {
            if (!interactor.TryGetComponent<NetworkObject>(out var interactorNetObj))
            {
                yield break;
            }
    
            // Check if player is allowed to use this pad
            if (!allowAllPlayers && interactorNetObj.OwnerClientId != m_PadOwnerClientId.Value)
            {
                Debug.Log($"[TeleportPadEffect] Player {interactorNetObj.OwnerClientId} " +
                          $"cannot use pad owned by {m_PadOwnerClientId.Value}");
                yield break;
            }
    
            // Check cooldown to prevent teleport loops
            ulong playerId = interactorNetObj.OwnerClientId;
            if (m_LastTeleportTimes.TryGetValue(playerId, out float lastTime))
            {
                if (Time.time - lastTime < teleportCooldown)
                {
                    yield break;
                }
            }
    
            TeleportPadEffect partner = PartnerPad;
            if (partner == null)
            {
                Debug.Log("[TeleportPadEffect] No partner pad available for teleportation.");
                yield break;
            }
    
            Vector3 startPosition = interactor.transform.position;
            Vector3 endPosition = partner.transform.position + Vector3.up * teleportHeightOffset;
    
            CorePlayerManager playerManager = null;
            if (disableMovementDuringTeleport)
            {
                playerManager = interactor.GetComponent<CorePlayerManager>();
                if (playerManager != null)
                {
                    playerManager.SetMovementInputEnabled(false);
                }
            }
    
            PlayTeleportStartEffectsRpc(startPosition);
            CoreDirector.RequestCameraShake()
                .WithImpulseDefinition(CinemachineImpulseDefinition.ImpulseShapes.Rumble,
                    CinemachineImpulseDefinition.ImpulseTypes.Dissipating,
                    0.15f)
                .WithVelocity(0.15f)
                .AtPosition(transform.position)
                .Execute();
    
            if (teleportDelay > 0f)
            {
                yield return new WaitForSeconds(teleportDelay);
            }
    
            PerformTeleport(interactor, endPosition);
    
            // Set cooldown on both pads to prevent teleport loops
            m_LastTeleportTimes[playerId] = Time.time;
            partner.m_LastTeleportTimes[playerId] = Time.time;
    
            PlayTeleportEndEffectsRpc(endPosition);
            CoreDirector.RequestCameraShake()
                .WithImpulseDefinition(CinemachineImpulseDefinition.ImpulseShapes.Rumble,
                    CinemachineImpulseDefinition.ImpulseTypes.Propagating,
                    0.15f)
                .WithVelocity(0.15f)
                .AtPosition(endPosition)
                .Execute();
    
            if (disableMovementDuringTeleport && playerManager != null)
            {
                playerManager.SetMovementInputEnabled(true);
            }
    
            yield return null;
        }
    
        public void CancelEffect(GameObject interactor)
        {
            if (disableMovementDuringTeleport)
            {
                var playerManager = interactor.GetComponent<CorePlayerManager>();
                if (playerManager != null)
                {
                    playerManager.SetMovementInputEnabled(true);
                }
            }
        }
    
        // ═══════════════════════════════════════════════════════════════
        // Pad Registration
        // ═══════════════════════════════════════════════════════════════
    
        private void RegisterPad()
        {
            ulong ownerId = m_PadOwnerClientId.Value;
    
            if (!k_PlayerPads.TryGetValue(ownerId, out var padList))
            {
                padList = new List<TeleportPadEffect>();
                k_PlayerPads[ownerId] = padList;
            }
    
            // If player already has max pads, despawn the oldest
            while (padList.Count >= maxPadsPerPlayer)
            {
                var oldestPad = padList[0];
                padList.RemoveAt(0);
                if (oldestPad != null && oldestPad.IsSpawned)
                {
                    oldestPad.NetworkObject.Despawn();
                }
            }
    
            padList.Add(this);
            UpdatePartnerReferences(padList);
        }
    
        private void UnregisterPad()
        {
            ulong ownerId = m_PadOwnerClientId.Value;
    
            if (k_PlayerPads.TryGetValue(ownerId, out var padList))
            {
                padList.Remove(this);
    
                if (padList.Count > 0)
                {
                    UpdatePartnerReferences(padList);
                }
                else
                {
                    k_PlayerPads.Remove(ownerId);
                }
            }
        }
    
        private void UpdatePartnerReferences(List<TeleportPadEffect> padList)
        {
            // Clear all partner references first
            foreach (var pad in padList)
            {
                if (pad != null && pad.IsSpawned)
                {
                    pad.m_PartnerPad.Value = default;
                }
            }
    
            // If we have exactly 2 pads, link them together
            if (padList.Count == 2)
            {
                var pad1 = padList[0];
                var pad2 = padList[1];
    
                if (pad1 != null && pad1.IsSpawned && pad2 != null && pad2.IsSpawned)
                {
                    pad1.m_PartnerPad.Value = new NetworkObjectReference(pad2.NetworkObject);
                    pad2.m_PartnerPad.Value = new NetworkObjectReference(pad1.NetworkObject);
                }
            }
        }
    
        // ═══════════════════════════════════════════════════════════════
        // Teleportation
        // ═══════════════════════════════════════════════════════════════
    
        private void PerformTeleport(GameObject interactor, Vector3 targetPosition)
        {
            if (interactor.TryGetComponent<CoreMovement>(out var coreMovement))
            {
                coreMovement.SetPosition(targetPosition);
            }
            else
            {
                Debug.LogWarning($"[TeleportPadEffect] Interactor '{interactor.name}' does not have " +
                                 "CoreMovement. Using direct transform.");
                interactor.transform.position = targetPosition;
            }
        }
    
        // ═══════════════════════════════════════════════════════════════
        // Visual & Audio Effects (synced via RPC)
        // ═══════════════════════════════════════════════════════════════
    
        [Rpc(SendTo.Everyone)]
        private void PlayTeleportStartEffectsRpc(Vector3 position)
        {
            if (teleportStartVFX != null)
            {
                GameObject vfxInstance = Instantiate(teleportStartVFX, position, Quaternion.identity);
    
                if (vfxInstance.TryGetComponent<VisualEffect>(out var vfx))
                {
                    vfx.Play();
                }
                else if (vfxInstance.TryGetComponent<ParticleSystem>(out var ps))
                {
                    ps.Play();
                }
    
                Destroy(vfxInstance, vfxDuration);
            }
    
            if (teleportStartSfx != null)
            {
                CoreDirector.RequestAudio(teleportStartSfx)
                    .WithPosition(position)
                    .Play(sfxVolume);
            }
        }
    
        [Rpc(SendTo.Everyone)]
        private void PlayTeleportEndEffectsRpc(Vector3 position)
        {
            if (teleportEndVFX != null)
            {
                GameObject vfxInstance = Instantiate(teleportEndVFX, position, Quaternion.identity);
    
                if (vfxInstance.TryGetComponent<VisualEffect>(out var vfx))
                {
                    vfx.Play();
                }
                else if (vfxInstance.TryGetComponent<ParticleSystem>(out var ps))
                {
                    ps.Play();
                }
    
                Destroy(vfxInstance, vfxDuration);
            }
    
            if (teleportEndSfx != null)
            {
                CoreDirector.RequestAudio(teleportEndSfx)
                    .WithPosition(position)
                    .Play(sfxVolume);
            }
        }
    }
    
    

    Setup

    1. Create the Teleport Pad Prefab: Create a new prefab with NetworkObject, ModularInteractable (set Trigger Mode to OnTriggerEnter), and a trigger collider. Add the TeleportPadEffect component and configure VFX/SFX references.
    2. Configure the Projectile: Use Projectile_Bullet from Shooter/Prefabs/Projectiles as a starting point. Add SpawnInteractableEffect to your ModularProjectile and assign your Teleport Pad prefab to Interactable Prefab. See image below for the setup.
    3. Add to Weapon: Add a ProjectileShootingBehavior to your weapon and assign your projectile prefab.

    Projectile Setup Weapon Setup

    Tip

    Reference the existing Teleporter prefab in the Platformer scene as a starting point.

    Teleporter in Scene


    Result

    Final Result

    Note

    This pattern works for other interactable-spawning weapons like turret launchers, shield generators, healing stations, and trap deployers. Simply create a new IInteractionEffect and assign it to the spawned prefab.

    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)