Multi-user lock — Exclusive Queue
Exclusive Queue: one player at a time, with a fair turn order for the rest.
Assumes you have finished quick-start.
Two tables below are generated, not typed. The table in § Setting it up and the table in § Validator rules are rendered from the kit's own settings and rule definitions and checked by its tests, so the values on this page cannot drift from the shipped ones.
How to read the brackets. A bracket after a statement says how it is known and on which engine:
[source-read 5.x]— read in that engine's source;[measured 5.x]— observed by the kit's own tests on that engine;[unverified]— not yet confirmed. The kit's multiplayer tests run on 5.8, so a behaviour of the lock that a session test observed reads[measured 5.8](§ Known gaps).
What the lock does
One object, several players, one at a time. With the lock on, while one player is using an interactable, every other player who presses it is either queued — they keep a place in line — or refused with Locked when there is no room. When the player using it finishes, the first player in line gets a turn: for Reservation Seconds only their press is let in. If they do not press, the turn passes to the next player in line. Nobody can cut the line, because the server keeps it and every other machine only reads it.
What that costs your game:
- No new traffic path. The lock adds no RPC, no multicast, no tick and no timer. The server
decides inside the arbiter seam the protocol already calls
(protocol.md § Behavior and arbiter seams), and the
result reaches clients as one replicated struct,
FUKLockState— the queue ofPlayerStates and the server time the current turn started — which Get Lock State returns. Every machine derives positions and turns from that struct and the shared server clock; nothing is predicted. A change reaches a dormant owner too: the commit calls the sameForceNetUpdatethe kit uses everywhere, which flushes dormancy for that one update[source-read 5.8 — Actor.cpp:3013-3031]. - Bytes. The design ceiling is 72 B per change at the largest queue, eight players
(
lock-wire-budget.yaml), and nothing is sent while the queue does not change. Measured on the kit's byte channel: 7 B when a player joins the line (at four and at eight), 10 B when a turn starts, 22 B when all eight entries shift at once, 0 B over 600 idle replication cycles, and the interactable's own state change unchanged at 9 B with the lock beside it. That channel leaves out the component's block header and property handle (5–8 B by the design model).[measured 5.8, advisory] - Server time. One arbitration costs 2.3 µs (median) and one ending 3.8 µs, at a full
queue of eight, against a design target of 2 µs and a ceiling of 5 µs each
(
lock-cost-vectors.yaml).[measured 5.8, advisory] - Late joiners. A player who joins mid-session receives the current queue with the object and
derives their own position from it, and on their machine a held object with an active lock reads
Usable.
[measured 5.8 — separate processes: a listen host and a late joiner]For up to about a second after joining, an entry in Get Queue can read null on the joiner: the queue arrives before that member'sPlayerStatehas been mapped (PlayerStatereplicates at 1 Hz), and the entry fills in on its own. The kit's positions and turns already skip such entries; if you walk the queue yourself, skip nulls too[measured 5.8 — real processes]. - Either way of adding it. A lock placed in a Blueprint's component list and a lock added at
runtime with Add Component by Class both register with their interactable before its first
usability read, on the server and on every client.
[measured 5.8]
Diagnostics. Get Lock Stats returns the server's counters — Admitted (arbiter admissions — a press that a behavior or a later check then rejects is still counted once here), QueuedNew,
QueuedRepeat, Locked, Expired, PrunedInvalid, ClearedOnDisable, MaxQueueObserved. They
are kept on the server only; a client's copy reads all zeros. The two timing fields,
ArbitrateMicroseconds and EndedMicroseconds, are filled only while the console variable
UK.Protocol.Profile is on (protocol.md § UK.Protocol.Profile)
and read exactly 0 otherwise. [measured 5.8] With LogUKInteraction at Verbose, the server
writes one line per decision — Lock: Queued PS=<name> pos=2/2 — where pos is the same n/m a
player sees.
The lock does not decide what the interaction does, and it does not stack with an arbiter of your
own: if your actor itself implements the arbiter interface, the protocol asks your actor, warns
UK-VAL-041, and the lock stays inert. To have your own admission rule and the queue, write an
arbiter that delegates to the lock.
Setting it up
Three steps. The lock adds no RPC, no tick and no timer, and every member it adds sits in the Advanced category — the five-minute quick-start path is unchanged.
- Pick the policy. On the interactable's preset, set Lock Policy to Exclusive Queue.
Your package must offer the policy (
AvailableLockPoliciesin the plugin'sConfig/Game.ini); a preset that names a policy the package does not offer leaves the lock inert (UK-VAL-026). - Add the component. Add a UK Interaction Lock component (
UUKInteractionLockComponent) to the same actor as itsUUKInteractableComponent— one lock and one interactable per actor. - Set the two numbers, if the defaults do not fit. Project-wide under Project Settings › Plugins › UK Interaction › Lock, or on one placed actor by turning the component's Use Project Defaults off and editing its own two fields.
| Setting | Type | Default | Range | Where it lives |
|---|---|---|---|---|
MaxQueueLength | int32 (players) | 4 | 0 – 8 | UUKInteractionSettings::Lock; the component's own field when bUseProjectDefaults is off |
ReservationSeconds | float (seconds) | 5.0 | 0.5 – 60.0 | UUKInteractionSettings::Lock; the component's own field when bUseProjectDefaults is off |
bUseProjectDefaults | bool | true | — | UUKInteractionLockComponent |
A MaxQueueLength of 0 is a valid choice: exclusive use with no queue, so every press while
someone is using the object reads Locked. The upper bound, 8, is a fixed framework constant
(UK::Interaction::Lock::MaxQueueLengthHardCap), not a setting.
Queued, Locked, Your turn
A press on a held object gets one of two answers, and a player in line sees one of three states.
| What the player sees | When | Where the kit reports it |
|---|---|---|
| Queued — Please wait | They pressed while someone else was using the object and there was room in line — or they pressed again while already in line | the refusal's reason Queued; the prompt's why-not line shows Please wait |
| Locked — Locked | They pressed while it was in use and the line was full, or Max Queue Length is 0, or the player using it pressed again from a second pawn | the refusal's reason Locked; the why-not line shows Locked |
| Your turn | They are first in line, the object is free and their turn is running | Is Reserved For returns true for their PlayerState |
The words come from Get Reason Text, like every other refusal (events.md § Reasons, prompt.md § The why-not line).
Before BeginPlay (a construction script, an early spawn) every getter answers "not queued" — the lock
resolves its interactable once, at BeginPlay, and reads nothing before that [measured 5.8 — 2026-09-21].
Queued n/m. n is Get Queue Position — the player's 1-based place in line, 0 when they
are not in it. m is Get Effective Queue Length — the players actually waiting, never Max Queue
Length. The third of three players waiting behind the holder reads Queued 3/3, not 3/4. Both
are computed on the machine that asks, from the replicated queue. Pass the local player's own
PlayerState (from their player controller); the lock never guesses who "me" is.
The player whose turn it is must press. A turn is a right to be let in, not an interaction: the kit never starts a hold on a player's behalf, because every interaction begins with that player's own press. So the gauge a player sees is always the one the server is running.
Pressing a held object now does something. Without the lock, a held object reports Engaged
and a press is refused on the pressing player's machine before anything is sent. With an active lock
the interactable reports Usable while held, so the press reaches the server and joins the line;
the result of a Queued or Locked press reports Usable too. Every other usability rule is
unchanged, and an interactable without an active lock behaves exactly as before. [measured 5.8]
What that costs in this version. Because the press now goes through, the pressing player's own
predicted feedback — the use cue and the hold gauge — starts for one round trip before the
server's Queued or Locked answer arrives and hides it. If your use cue is a pawn animation, a
queued player sees it start briefly. The kit does not suppress it locally today.
[measured 5.8 — the predicted start fires once and ends once with Server Resolved, never Retracted]
Pressing again while in line answers Queued with the same position, changes nothing on the
server and sends 0 B. [measured 5.8] The client's press rate limit bounds how often a player
can ask.
The reservation window
When a turn starts. A turn starts when the interaction of the player using the object ends —
completed, cancelled, or ended because that player left, disconnected, was unpossessed or travelled.
The protocol turns all of those into an ending, and the lock hands the turn to the first player in
line in the same server frame the object goes back to idle. [measured 5.8]
Reservation Seconds is usable time. If the preset has a cooldown, the turn starts when the cooldown ends, not when the interaction ends, so the first player in line always gets the full Reservation Seconds on an object they can actually use. During the cooldown their press is refused on their own machine as not usable; they keep first place and lose nothing.
Only their press is let in. While the turn runs, the first player in line is admitted; anyone else
who presses is queued (if there is room) or refused, as while the object was held. Their press begins
their interaction, and they leave the line when that interaction ends. [measured 5.8]
If they do not press, the turn passes on — with no timer. After Reservation Seconds the turn
belongs to the second player in line, then the third, each for Reservation Seconds, until someone
presses or the line is empty. Nothing fires when a turn passes: the server and every client work out
whose turn it is from the queue, the time the first turn started and the server clock. The server
drops the players whose turns lapsed the next time it decides anything; every client has already
stopped counting them. Get Reservation End Server Time is the server time the current turn ends
(0 while the object is held or nobody is waiting) — compare it with the game state's server world
time.
The boundary belongs to the next player. At exactly the end time the turn has already passed: the
comparison is "at or after", on the server and on clients alike. The server reads the lapse at the
boundary instant itself. [measured 5.8] A client computes the same instant from its own copy of the
server clock, which the engine re-syncs only periodically [source-read 5.8 — GameStateBase.cpp:155-161, a 0.1 s timer] [unverified 5.6] [unverified 5.7], so a client can show the turn passing a little
after the server already has. The server's answer is the one that counts.
Recompute over time. Because a turn can pass with nothing replicated, On Lock State Changed
does not fire when it does. A widget that shows Your turn or a countdown must re-read Is Reserved
For and Get Reservation End Server Time on its own cadence — every frame, or whenever it
refreshes — not only from the event.
Which machine? On Lock State Changed — Every machine.
If the player's press is refused by a behavior. A custom behavior that rejects the press when it
begins keeps the player's turn: they were not removed from the line, the turn's clock did not move,
and they may press again inside the same turn. The refusal reason is the behavior's, not Locked.
[measured 5.8]
If the server hitches for longer than a turn. Turns are clock-based, so every turn that elapsed during the stall has passed by the next decision — players who never had a chance to press lose their turn. This is recorded, not mitigated: a hitch as long as Reservation Seconds (5 s by default) breaks more than a queue.
What a handover sends. One handover — the object engaged, the ending, and the next turn starting —
costs each observing client at most 136 B and typically about 47 B, by the design model at the
measured 9 B per interactable state change (lock-wire-budget.yaml). An ending with nobody waiting
sends nothing for the lock. [measured 5.8, advisory]
Disconnects, disabled objects and other ways the queue shrinks
A player in line leaves the game. When their controller logs out, the server removes them from the
line at once, everyone behind them moves up, PrunedInvalid counts it and the server logs
Lock: pruned PS=<name> path=logout. The player's PlayerState is still valid when the engine's
logout event fires, which is what makes this removal immediate. [source-read 5.6]
[source-read 5.7] [source-read 5.8] [measured 5.8 — the logout event raised in a test world]
Removal through a real client disconnect has not been observed yet (§ Known gaps).
The player using the object leaves. That is not a queue change: their interaction ends — the protocol cancels it as owner lost — and the turn passes to the first player in line in the same server frame (§ The reservation window).
If a player in line has their PlayerState destroyed without a Logout — your own Destroy() on
it, or a level unload — the server writes that entry as null on the wire, because the engine
serializes an object that is pending destruction as a null reference
[source-read 5.6 — PackageMapClient.cpp:328] [source-read 5.7 — :329]
[source-read 5.8 — :329-333]. That null is not sent until the lock's next change, because the
lock's state reaches the wire only when the lock marks it dirty, and nothing marks it when a member
dies [source-read — the push-model dirty rule] [measured 5.8 — 0 B between changes over 600 idle cycles]. Meanwhile every machine ignores the entry — each client's own copy of that PlayerState
is gone too, and positions and m skip missing players — and the server's next arbitration or
ending prunes it, logging Lock: pruned PS=<name> path=lazy
[measured 5.8 — the skip, and the prune at an arbitration] [unverified — on real processes, and the prune at an ending has no test].
The object is disabled. Set Enabled false — or an interaction that disables its object when
it completes — empties the line and ends any turn, on every machine: a disabled object owes nobody a
turn. When it is enabled again the lock starts empty, and players who were in line press again.
[measured 5.8]
You remove players yourself. Clear Queue empties the line and ends any turn; Remove From
Queue removes one PlayerState. Both work on the server only; called anywhere else they change
nothing and log one Verbose line saying so.
A player changes pawn. Their place is kept. The line holds PlayerStates, not pawns, so a
possession change moves nothing, and the new pawn's press is a fresh request decided normally. For the
same reason a second pawn of the player using the object is refused Locked, not queued — a project
that wants a lock per pawn writes its own arbiter.
The level ends. The lock belongs to its actor and goes with it. The queue is runtime state: it is never saved, and every lock starts empty when play begins.
Handlers and the seam guard
On Lock State Changed hands you the previous and the new FUKLockState. On clients it fires when
the replicated queue arrives changed, and it is an ordinary event. On the server it fires from
inside the lock's own two seam calls — while the protocol is arbitrating a press, and while it is
telling the lock an interaction ended. Treat it as read-only on the server: update your own UI,
sounds or bookkeeping there, and never write the interactable's state (Apply State, Set
Enabled) from it.
Which machine? On Lock State Changed — Every machine.
At the arbitration commit, a state write is warned. The protocol runs its arbitration call inside
its seam guard. A handler that writes the interactable's state from there trips the guard, and the kit
logs one Warning (once for that class on that interactor) that names the seam's class —
UUKInteractionLockComponent, or your Blueprint subclass of it:
UUKInteractionLockComponent wrote interactable state from inside a protocol seam (ApplyState / SetEnabled). The seam contract forbids it: edit InOutNext instead and let the protocol apply once. The request's single result is preserved.
Read that Warning as "your On Lock State Changed handler", not as a defect in the kit. The class it
names is the seam that was running when your handler wrote, not the class that owns your handler; the
lock itself never writes interactable state, and the kit's own session tests assert that it logs no
seam Warning. [measured 5.8] The warn-once-and-carry-on behaviour itself is the protocol's, measured
with a test arbiter. [measured 5.8]
At the ending commit, nothing is warned. The protocol tells the lock an interaction ended after it has already applied the ending state, and that call runs outside any seam guard. A handler that writes state there performs a plain Apply State that replaces the protocol's ending state, with no Warning — this site is unwarned. Write nothing there either.
What the guard refuses, what the protocol does after it, and why a violating seam is warned rather than rejected is the protocol's contract: protocol.md § Behavior and arbiter seams. It is not restated here.
Settings must match on every machine
Do not give the lock's two settings different values on different machines.
Reservation Seconds is read every time any machine works out whose turn it is — the server when it decides, and every client when it computes a position, a turn or an end time from the replicated queue. Max Queue Length is read by the server when it decides whether a press may join. Neither replicates: the project settings are local to each machine, and so are a component's own override fields once play has begun. A project that changes Reservation Seconds on one machine only — a per-machine config file, a console or Blueprint edit on a client — gets clients whose turns pass at different times from the server's, with no runtime check and no warning. Keep Max Queue Length identical too: on a listen server the host's own value is the server's.
Set both before play begins, or change them identically on every machine. It is the same rule the protocol states for the detection profile: protocol.md § The one-machine profile rule.
Validator rules
| Rule | Severity | Checked | What it means |
|---|---|---|---|
UK-VAL-100 | Warning | editor | A preset with Lock Policy Exclusive Queue sits on an actor with no UK Interaction Lock component. The policy is inert: nothing arbitrates. |
UK-VAL-101 | Warning | runtime and editor | A lock component's actor has zero or more than one UK Interactable component. The lock is inert. At runtime: once per component per BeginPlay, on the authority. |
UK-VAL-102 | Info | editor | A lock component's interactable uses a preset with Lock Policy None. The lock is inert by configuration. |
UK-VAL-026 (runtime half) | Warning | runtime | The interactable's preset names Exclusive Queue but this package does not offer it (AvailableLockPolicies). The lock is inert by edition config. Runtime half of UK-VAL-026: once per component per BeginPlay, on the authority. |
Runtime lines are written to LogUKInteraction on the authority. The editor checks run before
Play in the kit's editor validator — see validator.md for the page they
appear on and the settings that control it.
Lite
The Lite edition ships the lock's class — Lite carries the core module whole — and its
AvailableLockPolicies offers None only. A lock in a Lite project is therefore present and
inert by configuration: a preset that names Exclusive Queue makes the lock log UK-VAL-026's
runtime line once and stay silent, and the object behaves as if it had no lock. Nothing in Lite's code
differs; the edition switch is a config row.
The queue lock is not in Lite: its config row is stripped when the Lite edition is staged, so a Lite archive can only ever offer None.
Whether Lite's own documentation lists the lock at all, or describes it as present and inert as this page does, is decided with the Lite edition.
Known gaps
Recorded so you can read every claim on this page as narrowly as it was proven.
- Engine coverage. The lock is written for 5.6–5.8 with no version gates; it compiles, and its logic and session tests run, on 5.6, 5.7 and 5.8. Its multi-process and performance tests ran on 5.8. The agreed wording is: "supported on 5.6–5.8; multiplayer end-to-end suite run on all three".
- A real client disconnect has not removed a queued player yet. The logout removal is proven with
the engine's logout event raised in a test world. The end-to-end test that makes a real client
process leave mid-session is written and runs its setup on real processes, but skips at the moment
of leaving: the test harness cannot yet make one participant exit. For the same reason the
path=lazycase — aPlayerStatedestroyed withoutLogout— has never been observed on real processes. - The prune at an ending has no test of its own. The lock prunes missing players both when it arbitrates and when an interaction ends; only the arbitration half is asserted.
- The sample prompt widget does not draw
Queued n/morYour turn. The getters ship; the two prompt lines are a prompt-layer change not made in this version. Build them in your own widget (§ Queued, Locked, Your turn). - The ending site is not guarded. A handler that writes state from the ending commit gets no Warning (§ Handlers and the seam guard). Guarding it is an optional protocol change that has not been made.
- The editor checks are not in the Lite edition.
UK-VAL-100,UK-VAL-102and the editor half ofUK-VAL-101run in the kit's editor validator (validator.md), which the Lite edition does not include; the runtime lines in § Validator rules run in both. - Iris. The lock's replicated struct has not been run under Iris replication
(
-UseIrisReplication); it is verified on the default replication path only. - The performance figures are advisory, from one engine, on a stand-in machine. The µs costs and the byte figures on this page were recorded on 5.8 on a development machine standing in for the reference hardware. They are measurements, not gates.