Лекция
Даже маленькое изменение может сломать сетевой контракт:
В результате клиент может получить disconnect, ошибку десериализации, рассинхрон состояния или даже WebGL/WASM-ошибку вида:
Uncaught RuntimeError: function signature mismatch
Главная идея статьи:
Старые клиенты можно поддерживать, но только если сетевой протокол заранее проектируется как версионированный и обратно совместимый.
В обычном веб-приложении сервер часто может поменяться, а старый frontend еще какое-то время продолжит работать, если API совместим. В мультиплеерной игре все строже: клиент и сервер обмениваются бинарными сетевыми сообщениями, RPC-вызовами, состоянием NetworkVariable, spawn-данными и идентификаторами сетевых объектов.
Если клиент и сервер по-разному понимают один и тот же сетевой пакет, возникает проблема.
Например, раньше был RPC:
[ServerRpc]
private void AttackServerRpc(int weaponId)
{
}
Потом на сервере сделали так:
[ServerRpc]
private void AttackServerRpc(int weaponId, float power)
{
}
Для разработчика это выглядит как маленькое изменение. Но для старого клиента это уже другой сетевой контракт. Старый клиент отправляет только weaponId, а сервер может ожидать weaponId + power.
В итоге ошибка может произойти еще до входа в тело метода. Обычный try/catch внутри RPC уже не поможет.
Сетевой контракт — это все, о чем клиент и сервер должны договориться заранее(это просто формат байтов в котра передается полезная нагрузка).
В Unity Netcode к сетевому контракту относятся:
Если этот контракт поменялся несовместимо, старый клиент не должен входить в игру. Его нужно мягко отключить с понятным сообщением:
Версия игры устарела. Обновите страницу или клиент.
Unity Netcode поддерживает Connection Approval, где сервер может проверить клиента до полноценного подключения. В ответе можно указать причину отказа через response.Reason, а клиент сможет прочитать ее через NetworkManager.DisconnectReason.
Частая ошибка — пытаться решить это так:
try
{
NetworkManager.Singleton.StartClient();
}
catch (Exception e)
{
Debug.LogError(e);
}
Или так:
[ServerRpc]
private void SomeServerRpc(int value)
{
try
{
// logic
}
catch (Exception e)
{
Debug.LogError(e);
}
}
Такой try/catch полезен, если ошибка возникла внутри вашей игровой логики. Но он не спасает, если ошибка возникла:
То есть:
ошибка внутри RPC-метода → try/catch может помочь ошибка до входа в RPC-метод → try/catch обычно не поможет ошибка WebGL function mismatch → C# try/catch обычно не поможет
Поэтому правильная стратегия — не “глушить” такие ошибки, а не допускать несовместимый клиент до игрового состояния.
Не нужно использовать только одну версию приложения. Лучше разделить две разные сущности:
GameVersion — версия билда игры, например 1.0.6 ProtocolVersion — версия сетевого протокола, например 4
GameVersion может изменяться часто: UI, баланс, графика, тексты, локализация.
ProtocolVersion нужно менять только тогда, когда изменился сетевой контракт.
Пример:
Клиент 1.0.5, protocol 4 Сервер 1.0.6, protocol 4 Можно подключать.
Но:
Клиент 1.0.5, protocol 3 Сервер 1.0.6, protocol 4 Можно подключать только если сервер еще поддерживает protocol 3.
Или:
Клиент 1.0.2, protocol 1 Сервер 1.0.6, minSupportedProtocol 3 Нельзя подключать.
На сервере удобно хранить:
private const int SERVER_PROTOCOL = 4; private const int MIN_SUPPORTED_PROTOCOL = 3;
Это означает:
сервер сам работает на protocol 4 сервер еще поддерживает клиентов protocol 3 клиенты protocol 1 и 2 уже слишком старые
Схема:
clientProtocol < MIN_SUPPORTED_PROTOCOL → отказ clientProtocol > SERVER_PROTOCOL → клиент новее сервера, отказ иначе → подключение разрешено

