Como construir um jogo multiusuário em tempo real a partir do zero

Publicados: 2022-03-10
Resumo rápido ↬ Este artigo destaca o processo, as decisões técnicas e as lições aprendidas por trás da construção do jogo em tempo real Autowuzzler. Aprenda a compartilhar o estado do jogo em vários clientes em tempo real com Colyseus, fazer cálculos físicos com Matter.js, armazenar dados em Supabase.io e construir o front-end com SvelteKit.

À medida que a pandemia persistia, a equipe de repente remota com a qual trabalho tornou-se cada vez mais privada de pebolim. Pensei em como jogar pebolim em um ambiente remoto, mas ficou claro que simplesmente reconstruir as regras do pebolim em uma tela não seria muito divertido.

O que é divertido é chutar uma bola usando carrinhos de brinquedo – uma percepção feita enquanto eu brincava com meu filho de 2 anos. Na mesma noite, comecei a construir o primeiro protótipo de um jogo que se tornaria Autowuzzler .

A ideia é simples : os jogadores dirigem carros de brinquedo virtuais em uma arena de cima para baixo que lembra uma mesa de pebolim. A primeira equipe a marcar 10 gols vence.

É claro que a ideia de usar carros para jogar futebol não é única, mas duas ideias principais devem diferenciar o Autowuzzler : eu queria reconstruir um pouco da aparência de jogar em uma mesa de pebolim física e queria ter certeza de que é o mais fácil possível convidar amigos ou colegas de equipe para um jogo casual rápido.

Neste artigo, descreverei o processo por trás da criação do Autowuzzler , quais ferramentas e frameworks eu escolhi e compartilharei alguns detalhes de implementação e lições que aprendi.

Interface de usuário do jogo mostrando um fundo de mesa de pebolim, seis carros em dois times e uma bola.
Autowuzzler (beta) com seis jogadores simultâneos em duas equipes. (Visualização grande)

Primeiro protótipo de trabalho (terrível)

O primeiro protótipo foi construído usando o motor de jogo de código aberto Phaser.js, principalmente para o motor de física incluído e porque eu já tinha alguma experiência com ele. O estágio do jogo foi incorporado em um aplicativo Next.js, novamente porque eu já tinha um sólido conhecimento do Next.js e queria me concentrar principalmente no jogo.

Como o jogo precisa suportar vários jogadores em tempo real , utilizei o Express como um corretor de WebSockets. Aqui é onde se torna complicado, no entanto.

Como os cálculos físicos eram feitos no cliente do jogo Phaser, optei por uma lógica simples, mas obviamente falha: o primeiro cliente conectado tinha o privilégio duvidoso de fazer os cálculos físicos para todos os objetos do jogo, enviando os resultados para o servidor expresso, que por sua vez transmitiu as posições atualizadas, ângulos e forças de volta para os clientes do outro jogador. Os outros clientes aplicariam as alterações aos objetos do jogo.

Isso levou à situação em que o primeiro jogador viu a física acontecendo em tempo real (afinal, está acontecendo localmente em seu navegador), enquanto todos os outros jogadores estavam atrasados ​​​​pelo menos 30 milissegundos (a taxa de transmissão que eu escolhi ), ou — se a conexão de rede do primeiro jogador for lenta — consideravelmente pior.

Se isso soa como arquitetura pobre para você - você está absolutamente certo. No entanto, aceitei esse fato em favor de obter rapidamente algo jogável para descobrir se o jogo é realmente divertido de jogar.

Valide a ideia, descarte o protótipo

Por mais falho que fosse a implementação, era suficientemente jogável para convidar amigos para um primeiro test drive. O feedback foi muito positivo , com a principal preocupação sendo – não surpreendentemente – o desempenho em tempo real. Outros problemas inerentes incluíam a situação em que o primeiro jogador (lembre-se, o responsável por tudo ) deixou o jogo – quem deveria assumir? Neste ponto havia apenas uma sala de jogos, então qualquer um entraria no mesmo jogo. Eu também estava um pouco preocupado com o tamanho do pacote que a biblioteca Phaser.js introduziu.

Era hora de descartar o protótipo e começar com uma nova configuração e um objetivo claro.

Configuração do projeto

