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 effectGameObjectinstances. 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=512for 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
| Field | Purpose |
|---|---|
asset | The EffectAsset GUID or reference — resolved via Assets.find() internally |
position | World 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 |
reliable | Whether to use reliable vs unreliable delivery |
relevantDistance | Culling 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:
-> SendEffectPointEach 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
| Method | Scope | RPC | Use Case |
|---|---|---|---|
ClearEffectByID_AllPlayers(ushort) | All clients, legacy ID | SendEffectClearById | Deprecated — prefer GUID-based clearing |
ClearEffectByGuid(Guid, connection) | Single player, GUID | SendEffectClearByGuid | Target one player's effects |
ClearEffectByGuid_AllPlayers(Guid) | All clients, GUID | SendEffectClearByGuid | Remove all instances of a specific effect |
askEffectClearAll() | All clients | SendEffectClearAll | Full 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
| Method | Signature | Purpose |
|---|---|---|
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): IteratesuiEffectInstancesand destroys matching instances.ClearEffectAll: Destroys all UI effect instances and clearsindexedUIEffects.- 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:
- Particle effects: Pooled prefab with full transform.
- One-shot audio:
AudioCliparray played at position with no visual. - 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:
| Pattern | API Call |
|---|---|
| Play effect at point | EffectManager.triggerEffect(new TriggerEffectParameters(asset) { position = point }) |
| Play effect for specific player | parameters.SetRelevantPlayer(player.transportConnection) |
| Clear specific effect globally | EffectManager.ClearEffectByGuid_AllPlayers(guid) |
| Send UI effect | EffectManager.SendUIEffect(asset, key, reliable, args) |
| Update UI text | EffectManager.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:
| Constant | Value | Use Case |
|---|---|---|
SMALL | 64 | Weapon sounds, footstep effects, small impacts |
MEDIUM | 128 | Explosions, gunfire, vehicle sounds |
LARGE | 256 | Large explosions, ambient sounds, weather |
INSANE | 512 | Map-wide effects, skybox changes |
Client-side: effects outside relevance distance are not received at all, saving network bandwidth and rendering cost.
Common Issues
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
DestroyIntoPoolcall), the pool grows unboundedly.UI effect key collisions: The
short keyinindexedUIEffectsis 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).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.
Debris accumulation: If
ClearEffectAllis not called periodically, debris-tagged objects accumulate. The server'sdestroyAllDebrisruns during cleanup, but clients may see debris persist.One-shot audio pooling: One-shot audio clips use
AudioSource.PlayClipAtPoint, which creates a temporaryGameObjectthat 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);