Multiplayer and Networking
Massive Loop VR fundamentally is a multiplayer platform, and you as a developer are responsible to make sure that the (necessary) objects and events in your worlds are synchronized properly between all players who join the game.
Multiplayer network structure
A Multiplayer network follows a shared structure. The full control of the game is shared between the clients, and the server only provides the communication means between the clients. Players are placed in multiplayer groups called rooms. Each world can have multiple rooms as per demand, but the players from different rooms cannot interact with each other. What happens in a room, stays in a room!
Every movement of the players, or other world events, synchronized with all players. As the number of players grows, the amount of data that is interchanged also grows, therefore the rooms have a limit on how many players can join.
Always one of the players in a room is selected by the network as the master player or master client. The Master Client can be used to keep track of events happening in the world and execute scripts that are related to the room. For example, in an FPS game, the state of the game, team scores can be handled by the master client.
Master client vs non-master client.
The only difference between the master client and a non-master client is the behavior. When joining the game, they both have access to the same assets and load the same scripts for the given world. Master client is still a player itself but may execute extra code to manage the world, game or etc.
Master client migration
So what happens if the master player leaves the game. This is a process called master client migration or host migration for short. In this case, the network selects another player in the room and declares them as the master client. The newly chosen master client starts executing the master duties immediately.
State of codes
Host migration does not transfer the state of executing code! The state of LAU scripts running in the host will be lost when the host migration happens. The new host starts executing the scripts from the beginning. All the states of variables that evolved during the execution of the code will be lost.
Let's consider this scenario:
In your world, you implemented a door. This door will allow the player to enter a room. The door has a state (open or close)
Now look at this code which is running in the master client and controlling the door:
- C#
- Lua
In this case we use the extension methods for MonoBehaviour
to register an event handler, and invoke the event over network. By using these extension methods, we can create an local event.
using ML.SDK;
using UnityEngine;
public class Script : MonoBehaviour
{
const string EVENT_ID = "DoorEvent";
bool doorState;
EventToken token;
private void OnDoorEvent(object[] args)
{
if (args.Length > 0)
{
bool newState = (bool)args[0];
if (doorState && !newState)
{
// door is open and new state requires to be open
Close();
doorState = false;
}
if(!doorState && newState)
{
// door is close and new state requires to be close
Close();
doorState = true;
}
}
}
public void Open()
{
// invoke event over network, with true
this.InvokeNetwork(EVENT_ID, EventTarget.All, null, true);
}
public void Close()
{
// invoke event over network
this.InvokeNetwork(EVENT_ID, EventTarget.All, null, false);
}
public void Start()
{
// register an event handler for the event id
token = this.AddEventHandler(EVENT_ID, OnDoorEvent);
}
}
do -- Door
local Door = LUA.script;
-- The state of door. true for open and false for close.
local doorState = false;
-- define that handler function when door state change requested
local function onDoorEvent()
if doorState then
close();
doorState = false;
else
open();
doorState = true;
end
end
function Door.Start()
-- attach the handler to the event.
eventId = LuaEvents.add("DoorEvent", onDoorEvent);
end
function open()
-- code for openning the door
LuaEvents.invokeForAll("DoorAction", true);
end
function close()
-- code for closing the door
LuaEvents.invokeForAll("DoorAction", false);
end
end
Here, the variable doorState
defines the state of the code. The problem is that when the host migration happens, the current state of the door is lost as the script in the new host will set the door state to false
or close again. If the door was open before the host migration, the state of the door in the master client conflicts with the real state of the door.
As you can see in this example, this will cause lots of issues in the games, where losing the state of something might have more important consequences than the door example.
Synchronized Variables
To remedy that, the Massive Loop incorporates Synchronized Variables. Synchronized variables stored in all clients and any change to them (from any player) will propagate to all players.
The Door example with synchronized variables would look likes this:
- C#
- Lua
In C#, you can make fields with in any MonoBehaviour
synchronized by attaching [MLSyncVar]
attribute to it.
using ML.SDK;
using UnityEngine;
public class Script : MonoBehaviour
{
/// <summary>
/// synchronized door state
/// </summary>
[MLSyncVar]
bool doorState;
bool currentDoorState;
public void Open()
{
// code for opening door
}
public void Close()
{
// code for closing door
}
public void Update()
{
if(doorState != currentDoorState)
{
currentDoorState = doorState;
if(currentDoorState)
{
Open();
}
else
{
Close();
}
}
}
}
Here, we declare the doorState
as a synchronized variable by adding an [MLSyncVar]
attribute to the doorState
field. This ensures the doorState
to be synchronized between all players. Here, each player keeps a copy of the doorState
and when one of the players makes changes to it, it will propagate to other players.
Only fields that are defined in MonoBehaviour
s can be made synchronized. In any other case, the attribute wont have any effect.
do -- Door
local Door = LUA.script;
-- Make Door state a synchronized variable
local doorState = SyncVar(Door, "DoorState");
local function open()
-- code for openning the door
end
local function close()
-- code for closing the door
end
-- define that handler function when door state change requested by button
local function onDoorEvent()
if doorState.SyncGet() then
close();
-- change the sync variable state
doorState.SyncSet(false);
else
open();
-- change the sync variable state
doorState.SyncSet(true);
end
end
-- to act when the value of the sync variable changed by other clients
local function OnVarChanged(val)
if val then
Open();
else
Close();
end
end
function Door.Start()
-- Check if this is the first client
if doorState.SyncGet() == nil then
doorState.SyncSet(false);
else
-- handle the initial state of the door
if doorState.SyncGet() then
Open();
else
Close();
end
end
-- attach the handler to variable change
doorState.OnVariableChange.Add(OnVarChanged);
-- attach the handler to the event. Will invoked using a UI button.
eventId = LuaEvents.Add("DoorEvent", onDoorEvent);
end
end
Here, we declare the doorState
as a synchronized variable by calling doorState = SyncVar(Door, "DoorState")
function. This ensures the doorState
to be synchronized between all players. Here, each player keeps a copy of the doorState
and when one of the players makes changes to it, it will propagate to other players.
Local Vs Global Variables
- C#
- Lua
Synchronized Variables can be local or global. A local synchronized variables scope is with in the (game object + MonoBehaviour
) instance. It means that the a local synchronized variables is different with an other local synchronized variable in an other game object. You can define a global synchronized variable by making the synchronized field static.
[MLSyncVar]
public static bool doorState;
Synchronized Variables can be local or global. A local synchronized variables scope is with in the (game object + ) instance. It means that the a local synchronized variables is different with an other local synchronized variable in an other game object, or script with the same name.
Limitations:
There are few limitation on the synchronized variables:
Type:
The synchronized variables type can change at any time during the game, however, only the value of Serializable Types gets synchronized. If a none-serializable type provided, it will synchronized as a nil
.
Size
There can be any number of synchronized variables, but each frame will only synchronize 1 KB of data. It means if the total size of all changed variables exceeds the 1 KB limit, then some variables might take more than one frame to be synchronized. For the same reason, any variables that is bigger than 1 KB would not synchronize (a nil will sent instead). Note that each variable have some other overheads such as variable name and etc. So make sure that the variable sizes are less that 700 Bytes. This is of course only valid for strings and arrays. Strings are serialized with UTF-8 encoding, so one character can take between one to four bytes.
Network Lua Events
As seen in the door example above, the Events can be raised over the network.
Check out the article on ML Events System
Adding an "Event Handler" for purpose of using it as a network event is exactly the same. As a matter of fact, an event handler can be used for both purposes. Internally and over the network.
Given the handlers exist in the intended target, we can call a different Invoke method to raise the event in specified targets.
In any over the network event, we have the following actors:
- Sender: The client which raises the event (calls the Invoke method).
- Receiver(s): The client in which the handlers are raised (Invoked upon).
- Master: The master client.
- Other Players: All players, except the sender.
- All Players : All players, including the sender.
Events can be rased using different Target modes:
-
Invoke for All
This mode invokes the handlers that are attached to given
eventName
in all players. This includes the sender as well.For example, if player 4 is raising the event, this is how the network would look like.
The event initiates from player 4, goes to the cloud, and from there propagates to other players.
-
Invoke for Others
Similar to Invoke For All but this time the sender won't receive the event.
-
Invoke for Master
This mode invokes the handlers in only the Master Client.
- Invoke for Local Only
This mode only invokes handler in the sender client (not networked)
Synchronized Transform, Instantiation
The MLSynchronizer
component is another way of synchronizing in massive loop. The MLSynchronizer works together with other components or it's Sync Modules to provide synchronization.
Sync modules are used for continuous synchronization of the object properties. Available Sync Modules are:
- Transform Sync Module, which can synchronizes the position, rotation or scale of the object.
- AnimationSyncModule, which can synchronize the animator layer weights and parameter values.
Synchronizing the Instantiation and Destruction.
when Synchronize Instantiate and Destroy
option selected, the MLSynchronizer
will attempt to synchronize the instantiation and the destruction of the game object. This works only in instances described bellow:
- Instantiating a prefab with
MLSynchronizer
attached on editor. - Instantiating a Scene object with
MLSynchronizer
which added to the scene in editor. - Instantiating a Scene object with
MLSynchronizer
which itself instantiated with the conditions above.
When the above conditions met, it is sufficient to just call
Object.Instantiate(gameobject)
or any of its other arrangements from one client to get object instantiated in all clients in the multiplayer session.
Check out the Instantiate
API: Object.Instantiate
Any object with conditions above also synchronously destroyed using
Object.Destroy(gameobject)
Ownership
MLSynchronizer can have a Network Ownership state and all game objects which are inherit from its game object will assume that state. The ownership state define which client in the room can write the values to the network. This is specifically important to operation of Sync Modules. The Sync Module which is in the owner client, write its current values to the network, others read and apply the value read.
For example, Lets have an object in the scene with Rigidbody component and Transform Sync Module which is synchronizing its position and rotation. This object will exists in all the user client who are in the room, but only one client can write the position and rotation value of that object to the network. That client is called the owner of that object. By default, scene objects are all owned by the Master client. However actions like grabbing that object or calling RequestOwnership
can transfer the ownership of the object to other players.
In our example, this is a critical information that we need to be aware of because actions like applying force, torque or velocity to the Rigidbody, or even moving the object by script will only be effective if applied in the owner client.
There are few way to know the current owner of the object or acquire it.
- C#
- Lua
OnOwnerChanged
messaged called when object ownership changed.
MonoBehaviour.OnOwnerChanged(int newOwnerActorNumber);
OnBecomeOwner
message called when current client become the object's owner.
MonoBehaviour.OnBecomeOwner();
OnLostOwnership
message called when current client lost the ownership of the object.
MonoBehaviour.OnLostOwnership();
here is an example on how to use these messages:
using ML.SDK;
using UnityEngine;
public class Script : MonoBehaviour
{
public void OnOwnerChanged(int newOwnerActorId)
{
MLPlayer newOwner = MassiveLoopRoom.FindPlayerByActorNumber(newOwnerActorId);
}
public void OnBecomeOwner()
{
// this client become the new owner of game object
}
public void OnLostOwnership()
{
// this client lost the owner ship of this game object
}
}
- The Gameobject.owner property. This property will return a MLPlayer owner of the object if the game object or its parents have an MLSynchronizer with a Network Ownership state.
- The Gameobject.RequestOwnership() method. You can call this method to transfer the ownership of the object to current player.
- The OnOwnerChanged callback. This callback called when the owner of the object is changed.
- The OnBecomeOwner callback. This callback called when this client becomes the network owner of the object.
- The OnLostOwnership callback. This callback called when this client loses the ownership status of the object.
Special cases
- When an owner of an object leaves the room, the master client becomes the new network owner of the object.
- When the master client leaves the room, all of the object owner by it transfers to the new master client.
Best practices
Performance considerations.
Having a lag-free game and smooth synchronization is key for the best player experience. Every data that needs to be synchronized must be distributed through the network and to all players. When building the worlds, you must conscious of the effects of the synchronizations you incorporate into your worlds.
Here are few recommendations and best practices for managing synchronizations in your game:
Synchronized objects:
Although the easiest method for synchronizing, it is the most costly one in terms of data transfer. The theoretical limit of the number of synchronized objects in a world is 999 but practically, it is recommended to keep the number of synchronized objects as low as possible. Use synchronized objects only if absolutely necessary. You can use synchronized variables, or network events instead of synchronized objects. For example, for a swarm of enemies, you can implements a single script running in the master client and update the location of enemies using events.
Network Events
Network events can be the most efficient way to send information through the network. However, because it is possible to send any amount of data with them, network events may have the potential to be inefficient. The trick is to avoid sending large variables with events.
Here are few other considerations about the events:
-
Avoid raising too many events all at once. This will cause congestion on delivery and may even cause the server to crash. Note: Events are used internally to control other aspects of the game.
-
Send events to proper target. Send an event to the player who needs it. Check receivers on Lua events.
-
Try not to send variables with events. For instance, in the door example above, instead of using the event
DoorAction
with a bool variable to indicate the state of the door, like as follow
- C#
- Lua
public void Open()
{
// invoke event over network, with true
this.InvokeNetwork("door-action", EventTarget.All, null, true); // one event with variable
}
public void Close()
{
// invoke event over network
this.InvokeNetwork("door-action", EventTarget.All, null, false);
}
we can have the following which each state of the door represented by a separate event:
public void Open()
{
// invoke event over network, with true
this.InvokeNetwork("door-open", EventTarget.All, null);
}
public void Close()
{
// invoke event over network
this.InvokeNetwork("door-close", EventTarget.All, null);
}
function open()
-- code for openning the door
LuaEvents.InvokeForAll("DoorAction", true); -- one event with variable
end
function close()
-- code for closing the door
LuaEvents.InvokeForAll("DoorAction", false);
end
we can have the following which each state of the door represented by a separate event:
function open()
-- code for opening the door
LuaEvents.InvokeForAll("DoorActionOpen"); -- event which opens the door
end
function close()
-- code for closing the door
LuaEvents.InvokeForAll("DoorActionClose"); -- event which closes the door
end
Synchronized variables
Any update of any synchronized variable will require this data to be propagated to all players in the room. To increase the efficiency and reduce the network usage, Try synchronizing basic types. Avoid synchronizing strings and arrays.