This document is about: SERVER 5
SWITCH TO

Photon Plugins Manual

Photon Plugins are available only for Enterprise Cloud or self-hosted Photon Server.

To discover what's new in Photon Server Plugins SDK v5 see here. For the changes of each Plugins API version (PhotonHivePlugin.dll) see the Plugins API Changelog.

To build a plugin from reusable modules (including the shipped Security, Voice and WebHooks modules) see Modular Plugins.

For best practices and frequently asked questions see the Photon Plugins FAQ.

Introduction

The Plugins API has been designed close to the core flow (create room, join, leave, etc.)

  1. keeping a high degree of flexibility: allowing to hook into the core flow before and after its processing.
  2. minimizing chances to break the flow: failing fast providing errors on client and server.
  3. allowing to use lock free code: plugin instances will never get more than one call at the time and the framework provides a HTTP client and timers integrated in the underlying Photon message passing architecture (fibers).
  4. lowering complexity and increasing ease of use: see "Minimal Plugin".

Concept

To add custom server logic, you inject your code into predefined Photon server hooks. Currently Photon server supports GameServer plugins only as hooks are triggered by room events.

By definition, a Photon plugin has a unique name and implements those event callbacks. Custom plugins are compiled into a DLL file called plugins assembly. Then, the required files are "deployed" on your Photon server or uploaded to your Enterprise Cloud.

The configured plugins assembly is dynamically loaded at runtime with each room creation. A plugin instance is then created based on a factory pattern.

The room which triggers the plugin creation is called the "host" game. The latter can be directly accessed from plugins and they both share the same lifecycle.

Webhooks is a good example of Photon plugins. Source code of Webhooks 1.2 is available in the plugins SDK. We encourage you to dig into it.

Basic Flow

The Photon hooking mechanism relies on a 6 steps flow:

  1. Intercept the hook call
    When the callback is triggered, the host transfers control to the plugin.
  2. [optional] Alter call info
    Access and modify request sent by client/server, before it is processed.
  3. [optional] Inject custom code
    Interact with the host before processing call (e.g. issue HTTP request, query room/actor, set timer, etc.)
  4. Process hook call
    Decide how and process the request (see "ICallInfo Processing Methods")
  5. [optional] Inject custom code
    Once processed, the request sent by client/server is useful "as read-only". However, plugin can still interact with host even after processing.
  6. Return
    Plugin returns control to host.

Photon Server 5.x and 6.x

The plugins API is the same in Photon Server 5.x and 6.x, but the two lines run plugins on a different .NET runtime:

Photon Server 5.x Photon Server 6.x
Runtime .NET Framework 4.7.1 .NET (formerly .NET Core)
Plugin target framework net471 .NET, e.g. netcoreapp3.1
Plugins SDK folder with the API assemblies "src-server\Plugins\lib\net471" "src-server\Plugins\lib\netcoreapp3.1"

Your plugin has to be built for the runtime of the server it runs on, referencing the assemblies of the matching SDK folder. The same source code can usually be compiled for both runtimes.

"PhotonHivePlugin.dll" and "Photon.Plugins.Common.dll" are provided by the server: the server always uses its own copy, whatever is deployed next to your plugin. Your plugin's other dependencies are loaded from the plugin's folder. In 6.x every plugin is loaded in its own assembly load context, so deploy the whole build output of your plugin including the ".deps.json" file generated by the build.

Getting Started

Minimal Plugin

A "Step by Step Guide" is available for beginners.

The Plugin

The recommended and easy way of making plugins is to extend PluginBase class instead of implementing all IGamePlugin methods directly. Then you can override just the ones you need.

To obtain a minimal plugin implementation, you only need to override the PluginBase.Name property. It is what identifies a plugin.

"Default" and "ErrorPlugin" are reserved names used internally and should not be used as a custom plugin name.

C#

namespace MyCompany.MyProject.HivePlugin
{
  public class CustomPlugin : PluginBase
  {
    public override string Name
    {
        get
        {
            return "CustomPlugin"; // anything other than "Default" or "ErrorPlugin"
        }
    }
  }
}

The Factory

A plugin factory class is expected to be implemented as part of the plugins assembly. It is responsible for the creation of a plugin instance per room. For the sake of simplicity, in the following snippet the factory returns an instance of CustomPlugin by default without checking the plugin name requested by client.

C#

namespace MyCompany.MyProject.HivePlugin
{
  public class PluginFactory : PluginFactoryBase
  {
    public override IGamePlugin CreatePlugin(string pluginName)
    {
        return new CustomPlugin();
    }
  }
}

Configuration

Enterprise Cloud Configuration

To add a new plugin:

  1. Go to the dashboard of one of the supported Photon product types.

  2. Go to the management page of one of the Photon applications listed there.

  3. Click on the "Create a new Plugin" button on bottom of the page.

  4. Configure the plugin by adding key/value entries of type string. Configuration is done by defining key/value pairs of strings. The maximum length allowed for each string is 256 characters. The required settings are:

    • AssemblyName: Full name of the DLL file uploaded containing the plugins.
    • Version: Version of the plugins. The same version string used or returned when uploading the plugins' files using the PowerShell script provided by Exit Games.
    • Path: Path to the assembly file. It should have the following format: "{customerName}\{pluginName}".
    • Type: Full name of the PluginFactory class to be used. It has the following format: "{plugins namespace}.{pluginfactory class name}".

Self-Hosted Server Configuration

Add or modify the following XML node in the "plugin.config" file of your GameServer application ("deploy\LoadBalancing\GameServer\bin\plugin.config"). The default attributes of <Plugin.../> element are presented in the example below. Only "Version" is not required. You can add other optional configuration key/value pairs that are passed to the plugin code. You can easily activate or deactivate plugins by changing Enabled attribute value in <PluginSettings> element.

XML

<Configuration>
  <PluginSettings Enabled="true">
    <Plugins>
      <Plugin
          Name="{pluginName}"
          Version="{pluginVersion}"
          AssemblyName="{pluginDllFileName}.dll"
          Type="{pluginNameSpace}.{pluginFactoryClassName}"
      />
    </Plugins>
  </PluginSettings>
</Configuration>

