Um motor, treze jogos: a camada partilhada do Lab
Uma plataforma merece o nome quando a décima terceira coisa é mais barata de construir do que a terceira. Este é o motor de jogos de navegador por baixo do nosso Lab — o que todos os jogos partilham, o que nenhum deles partilha e o punhado de pequenos contratos que impede treze brinquedos muito diferentes de andarem à luta.
Nesta página
São treze os jogos que correm no Lab — oito deles 3D em tempo real em WebGL, cinco deles originais em Canvas 2D. O número não é o dado interessante. O dado interessante é quanto custa o próximo, e a resposta a essa pergunta decide-se quase por inteiro pelas partes que concordámos em partilhar. Partilhamos as partes aborrecidas: o volume das coisas, quantos píxeis se pedem à GPU, o que acontece quando um telemóvel roda, o que acontece quando o navegador deita fora o contexto gráfico. Não partilhamos nenhuma das partes divertidas. Não há ciclo de jogo partilhado, não há física partilhada, não há sistema de entidades, não há abstração de grafo de cena entre um jogo e o three.js.
- 13
- jogos no Lab
- 8
- 3D em tempo real em WebGL
- 120 Hz
- simulações de passo temporal fixo
- 1
- tecla de silêncio para todos eles
O Lab a 27 de julho de 2026. Todos os jogos são gratuitos, no navegador, sem instalação e sem conta.
Oito em WebGL, cinco em canvas
Um único registo descreve cada jogo: slug, título, mote, matiz de destaque, que dispositivos suporta, estado, destaques, se é 3D ou 2D, e uma pequena etiqueta monoespaçada que nomeia a maquinaria («terreno de voxels em blocos», esse género de coisa). A grelha em /games lê esse registo e divide-se em duas — uma calha de 3D em tempo real para os jogos em React Three Fiber e, por baixo, uma grelha para os originais, os jogos em Canvas 2D que não precisam de GPU nenhuma.
A regra que dá sentido à divisão cabe numa linha: cada componente de jogo, e cada componente de demonstração automática, é embrulhado em React.lazy ao nível do módulo. O three.js divide-se, portanto, por jogo. Quem abrir um original em Canvas 2D e jogar uma hora nunca transfere um renderizador, e quem abrir o índice dos jogos não transfere nenhum dos dois — recebe cartazes. Manter cinco jogos em canvas simples não é nostalgia. Há ideias que ficam melhor em píxeis, e um jogo que não custa nada a carregar é um jogo que as pessoas acabam mesmo.
Níveis de qualidade WebGL, adivinhados uma vez
Todos os jogos 3D fazem a mesma pergunta ao montar e nunca mais a repetem: em que máquina é que estou a correr? A resposta é um nível — low, medium, high — calculado a partir da deteção de ponteiro grosseiro, do navigator.hardwareConcurrency, do navigator.deviceMemory e da menor dimensão do ecrã. Um dispositivo conta como telemóvel quando o ponteiro é grosseiro e o lado curto está abaixo de 700 píxeis CSS. O resultado mais consequente do nível é um teto rígido para a densidade de píxeis: 1,25, 1,5 ou 2,0. Num telemóvel moderno, a diferença entre um teto de 1,25 e um 3,0 sem limite é cerca de seis vezes mais fragmentos sombreados, que é a diferença entre um jogo e um aquecedor.
Esse pormenor do Safari merece ser dito com todas as letras em vez de arquivado como erro. O Safari não expõe deviceMemory, por isso um iPhone nunca consegue argumentar a subida na escada; é classificado como baixo por omissão. O Safari móvel é também o que mantém menos contextos WebGL vivos entre os navegadores que suportamos e o que recupera separadores em segundo plano com mais agressividade, por isso a resposta conservadora é a correta. Quando uma heurística está errada numa direção que nos ajuda, deixe-a estar.
Duas decisões mais pequenas no mesmo hook demoraram mais a assentar do que os limiares. A renderização no servidor devolve medium — o servidor não consegue medir nada, por isso adivinha o meio e deixa o cliente corrigir ao montar. E a preferência de movimento reduzido de um utilizador não baixa o nível. É tentador confundi-las, mas são pedidos diferentes: parem de mexer as coisas não é deem-me menos píxeis, e responder a uma preferência de legibilidade degradando a qualidade de imagem seria uma forma estranha de escutar. O movimento reduzido é respeitado onde deve, na própria animação.
Uma tecla de silêncio para todo o Lab
Não há ficheiros de áudio em lado nenhum do Lab, porque a especificação proíbe recursos binários. Todos os sons de todos os jogos saem de um único módulo de síntese com uma superfície de exatamente duas funções: beep() e noiseBurst(). Entre as duas cobrem um propulsor de acoplagem, um peão a aterrar, uma torre a disparar e um nível a terminar, o que diz menos sobre o nosso desenho de som do que sobre a quantidade de áudio de jogos que é, afinal, modelação de envelope.
O AudioContext por detrás delas é um singleton preguiçoso, criado na primeira utilização e não na importação, porque um contexto criado antes de um gesto do utilizador nasce suspenso e assim fica. Os sons pedidos enquanto o contexto retoma são postos em fila em vez de descartados, para que o clique que desbloqueia o áudio seja ele próprio audível. Quando o separador se esconde, o contexto é suspenso.
Uma chave de localStorage comanda um nó de ganho principal. Silencie um simulador de acoplagem e o tabuleiro de xadrez também se cala.
Essa chave — vaynerov:engine3d:muted:v1 — é deliberadamente global e não por jogo. Hoje, quinze módulos importam o sintetizador, incluindo uma página que nem sequer é um jogo, e todos eles pendem de um único ganho principal. A alternativa, um interruptor de silêncio por jogo, soa mais configurável e é pior: ninguém quer andar à caça de treze caixinhas porque começou uma reunião. Silenciar é uma ação de emergência, e as ações de emergência devem ser totais.
O resto da camada partilhada é igualmente pequeno de propósito:
- Sticks de toque com 18% de zona morta — suficiente para o tremor do polegar não conduzir, pequena o bastante para a precisão sobreviver.
- Deteção de deslize com um limiar de 24 px, em que um arrasto de rato conta como deslize, para que o mesmo manipulador funcione num portátil.
- Armazenamento por jogo sob
vaynerov:<slug>:v1— definições e progresso, com espaço de nomes próprio, para que nenhum jogo possa ler o estado de outro. - Movimento reduzido como um hook que fixa um valor inicial e depois subscreve, para que uma preferência alterada a meio da sessão faça efeito sem recarregar.
- Tratamento de perda de contexto, montado dentro da tela, que transforma um contexto WebGL perdido num cartaz em vez de num retângulo preto.
Este último traz um comentário honesto no próprio código-fonte: a proteção está ligada ao palco da montra, e os sete palcos por jogo que restam ainda precisam dela. É o item aberto mais antigo da camada. Hoje, uma perda de contexto numa página de jogo produz uma fronteira de erro em vez de um cartaz elegante — recuperável, não bonito.
Sem ciclo de jogo partilhado
A decisão mais útil de toda a camada foi recusar escrever aquilo que todos os motores escrevem primeiro: um ciclo de atualização partilhado.
Em vez disso, cada jogo é dono do seu ciclo, e os que têm física a sério correm com passo temporal fixo — 120 Hz no simulador de acoplagem e no empilhador de grua e pêndulo, onde um passo variável muda visivelmente a dificuldade do jogo. Mais importante ainda, a simulação de vários jogos vive num módulo simples com um comentário no topo que diz sem three.js, sem React: o estafeta à Crossy Road, o RTS de bolso, o corredor de faixas e a ilha de voxels guardam todos as suas regras em ficheiros que não importam nada. Física que se pode chamar a partir de um ficheiro de teste é física em que se pode confiar; física entrelaçada num callback de renderização é um boato.
O retorno mais claro está no Slipstream, o corredor de faixas. As suas filas de obstáculos são geradas a partir de padrões, e um padrão bonito pode na mesma ser impossível — um espaço em que se entra a passear torna-se um muro assim que a pista anda a 32 unidades por segundo. Por isso o ficheiro de padrões traz um validador que percorre todas as filas e prova que uma faixa sobrevivível se mantém alcançável à velocidade do pior caso, propagando a alcançabilidade para a frente pelas três faixas. Corre na inicialização do módulo em desenvolvimento, o que significa que um padrão injusto não sobrevive a um carregamento de página, quanto mais a um lançamento.
A parte de que gostamos é a reutilização: o mesmo modelo de filas comanda o piloto automático da demonstração na montra dos jogos. A demonstração que se joga sozinha na página inicial está a navegar exatamente pela alcançabilidade que o validador verificou. Justo para o validador é a mesma coisa que justo para o jogador, e a demonstração é a prova a correr em público.
Uma tela por trás de tudo
A montra dos jogos passa por slides de demonstração — uns segundos de cada jogo, a jogar-se sozinho. A implementação ingénua monta uma tela por slide e, num telemóvel, morre logo: os navegadores limitam o número de contextos WebGL vivos e, num telemóvel, esse teto é baixo o suficiente para se atingir antes de a calha completar uma volta. Por isso a montra corre uma tela partilhada por detrás de todos os slides 3D, com exatamente uma cena montada de cada vez.
Trocar de cena dentro de uma tela viva é a parte feia, e escondemo-la atrás de um véu opaco de DOM com uma coreografia de cinco constantes.
| Constante | ms | Papel |
|---|---|---|
| VEIL_UP | 240 | Um painel opaco de DOM sobe sobre a tela |
| SETTLE | 160 | Uma pausa por detrás do véu enquanto a cena troca |
| CANVAS_FADE | 700 | A própria tela esbate-se entre cenas |
| VEIL_DOWN | 480 | O painel levanta sobre o novo jogo |
| COOL_DOWN | 400 | Novas mudanças de slide são recusadas até isto expirar |
A troca de 3D para 3D. O tempo de arrefecimento existe porque um visitante a tocar depressa na calha pode, de outra forma, desmontar uma cena a meio da construção.
Por detrás do véu, uma linha faz um trabalho desproporcionado: a câmara por omissão é reposta nos valores de fábrica — campo de visão de 75°, plano distante a 1000 — antes de cada montagem. Os jogos deixam a câmara onde o seu mundo precisava dela. O jogo de tiro com bloqueio de ponteiro deixa um plano distante a 160, o que está certo para uma arena em corredor e é catastrófico para a ilha de voxels, cujo horizonte precisa de pelo menos 260. Sem a reposição, é a ordem dos slides que determina se um mundo é desenhado ou acaba abruptamente em nevoeiro. O estado mutável partilhado castiga-nos mais cedo ou mais tarde; uma reposição de fábrica é o pedido de desculpa mais barato.
A tela só corre em frameloop="always" enquanto está genuinamente no ecrã e houver um slide 3D ativo; de resto não desenha nada. E quando o contexto se perde na mesma, o palco recai num cartaz de DOM.
Esses cartazes merecem um parágrafo. Os treze jogos têm um, e cada cartaz é DOM e SVG puros — sem WebGL, sem canvas 2D, sem um único píxel de arte rasterizada. É essa restrição que os torna seguros para renderizar a partir de componentes de servidor, e dá a todo o Lab uma cadeia de recurso única para três falhas diferentes: renderização no servidor, um navegador sem WebGL e um chunk preguiçoso que ainda não chegou. O mesmo desenho cobre as três. Os originais em Canvas 2D partilham entre si um único módulo de cartaz, já que a arte dos seus cartões é uma família.
Contratos pequenos, treze vezes
O resto da plataforma é uma pequena lista de acordos, cada um dos quais existe porque alguma coisa correu mal uma vez.
A classe de dispositivo fica trancada. Um jogo pergunta uma vez, ao montar, se o dispositivo é suportado, e a resposta fica fixa para a sessão. O comentário no hook explica-o melhor do que nós conseguiríamos: um redimensionamento de janela a meio do jogo não pode inverter a classe e trocar um jogo a decorrer pelo ecrã de bloqueio. A deteção lê primeiro o user agent e só depois o tamanho da janela, e devolve null logo na primeira renderização, para que servidor e cliente concordem antes de algo se comprometer. Exatamente um jogo em treze é só para computador — o de tiro com bloqueio de ponteiro, que precisa de um rato para capturar — e um dispositivo não suportado recebe um ecrã de bloqueio como deve ser em vez de uma experiência partida.
Há uma regra que deliberadamente não fizemos: os telemóveis não são forçados aos controlos de toque à escala da plataforma. Cada jogo tranca o tipo de ponteiro ao montar e decide por si que sticks e que dicas de controlo desenhar. Um tablet com teclado ligado é uma coisa real, e uma regra à escala da plataforma teria estado errada quanto a ele nos dois sentidos.
O ecrã inteiro é um contrato, não uma chamada de API. Os jogos nunca tocam na Fullscreen API; leem um contexto. Onde a API nativa existe, usamo-la. No Safari do iPhone, que não a oferece para elementos arbitrários, recorremos a um pseudo-ecrã-inteiro em CSS: uma casca fixa à janela numa camada de empilhamento alta, com a rolagem da página bloqueada para que o efeito elástico não arraste o tabuleiro a meio de uma jogada. Ambos os caminhos reportam o mesmo booleano pelo mesmo hook, e um jogo que ligue o Escape tem de sair mais cedo enquanto estiver em ecrã inteiro, porque aí é o anfitrião o dono dessa tecla.
As teclas dividem-se por intenção. As teclas de movimento correspondem por e.code, a tecla física, para que o WASD continue a ser um losango sob a mão esquerda num teclado AZERTY. As letras mnemónicas — aquelas em que a letra é o objetivo — correspondem por e.key. O site sai em russo, e um jogador num teclado cirílico deve conseguir premir a tecla marcada com a letra certa. Quinze ficheiros do Lab seguem hoje a convenção.
Cada jogo tem a sua fronteira de erro, com chave pelo slug, para que uma falha num jogo não possa transbordar para o seguinte que o visitante abrir.
O que a especificação proíbe
A especificação-mestra do Lab 3D é sobretudo uma lista de proibições, que é a metade útil de qualquer especificação. Nenhuma dependência npm nova: o Lab inteiro é three.js 0.184 com React Three Fiber 9.6 e drei 10.7, e mais nada. Nenhum recurso binário: todas as texturas são procedimentais, todos os sons vêm do sintetizador. Todos os ciclos, ouvintes, AudioContext e bloqueios de ponteiro têm de se limpar ao desmontar. Tudo pausa em visibilitychange e ao fim de dois segundos sem foco. O movimento reduzido é respeitado em todo o lado.
O cabeçalho da própria especificação ainda diz nove jogos. Escrevemo-la em junho, lançámos mais três num só dia e nunca atualizámos a contagem — que é o destino comum de todos os documentos que não são executáveis. As restrições aguentaram-se na mesma, porque são impostas pela revisão e pela compilação, e não pela frase lá no topo.
Eis a economia, já que era essa a afirmação. O décimo terceiro jogo chegou como um diretório próprio de cerca de cinquenta ficheiros, mais edições a quatro coisas partilhadas: o registo, o mapa de cartazes, os seis catálogos de idioma e o resumo do site legível por máquinas. Nenhuma dependência nova. Nenhuma alteração aos níveis de qualidade, ao sintetizador, ao contrato de ecrã inteiro ou à montra. Herdou uma tecla de silêncio, um trinco de dispositivo, uma cadeia de recurso a cartazes e uma convenção de teclado em que nunca teve de pensar, e gastou todas as linhas da sua própria complexidade na única parte que ninguém tinha construído antes.
É esse todo o retorno de uma camada partilhada: não que ela faça jogos, mas que faça as partes de um jogo que ninguém gostaria de escrever duas vezes. As sete proteções de perda de contexto por jogo continuam na lista, e vão lá ficar até um navegador deitar fora um contexto a alguém que estava a ganhar.