GRIM

Magic Missiles & the Registries. (in Rust & With<Bevy>)

In the previous post, we covered the details of networking. Around early to mid October, I was also implementing “missiles” an object that follow it’s target and causes side-effects. You may recognize some classic projectile spells like World of Warcraft’s Frostbolt.

And very few other player abilities. I don't know why.

Missiles in the spell system.

The spell system (mentioned in part one) is flexible. To handle missiles, we add another variant to the SpellEffect enum.

pub enum SpellEffect {
    ...,
    SpawnMissile {
        missile: MissileId,
        missile_triggered_spell: SpellId,
    },
}

Then we handle that variant in the execute_spell_system, by pawning it off to another system with an event.

SpellEffect::SpawnMissile {
    missile,
    missile_triggered_spell,
} => {
    let target = get_single_target_from_data(&spell.target, spell_execute_event);

    if let Some(target) = target {
        missile_spawn.send(SpawnMissileEvent {
            source: spell_execute_event.source,
            target,
            spell_trigger: *missile_triggered_spell,
            missile: *missile,
        });
    }
}

And now we can spawn missiles from spells.

A gif where a blue humanoid sends an apple towards another blue man labelled "Tim Self"

Wait, what’s MissileId? Where does the missile’s properties come from? The model?

The Missile & MissileVisuals Registry. Registries are generic wrappers around a hashmap, defined as follows:

#[derive(Default, Resource)]
pub struct Registry<K: Eq + Hash + PartialEq, V>(pub HashMap<K, V>);
impl<K: Eq + Hash + PartialEq, V> Registry<K, V> {
    pub fn get(&self, id: &K) -> Option<&V> {
        self.0.get(id)
    }
}

pub type MissileRegistry = Registry<MissileId, Missile>;

And contained within the Missile struct is the data needed on the server-side to handle missiles.

#[derive(Default)]
pub struct Missile {
    pub collision_radius: f32,
    pub speed: f32,
}

Whilst the MissileId itself is simply a wrapper around a u32 alongside a smither of derives.

#[derive(
    Eq,
    Hash,
    PartialEq,
    Clone,
    Copy,
    Debug,
    Serialize,
    Deserialize,
    bincode::Encode,
    bincode::Decode,
    Default,
    Component,
)]
pub struct MissileId(pub u32);

What’s the point of a Registry?

Registries are meant to hold data from a local database such as SQLite or an actual database such as SQL-Not-Lite*.

*SQL-Not-Lite doesn't appear to exist at time of writing.

This data is then used to drive gameplay, spells and auras, are also stored in their own registries. Granting us the ability define & change all of these without recompiling the game, it also means someone without programming knowledge could create these.

This is why the ``MissileId`` struct derives Serialize & Deserialize from Serde.

One could for example, develop an editor like Bethesda’s Creation Kit that content designers could use to create anything for the game. The ESP files used by the Creation Engine are essentially databases containing all the data that drives the game’s content. Similar to the concept of registries here (though more advanced).

The Fallout 4 Creation Kit

A screenshot from Fallout 4's Creation Kit by Bethesda.

``Form ID`` in this is the equivelant to our ``MissileId`` above.

Missiles in the missile system.

We have missiles in the spell system and a registry telling us the properties of the missile. Next we need to get the missile to missile. Earlier in the spell system, we sent a SpawnMissileEvent. Now we need to handle it.

We take the SpawnMissileEvents and create an entity, it will hold a number of properties (divided up among several components):

  1. The source entity (Entity).
  2. The target entity (Entity)
  3. The Missile Id (MissileId)
  4. The Id of the spell to cast when we hit our target (SpellId).
  5. A copy of the target entity’s location (Vec3).
  6. The millisecond timestamp of when we will hit the target, counted from game start. (u32)
  7. Millisecond starting timestamp (u32).
  8. Starting position (Vec3).
  9. Current State (MissileState enum with variants: Hit, Active, TargetLost)

Why not u64 for time? Because at this scale we can count for ~49,7 days, be fine, and fit 2x the data in cache.
Is this a good reason? Meh.

You may ask: Why keep the time of hit and not time to hit? Because it allows us to do some neat logic.

