Creating multiplayer-friendly ZScript

From ZDoom Wiki
Jump to navigation Jump to search

Making your ZScript code multiplayer-friendly can be intimidating at first but is usually easy to do once the rules are known. This guide will cover potential pitfalls and best practices to make sure your ZScript code is ready for multiplayer out of the box. Note that this applies to ZDoom's current peer-to-peer networking architecture and is susceptible to change in the future.

How does multiplayer work?

ZDoom uses peer-to-peer networking to handle its multiplayer, much like the original Doom. This means every individual client (i.e. player) in the game runs the game on their own machine and messages are sent over the network to keep them synchronized. In the case of ZDoom this sends over all inputs. You can think of it similar to how a demo file works: all the inputs are recorded and, when played back, will always give the same deterministic result. Instead, inputs are sent over the network and gameplay will automatically synchronize since the outcome will always be the same. However, because this is the only barrier for safety, this means desynchronizing can occur if two machines do end up reacting differently thanks to non-deterministic code. Desynchronizing, as the name implies, means two or more clients no longer have the same game state e.g. one enemy is alive on a client's machine but dead on the other. ZDoom will give a warning to all clients when this occurs, but the game is still technically playable.

Understanding scoping

Scoping is meant to act as a safety measure to keep games synchronized. Things marked with the ui scope are not synchronized across the network. Rather than every player running that code, only the client themself runs it while other players on their machine completely ignore it. play is automatically synchronized since everyone will run the code on any client's machine. Assume PlayFunction() is play scoped while UIFunction() is ui scoped. For two different clients, A and B, the execution would look like this:

A's Computer

Player PlayFunction UIFunction
A is called is called
B is called isn't called

B's Computer

Player PlayFunction UIFunction
A is called isn't called
B is called is called

You can probably see the potential pitfalls that can occur with ui functions here: one player calls it while the other doesn't, and this changes based on whose machine it is. Functions in the ui scope are also often called every frame the game renders instead of every tic like the play simulation. If one player is running at 200 FPS while the other is running at 100 FPS, the first client will call those functions twice as many times as the second client. This is the reason the play scope isn't even allowed to read from the UI, while the UI can only read from the play sim.

What causes non-deterministic code?

This can be frustrating to debug if you're new to ZDoom's networking. Sometimes it appears to be completely random and it's hard to diagnose the issue when you don't even know what to look for. Below are some of the possible causes of desyncs:

RNG

RNG, or random number generation, is one of the biggest causes of desyncs in networking. Each random function uses a seed that advances its state whenever it's called. The state of this seed determines what number is given. If a seed ends up in different states across clients, this can cause them to start generating different numbers and desynchronize. While normally you can't modify the play sim from the UI, modifying the RNG seed is the one exception. If a random call in the UI uses the same identifier as calls in the play sim, this can cause the seed's state to desync between clients. Recall that functions in the UI run on a per-frame basis instead of a per-tic one.

The fix is simple: always use a unique identifier for any random calls within your UI code.

// in a ui-scoped function
FRandom(x, y); // This is a bad idea as it uses the global seed, something commonly used in the play sim
FRandom[MyPlayIdentifier](x, y); // If you're using the same identifier as one you use in the play sim, this will still cause a desync

FRandom[MyUIIdentifier](x, y); // This is correct since this identifier is unique to the UI scope and won't disrupt the play sim

In general it's good practice to use unique identifiers for all your actions, but this is required in the ui scope to prevent desyncs.

Using consoleplayer in play scope

When a player joins a game they're automatically assigned a player number based on the order they joined the game. There are two important global variables in ZScript related to networking that track these: net_arbitrator and consoleplayer. net_arbitrator is the number of the player currently considered the "host" of the game (peer-to-peer networking has no true host but this player is in charge of setting server CVars). consoleplayer is the player number for the client. As you'd expect, this means that consoleplayer is different on every client's machine. For the person hosting the game it will be 0, for the first person to join, it's 1, etc. Since the UI only runs on the client's machine, using consoleplayer here makes sense, but using it in the play sim will cause desyncs.

// in the ui
players[consoleplayer].mo // ok

