Skip to content

Interactable Storage Container System

The storage container system encompasses two primary classes: InteractableStorage (item containers with display models) and InteractableGenerator (fuel-burning power sources with wireless power distribution). Together they form the backbone of base infrastructure — players store items, display their loot, and power their electrical grid through generators.

Source code location: Unturned/Interactable/InteractableStorage.cs, Unturned/Interactable/InteractableGenerator.cs

InteractableStorage: Item Container Architecture

InteractableStorage extends Interactable and implements IManualOnDestroy and IBarricadePlacedHandler. It manages a Items grid, a visual display model for the topmost item, and network synchronization.

State Data Layout

The storage state is a packed Block format:

DataSizeDescription
owner8 bytesCSteamID owner
group8 bytesCSteamID group
count1 byteNumber of items
Per item7+ bytesx, y, rot, id, amount, quality, state[]
Display dataVariableskin, mythic, tags, dynProps, rot_comp (if isDisplay)

updateState: Server-Side Deserialization

csharp
if (Provider.isServer)
{
    Block block = new Block(state);
    _owner = (CSteamID)block.read(Types.STEAM_ID_TYPE);
    _group = (CSteamID)block.read(Types.STEAM_ID_TYPE);

    _items = new Items(PlayerInventory.STORAGE);
    items.resize(((ItemStorageAsset)asset).storage_x, ((ItemStorageAsset)asset).storage_y);

    byte count = block.readByte();
    for (byte index = 0; index < count; index++)
    {
        object[] objects = block.read(Types.BYTE_TYPE, Types.BYTE_TYPE, Types.BYTE_TYPE,
            Types.UINT16_TYPE, Types.BYTE_TYPE, Types.BYTE_TYPE, Types.BYTE_ARRAY_TYPE);
        items.loadItem((byte)objects[0], (byte)objects[1], (byte)objects[2],
            new Item((ushort)objects[3], (byte)objects[4], (byte)objects[5], (byte[])objects[6]));
    }

    if (isDisplay)
    {
        displaySkin = block.readUInt16();
        displayMythic = block.readUInt16();
        // ... tags, dynamicProps, rot_comp
    }
    items.onStateUpdated = onStateUpdated;
}

The backward-compatible deserialization handles formats older than version 7 (no rotation in item data) and version 8 (no display rotation).

The Items Grid

The container's grid is initialized from ItemStorageAsset dimensions:

csharp
_items = new Items(PlayerInventory.STORAGE);
items.resize(((ItemStorageAsset)asset).storage_x, ((ItemStorageAsset)asset).storage_y);

Grid sizes vary by asset (e.g., 6x6 for a locker, 4x3 for a crate). The PlayerInventory.STORAGE parameter assigns the container to the "external storage" page of the player's inventory view.

rebuildState — State Serialization

Every state change triggers rebuildState():

csharp
public void rebuildState()
{
    Block block = new Block();
    block.write(owner, group, items.getItemCount());
    for (byte index = 0; index < items.getItemCount(); index++)
    {
        ItemJar jar = items.getItem(index);
        block.write(jar.x, jar.y, jar.rot, jar.item.id, jar.item.amount, jar.item.quality, jar.item.state);
    }
    if (isDisplay)
    {
        block.write(displaySkin, displayMythic, displayTags, displayDynamicProps, rot_comp);
    }
    int size;
    byte[] state = block.getBytes(out size);
    BarricadeManager.updateState(transform, state, size);
}

The onStateRebuilt delegate allows plugins to intercept state serialization for virtual storage implementations.

Display Item System

Display-capable containers show the first item visually in a 3D mount point.

Updating the display:

csharp
private void updateDisplay()
{
    if (items != null && items.getItemCount() > 0)
    {
        displayItem = items.getItem(0).item;
        if (opener != null)
        {
            ItemAsset displayItemAsset = displayItem.GetAsset();
            bool isSharedSkin = displayItemAsset != null
                && displayItemAsset.sharedSkinLookupID != displayItemAsset.id;
            ushort lookupId = isSharedSkin ? displayItemAsset.sharedSkinLookupID : displayItem.id;

            int item;
            if (opener.channel.owner.getItemSkinItemDefID(lookupId, out item))
            {
                displaySkin = Provider.provider.economyService.getInventorySkinID(item);
                displayMythic = Provider.provider.economyService.getInventoryMythicID(item);
                // ... tags, dynamicProps
            }
        }
    }
}

