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:
| Data | Size | Description |
|---|---|---|
owner | 8 bytes | CSteamID owner |
group | 8 bytes | CSteamID group |
count | 1 byte | Number of items |
| Per item | 7+ bytes | x, y, rot, id, amount, quality, state[] |
| Display data | Variable | skin, 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
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | isPowered (on/off) |
| 1 | 2 | fuel (ushort) |
Asset Properties
csharp
_capacity = ((ItemGeneratorAsset)asset).capacity;
_wirerange = ((ItemGeneratorAsset)asset).wirerange;
burn = ((ItemGeneratorAsset)asset).burn;capacity— Maximum fuel the generator can holdwirerange— Distance the generator's power field extendsburn— 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 pointshouldCloseWhenOutsideRange— Auto-close if player moves awayCanPlayersOpen— Can be set false for "virtual storage" pluginsShouldDeleteContainedItemsOnDestroy— 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:
checkHint()returnsEPlayerMessage.STORAGEorEPlayerMessage.LOCKEDcheckUseable()validates permission and cursor stateuse()callsClientInteract(InputEx.GetKey(ControlsSettings.other))- The
ClientInteractmethod sendsSendInteractRequestwithquickGrabparameter
csharp
public void ClientInteract(bool quickGrab)
{
SendInteractRequest.Invoke(GetNetId(), NetTransport.ENetReliability.Unreliable, quickGrab);
}Server-Side Validation Chain
The ReceiveInteractRequest method at ratelimitHz = 4 performs:
- Player existence check —
context.GetPlayer()must return non-null - Alive check — Dead players cannot open storage
- Trunk priority — If already in trunk storage, reject
- Arrest check — Arrested players cannot interact
- Distance check — Must be within 20m (sqrMagnitude < 400)
- Line of sight check —
Physics.LinecastwithBLOCK_BARRICADE_INTERACT_LOSto 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;
}- Close existing storage — If the player already has a storage open, close it first
- Permission check —
checkStore(player.channel.owner.playerID.steamID, player.quests.groupID) - Plugin hook —
BarricadeManager.onOpenStorageRequested - Quick grab or open UI — If
isDisplay && quickGrab, grab the display item directly; otherwise, callplayer.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
- Block-packed state — Storage state uses Unturned's
Blockbinary format with type-tagged reads (Types.STEAM_ID_TYPE,Types.UINT16_TYPE, etc.) for backward compatibility - 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
- Multi-generator fallback — When one generator runs out of fuel,
updateWire()scans for alternative generators in range, creating a robust mesh power network - World candidate optimization —
worldCandidatestracks only world-placed (not vehicle) generators that are on and fueled, avoiding redundantPowerTool.checkPower()calls for the global electricity case - Plugin extensibility —
onStateRebuiltdelegate andonOpenStorageRequestedhook allow plugins to implement virtual storage without modifying the core - 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°)
- Line-of-sight validation — Container interaction validates line-of-sight with
BLOCK_BARRICADE_INTERACT_LOS, preventing players from accessing storage through walls - Deferred state rebuild —
onStateRebuiltplugin hook allows external systems to intercept state serialization, enabling virtual storage implementations that store data externally rather than in the barricade state array
