Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

In this book you will find two implementations of a seemingly simple game mechanic - guns left on the ground should become rusty, thus receiving a brown tint and shooting slower.

The first implementation will be in the form of pseudocode for a project called Unreal Engine Lyra. Where the titans of industry show us regular folk how it’s done.

The second implementation will be in Bevy. I will show how Bevy’s ECS is deeply conductive to good programming patterns. During this showcase, I will also make clear the short-comings that Lyra imposes upon the developer due to its Object-Oriented Programming (OOP) paradigm.

Rusty Items

First thing first. Let’s discuss in detail the mechanics we’re going to build.

Three states of an item

An item should occupy one of three mutually exclusive states:

  • Ground: present in the world and can be picked up by the player.
  • Stored: held in an inventory and represented by an inventory slot.
  • Equipped: actively held and used by the player.
flowchart LR
    Ground["<div style='text-align: left'>Ground<br/>Ammo: 6<br/>Shooting speed: 1.0</div>"]
    Stored["<div style='text-align: left'>Stored<br/>Ammo: 6<br/>Shooting speed: 1.0</div>"]
    Equipped["<div style='text-align: left'>Equipped<br/>Ammo: 6<br/>Shooting speed: 1.0</div>"]

    Ground -->|Pick up| Stored
    Stored -->|Equip| Equipped
    Equipped -->|Drop| Ground

One item, three representations. Ammunition and condition stay with the item.

We want to allow the same item to move between these three states any number of times. The amount of remaining ammo, the condition of the item and any other properties accumulated during gameplay should be preserved between these states.

A character that spends half of the bullets of a gun should be able to drop it on the ground and pick it back up again with the exact same ammo count. Sounds simple enough!

Rusty and grounded seconds

An item becomes Rusty after spending five seconds on the ground. Each item has to know that it lies on the ground. It should also count how many seconds have passed! After a total of five seconds on the ground, an item should become rusty. Which will be accompanied by the item turning brown and shooting slower when equipped.

Storing or equipping an item pauses the counter.

Rusty doubles an item’s cooldown and gives its ground mesh, equipped mesh, and inventory background a brown appearance.

flowchart LR
    Before["<div style='text-align: left'>Ground<br/>Ammo: 6<br/>Shooting speed: 1.0</div>"]
    After["<div style='text-align: left'>Ground<br/>Ammo: 6<br/><b>Shooting speed: <u>0.5</u></b></div>"]

    Before -->|5 seconds on the ground| After

    style After fill:#733814,color:#fff,stroke:#a66035

The following pages cover Lyra’s item system and implementing Rusty in Lyra.

Lyra’s item system

Lyra has a way to separate the nature of an item from how it is expressed during a state.

Shared item definitions

ULyraInventoryItemDefinition describes a specific item type, such as the Magnum pistol. The magnum in each player’s hand has a reference to this one definition.

class ULyraInventoryItemDefinition : public UObject
{
public:
    // Just a name like "ID_Magnum"
    FText DisplayName;
    
    // You can see fragments as simpler version of Bevy's Components
    TArray<TObjectPtr<ULyraInventoryItemFragment>> Fragments;
};

Each definition contains an array of ULyraInventoryItemFragment called Fragments. Despite having Inventory in their name, ULyraInventoryItemFragments are used everywhere to decide properties of the item in question. To illustrate this further, consider UInventoryFragment_PickupIcon below:

class UInventoryFragment_PickupIcon : public ULyraInventoryItemFragment
{
public:
    // The model of the gun
    TObjectPtr<USkeletalMesh> SkeletalMesh;
    
    // The color of the pad underneath
    FLinearColor PadColor;
};

When a gun is on the ground and ready to be picked up, it is displayed in the world using the properties defined in UInventoryFragment_PickupIcon as shown in Figure 1.

Figure 1: Weapon pickup showing the gun mesh and the colored pad beneath it.

Dynamic properties of an item

Item definition in Lyra can be seen as the recipe to create an item. ULyraInventoryItemInstance on the other hand is an expression of a concrete item in the world. Two different players can have the same gun equipped. The recipe, or the definition, of these two guns is going to be the same. But their Instance will be different.

class ULyraInventoryItemInstance : public UObject
{
public:
    // Dynamic aspects of a concrete item existing in the world
    FGameplayTagStackContainer StatTags;
    
    // A reference to the recipe of the item, shared between
    // all instances.
    TSubclassOf<ULyraInventoryItemDefinition> ItemDef;
};

Every item in the StatTags property holds a FName TagName and int32 StackCount. Rust equivalent of StatTags is something like HashMap<String, i32>.

For ammunition, the tag reads TagName = "Lyra.ShooterGame.Weapon.Ammo" with a count of 6 for our Magnum revolver. 5 after we take a shot and so on. Since the StatTags are persistent, that means that shooting a bullet from your revolver, dropping it on the ground and picking it back up again results in you still having 5 ammo instead of the original 6.

What inventory contains

An inventory holds the concrete items, not their recipes. If we carry two Magnums, the inventory holds two different ULyraInventoryItemInstance objects. Each Magnum has its own ammunition count.

FLyraInventoryList holds all the items in the inventory. Each entry contains an item instance and its quantity:

struct FLyraInventoryList : public FFastArraySerializer
{
public:
    // Reference to the player who owns this inventory
    TObjectPtr<UActorComponent> OwnerComponent;
    
    // The items themselves
    TArray<ULyraInventoryItemInstance> Entries;
};

— Human written above —

Equipped items

Having a Magnum in inventory does not put a gun model in the player’s hand or give them the ability to shoot it. Equipping handles that part. Lyra uses another recipe, ULyraEquipmentDefinition, to describe what to create when the item is equipped:

class ULyraEquipmentDefinition : public UObject
{
public:
    // The class of the runtime equipment object to create
    TSubclassOf<ULyraEquipmentInstance> InstanceType;

    // Actions to give the player, such as firing the gun
    TArray<TObjectPtr<const ULyraAbilitySet>> AbilitySetsToGrant;

    // Actors to spawn and attach, including the gun model
    TArray<FLyraEquipmentActorToSpawn> ActorsToSpawn;
};

How does the Magnum item definition know which equipment recipe to use? Through another fragment in its Fragments array:

class UInventoryFragment_EquippableItem : public ULyraInventoryItemFragment
{
public:
    // The equipment recipe for this item type
    TSubclassOf<ULyraEquipmentDefinition> EquipmentDefinition;
};

When the player selects the Magnum in the quick bar, Lyra reads this fragment and asks ULyraEquipmentManagerComponent to equip it:

class ULyraEquipmentManagerComponent : public UPawnComponent
{
public:
    // Create equipment from its recipe
    ULyraEquipmentInstance* EquipItem(
        TSubclassOf<ULyraEquipmentDefinition> EquipmentDefinition);

    // Remove the equipment, its actors, and its granted abilities
    void UnequipItem(ULyraEquipmentInstance* ItemInstance);
};

The result is a ULyraEquipmentInstance. This is not a replacement for the inventory item. It is another object that keeps track of the equipped representation. The quick bar sets its Instigator to the particular Magnum in our inventory:

class ULyraEquipmentInstance : public UObject
{
public:
    UObject* GetInstigator() const { return Instigator; }
    void SetInstigator(UObject* InInstigator) { Instigator = InInstigator; }

private:
    // In the quick-bar flow, points back to our inventory item
    TObjectPtr<UObject> Instigator;

    // The actors created for the equipped item
    TArray<TObjectPtr<AActor>> SpawnedActors;
};

If we fire a shot and then switch away from the Magnum, its equipment is removed, but its inventory instance stays. Equipping it again creates new equipment pointing back to the same item, which still has 5 bullets. In Lyra, an equipped gun is therefore still an inventory item.

Accessing the item from an ability

The firing ability needs to know which Magnum is being fired so it can use that gun’s ammunition and condition. ULyraGameplayAbility_FromEquipment provides the connection:

class ULyraGameplayAbility_FromEquipment : public ULyraGameplayAbility
{
public:
    // The equipment that granted this ability
    ULyraEquipmentInstance* GetAssociatedEquipment() const;

    // The inventory item referenced by that equipment's Instigator
    ULyraInventoryItemInstance* GetAssociatedItem() const;
};

For our Magnum, GetAssociatedItem() returns the instance holding its remaining ammunition. It does not return the shared definition or another Magnum that happens to be in the same inventory.

World pickups

The gun we see on the ground is a world actor, not the inventory data object itself. Its mesh and pad can use the pickup fragment shown in Figure 1. What the player receives when collecting it is described separately.

Lyra distinguishes a pickup that creates a new item from a recipe from one that carries an existing item:

struct FPickupTemplate
{
    // How many copies to create
    int32 StackCount = 1;

    // The recipe, such as the Magnum definition
    TSubclassOf<ULyraInventoryItemDefinition> ItemDef;
};

struct FPickupInstance
{
    // A particular item, with its existing ammunition and condition
    TObjectPtr<ULyraInventoryItemInstance> Item = nullptr;
};

struct FInventoryPickup
{
    // Existing items offered by this pickup
    TArray<FPickupInstance> Instances;

    // Recipes for fresh items offered by this pickup
    TArray<FPickupTemplate> Templates;
};

An actor or component uses IPickupable to expose what it offers. The pickup logic reads this list and adds its entries to the inventory:

class IPickupable
{
public:
    // What the player receives when collecting this pickup
    virtual FInventoryPickup GetPickupInventory() const = 0;
};

A fresh Magnum can come from a template. A Magnum we dropped with 5 bullets must keep its existing data instead of starting over from the recipe.

There is an important gap in Lyra 5.3: the inventory-list operation for adding an existing item instance is unfinished:

void FLyraInventoryList::AddEntry(ULyraInventoryItemInstance* Instance)
{
    unimplemented();
}

Keeping those 5 bullets through a drop and pickup is therefore not automatic in this version. We need to complete the existing-item transfer, or explicitly copy the item’s changing data into the new instance.

Rusty items in Unreal Engine Lyra

This page implements the Rusty mechanic.

ItemDefinition and Rustable fragment

Let’s say some of our items-in particular guns-should become worse after they spend an extended amount of time on the ground. To accomplish this, we need to create a fragment:

class UInventoryFragment_Rustable : public ULyraInventoryItemFragment
{
public:
    // Cumulative seconds on the ground before rust appears
    float RustAfterSecs = 5.0f;

    // Rust doubles the time between shots
    float CooldownMultiplier = 2.0f;

    // Brown tint for the gun and inventory background (sRGB)
    FColor RustColor = FColor(115, 56, 20);
};

UInventoryFragment_Rustable can now be a part of the ItemDefinition system that tells the game how an item’s Instance should be constructed. During gameplay we also want to add a Rusty tag to an item instance, but only after it has spent 5 seconds on the ground. Where does Rusty fit?

Rusty fits into the square hole!

That’s right. Much like every other tag, Rusty and it’s required counterpart GroundedSecs fit into the StatTags array that an ItemInstance contains. The reason why StatTags can be seen as a square hole that fits any shape is that fundamental parts of the gunplay like MagazineAmmo and RemainingMagazines also sit in StatTags alongside our newly added components. In fact every dynamic property of an ItemInstance is a part of String-to-Int nature of StatTags. This allows the system to support arbitrary functionalities of any item at the cost of type safety and code verbosity.

Rusty will be defined as:

UE_DEFINE_GAMEPLAY_TAG_STATIC(TAG_Rusty, "Item.Condition.Rusty");

As StatTags is the equivalent of HashMap<String, int32>, we will express a gun having rust as HashMap<"Item.Condition.Rusty", 1>.

GroundedSecs in turn is just:

UE_DEFINE_GAMEPLAY_TAG_STATIC(TAG_GroundedSecs, "Item.Stat.GroundedSecs");

