Designing Deterministic Value Resolution for Competing Gameplay Systems in Unreal Engine
VRS, the Value Resolution System, grew out of DAS, an Unreal Engine plugin for abilities and effects 2026-10-11 15:41:46 Author: hackernoon.com(查看原文) 阅读量:8 收藏

VRS, the Value Resolution System, grew out of DAS, an Unreal Engine plugin for abilities and effects in the spirit of Unreal's Gameplay Ability System (GAS). While developing DAS and building parkour gameplay on top of it, I encountered a separate problem: managing abilities and effects was not enough; I also needed to coordinate changes to the values those systems used. As I developed that value-resolution functionality, I realized it should become a separate plugin. That plugin became VRS.

This origin shaped VRS's scope: its subject is not a particular effect, such as a buff, but the interaction between systems that influence the same gameplay value.

Consider a character whose movement speed depends on equipment, terrain, and scripted restrictions. Each system has a contribution to make. If each writes the field independently, however, the final speed can depend on which writer ran last and what it assumed about the previous value.

VRS gives those contributions a shared calculation boundary. The interesting part of its architecture is how it divides responsibility: the object stores the value, one resolver owns its calculation, and multiple gameplay systems provide inputs. This article follows those decisions, their consequences, and the responsibilities they leave with the game.

The architecture before the API

A registered value in VRS connects an existing object member to a typed aggregator inside a resolver. The aggregator holds the base value and organizes changes into layers. Gameplay systems submit changes; an explicit Flush() evaluates them and writes the result back to the member.

This separation makes two questions distinct: who supplies an input, and who decides the result? An equipment system need not know which terrain effect is active. Both must nevertheless agree on the value they address and the rules under which their contributions interact.

Layers have project-defined meanings. Equipment, terrain, and control restrictions are conventions used in this article, not built-in VRS categories.

Keep storage separate from calculation ownership

VRS describes an existing field rather than requiring that field to move into a dedicated attribute container. A value advert identifies the field through typed C++ member pointers. TValueAdvert describes a direct member of an object; TInnerValueAdvert reaches a value inside a struct member, such as Character.Stats.MoveSpeed. After defining a UObject-derived AMyCharacter with an accessible float MoveSpeed member, its shared descriptor can be declared as:

inline TValueAdvert<AMyCharacter, float> MoveSpeedAdvert(
    TEXT("MoveSpeed"), &AMyCharacter::MoveSpeed);

The descriptor identifies the member; registration supplies the particular object. This lets the gameplay object retain its storage while VRS coordinates calculation.

Figure 1. The shared descriptor identifies a member of the class. Declaring it does not register a particular character or calculate a value.Figure 1. The shared descriptor identifies a member of the class. Declaring it does not register a particular character or calculate a value.

Register one calculation owner

The manager allows one resolver to claim a particular descriptor instance and target object. This prevents two resolvers from independently calculating and overwriting the same registered value using different bases.

One calculation owner therefore does not mean one contributor. It establishes the place where contributors meet.

The guarantee is cooperative, not memory protection. A second descriptor for the same physical member creates a different identity, and direct C++ writes are not intercepted. Callers must share the intended descriptor and avoid competing writes to the managed field.

Figure 2. Simplified registration flow. The resolver creates its aggregator after the ownership check succeeds. The base is 600 in this example; its source and the compile-time storage bounds are supplied by the caller.Figure 2. Simplified registration flow. The resolver creates its aggregator after the ownership check succeeds. The base is 600 in this example; its source and the compile-time storage bounds are supplied by the caller.

Turn execution order into a visible rule

A submitted change includes an operation, operand, priority, retention flags, and context identifying its layer and source. Within each evaluation pass, VRS processes layers in index order. Inside a layer, the comparator first checks priority. If priorities match, it compares operations; only if both match does it compare ChangeNum.

Within the same layer and priority, operations are ordered as Set, Add, Sub, Mul, then Div. Changes with matching priority and operation are ordered by ascending ChangeNum. Higher-priority ordinary changes execute later within their layer; they do not automatically cancel earlier ones.

For example, start with a speed base of 600. Put an equipment contribution of +100 in an earlier layer and terrain's x0.5 in a later one. The result is 350. Reverse those layers and the result is 400:

(600 + 100) x 0.5 = 350

600 x 0.5 + 100 = 400

The arithmetic is simple; the contract is the important part. A project must decide whether a bonus should be scaled by terrain. VRS makes that choice expressible through ordering rather than treating the inputs as interchangeable.

Figure 3. Contributions are stored in their selected layers before Flush() evaluates the registered value.Figure 3. Contributions are stored in their selected layers before Flush() evaluates the registered value.

Separate lasting changes from ongoing conditions

VRS makes a second distinction: a change to the base versus a contribution evaluated over that base.

An aggregator initializes its base from the object, class defaults, or supplied settings. Instant changes update that base and retire. Retained Modifier changes are evaluated over a fresh copy of it:

// Evaluation pseudocode, not the public API.

ApplyInstantChanges(BaseValue);

Clamp(BaseValue);

ResolvedValue = BaseValue;

ApplyModifiers(ResolvedValue);

Clamp(ResolvedValue);

All Instant changes across the layers run before the modifier pass. Because each flush rebuilds the resolved value from the current base and retained modifiers, removing a modifier requires no inverse operation.

