Skip to content

Ability Kits

Summary: An Ability Kit (UEternalAbilitySet) is the unit of ability composition — a data asset bundling abilities (with input mapping, activation policy, and level) plus passive gameplay effects, granted to an ASC atomically and revoked in one call. Kits carry the non-weapon ability layers (crafted skills, chanting). A character's "starting kit" is just an authored kit asset. This document explains the kit data model, the server-authoritative grant path, the three-layer grant order, and the possession-race latch that grants each kit exactly once per session.


Why Kits?

Before kits, startup abilities were a flat per-ability list on the character class. Kits replace that with a composable unit so that:

  • Composition is data, not code: a build is authored by stacking kit assets, no C++ per loadout.
  • Grant/revoke is atomic: one GiveAbilitySet grants every ability + passive; one RemoveAbilitySet (via the returned handle bundle) tears the whole kit down.
  • The same ability sits on different inputs in different builds: a kit entry can override the ability's default input slot.
  • Layers stay separated by ownership: kits own crafted/chanting abilities; weapon abilities stay on equipment (FAbilityFragment); Echo Mods stay on Remnant item modifiers. Each source grants through its own path onto the same ASC.

UEternalAbilitySet is the project's analog of Lyra's AbilitySet.

Kits are an authoring/preset construct, not a player-facing progression system. The GDD has no levels, no classes, and no chosen-abilities menu — every ability a player has is a consequence of what they wear (§2.4b.2). The four ability-layer names are gear-anchored (ruling 2026-07-09): Weapon = weapon skills on the equipment itself; Crafted = belt/neck equipables (vials, charms) with predefined slot counts; Echo = echo mods on Remnant items; Chanting = glyphwall stone-plate auras (no skill slots). A kit is how the authoring/preset pipeline bundles the Crafted + Chanting grants a given build's gear implies — the player never picks a kit.


Data Model

UEternalAbilitySet (UPrimaryDataAsset, PrimaryAssetType "AbilitySet")
  |  scanned from /Game/DataAssets/Kits
  |
  +-- Abilities : TArray<FEternalGrantedAbility>
  |       +-- Ability          : TSubclassOf<UEternalAbility>
  |       +-- Level            : int32 (default 1)
  |       +-- InputTagOverride : FGameplayTag   (Input.*)
  |       +-- ActivationPolicy : OnInput | WhileInputHeld | OnSpawn
  |       +-- RoleTag          : FGameplayTag   (Kit.Role.*, authoring metadata only)
  |
  +-- GrantedEffects : TArray<TSubclassOf<UGameplayEffect>>   (passives, infinite-duration)

FEternalGrantedAbility

Field Purpose
Ability The ability class to grant.
Level Ability level passed to the spec (ClampMin = 1).
InputTagOverride When set, replaces the ability CDO's StartupInputTag for this grant only — the kit decides the slot, so one ability can bind to different inputs in different builds. When unset, the CDO's StartupInputTag is used.
ActivationPolicy OnInput / WhileInputHeld bind to the input slot and activate on press / held routing. OnSpawn auto-activates the moment it is granted (passives, auras) — the generalized form of per-ability bAutoActivateOnGrant.
RoleTag Kit-sheet role (Kit.Role.GapFiller / Payoff / Enabler). Authoring metadata only — no runtime meaning. Consumed by the Kit Composer sheet, never read during grant.

GrantedEffects

Passive gameplay effects applied while the kit is granted. Infinite-duration GEs are expected; each is applied to self and its handle is tracked so RemoveAbilitySet can remove it.

Granted-Handle Bundle

FEternalAbilitySetGrantedHandles is the server-side receipt for one grant — a TArray<FGameplayAbilitySpecHandle> (abilities) plus a TArray<FActiveGameplayEffectHandle> (passives). It is never replicated (specs/effects replicate down through GAS on their own); it exists purely so the whole kit can be revoked in one call. Callers that intend to revoke later pass a handle bundle into GiveAbilitySet.


Grant & Revoke

UEternalAbilitySystemComponent::GiveAbilitySet(AbilitySet, OutHandles?)
    |
    +-- authority gate: IsOwnerActorAuthoritative()? no -> log + return (no-op)
    |
    +-- for each Abilities entry:
    |     EffectiveInputTag = InputTagOverride.IsValid() ? InputTagOverride : CDO.StartupInputTag
    |     if EffectiveInputTag valid -> UnbindInputTagFromExistingSpecs(EffectiveInputTag)   (last-granted-wins)
    |     GiveAbility(spec)  [SourceObject = the kit asset]
    |     if InputTagOverride -> swap CDO tag off the live spec, add the override tag
    |     if ActivationPolicy == OnSpawn -> TryActivateAbility(handle)
    |     record handle in OutHandles
    |
    +-- for each GrantedEffects entry:
          MakeOutgoingSpec + ApplyGameplayEffectSpecToSelf -> record effect handle

