Interação cliente-servidor em um novo dispositivo móvel de atirador PvP e servidor de jogos: problemas e soluções

Em artigos anteriores da série (todos os links no final do artigo) sobre o desenvolvimento de um novo jogo de tiro em ritmo acelerado, examinamos os mecanismos da arquitetura básica da lógica de jogos baseada no ECS e os recursos de trabalhar com um jogo de tiro no cliente, em particular, a implementação de um sistema para prever ações de jogadores locais para aumentar a capacidade de resposta ao jogo . Desta vez, abordaremos com mais detalhes questões de interação cliente-servidor em condições de baixa conexão de redes móveis e maneiras de melhorar a qualidade do jogo para o usuário final. Também descreverei brevemente a arquitetura do servidor do jogo.




Durante o desenvolvimento do novo PvP síncrono para dispositivos móveis, encontramos problemas típicos do gênero:

  1. A qualidade da conexão dos clientes móveis é ruim. Este é um ping médio relativamente alto na região de 200-250 ms e uma distribuição de tempo instável do ping, levando em consideração a alteração dos pontos de acesso (embora, contrariamente à crença popular, a porcentagem de perda de pacotes nas redes móveis 3G + seja bastante baixa - cerca de 1%).
  2. As soluções técnicas existentes são estruturas monstruosas que levam os desenvolvedores a estruturas rígidas.

Criamos o primeiro protótipo na UNet, apesar de ter imposto restrições à escalabilidade, controle sobre o componente de rede e dependência adicional à conexão caprichosa de clientes principais. Depois, mudamos para um código de rede auto-escrito em cima do Photon Server , mas mais sobre isso mais tarde.

Considere os mecanismos para organizar interações entre clientes em jogos PvP síncronos. O mais popular deles:

  • P2P ou ponto a ponto . Toda a lógica da partida está hospedada em um dos clientes e não exige quase nenhum custo de tráfego da nossa parte. Mas o escopo dos trapaceiros e os altos requisitos para o cliente que hospeda a partida, bem como as limitações do NAT, não nos permitiram levar essa solução para um jogo para celular.
  • Cliente-servidor . Um servidor dedicado, pelo contrário, permite controlar totalmente tudo o que acontece na partida (adeus, trapaceiros), e seu desempenho permite calcular algumas coisas específicas do nosso projeto. Além disso, muitos grandes provedores de hospedagem têm sua própria estrutura de sub-rede, o que fornece um atraso mínimo para o usuário final.

Decidiu-se escrever um servidor autoritário.


Rede com ponto a ponto (esquerda) e cliente-servidor (direita)

Transferência de dados entre cliente e servidor


Utilizamos o Photon Server - isso nos permitiu implantar rapidamente a infraestrutura necessária para o projeto com base em um esquema já elaborado ao longo dos anos (nos War Robots, usamos).

O Photon Server é exclusivamente uma solução de transporte para nós, sem designs de alto nível fortemente vinculados a um mecanismo de jogo específico. O que oferece alguma vantagem, pois a biblioteca de transferência de dados pode ser substituída a qualquer momento.

O servidor do jogo é um aplicativo multiencadeado no contêiner Photon. Um fluxo separado é criado para cada correspondência, que encapsula toda a lógica do trabalho e evita a influência de uma correspondência em outra. Todas as conexões do servidor são controladas pelo Photon e os dados que chegam dos clientes são adicionados à fila, que é então analisada no ECS.


Esquema geral de fluxos de correspondência no contêiner do Photon Server