The int32 part of the GroundedSecs tag counts seconds that the gun has spent lying on the ground. Once that counter reaches 5 or more - we switch the value of ItemCondition.Rusty to 1.

Rusty should affect gameplay

An item left too long on the ground should shoot slower and look worse. In technical terms, this means that multiple independent systems should be able to consume and react to Rusty. Preferably in a way that is generalizable and allows for other stat modifiers to be added later and work alongside all the old ones. For now let’s consider only the gameplay aspect.

First let’s create a GetEffectiveFireDelay function that would calculate the the effects various tags can have on the final cooldown:

double ULyraGameplayAbility_RangedWeapon::GetEffectiveFireDelay(
    double BaseDelaySeconds) const
{
    double FlatAdjustment = 0.0;
    double Multiplier = 1.0;

    if (const ULyraInventoryItemInstance* Item = GetAssociatedItem())
    {
        if (Item->HasStatTag(LyraGameplayTags::Item_Condition_Rusty))
        {
            if (const auto* Rust =
                Item->FindFragmentByClass<UInventoryFragment_Rustable>())
            {
                Multiplier *= FMath::Max(1.0f, Rust->CooldownMultiplier);
            }
        }
    }

    return (BaseDelaySeconds + FlatAdjustment) * Multiplier;

Various effects in our game can have both a Flat and a Mult consequence for the final timer until we can fire our next shot. The eagle-eyed ones might already see a potential issue; what if the game grows? What if there are 10 different conditions that affect an item’s performance? Something along the lines of:

double ULyraGameplayAbility_RangedWeapon::GetEffectiveFireDelay(
    double BaseDelaySeconds) const
{
    double FlatAdjustment = 0.0;
    double Multiplier = 1.0;

    if (const ULyraInventoryItemInstance* Item = GetAssociatedItem())
    {
        if (Item->HasStatTag(LyraGameplayTags::Item_Condition_Rusty))
        {
            if (const auto* Rust =
                Item->FindFragmentByClass<UInventoryFragment_Rustable>())
            {
                Multiplier *= Rust->CooldownMultiplier;
            }
        }

        if (Item->HasStatTag(LyraGameplayTags::Item_Condition_QualityRare))
        {
            if (const auto* Quality =
                Item->FindFragmentByClass<UInventoryFragment_QualityRare>())
            {
                Multiplier *= Quality->CooldownMultiplier;
            }
        }

        if (Item->HasStatTag(LyraGameplayTags::Item_Condition_TunedAction))
        {
            if (const auto* TunedAction =
                Item->FindFragmentByClass<UInventoryFragment_TunedAction>())
            {
                FlatAdjustment += TunedAction->CooldownAdjustmentSeconds;
            }
        }
        
        // ... + 7 more
    }

    return (BaseDelaySeconds + FlatAdjustment) * Multiplier;
}

We have now created an unwieldy beast of a function. Not to mention that our code is now littered with countless template definitions for various UInventoryFragment classes that we decided to create for our game.

TODO: next talk about implementation of cooldown impact and visual tinting of Rusted items

— Human written above —

Put rust configuration in a custom definition fragment: threshold, cooldown multiplier, and visual settings. Keep changing values in a custom per-item record associated with the inventory instance:

DataPurpose
GroundedSecs (float)Accumulated ground exposure.
bRusty (bool)Whether to apply rust’s gameplay and visual effects.
Location stateDetermines whether the ground counter should run.

These are custom additions, not built-in Lyra fields. Extend the pickup/drop flow to preserve this record or explicitly copy it between the world pickup and inventory instance. Creating an item from its definition alone would lose its accumulated exposure and rust.

While equipped, read the record through the equipment’s inventory-item reference rather than keeping a separate copy. Recreating an equipment actor must not reset the condition.

Update GroundedSecs

Use a custom pickup component’s server-side tick to add DeltaSeconds to GroundedSecs. Enable updates only while the item is in the ground state and not Rusty; disable them on pickup. Initialize the counter only for an item that has never been grounded, not on every pickup actor spawn.

Read the threshold from the definition fragment. When the counter reaches it, call a custom SetRusty(true) method to update bRusty, stop counter updates, and notify gameplay and presentation listeners.

Modify the firing cooldown

The equipment ability base class provides GetAssociatedItem(). Its actual implementation in Lyra 5.3 resolves the inventory item from the equipment’s instigator:

Source: Source/LyraGame/Equipment/LyraGameplayAbility_FromEquipment.cpp.

ULyraInventoryItemInstance* ULyraGameplayAbility_FromEquipment::GetAssociatedItem() const
{
    if (ULyraEquipmentInstance* Equipment = GetAssociatedEquipment())
    {
        return Cast<ULyraInventoryItemInstance>(Equipment->GetInstigator());
    }
    return nullptr;
}

Extend the firing ability’s cooldown/fire-delay calculation to read the custom rust record from this item. Handle a null result before accessing it. Use bRusty to include or exclude the configured rust multiplier in that item’s effective cooldown. The accessor above is built into Lyra; the rust record and cooldown changes are custom additions.

Keep the base cooldown unchanged and use a single rust contribution, rather than adding another modifier on every update or equip. Calculate it per item, not as a character-wide penalty that could also slow other weapons.

Update the three representations

Have pickup and equipment actors apply the configured material or material parameter, and have the inventory widget apply the configured background tint. Refresh them both when rust changes and when a representation is created, using the item’s current bRusty value. A one-time rust event would miss meshes or widgets created after the item became Rusty.

Replicate bRusty through the custom item state and refresh client views from its replication notification, so clients display the server’s rust decision.

Here be me whining.

Item states in Bevy 0.19

This page implements the Rusty mechanic.

Strongly-typed communication

An instance of an item can ever be in one of three states. It can be OnGround, StoredIn some container or EquippedBy some character. As an item moves between the systems, it needs to preserve dynamic properties such as RemainingAmmo as other tags that can be changed by the player’s behavior. In Lyra this is represented by three completely different systems sharing an ULyraInventoryItemInstance object between these systems.

In Bevy we have no need for weakly-typed contracts between independent systems. An item is an Entity. The properties of the item come not from a class but from the collection of Component’s that the item contains.

Expression of behavior via scenes

An item is just an entity with a specific SceneComponent on it such as Magnum or Rifle. The difference in behavior comes from the scene that the parent SceneComponent loads.

#![allow(unused)]
fn main() {
impl Magnum {
    pub fn scene() -> impl Scene {
        bsn! {
            Item {
                key: {ItemKey(MAGNUM_KEY.to_string())},
                label: {ItemLabel("Magnum".to_string())},
                footprint: {ItemFootprint(UVec2 { x: 4, y: 8 })}
            }
            Shootable { magazine_size: 6 }
            Cooldown::new(0.5)
            Ammo(12) // 2 Magazines
            Visibility
        }
    }
}
}

Here we define aspects such as the number of magazines an item has as well as gameplay aspects like Cooldown. Shootable is a special component that will later affect how the equipped item affects the player’s controls by injecting FireControl into the even processors (TODO: rewrite this).

State-based views

An item

Limitations: State Exclusivity

Currently the state transition is enforced via the functions defined in:

#![allow(unused)]
fn main() {
pub trait ItemCommandsExt {
    fn store_in(&mut self, entity: Entity) -> &mut Self;
    fn equip_for(&mut self, entity: Entity) -> &mut Self;
    fn drop(&mut self) -> &mut Self;
}
}

The implementation of each function is quite simple and only holds a few lines:

#![allow(unused)]
fn main() {
impl ItemCommandsExt for EntityCommands<'_> {
    fn store_in(&mut self, entity: Entity) -> &mut Self {
        // "We have mutually exclusive components at home"
        self.remove::<(OnGround, EquippedBy)>()
            .insert(StoredIn(entity))
    }
}

Bevy does not natively support mutually exclusive components. While attempts have been made, they’re yet to be merged to the main branch. The above function store_in has to make concessions and can therefore not represent a transition that we expect like:

{A, B, OnGround} -> {A, B, StoredIn(Entity)}

Instead, store_in produces behavior like: TODO: check if this is 100% true.

{A, B, OnGround} -> {A, B} -> {A, B, StoredIn(Entity)}

— Human written above —

Item definitions are scenes

Lyra needs a ULyraInventoryItemDefinition recipe with an array of fragments. In Bevy the recipe is a scene. The Magnum is described by the components a fresh Magnum entity should start with:

#![allow(unused)]
fn main() {
impl Magnum {
    pub fn scene() -> impl Scene {
        bsn! {
            Item {
                key: {ItemKey(MAGNUM_KEY.to_string())},
                label: {ItemLabel("Magnum".to_string())},
                footprint: {ItemFootprint(UVec2 { x: 4, y: 8 })}
            }
            Shootable { magazine_size: 6 }
            Cooldown::new(0.5)
            Ammo(12) // 2 Magazines
            Visibility
        }
    }
}
}

Spawning a Magnum on the ground is one scene plus the state it starts in:

#![allow(unused)]
fn main() {
commands.queue_spawn_scene(bsn! {
    @Magnum
    OnGround
    Transform { translation: Vec3::new(0.0, PLATFORM_TOP_Y + 0.075, -3.0) }
});
}

The Rifle is built the same way. It adds a Burst { shots: 3, interval: 0.1 } component, and that component alone is what makes it fire in bursts. There is no UInventoryFragment_Burst to look up and no base class to extend.

Item itself stays small. ItemKey identifies the item type, much like the definition class in Lyra, and is used to find shared data such as the item’s meshes. ItemLabel and ItemFootprint are the display name and the space it takes in an inventory.

Dynamic properties are components

Lyra keeps a gun’s changing data in StatTags, a HashMap<String, i32> on the item instance. The Bevy item keeps it in components on the entity:

#![allow(unused)]
fn main() {
#[derive(Component, Clone, Default)]
pub struct Ammo(pub u32);

#[derive(Component, Reflect, Clone, Default)]
#[reflect(Component)]
pub struct GroundedSecs(pub f32);
}

There is no difference between a “definition” property and an “instance” property. Shootable { magazine_size: 6 } and Ammo(12) sit next to each other on the same entity. Every Magnum gets its own copy of both, so two Magnums never share ammunition.

Each property has the right type. Ammo is a u32, GroundedSecs is an f32 and Rusty is a marker with no data at all. Compare this with Lyra, where grounded seconds had to be rounded into an int32 stack count and a boolean condition was written as a count of 1.

Three states as relationships

OnGround is a plain marker. EquippedBy and StoredIn are relationships. They point at the character holding the item and the inventory containing it:

#![allow(unused)]
fn main() {
#[derive(Component, Reflect, Default, Clone)]
#[reflect(Component)]
#[require(Transform)]
pub struct OnGround;

#[derive(Component, Reflect)]
#[reflect(Component)]
#[relationship(relationship_target = Equips)]
pub struct EquippedBy(pub Entity);

#[derive(Component, Reflect)]
#[reflect(Component)]
#[relationship_target(relationship = EquippedBy)]
pub struct Equips(Entity);

#[derive(Component, Reflect)]
#[reflect(Component)]
#[relationship(relationship_target = Stores)]
pub struct StoredIn(pub Entity);

#[derive(Component, Reflect, Default)]
#[reflect(Component)]
#[relationship_target(relationship = StoredIn)]
pub struct Stores(Vec<Entity>);
}

Bevy maintains the other side of each relationship for us. Inserting StoredIn(inventory) on a Magnum adds the Magnum to that inventory’s Stores list. Removing it, or despawning the Magnum, takes it out again. Equips holds a single Entity rather than a Vec, so a character can only equip one item at a time.

Keeping the states exclusive

An item must be in exactly one of the three states. Bevy 0.19 has no mutually exclusive components yet, so the three transitions are written as EntityCommands extensions. Each one removes the other two states before inserting the new one:

#![allow(unused)]
fn main() {
// "We have mutually exclusive components at home"
/// Ensures exclusivity in item states.
pub trait ItemCommandsExt {
    fn store_in(&mut self, entity: Entity) -> &mut Self;
    fn equip_for(&mut self, entity: Entity) -> &mut Self;
    fn drop(&mut self) -> &mut Self;
}

impl ItemCommandsExt for EntityCommands<'_> {
    fn store_in(&mut self, entity: Entity) -> &mut Self {
        self.remove::<(OnGround, EquippedBy)>()
            .insert(StoredIn(entity))
    }

    fn equip_for(&mut self, entity: Entity) -> &mut Self {
        self.remove::<(OnGround, StoredIn)>()
            .insert(EquippedBy(entity))
    }

    fn drop(&mut self) -> &mut Self {
        self.remove::<(EquippedBy, StoredIn)>().insert(OnGround)
    }
}
}