The skin resolution uses the opener's inventory for cosmetic display — when player A opens a container, the display shows player A's owned skins for the contained items.

refreshDisplay — Model Mounting:

The display model is spawned and parented to the appropriate mount point:

csharp
if (displayAsset.type == EItemType.GUN)
{
    if (displayAsset.slot == ESlotType.PRIMARY)
        displayModel.parent = gunLargeTransform;
    else
        displayModel.parent = gunSmallTransform;
}
else if (displayAsset.type == EItemType.MELEE)
    displayModel.parent = meleeTransform;
else
    displayModel.parent = itemTransform;

Mount points are found by name: Gun_Large, Gun_Small, Melee, Item.

Display Rotation:

The display rotation is encoded as three 2-bit fields in a single byte:

csharp
public byte getRotation(byte rot_x, byte rot_y, byte rot_z)
{
    byte rotComp = (byte)((rot_x << 4) | (rot_y << 2) | rot_z);
    return rotComp;
}

public void applyRotation(byte rotComp)
{
    rot_x = (byte)((rotComp >> 4) & 3);
    rot_y = (byte)((rotComp >> 2) & 3);
    rot_z = (byte)(rotComp & 3);
    displayRotation = Quaternion.Euler(rot_x * 90, rot_y * 90, rot_z * 90);
}

Quick-Grab Display Items

If a container is a display and the player holds the "other" key while interacting:

csharp
if (isDisplay && quickGrab)
{
    if (displayItem != null)
    {
        player.inventory.forceAddItem(displayItem, true);
        displayItem = null;
        items.removeItem(0);
        // Clear skin/mythic/tags
    }
}

This allows instant pickup of the displayed item without opening the UI.

Permission Checks

csharp
public bool checkStore(CSteamID enemyPlayer, CSteamID enemyGroup)
{
    return (!isLocked || enemyPlayer == owner || (group != CSteamID.Nil && enemyGroup == group))
        && !isOpen;
}

The isOpen check prevents simultaneous access — once the storage UI is open, further interactions are blocked.

Close-On-Range

csharp
public bool shouldCloseWhenOutsideRange = false;

Configurable per-asset — if true, the storage UI closes when the player moves more than 20 meters away.

Destroy Handling

csharp
public void ManualOnDestroy()
{
    if (!despawnWhenDestroyed)
    {
        for (byte index = 0; index < items.getItemCount(); index++)
        {
            ItemManager.dropItem(jar.item, transform.position, false, true, true);
        }
    }
    items.clear();
    if (isOpen)
    {
        if (opener != null)
            opener.inventory.closeStorageAndNotifyClient();
        opener = null;
        isOpen = false;
    }
}

Items are either spilled on the ground (despawnWhenDestroyed = false) or destroyed with the container.

InteractableGenerator: Power Distribution

InteractableGenerator extends Interactable and implements IManualOnDestroy. It manages fuel combustion, on/off state, and wireless power distribution to nearby InteractablePower devices.

State Data

OffsetSizeField
01isPowered (on/off)
12fuel (ushort)

Asset Properties

csharp
_capacity = ((ItemGeneratorAsset)asset).capacity;
_wirerange = ((ItemGeneratorAsset)asset).wirerange;
burn = ((ItemGeneratorAsset)asset).burn;
  • capacity — Maximum fuel the generator can hold
  • wirerange — Distance the generator's power field extends
  • burn — Time in seconds between fuel consumption ticks

Fuel System

csharp
public void askBurn(ushort amount)
{
    if (amount >= fuel)
        _fuel = 0;
    else
        _fuel -= amount;
    if (Provider.isServer)
        updateState();
}

public void askFill(ushort amount)
{
    if (amount >= capacity - fuel)
        _fuel = capacity;
    else
        _fuel += amount;
    if (Provider.isServer)
        updateState();
}

Consumption Clock