Cada partida consiste em várias etapas:

  1. O cliente do jogo enfileira no chamado serviço de criação de partidas. Assim que o número necessário de jogadores que satisfazem determinadas condições for reunido, ele relata isso ao servidor do jogo usando o gRPC. Ao mesmo tempo, todos os dados necessários para criar o jogo são transmitidos.


    Esquema geral para criar uma correspondência
  2. No servidor do jogo, a inicialização da partida começa. Todos os parâmetros de correspondência são processados ​​e preparados, incluindo dados do mapa, bem como todos os dados do cliente recebidos do serviço de criação de correspondência. O processamento e a preparação de dados implica que analisemos todos os dados necessários e os gravamos em um subconjunto especial de entidades que chamamos de RuleBook. Ele armazena estatísticas da partida (que não mudam durante o curso) e será transmitido a todos os clientes durante o processo de conexão e autorização no servidor do jogo uma vez ou ao reconectar após a perda da conexão. Os dados de correspondência estática incluem a configuração do mapa (apresentação do mapa pelos componentes do ECS que os conectam ao mecanismo físico), dados do cliente (apelidos, um conjunto de armas que eles possuem e não mudam durante a batalha etc.).
  3. Executando uma partida. Os sistemas ECS que compõem o jogo no servidor começam a funcionar. Todos os sistemas estão marcando 30 quadros por segundo.
  4. Cada quadro lê e descompacta as entradas ou cópias do jogador, se os jogadores não enviarem suas entradas dentro de um determinado intervalo.
  5. Então, no mesmo quadro, a entrada é processada no sistema ECS, a saber: mudança de estado do jogador; o mundo que ele influencia com sua contribuição; e o status de outros jogadores.
  6. No final do quadro, o estado mundial resultante é empacotado para o reprodutor e enviado pela rede.
  7. No final da partida, os resultados são enviados aos clientes e ao microsserviço, que processa as recompensas para a batalha usando o gRPC, além do analista da partida.
  8. Depois disso, o fluxo de correspondência se fecha e o fluxo é fechado.


A sequência de ações no servidor em um quadro

No lado do cliente, o processo de conexão com uma correspondência é o seguinte:

  1. Primeiro, é feita uma solicitação para enfileiramento no serviço para criar correspondências através do websocket com serialização através do protobuf.
  2. Ao criar uma partida, este serviço informa o cliente do endereço do servidor do jogo e transfere a carga útil adicional exigida pelo cliente antes da partida. Agora o cliente está pronto para iniciar o processo de autorização no servidor do jogo.
  3. O cliente cria um soquete UDP e começa a enviar uma solicitação ao servidor do jogo para conectar-se à partida junto com algumas credenciais. O servidor já está aguardando este cliente. Quando conectado, ele fornece todos os dados necessários para iniciar o jogo e exibir o mundo pela primeira vez. Eles incluem: RuleBook (uma lista de dados estáticos para a partida), bem como StringIntMap, aos quais nos referimos como dados sobre as linhas usadas na jogabilidade que serão identificadas por números inteiros durante a partida). Isso é necessário para economizar tráfego, porque passando linhas a cada quadro cria uma carga significativa na rede. Por exemplo, todos os nomes de jogadores, nomes de classes, identificadores de armas, contas e similares, todas as informações são gravadas no StringIntMap, onde são codificadas usando dados inteiros simples.

Quando um jogador afeta diretamente outros usuários (causa dano, impõe efeitos etc.), um histórico de estado é pesquisado no servidor para comparar o mundo do jogo que o cliente realmente vê em uma simulação específica com o que estava acontecendo no servidor com outros naquele momento entidades do jogo.

Por exemplo, você atira no seu cliente. Para você, isso acontece instantaneamente, mas o cliente já "foge" há algum tempo à frente em comparação com o mundo ao redor, que ele exibe. Portanto, devido à previsão local do comportamento do jogador, o servidor precisa entender onde e em que estado os adversários estavam no momento do tiro (talvez eles já estivessem mortos ou, inversamente, invulneráveis). O servidor verifica todos os fatores e apresenta seu veredicto sobre os danos causados.


Pedido de criação de uma partida, conexão com um servidor de jogo e autorização

Serialização e desserialização, empacotamento e descompactação dos primeiros bytes da partida


Temos uma serialização de dados binários proprietários e, para transferência de dados, usamos UDP.

O UDP é a opção mais óbvia para enviar rapidamente mensagens entre o cliente e o servidor, onde geralmente é muito mais importante exibir os dados o mais rápido possível do que exibi-los em princípio. Pacotes perdidos fazem ajustes, mas os problemas são resolvidos para cada caso individualmente, como Como os dados vêm constantemente do cliente para o servidor e vice-versa, é possível inserir o conceito de conexão entre o cliente e o servidor.

