Guia do Desenvolvedor

Tudo que você precisa pra colocar um jogo HTML5/JS no ar aqui, explicado direto ao ponto e com exemplo de código em cada parte. Os números (limites de tamanho, tempo, etc.) vêm direto da configuração do servidor — se mudarem, esta página muda sozinha junto.

📄 Baixar este guia em Markdown (.md) — cole direto numa IA (referência de API e exemplos completos; sem o simulador interativo, que só faz sentido no navegador)

Resumo em 4 passos

  1. Seu jogo é um index.html (+ CSS/JS/imagens/áudio junto) que funciona sozinho, sem depender de internet.
  2. Ele conversa com a plataforma (placar, salvar progresso, etc.) chamando funções do GameSDK — veja a seção 3.
  3. Você zipa tudo com o index.html na raiz e envia pelo formulário do seu painel de dev.
  4. Você valida, testa e solicita a aprovação — um admin aprova e o jogo aparece no catálogo.

1. O que seu jogo pode e não pode fazer

Seu jogo roda dentro de um "cercadinho" (<iframe sandbox>) — é uma proteção de segurança, não uma escolha arbitrária: sem isso, um jogo malicioso poderia roubar a sessão de quem está jogando. Na prática, isso significa:

  • Nada de salvar direto no navegador. localStorage, cookies, indexedDB — nenhum funciona aí dentro. Pra salvar progresso, use GameSDK.save() (seção 3).
  • Nada de fetch/XMLHttpRequest pra sua própria API. Mesmo se for pro domínio da própria plataforma, não funciona. Toda comunicação com o servidor passa pelo GameSDK.
  • Nada de recurso externo. Sem CDN, sem fonte do Google Fonts, sem script de analytics — só o que estiver dentro do seu zip.
  • Nada de popup, redirecionar a página, ou formulário.
  • Seu jogo precisa se adaptar ao tamanho da tela (width:100%; height:100%, escutar resize se usar <canvas>) — a plataforma decide o tamanho do espaço, não você.

Regra prática: se o seu jogo já roda offline, com um duplo-clique no index.html, sem precisar de internet — ele já está 90% pronto pra cá. O resto é só plugar o GameSDK.

2. O arquivo .zip que você envia

  • index.html obrigatoriamente na raiz do zip (não numa subpasta).
  • Extensões permitidas: .html .htm .css .js .json .png .jpg .jpeg .gif .webp .svg .mp3 .ogg .m4a .woff .woff2 — qualquer outra é rejeitada.
  • Sem symlink, sem ../ (zip-slip), sem pasta aninhada além de 6 níveis.
  • Zip comprimido: até 50MB.
  • Conteúdo descomprimido: até 200MB.
  • Cota por conta de dev: 200MB por padrão, somando todos os jogos publicados — o admin pode ajustar por dev, e o uso atual aparece no seu painel ("Armazenamento da sua conta").
  • Imagens passam por reencode automático no servidor (decodifica e regrava) — camada extra contra arquivo-polígloto.

3. GameSDK — como seu jogo fala com a plataforma

Cole essa linha antes do seu script principal:

<script src="/sdk/game-sdk.js?v=0480a806a6"></script>

O ?v=... no final é opcional (o site funciona sem ele), mas com ele o navegador de quem joga pode guardar o arquivo em cache com segurança em vez de baixar de novo toda hora.

Cache dos seus arquivos (script.js, style.css, imagens)

A plataforma adiciona ?v=... automático só na URL do index.html (o iframe). Os arquivos que o seu index.html referencia por dentro (ex: assets/script.js, assets/style.css) não ganham esse parâmetro — então o navegador pode continuar servindo a versão antiga em cache depois de você enviar uma versão nova.

Regra: ao mudar o jogo, suba um ?v= nos assets internos do index.html (ex: assets/script.js?v=2 → ?v=3). Sem isso, quem já jogou pode ver a versão antiga.

Importante: o botão "Mais uma partida"/reiniciar recarrega o iframe com a mesma URL — não muda o ?v=. Pra ver uma versão nova, recarregue a página do jogo (/jogar/slug, F5).

Isso cria um objeto global chamado GameSDK — é ele quem você vai chamar sempre que precisar falar com a plataforma. Pode chamar as funções dele direto, sem esperar nenhum "pronto" antes: o próprio SDK segura a chamada internamente até a conexão com a página estar pronta.

Duas coisas que valem pra todas as funções do GameSDK, não só uma específica: (1) toda chamada tem um limite de 10 segundos pra plataforma responder — se estourar, a Promise rejeita com "Tempo esgotado esperando resposta de ..." (trate isso no .catch() igual trataria qualquer outro erro). (2) Qualquer erro de JS não tratado no seu jogo (throw sem catch, Promise rejeitada sem .catch()) já é capturado automaticamente pelo SDK e enviado pro painel de debug da plataforma — você não precisa fazer nada pra isso funcionar, só abrir o painel de debug (visível só pra você, dono do jogo, ou admin) na página do seu jogo publicado quando quiser investigar um bug reportado.

Ir direto pra: startSession · addScore · finishSession · getPlayer · getLeaderboard · save/load · onPause/onResume · requireOrientation · onMuteChange · onVolumeChange · onRestart · listGameFiles

GameSDK.startSession()

Quando chamar: assim que uma partida começa. Cenário real: o jogador clicou em "Jogar" e a fase carregou — é aqui que você avisa a plataforma "a partir de agora, conte os pontos".

await GameSDK.startSession();

Repare no await: essa chamada não retorna na hora. Ela fica esperando por dentro até duas coisas acontecerem — (1) a plataforma revelar seu jogo pro jogador (o que só acontece depois do clique em "Jogar" lá fora, na capa) e (2) o servidor realmente criar a sessão da partida no banco e confirmar. Por causa disso, você pode chamar GameSDK.startSession() bem cedo — direto no DOMContentLoaded, por exemplo, sem esperar nenhum clique dentro do seu próprio jogo — que não tem problema nenhum: a própria chamada só "destrava" no momento certo. Quando o await passa da linha, é garantido que existe uma sessão válida no servidor e que o jogo já está visível.

