Skip to content

EffectManager

EffectManager (2121 lines in Unturned/Managers/EffectManager.cs) is the universal visual and audio effect playback system for the Unturned SDK. It manages a GameObjectPoolDictionary for pooled particle effect instances, a Dictionary<short, GameObject> for indexed UI effects, and a comprehensive TriggerEffectParameters-based API that replaces 20+ legacy sendEffect overloads. It also handles effect clearing by ID, GUID, or globally, debris cleanup, and one-shot audio through a dedicated RPC path.

EffectManager has the largest set of RPC dispatch variants in the SDK, reflecting the many combinations of effect parameters — position only, position with surface normal, position with uniform/non-uniform scale, full transform, and every combination thereof. The modern architecture consolidates these into a single triggerEffect(TriggerEffectParameters) method that selects the optimal RPC based on which optional parameters are set, minimizing network payload.

Source code location: Unturned/Managers/EffectManager.cs

Architecture Overview

EffectManager extends SteamCaller and operates as a static singleton. Key architectural components:

  • GameObjectPoolDictionary pool: Manages reuse of particle effect GameObject instances. Prefab-to-queue mapping with lazy instantiation on cache miss.
  • Dictionary<short, GameObject> indexedUIEffects: Maps short integer keys to active UI effect instances for lifecycle management.
  • TriggerEffectParameters: Modern struct-based API specifying effect parameters with automatic optimal-RPC selection.
  • Distance constants: SMALL=64, MEDIUM=128, LARGE=256, INSANE=512 for relevance-based culling.
  • Static UI component lists: formattingComponents (Text), buttonComponents (Button), inputFieldComponents (InputField), tmpTexts (TextMeshProUGUI), tmpInputFields (TMP_InputField) for per-frame UI formatting.

GameObjectPool System

Pool Architecture

The GameObjectPoolDictionary maps prefab references to queues of inactive instances:

csharp
// Conceptual pool structure
Dictionary<GameObject, Queue<PoolReference>> poolMap;

PoolReference Instantiate(GameObject prefab)
{
    if (poolMap.TryGetValue(prefab, out var queue) && queue.Count > 0)
        return queue.Dequeue(); // Reuse inactive instance

    // Create new instance
    GameObject instance = Object.Instantiate(prefab);
    PoolReference reference = new PoolReference(instance, prefab);
    return reference;
}

void Destroy(PoolReference reference)
{
    reference.instance.SetActive(false);
    poolMap[reference.prefab].Enqueue(reference);
}

InstantiateFromPool — Full Implementation

csharp
public static GameObject InstantiateFromPool(EffectAsset asset)
{
    if (asset == null || asset.effect == null)
        return null;

    // Step 1: Get from pool (reuse or create)
    PoolReference newInstanceRef = pool.Instantiate(asset.effect);

    // Step 2: Mark as excluded from destroy-all cleanup
    // Prevents gunfire/sentry/tracer effects from being cleaned mid-use
    newInstanceRef.excludeFromDestroyAll = true;

    GameObject newInstance = newInstanceRef.gameObject;

    // Step 3: Stop and clear any existing particle system
    ParticleSystem particle = newInstance.GetComponent<ParticleSystem>();
    if (particle != null)
    {
        particle.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
        particle.Clear(true);
    }

    // Step 4: Set debris tag and layer for cleanup
    newInstance.tag = "Debris";
    newInstance.layer = LayerMasks.DEBRIS;

    return newInstance;
}

The pool system's excludeFromDestroyAll flag is critical for combat effects — gunfire muzzle flashes, sentry tracer effects, and vehicle exhaust particles must not be cleaned up by ClearEffectAll while they're actively playing.

DestroyIntoPool

csharp
public static void DestroyIntoPool(GameObject element)
{
    if (element == null) return;
    pool.Destroy(element);
}

public static void DestroyIntoPool(GameObject element, float t)
{
    if (element == null) return;
    // Pool-managed delayed return (not Unity's Object.Destroy with delay)
    pool.DestroyDelayed(element, t);
}

The delayed overload does not use Object.Destroy(gameObject, t) — the pool internally manages the timed return, ensuring the instance is returned to the pool rather than destroyed permanently.

TriggerEffectParameters — The Modern API

