Getting started with NetCode
This documentation provides a walkthrough of how to create a very simple client server based simulation. This walkthrough describes how to spawn and control a simple Prefab.
Set up the Project
Open the Unity Hub and create a new Project. Note: To use Unity NetCode you must have at least Unity 2019.3.b11 installed.
Open the Package Manager (menu: Window > Package Manager). At the top of the window, under Advanced, select Show preview packages. Add the Entities, Hybrid Renderer, NetCode, and Transport packages.
Create an initial Scene
To begin, you need to set up a way to share data between the client and the server. To achieve this separation in NetCode, you need to create a different World for each client and the server. To share data between the server and the client, create an empty GameObject (called SharedData in the example), and add the ConvertToClientServerEntity component.
Empty SharedData GameObject
ConvertToClientServerEntity component
Once you set this up you can, for example, spawn a plane in both the client and the server world. To do this, right click the SharedData Prefab and select 3D Object > Plane which then creates a plane that is nested under SharedData.
Scene with a plane
Create a ghost Prefab
To make your Scene run with a client / server setup you need to create a definition of the networked object, which is called a ghost.
To create a ghost Prefab, create a cube in the Scene (right click on the Scene and select 3D Object > Cube). Then select the Cube GameObject under the Scene and drag it into the Project’s Asset folder. This creates a Prefab of the Cube.
Create a Cube Prefab
In NetCode you identify the player with its PlayerId. To identify the Cube Prefab, create a simple component with the following code:
using Unity.Entities;
using Unity.NetCode;
[GenerateAuthoringComponent]
public struct MovableCubeComponent : IComponentData
{
[GhostDefaultField]
public int PlayerId;
}
Once you create this component, add it to the Cube Prefab. Then, in the Inspector, add the Ghost Authoring Component to the Prefab. In this component, select Update Component List to update the list of components.
When you do this, Unity automatically adds default values to the Translation and Rotation components. Expand the components in the list to select where they should be present. In this example, disable PerInstanceCullingTag
and RenderMesh
on the server. This is because the server does not render anything, and interpolated objects on the client don’t simulate anything.
The Ghost Authoring component
Note: Change the Default Client Instantiation to Owner Predicted. This makes sure that you predict your own movement.
After you set up the component, select the Generate Code button.
Hook up the collections
To tell NetCode which Ghosts to use, set up a GhostCollection. Because both the client and the server need to know about these Ghosts, add it to the SharedData Scene. Right click on SharedData and select Create Empty. Rename it to GhostCollection and then add a GhostCollectionAuthoringComponent.
In the Inspector select the Update ghost list button and then the Generate collection code button.
Ghost Collection settings
Establish a connection
Next, you need to make sure the server starts listening for connections, the client connects, and all connections are marked as "in game" so NetCode can start sending snapshots. You don’t need a full flow in this case, write the minimal amount of code to set it up. Create a file called Game.cs under Assets and the following code to the file:
using System;
using Unity.Entities;
using Unity.NetCode;
using Unity.Networking.Transport;
using Unity.Burst;
// Control system updating in the default world
[UpdateInWorld(UpdateInWorld.TargetWorld.Default)]
public class Game : ComponentSystem
{
// Singleton component to trigger connections once from a control system
struct InitGameComponent : IComponentData
{
}
protected override void OnCreate()
{
RequireSingletonForUpdate<InitGameComponent>();
// Create singleton, require singleton for update so system runs once
EntityManager.CreateEntity(typeof(InitGameComponent));
}
protected override void OnUpdate()
{
// Destroy singleton to prevent system from running again
EntityManager.DestroyEntity(GetSingletonEntity<InitGameComponent>());
foreach (var world in World.AllWorlds)
{
var network = world.GetExistingSystem<NetworkStreamReceiveSystem>();
if (world.GetExistingSystem<ClientSimulationSystemGroup>() != null)
{
// Client worlds automatically connect to localhost
NetworkEndPoint ep = NetworkEndPoint.LoopbackIpv4;
ep.Port = 7979;
network.Connect(ep);
}
#if UNITY_EDITOR
else if (world.GetExistingSystem<ServerSimulationSystemGroup>() != null)
{
// Server world automatically listens for connections from any host
NetworkEndPoint ep = NetworkEndPoint.AnyIpv4;
ep.Port = 7979;
network.Listen(ep);
}
#endif
}
}
}
Next you need to tell the server you are ready to start playing. To do this, use the Rpc
calls that are available in the NetCode package.
In Game.cs, create the following RpcCommand that tells the server you are ready to start playing now:
[BurstCompile]
public struct GoInGameRequest : IRpcCommand
{
public void Deserialize(DataStreamReader reader, ref DataStreamReader.Context ctx)
{
}
public void Serialize(DataStreamWriter writer)
{
}
[BurstCompile]
private static void InvokeExecute(ref RpcExecutor.Parameters parameters)
{
RpcExecutor.ExecuteCreateRequestComponent<GoInGameRequest>(ref parameters);
}
static PortableFunctionPointer<RpcExecutor.ExecuteDelegate> InvokeExecuteFunctionPointer =
new PortableFunctionPointer<RpcExecutor.ExecuteDelegate>(InvokeExecute);
public PortableFunctionPointer<RpcExecutor.ExecuteDelegate> CompileExecute()
{
return InvokeExecuteFunctionPointer;
}
}
Note: Don’t forget the BurstCompile
attribute on InvokeExecute
.
To make sure NetCode handles the command, you need to create a RpcCommandRequestSystem as follows:
// The system that makes the RPC request component transfer
public class GoInGameRequestSystem : RpcCommandRequestSystem<GoInGameRequest>
{
}
Next, you need to create an ICommandData
struct to make sure you can send input from the client to the server. This struct is responsible for serializing and deserializing the input data. Create a script called CubeInput.cs and write the CubeInput CommandData
as follows:
public struct CubeInput : ICommandData<CubeInput>
{
public uint Tick => tick;
public uint tick;
public int horizontal;
public int vertical;
public void Deserialize(uint tick, DataStreamReader reader, ref DataStreamReader.Context ctx)
{
this.tick = tick;
horizontal = reader.ReadInt(ref ctx);
vertical = reader.ReadInt(ref ctx);
}
public void Serialize(DataStreamWriter writer)
{
writer.Write(horizontal);
writer.Write(vertical);
}
public void Deserialize(uint tick, DataStreamReader reader, ref DataStreamReader.Context ctx, CubeInput baseline,
NetworkCompressionModel compressionModel)
{
Deserialize(tick, reader, ref ctx);
}
public void Serialize(DataStreamWriter writer, CubeInput baseline, NetworkCompressionModel compressionModel)
{
Serialize(writer);
}
}
The command stream consists of the current tick and the horizontal and vertical movements. In the same fashion as Rpc
, you need to set up ICommandData
with some systems to handle the command as follows:
public class NetCubeSendCommandSystem : CommandSendSystem<CubeInput>
{
}
public class NetCubeReceiveCommandSystem : CommandReceiveSystem<CubeInput>
{
}
To sample the input, send it over the wire. To do this, create a System for it as follows:
[UpdateInGroup(typeof(ClientSimulationSystemGroup))]
public class SampleCubeInput : ComponentSystem
{
protected override void OnCreate()
{
RequireSingletonForUpdate<NetworkIdComponent>();
RequireSingletonForUpdate<EnableNetCubeGhostReceiveSystemComponent>();
}
protected override void OnUpdate()
{
var localInput = GetSingleton<CommandTargetComponent>().targetEntity;
if (localInput == Entity.Null)
{
var localPlayerId = GetSingleton<NetworkIdComponent>().Value;
Entities.WithNone<CubeInput>().ForEach((Entity ent, ref MovableCubeComponent cube) =>
{
if (cube.PlayerId == localPlayerId)
{
PostUpdateCommands.AddBuffer<CubeInput>(ent);
PostUpdateCommands.SetComponent(GetSingletonEntity<CommandTargetComponent>(), new CommandTargetComponent {targetEntity = ent});
}
});
return;
}
var input = default(CubeInput);
input.tick = World.GetExistingSystem<ClientSimulationSystemGroup>().ServerTick;
if (Input.GetKey("a"))
input.horizontal -= 1;
if (Input.GetKey("d"))
input.horizontal += 1;
if (Input.GetKey("s"))
input.vertical -= 1;
if (Input.GetKey("w"))
input.vertical += 1;
var inputBuffer = EntityManager.GetBuffer<CubeInput>(localInput);
inputBuffer.AddCommandData(input);
}
}
Finally, create a system that can read the CommandData
and move the player.
[UpdateInGroup(typeof(GhostPredictionSystemGroup))]
public class MoveCubeSystem : ComponentSystem
{
protected override void OnUpdate()
{
var group = World.GetExistingSystem<GhostPredictionSystemGroup>();
var tick = group.PredictingTick;
var deltaTime = Time.DeltaTime;
Entities.ForEach((DynamicBuffer<CubeInput> inputBuffer, ref Translation trans, ref PredictedGhostComponent prediction) =>
{
if (!GhostPredictionSystemGroup.ShouldPredict(tick, prediction))
return;
CubeInput input;
inputBuffer.GetDataAtTick(tick, out input);
if (input.horizontal > 0)
trans.Value.x += deltaTime;
if (input.horizontal < 0)
trans.Value.x -= deltaTime;
if (input.vertical > 0)
trans.Value.z += deltaTime;
if (input.vertical < 0)
trans.Value.z -= deltaTime;
});
}
}
Tie it together
The final step you need to do is to create the systems that handle when you go in-game on the client and what to do when a client connects on the server. You need to be able to send an Rpc
to the server when you connect that tells it you are ready to start playing.
// When client has a connection with network id, go in game and tell server to also go in game
[UpdateInGroup(typeof(ClientSimulationSystemGroup))]
public class GoInGameClientSystem : ComponentSystem
{
protected override void OnCreate()
{
}
protected override void OnUpdate()
{
Entities.WithNone<NetworkStreamInGame>().ForEach((Entity ent, ref NetworkIdComponent id) =>
{
PostUpdateCommands.AddComponent<NetworkStreamInGame>(ent);
var req = PostUpdateCommands.CreateEntity();
PostUpdateCommands.AddComponent<GoInGameRequest>(req);
PostUpdateCommands.AddComponent(req, new SendRpcCommandRequestComponent { TargetConnection = ent });
});
}
}
On the server you need to make sure that when you receive a GoInGameRequest
, you create and spawn a Cube for that player.
// When server receives go in game request, go in game and delete request
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
public class GoInGameServerSystem : ComponentSystem
{
protected override void OnUpdate()
{
Entities.WithNone<SendRpcCommandRequestComponent>().ForEach((Entity reqEnt, ref GoInGameRequest req, ref ReceiveRpcCommandRequestComponent reqSrc) =>
{
PostUpdateCommands.AddComponent<NetworkStreamInGame>(reqSrc.SourceConnection);
UnityEngine.Debug.Log(String.Format("Server setting connection {0} to in game", EntityManager.GetComponentData<NetworkIdComponent>(reqSrc.SourceConnection).Value));
var ghostCollection = GetSingleton<GhostPrefabCollectionComponent>();
var ghostId = NetCubeGhostSerializerCollection.FindGhostType<CubeSnapshotData>();
var prefab = EntityManager.GetBuffer<GhostPrefabBuffer>(ghostCollection.serverPrefabs)[ghostId].Value;
var player = EntityManager.Instantiate(prefab);
EntityManager.SetComponentData(player, new MovableCubeComponent { PlayerId = EntityManager.GetComponentData<NetworkIdComponent>(reqSrc.SourceConnection).Value});
PostUpdateCommands.AddBuffer<CubeInput>(player);
PostUpdateCommands.SetComponent(reqSrc.SourceConnection, new CommandTargetComponent {targetEntity = player});
PostUpdateCommands.DestroyEntity(reqEnt);
});
}
}
Testing it
Now you have set up your code, open Multiplayer > PlayMode Tools and set the PlayMode Type to Client & Server. Enter Play Mode, and the Cube spawns. Press the A,S,D, and W keys to move the Cube around.
To recap on this workflow:
- Created a GameObject and added the ConvertToClientServerEntity component to hold SharedData between the client and the server.
- Created a Prefab out of a simple 3D Cube and added a GhostAuthoringComponent to it as well as the MovableCubeComponent.
- Updated and generated code for the Ghost through the GhostAuthoringComponent Inspector view.
- Added the GhostCollectionAuthoringComponent to an empty GameObject to create a GhostCollection. Updated the ghost list and generated collection code.
- Established a connection between the client and the server.
- Wrote an
Rpc
to tell the server you are ready to play. - Wrote an
ICommandData
to serialize game input. - Wrote a client system to send an
Rpc
- Wrote a server system to handle the incoming
Rpc
.