Em um dos artigos
anteriores , analisamos as tecnologias usadas em nosso novo projeto - um jogo de tiro rápido para dispositivos móveis. Agora, quero compartilhar como a parte do cliente do código de rede do jogo futuro é organizada, quais dificuldades encontramos e como resolvê-las.

Em geral, as abordagens para a criação de jogos multiplayer rápidos nos últimos 20 anos não mudaram muito. Vários métodos podem ser distinguidos na arquitetura do código de rede:
- Erro de cálculo do estado do mundo no servidor e exibição dos resultados no cliente sem previsão para o player local e com a possibilidade de perder a entrada do player (entrada). A propósito, essa abordagem é usada em outro projeto em desenvolvimento - você pode ler sobre isso aqui .
- Lockstep
- Sincronização do estado do mundo sem lógica determinística com previsão para um player local.
- Sincronização de entrada com lógica totalmente determinística e previsão para um player local.
A peculiaridade reside no fato de que, nos atiradores, o mais importante é a responsividade do controle - o jogador pressiona um botão (ou move o joystick) e deseja ver imediatamente o resultado de sua ação. Primeiro de tudo, porque o estado do mundo em tais jogos muda muito rapidamente e é necessário responder imediatamente à situação.
Como resultado, abordagens sem o mecanismo de previsão das ações do jogador local (previsão) não eram adequadas para o projeto, e adotamos um método de sincronização do estado do mundo, sem lógica determinística.
Vantagem da abordagem: menor complexidade na implementação em comparação com o método de sincronização ao trocar entrada.
Menos: um aumento no tráfego ao enviar todo o estado do mundo para o cliente. Tivemos que aplicar várias técnicas diferentes de otimização de tráfego para fazer o jogo funcionar de maneira estável em uma rede móvel.
No coração da arquitetura de jogo, temos o ECS, sobre o qual já
falamos . Essa arquitetura permite que você armazene convenientemente dados sobre o mundo do jogo, serialize, copie e transfira pela rede. E também para executar o mesmo código no cliente e no servidor.
A simulação do mundo do jogo ocorre a uma frequência fixa de 30 ticks por segundo. Isso permite reduzir o atraso na entrada do player e quase não usar interpolação para exibir visualmente o estado do mundo. Mas há uma desvantagem significativa que deve ser considerada ao desenvolver um sistema desse tipo: para que o sistema de previsão do jogador local funcione corretamente, o cliente deve simular o mundo com a mesma frequência do servidor. E gastamos muito tempo para otimizar a simulação o suficiente para os dispositivos de destino.
Mecanismo de previsão de ação do jogador local (previsão)
O mecanismo de previsão do cliente é implementado com base no ECS devido à execução dos mesmos sistemas no cliente e no servidor. No entanto, nem todos os sistemas são executados no cliente, mas apenas aqueles que são responsáveis pelo player local e não exigem dados relevantes sobre outros players.
Exemplo de listas de sistemas em execução no cliente e servidor:

No momento, temos cerca de 30 sistemas em execução no cliente que fornecem a previsão do jogador e cerca de 80 sistemas em execução no servidor. Mas não previmos coisas como causar dano, usar habilidades ou curar aliados. Existem dois problemas nessa mecânica:
- O cliente não sabe nada sobre como entrar em outros jogadores e prever coisas como dano ou cura quase sempre diverge dos dados no servidor.
- Criar novas entidades localmente (tiros, projéteis, habilidades únicas) geradas por um jogador carrega o problema de combinar com entidades criadas no servidor.
Para esse mecânico, o atraso oculta o jogador de outras maneiras.
Exemplo: desenhamos o efeito de acertar o tiro imediatamente e atualizamos a vida do inimigo somente após recebermos a confirmação do golpe do servidor.O esquema geral do código de rede no projeto