Figure 4. One flush using the four modifiers from Figure 3 plus an Instant +20 at layer 0, priority 0: the base becomes 620, equipment and terrain produce 360, and Run mode sets the result to 500. The comparator diagram shows conditional tie-breaking.Figure 4. One flush using the four modifiers from Figure 3 plus an Instant +20 at layer 0, priority 0: the base becomes 620, equipment and terrain produce 360, and Run mode sets the result to 500. The comparator diagram shows conditional tie-breaking.

Flags and value bounds

Instant and Modifier define how long a contribution participates. Exclusive is an additional flag for modifiers: only the last exclusive candidate in a layer's sorted order applies, while the others remain stored.

In the illustrated final layer, Walk mode proposes Set 200 at priority 10 and Run mode proposes Set 500 at priority 20. Run mode wins because of its priority, not because 500 is larger. Removing it lets Walk mode produce 200 on the next flush. Ordinary modifiers still participate; this example has no ordinary operations after the final override.

Clamping is a separate value setting, not a change flag. Optional bounds supplied at registration are applied to the base after the Instant pass and to the result after the modifier pass, for types that support comparison. The illustrated calculations use no effective clamps.

The following illustrative sequence follows one registered value through layered contributions, a final stun override, and a base update. It assumes successful submissions, sufficient capacity, no effective clamps, and a flush after each action.

In Table 1, L means layer index and P means priority. Equipment uses L0/P0, terrain L1/P0, and the exclusive stun L2/P10. The Instant upgrade uses L0/P0 in the earlier Instant pass. Each applied-input cell lists the modifier evaluation order explicitly.

Action

Base

Stored modifiers

Applied modifiers, in order

Result

Register the value

600

None

None

600

Add equipment

600

Equipment

L0/P0: Add 100

700

Enter slowing terrain

600

Equipment, terrain

L0/P0: Add 100
L1/P0: Mul 0.5

350

Add stun

600

Equipment, terrain, stun

L0/P0: Add 100
L1/P0: Mul 0.5
L2/P10: Set 0

0

Remove stun

600

Equipment, terrain

L0/P0: Add 100
L1/P0: Mul 0.5

350

Apply an Instant +20 upgrade

620

Equipment, terrain

L0/P0: Add 100
L1/P0: Mul 0.5

360

Remove equipment

620

Terrain

L1/P0: Mul 0.5

310

Table 1. A final stun override temporarily sets the result to zero without removing equipment or terrain. The Instant upgrade changes the base rather than becoming another retained modifier.

The final step produces 620 x 0.5 = 310, not 360 - 100. Recomputed buffs fit here, but they are one consequence of the wider ownership and contribution model.

Observe inputs as well as outputs

For a separate diagnostic example, suppose a cutscene restriction and a stun both propose exclusive Set 0 in the final layer, with the cutscene at higher priority. Removing the cutscene changes the stored inputs without changing the resolved speed: the stun still produces zero. An output-only notification cannot explain that transition.

Outside Shipping builds, VRS records additions and removals of modifiers, applied Instant changes, and changes to resolved values. These lifecycle records complement value-change notifications. They distinguish “a source withdrew its contribution” from “the field received a different result.”

The current JSON trace is not a complete replay or explanation of the calculation. It does not preserve every intermediate result, priority, sequence number, or suppression reason. That limitation matters when investigating why a candidate won: the trace supplies some evidence, not a full reconstruction.

For the general Flush(), changed identities are collected during evaluation and public notifications are broadcast afterward. A guard rejects reentrant flushes. This separates public callbacks from aggregator-map traversal, but does not turn callbacks into a deferred-work scheduler.

Where the game's responsibility begins

Centralizing calculation does not centralize the entire gameplay lifecycle. The game still decides when to flush, what layers mean, when effects expire, and when their contributions should be removed.

Source association supports explicit grouped removal through RemoveChangesBySource; destroying a source does not automatically withdraw its inputs. Weak target references prevent evaluation from writing through a dead target, with stale-registration cleanup initiated periodically by the manager. Source lifetime and target lifetime solve different problems.

Integration also requires handling rejected submissions. The maximum number of changes per layer and the layer count are chosen through compile-time template parameters when registering a value. A layer can reject additional changes when its configured capacity is reached. Batches are best-effort rather than transactional: partial success is possible and submitted batches are cleared. The implementation assumes game-thread use, retains explicit descriptor and resolver lifetime contracts, and does not eliminate allocations or establish dependencies between different managed values.

VRS calculates values from submitted contributions. The surrounding game systems remain responsible for ability lifecycles, effect durations, and any network replication or prediction.

What this design makes explicit

VRS is a case study in separating responsibilities that can otherwise become entangled in shared-field writes:

  • Storage ownership and calculation ownership need not be the same responsibility.
  • Contributions need an explicit ordering contract, not just arithmetic operations.
  • A retained input can remain meaningful while another input suppresses it.
  • Changes to inputs and changes to outputs are different diagnostic events.

Those distinctions are useful even without this plugin. In VRS, they connect field registration, competing proposals, persistent base changes, and observation into one model. The result is not a replacement for gameplay policy, but a defined place where that policy's contributions can meet.


文章来源: https://hackernoon.com/designing-deterministic-value-resolution-for-competing-gameplay-systems-in-unreal-engine?source=rss
如有侵权请联系:admin#unsafe.sh