MHServerEmu Progress Report: December 2024
Another year is almost over, and we are steadily approaching our goal of fully restoring Marvel Heroes.
Conditions
With version 0.4.0’s release the game is now in a state that most people would probably consider “playable”. However, our work is still far from over, and now we need to focus on restoring the remaining depth of various systems.
In December it was time for me to go back to work on powers, which is something many people have been anxiously waiting for while I was busy with other features. We already had the basic framework for them in place since July, and more backend work was done in September while I was implementing dynamic combat levels, but all of that was just the tip of the power iceberg that we now need to tackle head-on:
The next big step for powers was implementing conditions, which is the term the game uses internally for various buffs and debuffs that can be applied to world entities. Here is an overview of how it works.
Each WorldEntity
instance has a ConditionCollection
that contains Condition
instances that have been applied to it. Conditions are applied as a result of various powers and can come from two main sources: a standalone ConditionPrototype
, or a “mix-in” prototype within the PowerPrototype
of the power that applied the condition. Generally speaking, standalone conditions are conditions that can be applied by different powers, such as boosts, while mix-in conditions are exclusive to a single power. The vast majority of conditions come from power mix-ins.
At their core Condition
instances are essentially PropertyCollection
instances with extra metadata that determine parameters like duration, pause state, and prototype overrides. When a Condition
is added to a ConditionCollection
, it is accrued to the collection’s owner, which includes attaching the condition’s properties to the owner’s in the same way as, for example, when you equip an item. Many conditions also affect the appearance of the entity they are applied to with various visual effects and animation changes.
Condition properties can cause a wide variety of effects. While some are simple stat modifiers, others are used to flag entities with status effects, like stuns and knockups. A whole separate category is over time properties, which are applied continuously using tickers. This is how things like damage over time (DoT) and regeneration effects are implemented.
If a condition’s metadata contains a valid duration, its end is scheduled when it is added. Some conditions do not have a duration, which means they have to be removed by an external event, like a power ending. A good example of this are bounce powers where the avatar is the one bouncing, like the ones Ant-Man and Daredevil have: when the bounce power activates, a condition is applied that makes the avatar’s mesh invisible and prevents movement. This condition is removed only when the bounce power ends, the timing of which varies depending on the distance between bounce targets. Another example is passive powers: they are powers that are automatically activated when an entity becomes simulated, which results in a condition that stays on for as long as the passive power is assigned. There are also conditions that last for as long as some movement is happening, like knockback conditions that prevent entities from moving themselves until the forced knockback movement ends.
Conditions can have various stacking behaviors defined in their prototypes, which restrict how multiple instances of the same condition can be applied to an entity. The main defining feature of a StackingBehaviorPrototype
is its ApplicationStyle
, which can have one of six possible values:
-
DontRefresh
: condition instances are applied separately from one another. -
Refresh
: applying new condition instances resets the duration of other instances that have already been applied. -
Recreate
: applying new condition instances removes all other instances. -
MatchDuration
: the duration of the newly applied instances match the longest remaining duration out of all instances that have already been applied. -
SingleStackAddDuration
: only a single condition instance can be applied, and additional applications extend the duration of the applied instance. -
MultiStackAddDuration
: a combination ofRefresh
andSingleStackAddDuration
, which results in the duration of other instances that have already been applied being refreshed and extended.
Condition instances are grouped into stacks using a structure called StackId
, which consists of a prototype reference and a creator id. Based on the StackingBehaviorPrototype
, in some cases conditions applied by different powers or created by different owners can still belong to the same stack. Finally, the stacking behavior determines how many instances should be applied per application, as well as how many instances in total can be applied using the same StackId
.
There are some unique quirks related to condition persistence. Because only world entities can have conditions applied to them, and the player entity representing the account is not a world entity, account-wide conditions, like boosts, are “faked” by copying them when you switch between avatars. Conditions also need to be carefully serialized and deserialized, because many conditions are not supposed to run out when you are offline.
As of the time of writing this, the core implementation of the condition system is available for testing in nightly work-in-progress builds of version 0.5.0. Some additional features still need to be implemented, including pausing conditions in hub regions and property ticking. Tickers specifically are going to have a huge effect on the entire combat, because they are also going to allow us to implement various primary and secondary resources used by different heroes.
Bounce Powers
As alluded to above, bounce powers are also now functional. This includes targeted powers that bounce around, like Storm’s Chain Lightning, bouncing projectiles, such as Captain America’s Shield Bounce, and powers where the avatar is bouncing in a chain of attacks, like Daredevil’s Street Sweeper. What is interesting about all of these is that everything about them is smoke and mirrors, there is no actual movement happening.
When a power is applied, its properties and the properties of its owner are snapshotted and recorded into a PowerPayload
object. Some of these properties determine the “bounciness” of the power: BounceCountPayload
, BounceRangePayload
, and BounceSpeedPayload
. When the payload is delivered and its effects are applied to its target, the game checks the remaining bounce count, and if it is higher than zero, it “bounces” the payload to another target, which simply changes its target id and schedules a new delivery after a delay. This delay is calculated from the payload’s bounce range and speed.
All the visuals of an object physically bouncing between targets is completely faked by the client. Every time a bounce delay is calculated, the server informs the client about it using the following message:
message NetMessagePowerBounce {
required uint64 idPowerUser = 1;
required uint64 idLastTarget = 2;
required NetStructPoint3 lastTargetPosition = 3;
required uint64 idNewTarget = 4;
required uint64 powerPrototypeId = 5;
required uint64 userOriginalAssetId = 6;
required uint64 userCurrentAssetId = 7;
required float projectileSpeed = 8;
required int32 fxRandomSeed = 9;
}
The client uses data from this message to fake all the movement.
One last piece of the puzzle is how bounce targets are determined. When a bouncing payload’s target is set, its id is recorded as a PowerPreviousTargetsID
property: depending on the power, these properties are later used to deprioritize or even completely avoid targets (if the BounceCanRepeatTarget
flag is not set on the payload). When the payload hits a target, the game scans all entities in a radius around it, skipping those that have the InvalidBounceTarget
property or are not valid targets for a particular power for other reasons. The most suitable target is then picked, taking into account distance, whether the entity has already been hit by this payload, and whether it is an enemy or a destructible environment object. The highest priority is given to closest enemies that have not been hit.
The bouncing continues until the payload either runs out of bounces or fails to find a new valid target. When the bouncing ends, the client is notified of it via one final NetMessagePowerBounce
, which it uses to play the finishing visual effect or animation (e.g. a weapon returning to its owner). And with all of that we have bouncing:
This is a relatively minor addition in the grand scheme of things, but it made many powers across the entire hero roster usable.
Achievements and Leaderboards
While I was busy working on powers and totally not grinding in Path of Exile 2, Alex was hard at work implementing achievements and leaderboards, which utilize event-based tracking similar to missions, in which he has become quite an expert in it over the past months.
One peculiar thing about how achievements and leaderboards work is how their static data is delivered. The logical appoach would have been to have achievements be more rigid and rely on client-side data, while doing the leaderboards in a more dynamic way that relies on server data, but it is actually the other way around. The entire achievement database is sent from the server to the client on login, while all the leaderboards are hardcoded into the client and cannot be changed without a patch. Thankfully, we have a dump of the achievement database for version 1.52 we extracted from captured packets, which gave us a mostly complete dataset for this version. However, unfortunately, it is the only one we have, meaning that supporting achievements for other versions, like 1.48, is going to require pretty extensive reconstruction efforts using secondary sources, such as archived versions of the good old Marvel Heroes Compendium by Mjoll. We may have to build some custom tools to make the reconstruction process easier, but with enough time and effort this is a problem that can be solved. On the bright side, this also means that custom achievements will be relatively easy to implement.
Achievements are now functional and available for testing in nightly 0.5.0 builds. Some achievements may not be obtainable right now due to certain gameplay features not being implemented, but the overall achievement system is working, and more things will be properly tracked as they are done. The tracking for achievements also includes retroactive granting for some achievements, like reaching specific level thresholds and collecting thematic sets of items.
As I am writing this, Alex has now turned his attention to leaderboards, which proved to actually be a tougher nut to crack. One reason for this is that leaderboards have a lot of unfinished functionality, like guild leaderboards that were never used. They also required a pretty significant amount of backend work, mainly because leaderboards have instances that run in real time, with expired instances being archived in the database in case somebody decides to claim their rewards at some point in the future. All of this is in addition to tracking and reward systems similar to missions and achievements.
Although it was more laborious than expected, an early version of leaderboards is now being tested, and if things go well it will be merged and added to nightly builds relatively soon.
That is all we have for you this year. See you all in the next one!