RemoveAbilitySet(GrantedHandles) is likewise authority-gated: it ClearAbility on each ability handle, RemoveActiveGameplayEffect on each effect handle, then resets the bundle.

Server Authority

Both GiveAbilitySet and RemoveAbilitySet require IsOwnerActorAuthoritative(). Called on a client, they log a warning and no-op — the server owns every grant, and GAS replicates the resulting specs and active effects down to the owning client. There is no client-request path; grants originate from server-side spawn/possession logic only.

Input Slot Lanes

Input.Ability.Slot1Slot6 are not six interchangeable ability slots. USkillbarController hard-maps each tag to a specific viewmodel, so the tag an ability claims decides which widget it renders into:

Tag Key Viewmodel What belongs here
Input.Ability.Slot1 1 Consumable1SlotViewModel Consumables (flask)
Input.Ability.Slot2 2 Consumable2SlotViewModel Consumables
Input.Ability.Slot3 Q Hotbar1SlotViewModel Skills
Input.Ability.Slot4 E Hotbar2SlotViewModel Skills
Input.Ability.Slot5 R Hotbar3SlotViewModel Skills
Input.Ability.Slot6 V Hotbar4SlotViewModel Skills

A skill authored on Slot1 or Slot2 is granted, input-bound, and invisible on the skill hotbar — it renders into a consumable viewmodel and fights whatever flask ability also claims that lane. This is not a validator error today; it presents as "the ability exists in the debug view but the skillbar is empty", and it is what GA_CrashLanding did until 2026-07-08.

The four skill keys are a single shared pool, by convention rather than by code:

  • Weapon skills author Slot3 by default (TPL_Skill, GA_Slash, GA_RisingMace); GA_ShieldSurge takes Slot4.
  • Kit abilities and crafted abilities also draw from Slot3Slot6. A kit's InputTagOverride picks whichever key is free for that preset — e.g. greatswords carry no weapon skill, so Slot3 is free for the kit in the GreatswordMomentum preset.
  • Last-granted-wins (below) is the conflict resolver, not the allocator.

Only four skill keys exist. A build carrying a weapon skill, a kit skill, and three crafted abilities runs out. That exhaustion — not designer preference — is the trigger for revisiting a player-assignable skillbar; until then there is no player-facing slot assignment.

Input-Slot Collision — Last-Granted-Wins

Two kits (or a kit and an item) can claim the same input slot. UnbindInputTagFromExistingSpecs runs before each bind and strips the tag from any earlier spec already bound to it, so the most recently granted ability owns the slot. The loser is not removed — it stays granted (and can still be activated by other means or re-bound), it is only unbound from that input. Because grant order is deterministic (see below), the winner is predictable.

This is a runtime resolution, not a guard: the compile-time guard against shipping a build with a silent collision is the Kit Composer validator (see Authoring).


Three-Layer Grant Order

A live character's abilities come from three sources, granted in a fixed order:

1. Class starter kit   (CharacterClassInfo.StartupKit)   GiveStartupAbilities
2. Preset kits         (PresetCharacterAsset.GrantedKits) GiveComposedKits
3. Item abilities      (equipment FAbilityFragment, Remnant Echo Mods)

Because input-slot collisions resolve last-granted-wins, this order defines precedence: a preset kit overrides a class default on the same slot; an item overrides both. Layer 3 is the equipment/Remnant apply path, documented under Equipment and Remnants.

Source Asset field Granted by Notes
Class starter kit FCharacterClassDefaultInfo::StartupKit (UEternalAbilitySet) GiveStartupAbilities One kit per class row; replaced the legacy per-ability StartupAbilities list.
Preset kits UPresetCharacterAsset::GrantedKits (TArray<TSoftObjectPtr<UEternalAbilitySet>>) GiveComposedKits Composed onto a curated build. Deliberately asset-level rather than inside the save payload, so kits need no save-version migration. Loaded synchronously (small assets, once at spawn).
Item abilities Equipment / Remnant modifier grants Equipment apply loop Outside kit scope; documented with the item systems.

Possession-Race Latch (Grant Exactly Once)

The ASC lives on the persistent AEternalPlayerState, so it outlives pawn death/respawn. Two facts drive the latch design:

  • A pawn respawn re-runs InitAbilityActorInfo on the same ASC — kits must not be granted a second time.
  • Possession and the persistence provider resolve in either order, depending on the boot path:
  • PIE "Play Preset" relaunch possesses the pawn before the controller's provider resolves ActivePreset.
  • A dedicated-server join resolves the provider before possession.