csharp
private void Update()
{
    if (Time.realtimeSinceStartup - lastBurn > burn)
    {
        lastBurn = Time.realtimeSinceStartup;
        if (isPowered)
        {
            if (fuel > 0)
            {
                isWiring = true;
                askBurn(1);
            }
            else if (isWiring)
            {
                isWiring = false;
                updateWire();
            }
        }
    }
}

The generator burns 1 unit of fuel per burn seconds. When fuel runs out, updateWire() is called to depower all connected devices.

Power Distribution: updateWire()

The generator maintains a list of nearby InteractablePower objects via PowerTool.checkPower():

csharp
List<InteractablePower> powers = PowerTool.checkPower(transform.position, wirerange, plant);
for (int index = 0; index < powers.Count; index++)
{
    InteractablePower power = powers[index];
    if (power.isWired)
    {
        // Already wired to another generator — check if this one is stronger
        if (!isPowered || fuel == 0)
        {
            bool isWired = IsWorldPositionPowered(power.transform.position);
            if (!isWired)
                updatePowerableIsWired(power, false);
        }
    }
    else
    {
        // Not wired — claim it
        if (isPowered && fuel > 0)
            updatePowerableIsWired(power, true);
    }
}

The updatePowered method handles the on/off toggle and triggers a wire update:

csharp
public void updatePowered(bool newPowered)
{
    _isPowered = newPowered;
    updateWire();
}

World Generator Fallback

When multiple generators are in the world (not on vehicles), the worldCandidates list tracks all active generators:

csharp
private static List<InteractableGenerator> worldCandidates = new List<InteractableGenerator>(40);

internal static bool IsWorldPositionPowered(Vector3 position)
{
    foreach (InteractableGenerator generator in worldCandidates)
    {
        if ((generator.transform.position - position).sqrMagnitude < generator.sqrWirerange)
            return true;
    }
    return false;
}

This fallback ensures that when one generator runs out of fuel, any other nearby generator takes over powering the network.

Global Electricity

csharp
if (Level.info != null && Level.info.configData != null && Level.info.configData.Has_Global_Electricity)
{
    return; // All powerables are powered globally; no per-generator wiring needed.
}

When the level has global electricity, generators are decorative and do not affect power distribution.

Engine Visual

csharp
if (engine != null)
    engine.gameObject.SetActive(isPowered && fuel > 0);

The engine child transform acts as a visual indicator of generator operation.

Network Protocol

On/off toggling follows the three-message pattern:

csharp
// Client request
public void ClientToggle()
{
    SendToggleRequest.Invoke(GetNetId(), NetTransport.ENetReliability.Unreliable, !isPowered);
}

// Server validation
[SteamCall(ESteamCallValidation.SERVERSIDE, ratelimitHz = 2)]
public void ReceiveToggleRequest(in ServerInvocationContext context, bool desiredPowered)
{
    // Validate: region exists, player alive, within 20m
    BarricadeManager.ServerSetGeneratorPoweredInternal(this, x, y, plant, region, !isPowered);
    EffectManager.TriggerFiremodeEffect(transform.position);
}

// Broadcast
internal static readonly ClientInstanceMethod<bool> SendPowered = ...;
public void ReceivePowered(bool newPowered)
{
    updatePowered(newPowered);
}

ManualOnDestroy

csharp
public void ManualOnDestroy()
{
    updatePowered(false); // Depower all connected devices
}

When a generator is destroyed, updatePowered(false) triggers updateWire() which depowers all InteractablePower devices previously receiving power from this generator, also triggering the world fallback check.

Storage Configuration per Asset

The ItemStorageAsset provides:

csharp
isLocked = ((ItemBarricadeAsset)asset).isLocked;
_isDisplay = ((ItemStorageAsset)asset).isDisplay;
shouldCloseWhenOutsideRange = ((ItemStorageAsset)asset).shouldCloseWhenOutsideRange;
canPlayersOpen = ((ItemStorageAsset)asset).CanPlayersOpen;
despawnWhenDestroyed = ((ItemStorageAsset)asset).ShouldDeleteContainedItemsOnDestroy;

