MHServerEmu Progress Report: February 2025
Another month, another MHServerEmu Progress Report.
0.5.0 Status
As I am writing this, we are finalizing the work on our next quarterly stable release, version 0.5.0. As a result of player feedback and testing it ourselves, we have shuffled features around, with some getting pushed forward (e.g. Infinity / Omega), and others being delayed (e.g. leaderboards). Overall I feel we ended up with a very substantial package of changes, focused mostly on getting powers and the overall combat into a more refined state.
All the major features that will be coming in 0.5.0 are now done, and we are currently working on some additional polish and bug fixes. The current plan is to have it all ready at some point next week.
Difficulty Scaling
One hot topic that came up after the major batch of power changes in February was the issue of difficulty scaling, particularly at level 60 in group-oriented content, such as Midtown Patrol. I would like to use this opportunity to talk more about what is happening behind the scenes with it.
First of all, there was no difficulty scaling prior to these changes. Green, Red, and Cosmic difficulty tiers were all using the same baseline values, which in most cases correspond to the Green difficulty. The only thing that was affected by difficulty tier selection before these changes was loot, so you were effectively getting Cosmic levels of loot for doing Green content. This was semi-balanced by the fact that many sources of damage and survivability were not working, but overall it was still heavily skewed towards being too easy. As procs and over time tickers were implemented, it became clear that leaving combat calculations as is would result in this situation becoming even more lopsided, so it was time to finally rip the bandaid off.
Get your tinfoil hats ready, because we are about to expose a bit of a conspiracy. Have you ever wondered why you never see the exact health values of the enemies you fight in Marvel Heroes? There is a very good reason for this: all damage numbers you see are actually fake. When the game needs to make an enemy twice as tanky, it does not increase its health or defenses. Instead, the player’s damage gets cut in half, and the damage numbers that are sent to the client are manipulated to hide this multiplier. This applies only to player -> mob damage, when enemies attack players or player-controlled entities you get more or less real damage with only the level scaling applied.
The key thing here is where these multipliers are coming from. This is handled by a class called TuningTable
, and each region has its own instance of it. The difficulty damage multiplier is calculated by the region’s TuningTable
instance each time damage is applied to a target based on the following factors:
-
Whether the source of the damage is a player-owned entity (avatar, summon, team-up, etc.) or not.
-
The rank of the target (popcorn, boss, etc.).
-
The difficulty tier of the current region (Green / Red / Cosmic).
-
The difficulty index of the current region. This is used in modes such as Holo-Sim and X-Defense to gradually escalate the difficulty.
-
The number of nearby players. The exact radius of “nearby” differs depending on the region. In general, for private instances it is set to 100000 units, which covers pretty much the entire region, while in public combat zones it is set to 1200 units, which is roughly about the size of a screen.
-
Whether the region is a public combat zone (PCZ) or not.
One important point to note here is how this is balanced in practice in group-oriented public combat zones, such as Midtown Patrol. The curves used in them start aggressive, but have heavy diminishing returns, which means that bosses will feel very tanky when fighting them solo, but as more players join the fight, their damage will relatively quickly outscale the fake “boss health” gains (which are actually player damage penalties).
I have tested everything extensively, which included manually calculating the multipliers for various situations, and I can say with a high degree of confidence that the system appears to be working consistent with the game data. However, there have been some very vocal feedback from some testers that the game is now “unplayable”. To address this feedback, we have rearranged the features on our roadmap to get some of the additional sources of player power working sooner.
Infinity and Omega
After evaluating the cost-benefit ratio of various missing sources of player power, with cost being the time it would take to implement them, and benefit being the increase in power they provide, I decided it made the most sense to push forward the two alternate advancement systems, Infinity and Omega. The main reason for this is because they rely mostly on the backend functionality we already have implemented, such as entity modifiers that are used for features like enemy ranks, which meant it took only a few days of work to get them up and running. At the same time, they are a major source of player power, although not quite as “infinite” as one of them implies.
“But wasn’t Omega removed in BUE?” — some of you may ask. Well, yes and no: when the client connects to the server, it receives a NetMessageLocalPlayer
that contains a NetStructGameOptions
that looks like this:
message NetStructGameOptions {
required bool teamUpSystemEnabled = 1;
required bool achievementsEnabled = 3;
required bool omegaMissionsEnabled = 4;
required bool veteranRewardsEnabled = 5;
required bool multiSpecRewardsEnabled = 6;
required bool giftingEnabled = 7;
required bool characterSelectV2Enabled = 8;
required bool communityNewsV2Enabled = 9;
required bool leaderboardsEnabled = 10;
required bool newPlayerExperienceEnabled = 12;
required int32 serverTimeOffsetUTC = 13;
required bool useServerTimeOffset = 14;
required bool missionTrackerV2Enabled = 15;
required int32 giftingAccountAgeInDaysRequired = 16;
required int32 giftingAvatarLevelRequired = 17;
required int32 giftingLoginCountRequired = 18;
required bool infinitySystemEnabled = 19;
required int32 chatBanVoteAccountAgeInDaysRequired = 20;
required int32 chatBanVoteAvatarLevelRequired = 21;
required int32 chatBanVoteLoginCountRequired = 22;
required bool isDifficultySliderEnabled = 23;
optional bool orbisTrophiesEnabled = 24 [default = false];
required int32 platformType = 25;
}
message NetMessageLocalPlayer {
required uint64 localPlayerEntityId = 1;
required NetStructGameOptions gameOptions = 2;
}
One of the fields here is called infinitySystemEnabled
. When it is set to false
, not only does it disable the Infinity system, but it also reenables Omega in the state it was left in as of version 1.52. While this is some neat trivia, why bother implementing it right now? The reason is very simple: the vast majority of code for Infinity is literally copypasted Omega code with the word “Omega” replaced with “Infinity”, and I am not exaggerating this. The biggest difference between them is how points are calculated: Infinity uses a “squished” experience curve in which 10 Omega points are equivalent to 1 Infinity point, and each Infinity point you receive is colored to match various Infinity Stones. Another semi-major difference is the unlock requirement: Infinity is unlocked at level 60 account-wide, while Omega is unlocked per-hero at level 30. Everything else is pretty much the exact same thing code-wise, all the way to checking various prerequisite nodes (that are simply left blank in Infinity). So, by implementing one of them, it was very easy to get the other one working as well.
While we are at it, here is another fun fact: despite being called “Infinity”, it is not actually infinite. The system is capped at 6 000 000 points, of which only 80 400 are actually spendable. This is still a heavy grind, especially when compared to Omega, which had a cap of 10 000 (equivalent to 1 000 when using the Infinity curve), but I would not be surprised to see people reach it years down the line after 1.0 is out and there are no more wipes.
As for Omega, the exact state of its balance in the context of BUE remains to be evaluated. One idea that has been floating around is “uncapping” to match Infinity, but it needs to be extensively tested to make sure nothing breaks. In any case, even if it does not find extensive use in 1.52, this is still going to be useful for when get to backporting the server to version 1.48 (aka “pre-BUE”).
With these two systems working (Infinity in particular), there is now both an additional long term grind to work on, and a way to brute force past the remaining unimplemented systems, such as talents, which require more time and effort to implement.
Summons
AlexBond is back for this month’s report to talk about his work on summon powers, hotspots, and controlled agents.
Hello everyone, this is AlexBond. In this report we are going to talk about summons.
Summons are allies created by powers that help you in combat. If you ask a Marvel Heroes player to give an example of a summoner hero you will probably hear Squirrel Girl, Magik, or Doctor Doom; nobody is going to say Cyclops, Deadpool, or Nova. However, almost all heroes in the game are summoners. Why is that? Allow me to explain.
Each power is a chain of different actions. When you play as Cyclops and activate the Optic Beam power (internally referred to as ChanneledBeam
), a hotspot called CyclopsChanneledEnergyBeamArea
is summoned. This hotspot has a triangle shape with a 10 degree angle and a length of 700 units. It is attached to Cyclops, and when you move the mouse cursor, he rotates towards it, and the attached hotspot follows. When the hotspot overlaps with other entities, it applies powers defined in the AppliesPowers
field of its prototype: in this example in particular it applies ChanneledEnergyBeamEffect
, which is the damage component, and ChanneledEnergyBeamSlowEffect
, which is an additional status effect.
Three systems needed to be implemented for all of this to work correctly: summon powers, hotspots, and attached entities.
Summon Powers
Each power prototype contains many different parameters, but the main one we are interested in is SummonEntityContexts
:

It contains the settings we need to use to create a SummonEntity
.
In most cases, when a power is activated, it creates a PowerPayload
instance. Summon powers register for the OnDeliverPayload()
event, which we use to get the target that we are going to attach our future summon entity to. In SummonPayloadEntity()
we calculate the number of entities that have already been summoned, and if the KillPreviousSummons
flag is set, we kill existing ones to prevent infinite spawning. We get the number of summon entities to create from SummonNumPerActivation
, compare it to SummonMaxSimultaneous
, and then create the entities using SummonEntityContext()
. We need to not only create the entities, but also calculate their position relative to the summoner, taking into account all the restrictions, offsets, collisions, and other parameters. All of this is handled by a function called GetSummonPositions()
. After we get our spawn coordinates, we put our newly created entities into the world and attach them to the caster or the target based on the prototype flags if needed (AttachSummonsToCaster
and AttachSummonsToTarget
respectively).
In most cases this entity is going to be an invisible area called a hotspot that will damage and/or apply other effects to whoever overlaps with it.
Hotspots
In most cases hotspots are invisible to players (except for visual effects in rare cases). When they enter the world, they also begin interacting with the game’s physics system, which is processed on the server and the client in parallel. The physics system simulates movement of entities within the game world and invokes events such as OnOverlapBegin()
and OnOverlapEnd()
.
The two main use cases for hotspots are mission triggers (HandleOverlapBegin_Missions()
) and power triggers (HandleOverlapBegin_Powers()
and HandleOverlapEnd_PowerEvent()
). We have already covered missions in previous reports, and now it is time to talk about powers.
Hotspots apply powers defined in the prototype in the following two fields:
-
AppliesPowers
-
AppliesIntervalPowers
And this is where it becomes complex. There are many powers and many targets, targets can enter and exit the hotspot, and all of this needs to be tracked. We use a dictionary with target id as key and a structure called PowerTargetMap
as value:
private Dictionary<ulong, PowerTargetMap> _overlapPowerTargets;
public struct PowerTargetMap
{
public HotspotPowerMask ActivePowers;
public HotspotPowerMask IgnorePowers;
}
HotspotPowerMask
is a bit field that can contain the state of up to 32 different powers. We use these masks to ensure that targets do not have effects applied to them multiple times, and the HasConditionsForTarget()
function helps us prevent infinite condition stacks.
AppliesIntervalPowers
works differently: it can either affect random targets if the IntervalPowersRandomTarget
flag is set, or it can target all entities at the same time.
When a new target begins overlapping with a hotspot’s bounds, it gets tracked using our dictionary, and all active powers are applied to it.
As you can see, this is a pretty complex process that involves the interaction of many different systems and events. Let’s take a closer look at one of them.
Attached Entities
When an entity flagged as IsAttachedToEntity
enters the world, it is attached to the EntityPhysics
component of the specified entity, where it is added to the AttachedEntities
collection.
Every time the PhysicsManager
processes entity movement, it calls UpdateAttachedEntityPositions()
, which calls ChangeRegionPosition()
for all entities in the AttachedEntities
collection. Hotspots override ChangeRegionPosition()
to take into account the offset it gets from GetCenterOffset()
and the relative SummonOffsetAngle
, which allows the beam to rotate around the user rather than its own center.
With this our short overview of hotspots is over, and you should have a general understanding of how it all works. Now I would like to talk about another topic.
Controlled Agents
While I was working on summon powers, I noticed that it was related to two other systems: TeamUpAgent
and ControlledAgent
. These two, along with VanityPet
summons, all belong to the same group called PersistentAgents
.
When you transition within the same region using teleports (e.g. elevators in tower regions), your pets should transition with you (and not temporarily get stuck in the previous area like team-ups used to do). This is handled by the RespawnPersistentAgents()
function. I had to overhaul our old team-up implementation for these new requirements. Now all three team-up modes are selectable, although not all functionality related to them is implemented at the time of writing. Let’s move over to controlled agents.
There is a relatively small number of heroes that can control enemies and turn them into pets. Here is the full least of them:
-
Emma Frost (living enemies)
-
Rogue (same as Emma Frost via a stolen power)
-
Vision (robots)
-
Magik (demons)
When you use a control power, the ControlAgentAI
power event is invoked via DoPowerEventActionControlAgentAI()
, which calls the SetControlledAgent()
function that establishes control via SetControlledAgent()
. This involves removing the existing controlled agent if needed (e.g. Magik) and binding the new one. The binding process includes placing the controlled entity into an inventory called AvatarControlledEntities
belonging to the controlling avatar, setting the AIMasterAvatarDbGuid
property on it to make it follow the avatar, and overriding its alliance to match the avatar. All boosts flagged as DisableForControlledAgents
are removed, and we get an obedient pet.

As it turns out, in version 1.52 it is not possible to change pets on the fly. I am not sure when exactly it was changed, but currently the data-defined requirement looks like this:
HasEntityInInventory(Context=[Var2], Entity=[], Inventory=[Controlled]) ? False : True
This means that if our inventory with the Controlled
label has any entity in it, it is impossible to control a new one. To get a new pet we first need to dismiss the existing one in the power panel:

Implementing all the systems we discussed above restored a lot of gameplay functionality: bosses are now more fun, enemies are more dangerous, and heroes are more playable (and summon-focused heroes are “playable” for the first time in a sense).
Hopefully you found this interesting. Until we meet again in future reports, and have fun playing!
That is all we have to share today. Time for us to get back to finishing 0.5.0!