Details about this configuration file:

  • The file is looked up in the GameServer binaries folder under the name set by the Photon:Hive:PluginConfig setting ("plugin.config" by default).
  • Enabled is false if the attribute is missing. If the file is missing, can't be parsed or is invalid, plugins are disabled, and the reason is logged.
  • Only the first <Plugin /> element is used.
  • All attributes of the <Plugin /> element, except Name, Type and AssemblyName, are passed as key/value strings (key names are case-sensitive) to the plugin factory (FactoryParams.PluginConfig) and to IGamePlugin.SetupInstance (the config argument). This includes Version.
  • The attribute IsPersistent (default false) is also read by the server. It should be set to true if the plugin loads and saves room states, e.g. WebHooks. When it is true, the attribute AsyncJoin (default true) controls whether a join request can create a room that is not in memory.
  • Changes to the file are detected while the server is running and are applied without a restart.

The plugins DLL, its dependencies and other files required by the build must be placed in the folder "deploy\plugins\{pluginName}\{pluginVersion}\bin\" according to the configured value of plugin name.

At least these two files should there:

  • "deploy\plugins\{pluginName}\{pluginVersion}\bin\{pluginDllFileName}.dll"
  • "deploy\plugins\{pluginName}\{pluginVersion}\bin\PhotonHivePlugin.dll"

The plugin assembly must reference "PhotonHivePlugin.dll", otherwise the plugin can't be loaded. The version of the plugins API the plugin was built against must be compatible with the one of the server: same major version and a minor version not higher than the server's (see the Plugins API Changelog). If it is not, the plugin fails to load with "Plugins version mismatch" logged by the server.

If the "Version" configuration key is not used or its value is kept empty, the path will be "deploy\plugins\{pluginName}\\bin\". And since "\\" is treated as "\" the expected path is "deploy\plugins\{pluginName}\bin\".

So at least these two files should there:

  • "deploy\plugins\{pluginName}\bin\{pluginDllFileName}.dll"
  • "deploy\plugins\{pluginName}\bin\PhotonHivePlugin.dll"

Plugin Factory

Our plugin model uses a factory design pattern. Plugins are instantiated by name on demand.

A single Photon plugins assembly may contain multiple plugin classes and one active "PluginFactory". Although it is possible, there is no particular use of making more than one "PluginFactory". On the contrary, writing multiple plugin classes can be very useful. For instance, you can have a plugin per game type (or mode or difficulty, etc.). The factory could also be used to serve multiple plugin versions. Read more about this use case.

Client creates rooms requesting a plugin setup using roomOptions.Plugins. roomOptions.Plugins is of type string[] where the first string (roomOptions.Plugins[0]) should be a plugin name that will be passed to the factory.

Examples:
roomOptions.Plugins = new string[] { "NameOfYourPlugin" }; or
roomOptions.Plugins = new string[] { "NameOfOtherPlugin" };

If the client doesn't send anything, the server will either use the default (when nothing is configured) or whatever the plugin factory returns on create if it is configured.

If the name of the returned plugin in PluginFactory.Create does not match the one requested by client, then the plugin will be unloaded and the client's create or join operation will fail with PluginMismatch (32751) error.

Also, currently, roomOptions.Plugins should contain one element (plugin name string) at most. If more than one element is sent (roomOptions.Plugins.Length > 1) then a PluginMismatch error will be received and room join or creation will fail.

If the client sends an empty roomOptions.Plugins array, the plugin used by the room must be the "Default" one, otherwise PluginMismatch is returned too. If roomOptions.Plugins is not set at all (null) the name is not validated.

In the factory just use the name to load the corresponding plugin as follows:

C#

using System.Collections.Generic;

public class PluginFactory : IPluginFactory
{

    public IGamePlugin Create(IPluginHost gameHost, string pluginName, Dictionary<string, string> config, out string errorMsg)
    {
        IGamePlugin plugin = new DefaultPlugin(); // default
        switch(pluginName)
        {
            case "Default":
                // name not allowed, throw error
            break;
            case "NameOfYourPlugin":
                plugin = new NameOfYourPlugin();
            break;
            case "NameOfOtherPlugin":
                plugin = new NameOfOtherPlugin();
            break;
            default:
                //plugin = new DefaultPlugin();
            break;
        }
        if (plugin.SetupInstance(gameHost, config, out errorMsg))
        {
            return plugin;
        }
        return null;
    }
}

ICallInfo Processing Methods

The idea of the plugin is that you hook into the "normal" Photon flow before or after the server processes the incoming request. The developer needs to decide what to do using one of the 3 available methods depending on the request type:

  1. Continue(): Used to resume the default Photon processing.
  2. Cancel(): Used to silently cancel processing, i.e. without any error or other notification to the clients. It is equivalent to skipping further processing:
    • Called inside OnRaiseEvent will ignore the incoming event.
    • Called inside BeforeSetProperties will cancel properties change.
  3. Fail(string msg, Dictionary<byte,object> errorData): Used to cancel further processing returning an error response to client. From client, you can get msg parameter in OperationResponse.DebugMessage and errorData in OperationResponse.Parameters.

Notes:

  • Strict mode is always enabled (PluginBase.UseStrictMode is obsolete and has no effect). Strict mode means that a call to one of the ICallInfo processing methods is expected in each plugin callback. If a callback returns without calling any of the available methods, the server reports a MissingCallProcessing error to IGamePlugin.ReportError and fails the call: the client receives ErrorCode.PluginReportedError. The only exceptions are calls that are paused (a synchronous HTTP request was sent, see Outbound HTTP Calls) and, in BeforeJoin, OnRaiseEvent, BeforeSetProperties and OnCloseGame only, calls deferred by an asynchronous HTTP request or a timer created for the call info. In the other callbacks the call has to be processed before the callback returns. Read more here.
  • All callbacks of the PluginBase implementation of IGamePlugin call {ICallInfo}.Continue().
  • Continue(), Fail() and Cancel() are expected to be called only once. Calling any of them again throws an exception.
  • Cancel() can only be called inside OnRaiseEvent, BeforeSetProperties or OnRawMessage.
  • Defer() (available on IBeforeJoinGameCallInfo, IRaiseEventCallInfo and IBeforeSetPropertiesCallInfo) marks the call as deferred to be processed later. It is not needed when you send an asynchronous HTTP request or create a timer for the call info: the call is deferred automatically then.
  • All classes that implement ICallInfo expose the client's original operation request when available. You can get the operation code and parameters from the {ICallInfo}.OperationRequest (or Request) property.
  • All classes that implement ICallInfo include helper properties to inform you of the processing CallStatus of the operation request:
    • IsNew: Indicates if the request was not processed nor deferred.
    • IsProcessed: Indicates if the request has already been processed (i.e. Continue or Cancel or Fail method was called).
    • IsSucceeded: Indicates if the request was successfully processed (i.e. Continue method was called).
    • IsCanceled: Indicates if the request was canceled (i.e. Cancel method was called).
    • IsPaused: Indicates if the request was paused internally (e.g. Synchronous HTTP request sent).
    • IsDeferred: Indicates if the request was deferred internally (e.g. Asynchronous HTTP request sent).
    • IsFailed: Indicates if the request "has failed" (i.e. Fail method was called).

