Skip to content

Latest commit

 

History

History
671 lines (508 loc) · 34 KB

File metadata and controls

671 lines (508 loc) · 34 KB

Odin Networking

Odin is a client/server voice and networking solutions based on latest technology and deep engine integration. Build a complete multiplayer experience with 3D audio, avatar synchronisation for hundreds of users in a couple of hours!

Odin consists of various building blocks that work together to make your development experience as simple as possible. You can drop various building blocks that we provide and built your own custom implementation.

Odin Core SDK

This is the core library built in Rust and made available as a standard C library. All our SDKs for the various platforms are built on top of this core SDK. Learn more about the core library here.

Odin C# SDK

The C# SDK is a wrapper around the Core SDK and provides a C# based interface. It's the foundation of the Unity SDK.

Odin Unity SDK

The Unity SDK offers a deep game engine integration with easy to use components and inspector scripts that allow you to create access keys and other settings directly in the Unity editor. This SDK provides the main building block for your custom voice and networking solution by exposing events that you can connect to if users connect to the server and provides ready to go voice support. Learn more about the Unity SDK in our developer documentation.

Odin Audio

We leverage Unitys audio pipeline for capture and playback. This is the reason why the integration is so simple. There are no functions that you need to call to send and receive audio data or adjusting volume based on the position to simulate 3D audio. Instead, we deeply integrated Odin into Unity. You will use Unitys AudioMixer for sound effects, and the standard AudioListener and AudioSource. The only difference is, that it's not a static media asset but that its dynamic content generated by your users in real-time (i.e. one player talking).

But, Unity has its weaknesses. Although Unity provides 3D audio support, it does not have any support for audio occlusion. Imagine two players standing 1 meter away. If one says something, the other will clearly here him. But volume will be different if the player is talking in the opposite direction. In the second case, a concrete wall is between the players. The players volume will be very low or cannot be heard at all depending on the material and thickness of the wall. Unity only handles distance, but not direction or objects in between.

This is where Odin Audio comes into play. We provide easy to use scripts that are handle direction and occlusion. For example, in your scene you just add a component to the wall and select an audio effect asset that mimics the audio damping of that material. We provide audio effects of standard materials like glass, concrete, bricks and many more, but you can also create your own.

Odin Networking

Odin Networking is built on top of the Odin Unity SDK and provides easy to use methods to built virtual experiences with ease that allow you to connect users with virtual characters and real-time 3D voice. A 3D virtual conferencing application can be built in no time, as Odin Networking provides components that automatically synchronize position, animation of all characters over the Odin servers.

Users can spawn objects in the world and can interact with the provided world in real-time. Odin Networking also integrates Odin Audio so your virtual world will automatically integrate audio occlusion (users cannot here each other if walls are in between) and even direction.

One important aspect: While Odin Networking is a powerful and easy to use system, it's mainly targeted for real-world like applications for the metaverse, board games, virtual conferences and conventions. If you want to build a fast competitive first person shooter or real-time physics racing game Odin Network is not the best solution. Choose other solutions like Mirror Networking or Photon for that use case. You should still use Odin for voice support and we have many guides on how to integrate Odin into Mirror or Photon.

Getting Started

In Odin, every peer can have arbitrary user data that is kept in sync automatically with all other peers connected to the same room. Odin also supports room data, which is arbitrary data stored for the room and also kept in sync with all peers. Last but not least, Odin provides support for messages which can be sent to all or selected peers.

Odin Networking makes use of these structures to provide a powerful and easy to use networking solution. Odin Networking automatically creates a flexible data structure that contains the position and animation of the character. It also contains so called sync vars that are properties that are automatically synced with the network. I.e. if players have different color. Next, players can spawn objects to the world. For example a player can drop an item and another player can pick it up. All this data is compiled into the peers user data and updated 10 times per second.

The system automatically and dynamically defines one peer to be the host. This host handles world objects, i.e. objects that don't have an owner and are part of the world. Think of a stack of cubes that are part of the world and can be manipulated by the users.

It's best to have one instance to have authority over the world (in other use cases a dedicated server does that). As Odin does not have a special dedicated gameserver but generic real-time data processing units, one peer in the network becomes the host. You as a developer don't have to do anything here, as Odin Networking handles all that internally for you.

Building a player prefab

