The cookbook
Prompts that produce a game which validates first time. Copy one, change what you want, and hand it to your agent.
- 28 recipes
- Agent-readable
- Free to use
What these are
A blank prompt is the hardest way to start. Each recipe below is a complete brief — the shape of the game, how it scores, how it should feel, and where the run ends — written to be pasted straight into whatever agent you use, with no editing required.
Every prompt ends with the same block of platform rules: one self-contained file, no network, no storage, portrait-first, the SDK calls and the audio and pause guards. That is the whole point of the thing. A creator who follows a recipe should never end up fighting the validator, so the constraints travel with the prompt rather than being something you have to remember to add.
Most also carry technique notes: the specific things that are wrong in nearly every first attempt at that pattern. They are short and they are the difference between a game that works and a game that works properly.
Not every recipe ships the same way. Most are ordinary single-file games. A Multiplayer recipe needs the multiplayer capability on your game, and a Preview recipe targets creator logic, which is built but cannot be published yet — copy one to experiment, not to ship. The badge is on each recipe below.
How to use one
A connected agent browses the same catalog live with list_cookbook_recipes and pulls one with get_cookbook_recipe, using the ids shown on each recipe below. You can just say “use the one-thumb-runner recipe” and it will read the current version rather than a copy you pasted.
Open a recipe, copy the prompt, and send it to any agent — connected or not. If it has no MCP support, ask for one self-contained HTML file and drop it at /create/upload, which runs the same validation the tools do.
Recipes are a starting point, not a template to follow literally. Change the theme, the numbers and the mechanics freely — the part worth keeping is the platform rules block at the end of each one.
The catalog
Genre starters
Complete briefs for a whole game, from nothing.
One-Thumb Endless Runner
An auto-running dodge-and-jump game driven by a single tap anywhere on screen. Legible in one glance, hard to put down.
- one-thumb-runner
- runner
- one-touch
- endless
- canvas
What goes wrong in most first attempts
- Step physics on a fixed 1/60 s accumulator rather than on raw dt. A 120 Hz phone and a throttled tab otherwise produce different jump arcs, and the second one is unplayable.
- Spawn obstacles against a distance counter, never a timer. When the scroll speed ramps, a timer quietly shrinks the gaps below what the jump arc can clear.
- Keep obstacles in one array of plain objects and reuse dead entries. Allocating a new object per spawn is what makes a runner hitch every few seconds.
- Clamp dt to about 1/20 s before integrating. One long frame otherwise teleports the player through an obstacle.
Show the full promptHide the prompt
one-thumb-runner — paste this to your agent
Build a one-thumb endless runner for TipTap Games.
The shape of it:
- The character auto-runs at a constant speed on the left third of the screen and
the world scrolls toward them.
- ONE input: a tap or click anywhere on the canvas, plus Space and ArrowUp for
keyboard. No on-screen buttons, no swipe, no second gesture.
- That input jumps. Variable jump height is the one variation worth adding: apply
reduced gravity while the pointer is still held, for at most 180 ms.
- Two obstacle kinds is enough — something to jump over and a gap to clear. Never
spawn a pair that cannot be passed; check every spawn against the jump arc at the
current speed before you commit it.
- One hit ends the run. No health bar. A good player should last 30 to 90 seconds.
Scoring:
- Distance travelled, plus a small bonus per obstacle cleared. Integers only.
- Call TipTap.updateScore on every change so the platform's top bar tracks the run.
Difficulty:
- Ramp scroll speed from comfortable to roughly 1.8x over 90 seconds, on a curve
that flattens rather than a straight line.
- Ramp obstacle density with it, but hold a minimum gap that scales with the current
speed so the game never becomes unclearable.
Feel:
- Two or three parallax background layers at different speeds. Flat colours and
simple shapes are fine; this is not a sprite exercise.
- A squash on landing, two or three particles on takeoff, a short colour flash on
impact.
- 3 to 5 px of screen shake for about 120 ms on death only, never during normal play.
Game over:
- Freeze the world for about 250 ms so the player sees what killed them, then submit
the score and raise the leaderboard.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.Tap-Timing Precision Bar
A sweeping marker and a shrinking target zone. One tap per round, scored on accuracy. Understood in two seconds, mastered in fifty runs.
- tap-timing-bar
- timing
- one-touch
- arcade
- precision
What goes wrong in most first attempts
- Drive the marker from accumulated time, not from a per-frame increment. A dropped frame otherwise changes where the marker is, and the player is punished for your frame budget.
- Judge accuracy against the marker position at the moment of the pointerdown event, not at the next animation frame. That one line is the difference between tight and mushy.
- Use pointerdown rather than click, and set touch-action:none on the body. click adds roughly 100 ms of browser delay on some mobile browsers.
- Shrink the zone on a curve with a hard floor (never below about 4% of the bar). A zone that keeps shrinking linearly ends the game on luck rather than skill.
Show the full promptHide the prompt
tap-timing-bar — paste this to your agent
Build a tap-timing precision game for TipTap Games.
The shape of it:
- A horizontal bar across the middle of the screen. A marker sweeps left to right and
back, continuously.
- A target zone sits somewhere on the bar. One tap per round locks the marker.
- Score the round by how close the marker was to the centre of the zone: three bands
is right. Perfect (the middle fifth of the zone), Good (the rest of the zone), Miss
(outside it).
- Perfect and Good advance the round. A Miss costs one of three lives.
- Every round: move the zone somewhere new, shrink it slightly, and speed the sweep
up slightly.
Scoring:
- Perfect scores triple a Good. Chain perfects for a multiplier that caps out — see
the combo-multiplier recipe if you want the full treatment.
- Show the band you hit as large text for about 400 ms. This is the entire feedback
loop of the game; do not make it subtle.
Feel:
- Snap the marker to the exact lock position and hold it there for the feedback beat
before the next round starts. A marker that keeps moving through the result reads
as a bug.
- A perfect is worth a brief flash, a rising tone and a handful of particles at the
lock point. A miss is worth a red pulse and a short screen shake.
- Keep the whole loop at one tap: no confirm, no continue button between rounds.
Game over:
- On the third miss, hold the final frame for about 250 ms, then submit and show the
board.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.Swipe Grid Puzzle
A four-by-four board where one swipe slides everything and merges matching tiles. Turn-based, no timer, thumb-sized targets.
- swipe-grid-puzzle
- puzzle
- swipe
- grid
- turn-based
What goes wrong in most first attempts
- Resolve a swipe on pointerup from the dominant axis of the total delta, with a threshold of about 24 px. Reading direction per pointermove produces diagonal swipes that fire twice.
- Set touch-action:none on the play area or the browser scrolls the feed instead of taking your swipe.
- Merge each tile at most once per move, and resolve from the far edge toward the near one. Both bugs are invisible until a row of four identical tiles collapses to one instead of two.
- Ignore a move that changes nothing: no slide, no merge, no spawn. Spawning on a no-op move is the classic way these games become unwinnable.
- Animate from the previous board to the next one rather than animating the model. Keeping the model instantaneous is what makes rapid swipes stay correct.
Show the full promptHide the prompt
swipe-grid-puzzle — paste this to your agent
Build a swipe grid puzzle for TipTap Games.
The shape of it:
- A four-by-four board of tiles, each holding a value. The board fills the width of
the portrait viewport with a comfortable margin.
- A swipe in any of the four directions slides every tile as far as it can go in that
direction. Two adjacent tiles of equal value merge into one of the next value, and
each tile merges at most once per move.
- Every move that actually changed the board spawns one new low tile in a random
empty cell.
- The game ends when no move can change the board.
- Keyboard arrows do the same thing as swipes.
Scoring:
- Score is the sum of the values created by merges, so a merge into a high tile is
worth more than several low ones.
- Call TipTap.updateScore after each move resolves.
Feel:
- Slide animations of about 120 ms and a merge pop of about 100 ms. Anything slower
and rapid play feels blocked; anything faster and the player cannot follow what
moved.
- The board should be readable at a glance: a distinct colour per value, big
numerals, high contrast.
- No timer, no pressure. This is the game someone plays while thinking.
Game over:
- Detect the no-legal-move state immediately after the spawn, not at the start of the
next input. Dim the board, submit the score, and raise the leaderboard.
Because this game has no run timer, treat the dead board as the only terminal state,
and let the player pick up a fresh board from the leaderboard's Continue.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.Sixty-Second Idle Clicker
A whole incremental arc compressed into one minute: tap, buy generators, watch the curve run away, bank the score.
- sixty-second-idle
- idle
- incremental
- tap
- one-minute
What goes wrong in most first attempts
- BROWSER storage is unavailable — localStorage throws in the sandbox — but the platform's own save state is not: TipTap.saveState / TipTap.loadState persist up to 8 KB across reloads and, for a signed-in player, across devices. This recipe still chooses not to use it, and that is a design decision rather than a limitation: a one-minute arc is a complete thing you can finish on a bus. If you want an endless idle game, keep everything else here and persist the run with saveState — see the SDK reference.
- Accumulate production from dt, not from a setInterval tick. An interval drifts and stops being paid while the tab is backgrounded, which silently makes the run shorter.
- Price generators geometrically (cost = base * 1.15^owned) and recompute the affordable set once per frame, not per button.
- Numbers pass 1e6 inside a minute: write one formatter (12.4K, 3.1M, 880M) and use it everywhere, including the score you submit.
Show the full promptHide the prompt
sixty-second-idle — paste this to your agent
Build a one-minute idle clicker for TipTap Games.
The premise: compress the entire incremental arc into a single 60-second run, so the
player feels the curve run away from them before the timer ends.
A note on saving, because this recipe used to be wrong about it. Browser storage IS
unavailable — localStorage throws in the sandbox — but the platform gives you
TipTap.saveState and TipTap.loadState, which persist up to 8 KB across reloads and
across devices for a signed-in player. So an endless idle game is entirely possible
here. This recipe deliberately does not build one: a one-minute run is a whole
experience you can finish on a bus, which suits the feed. If you want the endless
version, keep every other instruction below and persist the run instead of ending it.
The shape of it:
- A big tappable target in the lower half of the screen, thumb-reachable. Each tap
adds to the resource directly.
- Three or four generator types in a list above it, each with a cost, an owned count
and a per-second yield. Buying one raises its own price by about 15%.
- A 60-second countdown across the top, running from the first input.
- Production accrues continuously from elapsed time, not on a tick.
The arc, and this is the design work:
- Seconds 0-10: only tapping matters.
- Seconds 10-30: the first generator out-earns tapping, and the player notices.
- Seconds 30-50: the third generator turns the curve vertical.
- Seconds 50-60: nothing left to buy, just watching the number climb.
Tune the base costs and yields until that is what actually happens.
Scoring:
- The score is the total resource EARNED over the run, not the balance left after
purchases. Spending must never lower the score, or buying feels like a punishment.
Feel:
- The tap target wants a scale-down on press and a number that floats up and fades.
- A generator becoming affordable should announce itself — a glow, a subtle pulse.
- Keep every buy button at least 44 px tall. This game is played with one thumb at
speed.
Game over:
- At zero, freeze the counters, submit the earned total, and show the board.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.A Top-Down Arena the Platform Simulates
Preview · not publishable yetReal-time movement, walls, projectiles, health and respawns on arena2d — 60 Hz, host-authoritative, with the prediction written for you.
- sim-arena2d
- multiplayer
- simulation
- arena2d
- shooter
- realtime
What goes wrong in most first attempts
- maxSpeed / 60 must not exceed bodyRadius or the whole config is refused: a body crossing more than its own radius in one tick can tunnel through the SECOND wall after a slide, which three collision iterations cannot see. A maxSpeed of 9 against a bodyRadius of 1 is comfortable.
- The other hard bounds, each of which now names itself in the refusal: every axis of bounds must be at least 4 * bodyRadius, friction is 0..1, and you get at most 32 boxes, 16 circles and 4 resource slots.
- A generous hitbox is the single biggest lever on how a bad connection feels — a wide body absorbs the position error a 150 ms link produces, so a fair shot still lands. Size bodyRadius at roughly a twentieth of the map's short side and resist making it smaller.
- Draw the weapon's reach as a ring derived from the config: bodyRadius + projectileRadius + projectileSpeed * projectileTtlTicks / 60 + projectileRadius + bodyRadius. It tells the player what the simulation is about to decide, and retuning the gun moves it.
- facing is an angle in radians and it is INTENT — Math.atan2(aim.y - view.self.pos.y, aim.x - view.self.pos.x). It is the one number a game on this module works out for itself, and it is never read back into the simulation.
- Score from death events with e.predicted === false, never from hit. hit fires per projectile and a predicted death is this client guessing; count either and every player has a different scoreboard.
- Objective zones are sim.query({ kind: 'overlap' }) and the answer that counts is the HOST's — on any other client it is a round trip you pay for per player. Poll it inside TipTap.net.runOnHost against view.match.tick rather than the display's clock, publish the table with TipTap.net.state.set, and let each client run its own total in between and be overwritten when the host's arrives.
Show the full promptHide the prompt
sim-arena2d — paste this to your agent
Build a real-time top-down arena game for TipTap Games on the arena2d
simulation module. Declare arena2d as the game's one simulation module — see the
simulation-tier recipe for the boot sequence, which this one assumes.
The shape of it: two to eight players in one small room, circular bodies, a
point-blank weapon, and an objective that forces them together. A round lasts
about sixty seconds.
The arena, and small is the design:
var W = 22, H = 34; // world units, portrait, because the feed is
var CONFIG = {
bounds: { min: { x: 0, y: 0 }, max: { x: W, y: H } },
bodyRadius: 1,
accel: 52,
friction: 0.86,
maxSpeed: 9, // 9/60 = 0.15 units a tick, well under the radius
gravity: { x: 0, y: 0 }, // top-down
projectileSpeed: 24,
projectileRadius: 0.45,
projectileTtlTicks: 6, // six ticks of flight — a reach, not a rifle
projectileDamage: 13, // eight hits to a knockout against 100
fireCooldownTicks: 14,
respawnTicks: 100,
maxProjectiles: 28,
resources: [{ max: 100, initial: 100 }],
boxes: [ // two walls with slots, three rooms
{ min: { x: 0, y: 10.4 }, max: { x: 6.4, y: 12.0 } },
{ min: { x: 9.6, y: 10.4 }, max: { x: 12.4, y: 12.0 } },
{ min: { x: 15.6, y: 10.4 }, max: { x: 22, y: 12.0 } }
],
circles: [{ center: { x: 11, y: 17 }, radius: 2.2 }]
};
Keep the whole map on screen at all times. No camera. A correction that happens
where a player cannot see it is a correction they experience as teleporting.
The input, which is three fields and nothing else:
function readIntent(self) {
var mx = (keys.right ? 1 : 0) - (keys.left ? 1 : 0);
var my = (keys.down ? 1 : 0) - (keys.up ? 1 : 0);
var len = Math.sqrt(mx * mx + my * my);
if (len > 1) { mx /= len; my /= len; } // a diagonal is not faster
var facing = 0;
if (self) facing = Math.atan2(aim.y - self.pos.y, aim.x - self.pos.x);
return {
moveX: mx,
moveY: my,
facing: facing,
buttons: firing ? (TipTap.sim.BUTTON.FIRE || 0) : 0
};
}
Read BUTTON.FIRE at the call site. It does not exist until define resolves.
The weapon, and this is the design decision the whole game rests on: make it
point-blank. Six ticks of flight at 24 units a second is 2.4 units of travel, so
the whole reach centre to centre is about five units in a map whose diagonal is
forty. You cannot poke at somebody, you have to be on them, and a slow
time-to-kill means one whiffed lunge costs a beat rather than a life. Draw the
reach as a ring under the player's feet.
The objective, which is what stops it being a deathmatch nobody can read:
- One zone, lit, that moves to a new spot every fifteen seconds. Standing in it
earns time. Most time at the end wins, with knockouts as the tiebreak.
- Who is standing in it is a spatial question about the whole room, so it is
sim.query's job and never yours:
TipTap.net.runOnHost(function () {
TipTap.sim.query({ kind: 'overlap', center: core, radius: 2.8 })
.then(function (r) {
for (var i = 0; i < r.peers.length; i++) hold[r.peers[i]] += earned;
TipTap.net.state.set('hold', hold.slice(0, players));
});
});
- Poll it against view.match.tick rather than a wall clock or a frame count, so
every client asks at the same authoritative moment.
- Every client also runs its own overlap for a bar that moves smoothly between
publishes. That local table is a PREDICTION of the score in exactly the sense a
predicted event is a prediction of a hit, and the host's table overwrites it —
never merges with it — the moment it arrives.
Events:
- hit, death, respawn, collide and expired all arrive on sim.onEvent. hit carries
source, target, resource and amount; death carries peer and killer.
- Flash on any of them. Count only the ones where e.predicted is false.
The round:
- Derive it from view.match.tick: 3600 ticks is sixty seconds on every client
with nothing replicated and nothing agreed.
- At the end, the host reports the result and every client submits its own score
to the leaderboard.
The three symptoms to watch for while playing, because they are how this module
tells you something is wrong: a body that stops dead on a corner it should have
missed, a player standing on the objective whose bar is not moving, and another
player who slides rather than moves. A one-line HUD reading
view.local.corrections and view.local.pendingInputs is what tells you which of
those was the netcode.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.
Multiplayer rules, on top of everything above:
- TipTap.net exists ONLY in a game flagged multiplayer. It is a separate module the
platform injects, and TipTap.apiVersion does not move when it does. Guard with
window.TipTap && TipTap.net && TipTap.net.isAvailable(), in that order, and make the
game do something sensible when the answer is no — that is every local test run.
- The game still has no network of its own. connect-src is still 'none' and fetch,
WebSocket and EventSource are still blocked. The connection lives outside the frame.
- ONLY THE HOST WRITES STATE, and this is not like Playroom or Colyseus. One client in
the room is the host. TipTap.net.state.set and its siblings called anywhere else warn
on the console, return false and change nothing — no exception, no partial write. A
non-host player's move is a request the host validates and applies. Every authoritative
decision goes inside TipTap.net.runOnHost(). Design for this from the first line, or
three players in four cannot act and nothing says why.
- A non-host reaches the host with TipTap.net.sendTyped, which is GATED: it needs a
schema approved for the game and returns false until there is one. Check the return
value and tell the player. Never let a move disappear with only a console warning.
- A SCHEMA is the complete list of what a player may send, declared as JSON with the
game, and its fields may only be: bool, enum, uint, int, fixed, peer, preset, array
(with a required max) and struct. No strings, no bytes, no maps, no floats, no
unbounded lists — there is no extension hook and no flag that adds one. Declaring a
schema is not the same as having it approved: until a human approves the set the game
has no typed channel at all and sendTyped returns false for every name. A game that
works in your window and not for other players is usually waiting on that, not broken.
- Never Date.now() for anything compared between players — TipTap.net.now() is room
time. Never Math.random() in anything two clients both compute —
TipTap.net.randomFor(scope, id) is the same stream on every client. NOTHING ENFORCES
EITHER OF THESE: there is no lint and no validator rule, so a game that gets them
wrong passes every check, plays perfectly in one window, and desyncs silently with a
second player. Getting it right while writing is the only mechanism there is.
- Nothing derived from randomFor is secret; every client can recompute it. Hidden
information is rolled on the host and written with TipTap.net.state.setScoped().
- Never write reconnection logic. Reconnect, resync and liveness are handled.
TipTap.net.onDisconnect and TipTap.net.onReconnect are for showing a banner.
- Public rooms carry typed messages only. No free strings of any kind reach another
player, and nothing a peer sent may be rendered as text.
- validate_game_draft runs ONE client. It cannot test a join, a turn, a replication, a
host handover or a lobby, so a passing validation says nothing about whether the
multiplayer works. Say that plainly when you hand the game over.Pool, Golf and Curling: One Stroke at a Time
Preview · not publishable yetDrag to aim, release to strike, watch it settle. turnphysics owns the turn order, the contacts and the rest position on every device.
- sim-turnphysics
- multiplayer
- simulation
- turnphysics
- turn-based
- physics
What goes wrong in most first attempts
- A POOL CUE BALL MUST CARRY flags: 2. Re-spotting is opt-in per body and a potted body never comes back without it — so after a scratch the module falls through to the next usable body and every later stroke plays an OBJECT ball, and if there is no other usable body the commit produces no shot at all and the turn passes round the room in silence forever. Measured both ways against the real engine. The rack must NOT carry the flag: a potted eight ball has to stay potted.
- Set maxAimTicks. It bounds the AIMING phase; maxShotTicks only bounds a shot already in flight. Without it a player who locks their phone stalls the match permanently and no other seat, including the host, can pass the turn. The default is 1800 ticks (30 s), the range is 60 to 10800, and 0 turns it off.
- The commit bit is an EDGE the module latches. Hold it for about six frames on release and then drop it: held forever it fires once and never again, pulsed for one frame it can fall between two of the SDK's 60 Hz samples. Re-send the power on those frames or you commit a zero-power shot on the tick that matters.
- view.match.phase on this module is the module's own flag byte, NOT the relay's warm-up/live/ended string — the booleans view.match.simulating, .scored and .latched sit next to it. Gate input on view.match.turn === yourSeat && !view.match.simulating, and read the relay's phase from sim.onMatchState instead.
- Snap every position in the config to the 1/128 lattice yourself — Math.round(v * 128) / 128 — because the module snaps them anyway and you want the tee you wrote to be the tee it uses.
- A polygon static must be convex and counter-clockwise, a body may not be owned by a seat the room does not have, and restitution outside 0..1 is refused. Each refusal names which one it was — 'statics.polygon', 'bodies.owner', 'restitution' — so you no longer have to add statics one at a time to find out.
- Strokes come from shotCommitted and pots from captured, both with e.predicted === false. The settled event is the frame this whole module exists to produce — make an object out of it on screen rather than letting it slide past.
Show the full promptHide the prompt
sim-turnphysics — paste this to your agent
Build a turn-based physics game for TipTap Games on the turnphysics simulation
module — pool, golf, curling, shuffleboard, marbles. Declare turnphysics as the
game's one simulation module; the simulation-tier recipe has the boot sequence
this one assumes.
Why this module suits a feed: one actor at a time, so latency is irrelevant by
construction, and a shot resolves in about three seconds. Pick something a
watcher understands from one stroke.
The table. Two shapes work and they are different games:
ONE BODY PER SEAT (golf, curling, shuffleboard). Every seat owns its own body, so
nobody can ever play somebody else's:
bodies.push({
pos: teeFor(s), radius: 1.1, mass: 1,
owner: s, // the seat index — refused if the room has no such seat
tag: s,
flags: 0
});
ONE SHARED CUE (pool, snooker, shove-ha'penny). One unowned body every seat
strikes in turn, plus a rack:
bodies.push({
pos: cue, radius: 1.1, mass: 1,
owner: 65535, // UNOWNED — whoever's turn it is strikes this one
tag: 0,
flags: 2 // DEF_RESPOT. THIS IS NOT OPTIONAL. See below.
});
// …then the rack, every one of them flags: 0.
flags: 2 is the whole difference between a pool game and a broken one. A body
that is captured or leaves the field goes inactive, and nothing puts it back
unless it carries that bit. So after a scratch the module strikes the next usable
body instead — every stroke for the rest of the match plays an object ball — and
on a table where there is no other usable body the commit produces no shot at
all and the turn passes round the room in silence, forever. The flag is opt-in
per body precisely so the cue comes back and the eight ball does not.
The rest of the config, and these are the fields worth choosing deliberately:
var config = {
bounds: { min: { x: 0, y: 0 }, max: { x: 32, y: 56 } },
boundsMode: 'reflect', // rails. 'remove' is a cliff — pair it with respot
boundsRestitution: 0.72,
gravity: { x: 0, y: 0 },
bodies: bodies,
vertices: VERTS, // shared vertex pool; polygons index into it
statics: statics, // boxes, circles, convex CCW polygons
targets: [{ // a cup, a pocket, a house
region: { center: { x: 16, y: 28 }, radius: 1.8 },
tag: 1,
flags: 3 // 1 = captures the body, 2 = scores it
}],
restitution: 0.94, // ball on ball — a click, not a thud
friction: 0.16,
linearDamping: 0.965, // 0.12 per second: a full shot dies in ~3.4 s
spinDamping: 0.97,
magnus: 0, // no in-flight curve; side spin still bites at contact
restSpeed: 0.5,
settleSpeed: 0.0625,
settleSpin: 0.25,
settleTicks: 8,
maxShotTicks: 600, // bounds a shot in flight
maxAimTicks: 1800, // bounds the TURN. Do not leave this at 0.
maxPower: 90,
maxSpin: 14,
launchOffset: 0.25,
shotMode: 'strike', // 'launch' fires a projectile instead
turnPolicy: 'rotate' // or 'rotateUnlessScored', or 'fixed'
};
The gesture — one drag, and that is the entire control scheme:
function myTurn(view) {
return view.match.turn === seat && !view.match.simulating;
}
function release() { // pointerup
var pull = pullOf(drag); // aim = atan2 back along the drag
drag = null;
if (pull.power <= 0) return;
commitFrames = 6; // hold the commit bit, then let go
}
function readIntent(view) {
var out = { aim: lastAim, power: 0, spin: spin * 0.6, buttons: 0 };
if (drag) { out.aim = lastAim; out.power = livePower; }
else if (commitFrames > 0) { out.aim = lastAim; out.power = lastPower; }
if (commitFrames > 0) { out.buttons = TipTap.sim.BUTTON.COMMIT || 1; commitFrames--; }
return out;
}
Pull back and let go sends it the other way — every pool and golf game on a
phone has already taught the player that. Keep calling sim.input every frame
including while paused, or a held commit bit latches the shot forever.
Events: shotCommitted, contact, captured, outOfBounds, settled, turnChanged. Draw
from all of them; count only e.predicted === false. settled also carries
timedOut, which means maxShotTicks expired with something still moving — that is
a config problem, not a rules call, and it is worth saying so on screen while you
are tuning.
What makes it legible, and it is cheap:
- Never clear the table. Bodies lie where they stopped and the next stroke is
played from there, so every rest position is still on screen turns later.
- Stamp a ring where each body stopped, on the settled event, and leave it until
the next strike. "Where it stopped" becomes a drawn object rather than
something a player has to remember — and it is also how a determinism problem
becomes visible without an overlay.
- Say whose turn it is by name and colour, and show how long they have been
thinking. view.match.shotTicks is the phase clock and counts the turn's age
while nothing is moving.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.
Multiplayer rules, on top of everything above:
- TipTap.net exists ONLY in a game flagged multiplayer. It is a separate module the
platform injects, and TipTap.apiVersion does not move when it does. Guard with
window.TipTap && TipTap.net && TipTap.net.isAvailable(), in that order, and make the
game do something sensible when the answer is no — that is every local test run.
- The game still has no network of its own. connect-src is still 'none' and fetch,
WebSocket and EventSource are still blocked. The connection lives outside the frame.
- ONLY THE HOST WRITES STATE, and this is not like Playroom or Colyseus. One client in
the room is the host. TipTap.net.state.set and its siblings called anywhere else warn
on the console, return false and change nothing — no exception, no partial write. A
non-host player's move is a request the host validates and applies. Every authoritative
decision goes inside TipTap.net.runOnHost(). Design for this from the first line, or
three players in four cannot act and nothing says why.
- A non-host reaches the host with TipTap.net.sendTyped, which is GATED: it needs a
schema approved for the game and returns false until there is one. Check the return
value and tell the player. Never let a move disappear with only a console warning.
- A SCHEMA is the complete list of what a player may send, declared as JSON with the
game, and its fields may only be: bool, enum, uint, int, fixed, peer, preset, array
(with a required max) and struct. No strings, no bytes, no maps, no floats, no
unbounded lists — there is no extension hook and no flag that adds one. Declaring a
schema is not the same as having it approved: until a human approves the set the game
has no typed channel at all and sendTyped returns false for every name. A game that
works in your window and not for other players is usually waiting on that, not broken.
- Never Date.now() for anything compared between players — TipTap.net.now() is room
time. Never Math.random() in anything two clients both compute —
TipTap.net.randomFor(scope, id) is the same stream on every client. NOTHING ENFORCES
EITHER OF THESE: there is no lint and no validator rule, so a game that gets them
wrong passes every check, plays perfectly in one window, and desyncs silently with a
second player. Getting it right while writing is the only mechanism there is.
- Nothing derived from randomFor is secret; every client can recompute it. Hidden
information is rolled on the host and written with TipTap.net.state.setScoped().
- Never write reconnection logic. Reconnect, resync and liveness are handled.
TipTap.net.onDisconnect and TipTap.net.onReconnect are for showing a banner.
- Public rooms carry typed messages only. No free strings of any kind reach another
player, and nothing a peer sent may be rendered as text.
- validate_game_draft runs ONE client. It cannot test a join, a turn, a replication, a
host handover or a lobby, so a passing validation says nothing about whether the
multiplayer works. Say that plainly when you hand the game over.A Platformer With Ground That Moves
Preview · not publishable yetRun, jump, wall-jump and ride moving platforms on platformer2d, with the carry the module gets right and publishes as a field.
- sim-platformer2d
- multiplayer
- simulation
- platformer2d
- co-op
- platformer
What goes wrong in most first attempts
- Never infer 'standing on that platform' by comparing rectangles you computed. view.self.parent is the platform index the module says a body is riding, or null. It is already correct under prediction and it is the field a riding mechanic is built on.
- y is UP: gravity is a positive number that pulls toward negative y. Flip once in the render transform and never convert a coordinate by hand below that line.
- Set airFriction: 1. The default 0.999 costs about a tenth of a unit over a jump — invisible in play, and enough to blur a rider landing back on the exact spot of a moving deck it left from, which is the sharpest thing this module can show a player.
- Keep the air above a moving platform's turnaround clear. A body a platform moves into is pushed back out, so a one-way whose underside sits inside a jump arc scoops the jumper up and carries them off. Compute the apex — jumpVelocity * jumpVelocity / (2 * gravity) — and author every ceiling above it.
- The body is a BOX, not a circle: bodyHalf, not a radius. That is the deliberate difference from arena2d — a circle rolls off the corner of a ledge and 'I was clearly on it' is the complaint that follows. Make a shared platform at least three body-widths across.
- vel while riding is measured in the PLATFORM's frame, so vel.x === 0 means 'stopped relative to the deck'. A body that lands on a moving platform keeps its world velocity for the moment friction takes to match it, exactly like stepping onto a walkway — so the tick it lands is the one tick its offset is supposed to change.
- On a non-host client the local body is drawn from prediction and the platforms at the interpolated tick, so a rider's offset on a deck appears to drift by up to a unit at 100 ms while the physics underneath is exact. There is no correction available to a game — do not build a mechanic that reads sub-unit alignment on a moving deck.
Show the full promptHide the prompt
sim-platformer2d — paste this to your agent
Build a co-op platformer for TipTap Games on the platformer2d simulation module.
Declare platformer2d as the game's one simulation module; the simulation-tier
recipe has the boot sequence this one assumes.
The shape of it: two players, one vertical shaft, a set of moving platforms on a
clockwork. One platform is lit. Both of you get on it and stay on it for three
quarters of a second, then a different one lights. Sixty seconds. Falling costs
your chain.
Pick a scoring condition made of the module's hard part, which is riding a
moving platform. A game that would still look fine with the carry broken is not
using this module for anything.
The level:
var LEVEL = {
bounds: { min: { x: 0, y: -6 }, max: { x: 20, y: 34 } },
solids: [ // walls and ledges; no floor between them
{ min: { x: 0, y: -6 }, max: { x: 1, y: 34 } },
{ min: { x: 19, y: -6 }, max: { x: 20, y: 34 } },
{ min: { x: 1, y: 0 }, max: { x: 6.5, y: 0.65 } }
],
oneWays: [ // boardable from below, droppable with down+jump
{ min: { x: 8.5, y: 7.0 }, max: { x: 13, y: 7.4 } }
],
platforms: [ // periodTicks is a full there-and-back
{ half: { x: 1.8, y: 0.3 }, from: { x: 4, y: 3.2 }, to: { x: 15, y: 3.2 },
periodTicks: 300, phaseTicks: 0, oneWay: true },
{ half: { x: 1.4, y: 0.3 }, from: { x: 17, y: 7.4 }, to: { x: 17, y: 15.4 },
periodTicks: 260, phaseTicks: 0, oneWay: true }
],
ladders: [],
bodyHalf: { x: 0.4, y: 0.55 }, // a BOX
spawn: { x: 2.6, y: 1.2 },
spawnSpacing: 1.6,
runSpeed: 8,
runAccel: 100,
airAccel: 55,
groundFriction: 0.8,
airFriction: 1, // see the hints — leave this at exactly 1
gravity: 62,
jumpVelocity: 25, // apex 25*25/(2*62) = 5.04 units
terminalVelocity: 30,
jumpCut: 0.42, // variable jump height
coyoteTicks: 7,
jumpBufferTicks: 7,
wallSlideSpeed: 6,
wallJump: { x: 11, y: 16 },
wallJumpLockTicks: 10,
dropThroughTicks: 12,
killY: -2, // the pit
respawnTicks: 90,
resources: [{ max: 1, initial: 1 }]
};
Give the platform periods that do not divide each other, so the shaft never
repeats a configuration inside a round and the route to the lit platform is
different every time.
The input is three fields:
function readIntent() {
return {
moveX: (right ? 1 : 0) - (left ? 1 : 0),
moveY: (down ? 1 : 0) - (up ? 1 : 0), // down + jump drops through a one-way
buttons: jumping ? TipTap.sim.BUTTON.JUMP : 0
};
}
Coyote time and a jump buffer are already in the module — seven ticks each is
generous and means a jump the player meant is a jump they get. Do not implement
either yourself.
The scoring condition is four lines, because the module publishes the answer:
function everyoneAboard(view) {
if (!view.self || view.others.length === 0) return false;
if (!view.self.alive || view.self.parent !== lit) return false;
for (var i = 0; i < view.others.length; i++) {
if (!view.others[i].alive || view.others[i].parent !== lit) return false;
}
return true;
}
parent is the platform index the module says a body is riding. Never compute it.
The rule and the clock are yours and are host-owned:
- The module has no concept of a scoring rule. Which platform is lit, how long
the round has left, and the chain all live in your own replicated state, written
by the host with TipTap.net.state.set inside TipTap.net.runOnHost and rendered
by everyone from TipTap.net.state.onChange.
- Count the hold in ticks from view.match.tick. Clients may run the same count
locally for a progress bar; the host's is the one that banks.
- Reset the chain on authoritative death events only.
Events: ride, unride, jump, wallJump, land, death, respawn. ride carries the
platform index, land carries the impact speed. Paint a footprint on the deck
where a rider came to rest and it becomes an object the player can check against
their own feet — which is the cheapest possible instrument for the one thing
this module has to get right.
Two design notes worth taking:
- Tune the wall jump as a SAVE, not a climb. Chaining wall jumps up one wall
should lose height, or the level design stops mattering.
- Players do not collide with each other on this module, on purpose. Co-op here
is a shared rule, not a shove — build the game around that rather than
discovering it.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.
Multiplayer rules, on top of everything above:
- TipTap.net exists ONLY in a game flagged multiplayer. It is a separate module the
platform injects, and TipTap.apiVersion does not move when it does. Guard with
window.TipTap && TipTap.net && TipTap.net.isAvailable(), in that order, and make the
game do something sensible when the answer is no — that is every local test run.
- The game still has no network of its own. connect-src is still 'none' and fetch,
WebSocket and EventSource are still blocked. The connection lives outside the frame.
- ONLY THE HOST WRITES STATE, and this is not like Playroom or Colyseus. One client in
the room is the host. TipTap.net.state.set and its siblings called anywhere else warn
on the console, return false and change nothing — no exception, no partial write. A
non-host player's move is a request the host validates and applies. Every authoritative
decision goes inside TipTap.net.runOnHost(). Design for this from the first line, or
three players in four cannot act and nothing says why.
- A non-host reaches the host with TipTap.net.sendTyped, which is GATED: it needs a
schema approved for the game and returns false until there is one. Check the return
value and tell the player. Never let a move disappear with only a console warning.
- A SCHEMA is the complete list of what a player may send, declared as JSON with the
game, and its fields may only be: bool, enum, uint, int, fixed, peer, preset, array
(with a required max) and struct. No strings, no bytes, no maps, no floats, no
unbounded lists — there is no extension hook and no flag that adds one. Declaring a
schema is not the same as having it approved: until a human approves the set the game
has no typed channel at all and sendTyped returns false for every name. A game that
works in your window and not for other players is usually waiting on that, not broken.
- Never Date.now() for anything compared between players — TipTap.net.now() is room
time. Never Math.random() in anything two clients both compute —
TipTap.net.randomFor(scope, id) is the same stream on every client. NOTHING ENFORCES
EITHER OF THESE: there is no lint and no validator rule, so a game that gets them
wrong passes every check, plays perfectly in one window, and desyncs silently with a
second player. Getting it right while writing is the only mechanism there is.
- Nothing derived from randomFor is secret; every client can recompute it. Hidden
information is rolled on the host and written with TipTap.net.state.setScoped().
- Never write reconnection logic. Reconnect, resync and liveness are handled.
TipTap.net.onDisconnect and TipTap.net.onReconnect are for showing a banner.
- Public rooms carry typed messages only. No free strings of any kind reach another
player, and nothing a peer sent may be rendered as text.
- validate_game_draft runs ONE client. It cannot test a join, a turn, a replication, a
host handover or a lobby, so a passing validation says nothing about whether the
multiplayer works. Say that plainly when you hand the game over.A Two-Wide Circuit Race
Preview · not publishable yetA circuit race the racer module scores: a tire model, surfaces, checkpoints and laps, with standings you ask for rather than recompute.
- sim-racer
- multiplayer
- simulation
- racer
- racing
- realtime
What goes wrong in most first attempts
- Never recompute the running order. sim.query({ kind: 'standings' }) answers it — a peer index and a monotonic progress value per car — and rebuilding lap progress from getView() is a second copy of the one thing the module keeps score of. Poll it about four times a second behind an in-flight guard, keep the previous order when an answer does not arrive, and never await it inside the render loop.
- Generate the centreline offline and paste it in as decimal literals. Math.cos is not required to be correctly rounded and V8, JSC and SpiderMonkey genuinely disagree in the last bit — and nothing cross-checks two clients' configs, so a track that differs by one quantum is a desync nobody can find.
- checkpoints are node indices, ascending, and the first is the start/finish line. Laps, checkpoint crossings and the flag arrive as lap, checkpoint and finish events; you never count a lap yourself.
- maxSteer is refused past 0.125. Ramp the steering input toward its target rather than sending ±1 — a thumb is not an axis, and a car that snaps to full lock sits at the grip limit permanently, which is bad driving as well as a useless instrument.
- surfaces is exactly four entries, indexed tarmac, kerb, rough, void. Make the rough genuinely punishing — about a third of the grip and four times the drag — or the racing line stops being a thing the player is choosing.
- v squared over grip is the tightest radius the car will hold, so grip and maxSpeed together decide which corners need braking. Author one corner that does and one that does not, with a straight between them long enough to draw alongside.
- A car that has finished is still being simulated. Keep sending throttle after the flag, or somebody who stopped driving leaves a parked car on the racing line for everyone still on their last lap.
Show the full promptHide the prompt
sim-racer — paste this to your agent
Build a multiplayer circuit race for TipTap Games on the racer simulation module.
Declare racer as the game's one simulation module; the simulation-tier recipe has
the boot sequence this one assumes.
The shape of it: two to eight cars, two laps, about half a minute. The track is
two cars wide and in one place barely that, so an overtake has to be taken rather
than negotiated.
The car and the circuit:
var CONFIG = {
mass: 900,
halfLength: 1.6,
halfWidth: 0.8, // 1.6 units across — size the track against this
wheelBase: 2.4,
engineForce: 9000, // 0 to 30 in about three seconds
brakeForce: 16200,
reverseForce: 3600,
maxSpeed: 30,
maxReverseSpeed: 8,
drag: 0.14,
maxSteer: 0.085, // 30 degrees at the front wheels; refused past 0.125
steerSpeedFalloff: 34,
grip: 26, // v*v/grip is the tightest radius the car will hold
driftThreshold: 0.18,
driftGrip: 0.62,
handbrakeGrip: 0.34,
handbrakeYawBoost: 1.7,
yawResponse: 6.5,
yawDamping: 1.6,
track: {
nodes: [ // generated offline, pasted as literals
{ pos: { x: 104.00, y: 116.00 }, halfWidth: 3.80 },
{ pos: { x: 104.00, y: 132.00 }, halfWidth: 3.80 }
// …thirty more, closing the loop
],
kerbWidth: 1.2,
roughWidth: 4, // grass, then a wall
checkpoints: [0, 8, 16, 24]
},
lapCount: 2,
surfaces: [ // tarmac, kerb, rough, void
{ gripScale: 1, dragScale: 1, powerScale: 1 },
{ gripScale: 0.88, dragScale: 1.4, powerScale: 0.95 },
{ gripScale: 0.32, dragScale: 4, powerScale: 0.45 },
{ gripScale: 0.2, dragScale: 6, powerScale: 0.3 }
],
contactRestitution: 0.35,
barrierRestitution: 0.25,
barrierYawBite: 0.22,
respawnAfterTicks: 150,
respawnFreezeTicks: 45,
botSeats: 0, // filled in below, before define
botStartTick: 192 // 3.2 s at 60 Hz — do not let bots jump the start
};
Design the geometry to force contact. Most of the circuit at three car widths and
one short link at two and a bit is what turns a procession into a race — and a
node's halfWidth is per node, so the squeeze is authored rather than global.
FILL THE EMPTY SEATS OR YOUR PLAYERS WILL DRIVE INTO PARKED CARS.
racer lays out one car per SEAT, and a room's seats are its ceiling rather than
its attendance — so two people matched into an eight-seat room start behind six
stationary cars. botSeats is a bitmask of the seats the module drives itself,
with its own autopilot, and it is the answer to that and to §13.8's "degrade to
zero concurrent players": one human and seven bots is a race.
Read it off the roster immediately before define, after the lobby has closed:
net.onStart(function () {
var seats = TipTap.sim.getIdentity().players; // the room, known before define
var mask = 0;
for (var i = 0; i < seats && i < 32; i++) {
if (!TipTap.net.peerAtSeat(i)) mask |= (1 << i); // null = nobody in it
}
CONFIG.botSeats = mask;
TipTap.sim.define('racer', CONFIG).then(...);
});
It has to be config and not a per-tick call, because every client simulates every
car: a bot's input must be something all of them DERIVE rather than something one
of them sends. Every client runs the loop above over the same roster at the same
moment, so every client produces the same mask — and if one somehow does not, the
config hash disagrees and the room closes 4415 instead of quietly racing two
different fields.
Do not skip botStartTick. A bot with no green flag uses the throttle on tick 1,
while every human is still watching your countdown.
The input, with the steering ramped:
function intent(dtMs) {
var target = (left ? 1 : 0) - (right ? 1 : 0); // angles increase CCW
steer += (target - steer) * Math.min(1, dtMs / 110);
if (Math.abs(steer) < 0.004) steer = 0;
var buttons = 0;
if (handbrake) buttons |= (TipTap.sim.BUTTON.HANDBRAKE || 0);
if (resetHeld) buttons |= (TipTap.sim.BUTTON.RESET || 0);
return {
steer: steer,
throttle: braking ? 0 : 1, // auto-throttle: one less thing to hold on a phone
brake: braking ? 1 : 0,
buttons: buttons
};
}
Auto-throttle is worth it on a phone twice over: it is one fewer thumb, and it
means every car arrives at the tight section at the same speed unless somebody
lifts — which is what makes that corner contested.
The running order, from the module:
var inFlight = false, lastAt = 0;
function pollStandings(now) {
if (inFlight || now - lastAt < 220) return;
inFlight = true; lastAt = now;
TipTap.sim.query({ kind: 'standings' }).then(function (answer) {
inFlight = false;
if (!answer || !answer.standings) return;
order = answer.standings.map(function (s) { return s.peer; });
}, function () { inFlight = false; });
}
Events: checkpoint, lap, finish, contact, barrier, drift, respawn. contact
carries both cars and the closing speed, which is the one moment worth
capturing — record the order before it and compare a second later, and you can
tell the player whether a bump was actually an overtake.
The start:
- The grid countdown is a ROOM time, so the host writes it once and every car is
released against the same clock:
TipTap.net.runOnHost(function () {
TipTap.net.state.set('grid', { startAt: TipTap.net.now() + 3200 });
});
- Never Date.now() for anything two cars compare.
What to show, and what to be honest about:
- Position, lap, and the gap. All three come from the standings answer and the
lap events, never from arithmetic on positions.
- The host takes zero corrections all race and a client is corrected on roughly a
fifth of snapshots. That is host authority working as designed, not a defect,
and if you put view.local.corrections on the HUD then say which seat the player
is so the number reads as information rather than as an accusation.
- Nothing fills an empty grid. A solo session is one car on a circuit, which is a
time trial — say so rather than pretending it is a race.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.
Multiplayer rules, on top of everything above:
- TipTap.net exists ONLY in a game flagged multiplayer. It is a separate module the
platform injects, and TipTap.apiVersion does not move when it does. Guard with
window.TipTap && TipTap.net && TipTap.net.isAvailable(), in that order, and make the
game do something sensible when the answer is no — that is every local test run.
- The game still has no network of its own. connect-src is still 'none' and fetch,
WebSocket and EventSource are still blocked. The connection lives outside the frame.
- ONLY THE HOST WRITES STATE, and this is not like Playroom or Colyseus. One client in
the room is the host. TipTap.net.state.set and its siblings called anywhere else warn
on the console, return false and change nothing — no exception, no partial write. A
non-host player's move is a request the host validates and applies. Every authoritative
decision goes inside TipTap.net.runOnHost(). Design for this from the first line, or
three players in four cannot act and nothing says why.
- A non-host reaches the host with TipTap.net.sendTyped, which is GATED: it needs a
schema approved for the game and returns false until there is one. Check the return
value and tell the player. Never let a move disappear with only a console warning.
- A SCHEMA is the complete list of what a player may send, declared as JSON with the
game, and its fields may only be: bool, enum, uint, int, fixed, peer, preset, array
(with a required max) and struct. No strings, no bytes, no maps, no floats, no
unbounded lists — there is no extension hook and no flag that adds one. Declaring a
schema is not the same as having it approved: until a human approves the set the game
has no typed channel at all and sendTyped returns false for every name. A game that
works in your window and not for other players is usually waiting on that, not broken.
- Never Date.now() for anything compared between players — TipTap.net.now() is room
time. Never Math.random() in anything two clients both compute —
TipTap.net.randomFor(scope, id) is the same stream on every client. NOTHING ENFORCES
EITHER OF THESE: there is no lint and no validator rule, so a game that gets them
wrong passes every check, plays perfectly in one window, and desyncs silently with a
second player. Getting it right while writing is the only mechanism there is.
- Nothing derived from randomFor is secret; every client can recompute it. Hidden
information is rolled on the host and written with TipTap.net.state.setScoped().
- Never write reconnection logic. Reconnect, resync and liveness are handled.
TipTap.net.onDisconnect and TipTap.net.onReconnect are for showing a banner.
- Public rooms carry typed messages only. No free strings of any kind reach another
player, and nothing a peer sent may be rendered as text.
- validate_game_draft runs ONE client. It cannot test a join, a turn, a replication, a
host handover or a lobby, so a passing validation says nothing about whether the
multiplayer works. Say that plainly when you hand the game over.Mechanics
Systems to drop into a game that already runs.
Difficulty That Ramps Without a Wall
Replace a linear speed-up with a saturating curve, a provably clearable floor, and parameters that ramp on their own schedules.
- ramping-difficulty
- difficulty
- pacing
- tuning
- balance
What goes wrong in most first attempts
- d = 1 - Math.exp(-t / TAU) with TAU around 45 saturates near 1 and never runs off. A linear ramp always ends in a wall; the only question is when.
- Derive difficulty from elapsed time, never from score. Score-driven difficulty is a feedback loop: the player who is doing well gets punished for it, and a combo spike can double the speed in one frame.
- Assert the feasibility floor in code — minGap >= speed * REACTION_BUDGET with a budget of about 0.28 s — and clamp rather than trusting the tuning to stay honest.
- Ramp one parameter per axis of difficulty and stagger their curves. Speed, density and variety all ramping together is what produces a cliff at 40 seconds.
Show the full promptHide the prompt
ramping-difficulty — paste this to your agent
Rework the difficulty curve of this game so it ramps continuously and never
becomes unclearable.
Replace whatever linear ramp is in there with a single normalised difficulty value:
d = 1 - Math.exp(-elapsedSeconds / TAU)
with TAU around 45 seconds. d starts at 0, passes 0.5 near 30 seconds, and
approaches but never reaches 1 — so the game keeps getting harder forever without
ever reaching a speed nobody can play.
Then:
- Drive difficulty from elapsed time only, never from the score. Score-driven
difficulty punishes the player for doing well, and a combo spike can double the
speed inside one frame.
- Map each parameter separately, from its own min to its own max, so they do not all
peak together: scrollSpeed = lerp(SPEED_MIN, SPEED_MAX, d), spawnGap =
lerp(GAP_MAX, GAP_MIN, d), and so on.
- Stagger them. Let speed lead, bring density in after about 15 seconds, and hold new
obstacle types back until d passes 0.4. Everything ramping at once is what produces
a cliff.
- Enforce a feasibility floor. Whatever the difficulty says, the gap between hazards
must stay at least (currentSpeed * 0.28 s) — one human reaction time. Clamp it in
code, do not merely tune for it.
- Give the first eight seconds a guaranteed-gentle opening, identical every run.
Players judge a feed game in its first three seconds and a random hard opener reads
as the game being broken.
Then tune by playing: the target is 30 to 90 seconds for a competent player and a
death that always feels like the player's fault.
Then measure, because playing it yourself only tells you how it feels to the person
who built it. Instrument the curve with a handful of TipTap.event() counters and read
them back on your game's Analytics tab:
- One counter at each band you care about surviving to — reached_30s, reached_60s,
reached_90s. The ratios between them are the difficulty curve as players actually
experience it, and a cliff shows up as a band that almost nobody passes.
- One for each mechanic you were not sure anyone would use, fired the first time per
run: used_slow, used_shield.
- Name the EVENT, not the instance. reached_60s is a key; reached_61s is a slot
wasted, because a game holds at most 20 ACTIVE keys, counted
across every player and every day. TipTap.event('level_' + n) fills all
20 in 20 levels and every key after that is
dropped until a slot is free. Keys are a vocabulary, not values: bucket your levels
behind level_complete plus a few milestone keys.
- A slot is recoverable, so a typo is not fatal. Retire a key from the game's
Analytics tab and its slot is free immediately — every number it recorded is kept
and still shows in past date ranges. A key that records nothing for
90 days goes quiet on its own, which is how the vocabulary of
the PREVIOUS version of a game clears itself after a rewrite. If the game records a
retired or quiet key again and a slot is free, it comes straight back.
- Fire each counter from the moment the thing happens, never from the render loop.
A key is capped at 10 calls per second (plus a burst of
20) and the excess is dropped, so a counter called every frame records
your frame rate, badly, and buries the counters you actually wanted next to it.
reached_60s fires once per run, not once per frame after the sixtieth second — latch
it behind a flag.
Three or four counters answer the question. Do not instrument every branch — a
counter you will not act on is a slot you cannot spend on one you would.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.Combo Multiplier Scoring
A streak bonus that rewards mastery without exploding past scoreMax: capped multiplier, decaying window, and a ceiling you can defend.
- combo-multiplier
- scoring
- combo
- scoreMax
- balance
What goes wrong in most first attempts
- The platform enforces scoreMax server-side and a submission above it is rejected with a 400 — the player's whole run is lost. Clamp the score before you submit, and set scoreMax deliberately with update_game_metadata rather than leaving the 999999 default.
- Compute the ceiling: maxRounds * basePoints * MULT_CAP, plus roughly 20% headroom. If that number is above a million, lower the base points rather than raising the cap.
- Cap the multiplier at about 8x. An uncapped multiplier means the top of the leaderboard is one lucky run rather than the best player.
- Show the combo timer as a shrinking bar. A streak the player cannot see the deadline for is a streak they will lose to nothing they can name.
Show the full promptHide the prompt
combo-multiplier — paste this to your agent
Add combo multiplier scoring to this game.
The mechanic:
- Every successful action extends a streak and refreshes a combo window of about 2
seconds. Letting the window expire, or failing an action, resets the streak to 0.
- The multiplier is derived, not accumulated: mult = Math.min(1 + Math.floor(streak /
5), MULT_CAP) with MULT_CAP of 8. Deriving it means the multiplier can never drift
out of step with the streak after a bug.
- Points for an action are basePoints * mult, rounded to an integer. Scores are
integers on this platform.
Make it visible:
- The multiplier is displayed whenever it is above 1x, and it grows and pulses each
time it steps up.
- The combo window is a shrinking bar under the multiplier. A player who cannot see
the deadline loses streaks to nothing they can name, which feels arbitrary.
- Losing a streak deserves as much feedback as gaining one: a short desaturation, the
number falling away.
Now the part most games get wrong — the ceiling:
- Work out the theoretical maximum score for a very long, perfect run: maxActions *
basePoints * MULT_CAP. Add about 20% headroom.
- Set the game's scoreMax to that number (through update_game_metadata if you are
working through the MCP server). The default is 999999, which does no anti-cheat
work for a game that tops out at 4000 and rejects legitimate runs from a game that
tops out at two million. The ceiling you may set is 9999999999, which is high
enough for an idle or incremental game; anything above it is clamped, not refused.
- Clamp the score in the game as well, immediately before submitting. A submission
above scoreMax is rejected with a 400 and the player loses the entire run — an
arithmetic overflow in a bonus must never cost somebody their best score.
- If the theoretical maximum is above a million, reduce basePoints rather than the
cap. Small numbers that climb are more readable in a feed than large ones.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.Near-Miss Rewards
Pay the player for almost dying. A proximity band, a slow-motion beat and a bonus turn dodges into the best moment of a run.
- near-miss-reward
- risk-reward
- feel
- scoring
What goes wrong in most first attempts
- Award the near miss when the hazard has passed the player, not when it is closest. Awarding on approach pays out for a dodge the player has not survived yet.
- Flag each hazard as already-awarded. Without it a single obstacle pays every frame it spends inside the band, which is a score exploit anyone finds in one run.
- Time dilation is a multiplier on dt, not a change to the frame rate. Keep rendering at full rate and scale the simulation, or the reward beat reads as a stutter.
- Scale the bonus by how close the pass was, but bound it. An unbounded closeness bonus makes a one-pixel graze worth more than the rest of the run.
Show the full promptHide the prompt
near-miss-reward — paste this to your agent
Add near-miss rewards to this game, so surviving narrowly is worth more than
playing safe.
The mechanic:
- Around the player's hitbox, define a band about 1.5 to 2 times its radius. A hazard
that passes through the band without touching the hitbox is a near miss.
- Award it at the moment the hazard has passed the player, not at closest approach.
Paying out on approach rewards a dodge the player has not actually survived.
- Mark each hazard as awarded so it can only ever pay once. Without that flag, one
obstacle pays every frame it spends inside the band, and it is the first exploit
anyone finds.
- The bonus scales with closeness, bounded: bonus = Math.round(BASE * (1 + (1 -
distance / bandRadius))), so a graze is worth roughly double a wide pass and never
more.
The beat, which is what makes it feel good:
- On the award, scale dt by about 0.35 for 120 ms. Keep rendering at full rate and
slow the simulation, so it reads as slow motion rather than as a dropped frame.
- Flash the band outline, emit a few particles along the hazard's path, and float the
bonus number where the pass happened.
- A rising pitch that steps up with consecutive near misses, if the game has sound.
Interaction with the rest of the game:
- If there is a combo system, a near miss should extend the streak, not just add
points. That is what turns near misses from a bonus into a playstyle.
- Do not let a near miss ever be safer than a wide dodge — the risk has to be real,
or the reward means nothing.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.Progression That Survives the Swipe
Meta progression on top of short runs, kept in TipTap save state, with a restore path that handles a missing save and an older one.
- progression-with-save-state
- save-state
- progression
- meta
- roguelite
What goes wrong in most first attempts
- Put a version number in the saved object from the very first release and check it on load. The first update you ship is when an unversioned save turns into a crash for exactly the players who played most.
- Save DERIVED-FROM state, never derived state: which upgrades are owned, not the stats they add up to. Recomputing on load is free, and it means a balance change reaches existing players instead of only new ones.
- Never block the first frame on loadState. Start the run with defaults and fold the save in when it lands — the callback is fast, but a game that shows nothing until the network answers looks broken in a feed.
- Treat a rejected save as a real state, not an impossible one. Wire onStateError to a flag your end screen reads, so it stops promising a continue it cannot deliver.
- Keep the blob small by construction: ids and counters, not names and descriptions. The 8KB cap is generous for progression and trivially blown by saving display strings.
Show the full promptHide the prompt
progression-with-save-state — paste this to your agent
Build a game for TipTap Games with meta progression that survives between runs,
using the platform's save state.
The shape of it:
- Runs are short — 30 to 90 seconds — and end in a score, exactly like any other
feed game. Nothing about progression may make the first run slower to start.
- Between runs the player keeps something: a soft currency earned from the run, a
small set of permanent upgrades bought with it, and a best-run record.
- Every upgrade is a number the run reads at its start. Nothing unlocks new
screens or new modes — the game is the same game, tuned by what the player owns.
Persistence:
- On boot, call TipTap.loadState. Start the game immediately with defaults; when
the callback arrives, fold the saved values in. Do not gate the first frame on it.
- The saved object is versioned from day one, e.g.
{ v: 1, coins: 0, owned: [], best: 0 }. On load, if v is missing or unknown,
ignore the save and start fresh rather than trying to read it.
- Save at the end of each run and after each purchase — call TipTap.saveState
freely, it debounces and coalesces on its own, and it is flushed when the player
leaves the panel.
- Keep it small: ids and counters only. Do not save upgrade names, descriptions,
prices or anything else the code already knows.
- Wire TipTap.onStateError. If a save is refused, set a flag and have the end
screen say progress is not being kept this session, then keep playing. Never
offer a continue you cannot honour.
The upgrade screen:
- One screen, reachable from the end screen, showing 4 to 6 upgrades as rows: name,
what it does in plain words, cost, and owned/affordable state.
- Buying is one tap, applies immediately to the next run, and is never undoable —
no confirm dialog for a soft-currency purchase.
- If the player has no coins, the screen still opens and reads as a preview of what
is coming. An empty shop that says "come back later" wastes the one moment they
were curious.
The tuning that makes it work:
- The first upgrade must be affordable after two or three runs. Progression that
takes ten runs to show up does not exist in a feed.
- Each upgrade changes a number the player can feel within one run. No upgrade may
be a percentage so small it needs arithmetic to notice.
- Costs rise, effects saturate. The tenth upgrade is a small improvement to a game
that is already good, not the point at which the game finally starts.
Also required:
- Show the run's earnings on the end screen, separately from the score, so the
player sees what the run bought them.
- TipTap.submitScore with the RUN's score, never with a lifetime total — the
leaderboard compares runs.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.A Daily Everyone Plays Together
One board per UTC day from TipTap's daily seed, a deterministic PRNG, and an end screen built for comparing rather than grinding.
- daily-ritual-seeded
- daily
- seeded
- puzzle
- prng
What goes wrong in most first attempts
- Seed one PRNG from the string and draw EVERY generated value from it, in a fixed order. One stray Math.random() in the level builder and two players are playing different boards while both believe they are not.
- Build the whole board up front, before the first frame, rather than generating as you go. Lazy generation makes the sequence depend on what the player did, which is the same bug wearing a hat.
- Treat the seed string as opaque — hash it into your PRNG. The creator can author a specific seed for a specific date, so it is not always a hex digest and may not be a fixed length.
- getDailySeed() is null until the handshake lands. Either build the board in onDailySeed, or show a one-beat loading state — never fall back to a random board, which silently ships two versions of the day.
- Compare against the date the platform gave you, not one you computed. A player just past midnight in their own timezone is still on yesterday's board, and only the platform's date knows that.
Show the full promptHide the prompt
daily-ritual-seeded — paste this to your agent
Build a daily puzzle game for TipTap Games: one board a day, the same board for
every player, using the platform's daily seed.
The shape of it:
- A single self-contained puzzle that takes 60 to 120 seconds and ends in a score.
A word game, a route-finding grid, a packing puzzle, a set of timed rounds — the
genre is open, but the run must be finite and comparable.
- Every player who opens the game on the same UTC day gets exactly the same board.
- Tomorrow's board is different, and the player cannot reach it early.
Getting the seed:
- Read TipTap.getDailySeed(), which answers { seed, date } or null. It arrives with
the handshake, so it is not there on your first line — build the board inside
TipTap.onDailySeed, or show a brief loading state until it lands.
- Never fall back to Math.random() when the seed is missing. A game that quietly
generates a random board when the seed is late has shipped two different dailies
and no way to tell which one a score came from. If there is genuinely no seed
(the file opened directly during development), say so on screen.
- Hash the seed string into a small deterministic PRNG and draw every generated
value from that one generator, in a fixed order, before the player touches
anything. Do not call Math.random() anywhere in the generation path.
The board:
- Generate it fully up front. Lazy generation makes the sequence depend on player
actions, which breaks determinism just as thoroughly as an unseeded random.
- Verify it is solvable before showing it. If your generator can produce an
impossible board, re-roll from the same PRNG in a loop with a bounded number of
attempts, so the retry is itself deterministic.
- Show the date from the seed payload somewhere small and permanent — it is what
tells a player which day's board they are looking at.
One attempt, and an end screen built for comparing:
- The daily is one run. On finish, submit the score and show the board. If the
player wants to play again, that is tomorrow.
- The end screen leads with how they did against everyone else today, not with a
Play Again button — TipTap.onResult gives you rank and percentile.
- Offer a share from the end screen with the score and the date on it. This is the
moment the ritual spreads, and it is the only moment.
Also required:
- Handle a reload mid-run: the seed is stable, so the board rebuilds identically.
Use TipTap save state if you want to restore the run in progress, and version it.
- No timer that punishes thinking. A daily is discussed, and people discuss it in
the middle of playing it.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.A Turn Loop That Cannot Deadlock
MultiplayerTurn order, an auto-advancing timer, and a countdown that reads the same on every device — the shape most turn games get wrong once.
- mp-turn-loop
- multiplayer
- turn-based
- timer
- board-game
What goes wrong in most first attempts
- Call TipTap.net.turn.setTimer on the host, in the same place you call setOrder. Without a timer the game deadlocks permanently the first time a player puts their phone down, and 'the other three wait forever' is worse than a skipped turn in every game this platform ships.
- Gate input on TipTap.net.turn.isMine on every client, not just by hiding the button. The check is one line and it is the difference between a UI convention and a rule.
- Draw the countdown from TipTap.net.turn.remainingMs, which is computed from room time. A local timer you started when you rendered drifts from everyone else's and disagrees with the host that actually advances the turn.
- TipTap.net.turn is a separate module again — it arrives only in a game that declares turn-based play. Guard for it as carefully as you guard for TipTap.net.
- The turn module keeps its order in a replicated key of its own. Do not reimplement turn order in your own state as well; two sources of truth for whose turn it is will disagree the first time the host changes.
Show the full promptHide the prompt
mp-turn-loop — paste this to your agent
Build a turn-based multiplayer game for TipTap Games — two to four players, one
move at a time — using the platform's turn module rather than your own turn
tracking.
Pick something that resolves in 60 to 120 seconds: a small territory grab, a bluff,
a guessing duel, a three-round card trick. It must be understandable from watching
one turn.
The loop:
- When the match starts, the host sets the order and the timer:
TipTap.net.runOnHost(function () {
TipTap.net.turn.setOrder();
TipTap.net.turn.setTimer(20000);
});
- Every client subscribes with TipTap.net.turn.onChange and updates two things: a
clear "your turn" state, and whether input is accepted at all.
- Gate the move handler on TipTap.net.turn.isMine. Do not rely on having hidden
the button.
- A move is a request to the host, not a write. The host validates it, applies it
to replicated state, and advances with TipTap.net.turn.next inside
TipTap.net.runOnHost.
- Draw a countdown ring from TipTap.net.turn.remainingMs. It reads the same on
every device because it is computed from room time.
The timer is not optional:
- The turn auto-advances when it expires. That is the entire reason the game
cannot deadlock, and a game without it dies permanently the first time someone
walks away mid-match.
- A skipped turn must be a legal, survivable game state. Do not build a rule that
requires every player to have moved.
What makes it feel good rather than merely correct:
- Show whose turn it is by name and by colour, not only by an enabled button.
- Show the last move that happened, so a player returning to the screen can catch
up in one glance.
- When the timer expires, say so — "Nia ran out of time" — rather than silently
moving on.
- End on a real result: a winner, a score for each player, and a rematch offer via
TipTap.net.rematch, which is what stops the group dissolving back into the queue.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.
Multiplayer rules, on top of everything above:
- TipTap.net exists ONLY in a game flagged multiplayer. It is a separate module the
platform injects, and TipTap.apiVersion does not move when it does. Guard with
window.TipTap && TipTap.net && TipTap.net.isAvailable(), in that order, and make the
game do something sensible when the answer is no — that is every local test run.
- The game still has no network of its own. connect-src is still 'none' and fetch,
WebSocket and EventSource are still blocked. The connection lives outside the frame.
- ONLY THE HOST WRITES STATE, and this is not like Playroom or Colyseus. One client in
the room is the host. TipTap.net.state.set and its siblings called anywhere else warn
on the console, return false and change nothing — no exception, no partial write. A
non-host player's move is a request the host validates and applies. Every authoritative
decision goes inside TipTap.net.runOnHost(). Design for this from the first line, or
three players in four cannot act and nothing says why.
- A non-host reaches the host with TipTap.net.sendTyped, which is GATED: it needs a
schema approved for the game and returns false until there is one. Check the return
value and tell the player. Never let a move disappear with only a console warning.
- A SCHEMA is the complete list of what a player may send, declared as JSON with the
game, and its fields may only be: bool, enum, uint, int, fixed, peer, preset, array
(with a required max) and struct. No strings, no bytes, no maps, no floats, no
unbounded lists — there is no extension hook and no flag that adds one. Declaring a
schema is not the same as having it approved: until a human approves the set the game
has no typed channel at all and sendTyped returns false for every name. A game that
works in your window and not for other players is usually waiting on that, not broken.
- Never Date.now() for anything compared between players — TipTap.net.now() is room
time. Never Math.random() in anything two clients both compute —
TipTap.net.randomFor(scope, id) is the same stream on every client. NOTHING ENFORCES
EITHER OF THESE: there is no lint and no validator rule, so a game that gets them
wrong passes every check, plays perfectly in one window, and desyncs silently with a
second player. Getting it right while writing is the only mechanism there is.
- Nothing derived from randomFor is secret; every client can recompute it. Hidden
information is rolled on the host and written with TipTap.net.state.setScoped().
- Never write reconnection logic. Reconnect, resync and liveness are handled.
TipTap.net.onDisconnect and TipTap.net.onReconnect are for showing a banner.
- Public rooms carry typed messages only. No free strings of any kind reach another
player, and nothing a peer sent may be rendered as text.
- validate_game_draft runs ONE client. It cannot test a join, a turn, a replication, a
host handover or a lobby, so a passing validation says nothing about whether the
multiplayer works. Say that plainly when you hand the game over.Bots That Fill an Empty Room
MultiplayerFiller opponents you build yourself, host-run and kept in shared state, for rooms that never fill — and the labelling you owe the player.
- mp-bots
- multiplayer
- bots
- cold-start
- ai
What goes wrong in most first attempts
- The platform provides no filler players of any kind. Every seat in a room is held by a real authenticated connection, so peer.isBot on TipTap.net.getPeers is always false and TipTap.net.addBot returns false and does nothing. A game that fills its lobby by calling it fills nothing, and a branch on peer.isBot never runs.
- So a filler is not a player. It is part of the world: an entry the host owns, keeps in replicated state alongside the board, and moves on its own schedule. Do not try to make it look like a peer to your own code — it has no peerId and it never will.
- Everything about it runs inside TipTap.net.runOnHost. If every client decides what the filler does, every client disagrees about what it did.
- Give it a thinking pause before it acts, timed against TipTap.net.now, and vary it. An instant reaction is the biggest tell there is and reads as a glitch rather than as skill.
- Label it in your own UI, always. Platform chrome marks real players and your filler is not one, so nothing outside your game can tell the player it is not a person. That makes disclosure entirely yours.
Show the full promptHide the prompt
mp-bots — paste this to your agent
Give a multiplayer game for TipTap Games something to play against when the room is
short, so a player who arrives when nobody else is online still gets a real game.
Start from what is true: the platform fills nothing. Every seat in a room belongs
to a real connection, matchmaking will hand you a room with fewer players than you
asked for, and there is no API that adds a filler. So the fillers are yours,
built out of the same replicated state as everything else.
The model:
- A filler is an entry in the game's state, not a player in the room. The host
owns a list of them next to the board, exactly as it owns the board.
- Everything they do happens inside TipTap.net.runOnHost, and the result is
written with TipTap.net.state.set like any other change to the world. Every
other client watches them the way it watches the host's own decisions.
- Your rendering and scoring read one list that mixes real players and fillers, so
most of the game does not know the difference. Only the code that decides a
filler's move is separate, because that is the only part that actually differs.
How they should behave:
- Pause before acting: a few hundred milliseconds, varied, timed against
TipTap.net.now. Instant reactions are the tell, and they read as a bug.
- Be beatable, and make real mistakes rather than throwing on purpose. A filler
exists to make a thin room fun, not to test anybody.
- Drop out gracefully. When a real player joins and the room no longer needs the
filler, remove it between rounds and say so in a line of text.
Disclosure, which is entirely on you:
- Mark every filler in your own player list, permanently and visibly. Nothing
outside your game can do it — the platform marks real players and yours is not
one — so if you do not label it, nobody does, and the player is being told the
room is fuller than it is.
- Show the real human count somewhere. "You + 2 practice opponents" is honest and
costs nothing.
- Never award for beating a filler what you would award for beating a person, and
never submit a score from a filled room that you would not submit from an empty
one. If your leaderboard cannot tell the difference, the score is wrong.
The test that matters: play with the maximum number of fillers and no humans, and
again with one filler and the rest human. Both must be a complete game, and in both
the player must be able to see which is which.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.
Multiplayer rules, on top of everything above:
- TipTap.net exists ONLY in a game flagged multiplayer. It is a separate module the
platform injects, and TipTap.apiVersion does not move when it does. Guard with
window.TipTap && TipTap.net && TipTap.net.isAvailable(), in that order, and make the
game do something sensible when the answer is no — that is every local test run.
- The game still has no network of its own. connect-src is still 'none' and fetch,
WebSocket and EventSource are still blocked. The connection lives outside the frame.
- ONLY THE HOST WRITES STATE, and this is not like Playroom or Colyseus. One client in
the room is the host. TipTap.net.state.set and its siblings called anywhere else warn
on the console, return false and change nothing — no exception, no partial write. A
non-host player's move is a request the host validates and applies. Every authoritative
decision goes inside TipTap.net.runOnHost(). Design for this from the first line, or
three players in four cannot act and nothing says why.
- A non-host reaches the host with TipTap.net.sendTyped, which is GATED: it needs a
schema approved for the game and returns false until there is one. Check the return
value and tell the player. Never let a move disappear with only a console warning.
- A SCHEMA is the complete list of what a player may send, declared as JSON with the
game, and its fields may only be: bool, enum, uint, int, fixed, peer, preset, array
(with a required max) and struct. No strings, no bytes, no maps, no floats, no
unbounded lists — there is no extension hook and no flag that adds one. Declaring a
schema is not the same as having it approved: until a human approves the set the game
has no typed channel at all and sendTyped returns false for every name. A game that
works in your window and not for other players is usually waiting on that, not broken.
- Never Date.now() for anything compared between players — TipTap.net.now() is room
time. Never Math.random() in anything two clients both compute —
TipTap.net.randomFor(scope, id) is the same stream on every client. NOTHING ENFORCES
EITHER OF THESE: there is no lint and no validator rule, so a game that gets them
wrong passes every check, plays perfectly in one window, and desyncs silently with a
second player. Getting it right while writing is the only mechanism there is.
- Nothing derived from randomFor is secret; every client can recompute it. Hidden
information is rolled on the host and written with TipTap.net.state.setScoped().
- Never write reconnection logic. Reconnect, resync and liveness are handled.
TipTap.net.onDisconnect and TipTap.net.onReconnect are for showing a banner.
- Public rooms carry typed messages only. No free strings of any kind reach another
player, and nothing a peer sent may be rendered as text.
- validate_game_draft runs ONE client. It cannot test a join, a turn, a replication, a
host handover or a lobby, so a passing validation says nothing about whether the
multiplayer works. Say that plainly when you hand the game over.Game feel
The cheap upgrades players notice immediately.
Screen Shake, Hit-Stop, Particles and Haptics
The four cheapest upgrades to how a game feels, written to stay inside the sandbox CSP and the platform's pause contract.
- juice-shake-particles
- juice
- particles
- screen-shake
- haptics
- canvas
What goes wrong in most first attempts
- Shake from a trauma value: trauma += 0.4 on a hit, decay it at about 1.5 per second, and use shake = trauma * trauma for the offset. Squaring is what makes a big hit feel different from a small one instead of everything rattling equally.
- Apply shake with ctx.save() / ctx.translate() / ctx.restore() around the world draw, and leave the HUD outside it. A shaking score readout is nausea, not juice.
- Hit-stop freezes the simulation while rendering continues. Skip the update call for 60 to 90 ms; do not skip the frame, and never do it with a blocking loop.
- Preallocate a fixed particle pool (256 is plenty) with an alive flag. Allocating per burst is what turns a satisfying explosion into a garbage-collection hitch.
- No external images: img-src allows only data: and blob:. Draw shapes, or generate a sprite once into an offscreen canvas at startup.
- Honour prefers-reduced-motion by cutting shake amplitude to zero and halving particle counts. Everything else can stay. TipTap.isReducedMotion() is the platform's answer and is free to read.
- Pair TipTap.vibrate() with the SAME events that add trauma, not with every collision. Haptics have no equivalent of a small shake — a buzz either happens or it does not, so it belongs only on the hits that matter.
Show the full promptHide the prompt
juice-shake-particles — paste this to your agent
Add game feel to this game with the three effects that pay for themselves, and
keep all of them inside the platform's sandbox.
1. Screen shake, driven by trauma
- Keep a single trauma value in 0..1. An event adds to it (a big hit 0.5, a small one
0.2) and it decays at about 1.5 per second.
- The offset is trauma squared, times a maximum of about 8 px, times a smoothly
varying random direction. Squaring is what makes a big hit feel different from a
small one instead of everything rattling the same.
- Apply it with ctx.save(), ctx.translate(ox, oy), draw the world, ctx.restore().
Draw the HUD outside that transform — a shaking score is nausea, not juice.
2. Hit-stop
- On a significant impact, stop advancing the simulation for 60 to 90 ms while
continuing to render. The frozen frame is what sells the weight.
- Implement it by skipping the update call, never by blocking. Never with a busy
loop, and never with an await that outlives a pause.
- Use it sparingly: on death, on a boss hit, on a perfect. On every collision it just
reads as lag.
3. Particles
- One preallocated pool of about 256 particles, each with position, velocity, life
and colour, and an alive flag. Never allocate during a burst.
- Emit 8 to 16 on an impact, with randomised speed in a cone away from the point of
contact, gravity, and alpha fading with remaining life.
- Draw them as filled circles or squares. No external images — only data: and blob:
URLs load in the sandbox, so generate anything fancier into an offscreen canvas at
startup.
4. Haptics
- TipTap.vibrate(20) on the impacts that already add trauma — the big ones. Not on
every collision, and never on a timer.
- Haptics have no equivalent of a small shake: a buzz either happens or it does not.
So the rule is different from the one above. Shake scales with the hit; vibration
is reserved for the hits worth interrupting someone's hand for.
- A short pattern reads as a distinct event rather than a bigger buzz:
TipTap.vibrate([40, 60, 40]) on death, a single 20 on a hit.
- It is a no-op on desktop, on iOS Safari, and wherever the browser declines. Never
gate feedback on it — the visual effect has to carry the moment on its own, with
the buzz as a bonus for the phones that have it.
Two rules that keep this correct:
- Every one of these effects must stop when the platform pauses the game. They live
inside the same update the tiptap:pause handler stops; nothing here gets its own
timer.
- Respect reduced motion: when TipTap.isReducedMotion() is true, drop shake to zero
and halve the particle counts. Some players cannot use a game that shakes. The
platform suppresses haptics for those players itself, so vibrate() needs no guard
of its own — but everything you draw does.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.Synth Sound With the Audio Guard
Web Audio built from oscillators alone, wrapped in the focus and mute checks that stop two games playing at once in the feed.
- synth-audio-guard
- audio
- web-audio
- focus
- mute
What goes wrong in most first attempts
- canPlayAudio() already folds together on-screen and not-globally-muted. Check that one function rather than composing isFocused() and isMuted() yourself.
- Create the AudioContext lazily inside the first real user gesture and call resume() if its state is suspended. Building it at load leaves it suspended forever on most mobile browsers.
- Route everything through one master GainNode. Mute is then a single ramp instead of a hunt through every live voice.
- Ramp gain to 0.0001, never to exactly 0 — exponentialRampToValueAtTime cannot reach zero and will throw or silently do nothing.
- On tiptap:blur, ramp the master down over about 40 ms and then suspend the context. Cutting gain instantly clicks; leaving the context running drains battery in a feed.
- Two games playing at once is the worst bug on this platform, and the platform cannot reach inside your audio graph. Stopping your own sound is your job.
- Never let audio take the input with it. In an input handler, record the press FIRST and make sound second, and guard the whole audio path rather than only new AudioContext(). A game that builds its graph on the first line of a keydown handler stops responding entirely the moment any one Web Audio method is missing — and it still renders, so it looks fine.
Show the full promptHide the prompt
synth-audio-guard — paste this to your agent
Give this game sound, synthesized entirely in Web Audio, and wire it into the
platform's focus and mute contract.
Why synthesized: the sandbox allows media only from data: and blob: URLs, and
embedding real audio files would eat the 5 MB budget. Oscillators and noise buffers
cost nothing and are enough for an arcade game.
The voices to build:
- A short blip for a positive action: square or triangle, 60 to 120 ms, a quick pitch
rise, sharp attack and exponential decay.
- A lower thud for a negative one: sine or filtered noise, around 200 ms, pitch
falling.
- Optional bed: two detuned oscillators through a lowpass, gain around 0.05. Keep it
quiet enough that a player never reaches for their volume.
- Pitch the positive blip up a semitone per combo step, capped after about eight
steps. This is the single most satisfying line of audio code in an arcade game.
The guard, which is not optional:
- Create the AudioContext lazily inside the first real user gesture, and call
resume() if its state is suspended. Creating it at load leaves it suspended forever
on most mobile browsers.
- Route every voice through one master GainNode, so muting is one ramp rather than a
hunt through live voices.
- Check TipTap.canPlayAudio() at the top of every sound function and return
immediately when it is false. It already folds together "on screen" and "not
globally muted" — do not recompose that from isFocused() and isMuted().
- Re-check on tiptap:focus, tiptap:blur and tiptap:mutechange. On blur, ramp the
master gain down over about 40 ms and then suspend the context; on focus, resume it
only if canPlayAudio() is true.
- Ramp to 0.0001 rather than 0. An exponential ramp cannot reach zero.
- Audio must never be able to cost the player their input. Inside an input handler,
record the press first and make the sound second, and put the guard around the
whole audio path rather than only around new AudioContext(). A missing method in
one browser then costs a sound effect instead of the game: the alternative is a
game that draws perfectly and ignores every key, which is what this reads like
when it goes wrong.
Players swipe between games constantly, and the platform cannot reach inside your
audio graph. Two games playing at once is the worst bug on this platform and
preventing it is the game's job, not the platform's.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.Share-Worthy Moments
Turn a personal best into a share: the canvas snapshot, the caption, and the one rule that keeps it from becoming a nag.
- share-worthy-moments
- share
- sdk
- retention
- canvas
What goes wrong in most first attempts
- TipTap.share is SDK 2.1 and optional. Feature-detect it with typeof TipTap.share === 'function' — a game that assumes it exists breaks when opened directly during development.
- Offer the share, never trigger it. share() opens a sheet over the game; calling it automatically at every game over is the fastest way to teach players to dismiss your end screen without reading it.
- Snapshot budget: the image must be a data: URL of type png, jpeg or webp and at most 2 MB decoded. A full-resolution canvas PNG blows through that easily — downscale to about 1080px on the long edge first.
- Draw the snapshot on a SEPARATE offscreen canvas: final frame, then the score composited large. The live canvas is sized for a phone viewport and its raw pixels rarely read well as a shared image.
- toDataURL works here because everything on your canvas came from this document. There are no external images in the sandbox to taint it with.
- Pass no image and the platform generates a branded score card for you. That is the right default — only send your own when the final frame genuinely says something, like a finished maze or a tower the player built.
- share() is fire and forget. There is no callback and no result: the game is never told whether the player actually shared, so never gate a reward or progression on it.
Show the full promptHide the prompt
share-worthy-moments — paste this to your agent
Add sharing to this game at the moments that deserve it.
The API, which is SDK 2.1 and optional:
TipTap.share({ score: 4120 })
TipTap.share({ score: 4120, text: 'Cleared floor 9 without taking a hit' })
TipTap.share({ score: 4120, imageDataUrl: snapshot })
Feature-detect it (typeof TipTap.share === 'function') like every other SDK call.
It opens the platform's share sheet, and that is all it does — there is no callback,
no result, and the game is never told whether anything was shared.
Choosing the moment, which is the whole recipe:
- A personal best. This is the one that matters — the player just did something they
had not done before and they know it.
- A first clear, a rare outcome, a run that ended in a way worth showing.
- NOT every death. A share prompt on every game over is a nag, and players learn to
dismiss the end screen without reading it. You lose the prompt AND the end screen.
Offer it, do not fire it. Put your own Share button on your end screen next to the
score, and light it up only when the run cleared the bar. The player taps it, you call
share(). Calling share() unprompted throws UI over a game the player was still looking
at.
The caption: one specific line about what happened, not a generic boast. "Cleared
floor 9 without taking a hit" is worth reading; "I scored 4120" is what the platform
already writes for you.
Building the snapshot, if the final frame is worth showing:
- Draw it on a SEPARATE offscreen canvas rather than reusing the live one. Your live
canvas is sized for a phone viewport and its raw pixels rarely read well shared.
- Compose deliberately: the final frame, then the score large over it, then anything
that explains the run. Aim for a landscape-ish frame around 1080px on the long edge.
- Export with toDataURL. Use image/png for flat colour and sharp edges, image/jpeg or
image/webp at about 0.85 quality for anything with gradients or particles.
- Stay under 2 MB decoded. Over that the platform drops the image, logs why, and opens
the sheet with its generated card instead — so an oversized snapshot degrades rather
than failing, but you lose the picture you meant to send.
If you send no image at all the platform generates a branded score card with the
score, the game and the player on it. That is a good default. Only send your own when
the final frame says something the score alone cannot.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.The Beat-It Moment
Read the challenge a shared link carried, put the target inside the game rather than beside it, and make passing it the loudest moment.
- challenge-loop
- challenge
- share
- juice
- social
What goes wrong in most first attempts
- getChallenge() is null until the handshake lands, so read it in onChallenge or when you build the run — not on your first line. A run built before it arrives has no target in it and there is no second chance to add one.
- Draw the target in the SAME units and the same place as the live score. A marker on the score bar is read without thinking; a number in a corner is a second thing to track while playing.
- Fire the crossing exactly once, on the frame the score passes the target, and latch it. Testing score >= target every frame re-triggers the celebration for the whole rest of the run.
- Keep playing through the crossing. Stopping to celebrate steals the run the player is in the middle of — the moment is a 200ms punctuation, not a screen.
- Everything about the moment needs a reduced-motion path: check TipTap.isReducedMotion() and swap shake and burst for a colour flash and a scale pop. It is the loudest moment in the game, so it is the one that hurts most if it is not honoured.
Show the full promptHide the prompt
challenge-loop — paste this to your agent
Take a game for TipTap Games and build the challenge loop into it: the moment a
player who arrived from a shared score link passes the score they were sent to beat.
Reading the challenge:
- TipTap.getChallenge() returns { handle, score } or null. It is non-null only when
the player followed a shared score link, and it arrives with the handshake — so
read it in TipTap.onChallenge, or at the point you build the run, never on your
first line.
- handle is the sharer's public handle and MAY BE NULL — they shared while signed
out. Write the no-handle wording first: "Beat 12,450" reads fine, "Beat null's
12,450" is a bug on screen. score is always a number.
- With no challenge, the game is exactly the game. Nothing about this may make the
ordinary run worse, and no empty "no challenge" chrome may appear.
Putting the target IN the game:
- The platform already shows a chip while the game loads. Your job is to do better
than a chip, which means putting the target where the player is already looking:
- a marker on the score bar at the target value, so the gap is a distance;
- a ghost line, a high-water mark, a pace indicator — whatever your game's score
is made of, rendered in the same units;
- the sharer's handle attached to that marker, small, so it is a person.
- The target must be visible while playing, not only on the end screen. A challenge
the player reads once at the start is a fact; one they can see closing is a race.
The beat-it moment:
- On the exact frame the score crosses the target, once and only once:
- punctuate it — a brief hit-stop, a flash, the marker breaking or burning away;
- name it in one short line, "Beat " + handle, held for well under a second;
- a short haptic tick via TipTap.vibrate(20) if the moment deserves it;
- and keep playing. The run continues.
- Latch the crossing in a boolean. A raw score >= target check re-fires every frame
and turns the best moment in the game into a strobe.
- After the crossing the marker stays visible but goes quiet — it is now a thing
they are ahead of, which is its own reward.
The end screen:
- If they beat it, lead with that, then the score. If they did not, lead with how
close: "312 short" is a reason to press Play again, a rank is not.
- Offer the share from here either way, so a run that beat a challenge becomes the
next challenge. That is the whole loop.
Accessibility and honesty:
- Gate every part of the celebration on TipTap.isReducedMotion() and provide the
quiet version: colour and scale instead of shake and particles.
- Never fabricate a challenge to make the game feel busier. If getChallenge() is
null there is no rival, and inventing one is lying to the player.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.SDK patterns
Getting the platform integration right the first time.
Achievements the Validator Accepts
Declare the keys first, unlock them from a plain condition, and pass the cross-check that compares your code against your declarations.
- achievements-that-validate
- achievements
- validation
- sdk
What goes wrong in most first attempts
- Validation cross-checks declared keys against the unlockAchievement calls it can see in the source. Pass literal strings — TipTap.unlockAchievement('combo_10') — never a variable, a template literal or a computed key, or the scan finds nothing and the check fails on a game that works.
- Declare the achievements BEFORE calling validate_game_draft (set_game_achievements stages them on the session). Validating first means cross-checking against an empty list.
- Unlocks are idempotent server-side and unknown keys are ignored, so the call can sit directly inside the condition with no has-it-fired bookkeeping.
- Key format is [a-zA-Z0-9_-] up to 64 characters, name up to 80, description up to 200, icon a single emoji of at most 8 characters, at most 50 achievements.
- Do not invent achievements the user did not ask for. If they did, three to six with a real spread of difficulty beats twenty.
Show the full promptHide the prompt
achievements-that-validate — paste this to your agent
Add achievements to this game so they work at runtime and pass validation.
How the pieces fit:
- Achievement keys are declared on the game, and the game calls
TipTap.unlockAchievement('the_key') when the condition is met. Validation
cross-checks the two: every declared key must be reachable from the code, and every
key the code unlocks must be declared. An unknown key is ignored server-side, so a
mismatch is silent at runtime and only validation will tell you.
- Declare them first. If you are working through the MCP server, call
set_game_achievements before validate_game_draft — validating first cross-checks
against an empty list.
Writing the calls:
- Always a literal string: TipTap.unlockAchievement('survive_60s'). Never a variable,
never a template literal, never a key built from a loop index. The cross-check reads
the source, so a computed key is invisible to it and the check fails on a game that
works perfectly.
- Unlocks are idempotent and repeat calls are no-ops, so put the call directly inside
the condition. No "have I already fired this" bookkeeping is needed.
- Feature-detect like every other SDK call, so the file still runs when opened
directly.
Choosing them — three to six, with a spread:
- One almost everyone gets in their first run, so the mechanic announces itself.
- Two or three at genuine skill milestones: a score threshold, a streak, surviving
past a difficulty step.
- One that rewards playing differently rather than playing longer — a perfect round,
a clear without using the main mechanic, a full board.
Each needs a key ([a-zA-Z0-9_-], up to 64 characters), a name (up to 80), a short
description (up to 200) and one emoji as its icon.
Do not add achievements nobody asked for. If the brief did not mention them, ask
before spending the file size and the validation surface on them.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.Pause, Resume and Focus, Correctly
The lifecycle wiring that stops a game double-speeding after a resume, draining battery off-screen, or playing over the next game.
- pause-resume-correct
- lifecycle
- pause
- raf
- sdk
What goes wrong in most first attempts
- One place calls requestAnimationFrame. A second call site is how a resume ends up running two loops at once, and the game runs at double speed with no error anywhere.
- Reset lastT to 0 on resume and treat a zero lastT as dt = 0. Without it, the first frame back carries the entire time the game spent off-screen and teleports everything.
- Clamp dt to about 1/20 s on every frame regardless. That covers a backgrounded tab and a slow first frame in one line.
- Keep the rAF handle and cancelAnimationFrame on pause. Returning early from the callback without cancelling leaves the browser scheduling frames you throw away.
- setInterval and setTimeout keep firing while paused. Anything that changes game state belongs in the update function, not on a timer.
- The platform's pause is not your game-over. Keep them as separate flags, or the leaderboard overlay ends the run underneath it.
Show the full promptHide the prompt
pause-resume-correct — paste this to your agent
Fix this game's lifecycle handling so it behaves correctly when the player
scrolls away, comes back, or opens the leaderboard.
The four bugs to eliminate:
1. Double speed after a resume. Exactly one place in the file may call
requestAnimationFrame. Keep the handle, cancelAnimationFrame on pause, and on
resume start the chain from that single place. A second call site means two loops
running at once, which shows up as double speed with no error anywhere.
2. The teleport on return. Reset the timestamp on resume (lastT = 0) and treat a zero
lastT as dt = 0 for that frame. Otherwise the first frame back carries the entire
time the game was off-screen and everything jumps. Clamp dt to about 1/20 s on
every frame regardless.
3. State changing while paused. setInterval and setTimeout keep firing when the game
is paused. Anything that mutates game state belongs in the update function, driven
by dt, and nowhere else.
4. Sound over the next game. On tiptap:blur, stop your audio — the platform pauses
the game but cannot reach inside your audio graph. Gate every sound on
TipTap.canPlayAudio().
The wiring:
- tiptap:pause sets paused = true and cancels the frame. tiptap:resume clears it,
resets lastT, and restarts the chain.
- tiptap:focus, tiptap:blur and tiptap:mutechange all re-run the same audio-sync
function.
- TipTap.onLeaderboardClose fires when the player pressed Continue. Nothing was
reloaded and no state was lost, so resume the existing run if the game has
progression, or start a fresh one if it does not. Decide which deliberately.
Keep the platform's paused flag separate from your own running / game-over flag.
Collapsing them into one is how the leaderboard overlay ends the run underneath
itself.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.Choosing the Leaderboard Moment
submitScore is silent in SDK v2. Where you call showLeaderboard is what decides whether a run ends well or merely stops.
- leaderboard-moments
- leaderboard
- sdk
- score
- ux
What goes wrong in most first attempts
- submitScore() no longer opens the leaderboard — that changed in SDK v2. Ported code that relied on the old behaviour submits successfully and shows the player nothing.
- submitScore is the database write and updateScore is the live top-bar number. Calling submitScore every frame is a write per frame; call updateScore instead.
- Continue does not reload the game and destroys nothing. Play again remounts from scratch. Next game moves on through the feed. Only the first one hands control back to your code.
- Hold the final frame about 250 ms before raising the board, so the player sees what ended the run.
- onResult delivers rank, percentile, personalBest, ghostScore and ghostHandle. Read it on the way in and spend it on the next run, not on the game-over screen the platform already owns.
Show the full promptHide the prompt
leaderboard-moments — paste this to your agent
Get this game's score submission and leaderboard timing right.
The rule that catches everyone: TipTap.submitScore(score) is SILENT. It writes to the
leaderboard and returns, and your game keeps running. It used to raise the board
automatically and it no longer does, so ported code submits successfully and shows
the player nothing. TipTap.showLeaderboard() is what the player actually sees.
Where each call goes:
- TipTap.updateScore(score) on every score change. No database write, free, and it is
what drives the platform's live top bar.
- TipTap.submitScore(score) at EVERY terminal state — death, timer expiry, board
solved, level complete. Not every frame, and not on a pause.
- TipTap.showLeaderboard() immediately after the submit, but only at a real game
over. Hold the last frame for about 250 ms first so the player sees what ended the
run.
Games with progression need the split:
- A roguelike run, a level, a partially filled board — submit the score and let the
player decide. Continue closes the overlay and hands control straight back with
nothing reloaded and no state lost.
- Play again remounts the game from scratch and destroys everything. Next game moves
on through the feed. Only Continue returns to your code, through
TipTap.onLeaderboardClose.
- So handle onLeaderboardClose deliberately: resume the run in progress if the game
has one, start a fresh run if it does not. Doing neither leaves a frozen game
behind a dismissed overlay.
Spending the result well:
- TipTap.onResult gives you rank, percentile, personalBest, ghostScore and
ghostHandle. The platform already shows the board, so use these for something it
cannot: a "beat your best by 40" line on the NEXT run, or a ghost marker on the
bar showing the score to beat.
- Scores are integers, and each game has a server-enforced scoreMax. A submission
above it is rejected with a 400 and the run is lost, so clamp before you submit.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.The Player, In The Game
Put the player's own avatar and name into the game, with the null guard that keeps it working for the signed-out majority.
- personal-touch-avatar
- player
- avatar
- sdk
- identity
What goes wrong in most first attempts
- Every field is independently null. The callback always receives an OBJECT, so p itself never needs a null check — handle, displayName and avatarDataUrl each do.
- Most of the feed is signed out. The anonymous player — all three fields null — is the common case, not the edge case. Build that version of the screen first and treat the avatar as the enhancement.
- The reply is cached by the SDK for the life of the document, so calling getPlayer again is free. Still keep your own reference, so your draw path is synchronous and never waits on a callback mid-frame.
- avatarDataUrl is a 96x96 data URL. Load it into an Image and wait for onload before drawImage — a data URL decodes fast but not instantly. Handle onerror too and fall back to the initial.
- Do not scale the avatar much past 128px. It is 96 pixels square and it turns to mush.
- Ask for it only if you use it. getPlayer costs the host a canvas draw and a base64 encode; calling it and ignoring the answer is pure waste.
Show the full promptHide the prompt
personal-touch-avatar — paste this to your agent
Use the player's own identity inside this game.
The API, which is SDK 2.1 and optional:
TipTap.getPlayer(function (p) {
// p.handle, p.displayName, p.avatarDataUrl
});
It also returns a Promise where one exists, so await works too. Feature-detect
(typeof TipTap.getPlayer === 'function') like every other SDK call.
The contract, and the part that breaks games:
- The callback ALWAYS receives an object, so p itself never needs a null check.
- handle, displayName and avatarDataUrl are each INDEPENDENTLY null. A signed-out
player has no handle and no avatar. A signed-in player may still have no avatar.
- Most of the feed is signed out. The all-null player is the common case. Write that
version of the screen first and treat the avatar as decoration on top of it.
- The reply is cached for the life of the document. Call it once at boot, keep the
result in a variable, and draw from that variable — never wait on a callback in the
middle of a frame.
The guard, every time:
var me = { handle: null, avatar: null };
TipTap.getPlayer(function (p) {
me.handle = p.handle;
if (!p.avatarDataUrl) return;
var img = new Image();
img.onload = function () { me.avatar = img; };
img.src = p.avatarDataUrl;
});
Then at the draw site: if me.avatar, draw it; else if me.handle, draw its first letter
in a coloured disc; else draw a neutral silhouette. Never a broken image, never the
word "null", never a gap where a face should be.
Where it is worth doing:
- On the podium or trophy on your own end screen. This is the best one — the player
just earned it and their face is on it.
- On the ghost or rival marker, so the score you are chasing has someone attached.
- Beside their row in an in-game score list.
- Their name in a single line of result text: "Nice run, nova."
Where it is not:
- Plastered on every enemy, projectile or tile. It stops being a personal touch and
becomes a joke at the player's expense.
- On the loading screen or a splash. The game must be playable in about two seconds
and this is not what that time is for.
- Anywhere the game breaks without it. If a signed-out player gets a worse game rather
than the same game with less decoration, the feature is wrong.
Why this is safe to hand a game: it is the same handle and picture already shown on
every leaderboard and profile page, so nothing private crosses the boundary. And the
game sandbox runs under connect-src 'none' — it has no network at all — so the data
physically cannot leave the frame it was drawn in.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.One Board, Everybody Sees It
MultiplayerHost-authoritative replicated state, rendered from the change callback so a reconnecting player is never shown a stale board.
- mp-replicated-state
- multiplayer
- state
- host-authority
- sdk
What goes wrong in most first attempts
- Render from TipTap.net.state.onChange, never from your own write. Your write is applied optimistically on the host and confirmed by the relay, and everyone else only ever learns through the callback — so if the callback draws, all clients share one code path and the reconnect case is free.
- onChange also fires for the snapshot a joining or reconnecting player is served, which is exactly why drawing from it fixes the reconnect case you would otherwise have to write.
- Check the return value of every state write, or make it provably host-only. A non-host write returns false and warns; a game that ignores that silently does nothing on every client except one.
- One key per thing that changes independently, not one giant blob. A board and a score written as one object burn the per-key write budget together and send the board every time the score ticks.
- Write positions at whatever rate suits the game and read them through TipTap.net.state.interpolated in your draw loop. Interpolating in your own render code is the version that judders.
Show the full promptHide the prompt
mp-replicated-state — paste this to your agent
Build a multiplayer game for TipTap Games whose entire shared world is one
replicated state object — no message passing at all.
Pick a game that is genuinely shared state: a co-op tile board, a colour-by-tap
canvas with a fixed palette, a shared word grid, a puzzle several people poke at.
Two to four players.
The state model:
- The host owns the world. Every change to it happens inside
TipTap.net.runOnHost() and is written with TipTap.net.state.set.
- A non-host player never writes the world. It writes its own intent — which tile
it wants, where its cursor is — and the host reads that and decides.
- Per-player things (score, colour, cursor, ready flag) go in
TipTap.net.state.setPlayer, keyed by peerId, so they disappear with the player.
- Render entirely from TipTap.net.state.onChange and
TipTap.net.state.onPlayerChange. Never draw from a local variable you set at the
same time as a write.
The reconnect case, which you get for free if you follow the rule above:
- A player who joins late or reconnects is served the whole state before anything
else, and it arrives through the same onChange callback. If your renderer reads
state, that player sees the correct board with no code. If your renderer reads
local variables, they see an empty one.
Sizes and rates, designed for rather than discovered:
- Keep each value small and each key focused. Sizes are counted in bytes of
serialised JSON, so text in any non-Latin script is several times bigger than it
looks on screen.
- Do not write from inside your animation frame. Write when something happens.
- Wire TipTap.net.onError and log it while building. A steady trickle of refusals
is your write pattern being wrong, and the end of that road is a closed
connection and an ended match.
What the player sees:
- Every other player is visibly present: a cursor, a highlight, a name in a
corner. The whole appeal is watching someone else act.
- Use TipTap.net.getPeers for the roster and treat displayName as a label, not as
a person's real name — in a public room it is a platform-assigned alias.
- Show a banner on TipTap.net.onDisconnect and clear it on
TipTap.net.onReconnect. Pause input while it is up. Write no reconnection logic.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.
Multiplayer rules, on top of everything above:
- TipTap.net exists ONLY in a game flagged multiplayer. It is a separate module the
platform injects, and TipTap.apiVersion does not move when it does. Guard with
window.TipTap && TipTap.net && TipTap.net.isAvailable(), in that order, and make the
game do something sensible when the answer is no — that is every local test run.
- The game still has no network of its own. connect-src is still 'none' and fetch,
WebSocket and EventSource are still blocked. The connection lives outside the frame.
- ONLY THE HOST WRITES STATE, and this is not like Playroom or Colyseus. One client in
the room is the host. TipTap.net.state.set and its siblings called anywhere else warn
on the console, return false and change nothing — no exception, no partial write. A
non-host player's move is a request the host validates and applies. Every authoritative
decision goes inside TipTap.net.runOnHost(). Design for this from the first line, or
three players in four cannot act and nothing says why.
- A non-host reaches the host with TipTap.net.sendTyped, which is GATED: it needs a
schema approved for the game and returns false until there is one. Check the return
value and tell the player. Never let a move disappear with only a console warning.
- A SCHEMA is the complete list of what a player may send, declared as JSON with the
game, and its fields may only be: bool, enum, uint, int, fixed, peer, preset, array
(with a required max) and struct. No strings, no bytes, no maps, no floats, no
unbounded lists — there is no extension hook and no flag that adds one. Declaring a
schema is not the same as having it approved: until a human approves the set the game
has no typed channel at all and sendTyped returns false for every name. A game that
works in your window and not for other players is usually waiting on that, not broken.
- Never Date.now() for anything compared between players — TipTap.net.now() is room
time. Never Math.random() in anything two clients both compute —
TipTap.net.randomFor(scope, id) is the same stream on every client. NOTHING ENFORCES
EITHER OF THESE: there is no lint and no validator rule, so a game that gets them
wrong passes every check, plays perfectly in one window, and desyncs silently with a
second player. Getting it right while writing is the only mechanism there is.
- Nothing derived from randomFor is secret; every client can recompute it. Hidden
information is rolled on the host and written with TipTap.net.state.setScoped().
- Never write reconnection logic. Reconnect, resync and liveness are handled.
TipTap.net.onDisconnect and TipTap.net.onReconnect are for showing a banner.
- Public rooms carry typed messages only. No free strings of any kind reach another
player, and nothing a peer sent may be rendered as text.
- validate_game_draft runs ONE client. It cannot test a join, a turn, a replication, a
host handover or a lobby, so a passing validation says nothing about whether the
multiplayer works. Say that plainly when you hand the game over.The Lobby, From Queue to First Move
MultiplayerMatchmaking, a ready-up lobby, a private room code, and an honest unavailable state — the part a player meets before the game.
- mp-lobby
- multiplayer
- lobby
- matchmaking
- onboarding
What goes wrong in most first attempts
- Attach a .catch to every matchmaking call. The promise rejects on failure and an unhandled rejection is the normal outcome of a queue that found nobody, so 'searching' spins forever if you only wrote the happy path.
- quickMatch resolves when you are IN the room, not when the match starts. Deal cards on TipTap.net.onStart, never in the .then, or a late joiner arrives to a game already in progress that nobody dealt them into.
- TipTap.net.setTeams, setJoinPolicy and addBot exist on the object and do nothing at all today. A lobby built on any of them looks finished and is not — hold teams in your own replicated state instead.
- Draw the lobby from TipTap.net.onRoomState rather than from your own bookkeeping. It is the one view that is still correct after a reconnect.
- The private room code is a door, not an address: show it, let the player share it, and do not store it or reuse it — it is retired when the match starts.
Show the full promptHide the prompt
mp-lobby — paste this to your agent
Build the front half of a multiplayer game for TipTap Games: everything the player
touches between opening the game and the first move. Assume the game itself is a
simple placeholder — the lobby is the deliverable.
Four states, and the game must be honest in every one:
1. UNAVAILABLE. TipTap.net is missing, or TipTap.net.isAvailable() is false. This
is what happens every time the file is opened directly, so it is the state you
will see most while building. Show a playable solo or practice mode, or say
plainly that multiplayer needs the platform. Never show a spinner that cannot
finish and never show an error.
2. SEARCHING. TipTap.net.quickMatch with minPlayers and maxPlayers, and a .catch
that returns the player to the first screen with a reason. Give them something
to look at and a way out — a cancel that goes back rather than a modal they are
trapped in.
3. LOBBY. Draw it from TipTap.net.onRoomState: who is here, who is ready, how many
are still needed. Each player readies with TipTap.net.ready and can take it back
with TipTap.net.unready. Start on TipTap.net.onAllReady. Every name in that list
is a real person — the platform fills nothing — so the room can go live with
fewer players than you asked for and the game has to be worth playing anyway.
4. LIVE. Deal, build or reveal on TipTap.net.onStart, using the seed it gives you
with TipTap.net.randomFor so every client builds the same thing.
Also build the private path:
- TipTap.net.createPrivateRoom returns a room carrying a join code. Show it large,
in a font where 0 and O differ, with a share button.
- TipTap.net.joinPrivateRoom takes the code back. Handle a wrong code as an
ordinary, recoverable mistake.
Two details that separate a lobby that works from one that ships:
- TipTap.net.recoverSession on load. A phone locking mid-match is routine here, and
this is what turns a reload into a rejoin instead of a lost game.
- A disconnect banner driven by TipTap.net.onDisconnect and cleared by
TipTap.net.onReconnect, that says "reconnecting" while it is recoverable and
says the match is over when it is not. Write no reconnection logic behind it.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.
Multiplayer rules, on top of everything above:
- TipTap.net exists ONLY in a game flagged multiplayer. It is a separate module the
platform injects, and TipTap.apiVersion does not move when it does. Guard with
window.TipTap && TipTap.net && TipTap.net.isAvailable(), in that order, and make the
game do something sensible when the answer is no — that is every local test run.
- The game still has no network of its own. connect-src is still 'none' and fetch,
WebSocket and EventSource are still blocked. The connection lives outside the frame.
- ONLY THE HOST WRITES STATE, and this is not like Playroom or Colyseus. One client in
the room is the host. TipTap.net.state.set and its siblings called anywhere else warn
on the console, return false and change nothing — no exception, no partial write. A
non-host player's move is a request the host validates and applies. Every authoritative
decision goes inside TipTap.net.runOnHost(). Design for this from the first line, or
three players in four cannot act and nothing says why.
- A non-host reaches the host with TipTap.net.sendTyped, which is GATED: it needs a
schema approved for the game and returns false until there is one. Check the return
value and tell the player. Never let a move disappear with only a console warning.
- A SCHEMA is the complete list of what a player may send, declared as JSON with the
game, and its fields may only be: bool, enum, uint, int, fixed, peer, preset, array
(with a required max) and struct. No strings, no bytes, no maps, no floats, no
unbounded lists — there is no extension hook and no flag that adds one. Declaring a
schema is not the same as having it approved: until a human approves the set the game
has no typed channel at all and sendTyped returns false for every name. A game that
works in your window and not for other players is usually waiting on that, not broken.
- Never Date.now() for anything compared between players — TipTap.net.now() is room
time. Never Math.random() in anything two clients both compute —
TipTap.net.randomFor(scope, id) is the same stream on every client. NOTHING ENFORCES
EITHER OF THESE: there is no lint and no validator rule, so a game that gets them
wrong passes every check, plays perfectly in one window, and desyncs silently with a
second player. Getting it right while writing is the only mechanism there is.
- Nothing derived from randomFor is secret; every client can recompute it. Hidden
information is rolled on the host and written with TipTap.net.state.setScoped().
- Never write reconnection logic. Reconnect, resync and liveness are handled.
TipTap.net.onDisconnect and TipTap.net.onReconnect are for showing a banner.
- Public rooms carry typed messages only. No free strings of any kind reach another
player, and nothing a peer sent may be rendered as text.
- validate_game_draft runs ONE client. It cannot test a join, a turn, a replication, a
host handover or a lobby, so a passing validation says nothing about whether the
multiplayer works. Say that plainly when you hand the game over.Quick Chat Without a Chat Box
MultiplayerExpressive communication between strangers from a fixed phrase set and typed messages — no free text, which is all a public room carries.
- mp-quick-chat
- multiplayer
- chat
- safety
- typed-messages
What goes wrong in most first attempts
- A public room carries no free strings at all. Send the INDEX of a phrase, not the phrase — a typed message with a bounded numeric field — and render your own copy of the phrase locally from that index.
- The platform does not provide chat and you are not waiting for it to. Quick chat is something you assemble from a typed message carrying a preset id, which is exactly the shape a public room is designed to approve.
- Never render a string that came from another player, from any field, by any route. That includes displayName in anything you build yourself beyond a plain label.
- Rate-limit the phrases in your own UI before the platform does it for you: a short cooldown per player and a cap on repeats. Spam is the failure mode of every quick chat ever shipped.
- Wire every phrase to a key AND a tap target. A four-way radial on hold is the pattern that works on a phone.
Show the full promptHide the prompt
mp-quick-chat — paste this to your agent
Add quick chat to a multiplayer game for TipTap Games — the Rocket League model,
where players can be expressive to each other and free text does not exist.
The phrase set:
- Eight to twelve phrases, fixed, written by you, shipped in the game. "Nice one",
"My fault", "Good game", "Watch out", "Ready", "Wait" and so on.
- Group them: a praise group, an apology group, a tactical group. Two taps to
anything, one tap to the two most used.
- Every phrase must be impossible to weaponise. If a phrase is only useful
sarcastically, cut it — a sarcastic "nice one" is the entire history of quick
chat abuse and the only fix is not shipping it.
The wire:
- Send the INDEX of the phrase as a typed message with TipTap.net.sendTyped, never
the phrase itself and never anything a player typed. Public rooms carry typed
messages with bounded numeric and enumerated fields only, and no free strings at
all, in any field, by any route.
- Receive with TipTap.net.onTyped, look the index up in your own table, and render
your own copy of the text.
- If your typed schema is not approved yet, build the whole UI anyway and have it
render locally only. A quick chat that shows the sender their own phrase and
nobody else's is a working half; a chat that ships free text is not shippable at
all.
Presentation:
- Show it as a speech bubble over the sender's marker or beside their name for two
seconds, not as a scrolling log. A log invites conversation, and this is not one.
- Add a per-player cooldown of a second or two and suppress an immediate repeat of
the same phrase. Do this before anybody complains, not after.
- Give the player a mute toggle for other players' phrases that persists for the
match. Someone will want it in the first minute.
Also do the non-verbal half, which is usually better:
- A contextual marker — a tap-and-hold on the board that shows every player a
short-lived ping at that spot, sent the same way as a bounded coordinate pair.
- Emotes tied to game events rather than to a menu: an automatic reaction when
somebody wins a round is more expressive than any phrase and costs nothing to
moderate.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.
Multiplayer rules, on top of everything above:
- TipTap.net exists ONLY in a game flagged multiplayer. It is a separate module the
platform injects, and TipTap.apiVersion does not move when it does. Guard with
window.TipTap && TipTap.net && TipTap.net.isAvailable(), in that order, and make the
game do something sensible when the answer is no — that is every local test run.
- The game still has no network of its own. connect-src is still 'none' and fetch,
WebSocket and EventSource are still blocked. The connection lives outside the frame.
- ONLY THE HOST WRITES STATE, and this is not like Playroom or Colyseus. One client in
the room is the host. TipTap.net.state.set and its siblings called anywhere else warn
on the console, return false and change nothing — no exception, no partial write. A
non-host player's move is a request the host validates and applies. Every authoritative
decision goes inside TipTap.net.runOnHost(). Design for this from the first line, or
three players in four cannot act and nothing says why.
- A non-host reaches the host with TipTap.net.sendTyped, which is GATED: it needs a
schema approved for the game and returns false until there is one. Check the return
value and tell the player. Never let a move disappear with only a console warning.
- A SCHEMA is the complete list of what a player may send, declared as JSON with the
game, and its fields may only be: bool, enum, uint, int, fixed, peer, preset, array
(with a required max) and struct. No strings, no bytes, no maps, no floats, no
unbounded lists — there is no extension hook and no flag that adds one. Declaring a
schema is not the same as having it approved: until a human approves the set the game
has no typed channel at all and sendTyped returns false for every name. A game that
works in your window and not for other players is usually waiting on that, not broken.
- Never Date.now() for anything compared between players — TipTap.net.now() is room
time. Never Math.random() in anything two clients both compute —
TipTap.net.randomFor(scope, id) is the same stream on every client. NOTHING ENFORCES
EITHER OF THESE: there is no lint and no validator rule, so a game that gets them
wrong passes every check, plays perfectly in one window, and desyncs silently with a
second player. Getting it right while writing is the only mechanism there is.
- Nothing derived from randomFor is secret; every client can recompute it. Hidden
information is rolled on the host and written with TipTap.net.state.setScoped().
- Never write reconnection logic. Reconnect, resync and liveness are handled.
TipTap.net.onDisconnect and TipTap.net.onReconnect are for showing a banner.
- Public rooms carry typed messages only. No free strings of any kind reach another
player, and nothing a peer sent may be rendered as text.
- validate_game_draft runs ONE client. It cannot test a join, a turn, a replication, a
host handover or a lobby, so a passing validation says nothing about whether the
multiplayer works. Say that plainly when you hand the game over.A Move a Non-Host Player Can Actually Make
MultiplayerThe full typed round trip: declare a schema, send a move from a non-host, have the host judge it and write the result as replicated state.
- mp-typed-move
- multiplayer
- typed-messages
- schema
- host-authority
What goes wrong in most first attempts
- Design the schema before the game. State writes are host-only, so the schema is the entire vocabulary of every player who is not the host — a move the schema cannot express is a move nobody but the host can make.
- Fields may only be: bool, enum, uint, int, fixed, peer, preset, array (with a required max) and struct. Every numeric field needs an explicit min and max. There is no string type and no way to add one.
- Carry a round or move number in the payload as a bounded uint. It is what lets the host reject a stale or duplicated message, and it costs one byte.
- The schema proves the SHAPE and nothing else. It cannot tell the host the move is legal, in turn, or affordable — validate all of that on the host before writing anything.
- Check the return value of sendTyped every time and show the player something. It returns false when the schema set is not approved yet, and that is the state a new game is in.
Show the full promptHide the prompt
mp-typed-move — paste this to your agent
Build a small multiplayer game for TipTap Games around one typed move, and get the
whole round trip right: a non-host asks, the host decides, everyone sees the result.
Pick something with exactly one kind of player action. A sealed-bid round, a tile
claim, a colour pick, a card played face down. Two to four players.
Step 1 — write the schema before the game:
- One struct with two or three fields. Nothing else about your game may cross the
wire between players.
- Every field is a bool, an enum with a fixed number of variants, a bounded uint or
int with explicit bits, min and max, a fixed-point number, a peer id, a platform
preset index, or a bounded array of those. No strings, no bytes, no floats.
- Give it a lowercase dotted name like yourgame.move, a schemaVersion of 1, and a
schemaId of 256 or above — ids below that are reserved for the platform, and two
different games both using 256 is normal.
- Write the JSON down beside the game. That document is what gets reviewed.
Step 2 — the non-host sends:
- The player taps. Build the payload, clamp every value into the range the schema
declares, and call TipTap.net.sendTyped with the schema name and the payload.
- Check the return value. False means the message never left the device — usually
because the schema set is still awaiting approval — so show the player a plain
message and leave their input alone rather than pretending the move landed.
- Show the move as pending locally. Do not apply it. It is a request.
Step 3 — the host judges:
- Receive with TipTap.net.onTyped inside a guard on TipTap.net.isHost, and treat the
payload as a claim. The schema guaranteed it is a number in range and nothing more.
- Check it against the actual rules: is it this player's turn, is the round still
open, have they already acted, can they afford it. Reject silently or with a
per-player status value; never crash on it.
- Apply the result inside TipTap.net.runOnHost with TipTap.net.state.set, and put
per-player outcomes in TipTap.net.state.setPlayer keyed by peerId.
Step 4 — everyone renders from state:
- Every client, host included, draws from TipTap.net.state.onChange. The sender
learns their move landed the same way everyone else does, which makes the
reconnect and late-join cases free.
- If a move is hidden until a reveal — a sealed bid — the host holds it and writes
it with TipTap.net.state.setScoped until the reveal, then writes it for everyone.
Never derive hidden information from randomFor: every client can recompute it.
What to hand over:
- The game, the schema JSON, and a plain sentence saying that typed messaging does
nothing until the schema set is approved, so the first person to test it with a
second player will see every move refused and that is not a bug in the game.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.
Multiplayer rules, on top of everything above:
- TipTap.net exists ONLY in a game flagged multiplayer. It is a separate module the
platform injects, and TipTap.apiVersion does not move when it does. Guard with
window.TipTap && TipTap.net && TipTap.net.isAvailable(), in that order, and make the
game do something sensible when the answer is no — that is every local test run.
- The game still has no network of its own. connect-src is still 'none' and fetch,
WebSocket and EventSource are still blocked. The connection lives outside the frame.
- ONLY THE HOST WRITES STATE, and this is not like Playroom or Colyseus. One client in
the room is the host. TipTap.net.state.set and its siblings called anywhere else warn
on the console, return false and change nothing — no exception, no partial write. A
non-host player's move is a request the host validates and applies. Every authoritative
decision goes inside TipTap.net.runOnHost(). Design for this from the first line, or
three players in four cannot act and nothing says why.
- A non-host reaches the host with TipTap.net.sendTyped, which is GATED: it needs a
schema approved for the game and returns false until there is one. Check the return
value and tell the player. Never let a move disappear with only a console warning.
- A SCHEMA is the complete list of what a player may send, declared as JSON with the
game, and its fields may only be: bool, enum, uint, int, fixed, peer, preset, array
(with a required max) and struct. No strings, no bytes, no maps, no floats, no
unbounded lists — there is no extension hook and no flag that adds one. Declaring a
schema is not the same as having it approved: until a human approves the set the game
has no typed channel at all and sendTyped returns false for every name. A game that
works in your window and not for other players is usually waiting on that, not broken.
- Never Date.now() for anything compared between players — TipTap.net.now() is room
time. Never Math.random() in anything two clients both compute —
TipTap.net.randomFor(scope, id) is the same stream on every client. NOTHING ENFORCES
EITHER OF THESE: there is no lint and no validator rule, so a game that gets them
wrong passes every check, plays perfectly in one window, and desyncs silently with a
second player. Getting it right while writing is the only mechanism there is.
- Nothing derived from randomFor is secret; every client can recompute it. Hidden
information is rolled on the host and written with TipTap.net.state.setScoped().
- Never write reconnection logic. Reconnect, resync and liveness are handled.
TipTap.net.onDisconnect and TipTap.net.onReconnect are for showing a banner.
- Public rooms carry typed messages only. No free strings of any kind reach another
player, and nothing a peer sent may be rendered as text.
- validate_game_draft runs ONE client. It cannot test a join, a turn, a replication, a
host handover or a lobby, so a passing validation says nothing about whether the
multiplayer works. Say that plainly when you hand the game over.The Simulation Tier, From Declaration to First Frame
Preview · not publishable yetDeclare one simulation module, define, start, draw the view — the boot sequence arena2d, turnphysics, platformer2d and racer all share.
- sim-first-match
- multiplayer
- simulation
- realtime
- host-authority
- sdk
What goes wrong in most first attempts
- TipTap.sim exists ONLY in a game that declared a simulation module, and a game declares exactly one. Guard window.TipTap && TipTap.sim, and remember a file opened directly has neither — every call queues and then rejects with 'simulation unavailable' fifteen seconds later, which is every local test run.
- A refused config NAMES THE FIELD, and the value and the bound: 'config refused for turnphysics: "bodies.owner" is 5, above the maximum 1.' Read it rather than bisecting — the first pool game on this tier was brought up by changing one field at a time, because every refusal used to be the identical string. Starting from sim.define('arena2d', {}) is still a good habit, since the module's own defaults are always valid, but it is now a convenience rather than the only way to find out what is wrong.
- sim.BUTTON is EMPTY until define resolves. Read TipTap.sim.BUTTON.FIRE at the call site inside your input function; a constant captured at the top of the file is permanently undefined, which encodes as no button rather than the wrong one.
- sim.input() records intent and does NOT send a tick. The SDK turns whatever you last recorded into exactly 60 inputs a second, so calling it twice in a frame is harmless and skipping a frame means the same as last frame. Never write an accumulator of your own.
- Drive rounds off getView().match.tick. It is the simulation's own clock, so it is the same number on every client with no replicated state and no agreement protocol. match.timeRemainingMs and match.epoch are usually null.
- sim.onMatchState hands the callback a RECORD — { phase, timeRemainingMs, epoch } — not a string. if (s === 'ended') never matches; if (s.phase === 'ended') does.
- Every event carries e.predicted. Draw from either kind, because a hitmarker that waited for a round trip feels broken. SCORE only from e.predicted === false, or two players finish the same match holding two different scorecards and neither is the host's.
Show the full promptHide the prompt
sim-first-match — paste this to your agent
Build the boot sequence for a game on TipTap's simulation tier — the part every
arena2d, turnphysics, platformer2d and racer game does identically before its own
game begins.
What the tier is: the platform runs a deterministic simulation at 60 Hz, in
WebAssembly, in a Worker it owns, host-authoritative, with prediction and
reconciliation done for you. Your game never computes a position, a velocity, a
collision or a hit. It hands over geometry once, hands over intent every frame,
and draws what comes back.
Step 1 — declare the module, once, on the game:
- The game's multiplayer modules must name EXACTLY ONE of arena2d, turnphysics,
platformer2d or racer. Two is refused. Declaring multiplayer and no simulation
module gives you TipTap.net and no TipTap.sim.
- Through the MCP server that is update_game_metadata with multiplayer: true and
multiplayer_modules carrying the one you want; on the site it is the game's
Advanced page. You upload nothing — declaring the name is what injects
TipTap.sim and what puts the room on the simulation tier.
Step 2 — the availability check, which is the state you will see most:
var TT = window.TipTap;
var sim = TT && TT.sim;
var net = TT && TT.net;
if (!sim) {
// Say so on screen and stop.
}
There is no local fallback to write here, and that is not a gap. The simulation
IS the platform on this tier, so a hand-written stand-in would be your game
computing physics — the one thing the tier exists to take away from you. Say the
simulation is unavailable, plainly, and offer nothing.
Step 3 — define, then start:
sim.define('arena2d', CONFIG).then(function (info) {
seat = info.seat; // your seat index, 0-based
players = info.players; // seats in the room
isHost = info.isHost; // are you the authority
return sim.start();
}).then(function () {
phase = 'live';
}).catch(function (err) {
show('The simulation refused to start: ' + err.message);
});
- The config is PARTIAL. Every field you leave out keeps the module's own
default, which comes out of the WebAssembly rather than out of a table you
could get wrong — so sim.define('racer', {}) is a complete, valid default
circuit and a good first commit.
- define may be called once, and the config is immutable for the whole match.
- It does NOT take the seat, the room size, the seed or who hosts. Those come
from the relay, and a game that could set them would be setting its own
authority.
- If you need the room size to BUILD the config — every module with one body per
seat does — read TipTap.sim.getIdentity().players first. It carries the relay's
welcome and is normally there before your script finishes parsing. If it is
still 0, build inside TipTap.net.onStart or from
TipTap.net.onRoomState's players.length.
Step 4 — the loop, and there is only one:
function frame() {
window.requestAnimationFrame(frame);
var view = sim.getView();
if (phase === 'live') sim.input(readIntent(view));
draw(view);
}
window.requestAnimationFrame(frame);
sim.getView() is already predicted and already interpolated. Draw it and nothing
else. It has the same shape before define as after, so your first frame draws an
empty world instead of reading undefined.length.
view.self your body, or null before the first frame
view.others[] everybody else, each with a peerIndex
view.entities[] the module's non-player things — also under the module's own
name: view.projectiles, view.bodies, view.platforms,
view.vehicles
view.match { tick, phase, timeRemainingMs, epoch } plus the module's own
view.local { pendingInputs, corrections, snaps } — diagnostics, and
worth one line of HUD while you are building
Events, and the one line that decides whether your scoreboard is right:
sim.onEvent(function (e) {
if (e.type !== 'death') return;
flash(e.peer); // draw from ANY event
if (e.predicted) return; // count only the host's
kills[e.killer] += 1;
});
Authority, and this is the part to design around from the first line:
- The host is the authority, and TipTap.net.state.set is host-only here exactly
as it is everywhere else on the platform.
- TipTap.sim.query(q) is an authoritative spatial question and ALWAYS returns a
Promise. That signature is load-bearing: authority lives on one machine, so a
synchronous authoritative answer could only be a lie. On the HOST it settles
against authoritative state without leaving the machine. On any other client it
is a round trip — it costs a hop, it is rate limited, and it rejects with "the
host did not answer" rather than hanging if nothing comes back.
- So adjudicate on the host and republish the answer with
TipTap.net.state.set. One machine asks, everybody reads the table. That costs
nothing per extra player, it is the shape host authority already wants, and it
is the only version that behaves the same on a bad link as on a good one.
- Whenever you do query from a client, poll it behind an in-flight guard and no
faster than a few times a second, and keep the previous answer when one does
not arrive. It is a round trip, not a getter, and awaiting one inside a render
loop is a stall.
- TipTap.sim.queryLocal(q) is synchronous, returns the PREVIOUS answer — null on
the first call, one frame stale after that — and is cosmetic. A crosshair, yes.
A score, never.
- TipTap.sim.applyResource() is not implemented and rejects every call. Resources
are changed by the simulation, not assigned by a caller.
Reporting the result, which needs one conversion:
- The simulation speaks in SEATS and TipTap.net.reportResult takes scores keyed
by peerId. TipTap.net.peerAtSeat is the bridge, and it is the reason that
method exists:
TipTap.net.runOnHost(function () {
var scores = {};
var all = view.others.concat([view.self]);
for (var i = 0; i < all.length; i++) {
var peer = TipTap.net.peerAtSeat(all[i].peerIndex);
if (peer) scores[peer.peerId] = scoreFor(all[i].peerIndex);
}
TipTap.net.reportResult({ outcome: won ? 'win' : 'loss', scores: scores });
});
- That is the room's result. The player's own score is still
TipTap.updateScore during the match, then TipTap.submitScore and
TipTap.showLeaderboard at the end.
Two more things to design around:
- TipTap.sim.getVersion() tells you the module, its simulation version and the
ABI, which is what a diagnostic line should print when somebody reports a game
that will not start.
- Play again reloads the game document. A session cannot span matches, so there
is nothing to carry between them and nothing to tear down.
Platform rules, all enforced by the validator:
- ONE self-contained .html file. Every byte of CSS and JavaScript inline. No build
step, no second file, no external script, stylesheet, font or image.
- No network at runtime. fetch, XMLHttpRequest, WebSocket and EventSource are all
blocked by connect-src 'none'.
- No localStorage, sessionStorage, indexedDB or cookies. The game runs on an opaque
sandbox origin where those THROW rather than returning null, so keep every piece
of state in memory.
- Assets as data: or blob: URLs only. 5 MB for the whole file.
- Portrait-first. The game is played inside a phone-shaped frame in a vertical feed:
fill the container, handle resize, and treat landscape as a bonus.
- Dark background, score always visible, playable within about two seconds of load.
No splash screen and no menu unless the game genuinely needs one.
- window.TipTap is injected by the platform. Never define it, import it, or add a
script tag for it. Feature-detect every use so the file still runs when opened
directly during development.
- Call TipTap.updateScore(score) as the score changes, TipTap.submitScore(score) at
every terminal state, and TipTap.showLeaderboard() after the submit at a real game
over. submitScore is silent and shows the player nothing on its own.
- Gate every sound on TipTap.canPlayAudio() and re-check it on the tiptap:focus,
tiptap:blur and tiptap:mutechange events.
- Stop the requestAnimationFrame chain on tiptap:pause and restart it on
tiptap:resume. Handle TipTap.onLeaderboardClose: the player pressed Continue,
nothing was reloaded, and your state is intact.
- No API keys or credentials anywhere in the file. The validator rejects an upload
containing one, and game HTML is public to every player.
If you are working through the TipTap MCP server, call get_sdk_documentation for the
exact API surface before you write the SDK calls, and do not stop until
validate_game_draft returns passed: true.
Multiplayer rules, on top of everything above:
- TipTap.net exists ONLY in a game flagged multiplayer. It is a separate module the
platform injects, and TipTap.apiVersion does not move when it does. Guard with
window.TipTap && TipTap.net && TipTap.net.isAvailable(), in that order, and make the
game do something sensible when the answer is no — that is every local test run.
- The game still has no network of its own. connect-src is still 'none' and fetch,
WebSocket and EventSource are still blocked. The connection lives outside the frame.
- ONLY THE HOST WRITES STATE, and this is not like Playroom or Colyseus. One client in
the room is the host. TipTap.net.state.set and its siblings called anywhere else warn
on the console, return false and change nothing — no exception, no partial write. A
non-host player's move is a request the host validates and applies. Every authoritative
decision goes inside TipTap.net.runOnHost(). Design for this from the first line, or
three players in four cannot act and nothing says why.
- A non-host reaches the host with TipTap.net.sendTyped, which is GATED: it needs a
schema approved for the game and returns false until there is one. Check the return
value and tell the player. Never let a move disappear with only a console warning.
- A SCHEMA is the complete list of what a player may send, declared as JSON with the
game, and its fields may only be: bool, enum, uint, int, fixed, peer, preset, array
(with a required max) and struct. No strings, no bytes, no maps, no floats, no
unbounded lists — there is no extension hook and no flag that adds one. Declaring a
schema is not the same as having it approved: until a human approves the set the game
has no typed channel at all and sendTyped returns false for every name. A game that
works in your window and not for other players is usually waiting on that, not broken.
- Never Date.now() for anything compared between players — TipTap.net.now() is room
time. Never Math.random() in anything two clients both compute —
TipTap.net.randomFor(scope, id) is the same stream on every client. NOTHING ENFORCES
EITHER OF THESE: there is no lint and no validator rule, so a game that gets them
wrong passes every check, plays perfectly in one window, and desyncs silently with a
second player. Getting it right while writing is the only mechanism there is.
- Nothing derived from randomFor is secret; every client can recompute it. Hidden
information is rolled on the host and written with TipTap.net.state.setScoped().
- Never write reconnection logic. Reconnect, resync and liveness are handled.
TipTap.net.onDisconnect and TipTap.net.onReconnect are for showing a banner.
- Public rooms carry typed messages only. No free strings of any kind reach another
player, and nothing a peer sent may be rendered as text.
- validate_game_draft runs ONE client. It cannot test a join, a turn, a replication, a
host handover or a lobby, so a passing validation says nothing about whether the
multiplayer works. Say that plainly when you hand the game over.
