Skip to content

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.itemsList<ItemData> — Server-authoritative item metadata (id, amount, quality, state, position, instance ID, drop timestamp)
  • ItemRegion.dropsList<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
  • SpreadwideSpread (used by storage explosions) scatters items in a larger radius
  • Plugin hookonServerSpawningItemDrop allows plugins to reject or reposition drops
  • Pro itemsisPro items 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:

ClassScopePurpose
ItemDataServer, ItemRegion.itemsMetadata: item, position, instance ID, drop timestamp, isDropped flag
ItemDropClient, ItemRegion.dropsPhysics 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

HookDelegateCalled
onServerSpawningItemDropServerSpawningItemDropHandlerBefore drop creation; can reject or reposition
onTakeItemRequestedTakeItemRequestHandlerBefore pickup; can reject
onItemDropAddedItemDropAddedAfter client-side model instantiation
onItemDropRemovedItemDropRemovedBefore client-side model destruction

Key Design Insights

  1. Dual data structuresItemData (server metadata) and ItemDrop (client physics) are kept separate to minimize network traffic — only metadata is transmitted; client creates and simulates the physics body
  2. 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
  3. 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
  4. Respawn pacing — Respawns are region-walked with lastRespawn timers per region, ensuring fresh items reappear gradually rather than all at once
  5. Pro item exclusion — Items with isPro = true are never spawned as world drops, preventing exploit duplication of premium items
  6. Sort-order instantiation — Items are sorted by camera distance so nearest items appear first, minimizing the perception of loading delays
  7. Preserved dropped itemsgenerateItems() preserves player-dropped items through regeneration cycles, only refreshing natural spawns
  8. Dedicated server pre-generation — Dedicated servers call generateItems() immediately on level load, while clients generate items lazily as they enter each region