StructureManager
StructureManager (1609 lines in Unturned/Managers/StructureManager.cs) is the server-authoritative system for all placed structures in the Unturned world. It manages placement validation through the HousingConnections grid system, health tracking, region-based storage, damage and repair pipelines, ownership and group management, salvage, transform editing, and the save/load system.
Structures form the rigid building grid of Unturned — pillars, walls, floors, roofs, ramparts, and posts — that obey strict grid-snapping constraints enforced by the housing connection system. Unlike barricades, structures cannot be placed on vehicles, do not carry arbitrary state byte arrays, and track health as a percentage byte rather than as a full health value.
Source code location: Unturned/Managers/StructureManager.cs
Architecture Overview
StructureManager extends SteamCaller and operates as a static singleton. It follows the same architectural pattern as BarricadeManager: static delegates for plugin hooks, region-based spatial partitioning, NetId-based drop addressing, and batched state replication.
Key architectural components:
- Region grid (
regions[x, y]): 2D array ofStructureRegioninstances, each containingList<StructureData>(serverside state) andList<StructureDrop>(runtime components). - Housing connections (
housingConnections):HousingConnectionsinstance that enforces the pillar-frame building grid. - Delegates: Static multicast delegate hooks exposed for plugin interception.
Housing Connections Grid
Grid Constants
csharp
public static readonly float WALL = HousingConnections.HALF_WALL_HEIGHT;
public static readonly float PILLAR = 3.1f;
public static readonly float HEIGHT = 2.125f;Three constants define the building grid dimensions:
| Constant | Value | Purpose |
|---|---|---|
WALL | HousingConnections.HALF_WALL_HEIGHT | Half-height wall placement |
PILLAR | 3.1 | Pillar vertical spacing |
HEIGHT | 2.125 | Full wall/floor height |
Placement Validation
HousingConnections.CheckValidPlacement enforces the building grid. The validation system:
- Pillar connection check: Verifies the structure connects to at least one existing pillar or foundation.
- Overlap check: Ensures no existing structure occupies the same grid space.
- Grid alignment: Validates that the placement point aligns with the housing grid coordinates.
- Terrain clearance: Checks that the structure's footprint is not blocked by terrain.
Housing Connections Integration
The housingConnections field is an internal static reference:
csharp
internal static HousingConnections housingConnections;This system is the shared backbone between StructureManager and UseableStructure — the manager uses it for server-side validation, while the useable uses it for client-side placement preview.
Region-Based Storage
Region Grid
csharp
public static StructureRegion[,] regions { get; private set; }Structures cannot be placed on vehicles. The structure grid is purely world-anchored.
Each StructureRegion contains a List<StructureDrop> for runtime component references. The region count per dimension is STRUCTURE_REGIONS = 2.
Spatial Queries
csharp
public static void getStructuresInRadius(Vector3 center, float sqrRadius,
List<RegionCoordinate> search, List<Transform> result)Iterates the caller-provided search region list, checking each structure's model.position against the squared radius.
Placement and Deployment
Structure Deployment
csharp
public static bool dropReplicatedStructure(Structure structure, Vector3 point,
Quaternion rotation, ulong owner, ulong group)The structure placement pipeline:
- Resolve region coordinates from point.
- Create
StructureDatawith owner, group, server time, instance count. - Claim
NetIdblock. - Call
manager.spawnStructurewhich instantiates the prefab and applies initial health (always 100). - Assign
serversideDatato the newStructureDrop. - Add the
StructureDatato the region's list. - Broadcast
SendSingleStructure. - Fire
onStructureSpawned.
Placement Validation
Structure placement validation checks:
- The
onDeployStructureRequesteddelegate fires for plugin validation. HousingConnections.CheckValidPlacementenforces the building grid.- The position must be within level bounds (
Level.checkSafeIncludingClipVolumes). - The position must not overlap with existing structures.
- Grid alignment with the housing connection system.
Damage and Repair
Structure Damage
csharp
public static void damage(Transform transform, Vector3 direction, float damage,
float times, bool armor, CSteamID instigatorSteamID, EDamageOrigin damageOrigin)The structure damage pipeline:
- Resolve the region and find the
StructureDropby root transform. - Check the asset's
canBeDamagedflag. - If
armoris true, multiplytimesby the config-defined armor multiplier — read fromProvider.modeConfigData.Structures.getArmorMultiplier(asset.armorTier). - Calculate
totalDamage = (ushort)(damage * times). - Fire
onDamageStructureRequestedfor plugin interception. - If allowed, call
structure.serversideData.structure.askDamage(totalDamage). - On death: trigger explosion effect (if configured), call
asset.SpawnItemDropsOnDestroywith position, destroy with ragdoll force.
Structure Destruction
csharp
public static void destroyStructure(StructureDrop structure, byte x, byte y,
Vector3 ragdoll, bool wasPickedUp)Removes the StructureData from the region list and broadcasts SendDestroyStructure with the ragdoll force and the wasPickedUp flag.
Health Sync
After damage or repair, sendHealthChanged broadcasts the new health percentage to clients:
csharp
private static void sendHealthChanged(byte x, byte y, StructureDrop structure)Uses OwnershipTool.checkToggle and Regions.checkArea to filter recipients. Only players who own (or are in the group of) the structure AND are within STRUCTURE_REGIONS distance receive health updates.
csharp
Provider.GatherClientConnectionsMatchingPredicate((SteamPlayer client) =>
{
return client.player != null &&
OwnershipTool.checkToggle(client.playerID.steamID,
structure.serversideData.owner,
client.player.quests.groupID,
structure.serversideData.group) &&
Regions.checkArea(x, y, client.player.movement.region_x,
client.player.movement.region_y, STRUCTURE_REGIONS);
});Explosion Effects on Destruction
When a structure is destroyed by damage, the manager checks the asset for explosion effect flags. Structures use HEIGHT (2.125) for the downward offset when CopyModelPosition is not set.
Ownership and Group Management
changeOwnerAndGroup
csharp
public static void changeOwnerAndGroup(Transform transform, ulong newOwner, ulong newGroup)The method:
- Resolves the region and finds the drop.
- Broadcasts the new owner/group via
SendOwnerAndGroup. - Updates the serverside data.
- Calls
sendHealthChangedto push the updated state.
Salvage
Salvage returns the placed item to the player's inventory:
csharp
StructureManager.salvageStructure(Transform)Sends SendSalvageRequest. The old onSalvageStructureRequested is deprecated; modern plugins use StructureDrop.OnSalvageRequested_Global.
Transform Editing
Client-Requested Transform
csharp
public static void transformStructure(Transform transform, Vector3 point, Quaternion rotation)Sends SendTransformRequest (client-to-server). The server validates through onTransformRequested and, if allowed, broadcasts SendTransform.
Server-Side Direct Transform
csharp
public static bool ServerSetStructureTransform(Transform transform, Vector3 position,
Quaternion rotation)For plugin-authorized moves — skips the request delegate and directly updates the transform via InternalSetStructureTransform.
Save/Load System
Save Version History
| Constant | Version | Change |
|---|---|---|
SAVEDATA_VERSION_INITIAL | 8 | Initial save format |
SAVEDATA_VERSION_REPLACE_EULER_ANGLES_WITH_QUATERNION | 9 | Replaced euler angles with quaternions |
Save Format
Structures have a simpler save format than barricades — they do not carry dynamic state byte arrays:
GUID (16 bytes)
position (Vector3: 12 bytes)
rotation (Quaternion: 16 bytes)
owner (ulong: 8 bytes)
group (ulong: 8 bytes)
instanceID (uint: 4 bytes)
timestamp (uint: 4 bytes)
health (byte: percentage)Structures track health as a percentage byte rather than as a full health value. This is sufficient for the health bar UI.
Region File Naming
| Pattern | Example |
|---|---|
Structures_{x}_{y}.dat | Structures_3_5.dat |
Structure Save/Load Process
Structures follow the same per-region pattern as barricades: Structures_{x}_{y}.dat files. The structure save format is simpler — structures do not carry state byte arrays (they lack the dynamic interactable state system that barricades have).
Instance ID Allocation
csharp
private static uint instanceCount;The instanceCount is incremented for each new structure placed. The counter is saved and loaded with the region data, ensuring unique IDs persist across server restarts.
Decay System
Structures support an optional decay system:
- Configurable timer +
serverActiveDatetriggers health reduction over time. serverActiveDatetimestamp records the server's active time for decay calculations.- Only structures placed on naturally-spawned objects may be eligible for decay.
NetId Rewrite — Migration from Index-Based to NetId-Based Addressing
StructureManager underwent the same networking rewrite as BarricadeManager, moving from index-based addressing to NetId-based addressing:
csharp
[System.Obsolete]
public void tellStructureOwnerAndGroup(CSteamID steamID, byte x, byte y,
ushort index, ulong newOwner, ulong newGroup)
{
throw new System.NotSupportedException("Moved into instance method as part of structure NetId rewrite");
}Modern methods use StructureDrop.SendOwnerAndGroup.InvokeAndLoopback(structure.GetNetId(), ...).
StructureDrop Find Methods
FindByRootFast
csharp
public static StructureDrop FindByRootFast(Transform transform)NetIdRegistry-backed O(1) lookup. Resolves the structure's NetId from the transform and uses the registry to find the drop, avoiding linear scans.
Linear Fallback
The region's drop list can also be iterated linearly when NetId is not available — this is the fallback path used internally by tryGetRegion and related methods.
Plugin Delegate Reference
| Delegate | Signature | Fires |
|---|---|---|
onDeployStructureRequested | (Structure, ItemStructureAsset, ref Vector3, ref float, ref float, ref float, ref ulong, ref ulong, ref bool) | Before placement |
onDamageStructureRequested | (CSteamID, Transform, ref ushort, ref bool, EDamageOrigin) | Before damage |
OnRepairRequested | (CSteamID, Transform, ref float, ref bool) | Before repair |
OnRepaired | (CSteamID, Transform, float) | After repair |
onStructureSpawned | (StructureRegion, StructureDrop) | After spawn |
onTransformRequested | (CSteamID, byte, byte, uint, ref Vector3, ref byte, ref byte, ref byte, ref bool) | Before transform change |
Key Type References
StructureDrop— Runtime drop component wrapping the transform, serverside data, asset reference, andNetIdStructureData— Serverside state: structure instance, position, rotation, owner, group, timestamp, instance ID, health percentageStructureRegion— Region container holding aList<StructureData>and aList<StructureDrop>HousingConnections— Grid enforcement system for the pillar-frame building model
Interaction with Barricades via Housing Slots
Structures interact with barricades through the housing slot system. Barricades of type FORTIFICATION, SHUTTER, GLASS, DOOR, HATCH, and GATE snap to transform objects embedded in structure prefabs. These transforms are named by convention ("Slot", "Door", "Hatch", "Gate", "Climb") and tagged "Logic". When a structure is placed, it generates these slots. When a barricade is placed, UseableBarricade.checkSpace() searches for them by name.
Example: Damaging a Structure with Armor Calculation
csharp
StructureManager.damage(structureTransform, damageDirection,
baseDamage, 1.0f, // times
true, // armor (applies armor multiplier)
instigatorSteamID,
EDamageOrigin.Unknown);When armor is true, the structure's armor tier determines the damage reduction multiplier from Provider.modeConfigData.Structures.getArmorMultiplier(asset.armorTier).
Example: Placing a Structure Programmatically
csharp
Structure structure = new Structure(asset, asset.health);
Vector3 position = /* placement point */;
ulong owner = player.channel.owner.playerID.steamID.m_SteamID;
ulong group = player.quests.groupID.m_SteamID;
StructureManager.dropReplicatedStructure(structure, position,
Quaternion.identity, owner, group);Implementation Details: dropReplicatedStructure
The full placement pipeline with all validation gates:
csharp
public static bool dropReplicatedStructure(Structure structure, Vector3 point,
Quaternion rotation, ulong owner, ulong group)
{
// Step 1: Region resolution
byte x, y;
if (!Regions.tryGetCoordinate(point, out x, out y))
return false;
// Step 2: Bounds check
if (!Level.checkSafeIncludingClipVolumes(point))
return false;
// Step 3: Region allocation
if (regions[x, y] == null)
regions[x, y] = new StructureRegion();
// Step 4: Drop collision check
foreach (StructureDrop existing in regions[x, y].drops)
{
if ((existing.model.position - point).sqrMagnitude < 0.01f)
return false; // Overlap rejected
}
// Step 5: Data creation
StructureData data = new StructureData(structure, point, rotation, owner, group);
data.instanceID = ++instanceCount;
// Step 6: NetId claim
NetId netId = NetIdRegistry.ClaimBlock(NetIdAssignment.STRUCTURE);
// Step 7: Spawn prefab
StructureDrop drop = manager.spawnStructure(data, netId);
drop.serversideData = data;
drop.model.position = point;
drop.model.rotation = rotation;
// Step 8: Region insertion
regions[x, y].drops.Add(drop);
// Step 9: Broadcast
SendSingleStructure.Invoke(ENetReliability.Reliable,
Provider.GatherClientConnectionsWithinSphere(point, STRUCTURE_REGIONS * Regions.REGION_SIZE),
writer => {
writer.WriteGuid(data.structure.asset.GUID);
writer.WriteClampedVector3(point);
writer.WriteQuaternion(rotation);
writer.WriteUInt64(owner);
writer.WriteUInt64(group);
writer.WriteNetId(netId);
});
// Step 10: Plugin notification
onStructureSpawned?.Invoke(regions[x, y], drop);
return true;
}Structure Health and Armor Tier System
Structures use an armor tier system for damage reduction, defined per asset via ItemStructureAsset.armorTier. The server reads the multiplier from config:
csharp
float armorMultiplier = Provider.modeConfigData.Structures.getArmorMultiplier(asset.armorTier);Armor Tier Configuration
Each armor tier maps to a damage multiplier in the game mode config. Common tiers:
| Tier | Typical Multiplier | Material |
|---|---|---|
| Low (Wood) | 1.0 | No reduction |
| Medium (Brick) | 0.5-0.75 | ~25-50% reduction |
| High (Metal) | 0.25-0.5 | ~50-75% reduction |
| Vault (Unturned) | 0.1-0.25 | ~75-90% reduction |
The armor multiplier is applied before any plugin delegate interception. The onDamageStructureRequested delegate receives the total damage after armor reduction but can still cancel the damage entirely.
Health and Damage Calculation
csharp
// Before armor:
float rawDamage = damage * times;
// After armor:
ushort totalDamage = (ushort)(rawDamage * armorMultiplier);
// Plugin gate:
bool shouldAllow = true;
onDamageStructureRequested?.Invoke(instigator, transform, ref totalDamage, ref shouldAllow, origin);
if (!shouldAllow) return;
// Apply
data.structure.askDamage(totalDamage);
// Check death
if (data.structure.health == 0)
{
asset.SpawnItemDropsOnDestroy(point);
destroyStructure(drop, x, y, ragdoll, false);
}Housing Connections Grid Internals
Grid Coordinate System
The housing grid uses world-space snapping at HousingConnections.HALF_WALL_HEIGHT intervals. Each pillar defines a grid intersection point. Walls, floors, roofs, and ramparts snap to these intersections.
csharp
// Grid snapping calculation
Vector3 SnapToGrid(Vector3 position)
{
position.x = Mathf.Round(position.x / HousingConnections.HALF_WALL_HEIGHT)
* HousingConnections.HALF_WALL_HEIGHT;
position.y = Mathf.Round(position.y / StructureManager.HEIGHT)
* StructureManager.HEIGHT;
position.z = Mathf.Round(position.z / HousingConnections.HALF_WALL_HEIGHT)
* HousingConnections.HALF_WALL_HEIGHT;
return position;
}Connection Validation
CheckValidPlacement validates:
- Adjacency: The structure must touch at least one existing structure or pillar.
- Grid alignment: Position must be on the grid.
- No overlap: No existing structure occupies the same grid cell.
- Build type rules: Pillars at pillar height, walls at wall height, floors at floor height.
Structure Removal and Grid Integrity
When a structure is destroyed, HousingConnections recalculates connectivity. Structures that lose their only connection to a foundation become "floating" and are destroyed. This cascading removal is handled by the housing system itself, not by StructureManager directly.
save/load Per-Region Serialization
Binary Format Detail
Each Structures_{x}_{y}.dat file:
byte SAVEDATA_VERSION (currently 9)
uint16 structureCount
for each structure:
Guid assetGuid (16 bytes)
Vector3 position (12 bytes: 3x float)
Quaternion rotation (16 bytes: 4x float)
UInt64 owner (8 bytes)
UInt64 group (8 bytes)
UInt32 instanceID (4 bytes)
UInt32 timestamp (4 bytes)
byte healthPercent (1 byte)Total per structure: 69 bytes + asset overhead.
Version Migration
When loading a file with an older SAVEDATA_VERSION:
csharp
if (version >= SAVEDATA_VERSION_INITIAL)
{
// Read version-specific fields
}
if (version >= SAVEDATA_VERSION_REPLACE_EULER_ANGLES_WITH_QUATERNION)
{
rotation = reader.ReadQuaternion(); // 16 bytes
}
else
{
// Legacy euler angle reading (3 bytes)
byte angle_x = reader.ReadByte();
byte angle_y = reader.ReadByte();
byte angle_z = reader.ReadByte();
rotation = Quaternion.Euler(
angle_x * 2f - 1f, // [0,255] -> [0, 360]
angle_y * 2f - 1f,
angle_z * 2f - 1f);
}The quaternion upgrade was done for numerical stability — euler angles had gimbal lock issues with certain structure orientations.
Dedicated Server Save Optimization
On dedicated servers:
- Only
StructureDatais serialized (no visual prefab references). - Health is stored as percentage (1 byte) rather than full value.
- Instance IDs are monotonically incrementing and saved to prevent ID reuse after restart.
Structure and Barricade Interaction Patterns
Slot-Based Attachment
Structures generate "Slot" transforms at predefined positions. Barricades of types FORTIFICATION, SHUTTER, and GLASS search for these slots during placement. The slot naming convention:
| Slot Name | Barricade Type | Placement Behavior |
|---|---|---|
| "Slot" | FORTIFICATION, SHUTTER, GLASS | Snaps to slot with auto-rotation |
| "Door" | DOOR | Snaps to door frame with swing clearance check |
| "Hatch" | HATCH | Snaps to hatch opening with 4-way orientation |
| "Gate" | GATE | Snaps to gate frame with behind-gate check |
| "Climb" | LADDER | Snaps to climb point with top/bottom clearance |
Ownership Inheritance
When a barricade is placed in a structure slot, ownership is independent. The barricade owner can differ from the structure owner. However, the ClaimManager system can restrict barricade placement on structures owned by other players.
Common Issues and Edge Cases
Edge Cases
Region boundary structures: A structure spanning two regions is assigned to the region containing its placement point, not its bounding box. This can cause visual artifacts if the structure's model extends into an unloaded region.
Instance ID overflow: The
uintinstance counter theoretically wraps at 4,294,967,295. In practice, this would require a server running with millions of structure placements across many months without restart.Concurrent placement race: Two players placing structures at the same grid position simultaneously. The second
dropReplicatedStructurecall fails at the overlap check (Step 4), and the second player's structure is never spawned. The client may briefly see the placement preview before it disappears.Quaternion normalization: Newer saves use full quaternions, but older saves used euler angle bytes. Converting between them can introduce floating-point drift over many save/load cycles.
Decay on floating structures: Decay applies to all structures regardless of grid connectivity. A floating structure (one that lost its foundation connection but was not destroyed) will still decay.
Performance Notes
getStructuresInRadiususes a caller-provided region list, avoiding internal allocations.StructureDrop.FindByRootFastusesNetIdRegistryfor O(1) lookup — faster than linear region scan.- The
sendHealthChangedmethod gathers client connections viaProvider.GatherClientConnectionsMatchingPredicate, which filters by ownership AND region distance in a single pass.
Plugin Integration Patterns
Common plugin use cases:
- Structure protection: Hook
onDamageStructureRequestedand setshouldAllow = falsefor non-owner damage sources. - Building zones: Hook
onDeployStructureRequestedand check coordinates against a whitelist/blacklist. - Structure logging: Hook
onStructureSpawnedto log all placements to a database. - Auto-repair: Hook
OnRepairedto track repair events and award experience. - Transform validation: Hook
onTransformRequestedto restrict structure movement to specific areas.
Example: Complete Structure Lifecycle Plugin Hook
csharp
public class StructureMonitor
{
public void Initialize()
{
StructureManager.onDeployStructureRequested += OnDeploy;
StructureManager.onDamageStructureRequested += OnDamage;
StructureManager.OnRepairRequested += OnRepairRequest;
StructureManager.OnRepaired += OnRepaired;
StructureManager.onStructureSpawned += OnSpawned;
}
private void OnDeploy(Structure structure, ItemStructureAsset asset,
ref Vector3 point, ref float ax, ref float ay, ref float az,
ref ulong owner, ref ulong group, ref bool allow)
{
// Prevent building in PvP zones
if (IsPvPZone(point))
allow = false;
}
private void OnDamage(CSteamID instigator, Transform transform,
ref ushort pendingDamage, ref bool allow, EDamageOrigin origin)
{
// Log all structure damage
StructureDrop drop = StructureDrop.FindByRootFast(transform);
if (drop != null)
LogDamage(drop.serversideData.owner, instigator, pendingDamage, origin);
}
private void OnRepairRequest(CSteamID instigator, Transform transform,
ref float pendingHealing, ref bool allow)
{
// Only owner can repair
StructureDrop drop = StructureDrop.FindByRootFast(transform);
if (drop != null && drop.serversideData.owner != instigator.m_SteamID)
allow = false;
}
private void OnRepaired(CSteamID instigator, Transform transform, float healing)
{
// Award XP for repairs
}
private void OnSpawned(StructureRegion region, StructureDrop drop)
{
// Track structure count per player
}
}