First, you need to create a player object. In most cases this is human character with an Animator attached. Based on the speed or current input the Animator plays idle or walking animations and the root of the character moves around in the world. To get started you can also just use a capsule and attach a character controller to it.

Next, you need to create a new script derived from OdinPlayer and attach it to the characters root game object. Make sure to only handle any input if it's the local player, i.e. like this:

void Update()
{
   // Do nothing if this is a remote peer (position is updated from the network)
   if (!IsLocalPlayer) return;
   
   // Handle input and change transform and/or animation parameters of the character
}

void OnStartLocalClient()
{
    // Activate input controller and attach a camera
    Camera.main.followTarget = this.transform;
}

OdinPlayer has two two important requirements: Odin Ears and Odin Mouth. If you want to have real-time voice support, you need to drag the provided OdinEars and OdinMouth prefabs into your character prefab and position them where the avatars ears and mouth are located. These objects handle voice output and the listener of the player object.

Next, make sure that Sync Transform is enabled and if your character has animation also make sure that Sync Animation is enabled, too.

Last but not least, you should configure the Player prefab so that it does not handle any input and does not include any camera as we use the same player prefab for remote players and the local player. You can adjust all of this behaviour by overriding a few functions.

OnStartLocalClient is only called on the local player object, so that is the place to activate the input controller and to attach a camera to the player object.

Adding Odin Manager

Drag & Drop the Odin Manager prefab from the Odin SDK into your scene. Make these adjustments in the inspector:

  • Enter your Access Key in Client Authentication or let Odin automatically generate a key for you.
  • In the Odin Handler script make sure that Manual positional audio is selected as OdinNetworkManager will handle audio and not the Odin Handler script.
  • Make sure that Autostart Microphone is enabled in the Mic and Room Settings.

Adding Network Manager

Now you need to create a game object and add the OdinNetworkManager script to it. You can set the name of the room and various other settings.

Make sure that Auto Connect is enabled for this example and that you enter an name into Room Name (can be "World").

Drag and Drop the player prefab to the Player Prefab inspector setting. OdinNetworkManager will generate an instance of this prefab for every player that connects to same room.

In the Player Spawn Method chose Round Robbin instead of Random. This makes debugging a bit easier as spawn points will be used one after the other instead of random that can lead to collisions or overlapping players.

Adding Spawn points

We need to define the places where new players get spawned. Create a game object and attach the OdinNetworkSpawnPosition script to it. Position it somewhere in your scene. Voila, we have a first spawn point. Duplicate the object and place them also in the scene.

Adding Odin World

Last but not least, create another script and derive it from OdinWorld. This script will handle the worlds settings and properties and will automatically be kept in sync with all players. For example you could store if a light in a room is enabled or not. Then, if someone toggles the light, changes will be synced with the network. Create another game object and add your world script to it.

Press Play

If you press play, OdinNetworkManager will automatically connect to the room you provided in the inspector and will create an instance of the player prefab. You should be able to control the player and it feels much the same as if you simply build a single player game, right.

Now, Build your application and launch that, too. Make sure to have the Play button pressed in the Editor. Once the application launches it will also connect the same room, receive a message that a player already exists and will spawn two player objects at different spawn points.

You should now see two players that automatically sync position and animation.

Add Player Customization

Although this is great, it's a bit boring, as all player objects look the same. You can use Sync Vars to define player variables that will automatically kept in sync.

Adding a sync var

Add this member variable to your player script. It will be an integer that is kept in sync automatically with the network. So, if you change the value in your player object as you would in a single player app, Odin Networking automatically updates that value for each player in the network. In the hook you can provide a callback function that will be called once the value changes.

There is no need to send commands or rpc calls to do all that. Odin Networking does that all automatically for you. Just change values as you are used to and Odin Networking will make sure in the background that everything is kept in sync.

public class MyPlayer : OdinPlayer
{
    [OdinSyncVar(hook=nameof(OnBodyColorChanged))]
    public int BodyColor = 0;
  
    void Update()
    {
      if (!IsLocalPlayer) return;
      
      // Cycle through the body colors
      if (Input.GetKeyUp("space"))
      {
        BodyColor++;
        if (BodyColor >=3) {
          BodyColor = 0;
        }
      }
    }
    
    //...
}

Just syncing an integer value over the network does not change the color. Therefore, we need to do something. This is just an example. You will need to adjust the code so that it fits your characters structure but in most cases this code should change the material color of your character, or at least of one part of it.

