# Guia do Desenvolvedor — Catálogo de Games

Tudo que você precisa pra colocar um jogo HTML5/JS no ar aqui. Os números
(limites de tamanho, tempo, etc.) refletem a configuração atual do servidor.

## 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` — 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 `<iframe sandbox>` — proteção de segurança, não escolha arbitrária: sem isso, um jogo malicioso poderia roubar a sessão de quem está jogando. Na prática:

- **Nada de salvar direto no navegador.** `localStorage`, cookies, `indexedDB` — nenhum funciona aí dentro. Use `GameSDK.save()` (seção 3).
- **Nada de `fetch`/`XMLHttpRequest` pra sua própria API.** Mesmo 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 Google Fonts, sem 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 duplo-clique no `index.html`, sem internet — já está 90% pronto pra cá. O resto é 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 (admin pode ajustar por dev).
- 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 antes do seu script principal:

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

O `?v=...` é opcional (o site funciona sem ele), mas com ele o navegador pode cachear com segurança em vez de baixar de novo toda hora.

**Cache dos SEUS arquivos internos (script.js, style.css, imagens):** a plataforma só versiona automaticamente a URL do `index.html` (o iframe). Arquivos que o seu `index.html` referencia por dentro (ex: `assets/script.js`) NÃO ganham `?v=` automático — o navegador pode continuar servindo a versão antiga em cache depois de você enviar uma atualização. Regra: suba você mesmo um `?v=` nos assets internos a cada mudança (`assets/script.js?v=2` → `?v=3`). O botão "Mais uma partida"/reiniciar recarrega o iframe com a MESMA URL (não muda `?v=`) — pra ver versão nova, recarregue a página do jogo inteira (F5).

Isso cria um objeto global `GameSDK`. Pode chamar as funções direto, sem esperar nenhum "pronto" — o SDK segura a chamada internamente até a conexão estar pronta.

Duas regras valem pra **todas** as funções do GameSDK: (1) toda chamada tem limite de **10 segundos** pra plataforma responder — se estourar, a Promise rejeita com "Tempo esgotado esperando resposta de ...". (2) Qualquer erro de JS não tratado no seu jogo (`throw` sem `catch`, Promise rejeitada sem `.catch()`) já é capturado automaticamente e enviado pro painel de debug da plataforma (visível só pro dono do jogo ou admin).

### `GameSDK.startSession()`

**Quando chamar:** assim que uma partida começa. Pode chamar bem cedo (ex: no `DOMContentLoaded`), sem esperar clique nenhum dentro do próprio jogo — a chamada só "destrava" quando a plataforma revela o jogo (clique em "Jogar" lá fora) E o servidor confirma a sessão no banco.

```js
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();
});
```

### `GameSDK.addScore(valor)`

**Quando chamar:** toda vez que o jogador ganha pontos. `valor` é sempre o INCREMENTO ganho agora, nunca o placar acumulado (o servidor calcula sozinho — ver seção 4). Precisa ser inteiro positivo.