document.addEventListener('DOMContentLoaded', async function () { // pode preparar tudo aqui: carregar imagem, montar canvas, etc. await GameSDK.startSession(); // só passa daqui quando o jogador já clicou em "Jogar" iniciarLoopDoJogo(); // a partir daqui a partida realmente começou });

GameSDK.addScore(valor)

Quando chamar: toda vez que o jogador ganha pontos. Cenário real: o personagem pegou uma moeda que vale 10 pontos — chame addScore(10) na hora. Importante: valor é sempre o que ganhou AGORA, nunca o placar acumulado (isso o servidor calcula sozinho — veja a seção 4 do porquê). valor precisa ser um número inteiro positivo — o servidor rejeita valor fracionado (ex: addScore(2.5)).

var res = await GameSDK.addScore(10); console.log(res.accumulatedScore); // o total real, já somado pelo servidor

GameSDK.finishSession(stats)

Quando chamar: quando a partida acaba (o jogador perdeu, ou completou o jogo). O placar que aparece na tela final é sempre o que o servidor já somou — você não manda um número aqui, só o encerra. stats é opcional: até 8 { label, value } pra mostrar numa tabelinha "Resultado final" antes do placar — e essas mesmas linhas ficam salvas junto com o placar, aparecendo na setinha expansível de cada linha do ranking público. Não afetam o cálculo de nada (o placar continua sendo só a soma do servidor via addScore), são um resumo de exibição que o próprio jogador pode revisar depois.

Chamar finishSession() consome a sessão daquela partida — depois disso, qualquer addScore() tentando usar a mesma sessão é rejeitado pelo servidor (erro "sessão já finalizada"). Não existe um "reabrir" a mesma sessão: pra próxima partida, chame GameSDK.startSession() de novo, que cria uma sessão nova do zero.

var res = await GameSDK.finishSession([ { label: 'Ouro ganho', value: totalGold }, { label: 'Inimigos derrotados', value: kills }, { label: 'Ondas resistidas', value: wave }, ]); // A própria plataforma já mostra "Resultado final" + placar na tela de fim // de jogo, fora do seu iframe — você não precisa (e nem consegue: alert()/ // confirm()/prompt() não funcionam no sandbox) desenhar essa tela. Os stats // também ficam salvos e aparecem na setinha expansível de cada linha do // ranking público, pra quem quiser ver o detalhe depois.

E pra próxima partida, você não precisa chamar startSession() na mão logo em seguida. Na tela de fim de jogo, o botão "Mais uma partida" é da plataforma — ao clicar nele, ela recarrega seu index.html inteiro do zero (tipo um F5 só no seu jogo). Isso já faz o seu script rodar de novo desde o início, o DOMContentLoaded dispara de novo, e o startSession() que você já colocou ali chama de novo sozinho — sem nenhum código extra da sua parte. Só precisaria chamar startSession() manualmente de novo se o seu PRÓPRIO jogo tiver uma tela interna de "jogar de novo" que reseta o estado sem recarregar a página.

GameSDK.getPlayer()

Quando chamar: quando quiser personalizar a UI do seu jogo com quem está jogando. Cenário real: mostrar "Olá, Fulano!" numa tela de menu, ou mudar a mensagem quando o jogador já esteve ali antes.

var player = await GameSDK.getPlayer(); if (player) { welcomeEl.textContent = player.hasPlayedBefore ? 'Bem-vindo de volta, ' + player.displayName + '!' : 'Olá, ' + player.displayName + '!'; }

Devolve { displayName, avatarUrl, isGuest, hasPlayedBefore }, ou null se por algum motivo não der pra identificar quem está jogando. hasPlayedBefore é true quando esse jogador já terminou uma partida desse jogo alguma vez antes (não conta só ter aberto e fechado). De propósito não devolve e-mail nem o id da conta — seu jogo é código de terceiro rodando isolado, só recebe o mínimo necessário pra personalizar a tela.

GameSDK.getLeaderboard()

Quando chamar: se quiser mostrar o ranking DENTRO do seu jogo (ex: uma tela de "Top 10" no menu). Devolve os mesmos 50 melhores placares que já aparecem na página pública do jogo.

var scores = await GameSDK.getLeaderboard(); // [{ player_name, score, details, created_at }, ...] // details é o array de stats do finishSession (pode ser [])

GameSDK.save(dados) / GameSDK.load()

Quando chamar: pra salvar progresso entre uma sessão e outra (fases desbloqueadas, moedas guardadas, etc). É um espaço de JSON livre, até 256KB, por jogador — salvar de novo substitui o anterior por inteiro, então mande sempre o objeto completo. load() devolve undefined se o jogador nunca salvou nada ainda.

// salvando progresso await GameSDK.save({ level: 3, coins: 120, unlockedSkins: ['gold', 'ice'] }); // carregando de novo (ex: quando o jogo abre) var data = await GameSDK.load(); if (data) { level = data.level; coins = data.coins; }

GameSDK.onPause(fn) / GameSDK.onResume(fn)

Esses dois funcionam ao contrário dos outros: você não CHAMA onPause/onResume, você REGISTRA uma função pra a plataforma chamar quando precisar. Isso acontece quando o jogador sai da tela cheia sem querer (botão físico do celular, gesto do sistema) — a plataforma pausa a experiência e avisa seu jogo pra ele também pausar o loop/áudio. Registre os dois uma vez, logo no início do jogo.

GameSDK.onPause(function () { engine.pause(); music.pause(); }); GameSDK.onResume(function () { engine.resume(); music.play(); });

GameSDK.requireOrientation(orientation)

Quando chamar: uma vez, no início, se seu jogo só faz sentido numa orientação de tela. Cenário real: um jogo tipo tower defense que só funciona em paisagem — chame requireOrientation('landscape') e a própria plataforma cuida de travar a tela (quando o navegador permite) ou bloquear com um aviso pra girar o celular (quando não permite, como no iPhone).

GameSDK.requireOrientation('landscape'); // ou 'portrait'

GameSDK.onMuteChange(fn)

A barra lateral do jogador tem um botão de "Som" — a plataforma não tem áudio próprio pra mutar (quem toca som é o seu jogo), então ela só avisa quando o jogador aperta o botão: fn(true) = mutar, fn(false) = religar. Se você não registrar nada, o botão continua aparecendo mas não faz nada no seu jogo.

GameSDK.onMuteChange(function (muted) { bgMusic.muted = muted; sfx.muted = muted; });

GameSDK.onVolumeChange(fn)

Na popup de som (botão "Som" da barra lateral) o jogador tem três sliders: Principal, Efeitos e Música de fundo, cada um de 0 a 100. A plataforma entrega os três pro seu jogo; ele aplica onde fizer sentido (os sliders já vêm no estado salvo, e toda mudança dispara o handler). Se o seu jogo só tem um som geral, use onMuteChange/onVolumeChange com o master.

GameSDK.onVolumeChange(function (vol) { // vol = { master: 0-100, effects: 0-100, music: 0-100 } bgMusic.volume = (vol.music / 100) * (vol.master / 100); sfx.volume = (vol.effects / 100) * (vol.master / 100); });

Dica: a plataforma também muta automaticamente ao sair da tela cheia (e desmuta ao voltar, respeitando sua preferência) — o onMuteChange recebe esse estado igual recebe o do botão.

GameSDK.onRestart(fn)

No fim da partida, o jogador pode clicar em "Mais uma partida" (botão da plataforma na tela de fim de jogo). Por padrão, isso recarrega o seu index.html inteiro — o jogo roda do zero. Se você preferir um retorno mais rápido e sem "piscada", registre onRestart: a plataforma então só avisa o seu jogo pra reiniciar a partida internamente (reset do estado/loop), sem recarregar a página. Retrocompatível: se o jogo NÃO registrar (SDK/jogo antigo), a plataforma recarrega como sempre funcionou.

GameSDK.onRestart(function () { // reseta o estado da partida sem recarregar a página score = 0; stage = 1; engine.reset(); // seu método de reinício document.getElementById('menu').style.display = 'none'; syncUI(); });

Decisão fica 100% com o dev: se o seu jogo precisa passar pelo menu de início de novo (tela de "Jogar") a cada reinício, NÃO registre onRestart e a plataforma faz o reload normal — o fluxo todo rodará de novo.

GameSDK.listGameFiles(folder, extensions?)

Lista os nomes de arquivo de uma subpasta do próprio conteúdo do seu jogo (a mesma pasta que foi dentro do .zip que você enviou). Cenário real: um jogo com vários mapas/fases em arquivos separados usa isso pra montar o seletor de mapa no lobby sem precisar hardcodar a lista de nomes no código — só solta um arquivo novo na pasta e ele já aparece.

GameSDK.listGameFiles('maps').then(function (files) { // files = ['fase1.json', 'fase2.json', ...] }); // extensions é OPCIONAL — filtra ainda mais, em cima da whitelist de // segurança (nunca no lugar dela). Útil quando a mesma pasta mistura tipos. GameSDK.listGameFiles('assets', ['.json']).then(function (files) { ... });

Só devolve o NOME do arquivo — não o conteúdo (isso é um fetch normal na URL pública do asset). Só aparece arquivo com extensão da mesma whitelist aplicada na validação do upload: .html .htm .css .js .json .png .jpg .jpeg .gif .webp .svg .mp3 .ogg .m4a .woff .woff2. Pasta vazia ou inexistente devolve [], nunca erro.

4. A regra mais importante: nunca envie um placar pronto

Repara que não existe um GameSDK.setScore(numero) — isso é de propósito. O placar final que fica salvo é sempre a soma que o próprio servidor calculou, incremento a incremento, a partir de cada GameSDK.addScore(valor) que seu jogo chamou durante a partida. Mesmo que alguém abra o DevTools e tente "trapacear", não tem uma função pra simplesmente declarar "meu placar é 999999" — ela não existe.

  • Chame addScore(valor) a cada ganho de pontos real, na hora que acontece — nunca guarde tudo numa variável local pra mandar de uma vez só no final.
  • Cada chamada de addScore tem um teto de sanidade por evento (100.000 por padrão; o admin pode liberar outro valor se a mecânica do seu jogo precisar). Por quê, se o placar final já é a soma que o servidor calcula (não dá pra "declarar" um número final)? Porque mesmo sem conseguir forjar o total direto, um jogo malicioso ainda poderia inflar a soma chamando addScore(999999999) uma vez só — o teto fecha exatamente essa brecha.
  • Existe um limite de quantas chamadas de addScore a sua partida pode disparar numa janela de tempo — calibrável por jogo na aba de gerenciar ("Placar & anti-cheat"), de acordo com a velocidade da sua mecânica. Regra geral: envie um incremento por ponto real conquistado, na hora que ele acontece.
  • A partida tem uma duração mínima entre o início e o fim da sessão — partida relâmpago é rejeitada. Num jogo normal você nunca vai bater nesse limite.
  • Trate rejeição com .catch() — não deixe a Promise sem tratamento.
  • Placares muito acima do normal entram numa fila de revisão no seu painel (você aprova ou rejeita cada um); se não revisar dentro do prazo configurado, são publicados sozinhos. Enquanto pendente, o placar não aparece no ranking.

Cenário real: você já atualizou a pontuação na tela na hora (otimista, sem esperar o servidor) — se o addScore voltar rejeitado (bateu no teto, ou no rate limit), desfaça esse incremento visual, senão a tela mostra um número que o servidor nunca aceitou de verdade:

try { var res = await GameSDK.addScore(10); score = res.accumulatedScore; // servidor manda — sincroniza com o total real scoreEl.textContent = score + ' pontos'; } catch (err) { score -= 10; // evento rejeitado — desfaz o incremento otimista que já tinha mostrado scoreEl.textContent = score + ' pontos'; }

5. Boas práticas de UI dentro do jogo

Seu jogo trata clique, toque e teclado normalmente — o sandbox não bloqueia nenhum evento de input, só bloqueia acesso a coisas como localStorage e fetch (ver seção 1). Pode usar pointerdown/keydown/etc à vontade.

  • Layout responsivo a 100% do iframe, não pixels fixos pensando em desktop.
  • Use pointerdown/pointerup (funciona pra mouse e toque), não só click.
  • touch-action: none no CSS evita que o navegador tente rolar/dar zoom durante o toque.
  • Não dependa só de teclado — boa parte de quem joga está no celular.
  • Fontes: use fontes do sistema ou arquivos .woff/.woff2 embutidos no zip — nunca CDN externo.
  • Bloqueie o menu de botão direito do navegador (ele atrapalha jogos de mouse) com contextmenu + preventDefault(). E se você usa pointerdown pra construir/selecionar coisas no jogo, cuidado: ele dispara pra QUALQUER botão do mouse, não só o esquerdo — sem checar e.button, o botão direito também vai acionar a ação, mesmo com o menu nativo bloqueado.
  • contextmenu só bloqueia o menu nativo de botão direito — no celular, um long-press ainda aciona a SELEÇÃO DE TEXTO do sistema (lupa/highlight azul, ou o menu "Copiar" do iOS) em qualquer texto clicável, tipo o "+" de um botão de construir. Isso é CSS, não dá pra resolver via GameSDK: coloque user-select: none (+ os prefixos) no html, body.
document.addEventListener('contextmenu', function (e) { e.preventDefault(); }); canvas.addEventListener('pointerdown', function (e) { if (e.button > 0) return; // ignora botão direito/do meio do mouse (toque e clique esquerdo têm button 0) // ... sua lógica de clique/toque aqui });
html, body { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; /* desliga o menu "Copiar/Selecionar" do iOS especificamente */ }

6. Metadados do cadastro (fora do zip)

  • Título (2–100 caracteres)
  • Descrição (até 500 caracteres, opcional)
  • Gênero — texto livre; sugestões: Arcade, Tower Defense, RPG, Puzzle, Corrida, Simulação, Terror, Esporte, Estratégia, Plataforma, Outro
  • Ícone (obrigatório) — imagem quadrada, recomendado 512×512. Usada em qualquer lugar pequeno que precise representar o jogo (ex: fileira de ícones no Top 10 jogadores). Dá pra trocar depois no painel de gerenciar.
  • 1 a 3 screenshots (obrigatório) — recomendado 1280×800, formato paisagem (16:10); é como aparecem na página do jogo.
  • Mini banner / capa (opcional, configurável depois no painel de gerenciar) — usado no destaque da home e nos cards do catálogo, nunca na própria página do jogo. Duas variantes, porque o mesmo recorte que fica bom numa faixa larga de desktop corta o assunto principal numa tela de celular (a altura é sempre 350px, só a largura muda demais entre os dois):
    • Desktop — recomendado 1600×400 (bem largo).
    • Mobile (opcional) — recomendado 700×600 (mais próximo de quadrado). Sem uma definida, o celular usa a imagem Desktop mesmo — só vale a pena enviar se o recorte largo estiver cortando algo importante.

Ícone, capa (nas duas variantes) e screenshots passam pelo mesmo reencode automático (PNG/JPG → WebP) citado na seção 2.

7. O que acontece depois do envio

  1. Validação automática (fila): zip-slip, extensões, referência a domínio externo, antivírus, reencode de imagem — qualquer falha é logada com o motivo específico, visível pro dev.
  2. Se passar, a versão fica validada pra teste (ainda não publicada). Use o link Testar pra conferir a versão pendente antes de mandar pra aprovação.
  3. Quando estiver satisfeito, clique em "Solicitar aprovação" — só aí o jogo entra na fila de aprovação manual de um admin.
  4. Só depois de aprovado ele aparece (ou atualiza) no catálogo público.

A conta que envia precisa ter e-mail confirmado e papel de dev (ou admin) — conta comum não publica jogo.

8. Exemplo completo — jogo de pular obstáculo

Um mini-jogo de verdade, jogável: um personagem correndo, um obstáculo vindo — toque, clique ou aperte espaço pra pular. Cada obstáculo que você pula soma 1 ponto; se bater num, a partida acaba. Ele usa só os três métodos centrais do GameSDK (startSession, addScore, finishSession) — os outros (save/load, pause/resume, orientação, som) têm exemplo próprio na seção 3, junto da regra de cada um.

Abre um simulador — o mesmo jogo rodando aqui na página, mas com um GameSDK de mentira que só mostra na tela quando cada função seria chamada, sem mandar nada pro servidor de verdade. Bom pra entender o fluxo antes de copiar o código abaixo (que aí sim usa o GameSDK real).

O código abaixo é o real — zipe esse index.html sozinho (na raiz do zip) e já está pronto pra enviar pelo formulário.

<!doctype html> <html lang="pt-br"> <head> <meta charset="utf-8"> <title>Pulo do Obstáculo</title> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <style> html, body { margin:0; height:100%; overflow:hidden; background:#0d0e13; touch-action:none; } canvas { display:block; width:100%; height:100%; } #score { position:absolute; top:14px; left:14px; color:#fff; font:700 18px system-ui; } #msg { position:absolute; inset:0; display:none; align-items:center; justify-content:center; flex-direction:column; gap:10px; color:#fff; font:600 16px system-ui; background:rgba(0,0,0,.6); text-align:center; padding:0 20px; } </style> </head> <body> <canvas id="game"></canvas> <div id="score">0 pontos</div> <div id="msg"><p id="msg-text"></p></div> <script src="/sdk/game-sdk.js?v=0480a806a6"></script> <script> var canvas = document.getElementById('game'); var ctx = canvas.getContext('2d'); var scoreEl = document.getElementById('score'); var msgEl = document.getElementById('msg'); var msgText = document.getElementById('msg-text'); // canvas.parentElement (não innerWidth/innerHeight) pra funcionar tanto // no jogo publicado (ocupa a tela toda) quanto encolhido dentro do // simulador (onde o painel de log divide o espaço). function resizeCanvas() { canvas.width = canvas.parentElement.clientWidth; canvas.height = canvas.parentElement.clientHeight; } addEventListener('resize', function () { resizeCanvas(); if (!playing) draw(); }); var GROUND_Y, player, obstacle, score, playing, waitingToStart, speed; function reset() { GROUND_Y = canvas.height - 90; player = { x: 60, y: GROUND_Y, vy: 0, size: 32, jumping: false }; obstacle = { x: canvas.width, w: 24, h: 30, passed: false }; score = 0; speed = 6; scoreEl.textContent = '0 pontos'; } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#333'; ctx.fillRect(0, GROUND_Y + player.size, canvas.width, 4); ctx.fillStyle = '#6d5efc'; ctx.fillRect(player.x, player.y, player.size, player.size); var obsY = GROUND_Y + player.size - obstacle.h; ctx.fillStyle = '#ef4444'; ctx.fillRect(obstacle.x, obsY, obstacle.w, obstacle.h); } // Um único handler pra clique/toque/espaço: se ainda não começou (ou acabou // de terminar), inicia a partida; se já está jogando, pula. sim-gameover só // existe na versão do simulador — aqui dá null e o "if" abaixo nem entra. function onAction() { var fakeOverlay = document.getElementById('sim-gameover'); if (fakeOverlay && fakeOverlay.style.display === 'flex') return; // só o botão reinicia enquanto essa tela tá aberta if (waitingToStart) { waitingToStart = false; start(); return; } if (playing && !player.jumping) { player.jumping = true; player.vy = -13; } } addEventListener('pointerdown', onAction); addEventListener('keydown', function (e) { if (e.code !== 'Space') return; e.preventDefault(); // sem isso o navegador rola a página com espaço mesmo com o jogo focado onAction(); }); async function start() { reset(); msgEl.style.display = 'none'; try { await GameSDK.startSession(); playing = true; requestAnimationFrame(loop); } catch (err) { console.error('Erro ao iniciar sessão:', err.message); } } async function gameOver() { playing = false; waitingToStart = true; try { // stats é só pra mostrar na tela de fim de jogo — não afeta o placar real var res = await GameSDK.finishSession([{ label: 'Obstáculos pulados', value: score }]); msgText.textContent = 'Fim de jogo! Placar final: ' + res.score + ' — toque, clique ou espaço pra jogar de novo'; } catch (err) { msgText.textContent = 'Fim de jogo! — toque, clique ou espaço pra jogar de novo'; } msgEl.style.display = 'flex'; draw(); } function loop() { if (!playing) return; if (player.jumping) { player.y += player.vy; player.vy += 0.7; // gravidade if (player.y >= GROUND_Y) { player.y = GROUND_Y; player.jumping = false; player.vy = 0; } } obstacle.x -= speed; if (obstacle.x + obstacle.w < 0) { obstacle.x = canvas.width + Math.random() * 200; obstacle.passed = false; } // passou do obstáculo sem colidir = ganhou ponto if (!obstacle.passed && obstacle.x + obstacle.w < player.x) { obstacle.passed = true; score += 1; scoreEl.textContent = score + ' pontos'; GameSDK.addScore(1).catch(function () {}); // um incremento por pulo, nunca o total } var obsY = GROUND_Y + player.size - obstacle.h; var hit = player.x < obstacle.x + obstacle.w && player.x + player.size > obstacle.x && player.y < obsY + obstacle.h && player.y + player.size > obsY; if (hit) { gameOver(); return; } draw(); requestAnimationFrame(loop); } document.addEventListener('DOMContentLoaded', function () { resizeCanvas(); reset(); waitingToStart = true; msgText.textContent = 'Aperte espaço, clique ou toque pra pular o obstáculo. Não deixe ele te encostar!'; msgEl.style.display = 'flex'; draw(); }); </script> </body> </html>

O que cada parte faz (a explicação detalhada de cada método está na seção 3 — isso aqui é só o resumo aplicado a ESTE exemplo específico):

  1. O jogo carrega, o DOMContentLoaded dispara e chama GameSDK.startSession() na hora — não precisa esperar clique nenhum dentro do próprio jogo (a plataforma já cuida disso por fora, ver seção 3).
  2. Assim que a sessão é confirmada, playing = true e o loop do jogo começa: o personagem fica parado no chão, o obstáculo anda da direita pra esquerda.
  3. Toque/clique/espaço chamam jump(), que só aplica uma física simples (velocidade pra cima + gravidade puxando de volta) — nada de SDK envolvido aqui, é local mesmo.
  4. Toda vez que o obstáculo passa pelo personagem sem colisão, é um pulo bem-sucedido: soma 1 ponto na tela E chama GameSDK.addScore(1) — um incremento, nunca o total (seção 4 explica o porquê).
  5. Se o personagem colide com o obstáculo (não pulou a tempo), a partida acaba: chama GameSDK.finishSession(stats) passando quantos obstáculos foram pulados como estatística de exibição, e mostra o placar final que o servidor devolveu.

9. Erros comuns que rejeitam o jogo ou quebram em produção

  • index.html dentro de uma subpasta em vez de na raiz do zip.
  • Usar fetch/XMLHttpRequest esperando falar com sua própria API — sempre falha dentro do sandbox. Use o GameSDK.
  • Tentar localStorage.setItem(...) pra salvar progresso — não persiste (origem opaca). Use GameSDK.save()/GameSDK.load().
  • Carregar lib de CDN — bloqueado pelo CSP. Baixe e inclua o arquivo dentro do zip.
  • Mandar o placar final pronto — não existe essa função de propósito (releia a seção 4).
  • Zip com extensão fora da lista permitida (ex: .ttf em vez de .woff, ou .mp4) — converta antes de enviar.

10. Multiplayer em tempo real

Dá pra fazer jogo com mais de um jogador na mesma partida, ao vivo — mesma ponte do resto do GameSDK (postMessage pra fora do sandbox), sem servidor próprio nenhum pra você rodar ou manter. A plataforma cuida da conexão (WebSocket), de quem está em qual sala, e de repassar o que um jogador manda pros outros da mesma sala. O que seu jogo faz com essa troca é 100% seu.

Sala — criar, entrar, saber quem chegou

Quando chamar: no seu menu, antes da partida começar. Uma sala tem no máximo 4 jogadores; quem cria vira o "dono" (host) — só ele pode iniciar a partida ou adicionar bot (bot é um caso específico, ver abaixo).

var myId, isHost, roomCode; // dono cria a sala GameSDK.createRoom().then(function (res) { myId = res.yourId; // seu próprio id NESTA sala — guarde, você vai precisar roomCode = res.roomCode; codeDisplay.textContent = roomCode; // mostra pro dono compartilhar com os amigos }); // os outros entram com o código (a plataforma normaliza maiúsculas sozinha) GameSDK.joinRoom(inputCode.value).then(function (res) { myId = res.yourId; }).catch(function (err) { errorEl.textContent = err.message; // "Sala não encontrada.", "Sala cheia.", "Esta sala já começou." });

Sempre registre onRoomUpdate — é como você sabe quem está na sala agora, e é ele quem avisa quando o dono inicia a partida:

GameSDK.onRoomUpdate(function (players, status, hostUserId) { // players = [{ id, displayName }, ...] — todo mundo que está na sala AGORA isHost = String(hostUserId) === String(myId); renderPlayerList(players); if (isHost) startBtn.style.display = (players.length >= 2) ? 'inline-block' : 'none'; if (status === 'started') beginMatch(players); // dono chamou startRoom() — todo mundo recebe isso junto }); startBtn.addEventListener('click', function () { GameSDK.startRoom(); // só o dono pode chamar — status vira 'started' pra todo mundo });

GameSDK.leaveRoom() tira o jogador da sala atual (os demais recebem onRoomUpdate na hora). GameSDK.addBot() também existe — hoje ele só adiciona um jogador de treino de verdade se o SEU jogo implementar a inteligência dele do lado do servidor da plataforma (não é algo genérico pronto pra qualquer jogo usar ainda — ver nota no fim desta seção).

Trocando eventos entre jogadores — o coração do multiplayer

Quando chamar: a cada vez que algo no SEU jogo precisa aparecer pros outros jogadores da sala — posição de um personagem/bola, uma jogada, um placar parcial, o que for. É o único jeito de jogadores se "verem" em tempo real: a plataforma só entrega mensagens (repassa pra sala inteira), nunca decide o que elas significam — isso é 100% regra do seu jogo.

// manda pros OUTROS jogadores da sala (nunca chega de volta pra quem mandou) GameSDK.sendRoomEvent({ x: player.x, y: player.y, angle: player.angle }).catch(function () {}); // recebe o que os outros mandaram GameSDK.onRoomEvent(function (data, fromPlayerId, fromDisplayName) { var other = remotePlayers[fromPlayerId] || (remotePlayers[fromPlayerId] = {}); other.x = data.x; other.y = data.y; other.angle = data.angle; });

Duas coisas importantes: data precisa ser serializável em JSON e pequeno (até 2KB); e existe um limite de frequência de envio (hoje, 20 eventos por segundo) — a plataforma REJEITA (a Promise rejeita) se você mandar rápido demais, e desconecta o jogador se isso se repetir muitas vezes seguidas. Pra posição/estado que muda o tempo todo, manda num intervalo fixo bem abaixo desse teto (ex: a cada 70ms, uns 14 por segundo) em vez de a cada frame do seu loop ou de mandar bem em cima do limite — mandar mais rápido não deixa o jogo mais suave, só desperdiça banda; e mandar exatamente no limite (ex: 20/s cravado) arrisca estourar por qualquer variação de timing do navegador, derrubando a conexão bem no meio da partida. Deixe uma folga de propósito, e reserve parte dela pra eventos raros (ex: "marcou ponto") que sua taxa constante ainda não usou nesse instante.

setInterval(function () { GameSDK.sendRoomEvent({ x: me.x, y: me.y, angle: me.angle }).catch(function () {}); }, 70);

fromPlayerId/fromDisplayName vêm carimbados pelo SERVIDOR — nunca confie num "de quem é" que viesse dentro do próprio data, um jogo malicioso poderia declarar ser outra pessoa. Também não existe garantia de entrega (é fire-and-forget do lado de quem manda) — pra algo que TEM que chegar (ex: "a partida acabou, fulano venceu"), pense num protocolo simples de confirmação no seu próprio data, ou trate como estado que se corrige sozinho no próximo envio (o normal pra posição/animação). O exemplo de Mesa de Ar mais abaixo mostra esse cuidado na prática.

Simulação decidida pelo servidor — ainda não é genérico

Existe um outro grupo de funções no GameSDK (sendArenaInput, sendArenaAction, sendArenaAttack, sendArenaTrap, sendArenaReady, onArenaInit, onArenaState, onArenaEvent) pensado pra jogos onde o SERVIDOR decide o resultado (colisão, quem pegou o quê primeiro) em vez de confiar no que cada jogador manda — útil contra trapaça em jogos competitivos. Hoje essas funções rodam a lógica de UM jogo específico da plataforma (mapa, baú, chave, espada) — não é um framework genérico ainda que aceite as regras do SEU jogo. Se seu jogo precisa desse tipo de simulação autoritativa, o caminho por enquanto é sendRoomEvent/onRoomEvent com um dos jogadores (o dono da sala, por exemplo) fazendo esse papel — é exatamente o que o exemplo de mesa de ar abaixo faz.

Exemplo completo: Mesa de Ar (air hockey) de 2 jogadores

Um jogo de verdade, ponta a ponta — sala, lobby, e a partida em si — só com sendRoomEvent/onRoomEvent (a camada genérica de verdade, ver acima). Cada jogador arrasta a PRÓPRIA peça (mallet) livremente dentro da sua metade da mesa; o disco (puck) é decidido por UM dos dois (o dono da sala) e também transmitido — o outro cliente só desenha o que recebe e faz a própria colisão contra a peça do adversário localmente. Simples, funciona bem pra jogo casual entre amigos; não é à prova de trapaça (o dono poderia mentir sobre o disco), o que não é o objetivo aqui.

Abre dois "jogadores" lado a lado NA MESMA tela, cada um num iframe separado — um GameSDK de mentira faz o papel do servidor entre os dois (mesma forma de mensagem do de verdade), sem precisar de duas contas nem duas abas. Mande criar sala num lado e entrar com o código no outro pra ver o fluxo inteiro rodando ao vivo. Atenção: este simulador não aplica o limite de frequência de envio que o servidor de verdade aplica — ele só serve pra ver o FLUXO da partida, não pra validar se o ritmo de rede está seguro (isso só se confirma jogando de verdade, com o servidor real).

O código abaixo é o real — zipe esse index.html sozinho (na raiz do zip) e já está pronto pra enviar. Pra testar multiplayer DE VERDADE (não o simulador), precisa de duas sessões reais — publique e abra em duas abas/dispositivos logados como jogadores diferentes.

<!doctype html> <html lang="pt-br"> <head> <meta charset="utf-8"> <title>Mesa de Ar Multiplayer</title> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <style> html, body { margin:0; height:100%; overflow:hidden; background:#0d0e13; touch-action:none; font-family:system-ui,sans-serif; color:#fff; } .screen { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; padding:0 20px; text-align:center; } h1 { margin:0; } input, button { font-size:15px; padding:9px 14px; border-radius:8px; border:0; } button { background:#6d5efc; color:#fff; cursor:pointer; } input { text-align:center; text-transform:uppercase; width:110px; } .menu-join-row { display:flex; gap:8px; } .error-text { color:#ef4444; min-height:1.2em; font-size:13px; } #room-code { font-size:32px; font-weight:800; letter-spacing:4px; } #table-wrap { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; pointer-events:none; } canvas { display:block; pointer-events:auto; } #score { position:absolute; top:14px; left:50%; transform:translateX(-50%); font:700 26px system-ui; } #msg { position:absolute; inset:0; display:none; align-items:center; justify-content:center; color:#fff; font:600 16px system-ui; background:rgba(0,0,0,.65); text-align:center; padding:0 20px; } </style> </head> <body> <div class="screen" id="screen-menu"> <h1>Mesa de Ar</h1> <button id="btn-create">Criar sala</button> <div class="menu-join-row"> <input id="input-code" maxlength="5" placeholder="CÓDIGO"> <button id="btn-join">Entrar</button> </div> <p id="menu-error" class="error-text"></p> </div> <div class="screen" id="screen-lobby" style="display:none"> <p>Código da sala</p> <div id="room-code"></div> <p id="lobby-status">Aguardando o outro jogador entrar...</p> <button id="btn-start" style="display:none">Iniciar partida</button> </div> <div id="table-wrap"><canvas id="game" style="display:none"></canvas></div> <div id="score" style="display:none">0 — 0</div> <div id="msg"><p id="msg-text"></p></div> <script src="/sdk/game-sdk.js?v=0480a806a6"></script> <script> var myId, isHost, roomCode; var canvas = document.getElementById('game'); var ctx = canvas.getContext('2d'); var TABLE_W = 100, TABLE_H = 60; function resizeCanvas() { var parent = canvas.parentElement; var scale = Math.min(parent.clientWidth / TABLE_W, parent.clientHeight / TABLE_H); canvas.width = TABLE_W * scale; canvas.height = TABLE_H * scale; } addEventListener('resize', resizeCanvas); function showScreen(id) { ['screen-menu', 'screen-lobby'].forEach(function (s) { document.getElementById(s).style.display = 'none'; }); document.getElementById(id).style.display = 'flex'; } document.getElementById('btn-create').addEventListener('click', function () { GameSDK.createRoom().then(function (res) { myId = res.yourId; roomCode = res.roomCode; isHost = true; document.getElementById('room-code').textContent = roomCode; showScreen('screen-lobby'); }).catch(function (err) { document.getElementById('menu-error').textContent = err.message; }); }); document.getElementById('btn-join').addEventListener('click', function () { var code = document.getElementById('input-code').value.trim(); if (!code) return; GameSDK.joinRoom(code).then(function (res) { myId = res.yourId; roomCode = res.roomCode; isHost = false; document.getElementById('room-code').textContent = roomCode; showScreen('screen-lobby'); }).catch(function (err) { document.getElementById('menu-error').textContent = err.message; }); }); document.getElementById('btn-start').addEventListener('click', function () { GameSDK.startRoom(); }); GameSDK.onRoomUpdate(function (players, status, hostUserId) { isHost = String(hostUserId) === String(myId); if (status === 'waiting') { document.getElementById('lobby-status').textContent = players.length < 2 ? 'Aguardando o outro jogador entrar...' : (isHost ? 'Os dois prontos — pode iniciar!' : 'Aguardando o dono da sala iniciar...'); document.getElementById('btn-start').style.display = (isHost && players.length >= 2) ? 'inline-block' : 'none'; } if (status === 'started') startMatch(); }); // --- partida --- // Placar em termos ABSOLUTOS (hostScore/guestScore, nunca "meu/do outro") — // os DOIS clientes guardam os MESMOS dois números, sincronizados pelos // eventos de ponto abaixo, sem precisar de lógica relativa confusa. var WIN_SCORE = 5; var hostScore = 0, guestScore = 0; var MALLET_R = 6, PUCK_R = 3; // unidades virtuais (ver TABLE_W/TABLE_H acima) var GOAL_HALF = 10; // o gol é uma fresta de GOAL_HALF*2 unidades, centrada na altura da mesa var MAX_SPEED = 1.6, HIT_MIN_SPEED = 0.7; // Host fica preso na metade ESQUERDA da mesa, convidado na DIREITA — mallet // (peça) se move livre em x/y dentro da própria metade, bem diferente da // raquete só-vertical do Pong. myX/myY é a PRÓPRIA peça; otherX/otherY é a // do adversário (só o que chega pela rede). var myX = 25, myY = 30, otherX = 75, otherY = 30; var puck = { x: 50, y: 30, vx: 0.6, vy: 0.3 }; // só o DONO da sala simula de verdade var playing = false; function startMatch() { showScreen('screen-menu'); document.getElementById('screen-menu').style.display = 'none'; resizeCanvas(); canvas.style.display = 'block'; document.getElementById('score').style.display = 'block'; GameSDK.startSession().catch(function () {}); hostScore = 0; guestScore = 0; playing = true; myX = isHost ? 25 : 75; myY = 30; otherX = isHost ? 75 : 25; otherY = 30; resetPuck(); updateScoreText(); requestAnimationFrame(loop); sendLoop(); } function resetPuck() { puck.x = 50; puck.y = 30; puck.vx = (Math.random() < 0.5 ? -1 : 1) * 0.6; puck.vy = Math.random() * 0.6 - 0.3; } // Controle: arrasta (mouse ou toque) — a peça segue o dedo/cursor livre nas // duas direções, mas travada dentro da PRÓPRIA metade da mesa (não dá pra // invadir o lado do adversário). touch-action:none no CSS evita o // navegador tentando rolar a página ao arrastar. canvas.addEventListener('pointermove', function (e) { if (!playing) return; var rect = canvas.getBoundingClientRect(); var scale = canvas.width / TABLE_W; var px = (e.clientX - rect.left) / scale; var py = (e.clientY - rect.top) / scale; var minX = isHost ? MALLET_R : TABLE_W / 2 + MALLET_R; var maxX = isHost ? TABLE_W / 2 - MALLET_R : TABLE_W - MALLET_R; myX = Math.max(minX, Math.min(maxX, px)); myY = Math.max(MALLET_R, Math.min(TABLE_H - MALLET_R, py)); }); // Manda a PRÓPRIA peça sempre; o dono TAMBÉM manda o disco junto (só ele // simula ele de verdade) — o convidado só desenha o que recebe. ~15/s, de // PROPÓSITO com folga abaixo do limite de 20/s do servidor (ver seção 10) — // mandar bem NO limite (ex: exatos 20/s) arrisca estourar por qualquer // variação de timing do navegador e a plataforma DERRUBAR a conexão depois // de umas poucas violações seguidas. gol é raro (não é por frame), por isso // simplesmente pega carona no próximo envio deste MESMO ritmo em vez de // disparar uma chamada extra fora dele. var pendingPoint = null; function sendLoop() { if (!playing) return; var payload = { mallX: myX, mallY: myY }; if (isHost) { payload.puckX = puck.x; payload.puckY = puck.y; } if (pendingPoint) { payload.point = pendingPoint; pendingPoint = null; } GameSDK.sendRoomEvent(payload).catch(function () {}); setTimeout(sendLoop, 67); } GameSDK.onRoomEvent(function (data) { if (data.mallX !== undefined) { otherX = data.mallX; otherY = data.mallY; } if (!isHost && data.puckX !== undefined) { puck.x = data.puckX; puck.y = data.puckY; } if (data.point === 'host') hostScored(); if (data.point === 'guest') guestScored(); }); // Cada ponto é decidido só pelo DONO (é quem simula o disco) e avisado ao // outro via pendingPoint acima — os dois then chamam a versão local da // função que marcou o ponto, então os dois placares ficam iguais dos dois // lados. addScore só é chamado pelo lado que REALMENTE marcou o ponto — // cada jogador soma só os PRÓPRIOS pontos na própria sessão (ver seção 4). function hostScored() { hostScore++; if (isHost) GameSDK.addScore(1).catch(function () {}); updateScoreText(); checkWin(); resetPuck(); } function guestScored() { guestScore++; if (!isHost) GameSDK.addScore(1).catch(function () {}); updateScoreText(); checkWin(); resetPuck(); } function updateScoreText() { document.getElementById('score').textContent = hostScore + ' — ' + guestScore; } function checkWin() { if (!playing || (hostScore < WIN_SCORE && guestScore < WIN_SCORE)) return; playing = false; var myScore = isHost ? hostScore : guestScore, otherScore = isHost ? guestScore : hostScore; var iWon = myScore > otherScore; // "Seu placar" sozinho (só o que VOCÊ marcou, é o que finishSession devolve — // ver seção 4) deixava quem perdeu sem entender o resultado (viu só "Seu // placar: 1", sem saber que o adversário fez 5 — e o disco parado bem no // gol dele não ajudava a explicar). Mostra o placar final COMPLETO, sempre // "seu — do outro", pra fazer sentido pros dois lados. GameSDK.finishSession([{ label: 'Placar final', value: myScore + ' — ' + otherScore }]).then(function () { document.getElementById('msg-text').textContent = (iWon ? 'Você venceu! ' : 'Você perdeu. ') + 'Placar final: ' + myScore + ' — ' + otherScore; document.getElementById('msg').style.display = 'flex'; }).catch(function () { document.getElementById('msg-text').textContent = (iWon ? 'Você venceu! ' : 'Você perdeu. ') + 'Placar final: ' + myScore + ' — ' + otherScore; document.getElementById('msg').style.display = 'flex'; }); } function loop() { if (isHost && playing) updatePuckPhysics(); draw(); if (playing) requestAnimationFrame(loop); } // SÓ o dono roda isso (ver loop) — o convidado só recebe puck.x/puck.y // prontos via onRoomEvent e desenha. function updatePuckPhysics() { puck.x += puck.vx; puck.y += puck.vy; if (puck.y - PUCK_R <= 0) { puck.y = PUCK_R; puck.vy = Math.abs(puck.vy); } if (puck.y + PUCK_R >= TABLE_H) { puck.y = TABLE_H - PUCK_R; puck.vy = -Math.abs(puck.vy); } var goalTop = TABLE_H / 2 - GOAL_HALF, goalBottom = TABLE_H / 2 + GOAL_HALF; // Só conta gol se o disco passar DENTRO da fresta do gol — fora dela, // quica na parede da ponta da mesa igual as laterais. if (puck.x - PUCK_R <= 0) { if (puck.y > goalTop && puck.y < goalBottom) { pendingPoint = 'guest'; guestScored(); return; } puck.x = PUCK_R; puck.vx = Math.abs(puck.vx); } if (puck.x + PUCK_R >= TABLE_W) { if (puck.y > goalTop && puck.y < goalBottom) { pendingPoint = 'host'; hostScored(); return; } puck.x = TABLE_W - PUCK_R; puck.vx = -Math.abs(puck.vx); } hitMallet(isHost ? myX : otherX, isHost ? myY : otherY); hitMallet(isHost ? otherX : myX, isHost ? otherY : myY); } // Colisão círculo-círculo entre o disco e UMA peça — empurra o disco pra // fora da peça (evita "grudar" nela) e reflete a velocidade na direção do // choque, com um pequeno ganho de velocidade por rebatida (até MAX_SPEED, // senão a partida acelerava pra sempre a cada troca de rebatida). function hitMallet(mx, my) { var dx = puck.x - mx, dy = puck.y - my; var dist = Math.sqrt(dx * dx + dy * dy); var minDist = PUCK_R + MALLET_R; if (dist >= minDist || dist === 0) return; var nx = dx / dist, ny = dy / dist; puck.x = mx + nx * minDist; puck.y = my + ny * minDist; var speed = Math.min(MAX_SPEED, Math.max(HIT_MIN_SPEED, Math.hypot(puck.vx, puck.vy) * 1.08)); puck.vx = nx * speed; puck.vy = ny * speed; } function drawCircle(x, y, r, color) { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } function draw() { var scale = canvas.width / TABLE_W; // === canvas.height / TABLE_H (mesma proporção, ver resizeCanvas) ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#132030'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = 'rgba(255,255,255,.25)'; ctx.beginPath(); ctx.moveTo(canvas.width / 2, 0); ctx.lineTo(canvas.width / 2, canvas.height); ctx.stroke(); var goalTop = TABLE_H / 2 - GOAL_HALF, goalHeight = GOAL_HALF * 2; ctx.fillStyle = '#0d0e13'; ctx.fillRect(0, goalTop * scale, 4 * scale, goalHeight * scale); ctx.fillRect(canvas.width - 4 * scale, goalTop * scale, 4 * scale, goalHeight * scale); var hostX = isHost ? myX : otherX, hostY = isHost ? myY : otherY; var guestX = isHost ? otherX : myX, guestY = isHost ? otherY : myY; drawCircle(hostX * scale, hostY * scale, MALLET_R * scale, '#6d5efc'); drawCircle(guestX * scale, guestY * scale, MALLET_R * scale, '#ff5e7e'); drawCircle(puck.x * scale, puck.y * scale, PUCK_R * scale, '#fff'); } </script> </body> </html>

O que cada parte faz:

  1. Tela de menu: um cria a sala (createRoom) e vira o dono; o outro entra com o código (joinRoom).
  2. onRoomUpdate mostra quem já chegou e libera o botão "Iniciar" pro dono assim que os dois estão na sala.
  3. O dono clica "Iniciar" (startRoom) — os DOIS recebem status:'started' junto e a partida começa pros dois ao mesmo tempo.
  4. A física roda num espaço virtual fixo (100×60), não em pixels de tela — o <canvas> mantém sempre essa mesma proporção (5:3) ao redimensionar, então o disco e as peças (círculos) nunca ficam ovais e a colisão fica correta em qualquer tamanho de tela.
  5. Cada cliente manda a própria peça ~15x/s (sendRoomEvent) — de propósito com folga abaixo do limite de 20/s do servidor (ver seção 10): mandar bem no limite arrisca a plataforma derrubar a conexão por excesso de eventos. O dono manda o disco junto no MESMO envio; onRoomEvent do outro lado atualiza o que recebeu.
  6. Só o dono roda a física do disco (updatePuckPhysics) — quique nas paredes, colisão círculo-círculo com as duas peças (hitMallet), e gol quando o disco cruza a fresta na ponta da mesa. Um gol nunca dispara uma chamada de rede EXTRA — ele "pega carona" no próximo envio do mesmo ritmo fixo (pendingPoint), evitando qualquer risco de estourar o limite de frequência bem no momento mais importante da partida.
  7. Cada lado chama addScore(1) só quando FOI ELE quem marcou. Ao alcançar 5 pontos, cada cliente chama finishSession na PRÓPRIA sessão — é por isso que o placar final de cada um é independente (seção 4 explica o porquê disso ser sempre por sessão).

11. Itens e troca entre jogadores

Inventário genérico por jogador, rastreado pelo SERVIDOR — não é um save comum (save/load guardam um blob livre que só o SEU jogo entende; aqui a plataforma sabe exatamente quanto de cada item cada jogador tem, o que é o que permite trocar item com outro jogador com segurança). Serve tanto pra um jogo single-player que só quer guardar "quantos itens o jogador já ganhou" quanto pra jogos com troca entre jogadores (cartas, colecionáveis, etc).

Inventário — addItem(s) / removeItem(s) / getInventory

Quando chamar addItem: toda vez que o jogador ganhar algo de verdade dentro do seu jogo (abriu um pacote, venceu uma partida, completou uma fase) — nunca a partir de um valor que o próprio cliente "decidiu" sem o servidor conferir nada, já que é o valor acumulado aqui que depois é conferido numa troca.

// itemId é uma string que só o SEU jogo entende — a plataforma não sabe // nem precisa saber o que "carta-charizard" significa, só guarda a // quantidade. quantity é sempre um INCREMENTO (igual addScore), nunca o // total — máximo 50 por chamada. GameSDK.addItem('carta-charizard', 1).then(function (inventory) { // inventory = { 'carta-charizard': 3, 'carta-blastoise': 1, ... } — já // devolve o inventário inteiro atualizado, não precisa chamar getInventory de novo renderInventory(inventory); }); // noutro momento, só pra ler o que o jogador já tem GameSDK.getInventory().then(function (inventory) { renderInventory(inventory); });

Vários itens de uma vez — addItems: abriu um baú e ganhou 3 coisas diferentes? Chame uma vez só com uma lista, em vez de chamar addItem em sequência pra cada item — além de mais simples, gasta só UMA unidade do limite de taxa (60 chamadas a cada 5min) em vez de uma por item.

GameSDK.addItems([ { itemId: 'carta-charizard', quantity: 1 }, { itemId: 'carta-blastoise', quantity: 2 }, { itemId: 'moeda-ouro', quantity: 10 }, ]).then(function (inventory) { renderInventory(inventory); // inventário inteiro, já atualizado com os 3 de uma vez });

Não pode repetir o mesmo itemId duas vezes na mesma lista (some a quantidade antes de mandar) — até 20 itens diferentes por chamada.

Quando chamar removeItem: quando o jogador CONSOME/gasta um item dentro do seu próprio jogo (bebeu uma poção, usou uma carta num combate) — fora do fluxo de troca (ver abaixo, que já debita/credita sozinho nos dois lados). Rejeita se você não tiver a quantidade pedida, então trate o erro (ex: o jogador tentou usar algo que na verdade já tinha acabado).

GameSDK.removeItem('pocao-vida', 1).then(function (inventory) { renderInventory(inventory); // já atualizado }).catch(function (err) { showError(err.message); // ex: "Você não tem esse item (ou não tem o suficiente)." });

Vários itens de uma vez — removeItems: mesma lista, útil pra "craftar" (gastar vários itens diferentes de uma vez pra criar outro). Atômica — se faltar QUALQUER item da lista, a chamada inteira falha e nada é removido; nunca fica só com metade dos itens debitados.

// craftar uma "poção maior" gastando 2 poções pequenas + 1 erva rara, tudo ou nada GameSDK.removeItems([ { itemId: 'pocao-pequena', quantity: 2 }, { itemId: 'erva-rara', quantity: 1 }, ]).then(function (inventory) { return GameSDK.addItem('pocao-maior', 1); }).catch(function (err) { showError(err.message); // ex: 'Você não tem "erva-rara" (ou não tem o suficiente) — nada foi removido.' });

Propor e responder trocas

Quando chamar: só faz sentido com outro jogador da MESMA sala de multiplayer (ver seção 10) — toPlayerId vem de onRoomUpdate/onRoomEvent. Quem propõe monta a UI de "o que eu ofereço" e "o que eu peço"; o SEU jogo decide como essa tela se parece, a plataforma só executa a troca com segurança quando os dois lados topam.

Visitante (sem conta) nunca participa de troca — nem propondo, nem recebendo. Conta convidado é infinita de criar e sem nenhuma fricção, então trocar item com ela abriria brecha pra "farmar em várias contas descartáveis e mandar tudo pra uma conta de verdade". proposeTrade rejeita nos dois sentidos automaticamente (o servidor confere, não dá pra contornar do cliente) — mas pra UX melhor, esconda o botão de trade você mesmo quando for o caso, checando (await GameSDK.getPlayer()).isGuest (pro seu próprio jogador) e evitando montar o botão de trade em cima de outro jogador que também esteja como convidado (ver isGuest — hoje só vem no seu próprio getPlayer(), não há como checar o status de outro jogador da sala além de tentar propor e tratar o erro).

// Propõe: eu ofereço 1 carta-charizard, peço 2 carta-blastoise em troca GameSDK.proposeTrade(otherPlayerId, { 'carta-charizard': 1 }, { 'carta-blastoise': 2 }) .then(function (res) { showWaitingForResponse(res.tradeId); // ainda não foi aceita, só enviada }) .catch(function (err) { showError(err.message); // ex: "Você não tem os itens oferecidos." }); // Presente: mesma função, só que sem pedir nada em troca (request vazio) — // o outro ainda precisa aceitar, mas não precisa oferecer nada de volta. GameSDK.proposeTrade(otherPlayerId, { 'carta-charizard': 1 }, {});

Quem recebe é avisado via onTradeRequest — monta a própria tela (mostrar o que foi oferecido/pedido, botões Aceitar/Recusar) e responde com respondToTrade:

GameSDK.onTradeRequest(function (req) { // req = { tradeId, fromPlayerId, fromDisplayName, offer, request } showTradeOfferPopup(req, function (accepted) { GameSDK.respondToTrade(req.tradeId, accepted).then(function (res) { // res.accepted pode vir false MESMO respondendo true — ver onTradeResult abaixo if (res.accepted) renderInventory(res.inventory); }); }); }); // Os DOIS lados (quem propôs e quem respondeu) recebem o resultado final aqui GameSDK.onTradeResult(function (result) { // result = { tradeId, accepted, inventory?, reason? } if (result.accepted) { renderInventory(result.inventory); // já vem atualizado, não precisa buscar de novo } else { // reason: 'declined' | 'expired' | 'playerLeft' ou uma mensagem de erro // (ex: o outro jogador não tinha mais o item quando a troca foi de fato executada) showTradeFailed(result.reason); } });

O servidor confere a posse real dos DOIS lados (contra o inventário guardado, nunca contra o que os clientes declaram ter) no exato momento em que a troca é aceita — não só quando ela é proposta. Por isso respondToTrade pode falhar (ou o onTradeResult chegar com accepted:false) mesmo depois de aceitar: o inventário de alguém pode ter mudado entre a proposta e o aceite (ex: já gastou o item noutra troca). Uma proposta sem resposta expira sozinha depois de um tempo, e cai automaticamente se qualquer um dos dois sair da sala ou cair a conexão.

12. Conquistas

Cada jogo pode ter um catálogo de conquistas (nome, descrição e ícone) que o próprio jogo desbloqueia pro jogador em tempo real — tipo troféu da Steam. Aparece no perfil do jogador (vitrine com os jogos que joga) e, se o seu jogo não usa placar (progressão contínua, sem pontuação que faça sentido comparar entre jogadores), a CONTAGEM de conquistas desbloqueadas vira automaticamente o "Top conquistas" na página do seu jogo, ao lado de onde ficaria o ranking de pontuação.

Cada conquista é cadastrada no seu painel de dev, aba Gerenciar → Conquistas do jogo: nome, descrição (opcional) e ícone (opcional — mostra um 🏆 genérico enquanto não tiver um). Ao criar, você escolhe uma chave (ex: primeira-vitoria) — é ESSA chave, não o nome, que o seu jogo usa pra desbloquear. Pode trocar nome/descrição/ícone depois sem quebrar nada no código do jogo, já que ele só referencia a chave.

GameSDK.unlockAchievement(key)

Quando chamar: no exato momento em que o jogador CONQUISTA aquilo de verdade (venceu pela primeira vez, chegou numa fase específica, etc). Idempotente — chamar de novo pra uma conquista já desbloqueada não dá erro nem duplica, só devolve alreadyUnlocked: true, então não precisa controlar isso sozinho (save local, flag) antes de chamar.

GameSDK.unlockAchievement('primeira-vitoria').then(function (res) { if (!res.alreadyUnlocked) { showAchievementToast(res.achievement.name, res.achievement.icon_path); } });

Funciona em QUALQUER jogo — não precisa de placar nem de multiplayer. Visitante (sem conta) também desbloqueia normalmente (o toast aparece), só que como a conta é temporária (some em 24h), a conquista some junto — igual já acontece com placar de visitante.