Server communication¶
This module provides the means of communicating with the game server for state synchronization and user interaction. Since the game server employs an object oriented data model and the client follows a data driven entity component system approach there is a need to translate between the two worlds. This is controlled by the data transformation infrastructure that this module provides and other modules can hook into.
Services¶
Multiple services are provided by this module each of which are stored in a wrapping container as ecs singletons.
HttpClient¶
Used to send HTTP request and receiving HTTP responses. It is recommended to instantiate this class only once and reuse it during the lifetime of an application. So this instance is exposed to access it from outside this module.
Reference: System.Net.Http.HttpClient
IdMapper¶
Stores mapping of game server data model id to client side Entity
id and vice versa.
This is an unmanaged object safe to use in jobs and Burst-compiled code. Not thread-safe!
Creating mapped entities must not be done in parallel since entities created using a
command buffer are virtual and the realization of those entities is deferred until
EntityCommandBuffer.Playback(EntityManager)
is called. Creating entities mapping to the
same server id will create a race condition. This also applies to virtual entity ids shared
by multiple buffers. Since there is rarely a use-case for parallel game state processing
we avoid introducing the overhead of a locking buffer
(EntityCommandBuffer.AsParallelWriter()
).
When adding a mapping for an entity that is deferred add a DeferredEntityComponent
to the
created entity so the stored mapping can be updated once the entity is realized.
DataTransformer¶
Deserializes sup-server game state data JSON into ECS. The behavior is controlled by data transformers that are called when specific "@c" types are encountered in the data. The DataTransformer
stores all logic and configuration data required and acts as the entry for server to client data serialization.
Deserialization is performed by calling DataTransformer.Deserialize(GameStateData, EntityCommandBuffer)
passing the data received from the server and the command buffer to record structural changes into.
Known type¶
A known type as a "@c" data type that maps to an entity in a 1-to-1 relation. Each of those relations has to be registered beforehand by calling DataHandler.AddKnownType(string typeIdentifier, string idPropertyName)
specifying the "@c" type name and the corresponding name of the JSON property used to store the numeric id of the server model data. The mapping between server-side data model and entity is automatically maintained by the data transformer for newly added or existing entities.
The known type must only be registered once per "@c" type due to ambiguities of passing different names for the id property.
Type alias¶
It is possible to specify multiple "@c" type names that map to the same type by calling DataTransformer.AddTypeAlias(string from, string to)
. The use of this is to allow multiple server model types to map to the same type of entity.
Data handler¶
The transformation logic for a type is passed as a simple delegate function with the signature (IDeserializationContext, EntityCommandBuffer)
by calling DataTransformer.AddHandler(string typeIdentifier, DataHandler handler)
.
For a single type multiple handlers can be registered and the same handler can also be registered for multiple types. When deserialization is performed all handlers registered for a type will be called in the same order the handlers were added to the DataTransformer
.
When a handler is called it receives a context and the entity command buffer to write structural changes into. The context provides access to the raw data, the mapped entity and provides an interface for error handling. See IDeserializationContext
for a detailed description.
Given the following example game state data...
{
"@c": "GameState",
"stateType": 0,
"states": {
"6": {
"@c": "ArmyState",
"stateType": 6,
"stateID": 0,
"timeStamp": 1671922047822,
"armies": {
"123": {
"@c": "Army",
"id": 123,
"name": "13th Infantry Brigade",
"size": 2,
"owner": 4,
"position": { "x": 111.0, "y": 99.5 }
},
"456": {
"@c": "Army",
"id": 456,
"name": "3rd Military Unit",
"size": 1,
"owner": 3,
"position": { "x": 666.0, "y": 1081.5 }
}
}
}
}
}
...a data handler that deserializes the armies to entities could look as follows:
void DeserializeArmy(IDeserializationContext context, EntityCommandBuffer ecb) {
if (!context.HasAncestorType("ArmyState")) {
return; // Make sure we deserialize the armies from the army state.
}
if (!context.Node.TryGetStringValue("name", out string? name)) {
context.LogError("Army node missing \"name\" property.");
return;
}
if (!context.Node.TryGetFloat2Value("position", out float2? position)) {
context.LogError("Army node missing \"position\" property.");
return;
}
if (!context.Node.TryGetUInt32Value("size", out uint? size)) {
size = 0; // Size unknown. This is valid for enemy armies that are not fully in range.
}
if (!context.Node.TryGetInt64Value("owner", out long? ownerPlayerId)) {
context.LogError("Army node missing \"owner\" property.");
return;
}
// Resolve server-side id of owner player into an entity.
(FixedString64Bytes, long) playerServerId = ("PlayerProfile", (long)ownerPlayerId!);
if (!context.TryResolveEntity(playerServerId, out Entity ownerPlayer)) {
context.LogError($"Failed to resolve player with id {ownerPlayerId}.");
ownerPlayer = Entity.Null;
}
context.Node.TryGetInt64Value("id", out long? id);
Entity entity = context.GetOrCreateEntity(ecb, $"Army{id}");
ecb.AddComponent<ArmyComponent>(entity, new() {
Name = (string)name!,
Position = (float2)position!,
Size = (uint)size!,
Owner = ownerPlayer,
});
// We don't need to deserialize anything else from the army data, so stop recursing here.
context.StopRecurse = true;
}
// Register handler with `DataTransformer`:
dataTransformer.AddHandler("Army", DeserializeArmy);
IDataHandler interface¶
It is possible to pass known type, aliases and handler in a single shot to the DataTransformer
by implementing the IDataHandler
interface. This is a high level API that combines all configuration in one place and internally the lower level API (AddKnownType
, AddTypeAlias
, AddHandler
) is called. The IDataHandler
interface should only be implemented and registered once per type to avoid specifying redundant or conflicting configuration.
GameServerInterface¶
Provides an interface for client server communication for e.g. game state and commands (user actions).
Configuring versioning of substates contained in the game state¶
The game server uses state ids which are some kind of hash or timestamps to determine the currentness of the client's game state so it knows which data needs to be sent to the client to be in sync with the state of the game server.
Unfortunately the server does not send which type of versioning it uses for each substate and this configuration has to be hard-coded in the client by calling GameServerInterface.SubstateUseTimeStamp()
once for substates that do not use state ids which is the default behavior.
Loading a game¶
Loading a game is accomplished by calling GameServerInterface.LoadGame()
specifying the required configuration:
- The URL of the game server to talk to. This should include the scheme, host and the port.
- The user id which uniquely identifies an user account.
- The user authentication token which is returned by the authentication request (not implemented yet). For alpha game servers
GameServerInterface.LoadGame()
must only be called once at the start of a game session.
The function returns a Task<GameStateData>
that, once completed, can be handed to the DataTransformer
to be deserialized into an EntityCommandBuffer
.
Updating a game¶
To synchronize the client's state with the server the client needs to poll the server in regular intervals for updates. This is done by calling GameServerInterface.UpdateGame(IEnumerable<StateVersion>)
passing versioning information the server uses to determine what data needs to be sent for the client's state to be in sync with the server-managed state. Similar to GameServerInterface.LoadGame()
this function returns a Task<GameStateData>
which contains the data that the client needs to update.
Sending user actions to the server¶
User issued actions like moving an army or starting the construction of a building need to be sent to the server to be processed. The server validates and executes the actions and sends an updated game state differential as a response. These commands are send alongside with a request for update to the server as any accepted and executed action results in a modified game state.
User actions are sent to the server by calling the function GameServerInterface.UpdateGameState(IEnumerable<StateVersion>, IEnumerable<ServerCommand>)
additionally passing a list of server commands that wrap the user-issued actions.
User actions are implemented by sub-classing ServerCommand
providing the required data. A builder implementing IServerCommandBuilder<out T> where T: ServerCommand
should be provided to help constructing a valid command.
Each field or property of a server command needs to be annotated by the SerdeNameAttribute
so that the contained data is serialized with a key name that the server understands. Commands containing entities additionally need to specify the "@c" type along with the name of the id property key name so they are correctly mapped into the server-side namespace:
public class ArmyAction : SerializableType {
[SerdeName("id", type: "a")]
public Entity Entity;
[SerdeName("c")]
public ArmyCommand[] Commands = null!;
}
public class ArmyActionCommand : ServerCommand {
[SerdeName("armies")]
public ArmyAction[] Armies { get; set; } = null!;
}
Inspecting messages in-editor¶
Use with Odin -> Inspector -> Static Inspector
and find the class EditorServerMessageLogger in order to visualize all requests and responses with a game server. They're matched by requestId
and sorted by request time.