I've made a lot of these mistakes over the years, and I've found a unified, sensible way to not only mitigate them, but to do so using a generic data structure that allows you to write code faster, have less code duplication through common patterns, and have fewer lines of code overall to debug.
Some people are going to look at this and think it is overcomplicated, because it requires you to create a unified data structure to solve a number of problems that they have not yet approached themselves, or think that it's going to be too slow because it creates abstract hooks that are called periodically when you don't need them. They are right. It is slower, and slightly more complex than what you could write yourself. However, at some point projects that fail to create these unified data structures will collapse in on themselves and become either impossible to maintain or understand quickly by new developers, or they will reach a number of problems that can't easily be sidestepped without correcting thousands of lines of code, or creating hacky spaghetti code workarounds that ultimately eliminate the competing ideologies' "leaner" benefits.
This structure is not designed to the fastest or even the best. It is simply one ideal, generic solution to a variety of problems that present themselves when making a game. This isn't even exclusively targeted at MMORPG type games. This will work well in almost any sort of game provided it is applied properly and the user expands this structure by adding their own child effect types targeted specifically to their creation.
With all of that said, let's take a look at some of the common types of effects you see in games:
Toggle Effects
Toggle effects are effects that stay active until turned off. They have no definite time limit. Generally speaking, when added, they will change a stat, and when removed, will remove the bonus or debuff they applied.
Timed Effect
Timed effects are effects that stay active until a timer expires. They have a strict time limit. Something like temporary damage immunity or a boosted attack would fit well into timed effects.
Effect Over Time
Over time effects are effects that stay active until a timer expires. They also have a time limit between ticks that allows them to periodically do something. These are typically seen as slow heals, bleeds, or poison effects.
Stacking Effects
Stacking effects are a bit complicated to explain. Sometimes, an attack will apply a debuff that gets stronger the more times that you use it against someone. You can handle this by stacking the effect. Instead of applying multiple different effects to the player, you can apply a number of stacks to the same effect, and recalculate the stat debuff or bonus based on the number of stacks being applied. Other attacks might cause a series of stacks to build, and when a second ability is used, it may remove a certain number of those stacks for bonus effects.
Combinations
Timed stacking effects, and stacking over time effects are also possible variations of effect types. For instance, poison might do more damage the more stacks there are of it on the user, or a stacking effect might slowly go away over time.
So, in summary, combat effects more or less come with three optional properties: Timed, Ticking, and Stacking. Using these three basic structures, we can pretty much do anything we want in a combat system. You might think we need to create a different code structure for each one of those different types, but we don't actually. We can actually encapsulate all of this behavior in a single new object: /effect.
Meet the /effect Datum
effect
var
duration = 1#INF //for timed and ticker effects
tick_delay = 0 //for ticker effects
stacks = 1 //for stacking effects
max_stacks = 1 //for stacking effects
proc
Added(mob/target,time) //called when this effect has been added to a mob
Removed(mob/target,time) //called when this effect as been removed from a mob
Overridden(mob/target,effect/e,time)
Ticked(mob/target,tick,time)
Expired(mob/target,time) //called when this effect has expired
Canceled(mob/target,time) //called when this effect was canceled
The above variables are the ones that you will personally care about for defining new types of effects. The procs that we have added to the type are empty because they are override hooks. When you are creating new effects for your game, you will create new children of /effect and fill in these hooks to create their behavior.
Working with effect datums
We still need to keep track of effects. In some cases, we don't want to add effects to a user if they already have the same one. For instance, we don't want a player to have a broken spine, a broken spine, a broken spine, and a broken spine, so effects need some way to be unique. We also need to be able to keep track of what effects are active on any one mob at a time, and a quick way to get effects out of a user that we can use to apply and modify effects.
All of this can be served by a little bit of structure on the mob itself, as well as two new variables on the /effect datum.
effect
var
id //you can have multiple effects of the same id
sub_id = "global" //as long as their sub_ids don't match
mob
var
list/effects = list() //list of all effects affecting the mob
list/effect_registry = list() //a readable hashmap of effects sorted by their [id] and [id].[sub_id]
We now have to new variables on the effect datum, and two new ones on mob. When an effect is to be added to the mob we must first search effect_registry for "[id].[sub_id]". If we find a matching element, we should attempt to Override() that effect datum. If successful, we can continue and add the effect to the mob's effects list, as well as register the current effect in effect_registry under both "[id]" and "[id].[sub_id]".
Let's take a look at that:
effect
proc
Add(mob/target,time=world.time)
if(world.time-time>=duration) //reject any effects trying to be added after the duration is already expired.
active = 0
return 0
var/list/registry = target.effect_registry
var/uid
if(id&&sub_id)
uid = "[id].[sub_id]" //create the unique id
var/effect/e = registry[uid] //check the target's registry for a matching effect by unique id
if(e && !Override(target,e,time)) //if we found an effect matching our uniqueid, we tell this effect to attempt to override it. If it fails, we return 0
return 0
if(id)
var/list/group = registry[id] //get the current id group.
if(!group)
//if there is nothing matching the current effect id group
registry[id] = src
else if(istype(group))
//if there is already a list of effects in the id group, add this effect to the group
group += src
else if(istype(group,/effect))
//if there is a single effect in the id group, make it a list with the item and this effect in it.
registry[id] = list(group,src)
else
//if there is something else in place, fail to add this effect. (Possibly adding class immunities via a string?)
return 0
//Add() hasn't returned yet, so this effect is going to be added to the registry via unique id and the target's effects list.
if(uid)
registry[uid] = src
target.effects += src
Added(target,time) //call the Added() hook.
return 1 //return success
Override(mob/target,effect/overriding,time=world.time)
overriding.Cancel(target,time) //by default, allow the override to happen by canceling the effect src is overriding.
overriding.Overridden(target,src,time) //notify the overridden effect that it was overridden by this one.
return 1 //returning 1 will allow the src effect to be added. If you return 0, but cancel the overridden effect, both effects cancel each other out.
Overridden(mob/target,effect/override,time=world.time)
Cancel(mob/target,time=world.time) //Cancel is just a hook that allows us
Remove(target,time)
Canceled(target,time)
We also need to be able to remove effects from the target. This is a simple task of reversing the Add logic and maintaining the jagged lists inside of the target's registry:
effect
proc
Remove(mob/target,time=world.time)
var/list/registry = target.effect_registry
if(id&&sub_id)
var/uid = "[id].[sub_id]"
//some complex logic to test whether this object is actually in the registry. Fail out if the data isn't right.
if(registry[uid]==src)
registry -= uid //remove the uid of the object from the registry
if(id)
var/list/group = registry[id]
if(istype(group)) //if the id group is a list
group -= src
if(group.len==1) //if the removal of this effect left the list at length 1, we need to reset the id registry to be the item at group index 1 instead of a list.
registry[id] = group[1]
else if(group.len==0) //if the removal of this effect left the list at length 0 (this shouldn't happen), we need to remove the id registry from the registry entirely.
registry -= id
else if(group==src) //otherwise, we need to remove the entire id registry
registry -= id
//remove the effect from the list and the registry
target.effects -= src
active = 0
Removed(target,time) //call the Removed() hook and return success
return 1
Lastly, we need to build in the logic for the three different types of effects. Let's start with timed effects.
effect
var
active = 0 //keeps track of whether the effect is currently active. This is for differentiating between a canceled effect and an expired effect.
start_time //keeps track of the time an effect was added to the target.
proc
Added(mob/target,time=world.time) //change Added() to no longer be empty. This will call a new Timer() proc if duration is non-zero.
active = 1
start_time = time
if(duration<1#INF) //if the effect has a duration, initiate the timer proc
Timer(target,time)
Timer(mob/target,time=world.time)
set waitfor = 0 //make this not block the interpreter
while(active&&world.time<=start_time+duration) //continue to wait until the effect is no longer active, or the timer has expired.
sleep(min(10,start_time+duration-world.time)) //wait a maximum of 1 second. This is to prevent already-canceled effects from hanging out in the scheduler for too long.
Expire(target,world.time)
Expire(mob/target,time=world.time)
if(active) //only actually do this if the effect is currently marked as active.
active = 0
Remove(target,time)
Expired(target,time)
The above is the timed effect code structure. Let's take a look at the ticker effect structure in isolation:
effect
var
ticks = 0
proc
Added(mob/target,time=world.time)
active = 1
start_time = time
if(tick_delay) //if the effect currently has a tick delay set.
Ticker(target,time)
Ticker(mob/target,time=world.time)
set waitfor = 0
while(active&&world.time<=start_time+duration)
Ticked(target,++ticks,world.time) //call the Ticked() hook. This is another override that allows you to define what these ticker effects will do every time they tick.
sleep(min(tick_delay,start_time+duration-world.time)) //sleep for the minimum period
Expire(target,world.time)
Expire(mob/target,time=world.time)
if(active) //only actually do this if the effect is currently marked as active.
active = 0
Remove(target,time)
Expired(target,time)
Ticked(mob/target,tick,time=world.time)
The above looks extremely similar to the timer datum, with the exception that the Ticker calls the Ticked() hook before each delay.
Let's move on to stacking effects.
effect
proc
Override(mob/target,effect/overriding,time=world.time)
if(max_stacks>1 && max_stacks==e.max_stacks) //check if the max number of stacks match between the two and are greater than 1
Stack(target,overriding) //stack up the two effects.
overriding.Cancel(target,time)
overriding.Overridden(target,src,time) //this is our old code. Cancel the old one, and allow the new one to be added.
return 1
Stack(mob/target,effect/overriding)
stacks = min(stacks + overriding.stacks, max_stacks) //set this element's stacks to the old effect's stacks, then replace it.
Stacking effects don't really do much. All they do for you is increment their stack. It's up to you what their stacks mean down the line.
Putting it all together.
Now that we have a better grasp of all three of these acting separately, it's time to put it all together into a single modular datum that will unify your entire combat system and reduce the amount of time it takes to write code for your game.
mob
var
list/effects = list() //list of all effects affecting the mob
tmp
list/effect_registry = list() //a readable hashmap of effects sorted by their [id] and [id].[sub_id]
proc
AddEffect(effect/e,time=world.time) //mob redirect for effect.Add()
e.Add(src,time)
RemoveEffect(effect/e,time=world.time) //mob redirect for effect.Remove()
e.Remove(src,time)
effect
var
id
sub_id = "global"
active = 0 //whether the current effect is still active
start_time = 0 //The time that the effect was added to the target
duration = 1#INF //for timed and ticker effects (lifetime of timed and ticker effects)
tick_delay = 0 //for ticker effects (delay between ticks)
ticks = 0 //for ticker effects (current number of ticks passed)
stacks = 1 //for stacking effects (current number of stacks)
max_stacks = 1 //for stacking effects (maximum number of stacks)
proc
Add(mob/target,time=world.time)
if(world.time-time>=duration) //reject any effects trying to be added after the duration is already expired.
active = 0
return 0
var/list/registry = target.effect_registry
var/uid
if(id&&sub_id)
uid = "[id].[sub_id]" //create the unique id
var/effect/e = registry[uid] //check the target's registry for a matching effect by unique id
if(e && !Override(target,e,time)) //if we found an effect matching our uniqueid, we tell this effect to attempt to override it. If it fails, we return 0
return 0
if(id)
var/list/group = registry[id] //get the current id group.
if(!group)
//if there is nothing matching the current effect id group
registry[id] = src
else if(istype(group))
//if there is already a list of effects in the id group, add this effect to the group
group += src
else if(istype(group,/effect))
//if there is a single effect in the id group, make it a list with the item and this effect in it.
registry[id] = list(group,src)
else
//if there is something else in place, fail to add this effect. (Possibly adding class immunities via a string?)
return 0
//Add() hasn't returned yet, so this effect is going to be added to the registry via unique id and the target's effects list.
if(uid)
registry[uid] = src
target.effects += src
Added(target,time) //call the Added() hook.
return 1 //return success
Override(mob/target,effect/overriding,time=world.time)
if(max_stacks>1 && max_stacks==overriding.max_stacks) //check if the max number of stacks match between the two and are greater than 1
Stack(target,overriding) //stack up the two effects.
overriding.Cancel(target,time)
overriding.Overridden(target,src,time) //this is our old code. Cancel the old one, and allow the new one to be added.
return 1
Stack(mob/target,effect/overriding)
stacks = min(stacks + overriding.stacks, max_stacks) //set this element's stacks to the old effect's stacks, then replace it.
Timer(mob/target,time=world.time)
set waitfor = 0 //make this not block the interpreter
while(active&&world.time<=start_time+duration) //continue to wait until the effect is no longer active, or the timer has expired.
sleep(min(10,start_time+duration-world.time)) //wait a maximum of 1 second. This is to prevent already-canceled effects from hanging out in the scheduler for too long.
Expire(target,world.time)
Ticker(mob/target,time=world.time)
set waitfor = 0
while(active&&world.time<=start_time+duration)
Ticked(target,++ticks,world.time) //call the Ticked() hook. This is another override that allows you to define what these ticker effects will do every time they tick.
sleep(min(tick_delay,start_time+duration-world.time)) //sleep for the minimum period
Expire(target,world.time)
Cancel(mob/target,time=world.time)
if(active)
Remove(target,time)
Canceled(target,time)
Remove(mob/target,time=world.time)
var/list/registry = target.effect_registry
if(id&&sub_id)
var/uid = "[id].[sub_id]"
//some complex logic to test whether this object is actually in the registry. Fail out if the data isn't right.
if(registry[uid]==src)
registry -= uid //remove the uid of the object from the registry
if(id)
var/list/group = registry[id]
if(istype(group)) //if the id group is a list
group -= src
if(group.len==1) //if the removal of this effect left the list at length 1, we need to reset the id registry to be the item at group index 1 instead of a list.
registry[id] = group[1]
else if(group.len==0) //if the removal of this effect left the list at length 0 (this shouldn't happen), we need to remove the id registry from the registry entirely.
registry -= id
else if(group==src) //otherwise, we need to remove the entire id registry
registry -= id
//remove the effect from the list and the registry
target.effects -= src
active = 0
Removed(target,time) //call the Removed() hook and return success
return 1
Expire(mob/target,time=world.time)
if(active) //only actually do this if the effect is currently marked as active.
active = 0
Remove(target,time)
Expired(target,time)
Added(mob/target,time=world.time) //Added() is called when an effect is added to a target.
active = 1
start_time = time
if(duration<1#INF) //if the effect has a duration
if(tick_delay) //and the effect has a tick delay, initiate the ticker.
Ticker(target,time)
else //otherwise, initiate the timer
Timer(target,time)
Ticked(mob/target,time=world.time) //Ticked() is called when a ticker effect successfully reaches a new tick.
Removed(mob/target,time=world.time) //Removed() is called when an effect is removed from a target.
Overridden(mob/target,effect/override,time=world.time) //Overridden() is called when an effect overrides another existing effect.
Expired(mob/target,time=world.time) //Expired() is called when a timed or a ticker effect successfully reaches the end of its duration.
Canceled(mob/target,time=world.time) //Canceled() is called when an effect has been manually canceled via Cancel().
In subsequent posts in this thread, I'm going to detail how you can use these to simplify your code for combat and sidestep a huge number of bugs when it comes to impermissible states and the like.
Most of these problems can be summed up by a series of questions:
What happens when a mob that's in combat logs out?
What happens when a temporary effect is active during a character autosave?
How can we cancel ongoing timed effects?
How can we cure multiple status ailments mid-tick?
How can we ensure that the player currently has a particular buff or debuff with minimal effort?
How can we ensure that certain buffs don't stack?
How can we make sure that status effects are always aligned properly, and the player is always returned to normal when they finish?
In order to understand these questions, we have to look at how this kind of thing is usually done.
This is your standard naive Damage over time effect logic.
Let's ask ourselves a couple of questions from the above list:
Q) What happens when a mob that's bleeding logs out?
A) Bleed() will prevent the user from being garbage collected while it is active. This could be catastrophic with long debuffs. If the user is forcibly deleted, the proc will end, but manual deletion is a major CPU hog.
Q) What happens when bleed is active during a character autosave?
A) The character will save, and there's no way to save running procs. The user can safely reconnect to cancel the bleed effect.
Q) How can we cancel or cure a bleed?
A) ...We can't, actually. Not easily anyhow. The hoops we have to jump through in order to do it come with their own problems. Not without some kind of datum... Which leads us right back to our generic effect handler, or some shittier version of it.
Q) How can we tell the player is bleeding?
A) We can't really. We could implement a bleeding variable and increment/decrement it every time that Bleed() is called, true, but without tying that variable to something that saves with the player (a generic effect datum would be nice), it can't be saved accurately, and bleeds can't be restarted easily from where they left off.
Q) How can we ensure that bleeds don't stack?
A) We can implement an is_bleeding variable on the mob. But having one of these for each type of debuff is just insane. So how about a list then? Well now we have to implement a totally new proc for every different type of effect, or we have to pass some kind of string into each individual effect proc... Or... We could use a datum with a unique ID stored in some kind of an effect_registry hashmap...
Q) How can we ensure that status effects are always aligned properly, and the player is always returned to normal when they finish?
A) With a proc, the proc just has to reach the end. But if the user saves, or logs out, thus interrupting the effect, your data will be out of alignment. You either don't use saved variables to store temporary stat boosts/debuffs, or you don't allow logging out or saving while using buffs/debuffs, or you just suck it up and deal with potential stat boosting exploits.
As we can see, we have a TON of work to get done before we can solve each of the problems pointed out by these questions.
Let's ask these same questions of the effect datum generics:
Q) What happens when a mob that's in combat logs out?
A) effects can modify a can_logout variable on the client when added to prevent logging out while in combat. The player will periodically check whether or not can_logout has reached zero, or if a maximum time has been reached, they will be saved, then moved off the map. After saving, all of their effects will be canceled. This will allow the user to be garbage collected.
Q) What happens when a temporary effect is active during a character autosave?
A) effects can be saved and hotloaded just fine. There is zero consequence to saving effects provided child types obey certain sensible restrictions.
Q) How can we cancel ongoing timed effects?
A) It's built in for us.
Q) How can we cure multiple status ailments mid-tick?
A) We can either pull them by id/id.subid from the registry to heal them individually, or we can globally define an effect_type variable, and loop over mob.effects.
Q) How can we ensure that the player currently has a particular buff or debuff with minimal effort?
A) id/id.subid in the registry facilitate quick effect lookups.
Q) How can we ensure that certain buffs don't stack?
A) id.subid facilitates preventing buff stacking. We can also look up specific buffs and make them cancel themselves out on add.
Q) How can we make sure that status effects are always aligned properly, and the player is always returned to normal when they finish?
A) Added() and Removed() should be reciprocal to one another. Removed() will always be called when an effect is removed, and Added() will always be called when an effect is added. effects can keep track of the stat changes to the user in variables and simply removed them in Removed(). This will ensure even code changes to an effect between saves won't cause stat misalignments. Even better, we can ensure that Added() and Removed() are even called when loading the effect from a savefile.
If anyone wants to take the thrown down gauntlet here and demonstrate a system that solves all of the above problems without using either a shittier version of the effect datum, or butchering readability/modularity to all shit, I'd be very interested in your results.