The entire missile movement system works like this:

  1. Get the current_time.
  2. Get progress between starting_time & hit_time.
  3. Lerp translation between starting_position & target_position by progress.
  4. Check if current_time >= hit_time, if so update MissileState to Hit.

Missile Movement

About ~10 times per second, we run the update_target_position_system. This system does the following:

  1. Check if the target moved significantly
    • A. Target hasn’t moved: Return
    • B. Target no longer exists, update MissileState to TargetLost and return.
  2. Target moved, save new target position.
  3. Set starting_position to current missile position.
  4. Set starting_time to current_time.

Target Moved

When the target moves, the start position resets.

Why not just move at speed toward the target entity?

Performance & simplicity. For the aformentioned system we need to calculate two things:

  1. The progress float.
  2. The lerp between starting_position & target_position Everything we need exists in a single component that fits into half a cache-line.

For comparison moving constantly, toward the target, we’d have to:

  1. Get the translation of the current target entity.
  2. Calculate the direction.
  3. Multiple the direction with speed & delta_time.
  4. Add that onto our current positon. Not only is that a lot more math, it requires us to get the position from the target’s transform, which means more data to fetch.

This is the entire missile movement system:

fn move_towards_target_system(
    time: Res<Time>,
    mut query: Query<(&InterpolatedMissile, &mut Transform, &mut MissileState)>,
) {
    let elapsed_time_f64 = time.elapsed().as_micros() as f64 / 1000.0;
    let elapsed_time_millis = time.elapsed().as_millis() as u32;

    for (missile, mut transform, mut state) in query.iter_mut() {
        let progress = (elapsed_time_f64 - missile.start_time as f64)
            / (missile.hit_time - missile.start_time) as f64;

        transform.translation = missile
            .start_pos
            .lerp(missile.target_position, progress.min(1.0) as f32);

        if elapsed_time_millis >= missile.hit_time {
            *state = MissileState::Hit;
        }
    }
}

It’s incredible simple.

I love simplicity. ECS is a fantastic pattern for creating simple code.

What about hitting? How does that work?

As I’ve already alluded to, we keep a MissileState enum, which we modify when we either hit our target or it disappears. Yet another system reacts to changes in MissileState thanks to Bevy’s wonderful Changed<> filter.

Changed<> returns components that have been mutable de-referenced since the last execution of the system.

If the new state is TargetLost, the missile is despawned. If it is Hit, we send a SpellExecuteEvent) targeting our target containing the spell payload defined when the missile spawned, and finally the missile is despawned.

Missiles in the replication system.

Efficient replication is important to me. The missile system, was developed with network bandwidth in mind.

An interesting quirk of how missiles work is that we don’t have to keep replicating it’s position. We only need to send the MissileId, starting_position, target_position, and the time-of-hit.

CreateMissile {
    entity: Entity,
    translation: [i32; 3],
    missile_id: MissileId,
    target_position: [i32; 3],
    time_of_hit: u32,
}

We can also encode target_position relative to translation & the time_of_hit relative to the server time included in our packet meaning they'll always be relatively small.

When target_position changes, we only need to send it’s delta (which will usually be small) and the delta of the time of hit. Given a static target, we only need a single packet to initialize the missile. Given a moving target, we can tune the target_position to only update if the target has moved a certain distance from our previous target_position, currently I’ve set this to 0.5 units/meters, which keeps the hit close enough even if the target moves a bit.

On the client side.

Most of the previous text has only covered the server-side. What happens on the client? Not much. Essentially, just the same lerping as on the server but without the MissileState. When we pass the time-of-hit, the missile despawns on the client-side.

I don’t have any particles or effects on the client-side yet, but they’d appear somewhere around here. I’m aware Bevy Hanabi exists for particle systems, so I’ll likely check it out eventually.

Final notes.

In writing this post I’ve realised there’s a few points that could be improved in regards to missiles. I am actually not sure if missiles like this are truly the correct solution. It’s possible that this same effect could be recreated in a simpler way. All this really does is delay an effect some amount of time and showing a visual in the world for when it will be applied.

It is probably worth testing further.

Next post

Written 12 Jan 2023 by Grim