Struct Definition

csharp
public struct TriggerEffectParameters
{
    public EffectAsset asset;
    public Vector3 position;
    public Vector3? normal;
    public Quaternion? rotation;
    public Vector3? scale;
    public bool reliable;
    public float relevantDistance;
    public ITransportConnection relevantTransportConnection;
    public List<ITransportConnection> relevantTransportConnections;

    public void SetDirection(Vector3 value) { normal = value; }
    public void SetRotation(Quaternion value) { rotation = value; }
    public void SetUniformScale(float value) { scale = new Vector3(value, value, value); }
    public void SetRelevantPlayer(ITransportConnection tc) { relevantTransportConnection = tc; }
    public void SetRelevantTransportConnections(List<ITransportConnection> connections)
        { relevantTransportConnections = connections; }
}

Parameter Reference

FieldPurpose
assetThe EffectAsset GUID or reference — resolved via Assets.find() internally
positionWorld position for the effect origin
SetDirection(normal)Surface normal for oriented effects (bullet impacts, splatter)
SetRotation(quaternion)Full rotation for effects that need specific orientation
scale / SetUniformScale(float)Scale override — non-uniform requires setting scale directly
reliableWhether to use reliable vs unreliable delivery
relevantDistanceCulling radius — only clients within this distance receive the effect
SetRelevantPlayer(transportConnection)Send to a single specific player
SetRelevantTransportConnections(list)Send to a specific list of players

RPC Dispatch Cascade

The triggerEffect(TriggerEffectParameters) method selects the optimal RPC from 9 possible variants:

if (rotation is set && scale is set && non-uniform):
    -> SendEffectPositionRotation_NonUniformScale
elif (rotation is set && uniform scale is set):
    -> SendEffectPositionRotation_UniformScale
elif (rotation is set):
    -> SendEffectPositionRotation
elif (normal is set && non-uniform scale is set):
    -> SendEffectPointNormal_NonUniformScale
elif (normal is set && uniform scale is set):
    -> SendEffectPointNormal_UniformScale
elif (normal is set):
    -> SendEffectPointNormal
elif (non-uniform scale is set):
    -> SendEffectPoint_NonUniformScale
elif (uniform scale is set):
    -> SendEffectPoint_UniformScale
else:
    -> SendEffectPoint

Each shape has two format variants: legacy ushort id (deprecated) and modern System.Guid (current). The triggerEffect method always uses the GUID path.

Invocation Flow

csharp
public static void triggerEffect(TriggerEffectParameters parameters)
{
    // Step 1: Resolve asset
    EffectAsset asset = parameters.asset;
    if (asset == null) return;

    // Step 2: Determine target connections
    List<ITransportConnection> targets;
    if (parameters.relevantTransportConnection != null)
        targets = new List<ITransportConnection> { parameters.relevantTransportConnection };
    else if (parameters.relevantTransportConnections != null)
        targets = parameters.relevantTransportConnections;
    else if (parameters.relevantDistance > 0)
        targets = Provider.GatherClientConnectionsWithinSphere(
            parameters.position, parameters.relevantDistance);
    else
        targets = Provider.GatherRemoteClientConnections();

    // Step 3: Select RPC and invoke
    ENetReliability reliability = parameters.reliable
        ? ENetReliability.Reliable : ENetReliability.Unreliable;

    if (parameters.rotation.HasValue && parameters.scale.HasValue)
    {
        if (IsNonUniform(parameters.scale.Value))
            SendEffectPositionRotation_NonUniformScale.Invoke(reliability, targets, ...);
        else
            SendEffectPositionRotation_UniformScale.Invoke(reliability, targets, ...);
    }
    else if (parameters.rotation.HasValue)
        SendEffectPositionRotation.Invoke(reliability, targets, ...);
    else if (parameters.normal.HasValue && parameters.scale.HasValue)
    {
        // ... normal + scale dispatch
    }
    // ... remaining 5 branches

    // Step 4: Loopback for local player (if server)
    if (Provider.isServer && !Dedicator.IsDedicatedServer)
    {
        // Run the effect locally
        manager.internalSpawnEffect(asset, position, rotation, scale, ...);
    }
}

Effect Clearing

Clearing Methods Reference