Claramente, a abordagem “o primeiro cliente governa tudo” precisava ser substituída por uma solução na qual o estado do jogo resida no servidor . Em minha pesquisa, me deparei com o Colyseus, que parecia a ferramenta perfeita para o trabalho.

Para os outros blocos de construção principais do jogo eu escolhi:

  • Matter.js como um motor de física em vez de Phaser.js porque roda em Node e Autowuzzler não requer uma estrutura de jogo completa.
  • SvelteKit como uma estrutura de aplicativo em vez de Next.js, porque ele acabou de entrar em beta público na época. (Além disso: adoro trabalhar com Svelte.)
  • Supabase.io para armazenar PINs de jogos criados pelo usuário.

Vejamos esses blocos de construção com mais detalhes.

Mais depois do salto! Continue lendo abaixo ↓

Estado de jogo sincronizado e centralizado com Colyseus

Colyseus é uma estrutura de jogo multiplayer baseada em Node.js e Express. Em sua essência, ele fornece:

  • Sincronizando o estado entre os clientes de forma autoritária;
  • Comunicação eficiente em tempo real usando WebSockets enviando apenas dados alterados;
  • Configurações de várias salas;
  • Bibliotecas cliente para JavaScript, Unity, Defold Engine, Haxe, Cocos Creator, Construct3;
  • Ganchos do ciclo de vida, por exemplo, a sala é criada, o usuário se junta, o usuário sai e muito mais;
  • Envio de mensagens, seja como mensagens de difusão para todos os usuários da sala, ou para um único usuário;
  • Um painel de monitoramento integrado e uma ferramenta de teste de carga.

Nota : Os documentos do Colyseus facilitam a introdução de um servidor Colyseus barebones, fornecendo um script npm init e um repositório de exemplos.

Criando um esquema

A principal entidade de um aplicativo Colyseus é a sala de jogos, que mantém o estado de uma única instância de sala e todos os seus objetos de jogo. No caso do Autowuzzler , é uma sessão de jogo com:

  • duas equipes,
  • uma quantidade finita de jogadores,
  • uma bola.

