Olá! Sou Andrey Makhorin, desenvolvedor de servidores da Pixonic (MY.GAMES). Neste artigo, compartilharei como minha equipe e eu criamos uma solução universal para desenvolvimento de back-end. Você aprenderá sobre o conceito, seu resultado e como nosso sistema, chamado Singularity, funcionou em projetos do mundo real. Também me aprofundarei nos desafios que enfrentamos.
Quando um estúdio de jogos está começando, é crucial formular e implementar rapidamente uma ideia convincente: dezenas de hipóteses são testadas e o jogo passa por constantes mudanças; novos recursos são adicionados e soluções malsucedidas são revisadas ou descartadas. No entanto, este processo de iteração rápida, aliado a prazos apertados e a um horizonte de planeamento curto, pode levar à acumulação de dívida técnica.
Com a dívida técnica existente, a reutilização de soluções antigas pode ser complicada, uma vez que vários problemas precisam ser resolvidos com elas. Obviamente, isso não é o ideal. Mas existe outro caminho: um “quadro universal”. Ao projetar componentes genéricos e reutilizáveis (como elementos de layout, janelas e bibliotecas que implementam interações de rede), os estúdios podem reduzir significativamente o tempo e o esforço necessários para desenvolver novos recursos. Essa abordagem não apenas reduz a quantidade de código que os desenvolvedores precisam escrever, mas também garante que o código já tenha sido testado, resultando em menos tempo gasto em manutenção.
Discutimos o desenvolvimento de recursos no contexto de um jogo, mas agora vamos olhar a situação de outro ângulo: para qualquer estúdio de jogos, reutilizar pequenos pedaços de código dentro de um projeto pode ser uma estratégia eficaz para agilizar a produção, mas, eventualmente, eles precisaremos criar um novo jogo de sucesso. A reutilização de soluções de um projeto existente poderia, em teoria, acelerar este processo, mas surgem dois obstáculos significativos. Em primeiro lugar, os mesmos problemas de dívida técnica se aplicam aqui e, em segundo lugar, quaisquer soluções antigas provavelmente foram adaptadas aos requisitos específicos do jogo anterior, tornando-as inadequadas para o novo projeto.
Estas questões são agravadas por outras questões: a concepção da base de dados pode não satisfazer os requisitos do novo projecto, as tecnologias podem estar desactualizadas e a nova equipa pode não ter os conhecimentos necessários.
Além disso, o sistema central é muitas vezes concebido com um género ou jogo específico em mente, dificultando a adaptação a um novo projeto.
Novamente, é aqui que entra em jogo uma estrutura universal e, embora a criação de jogos muito diferentes uns dos outros possa parecer um desafio intransponível, há exemplos de plataformas que resolveram esse problema com sucesso: PlayFab, Photon Engine e plataformas semelhantes. demonstraram sua capacidade de reduzir significativamente o tempo de desenvolvimento, permitindo que os desenvolvedores se concentrem na construção de jogos em vez de na infraestrutura.
Agora, vamos pular para a nossa história.
Para jogos multijogador, um backend robusto é essencial. Caso em questão: nosso jogo principal, War Robots. É um jogo de tiro PvP móvel, existe há mais de 10 anos e acumulou vários recursos que requerem suporte de back-end. E embora nosso código de servidor tenha sido adaptado às especificidades do projeto, ele usava tecnologias que estavam desatualizadas.
Quando chegou a hora de desenvolver um novo jogo, percebemos que tentar reutilizar os componentes do servidor do War Robots seria problemático. O código era muito específico do projeto e exigia conhecimentos em tecnologias que faltavam à nova equipe.
Também reconhecemos que o sucesso do novo projeto não estava garantido e, mesmo que fosse bem-sucedido, eventualmente precisaríamos criar outro novo jogo e estaríamos enfrentando o mesmo problema de “quadro em branco”. Para evitar isso e nos prepararmos para o futuro, decidimos identificar os componentes essenciais necessários para o desenvolvimento de jogos e, em seguida, criar uma estrutura universal que pudesse ser usada em todos os projetos futuros.
Nosso objetivo era fornecer aos desenvolvedores uma ferramenta que os poupasse da necessidade de projetar repetidamente arquiteturas de back-end, esquemas de banco de dados, protocolos de interação e tecnologias específicas. Queríamos libertar as pessoas do fardo de implementar autorização, processamento de pagamentos e armazenamento de informações do usuário, permitindo que se concentrassem nos aspectos principais do jogo: jogabilidade, design, lógica de negócios e muito mais.
Além disso, queríamos não apenas acelerar o desenvolvimento com nossa nova estrutura, mas também permitir que os programadores clientes escrevessem lógica do lado do servidor sem conhecimento profundo de rede, SGBD ou infraestrutura.
Além disso, ao padronizar um conjunto de serviços, nossa equipe de DevOps seria capaz de tratar todos os projetos de jogos de forma semelhante, alterando apenas os endereços IP. Isso nos permitiria criar modelos de script de implantação reutilizáveis e painéis de monitoramento.
Ao longo do processo, tomamos decisões arquitetônicas que levaram em consideração a possibilidade de reaproveitamento do backend em jogos futuros. Essa abordagem garantiu que nossa estrutura fosse flexível, escalável e adaptável a diversos requisitos do projeto.
(Também vale a pena notar que o desenvolvimento da estrutura não foi uma ilha – foi criado em paralelo com outro projeto.)
Decidimos dar ao Singularity um conjunto de funções independentes do gênero, cenário ou jogabilidade central de um jogo, incluindo:
Estas funções são fundamentais para qualquer projeto móvel multiusuário (no mínimo, são relevantes para projetos desenvolvidos em Pixonic).
Além dessas funções principais, o Singularity foi projetado para acomodar recursos mais específicos do projeto, mais próximos da lógica de negócios. Esses recursos são construídos usando abstrações, tornando-os reutilizáveis e extensíveis em diferentes projetos.
Alguns exemplos incluem:
Tecnicamente, a plataforma Singularity consiste em quatro componentes:
A seguir, vamos examinar cada um desses componentes.
Alguns serviços, como o serviço de perfil e a combinação de partidas, exigem uma lógica de negócios específica do jogo. Para acomodar isso, projetamos esses serviços para serem distribuídos como bibliotecas. Ao desenvolver essas bibliotecas, os desenvolvedores podem criar aplicativos que incorporam manipuladores de comandos, lógica de matchmaking e outros componentes específicos do projeto.
Essa abordagem é análoga à construção de um aplicativo ASP.NET, onde a estrutura fornece funcionalidade de protocolo HTTP de baixo nível, enquanto o desenvolvedor pode se concentrar na criação de controladores e modelos que contenham a lógica de negócios.
Por exemplo, digamos que queremos adicionar a capacidade de alterar nomes de usuário no jogo. Para fazer isso, os programadores precisariam escrever uma classe de comando que incluísse o novo nome de usuário e um manipulador para esse comando.
Aqui está um exemplo de ChangeNameCommand:
public class ChangeNameCommand : ICommand { public string Name { get; set; } }
Um exemplo deste manipulador de comandos:
class ChangeNameCommandHandler : ICommandHandler<ChangeNameCommand> { private IProfile Profile { get; } public ChangeNameCommandHandler(IProfile profile) { Profile = profile; } public void Handle(ICommandContext context, ChangeNameCommand command) { Profile.Name = command.Name; } }
Neste exemplo, o manipulador deve ser inicializado com uma implementação IProfile, que é tratada automaticamente por meio de injeção de dependência. Alguns modelos, como IProfile, IWallet e IInventory, estão disponíveis para implementação sem etapas adicionais. No entanto, estes modelos podem não ser muito convenientes de trabalhar devido à sua natureza abstrata, fornecendo dados e aceitando argumentos que não são adaptados às necessidades específicas do projeto.
Para facilitar as coisas, os projetos podem definir seus próprios modelos de domínio, registrá-los de forma semelhante aos manipuladores e injetá-los em construtores conforme necessário. Essa abordagem permite uma experiência mais personalizada e conveniente ao trabalhar com dados.
Aqui está um exemplo de modelo de domínio:
public class WRProfile { public readonly IProfile Raw; public WRProfile(IProfile profile) { Raw = profile; } public int Level { get => Raw.Attributes["level"].AsInt(); set => Raw.Attributes["level"] = value; } }
Por padrão, o perfil do jogador não contém a propriedade Level. No entanto, ao criar um modelo específico do projeto, esse tipo de propriedade pode ser adicionado e é possível ler ou alterar facilmente as informações do nível do jogador nos manipuladores de comando.
Um exemplo de manipulador de comandos usando o modelo de domínio:
class LevelUpCommandHandler : ICommandHandler<LevelUpCommand> { private WRProfile Profile { get; } public LevelUpCommandHandler(WRProfile profile) { Profile = profile; } public void Handle(ICommandContext context, LevelUpCommand command) { Profile.Level += 1; } }
Esse código demonstra claramente que a lógica de negócios de um jogo específico é isolada das camadas subjacentes de transporte ou armazenamento de dados. Essa abstração permite que os programadores se concentrem na mecânica central do jogo sem se preocupar com transacionalidade, condições de corrida ou outros problemas comuns de back-end.
Além disso, o Singularity oferece ampla flexibilidade para aprimorar a lógica do jogo. O perfil do jogador é uma coleção de pares de “valores digitados por chave”, permitindo que os designers de jogos adicionem facilmente quaisquer propriedades, exatamente como eles imaginam.
Além do perfil, a entidade do jogador no Singularity é composta por vários componentes essenciais projetados para manter a flexibilidade. Notavelmente, isso inclui uma carteira que rastreia o valor de cada moeda dentro dela, bem como um inventário que lista os itens do jogador.
Curiosamente, os itens no Singularity são entidades abstratas semelhantes aos perfis; cada item possui um identificador exclusivo e um conjunto de pares de valores digitados por chave. Portanto, um item não precisa necessariamente ser um objeto tangível como uma arma, roupa ou recurso no mundo do jogo. Em vez disso, pode representar qualquer descrição geral emitida exclusivamente para os jogadores, como uma missão ou oferta. Na seção seguinte, detalharei como esses conceitos são implementados em um projeto de jogo específico.
Uma diferença importante no Singularity é que os itens armazenam uma referência a uma descrição geral no balanço patrimonial. Embora esta descrição permaneça estática, as propriedades do item individual emitido podem mudar. Por exemplo, os jogadores podem ter a capacidade de alterar as skins das armas.
Além disso, temos opções robustas para migrar dados de jogadores. No desenvolvimento de back-end tradicional, o esquema do banco de dados geralmente está fortemente acoplado à lógica de negócios, e as alterações nas propriedades de uma entidade normalmente exigem modificações diretas no esquema.
No entanto, a abordagem tradicional é inadequada para Singularity porque a estrutura não tem conhecimento das propriedades comerciais associadas a uma entidade de jogador e a equipe de desenvolvimento do jogo não tem acesso direto ao banco de dados. Em vez disso, as migrações são projetadas e registradas como manipuladores de comandos que operam sem interação direta com o repositório. Quando um jogador se conecta ao servidor, seus dados são obtidos do banco de dados. Se alguma migração registrada no servidor ainda não tiver sido aplicada a este player, ela será executada e o estado atualizado será salvo no banco de dados.
A lista de migrações aplicadas também é armazenada como propriedade do jogador, e essa abordagem tem outra vantagem significativa: permite que as migrações sejam escalonadas ao longo do tempo. Isso nos permite evitar tempos de inatividade e problemas de desempenho que alterações massivas de dados poderiam causar, como ao adicionar uma nova propriedade a todos os registros do jogador e configurá-la para um valor padrão.
Singularity oferece uma interface simples para interação de back-end, permitindo que as equipes de projeto se concentrem no desenvolvimento de jogos sem se preocupar com questões de protocolo ou tecnologias de comunicação de rede. (Dito isto, o SDK fornece flexibilidade para substituir métodos de serialização padrão para comandos específicos do projeto, se necessário.)
O SDK permite interação direta com a API, mas também inclui um wrapper que automatiza tarefas rotineiras. Por exemplo, a execução de um comando no serviço de perfil gera um conjunto de eventos que indicam alterações no perfil do jogador. O wrapper aplica esses eventos ao estado local, garantindo que o cliente mantenha a versão atual do perfil.
Aqui está um exemplo de chamada de comando:
var result = _sandbox.ExecSync(new LevelUpCommand())
A maioria dos serviços do Singularity são projetados para serem versáteis e não requerem customização para projetos específicos. Esses serviços são distribuídos como aplicativos pré-construídos e podem ser utilizados em vários jogos.
O conjunto de serviços prontos inclui:
Alguns serviços são fundamentais para a plataforma e devem ser implantados, como o serviço de autenticação e gateway. Outros são opcionais, como o serviço de amigos e a tabela de classificação, podendo ser excluídos do ambiente de jogos que não os exijam.
Abordarei mais tarde as questões relacionadas com a gestão de um grande número de serviços, mas por enquanto é essencial enfatizar que os serviços opcionais devem permanecer opcionais. À medida que o número de serviços cresce, a complexidade e o limite de integração para novos projetos também aumentam.
Embora a estrutura central do Singularity seja bastante capaz, recursos significativos podem ser implementados de forma independente pelas equipes de projeto, sem modificar o núcleo. Quando a funcionalidade é identificada como potencialmente benéfica para vários projetos, ela pode ser desenvolvida pela equipe da estrutura e lançada como bibliotecas de extensão separadas. Essas bibliotecas podem então ser integradas e utilizadas como manipuladores de comandos no jogo.
Alguns exemplos de recursos que podem ser aplicados aqui são missões e ofertas. Do ponto de vista da estrutura central, estas entidades são simplesmente itens atribuídos aos jogadores. No entanto, as bibliotecas de extensão podem imbuir esses itens com propriedades e comportamentos específicos, transformando-os em missões ou ofertas. Esta capacidade permite a modificação dinâmica das propriedades dos itens, permitindo o acompanhamento do progresso da missão ou registrando a última data em que uma oferta foi apresentada ao jogador.
Singularity foi implementado com sucesso em um de nossos jogos mais recentes disponíveis globalmente, Little Big Robots, e isso deu aos desenvolvedores clientes o poder de lidar eles próprios com a lógica do servidor. Além disso, conseguimos criar protótipos que utilizam funcionalidades existentes sem a necessidade de suporte direto da equipe da plataforma.
No entanto, esta solução universal não está isenta de desafios. À medida que o número de recursos aumentou, também aumentou a complexidade de interação com a plataforma. O Singularity evoluiu de uma ferramenta simples para um sistema sofisticado e intrincado – semelhante em alguns aspectos à transição de um telefone básico para um smartphone completo.
Embora o Singularity tenha aliviado a necessidade dos desenvolvedores mergulharem nas complexidades dos bancos de dados e da comunicação em rede, ele também introduziu sua própria curva de aprendizado. Os desenvolvedores agora precisam entender as nuances do próprio Singularity, o que pode ser uma mudança significativa.
Os desafios são enfrentados por pessoas que vão desde desenvolvedores até administradores de infraestrutura. Esses profissionais geralmente possuem profundo conhecimento na implantação e manutenção de soluções conhecidas como Postgres e Kafka. No entanto, o Singularity é um produto interno, necessitando que adquiram novas competências: precisam de aprender os meandros dos clusters do Singularity, diferenciar entre serviços obrigatórios e opcionais e compreender quais as métricas que são críticas para a monitorização.
Embora seja verdade que dentro de uma empresa os desenvolvedores sempre podem pedir conselhos aos criadores da plataforma, mas esse processo inevitavelmente exige tempo. Nosso objetivo é minimizar ao máximo a barreira de entrada. Alcançar isso exige documentação abrangente para cada novo recurso, o que pode retardar o desenvolvimento, mas ainda assim é considerado um investimento no sucesso da plataforma a longo prazo. Além disso, uma cobertura robusta de testes unitários e de integração é essencial para garantir a confiabilidade do sistema.
O Singularity depende fortemente de testes automatizados porque os testes manuais exigiriam o desenvolvimento de instâncias de jogo separadas, o que é impraticável. Os testes automatizados podem detectar a grande maioria – ou seja, 99% – dos erros. No entanto, há sempre uma pequena percentagem de problemas que só se tornam evidentes durante testes específicos do jogo. Isso pode afetar os cronogramas de lançamento porque a equipe do Singularity e as equipes de projeto geralmente trabalham de forma assíncrona. Um erro de bloqueio pode ser encontrado em um código escrito há muito tempo e a equipe de desenvolvimento da plataforma pode estar ocupada com outra tarefa crítica. (Esse desafio não é exclusivo do Singularity e também pode ocorrer no desenvolvimento de back-end personalizado.)
Outro desafio significativo é gerenciar atualizações em todos os projetos que usam o Singularity. Normalmente, há um projeto principal que impulsiona o desenvolvimento da estrutura com um fluxo constante de solicitações de recursos e melhorias. A interação com a equipe deste projeto é estreita; entendemos suas necessidades e como eles podem aproveitar nossa plataforma para resolver seus problemas.
Embora alguns projetos emblemáticos estejam intimamente envolvidos com a equipe de estrutura, outros jogos em estágios iniciais de desenvolvimento geralmente operam de forma independente, contando apenas com a funcionalidade e a documentação existentes. Às vezes, isso pode levar a soluções redundantes ou abaixo do ideal, pois os desenvolvedores podem interpretar mal a documentação ou usar indevidamente os recursos disponíveis. Para mitigar esta situação, é crucial facilitar a partilha de conhecimento através de apresentações, encontros e intercâmbios de equipa, embora tais iniciativas exijam um investimento considerável de tempo.
Singularity já demonstrou seu valor em nossos jogos e está preparada para evoluir ainda mais. Embora planejemos introduzir novos recursos, nosso foco principal agora é garantir que essas melhorias não compliquem a usabilidade da plataforma para as equipes de projeto.
Além disso, é necessário diminuir a barreira de entrada, simplificar a implantação, agregar flexibilidade em termos de análise, permitindo que os projetos conectem suas soluções. Este é um desafio para a equipe, mas acreditamos e vemos na prática que o esforço investido em nossa solução com certeza será totalmente recompensado!