MethodScopeRPCUse Case
ClearEffectByID_AllPlayers(ushort)All clients, legacy IDSendEffectClearByIdDeprecated — prefer GUID-based clearing
ClearEffectByGuid(Guid, connection)Single player, GUIDSendEffectClearByGuidTarget one player's effects
ClearEffectByGuid_AllPlayers(Guid)All clients, GUIDSendEffectClearByGuidRemove all instances of a specific effect
askEffectClearAll()All clientsSendEffectClearAllFull cleanup — pool, splatter, debris, UI

ClearEffect Implementation

The local implementation after receiving SendEffectClearByGuid:

csharp
private void ClearEffect(EffectAsset asset)
{
    // 1. Pool instances matching the prefab
    pool.DestroyAllMatchingPrefab(asset.effect);

    // 2. Splatter prefabs
    if (asset.splatterPrefab != null)
        pool.DestroyAllMatchingPrefab(asset.splatterPrefab);

    // 3. UI effect instances
    for (int i = uiEffectInstances.Count - 1; i >= 0; i--)
    {
        if (uiEffectInstances[i].asset == asset)
        {
            UnityEngine.Object.Destroy(uiEffectInstances[i].gameObject);
            uiEffectInstances.RemoveAt(i);
        }
    }
}

UI Effects

UI Effect Creation

UI effects are created via SendUIEffect with 0–4 string arguments for formatting:

csharp
public static void SendUIEffect(EffectAsset asset, short key, bool reliable)
public static void SendUIEffect(EffectAsset asset, short key, bool reliable, string arg0)
public static void SendUIEffect(EffectAsset asset, short key, bool reliable,
    string arg0, string arg1)
public static void SendUIEffect(EffectAsset asset, short key, bool reliable,
    string arg0, string arg1, string arg2)
public static void SendUIEffect(EffectAsset asset, short key, bool reliable,
    string arg0, string arg1, string arg2, string arg3)

createUIEffect

csharp
private static void createUIEffect(System.Guid assetGuid, short key)
{
    // Step 1: Resolve asset
    EffectAsset asset = Assets.find(assetGuid) as EffectAsset;
    if (asset == null || asset.effect == null) return;

    // Step 2: Instantiate as UI canvas child
    GameObject instance = UnityEngine.Object.Instantiate(asset.effect);
    instance.transform.SetParent(UIEffectsRoot, false);

    // Step 3: Scan for UI components
    instance.GetComponentsInChildren(true, formattingComponents);
    instance.GetComponentsInChildren(true, buttonComponents);
    instance.GetComponentsInChildren(true, inputFieldComponents);
    instance.GetComponentsInChildren(true, tmpTexts);
    instance.GetComponentsInChildren(true, tmpInputFields);

    // Step 4: Register in indexed dictionary
    indexedUIEffects[key] = instance;
}

UI Effect Formatting

csharp
private static void createAndFormatUIEffect(System.Guid assetGuid, short key,
    params string[] args)
{
    createUIEffect(assetGuid, key);
    GameObject instance = indexedUIEffects[key];
    if (instance == null) return;

    // Format Text components
    for (int i = 0; i < formattingComponents.Count; i++)
    {
        string text = formattingComponents[i].text;
        for (int a = 0; a < args.Length; a++)
            text = text.Replace("{" + a + "}", args[a]);
        formattingComponents[i].text = text;
    }

    // Format TextMeshPro components
    for (int i = 0; i < tmpTexts.Count; i++)
    {
        string text = tmpTexts[i].text;
        for (int a = 0; a < args.Length; a++)
            text = text.Replace("{" + a + "}", args[a]);
        tmpTexts[i].text = text;
    }
}

UI Effect Lifecycle Management

MethodSignaturePurpose
sendUIEffectVisibility(short key, connection, bool reliable, string childNameOrPath, bool visible)Toggle child visibility via gameObject.SetActive
sendUIEffectText(short key, connection, bool reliable, string childNameOrPath, string text)Update Text/TextMeshProUGUI text on a child
sendUIEffectImageURL(short key, connection, bool reliable, string childNameOrPath, string url, bool shouldCache, bool forceRefresh)Set RawImage from URL with caching

