
Зеролаг – це як Зератул, тільки краще. Краще, бо сам Зератул страждав від локстепу (lockstep – популярна колись архітектура мережевих ігор) – тобто весь старкрафт завмирав, якщо хоч у когось із гравців ставався мережевий лаг.
Мережевий режим гри – це, по-перше, необхідність для сьогоднішніх ігор. Він доречний і потрібен може 80% всіх ігор. І навіть креативно проривається в хороші одиночні ігри на проходження, як DarkSouls і тд. По-друге, це досить повторювана і абстраговувана задача.
Задача, яка знаходиться в такому ряду задач як фізика, AI супротивників, рендеринг, кросплатформенність, взагалі IDE розробки і тд. Але для фізики у нас є тільки два основних рішення – PhysX, Havoc і багато додаткових дуже специфічних або велосипедоподібних рішень. Для рендерингу гарно прижились древні OpenGL, DirectX, і новіші Vulkan, Metal. Для AI поки немає таких загальноприйнятих стандартів, хоча спроби робляться активно – і цьому є зрозуміле пояснення: все ж цю задачу важко абстрагувать, вимоги надто різні. Для кросплатформенності і IDE у нас є Unity, Unreal Engine як стандарти маже всієї індустрії (крім хіба що AAA).
А для мережі неясно чому немає таких стандартних інструментів. Вони мають бути. Ситуація нагадує часи мого дитинства, коли я з одногрупниками замість взяти DirectX писав наспір в кого краща, швидша растеризація трикутника. Є ‘первинний суп’ із багатьох варіантів бібліотек. Найкраще можна описати ситуацію як “є величезна збірка підходів і бібліотек, загальоприйняті лише підходи”.
Зеролаг став для нас відповіддю на запитання “а як найкраще можна організувати мережевий режим в нашій грі?”. Тобто тим, як найкраще ми могли помислити рішення проблеми мережі. Саме тому про Зеролаг цікаво розповідати.
Якою ж може бути найкраща для RiftersAR мережа?

