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
- Seu jogo é um
index.html(+ CSS/JS/imagens/áudio junto) que funciona sozinho, sem depender de internet. - Ele conversa com a plataforma (placar, salvar progresso, etc.) chamando funções do
GameSDK— veja a seção 3. - Você zipa tudo com o
index.htmlna raiz e envia pelo formulário do seu painel de dev. - 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, useGameSDK.save()(seção 3). - Nada de
fetch/XMLHttpRequestpra sua própria API. Mesmo se for pro domínio da própria plataforma, não funciona. Toda comunicação com o servidor passa peloGameSDK. - 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%, escutarresizese 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.htmlobrigatoriamente 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.
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 servidorGameSDK.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
addScoretem 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 chamandoaddScore(999999999)uma vez só — o teto fecha exatamente essa brecha. - Existe um limite de quantas chamadas de
addScorea 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: noneno 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/.woff2embutidos 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ê usapointerdownpra construir/selecionar coisas no jogo, cuidado: ele dispara pra QUALQUER botão do mouse, não só o esquerdo — sem checare.button, o botão direito também vai acionar a ação, mesmo com o menu nativo bloqueado. -
contextmenusó 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: coloqueuser-select: none(+ os prefixos) nohtml, 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
- 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.
- 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.
- Quando estiver satisfeito, clique em "Solicitar aprovação" — só aí o jogo entra na fila de aprovação manual de um admin.
- 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):
- O jogo carrega, o
DOMContentLoadeddispara e chamaGameSDK.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). - Assim que a sessão é confirmada,
playing = truee o loop do jogo começa: o personagem fica parado no chão, o obstáculo anda da direita pra esquerda. - 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. - 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ê). - 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.htmldentro de uma subpasta em vez de na raiz do zip.- Usar
fetch/XMLHttpRequestesperando falar com sua própria API — sempre falha dentro do sandbox. Use oGameSDK. - Tentar
localStorage.setItem(...)pra salvar progresso — não persiste (origem opaca). UseGameSDK.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:
.ttfem 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:
- Tela de menu: um cria a sala (
createRoom) e vira o dono; o outro entra com o código (joinRoom). onRoomUpdatemostra quem já chegou e libera o botão "Iniciar" pro dono assim que os dois estão na sala.- O dono clica "Iniciar" (
startRoom) — os DOIS recebemstatus:'started'junto e a partida começa pros dois ao mesmo tempo. - 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. - 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;onRoomEventdo outro lado atualiza o que recebeu. - 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. - Cada lado chama
addScore(1)só quando FOI ELE quem marcou. Ao alcançar 5 pontos, cada cliente chamafinishSessionna 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.
Cadastrando conquistas (fora do zip)
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.