The prompt and the glyphs
The on-screen prompt, its why-not line, and input-device glyphs.
Assumes you have finished quick-start.
This page covers what the prompt shows, how to replace the widget, the glyph providers, the why-not line, the
Clientsettings and the prompt's frame budget.
The kit draws a prompt for whatever the player is looking at, with the right button glyph for the device in their hands. You get it for free: install the plugin, add the two components, press Play. Nothing on this page is required reading to ship - it is here for when you want to change something.
What you see
Four things, and each one appears only when it has something to say.
| What it is | When it appears | |
|---|---|---|
| The glyph | The button to press - an icon for a gamepad face button or a mouse button, a keycap with the key's name for everything else | Whenever the prompt is visible. It changes the moment the player switches device |
| The label | The object's own prompt text (Open, Take, Read) | Whenever the object has one. An empty label is legal - you get glyph only |
| The hold gauge | A bar filling while a hold is in progress | Only for a hold. An instant press never shows one, because a bar that appears and vanishes in one frame is noise |
| The "why not" line | One line saying why the last attempt did not work - Out of range, Locked, Already interacting | For a moment after a refusal. A release is not a refusal, so letting go says nothing |
The prompt shows for every object the player is looking at, usable or not. An object on cooldown is shown dimmed with its count-down; a busy object is shown and says so. Hiding it would leave the player wondering whether the game had noticed them at all.
The prompt is never focusable and never hit-testable. It cannot take input from your game or your menus, it is not part of gamepad navigation, and the kit re-asserts both on every update - so a widget that changes them is corrected rather than trusted.
The sample widget's palette is white on black, and it is deliberate rather than default: white label, white keycap letter, and a white rounded outline around the keycap, all on an 82 % black backing. Those pairings are what keep the prompt legible over a bright scene - the kit's own test measures them at >= 4.5:1 for text and >= 3:1 for the keycap frame, derived for both of the ways Slate may composite the backing's alpha. If you re-skin the widget (see below), that measurement goes with your colours: the kit asserts the contrast of the widget it ships, never of yours.
Replace the widget
You need one Blueprint and one event.
- Create a Widget Blueprint whose parent class is UK Prompt Widget Base.
- Override the event On Prompt State Changed. It hands you one
Prompt Statestruct and it fires only when something in it changed - there is nothing to poll and nothing to tick. - Point Project Settings > Plugins > UK Interaction > Client > Prompt Widget Class at it.
That is the whole contract. The kit creates one instance per local player, adds it to that player's screen at Prompt Z Order, keeps it non-interactive, destroys it with its PlayerController and builds a new one for the next.
An empty reparented Widget Blueprint does not inherit the kit's sample layout - the sample is
built only into the kit's own shipped WBP_UKInteractionPrompt, so your widget draws exactly what
you put in it (copy the shipped asset if you want the kit's layout as a starting point).
Which machine? On Prompt State Changed — Owning client only.
The thirteen fields
FUKPromptState is the only interface between the kit's logic and your UI. Every field is
BlueprintReadOnly.
| Field | Type | What it is |
|---|---|---|
bVisible | bool | Whether a prompt should be drawn at all. When it is false, every other field carries its pinned "hidden" value |
Target | UK Interactable Component | What the player is looking at. Check IsValid(Target) before you touch it - it can have been destroyed between the publish and your read (see below) |
PromptText | Text | The object's resolved prompt text. Legally empty - draw glyph-only |
Usability | EUKUsability | Usable / NotUsable / Cooldown / Disabled - what to dim, and whether to show a count-down |
Priority | int32 | The focused object's priority, for a HUD that wants to style by importance |
CooldownRemaining | float | Seconds left, quantised to 0.1 s so a HUD text does not churn every frame. 0 when there is no cooldown |
Glyph | FUKInputGlyph | The button to press: Key, bHasIcon, IconBrush, KeycapText, DeviceKind |
bGaugeVisible | bool | Whether a hold is in progress and a bar should be shown |
HoldProgress | float | 0..1, quantised to 1/256. A passthrough of the protocol's own progress, re-timed when the server's answer changes the hold duration |
WhyNotText | Text | The refusal line's wording, already localised through the kit's reason table |
WhyNotReason | EUKInteractionReason | The same refusal as an enum, if you would rather choose your own wording |
bWhyNotVisible | bool | Whether that line is currently up |
Serial | int32 | Increments on every publish. Compare it for equality only - it wraps, and it keeps counting across a level change |
Serialisint32, notuint32. Unreal Header Tool refuses a Blueprint-exposeduint32, and every field of this struct isBlueprintReadOnly(measured on 5.8:Type 'uint32' is not supported by blueprint). Nothing about the contract changes - it increments, it wraps, and it is compared by equality.[measured 5.8][source-read 5.6][source-read 5.7]
IsValid(Target) before you use it
The prompt publishes on change, and one of the changes it publishes is your target was
destroyed. The kit hides the prompt on that frame, but a HUD that cached Target from an earlier
publish and dereferences it later can be holding a dead pointer. Every graph that reads Target
should run it through Is Valid first. This is the one lifetime rule the view-model cannot
enforce for you.
Turning the kit's widget off
Two ways, and they mean different things:
Auto Create Prompt Widget = false- the kit never creates a widget at all. Publishing continues, so binding On Prompt State Changed on your own HUD still works. This is the setting for a project that draws its own prompt somewhere else entirely.Set Prompt Widget Enabled(false)at runtime - the kit removes the widget it already made. Publishing keeps advancing while it is gone;Set Prompt Widget Enabled(true)builds a new one and it replays the last published state at once, so nothing has to be re-sent. Use this for a cutscene or a full-screen menu.
Clearing Prompt Widget Class works too, and is the bluntest form: nothing is created and a
UK-VAL-070 warning is logged once if the path is set but does not load.
Which machine? On Prompt State Changed — Owning client only.
Glyphs
The glyph is resolved by a provider, and the kit ships one.
The default provider
UUKDefaultGlyphProvider asks Enhanced Input which keys are mapped to the interact action
(Interact Action, or Prompt Input Action Fallback when that is unset), picks the one that
matches the device the player last used, and answers with:
- the icon from Glyph Set (
DA_UKGlyphsships 19 gamepad and mouse keys plus a keycap frame), or - a keycap carrying the key's own short display name when the set has no icon for it, or
- a keycap reading
"?"when nothing is mapped at all.
It resolves at most once per frame, only when something actually invalidated the glyph - a device change, a mapping rebuild, a settings change, a new controller - and never inside the delegate that triggered it. A burst of thirty rebuild events in one frame costs exactly one resolve.
Writing your own
Implement IUKGlyphProvider - one function, GetGlyph(Action, Kind, PC) returning a
FUKInputGlyph. It is a BlueprintNativeEvent, so a Blueprint class can implement it too. Then
install it, either way round:
- Blueprint / per player:
UK Prompt Subsystem > Set Glyph Provider. - C++ / process-wide:
UK::Interaction::Glyphs::SetProvider(TScriptInterface<IUKGlyphProvider>), withUK::Interaction::Glyphs::InvalidateGlyphs()to force every live prompt to re-resolve.
Passing nullptr (or an empty interface) restores the kit's default. The slot is one per
process, not one per player - a second SetProvider replaces the first, and every host is
invalidated so the next tick shows the new glyph.
The resolution order is: an adapter registered under the kind Glyphs first, then your
provider, then the default. That first slot is how Docs/adapters.md's CommonUI adapter will
plug in without you changing anything - it returns the platform's own key brush and calls
InvalidateGlyphs() when CommonUI's input type changes. The adapter is not in this version; the
seam it registers into is.
Kind (the EUKInputDeviceKind the kit classified) is advisory. A provider with its own
device authority - CommonUI's, for instance - is free to ignore it. What the kit publishes in
Glyph.DeviceKind is always the kit's own classification, so a HUD reading that field gets a
consistent answer regardless of which provider drew the icon.
Per-engine note (the rebuild delegate).
UEnhancedInputLocalPlayerSubsystem::ControlMappingsRebuiltDelegateis a dynamic multicast (DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnControlMappingsRebuilt)-EnhancedInputSubsystems.h:52on 5.6 / 5.7 / 5.8), and it never fires synchronously:AddMappingContext,RemoveMappingContextandRequestRebuildControlMappings()only raisebMappingRebuildPending, and evenbForceImmediatelymerely rebuilds the mappings and setsbControlMappingsRebuiltThisTick. The broadcast happens on the next Enhanced Input module tick (FEnhancedInputModule::Tick->HandleControlMappingRebuildDelegate()). So a context you add is reflected in the prompt's glyph on the following frame, not in the same call.[measured 5.8 - next module tick][source-read 5.6 - next module tick][source-read 5.7 - next module tick].Per-engine note (key order). Enhanced Input's
QueryKeysMappedToActionreturns keys without their mapping-context priority, and the applied-context map isprotectedon every supported engine, so the default provider keeps the query order - which the engine already sorts by context priority (descending) when it rebuilds control mappings.[measured 5.8 - query order][measured 5.6 - query order][unmeasured - 5.7].
The glyph never resolves on the frame the interactor gathered - with one caveat you should know about. The prompt host defers a pending glyph resolve by one frame whenever the interactor's
GatherCountadvanced since the host's previous tick, so a 200 microsecond resolve and an 80 microsecond detection gather do not land on the same frame's budget. Because tickables run before the actor tick groups (FTickableGameObject::TickObjectssits betweenTG_PostPhysicsandTG_PostUpdateWorkon 5.6 / 5.7 / 5.8) the counter the host reads reports the previous frame's gather, so the deferral shifts the resolve to the frame after the one following a gather. At the defaultGatherIntervalSeconds(0.1 s - a gather every sixth frame at 60 Hz) neither frame gathers and the cost is one frame of latency on a device switch. At the knob's minimum (0.033 s - every second frame) the deferral can move the resolve onto the next gather frame instead; if you run detection that hot and profile a spike on a glyph change, that is why. The wait is bounded at one tick: once a frame time reachesGatherIntervalSecondsthe interactor gathers on every frame, so an unbounded rule would never resolve the glyph at all - the host resolves on the second tick regardless of what the gather counter says.
The why-not line
When an interaction is refused, the prompt says so for a moment. When it merely ends, it says nothing - letting go of a hold is not a failure and does not deserve a line of text.
What arms it - four paths, all of them refusals:
| Path | Example |
|---|---|
| The server rejected the request | another player got there first: Already interacting |
| The server cancelled a hold for a reason that is not a release | you walked away mid-hold: Out of range |
| The client's own prediction cancelled the hold | the same Moved reason, seen locally before the server's answer arrives |
| The client refused before sending anything | the local pre-check: the object is on cooldown or unusable |
That last row matters more than it looks. The kit checks usability before it sends an RPC, so most refusals a player sees never reach the server at all - the line is armed from the local denial, in the same call that refused the press. Nothing goes on the wire, and nothing has to round-trip before the player is told.
What does not arm it: a completed interaction, a release, and a Rejected carrying the reason
None. The last one is deliberate - None is the reason a completion carries, so showing a
line for it would put the word "None" on screen. The kit logs one Verbose line instead so the
silence is visible in a log.
The rules, all four of them:
- Replace, never queue. A second refusal overwrites the first and restarts the window from its own time. There is no backlog of complaints.
- One window, from settings. The line hides at
armed +Why Not Seconds(default1.5, range0.2-10.0), on the first frame at or after that time - at every frame rate. Changing the setting while a line is up applies to that line. - Disarmed by a focus change, and by a later accept. Look at something else and the line goes with your attention; press again and succeed and it goes too. A usability change on the same object does not disarm it - the object saying "no" twice is one complaint, not two.
- Dropped when it is about something you stopped looking at. A rejection that arrives after
the player has moved on is counted (
FUKPromptStats) and logged atVerbose, and nothing is shown. This is the only place where a real server answer is deliberately discarded.
The wording comes from one place: Get Reason Text (Docs/events.md > Reasons), which reads
the kit's shipped string table and so is localisable. WhyNotReason carries the same refusal as
an enum if you would rather write your own line. Local denials are mapped into that same closed
enum before the lookup, so there is exactly one vocabulary: NotUsable -> NotUsable,
AlreadyInteracting -> AlreadyInteracting, and nothing focused -> no line at all.
Settings
Project Settings > Plugins > UK Interaction, section Client. Eleven fields.
The four asset fields are soft paths, never hard references: you can delete or replace any of the shipped assets and nothing in the kit's C++ stops compiling.
| Setting | Type | Range | Default | What it does |
|---|---|---|---|---|
| Prompt Widget Class | FSoftClassPath | - | WBP_UKInteractionPrompt.WBP_UKInteractionPrompt_C | The widget the kit creates for each local player. Point it at your own UKPromptWidgetBase subclass, or clear it and draw the prompt yourself. |
| Auto Create Prompt Widget | bool | - | true | Whether the kit creates that widget at all. Turn it off and nothing is drawn until you bind On Prompt State Changed. |
| Glyph Set | FSoftObjectPath | - | DA_UKGlyphs | Where the glyph icons come from. Empty is legal - every key then renders as a keycap with its own name. |
| Prompt Input Action Fallback | FSoftObjectPath | - | IA_UK_Interact | The action the glyph layer asks about when Interact Action is unset. |
| Why Not Seconds | float | 0.2 - 10.0 | 1.5 | How long a refusal line stays on screen. A newer refusal replaces it and restarts the window; refusals are never queued. |
| Prompt Z Order | int32 | -100 - 1000 | 10 | Where the prompt sits in the viewport. Below your HUD's Z order, your HUD covers it. |
| Dimmed Opacity | float | 0.0 - 1.0 | 0.5 | How far the glyph dims while the focused object cannot be used right now. |
| Interact Action | FSoftObjectPath | - | IA_UK_Interact.IA_UK_Interact | The interact action the kit binds - and the one the prompt shows a glyph for. |
| Mapping Context | FSoftObjectPath | - | IMC_UK_Default.IMC_UK_Default | The mapping context the kit adds for that action. |
| Mapping Context Priority | int32 | any int32 | 0 | The priority it is added at. Deliberately unclamped. |
| Add Mapping Context | bool | - | true | Whether the kit adds the context at all. Turn it off if you own the mapping. |
The last four are the input knobs. They belong to the kit's Enhanced Input binding rather than to the prompt, and they sit in this same section because that is where a project looks for them. The prompt reads exactly one of them - Interact Action, to know which action's glyph to show - and falls back to Prompt Input Action Fallback when it is unset.
Mapping Context Priority is deliberately unclamped. Whether it should have a range is an open decision, recorded rather than guessed.
Which machine? On Prompt State Changed — Owning client only.
Budgets
The prompt layer has five costs, and a ceiling for each. The kit's own measurements are far under them, which is the honest way to say that this is not where your frame time goes.
| Term | What it is | Target | Ceiling | Measured |
|---|---|---|---|---|
c_publish | Recompute + broadcast, host only (no widget) | 10 µs | 20 µs | 0.5 µs |
c_widget_full | The shipped widget's handler when text or visibility changed | 30 µs | 60 µs | 1.1 µs |
c_widget_gauge | The same handler when only the gauge moved | 8 µs | 15 µs | 0.5 µs |
c_tick | One host tick while something is live | 5 µs | 10 µs | 0.6 µs |
c_glyph | One glyph resolve, including the icon lookup | 100 µs | 200 µs | 0.8 µs |
An idle prompt costs zero: the host is an FTickableGameObject whose IsTickable() is false
unless a gauge is running, a cooldown is counting down or a glyph is dirty. 600 idle frames move
neither a publish nor a resolve, and the tick is not registered at all.
The shared client budget is 200 µs per frame, and the prompt is one of three claimants. The other two are the interactor's detection gather and the interaction protocol's predicted tick. On the kit's own test machine, a hold frame measures 1.5 µs for the prompt and the interactor's gather 7.1 µs - 8.6 µs of 200. Adding the protocol's measured predicted tick gives 9.3 µs; adding its budgeted 10 µs claim instead gives 18.6 µs. Both are inside the budget, so the prompt layer accepts the protocol's claim rather than contesting it.
Three cases exceed 200 µs at ceilings, by design, and are documented rather than gated:
| Case | Sum | Why it is recorded and not fixed |
|---|---|---|
| A detection gather plus a glyph resolve at ceiling | 280.4 µs | This is the reason for the gather-frame deferral - the two never share a frame |
| The detection knob corner plus a focus-change frame | 231.6 µs | Only reachable by turning detection's knobs to their most expensive corner |
| Split-screen: two players each gathering and holding | 250.8 µs | Two local players are two of everything, including two budgets' worth of work |
These are ceiling arithmetic, not measurements. Nothing in the kit has been observed near them.
What the exit fade costs: 0.3 µs per frame was measured on the "cannot play" branch, before
the shipped widget's fade played; the playing fade has not been re-measured (see § Known gaps). It is
recorded, never gated, and it adds no tick reason: the fade lives on the widget, not on the host.
All five figures are advisory, measured on one machine (a developer box, not a dedicated benchmark rig) on 5.8 only, on 2026-09-09. They are upper bounds: the measurement overhead is never subtracted, and the numbers come from an uncooked editor build, so they may not be quoted as shipping costs.
Known gaps
Everything on this list is a thing the kit does not prove, stated so you do not have to discover it.
-
"The widget is really on screen" is not asserted anywhere. No test in this kit owns a real viewport, so the kit claims only the half it can see: the widget is created, its
Add To Player Screendid not reach a screen, and exactly oneUK-VAL-073warning says so. Whether a headless test could own one was measured on 5.8: aUGameViewportClientcan be hand-built and the player-screen slot does recordPrompt Z Order- but a viewport client that never ran in a real window has noGameLayerManager, so the canvas dies as the call returns.Add To Player Screenalso returnstruein that situation, which is why the kit readsIsInViewport()for the answer instead of trusting the return value.[measured 5.8 - no][unmeasured - 5.6][unmeasured - 5.7] -
Engine coverage. In one line: "supported on 5.6–5.8; multiplayer end-to-end suite run on all three". On 5.6, 5.7 and 5.8 the compile, logic, reflection and structural suites pass, and so do the session tests.
-
OnPlayerControllerChangedfiring points: measured only NEGATIVELY. In the kit's in-process fixtures the delegate never fires - the player is set before the local player is added - so the host's controller half runs only through itsInitialize-time evaluation, which is exactly why that evaluation exists. The kit's level-travel test re-creates the controller and the delegate does fire there, so the recreate path is observed; the construction-time firing points are not. A shipping game reaches the delegate normally.[measured 5.8 - fixture-scoped][unmeasured - 5.6][unmeasured - 5.7] -
Tickables run BEFORE the actor tick groups, not after (measured).
FTickableGameObject::TickObjectssits betweenTG_PostPhysicsandTG_PostUpdateWork(5.6LevelTick.cpp:1582/ 5.7:1792/ 5.8:1821), and the interactor pins its detection tick toTG_PostUpdateWork- so the prompt host ticks first and the gather counter it reads reports the previous frame's gather. The gather-frame deferral is implemented against that measurement and its consequence is written out under § Glyphs.[measured 5.8][source-read 5.6][source-read 5.7] -
Focus usability lags a replicated change by up to one detection gather interval. The interactor caches an interactable's usability when it gathers (every
0.1s at the default), so for up to that long a prompt can readUsablefor an object another player has just taken - and the other way round. It is design, not a defect: it is what keeps detection off the per-frame path. It is also why a refusal often arms from the client's own pre-check rather than from a server verdict. -
Changing a setting at runtime from C++ does not re-publish. The prompt re-reads
Why Not Secondsand the otherClientknobs when something else drives a recompute, and in the editor it subscribes to the settings-changed delegate so a Details-panel edit applies at once. A packaged build writing the CDO throughGetMutableDefault<UUKInteractionSettings>()is silent until the next publish - there is no runtime broadcaster for that path in the engine. -
No reduced-motion setting exists to honour (measured). None of 5.6 / 5.7 / 5.8 exposes one the kit could read -
FSlateApplication::AreMenuAnimationsEnabled()is an editor menu preference and there is noUAccessibilitySettingsclass - so the kit adds nobReduceMotionknob of its own rather than inventing a second place to configure the same idea. If your project has an accessibility menu, turn the fade off by shipping your own widget. -
The prompt's visual design has not had its formal UX review. The layout, the
0.82backing opacity and the 120 ms exit fade come from the kit's prompt design spec, which is still in design. Those numbers may still move; the code that reads them will not. -
The exit fade's per-frame cost has not been re-measured since the sample widget began playing it. The shipped prompt fades out over 120 ms. Your own widget can carry an animation named
FadeOut(the name in its Animations panel) and the kit will play it on exit and restore whatever it animated when it stops. The animation must live on your Widget Blueprint itself - one inherited from a parent Widget Blueprint is not looked up, and that widget exits instantly. -
The world-anchored prompt (drawn at the object rather than at the bottom of the screen) is not in this version. Neither is a CommonUI glyph adapter. Both have their seams here already.