diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md
index 115b4e07af..a9f91da961 100644
--- a/com.unity.netcode.gameobjects/CHANGELOG.md
+++ b/com.unity.netcode.gameobjects/CHANGELOG.md
@@ -10,6 +10,7 @@ Additional documentation and release notes are available at [Multiplayer Documen
### Added
+- Added `SinglePlayerTransport` that provides the ability to start as a host for a single player network session. (#3473)
- When using UnityTransport >=2.4 and Unity >= 6000.1.0a1, SetConnectionData will accept a fully qualified hostname instead of an IP as a connect address on the client side. (#3441)
### Fixed
diff --git a/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer.meta b/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer.meta
new file mode 100644
index 0000000000..c258a8e265
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 2673df68d03763c43bcce03e872b91e7
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs b/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs
new file mode 100644
index 0000000000..17a240b676
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Collections.Generic;
+
+namespace Unity.Netcode.Transports.SinglePlayer
+{
+ ///
+ /// A transport that can be used to run a Netcode for GameObjects session in "single player" mode
+ /// by assigning this transport to the property before
+ /// starting as a host.
+ ///
+ ///
+ /// You can only start as a host when using this transport.
+ ///
+ public class SinglePlayerTransport : NetworkTransport
+ {
+ ///
+ public override ulong ServerClientId { get; } = 0;
+
+ internal static string NotStartingAsHostErrorMessage = $"When using {nameof(SinglePlayerTransport)}, you must start a hosted session so both client and server are available locally.";
+
+ private struct MessageData
+ {
+ public ulong FromClientId;
+ public ArraySegment Payload;
+ public NetworkEvent Event;
+ public float AvailableTime;
+ }
+
+ private static Dictionary> s_MessageQueue = new Dictionary>();
+
+ private bool m_Initialized;
+ private ulong m_TransportId = 0;
+ private NetworkManager m_NetworkManager;
+
+
+ ///
+ public override void Send(ulong clientId, ArraySegment payload, NetworkDelivery networkDelivery)
+ {
+ var copy = new byte[payload.Array.Length];
+ Array.Copy(payload.Array, copy, payload.Array.Length);
+ s_MessageQueue[clientId].Enqueue(new MessageData
+ {
+ FromClientId = m_TransportId,
+ Payload = new ArraySegment(copy, payload.Offset, payload.Count),
+ Event = NetworkEvent.Data,
+ AvailableTime = (float)m_NetworkManager.LocalTime.FixedTime,
+ });
+ }
+
+ ///
+ public override NetworkEvent PollEvent(out ulong clientId, out ArraySegment payload, out float receiveTime)
+ {
+ if (s_MessageQueue[m_TransportId].Count > 0)
+ {
+ var data = s_MessageQueue[m_TransportId].Peek();
+ if (data.AvailableTime > m_NetworkManager.LocalTime.FixedTime)
+ {
+ clientId = 0;
+ payload = new ArraySegment();
+ receiveTime = 0;
+ return NetworkEvent.Nothing;
+ }
+
+ s_MessageQueue[m_TransportId].Dequeue();
+ clientId = data.FromClientId;
+ payload = data.Payload;
+ receiveTime = m_NetworkManager.LocalTime.TimeAsFloat;
+ if (m_NetworkManager.IsServer && data.Event == NetworkEvent.Connect)
+ {
+ s_MessageQueue[data.FromClientId].Enqueue(new MessageData { Event = NetworkEvent.Connect, FromClientId = ServerClientId, Payload = new ArraySegment() });
+ }
+ return data.Event;
+ }
+ clientId = 0;
+ payload = new ArraySegment();
+ receiveTime = 0;
+ return NetworkEvent.Nothing;
+ }
+
+ ///
+ ///
+ /// This will always return false for .
+ /// Always use .
+ ///
+ public override bool StartClient()
+ {
+ NetworkLog.LogError(NotStartingAsHostErrorMessage);
+ return false;
+ }
+
+ ///
+ ///
+ /// Always use when hosting a local single player session.
+ ///
+ public override bool StartServer()
+ {
+ s_MessageQueue[ServerClientId] = new Queue();
+ if (!m_NetworkManager.LocalClient.IsHost && m_NetworkManager.LocalClient.IsServer)
+ {
+ NetworkLog.LogError(NotStartingAsHostErrorMessage);
+ return false;
+ }
+ return true;
+ }
+
+ ///
+ public override void DisconnectRemoteClient(ulong clientId)
+ {
+ s_MessageQueue[clientId].Enqueue(new MessageData { Event = NetworkEvent.Disconnect, FromClientId = m_TransportId, Payload = new ArraySegment() });
+ }
+
+ ///
+ public override void DisconnectLocalClient()
+ {
+ s_MessageQueue[ServerClientId].Enqueue(new MessageData { Event = NetworkEvent.Disconnect, FromClientId = m_TransportId, Payload = new ArraySegment() });
+ }
+
+ ///
+ ///
+ /// Will always return 0 since this transport is for a local single player session.
+ ///
+ public override ulong GetCurrentRtt(ulong clientId)
+ {
+ return 0;
+ }
+
+ ///
+ public override void Shutdown()
+ {
+ s_MessageQueue.Clear();
+ m_TransportId = 0;
+ }
+
+ ///
+ protected override NetworkTopologyTypes OnCurrentTopology()
+ {
+ return m_NetworkManager != null ? m_NetworkManager.NetworkConfig.NetworkTopology : NetworkTopologyTypes.ClientServer;
+ }
+
+ ///
+ public override void Initialize(NetworkManager networkManager = null)
+ {
+ s_MessageQueue.Clear();
+ m_NetworkManager = networkManager;
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs.meta b/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs.meta
new file mode 100644
index 0000000000..c630fcdb29
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a6fd89dc549847f4096c58273ff2e979
\ No newline at end of file
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs
new file mode 100644
index 0000000000..4f198c73d2
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs
@@ -0,0 +1,211 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using NUnit.Framework;
+using Unity.Netcode.TestHelpers.Runtime;
+using Unity.Netcode.Transports.SinglePlayer;
+using UnityEngine;
+using UnityEngine.TestTools;
+using Random = UnityEngine.Random;
+
+namespace Unity.Netcode.RuntimeTests
+{
+ internal class SinglePlayerTransportTests : NetcodeIntegrationTest
+ {
+ protected override int NumberOfClients => 0;
+
+ public struct SerializableStruct : INetworkSerializable, IEquatable
+ {
+ public bool BoolValue;
+ public ulong ULongValue;
+
+ public bool Equals(SerializableStruct other)
+ {
+ return other.BoolValue == BoolValue && other.ULongValue == ULongValue;
+ }
+
+ public void NetworkSerialize(BufferSerializer serializer) where T : IReaderWriter
+ {
+ serializer.SerializeValue(ref BoolValue);
+ serializer.SerializeValue(ref ULongValue);
+ }
+ }
+
+ public class SinglePlayerTestComponent : NetworkBehaviour
+ {
+ private enum SpawnStates
+ {
+ PreSpawn,
+ Spawn,
+ PostSpawn,
+ }
+
+ private enum RpcInvocations
+ {
+ SendToServerRpc,
+ SendToEveryoneRpc,
+ SendToOwnerRpc,
+ }
+
+ private Dictionary m_SpawnStateInvoked = new Dictionary();
+ private Dictionary m_RpcInvocations = new Dictionary();
+ private NetworkVariable m_IntValue = new NetworkVariable();
+ private NetworkVariable m_SerializableValue = new NetworkVariable();
+
+
+ private void SpawnStateInvoked(SpawnStates spawnState)
+ {
+ if (!m_SpawnStateInvoked.ContainsKey(spawnState))
+ {
+ m_SpawnStateInvoked.Add(spawnState, 1);
+ }
+ else
+ {
+ m_SpawnStateInvoked[spawnState]++;
+ }
+ }
+
+ private void RpcInvoked(RpcInvocations rpcInvocation)
+ {
+ if (!m_RpcInvocations.ContainsKey(rpcInvocation))
+ {
+ m_RpcInvocations.Add(rpcInvocation, 1);
+ }
+ else
+ {
+ m_RpcInvocations[rpcInvocation]++;
+ }
+ }
+
+ private void ValidateValues(int someIntValue, SerializableStruct someValues)
+ {
+ Assert.IsTrue(m_IntValue.Value == someIntValue);
+ Assert.IsTrue(someValues.BoolValue == m_SerializableValue.Value.BoolValue);
+ Assert.IsTrue(someValues.ULongValue == m_SerializableValue.Value.ULongValue);
+ }
+
+ [Rpc(SendTo.Server)]
+ private void SendToServerRpc(int someIntValue, SerializableStruct someValues, RpcParams rpcParams = default)
+ {
+ ValidateValues(someIntValue, someValues);
+ RpcInvoked(RpcInvocations.SendToServerRpc);
+ }
+
+ [Rpc(SendTo.Everyone)]
+ private void SendToEveryoneRpc(int someIntValue, SerializableStruct someValues, RpcParams rpcParams = default)
+ {
+ ValidateValues(someIntValue, someValues);
+ RpcInvoked(RpcInvocations.SendToEveryoneRpc);
+ }
+
+ [Rpc(SendTo.Owner)]
+ private void SendToOwnerRpc(int someIntValue, SerializableStruct someValues, RpcParams rpcParams = default)
+ {
+ ValidateValues(someIntValue, someValues);
+ RpcInvoked(RpcInvocations.SendToOwnerRpc);
+ }
+
+
+ protected override void OnNetworkPreSpawn(ref NetworkManager networkManager)
+ {
+ SpawnStateInvoked(SpawnStates.PreSpawn);
+ base.OnNetworkPreSpawn(ref networkManager);
+ }
+
+ public override void OnNetworkSpawn()
+ {
+ SpawnStateInvoked(SpawnStates.Spawn);
+ m_IntValue.Value = Random.Range(0, 100);
+ m_SerializableValue.Value = new SerializableStruct()
+ {
+ BoolValue = Random.Range(0, 100) >= 50.0 ? true : false,
+ ULongValue = (ulong)Random.Range(0, 100000),
+ };
+ base.OnNetworkSpawn();
+ }
+
+ protected override void OnNetworkPostSpawn()
+ {
+ SpawnStateInvoked(SpawnStates.PostSpawn);
+ SendToServerRpc(m_IntValue.Value, m_SerializableValue.Value);
+ SendToEveryoneRpc(m_IntValue.Value, m_SerializableValue.Value);
+ SendToOwnerRpc(m_IntValue.Value, m_SerializableValue.Value);
+ base.OnNetworkPostSpawn();
+ }
+
+ public void ValidateStatesAndRpcInvocations()
+ {
+ foreach (var entry in m_SpawnStateInvoked)
+ {
+ Assert.True(entry.Value == 1, $"{entry.Key} failed with {entry.Value} invocations!");
+ }
+ foreach (var entry in m_RpcInvocations)
+ {
+ Assert.True(entry.Value == 1, $"{entry.Key} failed with {entry.Value} invocations!");
+ }
+ }
+ }
+
+ private GameObject m_PrefabToSpawn;
+ private bool m_CanStartHost;
+
+ protected override IEnumerator OnSetup()
+ {
+ m_CanStartHost = false;
+ return base.OnSetup();
+ }
+
+ protected override void OnCreatePlayerPrefab()
+ {
+ m_PlayerPrefab.AddComponent();
+ base.OnCreatePlayerPrefab();
+ }
+
+ protected override void OnServerAndClientsCreated()
+ {
+ var singlePlayerTransport = m_ServerNetworkManager.gameObject.AddComponent();
+ m_ServerNetworkManager.NetworkConfig.NetworkTransport = singlePlayerTransport;
+ m_PrefabToSpawn = CreateNetworkObjectPrefab("TestObject");
+ m_PrefabToSpawn.AddComponent();
+ base.OnServerAndClientsCreated();
+ }
+
+ protected override bool CanStartServerAndClients()
+ {
+ return m_CanStartHost;
+ }
+
+ [UnityTest]
+ public IEnumerator StartSinglePlayerAndSpawn()
+ {
+ m_CanStartHost = true;
+
+ yield return StartServerAndClients();
+
+ var spawnedInstance = SpawnObject(m_PrefabToSpawn, m_ServerNetworkManager).GetComponent();
+ var testComponent = spawnedInstance.GetComponent();
+ yield return s_DefaultWaitForTick;
+ var playerTestComponent = m_ServerNetworkManager.LocalClient.PlayerObject.GetComponent();
+ testComponent.ValidateStatesAndRpcInvocations();
+ playerTestComponent.ValidateStatesAndRpcInvocations();
+ }
+
+ [UnityTest]
+ public IEnumerator StartSinglePlayerAsClientError()
+ {
+ LogAssert.Expect(LogType.Error, $"[Netcode] {SinglePlayerTransport.NotStartingAsHostErrorMessage}");
+ LogAssert.Expect(LogType.Error, $"[Netcode] Client is shutting down due to network transport start failure of {nameof(SinglePlayerTransport)}!");
+ Assert.IsFalse(m_ServerNetworkManager.StartClient());
+ yield return null;
+ }
+
+ [UnityTest]
+ public IEnumerator StartSinglePlayerAsServerError()
+ {
+ LogAssert.Expect(LogType.Error, $"[Netcode] {SinglePlayerTransport.NotStartingAsHostErrorMessage}");
+ LogAssert.Expect(LogType.Error, $"[Netcode] Server is shutting down due to network transport start failure of {nameof(SinglePlayerTransport)}!");
+ Assert.IsFalse(m_ServerNetworkManager.StartServer());
+ yield return null;
+ }
+ }
+}
diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs.meta
new file mode 100644
index 0000000000..6b0e890eea
--- /dev/null
+++ b/com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ef95e3cf960407d438bec5f96544c8ac
\ No newline at end of file