На клиенте перед подключением передаем данные:
using System;
using System.Text;
using Unity.Netcode;
using UnityEngine;
public class ClientConnector : MonoBehaviour
{
private const string GAME_VERSION = "1.0.5";
private const int CLIENT_PROTOCOL = 3;
public void Connect()
{
var payload = new ClientConnectionPayload
{
gameVersion = GAME_VERSION,
protocol = CLIENT_PROTOCOL
};
string json = JsonUtility.ToJson(payload);
NetworkManager.Singleton.NetworkConfig.ConnectionData =
Encoding.UTF8.GetBytes(json);
NetworkManager.Singleton.OnClientDisconnectCallback += OnClientDisconnected;
NetworkManager.Singleton.StartClient();
}
private void OnClientDisconnected(ulong clientId)
{
string reason = NetworkManager.Singleton.DisconnectReason;
if (!string.IsNullOrEmpty(reason))
{
Debug.LogWarning("Disconnected: " + reason);
// Здесь можно показать UI:
// "Версия игры устарела. Обновите игру."
}
else
{
Debug.LogWarning("Disconnected without reason.");
}
}
[Serializable]
private class ClientConnectionPayload
{
public string gameVersion;
public int protocol;
}
}
На сервере:
using System;
using System.Text;
using Unity.Netcode;
using UnityEngine;
public class ServerConnectionApproval : MonoBehaviour
{
private const int SERVER_PROTOCOL = 4;
private const int MIN_SUPPORTED_PROTOCOL = 3;
private void Awake()
{
NetworkManager.Singleton.NetworkConfig.ConnectionApproval = true;
NetworkManager.Singleton.ConnectionApprovalCallback = ApprovalCheck;
}
private void ApprovalCheck(
NetworkManager.ConnectionApprovalRequest request,
NetworkManager.ConnectionApprovalResponse response)
{
try
{
string json = Encoding.UTF8.GetString(request.Payload);
ClientConnectionPayload payload =
JsonUtility.FromJson(json);
if (payload == null)
{
Reject(response, "Invalid connection data.");
return;
}
if (payload.protocol < MIN_SUPPORTED_PROTOCOL)
{
Reject(response, "Your game version is too old. Please update.");
return;
}
if (payload.protocol > SERVER_PROTOCOL)
{
Reject(response, "Server version is older than your client. Please try again later.");
return;
}
response.Approved = true;
response.CreatePlayerObject = true;
response.Pending = false;
Debug.Log(
$"Client approved. GameVersion={payload.gameVersion}, Protocol={payload.protocol}"
);
}
catch (Exception e)
{
Debug.LogWarning("Connection approval failed: " + e.Message);
Reject(response, "Invalid client version data.");
}
}
private void Reject(
NetworkManager.ConnectionApprovalResponse response,
string reason)
{
response.Approved = false;
response.CreatePlayerObject = false;
response.Reason = reason;
response.Pending = false;
}
[Serializable]
private class ClientConnectionPayload
{
public string gameVersion;
public int protocol;
}
}
ConnectionData передается клиентом в процессе подключения, а сервер получает эти данные в ConnectionApprovalRequest.Payload. Это позволяет отклонить клиента до создания player object и до начала полноценной сетевой синхронизации.
В Unity Netcode есть собственная настройка NetworkConfig.ProtocolVersion. Если она отличается у клиента и сервера, Netcode может не позволить им общаться.
Это полезно для строгой несовместимости:
изменился сетевой контракт полностью старые клиенты точно нельзя пускать
Но если тебе нужно поддерживать старые клиенты, лучше использовать осторожную схему:
Unity NetworkConfig.ProtocolVersion менять только при жесткой несовместимости. Свою appProtocol/gameProtocol передавать через ConnectionData.
То есть у тебя может быть:
NetworkConfig.ProtocolVersion = 1 appProtocol = 3 или 4
И уже сервер сам решает, какие версии поддерживать.
Обычно безопаснее:
Пример безопасного изменения:
[ServerRpc]
private void AttackServerRpc(int weaponId)
{
AttackInternal(weaponId, 1.0f);
}
[ServerRpc]
private void AttackV2ServerRpc(int weaponId, float power)
{
AttackInternal(weaponId, power);
}
private void AttackInternal(int weaponId, float power)
{
// общая логика
}
Старый клиент вызывает:
AttackServerRpc(weaponId);
Новый клиент вызывает:
AttackV2ServerRpc(weaponId, power);
Сервер поддерживает оба варианта.
Опасные изменения:
Главное правило:
Если старый клиент может отправить или получить это сообщение — старый формат должен продолжать существовать.
Допустим, была структура:
public struct PlayerState : INetworkSerializable
{
public int hp;
public float speed;
public int weaponId;
public void NetworkSerialize(BufferSerializer serializer)
where T : IReaderWriter
{
serializer.SerializeValue(ref hp);
serializer.SerializeValue(ref speed);
serializer.SerializeValue(ref weaponId);
}
}
Потом ты поменял порядок:
serializer.SerializeValue(ref weaponId); serializer.SerializeValue(ref hp); serializer.SerializeValue(ref speed);
Для старого клиента это катастрофа. Он будет читать не те данные не в те поля.
Правильнее добавлять поля в конец и учитывать версию:
public struct PlayerState : INetworkSerializable
{
public int dataVersion;
public int hp;
public float speed;
public int weaponId;
public float armor;
public void NetworkSerialize(BufferSerializer serializer)
where T : IReaderWriter
{
serializer.SerializeValue(ref dataVersion);
serializer.SerializeValue(ref hp);
serializer.SerializeValue(ref speed);
serializer.SerializeValue(ref weaponId);
if (dataVersion >= 2)
{
serializer.SerializeValue(ref armor);
}
else
{
armor = 0f;
}
}
}
Unity Netcode поддерживает кастомную сериализацию через INetworkSerializable, что позволяет явно контролировать, как ваши типы записываются и читаются по сети.
Иногда удобнее разделять:
ClientToServerAttackRequest ServerToClientAttackResult PlayerStateSnapshotV1 PlayerStateSnapshotV2
Например:
public struct AttackRequestV1 : INetworkSerializable
{
public int weaponId;
public void NetworkSerialize(BufferSerializer serializer)
where T : IReaderWriter
{
serializer.SerializeValue(ref weaponId);
}
}
public struct AttackRequestV2 : INetworkSerializable
{
public int weaponId;
public float power;
public void NetworkSerialize(BufferSerializer serializer)
where T : IReaderWriter
{
serializer.SerializeValue(ref weaponId);
serializer.SerializeValue(ref power);
}
}
Такой код длиннее, но безопаснее и понятнее.
NetworkVariable — это не одноразовое сообщение, а синхронизируемое состояние. Когда клиент подключается, он получает актуальное значение NetworkVariable, а дальше может получать обновления. Unity указывает, что при первом подключении клиент синхронизируется с текущим значением NetworkVariable; обычно обработчики OnValueChanged регистрируют в OnNetworkSpawn.
Опасно менять:
public NetworkVariable intHealth = new();
на:
public NetworkVariablefloat Health = new();
Для старого клиента это уже другой формат.
Лучше:
public NetworkVariableint HealthLegacy = new(); public NetworkVariable float HealthV2 = new();
И дальше:
private void SetHealth(float health)
{
HealthV2.Value = health;
HealthLegacy.Value = Mathf.RoundToInt(health);
}
Старые клиенты читают HealthLegacy, новые — HealthV2.
Это одна из самых неприятных тонкостей.
На prefab может быть несколько NetworkBehaviour:
Если ты переставишь их так:
то старый клиент и новый сервер могут по-разному сопоставлять сетевые сообщения с компонентами.
Даже если классы остались теми же, порядок может иметь значение для сетевого поведения.
Безопаснее:
То есть:
Плохо:
// Было:
[ServerRpc]
private void BuyItemServerRpc(int itemId)
{
}
// Стало:
[ServerRpc]
private void BuyItemServerRpc(int itemId, int amount)
{
}
Схема жизненного цикла сетевого изменения
Пример хорошего изменнения, Допустим, нужно добавить силу атаки power.
|
Версия 1 |
Версия 2 |
Версия 3 |
Версия 4 |
[ServerRpc]
private void AttackServerRpc(int weaponId)
{
AttackInternal(weaponId, 1f);
}
|
Сервер поддерживает старый и новый RPC:
[ServerRpc]
private void AttackServerRpc(int weaponId)
{
AttackInternal(weaponId, 1f);
}
[ServerRpc]
private void AttackV2ServerRpc(int weaponId, float power)
{
AttackInternal(weaponId, power);
}
Новый клиент использует: AttackV2ServerRpc(weaponId, power); Старый клиент продолжает использовать: AttackServerRpc(weaponId); |
Когда старые клиенты больше не поддерживаются: private const int MIN_SUPPORTED_PROTOCOL = 4; |
Старый RPC можно удалить. остается только
[ServerRpc]
private void AttackV2ServerRpc(int weaponId, float power)
{
AttackInternal(weaponId, power);
}
|
| Существующий проект | Проект поддерживаемый старых и новых клиентов | ограничение с проверкой по версии | Проект поддерживаемый только новых клиентов |
Если у клиента и сервера отличаются сетевые prefab-ы, это тоже может привести к ошибкам подключения или spawn-логики. В NetworkManager есть настройки, связанные с проверкой сетевой конфигурации, включая Force Same Prefabs. Документация Unity описывает настройки NetworkManager, включая проверки конфигурации и параметры протокола.
Практическое правило:
Если старые клиенты должны подключаться, не ломай список старых network prefab.
Можно добавлять новые prefab, но нужно следить, чтобы старый клиент не получал spawn объекта, которого он не знает.
Например:
if (clientProtocol >= 4)
{
SpawnNewCosmeticObjectForClient(clientId);
}
else
{
// старому клиенту не спавним новый объект
}