// if working with the status bar, use CPlayer instead as this is the currently viewed
// player via the spy cam
statusBar.CPlayer.mo

// in the play sim
players[consoleplayer].mo // not ok

To fix this, always make sure you're using the correct player.

Direct access (the most common method; assumes you have a pointer to the player already):

// if in an item
if (owner)
    owner.DoThing();

// if using custom monster logic
if (target)
    target.DoThing();

Another benefit of direct access is generalizing your logic. Notice that both cases above don't actually require a player. Checking myActor.player can be used to quickly determine if something is a player or not should that be needed.

If a specific player number is needed:

if (myActor.player)
    DoThing(myActor.PlayerNumber()); // Warning: This will return 0 for non-players so always check they're a valid player first

If an action should occur for all players:

// this is commonly done in EventHandlers that have player logic in WorldTick()
for (int i = 0; i < MAXPLAYERS; i++)
{
    if (!playerInGame[i] || !players[i].mo)
        continue;

    DoThing(players[i].mo);
}

MAXPLAYERS represents the max amount of players that can be in a networked game. Remember that if gameplay logic should apply to any given player, it must be applied across all of them equally. Sometimes this means storing data for all of them instead of just an individual.

PlayerData pData[MAXPLAYERS]; // use player number to know whose data goes where

Events

EventHandlers are incredibly powerful but their player-specific functions can often be used wrong. Events that apply to a specific player (e.g. PlayerSpawned(), NetworkProcess()) give a player number but it's common to see consoleplayer used instead. To fix this, make sure to check you're applying things to the right player.

override void PlayerSpawned(PlayerEvent e)
{
    players[consoleplayer].mo.GiveInventory("Item", 1); // this is wrong
    players[e.PlayerNumber].mo.GiveInventory("Item", 1); // use the passed argument
}

override void NetworkProcess(ConsoleEvent e)
{
    if (e.Name ~== "MyAction")
    {
        players[consoleplayer].mo.GiveInventory("Item", 1); // this is wrong
        players[e.Player].mo.GiveInventory("Item", 1); // use the passed argument
    }
}

Both ConsoleProcess() and InterfaceProcess() run in the UI so consoleplayer can be safely used.

FindCVar

FindCVar() can cause desyncs because it'll only get the CVar from the client's machine, not any given player's. If a user CVar is being used to modify gameplay, this means that two clients with two different values for it will cause a conflict. Both player A and B will use A's value on A's machine while they'll use B's value on B's machine. The fix here is to use GetCVar() instead and pass the appropriate PlayerInfo to its second parameter. For instance, say an item modifies a player based on their user CVar:

// assume player B is the item's owner but this is being called from A's computer
// this is wrong because we want B's value, not A's
let myCVar = CVar.FindCVar("mycvar");

// instead, use the owner's player field
let myCVar = CVar.GetCVar("mycvar", owner.player);

Remember that server CVars can be accessed directly. If you have a server CVar called sv_servercvar, in ZScript it can be directly accessed as sv_servercvar.

Client-side Prediction

This is by far the most complex topic and the most difficult to work with. Normally when trying to do something a client will have to wait for all other clients to verify their action over the network before it happens in their own game. This means if client B has a ping of 90 to client A, B trying to fire their weapon will have a minimum delay of 90ms. This can cause ZDoom to feel very uncomfortable to play online over long distances, but movement suffers greatly as all movement actions are delayed by the ping. To accommodate this, ZDoom has a lighter form of client-side prediction. In particular when the player moves, rather than waiting to verify the action, ZDoom will let the client move on their screen in real-time and, should their movements not match their verified ones, snap them back to the correct place (this is referred to as rubberbanding). The result is that the client feels as though their movement actions are instant even though they still have to be verified by other clients first.

This can also come with many pitfalls when programming custom player logic, however. While some functions are already protected against client-side prediction, movement ones aren't and custom logic in both PlayerThink() and a player's Tick() are incredibly prone to desyncing.

Logic that can break while predicting includes:

  • Modifying the play sim in some way (e.g. taking an inventory item, changing a variable, etc.)
  • Random calls
  • Triggering map actions and scripts that modify the map

