# Clawcade — instructions for AI assistants Clawcade hosts small browser games at a permanent, shareable link (play.clawcade.gg/g/). No account and no API key. Games start UNLISTED (anyone with the link can play). ## Critical rules - The game is ONE self-contained HTML file (inline CSS/JS; assets as data URLs or drawn on canvas). Multi-file builds (Vite/Phaser/Unity/Godot) are uploaded as a .zip with index.html at the root. - REAL-TIME RELAY MULTIPLAYER (Clawcade.host/join) MUST USE INTERPOLATION (Clawcade.smoothing()), or remote players stutter. Clawcade Server interpolates for you. See below. - To remember the player, just use localStorage: Clawcade persists it per game on the player's device. Never build a backend or a login for saves. - After publishing, ALWAYS give the user the play link AND the edit key. The edit key is shown once and is the only way to edit the game, add a cover, or replace a build later. ## Three ways to publish 1. MCP connector (best, if your app supports MCP): add the remote server https://clawcade.gg/mcp (Streamable HTTP, no auth; legacy SSE at https://clawcade.gg/sse). Tools: publish_game, get_upload_url, get_bundle_upload_url, edit_game, get_thumbnail_upload_url, submit_to_arcade, get_game, clawcade_guide. 2. HTTP API with a shell (Codex, Gemini CLI, Cursor, Copilot, any agent that can run curl): see below. 3. No tools at all (plain chat): write the complete HTML file in one code block; the user pastes it at https://clawcade.gg/publish (or saves it as game.html and drops it there). ## HTTP API (no key needed) Publish a single HTML file (raw body; headers are optional): curl --data-binary @game.html -H "Content-Type: text/html" \ -H "X-Clawcade-Title: My Game" \ -H "X-Clawcade-Leaderboard: Max round" -H "X-Clawcade-Score-Order: high" \ https://clawcade.gg/api/publish → {"url","slug","editKey","bytes","sha256","warnings":[...]} Fix any warnings, then edit. Publish a multi-file build as a zip (index.html at the zip root): cd dist && zip -r ../game.zip . && cd .. curl --data-binary @game.zip -H "Content-Type: application/zip" -H "X-Clawcade-Title: My Game" \ https://clawcade.gg/api/publish-bundle → {"url","slug","editKey","fileCount","bytes"} Edit (same link; public games are held for a quick moderator recheck, unlisted ones update instantly): curl -X PUT --data-binary @game.html -H "Content-Type: text/html" \ -H "X-Clawcade-Edit-Key: " -H "X-Clawcade-Update-Note: what changed" \ https://clawcade.gg/api/game/ Replace a zip build: curl -X PUT --data-binary @game.zip -H "Content-Type: application/zip" \ -H "X-Clawcade-Edit-Key: " https://clawcade.gg/api/game//bundle Cover image (png/jpeg/webp/gif, max 2 MB, 16:10 looks best): curl -X PUT --data-binary @cover.png -H "Content-Type: image/png" \ -H "X-Clawcade-Edit-Key: " https://clawcade.gg/api/game//thumb Submit to the public arcade (moderated): curl -X POST https://clawcade.gg/api/submit/ Check status, plays, likes, top scores: curl https://clawcade.gg/api/game/ Group games on the creator dashboard (https://clawcade.gg/me): add -H "X-Clawcade-Creator: " when publishing. Limits: HTML up to 25 MB; zip up to 20 MB (60 MB unpacked, 200 files, no .svg). Publishing is rate-limited. CLAWCADE IN-GAME APIS. A served game gets a global `Clawcade` object plus a postMessage leaderboard hook. None of this is needed for a basic single-player game. SAVING PROGRESS (remembering the player) - Just use localStorage. It PERSISTS per game on the player's device: boot-time reads work, writes are saved automatically, and saves survive reloads and new versions of the game. Do NOT build a backend or ask for a login. const save = JSON.parse(localStorage.getItem('mygame_save') || 'null') || { level: 1, coins: 0 }; function persist() { localStorage.setItem('mygame_save', JSON.stringify(save)); } // call on checkpoints - Strings only (JSON.stringify objects). Keep it under ~500 KB per game; beyond that nothing more is saved. - Put a version in the key or the object (e.g. 'mygame_save_v2') and migrate old saves when the format changes. - Saves stay on that device + browser (no sync; Safari may clear them after ~7 days unvisited): offer 'New game' / 'Continue' instead of assuming a save exists. - sessionStorage resets each visit. Don't use window.name (Clawcade uses it to hand the game its save). - Players have a 'Delete my save' button on the play page. MULTIPLAYER (invite-code lobbies; no server to run, Clawcade relays JSON between players) - Competitive real-time game (shooter, brawler, sports, racing, .io)? Use Clawcade Server instead (topic 'server'): your rules run on a real authoritative server with prediction and lag compensation. The relay below is for turn-based and casual co-op games where cheating doesn't matter. !! REQUIRED FOR REAL-TIME RELAY GAMES: INTERPOLATION !! Network state arrives in bursts (~20-30 msgs/s, uneven on phones). If you draw remote players/enemies at the newest snapshot they STUTTER and TELEPORT. Every real-time relay game MUST render remote entities through Clawcade.smoothing() (pattern below); publish warns if it's missing. Turn-based games (chess, cards) can skip it. Clawcade Server/Arena already interpolate net.players()/entities(): never smooth on top of them. API - Host a room: const { code } = await Clawcade.host(); // show `code` to a friend - Join a room: await Clawcade.join(code); - Broadcast JSON to everyone else: Clawcade.send(obj); // ≤ 8 KB of JSON each, or it's NOT delivered (you get an 'error' event). Keep per-tick state compact: short keys, rounded numbers. - Receive: Clawcade.on('message', m => { /* m.from = sender id, m.data = payload */ }); - Roster: Clawcade.on('peers', list => { /* start when list.length === 2 */ }); - Other events: 'host' (you were promoted), 'open', 'close', 'error', 'reconnecting', 'reconnected', 'invite' (room code from a shared #room= link; Clawcade.invitedRoom also holds it). - Up to 8 players per room. A relay with NO anti-cheat: great for friends, not ranked play. ARCHITECTURE (proven pattern) - The FIRST peer in is the AUTHORITATIVE host: it simulates the game and broadcasts the FULL state every tick. - Guests send only their input, and render what the host sends, through the smoothing buffer. - Send full state each tick (idempotent), not one-off 'spawn' deltas, so a dropped message self-heals next tick. INTERPOLATION PATTERN (copy this) const buf = Clawcade.smoothing({ delay: 100, angles: ['angle'] }); // renders ~100 ms in the past // HOST, every tick: the WHOLE list, each entity with a STABLE id Clawcade.send({ t:'state', ents: ents.map(e => ({ id:e.id, x:e.x, y:e.y, angle:e.a })) }); // GUEST: which entities EXIST comes from the latest message; POSITIONS come from the buffer let liveIds = []; Clawcade.on('message', m => { if (!m.data || m.data.t !== 'state') return; liveIds = m.data.ents.map(e => e.id); m.data.ents.forEach(e => buf.push(e.id, e)); }); function frame() { requestAnimationFrame(frame); // at the TOP, so one thrown frame can't kill rendering liveIds.forEach(id => { const s = buf.get(id); if (s) draw(s.x, s.y, s.angle); }); } INTERPOLATION RULES (breaking these makes entities flicker or vanish) 1. Positions, not existence: never decide whether an entity exists from buf.get(). 2. One shape per id: don't push a player update and an enemy list into the same id. 3. buf.get(id) is null until that id has a snapshot: always null-check. 4. The host's OWN local player can be drawn directly; everyone else goes through the buffer. - A single remote object (e.g. one opponent paddle) can skip ids: buf.push(data) / buf.get(). - `angles` fields use shortest-path lerp. Helpers: Clawcade.lerp(a,b,t), Clawcade.lerpAngle(a,b,t). RECONNECTION - A dropped player rejoins the SAME room by itself within ~1 s ('reconnecting', then 'reconnected' + 'open'), keeping a STABLE id: Clawcade.playerId (and `from` on messages). Key players by it; hold a dropped player's character ~20 s and re-attach it when they return. The host re-sends the full state when someone comes back. - Roles can change while away: a host back within 8 s stays host; later (someone was promoted) or into an emptied room, not. On EVERY 'open' set your role from e.isHost; 'host' = you were just promoted. 'close' = it could not get back in (2 min). CLAWCADE SERVER — your game's own authoritative server (free, opt-in, 1-16 players per room) For ANY competitive real-time multiplayer: FPS/TPS, top-down brawlers, projectiles, abilities, sports, racing, .io. You write the rules ONCE as plain JS. Clawcade runs them on its server (authoritative: no speed hacks, no fake hits) AND in each player's browser (prediction: your own moves/shots are instant). Built in, do NOT write your own: prediction + reconciliation, smooth interpolation of others, lag-compensated hitscan (if it hit on your screen, it hit), instant predicted events (hitmarkers), rooms with invite links, input rate limits. Each room runs in the Cloudflare data center nearest to whoever creates it (measured fastest; don't try to pick one). For the most responsive feel use tickRate 60 + snapRate 60: snapRate 30 shows others ~17 ms later. 1) THE SERVER CODE: ONE SELF-CONTAINED FUNCTION (everything it uses is declared INSIDE it; it runs on the server) const SERVER = function () { var SPEED = 7, RATE = 10; // constants + helpers live in here return { tickRate: 60, snapRate: 60, maxPlayers: 8, // tickRate 20|30|60; snapRate 10|15|20|30|60 dividing it hitbox: { radius: 0.4, height: 1.8, head: 0.25 }, init: function (ctx) { // once per room, on the server AND on every client (same seed) ctx.world.box(0, 1, -10, 8, 2, 1, 'wall'); // solid boxes: CENTER x,y,z + size w,h,d (+ a tag) ctx.world.bounds = [30, 30]; // players stay within |x|<=30, |z|<=30. Ground: y = 0 return { shots: [], nextId: 1 }; // your room state (s). s.players is managed for you }, join: function (s, id, ctx) { // SERVER ONLY: a new player's object → s.players[id] return { x: 0, y: 0, z: 0, hp: 100, alive: true, nextFire: 0 }; // set the spawn position here }, leave: function (s, id, ctx) {}, // SERVER ONLY move: function (p, input, dt, s, ctx) { // ONE input of ONE player: server AND that player's device p.yaw = +input.yaw || 0; if (!p.alive) return; ctx.walk(p, input, { speed: SPEED }); // movement + collisions (see ctx.walk) if (input.fire && ctx.time >= p.nextFire) { // ctx.time = this player's command clock (cooldowns, fire rate) p.nextFire = ctx.time + 1 / RATE; var eye = [p.x, p.y + 1.6, p.z], d = ctx.aim(p.yaw, +input.pitch || 0); var h = ctx.hitscan(eye, d, 100, { ignore: p.id }); // lag-compensated on the server ctx.emit('shot', { from: eye, to: h ? h.point : [eye[0] + d[0] * 100, eye[1] + d[1] * 100, eye[2] + d[2] * 100] }); if (h && h.player) { ctx.emit('hit', { by: p.id, to: h.player, head: h.head }); // instant hitmarker for the shooter if (ctx.server) { var v = s.players[h.player]; v.hp -= h.head ? 40 : 20; } // only the server changes others } } }, tick: function (s, dt, ctx) {}, // SERVER ONLY, after the moves: projectiles, deaths, respawns, rounds, AI view: function (s, id, ctx) { // what player `id` receives (leave out what they can't see) var players = {}; for (var k in s.players) { var q = s.players[k]; players[k] = { x: q.x, y: q.y, z: q.z, yaw: q.yaw, hp: q.hp, alive: q.alive }; } return { players: players, entities: {}, round: 1 }; // players/entities are interpolated for you } }; }; const net = Clawcade.server(SERVER); RULES (the server and the browsers must compute the same thing) - Nothing from outside the function: it's sent to the server as source text. The browser runs the same text, so that mistake fails right away in your console too. - Randomness: ctx.random() (inside these functions Math.random is also a seeded, matching generator). - Time (in SECONDS): never Date.now()/performance.now(). ctx.time in move() = that player's command clock (cooldowns, reload, fire rate, dash). ctx.now = the room clock (timers set in tick()/join() or when damaging someone, e.g. respawnAt = ctx.now + 3). Don't mix the two clocks in one timer. - WHERE CODE RUNS: init → server + every client · move → server + the moving player's client · join, leave, tick, view → server only. - On the client, the `s` that move() receives is YOUR LOCAL copy from init() (also net.world()). The server never syncs it. If move() reads world state that changes (paint that speeds you up, doors, destroyed cover), keep that local copy updated from your events, e.g. net.on('paint', e => apply(net.world(), e)). Otherwise predictions disagree with the server and the player rubber-bands. - move() may only change p (it's replayed to correct predictions). Anything that affects others (damage, spawning projectiles, score) goes inside if (ctx.server) { … }. - Input is untrusted: clamp what you read (fire rate and cooldowns belong in move(), not in the client). - State, players and views must be JSON (numbers, strings, booleans, arrays, plain objects). - A player object needs x, y, z (between the feet) for hitscan/near/interpolation; alive: false = can't be hit; team = skipped by ctx.hitscan/near(…, { team }). Server changes to a player (damage, death) reach that player's net.me() on the next snapshot. REPOSITION players with ctx.teleport(p, x, y, z) (spawn, respawn, pads): the owner snaps there. Moving p.x/y/z by hand in tick() looks like a prediction error and glides. The same goes for moving ANOTHER human player from the server (grabs, pins, pulls, being carried, knockback): either ctx.teleport them every tick it happens (they snap along) or, better, make it a status on the victim (p.pinnedBy, p.knock = { vx, vz, until }) that the victim's own move() applies, so their client predicts it. - ANYTHING THAT CHANGES A PLAYER'S MOVEMENT BELONGS IN move(), on the command clock, or it causes corrections. A rule with a known time (round intro ends, freeze lifts): store p.until = ctx.time + 3 in move() and check ctx.time >= p.until in move(); the client predicts it exactly. Unavoidable corrections: dying, and world state the server changes under you (enemy paint arrives one round trip late). Don't chase zero there. - Round the player's floats at the END of move() (e.g. p.x = Math.round(p.x * 1e4) / 1e4): the server's math (QuickJS) and the browser's differ in the last bit and a collision edge can amplify it. - An event that move() emits must be emitted on BOTH sides: never put a predicted ctx.emit inside if (ctx.server) (the pairing with 'confirm' counts events per name). Server-only events: use another name. - BUDGETS (work, not time): init() ≈ 2 s; each step ≈ 100 ms for ALL move() + tick() + view() calls together. Build levels, nav graphs and lookup tables in init(); cap loops (A*), ONE expensive job per tick. A step over budget is stopped (and may be half applied); 3 failures in 10 s close the room (4012). The server also warns if ticks get heavy on average. Everything shows in the browser console as [Clawcade server] … and net.on('error'). - Server code (the factory's source, including baked data like maps) ≤ 512 KB. Big data: compress it (run-length + base36) and verify it with a checksum. - Late joiners: never send a big grid in one message from join(); send a header, then chunks from tick() with { to: id } (e.g. 16k cells per tick). - BOTS: simulate them in tick() by calling your own move() with a ctx-like object whose time = ctx.now; think at 10-15 Hz, move every tick. Others see them interpolated like players (put them in view.players). 2) CTX (same API on the server and in the browser) ctx.world.box(x,y,z, w,h,d, tag) · ctx.world.bounds = [hx, hz] · ctx.world.floor = false (no ground plane) ctx.walk(p, input, opts): FPS/TPS/top-down controller. Reads input.mx (strafe, +right), input.mz (+forward), input.yaw, input.jump, input.sprint. opts: speed 7, sprint 1.35, jump 8, gravity 22, air 4, radius 0.4, height 1.8, step 0.45 (walks up ledges). Sets p.vx/vy/vz/grounded. Or move p yourself. ctx.aim(yaw, pitch) → unit direction · ctx.wishDir(yaw, mx, mz) → [x, z] ctx.raycast(origin, dir, maxDist) → { t, point, normal, tag } | null ctx.hitscan(origin, dir, maxDist, { ignore: id, team }) → { t, point, player (id, 0 = wall), head } | null ctx.fly(proj, gravity): moves { x,y,z, vx,vy,vz } by one tick (ctx.dt = 1/tickRate); gravity > 0 pulls down (m/s², e.g. 20); returns the hit { point, normal, tag } (walls, and the ground unless world.floor = false) or null ctx.near(x, y, z, radius, { ignore, team }) → [{ id, dist }] nearest first; dist = to the hitbox (0 = touching). For explosions, pickups, melee. ctx.emit(name, data, { to: id }?) → event to the clients. Emitted in move() it fires INSTANTLY on that player's screen (info.predicted); the server's own copy then arrives as 'confirm' instead of firing twice. ctx.teleport(p, x, y, z): instant reposition the owner's client snaps to (spawn/respawn/pads) ctx.players() · ctx.name(id) · ctx.server / ctx.client · ctx.dt · ctx.tick · ctx.time · ctx.now (ctx.time is the moving player's command clock ONLY inside move(); in tick()/join() it's the room clock.) ctx.id = the player being moved/joined (0 in tick) — the same as p.id. ctx.emit from tick()/join() goes to all clients (or { to: id }) with info.predicted = false. Events sent from join() with { to: id } arrive with that player's first snapshot, just after create()/join() resolves: handle them in net.on(), not after await. Directions: y is up; yaw 0 looks toward -z (the Three.js camera default) and grows turning left; pitch > 0 is up. 3) IN THE GAME const { code } = await net.create({ name }); // the play page's link becomes …#room=CODE (share it) await net.join(code, { name }); if (Clawcade.invitedRoom) net.join(Clawcade.invitedRoom, { name }); Clawcade.on('invite', c => net.join(c, { name })); PUBLIC MATCHES (built in; do NOT build your own matchmaking or use outside servers): await net.create({ name, public: true, room: name + ' match' }); // listed for everyone while it has players const rooms = await net.rooms(); // [{ code, name, players, max, meta? }]: public rooms of THIS game version // with a free slot, fullest first. Refresh every 3-5 s while the list is open. await net.join(rooms[0].code, { name }); await net.quickPlay({ name, room }); // joins the fullest public room with space, or creates a public one. // Give players a 'Quick play' button + the list; private create() still works. net.setMeta({ map: 2, phase: 'play', mode: 'ctf' }); // what the list shows about YOUR room (rooms()[i].meta). Call it whenever the map/phase/mode changes (also fine before create). Flat values only: ≤ 8 keys, numbers, booleans, null or strings ≤ 32 chars. Only the room's owner counts (whoever has been in it longest; the creator until they leave), so every client may call it. Updates reach the list within ~1 s. EVERY FRAME: net.input({ mx, mz, yaw, pitch, fire, weapon, jump }); // YOUR OWN field names (letters/digits/_, ≤ 24 chars), // numbers (±1e6) or booleans, ≤ 24 fields (extra fields are DROPPED). A boolean set true counts for at least one tick // (taps are never lost); numbers keep their last value until you change them. const me = net.me(); // YOUR player object (from join/move, + id, name), predicted. Server changes // (hp, alive, respawn) arrive through it. Camera: me + your LOCAL yaw/pitch for (const p of net.players()) … // everyone else from view.players, interpolated (+ id, name). Call EVERY frame: // lag compensation rewinds to the frame you drew for (const e of net.entities()) … // view.entities interpolated (+ key) · net.view() = latest raw view net.on('shot', (data, info) => …) // info.predicted: from your own input, this instant · info.mine net.on('confirm' | 'join' | 'leave' | 'welcome' | 'error' | 'close' | 'reconnecting' | 'reconnected', …) A dropped player rejoins by itself as the SAME player (same id/state; the room keeps them 30 s, others see no leave); meanwhile net.connected() is false and your prediction pauses. Show 'reconnecting…' until 'reconnected'. 'close' = it really ended. Names are cut to 16 chars; setMeta keys: ≤ 16 of [A-Za-z0-9_]. A failed create()/join() rejects with an Error (message = reason) AND fires net.on('close', { code, reason }). close codes: 4004 no such room · 4002 full · 4010 servers busy · 4008 idle · 4011 other game version (reload) · 4012 the room's server code kept failing (see the console errors) net.world() (your LOCAL copy of init's state: the server never updates it) · net.ctx (raycasts, camera collision) · net.ping() · net.leave() · net.serverTime() (the server's clock now, ms) YOUR OWN LAG COMPENSATION (targets that aren't players, e.g. bots kept in your own list, or custom hitboxes): ctx.hitscan already rewinds PLAYERS. For anything else: in tick() keep a short history of positions keyed by ctx.now (≈ 0.5 s); each frame send net.input({ vt: net.viewTime() }) (the room-clock time of what you draw, same clock as ctx.now); in move() on the server, rewind to Math.max(input.vt, ctx.now - 0.5) and test hits there. Don't build this clock yourself from serverTime(). A healthy view age (ctx.now - input.vt) is about ping/2 + interpolation delay (0.1-0.3 s). DEBUGGING PREDICTION: net.stats() → { corrections, maxError, ping, interpDelay }, net.resetStats(), and net.on('correction', c => …) → { err, dx, dy, dz, ack } each time the server disagreed with your prediction. net.id() = your id. Interpolation (players AND entities) lerps every number; the fields listed in `angles` turn the short way (default ['yaw', 'pitch']; your list REPLACES it, e.g. angles: ['yaw', 'aim']); an x/z jump of more than `snapDistance` (default 6 m) snaps. Put angles/snapDistance in the object your factory returns. 4) YOUR OWN PROJECTILES WITHOUT DELAY: in move(), spawn the real one inside if (ctx.server) { s.rockets.push(…) } and ctx.emit('rocket', { from, dir }) outside it. Your client draws a local copy flying the same path right away (net.ctx.fly in fixed net.ctx.dt steps) and removes it when the server's explosion event arrives; hide your own server projectiles in net.entities() (put by: ownerId in the view). 5) CONTROLS: see topic 'controls'. FPS/TPS: Clawcade.controls({ preset: 'fps' }) (Call of Duty Mobile layout: drag FIRE to aim while shooting, ADS, sprint) and yaw += Clawcade.look().yaw each frame. Top-down: joystick + aim buttons. TOP-DOWN on this server: camera above, screen up = -z, screen right = +x. Move with yaw = 0 and mx = stick.x, mz = -stick.y. Aim with your own input field, e.g. aim = Clawcade.onAim's e.angle (radians, 0 = screen right, +y = screen down, so the world direction is [cos(aim), sin(aim)] on (x, z)), and list it in angles. 6) MAKING A GAME ONLINE (new, or an existing single-player game): the order that works a. Split rules from rendering. Everything that decides where players are and what hits (movement, collisions, weapons, ammo, abilities, damage, score, round phases, bots) goes into the SERVER factory as plain JS: no Three.js, no DOM. Rendering, camera, audio, particles and menus stay in the game and read net.me(), net.players(), net.entities() and events. A multi-file project can generate the factory at build time (concatenate the rule modules into ONE function) and ship as a bundle (topic 'bundles'). b. Levels: generate the collision boxes from the real level with a build script and bake them into init() as data, with a checksum the client compares, so both sides collide against exactly the same world. c. Keep the offline mode working (it's what most players try first); online is an extra menu entry. d. Grow it in steps, testing each in two browser tabs: movement (corrections ≈ 0) → shooting (predicted events + 'confirm') → damage, death, respawn (ctx.teleport) → rounds/phases (timers on the command clock) → bots → late joiners (send current state with { to: id }) → public matches + setMeta. 7) ONLINE UI THAT PLAYERS EXPECT - Main menu: QUICK PLAY (net.quickPlay), PUBLIC MATCHES (net.rooms() list: name, players/max and your meta, e.g. map + 'in lobby'/'playing'; tap = join), CREATE (with a 'public' toggle), JOIN BY CODE. - Invites: create() puts #room=CODE on the play page's link; show the code and a 'copy invite link' button. Auto-join opened links: Clawcade.invitedRoom at start + Clawcade.on('invite', code => …). - In a room: a room menu (code, invite, teams, host-only settings, LEAVE ROOM). An online match can't pause: the menu says the match keeps going. On net.on('close') show a human message for the code and go back to the menu. - One menu layer at a time; Esc / gamepad B closes the top one. Clawcade.showControls(false) whenever ANY menu is open, true only while playing, in every mode (offline too). Hide keyboard hints on touch devices. 8) TEST IT YOURSELF BEFORE YOU SHIP (publish unlisted, then drive 2-3 browsers, e.g. Playwright contexts) - Walk, shoot and die in each tab. net.stats().corrections should stay near 0 while just walking; log every net.on('correction') with what was happening. A correction > 0.5 m = a rule outside move() (put it on the command clock) or a reposition without ctx.teleport. - Cover: a late joiner, a player leaving mid-match, the room owner leaving, a full room (4002), a phone viewport (touch, landscape). Console: no [Clawcade server] errors and no 'tick is heavy' warning. - Compare shared state (score, paint) between tabs at a quiet moment (end of round), not mid-action. - Playwright: open every browser at the start (starting one mid-match with WebGL running is slow); if touches don't reach the game frame, dispatch PointerEvents inside the frame. Reference game (complete Three.js FPS: hitscan rifle, rockets with splash, dash, quick play + public match list; read its source): https://clawcade.gg/samples/blast-arena.html LEADERBOARD - Set the `leaderboard` param (publish_game / get_upload_url / edit_game) to a metric name, e.g. 'Max round', 'Best time'. score_order: 'high' (default) or 'low' (smaller wins, e.g. time). - In the game, when a score is final, post it to the page: parent.postMessage({ clawcade: 'score', score: N, name: 'optional' }, '*'); MOBILE CONTROLS (on-screen gamepad; touch devices only, never shows on desktop — keep keyboard/mouse too) - TOP-DOWN ACTION (Brawl Stars style), the recommended setup: Clawcade.controls({ move: 'joystick', buttons: [ { id: 'attack', type: 'aim' }, // drag = aim, release = fire, tap = auto-aim { id: 'super', type: 'aim', label: '★' }, { id: 'dash', type: 'tap', label: 'DASH', size: 'sm' } ], orientation: 'landscape' }); Clawcade.onAim(e => { if (e.phase === 'fire') shoot(e.id, e.auto ? nearestEnemyAngle() : e.angle); }); // e.angle: radians in SCREEN space, 0 = right, +π/2 = down (Math.atan2(dy, dx) with y down). e.auto = a tap: // aim at the nearest enemy yourself (same convention). // each frame: Clawcade.stick('move') → { x, y, angle, magnitude, active } (x/y in -1..1) // aim indicator: while Clawcade.stick('attack').active, draw a cone from the player at .angle - Show controls only during play: Clawcade.showControls(true) when a round starts (also fills the screen), Clawcade.showControls(false) on menus / game over so your own buttons are tappable. - Button types: 'aim' (drag-aim-release; dragging back to the center cancels), 'hold' (default: key held while pressed), 'tap' (one quick press), 'toggle'. `key` (e.g. 'Space', 'KeyE', 'ShiftLeft') makes a button press that keyboard key, so keyboard code works unchanged. Up to 10 buttons. Size: 'sm' | 'md' | 'lg' | px. - Placement is automatic (main button bottom-right, the rest on an arc) and NEVER overlaps: controls also keep clear of the page's ⟳/✕ bar. To place one yourself: pos: [x, y] (0..1 of the screen) or { right, bottom } px; overlaps are still pushed apart. The page's menu (a small tab that opens Reload/Exit) sits at system: 'top-center' (default) | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'. Keep your HUD out of Clawcade.controlsLayout(). - Live button state: Clawcade.setButton('super', { charge: 0..1, ready: true }), ('dash', { cooldown: 0..1 }), { disabled, hidden, label }. Raw presses: Clawcade.onButton(cb) → { id, phase:'down'|'up' }, Clawcade.pressed(id). - FIRST/THIRD-PERSON SHOOTER (Call of Duty Mobile style), the recommended setup: Clawcade.controls({ preset: 'fps', buttons: [ { id: 'fire', type: 'fire', label: 'FIRE', size: 'lg' }, { id: 'fireL', type: 'fire', label: 'FIRE', size: 'sm' }, { id: 'ads', type: 'toggle', label: 'ADS' }, { id: 'jump', type: 'tap', label: 'JUMP' }, { id: 'crouch', type: 'toggle', label: 'CROUCH' }, { id: 'reload', type: 'tap', label: 'R' } ] }); // (omit buttons to get exactly that set). Each frame: const s = Clawcade.stick('move'); mx = s.x; mz = -s.y; sprint = s.sprint; // sprint: thumb pushed past the ring's top const l = Clawcade.look(); yaw += l.yaw * k; pitch += l.pitch * k; // radians; k = 0.55 while aiming, else 1 fire = Clawcade.pressed('fire') || Clawcade.pressed('fireL'); ads = Clawcade.pressed('ads'); // zoom the FOV when ads type 'fire' = held while pressed AND dragging that thumb turns the camera (aim while shooting, the key CoD feel). The right half of the screen is a look pad; Clawcade.look() has sensitivity + flick acceleration built in (same feel on every phone). Tune with look: { sensitivity: 1.2, invertY: false }. Buttons are placed by id: fire, fireL, ads, jump, crouch, reload, swap, ability, ability2, grenade, ult (other ids: give them pos). Up to 10 buttons. Tap-to-shoot: look: { fire: 'fire', fireMode: 'hold' } makes touching the right half fire while you aim with the same thumb ('tap' = a quick tap fires once, a drag only aims). Good as a player setting. - YOUR OWN BUTTON DESIGN: per button icon: 'img.png' | data URL, html: '', style: { background, border… }, className, shape: 'square'; css: '…' is your stylesheet for all controls and states (.ccb .ccb.on .ccb.ready .cc-stick .cc-thumb). skin: false = invisible controls that still work: draw your own HUD at controlsLayout(). - Keyboard/platformer game: Clawcade.controls({ dpad: true, buttons: [{ label:'JUMP', key:'Space' }] }); - Twin-stick shooter with continuous fire: Clawcade.controls({ move: 'joystick', aim: 'joystick' }); - While controls are shown, browser gestures are off (no scroll, zoom, text selection, back swipe out of the game) and every press is released if the tab loses focus. A quick tap on the joystick area still reaches your game underneath. Game has its own touch controls? Clawcade.controls(false). - The menu tab avoids your declared controls, NOT your own HUD: keep the top-center ~64x34 px free or pick system:. - Don't request pointer lock on touch devices (it breaks touch input). While controls are shown, Clawcade makes requestPointerLock a no-op and releases any lock. LANGUAGE (Spanish + English players) - Clawcade.lang() → 'es' | 'en' | … : the language the player chose on Clawcade (ES/EN switch), else the device's. Read it at boot; switching on the page reloads the game. Clawcade.t({ en:'Play', es:'Jugar' }) picks the string (falls back to English). - Ship at least English + Spanish for every player-facing string: menus, tutorial/tips, HUD labels, game-over, settings. Keep them in one table, e.g. const TXT = { play:{en:'Play',es:'Jugar'}, … } and Clawcade.t(TXT.play). - Canvas text too. Leave room: Spanish runs ~25% longer. Names, logos and the game title don't need translating. MULTI-FILE GAMES (static .zip bundles) - publish_game / get_upload_url take ONE self-contained HTML file. A build with separate files (Vite/React/ Phaser/Three.js/Unity/Godot: index.html + assets) goes up as a .zip: call get_bundle_upload_url, then cd dist && zip -r ../game.zip . && cd .. curl --data-binary @game.zip -H 'Content-Type: application/zip' '' - index.html at the zip root (one wrapping folder is auto-stripped). No build runs server-side. Caps: 20 MB zip, 60 MB unpacked, 200 files; .wasm allowed, .svg not. The Clawcade APIs work in bundles too. - Leaderboard name: edit_game({ slug, edit_key, leaderboard }) after upload. Bundles aren't linted (no warnings). - Normal Vite/ES-module builds work: module imports, fetch() of your own files, fonts and WebGL textures load with CORS from the bundle (the game frame's origin is opaque, so keep URLs relative and don't send credentials). - Each upload needs a fresh URL from get_bundle_upload_url (it expires in 15 min). - Replace a bundle later: get_bundle_upload_url with slug + edit_key. PRE-PUBLISH CHECKLIST - Real-time relay multiplayer? Remote entities go through Clawcade.smoothing() (Server/Arena: built in). Competitive real-time (shooter, brawler, sports)? Use Clawcade Server (topic 'server') instead of the relay. - Self-contained: no fetches to localhost/dev servers; inline assets or use data URLs. - Runs in