public class MyPlayer : OdinPlayer
{
    //...
    
    public void OnBodyColorChanged(int oldColor, int newColor)
    {
        Debug.Log("BODY COLOR CHANGED");

        SkinnedMeshRenderer meshRenderer = GetComponentInChildren<SkinnedMeshRenderer>();
        if (meshRenderer == null) return;

        if (newColor == 0)
        {
            meshRenderer.materials[0].color = Color.white;
        } 
        else if (newColor == 1)
        {
            meshRenderer.materials[0].color = Color.red;
        }
        else if (newColor == 2)
        {
            meshRenderer.materials[0].color = Color.blue;
        }
    }
    
    //...
}

The hook function always has this structure: (oldColor, newColor) => void

Let's test it: Build your application and run it. Press Play in the Unity editor. You should see two player objects in the scene. Press Space in the application and the body color will change in both screens. Do the same in the Editor and it will also change in the application.

That's it. It's that easy to extend your players functionality. Instead of changing material colors you could also spawn different clothing objects like hats or change skins, whatever makes sense for you.

In a nutshell, we just build a data structure with sync vars that describe the state. And we respond to state changes by adjusting the scene. As the state is synced over the network, the same changes will be applied at all clients at (roughly) the same time and everyone sees the same things.

Understanding managed networked objects

Many use cases require players to interact with objects in the world. These objects are either part of the world, or are "created" or spawned by players. A good example is one player dropping a coin and another player picking it up to transfer money in a virtual environment.

What happens here is, that player A spawns a coin object into the world with some metadata attached to it (like the previous owner) and player B destroys the coin once he walks over the coin. Leveraging attached metadata can be used to understand who dropped the coin and its value for example.

Understanding authority

In networking authority is an important concept. In a networked world we need to make sure that every peer connected to it has (roughly) the same state of the world and everyone can interact with objects in that world. It often happens, that objects are manipulated by more than one player, for example if players collide with an object that moves - it should move for all players. But the state of each networked object must be compiled and synced over the network. To prevent that the state of the same object is sent over the network with perhaps slightly different values by every player, every networked object has exactly one peer that has authority over it.

Only the peer with authority is responsible for updating the state of that object in the network. All other peers get the updated state and just set the new values in their own copy of the world.

Authority is set by these rules:

  • Every peer has authority over its own virtual representation (identity) in the network. I.e. every peer controls its own characters state like position, animation and sync vars.
  • The peer that spawned an object in the network has authority over it
  • Objects can only be destroyed by its owner (i.e. the peer that has authority)
  • The (dynamically chosen host peer) has authority over world objects, i.e. objects that are placed at design time

Lifecycle of a managed object

Odin Networking supports this use case with managed networked objects. Each managed networked object is owned by one and only peer and thus becomes part of the update message that the peer sends 10 times a second.

Lets have a look what happens under the hood:

  • If a player (or more exact a OdinNetworkIdentity) spawns an object a message is created which is sent to all other players in the world containing an identifier which object to create and where it should be placed
  • All players receive that message in their OnMessageReceived callback function
  • The default implementation (you should always call the base method if your override that method in your own class) finds a OdinSpawnPrefabMessage with the information what object should be create and where to place it
  • Now, every player Instantiates the same prefab at roughly the same time and sets its initial transform
  • As spawnable prefabs need to have the OdinNetworkedObject script attached the sender of the message will become the owner (i.e. that player that created it) and the object will get an ObjectId that is also sent by the player who created it and uniquely identifies the object within the domain of its owner.
  • From now on, the objects transform and sync vars will be part of the creators update message
  • Every 10th of a second all players send an OdinUserDataUpdateMessage which contains information about the players state but also all objects the player has authority.
  • Every peer receives that message in the OnUpdatedFromNetwork. The object created by the player will also have an updated state of the object. So every peer now searches the managed object by the owner and ObjectId and will update the its state accordingly.
  • Only the player who has authority over an object can destroy it. So, it the player destroys the object he created no message will be sent. The creator just destroys the object from the scene so it will not become part of the next update message.
  • Peers will iterate through all managed objects in the OdinUserDataUpdateMessage. After that cycle all objects that were not part of that message will be removed as the obviously dont exist in the creators world anymore.

That's it. That's the whole lifecycle of managed objects.

Understanding unmanaged objects

