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.

ReasonCompletedRejectedCancelledWhat 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 LastOriginDivergenceCm diagnostic (the Origin / Direction parameters 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:

SeverityEscalation quotaPeriod
NormalEscalateQuotaRPCsPerFrame=256, EscalateTimeQuotaMSPerFrame=10per frame
HitchEscalateQuotaRPCsPerPeriod=512EscalateQuotaTimePeriod=2 s
Burst10248 s
PersistentBurst20488 s
DoS40968 s
ExpensiveDoS3276816 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:

Reading5.8
Calls injected600
EndIgnored (executed)85
ThrottleDropped515
ThrottleViolations515
Authority connections, before → after2 → 2
Remote clients, before → after2 → 2
LogNet reliable-buffer saturation / overflow warningnone 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:

  1. The category is LogRep, not LogNet. Both lines above are LogRep. A test that expects LogNet will never match.
  2. The validate-failure line is an Error; the unwanted-function line is Verbose. 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:

  1. There is no out-parameter form ((TSubclassOf<UInterface>, TArray<UActorComponent*>&)) on these engines, whatever older references suggest. Returning by value means one heap TArray per resolution; the kit calls this once per admitted request, at validation check (i), and that cost is inside the measured validation figure in § Budgets.

  2. FindComponentByInterface exists 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-041 needs.



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.

BudgetCeilingMeasuredVerdict
validation_cost — one admitted request, body entry → ApplyState returned or rejection sent≤ 50 µsmedian 1.5 µs on Dedicated, 8pass, 3 % of budget
server_frame_cost — the authority's whole protocol frame≤ 400 µs hard; 200 + 200 advisory20.3 µs at 8 players, 8 same-frame Instantspass
de-phased hold-tick sum≤ 200 µs (the hold half)max 7.9 µs at 8, 14.1 µs at 16pass
PredictedTickMicroseconds — the owning client's mirror≤ 10 µs / frame during a hold, 0 outside onemedian 0.2 µs, max 0.7 µs; exactly 0.0 µs on every idle framepass, 7 % of the claim
wire_instant / wire_hold, per direction≤ 64 B / ≤ 96 Bmeasured through the engine's own serialiserspass

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. OnPredictedInteractionStarted fires on the press, before the request leaves. The predicted hold duration comes from the behavior's GetPredictedHoldSeconds(Preset).
  • Re-time, not restart. ClientRequestAccepted carries the server's HoldSeconds. 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::HoldMismatchCount counts 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 OnStateChanged broadcasts on any machine.
  • OnPredictedInteractionEnded's reason is presentational: ServerResolved, LocalResolved, Released, Retracted, PredictedCancel. "Retracted, then Completed" 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 OnStateChanged by ShouldRetract(New), on the state's semantics. The Serial is 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 answers None by 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

EventMachineContract
ServerBegin(Ctx, OutHoldSeconds, OutReason) → EUKBeginVerdictauthorityReturn 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)authorityEdit InOutNext. The protocol applies it once, afterwards — never call ApplyState yourself.
ServerCancel(Ctx, Reason, InOutNext)authoritySame InOutNext rule; Reason is the protocol's.
TickHold(Ctx, DeltaSeconds, OutReason) → EUKHoldTickVerdictbothOne 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) → floatowning clientThe 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.

EventContract
Arbitrate(Ctx) → FUKArbitrationVerdictAdmit 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.

KnobDefaultRangeToo lowToo high
RequestsPerSecond (r)40.5 – 20legitimate rapid use is refused with Throttleda modified client's sustained ceiling rises with it (r × S)
RequestBurst (B)31 – 10at 1 a double-tap is throttleda burst of presses all reach the authority in one frame
ServerThrottleSlack (S)21 – 4at 1 ordinary jitter makes the server refuse what the client already allowedwidens the authority's own ceiling to r × S
RangeToleranceCm500 – 500borderline presses the player thought were in reach are rejectedreach beyond the preset's range, in centimetres
RangeToleranceFraction0.10 – 1as above, on large presetsthe same fraction of a 10,000 cm preset is 1,000 cm — see § Threat model
HoldRevalidateIntervalSeconds0.250.05 – 2one LOS trace per interactor per 0.05 s — the server frame cost scales with ita player can break line of sight for most of a second and still complete
AckTimeoutSeconds2.00.5 – 10a slow but live connection resolves locally as Timeout while the authority is still workinga dead request holds the interactor that long
MaxHoldSeconds601 – 600a legitimately long hold is clamped at resolve timea stuck hold occupies the interactor for minutes
MaxReleasedRecords4constant——
ThrottleWarningThreshold10constant——

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:

FieldMeaning
RequestsSentRequests that actually left for the authority. A locally-refused press does not count.
Accepted / Completed / Rejected / CancelledOutcomes as the client saw them.
LocalRejectedResolved by the client half itself — the send-path drop, a timeout, Inert.
StaleResolvesA reply for a serial with no record: the late arrival after a local timeout.
HoldMismatchCountAcks whose HoldSeconds differed from the prediction and re-timed the gauge.
RetractionsSemantic retractions of a predicted presentation.
RejectedByReasonTMap<EUKInteractionReason, int32> — the client's view.

Cumulative, authority:

FieldMeaning
Received / ServerRejected / ServerRejectedByReasonWhat arrived and what the authority refused, by reason.
ThrottleViolationsRequests over the authority's bucket. Exposed so you can apply your own policy — the kit never disconnects.
ThrottleDroppedRefusals the reply-amplification rule dropped silently — no reply was sent.
TargetUnresolvedThe replicated component reference arrived null: "destroyed or unresolved".
EndIgnoredServerEndInteract for a serial that is not the active one — a normal race, never a Warning.
HoldsCompleted / HoldsCancelledHold outcomes on the authority.
ServerLosTracesLine-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

  1. RejectedByReason on the client and ServerRejectedByReason on the authority — a reason that appears only on the client never left the machine.
  2. OutOfRange with a large LastOriginDivergenceCm means the client's press origin and the server's GetReachabilityOrigin() disagree, not that the range is wrong.
  3. NoLineOfSight the player cannot explain: read § The one-machine profile rule first.
  4. NoBehavior means the target's preset binding class has no registered behavior — or a subclass that never overrode ServerBegin.
  5. Rejected(None) is the local send-path drop; Timeout is 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) and RunDetectionTickForTest(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.

MemberKindCategory
BeginInteract / EndInteractcallableUK Interaction
GetHoldProgress()pureUK Interaction
GetProtocolStats()pureUK Interaction|Debug
GetPendingRequest() / GetActiveInteraction()pureUK Interaction|Advanced
GetLastPredictedCancelReason()pureUK Interaction|Advanced ("Owning client only.")
OnPredictedInteractionStarted / OnPredictedInteractionEndeddelegateUK Interaction|Advanced
OnRequestAccepted / OnRequestResolveddelegateUK Interaction|Advanced
OnServerInteractionBegan / OnServerInteractionEndeddelegateUK Interaction|Advanced
ServerBegin / ServerComplete / ServerCancel / TickHold / GetPredictedHoldSecondsbehavior eventUK Interaction|Advanced
Arbitrate / OnInteractionEndedarbiter eventUK 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.

RuleWhat it meansFix
UK-VAL-040The 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-041More 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-042The 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 as Rejected(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 whose TickHold never returns a terminal verdict.

Other symptoms.

  • The gauge jumps at the start of a hold. The server's HoldSeconds differed from GetPredictedHoldSeconds. It is re-timed from the original press, so the jump is a correction and not a restart; HoldMismatchCount counts 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 LocalRejected climbs. 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.

EngineCompileLogic + structuralSession (listen / dedicated end-to-end)perf
5.6PASSPASS (UK.Interaction.Core.Protocol 56/56 then 80/80)PASS — the full UK.Interaction suite, listen and dedicated, 0 failuresnot run (the performance run is 5.8-only)
5.7PASSPASS (same legs)PASS — the full UK.Interaction suite, listen and dedicated, 0 failuresnot run (same)
5.8PASSPASSPASS — UK.Interaction 701/701, listen and dedicatedPASS (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.