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 of PlayerStates 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 same ForceNetUpdate the 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's PlayerState has been mapped (PlayerState replicates 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.

  1. Pick the policy. On the interactable's preset, set Lock Policy to Exclusive Queue. Your package must offer the policy (AvailableLockPolicies in the plugin's Config/Game.ini); a preset that names a policy the package does not offer leaves the lock inert (UK-VAL-026).
  2. Add the component. Add a UK Interaction Lock component (UUKInteractionLockComponent) to the same actor as its UUKInteractableComponent — one lock and one interactable per actor.
  3. 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.
SettingTypeDefaultRangeWhere it lives
MaxQueueLengthint32 (players)40 – 8UUKInteractionSettings::Lock; the component's own field when bUseProjectDefaults is off
ReservationSecondsfloat (seconds)5.00.5 – 60.0UUKInteractionSettings::Lock; the component's own field when bUseProjectDefaults is off
bUseProjectDefaultsbooltrue—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 seesWhenWhere the kit reports it
Queued — Please waitThey pressed while someone else was using the object and there was room in line — or they pressed again while already in linethe refusal's reason Queued; the prompt's why-not line shows Please wait
Locked — LockedThey 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 pawnthe refusal's reason Locked; the why-not line shows Locked
Your turnThey are first in line, the object is free and their turn is runningIs 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

RuleSeverityCheckedWhat it means
UK-VAL-100WarningeditorA preset with Lock Policy Exclusive Queue sits on an actor with no UK Interaction Lock component. The policy is inert: nothing arbitrates.
UK-VAL-101Warningruntime and editorA 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-102InfoeditorA lock component's interactable uses a preset with Lock Policy None. The lock is inert by configuration.
UK-VAL-026 (runtime half)WarningruntimeThe 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=lazy case — a PlayerState destroyed without Logout — 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/m or Your 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-102 and the editor half of UK-VAL-101 run 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.