Skip to content

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 of StructureRegion instances, each containing List<StructureData> (serverside state) and List<StructureDrop> (runtime components).
  • Housing connections (housingConnections): HousingConnections instance 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:

ConstantValuePurpose
WALLHousingConnections.HALF_WALL_HEIGHTHalf-height wall placement
PILLAR3.1Pillar vertical spacing
HEIGHT2.125Full wall/floor height

Placement Validation

HousingConnections.CheckValidPlacement enforces the building grid. The validation system:

  1. Pillar connection check: Verifies the structure connects to at least one existing pillar or foundation.
  2. Overlap check: Ensures no existing structure occupies the same grid space.
  3. Grid alignment: Validates that the placement point aligns with the housing grid coordinates.
  4. 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:

  1. Resolve region coordinates from point.
  2. Create StructureData with owner, group, server time, instance count.
  3. Claim NetId block.
  4. Call manager.spawnStructure which instantiates the prefab and applies initial health (always 100).
  5. Assign serversideData to the new StructureDrop.
  6. Add the StructureData to the region's list.
  7. Broadcast SendSingleStructure.
  8. Fire onStructureSpawned.

Placement Validation

Structure placement validation checks:

  1. The onDeployStructureRequested delegate fires for plugin validation.
  2. HousingConnections.CheckValidPlacement enforces the building grid.
  3. The position must be within level bounds (Level.checkSafeIncludingClipVolumes).
  4. The position must not overlap with existing structures.
  5. 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:

  1. Resolve the region and find the StructureDrop by root transform.
  2. Check the asset's canBeDamaged flag.
  3. If armor is true, multiply times by the config-defined armor multiplier — read from Provider.modeConfigData.Structures.getArmorMultiplier(asset.armorTier).
  4. Calculate totalDamage = (ushort)(damage * times).
  5. Fire onDamageStructureRequested for plugin interception.
  6. If allowed, call structure.serversideData.structure.askDamage(totalDamage).
  7. On death: trigger explosion effect (if configured), call asset.SpawnItemDropsOnDestroy with 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:

  1. Resolves the region and finds the drop.
  2. Broadcasts the new owner/group via SendOwnerAndGroup.
  3. Updates the serverside data.
  4. Calls sendHealthChanged to 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

ConstantVersionChange
SAVEDATA_VERSION_INITIAL8Initial save format
SAVEDATA_VERSION_REPLACE_EULER_ANGLES_WITH_QUATERNION9Replaced 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

PatternExample
Structures_{x}_{y}.datStructures_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 + serverActiveDate triggers health reduction over time.
  • serverActiveDate timestamp 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

DelegateSignatureFires
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, and NetId
  • StructureData — Serverside state: structure instance, position, rotation, owner, group, timestamp, instance ID, health percentage
  • StructureRegion — Region container holding a List<StructureData> and a List<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:

TierTypical MultiplierMaterial
Low (Wood)1.0No 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:

  1. Adjacency: The structure must touch at least one existing structure or pillar.
  2. Grid alignment: Position must be on the grid.
  3. No overlap: No existing structure occupies the same grid cell.
  4. 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 StructureData is 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 NameBarricade TypePlacement Behavior
"Slot"FORTIFICATION, SHUTTER, GLASSSnaps to slot with auto-rotation
"Door"DOORSnaps to door frame with swing clearance check
"Hatch"HATCHSnaps to hatch opening with 4-way orientation
"Gate"GATESnaps to gate frame with behind-gate check
"Climb"LADDERSnaps 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

  1. 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.

  2. Instance ID overflow: The uint instance 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.

  3. Concurrent placement race: Two players placing structures at the same grid position simultaneously. The second dropReplicatedStructure call 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.

  4. 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.

  5. 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

  • getStructuresInRadius uses a caller-provided region list, avoiding internal allocations.
  • StructureDrop.FindByRootFast uses NetIdRegistry for O(1) lookup — faster than linear region scan.
  • The sendHealthChanged method gathers client connections via Provider.GatherClientConnectionsMatchingPredicate, which filters by ownership AND region distance in a single pass.

Plugin Integration Patterns

Common plugin use cases:

  • Structure protection: Hook onDamageStructureRequested and set shouldAllow = false for non-owner damage sources.
  • Building zones: Hook onDeployStructureRequested and check coordinates against a whitelist/blacklist.
  • Structure logging: Hook onStructureSpawned to log all placements to a database.
  • Auto-repair: Hook OnRepaired to track repair events and award experience.
  • Transform validation: Hook onTransformRequested to 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
    }
}

Document history