As such, caution must be taken to check if the player is predicting.

if (player.cheats & CF_PREDICTING)
    // player is currently using client-side prediction

Naturally this will only need to be done in the player class itself as any logic from the play sim will only trigger when the client isn't predicting.

// in a custom non-player Actor
override void Activate(Actor activator)
{
    // the prediction check here is unnecessary as Activate() on this world Actor will only call
    // when a player isn't predicting
    if (activator && activator.player && !(activator.player.cheats & CF_PREDICTING))
        DoStuff();
}

As an example of when you'd want to consider checking for client-side prediction:

// in MovePlayer()
// consider disabling actions like this while predicting to make sure the game doesn't desync
if (!(player.cheats & CF_PREDICTING) && ShouldClimb())
  StartClimbing();

Creating Prediction-safe Code

Due to the nature of client-side prediction, predictive code only has to be taken into account when programming player class logic. All other playsim objects will be automatically synchronized. GZDoom has some built-in behaviors to guard against network desyncs, but manual guarding must be done for your own code. Even if GZDoom were to update to a client/server architecture, these same principles would still apply as the nature of client-side prediction isn't tied to any specific network architecture. Note that the UI does not need to care about prediction as it's already not synchronized over the network. Some of the basic principles for safe client-side code:

  • Do not modify anything that directly affects the world (play objects, Actors, PSprites, level geometry, etc.). The world state should stay completely untouched while predicting.
  • Do not create anything that can affect the world. Client-side objects like data/UI scoped objects or particles are fine.
  • Remove RNG calls wherever possible, instead using deterministic values. If RNG while predicting is required, use a special identifier that only gets called while predicting.
  • Do not modify any CVars.
  • Do not change the State of the predicting player.
  • Fields and properties on the predicting player itself and its PlayerInfo are safe to modify with a few restrictions:
  • Do not modify any field dynamic arrays
  • Do not modify any field maps
  • Do not modify any field strings
These value types cannot be backed up due to their nature and will not be kept synchronized correctly.
  • Do not call any play functions that can have an affect on the world unless they have a safe guard in place.
  • Properly safe guard your play functions if they are expected to be called while predicting. The player can think, move around, and teleport which involves all relevant code.
  • Both PlayerThink() and Tick() are predicted. Do not assume either are safe unless a function is listed as not being called while predicting (e.g. TickPSprite()).
  • If completely overriding PlayerThink() or Tick(), make sure to put back in the safety guards that the original code uses as this isn't automatic.

While some actions can technically cause desyncs, they are unlikely to. This includes playing sounds and modifying rendering properties. These actions generally involve no RNG and do not impact the world state in any meaningful way, so they remain fairly safe. As a word of warning, these things can be accessed from the playsim, so anyone could code logic around these that does affect the world. Programming logic around these should be avoided, however, unless that logic is also client-side safe. Never treat sound and rendering properties as network safe when creating world state altering logic.

Sending Events

Sometimes you're working on UI code and need to update the play sim. To do this, EventHandler.SendNetworkEvent() can be used. This will send the event over the network to every client and can then be processed in NetworkProcess(). The player number passed will be the client who called the event ensuring the logic can be applied to the correct player across all clients.

// in ui
EventHandler.SendNetworkEvent("blahblah");

// in the event handler
override void NetworkProcess(ConsoleEvent e)
{
    if (e.Name ~== "blahblah")
        DoStuff(players[e.Player].mo);
}

The reverse is EventHandler.SendInterfaceEvent() which can be used to send information from the play sim to the UI. A specific player number has to be passed since the code will run on everyone's machine and it must determine who specifically should accept the UI event.

// in a custom Actor
override void Activate(Actor activator)
{
    if (activator && activator.player)
        EventHandler.SendInterfaceEvent(activator.PlayerNumber(), "blahblah");
}

// in the event handler
override void InterfaceProcess(ConsoleEvent e)
{
    if (e.Name ~== "blahblah")
        DoStuff(players[consoleplayer].mo); // this is in the ui so it's safe to use consoleplayer
}

ConsoleProcess() also runs in the UI.