После approval полезно сохранить версию клиента:
private readonly Dictionary_clientProtocols = new(); private void ApprovalCheck( NetworkManager.ConnectionApprovalRequest request, NetworkManager.ConnectionApprovalResponse response) { var payload = DecodePayload(request.Payload); if (payload.protocol < MIN_SUPPORTED_PROTOCOL) { Reject(response, "Your game version is too old. Please update."); return; } _clientProtocols[request.ClientNetworkId] = payload.protocol; response.Approved = true; response.CreatePlayerObject = true; response.Pending = false; } private bool IsLegacyClient(ulong clientId) { return _clientProtocols.TryGetValue(clientId, out int protocol) && protocol < 4; }
Но нужно не забыть удалять запись при disconnect:
private void OnEnable()
{
NetworkManager.Singleton.OnClientDisconnectCallback += OnClientDisconnected;
}
private void OnDisable()
{
if (NetworkManager.Singleton != null)
{
NetworkManager.Singleton.OnClientDisconnectCallback -= OnClientDisconnected;
}
}
private void OnClientDisconnected(ulong clientId)
{
_clientProtocols.Remove(clientId);
}
Пример:
private void SendMatchState(ulong clientId)
{
if (IsLegacyClient(clientId))
{
SendMatchStateLegacyClientRpc(
CreateLegacyState(),
new ClientRpcParams
{
Send = new ClientRpcSendParams
{
TargetClientIds = new[] { clientId }
}
});
}
else
{
SendMatchStateV2ClientRpc(
CreateV2State(),
new ClientRpcParams
{
Send = new ClientRpcSendParams
{
TargetClientIds = new[] { clientId }
}
});
}
}
[ClientRpc]
private void SendMatchStateLegacyClientRpc(
MatchStateLegacy state,
ClientRpcParams clientRpcParams = default)
{
}
[ClientRpc]
private void SendMatchStateV2ClientRpc(
MatchStateV2 state,
ClientRpcParams clientRpcParams = default)
{
}
Идея простая:
Ошибка:
Uncaught RuntimeError: function signature mismatch
в WebGL может быть связана не только с Netcode. Часто это проблема несовместимости загруженных файлов, кэша или WASM/JS-связки.
Например браузер мог загрузить:
Или наоборот.
Для WebGL обязательно нужно:
Хорошая схема:
И HTML должен ссылаться на конкретную версию.
Плохая схема:
/game/Build/game.wasm /game/Build/game.data /game/Build/game.framework.js
если файлы перезаписываются поверх старых, а браузер/прокси/CDN может отдать смешанные версии.
Можно добавить глобальный перехватчик в index.html:
window.addEventListener("error", function (event) {
console.error("Global JS error:", event.message);
if (
event.message &&
event.message.includes("function signature mismatch")
) {
alert("Ошибка версии игры. Обновите страницу.");
}
});
window.addEventListener("unhandledrejection", function (event) {
console.error("Unhandled promise rejection:", event.reason);
});
Но это только аварийная диагностика.
Важно:
Можно собирать критические ошибки:
using UnityEngine;
public class ClientErrorLogger : MonoBehaviour
{
private void OnEnable()
{
Application.logMessageReceived += OnLogMessage;
}
private void OnDisable()
{
Application.logMessageReceived -= OnLogMessage;
}
private void OnLogMessage(string condition, string stackTrace, LogType type)
{
if (type != LogType.Exception && type != LogType.Error)
{
return;
}
bool looksLikeNetworkCompatibilityError =
condition.Contains("RPC") ||
condition.Contains("NetworkVariable") ||
condition.Contains("NetworkBehaviour") ||
condition.Contains("NetworkObject") ||
condition.Contains("serialization") ||
condition.Contains("deserialization") ||
condition.Contains("DisconnectReason");
if (looksLikeNetworkCompatibilityError)
{
Debug.LogWarning("Possible network compatibility error: " + condition);
// Здесь можно отправить лог в аналитику:
// gameVersion, protocolVersion, serverVersion, userId, platform
}
}
}
Что стоит логировать:
Хорошая стратегия обновления:
То есть сервер сначала становится совместимым с двумя версиями, а уже потом старый протокол отключается.
Плохая стратегия:
Перед обновлением сервера проверь:
Если ответ “да” на опасные пункты, нужно либо:
оставить legacy-совместимость
либо:
поднять минимальную версию и мягко отключить старых клиентов
Пример:
[ServerRpc]
private void UseSkillServerRpc(int skillId)
{
UseSkillInternal(skillId, Vector3.zero, false);
}
[ServerRpc]
private void UseSkillV2ServerRpc(int skillId, Vector3 targetPosition, bool charged)
{
UseSkillInternal(skillId, targetPosition, charged);
}
private void UseSkillInternal(int skillId, Vector3 targetPosition, bool charged)
{
// вся серверная логика здесь
}
Пример:
public struct InventoryItemV2 : INetworkSerializable
{
public int dataVersion;
public int itemId;
public int amount;
public int rarity;
public void NetworkSerialize(BufferSerializer serializer)
where T : IReaderWriter
{
serializer.SerializeValue(ref dataVersion);
serializer.SerializeValue(ref itemId);
serializer.SerializeValue(ref amount);
if (dataVersion >= 2)
{
serializer.SerializeValue(ref rarity);
}
else
{
rarity = 0;
}
}
}
Плохое сообщение:
Connection failed.
Лучше:
Версия игры устарела. Обновите страницу или перезапустите игру.
Для WebGL:
Версия игры обновилась. Перезагрузите страницу, чтобы загрузить новую версию.
Для мобильной игры:
Доступно обновление. Установите новую версию из магазина.
Для Steam/desktop:
Версия клиента устарела. Перезапустите игру, чтобы получить обновление.

