Reference · Updated 2552.08.11.09.20
Doors — ASLDoor
Server-authoritative proximity doors. C++ owns the decision and the replicated state; the Blueprint subclass owns the meshes and the animation.
Server-authoritative proximity doors. C++ owns the decision and the replicated state; the Blueprint subclass owns the meshes and the animation.
Class: Plugins/SystemLinkCore/Source/SystemLinkCore/Public/World/SLDoor.h
#1. Why this class exists
The migrated BP_ForerunnerDoor1 (from the 5.6 project) got three things wrong, and each one is worth recognising because they recur in any world interactable:
1. Overlap with no authority check. It opened straight off OnComponentBeginOverlap. OnComponentBeginOverlap fires on every machine that simulates the overlap — the server and each client independently. So a client opened its own door locally, any NetMulticast it called was silently dropped (multicasts only replicate from the server), and the server's own multicast then played the animation a second time on that client.
2. Animation driven by a multicast. A multicast is fire-and-forget. A player who was not network-relevant when it fired never receives it, so anyone arriving after the door opened saw it closed — permanently. Replicated state plus OnRep fixes that for free, because a client entering relevancy receives the current value.
3. A filter that failed silently. The trigger check was "does the overlapping actor implement this interface", and the interface reference was empty after migration — so the branch was always false and the door could not open at all. Nothing logged.
The general rule: a door is world state that every machine must agree on. Decide on the server,
replicate the decision, and let each machine animate itself.
#2. How it works
Overlap (AUTHORITY ONLY) ──▶ TrackedOccupants ──▶ bIsOpen = true
│
┌────────────────────────────┴────────────────────────┐
▼ ▼
OnRep_IsOpen (clients) BroadcastDoorState (server)
└──────────────▶ OnDoorStateChanged(bOpen, bImmediate) ◀────┘
│
Blueprint runs the timeline
The door's visual position is never replicated and should not be. Each machine animates itself from one replicated bool, rather than the server streaming a transform.
bLocked is replicated the same way, with OnDoorLockedChanged.
#3. Making a door
- Create a Blueprint deriving from
SLDoor(the C++ class isAbstract, so it must be subclassed).
- Add your meshes as children of the inherited
Root. Do not add another trigger volume — C++
creates TriggerVolume and binds it. Resize that one instead.
- Set the box extent on
TriggerVolumeto the approach area.
- Implement
OnDoorStateChanged(bOpen, bImmediate):
OnDoorStateChanged (bOpen, bImmediate)
│
├── bImmediate TRUE ──▶ Set Timeline position to (bOpen ? end : start) [SNAP, no animation]
│
└── bImmediate FALSE ──▶ bOpen ? Timeline Play : Timeline Reverse
+ play the door sound
⚠ Honour bImmediate. It is true when the door was already in that state as you arrived — a late joiner, or the door entering network relevancy. Animating then makes it look like the door just opened for a player who was never there to trigger it. Snap to the end pose instead.
- Optionally implement
OnDoorLockedChanged(bNowLocked)for a light or hologram.
#4. Properties
| Property | Default | Notes |
|---|---|---|
TriggerActorClass | APawn | Type gate. Keeps projectiles, pickups and dropped weapons from triggering the door. |
TriggerActorTags | empty | If non-empty the actor must also carry one of these AActor::Tags. Empty accepts any actor of TriggerActorClass. |
bAutoClose | true | Off makes a door that opens once and stays open. |
AutoCloseDelay | 0.75 | Seconds after the last occupant leaves. 0 closes instantly, which reads as snapping at your back. |
MaxOpenTime | 5.0 | Longest the door stays open with nothing in the trigger volume. 0 disables. Needed because AutoCloseDelay only runs when the volume empties, and someone who shoots a switch from cover never fills it — without this a shot-opened door stays open for the rest of the match. Occupancy always wins. |
bShootableSwitchOpens | true | What a shot switch does by default. Off still fires OnDoorSwitchShot, it just stops opening the door on its own. |
bDisableSwitchesWhileOpen | true | Stops switches blocking weapon traces while open — see §4a. |
bStartLocked | false | Applied on the server in BeginPlay. |
bDebugLog | false | EditAnywhere, so one problem door can be traced without every door flooding the log. Lines are tagged SERVER / CLIENT. |
#On TriggerActorTags versus an interface
This replaces the old "implements this interface" check. An interface asset is one more thing to migrate and lose — which is exactly how the original ended up with an empty reference and a door that could never open.
But note the trade: a mistyped actor tag matches nothing and says nothing about it. Same failure shape as a BindWidgetOptional name mismatch. So:
- Prefer leaving
TriggerActorTagsempty.TriggerActorClass = APawnis usually the whole filter you
need, and it is compile-checked rather than string-matched.
- Only add tags when a door is genuinely selective (a team-only door, a keycard door).
A GameplayTag would not be a better choice here: reading gameplay tags off an arbitrary actor requires an ASC or IGameplayTagAssetInterface, so it swaps one interface dependency for another.
#4a. Shootable switches — USLShootableComponent
A panel the player shoots to open the door. Reusable: teleporters, consoles and destructible targets want the same thing.
#Setup
- Add a
USLShootableComponentto the door Blueprint, positioned over the switch panel
- Size the box to the panel
- Done —
ASLDoorbinds every shootable component it finds inBeginPlay, and the default
behaviour opens the door
For anything else, override OnDoorSwitchShot or bind the component's OnShot directly. Setting bShootableSwitchOpens = false stops the default open while still firing the event.
#How a shot reaches it
The weapon pipeline dispatches non-pawn hits through ISLDamageable on the hit actor (SLWeaponsComponent.cpp ~987), passing the full FHitResult:
weapon trace hits ──▶ ASLDoor::ReceiveWeaponDamage (authority, ISLDamageable)
└─▶ USLShootableComponent::RouteWeaponDamage
matches Hit.GetComponent() ──▶ OnShot ──▶ OnDoorSwitchShot
The component match is what makes "shoot the panel" different from "shoot the door" — the interface is actor-level, so without it any stray round clipping the frame would count.
For a different actor to use shootable components, implement ISLDamageable and forward one line to USLShootableComponent::RouteWeaponDamage. It returns false when the shot hit the actor somewhere other than a shootable component, so the caller can fall back to its own behaviour.
#Properties
| Property | Default | Notes |
|---|---|---|
bArmed | true | While false the component ignores hits. Server-side state, not replicated. |
RetriggerDelay | 0.25 | Ignore further hits for this long. Without it a full-auto burst counts as twenty activations and the door opens, closes and opens again mid-magazine. |
#Footguns
- ⚠
OnShotis AUTHORITY ONLY. It fires inside the server's shot processing and never runs on a
client. Do gameplay here and let replication carry the result — a cosmetic hung straight off OnShot is only ever seen by the listen-server host.
- ⚠ The collision setup is the whole trick and is deliberately narrow: blocks
ECC_WeaponTrace,
ignores everything else. Shots register, players walk through it, and it never pushes the camera or the movement capsule. If it seems unresponsive, the tempting wrong fix is to make it block everything.
- A shot switch opens, it does not toggle. A toggle means a stray round during a firefight slams the
door in your face. Auto-close handles closing.
- Hidden in game, visible in editor. The panel art is your own mesh; this is just the target volume.
#5. Blueprint / C++ API
| Call | Authority | Notes |
|---|---|---|
OpenDoor() | server only | Ignored while locked. Client calls are ignored, not half-applied. |
CloseDoor() | server only | Closes even with occupants inside; the next overlap change re-evaluates. A nudge, not a latch — use SetLocked to hold it shut. |
SetLocked(bool) | server only | Locking closes immediately. Unlocking re-evaluates, so someone already standing inside opens it. |
IsOpen() / IsLocked() | anywhere | Replicated, safe on clients. |
GetOccupantCount() | server meaningful | Clients always read 0 — the occupant list is server-side and replicating it would buy nothing. |
#6. Footguns
- Do not add your own trigger volume. C++ creates and binds
TriggerVolume. A second one will not be
bound to anything and will look like the door is ignoring you.
- Do not open the door from Blueprint overlap events. That is the original bug. Overlap is handled in
C++ behind an authority check; a Blueprint overlap runs on every machine.
GetOccupantCount()is 0 on clients. Do not branch client visuals on it.
- Dying in a doorway does not wedge the door open.
EndOverlapis not reliably delivered for an actor
destroyed inside the volume, so ASLDoor also binds OnDestroyed per occupant. If you ever track occupants yourself, you need the same — a pruning pass alone will not fire without an event.
- The trigger only responds to
ECC_Pawnoverlaps by default. ChangingTriggerActorClassto
something that is not a pawn also means widening the collision response, or the overlap never fires.
bImmediateexists for a reason. Ignoring it produces a door that appears to open by itself
whenever a player joins or walks into relevancy.
#7. Status
ASLDoorwritten 2026-08-07 on branchlevel1-interactables. **Not yet used by any Blueprint and not
PIE-tested.**
BP_ForerunnerDoor1still contains the original non-authoritative logic. It needs either reparenting to
SLDoor (with the overlap → Open Door graph deleted and OnDoorStateChanged implemented instead) or replacing with a fresh subclass. ⚠ Reparenting a Blueprint kills the editor during the reparent — the asset saves first, so relaunch and verify rather than redoing it. → Docs/Footguns.md.
- Test in 2-player PIE, not single-player. Every bug this class exists to prevent is invisible with one
player. The specific cases: a client triggering the door, a client watching the host trigger it, and a door that is already open when a second player joins.