Interactable Door and Lock System
The Interactable door and lock system encompasses three classes: InteractableDoor for hinge-based doors and gates, InteractableLock (lock functionality integrated into door state), and InteractableSign for writable signs with profanity filtering. Doors represent the most security-critical interactable in Unturned — they must prevent players from walking through walls while remaining animation-driven and physically responsive.
Source code location: Unturned/Interactable/InteractableDoor.cs, Unturned/Interactable/InteractableSign.cs
InteractableDoor: Architecture
InteractableDoor extends Interactable and manages the open/close state, lock ownership, hinge animation, and physics overlap prevention for door-type barricades. It supports doors, gates, shutters, and hatches through the EBuild-determined behavior in the barricade assets.
State Data Layout
The door state byte array (20 bytes) stores:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 8 | owner | ulong Steam ID of the owner |
| 8 | 8 | group | ulong Steam group ID |
| 16 | 1 | isOpen | byte — 1 for open, 0 for closed |
The isLocked flag is set from the ItemBarricadeAsset.isLocked property, not from the state array — this means locked status is an asset-level attribute rather than a per-instance property.
updateState — State Application
csharp
public override void updateState(Asset asset, byte[] state)
{
isLocked = ((ItemBarricadeAsset)asset).isLocked;
_owner = new CSteamID(System.BitConverter.ToUInt64(state, 0));
_group = new CSteamID(System.BitConverter.ToUInt64(state, 8));
_isOpen = state[16] == 1;
Animation animationComponent = GetComponent<Animation>();
if (animationComponent != null)
playAnimation(animationComponent, true); // apply instantly
Transform placeholderTransform = transform.Find("Placeholder");
if (placeholderTransform != null)
placeholderCollider = placeholderTransform.GetComponent<BoxCollider>();
// Fix barrier collider state
if (barrierTransform != null)
barrierTransform.gameObject.SetActive(!isOpen);
}The applyInstantly = true parameter skips the animation to the final frame, so the visual state matches the data state immediately on world load.
Barrier Expansion: Start()
The Start() method creates a physical barrier collider that fills the door frame:
csharp
protected virtual void Start()
{
if (placeholderCollider != null && !IsChildOfVehicle)
{
barrierTransform = Instantiate(placeholderCollider.gameObject,
placeholderPosition, placeholderRotation, instantiateParameters).transform;
barrierTransform.tag = "Barricade";
barrierTransform.name = "ExpandedBarrier";
barrierTransform.gameObject.layer = LayerMasks.BARRICADE;
// Expand the box to eliminate gaps
BoxCollider box = barrierTransform.GetComponent<BoxCollider>();
if (box != null)
box.size = new Vector3(box.size.x + 0.25f, box.size.y + 0.25f, 0.1f);
barrierTransform.gameObject.SetActive(!isOpen);
}
}The barrier is artificially expanded by 0.25 units on the X and Y axes to eliminate the "see-through gap" that exists between the door frame and the door model due to floating-point imprecision. The barrier is active when the door is closed and inactive when open.
Collider Exploit Prevention: Animated Colliders
Unturned doors have historically been exploited to push players through walls. The fix involves three complementary systems:
1. Collider disabling during animation:
csharp
protected IEnumerator disableAnimatedColliders(float delay)
{
foreach (Collider doorCollider in doorColliders)
doorCollider.enabled = false;
yield return new WaitForSeconds(delay);
// Wait until nobody is overlapping the door colliders
while (areAnimatedCollidersOverlapping())
yield return new WaitForSeconds(0.1f);
foreach (Collider doorCollider in doorColliders)
doorCollider.enabled = true;
}The colliders are disabled for the duration of the animation, then re-enabled only after no players are overlapping.
2. Overlap detection on toggle:
csharp
public bool checkToggle(CSteamID enemyPlayer, CSteamID enemyGroup)
{
if (Provider.isServer && placeholderCollider != null)
{
if (overlapBox(placeholderCollider) > 0)
return false; // Can't toggle, someone is blocking
}
return !isLocked || enemyPlayer == owner || (group != CSteamID.Nil && enemyGroup == group);
}The overlapBox method uses the placeholder collider to check for players in the door's swing path:
csharp
protected int overlapBox(BoxCollider boxCollider)
{
int mask = IsChildOfVehicle
? RayMasks.BLOCK_CHAR_HINGE_OVERLAP_ON_VEHICLE
: RayMasks.BLOCK_CHAR_HINGE_OVERLAP;
return CollisionUtil.OverlapBoxColliderNonAlloc(boxCollider,
checkColliders, mask, QueryTriggerInteraction.Collide);
}3. Pool cleanup:
csharp
protected virtual void OnDisable()
{
if (animCoroutine != null)
{
StopCoroutine(animCoroutine);
animCoroutine = null;
}
if (doorColliders != null)
{
foreach (Collider doorCollider in doorColliders)
doorCollider.enabled = true;
}
}When a door returns to the object pool, all colliders are re-enabled to prevent the "stuck off" state.
IsOpenable Timer
A cooldown prevents rapid toggling:
csharp
public bool isOpenable => Time.realtimeSinceStartup - opened > 0.75f;This 0.75-second delay prevents animation overlap and abuse.
Animation Playback
The playAnimation method selects the clip based on the target state:
csharp
protected void playAnimation(Animation animationComponent, bool applyInstantly)
{
string clipName = isOpen ? "Open" : "Close";
if (animationComponent.GetClip(clipName) == null)
return;
animationComponent.Play(clipName);
if (applyInstantly)
animationComponent[clipName].normalizedTime = 1.0f;
}Audio Feedback
During updateToggle(), the door plays its attached AudioSource:
csharp
if (!Dedicator.IsDedicatedServer)
{
AudioSource audioSource = GetComponent<AudioSource>();
if (audioSource != null)
audioSource.Play();
}And alerts nearby zombies:
csharp
if (Provider.isServer)
AlertTool.alert(transform.position, 8);Network Protocol
Door networking uses a two-message pattern: client request → server validation → broadcast.
Client-side:
csharp
public void ClientToggle()
{
SendToggleRequest.Invoke(GetNetId(), NetTransport.ENetReliability.Unreliable, !isOpen);
}Server-side (rate-limited to 2 Hz):
csharp
[SteamCall(ESteamCallValidation.SERVERSIDE, ratelimitHz = 2)]
public void ReceiveToggleRequest(in ServerInvocationContext context, bool desiredOpen)
{
if (isOpen == desiredOpen)
return;
// Validate region exists
BarricadeManager.tryGetRegion(transform, out x, out y, out plant, out region);
Player player = context.GetPlayer();
if (player == null || player.life.isDead) return;
// Distance check (20m radius)
if ((transform.position - player.transform.position).sqrMagnitude > 400)
return;
if (isOpenable && checkToggle(player.channel.owner.playerID.steamID, player.quests.groupID))
{
BarricadeManager.ServerSetDoorOpenInternal(this, x, y, plant, region, !isOpen);
}
}Broadcast:
csharp
internal static readonly ClientInstanceMethod<bool> SendOpen = ClientInstanceMethod<bool>
.Get(typeof(InteractableDoor), nameof(ReceiveOpen));
[SteamCall(ESteamCallValidation.ONLY_FROM_SERVER, deferMode = ENetInvocationDeferMode.Queue)]
public void ReceiveOpen(bool newOpen)
{
updateToggle(newOpen);
}The deferMode = Queue ensures that door state updates are processed in order and not dropped during high-load scenarios.
Ownership Validation
csharp
public bool checkToggle(CSteamID enemyPlayer, CSteamID enemyGroup)
{
return !isLocked || enemyPlayer == owner || (group != CSteamID.Nil && enemyGroup == group);
}This logic is:
- Unlocked doors: anyone can toggle
- Locked doors: only owner or group members can toggle
- Singleplayer: always toggleable (bypasses lock)
Global Event
csharp
public static event System.Action<InteractableDoor> OnDoorChanged_Global;This event fires on both open and close transitions (not on initial load), allowing plugins and systems to react to door state changes.
Lock Functionality
There is no standalone InteractableLock class in the SDK. Lock behavior is embedded in:
InteractableDoor.checkToggle()— Gates access to door togglingInteractableSign.checkUpdate()— Gates access to sign editingInteractableStorage.checkStore()— Gates access to container storageInteractableMannequin.checkUpdate()— Gates access to mannequin clothing
Each follows the same pattern:
csharp
if (Provider.isServer && !Dedicator.IsDedicatedServer)
return true; // Singleplayer bypass
return !isLocked || enemyPlayer == owner || (group != CSteamID.Nil && enemyGroup == group);The isLocked flag originates from ItemBarricadeAsset.isLocked, a per-asset property rather than per-instance state. This means all instances of a locked barricade type are locked; individual lock/unlock toggling is handled through the BarricadeManager.ServerSetBarricadeLockedInternal API which flips the isLocked flag on the serverside data.
InteractableSign: Text Display and Editing
InteractableSign manages 3D-sign text with two rendering paths: legacy uGUI Text and modern TextMeshPro.
State Data Layout
| Offset | Size | Field |
|---|---|---|
| 0 | 8 | owner (ulong) |
| 8 | 8 | group (ulong) |
| 16 | 1 | length of UTF-8 text |
| 17 | length | UTF-8 encoded text |
updateState — Text Loading
csharp
public override void updateState(Asset asset, byte[] state)
{
isLocked = ((ItemBarricadeAsset)asset).isLocked;
if (!Dedicator.IsDedicatedServer && !hasInitializedTextComponents)
{
hasInitializedTextComponents = true;
// Find canvas labels (uGUI) or TextMeshPro components
Transform canvas = transform.Find("Canvas");
if (canvas != null)
{
Transform label = canvas.Find("Label");
// Single label or dual-label (Label_0, Label_1)
}
if (label_0 == null && label_1 == null)
{
tmpComponents = new List<TextMeshPro>(1);
transform.GetComponentsInChildren(true, tmpComponents);
}
}
_owner = new CSteamID(System.BitConverter.ToUInt64(state, 0));
_group = new CSteamID(System.BitConverter.ToUInt64(state, 8));
byte length = state[16];
if (length > 0)
{
string loadedText = System.Text.Encoding.UTF8.GetString(state, 17, length);
updateText(loadedText);
}
}Text Validation
csharp
public bool isTextValid(string text)
{
int newTextBytesSize = System.Text.Encoding.UTF8.GetByteCount(text);
if (newTextBytesSize > 230)
return false; // Total state cannot exceed 255 bytes
if (hasMesh)
{
if (!RichTextUtil.isTextValidForSign(text))
return false;
if (text.CountNewlines() > 8)
return false; // TMP stack overflow prevention
}
return true;
}The 230-byte limit (255 minus 17 overhead bytes) is enforced before network transmission. The 8-newline limit prevents TMP from stack-overflowing on emoji-rich strings.
Profanity Filtering
csharp
public void updateText(string newText)
{
text = newText;
if (Dedicator.IsDedicatedServer)
return;
Profiler.BeginSample("InteractableSign.FilterProfanity");
ProfanityFilter.ApplyFilter(OptionsSettings.filter, ref newText);
Profiler.EndSample();
DisplayText = newText; // After potential filtering
if (label_0 != null)
label_0.text = DisplayText;
if (label_1 != null)
label_1.text = DisplayText;
if (tmpComponents != null)
{
foreach (TextMeshPro tmpLabel in tmpComponents)
tmpLabel.SetText(DisplayText);
}
}The profanity filter respects the player's OptionsSettings.filter setting and is instrumented with Profiler for performance tracking. The original text property retains the unfiltered string for plugin access.
Network Protocol
Sign text changes follow the same client-request → server-validate → broadcast pattern:
csharp
public void ClientSetText(string newText)
{
SendChangeTextRequest.Invoke(GetNetId(), NetTransport.ENetReliability.Unreliable, newText);
}Server-side validation includes:
- Player exists and is alive
- Distance check (20m)
- Ownership/permission check
- Text validity (length, rich text, newline count)
- Plugin hook:
BarricadeManager.onModifySignRequested
csharp
bool shouldAllow = true;
BarricadeManager.onModifySignRequested?.Invoke(
player.channel.owner.playerID.steamID, this, ref trimmedText, ref shouldAllow);Use Interaction
csharp
public override void use()
{
PlayerBarricadeSignUI.open(this);
PlayerLifeUI.close();
}The sign UI opens for editing if the player has permission and the cursor is not already active.
Barrier Collider Detailed Analysis
The barrier expansion system creates a physical barrier that fills the entire door opening. This is critical because the door model itself has gaps around its edges due to:
- Floating-point precision — Colliders aligned to world coordinates may have sub-millimeter gaps
- Animation offset — The door model may shift slightly during open/close animation
- Player collider radius — A player's capsule collider has a non-zero radius that can clip through thin edges
The fix: expand the barrier by 0.25 units on X and Y and set depth to 0.1:
csharp
box.size = new Vector3(box.size.x + 0.25f, box.size.y + 0.25f, 0.1f);The barrier is parented to the door's Placeholder collider position. The Rigidbody on the instantiated barrier is destroyed because the barrier should be static:
csharp
Rigidbody barrierRigidbody = barrierTransform.GetComponent<Rigidbody>();
if (barrierRigidbody != null)
Destroy(barrierRigidbody);Animated Collider Complete Flow
The coroutine-based collider disabling system works as follows:
playAnimation()is called withapplyInstantly = false- All door colliders are immediately disabled
- The animation plays to completion (duration from clip)
- After the clip finishes, the system waits until no players overlap any door collider
- Once safe, all colliders are re-enabled
The wait loop:
csharp
while (areAnimatedCollidersOverlapping())
{
yield return new WaitForSeconds(0.1f);
}This prevents the "door crushes player" exploit where an open door's collider intersects a player, then the door closes and the physics engine pushes the player through a wall. By waiting for the collider space to be clear before re-enabling, the door cannot trap players.
Collider Identification
The Start() method identifies door colliders by excluding the placeholder and barrier:
csharp
GetComponentsInChildren(doorColliders);
for (int index = doorColliders.Count - 1; index >= 0; --index)
{
Collider doorCollider = doorColliders[index];
if (doorCollider == placeholderCollider || doorCollider.transform == barrierTransform)
doorColliders.RemoveAtFast(index);
}Only colliders that are part of the actual door model (hinge, panel, frame) are animated. The placeholder and barrier are always active (when door is closed).
Door, Gate, Shutter Variation
While InteractableDoor handles all four build types, there are subtle differences:
Single Doors (EBuild.DOOR):
- Check left swing only (
checkLeft = true) - Right swing only if
boundsDoubleDooris true boundsDoubleDooris determined by checking for the "Hinge" child in the skeleton:
csharp
boundsDoubleDoor = helper.transform.Find("Skeleton").Find("Hinge") == null;If there is no "Hinge" transform, the door is treated as a double door.
Gates (EBuild.GATE):
- Both left and right checks enabled when
boundsDoubleDooris true - Additional clearance check behind the gate:
csharp
if (Physics.OverlapSphereNonAlloc(
point + (hit.transform.forward * -1.5f) + (hit.transform.up * -2f),
0.25f, checkColliders, RayMasks.BLOCK_FRAME) > 0)
{
PlayerUI.hint(null, EPlayerMessage.BLOCKED);
return false;
}Shutters (EBuild.SHUTTER):
- Always check both left and right swings
Sign Text Complete Validation Path
isTextValid() Full Analysis
csharp
public bool isTextValid(string text)
{
int newTextBytesSize = System.Text.Encoding.UTF8.GetByteCount(text);
if (newTextBytesSize > 230)
return false;
if (hasMesh)
{
if (!RichTextUtil.isTextValidForSign(text))
return false;
if (text.CountNewlines() > 8)
return false;
}
return true;
}Byte budget calculation:
- 16 bytes: owner (8) + group (8)
- 1 byte: text length field
- 230 bytes: UTF-8 text content max
- 8 bytes: reserved for future use
- Total: 255 bytes (barricade state limit)
Rich text validation (RichTextUtil.isTextValidForSign):
csharp
public static bool isTextValidForSign(string text)
{
// Rejects:
// - Unclosed rich text tags
// - Nested tags of the same type
// - Tags with invalid parameters
// - Tags exceeding max depth (16)
return true; // if valid
}This prevents sign text from breaking the TMP renderer with malformed rich text.
Newline limit rationale:
The comment in the code states: "TMP throws a stack overflow exception with high number of emoji lines." This is because TMP processes each line as a separate mesh operation. With emoji characters (which can be multiple UTF-16 code units), a very long string with many newlines can cause a stack overflow in TMP's text processing.
Profanity Filter Pipeline
The filter operates on the client only (servers skip it):
csharp
if (Dedicator.IsDedicatedServer)
return;
ProfanityFilter.ApplyFilter(OptionsSettings.filter, ref newText);Two filter modes:
- Disabled (
OptionsSettings.filter == 0): text passes through unchanged - Enabled (
OptionsSettings.filter == 1): words from the profanity database are replaced with asterisks
The profanity filter is instrumented with Profiler.BeginSample:
csharp
Profiler.BeginSample("InteractableSign.FilterProfanity");
ProfanityFilter.ApplyFilter(OptionsSettings.filter, ref newText);
Profiler.EndSample();This allows performance profiling of the filter operation, which is important because sign text updates can happen frequently during text input.
Plugin Integration Point
csharp
bool shouldAllow = true;
BarricadeManager.onModifySignRequested?.Invoke(
player.channel.owner.playerID.steamID, this, ref trimmedText, ref shouldAllow);The onModifySignRequested event allows plugins to:
- Reject sign text updates entirely
- Modify the text content (e.g., prepend clan tags)
- Log sign text changes
- Apply custom profanity filters
Text Trimming
csharp
public string trimText(string text)
{
return text.Trim();
}Whitespace-only text is rejected because trimText would produce an empty string, and empty strings have newTextBytesSize == 0, which passes the validation but would display as blank.
Lock State Synchronization
Since there is no standalone lock component, lock state is managed through BarricadeManager:
csharp
BarricadeManager.ServerSetBarricadeLockedInternal(...)This method:
- Flips the
isLockedflag on theBarricadeData.serversideData - Updates the state byte array (setting a lock flag byte)
- Broadcasts the updated state to all clients in the region
The lock state is persisted in the save file as part of the barricade's state bytes. On load, updateState reads:
csharp
isLocked = ((ItemBarricadeAsset)asset).isLocked;For items that can be locked/unlocked dynamically, the state byte at a specific offset stores the locked flag. The base class pattern uses the asset's default, while runtime-toggled locks override from state.
Key Design Insights
- Two-layer collision — Doors use both the model's animated colliders (for physics interactions) and the expanded barrier collider (for blocking passage), with careful enable/disable sequencing to prevent player displacement exploits
- No separate lock class — Lock behavior is a cross-cutting concern implemented via
checkToggle/checkUpdate/checkStorepatterns across all locked interactables - State byte budget — Sign text is constrained to 230 UTF-8 bytes within the 255-byte barricade state budget (16 bytes for owner/group + 1 byte for length + 230 for text = 247, with 8 bytes reserved)
- Animation skip on load —
applyInstantly = trueinupdateStateprevents doors from playing their open/close animation every time a player enters the region - Deferred network delivery —
deferMode = ENetInvocationDeferMode.Queueon state broadcasts prevents out-of-order door state updates during high-throughput periods - Dual rendering paths — Signs support legacy uGUI (
Textcomponent) and modern TextMeshPro, with a fallback detection order: canvas/Label → canvas/Label_0 + Label_1 → GetComponentsInChildrenTMP - Expanded barrier defense-in-depth — The 0.25-unit expansion on the barrier collider, combined with the coroutine-based collider disabling and overlap checking, forms a three-layer defense against door-wall exploits
- Collider state lifecycle — Pooled doors must reset all colliders on
OnDisableto prevent the "stuck off" state where a returned-to-pool door has permanently disabled colliders