The childNameOrPath parameter supports both direct child names and full Transform paths (e.g., "HeaderPanel/TitleLabel"). The Find then FindChildRecursive fallback pattern ensures robust child lookup.

UI Effect Destruction

UI effects are destroyed by:

  • ClearEffect (by asset GUID): Iterates uiEffectInstances and destroys matching instances.
  • ClearEffectAll: Destroys all UI effect instances and clears indexedUIEffects.
  • Explicit UnityEngine.Object.Destroy(indexedUIEffects[key]) via plugin or vanilla code.

internalSpawnEffect — The Common Spawn Path

csharp
private void internalSpawnEffect(EffectAsset asset, Vector3 position,
    Quaternion rotation, Vector3 scale, bool wasInstigatedByPlayer,
    Transform parent)
{
    // 1. Particle effect
    if (asset.effect != null)
    {
        GameObject effectInstance = InstantiateFromPool(asset);
        if (effectInstance != null)
        {
            effectInstance.transform.position = position;
            effectInstance.transform.rotation = rotation;
            effectInstance.transform.localScale = scale;

            if (parent != null)
                effectInstance.transform.SetParent(parent);

            if (wasInstigatedByPlayer)
                AddToInstigatorTracking(effectInstance);

            return; // Particle effect spawned, no audio needed
        }
    }

    // 2. One-shot audio (no visual)
    if (asset.oneShotAudioClips != null)
    {
        for (int i = 0; i < asset.oneShotAudioClips.Length; i++)
        {
            AudioClip clip = asset.oneShotAudioClips[i];
            if (clip != null)
                AudioSource.PlayClipAtPoint(clip, position);
        }
    }

    // 3. Splatter prefabs
    if (asset.splatter > 0 && asset.splatterPrefab != null)
    {
        for (int i = 0; i < asset.splatter; i++)
        {
            Vector3 randomOffset = Random.insideUnitSphere * asset.splatterRadius;
            Vector3 splatPos = position + randomOffset;
            // Spawn splatter at random nearby position
        }
    }
}

All effect RPCs eventually call this method on the client. It handles three effect categories:

  1. Particle effects: Pooled prefab with full transform.
  2. One-shot audio: AudioClip array played at position with no visual.
  3. Splatter: Decal-like prefabs scattered at random positions within a radius.

One-Shot Audio

TriggerFiremodeEffect

csharp
internal static void TriggerFiremodeEffect(Vector3 position)
{
    EffectAsset firemodeEffect = FiremodeRef.Find();
    if (firemodeEffect != null)
    {
        TriggerEffectParameters p = new TriggerEffectParameters(firemodeEffect);
        p.position = position;
        p.relevantDistance = SMALL; // 64 units
        triggerEffect(p);
    }
}

private static AssetReference<EffectAsset> FiremodeRef =
    new AssetReference<EffectAsset>("bc41e0feaebe4e788a3612811b8722d3");

A convenience helper that plays the firemode toggle click sound. Uses hardcoded GUID with SMALL relevance — only nearby players hear it. Called when switching firemode (safety, semi, auto, burst).

Debris Cleanup and Attachment Tracking

Debris Cleanup

csharp
private void destroyAllDebris()
{
    GameObject[] allObjects = UnityEngine.Object.FindObjectsOfType<GameObject>();
    for (int i = allObjects.Length - 1; i >= 0; i--)
    {
        if (allObjects[i].CompareTag("Debris"))
            UnityEngine.Object.Destroy(allObjects[i]);
    }
}

Called during ClearEffectAll. Iterates all active GameObject instances tagged "Debris" (layer LayerMasks.DEBRIS) and destroys them. This is a brute-force tag scan — not a pool operation — because debris can come from multiple sources.

Attachment Tracking

csharp
internal static void ClearAttachments(Transform parent)
{
    for (int i = parentEffectInstances.Count - 1; i >= 0; i--)
    {
        if (parentEffectInstances[i].transform.parent == parent)
        {
            DestroyIntoPool(parentEffectInstances[i]);
            parentEffectInstances.RemoveAt(i);
        }
    }
}

Called during VehicleManager.DestroyVehicleCommon to detach and destroy effects parented to a vehicle's transform (muzzle flashes, exhaust smoke, etc.).

ClientAssetIntegrity Integration

