Designing a multiplayer Spell-System


Inspiration #

I am developing Raiding.Zone for over 16 months now. For the spell system, I got inspired by World of Warcraft. WoW was my first contact with MMORPG, and it stuck with me. Their spell system feels natural, and I tried to recreate this experience.

Requirements #

There are tons of solid spell systems out there. I do not know which elements exactly make a good one. But there are a few attributes that classify an unfun system.
A bad Spell-System feels sluggish, dull, and unpredictable. So we want to prioritize on User-Feedback, Variety, and Precision.
Furthermore, we want to minimize the advantages you gain by cheating.

Basics #

In Raiding.Zone a Spell is the primary unit of action. It gives Players and NPCs the ability to interact with the game.
All spells have a cast time ranging from zero to three seconds. Casts with a cast time of zero are called instant. After performing an instant ability, you must wait for the GlobalCooldown. The Global Cooldown is one second long. It is needed to give animations on the client time to play and to allow players with a higher ping(100-200ms) to contribute to the game.
While performing, the caster has to stand still for the cast time. After the cast time, the spell consumes resources (e. g. Mana, Energy, Rage) and applies its effect. That can be damaging or healing effects, but also Buffs, AoEs, or Pets.

Implementation #

To cast a spell, the player sends a command over WebSocket to the GameServer:

data class AbilityEvent(val abilityId: UUID,
                        val targetId: UUID? = null,
                        val targetZone: Point? = null)

It contains the abililtyId along with an optional targetId and targetZone. These parameters are optional because some spells do not require a target. The targetZone parameter enables the player to perform an ability on an area instead of a fixed target.

Once received at the GameServer, it evaluates a bunch of preconditions:

  1. The caster has to be alive
  2. The Cooldown of the ability has to be up
  3. The current player load-out contains this spell
  4. The target of the player has to be in Line of Sight
  5. The skill can target your target (e. g. heal spells can not target enemies)

After passing all conditions, the command moves to the AbilityQueue. This queue is processed every 30 milliseconds. It checks if there are new abilities and distributes them to the specific TargetCaster.
Every Player and NPC has their own TargetCaster. They run in their own thread context and are isolated from each other. A delay on one player could never affect other targets.
If the TargetCaster receives a new ability request, it will immediately process it. At first, he sends a message to the player confirming the new CastStatus.
At this moment, the player starts to see the cast bar: castBar After sending the update, he sets the Cooldown for this ability.
The TargetCaster will now wait for the cast time to pass. After elapsed cast time, he sends an update to the player, indicating that the spell was successful. At this moment, the cast bar vanishes again.

The TargetCaster consumes the spell resource and calculates all effects of the ability. We cannot apply these effects immediately because we need to coordinate with all other TargetCasters that currently cast a spell.

For example, five players cast a Fireball on the same target simultaneously (maximum 30ms apart). A Fireball deals 15 damage, and the target has 50 health left. The TargetCaster of Player1 will see the target at 50 health and subtract 15 from it, making it 35 health. Player2’s TargetCaster evaluates his spell concurrently on another CPU core and views the health at 50 too. So he assumes the new health has to be 35 too.
You can see the problem with that example. There needs to be a total order for damaging effects to apply them usefully.

Let us come back to the Fireball example. The TargetCaster calculated the damaging effect and now sends this effect to an AttributesModifier. This AttributesModifier collects all modifications in a thread-safe queue.
Then the AttributesModifer takes each ResourceChange and processes them atomically.
After processing, we send a message to the player containing the actual damage done. That’s the time you see the damage numbers popping up. castBar

Overall the spell lifecycle looks like this: castBar

Some spells get channeled. They work a lot like standard spells.
The only difference is that their spell effects evaluate more than once while channeling.

The player can queue a spell while casting the first. That makes for smoother gameplay because you don’t need to hammer your keys after finishing the first ability.
This queue visualizes the spell to cast next: castBar It renders the gameplay less ping dependent because the server stores the queued spell. So even if your ping is high, your second spell would still be directly cast after the first.

Conclusion #

Overall the spell system should feel quite natural now.
The cast bar gives the client instant feedback, and he can choose from a palette effects.
The system is structured to rule out any cheat attempts.
The client sends the intent to cast an ability on a specific target. The server does the whole calculation of effects.

The GameServer doesn’t have a global game loop. Instead, there are a lot of smaller loops that tick at different speeds.
The ability loop is the fastest of them at 33 ticks per second. Buffs, Pets, and Area-Effects evaluate only ten times per second.