Options/settings screen architecture: draft, Apply, Cancel, and Defaults¶
At a glance
Controls edit: one screen-owned draft · Runtime systems use: the currently applied values · Disk/profile storage holds: the last successfully persisted values · Apply: validate → apply to each real owner → persist → update the snapshot · Cancel: restore any previews → discard the draft · Defaults: put default values into the draft — do not silently apply or save them · Primary references: UGameUserSettings · Enhanced Input User Settings · Saving and Loading Your Game
The one-minute version¶
- An options widget should not let every Slider, Check Box, and Combo Box write directly to unrelated engine services. Put their editable values in one draft model first.
- Keep three states distinct: draft (what the screen currently shows), applied (what the running game currently uses), and persisted (what will load next launch). They can temporarily differ.
- On Open, read each real owner, build one normalized draft, and keep an open snapshot for Cancel/dirty comparison.
- On Apply, validate the whole draft, apply values in a deliberate order, persist them through their actual owners, and report any asynchronous save failure. Only then make the new values the clean snapshot.
- On Cancel, discard uncommitted edits and undo any live preview you already applied. Cancel cannot reverse a preview value you never recorded.
- Defaults should change the controls to a proposed default draft. Let the player inspect it, then choose Apply or Cancel.
- Graphics/device settings usually belong to
UGameUserSettings; local input mappings belong to the correct local player's Enhanced Input user settings; project/profile preferences may belong in SaveGame; GameInstance can coordinate runtime state but is not persistence. - These are local preferences. Do not replicate screen brightness, category volume, key bindings, or display mode as authoritative multiplayer state.
The three states¶
The word "setting" often hides three different values:
| State | Example master-volume value | Owner |
|---|---|---|
| Persisted | 0.70 from the last successful save |
GameUserSettings config, Enhanced Input user-settings save, or project SaveGame |
| Applied | 0.70 currently driving the audio mix |
The runtime audio/settings service |
| Draft | 0.45 while the Slider is being edited |
The open options-screen model |
The screen can preview 0.45, making Applied temporarily match Draft, while
Persisted remains 0.70. If the player cancels, Applied must return to the
captured 0.70. If the player applies and saving succeeds, all three become
0.45.
flowchart LR
P[Persisted values] -->|load/open| A[Applied values]
A -->|snapshot| D[Draft model]
D -->|control edits| D
D -->|optional preview| A
D -->|Apply: validate + commit| A
A -->|save succeeds| P
D -->|Cancel: restore snapshot| A
This separation prevents the classic half-working screen:
- resolution changed immediately but Cancel does nothing;
- audio preview works but was never saved;
- key mapping saved but the active local player never applied it;
- Defaults silently overwrote the player's file;
- one failed save leaves the UI claiming everything is clean.
Give the screen one draft model¶
Create a Blueprint Struct such as ST_OptionsDraft, or a dedicated UObject
model when you need identity, change notifications, or richer validation.
ST_OptionsDraft
WindowMode
Resolution
VSyncEnabled
OverallQuality
MasterVolume
MusicVolume
MouseSensitivity
InvertY
SubtitleMode
KeyMappingDrafts
The widget binds or pushes its controls to this one draft. It does not know
how UGameUserSettings, Sound Mixes, Enhanced Input, or SaveGame write their
storage.
Use a controller, GameInstance subsystem, Local Player subsystem, or another project-owned service as an options coordinator:
WBP_Options
-> asks OptionsCoordinator: OpenDraft(LocalPlayer)
<- receives Draft + validation/display metadata
controls
-> edit Draft only
-> optional Preview request goes through coordinator
Apply button
-> OptionsCoordinator.ApplyDraft(Draft)
<- result per settings owner + any pending async save
This is not abstraction for its own sake. The quick path—each control calling one engine setter directly—leaves no single place that can validate the whole draft, order cross-system changes, restore previews, report partial failure, or decide when the screen is clean.
Which owner stores which value?¶
Use the narrowest owner whose lifetime and user scope match the value.
| Owner | Good fit | Wrong fit / boundary |
|---|---|---|
| UGameUserSettings | Engine/device graphics and display choices; scalability, resolution/window mode, VSync, frame cap; custom machine settings if your subclass owns them | It is a global game-user-settings instance, not one object per split-screen player and not gameplay authority |
| Enhanced Input User Settings | Player-mappable keys, profiles, and input preferences for one Local Player | It does not edit shared Input Mapping Context assets and should not be replicated |
| Project SaveGame | Profile/playthrough preferences not owned by an engine service: subtitles, accessibility choices, tutorial flags, project audio values, or per-profile policy | A live SaveGame UObject is only a data container until a slot save succeeds |
| GameInstance / GameInstance Subsystem | Runtime coordinator/cache that survives ordinary map travel; loading services and reapplying settings | It is transient process memory, not disk persistence, and each multiplayer process has its own instance |
| Local Player / Local Player Subsystem | Per-local-player runtime options and the correct split-screen identity | It does not by itself survive quitting |
| Widget / draft model | Temporary edits, validation messages, dirty state, pending confirmation | It must not become the only persisted or authoritative owner |
| Replicated gameplay owner | Match rules that genuinely change simulation and need server authority | Do not put local display/audio/input preferences here |
Audio is a policy choice, not an automatic GameUserSettings feature¶
UGameUserSettings can be subclassed, but Unreal does not magically connect a
project's MasterVolume field to its Sound Classes/Mixes. Decide which project
service applies category volume and which storage owner persists the plain
values. The coordinator then calls both deliberately.
GameInstance survives travel, not shutdown¶
GameInstance is created for the running game and survives ordinary level loads. In play-in-editor (PIE), each play instance has its own GameInstance. It is excellent for coordinating a settings load and holding the current runtime model, but closing the process discards unsaved GameInstance state.
See Game Instance and Save Game: Create / Save / Load Game to Slot for that lifetime boundary.
Open the screen without creating event loops¶
A control's "changed" event should mean a user/edit intent, not "some code set the initial display value."
Use a deterministic open sequence:
OpenOptions(LocalPlayer)
-> bInitializing = true
-> read all owners
-> normalize into AppliedSnapshot
-> copy AppliedSnapshot to Draft
-> populate controls from Draft
-> clear validation messages
-> bInitializing = false
-> Dirty = false
Every control handler begins with the same boundary:
OnMasterVolumeChanged(NewValue)
-> if bInitializing: return
-> Draft.MasterVolume = Clamp(NewValue)
-> Dirty = Draft != AppliedSnapshot
-> optional PreviewMasterVolume(NewValue)
An initialization guard is useful, but do not scatter five unrelated booleans across child widgets. Let the screen/coordinator own one load phase. Also compare values before writing them; this avoids loops when a model notification repaints a control with the value it already has.
The concrete Slider/Check Box/Combo Box event contracts are the next roadmap page. This architecture remains the same even if a particular setter does or does not broadcast.
Apply is a transaction-shaped boundary, not an atomic commit¶
Apply should have one visible result, even when several services participate. That is a UI and coordination guarantee, not an engine-level database transaction: Unreal does not provide one atomic commit or rollback spanning game-user settings, audio, input mappings, and SaveGame data. The coordinator therefore owns the order of operations, the failure policy, and any recovery from a partial apply.
1. Validate and normalize¶
Before changing runtime state:
- clamp numeric ranges;
- reject unsupported/empty selections;
- resolve key conflicts according to one policy;
- ensure the correct Local Player owns per-player settings;
- confirm the draft was built against the current profile/user; and
- calculate which owner groups are actually dirty.
If validation fails, keep the draft, focus the first invalid control, and do not partially apply unrelated fields merely because they were easier.
2. Apply in a deliberate order¶
A practical order is:
- apply non-destructive local previews/settings;
- apply Enhanced Input changes for the correct Local Player;
- apply audio/accessibility services;
- apply display/video changes through their confirmation/rollback path; and
- persist each dirty owner.
The exact order is project policy. What matters is that it is explicit and reversible. A resolution change that can blank the display should not happen before the screen has captured a known-good mode and armed a confirmation timeout.
3. Wait for persistence results¶
Some settings APIs save synchronously; SaveGame and Enhanced Input also offer asynchronous paths. An async request is not success merely because its start node executed.
While saving:
- disable duplicate Apply requests or attach a request generation ID;
- keep the draft and original snapshot alive;
- show a pending state;
- ignore stale callbacks from an older screen/profile request; and
- update the clean snapshot only for the state that actually completed.
On failure, say which owner failed and keep the relevant group dirty. Do not show "Saved" and discard the draft.
UGameUserSettings::ApplySettings also saves its settings as part of that
operation in the current API. Keep that fact behind the coordinator instead of
having the widget call Apply and then invent a second save sequence.
Cancel means undo previews, then discard¶
If controls only edited Draft, Cancel is simple:
Cancel
-> discard Draft
-> close screen
If the screen previewed audio, brightness, camera/input feel, or another live value:
Cancel
-> coordinator restores every previewed owner from AppliedSnapshot
-> verify restoration
-> discard Draft
-> close screen
Do not reload persisted storage as the default Cancel implementation. Persisted may be older than the runtime state that was applied before the screen opened, and an async load adds another race. The open snapshot is the correct rollback target.
If a preview cannot be reversed reliably, do not preview it. Mark it as "applies after Apply" instead.
Defaults should propose, not commit¶
"Defaults" has at least three possible meanings:
- engine/device recommended values;
- project-authored defaults; or
- the values currently active on this machine.
Name the policy. ResetToCurrentSettings on UGameUserSettings means current
system settings, not factory defaults.
The beginner-safe button behavior is:
Defaults clicked
-> Draft = ProjectDefaultDraftForThisPlatformAndProfile
-> repaint controls
-> Dirty = Draft != AppliedSnapshot
-> wait for Apply or Cancel
Do not call Apply or Save from Defaults unless the interface explicitly says "Reset and apply now" and offers confirmation.
Loading, missing data, and migration¶
Opening an options screen can fail before the first edit:
- no SaveGame slot exists yet;
- a slot load fails or returns the wrong class;
- an older save lacks a newly added field;
- a Local Player or Enhanced Input settings object is not ready;
- platform restrictions force a different display mode; or
- a value is outside today's supported range.
Use a versioned normalization step:
Load available owner state
-> fill missing fields from current project defaults
-> migrate renamed/range-changed fields
-> clamp to platform-supported values
-> build one valid AppliedSnapshot
-> only then populate controls
A missing first-run slot is not an error; create defaults and mark whether they need a first save. A real read/corruption/platform failure should be visible and should not silently overwrite the old slot before the player can retry or choose Reset.
PIE, packaged builds, and local users¶
PIE is useful for widget flow, but it is not the final proof for settings:
- each PIE instance has its own GameInstance and play World;
- display mode/resolution behavior is constrained by the editor window;
- platform user indices and storage paths may not behave like a packaged game;
- several PIE clients can accidentally exercise the same test profile/slot if your project hard-codes it; and
- command-line/device overrides can beat stored GameUserSettings values.
Verify load, Apply, Cancel, restart persistence, bad-display rollback, and per-local-player input in Standalone and a packaged Development build before shipping the screen.
For split screen, pass the initiating Local Player into the coordinator. Never silently fall back to player index 0 for key mappings or per-player accessibility/input values.
Multiplayer: local preference versus game rule¶
Graphics, local audio mix, UI scale, subtitles, and input mappings should stay on the machine/local player that uses them. A dedicated server does not need a client's window mode or music volume.
If an option changes authoritative simulation—difficulty, aim assistance that affects validated shots, friendly fire, match time—then it is a game rule: send a request, validate it on the server or lobby owner, store it in the appropriate replicated framework object, and let the settings screen present that result. Do not smuggle it into a local SaveGame and expect other machines to agree.
When the screen fails (and what failure should do)¶
| Symptom | Likely design error | Correct response |
|---|---|---|
| Cancel leaves changed audio/input | Preview wrote Applied state without an open snapshot/restoration path | Capture before preview and restore through the same owner |
| Apply closes but restart loses values | Runtime application was mistaken for persistence, or async save result was ignored | Keep dirty/pending state until the owning save reports completion |
| Controls change while opening | Programmatic initialization flowed through user handlers | Use one initialization phase and value comparison |
| Defaults immediately overwrite settings | Defaults called owner setters/save directly | Put defaults into Draft only |
| Split-screen player two changes player one's keys | Coordinator used global/player-zero lookup | Pass and retain the initiating Local Player |
| One owner saves and another fails | No transaction/result model across services | Report per-owner results, retain the failed group as dirty, offer retry/rollback |
| PIE works but packaged display/storage does not | Editor behavior was treated as platform proof | Run Standalone/package persistence and rollback checks |
Failure should preserve recoverable user intent. Keep the draft on validation or save failure, show a specific message, and never replace a known-good persisted configuration with an unchecked partial default.
Lookalikes — which owner do I want?¶
| Surface | Use it for | It does not give you |
|---|---|---|
| Options draft/model | Temporary edits, validation, dirty state, preview rollback | Persistence or gameplay authority |
| UGameUserSettings | Global engine/device user settings and config-backed custom fields | Per-local-player mappings or automatic project audio application |
| Enhanced Input User Settings | One Local Player's player-mappable input preferences | Shared asset editing or network replication |
| SaveGame | Project/profile values across launches | Automatic runtime application |
| GameInstance / subsystem | Travel-persistent runtime coordination | Disk persistence or replication |
| Data Asset / Developer Settings | Authored defaults and project policy | Per-user mutable saved state |
| GameState / PlayerState / replicated component | Authoritative or public multiplayer gameplay state | Local display/audio/input preferences |
Going deeper¶
- Game Instance — runtime lifetime across map travel.
- Save Game: Create / Save / Load Game to Slot — manual copy-in/copy-out and slot failure.
- Does Save Game Exist / Delete / Async Save — completion, failure, and overlapping request gates.
- Set Input Mode & Show Mouse Cursor and Set Keyboard Focus / Set User Focus / Set Is Enabled — the menu shell around the settings model.
- Updating a Widget: Bindings vs Events — event-driven presentation instead of hidden polling.
- Engine source (requires engine access; we do not reproduce it):
UGameUserSettings::LoadSettings,ApplySettings,SaveSettings, andResetToCurrentSettingsinEngine/Source/Runtime/Engine/Classes/GameFramework/GameUserSettings.hand implementation inEngine/Source/Runtime/Engine/Private/GameUserSettings.cpp;UEnhancedInputUserSettings::LoadOrCreateSettings,ApplySettings,SaveSettings, andAsyncSaveSettingsinEngine/Plugins/EnhancedInput/Source/EnhancedInput/Public/UserSettings/EnhancedInputUserSettings.handEngine/Plugins/EnhancedInput/Source/EnhancedInput/Private/UserSettings/EnhancedInputUserSettings.cpp;UGameInstance::InitandShutdowninEngine/Source/Runtime/Engine/Classes/Engine/GameInstance.handEngine/Source/Runtime/Engine/Private/GameInstance.cpp; andUGameplayStatics::SaveGameToSlotandAsyncSaveGameToSlotinEngine/Source/Runtime/Engine/Private/GameplayStatics.cpp.