Делательно иметь такие классы:
NetworkVersionInfo ClientConnectionPayload ServerConnectionApproval ClientDisconnectHandler NetworkCompatibilityService LegacyRpcSupport ClientErrorLogger
Пример NetworkVersionInfo:
public static class NetworkVersionInfo
{
public const string GameVersion = "1.0.6";
public const int CurrentProtocol = 4;
public const int MinSupportedProtocol = 3;
}
Payload:
[Serializable]
public class ClientConnectionPayload
{
public string gameVersion;
public int protocol;
public string platform;
}
Повышай protocol, если изменилось:
RPC API формат RPC-параметров custom serialization NetworkVariable type NetworkBehaviour порядок NetworkObject структура NetworkPrefab список обязательная scene/spawn логика формат custom messages
Не обязательно повышать protocol, если изменилось:
UI тексты анимации иконки баланс на сервере визуальные эффекты локализация не сетевые MonoBehaviour внутренняя server-side логика без изменения формата сообщений
Старые клиенты можно поддерживать после обновления сервера, но нельзя полагаться на случайную совместимость.
Главные правила:
Самое важное:
GameVersion может отличаться. ProtocolVersion должен быть совместим.
А ошибки вроде:
function signature mismatch
лучше не пытаться “глушить”. Это уже признак того, что клиент и сервер или WebGL-файлы оказались несовместимы. Правильное решение — обнаружить несовместимость заранее, мягко отключить клиента и показать понятное сообщение об обновлении.
Комментарии