What it does
A retro arcade game in the style of Space Invaders, inside the customer portal. The subscriber's hero shoots at three kinds of gremlin (Cancel, Pause and Skip in the sample), and clearing them all adds a free gift to their next order. It keeps score and a high score, plays 8-bit music and sound effects with a mute button, works with keyboard or touch, and pauses itself when scrolled out of view.
Why it exists
For subscription brands that want subscribers opening the portal more, with a playful, low-effort way to hand out a gift: subscribers earn it instead of just being given one. Best for consumable brands with a fun voice. The sprites, projectiles and music are all customizable, so any brand can make it its own.
The code
Paste it as-is, or download subs-invaders.html<!--
SUBS INVADERS — a custom extension for subscription customer portals (built for Skio).
Defend your subscription: shoot a star at each churn gremlin (Cancel, Pause, Skip)
to win them back. Win back every one and a free gift unlocks.
INSTALL
Skio Dashboard → Storefront → Customer Portal → Branding → Layout →
Add custom extension → paste this WHOLE file (HTML + CSS + JS, nothing else)
→ Save → "+" to add it to the layout → Publish. Test on a test subscription's
magic link with ?forcePreview=true (scripts may not run in Preview).
BEFORE YOU PASTE (all in the CONFIG block of the script)
1. REWARD — set mode to 'quickAction' (and your stackId) or 'endpoint' (and
your URL). It ships in 'demo' mode, which refuses to hand out gifts on a
live site, so nothing breaks if you forget — but nobody gets a gift either.
2. TEXT.giftName — what the winner actually gets, e.g. "a free sample pack".
3. Make it yours: the four brand touchpoints below.
THE GIFT
Skio's Quick Action called "Add Free Gift" adds a product at $0, one time,
quantity 1, to the subscription's next order. Create one under Grow → Quick
Actions, pick the gift product there, and paste its stackId into REWARD.
The free gift is whichever product you choose in that action.
REWARD.mode = 'demo' → no network, pretends to grant (for building)
= 'quickAction' → sends the winner to that Quick Action link
= 'endpoint' → POSTs to your own backend, which calls Skio's
API with a private key (reward-endpoint.example.mjs)
Nothing in this file holds a secret.
BRAND TOUCHPOINTS — the four things that make this game yours
1. LANGUAGE Every word the player sees is in TEXT: title, subtitle, buttons,
win / game-over messages, claim messages, the gift's name.
2. COLORS The CSS variables at the top of the <style> (card, text, buttons,
progress bar, overlays), COLORS (board lines and banner), and the
`colors` / `color` inside each sprite. The font is --inv-font
plus the Google Font <link> at the top.
3. SPRITES SPRITES (all the art) and ROWS (which enemy sits on which row and
what it scores). Who is the hero? Who are the enemies? What does
the hero throw? What do they throw back?
4. MUSIC MUSIC (melody, chords, tempo, instruments, drums). Sound effects
are in Snd.sfx further down.
Everything under the ENGINE line is not brand-specific and shouldn't need edits.
SPRITES — two ways to draw anything in the game:
1) Text art (default). Rows of characters; every row the same width.
myThing: { colors: { 'p': '#a794e4', 'O': '#2e2463' }, // letter → color
frames: [ [ 'OOO', 'OpO', 'OOO' ], [ …frame 2… ] ] } // 2+ frames animate
('.' and spaces are empty. Single color? use color: '#fff' and '#' for pixels.)
2) Real images (PNG with a transparent background works best).
myThing: { images: ['https://…/hero.png'], w: 13, h: 11 }
w and h are the size in game units (the board is 224 wide), NOT pixels. Make
your PNG about 4× that (52×44 for a 13×11 sprite) so it stays sharp.
Upload the PNG to Shopify → Content → Files and paste its URL. Pass 2 URLs
for a 2-frame animation. Only the box forts (`shield`) must stay text art,
because they get chipped away pixel by pixel.
Sprites you can replace: ship (the hero), alien1-3 (the enemies), ufo (the bonus
flyer), shield (cover), bullet (what the hero throws), alienBullet (what they throw
back), explosion (what appears when an enemy is won over), shipExplosion (the hero
getting hit).
-->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap">
<div id="inv-root">
<div class="inv-card">
<div class="inv-head">
<div class="inv-title" data-text="title"></div>
<div class="inv-sub" data-text="subtitle"></div>
</div>
<div class="inv-hud">
<div class="inv-row">
<span class="inv-k" data-text="progress"></span>
<span class="inv-v" data-hud="cleared">0 / 0</span>
<div class="inv-bar"><i data-hud="bar"></i></div>
<span class="inv-lives" data-hud="lives" aria-label="Lives"></span>
</div>
<div class="inv-row">
<span class="inv-k"><span data-text="scoreLabel"></span> <b data-hud="score">0000</b></span>
<span class="inv-k"><span data-text="hiLabel"></span> <b data-hud="hi">0000</b></span>
<button type="button" class="inv-mute" data-act="mute" aria-pressed="false" aria-label="Toggle sound">🔊</button>
</div>
</div>
<div class="inv-stage">
<canvas class="inv-canvas" width="224" height="240" role="img" aria-label="Game board"></canvas>
<div class="inv-screen" data-screen="start">
<div class="inv-big" data-text="title"></div>
<p data-text="subtitle"></p>
<button type="button" class="inv-btn inv-primary" data-act="start" data-text="start"></button>
<p class="inv-help" data-text="help"></p>
</div>
<div class="inv-screen" data-screen="pause" hidden>
<div class="inv-big" data-text="pauseTitle"></div>
<button type="button" class="inv-btn inv-primary" data-act="resume" data-text="resume"></button>
</div>
<div class="inv-screen" data-screen="win" hidden>
<div class="inv-big" data-text="winTitle"></div>
<p data-win-msg></p>
<button type="button" class="inv-btn inv-primary" data-act="claim" data-text="claim"></button>
<button type="button" class="inv-btn" data-act="again" data-text="again"></button>
<p class="inv-status" data-status role="status" aria-live="polite"></p>
</div>
<div class="inv-screen" data-screen="lost" hidden>
<div class="inv-big inv-bad" data-text="loseTitle"></div>
<p data-lose-msg></p>
<button type="button" class="inv-btn inv-primary" data-act="again" data-text="again"></button>
</div>
</div>
<div class="inv-pad">
<button type="button" class="inv-pad-btn" data-hold="left" aria-label="Move left">◀</button>
<button type="button" class="inv-pad-btn inv-fire" data-hold="fire" aria-label="Fire" data-text="fireButton"></button>
<button type="button" class="inv-pad-btn" data-hold="right" aria-label="Move right">▶</button>
</div>
</div>
</div>
<style>
/* Everything is scoped under #inv-root with inv- prefixed classes so the
portal's own CSS can't restyle it (and it can't restyle the portal). */
#inv-root{
/* ---- brand colors: change these to re-theme ---- */
--inv-bg:#0b1220; /* the game board */
--inv-panel:#1b2438; /* the card */
--inv-line:#33415f; /* borders */
--inv-ink:#eef2ff; /* main text */
--inv-dim:#9aa7c7; /* secondary text */
--inv-accent:#5eead4; /* title, primary buttons, progress bar */
--inv-on-accent:#06201c; /* text on top of the accent */
--inv-gold:#fbbf24; /* highlights and status messages */
--inv-bad:#fb7185; /* game over */
--inv-bar-bg:#121a2c; /* the empty part of the progress bar */
--inv-pad-bg:#18213a; /* on-screen buttons */
--inv-overlay:rgba(11,18,32,.92); /* start / win / game-over screens */
--inv-font:'Press Start 2P', ui-monospace, Menlo, Consolas, monospace; /* swap for your brand font if you like */
display:flex; justify-content:center; padding:16px;
color:var(--inv-ink); font-family:var(--inv-font);
}
#inv-root, #inv-root *{ box-sizing:border-box; font-family:var(--inv-font); }
#inv-root [hidden]{ display:none !important; }
#inv-root .inv-card{
width:100%; max-width:480px; background:var(--inv-panel);
border:1px solid var(--inv-line); border-radius:16px; padding:16px;
}
#inv-root .inv-head{ text-align:center; margin-bottom:12px; }
#inv-root .inv-title{ font-size:15px; line-height:1.3; color:var(--inv-accent); }
#inv-root .inv-sub{ font-size:8px; line-height:1.6; color:var(--inv-dim); margin-top:8px; }
#inv-root .inv-hud{ display:grid; gap:8px; margin-bottom:10px; font-size:8px; }
#inv-root .inv-row{ display:flex; align-items:center; gap:10px; }
#inv-root .inv-row:last-child{ justify-content:space-between; }
#inv-root .inv-k{ color:var(--inv-dim); }
#inv-root .inv-k b, #inv-root .inv-v{ color:var(--inv-ink); font-weight:400; }
#inv-root .inv-bar{ flex:1; height:10px; background:var(--inv-bar-bg); border:1px solid var(--inv-line); border-radius:999px; overflow:hidden; }
#inv-root .inv-bar i{ display:block; height:100%; width:0; background:var(--inv-accent); }
#inv-root .inv-lives{ color:var(--inv-accent); letter-spacing:2px; min-width:40px; text-align:right; }
#inv-root button.inv-mute{
appearance:none; background:transparent !important; border:1px solid var(--inv-line) !important;
border-radius:8px !important; color:var(--inv-ink); padding:4px 8px !important; cursor:pointer; font-size:12px;
}
#inv-root .inv-stage{
position:relative; width:100%; aspect-ratio:224 / 240; background-color:var(--inv-bg);
border:2px solid var(--inv-line); border-radius:8px; overflow:hidden;
background-image:radial-gradient(circle at 88% 8%, rgba(94,234,212,.12), transparent 38%),
radial-gradient(1px 1px at 20% 30%, #fff8, transparent),
radial-gradient(1px 1px at 70% 22%, #fff6, transparent),
radial-gradient(1px 1px at 40% 70%, #fff5, transparent),
radial-gradient(1px 1px at 85% 60%, #fff7, transparent),
radial-gradient(1px 1px at 10% 85%, #fff5, transparent);
}
#inv-root .inv-canvas{ position:absolute; inset:0; width:100%; height:100%; display:block; }
#inv-root .inv-screen{
position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:12px; padding:16px; text-align:center; background:var(--inv-overlay);
font-size:8px; line-height:1.7; color:var(--inv-ink);
}
#inv-root .inv-screen p{ margin:0; }
#inv-root .inv-big{ font-size:16px; line-height:1.3; color:var(--inv-accent); }
#inv-root .inv-big.inv-bad{ color:var(--inv-bad); }
#inv-root .inv-help{ color:var(--inv-dim); font-size:7px; }
#inv-root .inv-status{ color:var(--inv-gold); min-height:1.7em; }
#inv-root button.inv-btn{
appearance:none; cursor:pointer; font-size:10px; letter-spacing:.04em; text-transform:uppercase;
color:var(--inv-ink); background:transparent !important; border:2px solid var(--inv-line) !important;
border-radius:10px !important; padding:12px 18px !important; min-width:180px;
}
#inv-root button.inv-btn.inv-primary{ background:var(--inv-accent) !important; border-color:var(--inv-accent) !important; color:var(--inv-on-accent); }
#inv-root button.inv-btn:disabled{ opacity:.5; cursor:wait; }
#inv-root .inv-pad{ display:grid; grid-template-columns:1fr 1.6fr 1fr; gap:10px; margin-top:12px; }
#inv-root button.inv-pad-btn{
appearance:none; height:56px; font-size:14px; color:var(--inv-ink); cursor:pointer;
background:var(--inv-pad-bg) !important; border:2px solid var(--inv-line) !important; border-radius:12px !important;
touch-action:none; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent;
}
#inv-root button.inv-pad-btn.inv-fire{ font-size:11px; color:var(--inv-accent); }
#inv-root button.inv-pad-btn.inv-on{ background:var(--inv-accent) !important; color:var(--inv-on-accent); }
#inv-root button:focus-visible{ outline:2px solid var(--inv-gold); outline-offset:2px; }
@media (prefers-reduced-motion: reduce){ #inv-root *{ animation:none !important; transition:none !important; } }
</style>
<script>
(function () {
'use strict';
/* ======================================================================
CONFIG — the part you edit
====================================================================== */
// {score} and {gift} are filled in for you.
var TEXT = {
title: 'Subs Invaders',
subtitle: 'Win back every churn gremlin to unlock a free gift',
giftName: 'a free gift',
start: 'Start game',
progress: 'Won back',
lifeIcon: '♥',
scoreLabel: 'Score',
hiLabel: 'Hi',
winTitle: 'Subscription saved!',
winMsg: 'Score {score} — every gremlin is won back. You unlocked {gift}!',
loseTitle: 'Churned…',
loseMsg: 'Score {score} — the gremlins got through. Try again!',
pendingMsg: 'Welcome back — {gift} is still waiting for you.',
pauseTitle: 'Paused',
resume: 'Resume',
wave: 'WAVE',
fireButton: 'FIRE',
claim: 'Claim my gift',
again: 'Play again',
claiming: 'Adding your gift…',
granted: 'Gift added to your next order!',
alreadyGranted: 'Your gift is already on its way.',
needSubscription: 'Open one of your subscriptions to claim — your win is saved.',
claimFailed: 'Could not add your gift. Please try again.',
notConfigured: 'The gift isn\'t connected yet — the store still needs to finish setup.',
help: '← → move · SPACE fire · P pause · M mute'
};
var GAME = {
lives: 3,
cols: 8, // gremlins per row
wavesToWin: 1, // win back this many waves to win (each is faster)
alienSpeed: 0.9, // 0.6 = chill, 1 = brisk, 1.6 = sweaty
alienFire: 0.8, // how often they drop X marks: 0.5 = rarely, 1 = often, 2 = constantly
maxPlayerBullets: 3, // stars in the air at once
fireCooldownMs: 220,
shields: 4, // box forts. 0 turns them off
ufo: true // bonus gift that drifts across the top
};
// Sharpness of the board. 3 looks crisp for both text-art and image sprites.
// Set to 1 for chunky retro pixels.
var RENDER_SCALE = 3;
// One entry per row of gremlins, top to bottom. Add/remove rows freely.
// `sprite` is a key in SPRITES below; `points` is the score when you win one over.
var ROWS = [
{ sprite: 'alien1', points: 30 },
{ sprite: 'alien2', points: 20 },
{ sprite: 'alien2', points: 20 },
{ sprite: 'alien3', points: 10 }
];
var COLORS = { ground: '#38476b', text: '#ffffff', banner: '#fbbf24' };
var SPRITES = {
// The hero: a smiling subscription box. 13×11.
ship: { colors: { O: '#8a5a2b', d: '#c58b4e', b: '#e8b77c', t: '#f6e3b8', k: '#3a2412' }, frames: [[
'OOOOOOOOOOOOO',
'OddddtttddddO',
'OddddtttddddO',
'ObbbbtttbbbbO',
'ObbbbbbbbbbbO',
'ObbkkbbbkkbbO',
'ObbkkbbbkkbbO',
'ObbbbbbbbbbbO',
'ObbbkbbbkbbbO',
'ObbbbkkkbbbbO',
'OOOOOOOOOOOOO'
]]},
// The enemies: three churn gremlins, 11×8, two frames each (the feet shuffle).
// 1 — Cancel (the ✕ on its belly)
alien1: { colors: { r: '#ff6b6b', w: '#ffffff', k: '#1b2438' }, frames: [[
'..rrrrrrr..',
'.rrrrrrrrr.',
'rrkrrrrrkrr',
'rrrrwrwrrrr',
'rrrrrwrrrrr',
'rrrrwrwrrrr',
'.rrr...rrr.',
'.rr.....rr.'
],[
'..rrrrrrr..',
'.rrrrrrrrr.',
'rrkrrrrrkrr',
'rrrrwrwrrrr',
'rrrrrwrrrrr',
'rrrrwrwrrrr',
'..rrr.rrr..',
'..rr...rr..'
]]},
// 2 — Pause (the ‖ on its belly)
alien2: { colors: { a: '#fbbf24', w: '#3b2a00', k: '#1b2438' }, frames: [[
'..aaaaaaa..',
'.aaaaaaaaa.',
'aakaaaaakaa',
'aaaawawaaaa',
'aaaawawaaaa',
'aaaawawaaaa',
'.aaa...aaa.',
'.aa.....aa.'
],[
'..aaaaaaa..',
'.aaaaaaaaa.',
'aakaaaaakaa',
'aaaawawaaaa',
'aaaawawaaaa',
'aaaawawaaaa',
'..aaa.aaa..',
'..aa...aa..'
]]},
// 3 — Skip (the ▶▶ on its belly)
alien3: { colors: { v: '#a78bfa', w: '#ffffff', k: '#1b2438' }, frames: [[
'..vvvvvvv..',
'.vvvvvvvvv.',
'vvkvvvvvkvv',
'vvvwvwvvvvv',
'vvvwwwwvvvv',
'vvvwvwvvvvv',
'.vvv...vvv.',
'.vv.....vv.'
],[
'..vvvvvvv..',
'.vvvvvvvvv.',
'vvkvvvvvkvv',
'vvvwvwvvvvv',
'vvvwwwwvvvv',
'vvvwvwvvvvv',
'..vvv.vvv..',
'..vv...vv..'
]]},
// Bonus flyer: a gift box, 10×9
ufo: { colors: { g: '#e879f9', y: '#fbbf24' }, frames: [[
'...yyyy...',
'..y.yy.y..',
'...y..y...',
'ggggyygggg',
'ggggyygggg',
'yyyyyyyyyy',
'ggggyygggg',
'ggggyygggg',
'ggggyygggg'
]]},
// Cover: a fort of shipping boxes, 24×14. It gets chipped away, so keep it text art.
shield: { colors: { '#': '#c9955a', s: '#a8743c', x: '#f1dcae' }, frames: [[
'..####################..',
'.######################.',
'###########xx###########',
'###########xx###########',
'###########xx###########',
'###########xx###########',
'###########xx###########',
'###########xx###########',
'###########xx###########',
'sssssssssssxxsssssssssss',
'sssssssssssxxsssssssssss',
'sssssss..........sssssss',
'ssssss............ssssss',
'sssss..............sssss'
]]},
// What the hero throws: a gold star, 5×5
bullet: { color: '#fbbf24', frames: [[
'..#..',
'.###.',
'#####',
'.###.',
'.#.#.'
]]},
// What they throw back: a tumbling ✕, 5×5
alienBullet: { color: '#fb7185', frames: [[
'#...#',
'.#.#.',
'..#..',
'.#.#.',
'#...#'
],[
'..#..',
'..#..',
'#####',
'..#..',
'..#..'
]]},
// When a gremlin is won over: a heart floats up, 11×8
explosion: { color: '#ff7ab6', frames: [[
'..##...##..',
'.####.####.',
'.#########.',
'.#########.',
'..#######..',
'...#####...',
'....###....',
'.....#.....'
]]},
// The hero gets hit: the box bursts into cardboard bits, 13×11
shipExplosion: { colors: { b: '#e8b77c', d: '#c58b4e', t: '#f6e3b8' }, frames: [[
'...b.....b...',
'.d....d....d.',
'....bbbbb....',
'b..bbbbbbb..b',
'...bbtttbb...',
'd..bbbbbbb..d',
'...bbbbbbb...',
'b..bbbbbbb..b',
'....bbbbb....',
'.d....d....d.',
'...b.....b...'
],[
'b.....t.....b',
'..d.......d..',
'....b...b....',
'.d.........d.',
'.............',
't.....b.....t',
'.............',
'.d.........d.',
'....b...b....',
'..d.......d..',
'b.....t.....b'
]]}
};
// An 8-bit arcade loop in A minor: bright lead, bouncing bass, light drums. An original
// tune, 8 bars (about 15 seconds), looping. It speeds up from tempoStart to tempoEnd as
// the gremlins are won back. Swap it for anything: mellow, funky, epic.
//
// `lead` is one token per eighth-note: a note name (B4, F#5), "-" to hold the previous
// note, "." for a rest. stepsPerBar tokens make a bar (8 = 4/4, 6 = 6/8).
var MUSIC = {
bpm: 126, // quarter-note beats per minute (an eighth-note is half of that)
stepsPerBar: 8,
tempoStart: 0.95, // playback speed at the first shot…
tempoEnd: 1.3, // …climbing to this as the board clears
leadWave: 'pulse', // 'pulse' (NES lead) | 'pulse12' (thin, bell-like) | 'square' | 'triangle' | 'sine'
leadVol: 0.07,
leadHold: true, // true = notes sustain; false = each note rings and fades
lead: [
'E5 - C5 - A4 - C5 E5', 'F5 - A5 - F5 - E5 C5',
'D5 - G5 - D5 - B4 D5', 'E5 - G#5 - B5 - G#5 E5',
'A5 - G5 E5 A5 - G5 E5', 'A5 - G5 F5 A5 - C6 A5',
'G5 - F5 D5 G5 - B5 G5', 'B5 - A5 G#5 E5 - G#5 B5'
].join(' '),
progression: ['Am', 'F', 'G', 'E', 'Am', 'F', 'G', 'E'], // one chord per bar
chords: { // the notes bassPattern / arpPattern pick from, by position
Am: ['A2', 'A3', 'E3'],
F: ['F2', 'F3', 'C3'],
G: ['G2', 'G3', 'D3'],
E: ['E2', 'E3', 'B2']
},
bassPattern: [0, 1, 0, 2, 0, 1, 0, 2], // per step in a bar: which chord note (0, 1, 2…), -1 = rest
arpPattern: [-1, -1, -1, -1, -1, -1, -1, -1],
kick: [0, 4], snare: [2, 6], hat: [1, 3, 5, 7] // steps that get a drum hit. Empty = no drums
};
var REWARD = {
mode: 'demo', // 'demo' | 'quickAction' | 'endpoint'
quickAction: { stackId: '' }, // Skio → Grow → Quick Actions → "Add Free Gift" → the stackId in its link
endpoint: { url: '' } // your backend, e.g. https://rewards.yourbrand.com/subs-invaders/claim
};
/* ======================================================================
ENGINE — you shouldn't need to edit below this line
====================================================================== */
var W = 224, H = 240, GROUND_Y = H - 12, RES = Math.max(1, RENDER_SCALE | 0);
var CELL_W = 16, CELL_H = 14, MARGIN = 6, DROP = 8, STEP_PX = 4;
var TAG = '[subs-invaders]';
function findRoot(cb, tries) {
var el = document.getElementById('inv-root');
if (el) return cb(el);
if ((tries || 0) < 50) setTimeout(function () { findRoot(cb, (tries || 0) + 1); }, 100);
}
function fmt(s, o) { return String(s).replace(/\{(\w+)\}/g, function (m, k) { return k in o ? o[k] : m; }); }
/* ---------- storage (every access guarded: private windows throw) ---------- */
var store = {
get: function (k) { try { return window.localStorage.getItem('subs:v1:' + k); } catch (e) { return null; } },
set: function (k, v) { try { window.localStorage.setItem('subs:v1:' + k, v); } catch (e) {} },
del: function (k) { try { window.localStorage.removeItem('subs:v1:' + k); } catch (e) {} }
};
/* ---------- sprites ---------- */
function buildSprite(name, spec) {
if (spec.images) {
return { w: spec.w, h: spec.h, frames: spec.images.map(function (src) {
var img = new Image(); img.decoding = 'async';
img.onerror = function () { console.warn(TAG, 'sprite "' + name + '": image failed to load —', src); };
img.src = src; return img;
})};
}
var palette = spec.colors || { '#': spec.color || '#fff' };
var rows0 = spec.frames[0], w = rows0[0].length, h = rows0.length;
var frames = spec.frames.map(function (rows, fi) {
var c = document.createElement('canvas'); c.width = w; c.height = h;
var g = c.getContext('2d', { willReadFrequently: true });
rows.forEach(function (row, y) {
if (row.length !== w || rows.length !== h) {
console.warn(TAG, 'sprite "' + name + '" frame ' + fi + ' row ' + y + ' is ' + row.length + ' wide (expected ' + w + ') — check your art');
}
for (var x = 0; x < row.length; x++) {
var col = palette[row.charAt(x)];
if (col) { g.fillStyle = col; g.fillRect(x, y, 1, 1); }
}
});
return c;
});
return { w: w, h: h, frames: frames };
}
var spr = {};
Object.keys(SPRITES).forEach(function (k) { spr[k] = buildSprite(k, SPRITES[k]); });
function drawSprite(g, sp, frame, x, y) {
var f = sp.frames[frame % sp.frames.length], isImg = f.tagName === 'IMG';
if (isImg && !f.complete) return; // still loading
if (isImg && !f.naturalWidth) { // broken URL: loud placeholder, not an invisible sprite
g.fillStyle = '#ff00ff'; g.fillRect(Math.round(x), Math.round(y), sp.w, sp.h); return;
}
g.imageSmoothingEnabled = isImg; // text art stays crisp, real images stay smooth
g.drawImage(f, Math.round(x), Math.round(y), sp.w, sp.h);
}
/* ---------- sound: everything synthesized, no audio files ---------- */
var Snd = (function () {
var ctx = null, master = null, noiseBuf = null, pulse = null, pulse12 = null;
var timer = 0, step = 0, nextT = 0, tempo = 1;
var muted = store.get('muted') === '1';
var STEP_S = 60 / MUSIC.bpm / 2; // one eighth note
var NAMES = { C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5, 'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11 };
function freq(n) {
var m = /^([A-G]#?)(\d)$/.exec(n); if (!m) return 0;
return 440 * Math.pow(2, ((12 * (+m[2] + 1) + NAMES[m[1]]) - 69) / 12);
}
function parseLine(str) {
var ev = [], cur = null;
str.trim().split(/\s+/).forEach(function (t, i) {
if (t === '-') { if (cur) cur.len++; }
else if (t === '.') cur = null;
else { cur = { step: i, note: t, len: 1 }; ev.push(cur); }
});
return ev;
}
var SPB = MUSIC.stepsPerBar || 8;
var LEAD = parseLine(MUSIC.lead);
var LEN = MUSIC.lead.trim().split(/\s+/).length;
if (LEN % SPB) console.warn(TAG, 'MUSIC.lead has ' + LEN + ' steps, not a multiple of stepsPerBar (' + SPB + ') — the loop will drift');
if (MUSIC.bassPattern.length !== SPB || MUSIC.arpPattern.length !== SPB) console.warn(TAG, 'MUSIC.bassPattern and arpPattern need exactly stepsPerBar (' + SPB + ') entries');
function pulseWave(duty) {
var re = new Float32Array(32), im = new Float32Array(32);
for (var n = 1; n < 32; n++) { re[n] = (2 / (n * Math.PI)) * Math.sin(n * Math.PI * duty); im[n] = (2 / (n * Math.PI)) * (1 - Math.cos(n * Math.PI * duty)); }
return ctx.createPeriodicWave(re, im);
}
function restRun(pat, k) { var n = 1; while (n < pat.length && pat[(k + n) % pat.length] < 0) n++; return n; } // steps until the next note
function ensure() {
if (ctx) return true;
try {
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return false;
ctx = new AC();
master = ctx.createGain(); master.gain.value = muted ? 0 : 0.32; master.connect(ctx.destination);
noiseBuf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
var d = noiseBuf.getChannelData(0); for (var i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
pulse = pulseWave(0.25); // the classic "NES lead" — used for the sound effects
pulse12 = pulseWave(0.125); // thinner and brighter — bell-like leads and arpeggios
return true;
} catch (e) { ctx = null; return false; }
}
function tone(f, t, dur, vol, type, f2, hold) {
var o = ctx.createOscillator(), g = ctx.createGain();
if (type === 'pulse') o.setPeriodicWave(pulse); else if (type === 'pulse12') o.setPeriodicWave(pulse12); else o.type = type;
o.frequency.setValueAtTime(f, t);
if (f2) o.frequency.exponentialRampToValueAtTime(Math.max(1, f2), t + dur);
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(vol, t + 0.004);
if (hold) { g.gain.setValueAtTime(vol, t + Math.max(0.01, dur - 0.04)); g.gain.linearRampToValueAtTime(0.0001, t + dur); }
else g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(master); o.start(t); o.stop(t + dur + 0.03);
}
function noise(t, dur, vol, type, f1, f2) {
var s = ctx.createBufferSource(), fl = ctx.createBiquadFilter(), g = ctx.createGain();
s.buffer = noiseBuf; fl.type = type; fl.frequency.setValueAtTime(f1, t);
if (f2) fl.frequency.exponentialRampToValueAtTime(f2, t + dur);
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
s.connect(fl); fl.connect(g); g.connect(master); s.start(t, Math.random() * 0.5); s.stop(t + dur + 0.03);
}
function scheduleStep(i, t) {
var bar = Math.floor(i / SPB), k = i % SPB, ch = MUSIC.chords[MUSIC.progression[bar % MUSIC.progression.length]];
// lead voice — timbre is set in MUSIC (leadWave / leadVol / leadHold)
LEAD.forEach(function (e) { if (e.step === i) tone(freq(e.note), t, MUSIC.leadHold ? e.len * STEP_S * 0.95 : Math.min(e.len * STEP_S * 0.95, 1.2), MUSIC.leadVol, MUSIC.leadWave, 0, MUSIC.leadHold); });
var b = MUSIC.bassPattern[k]; // bass line
if (b >= 0 && ch[b]) tone(freq(ch[b]), t, STEP_S * restRun(MUSIC.bassPattern, k) * 0.95, 0.24, 'triangle', 0, true);
var a = MUSIC.arpPattern[k]; // optional arpeggio
if (a >= 0 && ch[a]) tone(freq(ch[a]), t, STEP_S * 1.4, 0.045, 'pulse12', 0, false);
if (MUSIC.kick.indexOf(k) >= 0) tone(150, t, 0.12, 0.22, 'sine', 45);
if (MUSIC.snare.indexOf(k) >= 0) noise(t, 0.09, 0.07, 'bandpass', 1800);
if (MUSIC.hat.indexOf(k) >= 0) noise(t, 0.03, 0.02, 'highpass', 7000);
}
function pump() {
if (!ctx) return;
while (nextT < ctx.currentTime + 0.18) { scheduleStep(step % LEN, nextT); nextT += STEP_S / tempo; step++; }
}
return {
unlock: function () { if (ensure() && ctx.state === 'suspended') ctx.resume(); },
music: function (start) {
if (!ensure()) return;
clearInterval(timer);
if (!start) return;
step = 0; nextT = ctx.currentTime + 0.08;
timer = setInterval(pump, 40); pump();
},
tempo: function (x) { tempo = x; },
suspend: function () { if (ctx && ctx.state === 'running') ctx.suspend(); },
resume: function () { if (ctx && ctx.state === 'suspended') ctx.resume(); },
setMuted: function (m) { muted = m; store.set('muted', m ? '1' : '0'); if (master) master.gain.value = m ? 0 : 0.32; },
isMuted: function () { return muted; },
destroy: function () { clearInterval(timer); try { if (ctx) ctx.close(); } catch (e) {} ctx = null; },
sfx: function (name) {
if (!ensure() || muted) return;
var t = ctx.currentTime;
switch (name) {
case 'shoot': tone(1100, t, 0.09, 0.09, 'pulse', 260); break;
case 'kill': tone(660, t, 0.08, 0.1, 'pulse'); tone(990, t + 0.07, 0.12, 0.1, 'pulse'); break; // a happy "won back" chirp
case 'ufo': tone(520, t, 0.07, 0.05, 'square'); tone(700, t + 0.08, 0.07, 0.05, 'square'); break;
case 'ufoKill': [660, 880, 1320, 1760].forEach(function (f, i) { tone(f, t + i * 0.05, 0.08, 0.09, 'pulse'); }); break;
case 'chip': noise(t, 0.04, 0.12, 'highpass', 3000); break;
case 'shipHit': noise(t, 0.8, 0.3, 'lowpass', 2200, 80); tone(260, t, 0.7, 0.14, 'sawtooth', 40); break;
case 'blip': tone(880, t, 0.06, 0.08, 'pulse'); break;
case 'win': [523, 659, 784, 1046, 1318].forEach(function (f, i) { tone(f, t + i * 0.11, i === 4 ? 0.5 : 0.14, 0.1, 'pulse', 0, true); }); break;
case 'lose': [440, 349, 294, 220].forEach(function (f, i) { tone(f, t + i * 0.2, 0.3, 0.1, 'pulse', 0, true); }); break;
}
}
};
})();
/* ---------- game ---------- */
findRoot(function (root) {
if (root.__invInit) return;
root.__invInit = true;
var canvas = root.querySelector('.inv-canvas');
canvas.width = W * RES; canvas.height = H * RES;
if (RES === 1) canvas.style.imageRendering = 'pixelated';
var g = canvas.getContext('2d');
function q(sel) { return root.querySelector(sel); }
function qa(sel) { return Array.prototype.slice.call(root.querySelectorAll(sel)); }
qa('[data-text]').forEach(function (el) { el.textContent = TEXT[el.getAttribute('data-text')] || ''; });
var hudEl = {};
qa('[data-hud]').forEach(function (el) { hudEl[el.getAttribute('data-hud')] = el; });
var kb = { left: false, right: false, fire: false }, pad = { left: false, right: false, fire: false };
function down(k) { return kb[k] || pad[k]; }
var S = null, hi = parseInt(store.get('hi') || '0', 10) || 0;
var raf = 0, last = 0, acc = 0, active = false, claiming = false;
function rand(a, b) { return a + Math.random() * (b - a); }
function pad4(n) { return ('0000' + n).slice(-4); }
/* --- screens --- */
function show(name) {
qa('.inv-screen').forEach(function (el) { el.hidden = el.getAttribute('data-screen') !== name; });
if (document.activeElement && document.activeElement.blur && root.contains(document.activeElement)) document.activeElement.blur();
}
function status(msg) { q('[data-status]').textContent = msg || ''; }
/* --- state --- */
function shieldCanvases() {
var out = [], n = GAME.shields;
if (!n || !spr.shield) return out;
var sp = spr.shield, gap = (W - n * sp.w) / (n + 1);
for (var i = 0; i < n; i++) {
var c = document.createElement('canvas'); c.width = sp.w; c.height = sp.h;
var cg = c.getContext('2d', { willReadFrequently: true });
cg.drawImage(sp.frames[0], 0, 0);
out.push({ x: Math.round(gap + i * (sp.w + gap)), y: GROUND_Y - spr.ship.h - 2 - 34, c: c, g: cg });
}
return out;
}
function startWave() {
var n = S.wave;
S.aliens = [];
for (var r = 0; r < ROWS.length; r++) for (var c = 0; c < GAME.cols; c++) S.aliens.push({ r: r, c: c, alive: true });
S.alive = S.total = S.aliens.length;
S.form = { x: (W - GAME.cols * CELL_W) / 2, y: 34 + Math.min(n, 4) * 6, dir: 1, timer: 300, frame: 0 };
S.bullets = []; S.abullets = []; S.fx = [];
S.aFire = 1.4; S.ufo = null; S.ufoTimer = rand(7, 14); S.banner = n > 0 || GAME.wavesToWin > 1 ? 1.6 : 0;
S.shields = shieldCanvases();
Snd.tempo(MUSIC.tempoStart);
}
function newState(mode) {
S = {
mode: mode, score: 0, lives: GAME.lives, wave: 0, t: 0,
ship: { x: (W - spr.ship.w) / 2, y: GROUND_Y - spr.ship.h - 2, dead: 0, inv: 0, cool: 0 },
endTimer: 0
};
startWave();
}
function hud() {
var cleared = S.wave * S.total + (S.total - S.alive), goal = GAME.wavesToWin * S.total;
hudEl.cleared.textContent = cleared + ' / ' + goal;
hudEl.bar.style.width = Math.min(100, cleared / goal * 100) + '%';
hudEl.score.textContent = pad4(S.score);
hudEl.hi.textContent = pad4(Math.max(hi, S.score));
hudEl.lives.textContent = S.lives > 0 ? new Array(S.lives + 1).join(TEXT.lifeIcon + ' ').trim() : '';
}
function startGame() {
Snd.unlock(); Snd.sfx('blip');
newState('playing'); show(null); hud();
Snd.music(true); setActive(true);
}
function finish(kind) {
S.mode = kind;
Snd.music(false);
if (S.score > hi) { hi = S.score; store.set('hi', String(hi)); }
hud();
if (kind === 'won') {
Snd.sfx('win');
store.set('won', JSON.stringify({ score: S.score, at: Date.now() }));
q('[data-win-msg]').textContent = fmt(TEXT.winMsg, { score: S.score, gift: TEXT.giftName });
status(''); q('[data-act="claim"]').disabled = false; q('[data-act="claim"]').hidden = false;
show('win');
} else {
Snd.sfx('lose');
q('[data-lose-msg]').textContent = fmt(TEXT.loseMsg, { score: S.score, gift: TEXT.giftName });
show('lost');
}
}
function pause() {
if (!S || S.mode !== 'playing') return;
S.mode = 'paused'; show('pause'); Snd.suspend();
}
function resume() {
if (!S || S.mode !== 'paused') return;
S.mode = 'playing'; show(null); Snd.resume(); last = 0;
}
/* --- collisions --- */
function alienRect(a) {
var sp = spr[ROWS[a.r].sprite], off = Math.floor((CELL_W - sp.w) / 2);
return { x: S.form.x + a.c * CELL_W + off, y: S.form.y + a.r * CELL_H, w: sp.w, h: sp.h };
}
function overlap(a, b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; }
function shieldHit(px, py) {
for (var i = 0; i < S.shields.length; i++) {
var s = S.shields[i], lx = Math.floor(px - s.x), ly = Math.floor(py - s.y);
if (lx < 0 || ly < 0 || lx >= s.c.width || ly >= s.c.height) continue;
if (s.g.getImageData(lx, ly, 1, 1).data[3] > 0) {
s.g.clearRect(lx - 3, ly - 3, 7, 7);
for (var k = 0; k < 5; k++) s.g.clearRect(lx + Math.round(rand(-5, 5)), ly + Math.round(rand(-5, 5)), 2, 2);
Snd.sfx('chip');
return true;
}
}
return false;
}
function shieldSweep(x, y0, y1) {
var dir = y1 >= y0 ? 1 : -1;
for (var y = y0; dir > 0 ? y <= y1 : y >= y1; y += dir) if (shieldHit(x, y)) return true;
return false;
}
function killAlien(a) {
a.alive = false; S.alive--; S.score += ROWS[a.r].points;
var r = alienRect(a); S.fx.push({ kind: 'explosion', x: r.x, y: r.y, t: 0.6, dur: 0.6 });
Snd.sfx('kill');
Snd.tempo(MUSIC.tempoStart + (MUSIC.tempoEnd - MUSIC.tempoStart) * (1 - S.alive / S.total));
hud();
}
function killShip() {
var sh = S.ship;
if (sh.dead > 0 || sh.inv > 0) return;
S.lives--; sh.dead = 1.1; S.abullets.length = 0; S.bullets.length = 0;
Snd.sfx('shipHit'); hud();
}
/* --- update --- */
function stepInterval() {
var f = S.alive / S.total;
return (30 + 350 * f) / (GAME.alienSpeed * (1 + 0.18 * S.wave));
}
function marchAliens() {
var minC = 99, maxC = -1;
S.aliens.forEach(function (a) {
if (!a.alive) return;
if (a.c < minC) minC = a.c; if (a.c > maxC) maxC = a.c;
});
var left = S.form.x + minC * CELL_W + 2, right = S.form.x + maxC * CELL_W + CELL_W - 2;
var f = S.form;
if ((f.dir > 0 && right + STEP_PX > W - MARGIN) || (f.dir < 0 && left - STEP_PX < MARGIN)) { f.y += DROP; f.dir = -f.dir; }
else f.x += f.dir * STEP_PX;
f.frame ^= 1;
// the gremlins trample the box forts
S.aliens.forEach(function (a) {
if (!a.alive) return;
var r = alienRect(a);
S.shields.forEach(function (s) {
if (r.x < s.x + s.c.width && r.x + r.w > s.x && r.y + r.h > s.y && r.y < s.y + s.c.height) {
s.g.clearRect(r.x - s.x, r.y - s.y, r.w, r.h);
}
});
if (r.y + r.h >= S.ship.y) { S.lives = 1; killShip(); S.lives = 0; hud(); }
});
}
function update(dt) {
var sh = S.ship; S.t += dt;
if (S.banner > 0) S.banner -= dt;
S.fx = S.fx.filter(function (e) { e.t -= dt; return e.t > 0; });
if (sh.dead > 0) { // freeze the world while the ship pops
sh.dead -= dt;
if (sh.dead <= 0) {
if (S.lives <= 0) { finish('lost'); return; }
sh.dead = 0; sh.inv = 1.8; sh.x = (W - spr.ship.w) / 2;
}
return;
}
if (sh.inv > 0) sh.inv -= dt;
if (S.endTimer > 0) { S.endTimer -= dt; if (S.endTimer <= 0) nextWaveOrWin(); return; }
// player
var dx = (down('right') ? 1 : 0) - (down('left') ? 1 : 0);
sh.x = Math.max(MARGIN, Math.min(W - MARGIN - spr.ship.w, sh.x + dx * 96 * dt));
sh.cool -= dt;
if (down('fire') && sh.cool <= 0 && S.bullets.length < GAME.maxPlayerBullets) {
S.bullets.push({ x: Math.round(sh.x + (spr.ship.w - spr.bullet.w) / 2), y: sh.y - spr.bullet.h + 2, w: spr.bullet.w, h: spr.bullet.h });
sh.cool = GAME.fireCooldownMs / 1000; Snd.sfx('shoot');
}
// the gremlins
S.form.timer -= dt * 1000;
if (S.form.timer <= 0) { S.form.timer += stepInterval(); marchAliens(); if (sh.dead > 0) return; }
S.aFire -= dt;
if (S.aFire <= 0 && S.alive > 0 && S.abullets.length < 3 + S.wave) {
var cols = {};
S.aliens.forEach(function (a) { if (a.alive && (!cols[a.c] || a.r > cols[a.c].r)) cols[a.c] = a; });
var keys = Object.keys(cols), shooter = cols[keys[Math.floor(Math.random() * keys.length)]];
if (shooter) {
var r = alienRect(shooter);
S.abullets.push({ x: r.x + Math.floor((r.w - spr.alienBullet.w) / 2), y: r.y + r.h, w: spr.alienBullet.w, h: spr.alienBullet.h });
}
S.aFire = rand(0.5, 1.6) / GAME.alienFire;
}
// stars flying up
S.bullets = S.bullets.filter(function (b) {
var y0 = b.y; b.y -= 260 * dt;
if (b.y + b.h < 0) return false;
if (shieldSweep(b.x + (b.w >> 1), Math.round(y0), Math.round(b.y))) return false;
var br = { x: b.x, y: b.y, w: b.w, h: b.h };
for (var i = 0; i < S.aliens.length; i++) {
var a = S.aliens[i];
if (a.alive && overlap(br, alienRect(a))) { killAlien(a); return false; }
}
if (S.ufo && overlap(br, { x: S.ufo.x, y: S.ufo.y, w: spr.ufo.w, h: spr.ufo.h })) {
S.score += S.ufo.pts; S.fx.push({ kind: 'explosion', x: S.ufo.x, y: S.ufo.y, t: 0.6, dur: 0.6 }, { kind: 'text', x: S.ufo.x, y: S.ufo.y, t: 0.9, s: String(S.ufo.pts) });
S.ufo = null; Snd.sfx('ufoKill'); hud(); return false;
}
return true;
});
// X marks raining down
S.abullets = S.abullets.filter(function (b) {
var y0 = b.y; b.y += (80 + S.wave * 12) * dt;
if (b.y > GROUND_Y) return false;
if (shieldSweep(b.x + (b.w >> 1), Math.round(y0 + b.h), Math.round(b.y + b.h))) return false;
if (overlap(b, { x: sh.x, y: sh.y, w: spr.ship.w, h: spr.ship.h })) { killShip(); return false; }
return true;
});
if (sh.dead > 0) return;
// bonus gift
if (GAME.ufo && spr.ufo) {
if (S.ufo) {
S.ufo.x += S.ufo.dir * 42 * dt;
if (Math.floor(S.t * 10) !== S.ufo.tick) { S.ufo.tick = Math.floor(S.t * 10); if (S.ufo.tick % 4 === 0) Snd.sfx('ufo'); }
if (S.ufo.x < -spr.ufo.w - 2 || S.ufo.x > W + 2) S.ufo = null;
} else if ((S.ufoTimer -= dt) <= 0 && S.alive > 6) {
var dir = Math.random() < 0.5 ? 1 : -1;
S.ufo = { x: dir > 0 ? -spr.ufo.w : W, y: 14, dir: dir, pts: [50, 100, 150, 300][Math.floor(Math.random() * 4)], tick: -1 };
S.ufoTimer = rand(15, 25);
}
}
if (S.alive === 0 && S.endTimer <= 0) S.endTimer = 0.9;
}
function nextWaveOrWin() {
if (S.wave + 1 >= GAME.wavesToWin) { finish('won'); return; }
S.wave++; S.endTimer = 0; startWave(); hud();
}
/* --- render --- */
function draw(ts) {
g.setTransform(RES, 0, 0, RES, 0, 0);
g.clearRect(0, 0, W, H);
g.fillStyle = COLORS.ground; g.fillRect(0, GROUND_Y, W, 1);
if (!S) return;
var frame = S.mode !== 'start' ? S.form.frame : (Math.floor(ts / 500) & 1);
S.shields.forEach(function (s) { g.imageSmoothingEnabled = false; g.drawImage(s.c, s.x, s.y); });
S.aliens.forEach(function (a) { if (a.alive) { var r = alienRect(a); drawSprite(g, spr[ROWS[a.r].sprite], frame, r.x, r.y); } });
if (S.ufo) drawSprite(g, spr.ufo, 0, S.ufo.x, S.ufo.y);
var sh = S.ship;
if (sh.dead > 0) drawSprite(g, spr.shipExplosion, Math.floor(sh.dead * 8), sh.x, sh.y);
else if (!(sh.inv > 0 && Math.floor(sh.inv * 12) % 2)) drawSprite(g, spr.ship, 0, sh.x, sh.y);
S.bullets.forEach(function (b) { drawSprite(g, spr.bullet, 0, b.x, b.y); });
S.abullets.forEach(function (b) { drawSprite(g, spr.alienBullet, Math.floor(S.t * 8), b.x, b.y); });
S.fx.forEach(function (e) {
if (e.kind === 'explosion') { // the heart drifts up and fades
g.globalAlpha = Math.max(0, e.t / e.dur);
drawSprite(g, spr.explosion, 0, e.x, e.y - (1 - e.t / e.dur) * 8);
g.globalAlpha = 1;
} else { g.fillStyle = COLORS.banner; g.font = '8px "Press Start 2P", monospace'; g.fillText(e.s, e.x, e.y + 8); }
});
if (S.banner > 0 && S.mode === 'playing') {
g.fillStyle = COLORS.banner; g.font = '10px "Press Start 2P", monospace'; g.textAlign = 'center';
g.fillText(TEXT.wave + ' ' + (S.wave + 1), W / 2, H / 2 + 20); g.textAlign = 'left';
}
}
function frameLoop(ts) {
if (!active) return;
raf = requestAnimationFrame(frameLoop);
if (!root.isConnected) { destroy(); return; }
if (!last) last = ts;
acc += Math.min(ts - last, 250); last = ts;
var STEP = 1000 / 60;
while (acc >= STEP) { if (S.mode === 'playing') update(STEP / 1000); acc -= STEP; }
draw(ts);
}
function setActive(on) {
if (on === active) return;
active = on; cancelAnimationFrame(raf);
if (on) { last = 0; raf = requestAnimationFrame(frameLoop); }
}
/* --- reward --- */
function getSubscriptionId() {
try { var m = (window.location.href || '').match(/subscriptions\/([^/?#]+)/); if (m) return m[1]; } catch (e) {}
try {
var og = document.querySelector('meta[property="og:url"]');
var m2 = og && og.content && og.content.match(/subscriptions\/([^/?#]+)/);
if (m2) return m2[1];
} catch (e) {}
return null;
}
// Demo mode only pretends. On a live site that would tell real customers they got a gift they didn't,
// so outside localhost it refuses (add ?invDemo to the URL to preview it anyway).
var isLocal = /^(localhost|127\.0\.0\.1|\[::1\])$/.test(window.location.hostname) || /[?&]invDemo\b/.test(window.location.search);
function claimDemo() {
if (!isLocal) {
console.error(TAG, 'REWARD.mode is still "demo" on a live site — set it to "quickAction" or "endpoint" in the CONFIG block.');
return Promise.resolve({ status: 'not_configured', message: TEXT.notConfigured });
}
return new Promise(function (res) { setTimeout(function () { res({ status: 'granted', message: 'Demo mode: pretend gift added.' }); }, 900); });
}
function claimQuickAction() {
var id = REWARD.quickAction.stackId, sub = getSubscriptionId();
if (!id) return Promise.reject(new Error('REWARD.quickAction.stackId is empty'));
if (!sub) return Promise.resolve({ status: 'need_subscription', message: TEXT.needSubscription });
var url = window.location.origin + '/a/account/quick-action-v3?stackId=' + encodeURIComponent(id) + '&subscriptionId=' + encodeURIComponent(sub);
store.del('won');
window.location.assign(url); // Skio shows its own confirm page, then returns to the portal
return new Promise(function () {});
}
function claimEndpoint() {
var url = REWARD.endpoint.url;
if (!url) return Promise.reject(new Error('REWARD.endpoint.url is empty'));
return fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscriptionId: getSubscriptionId(), score: S.score, wave: S.wave, elapsedMs: Math.round(S.t * 1000) })
}).then(function (r) {
return r.json().then(function (b) { if (!r.ok) throw new Error(b && b.message || 'Request failed (' + r.status + ')'); return b; });
});
}
function claim() {
if (claiming) return;
claiming = true;
var btn = q('[data-act="claim"]'); btn.disabled = true; status(TEXT.claiming);
var p = REWARD.mode === 'quickAction' ? claimQuickAction() : REWARD.mode === 'endpoint' ? claimEndpoint() : claimDemo();
p.then(function (res) {
claiming = false;
if (res.status === 'granted' || res.status === 'already_granted') {
store.del('won'); btn.hidden = true;
status(res.message || (res.status === 'granted' ? TEXT.granted : TEXT.alreadyGranted));
Snd.sfx('ufoKill');
} else {
btn.disabled = false; status(res.message || TEXT.claimFailed);
}
}).catch(function (err) {
claiming = false; btn.disabled = false;
console.error(TAG, 'claim failed:', err);
status(TEXT.claimFailed);
});
}
/* --- input --- */
var KEYMAP = { ArrowLeft: 'left', a: 'left', A: 'left', ArrowRight: 'right', d: 'right', D: 'right', ' ': 'fire', ArrowUp: 'fire', w: 'fire', W: 'fire' };
function onKeyDown(e) {
var t = e.target; // never steal keys from a form field on the portal page
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
if (e.key === 'p' || e.key === 'P') { if (S && S.mode === 'playing') { pause(); } else resume(); return; }
if (e.key === 'm' || e.key === 'M') { toggleMute(); return; }
var k = KEYMAP[e.key];
if (!k || !S || S.mode !== 'playing') return;
kb[k] = true; e.preventDefault(); // stop arrows/space scrolling the portal mid-game
}
function onKeyUp(e) {
var k = KEYMAP[e.key]; if (!k) return;
kb[k] = false;
if (S && S.mode === 'playing') e.preventDefault();
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
qa('[data-hold]').forEach(function (b) {
var k = b.getAttribute('data-hold');
function on(e) { e.preventDefault(); pad[k] = true; b.classList.add('inv-on'); try { b.setPointerCapture(e.pointerId); } catch (x) {} }
function off() { pad[k] = false; b.classList.remove('inv-on'); }
b.addEventListener('pointerdown', on); b.addEventListener('pointerup', off);
b.addEventListener('pointercancel', off); b.addEventListener('lostpointercapture', off);
});
function toggleMute() {
Snd.setMuted(!Snd.isMuted());
var b = q('[data-act="mute"]'); b.textContent = Snd.isMuted() ? '🔇' : '🔊'; b.setAttribute('aria-pressed', String(Snd.isMuted()));
}
root.addEventListener('click', function (e) {
var b = e.target.closest ? e.target.closest('[data-act]') : null; if (!b) return;
switch (b.getAttribute('data-act')) {
case 'start': case 'again': startGame(); break;
case 'resume': resume(); break;
case 'claim': claim(); break;
case 'mute': Snd.unlock(); toggleMute(); break;
}
});
function onVisibility() { if (document.hidden) pause(); }
document.addEventListener('visibilitychange', onVisibility);
var io = null;
if (window.IntersectionObserver) {
io = new IntersectionObserver(function (entries) { if (!entries[0].isIntersecting) pause(); }, { threshold: 0.25 });
io.observe(canvas);
}
function destroy() {
setActive(false);
window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp);
document.removeEventListener('visibilitychange', onVisibility);
if (io) io.disconnect();
Snd.destroy();
}
/* --- boot --- */
newState('start'); hud(); show('start');
var muteBtn = q('[data-act="mute"]'); muteBtn.textContent = Snd.isMuted() ? '🔇' : '🔊'; muteBtn.setAttribute('aria-pressed', String(Snd.isMuted()));
setActive(true);
// A previous win that was never claimed (e.g. no subscription page was open) — offer it again.
try {
var pending = JSON.parse(store.get('won') || 'null');
if (pending) {
q('[data-win-msg]').textContent = fmt(TEXT.pendingMsg, { score: pending.score, gift: TEXT.giftName });
status(''); show('win');
}
} catch (e) {}
// Dev hook for local testing: only on localhost, and only with ?invDebug in the URL. Never present on a live site.
if (/^(localhost|127\.0\.0\.1|\[::1\])$/.test(window.location.hostname) && /[?&]invDebug\b/.test(window.location.search)) {
window.__inv = {
get S() { return S; }, kb: kb, alienRect: alienRect, GAME: GAME, REWARD: REWARD, spr: spr, build: buildSprite,
start: startGame, win: function () { finish('won'); }, lose: function () { finish('lost'); },
setLocal: function (v) { isLocal = v; }, // pretend to be on a live site
step: function (n) { for (var i = 0; i < n && S.mode === 'playing'; i++) update(1 / 60); } // headless sim, no rendering
};
}
});
})();
</script>
How to install
- Copy the code above, or download the .html file.
- Go to
Storefront→Customer Portal→Branding→Layoutand clickAdd custom extension. - Pick
HTML with embedded CSS and JavaScriptand paste the whole file into the code editor. - Swap anything the file asks you to, like
[BRAND], product names or Quick Action IDs. The AI prompt below can walk you through it. - Save, click
+to add it to the layout, thenPublish. Check it on a test subscription’s magic link with?forcePreview=true, since some scripts don’t run in Preview.
It ships in demo mode, which hands out no gifts on a live site. In the CONFIG block, set REWARD.mode to 'quickAction', paste the stackId from an "Add Free Gift" Quick Action (Grow → Quick Actions → Modify an existing subscription, Universal link), and set TEXT.giftName. The gift claim uses Skio Quick Actions; on other portals, use REWARD.mode 'endpoint' with your own backend (not included). Re-skin it through TEXT, the --inv-* colors, SPRITES and MUSIC, and leave everything below the ENGINE line alone. It loads Press Start 2P from Google Fonts.
Customize it with AI
Paste this prompt into Claude Code or Codex. It asks for the code and your brand and product details, then writes a version for your store.
I want to add a custom extension from customizeyourportal.xyz to my subscription customer portal. Walk me through customizing it for my brand, then write the finished code. EXTENSION Name: Subs Invaders (No. 18) Page: /blocks/subs-invaders What it does: A retro arcade game in the style of Space Invaders, inside the customer portal. The subscriber's hero shoots at three kinds of gremlin (Cancel, Pause and Skip in the sample), and clearing them all adds a free gift to their next order. It keeps score and a high score, plays 8-bit music and sound effects with a mute button, works with keyboard or touch, and pauses itself when scrolled out of view. My portal app: Skio WHAT THE BUILDER SAYS TO CUSTOMIZE It ships in demo mode, which hands out no gifts on a live site. In the CONFIG block, set REWARD.mode to 'quickAction', paste the stackId from an "Add Free Gift" Quick Action (Grow → Quick Actions → Modify an existing subscription, Universal link), and set TEXT.giftName. The gift claim uses Skio Quick Actions; on other portals, use REWARD.mode 'endpoint' with your own backend (not included). Re-skin it through TEXT, the --inv-* colors, SPRITES and MUSIC, and leave everything below the ENGINE line alone. It loads Press Start 2P from Google Fonts. HOW TO WORK 1. Ask me to paste the full code (the "Copy code" button on the page) or to give you the path to the downloaded subs-invaders.html. Wait until you have it. 2. Read the whole file. List every value that is specific to the original brand or needs my input: brand name and [PLACEHOLDERS], product and variant names or IDs, prices, discount codes and reward amounts, Quick Action IDs or tokens, store domains and API endpoints, colors, fonts, logos and images, and any copy that names the original brand, products or offers. Include everything in the builder's notes above. 3. Ask me for those values in one numbered list, with the current value next to each, so I can answer or say "keep". If you can open web pages, offer to pull my brand colors, fonts, logo and product names from my store's URL. 4. Don't invent stats, reviews, IDs, prices or claims. If I don't have a value, leave a clearly marked TODO in the code and tell me. 5. Write the customized version as subs-invaders-<my-brand>.html. Change only what my brand needs. Keep the logic, behavior and structure the same, and keep it one self-contained file (HTML, <style> and <script>). 6. Finish with a short list of what you changed, any TODOs left, and these install steps for Skio: 1. Go to `Storefront` → `Customer Portal` → `Branding` → `Layout` and click `Add custom extension`. 2. Pick `HTML with embedded CSS and JavaScript` and paste the whole file into the code editor. 3. Save, click `+` to add it to the layout, then `Publish`. Check it on a test subscription’s magic link with `?forcePreview=true`, since some scripts don’t run in Preview.
Blocks are shared as-is by the people who built them. Read the code before you add it to your portal, and test it with a test subscriber first.