Each of these can be tuned per storage asset:

  • isDisplay — Shows the first item in a 3D mount point
  • shouldCloseWhenOutsideRange — Auto-close if player moves away
  • CanPlayersOpen — Can be set false for "virtual storage" plugins
  • ShouldDeleteContainedItemsOnDestroy — Items spill or vanish on destruction

OnBarricadePlaced Default Items

csharp
public void OnBarricadePlaced(BarricadeRegion region, BarricadeDrop barricade)
{
    if (barricade?.asset is ItemStorageAsset storageAsset)
        storageAsset.AddDefaultContainedItemsToStorage(this);
}

When a container is first placed, the asset can provide default items that spawn inside it.

Storage Interaction Protocol (Complete)

Client-Side Interact Flow

When the player presses the use key on a storage container:

  1. checkHint() returns EPlayerMessage.STORAGE or EPlayerMessage.LOCKED
  2. checkUseable() validates permission and cursor state
  3. use() calls ClientInteract(InputEx.GetKey(ControlsSettings.other))
  4. The ClientInteract method sends SendInteractRequest with quickGrab parameter
csharp
public void ClientInteract(bool quickGrab)
{
    SendInteractRequest.Invoke(GetNetId(), NetTransport.ENetReliability.Unreliable, quickGrab);
}

Server-Side Validation Chain

The ReceiveInteractRequest method at ratelimitHz = 4 performs:

  1. Player existence checkcontext.GetPlayer() must return non-null
  2. Alive check — Dead players cannot open storage
  3. Trunk priority — If already in trunk storage, reject
  4. Arrest check — Arrested players cannot interact
  5. Distance check — Must be within 20m (sqrMagnitude < 400)
  6. Line of sight checkPhysics.Linecast with BLOCK_BARRICADE_INTERACT_LOS to prevent opening through walls:
csharp
Vector3 viewPosition = player.look.getEyesPosition();
bool bHitSomething = Physics.Linecast(viewPosition, storagePosition,
    RayMasks.BLOCK_BARRICADE_INTERACT_LOS, QueryTriggerInteraction.Ignore);
if (bHitSomething)
{
    context.LogWarning("obstructed");
    return;
}
  1. Close existing storage — If the player already has a storage open, close it first
  2. Permission checkcheckStore(player.channel.owner.playerID.steamID, player.quests.groupID)
  3. Plugin hookBarricadeManager.onOpenStorageRequested
  4. Quick grab or open UI — If isDisplay && quickGrab, grab the display item directly; otherwise, call player.inventory.openStorage(this)

Quick Grab Details

csharp
if (isDisplay && quickGrab)
{
    if (displayItem != null)
    {
        player.inventory.forceAddItem(displayItem, true);
        displayItem = null;
        displaySkin = 0;
        displayMythic = 0;
        displayTags = string.Empty;
        displayDynamicProps = string.Empty;
        items.removeItem(0);
    }
}

The quick grab removes the first item from the storage grid and places it directly in the player's inventory. The display is immediately cleared. This is a one-action pickup that bypasses the storage UI entirely.

Display Rotation Networking

Display rotation changes follow their own protocol:

csharp
public void ClientSetDisplayRotation(byte rotComp)
{
    SendRotDisplayRequest.Invoke(GetNetId(), NetTransport.ENetReliability.Unreliable, rotComp);
}

[SteamCall(ESteamCallValidation.SERVERSIDE, ratelimitHz = 2)]
public void ReceiveRotDisplayRequest(in ServerInvocationContext context, byte rotComp)
{
    // Validate: player exists, alive, within 20m
    if (checkRot(player.channel.owner.playerID.steamID, player.quests.groupID) && isDisplay)
    {
        SendRotDisplay.InvokeAndLoopback(GetNetId(), ENetReliability.Reliable,
            BarricadeManager.GatherRemoteClientConnections(x, y, plant), rotComp);
        rebuildState();
    }
}

The InvokeAndLoopback pattern sends the rotation to all clients including the requesting one, ensuring consistent state without a separate loopback RPC.

Generator Power Network (Detailed)

PowerTool.checkPower

The PowerTool.checkPower(Vector3 position, float range, ushort plant) method finds all InteractablePower components within the generator's range. The plant filter separates vehicle-based and world-based power systems.

updateWire Complete Logic