```js
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. `stats` é opcional: até 8 `{ label, value }` pra mostrar numa tabela "Resultado final" — ficam salvos junto do placar e aparecem na linha do ranking público. Chamar `finishSession()` CONSOME a sessão — depois, qualquer `addScore()` na mesma sessão é rejeitado. Pra próxima partida, chame `startSession()` de novo.

```js
var res = await GameSDK.finishSession([
  { label: 'Ouro ganho', value: totalGold },
  { label: 'Inimigos derrotados', value: kills },
]);
```

Não precisa chamar `startSession()` manualmente pra próxima partida: o botão "Mais uma partida" da plataforma recarrega o `index.html` inteiro, o `DOMContentLoaded` dispara de novo, e o `startSession()` que já está no seu código roda sozinho. Só precisaria chamar na mão se o SEU jogo tiver uma tela interna de "jogar de novo" que reseta sem recarregar a página.

### `GameSDK.getPlayer()`

Devolve `{ displayName, avatarUrl, isGuest, hasPlayedBefore }` ou `null`. `hasPlayedBefore` é `true` quando o jogador já TERMINOU uma partida desse jogo antes. Não devolve e-mail nem id da conta de propósito.

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

### `GameSDK.getLeaderboard()`

Devolve os mesmos 50 melhores placares da página pública do jogo.

```js
var scores = await GameSDK.getLeaderboard();
// [{ player_name, score, details, created_at }, ...]
```

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

JSON livre, até 256KB, por jogador. Salvar de novo substitui por inteiro (mande sempre o objeto completo). `load()` devolve `undefined` se nunca salvou.

```js
await GameSDK.save({ level: 3, coins: 120, unlockedSkins: ['gold', 'ice'] });
var data = await GameSDK.load();
if (data) { level = data.level; coins = data.coins; }
```

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

Registre (não chame) — a plataforma chama quando o jogador sai da tela cheia sem querer.

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

### `GameSDK.requireOrientation(orientation)`

Chame uma vez, no início, se o jogo só faz sentido numa orientação. `'landscape'` ou `'portrait'`. A plataforma trava a tela quando o navegador permite, ou bloqueia com aviso pra girar quando não (iPhone).

```js
GameSDK.requireOrientation('landscape');
```

### `GameSDK.onMuteChange(fn)`

A barra lateral tem um botão de Som — `fn(true)` = mutar, `fn(false)` = religar.

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

### `GameSDK.onVolumeChange(fn)`

Três sliders (Principal/Efeitos/Música), 0-100 cada.

```js
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);
});
```

### `GameSDK.onRestart(fn)`

Botão "Mais uma partida" por padrão recarrega o `index.html` inteiro. Registrando `onRestart`, a plataforma só avisa pra reiniciar a PARTIDA internamente (sem recarregar). Se não registrar, comportamento antigo (reload) continua.

```js
GameSDK.onRestart(function () {
  score = 0; stage = 1; engine.reset();
  document.getElementById('menu').style.display = 'none';
  syncUI();
});
```

### `GameSDK.listGameFiles(folder, extensions?)`

Lista nomes de arquivo de uma subpasta do PRÓPRIO conteúdo do jogo (dentro do zip enviado) — útil pra montar seletor de mapa/fase sem hardcodar nomes.

```js
GameSDK.listGameFiles('maps').then(function (files) {
  // files = ['fase1.json', 'fase2.json', ...]
});
GameSDK.listGameFiles('assets', ['.json']).then(function (files) { /* ... */ });
```

Só devolve o NOME (não o conteúdo). Extensões seguem a mesma whitelist: `.html .htm .css .js .json .png .jpg .jpeg .gif .webp .svg .mp3 .ogg .m4a .woff .woff2`. Pasta vazia/inexistente devolve `[]`, nunca erro.

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

Não existe `GameSDK.setScore(numero)` de propósito. O placar final é sempre a SOMA que o servidor calculou, incremento a incremento, a partir de cada `addScore(valor)`.

- Chame `addScore(valor)` a cada ganho real, na hora — nunca acumule numa variável local pra mandar de uma vez.
- Teto de sanidade por evento (100.000 por padrão, admin pode ajustar) — fecha a brecha de `addScore(999999999)` uma vez só.
- Limite de FREQUÊNCIA de chamadas por partida (calibrável por jogo).
- Duração mínima entre início e fim da sessão — partida relâmpago é rejeitada.
- Trate rejeição com `.catch()`.
- Placar muito acima do normal entra numa fila de revisão do dono do jogo; se não revisado no prazo, publica sozinho. Enquanto pendente, não aparece no ranking.

Se atualizar a tela otimisticamente antes da resposta do servidor, desfaça se `addScore` for rejeitado:

```js
try {
  var res = await GameSDK.addScore(10);
  score = res.accumulatedScore;
  scoreEl.textContent = score + ' pontos';
} catch (err) {
  score -= 10; // desfaz o incremento otimista
  scoreEl.textContent = score + ' pontos';
}
```

## 5. Boas práticas de UI dentro do jogo

O sandbox não bloqueia eventos de input, só bloqueia `localStorage`/`fetch`/etc (seção 1).

- Layout responsivo a 100% do iframe, não pixels fixos pensando em desktop.
- Use `pointerdown`/`pointerup` (mouse e toque), não só `click`.
- `touch-action: none` no CSS evita o navegador rolar/dar zoom durante o toque.
- Não dependa só de teclado — boa parte joga no celular.
- Fontes do sistema ou `.woff/.woff2` embutidos no zip — nunca CDN externo.
- Bloqueie o menu de botão direito (`contextmenu` + `preventDefault()`); se usa `pointerdown` pra construir/selecionar, cheque `e.button` (dispara pra qualquer botão do mouse, não só o esquerdo).
- Long-press no celular aciona seleção de texto do sistema mesmo com `contextmenu` bloqueado — resolva com CSS (`user-select: none`), não dá pra resolver via SDK.

```js
document.addEventListener('contextmenu', function (e) { e.preventDefault(); });
canvas.addEventListener('pointerdown', function (e) {
  if (e.button > 0) return; // ignora botão direito/do meio
  // ... sua lógica de clique/toque aqui
});
```

```css
html, body {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none; /* desliga o menu "Copiar/Selecionar" do iOS */
}
```

## 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.
- **1 a 3 screenshots (obrigatório)** — recomendado 1280×800, paisagem (16:10).
- **Mini banner / capa (opcional)** — usado no destaque da home e cards do catálogo, nunca na própria página do jogo. Duas variantes (altura sempre 350px): Desktop (recomendado 1600×400) e Mobile opcional (recomendado 700×600, sem uma definida usa a Desktop).

Ícone, capa e screenshots passam pelo mesmo reencode automático (PNG/JPG → WebP) da 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 — falha é logada com o motivo específico.
2. Se passar, a versão fica validada PRA TESTE (ainda não publicada). Use o link "Testar" antes de mandar pra aprovação.
3. Clique em "Solicitar aprovação" — só aí entra na fila de aprovação manual de um admin.
4. Só depois de aprovado aparece (ou atualiza) no catálogo público.

A conta que envia precisa ter e-mail confirmado e papel de dev (ou admin).

## 8. Exemplo completo — jogo de pular obstáculo

Mini-jogo jogável: personagem correndo, obstáculo vindo — toque/clique/espaço pra pular. Cada pulo soma 1 ponto; colidir termina a partida. Usa só os três métodos centrais (`startSession`, `addScore`, `finishSession`).

Zipe o código abaixo como `index.html` sozinho na raiz e já está pronto pra enviar:

```html
<!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:

1. `DOMContentLoaded` chama `GameSDK.startSession()` na hora — não espera clique nenhum dentro do próprio jogo.
2. Sessão confirmada → `playing = true`, loop começa: personagem parado, obstáculo anda da direita pra esquerda.
3. Toque/clique/espaço chamam `jump()` (física local, sem SDK).
4. Obstáculo passa sem colisão → soma 1 ponto na tela E chama `GameSDK.addScore(1)` (incremento, nunca o total).
5. Colisão → `GameSDK.finishSession(stats)` com quantos obstáculos foram pulados, mostra o placar final devolvido pelo servidor.

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

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

## 10. Multiplayer em tempo real

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

### Sala — criar, entrar, saber quem chegou

Sala tem no máximo 4 jogadores; quem cria vira "dono" (host) — só ele inicia a partida ou adiciona bot.

```js
var myId, isHost, roomCode;

// dono cria a sala
GameSDK.createRoom().then(function (res) {
  myId = res.yourId; // seu id NESTA sala — guarde
  roomCode = res.roomCode;
  codeDisplay.textContent = roomCode;
});

// os outros entram com o código (plataforma normaliza maiúsculas)
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`:

```js
GameSDK.onRoomUpdate(function (players, status, hostUserId) {
  // players = [{ id, displayName }, ...] — todo mundo 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()
});

startBtn.addEventListener('click', function () {
  GameSDK.startRoom(); // só o dono pode chamar
});
```

`GameSDK.leaveRoom()` tira o jogador da sala atual. `GameSDK.addBot()` existe, mas hoje 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 é genérico pronto pra qualquer jogo ainda. Alternativa viável: o próprio jogo simula um "jogador fantasma" localmente e manda os movimentos dele via `sendRoomEvent` igual um jogador real — funciona hoje, sem precisar de nada especial da plataforma.

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

