Build10 min read
Testing your game
Test it in the portal's phone preview, test ads on your own computer, and check performance, touch and orientation like a reviewer would.
On this page6 sections
A reviewer plays every version on a phone before it goes live. If you test the same things first, your game sails through. There are two places to test:
- On your own computer, while you build. The SDK runs in standalone mode and shows test ads.
- In the portal, after you upload. The Test it button plays your uploaded zip in a phone frame, exactly as Twiddle Play hosts it.
Test on your own computer#
Double-click or local server?#
The quickest test is to double-click index.html. That opens it from file://, which works for simple games made of plain <script> tags and images.
Use a local server when your game uses any of these, because browsers block them on file://:
- ES modules (
<script type="module">, anything built with Vite or webpack); fetch()orXMLHttpRequestto load your own JSON, levels or sounds;- engines that load assets with
fetch(KAPLAY, Phaser, PixiJS, Three.js, Godot, Unity and most others).
Start one from your game folder with either of these:
npx serve .python3 -m http.server 8080Then open the address it prints, like http://localhost:8080.
Read the console#
Open the browser's developer tools (F12, or Cmd+Option+I on a Mac) and look at the Console.
- One 404 for
/sdk/v1/twiddleplay.jsis expected. That file only exists on Twiddle Play; your owntwiddleplay-sdk.jsloads instead. - Every other red line is a bug. Fix it before you upload.
- Lines starting with
[TwiddlePlay]are the SDK telling you what it did, like why an ad was skipped.
Test ads on your computer (standalone mode)#
On your computer, TwiddlePlay.env is 'standalone' and every full-screen ad call shows a test overlay:
- a dark screen labelled
INTERSTITIAL · TESTorREWARDED · TEST; - the text "Test ad — real ads show only in the Twiddle Play app";
- a countdown from 3, after which it closes by itself.
While the overlay is up, your game gets pause and mute, then resume and unmute, exactly like in the app.
Standalone mode copies the app's rules:
| What you call | What happens in standalone mode |
|---|---|
showInterstitial() in the first 60 s after the page loaded |
Nothing shows. Console: [TwiddlePlay] interstitial not shown: first 60 s of a session. Returns { shown: false }. |
showInterstitial() under 90 s after the last one |
Console: ... not shown: under 90 s since the last. |
| More than one interstitial per 3 minutes | Console: ... not shown: max 1 per 3 min of play. |
showRewarded() |
The test ad always plays and returns { rewarded: true }. |
showRewarded() while another test ad is showing |
Console: [TwiddlePlay] rewarded not shown: an ad is showing. Returns { rewarded: false }. |
hasRewarded() |
Always true. |
showBanner() |
A slim test bar labelled "Test banner — real ads only in the Twiddle Play app", fixed to the bottom (or top) of the page until hideBanner(). Returns { shown: true }. It covers that strip of your game, because a page can't make its own window smaller; in the app and the preview your game shrinks instead. |
exit() |
Console: [TwiddlePlay] exit() closes the game in the app. |
To test your interstitial straight away, use the portal preview instead: its ad limits are off unless you turn them on.
Test pause and mute without an ad#
Switch to another browser tab, wait a few seconds, and come back. The SDK sends pause + mute when the page is hidden and resume + unmute when it comes back, in every environment. Your game should:
- freeze completely (timers too) and go silent;
- carry on exactly where it was, without a jump;
- keep the sound off if the player had muted it with your own sound button.
Test on a phone-sized screen#
In Chrome's developer tools, click the device toolbar icon (or press Cmd+Shift+M / Ctrl+Shift+M) and pick a phone, or type a size. Reviewers check at 360×740. Then:
- use touch mode (the cursor turns into a circle) and play a full round;
- try a small phone (360×640) and a tall one (412×915);
- rotate if your game's orientation is Any.
Test it in the portal#
After you upload a zip, open your game's Versions tab and press Test it on the version. Your game opens full screen in a phone frame, served from the same game server players' phones download from.
What's on screen:
- The phone frame. It starts in your store page's orientation.
- Portrait / Landscape buttons at the top. Flip them to check both if your orientation is Any.
- Restart (the circular arrow) reloads the game and clears the log.
- Open in a new tab runs the same files outside the frame, in standalone mode, with the SDK's own test ads and limits.
- Close (the X, or Esc).
- SDK activity on the side (below the phone on small screens). It shows "Waiting for the SDK…" until your game's SDK says hello, then Connected. Every call appears with a timestamp:
ready(),hasRewarded() → true,showInterstitial(): showing a test interstitial,Sent pause + mute to the game, and so on.
Ads in the preview#
- Test ads count down from 3 inside the phone frame.
- A rewarded test ad has a Close early (no reward) link. Use it to check that your game gives no reward when
rewardedisfalse. - App ad limits is a checkbox above the log, off by default. While it's off, every
showInterstitial()shows a test ad, so you can check your interstitial at once. Tick it to apply the app's timing (none in the first 60 s, at least 90 s apart, at most one per 3 minutes). The log then says why a call was skipped, likeskipped by the app rules: no ads in the first 60 s (12 s so far). showBanner()shows a test bar below the game (or above it), and the game area shrinks by its height, like in the app. Check that nothing gets cut off, and that the bar is gone once play starts.- If your game's ads are off, the panel says "Ads are off for this game, so every ad call answers “no”." Every ad call returns no, and
hasRewarded()returnsfalse. Check that your game still plays well that way. exit()shows a message: "In the app this closes the game."
What the preview can't show you#
- Real ads. Real ads only run in the Twiddle Play app.
- Your phone's real speed. Your laptop is much faster than most phones. See the performance checklist.
- Real touch. A mouse is not a thumb. Try the game on an actual phone (log in to the portal on your phone and press Test it there), or at least use touch mode in your browser's device toolbar.
Keep your game smooth#
Phones are slower than your laptop. Many also redraw the screen 90 or 120 times a second instead of 60, and in the Twiddle Play app your game runs at the phone's fastest rate. Six habits keep a game smooth on all of them:
- Cap the canvas at 2×. Phones report a
devicePixelRatioof 3 or more. A canvas that big has over twice the pixels to fill every frame, and nobody can see the difference. UseMath.min(devicePixelRatio, 2). - Move things by time, not by frame. Measure how many seconds passed since the last frame (
dt) and move byspeed * dt. Then your game runs at the same speed at 60 and 120 frames a second, and a slow frame doesn't slow the game down. - No
shadowBlurorfilterin your game loop. Both blur pixels on every draw, which is very slow on phones. Draw the glow once into a small canvas, then copy it withdrawImage. - Reuse objects (a "pool"). Creating bullets, sparks or arrays every frame makes the browser stop now and then to clean up, which shows up as a hiccup. Make them once and reuse them.
- Keep images small. Use WebP or compressed PNG, no bigger than you draw them. A 4000 px background on a 400 px screen just wastes memory and loading time.
- Stop on
pause. During an ad, or when the player leaves the app, stop updating. Onresume, reset your frame clock so nothing jumps.
All six in one small game loop:
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// 1. Sharp, but at most 2 canvas pixels per CSS pixel
function fit() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(innerWidth * dpr);
canvas.height = Math.round(innerHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixels
}
addEventListener('resize', fit);
fit();
// 3. A glow drawn once (instead of shadowBlur every frame)
const glow = document.createElement('canvas');
glow.width = glow.height = 64;
const g = glow.getContext('2d');
const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32);
grad.addColorStop(0, 'rgba(255, 220, 120, 1)');
grad.addColorStop(1, 'rgba(255, 220, 120, 0)');
g.fillStyle = grad;
g.fillRect(0, 0, 64, 64);
// 4. A pool of 100 sparks, made once and reused
const sparks = [];
for (let i = 0; i < 100; i++) sparks.push({ alive: false, x: 0, y: 0, vx: 0, vy: 0 });
function spawnSpark(x, y) {
for (const s of sparks) {
if (s.alive) continue;
s.alive = true;
s.x = x; s.y = y;
s.vx = Math.random() * 200 - 100; // pixels per second
s.vy = -200;
return;
}
}
// 6. Stop while paused, and don't jump on resume
let paused = false;
let last = 0;
TwiddlePlay.on('pause', function () { paused = true; });
TwiddlePlay.on('resume', function () { paused = false; last = 0; });
// 2. Move by elapsed time
function frame(now) {
requestAnimationFrame(frame);
if (paused) return;
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; // seconds, at most 0.05
last = now;
ctx.clearRect(0, 0, innerWidth, innerHeight);
for (const s of sparks) {
if (!s.alive) continue;
s.vy += 400 * dt; // gravity
s.x += s.vx * dt;
s.y += s.vy * dt;
if (s.y > innerHeight) s.alive = false; // back into the pool
ctx.drawImage(glow, s.x - 16, s.y - 16, 32, 32);
}
}
requestAnimationFrame(frame);
canvas.addEventListener('pointerdown', function (e) { spawnSpark(e.clientX, e.clientY); });The sample games on Twiddle Play follow the same habits. Run through the checklist below before you submit.
Performance checklist#
Most players have mid-range Android phones. Check these before you submit:
- Steady frame rate. Use
requestAnimationFrameand move things by elapsed time (dt), not by a fixed amount per frame. - Sharp but not huge. Scale your canvas by
devicePixelRatio, but cap it at 2:Math.min(devicePixelRatio, 2). Many phones report 3, which makes the canvas over twice as big for little visible gain. - No garbage every frame. Avoid creating new arrays and objects inside your game loop. Reuse them.
- Cheap effects.
shadowBlur, bigfilters and full-screen gradients redrawn every frame are slow on phones. Pre-render glows into an image once. - Small images. Use WebP or compressed PNG. A background never needs to be bigger than 2048 px. Pack small sprites into one atlas.
- Small audio. Use OGG or MP3, mono for sound effects. A 3-minute WAV music file can be 30 MB; the same in MP3 is about 3 MB.
- Quick start. Show something within a second, even if it's a loading bar.
- Small download. Players download your whole zip. The upload page warns you when the game unpacks to more than 60% of the limit. Smaller is better on mobile data.
- Memory. Unload what you don't need. If the page runs out of memory in the app, the game closes with "The game stopped unexpectedly."
Touch and layout checklist#
- Big targets. Buttons at least 48×48 CSS pixels (44 at the very least), with space between them. Fingers are much less precise than a mouse.
- Touch and mouse both work. Pointer events (
pointerdown) handle both with one listener. - No page scrolling or zooming. Use
touch-action: noneon your canvas and a viewport meta tag. See Troubleshooting. - Nothing important at the very edges. Rounded corners and camera cutouts can hide the outer few pixels. Keep buttons and scores away from the edges.
- Fits every screen. Resize your canvas when the window size changes (a banner appearing or going away changes it too), and try short and tall phones.
- Orientation. Pick the right one in your store page: Portrait, Landscape or Any. The app locks the screen to it. With Any, your game must handle rotation at any moment.
- No pop-ups.
alert(),confirm()andprompt()are blocked in the preview frame, and they look out of place anyway. Draw your own dialogs. - No hover-only controls. Phones have no hover.
Before you submit#
Run through this once with your final zip, in Test it:
- The game loads without errors and SDK activity says Connected, then
ready(). - You can play from the start to a level end, and to a loss.
- Your interstitial shows at a level end (limits off), and the game continues correctly after it.
- Your rewarded button appears only when
hasRewarded()is true. A full watch gives the reward; Close early (no reward) gives nothing. - Pause and mute work: the game freezes and goes quiet during every test ad.
- If you use a banner: it shows only on menus and between rounds, the game re-fits when it appears, and it's gone when play starts.
- Restart, then reload: saved progress is still there.
- Both orientations look right, if you chose Any.
Then submit. The review guidelines list everything the reviewer checks.