O cliente e o servidor sincronizam o tempo por números de tick. Devido ao fato de a transmissão de dados pela rede levar algum tempo, o cliente está sempre à frente do servidor pela metade do
RTT + o tamanho do buffer de entrada no servidor. O diagrama acima mostra que o cliente envia uma entrada para o tick 20 (a). Ao mesmo tempo, o tick 15 (b) é processado no servidor. Quando a entrada do cliente chegar ao servidor, o tick 20 será processado no servidor.
Todo o processo consiste nas seguintes etapas: o cliente envia a entrada do jogador para o servidor (a) → essa entrada é processada no servidor após o tamanho do buffer de entrada HRTT + (b) → o servidor envia o estado mundial resultante ao (s) cliente (s) → o cliente aplica o estado mundial confirmado com tempo do servidor RTT + tamanho do buffer de entrada + tamanho do buffer de interpolação do estado do jogo (d).
Depois que o cliente recebe um novo estado confirmado do mundo do servidor (d), ele precisa concluir o processo de reconciliação. O fato é que o cliente realiza previsões mundiais com base apenas nas informações do jogador local. As contribuições de outros jogadores não são conhecidas por ele. E, ao calcular o estado do mundo no servidor, o jogador pode estar em um estado diferente, diferente do que o cliente previu. Isso pode acontecer quando um jogador é atordoado ou morto.
O processo de aprovação consiste em duas partes:
- Comparações do estado previsto do mundo para o tick N recebido do servidor. Somente os dados relacionados ao player local estão envolvidos na comparação. O restante dos dados do mundo é sempre retirado do estado do servidor e não participa da coordenação.
- Durante a comparação, dois casos podem ocorrer:
- se o estado previsto do mundo coincidir com o confirmado do servidor, o cliente, usando os dados previstos para o player local e novos dados para o resto do mundo, continuará simulando o mundo no modo normal;
- se o estado previsto não corresponder, o cliente usará todo o estado do servidor no mundo e o histórico de informações do cliente e recontará o novo estado previsto no mundo do jogador.
No código, parece algo como isto:GameState Reconcile(int currentTick, ServerGameStateData serverStateData, GameState currentState, uint playerID) { var serverState = serverStateData.GameState; var serverTick = serverState.Time; var predictedState = _localStateHistory.Get(serverTick);
A comparação de dois estados mundiais ocorre apenas para os dados que se relacionam com o player local e participam do sistema de previsão. Os dados são amostrados pelo ID do jogador.
Método de comparação: 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; }
Operadores de comparação para componentes específicos são gerados junto com toda a estrutura EC, especialmente escrita por um gerador de código. Por exemplo, fornecerei o código gerado do operador de comparação de componentes Transform:
Código 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; }
Note-se que nossos valores de flutuação são comparados com um erro bastante alto. Isso é feito para reduzir a quantidade de dessincronização entre o cliente e o servidor. Para o jogador, esse erro será invisível, mas isso economiza significativamente os recursos de computação do sistema.
A complexidade do mecanismo de coordenação é que, no caso de uma sincronização incorreta dos estados do cliente e do servidor (previsão incorreta), é necessário simular repetidamente todos os estados previstos do cliente sobre os quais não há confirmação do servidor, até o tick atual em um quadro. Dependendo do ping do jogador, isso pode variar de 5 a 20 carrapatos de simulação. Tivemos que otimizar significativamente o código de simulação para ajustar-se ao prazo: 30 fps.
Para concluir o processo de aprovação, dois tipos de dados devem ser armazenados no cliente:
- Uma história dos estados previstos do jogador.
- E a história da entrada.
Para esses fins, usamos um buffer circular. O tamanho do buffer é de 32 ticks. Isso na frequência de 30 HZ dá cerca de 1 segundo em tempo real. O cliente pode continuar trabalhando com segurança no mecanismo de previsão, sem receber novos dados do servidor, até preencher esse buffer. Se a diferença entre a hora do cliente e o servidor começar a ser superior a um segundo, o cliente será forçado a se desconectar com uma tentativa de se reconectar. Temos esse tamanho de buffer devido aos custos do processo de coordenação em caso de discrepância entre os estados do mundo. Mas se a diferença entre o cliente e o servidor for superior a um segundo, é mais barato executar uma reconexão completa com o servidor.
Redução do tempo de atraso
O diagrama acima mostra que no jogo existem dois buffers no esquema de transferência de dados:
- buffer de entrada no servidor;
- um buffer de estados mundiais no cliente.
O objetivo desses buffers é o mesmo - para compensar os saltos de rede (tremulação). O fato é que a transmissão de pacotes pela rede é desigual. E como o mecanismo de rede opera com uma frequência fixa de 30 HZ, os dados devem ser fornecidos ao mecanismo na mesma frequência. Não temos a oportunidade de "esperar" alguns ms até que o próximo pacote chegue ao destinatário. Usamos buffers para dados de entrada e estados mundiais, a fim de ter uma margem de tempo para compensação de tremulação. Também usamos o buffer gamestate para interpolação se um dos pacotes for perdido.
No início do jogo, o cliente inicia a sincronização com o servidor somente depois de receber vários estados mundiais do servidor e o buffer do estado do jogo está cheio. Normalmente, o tamanho desse buffer é de 3 ticks (100 ms).
Ao mesmo tempo, quando o cliente sincroniza com o servidor, ele "executa" antes do horário do servidor pelo valor do buffer de entrada no servidor. I.e. o próprio cliente controla a distância do servidor. O tamanho inicial do buffer de entrada também é igual a 3 ticks (100 ms).
Inicialmente, implementamos o tamanho desses buffers como constantes. I.e. independentemente de o jitter realmente existir na rede ou não, houve um atraso fixo de 200 ms (tamanho do buffer de entrada + tamanho do buffer do estado do jogo) para atualizar os dados. Se adicionarmos a isso o ping médio estimado em dispositivos móveis em torno de 200 ms, o atraso real entre o uso da entrada no cliente e a confirmação do aplicativo no servidor foi de 400 ms!
Isso não nos convinha.
O fato é que alguns sistemas são executados apenas no servidor - como, por exemplo, o cálculo do HP do player. Com esse atraso, o jogador atira e somente após 400 ms vê como ele mata o oponente. Se isso acontecesse em movimento, geralmente o jogador conseguia correr atrás da parede ou se esconder e já estava morrendo lá. Os testes da equipe mostraram que esse atraso interrompe completamente a jogabilidade.
A solução para esse problema foi a implementação de tamanhos dinâmicos de buffers de entrada e gamestates:
- para um buffer de gamestate, o cliente sempre conhece o conteúdo atual do buffer. No momento do cálculo do próximo tick, o cliente verifica quantos estados já estão no buffer;
- para o buffer de entrada - o servidor, além do estado do jogo, começou a enviar ao cliente o valor do preenchimento atual do buffer de entrada para um cliente específico. O cliente, por sua vez, analisa esses dois valores.
O algoritmo de redimensionamento do buffer de gamestate é aproximadamente o seguinte:
- O cliente considera o valor médio do tamanho do buffer durante um período de tempo e variação.
- Se a variação estiver dentro dos limites normais (ou seja, durante um determinado período de tempo, não houve grandes saltos no preenchimento e na leitura do buffer), o cliente verificará o valor do tamanho médio do buffer para esse período de tempo.
- Se o preenchimento médio do buffer for maior que a condição do limite superior (ou seja, o buffer será preenchido mais do que o necessário), o cliente "reduzirá" o tamanho do buffer executando uma escala de simulação adicional.
- Se o preenchimento médio do buffer for menor que a condição do limite inferior (ou seja, o buffer não terá tempo para preencher antes que o cliente comece a lê-lo) - nesse caso, o cliente "aumentará" o tamanho do buffer ignorando um tique da simulação.
- No caso em que a variação estava acima do normal, não podemos confiar nesses dados, porque os surtos de rede por um determinado período foram muito grandes. Em seguida, o cliente descarta todos os dados atuais e começa a coletar estatísticas novamente.
Compensação de atraso do servidor
Devido ao fato de o cliente receber atualizações mundiais do servidor com um atraso (atraso), o jogador vê o mundo um pouco diferente do que existe no servidor. O jogador se vê no presente e no resto do mundo - no passado. No servidor, o mundo inteiro existe ao mesmo tempo.