**Quando chamar:** toda vez que algo no SEU jogo precisa aparecer pros outros da sala — posição, jogada, placar parcial. A plataforma só entrega mensagens (repassa pra sala inteira), nunca decide o que significam.

```js
// 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;
});
```

`data` precisa ser serializável em JSON e pequeno (até 2KB). Limite de frequência: hoje 20 eventos/segundo — a plataforma REJEITA se mandar rápido demais, e desconecta se isso se repetir. Mande num intervalo fixo BEM abaixo do teto (ex: a cada 70ms, ~14/s) — nunca no limite exato (risco de estourar por variação de timing do navegador):

```js
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 é" dentro do próprio `data`. Não há garantia de entrega (fire-and-forget) — pra algo que TEM que chegar, use um protocolo de confirmação no próprio `data`, ou trate como estado que se corrige sozinho no próximo envio.

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

Existe outro grupo de funções (`sendArenaInput`, `sendArenaAction`, `sendArenaAttack`, `sendArenaTrap`, `sendArenaReady`, `onArenaInit`, `onArenaState`, `onArenaEvent`) pensado pra jogos onde o SERVIDOR decide o resultado — mas hoje roda a lógica de UM jogo específico da plataforma (mapa/baú/chave/espada), não é framework genérico ainda. Se seu jogo precisa de simulação autoritativa, o caminho por enquanto é `sendRoomEvent`/`onRoomEvent` com um dos jogadores (o dono da sala) fazendo esse papel — exatamente o que o exemplo de Mesa de Ar abaixo faz.

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

Sala, lobby e partida só com `sendRoomEvent`/`onRoomEvent`. Cada jogador arrasta a PRÓPRIA peça livremente dentro da própria metade; o disco é decidido por UM dos dois (o dono da sala) e transmitido — o outro só desenha o que recebe e faz a própria colisão localmente. Simples, bom pra jogo casual entre amigos; não é à prova de trapaça (o dono poderia mentir sobre o disco).

Zipe o código abaixo como `index.html` sozinho e já está pronto pra enviar. Pra testar multiplayer DE VERDADE, precisa de duas sessões reais — publique e abra em duas abas/dispositivos logados como jogadores diferentes (duas abas normais do MESMO navegador NÃO servem — cookie é por navegador, não por aba; use uma aba normal + uma anônima, ou dois navegadores/dispositivos):

```html
<!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`), o outro entra com o código (`joinRoom`).
2. `onRoomUpdate` mostra quem chegou e libera o botão "Iniciar" pro dono quando os dois estão na sala.
3. Dono clica "Iniciar" (`startRoom`) — os DOIS recebem `status:'started'` junto.
4. Física roda num espaço virtual fixo (100×60), não em pixels de tela — o `<canvas>` mantém a proporção (5:3) ao redimensionar.
5. Cada cliente manda a própria peça ~15x/s (`sendRoomEvent`), de propósito abaixo do limite de 20/s. O dono manda o disco junto no MESMO envio. Gol nunca dispara chamada de rede EXTRA — pega carona no próximo envio do mesmo ritmo (`pendingPoint`).
6. Só o dono roda a física do disco (`updatePuckPhysics`) — quique nas paredes, colisão círculo-círculo (`hitMallet`), gol quando o disco cruza a fresta.
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.

## 11. Itens e troca entre jogadores

Inventário genérico por jogador, rastreado pelo SERVIDOR — diferente de `save`/`load` (blob livre que só o jogo entende), aqui a plataforma sabe exatamente QUANTO de cada item cada jogador tem, o que permite trocar com segurança. Serve pra single-player ("quantos itens já ganhei") ou multiplayer com troca (cartas, colecionáveis).

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

**Quando chamar `addItem`:** toda vez que o jogador ganha algo de verdade (abriu pacote, venceu partida, completou fase) — nunca a partir de um valor que o cliente "decidiu" sozinho.

```js
// itemId é string que só o SEU jogo entende — plataforma só guarda a
// quantidade. quantity é sempre incremento (igual addScore), máx 50/chamada.
GameSDK.addItem('carta-charizard', 1).then(function (inventory) {
  // inventory = { 'carta-charizard': 3, ... } — já devolve tudo atualizado
  renderInventory(inventory);
});