Not every networked object needs to be in perfect sync on all clients. A firework for example, or think of an animation that shows an upgrade of an avatar. It may be important, that the animation of firework starts on all clients at the same time, but it's not necessary that every particle is exactly positioned. Another example would be a pigeon "created" by a magician. It should be getting visible on every client, but once its flying away, we don't need to have the same route on all clients.

For these use cases, Odin Networking supports unmanaged objects. These are prefabs that get spawned at a specific transformation on every client but once created, they are not tracked in the network, they are independently in each clients scene.

A few simply rules apply:

  • As only the creation of the object is triggered over the network, you need to make sure that the objects getting destroyed once it's not required anymore.
  • Best way to do that is to create a script and derive it from OdinNetworkedObject or just adding the OdinNetworkedObject to the prefab. This script features a Lifetime setting which if set to greater than 0 will automatically destroy the object after timeout.
  • The prefab must be added to the managed prefab list of your OdinNetworkManager instance (it's the same workflow as for managed objects)
  • You can spawn the object in the network with a simple call to OdinNetworkItem.SpawnNetworkedObject with the name of the prefab and the position and rotation.

Under the hood, a special SpawnPrefab message will be sent to all clients that will search the prefab in their prefab list and use the standard Unity Instantiate function to create an instance of it at the given position and rotation.

Syncing the world

We have come a long way with avatars synced automatically over the network and being able to spawn objects and keeping them updated over the network. However, in most use cases, players don't start in an empty world but in scenes containing objects that players/avatars can interact with. And these interactions should be visible on all clients. A simple example would be a light switch. Every user in the world can toggle the switch and it's state should be synced over the network.

As with managed objects, it's required that we have one single source of trust, that has authority over world objects. The classic solution to this is to build a dedicated server that holds the world state. While this is a very good way of doing it, it's also the most complicated one as you always have to deal with both sides, the server and the client. That is getting very complicated especially if you have a larger project. With Odin Networking we want to make it as easy as possible which will lack some features, but you don't have to handle or create a server. You just do things as you would in a single player game and Odin does the heavy lifting of syncing the state back and forth to other players.

So, how does it work.

Dynamic hosts

The concept of one single source of trust for all world objects (i.e. objects that have been created at design time and that allow users to interact with) also applies in Odin Networking. However, there is no fixed host or server. The host is determined dynamically at runtime based on some deterministic rules. To understand the concept, let's have a simple experiment:

Think of a system that requires 100 people to have the same integer number at the same time. And you want to make sure, that all of these people have the same number instantly. You could build a very complex communication pipeline and someone decides the about it's about to change that number and then needs to make sure every other peer in the group gets notified about it. That is the challenge of building a networked application. Sometimes, it's much simpler to not sending around information but instead just building a set of simple rules that will make sure that everyone will get the same number at the same point in time. This is called determinism or a deterministic algorithm. A solution for our challenge could be this: hours * 100 + minutes. So, if it's 10:23, the integer value would be 10*100+23 which is 1023. Every peer in the group will automatically generate the same number at the same time without sending anything around.

This is how Odin Networking works. Whenever its time to update the state over the network (in general it's every 1/10th of a second) every peer in the network determines the host by a deterministic algorithm. That host will pack the state of the world into his own update message and mark the message to contain host data. Every peer will get that message and will update the world state with the info packaged in that message.

How do we determine the host? It's very simple. Every peer connected to the room has a PeerId, which is an integer value identifying the peer anonymously in the network. Host is the peer with the smallest PeerId. So, usually the first player connected to the room will become host. But, if that host player disconnects, the system will automatically determine another host in real-time without any data management.

You can use the function OdinNetworkItem.IsHost to figure out if the current player is the host.

Setting up the world

To set up the networked world in the Unity editor you only have to follow these few steps:

  • Create a new script and derive it from OdinWorld. In this script, you can create sync vars that will automatically be synced over the network for global settings like the sun position.
  • Create a new game object and name it Odin World (or whatever makes sense for you) in the scene and attach your new script to it.
  • Create OdinSyncVar properties that will be synced over the network automatically.

The world still is a bit boring, so let's add some objects users can interact with:

  • Create another game object like a cube and either add the OdinNetworkedObject script to it or create a new class and derive it from OdinNetworkedObject and attach it to your new game object. You can do everything with it that you can do with managed objects. You can create sync vars and the transform will automatically synced over the network.

Syncing the world

When the scene starts, your OdinWorld script will search for all (even inactive) objects in the scene that have an OdinNetworkedObject script attached to it and add it to an internal ManagedObjects list. Whenever the host peer sends an update it will ask the world object to compile its state. It will compile the state of all managed objects in the list and the sync vars of the world object and hand it over to the host peer to sync over the network.

Every non-host peer will receive that update and will update all world objects (they have compiled in their scene).

That's it. Now you can create a world with dynamic objects and every player can interact with the world synced over the network. But there is one important concept left: Commands.

Under the hood

As defined before, the host is handling the worlds state. Although the host is determined dynamically it still applies, that there needs to be a single source of trust for the worlds state. Let's get back to our example of a light switch that can be used to toggle a light on and off. We want that switch to be synced over the network.

The easiest way to build that with Odin Networking would be to create a new LightToggle script with a sync var storing the state of the light switch. A hook function OnLightEnabled would be added and called whenever the state of the sync var changes with code to enable or disable the light. Let's have a look into that script:

class LightToggle: OdinNetworkedObject
{
    [OdinSyncVar(hook=nameof(OnLightEnabledChanged))]
    book lightEnabled = true;
    
    private Light _light = null;
    
    void Start()
    {
      _light = GetComponent<Light>();
      if (_light == null) return;
      _light.enabled = lightEnabled;
    }
    
    void OnLightEnabledChanged(bool oldValue, bool newValue)
    {
      if (_light == null) return;
      _light.enabled = newValue;
    }
    
    void ToggleLight()
    {
      // This will set the value of the sync var which will be automatically be 
      // synced over the network in the next interval
      lightEnabled = !lightEnabled;
    }
}

As simple as it gets. Whenever a player toggles the light, i.e. using the Use key close to the toggle, you would call the ToggleLight function which will set the sync var value which will then be synced as part of the update state of the host in the next interval.

Wait! This sounds like that only the host can adjust the worlds state. But, what if a player is not the host? In most networking frameworks "Commands" are used for that. So all non-host clients need to send a command to the server, which then changes the world state and syncing it back to clients. This quickly becomes messy, as you always have to think about that client-server structure. In some use-cases, like competitive multiplayer this makes sense because cheating is getting more difficult. But with Odin Networking we prioritize simplicity over cheat protection.

With Odin Networking you can can also change the world state from clients without sending commands. So, if a player toggles the light you don't have to care if the player is the host or not. You just change the world state and Odin Networking make sure that this change gets distributed over the network.

What happens under the hood is this:

  • Every interval the system identifies sync vars of world objects that have been changed (locally)
  • If the local player is host, sync vars are updated and will get compiled as usual as part of the world update package as described above
  • If the local player is not a host, we need to do it differently, as only the host can distribute the new values. Instead an OdinUpdateSyncVarMessage command (see below) is sent to the (current) host.
  • The host will receive the command and will update the sync var on the other peers.

Remote Procedure Calls (RPCs)

RPCs are a standard concept in networking and stands for Remote Procedure Call. The idea is to provide a way to execute a function on a remote peer. Odin Networking supports RPCs via the OdinRpcMessage class which is derived from OdinMessage and as such can be sent via the SendMessage or SendCommand interfaces.

You create an instance of that class and provide the function name and the parameters. We support up to four parameters:

// Prepare calling function named ToggleLight on remote peers with one boolean parameter
OdinRpcMessage command = new OdinRpcMessage("ToggleLight", world.lightEnabled);

// Prepare calling a function named SetPosition on remote peers with three float parameters
OdinRpcMessage command = new OdinRpcMessage("SetPosition", transform.position.x, transform.position.y, transform.position.z);

The functions will look like this:

public void ToggleLight(bool enabled)
{    
    Debug.Log($"Toggle Light RPC received: {enabled}");
}

public void SetPosition(float x, float y, float z)
{    
    Debug.Log($"Set Position RPC received: {x}, {y}, {z}");
}

After you have created your RPC you need to send it over the network. Remote peers will receive that message and will try to call the function given by the name using standard C# method invoking. If the method can not be found an error will be posted in the console.

You can either send RPCs as a command or a message. The difference is that messages are sent to all remote peers and commands are only sent to the (current) host.

Commands

Commands are messages that are sent from one peer to the host. The host uses these commands to update the world state. This way, also peers can modify the world without being host.

Under the hood, commands are standard messages sent via the SendCommand function that is available in OdinNetworkIdentity. To send the rpc message that we created earlier (ToggleLight) use this code:

public class MyPlayer: OdinPlayer
{
    /// ...
    
    // Called if the player toggles the light switch
    void OnLightToggled() 
    {    
        // Prepare calling function named ToggleLight on remote peers with one boolean parameter
        OdinRpcMessage command = new OdinRpcMessage("ToggleLight", world.lightEnabled);
        
        // Send the rpc message to the host
        SendCommand(command);
    }
    
    // This will only be called on the host (if sent via SendCommand)
    [OdinCommand]
    void ToggleLight(bool enabled)
    {
        Debug.Log($"Toggle Light Command received: {enabled}");
    }
    
    /// ...
}

Message

Sending RPCs as a message just broadcasts that function call to all remote peers. If you want something happen on all peers at once (without changing the world state) you can send an RPC call to all peers using the SendMessage function.

This is useful if you want to trigger an animation, or you can also trigger a game over sequence if one peer in a cooperative game has lost his last life.

public class MyPlayer: OdinPlayer
{
    /// ...
    
    // Called if the player has lost his last life
    void OnGameOver() 
    {    
        // Prepare calling function named ToggleLight on remote peers with one boolean parameter
        OdinRpcMessage command = new OdinRpcMessage("LoadGameOverScene");
        
        // Send the rpc message to all peers that will execute that function at the same time
        SendMessage(command);
    }
    
    // Called on all peers at (roughly) the same time
    void LoadGameOverScene()
    {
        SceneManager.LoadScene("GameOver");
    }
    
    /// ...
}

Custom Messages

OdinNetworking comes with a variaty of internal messages that handle the sync of world state and calling remote procedures or spawning prefabs.

However, you might want to create your own messages. For this, you have two options:

  • Using the OdinCustomMessage class and the OnCustomMessageReceived callback
  • Creating your own message classes and handling them in OnMessageReceived

Let's dive into both options:

Using OdinCustomMessage

This is the easiest option because you don't have to create your own message class. An OdinCustomMessage has a name and a key value store attached to it. So they are very flexible:

// Code executed by the client
OdinCustomMessage message = new OdinCustomMessage("ShowNotification");
message.SetValue("message", "I am the king");
OdinNetworkManager.Instance.SendMessage(message);

The remote peers will receive that message object in the OnCustomMessageReceived function and will handle it like this:

// Code executed on the host
public override void OnCustomMessageReceived(OdinCustomMessage message)
{
    if (command.Name == "ShowNotification")
    {
        // Show a notification
        ShowNotification(message.GetValue("message"));        
    }
}

As you can see, this is pretty simple. However, there is one caveat: You won't get any design time help from your IDE for this. If there is a typo there is no way for Odin to prevent that. So, debugging might get a bit difficult. For simple use cases or prototyping this is not a big deal, but for larger projects we suggest creating your own messages.

Custom Message Classes

This is the most flexible option. You can create your own message classes and handle them in the OnMessageReceived function.

Please note: You will also need to implement serialization and deserialization for your custom message classes. That is pretty simple, but it makes the whole process a bit more complicated.

So, let's dive in:

public class ShowNotificationMessage : OdinMessage
{
    public string Message;
    
    public ShowNotificationMessage(string message) : base(name)
    {
        Message = message;
    }
    
    // We need to implement deserialization
    public OdinRpcMessage(OdinNetworkReader reader) : base(reader)
    {
        // Read a string from the memory store 
        Message = reader.ReadString();        
    }

    // We need to implement serialization
    public override OdinNetworkWriter GetWriter()
    {
        // Create a memory store and write the string to it
        OdinNetworkWriter writer = base.GetWriter();
        writer.Write(Message);        
    }
}

That's it. Now you can send your custom message like this:

// Code executed by the client
ShowNotificationMessage message = new ShowNotificationMessage("I am the king");
OdinNetworkManager.Instance.SendMessage(message);

The peers will receive that message object in the OnCustomMessageReceived function and will handle it like this:

// Code executed on the host
public override void OnMessageReceived(OdinMessage message)
{
    if (message is ShowNotificationMessage)
    {
        // Show a notification
        ShowNotification((message as ShowNotificationMessage).Message);        
    }    
}

As you can see, this is a bit more complicated. However, you get full IDE support, code analyses, and many other features that make your life easier once your project is getting a larger and more complex.