This is a convention, not a guarantee. Nothing stops a system from inserting OnGround directly on an equipped gun. Once mutually exclusive components land in Bevy, ItemPlugin can register the three states and let the engine enforce the rule:

#![allow(unused)]
fn main() {
app.world_mut()
    .register_mutually_exclusive_components::<(OnGround, EquippedBy, StoredIn)>();
}

Inventory

An inventory is an entity with an Inventory component. The player’s inventory is spawned as one of the player’s children:

#![allow(unused)]
fn main() {
commands
    .spawn((Camera3d::default(), Player::default(), /* ... */))
    .with_children(|parent| {
        // ...
        parent.spawn(Inventory);
    });
}

There is no FLyraInventoryList to keep in sync. The items in an inventory are whatever entities have StoredIn pointing at it, and Stores lists them. Two Magnums in the inventory are two entities with their own Ammo.

Equipped items

Equipping an item does not create a second object. equip_for swaps StoredIn for EquippedBy on the same entity:

#![allow(unused)]
fn main() {
fn on_select_magnum(
    select: On<Fire<SelectMagnum>>,
    items: Query<Entity, (With<StoredIn>, With<Magnum>)>,
    mut commands: Commands,
) {
    let Ok(item) = items.single() else {
        return;
    };

    commands.entity(item).equip_for(select.context);
}
}

