company_banner

Как мы писали сетевой код мобильного PvP шутера: синхронизация игрока на клиенте

    В одной из предыдущих статей мы провели обзор технологий, которые используются на нашем новом проекте — fast paced шутере для мобильных устройств. Теперь хочу поделиться, как устроена клиентская часть сетевого кода будущей игры, с какими трудностями мы столкнулись и как их решали.




    В целом подходы к созданию быстрых мультиплеерных игр за последние 20 лет не особо изменились. Можно выделить несколько методов в архитектуре сетевого кода:

    1. Просчет состояния мира на сервере, и отображение результатов на клиенте без предсказания для локального игрока и с возможностью потери ввода игрока (инпута). Такой подход, кстати, используется на другом нашем проекте в разработке — про него можно почитать тут.
    2. Lockstep.
    3. Синхронизация состояния мира без детерминированной логики с предсказанием для локального игрока.
    4. Синхронизация по инпуту с полностью детерминированной логикой и предсказанием для локального игрока.

    Особенность заключается в том, что в шутерах максимально важна отзывчивость управления — игрок нажимает кнопку (или двигает джойстик) и хочет сразу же увидеть результат своего действия. В первую очередь, потому что состояние мира в таких играх изменяется очень быстро и необходимо моментально реагировать на ситуацию.

    Вследствие этого проекту не подходили подходы без механизма предсказаний действия локального игрока (prediction) и мы остановились на методе с синхронизацией состояния мира, без детерминированной логики.

    Плюс подхода: меньшая сложность в реализации по сравнению с методом синхронизации при обмене инпутом.
    Минус: увеличение объема трафика при отсылке всего состояния мира на клиент. Нам пришлось применить несколько различных техник оптимизации трафика, чтобы игра стабильно работала в мобильной сети.

    В основе архитектуры геймплея у нас лежит ECS, про которую уже рассказывали. Такая архитектура позволяет удобно хранить данные об игровом мире, сериализовать, копировать и передавать их по сети. А также выполнять один и тот же код как на клиенте, так и на сервере.

    Симуляция игрового мира происходит с фиксированной частотой 30 тиков в секунду. Это позволяет уменьшить лаг на инпут игрока и почти не использовать интерполяцию для визуального отображения состояния мира. Но здесь есть один существенный недостаток, который следует учитывать при разработке такой системы: для корректной работы системы предсказания локального игрока клиент должен выполнять симуляцию мира с той же частотой, что и сервер. И мы потратили уйму времени, чтобы оптимизировать симуляцию достаточно для целевых устройств.

    Механизм предсказания действий локального игрока (prediction)


    Механизм предикшена на клиенте реализован на базе ECS за счет выполнения одних и тех же систем как на клиенте, так и на сервере. Однако на клиенте выполняются не все системы, а только те, которые отвечают за локального игрока и не требуют актуальных данных о других игроках.

    Пример списков систем выполняемых на клиенте и сервере:



    На данный момент у нас порядка 30 систем, выполняющихся на клиенте и обеспечивающих предикшн игрока и около 80 систем, которые выполняются на сервере. Но мы не выполняем предсказания таких вещей, как нанесение урона, использование способностей или лечение союзников. В этих механиках существуют две проблемы:

    1. Клиент ничего не знает о вводе других игроков и предсказание таких вещей, как урон или лечение почти всегда будет расходиться с данными на сервере.
    2. Создание новых сущностей локально (выстрелов, снарядов, уникальных способностей), порожденных одним игроком, несет проблему сопоставления с сущностями, созданными на сервере.

    Для таких механик лаг скрывается от игрока другими способами.

    Пример: рисуем эффект попадания от выстрела сразу, а жизни противника обновляем только после того, как получим подтверждение о попадании с сервера.

    Общая схема работы сетевого кода в проекте




    Клиент и сервер синхронизируют время по номерам тиков. Из-за того, что передача данных по сети требует некоторого времени, клиент всегда находится впереди сервера на величину половины RTT + размер буфера ввода на сервере. На диаграмме выше показано, что клиент отправляет инпут для тика 20 (a). В этот же момент на сервере обрабатывается тик 15 (b). К моменту, когда инпут клиента дойдет до сервера, на сервере будет обрабатываться тик 20.

    Весь процесс состоит из следующих шагов: клиент отсылает инпут игрока на сервер (a) → этот инпут обработается на сервере через время HRTT + input buffer size (b) → сервер шлет результирующее состояние мира на клиент (с) → клиент применит подтвержденное состояние мира с сервера через время RTT+input buffer size + game state interpolation buffer size (d).

    После того как клиент получит новое подтвержденное состояние мира с сервера (d), ему необходимо выполнить процесс согласования (reconciliation). Дело в том, что клиент выполняет предсказание мира основываясь только на инпуте локального игрока. Инпуты других игроков ему не известны. И при расчете состояния мира на сервере игрок может находиться в другом состоянии, отличном от того, что предсказал клиент. Это может произойти в тех случаях, когда игрок попадает под оглушение или его убивают.

    Процесс согласования состоит из двух частей:

    1. Сравнения предсказанного состояния мира для тика N, полученным с сервера. В сравнении участвуют только данные относящиеся к локальному игроку. Остальные данные мира всегда берутся с серверного состояния и не участвуют в согласовании.
    2. Во время сравнения могут возникнуть два случая:

    — если предсказанное состояние мира совпало с подтвержденным с сервера, то клиент, используя предсказанные данные для локального игрока и новые данные для всего остального мира, продолжает симуляцию мира в обычном режиме;
    — если же предсказанное состояние не совпало, то клиент использует всё серверное состояние мира и историю инпутов от клиента и пересчитывает новое предсказанное состояние мира игрока.

    В коде это выглядит примерно так:
    GameState Reconcile(int currentTick, ServerGameStateData serverStateData,   GameState currentState, uint playerID)
    {
    
      var serverState =  serverStateData.GameState;
      var serverTick = serverState.Time;
    
      var predictedState = _localStateHistory.Get(serverTick);
    
      //if predicted state matches server last state use server predicted state with predicted player
      if (_gameStateComparer.IsSame(predictedState, serverState, playerID))
      {
         _tempState.Copy(serverState);
         _gameStateCopier.CopyPlayerEntities(currentState, _tempState, playerID);
         return _localStateHistory.Put(_tempState); // replace predicted state with correct server state
      }
    
      //if predicted state doesn't match server state, reapply local inputs to server state
      var last = _localStateHistory.Put(serverState); // replace wrong predicted state with correct server state
      for (var i = serverTick; i < currentTick; i++) 
      {
         last = _prediction.Predict(last); // resimulate all wrong states
      }
      return last;
    }


    Сравнение двух состояний мира происходит только для тех данных, которые относятся к локальному игроку и участвуют в системе предсказания. Выборка данных происходит по ID игрока.

    Метод сравнения:
    public bool IsSame(GameState s1, GameState s2, uint avatarId)
        {
            if (s1 == null && s2 != null ||  s1 != null && s2 == null)
                return false;
    
            if (s1 == null && s2 == null)
                return false;
    
            var entity1 = s1.WorldState[avatarId];
            var entity2 = s2.WorldState[avatarId];
    
            if (entity1 == null && entity2 == null)
                return false;
    
            if (entity1 == null || entity2 == null)
                return false;
    
            if (s1.Time != s2.Time)
                return false;
            
            if (s1.WorldState.Transform[avatarId] != s2.WorldState.Transform[avatarId])
                return false;
            
            foreach (var s1Weapon in s1.WorldState.Weapon)
            {
                if (s1Weapon.Value.Owner.Id != avatarId)
                    continue;
                
                var s2Weapon = s2.WorldState.Weapon[s1Weapon.Key];
                if (s1Weapon.Value != s2Weapon)
                    return false;
    
                var s1Ammo = s1.WorldState.WeaponAmmo[s1Weapon.Key];
                var s2Ammo = s2.WorldState.WeaponAmmo[s1Weapon.Key];
                if (s1Ammo != s2Ammo)
                    return false;
    
                var s1Reload = s1.WorldState.WeaponReloading[s1Weapon.Key];
                var s2Reload = s2.WorldState.WeaponReloading[s1Weapon.Key];
                if (s1Reload != s2Reload)
                    return false;
            }
    
            if (entity1.Aiming != entity2.Aiming)
                return false;
    
            if (entity1.ChangeWeapon != entity2.ChangeWeapon)
                return false;
            
            return true;
        }


    Операторы сравнения конкретных компонентов у нас генерируются вместе со всей структурой EC, специально написанным генератором кода. Для примера приведу сгенерированный код оператора сравнения Transform компонента:

    Код
    public static bool operator ==(Transform a, Transform b)
    {
        if ((object)a == null && (object)b == null)
            return true;
        if ((object)a == null && (object)b != null)
            return false;
        if ((object)a != null && (object)b == null)
            return false;
        if (Math.Abs(a.Angle - b.Angle) > 0.01f)
            return false;
        if (Math.Abs(a.Position.x - b.Position.x) > 0.01f || Math.Abs(a.Position.y - b.Position.y) > 0.01f)
            return false;
        return true;
    }


    Следует отметить, что Float значения у нас сравниваются с довольно высокой погрешностью. Это сделано для того, чтобы снизить количество рассинхронизаций между клиентом и сервером. Для игрока такая погрешность будет незаметна, но это значительно экономит вычислительные ресурсы системы.

    Сложность механизма согласования в том, что в случае рассинхронизации состояния клиента и сервера (misprediction) необходимо повторно выполнить симуляцию всех предсказанных состояний клиента, о которых еще нет подтверждения с сервера, вплоть до текущего тика за один кадр. В зависимости от пинга игрока это может быть от 5 до 20 тиков симуляции. Нам пришлось существенно оптимизировать код симуляции, чтобы вложиться во временные рамки: 30 фпс.

    Для выполнения процесса согласования на клиенте необходимо хранить два типа данных:

    1. Историю предсказанных состояний игрока.
    2. И историю инпутов.

    Для этих целей мы используем циклический буфер. Размер буфера равен 32 тикам. Что при частоте 30 HZ дает около 1 секунды реального времени. Клиент может безболезненно продолжать работу на механизме предсказания, без получения новых данных от сервера, вплоть до заполнения данного буфера. Если же разница между временем клиента и сервера начинает составлять больше одной секунды — происходит принудительное отключение клиента с попыткой переподключится. У нас такой размер буфера обусловлен затратами на процесс согласования в случае расхождения состояний мира. Но если разница между клиентом и сервером больше одной секунды — дешевле выполнить полное переподключение к серверу.

    Уменьшение времени лага


    На схеме выше показано, что в игре существует два буфера в схеме передачи данных:

    • буфер инпутов на сервере;
    • буфер состояний мира на клиенте.

    Назначение этих буферов одинаковое — компенсировать сетевые скачки (jitter). Дело в том, что передача пакетов по сети происходит неравномерно. А так как сетевой движок работает с фиксированной частотой в 30 HZ, данные на вход в движок должны подаваться с той же частотой. У нас нет возможности «подождать» несколько ms, пока очередной пакет дойдет до получателя. Мы используем буферы для данных ввода и состояний мира для того, чтобы иметь запас времени на компенсацию jitter-а. Также мы используем буфер геймстейтов для интерполяции, если один из пакетов потерялся.

    На старте игры клиент начинает синхронизацию с сервером только после того, как получит от сервера несколько состояний мира и буфер геймстейтов заполнится. Обычно размер этого буфера равен 3-м тикам (100 ms).

    В то же время, когда клиент синхронизируется с сервером, он «забегает» вперед от времени сервера на величину буфера инпута на сервера. Т.е. клиент сам контролирует насколько впереди сервера ему находиться. Стартовый размер буфера инпутов у нас также равен 3-м тикам (100 ms).

    Первоначально мы реализовали размер этих буферов как константы. Т.е. в не зависимости от того, существовал ли реально jitter в сети или нет, существовала фиксированная задержка в 200 ms (input buffer size + game state buffer size) на обновление данных. Если добавить к этому средний предполагаемый пинг на мобильных устройствах где-то в 200 ms, то реальная задержка между применением инпута на клиенте и подтверждением применения со стороны сервера выходила 400 ms!

    Нас это не устраивало.

    Дело в том, что некоторые системы выполняются только на сервере — такие как, например, расчет HP игрока. При такой задержке игрок делает выстрел и только через 400 ms видит, как убивает соперника. Если это происходило в движении, то обычно игрок успевал забежать за стену или в укрытие и уже там умирал. Плейтесты внутри команды показали, что такая задержка полностью ломает весь геймплей.

    Решением этой проблемы стала реализация динамических размеров буферов инпута и геймстейтов:
    • для буфера геймстейтов клиент всегда знает текущее наполнение буфера. В момент расчета очередного тика, клиент проверяет сколько стейтов уже находится в буфере;
    • для буфера инпутов — сервер, помимо геймстейта, начал отправлять на клиент значение текущего заполнения буфера инпутов для конкретного клиента. Клиент в свою очередь анализирует эти два значения.

    Алгоритм изменения размера буфера геймстейтов примерно следующий:

    1. Клиент считает среднее значение размера буфера за какой-то период времени и дисперсию.
    2. Если дисперсия в пределах нормы (т.е. за заданный промежуток времени не было больших скачков в заполнении и чтении из буфера) — клиент проверяет значение среднего размера буфера за этот период времени.
    3. Если среднее заполнение буфера было больше верхнего граничного условия (т.е. буфер бы заполнен больше, чем требуется) — клиент «уменьшает» размер буфера путем совершения дополнительного тика симуляции.
    4. Если же среднее заполнение буфера было меньше нижнего граничного условия (т.е. буфер не успевал заполнятся, прежде чем клиент начинал чтение из него) — в этом случае клиент «увеличивает» размер буфера путем пропуска одного тика симуляции.
    5. В случае, когда дисперсия была выше нормы, мы не можем полагаться на эти данные, т.к. сетевые скачки за данный промежуток времени были слишком большие. Тогда клиент отбрасывает все текущие данные и начинает сбор статистики заново.

    Компенсация лага на сервере


    Из-за того, что клиент получает обновления мира с сервера с задержкой (лагом), игрок видит мир немного не таким, как он существует на сервере. Игрок видит себя в настоящем, а весь остальной мир — в прошлом. На сервере же весь мир существует в одном времени.


    Из-за этого происходит ситуация с тем, что игрок локально стреляет в цель, которая находится на сервере в другом месте.

    Для компенсации лага мы используем перемотку времени на сервере. Алгоритм работы примерно такой:

    1. Клиент с каждым инпутом дополнительно отсылает на сервер время тика, в котором он видит остальной мир.
    2. Сервер валидирует это время: входит ли разница между текущим временем и видимым временем мира клиента в доверительный интервал.
    3. Если время валидно, сервер оставляет игрока в текущем времени, а весь остальной мир откатывает в прошлое к тому состоянию, которое видел игрок, и просчитывает результат выстрела.
    4. Если игрок попал, то урон наносится в текущем серверном времени.

    Перемотка времени на сервере работает следующим образом: на севере хранится история мира (в ECS) и история физики (поддерживается движком Volatile Physics). В момент просчета выстрела данные игрока берутся с текущего состояния мира, а остальных игроков — из истории.

    Код системы валидации выстрела выглядит примерно так:
    public void Execute(GameState gs)
    {
        foreach (var shotPair in gs.WorldState.Shot)
        {
            var shot = shotPair.Value;
            var shooter = gs.WorldState[shotPair.Key];
            var shooterTransform = shooter.Transform;
            var weaponStats = gs.WorldState.WeaponStats[shot.WeaponId];
    
            // DeltaTime shouldn't exceed physics history size
            var shootDeltaTime = (int) (gs.Time - shot.ShotPlayerWorldTime);
            if (shootDeltaTime > PhysicsWorld.HistoryLength)
            {
                continue;
            }
    
            // Get the world at the time of shooting.
            var oldState = _immutableHistory.Get(shot.ShotPlayerWorldTime);
            
            var potentialTarget = oldState.WorldState[shot.Target.Id];
            var hitTargetId = _singleShotValidator.ValidateTargetAvailabilityInLine(oldState, potentialTarget, shooter,
                shootDeltaTime, weaponStats.ShotDistance, shooter.Transform.Angle.GetDirection());
    
            if (hitTargetId != 0)
            {    
                gs.WorldState.CreateEntity().AddDamage(gs.WorldState[hitTargetId], shooter, weaponStats.ShotDamage);
            }
        }
    }


    Один существенный недостаток в подходе это то, что мы доверяем клиенту в данных о времени тика, который он видит. Потенциально, игрок может получить преимущество за счет искусственного повышения пинга. Т.к. чем больше у игрока пинг, тем дальше в прошлом он производит выстрел.

    Некоторые проблемы, с которыми мы столкнулись


    В ходе реализации данного сетевого движка мы столкнулись с множеством проблем, некоторые из них достойны отдельной статьи, но здесь я затрону только некоторые из них.

    Симуляция всего мира в системе предсказания и копирование


    Изначально все системы в нашей ECS имели только один метод: void Execute (GameState gs). В таком методе обычно обрабатывались компоненты относящиеся ко всем игрокам.

    Пример системы движения в изначальной реализации:
    public sealed class MovementSystem : ISystem
    {
      public void Execute(GameState gs)
      {
        foreach (var movementPair in gs.WorldState.Movement)
        {
          var transform = gs.WorldState.Transform[movementPair.Key];
          transform.Position += movementPair.Value.Velocity * GameState.TickDuration;
        }
      }
    }


    Но в системе предсказания локального игрока нам необходимо было обрабатывать только компоненты, относящиеся к конкретному игроку. Изначально мы реализовали это с помощью копирования.

    Процесс предсказания происходил следующим образом:

    1. Создавалась копия геймстейта.
    2. На вход ECS подавалась копия.
    3. Проходила симуляция всего мира в ECS.
    4. Из нового полученного геймстейта копировались все данные, относящиеся к локальному игроку.

    Метод предикшена выглядел так:
    void PredictNewState(GameState state)
    {
      var newState = _stateHistory.Get(state.Tick+1);
      var input = _inputHistory.Get(state.Tick);
      newState.Copy(state);
      _tempGameState.Copy(state);
      _ecsExecutor.Execute(_tempGameState, input);
      _playerEntitiesCopier.Copy(_tempGameState, newState);
    }


    Проблем в данной реализации было две:

    1. Т.к. мы используем классы, а не структуры — копирование для нас довольно дорогая операция (примерно 0.1-0.15 ms на iPhone 5S).
    2. Симуляция всего мира тоже занимает немало времени (порядка 1.5-2 ms на iPhone 5S).

    Если учесть, что при процессе согласования необходимо пересчитать от 5 до 15 состояний мира за один кадр, то с такой реализацией все жутко тормозило.

    Решение было довольно простым: научиться симулировать мир по частям, а именно симулировать только конкретного игрока. Мы переписали все системы так, чтобы можно было передавать ID игрока и симулировать только его.

    Пример системы движения после изменений:
    public sealed class MovementSystem : ISystem
    {
      public void Execute(GameState gs)
      {
        foreach (var movementPair in gs.WorldState.Movement)
        {
            Move(gs.WorldState.Transform[movementPair.Key], movementPair.Value);
        }
      }
    
      public void ExecutePlayer(GameState gs, uint playerId)
      {
        var movement = gs.WorldState.Movement[playerId];
        if(movement != null)
        {
            Move(gs.WorldState.Transform[playerId], movement);
        }
      }
    
      private void Move(Transform transform, Movement movement)
      {
        transform.Position += movement.Velocity * GameState.TickDuration;
      }
    }


    После изменений мы смогли избавиться от лишних копирований в системе предсказания и снизить нагрузку на систему согласования.

    Код:
    void PredictNewState(GameState state, uint playerId)
    {
      var newState = _stateHistory.Get(state.Tick+1);
      var input = _inputHistory.Get(state.Tick);
      newState.Copy(state);
      _ecsExecutor.Execute(newState, input, playerId);
    }


    Создание и удаление сущностей в системе предсказания


    В нашей системе сопоставление сущностей на сервере и клиенте происходит по целочисленному идентификатору (id). Для всех сущностей у нас используется сквозная нумерация идентификаторов, каждая новая сущность имеет значение id = oldID+1.

    Такой подход очень удобен в реализации, однако у него есть один существенный недостаток: очередность создания новых сущностей на клиенте и сервере может быть разная и вследствие этого идентификаторы у сущностей будут отличаться.

    Эта проблема у нас проявилась, когда мы реализовывали систему предсказания выстрелов игрока. Каждый выстрел у нас — это отдельная сущность с компонентом shot. У каждого клиента id сущностей выстрелов в системе предсказания были последовательны. Но если в этот же момент другой игрок стрелял — то на сервере id всех выстрелов отличались от клиентский.

    Выстрелы на сервере создавались в другой очередности:



    Для выстрелов мы обошли это ограничение, исходя из геймплейных особенностей игры. Выстрелы — это быстро-живущие сущности, которые уничтожаются в системе через доли секунд после создания. На клиенте мы выделили отдельный диапазон ID, которые не пересекаются с серверными ID и перестали учитывать выстрелы в системе согласования. Т.е. выстрелы локального игрока рисуются в игре всегда только по системе предсказания и не учитывают данные с сервера.

    При таком подходе игрок не видит артефактов на экране (удаление, пересоздание, откаты выстрелов), а расхождения с сервером — незначительные и никак не влияют на геймплей в целом.

    Этот метод позволил решить проблему с выстрелами, но не всю проблему создания сущностей на клиенте целиком. Мы еще работает над возможными методами решения сопоставления созданных объектов на клиенте и сервере.

    Также следует заметить, что данная проблема касается только создания новых сущностей (с новыми ID). Добавление и удаление компонентов на уже созданных сущностях выполняется без проблем: компоненты не имеют идентификаторов и каждая сущность может иметь только один компонент конкретного типа. Поэтому обычно мы создаем сущности на сервере, а в системах предсказания только добавляем/удаляем компоненты.

    В заключении хочу сказать, что задача реализации мультиплеера не самая легкая и быстрая, но информации о том, как это делать, довольно много.

    Что почитать


    Pixonic 253,83
    Международная компания по разработке мобильных игр
    Поделиться публикацией
    Комментарии 31
      +1
      Если время валидно, сервер оставляет игрока в текущем времени, а весь остальной мир откатывает в прошлое к тому состоянию, которое видел игрок, и просчитывает результат выстрела.

      Текущее время это время когда был сделан выстрел? Или это время на сервере? Просто создается впечатление что берется угол поворота который «сейчас», а расположение остальных в момент выстрела). А вроде как состояние игрока (патроны например) надо брать которое сейчас, угол поворота в момент выстрела, а расстановку ту которую видит игрок в момент выстрела(вроде бы у вас 100 мс).
      Перемотка времени на сервере работает следующим образом: на севере хранится история мира (в ECS) и история физики (поддерживается движком Volatile Physics).

      И еще вопрос, у вас 2d мобильный шутер?
        +1
        Текущее время это время когда был сделан выстрел?
        Да, все верно:
        текущее время — это время игрока сделавшего выстрел. Состояние игрока берется на момент выстрела. А весь остальной мир (противники, окружение) берутся из истории, так как их видел игрок. Клиент отсылает на сервер номер тика, в котором находилось все окружение, когда был произведен выстрел.

        И еще вопрос, у вас 2d мобильный шутер?

        Покажем после релиза :)
          +1
          Клиент отсылает на сервер номер тика, в котором находилось все окружение, когда был произведен выстрел.

          Я так понимаю клиент отсылает номер тика с учетом того на сколько он впереди сервера?
            +4
            Клиент в данных инпута отсылает два времени:
            1) Время тика в котором находится он сам (с учетом на сколько он впереди сервера) На диаграмме в статье это тик №20.
            2) Время тика в котором клиент видит остальной мир, на диаграмме это тик №10.

            На диаграмме, сервер в момент отсылки находится в тике 15.
            Когда пакет с данными дойдет до сервера, сервер будет находится в тике №20. Для сервера это станет «настоящим» временем. И сервер применит инпут от клиента подписанный 20м тиком. Т.е. клиент будет совершать выстрел в настоящем времени сервера.
            Но для того что бы правильно смоделировать ситуацию того что видел клиент, будет использоваться второе время, пришедшее с клиента. (тик №10)

            Резюмируя:
            1) Время клиента в инпуте необходимо для определения в какой тик нужно применить инпут.
            2) Время остального мира которое видит клиент, необходимо для того что бы правильно рассчитать результаты выстрела.
        +1
        Вы используете udp протокол как я понимаю. Скажите как вы добились «reliable UDP». У вас какая то своя реализация? Или вы пользовались готовыми решениями?
          +1
          Да мы используем UPD. В данный момент мы используем Photon SDK (не PUN) для транспортного слоя. Но хотим уйти от этого решения. Оно нас не устраивает по ряду причин.
          По-поводу reliable — в Photon SDK поддерживает reliable из коробки. Но по-факту reliable у нас используется только в момент авторизации на гейм. сервере, когда передается большой объем статических данных (конфигурация матча).

          Основной гейм.плей использует unreliable unordered udp. Причина в том, что нам просто не нужно гарантировать доставку данных. Т.к. сервер каждый тик шлет обновленное состояние мира. И если если какой-либо пакет потеряется, мы просто пропустим один кадр.
          Так же стоит учитывать что на клиенте реализован механизм интерполяции, на случай потерь единичных пакетов.
          В быстрых мультиплеерных играх более важна скорость доставки пакетов, чем потери.
            +1
            В быстрых мультиплеерных играх более важна скорость доставки пакетов, чем потери.

            А во время боя у вас разве нету каких нибудь событий которые гарантированно пусть и с задержкой должны доставиться серверу от клиента или доставиться от сервера игрокам? Допустим что будет если пакет с выстрелом не дошел до сервера?
              +1
              Тут дело в том что, если пакет с выстрелом не дошел до сервера вовремя — выстрел на сервере уже не применится. Сервер обрабатывает инпут только для текущего тика. Если на сервере уже наступил тик N, и в этот момент с клиента дошел инпут для тика N-1, сервер просто проигнорирует эти данные.
              Для того что бы уменьшить количество потерь при отправке инпутов игрока мы используем механизм скользящего окна, и буфер инпутов на сервере.

              Если же потеря пакета все же произошла, произойдет расхождение состояний мира клиента и сервера, и на клиенте отработает механизм согласования (reconciliation).
              0
              В быстрых мультиплеерных играх более важна скорость доставки пакетов, чем потери.

              Главное не увлекаться этим постулатом. Яркий пример современности Quake Champions, где огромные проблемы с регистрацией попаданий, которые по-ощущением чинят, но не столь успешно.


              Из менее быстрого PUBG, который тоже грешит кривой регистрацией.


              Впечатление может испортить сильнее, чем задержки. Хоть и это стороны одной медали по-сути.

                0
                habr.com/company/pixonic/blog/415959/#comment_18840585 вот этот пост очень грамотно описывает, почему такой проблемы, скорее всего, не будет.
                Соглашусь про регистрацию, у «новых» шутеров с этим невероятные проблемы. Что у Quake Champions, что у Overwatch. Разработчики, видимо, считают, что пинга больше 20 не бывает.
                У Overwatch до смешного доходит, можно наблюдать, как projectile атаки проходят сквозь модель/голову игрока, но не регистрируются, т.к. на сервере игрок уже имеет другие координаты. :)
                  0

                  Тут проблема баланса двух сторон.
                  Стреляющий не доволен что его визуально точный выстрел не засчитывается. А жертва, забежавшая в укрытие, не довольна умирать от выстрелов просчитанных в прошлом. И если в случае стрелка игрок может научиться стрелять наперёд, то в случае жертвы, игрок ничего сделать не может.

            +1
            Раз мир синхронизируется целиком, то невозможно фильтровать данные, например как это сделали в Battlefield 4 или 1? Или есть еще куда уменьшать трафик?
            Потенциально, игрок может получить преимущество за счет искусственного повышения пинга. Т.к. чем больше у игрока пинг, тем дальше в прошлом он производит выстрел.

            Эту проблему долго решали в Rainbow6 Siege. Игроки специально увеличивали свой пинг до 150-200 мс и получали игровое преимущество. Ubisoft тогда разделил обработку попаданий по задержкам пользователей — пинг <100 обрабатываем просто, пинг > 100 и < 500 по более сложному алгоритму, пинг >500 отключаем игрока.
              0
              Ubisoft тогда разделил обработку попаданий по задержкам пользователей — пинг <100 обрабатываем просто, пинг > 100 и < 500 по более сложному алгоритму, пинг >500 отключаем игрока.

              Спасибо, поресерчим этот вопрос. У нас грубо говоря сейчас только два кейса пинг <= 500, и пинг >500. Если пинг выше, отключаем игрока, если ниже считаем по алгоритму из статьи

              Раз мир синхронизируется целиком, то невозможно фильтровать данные, например как это сделали в Battlefield 4 или 1? Или есть еще куда уменьшать трафик?

              Да у нас не используется конкретно этот метод, однако каждому игроку отправляются только те данные, которые необходимы ему для визуализации картинки, и расчета предикшена. Так же у нас применяется дельта-компрессия, если какая-то сущность не изменилась между тиками, данные о ней не отправляются на клиент.
              Мы выпустим отдельную статью о всех оптимизациях трафика которые использовали на этом проекте.
                0
                А вы замеряли какой пинг на каком интернете? Типа 4G, WiFi, 3G. Я к тому что пинг меньше 500 это реальная цифра для мобильного сетевого шутера?
                  0
                  500 — это абстрактная цифра с вашего примера. Но да, мы замеряли пинг на различных сетях: в целом ситуация по пингу зависит от того как далеко находятся пользователи от гейм. серверов. Тут нужно найти золотую середину между количеством серверов в регионах и качеством геймплея у пользователей
                    0
                    в целом ситуация по пингу зависит от того как далеко находятся пользователи от гейм. серверов.


                    А влияние загрузки сети? Случай из личной практики — стабильный пинг до 8.8.8.8 10000-50000мс (нет, я не ошибся с кол-вом нолей). Это в дневное время через дорогу от второго по размеру вуза в регионе (в вечернее такого треша небыло). Правда было это в 2014-2015 году.

                    К стати, если не секрет, поделитесь в каких городах (и их районах) и на каких операторах вы проводили замеры и какие (хотя бы примерно) результаты.
                      0
                      Проводили замеры из Москвы до серверов в Амстердаме, на офисной сети WiFi, 4G и 3G.
                      Из операторов MTC и Билайн.
                      На 4g в среднем пинг 50 — 60 ms.
                      3g — ближе к 60-65 ms.
              –6

              Переводили промтом?

                0
                Очень интересен ваш подход. А разумно ли сделать так, на ваш взгляд?
                Игрок отправляет текущее состояние и управление своего персонажа на сервер. Сервер верит ему и тут же (или с минимальными проверками на достоверность) пересылает всем остальным. Но сервер записывает историю сообщений от каждого клиента.
                Когда пришли сообщения от всех клиентов, или вышло предельное время ожидания, происходит симуляция с последнего достоверного состояния мира до момента, на который нам известны управления от всех клиентов, и создается истинная картина мира на новый момент времени.
                Новая картина мира сверяется с сообщениями клиентов на тот момент времени и отправляется им в случае сильного расхождения.
                Когда клиенты получают истинную историю, они производят ресимуляцию от нее до настоящего времени на основании истории своего управления.
                В таком случае, как мне кажется, будет достигнута максимальная отзывчивость за счет доверия клиентам, и надежность за счет перепроверки их состояний.
                  0
                  Доверять клиенту можно в кооперативных играх, где нет соперника-игрока (и то не всегда), а в играх где игроки играют против друг-друга доверять можно только если вы (или другие играющие с вами) можете сами повлиять как-то на подбор игроков (чтобы не допускать уличеных в читерстве), например играя с друзьями и знакомыми. А в случае игры со случайным подбором соперников доверять игроку неприемлимо. Есть доверие — есть возможность читерить. А если такая возможность есть, то обязательно кто-нибудь ее будет использовать.
                    0
                    К стати, на счет доверия клиенту вспомнился забавный случай из коопа, когда легкий рассинхрон в купе с доверием к клиенту приводил к непреднамеренному читерству с моей стороны. Играли мы как-то с друзьями в Dungeon of Endless. У разрабов есть какие-то косяки с периодическими рассинхронами во всех «Endless» играх, и тут в частности. Причем игра этого не обнаруживала. По механике там персонаж мог стрелять только во врагов в своей комнате. Из-за рассинхрона у меня враги шли сквозь стены кратчайшим путем до цели. и проходили мимо меня, где я спокойно в них стрелял. Другие же игроки видели как я, стоя в пустой комнате, убиваю врагов на другом конце карты (чего механика игры вроде как не допускает)
                    p.s.
                    После выхода игры такое веселье у меня было каждую вторую партию, но сейчас вроде поправили.
                      0
                      Я же предлагаю перепроверять позже. Доверять только на то время пока управление от всех игроков не пришло и сервер не имеет возможности произвести достоверную симуляцию с учетом управления от всех клиентов.
                      0
                      По-мимо того что отметили в комментарии habr.com/company/pixonic/blog/415959/#comment_18845211
                      Хочу добавить что:
                      1) Пересылка помимо инпута еще и состояния мира с клиента на сервер влечет за собой значительное увеличение трафика. На мобильных сетях это критично.
                      2) Что делать когда два клиента шлют противоречивую информацию о мире (например оба стоят в одной позиции)
                      Основные проблемы с реализацией сетевого кода с которыми мы столкнулись это был объем трафика, потери в сети и производительность симуляции мира на клиенте.
                      Ваше решение снижает нагрузку на сервер (не нужно симулировать мир с большой частотой), но практика реализации нашего проекта показывает что это не самое узкое место в мультиплеерных играх
                        0
                        Я предлагаю же отсылать не состояние мира, а состояние персонажа которым управляет клиент, это экономно. И его управление. Пока управление от всех игроков еще не пришло, сервер не может проверить правдивость состояния от клиента, но может ему довериться до тех пор, пока не произведет симуляцию.
                        Чаще всего перепроверка не будет приводить к поправкам. Но если придет противоречивая информация (а такое случится когда игроки например сталкиваются друг с другом — каждый будет видеть себя немного в будущем по сравнению с соперником, поэтому место столкновения будет отличаться), и тогда поправка после прихода истинной истории и ресимуляция откинет игроков на корректные позиции. Выглядеть у клиентов это будет так, будто игроки столкнулись в одном месте, а потом отъехали на другое место. Так сейчас и происходит при столкновении техники в батлфилде, как мне кажется.
                      –3
                      Какой-то оверинжиниринг для такой просто в общем-то концепции.
                        +3
                        Вы считаете простой задачу синхронного пвп по сети в игре с быстрым геймплеем с компенсацией задержки? Обоснуйте, пожалуйста.
                          0
                          Мой комментарий в основном относится к ECS
                        0
                        Спасибо за статью.
                        Если среднее заполнение буфера было больше верхнего граничного условия (т.е. буфер бы заполнен больше, чем требуется) — клиент «уменьшает» размер буфера путем совершения дополнительного тика симуляции.
                        Если же среднее заполнение буфера было меньше нижнего граничного условия (т.е. буфер не успевал заполнятся, прежде чем клиент начинал чтение из него) — в этом случае клиент «увеличивает» размер буфера путем пропуска одного тика симуляции.

                        Как это (пропуск или добавление тика) отражается на поведении объектов, видны ли какие-то артефакты?

                        Еще один вопрос. Решали ли вы вопрос с шарингом общего сетевого кода? Насколько я понимаю, сервер — это standalone решение, тогда может возникнуть желание работать с common частью в и вне юнити. Например, видел такое решение в целом для shared кода.
                          0
                          Как это (пропуск или добавление тика) отражается на поведении объектов, видны ли какие-то артефакты?


                          Артефакты игроку не видны, если дополнительные тики происходят редко (один дополнительный тик раз в несколько секунд).
                          Если выполнять ускорение/замедление симуляции чаще, то это проявляется для игрока как ускорение/замедление объектов в игре. Например персонаж начинает двигаться не с постоянной скоростью, а то ускоряясь, то замедляясь.

                          Еще один вопрос. Решали ли вы вопрос с шарингом общего сетевого кода? Насколько я понимаю, сервер — это standalone решение, тогда может возникнуть желание работать с common частью в и вне юнити.

                          У нас шарится весь код симуляции (ECS). Изначально у нас было три репозитория, клиент, сервер и сабмодуль шареного кода. Но позже пришли к выводу что команде удобнее работать в одном репозитории, где просто существует папка с шаренным кодом.
                            0
                            Я правильно понимаю, что сервер и клиент были отдельные солюшны, а теперь это один репозиторий и один солюшн с клиентскими, серверными и коммон проектами? (Иначе нужно как-то референсить в сервер солюшн общую часть и обновлять файл проекта). Тогда, на первый взгляд, видится менее удобной работа с сервером — дебаг, билд/CI и т.д.
                            Хотелось бы прояснить этот момент, и узнать какие в итоге плюсы и минусы перехода на такую модель работы.
                              0
                              Репозиторий один, солюшина два. Первый — тот, который генерирует Unity, второй — с серверным и общим кодом. Общий код лежит внутри проекта юнити, в папке Assets.
                              Добавление общего кода происходит через серверный солюшн, а Unity из коробки автоматически подхватывает все новые файлы в свой солюшн.
                              Для нас основным плюсом в переходе на такую систему было упрощение вливание фичей в develop. (Мы работаем по стандартному gitflow) В системе с 3 репозиториями, когда делается фича нужно было синхронизировать время вливание фичи во всех репозиториях. А так же создавать отдельные pull-requests в каждом репозитории. Для нашей команды это было неудобно.

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое