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
- Create the Teleport Pad Prefab: Create a new prefab with
NetworkObject,ModularInteractable(set Trigger Mode toOnTriggerEnter), and a trigger collider. Add theTeleportPadEffectcomponent and configure VFX/SFX references. - Configure the Projectile: Use
Projectile_BulletfromShooter/Prefabs/Projectilesas a starting point. AddSpawnInteractableEffectto your ModularProjectile and assign your Teleport Pad prefab to Interactable Prefab. See image below for the setup. - Add to Weapon: Add a
ProjectileShootingBehaviorto your weapon and assign your projectile prefab.

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

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.