Por esse motivo, a situação é que o jogador atira localmente em um alvo localizado no servidor em outro local.
Para compensar o atraso, usamos o tempo de retrocesso no servidor. O algoritmo de operação é aproximadamente o seguinte:
- O cliente com cada entrada envia adicionalmente ao servidor o tempo de verificação em que ele vê o resto do mundo.
- O servidor valida esse horário: é a diferença entre o horário atual e o horário visível do mundo do cliente no intervalo de confiança.
- Se o tempo for válido, o servidor deixa o jogador no horário atual e o resto do mundo volta ao estado que o jogador viu e calcula o resultado do arremesso.
- Se um jogador acertar, o dano será causado no horário atual do servidor.
O tempo de rebobinagem em um servidor funciona da seguinte maneira: a história do mundo (no ECS) e a história da física (suportada pelo mecanismo de
Física Volátil ) são armazenadas no norte. No momento em que o arremesso foi calculado, os dados do jogador são retirados do estado atual do mundo e os demais jogadores do histórico.
O código para o sistema de validação de tiro é mais ou menos assim: 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];
Uma desvantagem significativa na abordagem é que confiamos no cliente nos dados sobre o horário do tick que ele vê. Potencialmente, um jogador pode obter uma vantagem aumentando artificialmente o ping. Porque quanto mais ping um jogador tem, mais ele dispara no passado.
Alguns problemas que encontramos
Durante a implementação desse mecanismo de rede, encontramos muitos problemas, alguns dos quais merecem um artigo separado, mas aqui abordarei apenas alguns deles.
Simulação do mundo inteiro em um sistema de previsão e cópia
Inicialmente, todos os sistemas em nosso ECS tinham apenas um método: void Execute (GameState gs). Nesse método, os componentes relacionados a todos os jogadores eram geralmente processados.
Um exemplo de um sistema de movimento na implementação inicial: 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; } } }
Porém, no sistema local de previsão de jogadores, precisamos apenas processar os componentes relacionados a um jogador específico. Inicialmente, implementamos isso usando copy.
O processo de previsão foi o seguinte:
- Uma cópia do estado do jogo foi criada.
- Uma cópia foi fornecida à entrada do ECS.
- Houve uma simulação do mundo inteiro no ECS.
- Todos os dados relacionados ao player local foram copiados do gamestate recém-recebido.
O método de previsão ficou assim: 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); }
Havia dois problemas nessa implementação:
- Porque usamos classes, não estruturas - a cópia é uma operação bastante cara para nós (aproximadamente 0,1-0,15 ms no iPhone 5S).
- A simulação de todo o mundo também leva muito tempo (cerca de 1,5 a 2 ms no iPhone 5S).
Se levarmos em conta que, durante o processo de coordenação, é necessário recalcular de 5 a 15 estados mundiais em um quadro, então, com essa implementação, tudo ficou terrivelmente lento.
A solução foi bastante simples: aprender a simular o mundo em partes, ou seja, simular apenas um jogador específico. Reescrevemos todos os sistemas para que você possa transferir o ID do jogador e simular apenas ele.
Um exemplo de um sistema de movimento após uma alteração: 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; } }
Após as alterações, conseguimos livrar-nos de cópias desnecessárias no sistema de previsão e reduzir a carga no sistema correspondente.
Código: 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); }
Criando e excluindo entidades em um sistema de previsão
Em nosso sistema, a correspondência de entidades no servidor e no cliente ocorre por um identificador inteiro (id). Para todas as entidades, usamos numeração de ponta a ponta de identificadores, cada nova entidade possui o valor id = oldID + 1.
Essa abordagem é muito conveniente de implementar, mas tem uma desvantagem significativa: a ordem de criação de novas entidades no cliente e no servidor pode ser diferente e, como resultado, os identificadores das entidades serão diferentes.
Esse problema se manifestou quando implementamos um sistema para prever as tacadas dos jogadores. Cada tiro com a gente é uma entidade separada com o componente tiro. Para cada cliente, o ID das entidades de captura no sistema de previsão era seqüencial. Mas se, no mesmo momento, outro jogador disparou, no servidor o ID de todos os disparos diferia do cliente.
As fotos no servidor foram criadas em uma ordem diferente:

Para tiros, contornamos essa limitação, com base nos recursos de jogabilidade do jogo. Tiros são entidades de vida rápida que são destruídas no sistema uma fração de segundo após a criação. No cliente, destacamos um intervalo separado de IDs que não se cruzam com os IDs do servidor e não levam mais em consideração as capturas no sistema de coordenação. I.e. as tacadas dos jogadores locais são sempre sorteadas no jogo apenas de acordo com o sistema de previsão e não levam em consideração os dados do servidor.
Com essa abordagem, o jogador não vê artefatos na tela (exclusão, recriação, retrocessos de tiros) e as discrepâncias com o servidor são pequenas e não afetam a jogabilidade como um todo.
Este método permitiu resolver o problema com disparos, mas não o problema inteiro de criar entidades no cliente como um todo. Ainda estamos trabalhando em métodos possíveis para resolver a comparação de objetos criados no cliente e no servidor.
Deve-se notar também que esse problema diz respeito apenas à criação de novas entidades (com novos IDs). A adição e remoção de componentes em entidades já criadas é realizada sem problemas: os componentes não possuem identificadores e cada entidade pode ter apenas um componente de um tipo específico. Portanto, geralmente criamos entidades no servidor e, nos sistemas de previsão, apenas adicionamos / removemos componentes.
Concluindo, quero dizer que a tarefa de implementar o multiplayer não é a mais fácil e a mais rápida, mas há muitas informações sobre como fazer isso.
O que ler