ItemManager — Item Spawning and World State
ItemManager is the central authority for all world-item lifecycle management in Unturned. It handles the spawn, simulation, pickup, despawn, and respawn of items dropped on the ground. At 1,311 lines, it is one of the largest manager classes, managing a dual-region system with server-authoritative drop data and client-side physics-simulated item bodies.
Source code location: Unturned/Managers/ItemManager.cs
Architecture Overview
ItemManager extends SteamCaller and operates as a singleton (ItemManager.instance). It manages two parallel data structures per region:
ItemRegion.items—List<ItemData>— Server-authoritative item metadata (id, amount, quality, state, position, instance ID, drop timestamp)ItemRegion.drops—List<ItemDrop>— Client-side physics-simulated item GameObjects
Item regions are 1×1 coordinate units, matching the world grid defined by Regions.WORLD_SIZE. The manager aligns its region tracking with player movement via the onRegionUpdated delegate.
Region Grid
csharp
public static ItemRegion[,] regions
{
get;
private set;
}
public static readonly byte ITEM_REGIONS = 1;The ITEM_REGIONS = 1 constant means the item loading radius is 1 region in each direction (3×3 regions total), unlike barricades or vehicles which use larger radii.
Level Load Initialization
csharp
private void onLevelLoaded(int level)
{
if (level > Level.BUILD_INDEX_SETUP)
{
regions = new ItemRegion[Regions.WORLD_SIZE, Regions.WORLD_SIZE];
for (byte x = 0; x < Regions.WORLD_SIZE; x++)
for (byte y = 0; y < Regions.WORLD_SIZE; y++)
regions[x, y] = new ItemRegion();
clampedItems = new List<InteractableItem>();
instanceCount = 0;
clampItemIndex = 0;
despawnItems_X = 0;
despawnItems_Y = 0;
respawnItems_X = 0;
respawnItems_Y = 0;
if (Dedicator.IsDedicatedServer)
{
for (byte x = 0; x < Regions.WORLD_SIZE; x++)
for (byte y = 0; y < Regions.WORLD_SIZE; y++)
generateItems(x, y);
}
}
}On load, the server generates all world items immediately — important because dedicated servers pre-spawn items globally rather than per-player.
Item Drop Lifecycle
dropItem — Creating a World Item
csharp
public static void dropItem(Item item, Vector3 point, bool playEffect, bool isDropped, bool wideSpread)
{
if (wideSpread)
{
point.x += Random.Range(-0.75f, 0.75f);
point.z += Random.Range(-0.75f, 0.75f);
}
else
{
point.x += Random.Range(-0.125f, 0.125f);
point.z += Random.Range(-0.125f, 0.125f);
}
if (Regions.tryGetCoordinate(point, out byte x, out byte y))
{
ItemAsset asset = item.GetAsset();
if (asset != null && !asset.isPro)
{
if (point.y > 0.0f)
{
Ray ray = new Ray(point + Vector3.up, Vector3.down);
Physics.SphereCast(ray, 0.1f, out hit, 2048.0f, RayMasks.BLOCK_ITEM);
if (hit.collider != null)
point.y = hit.point.y; // Drop to ground level
}
bool shouldAllow = true;
onServerSpawningItemDrop?.Invoke(item, ref point, ref shouldAllow);
if (!shouldAllow) return;
ItemData itemData = new ItemData(item, ++instanceCount, point, isDropped);
regions[x, y].items.Add(itemData);
SendItem.Invoke(ENetReliability.Reliable,
Regions.GatherClientConnections(x, y, ITEM_REGIONS),
x, y, item.id, item.amount, item.quality, item.state,
point, itemData.instanceID, playEffect);
}
}
}Key behaviors:
- Ground snapping — A sphere-cast from above drops the item to the terrain surface
- Spread —
wideSpread(used by storage explosions) scatters items in a larger radius - Plugin hook —
onServerSpawningItemDropallows plugins to reject or reposition drops - Pro items —
isProitems are never dropped as world items
pickItem (takeItem)
Items are picked up via RPC with 10 Hz rate limiting:
csharp
public static void takeItem(Transform item, byte to_x, byte to_y, byte to_rot, byte to_page)
{
if (Regions.tryGetCoordinate(item.position, out byte x, out byte y))
{
ItemRegion region = regions[x, y];
for (int index = 0; index < region.drops.Count; index++)
{
if (region.drops[index].model == item)
{
SendTakeItemRequest.Invoke(ENetReliability.Unreliable,
x, y, region.drops[index].instanceID,
to_x, to_y, to_rot, to_page);
return;
}
}
}
}Server-side Pickup Validation (ReceiveTakeItemRequest)
csharp
public static void ReceiveTakeItemRequest(in ServerInvocationContext context,
byte x, byte y, uint instanceID,
byte to_x, byte to_y, byte to_rot, byte to_page)
{
// Guard: safe region, player exists, alive, not arrested
if (!Regions.checkSafe(x, y)) return;
Player player = context.GetPlayer();
if (player == null || player.life.isDead
|| player.animator.gesture == EPlayerGesture.ARREST_START) return;
ItemRegion region = regions[x, y];
for (ushort index = 0; index < region.items.Count; index++)
{
ItemData itemData = region.items[index];
if (itemData.instanceID != instanceID) continue;
// Distance check (20m)
if ((itemData.point - player.transform.position).sqrMagnitude > 400) return;
// Plugin hook
bool shouldAllow = true;
onTakeItemRequested?.Invoke(player, x, y, instanceID,
to_x, to_y, to_rot, to_page, itemData, ref shouldAllow);
if (!shouldAllow) return;
// Try to add to inventory
bool succesfullyTook;
if (to_page == 255)
succesfullyTook = player.inventory.tryAddItem(region.items[index].item, true);
else
succesfullyTook = player.inventory.tryAddItem(
region.items[index].item, to_x, to_y, to_page, to_rot);
if (succesfullyTook)
{
if (!player.equipment.wasTryingToSelect && !player.equipment.HasValidUseable)
player.animator.sendGesture(EPlayerGesture.PICKUP, true);
region.items.RemoveAt(index);
player.sendStat(EPlayerStat.FOUND_ITEMS);
SendDestroyItem.Invoke(ENetReliability.Reliable,
Regions.GatherClientConnections(x, y, ITEM_REGIONS),
x, y, instanceID, true);
}
else
player.sendMessage(EPlayerMessage.SPACE);
return;
}
}When to_page == 255, the item is auto-slotted into the best available inventory position. Otherwise, it goes to the specified page and coordinates.
Destroy Item (ReceiveDestroyItem)
csharp
public static void ReceiveDestroyItem(byte x, byte y, uint instanceID, bool shouldPlayEffect)
{
ItemRegion region = regions[x, y];
for (ushort index = 0; index < region.drops.Count; index++)
{
if (region.drops[index].instanceID == instanceID)
{
onItemDropRemoved?.Invoke(region.drops[index].model,
region.drops[index].interactableItem);
if (shouldPlayEffect)
PlayInventoryAudio(region.drops[index].interactableItem.asset,
region.drops[index].model.position);
Destroy(region.drops[index].model.gameObject);
region.drops.RemoveAt(index);
return;
}
}
CancelInstantiationByInstanceId(instanceID);
}If the item's model doesn't exist yet (pending instantiation), the instance ID is canceled from the queue.
Item Spawning on the Client
ReceiveItem: Queue-Based Instantiation
The client does not spawn items immediately. Instead, they are queued in a sorted pending list:
csharp
public static void ReceiveItem(byte x, byte y, ushort id, byte amount, byte quality,
byte[] state, Vector3 point, uint instanceID, bool shouldPlayEffect)
{
ItemInstantiationParameters instantiation = new ItemInstantiationParameters
{
region_x = x,
region_y = y,
assetId = id,
amount = amount,
quality = quality,
state = state,
point = point,
instanceID = instanceID,
sortOrder = (MainCamera.instance.transform.position - point).sqrMagnitude,
shouldPlayEffect = shouldPlayEffect,
};
pendingInstantiations.Insert(
pendingInstantiations.FindInsertionIndex(instantiation),
instantiation);
}Items are sorted by distance to the camera — nearest items spawn first.
spawnItem: Model Creation
csharp
private void spawnItem(byte x, byte y, ushort id, byte amount, byte quality,
byte[] state, Vector3 point, uint instanceID, bool shouldPlayEffect)
{
ItemAsset asset = Assets.find(EAssetType.ITEM, id) as ItemAsset;
if (asset != null)
{
Transform origin = new GameObject().transform;
origin.name = id.ToString();
origin.transform.position = point;
Transform item = ItemTool.getItem(id, 0, quality, state, false, asset, null);
item.parent = origin;
InteractableItem interactableItem = item.gameObject.AddComponent<InteractableItem>();
interactableItem.item = new Item(id, amount, quality, state);
interactableItem.asset = asset;
item.position = point + (Vector3.up * 0.75f);
item.rotation = Quaternion.Euler(-90 + Random.Range(-15, 15),
Random.Range(0, 360), Random.Range(-15, 15));
item.gameObject.AddComponent<Rigidbody>();
// ... rigidbody setup
ItemDrop drop = new ItemDrop(origin, interactableItem, instanceID);
regions[x, y].drops.Add(drop);
onItemDropAdded?.Invoke(item, interactableItem);
}
}The item is parented to an origin GameObject so the origin stays at the ground point while the item hovers slightly above with a randomized rotation.
Region Batch Loading (ReceiveItems)
When a player enters a new region, ReceiveItems processes a batch of items:
csharp
public static void ReceiveItems(in ClientInvocationContext context)
{
reader.ReadUInt8(out byte x);
reader.ReadUInt8(out byte y);
reader.ReadUInt8(out byte packet);
if (packet == 0)
{
// Clear existing drops in region
DestroyAllInRegion(regions[x, y]);
// ...
}
regions[x, y].isNetworked = true;
ushort count;
reader.ReadUInt16(out count);
if (count > 0)
{
// Read batch of items into instantiationsToInsert
// Insert at correct sorted position
pendingInstantiations.InsertRange(
pendingInstantiations.FindInsertionIndex(instantiationsToInsert[0]),
instantiationsToInsert);
}
}Frame-Budgeted Instantiation
The Update() method processes the pending queue with a time budget:
csharp
if (pendingInstantiations.Count > 0)
{
instantiationTimer.Restart();
int instantiationIndex = 0;
do
{
spawnItem(...);
++instantiationIndex;
}
while (instantiationIndex < pendingInstantiations.Count
&& (instantiationTimer.ElapsedMilliseconds < 1
|| instantiationIndex < MIN_INSTANTIATIONS_PER_FRAME));
pendingInstantiations.RemoveRange(0, instantiationIndex);
}- Minimum 5 items per frame (
MIN_INSTANTIATIONS_PER_FRAME) - Extended processing if under 1ms budget
Similarly, pending destroys from region unloads are budgeted:
csharp
if (regionsPendingDestroy.Count > 0)
{
// Destroy at least 10 items per frame, or until 1ms budget
do
{
ItemRegion region = regionsPendingDestroy.GetTail();
region.DestroyTail();
}
while (regionsPendingDestroy.Count > 0
&& (instantiationTimer.ElapsedMilliseconds < 1
|| destroyCount < MIN_DESTROY_PER_FRAME));
}Item Clamping
The clampRange() call on each InteractableItem prevents items from drifting too far from their spawn point:
csharp
if (clampedItems.Count > 0)
{
if (clampItemIndex >= clampedItems.Count)
clampItemIndex = 0;
InteractableItem interactable = clampedItems[clampItemIndex];
if (interactable != null)
interactable.clampRange();
else
clampedItems.RemoveAtFast(clampItemIndex);
++clampItemIndex;
}Despawn System
The despawnItems() method iterates through regions one at a time per frame:
csharp
private bool despawnItems()
{
if (Level.info == null || Level.info.type == ELevelType.ARENA)
return false;
if (regions[despawnItems_X, despawnItems_Y].items.Count > 0)
{
for (int index = 0; index < regions[...].items.Count; index++)
{
float despawnTime = regions[...].items[index].isDropped
? Provider.modeConfigData.Items.Despawn_Dropped_Time
: Provider.modeConfigData.Items.Despawn_Natural_Time;
if (Time.realtimeSinceStartup - regions[...].items[index].lastDropped > despawnTime)
{
uint instanceID = regions[...].items[index].instanceID;
regions[...].items[index].RemoveAt(index);
SendDestroyItem.Invoke(ENetReliability.Reliable,
Regions.GatherClientConnections(...), ..., instanceID, false);
break; // One item per frame
}
}
return true;
}
return false;
}Two despawn timers are configurable:
- Dropped items — Player-dropped items (
Despawn_Dropped_Time) - Natural items — World-spawned items (
Despawn_Natural_Time)
The despawn loop advances one region per frame:
csharp
while (true)
{
bool regionHadItems = despawnItems();
despawnItems_X++;
if (despawnItems_X >= Regions.WORLD_SIZE)
{
despawnItems_X = 0;
despawnItems_Y++;
if (despawnItems_Y >= Regions.WORLD_SIZE)
{
despawnItems_Y = 0;
break; // Full grid scan complete
}
}
if (regionHadItems) break; // Work done for this frame
}This spreads the despawn cost across frames — at most one item despawn per frame when in cruising mode, but the entire world is scanned over time.
Respawn System
The respawnItems() method uses the level's ItemSpawnpoint data:
csharp
private bool respawnItems()
{
if (regions[respawnItems_X, respawnItems_Y].lastRespawn > Time.realtimeSinceStartup
- Provider.modeConfigData.Items.Respawn_Time)
return false; // Not yet due for respawn
int currentNumItems = regions[...].items.Count;
int desiredNumItems = (int)(LevelItems.spawns[..., ...].Count
* Provider.modeConfigData.Items.Spawn_Chance);
for (int attempt = currentNumItems; attempt < desiredNumItems; attempt++)
{
ItemSpawnpoint spawn = LevelItems.spawns[...][Random.Range(0, ...)];
// Validate: not in safezone, not too close to other items
ushort id = LevelItems.getItem(spawn);
Item item = new Item(id, EItemOrigin.WORLD);
point = spawn.point;
bool shouldAllow = true;
onServerSpawningItemDrop?.Invoke(item, ref point, ref shouldAllow);
if (!shouldAllow) continue;
ItemData itemData = new ItemData(item, ++instanceCount, spawn.point, false);
regions[...].items.Add(itemData);
SendItem.Invoke(..., x, y, ..., false);
}
}The respawn target is Spawn_Chance fraction of total spawn points in a region. Validated spawns exclude safezone-ineligible points and items within 2 meters of an existing item.
World Item Initialization
The generateItems() method runs at level load:
csharp
private void generateItems(byte x, byte y)
{
List<ItemSpawnpoint> valid = new List<ItemSpawnpoint>();
for (int index = 0; index < LevelItems.spawns[x, y].Count; index++)
{
ItemSpawnpoint isp = LevelItems.spawns[x, y][index];
if (SafezoneManager.checkPointValid(isp.point))
valid.Add(isp);
}
while (items.Count < LevelItems.spawns[x, y].Count
* Provider.modeConfigData.Items.Spawn_Chance
&& valid.Count > 0)
{
int index = Random.Range(0, valid.Count);
ItemSpawnpoint spawn = valid[index];
valid.RemoveAt(index);
ushort id = LevelItems.getItem(spawn);
Item item = new Item(id, EItemOrigin.WORLD);
// Plugin hook
items.Add(new ItemData(item, ++instanceCount, point, false));
}
// Preserve dropped items during regeneration
for (int index = 0; index < regions[x, y].items.Count; index++)
{
if (regions[x, y].items[index].isDropped)
items.Add(regions[x, y].items[index]);
}
regions[x, y].items = items;
}Dropped items from players are preserved through region regeneration — only natural spawns are refreshed.
Region Loading (onRegionUpdated)
When a player moves between regions, the manager loads/unloads item regions:
csharp
// Step 0: Unload distant regions
if (regions[x, y].isNetworked && !Regions.checkArea(x, y, new_x, new_y, ITEM_REGIONS))
{
if (regions[x, y].drops.Count > 0)
{
regions[x, y].isPendingDestroy = true; // Deferred destroy
regionsPendingDestroy.Add(regions[x, y]);
}
CancelInstantiationsInRegion(x, y);
regions[x, y].isNetworked = false;
}
// Step 5: Load new regions
if (Regions.checkSafe(new_x, new_y))
{
for (int x = new_x - ITEM_REGIONS; x <= new_x + ITEM_REGIONS; x++)
{
for (int y = new_y - ITEM_REGIONS; y <= new_y + ITEM_REGIONS; y++)
{
if (!player.movement.loadedRegions[x, y].isItemsLoaded)
{
if (player.channel.IsLocalPlayer)
generateItems((byte)x, (byte)y);
player.movement.loadedRegions[x, y].isItemsLoaded = true;
// Send items to client
if (Dedicator.IsDedicatedServer)
askItems(player.channel.owner.transportConnection, ...);
else
{
DestroyAllInRegion(regions[x, y]);
regions[x, y].isNetworked = true;
// Queue all items for instantiation
foreach (ItemData data in regions[x, y].items)
{
pendingInstantiations.Insert(
pendingInstantiations.FindInsertionIndex(...), ...);
}
}
}
}
}
}In singleplayer/co-op, items are generated locally and instantiated. On a dedicated server, items are sent via the batch SendItems RPC.
Pending Instantiation Management
The ItemInstantiationParameters struct implements IComparable for sort order:
csharp
internal struct ItemInstantiationParameters : System.IComparable<ItemInstantiationParameters>
{
public byte region_x, region_y;
public ushort assetId;
public byte amount, quality;
public byte[] state;
public Vector3 point;
public uint instanceID;
public float sortOrder;
public bool shouldPlayEffect;
public int CompareTo(ItemInstantiationParameters other)
{
return sortOrder.CompareTo(other.sortOrder);
}
}Items are inserted in sort order (camera-distance, nearest first) for natural proximity-based spawning.
ItemData vs ItemDrop
The two data structures serve distinct purposes:
| Class | Scope | Purpose |
|---|---|---|
ItemData | Server, ItemRegion.items | Metadata: item, position, instance ID, drop timestamp, isDropped flag |
ItemDrop | Client, ItemRegion.drops | Physics body: model Transform, InteractableItem component, instance ID |
On the server, only ItemData exists. On the client, ItemData creates ItemDrop through spawnItem(), and the ItemDrop's physics body simulates the item until pickup or despawn.
Utility Methods
Find Simulated Items
csharp
public static void findSimulatedItemsInRadius(Vector3 center, float sqrRadius,
List<InteractableItem> result)
{
foreach (InteractableItem item in clampedItems)
{
if (item == null) continue;
if ((item.transform.position - center).sqrMagnitude <= sqrRadius)
result.Add(item);
}
}Used by the nearby-items UI. The clampedItems list tracks all items with active physics.
Clear All Items
csharp
public static void askClearAllItems()
{
for (byte x = 0; x < Regions.WORLD_SIZE; x++)
for (byte y = 0; y < Regions.WORLD_SIZE; y++)
askClearRegionItems(x, y);
}
public static void askClearRegionItems(byte x, byte y)
{
region.items.Clear();
SendClearRegionItems.Invoke(ENetReliability.Reliable,
Regions.GatherClientConnections(x, y, ITEM_REGIONS), x, y);
}Sphere-Based Clear
csharp
public static void ServerClearItemsInSphere(Vector3 center, float radius)
{
Regions.getRegionsInRadius(center, radius, clearItemRegions);
float sqrRadius = MathfEx.Square(radius);
foreach (RegionCoordinate coord in clearItemRegions)
{
ItemRegion region = regions[coord.x, coord.y];
for (int itemIndex = region.items.Count - 1; itemIndex >= 0; --itemIndex)
{
ItemData itemData = region.items[itemIndex];
if ((itemData.point - center).sqrMagnitude > sqrRadius) continue;
// Remove and send destroy
}
}
}Physics Activation on Region Load
csharp
private void onRegionActivated(byte x, byte y)
{
for (int index = 0; index < regions[x, y].drops.Count; index++)
{
Rigidbody rb = regions[x, y].drops[index].interactableItem?.GetComponent<Rigidbody>();
if (rb != null)
{
rb.useGravity = true;
rb.isKinematic = false;
}
}
}Items deferred during level loading have their physics enabled once the region objects finish loading.
Plugin Hook System
| Hook | Delegate | Called |
|---|---|---|
onServerSpawningItemDrop | ServerSpawningItemDropHandler | Before drop creation; can reject or reposition |
onTakeItemRequested | TakeItemRequestHandler | Before pickup; can reject |
onItemDropAdded | ItemDropAdded | After client-side model instantiation |
onItemDropRemoved | ItemDropRemoved | Before client-side model destruction |
Key Design Insights
- Dual data structures —
ItemData(server metadata) andItemDrop(client physics) are kept separate to minimize network traffic — only metadata is transmitted; client creates and simulates the physics body - Frame-budgeted instantiation — Both spawn and destroy use time-budgeted loops with minimum per-frame guarantees (5 spawn, 10 destroy), preventing frame-time spikes on region transitions
- One-item-per-frame despawn — The despawn loop processes at most one item removal per frame but scans all regions over time, distributing cost evenly
- Respawn pacing — Respawns are region-walked with
lastRespawntimers per region, ensuring fresh items reappear gradually rather than all at once - Pro item exclusion — Items with
isPro = trueare never spawned as world drops, preventing exploit duplication of premium items - Sort-order instantiation — Items are sorted by camera distance so nearest items appear first, minimizing the perception of loading delays
- Preserved dropped items —
generateItems()preserves player-dropped items through regeneration cycles, only refreshing natural spawns - Dedicated server pre-generation — Dedicated servers call
generateItems()immediately on level load, while clients generate items lazily as they enter each region