csharp
if (!Provider.isServer)
{
    ClientAssetIntegrity.QueueRequest(assetGuid, asset, "TriggerEffect");
}

When a TriggerEffect RPC arrives on the client and the effect asset GUID is missing from the local asset database, the client queues a request for that asset. If the server confirms the asset is missing, the connection may be terminated. This prevents players from connecting with intentionally-missing effect assets to gain an advantage (e.g., disabling explosion visuals).

Implemented for modern GUID-based RPCs only — legacy ushort id RPCs do not have asset integrity checking.

Plugin Integration

EffectManager Integration Points

EffectManager has no static delegate hooks for effect triggering. Integration is through the public API:

PatternAPI Call
Play effect at pointEffectManager.triggerEffect(new TriggerEffectParameters(asset) { position = point })
Play effect for specific playerparameters.SetRelevantPlayer(player.transportConnection)
Clear specific effect globallyEffectManager.ClearEffectByGuid_AllPlayers(guid)
Send UI effectEffectManager.SendUIEffect(asset, key, reliable, args)
Update UI textEffectManager.sendUIEffectText(key, connection, reliable, path, text)

Wrapping Clearing Methods

Clearing effects can be intercepted by wrapping the clearing methods. The recommended pattern:

csharp
// Psuedo-plugin: Suppress effect clearing for protected effects
private static readonly HashSet<Guid> ProtectedEffects = new HashSet<Guid>();

// Harmony patch on ClearEffectByGuid_AllPlayers
[HarmonyPatch(typeof(EffectManager), nameof(EffectManager.ClearEffectByGuid_AllPlayers))]
[HarmonyPrefix]
private static bool ClearEffectByGuid_AllPlayers_Prefix(Guid guid)
{
    return !ProtectedEffects.Contains(guid); // Skip if protected
}

Distance Relevance Optimization

The four distance constants optimize network bandwidth by only sending effects to clients within range:

ConstantValueUse Case
SMALL64Weapon sounds, footstep effects, small impacts
MEDIUM128Explosions, gunfire, vehicle sounds
LARGE256Large explosions, ambient sounds, weather
INSANE512Map-wide effects, skybox changes

Client-side: effects outside relevance distance are not received at all, saving network bandwidth and rendering cost.

Common Issues

  1. Pool exhaustion: If more effect instances are requested than the pool has created, new instances are instantiated (not rejected). However, if they're never returned to the pool (missing DestroyIntoPool call), the pool grows unboundedly.

  2. UI effect key collisions: The short key in indexedUIEffects is not validated for uniqueness. Creating two effects with the same key overwrites the first. Plugin authors should use unique keys (e.g., hash-based or incrementing).

  3. Asset integrity timeouts: If a client requests a missing effect asset from the server and the server is slow to respond, there may be a visual artifact (missing explosion) before the connection is terminated.

  4. Debris accumulation: If ClearEffectAll is not called periodically, debris-tagged objects accumulate. The server's destroyAllDebris runs during cleanup, but clients may see debris persist.

  5. One-shot audio pooling: One-shot audio clips use AudioSource.PlayClipAtPoint, which creates a temporary GameObject that is destroyed after the clip finishes. This does not use the pool system.

Example: Triggering an Effect at a Point with Normal

csharp
EffectAsset effectAsset = Assets.find(effectGUID) as EffectAsset;
if (effectAsset != null)
{
    TriggerEffectParameters parameters = new TriggerEffectParameters(effectAsset);
    parameters.position = hitPoint;
    parameters.SetDirection(hitNormal);  // Orients effect along surface
    parameters.relevantDistance = EffectManager.MEDIUM;
    parameters.reliable = true;           // Reliable for gameplay-critical effects
    EffectManager.triggerEffect(parameters);
}

Example: Sending a UI Effect with Formatting

csharp
short key = 42;
EffectManager.SendUIEffect(myEffectAsset, key,
    player.transportConnection, true, // reliable
    "Player name", player.health.ToString());

// Later, update a text field:
EffectManager.sendUIEffectText(key, player.transportConnection, true,
    "StatusLabel", $"Health: {player.health}");

Example: Clearing All Effects of a Type

csharp
EffectManager.ClearEffectByGuid_AllPlayers(effectAsset.GUID);

Document history