- По-перше, у нас шутер в реальному часі, це значить мінімізація впливу лагів. Це значить передбачення на клієнтах
- інтерполяція станів при зміні передбачення
- хедшоти і взагалі постріли повинні передаватись якнайшвидше – а значить на рівні протоколу потрібно могти використовувати найшвидше що є – спамити один і той же пакет по UDP, поки не отримано підтвердження про отримання. В Unity це називається allcostdelivery
- переміщення гравців найефективніше отримувати періодично по UDP, ігноруючи пакети які прийшли не в тому порядку. В Unity це updatestate
- а отже для різних команд – різні протоколи
- але мінімізувати використання мережі – отже передавати мінімум. А мінімум у нас – це дії гравців. Отже передавати тільки вхідні команди гравців. (помічу, що наприклад для батл-роялів мінімум – це не команди гравців, гравців багато, світ великий. Там мінімум – це стан видимого гравцю. А у нас AR-гра, в в мережевому режимі там найчастіше 2 гравці – бо їм потрібно зібратись разом фізично щоб грати)
- якщо у нас передаються лише команди гравців і є передбачення – це означає, що нам необхідний детермінізм. Заодно отримуємо безкоштовні легкі реплеї і юніт тести майже всіх фіч ігрової логіки (чим ми і скористались).
- передбачення необхідно змінювати на основі нової інформації, що прийшла з мережі із запізненням, тобто команди ‘ваш союзник вистрелив туди-то, використавши такий-то скіл. І сталось стільки-то мілісекунд назад’ – тому потрібна хороша синхронізація в часі, потрібна можливість відкотитись в минуле і пересимулювати все – отже треба пам’ятати попередні стани ігрового світу
- хороша синхронізація вимагає одномоментний початок бою. Отже правильно дочекатись завантаження всіх ресірсів у всіх гравців, передати всім настройки матчу, звірити годинники і призначити час, коли всі клієнти одночасно стартують симуляцію.
- пересимуляція – іноді проста, але іноді обчислювально складна задача. А мобільні пристрої – не найсильніші у відомій галактиці комп’ютери. Крім того, вони уже сильно зайняті задачею доповненої реальності. Насамперед, треба їх по максимуму використати – зробити пересимуляцію з багатопоточним режимом. Насправді, винести ресимуляції просто в окремий потік, так як сама задача складно паралелиться і все одно продуктивнісно-оптимізованих ядер у айфонів всього два.
- З тої ж причини все повинно бути оптимізовано під швидкі ресимуляції – як сама логіка геймплею, її архітектура коду. Так, наприклад, перевірка колізій спочатку була із стороннього фізичного движка із відкритим кодом, потім отримувала оптимізації і спеціалізації аж доки не опинилась повністю за бортом – я написав перевірку колізій заново і з нулем непотрібних в нашій грі обчислень під капотом.
- Максимально швидкі реплікації даних – збереження попередніх станів світу відбувається часто.
- також потрібно враховувати, що старі пристрої все одно можуть не справлятись із задачею. Старі пристрої повинні зменшити якість ресимуляцій а не потрапити в лавиноподібну проблему накопичення нових запитів ресимуляцій. Це призвело до рішення, яке я назвав ‘хвилевою’ ресимуляцією.
- максимально швидкі реплікації і серіалізація команд після деякого дослідження привели нас до рішення, що кращий варіант – не використовувати якісь хирті бібліотеки, а просто тупо написати код, який робить саме те, що потрібно. Але це рутинна задача, писати вручну неефективно через помилки і трату часу. І потім ще лікувати від депресії співробітника, який це писатиме. Тому наше рішення – кодогенерація для реплікації
- що повинно ставатись при власне мережевих проблемах, на які не можна вплинути? що якщо є гравець який дійсно лагає? потрібно, щоб гра вела себе адекватно – не псувала задоволення всім – як тим, хто лагає, так і звичайним гравцям. Але якщо лаги дійсно унеможливлюють гру, то щоб гра так чесно і оголосила гравцям. Отже, є якийсь поріг затримки, після якого затримана команда не виконається, а буде завернута на клієнт із повідомленням про відміну. Але якщо ця команда була не значною, – наприклад, переміщення гравця на 1 см вправо – то її можна просто виконати пізніше.
- не завжди нам потрібні ресимуляції і передбачення – більшість партій в нашій грі все ж сінглплеєрні, і повторам все одно чи по мережі була зіграна гра. Отже, рушій повинен підтримувати лінійний режим. (Насправді, у нас є два типи реплею – той, детерміністичний, до якого збіглись всі ресимульовані на всіх клієнтах, і реплей того, що бачив конкретний клієнт. Цей реплей дозволяє відлагоджувати пересимуляції і інтерполяції уже на графічному рівні)
- написання нових фіч повинно ніяк не бути залежним від мережі. Код мережі розв’язаний із логікою гри. А це значить, що код нового скіла повинен взагалі не уявляти чи він зараз виконується в грі з пересимуляціями, чи в сінглплеєрній, чи взагалі в реплеї або в юніт тесті, де немає графіки.
- потрібно мінімально використовувати сервер в обчисленнях – від цього залежить ціна підтримки гри. На сервері не повинно бути пересимуляцій в жлдному разі – тому він просто “живе в минулому” – обчислює тільки той час, який вже не може бути зміненим. Також не обов’язково взагалі кожну партію обчислювати на сервері – можна тільки кожну 10-ту випадково обрану, або тільки важливі, турнірні партії. Тому є режим, коли сервер просто пересилає команді і слідкує за таймаутами. Але не симулює саму партію.
Тепер, коли ми знаємо, що потрібно, час дізнатись
Як це все запрограмувати в C#
Що потрібно від власне ігрової логіки? – наслідуватись від PredictableModel
public abstract partial class PredictableModel<T, S> where T : PredictableModel<T, S>
{
public abstract void UpdateStep(Fix64 dt, IEnumerable<ZeroLagCommand> consideredCommands); // Inc step, check that commands are from appropriate step and call Update.
public abstract void Update(Fix64 dt, IEnumerable<ZeroLagCommand> consideredCommands); // Update game logic with every given command, dont inc curr step.
public abstract T Copy();
public abstract int step { get; }
public abstract long CalculateHash();
public virtual void EnliveWorld() { }
public virtual void MortifyWorld() { }
public abstract void UpdateFrom(T other);
}
Таким чином, ігрова логіка повинна давати:
- методи симуляції (UpdateStep() – для покрокової симуляції, Update() – для плавної симуляції, наприклад view model симулюється плавно, для кожного графічного фрейму),
- реплікації (Copy(), UpdateFrom()) і пам’ятати, на якому кроці зараз симуляція (int step),
- підрахунку хешсуми (CalculateHash()) – для перевірок детермінізму.
Допоміжні EnliveWorld і MortifyWorld використовуються для оптимізації. Справа в тому, що більшість інстанцій PredictableModel ніколи не будуть нічого симулювати – вони просто тримають дані про попередні роки симуляції для повернення в минуле. Тільки одна модель симулює гру – отже всі ініціалізації, які потрібні для симуляції – тільки в “живих” моделях, для яких виконано EnliveWorld().
Власне ми вирішили реплікацію робити через кодогенерацію. От, наприклад, клас здібності гравця:
[GenBattleData]
public partial struct HunterAbilityInstance
{
public HunterAbilityConfig config;
public Fix64 cooldown;
[GenIgnore] public StatePrototype abilityStatePrototypePressed;
[GenIgnore] public StatePrototype abilityStatePrototypeReleased;
[CanBeNull] public HunterAbilityRuntimeData data;
public int charges; // if any
public bool canActivate => cooldown <= 0;
}
Тут тегом GenBattleData ми пояснюємо кодогенератору, що потрібно згенерувати для цього класу реплікацію, підрахунок хешсуми, серіалізацію, перевірочні методи і взагалі, все, що можна автоматизувати. Тег GenIgnore – для даних, які не потрібно реплікувати, запам’ятовувати і тд. Зазвичай такі дані заповнюються в EnliveWorld.
Також гра повинна оперувати діями гравця у виді команд, зрозумілих Зеролагу. Отже, вони наслідуються від ZeroLagCommand
public abstract partial class ZeroLagCommand : Command
{
public string playerId;
public int stepInd;
public virtual ActionOnTimeout WhatToDoOnTimeout() => ActionOnTimeout.Cancel;
}
Необхідний мінімум – знати від якого гравця команда (playerId), на якому кроці команда спрацювала (stepInd), і що з цією командою робити, якщо прийшла занадто пізно – якщо це, до прикладу, снайперський постріл, який сильно залежить від точного позиціонування в часі і просторі, то цю команду варто відмінити. Так гравець буде впевнений, що постріл буде тільки в момент, в який він натис на гачок. Але менш чутливі команди, наприклад переміщення, можна і не відміняти – просто виконати пізніше.
Важливість команд також визначає, яким способом їх відправляти мережею – AllCostDelivery чи StateUpdate чи просто Reliable. звичний TCP-спосіб передачі – ReliableSequenced тут неактуальний і навіть шкідливий – адже послідовність неважлива. Навпаки, вся ідея ресимуляцій і заточена правильно реагувати на різні запізнення команд. Вирішити, як слати конкретну команду, виявилось найлегше так:
[BattleNetworkChannelUsage(BattleNetworkChannel.AllCostClient2Server, BattleNetworkChannel.AllCostServer2Client)]
public partial class PlayerScreenPresedCommand : PositionBasedCommand
{
// ...
}
[BattleNetworkChannelUsage(BattleNetworkChannel.ReliableClient2Server, BattleNetworkChannel.ReliableServer2Client)]
public partial class PlayerPosCommand : PositionBasedCommand
{
// ...
}
Тут в тегах BattleNetworkChannelUsage просто задається спосіб передачі. Все, тепер можна викликати network.send(command) – далі згенерований кодогенератором на основі тегів код сконфігурує мережу в Unity, створить необхідні канали і розбереться до якого каналу відправляти яку команду.
Тепер, коли ігровий світ готовий, пора розібратись із власне мережевим рушієм.
Мережа оперує ігровими світами – їх симулює, передбачає і пересимульовує.
void RunCalcThread()
{
while (true)
{
ApplyCommandsFromThreadBuffers();
TryConsumeTimeouts();
AddReplayStepsUntilPresentTime();
int presentStep = this.presentStep;
int cursorStep = cursor.step;
if (presentStep > cursorStep)
{
DoOneWaveStep(presentStep);
//UnityEngine.Debug.Log($"I'm updating step {cursorStep} while presentStep is {presentStep}");
}
else
{
Thread.Sleep(30);
//UnityEngine.Debug.Log($"I'm sleeping while presentStep is {presentStep}");
}
}
}
Спочатку в ApplyCommandsFromThreadBuffers() гра отримує нові команди гравців, що прийшли з мережі і від поточного гравця локально. І якщо найстаріша команда, що прийшла, була виконана якимось гравцем 7 циклів тому, то останні 7 кроків оголошуються неактуальними. Вони повинні бути перераховані.
Далі робота з таймаутами. Якщо команда від мене прийшла на сервер із великим запізненням, і він її відмінив, то присилає мені повідомлення про це. в TryConsumeTimeouts() такі повідомлення знаходять в пам’яті команд відмінені, і видаляють їх. Це теж робить відповідні кадри неактуальними – їх доведеться перерахувати без видаленої команди.
public class TimeoutCommand : Command
{
public int targetCommandStep { get; private set; }
public long targetCommandHash { get; private set; }
public ActionOnTimeout whatToDo { get; private set; }
public int executeLaterStep { get; private set; }
public TimeoutCommand(ZeroLagCommand command, int minModifyableStep)
{
targetCommandStep = command.stepInd;
targetCommandHash = command.CalculateHash();
whatToDo = command.WhatToDoOnTimeout();
switch (whatToDo) {
case ActionOnTimeout.Cancel: executeLaterStep = -1; break;
case ActionOnTimeout.ExecuteLater: executeLaterStep = minModifyableStep; break;
}
}
}
Після таймаутів AddReplayStepsUntilPresentTime формує повтор – просто складає команди в нього.
Тепер потрібно провести симуляцію гри – від кадру cursorStep, на якому зараз є симуляція і до presentStep – це крок, який відповідає теперішньому часу.
Симуляція проходить так званими “хвилями”. Хвиля завжди йде від останнього актуального кроку (кроку який не відрізняється від серверного. Принаймні, у клієнта ще нема підстав вважати, що відрізняється) до кроку який бачить гравець, теперішнього.
protected void DoOneWaveStep(int presentStep)
{
int stepToUpdateTo = cursor.step + 1;
bool updatingActual = lastActualStep >= cursor.step;
MoveCursorForwardTo(stepToUpdateTo);
if (updatingActual)
lastActualStep = Math.Max(stepToUpdateTo, lastActualStep);
bool waveCameToPresent = stepToUpdateTo == presentStep;
if (waveCameToPresent)
OnWaveCameToPresent();
}
Коли хвиля доходить із минулого до теперішнього, вона завершується, передає свій результат на відображення (переміщає у view model, в основний потік). Починається нова хвиля
void OnWaveCameToPresent()
{
// Update viewmodel if needed.
SetAsViewModel(ref cursor);
// Cursor goes to past and next wave started.
int lastActualModelStep;
T lastActualModel;
GetLastActual(out lastActualModelStep, out lastActualModel);
if (cursor!=null && lastActualModelStep==cursor.step)
{
// Just use current cursor, no need to go to past.
} else
{
// Go to past and get new cursor.
if (cursor != null)
{
cursor.MortifyWorld();
ReturnToPool(cursor);
cursor = null;
}
cursor = GetModelFromPool();
cursor.UpdateFrom(lastActualModel);
cursor.EnliveWorld();
}
// Reset wave.
waveSimulationTimeElapsed = 0;
waveTimeElapsed = 0;
}
Швидкість хвилі залежить тільки від того, наскільки швидко процесор може симулювати гру. Чим швидше хвиля – тим частіше оновлюється view model. Тобто пересимуляції виглядають меншими. А повільніший процесор призводить тільки до рідших оновлень – лише плавно деградує якість пересимуляції – але не призводить до нічого страшного.
При цьому сама view model має свій механізм оновлення – він не квантує час на кроки, просто оновлюється кожен фрейм і нічого не пересимульовує. Це не детерміністично, але плавно. Все одно з наступною хвилею прийде детерміністична і офіційно затверджена корекція.
Звісно, симуляція повинна бути швидшою, ніж 1 сек симуляції за 1 сек гри – інакше хвиля ніколи не наздожене гру. Такі баги були на початку розробки – найменший лаг і гра “захлинається”, застигає, симулює постійно тільки минуле і гріє телефон. Але зараз симуляція на порядки швидша, таких проблем нема.
Як бонус детермінізму, все це можна перевірити на юніт тестах. На клієнтах завжди різні хешсуми останніх кількох моделей світу, але попередні завжди збігаються між собою і із серверними.

От така історія про мережевий рушій. Заберу її в наступні проекти, так що виділив як бібліотеку.
А взагалі яким стане програмування мереж в найближчому майбутньому? Як мінімум включатиме вдалі підходи, які є зараз і додасть уніфікацію, загальну систему. Досвід Зеролага теж повинен буде бути включеним в створення тих майбутніх стандартів.