How to Create Multiplayer Mods (Legacy)
See the Following Chapters Before Proceeding
All players may execute console commands but only the lobby host may execute entity triggers.
Assets Info
- If you are editing a Campaign, DLC, or Horde map, use Assetsinfo to pull assets from a BATTLEMODE map (both the base folder and _patch1).
- Example json path: e1m3_cult_patch3\EternalMod\assetsinfo\e1m3_cult.json
{
"resources":
[
{
"name":"pvp_inferno.resources"
},
{
"name":"pvp_inferno_patch1.resources"
}
]
}
Network Replication
- You may notice that most entities have either networkReplicated = false; or networkReplicated = true; near the top of the entity. They determine how entities are broadcasted to clients, but they often create many visual issues or crashes when set to true.
- One example is that a network replicated door obstacle will have a positioning offset that looks very out-of-place for clients only. The effect is greater the farther away the entity is from 0, 0, 0 spawn. This applies to every network replicated entity but the effects are not noticeable within 100, 100, 100 spawn.
- Refrain from spawning network replicated entities beyond 500, 500, 200 spawn.
Entity offsets are roughly 0.5% closer to 0, 0, 0 spawn for clients.
Do not activate an idTarget_Spawn spawning a network replicated idTrigger_Teleporter entity.
IT WILL CRASH THE GAME FOR CLIENTS.
Timelines and Relays
- If you want to activate a non-network replicated entity, you will need 2 or more additional entities to make sure the desired entity is activated properly for all players.
- First, you need a network replicated idTarget_Timeline to activate any non-network replicated idTarget entity (relay) such as idTarget_Count and idTarget_Remove. The idTarget entities will activate the desired entity.
entity {
entityDef coop_activate_door_1_timeline {
inherit = "target/timeline";
class = "idTarget_Timeline";
expandInheritance = false;
poolCount = 0;
poolGranularity = 2;
networkReplicated = true; // Must be true
disableAIPooling = false;
edit = {
flags = {
noFlood = true;
}
networkSerializeTransforms = false;
spawnPosition = { // Spawn position does not matter
x = 1;
y = 1;
z = 1;
}
componentTimeLine = {
entityEvents = {
num = 2;
item[0] = {
entity = "coop_activate_door_1_relay"; // Activates non-network replicated door opening entity
events = {
num = 1;
item[0] = {
eventTime = 250;
eventCall = {
eventDef = "activate";
args = {
num = 1;
item[0] = {
entity = "";
}
}
}
}
}
}
item[1] = { // Remove item[1] if only one relay is activating another entity and no deletion is necessary
entity = "coop_activate_door_1_remove"; // Removes non-network replicated door obstacle entity so clients can pass through
events = {
num = 1;
item[0] = {
eventTime = 2500;
eventCall = {
eventDef = "activate";
args = {
num = 1;
item[0] = {
entity = "";
}
}
}
}
}
}
}
}
allowClientsToStart = true; // Must be true
}
}
}
An idMusicEntity should be triggered the same way by a timeline, then an idTarget_Count to activate the music entity directly.
If you want a timeline to activate a series of entities such as hazards, you will need a dedicated timeline to activate a relay, that activates the hazard timeline.
Interactables
- If an idInteractable entity such as a door or switch is network replicated, it may yield unexpected results when trigger by a client.
- Most interactables should not be network replicated, making them only triggerable by the host. Interactables should always target the idTarget_Timeline to activate a relay.
Loot Drops
- Loot drop decls in multiplayer are different than single player. In the idAI2 entity, there is a lootDropComponent parameter for single player and pvpLootDropComponent for multiplayer.
- If you want campaign loot drops in your multiplayer mod, match the content from the first screenshot to the second.
lootDropComponent = {
lootDropDataDecl = "ai/default_fodder";
}
pvpLootDropComponent = {
lootDropDataDecl = "ai/default_fodder_pvp";
}
lootDropComponent = {
lootDropDataDecl = "ai/default_fodder";
}
pvpLootDropComponent = {
lootDropDataDecl = "ai/default_fodder";
}
Lag could be an issue if 3 Slayers are constantly generating loot drops from enemies. It is recommended to limit how much total loot drops an attack can create. Each individual loot drop value can be changed to balance out the loot drop quantity.
Pickups
- idProp_2 entities (pickups) will crash the game for clients when network replicated by default. Setting network replication to false will make the pickup invisible to clients. Adding isLootDrop = true; while keeping network replication enabled will make the entity visible and usable to all players without crashing.
- Pickups will not properly respawn for clients after consumption, even with timeUntilRespawnMS enabled.
See this page for more info: https://wiki.eternalmods.com/books/2-how-to-create-mods/page/respawning-pickups
Clients cannot obtain charges from Blood Punch Refills, Hammer Refills, or Dash Refills.
Extra Lives and Explodable Barrels do not work for any player.
Client Teleporters
- idTrigger_Teleporter entities should not be network replicated for most instances. If they are, their offset may be too extreme for clients to interact with. If a player uses a non-network replicated teleporter, its use sound will not be broadcasted to all players.
- If you do not want teleporter cooldowns to be shared globally, they must not be network replicated.
See this page for more info: https://wiki.eternalmods.com/books/2-how-to-create-mods/page/demon-teleporters-bouncepads
idTrigger_Teleporter_Fade
- Fade teleporters will crash the game for clients. Change all entity instances of idTrigger_Teleporter_Fade to idTrigger_Teleporter.
inherit = "trigger/teleport_fade";
class = "idTrigger_Teleporter_Fade";
inherit = "trigger/teleporter";
class = "idTrigger_Teleporter";
idPlayerStart & idDemonPlayerStart
- Both the idPlayerStart and idDemonPlayerStart entities are required for both Slayers and Demon Players to be present. There must be a TEAM_ONE and TEAM_TWO as well. Here is an example of a Demon Player initial spawn position.
entity {
entityDef game_online_battle_arena_demon_start_1 {
inherit = "online/battle_arena/demon_start";
class = "idDemonPlayerStart";
expandInheritance = false;
poolCount = 0;
poolGranularity = 2;
networkReplicated = true; // Keep this true
disableAIPooling = false;
edit = {
spawnPosition = { // Change spawn position
x = 1;
y = 1;
z = 1;
}
spawnOrientation = {
mat = {
mat[0] = {
x = -0.707106709;
y = 0.707106829;
}
mat[1] = {
x = -0.707106829;
y = -0.707106709;
}
}
}
team = "TEAM_TWO";
respawnOnly = true; // Set this to false for initial spawn positions, all players can respawn here regardless of team
}
}
}
Remove all other unused idPlayerStart entities.
Remove initial = true; for all PlayerStart entities as this will cause inconsistent spawn positions.
idGameChallenge
- The idGameChallenge entity must be idGameChallenge_PVP.
entity {
entityDef game_info_game_challenge_battlearena_1 {
inherit = "info/game_challenge/battlearena";
class = "idGameChallenge_PVP";
expandInheritance = false;
poolCount = 0;
poolGranularity = 2;
networkReplicated = true;
disableAIPooling = false;
edit = {
networkSerializeTransforms = false;
modeFlags = {
allowRespawning = true;
allowBulletPenetration = false;
}
difficultySettings = {
playerIncomingDamageScale = "campaign/playerincomingdamage";
aiIncomingDamageScale = "campaign/aiincomingdamage";
healthPickupScale = "campaign/healthpickup";
armorPickupScale = "campaign/armorpickup";
ammoPickupScale = "campaign/ammopickup";
bfgAmmoPickupScale = "campaign/bfgammopickup";
healthDropScale = "campaign/healthdrop";
armorDropScale = "campaign/armordrop";
ammoDropScale = "campaign/ammodrop";
bfgAmmoDropScale = "campaign/bfgammodrop";
}
scoreLimit = 99;
numLives = 0;
numRounds = 99;
actorModifierListDecl = "actormodifiers_pvp";
gcGameEventCallouts = {
slayerPowerWeapon = "pvp/combat_events/slayer_power_weapon";
demonSummonedCallout = "pvp/combat_events/general_card_event";
demonEffectCallout = "pvp/combat_events/general_card_event";
demonEffectStatusTimer = "pvp/status_timers/demon_effect";
demonQuickUse1Callout = "pvp/combat_events/general_card_event";
demonQuickUse2Callout = "pvp/combat_events/general_card_event";
damageBoostCallout = "pvp/combat_events/general_card_event";
damageBoostStatusTimer = "pvp/status_timers/timer_damage_boost";
hasteCallout = "pvp/combat_events/general_card_event";
hasteStatusTimer = "pvp/status_timers/timer_haste";
mitigationCallout = "pvp/combat_events/general_card_event";
mitigationStatusTimer = "pvp/status_timers/timer_damage_mitigation";
invulnerableCallout = "pvp/combat_events/general_card_event";
invulnerableStatusTimer = "pvp/status_timers/timer_invulnerable";
berserkCallout = "pvp/combat_events/general_card_event";
berserkStatusTimer = "pvp/status_timers/timer_berserk";
regenCallout = "pvp/combat_events/general_card_event";
regenStatusTimer = "pvp/status_timers/timer_regeneration";
lootBlockedCallout = "pvp/voiced/loot_blocked";
lootBlockedStatusTimer = "pvp/status_timers/timer_loot_blocked";
extraLifeCallout = "pvp/combat_events/general_card_event";
demonCriticalHealth = "";
demonCriticalRecovery = "pvp/voiced/demon_healed";
slayerCriticalHealth = "pvp/voiced/slayer_health_critical";
everyoneCritical = "main_heavy";
}
hitConfirmSoundsInfo = "default";
characterStatusEventText = {
invulnerableText = {
textId = "#str_decl_powerup_statuseffect_GHOST45053";
color = {
r = 0.87450999;
g = 0.87450999;
b = 0.87450999;
}
}
toughenedText = {
textId = "#str_decl_powerup_statuseffect_GHOST45054";
color = {
r = 0.87450999;
g = 0.87450999;
b = 0.87450999;
}
}
vulnerableText = {
textId = "#str_decl_powerup_statuseffect_GHOST45055";
color = {
r = 0.87450999;
g = 0.87450999;
b = 0.87450999;
}
}
strengthenedText = {
textId = "#str_decl_powerup_statuseffect_GHOST45056";
color = {
r = 0.87450999;
g = 0.87450999;
b = 0.87450999;
}
}
hastedText = {
textId = "#str_decl_powerup_statuseffect_GHOST45057";
color = {
r = 0.87450999;
g = 0.87450999;
b = 0.87450999;
}
}
slowedText = {
textId = "#str_decl_powerup_statuseffect_GHOST45058";
color = {
r = 0.87450999;
g = 0.87450999;
b = 0.87450999;
}
}
berserkingText = {
textId = "#str_decl_powerup_statuseffect_GHOST45059";
color = {
r = 0.87450999;
g = 0.87450999;
b = 0.87450999;
}
}
lootBlockedText = {
textId = "#str_decl_powerup_statuseffect_GHOST45060";
color = {
r = 0.87450999;
g = 0.87450999;
b = 0.87450999;
}
}
}
aiSpawnPoolDecl = "maps/game/pvp/battlemode";
enableBrinkOfDeath = true;
desummonKillDamage = "damage/hazard/pvp_round_kill";
slayerHighlightDecl = "pvp/demon_view_slayer_outline";
demonHighlightDecl = "pvp/demon_view_slayer_outline";
teammateHighlightDecl = "pvp/demon_view_slayer_outline";
slayerHighlightLOSBoxDecl = "highlight_los_slayer";
demonSpawnTargetEntity = "online/summon_target";
demonSpawnTargetEntityOneHit = "online/summon_target_onehit";
playerSpawnDef = "player";
teamSuperSettings = {
damageFactorAI = 9.99999975e-05;
tickFactor = 0.00329999998;
}
playerAlwaysFullBodyGibs = true;
maxPlayersPerTeam = {
ptr = {
ptr[0] = 1;
ptr[1] = 2;
}
}
demonOutlineColor = {
g = 0.501960993;
}
demonAllyOutlineColor = {
r = 1;
g = 0.501960993;
b = 0;
}
fillColorDemonSees = {
r = 0.199999988;
g = 0.199999988;
b = 0.199999988;
a = 0.850000024;
}
fillColorSlayerSees = {
r = 0.199999988;
g = 0.199999988;
b = 0.199999988;
a = 0.850000024;
}
fillColorHitFlash = {
g = 0;
b = 0;
a = 0.501960993;
}
slayerHighlightOptions = {
allyDemon = "EHM_ALWAYS";
enemyDemon = "EHM_ALWAYS";
allySlayer = "EHM_ALWAYS";
enemySlayer = "EHM_ALWAYS";
}
demonHighlightOptions = {
allyDemon = "EHM_ALWAYS";
enemyDemon = "EHM_ALWAYS";
allySlayer = "EHM_ALWAYS";
enemySlayer = "EHM_ALWAYS";
}
raceToStyle = true;
pvpGameEventCallouts = {
roundOne = "";
roundTwo = "";
roundThree = "";
roundFour = "";
roundFinal = "";
preMatchFiveSecondsRemaining = "";
preMatchFourSecondsRemaining = "";
preMatchThreeSecondsRemaining = "";
preMatchTwoSecondsRemaining = "";
preMatchOneSecondRemaining = "";
roundStart = "";
finalRound = "";
demonsRoundLost = "";
slayerRoundLost = "";
slayerKilled = "pvp/voiced/slayer_killed";
demonKilled = "pvp/voiced/slayer_killed";
demonsKilled = "pvp/voiced/slayer_killed";
slayerVictory = "";
demonVictory = "";
demonRespawningSoon = "";
respawnFiveSecondsRemaining = "";
respawnFourSecondsRemaining = "";
respawnThreeSecondsRemaining = "";
respawnTwoSecondsRemaining = "";
respawnOneSecondRemaining = "";
demonRespawned = "pvp/voiced/demon_resurrected";
delayDuringSync = {
num = 1;
item[0] = "pvp/voiced/slayer_killed";
}
}
jockeyTimeDuration = {
value = 0;
}
roundCalloutTimeDuration = {
value = 0.1;
}
roundStartCalloutDelaySec = {
value = 0;
}
roundEndCalloutDelaySec = {
value = 0;
}
upgradeUIDelaySec = {
value = 0;
}
upgradeUIDuration = {
value = 0;
}
matchEndCalloutDelaySec = {
value = 0;
}
matchEndResultsDelaySec = {
value = 0;
}
pvpProgressionScoringDecl = "battle_arena";
pvpLifecycleManager = {
podiumAvatarEntDefs = {
num = 7;
item[0] = "podiums/avatars/archvile";
item[1] = "podiums/avatars/doom_marine";
item[2] = "podiums/avatars/mancubus";
item[3] = "podiums/avatars/pain_elemental";
item[4] = "podiums/avatars/revenant";
item[5] = "podiums/avatars/marauder";
item[6] = "podiums/avatars/dreadknight";
}
podiumLayers = {
num = 1;
item[0] = "game/pvp/podium_stage";
}
slayerPodiumEntities = "pvp_match_slayer_podium_";
demonPodiumEntities = "pvp_match_demon_podium_slot_";
characterAnimBlendMS = 0;
playerAppearSound = "play_pvp_staging_spawnin";
}
slayerPVPLoadoutDecl = "pvploadout/default";
demonPVPLoadoutDecl = "pvpdemonloadout/default";
respawnTimeSec = {
branchPairs = {
num = 1;
item[0] = {
branchKey = "CONTROLLERPAD_DECL";
branchResult = {
value = 0.1;
}
}
}
}
respawnTimeCapSec = {
defaultValue = {
value = 0.1;
}
branchPairs = {
num = 1;
item[0] = {
branchKey = "CONTROLLERPAD_DECL";
branchResult = {
value = 0.1;
}
}
}
}
respawnStatusEffects = {
num = 0;
}
idealRespawnDistanceFromSlayer = 0;
slayerLayersToActivate = {
num = 1;
item[0] = "game/pvp/slayer_team";
}
demonLayersToActivate = {
num = 1;
item[0] = "game/pvp/demon_team";
}
layersToDeactivate = {
num = 1;
item[0] = "game/pvp/permanently_hidden";
}
musicWinState = "music_ghost_states/pvp_lose";
musicLoseState = "music_ghost_states/pvp_lose";
musicHealthCriticalState = "music_ghost_states/main_heavy";
closeCallHealthThreshold = 50;
clutchThreshold = 5;
comebackThreshold = 3;
surpriseTimeLimit = {
value = 3;
}
surviveThreshold = 4;
requiredPlayerCount = 2;
respawnHealthScalar = 0.5;
powerUpgradeRound = 99;
soundOcclusionBypass = false;
gameDifficulty = "DIFFICULTY_HARD";
spawnPosition = {
x = 1;
y = 1;
z = 1;
}
}
}
}
General Recommendations
Avoid the use the logic entities, they will not broadcast to clients under most circumstances.
Instead, copy the logic into actual entities and translate them into timelines, if possible.
- Delete most if not all idInteractable entities. These are usually buttons or switches that the player can activate, but they also include Mod Bots, Rune Stations, and Praetor Suit Sentinel Ghosts. If an interactable opens a door, just add an activateTarget eventCall to activate anĀ idTarget_Timeline at the end of an encounter to open it.
- Since checkpoints don't exist, it is recommended to activate portals near the Slayer spawn position when they reach farther into the map. Use a simple activateTarget eventCall to activate an idTarget_Timeline to activate an idTarget_Count relay in an encounter to do so. The relay should contain the idEntityFx, the idTarget_Spawn for the teleporter, then the idTrigger_Teleporter.
- Hidden entities with physical boundaries such as func_dynamics, doors, and teleporters, that you want to spawn in with an idTarget_Spawn later in the map, should initially spawn outside of the world's boundaries. The idTarget_Spawn should contain the desired spawn position and orientation for that entity. This must be done because clients can still interact with the entities even if they are hidden and invisible.
- Delete the spawnFXEntityDef value for the Pain Elemental idAI2 entity. These will occasionally flicker rapidly for clients.
- Get another PC/Laptop that can at least run DOOM Eternal, it doesn't have to run well, but it has to run. Buy TAG1 or TAG2 on a separate Steam account. Use this device to verify that changes to the client works as intended. This is not a required step but it will make the process much faster and more importantly, less miserable.
Recommendations for Modded BATTLEMODE
- Open a Campaign entities file (preferably from the Base Campaign since they contain leftover Invasion entities).
- Use the EternalTraversalInfoGenerator for the marauder_wolf so the playable Marauder's Wolf summon has proper traversal navigation for the entire level. Do the same for other AI that Demon Players could summon.
- Add 2 spawn positions for Demon Players and additional respawn positions. TEAM_ONE is for the Slayer team, TEAM_TWO is for the demon team. Add respawnOnly = true; to the demon respawn entities. Find the idWorldspawn entity and add the pvpRespawnZ = -500; parameter. Set it to a negative value that is just below the world's geometry. This will allow players to respawn if they fall below a certain vertical z distance.
- Barricade or add teleporters to dead ends. Energy Barrier entities found in single player can obstruct only Slayers, Demon Players, or both. There are idProp_Coop CLIPMODEL_BOX entities that are completely solid so no player can pass through them. Make sure to scale their clip model appropriately.
- Applying the demonPlayersPassThrough = false; parameter within the edit branch pair will prevent Demon Players from passing through the entity.
- Moving invisible wall func_dynamic entities to the slayer_team will help Demon Players navigate to certain locations since they cannot activate triggers as clients.
- DO NOT activate the Invasion layer. This will crash the game. Instead, remove the "game/sp/invasion" layer for the demon jump pad and portal entities. Change all instances of /sp/ in the slayer_team and demon_team layers to /pvp/ as shown below.
layers {
"game/sp/slayer_team"
"game/sp/demon_team"
}
layers {
"game/pvp/slayer_team"
"game/pvp/demon_team"
}
No Comments