Since Equips holds one entity, equipping the Magnum takes EquippedBy away from the gun the player was holding. An observer catches that removal and puts the old gun back into the inventory:

#![allow(unused)]
fn main() {
fn store_unequipped(
    remove: On<Remove, EquippedBy>,
    inventory: Single<Entity, With<Inventory>>,
    mut commands: Commands,
) {
    commands
        .entity(remove.entity)
        .try_insert(StoredIn(*inventory));
}
}

Components that only make sense while equipped are added and removed with the state. FireControl tracks a burst in progress. It is added when a Shootable item gets EquippedBy and removed when it loses it:

#![allow(unused)]
fn main() {
fn arm_on_equip(
    insert: On<Insert, EquippedBy>,
    guns: Query<Has<FireControl>, With<Shootable>>,
    mut commands: Commands,
) {
    // ...
    gun.try_insert(FireControl::default());
}

fn disarm_on_unequip(
    remove: On<Remove, EquippedBy>,
    guns: Query<(), With<Shootable>>,
    mut commands: Commands,
) {
    // ...
    gun.remove::<FireControl>();
}
}

Accessing the item when firing

Lyra’s firing ability has to walk from the ability to its ULyraEquipmentInstance, cast its Instigator to ULyraInventoryItemInstance and handle nullptr along the way. In Bevy the equipped item is the gun, so the fire system asks for the equipped entity’s components directly:

#![allow(unused)]
fn main() {
fn on_shoot(
    _shoot: On<Fire<Shoot>>,
    time: Res<Time>,
    mut guns: Query<
        (
            &mut FireControl,
            &Ammo,
            &Cooldown,
            Option<&LastShotAt>,
            Option<&Burst>,
        ),
        With<EquippedBy>,
    >,
) {
    let Ok((mut control, ammo, cooldown, last_shot, burst)) = guns.single_mut() else {
        return;
    };
    // ...
}
}

The Ammo read here is the same Ammo the Magnum had on the ground and in the inventory. No cast is needed, and the query only matches guns that have everything it asks for.

Views of an item

A Lyra item needs a pickup actor on the ground, an equipment actor in the hand and a widget in the inventory. The Bevy item is always the same entity, so its appearance lives in a separate view entity. ViewOf links a view to its item:

#![allow(unused)]
fn main() {
#[derive(Component, Reflect)]
#[reflect(Component)]
#[relationship(relationship_target = View)]
pub struct ViewOf(pub Entity);

#[derive(Component, Reflect)]
#[reflect(Component)]
#[relationship_target(relationship = ViewOf, linked_spawn)]
pub struct View(Entity);
}

linked_spawn means despawning an item despawns its view as well.

Each item type registers one scene patch per state in the ItemViewRegistry, keyed by its ItemKey. The state is identified by its component’s type path:

#![allow(unused)]
fn main() {
views.register(
    ItemKey(MAGNUM_KEY.to_string()),
    ItemViewDefinition {
        chrome: HashMap::from([
            (OnGround::type_path(), ground),
            (EquippedBy::type_path(), equipped),
            (StoredIn::type_path(), stored),
        ]),
    },
);
}

The Magnum on the ground is a cuboid, in the hand it is a sphere and in the inventory it is a UI node with a background color. The ground and hand meshes are different on purpose: a change such as rust has to show up in every representation.

One observer per state swaps the view whenever the item enters that state. For EquippedBy, the new view is parented to the character holding the gun:

#![allow(unused)]
fn main() {
fn change_view_on_equipped(
    add: On<Add, EquippedBy>,
    mut commands: Commands,
    views: Res<ItemViewRegistry>,
    items: Query<(&Item, &EquippedBy)>,
) {
    let Ok((item, equipped_by)) = items.get(add.entity) else {
        return;
    };

    commands.entity(add.entity).despawn_related::<View>();

    let Some(ground) = views
        .get(&item.key)
        .and_then(|definition| definition.chrome.get(EquippedBy::type_path()))
    else {
        return;
    };

    commands.spawn((
        ViewOf(add.entity),
        ChildOf(equipped_by.0),
        // Queues the spawning of the view for the next tick
        ScenePatchInstance(ground.clone()),
        Transform::from_xyz(0.3, -0.3, -0.9),
        Visibility::default(),
    ));
}
}

Views only show the item. They hold none of its data, so throwing one away and spawning another never resets ammunition or condition.

Picking items up

Lyra’s pickups choose between a fresh item from a template and an existing item instance. The existing-instance path is the one left unimplemented() in Lyra 5.3. Bevy has nothing to transfer. A gun on the ground is already the item, and picking it up only changes its state:

#![allow(unused)]
fn main() {
fn pick_up_close(
    players: Query<(Entity, Option<&Equips>, &Transform, &Children), With<Player>>,
    inventories: Query<Entity, With<Inventory>>,
    items: Query<(Entity, &Transform), (With<Item>, With<OnGround>)>,
    mut commands: Commands,
) {
    // ...

    // If the player has nothing equipped then we
    // give them the first gun they walk over.
    let mut has_equip = equips.and_then(Equips::entity).is_some();

    for (item, item_pos) in items {
        if pos
            .translation
            .abs_diff_eq(item_pos.translation, PICKUP_RANGE)
        {
            if !has_equip {
                commands.entity(item).equip_for(player);
                has_equip = true;
            } else {
                commands.entity(item).store_in(inventory);
            }
        }
    }
}
}

A Magnum that fired one shot, is dropped and is picked up again still has Ammo(11). Nothing copied that number. The entity kept it.

The next page, Rusty in Bevy, builds the Rusty mechanic on top of these states.

Here be me talk about Rusty.

and cooldown contributions and compilation of effective