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:
- 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%).
- 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 ServerCada partida consiste em várias etapas:
- 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 - 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.).
- 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.
- 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.
- 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.
- No final do quadro, o estado mundial resultante é empacotado para o reprodutor e enviado pela rede.
- 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.
- Depois disso, o fluxo de correspondência se fecha e o fluxo é fechado.
A sequência de ações no servidor em um quadroNo lado do cliente, o processo de conexão com uma correspondência é o seguinte:
- 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.
- 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.
- 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çãoSerializaçã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 - prevGameStateMas 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 projetoVamos 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 {
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;
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 servidorA 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:
- "Como entramos em um jogo de tiro rápido e móvel: tecnologia e abordagens . "
- "Como e por que escrevemos nossa ECS" .
- "Como escrevemos o código de rede do shooter PvP móvel: sincronização do jogador no cliente . "