Plugin Callbacks

Photon Server has 9 predefined room hooks, described below. There are also two special callbacks, OnRawMessage (raw messages sent by clients) and OnUnknownType (deserialization of unregistered custom types), as well as ReportError which is called when the plugin or the host encounters an error. You don't need to explicitly register for those hooks in the code. By default, any plugin class can intercept all 9 events. However, you should implement the ones you need. We recommend extending PluginBase and overriding the required callbacks.

All core event callbacks have a specific ICallInfo contract. Most callbacks are directly triggered by client actions. The operation request sent by the client is provided in ICallInfo.Request where available.

OnCreateGame(ICreateGameCallInfo info)

Precondition: client called OpCreateRoom or OpJoinOrCreateRoom or OpJoinRoom and room cannot be found in Photon Servers memory.

Processing Method Processing Result
Continue
  • CreateGame operation response is sent to the client with ReturnCode == ErrorCode.Ok.
  • Join event is sent back to client unless room events are suppressed (SuppressRoomEvents == true).
  • If ICreateGameCallInfo.Request.BroadcastActorProperties == true player custom properties, if any, will be included in the event parameters.
  • If initialized for the first time; room options and initial properties are assigned to the room state, ActorList should contain first actor with its default properties (UserId and NickName). If request contains custom actor properties they should also be added to the actor entry in the list.
  • If loaded, room state should be the same as it was before last removal from Photon Servers memory unless it was altered.
Fail CreateGame operation response is sent to the client with ReturnCode == ErrorCode.PluginReportedError.
Cancel N/A
  • Notes:
    • Before processing request, the room state is not initialized and contains default values. This is the only situation where the room state can be loaded from external source, parsed and assigned to the room by calling IPluginHost.SetGameState.
    • Before processing request, calling PluginHost.SetProperties is not allowed: a warning with the call stack is logged. Also, as the actor is not part of the room yet, no actor can receive an event sent using PluginHost.BroadcastEvent.
    • You can use ICreateGameCallInfo.IsJoin and ICreateGameCallInfo.CreateIfNotExists to know the type of the operation request.
Operation method IsJoin CreateIfNotExist
OpCreateRoom false false
OpJoinRoom true false
OpJoinOrCreateRoom true true

BeforeJoin(IBeforeJoinGameCallInfo info)

Precondition: client called OpJoinRoom or OpJoinOrCreateRoom or OpJoinRandomRoom and room is in Photon Servers memory.

Processing Method Processing Result
Continue triggers the OnJoin callback.
Fail JoinGame operation response is sent to the client with ReturnCode == ErrorCode.PluginReportedError.
Cancel N/A
  • Notes:
    • Before processing the IBeforeJoinGameCallInfo, if you call PluginHost.BroadcastEvent do not expect the joining actor to receive the event unless you cache it.

OnJoin(IJoinGameCallInfo info)

Precondition: IBeforeJoinGameCallInfo.Continue() is called in BeforeJoin.

Processing Method Processing Result
Continue
  • If the join is allowed, joining actor is added to ActorList with its default properties (UserId and NickName).
  • If request contains custom actor properties they should be set also.
  • JoinGame operation response is sent back to the client with ReturnCode == ErrorCode.Ok.
  • If the room was created with the PublishUserId option (RoomOptionFlags.PublishUserId), UserId of other actors will be sent back to client in the operation response.
  • Join event is broadcasted unless room events are suppressed (SuppressRoomEvents == true).
  • If IJoinGameCallInfo.Request.BroadcastActorProperties == true player custom properties, if any, will be included in the event parameters.
Fail
  • JoinGame operation response is sent to the client with ReturnCode == ErrorCode.PluginReportedError.
  • Adding of actor is reverted.
Cancel N/A

OnLeave(ILeaveGameCallInfo info)

Precondition: client called OpLeave, peer disconnects or PlayerTTL elapses. (see Actors life cycle).

Processing Method Processing Result
Continue
  • If triggered by OpLeave operation, its response is sent to the client with ReturnCode == ErrorCode.Ok.
  • Leave event is sent to any other actor unless room events are suppressed (SuppressRoomEvents == true).
  • If ILeaveGameCallInfo.IsInactive == true:
    • The actor is marked as inactive.
    • DeactivationTime is added to its properties.
  • If ILeaveGameCallInfo.IsInactive == false:
    • The actor and its properties are removed from ActorList.
    • The relative cached events could also be removed if DeleteCacheOnLeave == true.
    • If the ActiveActorList becomes empty, a BeforeCloseGame call will follow after EmptyRoomTTL milliseconds.
Fail The failure is logged and the processing continues as if Continue was called: the actor leaves the room anyway, and no error response is sent to the client.
Cancel N/A
  • Notes:
    • PlayerTTL can be set during room creation.
    • If you call PluginHost.BroadcastEvent do not expect the leaving actor to receive the event.

OnRaiseEvent(IRaiseEventCallInfo info)

Precondition: client calls OpRaiseEvent.

Processing Method Processing Result
Continue
  • The room state's events cache could be updated if a caching option is used.
  • The custom event is sent according to its parameters.
Fail RaiseEvent operation response is sent to the client with ReturnCode == ErrorCode.PluginReportedError.
Cancel silently skips processing.
  • Notes:
    • If the IRaiseEventCallInfo is successfully processed, no operation response is sent back to the client.

