Start here17 min read
Tutorial: build Firefly Jar
Build a complete little game step by step, from a blank file to a published game with ads and saved progress.
On this page12 sections
- Before you start
- Step 1: A canvas that fills the screen
- Step 2: The game loop and one firefly
- Step 3: Catch them with touch or mouse
- Step 4: Rounds, a timer and a jar
- Step 5: Sound
- Step 6: Add the Twiddle Play SDK
- Step 7: An ad between rounds
- Step 8: A rewarded "+10 seconds" button
- Step 9: Save progress
- Step 10: Test, zip, upload and submit
- Where to go next
In this tutorial you build Firefly Jar from a blank file to a game that's ready for the Twiddle Play app. Fireflies drift across a night sky, blinking on and off. Tap them while they glow to put them in your jar. Catch enough before the timer runs out to clear the round. Tap a dark one and you lose 2 seconds.
Along the way you'll learn everything a Twiddle Play game needs:
| You'll learn | Step |
|---|---|
| A sharp, full-screen canvas on any phone | 1 |
| A game loop that runs at the same speed on every device | 2 |
| Touch and mouse input with one listener, and a score | 3 |
| Rounds, a timer and screens between rounds | 4 |
| Sound effects, and a sound on/off button | 5 |
| The Twiddle Play SDK, and pausing and muting for ads | 6 |
| An interstitial ad at the natural break between rounds | 7 |
| A rewarded "+10 seconds" button | 8 |
Saving progress with localStorage |
9 |
| Testing, zipping, uploading and submitting | 10 |
Before you start#
You need:
- Chrome (or another modern browser) and a code editor, like VS Code.
- A little JavaScript: variables,
if, functions. Everything else is explained as we go. - About an hour.
How each step works:
- You add or change some code. The code blocks show exactly what to type, with the file name in the header. Use the Copy button.
- What it does explains the code in plain words.
- Check it tells you what you should see. Save, then refresh the page in Chrome.
If you get lost, every step ends with the complete files for that step under "Full code after step N". Copy them over yours and carry on.
Step 1: A canvas that fills the screen#
Every game needs something to draw on. We'll use a <canvas>: a rectangle of pixels you can paint with JavaScript. In this step you make it fill the screen, stay sharp on phones, and paint a night sky.
Make a folder called firefly-jar with two files in it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Firefly Jar</title>
<link rel="icon" href="data:,">
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #0b1030; }
body {
font-family: system-ui, sans-serif;
color: #fff;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
overscroll-behavior: none;
}
#game { display: block; width: 100vw; height: 100vh; height: 100dvh; touch-action: none; }
</style>
</head>
<body>
<canvas id="game"></canvas>
<script src="game.js"></script>
</body>
</html>// Firefly Jar: catch the fireflies while they glow.
// A Twiddle Play tutorial game. Plain JavaScript, no libraries.
// ---------- The canvas ----------
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
let W = 0; // canvas width in CSS pixels
let H = 0; // canvas height in CSS pixels
function resize() {
// Draw at the screen's real pixel density (capped at 2 for speed), so nothing looks blurry.
const dpr = Math.min(window.devicePixelRatio || 1, 2);
W = canvas.clientWidth;
H = canvas.clientHeight;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // from now on, 1 unit = 1 CSS pixel
makeStars();
}
// ---------- The night sky ----------
let stars = [];
function makeStars() {
stars = [];
for (let i = 0; i < 60; i++) {
stars.push({ x: Math.random() * W, y: Math.random() * H * 0.7, r: 0.4 + Math.random() * 1.2 });
}
}
function drawBackground() {
const sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, '#0b1030');
sky.addColorStop(1, '#23306a');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(255, 255, 255, 0.75)';
for (const s of stars) {
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
// dark hills along the bottom
ctx.fillStyle = '#081420';
ctx.beginPath();
ctx.moveTo(0, H);
ctx.lineTo(0, H - 80);
ctx.quadraticCurveTo(W * 0.3, H - 130, W * 0.55, H - 85);
ctx.quadraticCurveTo(W * 0.8, H - 45, W, H - 100);
ctx.lineTo(W, H);
ctx.closePath();
ctx.fill();
}
// ---------- Start ----------
resize();
drawBackground();
window.addEventListener('resize', function () {
resize();
drawBackground();
});What it does:
- The CSS makes the canvas fill the window.
100dvhis the visible height on phones, even when the address bar shows.touch-action: nonestops the browser from scrolling or zooming when the player swipes on your game.user-select: noneand friends stop long-presses from selecting text or opening menus. <link rel="icon" href="data:,">tells the browser you have no icon, so it doesn't log an error asking forfavicon.ico.resize()gives the canvas the right number of pixels. A canvas has two sizes: how big it looks (its CSS size) and how many pixels it has. Phones squeeze 2 or 3 real pixels into each CSS pixel; that ratio isdevicePixelRatio. We give the canvas that many real pixels, thensetTransformlets us keep drawing in CSS pixels. Without this, everything looks blurry on phones. We cap the ratio at 2 because 3× pixels cost a lot of speed and barely look different.drawBackground()paints a blue gradient, 60 random stars, and two curved hills along the bottom.
What you should see
Check it: double-click index.html. You should see a dark-blue night sky with little stars and black hills, filling the whole window. Resize the window: it still fills it (the stars move, because they're random each time).
Step 2: The game loop and one firefly#
Games don't draw once. They redraw the whole screen about 60 times a second, moving things a tiny bit each time. That's the game loop: every frame, update() moves things, then draw() paints everything again.
In game.js, delete the // ---------- Start ---------- part at the bottom and put this in its place:
// ---------- Fireflies ----------
const TOP = 90; // fireflies stay below the score line...
const BOTTOM = 150; // ...and above the jar at the bottom
let fireflies = [];
let speed = 50; // pixels per second
let blinkSpeed = 1.7; // how fast the glow cycles
function makeFirefly() {
return {
x: 30 + Math.random() * (W - 60),
y: TOP + Math.random() * Math.max(1, H - TOP - BOTTOM),
angle: Math.random() * Math.PI * 2, // the direction it flies
blink: Math.random() * Math.PI * 2, // where it is in its glow cycle
};
}
function isLit(f) {
return Math.sin(f.blink) > 0.3;
}
function moveFirefly(f, dt) {
f.angle += (Math.random() - 0.5) * 4 * dt; // wander a little
f.x += Math.cos(f.angle) * speed * dt;
f.y += Math.sin(f.angle) * speed * dt;
f.blink += blinkSpeed * dt;
// turn back at the edges of the play area
if (f.x < 20 || f.x > W - 20) f.angle = Math.PI - f.angle;
if (f.y < TOP || f.y > H - BOTTOM) f.angle = -f.angle;
f.x = Math.max(20, Math.min(W - 20, f.x));
f.y = Math.max(TOP, Math.min(H - BOTTOM, f.y));
}
function drawFirefly(f) {
const glow = Math.max(0, Math.sin(f.blink)); // 0 = dark, 1 = brightest
ctx.globalCompositeOperation = 'lighter'; // add light instead of paint
ctx.globalAlpha = glow * 0.35;
ctx.fillStyle = '#ffdc6e';
ctx.beginPath();
ctx.arc(f.x, f.y, 10 + glow * 14, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = isLit(f) ? '#fff3b0' : '#56607e';
ctx.beginPath();
ctx.arc(f.x, f.y, 5, 0, Math.PI * 2);
ctx.fill();
}
// ---------- The game loop ----------
let lastTime = 0;
function update(dt) {
for (const f of fireflies) moveFirefly(f, dt);
}
function draw() {
drawBackground();
for (const f of fireflies) drawFirefly(f);
}
function frame(time) {
// dt = seconds since the last frame, capped so one slow frame can't make things jump
const dt = lastTime ? Math.min((time - lastTime) / 1000, 0.05) : 0;
lastTime = time;
update(dt);
draw();
requestAnimationFrame(frame);
}
// ---------- Start ----------
resize();
window.addEventListener('resize', resize);
fireflies.push(makeFirefly());
requestAnimationFrame(frame);What it does:
- A firefly is an object: its position (
x,y), the direction it's flying (angle), and where it is in its glow cycle (blink). moveFirefly()nudges the direction a little at random so it wanders, moves it forward, and turns it back at the edges.TOPandBOTTOMkeep fireflies away from the score at the top and the jar we'll add at the bottom.isLit()usesMath.sin(blink). Asblinkgrows, the sine swings smoothly between -1 and 1. Above 0.3 counts as glowing. That gives a soft blink with no timers.drawFirefly()draws a halo whose size and brightness follow the glow, then a small body: pale yellow when lit, grey when dark.globalCompositeOperation = 'lighter'adds the halo's light to the sky instead of painting over it, so it really glows.frame(time)is the loop.requestAnimationFramecalls it right before the screen refreshes.dtis the time since the last frame, in seconds. Phones refresh at 60, 90 or 120 times a second, so we move things byspeed * dt: a firefly crosses the screen in the same time on every phone. The cap at 0.05 s stops a hiccup (like switching tabs) from teleporting everything.
What you should see
Check it: refresh. One firefly wanders around the sky, glowing yellow, then fading to grey, then glowing again.
Full game.js after step 2
Loading… Open game.js as plain text
Step 3: Catch them with touch or mouse#
Now let's make it a game: more fireflies, and tapping a glowing one catches it.
Add these two sections to game.js, just above // ---------- The game loop ----------:
// ---------- Sparkles ----------
let sparks = [];
function burst(x, y) {
for (let i = 0; i < 12; i++) {
const a = Math.random() * Math.PI * 2;
const v = 60 + Math.random() * 120;
sparks.push({ x: x, y: y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0.5 });
}
}
function updateSparks(dt) {
for (const s of sparks) {
s.x += s.vx * dt;
s.y += s.vy * dt;
s.life -= dt;
}
sparks = sparks.filter(function (s) { return s.life > 0; });
}
function drawSparks() {
ctx.fillStyle = '#ffe27a';
for (const s of sparks) {
ctx.globalAlpha = s.life / 0.5;
ctx.fillRect(s.x - 2, s.y - 2, 4, 4);
}
ctx.globalAlpha = 1;
}
// ---------- Catching ----------
let score = 0;
// Pointer events cover touch, mouse and pen with one listener.
canvas.addEventListener('pointerdown', function (e) {
const rect = canvas.getBoundingClientRect();
tryCatch(e.clientX - rect.left, e.clientY - rect.top);
});
function tryCatch(x, y) {
// Find the nearest firefly within a finger's reach (44 px).
let nearest = null;
let nearestDist = 44;
for (const f of fireflies) {
const d = Math.hypot(f.x - x, f.y - y);
if (d < nearestDist) {
nearest = f;
nearestDist = d;
}
}
if (!nearest || !isLit(nearest)) return; // empty sky or a sleeping firefly
fireflies.splice(fireflies.indexOf(nearest), 1); // into the jar it goes...
fireflies.push(makeFirefly()); // ...and a new one flies in
score += 10;
burst(nearest.x, nearest.y);
}Then replace update() and draw() so they handle the sparkles and show the score:
function update(dt) {
for (const f of fireflies) moveFirefly(f, dt);
updateSparks(dt);
}
function draw() {
drawBackground();
for (const f of fireflies) drawFirefly(f);
drawSparks();
ctx.fillStyle = '#ffffff';
ctx.font = '700 22px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(String(score), W / 2, 34);
}And at the very bottom, swap fireflies.push(makeFirefly()); for eight of them:
for (let i = 0; i < 8; i++) fireflies.push(makeFirefly());What it does:
- Pointer events (
pointerdown) fire for a finger, a mouse or a pen. One listener covers phones and computers. Don't useclickfor gameplay: it waits until the finger lifts, which feels slow. getBoundingClientRect()turns the tap's position on the page into a position on the canvas. Here they're the same (the canvas fills the page), but this keeps working if you ever move it.tryCatch()finds the nearest firefly within 44 pixels of the tap. Fingers are big and fireflies are small, so we're generous: 44 to 48 pixels is a comfortable touch target on a phone.Math.hypotis the distance between two points.- A catch removes that firefly, adds a new one somewhere else, adds 10 points and bursts sparkles: twelve tiny squares that fly out and fade over half a second.
What you should see
Check it: refresh. Eight fireflies blink around the sky. Tap (or click) a glowing one: it bursts into sparkles and the score at the top goes up by 10. Tapping a dark one does nothing, yet.
Full game.js after step 3
Loading… Open game.js as plain text
Step 4: Rounds, a timer and a jar#
A game needs a goal. Each round, you must catch a number of fireflies in 20 seconds. Dark fireflies cost 2 seconds. Between rounds, a screen shows how you did.
In index.html, add this CSS at the end of the <style> block:
.panel {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
text-align: center;
background: rgba(6, 9, 30, 0.6);
}
.panel[hidden] { display: none; }
.panel h1 { margin: 0; font-size: 34px; color: #ffe27a; }
.panel p { margin: 0; max-width: 300px; font-size: 17px; line-height: 1.45; }
button {
min-width: 200px;
min-height: 52px;
padding: 0 24px;
border: 0;
border-radius: 26px;
font: 700 18px system-ui, sans-serif;
color: #1a1440;
background: #ffe27a;
cursor: pointer;
}
button:disabled { opacity: 0.5; }and this right after the <canvas>:
<div id="panel" class="panel">
<h1 id="panel-title">Firefly Jar</h1>
<p id="panel-text"></p>
<button id="panel-main" type="button">Play</button>
</div>In game.js, add the Rounds and Screens sections just above // ---------- Catching ----------:
// ---------- Rounds ----------
const ROUND_TIME = 20; // seconds per round
let state = 'title'; // 'title', 'play', 'clear' or 'timeup'
let round = 1;
let goal = 0; // fireflies to catch this round
let caught = 0;
let timeLeft = 0; // seconds
let roundStartScore = 0;
let flash = 0; // red flash after tapping a sleeping firefly
function spawnFireflies(count) {
fireflies = [];
for (let i = 0; i < count; i++) fireflies.push(makeFirefly());
}
function startRound() {
goal = 4 + round * 2; // 6, 8, 10...
speed = 40 + round * 10; // faster every round
blinkSpeed = 1.4 + round * 0.3; // shorter glows every round
caught = 0;
timeLeft = ROUND_TIME;
roundStartScore = score;
spawnFireflies(Math.min(4 + round, 12));
state = 'play';
hidePanel();
}
function roundClear() {
const bonus = Math.ceil(timeLeft) * 5;
score += bonus;
round += 1;
state = 'clear';
showPanel('Round ' + (round - 1) + ' clear!', 'Time bonus +' + bonus + '. Score: ' + score + '.', 'Next round', startRound);
}
function timeUp() {
state = 'timeup';
showPanel('Out of time!', 'You caught ' + caught + ' of ' + goal + '.', 'Try again', retryRound);
}
function retryRound() {
score = roundStartScore; // take back this round's points
startRound();
}
function showTitle() {
state = 'title';
speed = 50;
blinkSpeed = 1.7;
spawnFireflies(8);
showPanel('Firefly Jar', 'Tap the fireflies while they glow. Tapping a dark one costs 2 seconds.', 'Play', startRound);
}
// ---------- Screens ----------
const panel = document.getElementById('panel');
const panelTitle = document.getElementById('panel-title');
const panelText = document.getElementById('panel-text');
const mainButton = document.getElementById('panel-main');
let mainAction = null;
mainButton.addEventListener('click', function () {
if (mainAction) mainAction();
});
function showPanel(title, text, buttonLabel, action) {
panelTitle.textContent = title;
panelText.textContent = text;
mainButton.textContent = buttonLabel;
mainAction = action;
panel.hidden = false;
}
function hidePanel() {
panel.hidden = true;
}Replace tryCatch(), and add the HUD and jar drawing right after it:
function tryCatch(x, y) {
if (state !== 'play') return;
// Find the nearest firefly within a finger's reach (44 px).
let nearest = null;
let nearestDist = 44;
for (const f of fireflies) {
const d = Math.hypot(f.x - x, f.y - y);
if (d < nearestDist) {
nearest = f;
nearestDist = d;
}
}
if (!nearest) return; // tapped empty sky
if (isLit(nearest)) {
fireflies.splice(fireflies.indexOf(nearest), 1); // into the jar it goes...
fireflies.push(makeFirefly()); // ...and a new one flies in
score += 10;
caught += 1;
burst(nearest.x, nearest.y);
} else {
timeLeft = Math.max(0, timeLeft - 2); // a sleeping firefly costs 2 seconds
flash = 0.3;
}
}
// ---------- HUD and jar ----------
function drawHud() {
ctx.fillStyle = '#ffffff';
ctx.font = '700 18px system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Round ' + round, 16, 34);
ctx.font = '700 22px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(String(score), W / 2, 34);
// the timer bar
const barWidth = W - 32;
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
ctx.fillRect(16, 62, barWidth, 8);
ctx.fillStyle = timeLeft < 5 ? '#ff6b81' : '#ffe27a';
ctx.fillRect(16, 62, barWidth * Math.min(1, timeLeft / ROUND_TIME), 8);
}
function drawJar() {
const w = 64;
const h = 84;
const x = W / 2 - w / 2;
const y = H - h - 24;
const fill = Math.min(caught / goal, 1);
// the light inside
ctx.globalAlpha = 0.85;
ctx.fillStyle = '#ffdc6e';
ctx.fillRect(x + 4, y + 4 + (h - 8) * (1 - fill), w - 8, (h - 8) * fill);
ctx.globalAlpha = 1;
// the glass and the lid
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 3;
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = '#9b7344';
ctx.fillRect(x - 5, y - 12, w + 10, 12);
// how many so far
ctx.fillStyle = '#ffffff';
ctx.font = '700 16px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(caught + ' / ' + goal, W / 2, y - 22);
}Replace update() and draw() once more:
function update(dt) {
for (const f of fireflies) moveFirefly(f, dt);
updateSparks(dt);
flash = Math.max(0, flash - dt);
if (state !== 'play') return;
timeLeft -= dt;
if (caught >= goal) {
roundClear();
} else if (timeLeft <= 0) {
timeLeft = 0;
timeUp();
}
}
function draw() {
drawBackground();
for (const f of fireflies) drawFirefly(f);
drawSparks();
if (state === 'play') {
drawJar();
drawHud();
}
if (flash > 0) {
ctx.globalAlpha = flash;
ctx.fillStyle = '#ff4d6d';
ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
}
}Finally, at the bottom, start on the title screen instead of spawning fireflies yourself:
// ---------- Start ----------
resize();
window.addEventListener('resize', resize);
showTitle();
requestAnimationFrame(frame);What it does:
statesays which part of the game we're in:'title','play','clear'(round won) or'timeup'(round lost). Taps only catch fireflies in'play', and the timer only runs in'play'. Most games are a handful of states like this.startRound()sets up the round from its number, so every round is a little harder:Round Goal Fireflies Speed (px/s) 1 6 5 50 2 8 6 60 3 10 7 70 5 14 9 90 8 20 12 120 blinkSpeedalso grows, so glows get shorter.roundClear()adds a time bonus (5 points per second left) and moves to the next round.timeUp()offers a retry;retryRound()takes back the points from the failed attempt.The screens are plain HTML on top of the canvas: a heading, a line of text and one big button. HTML buttons are easy to style, easy to tap, and work with screen readers.
showPanel()fills them in andhidePanel()hides them..panel[hidden] { display: none; }matters:display: flexwould otherwise beat thehiddenattribute and the panel would never hide.The HUD (heads-up display) draws the round, the score and a timer bar that turns red under 5 seconds. The jar fills with light as you catch fireflies.
What you should see
Check it: refresh. A title screen says "Firefly Jar" with a Play button. Tap it: the timer bar starts shrinking and the jar shows 0 / 6. Catch six to see "Round 1 clear!". Let the timer run out to see "Out of time!" and Try again. Tap a dark firefly: the screen flashes red and the bar jumps back.
Full index.html after step 4
Loading… Open index.html as plain text
Full game.js after step 4
Loading… Open game.js as plain text
Step 5: Sound#
A little sound makes catching feel great. We'll make it with the Web Audio API, so there are no sound files to load. You'll also add a Sound: on/off button, because players expect one.
In index.html, add the button's style at the end of the <style> block:
#sound {
position: fixed;
top: 10px;
right: 10px;
min-width: 0;
min-height: 44px;
padding: 0 16px;
font-size: 14px;
color: #fff;
background: rgba(255, 255, 255, 0.14);
}and the button itself after the panel's closing </div>:
<button id="sound" type="button">Sound: on</button>In game.js, add a Sound section just above // ---------- Catching ----------:
// ---------- Sound ----------
let audio = null; // the Web Audio context, created on the first tap
let master = null; // the master volume knob
let playerMuted = false; // the player's own Sound on/off choice
function unlockAudio() {
// Browsers only allow sound after the player has touched the page.
if (!audio) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return;
audio = new AudioCtx();
master = audio.createGain();
master.connect(audio.destination);
applyVolume();
}
if (audio.state === 'suspended') audio.resume();
}
window.addEventListener('pointerdown', unlockAudio);
function applyVolume() {
if (master) master.gain.value = playerMuted ? 0 : 0.5;
}
// A short synth note: no sound files needed.
function beep(freq, duration, delay, type) {
if (!audio) return;
const t = audio.currentTime + (delay || 0);
const osc = audio.createOscillator();
const env = audio.createGain();
osc.type = type || 'sine';
osc.frequency.setValueAtTime(freq, t);
env.gain.setValueAtTime(0.4, t);
env.gain.exponentialRampToValueAtTime(0.001, t + duration);
osc.connect(env);
env.connect(master);
osc.start(t);
osc.stop(t + duration);
}
const soundButton = document.getElementById('sound');
soundButton.addEventListener('click', function () {
playerMuted = !playerMuted;
soundButton.textContent = playerMuted ? 'Sound: off' : 'Sound: on';
applyVolume();
});Then play sounds at the right moments. Replace tryCatch(), roundClear() and timeUp() with these versions; the new lines are the beep(...) calls:
function tryCatch(x, y) {
if (state !== 'play') return;
// Find the nearest firefly within a finger's reach (44 px).
let nearest = null;
let nearestDist = 44;
for (const f of fireflies) {
const d = Math.hypot(f.x - x, f.y - y);
if (d < nearestDist) {
nearest = f;
nearestDist = d;
}
}
if (!nearest) return; // tapped empty sky
if (isLit(nearest)) {
fireflies.splice(fireflies.indexOf(nearest), 1); // into the jar it goes...
fireflies.push(makeFirefly()); // ...and a new one flies in
score += 10;
caught += 1;
burst(nearest.x, nearest.y);
beep(880, 0.12);
} else {
timeLeft = Math.max(0, timeLeft - 2); // a sleeping firefly costs 2 seconds
flash = 0.3;
beep(150, 0.25, 0, 'triangle');
}
}
function roundClear() {
const bonus = Math.ceil(timeLeft) * 5;
score += bonus;
round += 1;
state = 'clear';
beep(660, 0.15);
beep(880, 0.15, 0.12);
beep(1320, 0.3, 0.24);
showPanel('Round ' + (round - 1) + ' clear!', 'Time bonus +' + bonus + '. Score: ' + score + '.', 'Next round', startRound);
}
function timeUp() {
state = 'timeup';
beep(330, 0.3);
beep(220, 0.45, 0.25);
showPanel('Out of time!', 'You caught ' + caught + ' of ' + goal + '.', 'Try again', retryRound);
}What it does:
- Browsers block sound until the player touches the page. So
unlockAudio()creates theAudioContexton the firstpointerdown. The first touch is usually the Play button, so sound is ready when the round starts. beep()plays one synth note: an oscillator (a tone atfreqhertz) through a volume envelope that fades out overdurationseconds.delaylets us play a little tune: the round-clear jingle is three rising notes 0.12 seconds apart.masteris one volume knob for every sound.applyVolume()sets it to 0 when the player turns sound off. Keeping one knob makes the next step easy.- The Sound button sits after the panel in the HTML, so it stays on top and works on every screen.
What you should see
Check it: refresh and tap Play. Catching a firefly plays a high blip, a dark one a low buzz, clearing a round a little jingle. Tap Sound: on: it changes to Sound: off and the game goes quiet.
Full index.html after step 5
Loading… Open index.html as plain text
Full game.js after step 5
Loading… Open game.js as plain text
Step 6: Add the Twiddle Play SDK#
Your game works. Now let's make it a Twiddle Play game. The SDK is one small file that gives your game the TwiddlePlay object: it lets your game ask for ads, and tells your game when to pause and go quiet.
- Download
twiddleplay.jsand save it in yourfirefly-jarfolder astwiddleplay-sdk.js. - In
index.html, replace<script src="game.js"></script>with these lines:
<!-- Twiddle Play SDK: the platform's copy first, then the copy in your zip (whichever loads first wins) -->
<script src="/sdk/v1/twiddleplay.js"></script>
<script src="twiddleplay-sdk.js"></script>
<script src="game.js"></script>- In
game.js, in theSoundsection, add a second "muted" flag underplayerMuted, and let either one silence the game:
let playerMuted = false; // the player's own Sound on/off choice
let platformMuted = false; // Twiddle Play mutes the game during ads and in the backgroundfunction applyVolume() {
if (master) master.gain.value = playerMuted || platformMuted ? 0 : 0.5;
}- In the game loop, add a
pausedflag and skipupdate()while it's set:
let lastTime = 0;
let paused = false; // true while an ad shows or the app is in the backgroundfunction frame(time) {
// dt = seconds since the last frame, capped so one slow frame can't make things jump
const dt = lastTime ? Math.min((time - lastTime) / 1000, 0.05) : 0;
lastTime = time;
if (!paused) update(dt);
draw();
requestAnimationFrame(frame);
}- Replace the
Startsection at the bottom with aTwiddle Playsection and a newStart:
// ---------- Twiddle Play ----------
// Before an ad, and when the app goes to the background, the game gets pause + mute.
// After, it gets resume + unmute.
TwiddlePlay.on('pause', function () {
paused = true;
});
TwiddlePlay.on('resume', function () {
paused = false;
});
TwiddlePlay.on('mute', function () {
platformMuted = true;
applyVolume();
});
TwiddlePlay.on('unmute', function () {
platformMuted = false;
applyVolume();
});
// ---------- Start ----------
resize();
window.addEventListener('resize', resize);
requestAnimationFrame(frame);
TwiddlePlay.ready().then(function () {
console.log('Twiddle Play is ready. Environment: ' + TwiddlePlay.env);
showTitle();
});- Tell Twiddle Play when play starts and stops. Add
TwiddlePlay.gameplayStart();as the last line ofstartRound(), andTwiddlePlay.gameplayStop();right afterstate = 'clear';inroundClear()and right afterstate = 'timeup';intimeUp():
function startRound() {
goal = 4 + round * 2; // 6, 8, 10...
speed = 40 + round * 10; // faster every round
blinkSpeed = 1.4 + round * 0.3; // shorter glows every round
caught = 0;
timeLeft = ROUND_TIME;
roundStartScore = score;
spawnFireflies(Math.min(4 + round, 12));
state = 'play';
hidePanel();
TwiddlePlay.gameplayStart();
}What it does:
- Two script tags. The first loads Twiddle Play's own copy of the SDK. It's always there inside the app and the portal preview, but not on your computer. The second loads the copy in your folder. The SDK only sets itself up once, so whichever loads first wins and the other does nothing.
TwiddlePlay.ready()finds out where the game is running, then resolves. After that,TwiddlePlay.envtells you:'android'in the app,'host'in the portal preview,'standalone'anywhere else (like your computer). We wait for it before showing the title screen.pauseandresumearrive before and after an ad, and when the player leaves the app and comes back. Whilepausedis true,update()doesn't run: fireflies freeze and, most importantly, the timer stops. A player should never lose a round because an ad played.muteandunmutearrive at the same moments. We keepplatformMutedseparate fromplayerMuted, so anunmuteafter an ad can't switch on sound the player turned off.gameplayStart()andgameplayStop()are optional hints that tell Twiddle Play when real play is happening. They never show anything.
What you should see
Check it: refresh. The game plays exactly as before, and the console says Twiddle Play is ready. Environment: standalone. You'll also see one error for /sdk/v1/twiddleplay.js: that's expected on your computer, because only Twiddle Play has that file; your copy loaded instead. You'll see pause and mute in action in the next step, when the first test ad plays.
Full index.html after step 6
Loading… Open index.html as plain text
Full game.js after step 6
Loading… Open game.js as plain text
Step 7: An ad between rounds#
The moment after "Round 1 clear!" is a natural break: the player has stopped playing and is about to tap Next round. That's the right place for an interstitial (a full-screen ad). Players expect a short pause there; an ad in the middle of a round would make them quit.
In roundClear(), change the last argument of showPanel(...) from startRound to nextRound, and add nextRound() below it:
showPanel('Round ' + (round - 1) + ' clear!', 'Time bonus +' + bonus + '. Score: ' + score + '.', 'Next round', nextRound);
}
async function nextRound() {
// A natural break: the app may show an ad here. Shown or not, the game carries on.
mainButton.disabled = true;
await TwiddlePlay.showInterstitial();
mainButton.disabled = false;
startRound();
}What it does:
showInterstitial()asks the app for an ad. The app decides whether one actually shows, then the promise resolves. Shown or not, carry on: we always callstartRound()afterwards.awaitwaits for the ad to finish before the next round starts. That's why the function isasync.- Disabling the button while we wait stops a double tap from starting two rounds.
- Most calls show nothing, and that's normal. The app never shows an interstitial in the first 60 seconds of a session, never within 90 seconds of the last one, and never more than one per 3 minutes of play. You don't need to count anything yourself: call
showInterstitial()at every natural break and let the app decide. See the limits the app enforces.
What you should see
Check it: refresh, play, and clear round 1 within the first minute. Tap Next round: round 2 starts straight away, and the console says [TwiddlePlay] interstitial not shown: first 60 s of a session. Keep playing past the first minute and clear another round. This time Next round shows a dark INTERSTITIAL · TEST screen with a 3-second countdown, then the round starts. While it's up, your game is paused and silent.
Full game.js after step 7
Loading… Open game.js as plain text
Step 8: A rewarded "+10 seconds" button#
When the timer runs out, a player is often just one or two fireflies short. Let's offer them a choice: watch an ad, get 10 more seconds. That's a rewarded ad: the player decides to watch it and gets something they want. These ads are popular with players and usually earn more than interstitials.
In index.html, add a style for a second, quieter button at the end of the <style> block:
button.secondary { color: #fff; background: #343a6e; }and add the button inside the panel, after the main button:
<div id="panel" class="panel">
<h1 id="panel-title">Firefly Jar</h1>
<p id="panel-text"></p>
<button id="panel-main" type="button">Play</button>
<button id="panel-extra" type="button" class="secondary" hidden></button>
</div>In game.js, in the Screens section, wire up the second button and let showPanel() hide it each time. Replace the section with:
// ---------- Screens ----------
const panel = document.getElementById('panel');
const panelTitle = document.getElementById('panel-title');
const panelText = document.getElementById('panel-text');
const mainButton = document.getElementById('panel-main');
const extraButton = document.getElementById('panel-extra');
let mainAction = null;
let extraAction = null;
mainButton.addEventListener('click', function () {
if (mainAction) mainAction();
});
extraButton.addEventListener('click', function () {
if (extraAction) extraAction();
});
function showPanel(title, text, buttonLabel, action) {
panelTitle.textContent = title;
panelText.textContent = text;
mainButton.textContent = buttonLabel;
mainAction = action;
extraButton.hidden = true; // a second button only when we ask for one
panel.hidden = false;
}
function showExtra(buttonLabel, action) {
extraButton.textContent = buttonLabel;
extraAction = action;
extraButton.hidden = false;
}
function hidePanel() {
panel.hidden = true;
}In the Rounds section, add a flag under let flash = 0;, and reset it in startRound():
let continueUsed = false; // one rewarded continue per roundfunction startRound() {
goal = 4 + round * 2; // 6, 8, 10...
speed = 40 + round * 10; // faster every round
blinkSpeed = 1.4 + round * 0.3; // shorter glows every round
caught = 0;
timeLeft = ROUND_TIME;
roundStartScore = score;
continueUsed = false;
spawnFireflies(Math.min(4 + round, 12));
state = 'play';
hidePanel();
TwiddlePlay.gameplayStart();
}Then replace timeUp() and add the two functions that offer and play the ad:
function timeUp() {
state = 'timeup';
TwiddlePlay.gameplayStop();
beep(330, 0.3);
beep(220, 0.45, 0.25);
showPanel('Out of time!', 'You caught ' + caught + ' of ' + goal + '.', 'Try again', retryRound);
offerContinue();
}
async function offerContinue() {
if (continueUsed) return;
// Only offer the button when an ad can actually play.
const available = await TwiddlePlay.hasRewarded();
if (available && state === 'timeup') showExtra('+10 seconds (watch an ad)', watchAdToContinue);
}
async function watchAdToContinue() {
extraButton.disabled = true;
const result = await TwiddlePlay.showRewarded();
extraButton.disabled = false;
if (result.rewarded && state === 'timeup') {
// The player watched to the end: give exactly what the button promised.
continueUsed = true;
timeLeft = 10;
state = 'play';
hidePanel();
TwiddlePlay.gameplayStart();
} else {
panelText.textContent = "The ad didn't finish, so no extra time. You can try again.";
}
}What it does:
hasRewarded()tells you whether a rewarded ad can play right now. It'sfalsewhen ads are off for your game, when another ad is on screen, or (in the app) when no ad has loaded yet. We only show the button when it'strue, so players never tap a button that does nothing.state === 'timeup'is checked again afterawait, becausehasRewarded()can take a moment, and the player may have tapped Try again meanwhile.showRewarded()plays the ad and resolves{ rewarded: true }only if the player watched to the end. Give the reward only then, and give exactly what the button said.- One continue per round (
continueUsed) keeps the game fair and the round meaningful.
What you should see
Check it: start a round and let the timer run out. Under Try again, a second button appears: +10 seconds (watch an ad). Tap it: a REWARDED · TEST screen counts down from 3, then you're back in the round with 10 seconds on the clock and your fireflies still in the jar. The fireflies pick up exactly where they were, because your pause handler froze the game while the ad was up. Run out again: the button doesn't come back this round.
Full index.html after step 8
Loading… Open index.html as plain text
Full game.js after step 8
Loading… Open game.js as plain text
Step 9: Save progress#
Players close games all the time: a bus stop, a phone call, bedtime. If they come back to round 1 every time, they won't come back. Let's remember the round, the score, the best score and the sound setting.
Add a Saving progress section just above // ---------- Start ----------:
// ---------- Saving progress ----------
// Each game in the app has its own storage, kept when the game updates.
const SAVE_KEY = 'firefly-jar.v1';
let best = 0;
function save() {
try {
localStorage.setItem(SAVE_KEY, JSON.stringify({ round: round, score: score, best: best, muted: playerMuted }));
} catch (e) {
// Storage can be full or switched off. The game still works; it just won't remember.
}
}
function load() {
try {
const data = JSON.parse(localStorage.getItem(SAVE_KEY));
if (data) {
round = data.round || 1;
score = data.score || 0;
best = data.best || 0;
playerMuted = !!data.muted;
}
} catch (e) {
// Nothing saved yet, or the save is broken: start fresh.
}
}Load the save when the game starts. Replace the Start section:
// ---------- Start ----------
resize();
window.addEventListener('resize', resize);
load();
updateSoundButton();
requestAnimationFrame(frame);
TwiddlePlay.ready().then(function () {
console.log('Twiddle Play is ready. Environment: ' + TwiddlePlay.env);
showTitle();
});Save after every cleared round. In roundClear(), add the two lines after round += 1;:
function roundClear() {
const bonus = Math.ceil(timeLeft) * 5;
score += bonus;
round += 1;
best = Math.max(best, score);
save(); // a finished round is a safe point to come back to
state = 'clear';
TwiddlePlay.gameplayStop();
beep(660, 0.15);
beep(880, 0.15, 0.12);
beep(1320, 0.3, 0.24);
showPanel('Round ' + (round - 1) + ' clear!', 'Time bonus +' + bonus + '. Score: ' + score + '.', 'Next round', nextRound);
}Welcome players back on the title screen, with a way to start over. Replace showTitle() and add newGame():
function showTitle() {
state = 'title';
speed = 50;
blinkSpeed = 1.7;
spawnFireflies(8);
if (round > 1) {
showPanel('Firefly Jar', 'Welcome back! Round ' + round + ', score ' + score + '. Best: ' + best + '.', 'Continue', startRound);
showExtra('New game', newGame);
} else {
showPanel('Firefly Jar', 'Tap the fireflies while they glow. Tapping a dark one costs 2 seconds.', 'Play', startRound);
}
}
function newGame() {
round = 1;
score = 0;
save();
startRound();
}Remember the sound choice too. In the Sound section, replace the sound button code with:
const soundButton = document.getElementById('sound');
function updateSoundButton() {
soundButton.textContent = playerMuted ? 'Sound: off' : 'Sound: on';
}
soundButton.addEventListener('click', function () {
playerMuted = !playerMuted;
updateSoundButton();
applyVolume();
save();
});What it does:
localStoragekeeps text between visits. We turn our data into text withJSON.stringifyand back withJSON.parse.- The key
firefly-jar.v1is unique to this game. The.v1lets you change the format later without reading old saves wrongly. try/catchmatters: storage can be full or switched off. If saving fails, the game still works; it just won't remember.- We save at safe points: when a round is cleared, and when a setting changes. We don't save in the middle of a round, so a player who quits mid-round starts that round fresh, with the score they had when it began.
- In the Twiddle Play app, each game gets its own storage. Other games can't read yours, and it's kept when you ship an update.
What you should see
Check it: clear a round, then refresh the page. The title now says "Welcome back! Round 2, score …" with Continue and New game. Turn the sound off and refresh: it stays off. To see the save itself, open the developer tools' Application tab, then Local storage.
Full game.js after step 9 (the finished game)
Loading… Open game.js as plain text
The game is finished.
Step 10: Test, zip, upload and submit#
Test it on your computer#
Run through this list with the developer tools open at phone size:
- The console shows no red errors except the one for
/sdk/v1/twiddleplay.js. - Rounds start, clear and fail as expected, and the timer stops while you're on another tab.
- Sound: off silences everything, and stays off after a refresh.
- +10 seconds (watch an ad) plays a test ad and gives you 10 seconds.
- After your first minute of play, Next round shows a test interstitial.
- Refreshing after a cleared round offers Continue.
More ways to test, including on a real phone: Testing your game.
Zip it#
Your folder holds three files:
firefly-jar/
├── index.html
├── game.js
└── twiddleplay-sdk.jsZip the folder: right-click it, then Compress (Mac) or Send to → Compressed (zipped) folder (Windows). Twiddle Play strips the single top folder, so index.html ends up at the root. Every file type here is allowed, and the paths are relative (game.js, not /game.js), so the game works from the sub-folder Twiddle Play serves it from. The rules are in Packaging and upload.
Upload it and press Test it#
- On your Dashboard, click New game. Use the title "Firefly Jar", a tagline like "Catch the fireflies while they glow", a description of 20 characters or more, category Arcade, age rating All ages, orientation Portrait, and Ads on. Click Create game.
- On the Versions tab, drop your zip in Upload your game and click Upload v1.
- Check the upload checks: SDK detected and Works offline should both be green.
- Click Test it. Play a round in the phone frame and watch the SDK activity panel: you'll see
ready(),gameplayStart(), andshowInterstitial()when you tap Next round. In the preview, every ad call plays a 3-second test ad straight away; tick App ad limits to see the real timing.
Finish the store page and submit#
- On the Store page tab, upload an icon: square, 256 to 1024 pixels, PNG, JPG or WebP, at most 1 MB. A glowing jar on a dark-blue background reads well at small sizes.
- Add a few screenshots. In Chrome's phone view, open the developer tools' ⋮ menu and choose Capture screenshot. Portrait shots around 1080×1920 look great; each must be under 2 MB.
- Back on Versions, click Submit for review.
A reviewer plays it on a phone. Once approved, Firefly Jar is live in the app. When you ship v2 later, v1 stays live until v2 is approved.
Level complete
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
Firefly Jar, from a blank file to a game with ads and saved progress, ready to submit.
Download the finished gameWhere to go next#
Ideas to make Firefly Jar your own:
- A golden firefly that's worth 50 points but only glows for a moment.
- Combos: catch three in a row without a miss for double points.
- A pause button in the HUD that stops the round and calls
TwiddlePlay.gameplayStop(). - A banner between rounds: call
TwiddlePlay.showBanner()inshowPanel()andTwiddlePlay.hideBanner()inhidePanel(). The canvas already re-fits onresize, so the game shrinks neatly to make room. See Banners. - Your own art: swap the circles for little drawn fireflies with wings.
Then read:
- Ads and earnings to place ads that earn without annoying players.
- SDK reference for every call in detail.
- Review guidelines so your next game is approved first time.