GameSDK.getInventory().then(function (inventory) { renderInventory(inventory); });
```

Vários itens de uma vez (`addItems`) — gasta só UMA unidade do limite de taxa (60 chamadas/5min) em vez de uma por item:

```js
GameSDK.addItems([
  { itemId: 'carta-charizard', quantity: 1 },
  { itemId: 'carta-blastoise', quantity: 2 },
  { itemId: 'moeda-ouro', quantity: 10 },
]).then(function (inventory) { renderInventory(inventory); });
```

Não pode repetir o mesmo `itemId` na mesma lista — até 20 itens diferentes por chamada.

**Quando chamar `removeItem`:** quando o jogador CONSOME um item dentro do próprio jogo (fora do fluxo de troca, que já debita/credita sozinho). Rejeita se não tiver a quantidade.

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

`removeItems` (lote) é ATÔMICA — se faltar QUALQUER item, nada é removido:

```js
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); });
```

### Propor e responder trocas

**Quando chamar:** só com outro jogador da MESMA sala de multiplayer — `toPlayerId` vem de `onRoomUpdate`/`onRoomEvent`.

Visitante (sem conta) NUNCA participa de troca — nem propondo, nem recebendo (fecha a brecha de farmar em contas descartáveis e consolidar numa conta real). O servidor rejeita nos dois sentidos automaticamente; pra UX melhor, esconda o botão de trade quando `(await GameSDK.getPlayer()).isGuest` for `true`.

```js
// 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); })
  .catch(function (err) { showError(err.message); });

// presente: request vazio, o outro ainda precisa aceitar mas não oferece nada de volta
GameSDK.proposeTrade(otherPlayerId, { 'carta-charizard': 1 }, {});
```

```js
GameSDK.onTradeRequest(function (req) {
  // req = { tradeId, fromPlayerId, fromDisplayName, offer, request }
  showTradeOfferPopup(req, function (accepted) {
    GameSDK.respondToTrade(req.tradeId, accepted).then(function (res) {
      if (res.accepted) renderInventory(res.inventory);
    });
  });
});

// os DOIS lados recebem o resultado final aqui
GameSDK.onTradeResult(function (result) {
  // result = { tradeId, accepted, inventory?, reason? }
  if (result.accepted) {
    renderInventory(result.inventory);
  } else {
    // reason: 'declined' | 'expired' | 'playerLeft' ou mensagem de erro
    showTradeFailed(result.reason);
  }
});
```

O servidor confere a posse real dos DOIS lados no momento em que a troca é ACEITA, não quando é proposta — por isso pode falhar mesmo depois de aceitar (o inventário de alguém pode ter mudado entre a proposta e o aceite). Proposta sem resposta expira sozinha; cai automaticamente se qualquer um sair da sala ou cair a conexão.

## 12. Conquistas

Cada jogo pode ter um catálogo de conquistas (nome, descrição, ícone) que o próprio jogo desbloqueia pro jogador em tempo real — tipo troféu da Steam. Aparece no perfil do jogador e, se o jogo não usa placar (progressão contínua), a CONTAGEM de conquistas vira automaticamente o "Top conquistas" na página do jogo.

### Cadastrando conquistas (fora do zip)

Cada conquista é cadastrada no painel de dev, aba Gerenciar → Conquistas do jogo: nome, descrição (opcional) e ícone (opcional — 🏆 genérico até ter um). Ao criar, você escolhe uma **chave** (ex: `primeira-vitoria`) — é essa chave, não o nome, que o jogo usa pra desbloquear. Nome/descrição/ícone podem mudar depois sem quebrar o código do jogo.

### `GameSDK.unlockAchievement(key)`

**Quando chamar:** no momento em que o jogador CONQUISTA aquilo de verdade. Idempotente — chamar de novo numa já desbloqueada não dá erro nem duplica, só devolve `alreadyUnlocked: true`.

```js
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 multiplayer. Visitante (sem conta) desbloqueia normalmente (o toast aparece), mas como a conta é temporária (some em 24h), a conquista some junto — igual placar de visitante.
