Interaction protocol — server authority, client prediction, and what a modified client gains
Server authority, client prediction, and what a modified client can and cannot gain.
Assumes you have finished quick-start.
Every section below is written from a measured fact or from data the kit's own tests assert, and says which engines it was checked on.
The kit's interaction protocol is server-authoritative with client presentation prediction. The client predicts a gauge and a use cue and nothing else; the interactable's state is never written or predicted on a client. Every interaction is one request, resolved exactly once.
Sequence diagrams
Three shapes. C is the owning client, S the authority.
Instant — the whole interaction completes inside the server's own call, so there is no ack.
C press
| OnPredictedInteractionStarted (gauge = 0, use cue shown)
|---- ServerBeginInteract(Target, 1, O, D) -------------------> S
| validate a..j -> Accept
| ApplyState(Engaged)
| OnServerInteractionBegan
| behavior ServerComplete
| ApplyState(Idle | Cooldown)
| OnServerInteractionEnded
| GetOwner()->ForceNetUpdate()
|<--- ClientRequestResolved(Completed, None) -----------------|
| OnPredictedInteractionEnded(ServerResolved)
| NotifyRequestResolved(1, Completed)
| OnRequestResolved(Completed)
Hold — the ack carries the server's duration, and the gauge re-times from the original press.
C press
| OnPredictedInteractionStarted (gauge climbing, predicted HoldSeconds)
|---- ServerBeginInteract(Target, 2, O, D) -------------------> S
| validate -> Accept, bHold
| ApplyState(Engaged)
|<--- ClientRequestAccepted(2, HoldSeconds) ------------------|
| gauge RE-TIMED from the ORIGINAL press if the value differs
| ... hold clock, LOS re-trace
| every HoldRevalidateIntervalSeconds
| phased by los_phase_offset ...
| Elapsed >= HoldSeconds -> complete
|<--- ClientRequestResolved(Completed, None) -----------------|
| OnPredictedInteractionEnded(ServerResolved) -> OnRequestResolved
Rejection — the presentation is hidden, never rewound, because nothing was moved.
C press
| OnPredictedInteractionStarted
|---- ServerBeginInteract(Target, 3, O, D) -------------------> S
| validate -> OutOfRange
| (no state write, no ApplyState)
| OnServerRequestRejectedNative [C++ only]
|<--- ClientRequestResolved(Rejected, OutOfRange) ------------|
| OnPredictedInteractionEnded(ServerResolved)
| OnRequestResolved(Rejected, OutOfRange)
OnPredictedInteractionEnded's reason is a presentation reason (ServerResolved,
LocalResolved, Released, Retracted, PredictedCancel). It says why the gauge stopped, never
what the server decided — "retracted, then Completed" is a legal sequence.
Which machine? On Predicted Interaction Ended — Owning client only.
Reason table
The vocabulary is closed: 21 reasons, and every one is admissible under a fixed set of
outcomes. The kit's tests pin the EUKInteractionReason enum against this table.
| Reason | Completed | Rejected | Cancelled | What it means |
|---|---|---|---|---|
None | ● | ● | No reason. Completed carries this and nothing else; a Rejected(None) is the local send-path drop. | |
Throttled | ● | The token bucket refused it — client-side, or the server's r × ServerThrottleSlack bucket. | ||
NotReplicated | ● | Local only. The pawn has no owning connection, so nothing could be sent. | ||
AlreadyInteracting | ● | This interactor already has an active interaction (or too many released-but-unresolved records). | ||
NoBehavior | ● | No behavior is registered for the target's preset binding class. | ||
StaleSerial | ● | The serial is not newer than the last accepted one — a reconnect or possession-change race. | ||
InvalidTarget | ● | Null, pending kill, not begun play, or not registered in this world. | ||
NoPlayerState | ● | The pawn is dead, unpossessed, or has no APlayerState. | ||
NotUsable | ● | ● | The target's usability is not Usable; the result carries which. | |
OutOfRange | ● | ● | Beyond server_range. | |
NoLineOfSight | ● | ● | The server's trace was blocked. | |
Locked | ● | Reserved for the multi-user lock system. | ||
Queued | ● | Reserved for the multi-user lock system. | ||
AlreadyTaken | ● | Reserved for stateful presets (pickups). | ||
Released | ● | The player let go before the hold completed. | ||
Moved | ● | Reserved for the hold behavior's velocity rule. | ||
Disabled | ● | SetEnabled(false) arrived while the interactable was Engaged. | ||
TargetLost | ● | The target stopped being valid mid-hold. | ||
OwnerLost | ● | Unpossess, EndPlay, or a new APlayerState (object identity). | ||
Timeout | ● | ● | Local only. The ack timeout rejects; the resolve timeout cancels. | |
Custom | ● | ● | A customer arbiter or behavior supplied CustomReasonTag. |
Four goldens worth stating outright, because each one is a plausible wrong reading:
(Completed, Released) is invalid; (Rejected, Released) is invalid;
(Cancelled, StaleSerial) is invalid; (Cancelled, Timeout) is valid.
Threat model
Written down so you can decide what, if anything, to add. This is what a modified client gains — everything else on the list below it, it does not.
It gains:
- Reach beyond the preset's range, by
max(RangeToleranceCm, RangeToleranceFraction × R). At the defaults that is 50 cm on a 250 cm door — and 1,000 cm on a 10,000 cm whole-room preset. If that matters for a particular interactable, lower the fraction or the range. - Interaction without facing the target. The view cone is a client focus rule; the server never checks it. A customer who needs facing enforced adds it in an arbiter, which has the pawn's control rotation.
- A sustained request rate up to
RequestsPerSecond × ServerThrottleSlack— 8/s by default — per interactor. - Targeting any interactable it can resolve, focused or not.
- A poisoned
LastOriginDivergenceCmdiagnostic (theOrigin/Directionparameters are advisory: the server never scores, ranges or traces with them).
It gains nothing on: hold duration, completion, interactable state, line of sight, usability, cooldown, exclusivity, or another player's presentation.
The kit never initiates a disconnect. _Validate returns false for exactly one condition —
RequestSerial <= 0, which an unmodified client cannot produce — and every racy input (a null
target, a stale serial, an out-of-range press) is a reason, not a kick. The engine's own RPC DoS
detection may disconnect on your configuration; see below. ThrottleViolations is exposed so you
can apply your own policy.
The one-machine profile rule
Do not change a resolved detection profile at runtime on one machine only.
The server's line-of-sight re-check traces with the server's own resolved detection profile —
its trace channel and its complexity flag. The detection profile is deliberately not replicated
(it is a class default or an SCS property, identical on both machines by construction). If you
swap it at runtime on the client only — or on the server only — the two traces stop asking the
same question, and the result is a NoLineOfSight rejection the player cannot explain: their
prompt said the target was reachable and the server disagreed.
There is no runtime check for this. It is a rule, not a guard, because the alternative is replicating a data asset reference on every interactor for a case that only a deliberate act produces. Change the profile on both machines, or change it before play begins.
RPC DoS headroom
The engine has its own RPC denial-of-service detector, and it is off by default. What follows is read from the engine's own configuration, not recalled.
[source-read 5.8] Engine/Config/BaseEngine.ini:1924 —
[GameNetDriver RPCDoSDetection] carries bRPCDoSDetection=false and bRPCDoSAnalytics=false,
with HitchTimeQuotaMS=1000, HitchSuspendDetectionTimeMS=1000,
InitialConnectToleranceMS=2000 and an RPCBlockAllowlist of ServerUpdateCamera,
ServerMove, ServerMovePacked. Seven severity levels are registered
(Normal, Hitch, Burst, PersistentBurst, DoS, ExpensiveDoS, Kick), each its own
[RPCDoSDetection.<Severity>] section:
| Severity | Escalation quota | Period |
|---|---|---|
Normal | EscalateQuotaRPCsPerFrame=256, EscalateTimeQuotaMSPerFrame=10 | per frame |
Hitch | EscalateQuotaRPCsPerPeriod=512 | EscalateQuotaTimePeriod=2 s |
Burst | 1024 | 8 s |
PersistentBurst | 2048 | 8 s |
DoS | 4096 | 8 s |
ExpensiveDoS | 32768 | 16 s |
Kick | — (bKickPlayer=true) | — |
The kit's ceiling is three orders of magnitude below the first rung. The client bucket caps a
player at RequestsPerSecond (4/s by default) and the server bucket at
RequestsPerSecond × ServerThrottleSlack = 8/s, against a Normal escalation quota of 256
RPCs per frame. No kit configuration within the documented ranges (RequestsPerSecond max 20,
ServerThrottleSlack max 4 → 80/s) approaches it either.
Two things this does not say. It does not say the detector is running — you must enable it. And it does not say the kit relies on it: the kit's own throttle exists because the detector is off by default and because a per-interactor bucket is a gameplay rule, not a security boundary.
[unverified — 5.6] [unverified — 5.7]: the quotas above were read from the 5.8
engine configuration only. The section names and keys are stable engine
configuration and are not expected to differ, but that expectation was not checked against source.
Measured under a sustained flood
The paragraph above is arithmetic against a config file. This is a measurement. It is a recorded figure and not a promise: nothing here is a supported limit, and the numbers move with the runner.
ServerEndInteract(99) — a serial no request ever used, so every admitted call costs the authority
its full EndIgnored path — injected one per cycle for 600 cycles (10 s at the harness's pinned
60 Hz) on a Dedicated, 2 in-process session, from a client with its own bucket bypassed:
| Reading | 5.8 |
|---|---|
| Calls injected | 600 |
EndIgnored (executed) | 85 |
ThrottleDropped | 515 |
ThrottleViolations | 515 |
| Authority connections, before → after | 2 → 2 |
| Remote clients, before → after | 2 → 2 |
LogNet reliable-buffer saturation / overflow warning | none observed |
85 is what the bucket predicts: RequestBurst × ServerThrottleSlack = 6 admitted in the first
instant, then RequestsPerSecond × ServerThrottleSlack = 8 per second for 10 s. So a sustained
flood at ten times the legitimate rate costs the authority 8.5 end-path executions per second
and nothing else, and the owning connection's actor channel never reached UActorChannel's reliable-bunch
limit — the failure that does close a connection engine-side.
The only promise in this section is the one the kit made everywhere else: no disconnect. That clause is asserted; the rest of the table is printed by the test and read here.
Measured by the kit's session test protocol_floods.spec.cpp
(test_a_sustained_end_flood_at_the_bucket_ceiling_keeps_every_client_dedicated).
[unmeasured — 5.6] [unmeasured — 5.7]: the test also passes on 5.6 and 5.7 (see
§ Known gaps), but the figures in the row above were read from the 5.8 run only.
Engine facts quoted
What the engine actually does when a Server RPC is refused, read from source rather than recalled.
[source-read 5.8] A _Validate that returns false fails the RPC and returns an error up the
receive path. Engine/Source/Runtime/Engine/Private/DataReplication.cpp:1465-1468:
if (RPC_GetLastFailedReason() != nullptr)
{
UE_LOGF(LogRep, Error, "ReceivedRPC: RPC_GetLastFailedReason: %ls", RPC_GetLastFailedReason());
return false;
}
The reason string is set by RPC_ValidateFailed(const TCHAR* Reason), declared at
Engine/Source/Runtime/CoreUObject/Public/UObject/CoreNet.h:801 beside
RPC_GetLastFailedReason() at :802 and defined in CoreNet.cpp:667 / :672.
[source-read 5.8] A Server RPC that arrives on a connection that does not own the actor is
dropped before the body runs, at DataReplication.cpp:1473:
UE_LOGF(LogRep, Verbose, "Rejected unwanted function %ls in %ls",
*FunctionName.ToString(), *Object->GetFullName());
Two corrections to the recalled version of these facts, recorded because they matter to anyone writing a log assertion:
- The category is
LogRep, notLogNet. Both lines above areLogRep. A test that expectsLogNetwill never match. - The validate-failure line is an
Error; the unwanted-function line isVerbose. They are different paths with different severities: the first is "this client sent something a correct client cannot", the second is "this client sent something it has no business sending here".
[unverified — 5.6] [unverified — 5.7]: the two spellings were read from 5.8 source only.
The kit's own test (protocol_listen_host_order.spec.cpp) asserts the absence of the
RPC_ValidateFailed line in BOTH forms - LogNet / Warning and the source-read LogRep /
Error - because an expectation written with the wrong category can never match and would
therefore pass vacuously. [measured 5.8] neither line appears when a stale serial is
rejected: the authority answers Rejected(StaleSerial) through ClientRequestResolved and the
session keeps every remote client. What the engine does with the connection after a false from
_Validate — and on which path — is deliberately not asserted here.
AActor::GetComponentsByInterface
[source-read 5.6] [source-read 5.7] [source-read 5.8] The arbiter-resolution query returns
by value, is const, and takes no out-parameter. Read from all three engine workspaces
(Engine/Source/Runtime/Engine/Classes/GameFramework/Actor.h) — 5.6 :3778, 5.7 :3821,
5.8 :3822, identical text on all three:
ENGINE_API TArray<UActorComponent*> GetComponentsByInterface(TSubclassOf<UInterface> Interface) const;
Two things follow:
-
There is no out-parameter form (
(TSubclassOf<UInterface>, TArray<UActorComponent*>&)) on these engines, whatever older references suggest. Returning by value means one heapTArrayper resolution; the kit calls this once per admitted request, at validation check (i), and that cost is inside the measured validation figure in § Budgets. -
FindComponentByInterfaceexists on all three — 5.6:3774, 5.7:3817, 5.8:3818:ENGINE_API virtual UActorComponent* FindComponentByInterface(const TSubclassOf<UInterface> Interface) const;It is the allocation-free first-only alternative, and the kit does not use it: it cannot report "more than one implementer", which is what
UK-VAL-041needs.
Budgets
Three machines, three numbers. Every figure below is advisory: it comes from a single-engine (5.8) performance run on one test machine that stands in for the target hardware, median of 5 after one discarded warm-up (measured 2026-09-08). The figures are recorded, not enforced as build failures.
| Budget | Ceiling | Measured | Verdict |
|---|---|---|---|
validation_cost — one admitted request, body entry → ApplyState returned or rejection sent | ≤ 50 µs | median 1.5 µs on Dedicated, 8 | pass, 3 % of budget |
server_frame_cost — the authority's whole protocol frame | ≤ 400 µs hard; 200 + 200 advisory | 20.3 µs at 8 players, 8 same-frame Instants | pass |
| de-phased hold-tick sum | ≤ 200 µs (the hold half) | max 7.9 µs at 8, 14.1 µs at 16 | pass |
PredictedTickMicroseconds — the owning client's mirror | ≤ 10 µs / frame during a hold, 0 outside one | median 0.2 µs, max 0.7 µs; exactly 0.0 µs on every idle frame | pass, 7 % of the claim |
wire_instant / wire_hold, per direction | ≤ 64 B / ≤ 96 B | measured through the engine's own serialisers | pass |
The declared 16-player breach is real and is not hidden. The kit's performance model predicts more than 400 µs of server frame cost when 16 players each complete an Instant interaction in the same frame, and it says so rather than claiming that case fits. The measurement at 8 players is 15–30× below the model; the model is kept as the conservative figure rather than rewritten from the measurement, and which of the two is right at 16 players is still open.
The editor-overhead caveat travels with every µs figure above. They were taken in an uncooked
editor -game process, which is an upper bound; the editor-to-packaged overhead factor has not
been measured yet and will be at the first packaged demo build.
[unmeasured — 5.6] [unmeasured — 5.7]: the performance run is single-engine (5.8), so every
figure above is a 5.8 reading.
The prediction contract
The client predicts two things and nothing else: the hold gauge and the use cue. It never writes, guesses or rolls back interactable state.
- Start.
OnPredictedInteractionStartedfires on the press, before the request leaves. The predicted hold duration comes from the behavior'sGetPredictedHoldSeconds(Preset). - Re-time, not restart.
ClientRequestAcceptedcarries the server'sHoldSeconds. If it differs from the prediction the gauge is re-timed from the original press instant, so a correction never rewinds the bar to zero.FUKProtocolStats::HoldMismatchCountcounts it. - Rollback is a hide. Every ending — server resolve, local timeout, release, retraction,
predicted cancel — hides the presentation. Nothing was moved, so nothing is rewound, and a
rejected request produces zero
OnStateChangedbroadcasts on any machine. OnPredictedInteractionEnded's reason is presentational:ServerResolved,LocalResolved,Released,Retracted,PredictedCancel. "Retracted, thenCompleted" is a legal pair.- Semantic retraction. The client drops its predicted presentation when the replicated state it
was predicting against stops meaning "this interaction is running" — evaluated on
OnStateChangedbyShouldRetract(New), on the state's semantics. TheSerialis never consulted: an advanced serial carrying{Idle, null}keeps the presentation exactly as a same-serial one does. - The clock is the server's. The authority owns the hold clock; the client's mirror exists to
drive the gauge and the local timeouts, and its cost is
PredictedTickMicroseconds. GetLastPredictedCancelReason()is owning-client only. On any other machine the client half never ran, so it answersNoneby construction rather than by a guard.
Which machine? On Predicted Interaction Ended — Owning client only. Which machine? On Predicted Interaction Started — Owning client only. Which machine? On State Changed — Every machine.
Behavior and arbiter seams
Two seams, both BlueprintNativeEvent, both authority-side except where the text says otherwise.
Neither is required: a project that uses only the shipped behaviors touches neither.
(What the three shipped behaviours do with these seams — the move rule, the cooldown rule, the counter and the toggle — is behaviors.md. This section owns the seam contract; that page owns the behaviours.)
Registering a behavior
Behaviors are registered per preset binding class through the free functions in
namespace UK::Interaction::Behaviors. The registry itself is a function-local
static FUKInteractionBehaviorRegistry that constructs on first use and is an FGCObject: it
keeps every registered UClass — including a Blueprint class — alive across a garbage
collection through AddReferencedObjects. You do not have to root what you register. Register
from your module's StartupModule (the kit registers its own from a Default-phase one) or later;
FUKScopedBehaviorOverride swaps one binding for the lifetime of a scope, which is what the kit's
own tests use.
UUKInteractionBehavior — five events
| Event | Machine | Contract |
|---|---|---|
ServerBegin(Ctx, OutHoldSeconds, OutReason) → EUKBeginVerdict | authority | Return Instant, Hold (set OutHoldSeconds) or Reject (set OutReason). The base implementation rejects with NoBehavior, so an unoverridden subclass and a missing registration read identically in the overlay — deliberately. |
ServerComplete(Ctx, InOutNext) | authority | Edit InOutNext. The protocol applies it once, afterwards — never call ApplyState yourself. |
ServerCancel(Ctx, Reason, InOutNext) | authority | Same InOutNext rule; Reason is the protocol's. |
TickHold(Ctx, DeltaSeconds, OutReason) → EUKHoldTickVerdict | both | One hold frame on the authority and on the owning client's mirror. It returns a verdict and nothing else — it must not mutate world state, because it runs twice. |
GetPredictedHoldSeconds(Preset) → float | owning client | The press-time prediction. A mismatch is re-timed by the ack, never rejected. |
What a behavior must not do: call ApplyState, begin or end another interaction on the same
interactor, or destroy the target inside the call. The protocol holds a re-entrancy guard across
the whole server chain with exactly one guarded emit site, so a seam callback that tries to re-enter
is refused rather than nested; the guard covers TickHold as well (measured). Destroying the target between frames is legal and resolves as
Cancelled(TargetLost).
A violating seam is warned, not rejected — and the warning names the class. Breaking the rules
above does not fail the request: the guard fires, the kit emits one Warning naming the offending
behavior or arbiter class, and the protocol applies its state once, after the call, as it always
does. That is deliberate. Rejecting at the seam would mean a customer's own code could silently turn a
legal press into a refusal the player cannot explain, and it would need a reason value the closed
vocabulary does not have. So the kit tells you which class misbehaved and carries on; the observable
consequence — for example an Arbitrate that disables the target being admitted, with the accept path
then pushing Engaged over your Disabled — is a consequence of your seam, not of the protocol.
The same holds inside TickHold: a body that calls SetEnabled(false) on its own target is admitted,
the kit's CancelActive is refused for the duration of the seam call, so the hold is not ended there —
the next hold tick's usability check ends it with Cancelled(NotUsable) one tick later, and the
interactable goes Engaged → Disabled with no Idle in between (measured 2026-09-08).
The warn-not-reject rule applies at both seam sites, behaviors and arbiters alike.
IUKInteractionArbiter — two events
Implemented by any actor or component on the target's actor; the protocol resolves it at
validation check (i) with AActor::GetComponentsByInterface.
| Event | Contract |
|---|---|
Arbitrate(Ctx) → FUKArbitrationVerdict | Admit or refuse one request. The default is admit, and it is UHT's default rather than the kit's: for a UINTERFACE, UHT emits an inline Arbitrate_Implementation returning a default-constructed verdict whose bAdmit is true. Refuse with a reserved reason (Locked, Queued, AlreadyTaken) or with Custom + CustomReasonTag. |
OnInteractionEnded(Ctx, Result) | Fires on the authority when an interaction this arbiter admitted has ended, however it ended. A request the arbiter refused never reaches here, because it never began. |
More than one implementer on one actor is a configuration error — UK-VAL-041 reports it. That
is why the resolver calls GetComponentsByInterface (all implementers) rather than the
allocation-free FindComponentByInterface (the first one), which cannot see the second.
The kit's own arbiter is the Exclusive Queue lock — see Docs/lock.md › Handlers and the seam guard for what its Warning means.
The kit's first behaviors that refuse a press from ServerBegin are the door (Locked) and the pickup (Already taken) — see Docs/presets.md › Door. The three basic behaviours never refuse.
Tuning
Every knob lives on UUKInteractionSettings::Protocol (FUKProtocolSettings) — Project Settings →
Plugins → UK Interaction. The ranges below are the meta=(ClampMin, ClampMax) values; the
kit's tests check this table against the code, so the two cannot drift apart.
| Knob | Default | Range | Too low | Too high |
|---|---|---|---|---|
RequestsPerSecond (r) | 4 | 0.5 – 20 | legitimate rapid use is refused with Throttled | a modified client's sustained ceiling rises with it (r × S) |
RequestBurst (B) | 3 | 1 – 10 | at 1 a double-tap is throttled | a burst of presses all reach the authority in one frame |
ServerThrottleSlack (S) | 2 | 1 – 4 | at 1 ordinary jitter makes the server refuse what the client already allowed | widens the authority's own ceiling to r × S |
RangeToleranceCm | 50 | 0 – 500 | borderline presses the player thought were in reach are rejected | reach beyond the preset's range, in centimetres |
RangeToleranceFraction | 0.1 | 0 – 1 | as above, on large presets | the same fraction of a 10,000 cm preset is 1,000 cm — see § Threat model |
HoldRevalidateIntervalSeconds | 0.25 | 0.05 – 2 | one LOS trace per interactor per 0.05 s — the server frame cost scales with it | a player can break line of sight for most of a second and still complete |
AckTimeoutSeconds | 2.0 | 0.5 – 10 | a slow but live connection resolves locally as Timeout while the authority is still working | a dead request holds the interactor that long |
MaxHoldSeconds | 60 | 1 – 600 | a legitimately long hold is clamped at resolve time | a stuck hold occupies the interactor for minutes |
MaxReleasedRecords | 4 | constant | — | — |
ThrottleWarningThreshold | 10 | constant | — | — |
Which knobs interact. r, B and S are one system: the client bucket is (r, B) and the
authority's is (r × S, B × S), so raising S raises the ceiling a modified client can sustain and
lowering it makes ordinary latency look like abuse. RangeToleranceCm and RangeToleranceFraction
are a max(), not a sum — the effective tolerance is
max(RangeToleranceCm, RangeToleranceFraction × R), so the centimetre term wins on small presets
and the fraction on large ones. HoldRevalidateIntervalSeconds is the only knob that moves the
server frame budget: traces are de-phased per interactor by LosPhaseOffset, so lowering it
raises the per-frame trace count rather than clumping it.
Two values are not knobs and are pinned as constants: MaxReleasedRecords = 4 (the released
ring) and ThrottleWarningThreshold = 10.
Diagnostics and the debug overlay
GetProtocolStats() — the field glossary
UK Interaction|Debug, Blueprint-pure, returns FUKProtocolStats by value. Cumulative fields
count for the life of the component; current fields describe this instant.
Cumulative, client:
| Field | Meaning |
|---|---|
RequestsSent | Requests that actually left for the authority. A locally-refused press does not count. |
Accepted / Completed / Rejected / Cancelled | Outcomes as the client saw them. |
LocalRejected | Resolved by the client half itself — the send-path drop, a timeout, Inert. |
StaleResolves | A reply for a serial with no record: the late arrival after a local timeout. |
HoldMismatchCount | Acks whose HoldSeconds differed from the prediction and re-timed the gauge. |
Retractions | Semantic retractions of a predicted presentation. |
RejectedByReason | TMap<EUKInteractionReason, int32> — the client's view. |
Cumulative, authority:
| Field | Meaning |
|---|---|
Received / ServerRejected / ServerRejectedByReason | What arrived and what the authority refused, by reason. |
ThrottleViolations | Requests over the authority's bucket. Exposed so you can apply your own policy — the kit never disconnects. |
ThrottleDropped | Refusals the reply-amplification rule dropped silently — no reply was sent. |
TargetUnresolved | The replicated component reference arrived null: "destroyed or unresolved". |
EndIgnored | ServerEndInteract for a serial that is not the active one — a normal race, never a Warning. |
HoldsCompleted / HoldsCancelled | Hold outcomes on the authority. |
ServerLosTraces | Line-of-sight re-traces run. Divide by elapsed time to see what HoldRevalidateIntervalSeconds costs. |
Current, client: PendingSerial (0 = none), PendingPhase (None / Pending / Holding),
ReleasedRecords (bounded by MaxReleasedRecords), LastResolveLatencySeconds,
PredictedTickMicroseconds.
Current, authority: ActiveSerial (0 = none), LastOriginDivergenceCm
(|Origin - GetReachabilityOrigin()| — the diagnostic that explains a range rejection),
ValidationMicroseconds with its ValidationLosMicroseconds / ValidationApplyMicroseconds
sub-brackets, and HoldTickMicroseconds.
The two views
GetPendingRequest(OutPending) (UK Interaction|Advanced) returns false and zeroes
OutPending when there is no record — "nothing pending" is a return value, not a sentinel serial,
so a caller that reuses one view never reads the previous record behind a false.
GetActiveInteraction(OutActive) is the authority's twin.
UK.Protocol.Profile
The five µs brackets — ValidationMicroseconds, ValidationLosMicroseconds,
ValidationApplyMicroseconds, HoldTickMicroseconds, PredictedTickMicroseconds — are measured
only while the console variable UK.Protocol.Profile is 1. With it off they read exactly
0.0 and the timer calls are not made: the kit does not pay for diagnostics nobody asked for.
UK.Detection.Profile is the interactor's separate switch.
Reading a rejection back to its cause
RejectedByReasonon the client andServerRejectedByReasonon the authority — a reason that appears only on the client never left the machine.OutOfRangewith a largeLastOriginDivergenceCmmeans the client's press origin and the server'sGetReachabilityOrigin()disagree, not that the range is wrong.NoLineOfSightthe player cannot explain: read § The one-machine profile rule first.NoBehaviormeans the target's preset binding class has no registered behavior — or a subclass that never overrodeServerBegin.Rejected(None)is the local send-path drop;Timeoutis local too.
Test seams
Every seam below compiles only under WITH_DEV_AUTOMATION_TESTS, so none of it exists in a
Shipping build. They are listed because a customer writing automation tests against the kit needs
them; they are not a supported runtime API and may change with the kit.
- Throttle / pre-check bypass —
IsClientThrottleBypassedForTest(),IsClientPendingCheckBypassedForTest(),PrimeClientBucket(Settings). - Reply control —
HoldClientReplyForTest(Reply),ReleaseHeldClientRepliesForTest(),SimulateClientResolvedForTest(Wire),SimulateAcceptedForTest(Serial, HoldSeconds). - Request injection —
InjectBeginRequestForTest(Request),InjectEndRequestForTest(Serial). - Tick control —
RunProtocolTickForTest(DeltaSeconds)(the protocol body only, never the detection body) andRunDetectionTickForTest(DeltaSeconds). - Phase control —
SetLosPhaseSeedForTest(Seed)and the pin-to-zero flag, so a test can put every interactor's LOS trace on one frame deliberately. - State readers —
HasActiveHoldForTest(),ReleasedRecordCountForTest(),LastAcceptedRequestSerialForTest(),IsProtocolBoundForTest(),CancelActiveForTest(Reason).
Blueprint surface
Every protocol member and its category, so a Blueprint author knows which node list to look in.
UK Interaction is the simple tier, UK Interaction|Advanced the one you opt into, and
UK Interaction|Debug the diagnostics.
| Member | Kind | Category |
|---|---|---|
BeginInteract / EndInteract | callable | UK Interaction |
GetHoldProgress() | pure | UK Interaction |
GetProtocolStats() | pure | UK Interaction|Debug |
GetPendingRequest() / GetActiveInteraction() | pure | UK Interaction|Advanced |
GetLastPredictedCancelReason() | pure | UK Interaction|Advanced ("Owning client only.") |
OnPredictedInteractionStarted / OnPredictedInteractionEnded | delegate | UK Interaction|Advanced |
OnRequestAccepted / OnRequestResolved | delegate | UK Interaction|Advanced |
OnServerInteractionBegan / OnServerInteractionEnded | delegate | UK Interaction|Advanced |
ServerBegin / ServerComplete / ServerCancel / TickHold / GetPredictedHoldSeconds | behavior event | UK Interaction|Advanced |
Arbitrate / OnInteractionEnded | arbiter event | UK Interaction|Advanced |
OnServerRequestRejectedNative is C++ only — a native multicast with no Blueprint node, because
a rejection the player never sees is a diagnostic, not a gameplay event.
Which machine? On Server Interaction Began — Authority only. Which machine? On Server Interaction Ended — Authority only.
Troubleshooting
UK-VAL-040 / 041 / 042 — the validator messages. They are project-data warnings, not kit
faults; each names what your project is doing.
| Rule | What it means | Fix |
|---|---|---|
UK-VAL-040 | The interactor's pawn has no owning connection, so nothing it presses can be sent. A runtime Warning. | Possess the pawn with a player controller that has a connection; on a dedicated server an AI-possessed pawn is expected to hit this. |
UK-VAL-041 | More than one IUKInteractionArbiter implementer on one target actor — the protocol would have to pick, and picking silently is worse. | Keep one arbiter per actor. |
UK-VAL-042 | The target actor's replication configuration cannot deliver the protocol's reference reliably (net cull distance / update frequency). | Widen NetCullDistanceSquared, or accept that the interactable is not relevant at that range. Reported by the editor validator — see validator.md. |
The two timeout warnings.
- Ack timeout (
AckTimeoutSeconds, default 2 s) — the authority never answered a begin, and the request resolves locally asRejected(Timeout). A live, awake, relevant connection never reaches it, so seeing this means the connection, the relevancy or the target is the problem, not the protocol. - Resolve timeout — a hold was accepted and then never resolved; it resolves locally as
Cancelled(Timeout). The commonest cause is a behavior whoseTickHoldnever returns a terminal verdict.
Other symptoms.
- The gauge jumps at the start of a hold. The server's
HoldSecondsdiffered fromGetPredictedHoldSeconds. It is re-timed from the original press, so the jump is a correction and not a restart;HoldMismatchCountcounts it. - Presses stop working after a possession change. The serial epoch moved. That is correct — the
old possession's serials are stale by construction and answer
StaleSerial. - Nothing is sent at all and
LocalRejectedclimbs.NotReplicated: the pawn has no owning connection.
Known gaps
Recorded so the state of this document is not overstated.
- Two multiplayer cases — two interactors pressing the same target, and the either-order arrival of their requests — are not yet asserted in a session test; they wait on a two-interactor test rig, not on a kit change.
- The kit's performance model reads 15–30× above measurement (§ Budgets); which of the two is right at 16 players is open.
- No automated build compiles the core in
Shipping/Test, so the test-only code fence is verified by a local build only.
Engine coverage — what was verified where
5.6, 5.7 and 5.8 are all supported. The kit compiles and its logic, structural and in-process session suites pass on all three. In one line: "supported on 5.6–5.8; multiplayer end-to-end suite run on all three". The performance run is the one leg measured on 5.8 only.
| Engine | Compile | Logic + structural | Session (listen / dedicated end-to-end) | perf |
|---|---|---|---|---|
| 5.6 | PASS | PASS (UK.Interaction.Core.Protocol 56/56 then 80/80) | PASS — the full UK.Interaction suite, listen and dedicated, 0 failures | not run (the performance run is 5.8-only) |
| 5.7 | PASS | PASS (same legs) | PASS — the full UK.Interaction suite, listen and dedicated, 0 failures | not run (same) |
| 5.8 | PASS | PASS | PASS — UK.Interaction 701/701, listen and dedicated | PASS (advisory) |
Every measured figure in this document (§ Budgets, § RPC DoS headroom's flood table) is therefore a 5.8 reading, and every engine fact says which engines it was read on. On 5.6 and 5.7 the session tests pass; their figures have not been read into this page.