Para criar um código ideal e conveniente, com base na descrição declarativa da estrutura do nosso ECS, usamos a geração de código. Ao criar componentes, regras de serialização e desserialização também são geradas para eles. A serialização é baseada em um empacotador binário personalizado que permite empacotar dados da maneira mais econômica. O conjunto de bytes obtido durante sua operação não é o ideal, mas permite criar um fluxo a partir do qual você pode ler alguns dados de pacote sem a necessidade de sua desserialização completa.

O limite de transferência de dados de 1500 bytes (também conhecido como MTU) é, de fato, o tamanho máximo de pacote que pode ser transferido pela Ethernet. Essa propriedade pode ser configurada em cada salto da rede e geralmente mesmo abaixo de 1500 bytes. O que acontece se eu enviar um pacote maior que 1500 bytes? A fragmentação de pacotes começa. I.e. cada pacote será dividido à força em vários fragmentos, que serão enviados separadamente de uma interface para outra. Eles podem ser enviados por rotas completamente diferentes, e o tempo para receber esses pacotes pode aumentar significativamente antes que a camada de rede emita um pacote colado ao seu aplicativo.

No caso do Photon, a biblioteca começa a enviar esses pacotes à força no modo UDP confiável. I.e. O Photon aguardará cada fragmento do pacote e encaminhará os fragmentos ausentes se eles forem perdidos durante o encaminhamento. Mas esse trabalho da parte da rede é inaceitável em jogos em que é necessário um atraso mínimo na rede. Portanto, é recomendável reduzir o tamanho dos pacotes encaminhados ao mínimo e não exceder os 1500 bytes recomendados (em nosso jogo, o tamanho de um estado completo do mundo não excede 1000 bytes; o tamanho do pacote com compactação delta é de 200 bytes).

Cada pacote do servidor possui um cabeçalho curto que contém vários bytes que descrevem o tipo de pacote. O cliente descompacta primeiro esse conjunto de bytes e determina com qual pacote estamos lidando. Confiamos fortemente nessa propriedade de nosso mecanismo de desserialização durante a autorização: para não exceder o tamanho recomendado de pacote de 1500 bytes, dividimos os pacotes RuleBook e StringIntMap em vários estágios; e para entender exatamente o que obtivemos do servidor - as regras do jogo ou o próprio estado - usamos o cabeçalho do pacote.

Ao desenvolver novos recursos do projeto, o tamanho do pacote está aumentando constantemente. Quando encontramos esse problema, decidiu-se escrever nosso próprio sistema de compactação delta, bem como o recorte contextual de dados que o cliente não precisava.

Otimização do tráfego de rede sensível ao contexto. Compressão delta


O recorte de dados contextuais é gravado manualmente com base nos dados que o cliente precisa para exibir corretamente o mundo e na previsão local de seus próprios dados para funcionar corretamente. Em seguida, a compactação delta é aplicada aos dados restantes.

Nosso jogo a cada tick produz um novo estado do mundo, que deve ser empacotado e repassado aos clientes. Normalmente, a compactação delta é primeiro enviar um estado completo com todos os dados necessários para o cliente e, em seguida, enviar apenas alterações para esses dados. Isso pode ser representado da seguinte maneira:

deltaGameState = newGameState - prevGameState

Mas para cada cliente são enviados dados diferentes e a perda de apenas um pacote pode levar ao fato de que você deve encaminhar o estado completo do mundo.

Encaminhar o estado completo do mundo é uma tarefa bastante cara para a rede. Portanto, modificamos a abordagem e enviamos a diferença entre o estado atual processado do mundo e o que é recebido exatamente pelo cliente. Para fazer isso, o cliente em seu pacote com a entrada também envia um número de tick, que é um identificador exclusivo do estado do jogo que ele já recebeu exatamente. Agora, o servidor sabe com base em que estado é necessário criar compactação delta. Normalmente, o cliente não tem tempo para enviar ao servidor o número de escala que possui antes de o servidor preparar o próximo quadro com os dados. Portanto, no cliente há um histórico de estados do servidor no mundo, ao qual o patch deltaGameState gerado pelo servidor é aplicado.


Ilustração da frequência da interação cliente-servidor no projeto

Vamos nos debruçar com mais detalhes sobre o que o cliente envia. Nos atiradores clássicos, esse pacote é chamado ClientCmd e contém informações sobre as teclas pressionadas do jogador e a hora em que a equipe foi criada. Dentro do pacote de entrada, enviamos muito mais dados:

public sealed class InputSample { //  ,        public uint WorldTick; // ,      ,     public uint PlayerSimulationTick; //   .  (idle, , ) public MovementMagnitude MovementMagnitude; //  ,   public float MovementAngle; //    public AimMagnitude AimMagnitude; //    public float AimAngle; //   ,       public uint ShotTarget; //    ,        public float AimMagnitudeCompressed; } 


Existem alguns pontos interessantes. Em primeiro lugar, o cliente informa ao servidor em que tick vê todos os objetos do mundo do jogo que o rodeiam que não é capaz de prever (WorldTick). Pode parecer que o cliente é capaz de "parar" o tempo para o mundo e executar e atirar em todos por causa da previsão local. Isto não é verdade. Confiamos apenas em um conjunto limitado de valores do cliente e não o deixamos entrar no passado por mais de um segundo. O campo WorldTick também é usado como um pacote de reconhecimento, com base no qual a compactação delta é criada.

Você pode encontrar números de ponto flutuante em um pacote. Normalmente, esses valores costumam ser usados ​​para fazer leituras do joystick do jogador, mas não são muito bem transmitidas pela rede, pois possuem um grande "salto" e geralmente são muito precisas. Quantificamos esses números e empacotamos usando um empacotador binário para que eles não excedam um valor inteiro que possa caber em vários bits, dependendo do tamanho. Assim, o empacotamento da entrada do joystick de mira é quebrado:

 if (Math.Abs(s.AimMagnitudeCompressed) < float.Epsilon) { packer.PackByte(0, 1); } else { packer.PackByte(1, 1); float min = 0; float max = 1; float step = 0.001f; //     1000    , //          //     packer.PackUInt32((uint)((s.AimMagnitudeCompressed - min)/step), CalcFloatRangeBits(min, max, step)); } 


Outro recurso interessante ao enviar entrada é que alguns comandos podem ser enviados várias vezes. Muitas vezes nos perguntam o que fazer se uma pessoa pressionou a habilidade final e o pacote com sua entrada foi perdido? Apenas enviamos essa entrada várias vezes. Parece entrega garantida, mas mais flexível e mais rápida. Porque o tamanho do pacote de entrada é muito pequeno, podemos empacotar várias entradas adjacentes do player no pacote resultante. No momento, o tamanho da janela que determina seu número é cinco.


Pacotes de entrada gerados no cliente em cada tick e enviados ao servidor

A transmissão desse tipo de dados é a mais rápida e confiável o suficiente para resolver nossos problemas sem o uso de UDP confiável. Partimos do fato de que a probabilidade de perder um número tão grande de pacotes seguidos é muito baixa e é um indicador de uma grave degradação da qualidade da rede como um todo. Se isso acontecer, o servidor simplesmente copia a última entrada recebida do player e a aplica, esperando que permaneça inalterada.

Se o cliente perceber que ele não recebeu pacotes pela rede por um período muito longo, o processo de reconexão com o servidor é iniciado. O servidor, por sua vez, monitora se a fila de entrada do player está concluída.

Em vez de conclusão e referência


Existem muitos outros sistemas no servidor de jogos que são responsáveis ​​por detectar, depurar e editar os "ganhos" correspondentes, os designers de jogos atualizando a configuração sem reiniciar, registrar e monitorar o status dos servidores. Também queremos escrever sobre isso com mais detalhes, mas separadamente.

Antes de tudo, ao desenvolver um jogo em rede em plataformas móveis, você deve prestar atenção à operação correta do seu cliente com pings altos (cerca de 200 ms), perda de dados um pouco mais frequente e tamanho das informações enviadas. E você precisa se encaixar claramente no limite de pacotes de 1500 bytes para evitar atrasos na fragmentação e no tráfego.

Links úteis:


Artigos anteriores sobre o projeto:

  1. "Como entramos em um jogo de tiro rápido e móvel: tecnologia e abordagens . "
  2. "Como e por que escrevemos nossa ECS" .
  3. "Como escrevemos o código de rede do shooter PvP móvel: sincronização do jogador no cliente . "

Source: https://habr.com/ru/post/pt420019/


All Articles