Of course, this number is inflated by the ~7 commits dedicated to making the Github workflow run.
But all those commits had a purpose! I present for you, dear reader: “Bevy Töst Game” (name not final)
A prototype for a multiplayer RPG.
Excellent question. I got bored and started experimenting with Bevy. Bevy is, according to their website: “a refreshingly simple data-driven game engine built in Rust.” a statement I agree with. It’s a great engine built around ECS. I believe that a large part of the reason I have felt so productive recently is due to Bevy being very ergonomic. There’s not much friction to get you started with Bevy (at least for me, I doubt I represent everyone).
All that existed in my experiment project at the time was movement (and it wasn’t that good).
But I didn’t worry about that and instead did the obvious next thing. I added a server and another client to the mix.
After some movement replication code fixes…
An issue here should quickly be obvious: There aren’t two clients in the right window. That’s because we don’t tell clients about existing clients yet. We could work on that or work on refining movement replication. I chose the latter.
So, at this point, we have Replicated Movement & Server prediction of movement. It’s not good. When a player stops or switches direction, the server snaps into position. This is noticeable if you look closely at the video.
Around here, I implemented a player-attached third-person camera based on MMOs. We still don’t initialize old clients on new ones.
The day after making the camera system, I finally gave up on Server side prediction.
And then finally implemented network relevance, new clients are now sent existing entities around them.
We have reached the middle of September by now. The month of movement gives way to blood. The question is: “How do you develop a combat system?”.
Well, you need things to modify and other things that modify them or Statistics and Abilities. Statistics are things like; Health, Stamina, Intellect, and Resistances. Abilities are the spells & actions you can take to modify statistics or the world: Fireball, Limit Break, Lay on Hands, as well as spells like Teleport, are all abilities that would be representable in a flexible enough spell system.
Let’s start with the Statistics part. Here’s how I represent health:
pub struct StatHealth {
// Not including active effects.
pub base_health: f32,
pub base_health_max: f32,
// With timed-effects & multipliers applied.
pub current_health: f32,
pub current_health_max: f32,
// Additional health buffer / shield.
pub absorption: f32,
}
Current (Max) Health is what we display to players. It takes into account multipliers & additions from timed effects. Base (Max) Health is what we apply direct damage & healing to.
With a representation of health implemented, we need a way to modify it. We could theoretically get an entity’s StatHealth
in every place where we want to modify it and go health -= damage
but that quickly becomes messy when resistances come into the mix. Directly changing health is bad (at least in this context).
So how exactly do we modify health?
Bevy has a concept of Events where we can pass data between systems. We could for example, pass an event such as this when we wish to deal damage:
pub struct TargetedDamageOperation {
pub source: Entity, // The entity from which this operation originated.
pub target: Entity, // The entity this operation is targeting.
pub school: Option<SpellSchool>, // Optional Damage School, used for resistances, for example: Physical.
pub bonus: f32, // Base damage value.
pub attribute: Option<(f32, Attribute)>, // Optional attribute backed damage, for example: 5% of Strength
}
Into a system that handles the damage calculations and takes into account resistances. My system works as follows:
Where each step is a separate system.
TargetedDamageOperation
/TargetedHealingOperation
events and push them into a list on the target entity.bonus
damage/healing & attribute
damage/healing, and push it into another list.school
.total_healing - total_damage
) operations from this frame and apply to StatHealth::base_health
.current_health
based on base_health
& timed-effects.All damage & healing feeds into this, whether it’s a rogue’s stab or your healer’s Rejuvenating effects, both generate TargetedDamageOperation
or TargetedHealingOperation
events.
And then there were abilities somehow we gotta supply those health modifications. Players need fireballs that hurt and holy fireballs that heal and spatial fireballs that–
What does a spell look like? Well, a spell needs; Effects that execute when cast, which I represent with enums:
pub enum SpellEffect {
Damage {
school: Option<SpellSchool>,
bonus: f32,
attribute: Option<(f32, Attribute)>,
},
Heal {
bonus: f32,
attribute: Option<(f32, Attribute)>,
},
// etc...
}
The Damage & Heal effects match to the TargetedDamageOperation
& TargetedHealingOperation
events mentioned above. If we slot one into a spell, it will push out an event into the health systems when the spell is executed.
We also need a target, again represented as an enum.
pub enum SpellTarget {
Caster, // Self
Character, // Targeted character
}
The spell structure then looks like this:
pub struct Spell {
pub target: SpellTarget,
pub effects: Vec<SpellEffect>, // Effects applied by this spell.
}
And that’s all we need for now. Now we need to execute the spell, apply the effects, etc. And at this point in the project, that process looks like this for a direct damage spell:
This is technically a different gif from the last one. This one has a healing spell! It's not very visual though.. We'll have to solve that eventually.
Poison, a favored tool of the good rogue. We don’t support a poison yet. We need a way to execute effects over time. We will refer to these over-time effects as Auras from now on. Auras do what spells do but over time (and not everything spells do & also things spells don’t do because it doesn’t make sense as an over-time or direct thing).
For example, we could have an aura like this:
Poisonous Intellect
Deals 10 damage every 0.5 seconds & increases the Intellect attribute by 100 points.
30 seconds remaining.
Which is applied to a target by a spell with the ApplyAura
effect.
Aura effects are defined similarly to spell effects:
pub enum AuraEffect {
PeriodicDamage { // A damage effect applied every X units of time.
school: Option<SpellSchool>,
bonus: f32,
attribute: Option<(f32, Attribute)>,
},
PeriodicHeal { // A healing effect applied every X units of time.
bonus: f32,
attribute: Option<(f32, Attribute)>,
},
// etc...
}
And the structure for an aura looks like this:
pub struct Aura {
pub effects: [AuraEffect; 3], // Effects this aura apply.
pub amplitudes: [Option<u32>; 3], // Time between execution of periodic effects in milliseconds. This is the unit of time referenced in the previous codeblock.
pub duration_millis: u32, // How long the aura is applied for.
}
Every frame, the server then goes through the following process:
Not all abilities execute instantly. Some may take a bit of time to execute. We just figured out how to do auras. They’re not quite cast times, but the duration works similarly.
We can slot the ideas from aura duration into the spell system:
We also got ourselves a cast bar and a health bar. That would've been useful a few paragraphs back..
Cooldowns are essentially another timer. When a spell is executed, we send a cooldown event then we slap a cooldown check into the cast spell system.
This is very hard to visualise without UI.
From the screenshot that started this post, it is probably clear that there is more to tell after this, but this post is already very long, and we’ve caught up with most of my work. I’ve skipped over refactors and headbanging so far, as well as more in-depth explanations of how everything works.
Bevy is a fantastic engine. My favorite feature so far is change detection, where we can do things only on entities where relevant data has changed. I love it. I use it quite heavily in things that haven’t happened yet in the time frame of this post. We’ll get to that eventually.
Written 13 Oct 2022 by Grim