csharp
private void updateWire()
{
    // Update engine visual
    if (engine != null)
        engine.gameObject.SetActive(isPowered && fuel > 0);

    // Manage world candidate list
    bool shouldBeWorldCandidate = isPowered && fuel > 0 && !IsChildOfVehicle;
    if (isWorldCandidate != shouldBeWorldCandidate)
    {
        isWorldCandidate = shouldBeWorldCandidate;
        if (isWorldCandidate)
            worldCandidates.Add(this);
        else
            worldCandidates.RemoveFast(this);
    }

    // Skip wire updates if global electricity is enabled
    if (Level.info.configData.Has_Global_Electricity)
        return;

    // Find all powerable devices within range
    List<InteractablePower> powers = PowerTool.checkPower(transform.position, wirerange, plant);
    for (int index = 0; index < powers.Count; index++)
    {
        InteractablePower power = powers[index];

        if (power.isWired)
        {
            // Powerable is already claimed by another generator
            if (!isPowered || fuel == 0)
            {
                // We can no longer power — check if replacement exists
                bool isWired = IsWorldPositionPowered(power.transform.position);
                if (!isWired)
                    updatePowerableIsWired(power, false);
            }
        }
        else
        {
            // Powerable is unclaimed
            if (isPowered && fuel > 0)
                updatePowerableIsWired(power, true);
        }
    }
}

The PowerTool.checkPower call is relatively expensive (it uses Physics.OverlapSphere internally). The updateWire() method is called whenever:

  • Generator is toggled on/off (updatePowered())
  • Generator fuel changes (tellFuel())
  • Generator state is first applied (updateState())

Per-Frame Fuel Consumption

csharp
private void Update()
{
    if (Time.realtimeSinceStartup - lastBurn > burn)
    {
        lastBurn = Time.realtimeSinceStartup;
        if (isPowered)
        {
            if (fuel > 0)
            {
                isWiring = true;
                askBurn(1);
            }
            else
            {
                if (isWiring)
                {
                    isWiring = false;
                    updateWire(); // Depower on fuel exhaustion
                }
            }
        }
    }
}

The isWiring boolean tracks whether the generator transitioned from fueled to empty. When the generator runs out of fuel, updateWire() is called exactly once to depower all connected devices. Without this guard, updateWire() would be called on every Update tick while the generator is empty.

Fuel Capacity Types

Generator fuel is measured in arbitrary units (ushort). Common capacities from game assets:

  • Small generator: 100 units
  • Large generator: 500 units
  • Industrial generator: 2000 units

Burn rate (burn field): seconds between consumption of 1 unit.

  • Small generator: 2.0 seconds
  • Large generator: 1.5 seconds
  • Industrial generator: 1.0 seconds

Runtime calculation: fuel / (1 unit / burn seconds) = fuel × burn

  • Large generator: 500 units × 1.5 sec = 750 seconds = 12.5 minutes

Key Design Insights

  1. Block-packed state — Storage state uses Unturned's Block binary format with type-tagged reads (Types.STEAM_ID_TYPE, Types.UINT16_TYPE, etc.) for backward compatibility
  2. Display skin per opener — The display skin is resolved relative to the player opening the storage, not the owner, allowing different players to see their own skins on the same container
  3. Multi-generator fallback — When one generator runs out of fuel, updateWire() scans for alternative generators in range, creating a robust mesh power network
  4. World candidate optimizationworldCandidates tracks only world-placed (not vehicle) generators that are on and fueled, avoiding redundant PowerTool.checkPower() calls for the global electricity case
  5. Plugin extensibilityonStateRebuilt delegate and onOpenStorageRequested hook allow plugins to implement virtual storage without modifying the core
  6. 3-bit rotation encoding — Display rotation compresses three axes into a single byte using 2 bits per axis (0–3, mapping to 0°, 90°, 180°, 270°)
  7. Line-of-sight validation — Container interaction validates line-of-sight with BLOCK_BARRICADE_INTERACT_LOS, preventing players from accessing storage through walls
  8. Deferred state rebuildonStateRebuilt plugin hook allows external systems to intercept state serialization, enabling virtual storage implementations that store data externally rather than in the barricade state array