BeforeSetProperties(IBeforeSetPropertiesCallInfo info)

Precondition: client calls OpSetProperties.

Processing Method Processing Result
Continue
  • The new room or actor property values are applied (including the check of the expected values if CAS is used).
  • If applying the values fails (e.g. the expected values don't match), the SetProperties operation response is sent to the client with ReturnCode == ErrorCode.OperationInvalid, ReportError is called with ErrorCodes.SetPropertiesCASFail and OnSetProperties is not triggered.
  • Otherwise OnSetProperties is triggered. The operation response and the PropertiesChanged event are sent when OnSetProperties is processed with Continue.
Fail SetProperties operation response is sent to the client with ReturnCode == ErrorCode.PluginReportedError.
Cancel silently skips processing.
  • Notes:
    • In order to know if the properties to be changed belong to the room or to an actor, you can check the value of IBeforeSetPropertiesCallInfo.Request.ActorNumber. If it's 0 than the room's properties are about to be updated. Otherwise it's the target actor number whose properties need to be updated.
    • Be careful not to mix the previously mentioned ActorNumber with IBeforeSetPropertiesCallInfo.ActorNr. The latter refers to the actor making the operation request.

OnSetProperties(ISetPropertiesCallInfo info)

Precondition: IBeforeSetPropertiesCallInfo.Continue() is called in BeforeSetProperties.

Processing Method Processing Result
Continue
  • SetProperties operation response is sent back to the client with ReturnCode == ErrorCode.Ok.
  • PropertiesChanged event is broadcasted.
  • If room properties changed, the changes relevant for the lobby are reported to the Master Server.
Fail SetProperties operation response is sent to the client with ReturnCode == ErrorCode.PluginReportedError and no PropertiesChanged event is sent. The new property values were already applied before OnSetProperties was called and are not reverted.
Cancel N/A

BeforeCloseGame(IBeforeCloseGameCallInfo info)

Precondition: all peers disconnected.

Processing Method Processing Result
Continue triggers OnCloseGame.
Fail logs the failure and the processing continues as if Continue was called.
Cancel N/A
  • Notes:
    • EmptyRoomTTL can be set by clients during room creation.
    • Any call to PluginHost.BroadcastEvent will be ignored unless it changes the room events cache.

OnCloseGame(ICloseGameCallInfo info)

Precondition: IBeforeCloseGameCallInfo.Continue() is called in BeforeCloseGame and the EmptyRoomTTL elapses.

Processing Method Processing Result
Continue the room is removed from Photon Servers memory and plugin instance is unloaded.
Fail logs the failure and the processing continues as if Continue was called.
Cancel N/A
  • Notes:
    • Before processing the ICloseGameCallInfo, you can choose to save room state or lose it forever. In webhooks, this can be done when there is still at least one inactive actor in the room.

OnRawMessage(IOnRawMessageCallInfo info)

Precondition: an actor of the room sends a raw message, i.e. binary data that is not an operation request. See Raw Messages.

Processing Method Processing Result
Continue The raw message is relayed to the other actors of the room.
Fail The raw message is not relayed and an operation response with ReturnCode == ErrorCode.PluginReportedError and OperationCode == 0 is sent to the sender.
Cancel silently skips processing: the raw message is dropped.
  • Notes:
    • The call info is not an ITypedCallInfo: it has no Request. It provides RawMessageData, ActorNr, UserId, Nickname and AuthCookie of the sender.
    • The call has to be processed before the callback returns: it can't be deferred.
    • PluginBase continues the call by default.

Advanced Concepts

Actors Life Cycle

Peer <-> Actor <-> Room

An actor is a player inside a room. Once a player enters a room, either by creating or joining it, he/she will be represented by an actor. The actor is defined first by its ActorNr then using UserId and NickName. It can also have custom properties. If the player enters the room for the first time, he/she will get an actor number inside that room that no other player can claim. Also, for each new player an actor will be added to the room's ActorsList.

If a player leaves the room for good, the respective actor will be removed from that list. However, if the room options permit it, a player can leave a room and come back later. The corresponding actor in this case will be marked inactive when the player leaves. The timestamp of the event will be saved in DeactivationTime actor property.

You can limit the amount of time actors can stay inactive inside rooms. This duration can be defined when creating rooms by setting the PlayerTTL option to the value needed in milliseconds. If the value is negative or equal to maximum int value, actors can stay inactive indefinitely. Otherwise, inactive actors will be removed from the room once PlayerTTL is elapsed after their DeactivationTime. The player can rejoin the room until then. And if he/she decides to temporarily leave the room again, a new DeactivationTime will be calculated and countdown will be reset. So there is no restriction on the number of rejoins.

Photon plugins SDK offers a way to get all actors at any given moment using one of the following properties:

  • IPluginHost.GameActors contains all actors inside a room (active and inactive).
  • IPluginHost.GameActorsActive contains all actors currently joined to the room.
  • IPluginHost.GameActorsInactive contains all actors who left the room (without abandoning).

Sending Events From Plugins

You can send custom events inside the room using the Photon plugins SDK. Custom events' type and content should be defined by their codes. Your event codes should stay below 200.

There are two overload methods for this. Although the name BroadcastEvent suggests that events will be broadcasted, you can do a multicast based on filters or even send to a single actor:

  • Send to a group of actors:

C#

void IPluginHost.BroadcastEvent(byte target, int senderActor, byte targetGroup, byte evCode, Dictionary<byte, object> data, byte cacheOp, SendParameters sendParameters = default(SendParameters));

You can set the target argument to the following values:

  • 0 (ReciverGroup.All): all active actors. targetGroup parameter is ignored.
  • 1 (ReciverGroup.Others): all active actors except the actor with actor number equal to senderActor. targetGroup parameter is ignored. If senderActor is equal to 0, the behaviour is equivalent to the one where target is set to 0 (ReciverGroup.All).
  • 2 (ReciverGroup.Group): only active actors subscribed to the interest group specified using targetGroup argument.

The ReciverGroup enum and values should not be confused with the ReceiverGroup enum and values in Photon's C# client SDKs including PUN.

  • Send to a specific list of actors using their actor numbers:

C#

void IPluginHost.BroadcastEvent(IList<int> recieverActors, int senderActor, byte evCode, Dictionary<byte, object> data, byte cacheOp, SendParameters sendParameters = default(SendParameters));

It is also possible to use either methods to update room events cache. You can define the caching option using the cacheOp parameter. The IPluginHost.Broadcastevent methods do not support all cache operations though. All cacheOp values higher than 5 (CacheOperations.AddToRoomCacheGlobal) are not accepted and the BroadcastEvent call will throw an ArgumentException. Use IPluginHost.ExecuteCacheOperation method in general for interacting with the room's events cache. Read more about "Photon Events Caching".

And since Photon events require an actor number as the origin of the event (sender), you have two options:

  • Impersonate an actor: set the senderActor argument to an actor number of an actor joined to the room (must be an active actor).
  • Send an "authoritative" or "global" room event: set the senderActor argument to 0. Since the actor number 0 is never assigned to a player. So it may be used to indicate that the event origin is not a client.

You can't combine senderActor set to 0 with a cacheOp value other than 0 (DoNotCache) or 5 (AddToRoomCacheGlobal). Any other caching option requires the number of an existing actor as senderActor.

If you choose to extend your plugin class from PluginBase, which is what we recommend, you may want to use the following helper method to broadcast event to all actors joined in the room:

C#

protected void BroadcastEvent(byte code, Dictionary<byte, object> data)

In order to be able to retrieve event data sent from plugins and the sender actor number from clients without changing client code, send the data in the expected events structure as follows new Dictionary<byte, object>(){ { 245, eventData },{ 254,senderActorNr } } instead of (Dictionary<byte, object>)eventData. You could use one of the following helper or wrapper methods:

C#

public void RaiseEvent(byte eventCode, object eventData, 
    byte receiverGroup = ReciverGroup.All, 
    int senderActorNumber = 0, 
    byte cachingOption = CacheOperations.DoNotCache, 
    byte interestGroup = 0, 
    SendParameters sendParams = default(SendParameters))
{
    Dictionary<byte, object> parameters = new Dictionary<byte, object>();
    parameters.Add(245, eventData);
    parameters.Add(254, senderActorNumber);
    PluginHost.BroadcastEvent(receiverGroup, senderActorNumber, interestGroup, eventCode, parameters, cachingOption, sendParams);
}

public void RaiseEvent(byte eventCode, object eventData, IList<int> targetActorsNumbers, 
    int senderActorNumber = 0, 
    byte cachingOption = CacheOperations.DoNotCache, 
    SendParameters sendParams = default(SendParameters))
{
    Dictionary<byte, object> parameters = new Dictionary<byte, object>();
    parameters.Add(245, eventData);
    parameters.Add(254, senderActorNumber);
    PluginHost.BroadcastEvent(targetActorsNumbers, senderActorNumber, eventCode, parameters, cachingOption, sendParams);
}

Raw Messages

Raw messages are binary messages that clients send to the room outside of the operation and event structure. The server does not parse them. They are meant for data where the overhead of operations and events is too high, such as voice or other streams. Plugins can inspect them, filter them and send raw messages of their own.

Receiving: override OnRawMessage(IOnRawMessageCallInfo info) (see the OnRawMessage callback in Plugin Callbacks). info.RawMessageData gives access to the payload:

  • GetClientData() returns the data as a Span<byte>.
  • ClientDataLength is its length.

RawMessageData is a ref struct, and the buffer behind it is only valid while the callback runs: it is released and reused after it returns. Copy the data (e.g. GetClientData().ToArray()) if you need it later.

Sending: use these IPluginHost methods. Each of them has an overload taking a RawMessageData (from OnRawMessage) and one taking an IRawMessageData (allocated by the plugin):

Method Receivers
BroadcastRawMessage(IList<int> receiverActors, message, sendParameters) The listed actors. null means all active actors.
BroadcastRawMessage(byte target, int senderActor, byte targetGroup, message, sendParameters) A group of actors: target is a ReciverGroup value (All, Others, Group), like in BroadcastEvent.
SendRawMessage(int targetActor, message, sendParameters, bool copyOnEncrypt) One actor. Nothing is sent if the actor is not found or is not connected. If the connection is encrypted, copyOnEncrypt: true makes the server encrypt a copy of the data instead of the buffer itself.

To send data created by the plugin, allocate a message with IPluginHost.RawMessageDataAllocator. Allocate(int size) returns an IDisposableRawMessageData: write into the buffer returned by GetClientData(), optionally shorten it with SetClientDataLength(int length), and dispose it when you are done.

C#

public override void OnRawMessage(IOnRawMessageCallInfo info)
{
    var bytes = Encoding.UTF8.GetBytes("hello");

    using (var message = this.PluginHost.RawMessageDataAllocator.Allocate(bytes.Length))
    {
        bytes.AsSpan().CopyTo(message.GetClientData());
        this.PluginHost.BroadcastRawMessage(ReciverGroup.All, 0, 0, message);
    }

    info.Cancel(); // do not relay the original raw message
}

To forward the received message to somebody else, pass info.RawMessageData directly to one of the methods above, and then Cancel the call so that it is not relayed a second time.

Plugin Factory Lifecycle

The plugin factory class (the Type in plugin.config) is instantiated once by the server, using its parameterless constructor. A new instance is created again when the plugin configuration changes. The factory goes through the following steps:

  1. SetFactoryHost(IFactoryHost, FactoryParams) (IPluginFactory2) is called right after the instance is created. IFactoryHost provides what is needed outside of rooms: loggers (CreateLogger), log count guards (CreateLogCountGuard), the application Environment and workers. FactoryParams.PluginConfig contains the key/value attributes of the plugin from plugin.config. PluginFactoryBase stores the host in the factoryHost field, calls the virtual SetFactoryHost method (empty by default) and then the virtual OnFactoryHostSet(FactoryParams).
  2. IFactoryHost.InitializationFinished(bool success = true, string errorMessage = null) has to be called to tell the server that the factory is ready (factories implementing IPluginFactory3, which includes everything derived from PluginFactoryBase). The default PluginFactoryBase.OnFactoryHostSet does it. If you override it, call the base method or call InitializationFinished yourself, from the override or later, for example when asynchronous initialization is done. Rooms can't be created or joined until then. If the call does not happen within PluginManagerInitializationTimeout (30 seconds by default), or if it is called with success = false, the plugin fails to initialize: the error is logged, and all create and join operations fail.
  3. Create(IPluginHost, string pluginName, Dictionary<string,string> config, out string errorMsg) is called for each room. PluginFactoryBase calls your CreatePlugin(pluginName) and then SetupInstance on the returned plugin. Returning null (with errorMsg) fails the operation.
  4. HostReleased() (IPluginFactory3) is called when the server stops using the factory instance: after the plugin configuration changed and the last room created by this instance was closed. Use it to stop what the factory started: timers, workers' timers, connections.

C#

public class PluginFactory : PluginFactoryBase
{
    public override void OnFactoryHostSet(FactoryParams factoryParams)
    {
        // start shared services here, e.g. create a worker
        base.OnFactoryHostSet(factoryParams); // calls InitializationFinished
    }

    public override IGamePlugin CreatePlugin(string pluginName)
    {
        return new CustomPlugin();
    }

    public override void HostReleased()
    {
        // stop timers, release resources
    }
}

A factory that only implements IPluginFactory (the 4 argument Create) does not get any of the host callbacks and is considered ready right away.

Workers

A worker is an object that runs application-wide logic outside of rooms: for example batching statistics and sending them to a web service, polling a list of banned users, or any shared timer. It has its own fiber, so it never blocks room fibers, and its code does not need locks.

Create workers in the factory, once, and give them to the plugin instances you create. Derive from PluginWorkerBase and create it with IFactoryHost.CreateWorkerHost(IPluginWorker):

C#

public class StatsWorker : PluginWorkerBase
{
    protected override void SetupInstance()
    {
        // this.Host and this.logger are available here
        this.Host.CreateTimer(this.Flush, 5000, 5000);
    }

    private void Flush()
    {
        this.logger.Info("flushing");
    }
}

public class PluginFactory : PluginFactoryBase
{
    private StatsWorker worker;

    public override void OnFactoryHostSet(FactoryParams factoryParams)
    {
        this.worker = new StatsWorker();
        this.factoryHost.CreateWorkerHost(this.worker); // calls worker.SetupInstance
        base.OnFactoryHostSet(factoryParams);
    }

    public override IGamePlugin CreatePlugin(string pluginName)
    {
        return new CustomPlugin(this.worker);
    }
}

IPluginWorkerHost (available as Host in the worker) offers:

Member Purpose
CreateLogger(name), CreateLogger(name, tags) Loggers for the worker.
Enqueue(Action) Runs an action on the worker's fiber. Returns EnqueueStatus.Success (0) or EnqueueStatus.Closed (1).
CreateTimer(action, firstInMs, regularInMs), CreateOneTimeTimer(action, firstInMs), StopTimer(timer) Timers running on the worker's fiber.
HttpRequest(HttpRequest) Sends an HTTP request. Its callback runs on the worker's fiber.

Unhandled exceptions thrown on the worker's fiber are passed to ReportError of the worker.

Timers and HTTP callbacks of a worker run on the worker's fiber, not on a room's fiber. Do not access rooms from them directly. Call PluginHost.Enqueue(...) of the room's plugin instance (or GetRoomFiber().Enqueue(...)) from the worker's callback to continue on the room's fiber. The worker gets no call info: it is not part of any room callback flow.

A worker host has no way to be disposed: stop the timers of your workers (StopTimer) in HostReleased(), so that the workers of a replaced factory instance don't keep running.

Outbound HTTP Calls

HttpRequest is a helper struct to construct HTTP requests (Photon.Hive.Plugin.HttpRequest). Using this class, you can set the URL and the HTTP method (default is "GET"), the required Accept and ContentType headers. The values of these properties should be supported by HttpWebRequest. Additionally, you can specify other custom HTTP headers as a IDictionary<string, string> and assign it to HttpRequest.CustomHeaders. You may also add request data by converting it into a MemoryStream object first then assigning it to HttpRequest.DataStream. See the Post JSON example for more information on how to do this.

What's worth noting, is that there are two properties that are exclusive to Photon and important in the plugins logic:

  • Async: a flag that indicates if the normal processing of the room logic should be interrupted until the response is received or not. This should be set to false if the room logic depends on the HTTP response.
  • UserState: an object that will be saved by Photon Server for each request and sent back in the response's callback.

The HttpRequest class also should hold reference to the response callback which should have the following signature: public delegate void HttpRequestCallback(IHttpResponse response, object userState).

Once the request object is setup, you can send it by calling IPluginHost.HttpRequest(HttpRequest request, ICallInfo info).

If you send a HTTP Request in synchronous mode, it does not make sense to process the ICallInfo object right away without waiting for the HTTP response. You would be blocking the room fiber for nothing. We recommend that you process the plugin call only until the response callback is triggered.

Asynchornous HTTP requests

You can delay processing of a hook until a HTTP response is received:

C#

HttpRequest request = new HttpRequest()
{
  Callback = OnHttpResponse,
  Url = yourCustomUrl,
  Async = true,
  UserState = yourOptionalUserObject
};
PluginHost.HttpRequest(request, info);

When the response is received you should decide how you want to process the ICallInfo if needed.

C#

private void OnHttpResponse(IHttpResponse response, object userState)
{
  ICallInfo info = response.CallInfo;
  ProcessCallInfoIfNeeded(info);

Handling HTTP Response

In the response callback, the first thing to check is IHttpResponse.Status. It can take one of the following HttpRequestQueueResult values:

  • Success (0): The endpoint returned a successful HTTP status code. (i.e. 2xx codes).
  • RequestTimeout (1): The endpoint did not return a response in a timely manner.
  • QueueTimeout (2): The request timed out inside the HttpRequestQueue. A timer starts when a request is enqueued. It times out when the previous queries take too much time.
  • Offline (3): The application's respective HttpRequestQueue is in offline mode. No HttpRequest should be made during 10 seconds which is the time the HttpRequestQueue takes to reconnect.
  • QueueFull (4): The HttpRequestQueue has reached a certain threshold for the respective application.
  • Error (5): The request's URL couldn't be parsed or the hostname couldn't be resolved or the endpoint is unreachable. Also this may happen if the endpoint returns an error HTTP status code. (e.g. 400:BAD REQUEST)

If the result is not Success (0) you can get more details about what went wrong using the following properties:

  • Reason: readable form of the error. Useful when IHttpResponse.Status is equal to HttpRequestQueueResult.Error.
  • WebStatus: contains the code of the WebExceptionStatus. that indicates any eventual WebException.
  • HttpCode: contains the returned HTTP status code.

Here is an example on how to do this in code:

C#

private void OnHttpResponse(IHttpResponse response, object userState)
{
  switch(response.Status)
    {
    case HttpRequestQueueResult.Success:
      // on success logic
      break;
    case HttpRequestQueueResult.Error:
      if (response.HttpCode <= 0)
      {
        PluginHost.BroadcastErrorInfoEvent(
          string.Format("Error on web service level: WebExceptionStatus={0} Reason={1}",
          (WebExceptionStatus)response.WebStatus, response.Reason));
      } 
      else
      {
        PluginHost.BroadcastErrorInfoEvent(
          string.Format("Error on endpoint level: HttpCode={0} Reason={1}",
          response.HttpCode, response.Reason));
      }
      break;
    default:
      PluginHost.BroadcastErrorInfoEvent(
        string.Format("Error on HttpQueue level: {0}", response.Status));
      break;
  }
}

For convenience and ease-of-use, Photon plugins SDK offer two ways of getting data from the HTTP response. Two properties are exposed in the class that implements IHttpResponse:

  • ResponseData: byte array of the response body. It can be useful if the received data is not textual.
  • ResponseText: UTF8 string version of the response body. It can be useful if the received data is textual.

The response class also holds reference to the corresponding HttpRequest in case you need it later. It is available in IHttpResponse.Request.

Timers

Timers are objects that can be setup with a purpose of calling a method after a specific period of time. Countdown starts automatically once the timer is created. It is the best way to schedule or delay code execution from a plugin.

Timers need to be stopped before the plugin is disposed of. Do not forget to call StopTimer for every timer object you create no matter its type. The last good timing to clean up those timers is BeforeCloseGame. It's also recommended to not keep reference to timers after they are stopped so you may set them to null.

Photon plugins SDK offer two different variants of timers depending on the use case:

One-time Timers

One-time timers are meant to trigger a method once after a due time. In order to create such timer, you need to use the following method:
object CreateOneTimeTimer(ICallInfo callInfo, Action callback, int dueTimeMs);
To stop the timer call void IPluginHost.StopTimer(object timer). If the timer is stopped before it's elapsed no action will be called. However, we still recommend to stop the timer even after the scheduled action has been executed.

Example: delaying SetProperties

C#

public override void BeforeSetProperties(IBeforeSetPropertiesCallInfo info)
{
  object oneTimeTimer = null;
  oneTimeTimer = PluginHost.CreateOneTimeTimer(
                info,
                () => { 
                  info.Continue();
                  PluginHost.StopTimer(oneTimeTimer);
                  oneTimeTimer = null;
                },
                1000);
} 

Example 2: scheduled single time event

C#

private object ontTimeTimer;
public override void OnCreateGame(ICreateGameCallInfo info)
{
  info.Continue();
  ontTimeTimer = PluginHost.CreateOneTimeTimer(
                info,
                ScheduledOneTimeEvent,
                1000);
}
private void ScheduledOneTimeEvent(){
  // ...
}
public override void BeforeCloseGame(IBeforeCloseGameCallInfo info)
{
  PluginHost.StopTimer(ontTimeTimer);
  oneTimeTimer = null;
  info.Continue();
}

Repeating Timers

A repeating timer periodically invokes a method. You can define the time of execution of the first callback and the interval between the following successive executions.
In order to create such timer, you need to use the following method:
object CreateTimer(Action callback, int dueTimeMs, int intervalMs);
This kind of timer will keep calling the corresponding method as long as it is running and the plugin is loaded (the room is still ongoing). It can be stopped at any time using void IPluginHost.StopTimer(object timer).

Example: scheduled events

C#

private object repeatingTimer;
public override void OnCreateGame(ICreateGameCallInfo info)
{
  info.Continue();
  repeatingTimer = PluginHost.CreateTimer(
        ScheduledEvent,
        1000,
        2000);
}
private void ScheduledEvent()
{
  BroadcastEvent(1, new Dictionary<byte, string>() { { 245, "Time is up" } });
}
public override void BeforeCloseGame(IBeforeCloseGameCallInfo info)
{
  PluginHost.StopTimer(repeatingTimer);
  repeatingTimer = null;
  info.Continue();
}

Custom Types

If you want Photon to support serialization of your custom classes then you need to register their types. You need to manually assign a code (byte) for each type and provide the serialization and deserilization methods of the fields and properties of the class. The same code for registering new types should be used in client also. Then to complete registration you need to call the following method:

C#

bool IPluginHost.TryRegisterType(Type type, byte typeCode, Func<object, byte[]> serializeFunction, Func<byte[], object> deserializeFunction);

Example: Registering CustomPluginType

Here is the example of custom type class to register:

C#

class CustomPluginType
{
  public int intField;
  public byte byteField;
  public string stringField;
}

The registration of custom types should be done by both ends. Meaning the Photon client should also register the custom type with the same code and serialization methods.

The serialization method should transform the object of custom type to a byte array. Note that you should cast the object to the expected type (CustomPluginType) first.

C#

private byte[] SerializeCustomPluginType(object o)
{
  CustomPluginType customObject = o as CustomPluginType;
  if (customObject == null) { return null; }
  using (var s = new MemoryStream())
    {
    using (var bw = new BinaryWriter(s))
    {
      bw.Write(customObject.intField);
      bw.Write(customObject.byteField);
      bw.Write(customObject.stringField);
      return s.ToArray();
    }
  }
}

The deserilization method should do the opposite. It constructs the custom type object back from a byte array.

C#

private object DeserializeCustomPluginType(byte[] bytes)
{
  CustomPluginType customObject = new CustomPluginType();
  using (var s = new MemoryStream(bytes))
    {
    using (var br = new BinaryReader(s))
    {
      customObject.intField = br.ReadInt32();
      customObject.byteField = br.ReadByte();
      customObject.stringField = br.ReadString();
    }
  }
  return customobject;
}

Finally, we need to register the CustomPluginType. We can do this as soon as the plugin is initialized in the SetupInstance:

C#

public override bool SetupInstance(IPluginHost host, Dictionary<string, string> config, out string errorMsg)
{
  host.TryRegisterType(typeof(CustomPluginType), 1,
    SerializeCustomPluginType,
    DeserializeCustomPluginType);
  return base.SetupInstance(host, config, out errorMsg);
}

Logging

Create a new IPluginLogger object per plugin instance and use it to log everything from your plugin:

C#

public const string PluginName = "MyPlugin";

private IPluginLogger pluginLogger;

public override bool SetupInstance(IPluginHost host, Dictionary<string, string> config, out string errorMsg)
{
    pluginLogger = host.CreateLogger(PluginName);
    // ...
}

// ...

this.pluginLogger.Debug("debug");
this.pluginLogger.Warn("warning");
this.pluginLogger.Error("error");
this.pluginLogger.Fatal("fatal");

IPluginLogger also offers Info and the *Format variants (e.g. InfoFormat).

When setting the name of the logger (passed to IPluginHost.CreateLogger(loggerName)), we prepend the logger name with Plugin.. For example, if you set MyPlugin.MyClass, it will be logged as Plugin.MyPlugin.MyClass. The code snippet above will generate these log entries in case the log level is set to maximum level:

2020-01-31 17:10:07,394 [1] DEBUG  Plugin.MyPLugin - debug
2020-01-31 17:10:07,901 [1] WARN  Plugin.MyPLugin - warning
2020-01-31 17:10:08,152 [1] ERROR  Plugin.MyPLugin - error
2020-01-31 17:10:08,724 [1] FATAL  Plugin.MyPLugin - fatal

Enterprise Cloud Logging Configuration

Since access to log files on our servers is not granted an external service should be used for logs or alerts. We recommend using Logentries or Papertrail. So we ask our Enterprise Customers to get in touch with us to configure their private cloud with the logging service of their choice. If you want to use Logentries provide the configured log token. If you want to use Papertrail provide your custom URL with the port.

Self-Hosted Server Logging Configuration

By default, log output can be retrieved in "GSGame.log" file along with GameServer log entries. To use a separate log file, add the following snippet to the log4net configuration file ("log4net.config") of the GameServer:

XML

<!-- "plugin" log file appender -->
<appender name="PluginLogFileAppender" type="log4net.Appender.RollingFileAppender">
    <file type="log4net.Util.PatternString" value="%property{Photon:ApplicationLogPath}\\Plugins.log" />
    <param name="AppendToFile" value="true" />
    <param name="MaxSizeRollBackups" value="20" />
    <param name="MaximumFileSize" value="10MB" />
    <param name="RollingStyle" value="Size" />
    <param name="LockingModel" type="log4net.Appender.FileAppender+MinimalLock" />
    <layout type="log4net.Layout.PatternLayout">
        <param name="ConversionPattern" value="%d [%t] %-5p %c - %m%n" />
    </layout>
</appender>
<!-- CUSTOM PLUGINS: new way, the new loggers have prefix "Plugin" -->
<logger name="Plugin" additivity="false">
  <level value="INFO" />
  <appender-ref ref="PluginLogFileAppender" />
</logger>
<!-- CUSTOM PLUGINS: old way  -->
<logger name="Photon.Hive.HiveGame.HiveHostGame.Plugin" additivity="false">
  <level value="DEBUG" />
  <appender-ref ref="PluginLogFileAppender" />
</logger>

Plugins Versioning in Enterprise Cloud

Currently Photon Plugins support only side-by-side assembly versioning: one plugin DLL version per AppId.

Here are two methods we recommend for rolling out new plugins versions:

A. "Compatible" plugins deploy: does not require new client version

  1. Upload new version of plugins assembly.
  2. On a staging AppId: test to verify that new version works as expected. (recommended)
  3. Update production AppId configuration to use new plugins assembly version.

B. "Incompatible" plugins deploy: requires new client version

  1. Upload new version of plugins assembly.
  2. Setup a new production AppId.
  3. Configure the new production AppId to use new plugins assembly version.

PUN Specific Plugins

If you use PUN as a client SDK and want to implement a server side plugin that works with it, you should know the following:

  • PUN registers some extra custom types mainly Unity basic classes. If you want to handle those from the plugin you should register the same custom types from the plugin as well. You can find all those custom types and how to register them in "CustomTypes.cs" class inside PUN package. Failure to register some custom types may result in errors or unexpected behavior. You should implement IGamePlugin.OnUnknownType or IGamePlugin.ReportError to be notified of such issues.

  • All PUN's high level and unique features use RaiseEvent under the hood. Each feature uses one or more event codes and special event data structure. To get the list of events reserved by PUN, take a look at "PunEvent" class from "PunClasses.cs" file inside PUN package. For example to intercept OnSerializeView calls, you need to implement OnRaiseEvent callback and catch event codes with the corresponding type, in this case "SendSerialize = 201". To know each event's expected content, take a look at how its event data is constructed in PUN's code or inspect it from the incoming event inside the plugin.

  • To get PUN's PhotonNetwork.ServerTimestamp in a Photon Plugin, use Environment.TickCount.

AuthCookie

Also called secure data, is an optional JSON object returned by the web service set up as authentication provider. This object will not be accessible from client side. For more information please visit the custom authentication documentation page.

From Plugins you could access the AuthCookie as follows:

  • AuthCookie property of the call info (ITypedCallInfo<T>, which all room callback call infos implement, and IOnRawMessageCallInfo): to get the AuthCookie of the current actor triggering the hook. However, in BeforeCloseGame and OnCloseGame, IBeforeCloseGameCallInfo.AuthCookie and ICloseGameCallInfo.AuthCookie respectively won't have any value since those are triggered outside of a user context. e.g.

    C#

    public void OnCreateGame(ICreateGameCallInfo info)
    {
        Dictionary<string, object> authCookie = info.AuthCookie;
    
  • IActor.Secure: to get the AuthCookie for any active actor. e.g.

    C#

    foreach (var actor in this.PluginHost.GameActorsActive)
    {
        var authCookie = actor.Secure as Dictionary<string, object>;
    }
    
  • Update the secure authentication cookie per actor using void IActor.UpdateSecure(string key, object value)

Last updated on

Back to top