Two independent latch flags on the ASC solve both:

Flag Set by Guards
bStartupKitsGranted AEternalCharacter::InitAbilityActorInfo (once, on authority) Class starter kit + the first composed-kit attempt. Also the "possession already ran" signal the persistence hook reads.
bComposedKitsGranted GiveComposedKits itself, after the grants run Preset kits. Self-latches only when it actually granted, so whichever side resolves last performs the grant.

Flow

InitAbilityActorInfo (authority, first run for this ASC):
    if !bStartupKitsGranted:
        GiveStartupAbilities(class kit)
        GiveComposedKits(controller)      // no-ops if ActivePreset not resolved yet
        bStartupKitsGranted = true

PersistenceComponent::InitializeProvider (when the preset resolves):
    if controller has authority AND ASC.bStartupKitsGranted:   // possession already happened
        GiveComposedKits(controller)      // grants now; the InitAbilityActorInfo attempt no-op'd

GiveComposedKits(controller, ASC):
    if ASC.bComposedKitsGranted OR no authority -> return
    if no ActivePreset -> return WITHOUT latching   // provider not resolved; retried later
    grant each preset kit
    bComposedKitsGranted = true                     // latch AFTER granting; empty preset still latches

The key invariant: GiveComposedKits only latches when it had a resolved preset to act on. If the provider hasn't resolved, it returns without latching so the other side retries. A preset with zero kits still latches — there is nothing left to grant. This makes the composed-kit grant fire exactly once regardless of which of possession / provider-init wins the race.


Authoring Kits

Designers build kits with the Kit Composer editor tab, not by hand-editing the asset. See Ability Authoring Tools for the composer workflow.

Every kit and preset kit-list is checked by KitValidation — shared checks consumed by both the on-save / -run=DataValidation validator (commandlet available; not yet invoked by CI — B-1/B-2 in WorldSystemsScalingReview.plan.md) and the composer's inline validation strips. The checks that matter for a correct grant:

Check Severity Why
Null ability / granted-effect entry Error Would silently skip at grant time.
OnInput / WhileInputHeld with no effective input tag Error Bound-input policy with nothing to bind to never activates.
OnSpawn entry that still claims an input slot Error Auto-activate abilities should not occupy a bindable slot.
Duplicate input slot within a kit Error The later entry silently unbinds the earlier at grant time.
Non-positive level / empty kit Error Malformed asset.
Cross-kit input collision between two preset kits Error A colliding build must never ship silently.
A preset kit claiming a class-starter-kit slot Warning Replacing a class default is last-granted-wins working as intended — but it should be a visible choice, not an accident.

Source Reference

Symbol Location
UEternalAbilitySet, FEternalGrantedAbility, FEternalAbilitySetGrantedHandles, EEternalAbilityActivationPolicy Source/ProjectEternal/Public/AbilitySystem/Data/EternalAbilitySet.h
Slot-tag → viewmodel mapping Source/ProjectEternal/Private/UI/Controllers/SkillbarController.cppRefreshSlotBindings
GiveAbilitySet / RemoveAbilitySet / UnbindInputTagFromExistingSpecs Source/ProjectEternal/Private/AbilitySystem/EternalAbilitySystemComponent.cpp
GiveStartupAbilities / GiveComposedKits Source/ProjectEternal/Private/Utils/EternalAbilitySystemLibrary.cpp
FCharacterClassDefaultInfo::StartupKit Source/ProjectEternal/Public/AbilitySystem/Data/CharacterClassInfo.h
UPresetCharacterAsset::GrantedKits Source/ProjectEternal/Public/Persistence/Presets/PresetCharacterAsset.h
Startup latch (bStartupKitsGranted) Source/ProjectEternal/Private/Character/EternalCharacter.cppInitAbilityActorInfo
Provider-side composed-kit grant Source/ProjectEternal/Private/Persistence/Components/PersistenceComponent.cppInitializeProvider
Kit validation checks Source/ProjectEternalEditor/Public/Validation/KitValidationCore.h


Recent Changes

Date Change Impact
2026-07-08 Input slot lanes documented Slot1/Slot2 are the consumable lanes (keys 1/2); skills live on Slot3Slot6 (Q/E/R/V), shared between weapon, kit, and crafted abilities. A skill on a consumable lane binds but never reaches the skill hotbar
2026-07-07 Ability Kit system documented UEternalAbilitySet as the composition unit; three-layer grant order (class → preset → items); last-granted-wins input collision; two-flag possession-race latch (bStartupKitsGranted / bComposedKitsGranted) grants each kit exactly once per session