Um esquema precisa ser definido para todas as propriedades dos objetos do jogo que devem ser sincronizados entre os clientes . Por exemplo, queremos que a bola sincronize e, portanto, precisamos criar um esquema para a bola:

 class Ball extends Schema { constructor() { super(); this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; } } defineTypes(Ball, { x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number" });

No exemplo acima, uma nova classe que estende a classe de esquema fornecida pelo Colyseus é criada; no construtor, todas as propriedades recebem um valor inicial. A posição e o movimento da bola são descritos usando as cinco propriedades: x , y , angle , velocityX, velocityY . Além disso, precisamos especificar os tipos de cada propriedade . Este exemplo usa a sintaxe JavaScript, mas você também pode usar a sintaxe TypeScript um pouco mais compacta.

Os tipos de propriedade podem ser tipos primitivos:

  • string
  • boolean
  • number (assim como tipos inteiros e flutuantes mais eficientes)

ou tipos complexos:

  • ArraySchema (semelhante ao Array em JavaScript)
  • MapSchema (semelhante ao Map em JavaScript)
  • SetSchema (semelhante a Set em JavaScript)
  • CollectionSchema (semelhante a ArraySchema, mas sem controle sobre índices)

A classe Ball acima tem cinco propriedades do tipo number : suas coordenadas ( x , y ), seu angle atual e o vetor de velocidade ( velocityX , velocityY ).

O esquema para jogadores é semelhante, mas inclui mais algumas propriedades para armazenar o nome do jogador e o número do time, que precisam ser fornecidos ao criar uma instância de Player:

 class Player extends Schema { constructor(teamNumber) { super(); this.name = ""; this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; this.teamNumber = teamNumber; } } defineTypes(Player, { name: "string", x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number", angularVelocity: "number", teamNumber: "number", });

Por fim, o esquema da Room Autowuzzler conecta as classes definidas anteriormente: Uma instância da sala tem várias equipes (armazenadas em um ArraySchema). Ele também contém uma única bola, portanto, criamos uma nova instância Ball no construtor do RoomSchema. Os jogadores são armazenados em um MapSchema para recuperação rápida usando seus IDs.

 class RoomSchema extends Schema { constructor() { super(); this.teams = new ArraySchema(); this.ball = new Ball(); this.players = new MapSchema(); } } defineTypes(RoomSchema, { teams: [Team], // an Array of Team ball: Ball, // a single Ball instance players: { map: Player } // a Map of Players });
Nota : A definição da classe Team é omitida.

Configuração de várias salas (“Match-Making”)

Qualquer pessoa pode participar de um jogo Autowuzzler se tiver um PIN de jogo válido. Nosso servidor Colyseus cria uma nova instância de Sala para cada sessão de jogo assim que o primeiro jogador entra e descarta a sala quando o último jogador sai.

O processo de designar jogadores para a sala de jogos desejada é chamado de “match-making”. O Colyseus facilita muito a configuração usando o método filterBy ao definir uma nova sala:

 gameServer.define("autowuzzler", AutowuzzlerRoom).filterBy(['gamePIN']);

Agora, qualquer jogador que entrar no jogo com o mesmo gamePIN (veremos como “entrar” mais tarde) acabará na mesma sala de jogo! Quaisquer atualizações de estado e outras mensagens de transmissão são limitadas aos jogadores na mesma sala.

Física em um aplicativo Colyseus

O Colyseus oferece muito para começar a funcionar rapidamente com um servidor de jogo autoritário, mas deixa para o desenvolvedor criar a mecânica do jogo real - incluindo a física. O Phaser.js, que usei no protótipo, não pode ser executado em um ambiente que não seja de navegador, mas o mecanismo de física integrado Matter.js do Phaser.js pode ser executado no Node.js.

Com o Matter.js, você define um mundo físico com certas propriedades físicas, como tamanho e gravidade. Ele fornece vários métodos para criar objetos físicos primitivos que interagem uns com os outros aderindo às leis (simuladas) da física, incluindo massa, colisões, movimento com atrito e assim por diante. Você pode mover objetos aplicando força – exatamente como faria no mundo real.

Um “mundo” Matter.js está no coração do jogo Autowuzzler ; define o quão rápido os carros se movem, quão quicando a bola deve ser, onde os gols estão localizados e o que acontece se alguém chutar um gol.

 let ball = Bodies.circle( ballInitialXPosition, ballInitialYPosition, radius, { render: { sprite: { texture: '/assets/ball.png', } }, friction: 0.002, restitution: 0.8 } ); World.add(this.engine.world, [ball]);

Código simplificado para adicionar um objeto de jogo “bola” ao palco no Matter.js.

Depois que as regras são definidas, o Matter.js pode ser executado com ou sem renderizar algo em uma tela. Para Autowuzzler , estou utilizando esse recurso para reutilizar o código do mundo da física para o servidor e o cliente - com várias diferenças importantes:

Mundo da física no servidor :

  • recebe entrada do usuário (eventos de teclado para dirigir um carro) via Colyseus e aplica a força apropriada no objeto do jogo (o carro do usuário);
  • faz todos os cálculos físicos para todos os objetos (jogadores e a bola), incluindo a detecção de colisões;
  • comunica o estado atualizado de cada objeto do jogo de volta ao Colyseus, que por sua vez o transmite aos clientes;
  • é atualizado a cada 16,6 milissegundos (= 60 quadros por segundo), acionado pelo nosso servidor Colyseus.

Mundo da física no cliente :

  • não manipula objetos do jogo diretamente;
  • recebe o estado atualizado para cada objeto do jogo da Colyseus;
  • aplica mudanças na posição, velocidade e ângulo após receber o estado atualizado;
  • envia a entrada do usuário (eventos de teclado para dirigir um carro) para Colyseus;
  • carrega sprites do jogo e usa um renderizador para desenhar o mundo da física em um elemento de tela;
  • ignora a detecção de colisão (usando a opção isSensor para objetos);
  • atualizações usando requestAnimationFrame, idealmente a 60 fps.
Diagrama mostrando dois blocos principais: App Colyseus Server e App SvelteKit. Colyseus Server App contém bloco Autowuzzler Room, SvelteKit App contém bloco Colyseus Client. Ambos os blocos principais compartilham um bloco chamado Physics World (Matter.js)
Principais unidades lógicas da arquitetura Autowuzzler: o Physics World é compartilhado entre o servidor Colyseus e o aplicativo cliente SvelteKit. (Visualização grande)

Agora, com toda a mágica acontecendo no servidor, o cliente apenas manipula a entrada e desenha na tela o estado que recebe do servidor. Com uma exceção:

Interpolação no cliente

Como estamos reutilizando o mesmo mundo de física Matter.js no cliente, podemos melhorar o desempenho da experiência com um truque simples. Em vez de apenas atualizar a posição de um objeto do jogo, também sincronizamos a velocidade do objeto . Dessa forma, o objeto continua se movendo em sua trajetória mesmo que a próxima atualização do servidor demore mais do que o normal. Então, em vez de mover objetos em etapas discretas da posição A para a posição B, mudamos sua posição e fazemos com que eles se movam em uma determinada direção.

Ciclo da vida

A classe Autowuzzler Room é onde é tratada a lógica relacionada às diferentes fases de uma sala Colyseus. A Colyseus fornece vários métodos de ciclo de vida:

  • onCreate : quando uma nova sala é criada (geralmente quando o primeiro cliente se conecta);
  • onAuth : como um gancho de autorização para permitir ou negar a entrada na sala;
  • onJoin : quando um cliente se conecta à sala;
  • onLeave : quando um cliente se desconecta da sala;
  • onDispose : quando a sala é descartada.

A sala Autowuzzler cria uma nova instância do mundo da física (consulte a seção “Física em um aplicativo Colyseus”) assim que é criada ( onCreate ) e adiciona um jogador ao mundo quando um cliente se conecta ( onJoin ). Em seguida, ele atualiza o mundo da física 60 vezes por segundo (a cada 16,6 milissegundos) usando o método setSimulationInterval (nosso loop de jogo principal):

 // deltaTime is roughly 16.6 milliseconds this.setSimulationInterval((deltaTime) => this.world.updateWorld(deltaTime));

Os objetos físicos são independentes dos objetos Colyseus, o que nos deixa com duas permutações do mesmo objeto do jogo (como a bola), ou seja, um objeto no mundo da física e um objeto Colyseus que pode ser sincronizado.

Assim que o objeto físico muda, suas propriedades atualizadas precisam ser aplicadas de volta ao objeto Colyseus. Podemos conseguir isso ouvindo o evento afterUpdate do afterUpdate e definindo os valores a partir daí:

 Events.on(this.engine, "afterUpdate", () => { // apply the x position of the physics ball object back to the colyseus ball object this.state.ball.x = this.physicsWorld.ball.position.x; // ... all other ball properties // loop over all physics players and apply their properties back to colyseus players objects })

Há mais uma cópia dos objetos que precisamos cuidar: os objetos do jogo no jogo voltado para o usuário .

Diagrama mostrando as três versões de um objeto de jogo: Objetos de Esquema Colyseus, Objetos de Física Matter.js, Objetos de Física Cliente Matter.js. Matter.js atualiza a versão Colyseus do objeto, Colyseus sincroniza com o objeto de física Matter.js do cliente.
O Autowuzzler mantém três cópias de cada objeto físico, uma versão oficial (objeto Colyseus), uma versão no mundo da física Matter.js e uma versão no cliente. (Visualização grande)

Aplicativo do lado do cliente

Agora que temos um aplicativo no servidor que lida com a sincronização do estado do jogo para várias salas, bem como cálculos físicos, vamos nos concentrar na construção do site e da interface real do jogo . O frontend Autowuzzler tem as seguintes responsabilidades:

  • permite que os usuários criem e compartilhem PINs de jogos para acessar salas individuais;
  • envia os PINs do jogo criado para um banco de dados Supabase para persistência;
  • fornece uma página opcional “Entrar em um jogo” para os jogadores inserirem o PIN do jogo;
  • valida PINs de jogo quando um jogador entra em um jogo;
  • hospeda e renderiza o jogo real em uma URL compartilhável (ou seja, única);
  • conecta-se ao servidor Colyseus e trata das atualizações de estado;
  • fornece uma página de destino (“marketing”).

Para a implementação dessas tarefas, escolhi o SvelteKit em vez do Next.js pelos seguintes motivos:

Por que SvelteKit?

Eu queria desenvolver outro aplicativo usando o Svelte desde que construí o neolightsout. Quando o SvelteKit (a estrutura de aplicativo oficial do Svelte) entrou em versão beta pública, decidi construir o Autowuzzler com ele e aceitar qualquer dor de cabeça que venha com o uso de um novo beta - a alegria de usar o Svelte claramente compensa isso.

Esses recursos principais me fizeram escolher o SvelteKit em vez do Next.js para a implementação real do frontend do jogo:

  • Svelte é uma estrutura de interface do usuário e um compilador e, portanto, envia código mínimo sem um tempo de execução do cliente;
  • Svelte tem uma linguagem de modelagem expressiva e sistema de componentes (preferência pessoal);
  • O Svelte inclui lojas globais, transições e animações prontas para uso, o que significa: sem fadiga de decisão escolhendo um kit de ferramentas de gerenciamento de estado global e uma biblioteca de animação;
  • O Svelte suporta CSS com escopo em componentes de arquivo único;
  • SvelteKit suporta SSR, roteamento baseado em arquivo simples mas flexível e rotas do lado do servidor para construir uma API;
  • O SvelteKit permite que cada página execute código no servidor, por exemplo, para buscar dados que são usados ​​para renderizar a página;
  • Layouts compartilhados entre rotas;
  • O SvelteKit pode ser executado em um ambiente sem servidor.

Criando e armazenando PINs de jogos

Antes que um usuário possa começar a jogar, primeiro ele precisa criar um PIN de jogo. Ao compartilhar o PIN com outras pessoas, todos podem acessar a mesma sala de jogos.

Captura de tela da seção iniciar um novo jogo do site Autowuzzler mostrando o PIN do jogo 751428 e as opções para copiar e compartilhar o PIN e o URL do jogo.
Inicie um novo jogo copiando o PIN do jogo gerado ou compartilhe o link direto para a sala de jogos. (Visualização grande)

Este é um ótimo caso de uso para endpoints do lado do servidor SvelteKits em conjunto com a função Sveltes onMount: O endpoint /api/createcode gera um PIN de jogo, armazena-o em um banco de dados Supabase.io e gera o PIN do jogo como resposta . Esta resposta é buscada assim que o componente de página da página “criar” é montado:

Diagrama mostrando três seções: Create page, createcode endpoint e Supabase.io. A página de criação busca o endpoint em sua função onMount, o endpoint gera um PIN do jogo, armazena-o no Supabase.io e responde com o PIN do jogo. A página Criar exibe o PIN do jogo.
Os PINs do jogo são criados no terminal, armazenados em um banco de dados Supabase.io e exibidos na página “Criar”. (Visualização grande)

Armazenando PINs de jogos com Supabase.io

O Supabase.io é uma alternativa de código aberto ao Firebase. O Supabase torna muito fácil criar um banco de dados PostgreSQL e acessá-lo por meio de uma de suas bibliotecas cliente ou via REST.

Para o cliente JavaScript, importamos a função createClient e a executamos usando os parâmetros supabase_url e supabase_key que recebemos ao criar o banco de dados. Para armazenar o PIN do jogo que é criado em cada chamada para o endpoint createcode , tudo o que precisamos fazer é executar esta consulta de insert simples:

 import { createClient } from '@supabase/supabase-js' const database = createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_KEY ); const { data, error } = await database .from("games") .insert([{ code: 123456 }]);

Nota : O supabase_url e supabase_key são armazenados em um arquivo .env. Devido ao Vite — a ferramenta de compilação no coração do SvelteKit — é necessário prefixar as variáveis ​​de ambiente com VITE_ para torná-las acessíveis no SvelteKit.

Acessando o jogo

Eu queria tornar a entrada em um jogo Autowuzzler tão fácil quanto seguir um link. Portanto, cada sala de jogos precisava ter seu próprio URL com base no PIN do jogo criado anteriormente , por exemplo, https://autowuzzler.com/play/12345.

No SvelteKit, as páginas com parâmetros de rota dinâmica são criadas colocando as partes dinâmicas da rota entre colchetes ao nomear o arquivo de página: client/src/routes/play/[gamePIN].svelte . O valor do parâmetro gamePIN ficará disponível no componente de página (consulte os documentos do SvelteKit para obter detalhes). Na rota de play , precisamos nos conectar ao servidor Colyseus, instanciar o mundo da física para renderizar na tela, lidar com atualizações nos objetos do jogo, ouvir a entrada do teclado e exibir outra interface do usuário, como a pontuação e assim por diante.

Conectando-se ao Colyseus e atualizando o estado

A biblioteca do cliente Colyseus nos permite conectar um cliente a um servidor Colyseus. Primeiro, vamos criar um novo Colyseus.Client apontando-o para o servidor Colyseus ( ws://localhost:2567 em desenvolvimento). Em seguida, junte-se à sala com o nome que escolhemos anteriormente ( autowuzzler ) e o gamePIN do parâmetro route. O parâmetro gamePIN garante que o usuário entre na instância correta da sala (veja “match-making” acima).

 let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN });

Como o SvelteKit renderiza páginas no servidor inicialmente, precisamos garantir que esse código seja executado apenas no cliente após o carregamento da página. Novamente, usamos a função de ciclo de vida onMount para esse caso de uso. (Se você estiver familiarizado com o React, onMount é semelhante ao gancho useEffect com uma matriz de dependência vazia.)

 onMount(async () => { let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN }); })

Agora que estamos conectados ao servidor de jogo Colyseus, podemos começar a ouvir quaisquer alterações em nossos objetos de jogo.

Aqui está um exemplo de como ouvir um jogador entrando na sala ( onAdd ) e recebendo atualizações de estado consecutivas para este jogador:

 this.room.state.players.onAdd = (player, key) => { console.log(`Player has been added with sessionId: ${key}`); // add player entity to the game world this.world.createPlayer(key, player.teamNumber); // listen for changes to this player player.onChange = (changes) => { changes.forEach(({ field, value }) => { this.world.updatePlayer(key, field, value); // see below }); }; };

No método updatePlayer do mundo da física, atualizamos as propriedades uma a uma porque o onChange do Colyseus entrega um conjunto de todas as propriedades alteradas.

Nota : Esta função é executada apenas na versão cliente do mundo da física, pois os objetos do jogo são manipulados apenas indiretamente através do servidor Colyseus.

 updatePlayer(sessionId, field, value) { // get the player physics object by its sessionId let player = this.world.players.get(sessionId); // exit if not found if (!player) return; // apply changes to the properties switch (field) { case "angle": Body.setAngle(player, value); break; case "x": Body.setPosition(player, { x: value, y: player.position.y }); break; case "y": Body.setPosition(player, { x: player.position.x, y: value }); break; // set velocityX, velocityY, angularVelocity ... } }

O mesmo procedimento se aplica aos outros objetos do jogo (bola e times): ouça suas alterações e aplique os valores alterados ao mundo físico do cliente.

Até agora, nenhum objeto está se movendo porque ainda precisamos ouvir a entrada do teclado e enviá-la ao servidor . Em vez de enviar eventos diretamente em cada evento de keydown , mantemos um mapa das teclas pressionadas no momento e enviamos eventos para o servidor Colyseus em um loop de 50ms. Dessa forma, podemos suportar o pressionamento de várias teclas ao mesmo tempo e mitigar a pausa que ocorre após o primeiro e consecutivos eventos de keydown quando a tecla permanece pressionada:

 let keys = {}; const keyDown = e => { keys[e.key] = true; }; const keyUp = e => { keys[e.key] = false; }; document.addEventListener('keydown', keyDown); document.addEventListener('keyup', keyUp); let loop = () => { if (keys["ArrowLeft"]) { this.room.send("move", { direction: "left" }); } else if (keys["ArrowRight"]) { this.room.send("move", { direction: "right" }); } if (keys["ArrowUp"]) { this.room.send("move", { direction: "up" }); } else if (keys["ArrowDown"]) { this.room.send("move", { direction: "down" }); } // next iteration requestAnimationFrame(() => { setTimeout(loop, 50); }); } // start loop setTimeout(loop, 50);

Agora o ciclo está completo: ouça as teclas digitadas, envie os comandos correspondentes ao servidor Colyseus para manipular o mundo da física no servidor. O servidor Colyseus então aplica as novas propriedades físicas a todos os objetos do jogo e propaga os dados de volta ao cliente para atualizar a instância do jogo voltada para o usuário.

Incômodos Menores

Em retrospecto, duas coisas da categoria ninguém-me-disse-mas-alguém-deveria ter vindo à mente:

  • Uma boa compreensão de como funcionam os motores de física é benéfica. Passei uma quantidade considerável de tempo ajustando as propriedades e restrições da física. Embora eu tenha construído um pequeno jogo com Phaser.js e Matter.js antes, havia muitas tentativas e erros para fazer os objetos se moverem da maneira que eu imaginava.
  • O tempo real é difícil – especialmente em jogos baseados em física. Pequenos atrasos pioram consideravelmente a experiência e, embora a sincronização do estado entre clientes com o Colyseus funcione muito bem, ele não pode remover atrasos de computação e transmissão.

Pegadinhas e advertências com SvelteKit

Como eu usei o SvelteKit quando estava recém-saído do forno beta, havia algumas pegadinhas e ressalvas que gostaria de destacar:

  • Demorou um pouco para descobrir que as variáveis ​​de ambiente precisam ser prefixadas com VITE_ para usá-las no SvelteKit. Isso agora está devidamente documentado no FAQ.
  • Para usar o Supabase, tive que adicionar o Supabase às listas de dependencies e devDependencies do package.json. Acredito que este não seja mais o caso.
  • A função de load do SvelteKits é executada tanto no servidor quanto no cliente!
  • Para habilitar a substituição completa do módulo hot (incluindo a preservação do estado), você deve adicionar manualmente uma linha de comentário <!-- @hmr:keep-all --> nos componentes da sua página. Consulte as Perguntas frequentes para obter mais detalhes.

Muitos outros frameworks também seriam ótimos, mas não me arrependo de ter escolhido o SvelteKit para este projeto. Isso me permitiu trabalhar no aplicativo cliente de uma maneira muito eficiente - principalmente porque o próprio Svelte é muito expressivo e pula muito do código clichê, mas também porque o Svelte tem coisas como animações, transições, CSS com escopo e armazenamentos globais incorporados. O SvelteKit forneceu todos os blocos de construção que eu precisava (SSR, roteamento, rotas de servidor) e, embora ainda em beta, parecia muito estável e rápido.

Implantação e hospedagem

Inicialmente, hospedei o servidor Colyseus (Node) em uma instância Heroku e perdi muito tempo fazendo WebSockets e CORS funcionarem. Acontece que o desempenho de um minúsculo dinamômetro Heroku (gratuito) não é suficiente para um caso de uso em tempo real. Mais tarde, migrei o aplicativo Colyseus para um pequeno servidor na Linode. O aplicativo do lado do cliente é implantado e hospedado no Netlify por meio do adaptador SvelteKits-netlify. Sem surpresas aqui: Netlify funcionou muito bem!

Conclusão

Começar com um protótipo bem simples para validar a ideia me ajudou muito a descobrir se vale a pena seguir o projeto e onde estão os desafios técnicos do jogo. Na implementação final, a Colyseus cuidou de todo o trabalho pesado de sincronizar o estado em tempo real em vários clientes, distribuídos em várias salas. É impressionante a rapidez com que um aplicativo multiusuário em tempo real pode ser construído com o Colyseus — assim que você descobrir como descrever adequadamente o esquema. O painel de monitoramento integrado do Colyseus ajuda na solução de problemas de sincronização.

O que complicou essa configuração foi a camada de física do jogo, porque introduziu uma cópia adicional de cada objeto de jogo relacionado à física que precisava ser mantido. Armazenar PINs de jogos no Supabase.io a partir do aplicativo SvelteKit foi muito simples. Em retrospectiva, eu poderia ter usado apenas um banco de dados SQLite para armazenar os PINs do jogo, mas experimentar coisas novas é metade da diversão ao criar projetos paralelos.

Finalmente, usar o SvelteKit para construir o frontend do jogo permitiu que eu me movesse rapidamente – e com um ocasional sorriso de alegria no rosto.

Agora, vá em frente e convide seus amigos para uma rodada de Autowuzzler!

Leitura adicional na revista Smashing

  • “Comece a usar o React construindo um jogo Whac-A-Mole”, Jhey Tompkins
  • “Como construir um jogo de realidade virtual multijogador em tempo real”, Alvin Wan
  • “Escrevendo um mecanismo de aventura de texto multijogador em Node.js”, Fernando Doglio
  • “O futuro do web design móvel: design de videogame e narrativa”, Suzanne Scacca
  • “Como construir um jogo de corredor sem fim em realidade virtual”, Alvin Wan