Collapsible Game Boy popup with 7 mini-games (Snake, Breakout, Tetris, 2048, Flappy, Pong, Simon) plus a real Game Boy / GBC emulator (binjgb — bring your own ROM), sound, palettes and LCD FX to kill dead flight time on Torn's travel screen. Cosmetic, offline, no game-state access.
// ==UserScript==
// @name Contraband
// @namespace torn.contraband.inflight
// @version 1.8.1
// @license MIT
// @description Collapsible Game Boy popup with 7 mini-games (Snake, Breakout, Tetris, 2048, Flappy, Pong, Simon) plus a real Game Boy / GBC emulator (binjgb — bring your own ROM), sound, palettes and LCD FX to kill dead flight time on Torn's travel screen. Cosmetic, offline, no game-state access.
// @author BreadHerring
// @match https://www.torn.com/page.php*
// @match https://www.torn.com/index.php*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @grant GM_setClipboard
// @connect cdn.jsdelivr.net
// @run-at document-idle
// @noframes
// ==/UserScript==
/*
* CONTRABAND — an offline, cosmetic toy that mounts a Game Boy on the Torn
* in-transit (flying) screen with a menu of mini-games (Snake, Breakout,
* Tetris, 2048, Flappy) to play until you land. Press SELECT to pick a game.
*
* It NEVER reads or writes Torn game state, never touches the API, and never
* automates anything. It is a self-contained overlay sitting on top of the page.
*
* A "PLAY GAME / HIDE GAME" toggle is injected into the travel screen's
* "Traveling" header (titleContainer). Use it to show or hide the handheld;
* the choice is remembered across page refreshes.
*
* ── Tuning detection (if it doesn't auto-appear when flying) ──────────────
* Detection keys off confirmed in-flight markers: <body data-traveling>,
* .content-wrapper.travelling, and the #travel-root flight scene. If needed,
* open the browser console on the travel page and use:
* __CONTRABAND__.debug() // show a live panel of detection signals
* __CONTRABAND__.force('on') // force-mount regardless of detection
* __CONTRABAND__.force('off') // force-hide
* __CONTRABAND__.force('auto')// back to automatic detection (default)
* __CONTRABAND__.signals() // print the current detection result
* __CONTRABAND__.toggle() // show/hide the handheld (same as the button)
* Or add one of these to the URL: #cb-debug #cb-on #cb-off
*/
(function () {
'use strict';
// One instance only.
if (window.__CONTRABAND__) return;
// ───────────────────────────────────────────────────────────── constants ──
var ROOT_ID = 'contraband-root';
var STYLE_ID = 'contraband-style';
// Selectable 4-shade palettes (dark → light). PAL is mutated in place by
// applyPalette so every game/menu (which read PAL.dN at draw time) update live.
var PALETTES = [
{ name: 'DMG', d0: '#0f380f', d1: '#306230', d2: '#8bac0f', d3: '#9bbc0f' },
{ name: 'POCKET', d0: '#0b0b0b', d1: '#4d4d4d', d2: '#8f8f8f', d3: '#c7c7c7' },
{ name: 'AMBER', d0: '#1a0f00', d1: '#5a3410', d2: '#b06b1a', d3: '#f5c96b' }
];
var PAL = { d0: '#0f380f', d1: '#306230', d2: '#8bac0f', d3: '#9bbc0f' };
function applyPalette(i) {
var p = PALETTES[((i | 0) % PALETTES.length + PALETTES.length) % PALETTES.length];
PAL.d0 = p.d0; PAL.d1 = p.d1; PAL.d2 = p.d2; PAL.d3 = p.d3;
}
// Runtime cosmetic flags (populated in boot()).
var FX = { palette: 0, crt: false, reduced: false };
// ─────────────────────────────────────────────────────────────── storage ──
// GM_setValue/GM_getValue when available (survives refresh + works in PDA/TM),
// otherwise localStorage. All access wrapped so a failure never breaks the toy.
var Storage = (function () {
var hasGM = typeof GM_setValue === 'function' && typeof GM_getValue === 'function';
function get(key, def) {
try {
if (hasGM) {
var v = GM_getValue(key, undefined);
return (v === undefined || v === null) ? def : v;
}
var raw = window.localStorage.getItem(key);
return raw === null ? def : JSON.parse(raw);
} catch (e) { return def; }
}
function set(key, val) {
try {
if (hasGM) { GM_setValue(key, val); return; }
window.localStorage.setItem(key, JSON.stringify(val));
} catch (e) { /* ignore */ }
}
return { get: get, set: set };
})();
// ───────────────────────────────────────────────────────────────── sound ──
// Self-contained WebAudio bleeper. No assets, no network. Off by default;
// the AudioContext is created/resumed only from a real user gesture (see the
// unlock() calls in the control handlers), which satisfies autoplay policies.
var Sound = (function () {
var ctx = null;
var enabled = Storage.get('contraband.sound', false) === true;
function unlock() {
try {
if (!ctx) {
var AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
ctx = new AC();
}
if (ctx.state === 'suspended') ctx.resume();
} catch (e) {}
}
function tone(freq, dur, type, vol, delay) {
if (!ctx || ctx.state !== 'running') return;
try {
var t0 = ctx.currentTime + (delay || 0);
var osc = ctx.createOscillator(), gain = ctx.createGain();
osc.type = type || 'square';
osc.frequency.setValueAtTime(freq, t0);
gain.gain.setValueAtTime(vol || 0.06, t0);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(gain); gain.connect(ctx.destination);
osc.start(t0); osc.stop(t0 + dur + 0.02);
} catch (e) {}
}
function play(name) {
if (!enabled) return;
switch (name) {
case 'move': tone(200, 0.03, 'square', 0.04); break;
case 'rotate': tone(300, 0.03, 'square', 0.05); break;
case 'eat': tone(660, 0.06, 'square', 0.07); break;
case 'flap': tone(520, 0.05, 'square', 0.06); break;
case 'score': tone(784, 0.05, 'square', 0.06); break;
case 'hit': tone(320, 0.03, 'square', 0.05); break;
case 'launch': tone(880, 0.05, 'square', 0.06); break;
case 'lock': tone(240, 0.04, 'square', 0.05); break;
case 'merge': tone(440, 0.05, 'square', 0.07); tone(660, 0.06, 'square', 0.06, 0.05); break;
case 'clear': tone(523, 0.05, 'square', 0.07); tone(659, 0.05, 'square', 0.07, 0.06); tone(784, 0.08, 'square', 0.07, 0.12); break;
case 'select': tone(330, 0.03, 'square', 0.05); break;
case 'confirm': tone(523, 0.05, 'square', 0.06); tone(784, 0.07, 'square', 0.06, 0.05); break;
case 'pause': tone(392, 0.05, 'square', 0.05); break;
case 'over': tone(330, 0.08, 'square', 0.07); tone(247, 0.10, 'square', 0.07, 0.09); tone(165, 0.16, 'square', 0.07, 0.20); break;
}
}
return {
unlock: unlock,
context: function () { return ctx; },
play: play,
isEnabled: function () { return enabled; },
setEnabled: function (v) { enabled = !!v; Storage.set('contraband.sound', enabled); if (enabled) unlock(); }
};
})();
// ─────────────────────────────────────────────────────────────── haptics ──
// Mobile vibration for button taps + crashes (no-op on desktop). On by default.
var Haptics = (function () {
var enabled = Storage.get('contraband.haptics', true) !== false;
function can() { return enabled && typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function'; }
return {
tap: function () { if (can()) try { navigator.vibrate(6); } catch (e) {} },
hit: function () { if (can()) try { navigator.vibrate([0, 25, 40, 35]); } catch (e) {} },
isEnabled: function () { return enabled; },
setEnabled: function (v) { enabled = !!v; Storage.set('contraband.haptics', enabled); }
};
})();
// ───────────────────────────────────────────────────────────── input bus ──
// One abstract bus. Desktop keydown/up and mobile pointer taps both feed it.
// Game code only ever reads isDown()/justPressed() — never raw DOM events.
var ACTIONS = ['up', 'down', 'left', 'right', 'a', 'b', 'start', 'select'];
function createInput() {
var down = {}, pressed = {};
ACTIONS.forEach(function (a) { down[a] = false; pressed[a] = false; });
return {
press: function (a) {
if (down[a] === undefined) return;
if (!down[a]) pressed[a] = true; // edge-trigger (ignores key auto-repeat)
down[a] = true;
},
release: function (a) {
if (down[a] === undefined) return;
down[a] = false;
},
isDown: function (a) { return !!down[a]; },
justPressed: function (a) { return !!pressed[a]; },
endFrame: function () { for (var k in pressed) pressed[k] = false; },
resetAll: function () {
ACTIONS.forEach(function (a) { down[a] = false; pressed[a] = false; });
}
};
}
// ─────────────────────────────────────────────────────────── cartridges ──
// Cartridge interface: { id, name, init(api), update(dt), render(g), reset() }
// api = { W, H, input, storage, cols, rows }
// Games are platform-agnostic and read input only through api.input.
function makeSnake() {
var W = 160, H = 144; // internal LCD buffer (real GB resolution)
var CELL = 8;
var HUD_H = 16; // top strip for score
var COLS = W / CELL; // 20
var ROWS = (H - HUD_H) / CELL; // 16
var OY = HUD_H; // playfield pixel y-offset
var HISCORE_KEY = 'contraband.snake.hiscore';
var api = null;
var state = 'title'; // title | play | over
var snake, dir, pendingDir, food, score, hi, moveEvery, acc, wrap, justHigh;
function placeFood() {
var free = [];
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++) {
var on = false;
for (var i = 0; i < snake.length; i++) {
if (snake[i].x === x && snake[i].y === y) { on = true; break; }
}
if (!on) free.push({ x: x, y: y });
}
}
food = free.length ? free[(Math.random() * free.length) | 0] : null;
}
function newGame() {
var cy = (ROWS / 2) | 0;
snake = [{ x: 5, y: cy }, { x: 4, y: cy }, { x: 3, y: cy }];
dir = { x: 1, y: 0 };
pendingDir = { x: 1, y: 0 };
score = 0;
moveEvery = 150; // ms per step; ramps down as you eat
acc = 0;
wrap = api.storage.get('contraband.snake.wrap', false) === true; // Settings toggle
justHigh = false;
placeFood();
state = 'play';
}
function readTurn() {
// Edge-triggered direction changes; reject reversing onto yourself.
var nd = null;
if (api.input.justPressed('up')) nd = { x: 0, y: -1 };
else if (api.input.justPressed('down')) nd = { x: 0, y: 1 };
else if (api.input.justPressed('left')) nd = { x: -1, y: 0 };
else if (api.input.justPressed('right')) nd = { x: 1, y: 0 };
if (nd && !(nd.x === -dir.x && nd.y === -dir.y)) pendingDir = nd;
}
function step() {
dir = pendingDir;
var head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
if (wrap) { // wrap-around mode (Settings)
head.x = (head.x + COLS) % COLS;
head.y = (head.y + ROWS) % ROWS;
} else if (head.x < 0 || head.x >= COLS || head.y < 0 || head.y >= ROWS) {
return gameOver(); // walls kill (classic)
}
// Self collision (allow moving into the current tail cell, which frees up).
for (var i = 0; i < snake.length - 1; i++) {
if (snake[i].x === head.x && snake[i].y === head.y) return gameOver();
}
snake.unshift(head);
if (food && head.x === food.x && head.y === food.y) {
score++;
if (moveEvery > 70) moveEvery -= 4;
api.sound.play('eat');
placeFood();
} else {
snake.pop();
}
}
function gameOver() {
state = 'over';
api.sound.play('over'); api.haptic.hit();
if (score > hi) { hi = score; api.storage.set(HISCORE_KEY, hi); justHigh = true; }
}
// ── rendering helpers ──
function pad(n, len) { n = '' + n; while (n.length < len) n = '0' + n; return n; }
function text(g, s, x, y, color) {
g.fillStyle = color;
g.fillText(s, x, y);
}
return {
id: 'snake',
name: 'SNAKE',
init: function (a) {
api = a;
hi = parseInt(api.storage.get(HISCORE_KEY, 0), 10) || 0;
state = 'title';
},
reset: function () { state = 'title'; },
isPlaying: function () { return state === 'play'; },
tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
update: function (dt) {
var i = api.input;
if (state === 'title') {
if (i.justPressed('a') || i.justPressed('start')) newGame();
return;
}
if (state === 'over') {
if (i.justPressed('a') || i.justPressed('start')) newGame();
return;
}
// play
readTurn();
acc += dt;
while (acc >= moveEvery) {
acc -= moveEvery;
if (state !== 'play') break;
step();
}
},
render: function (g) {
// LCD background
g.fillStyle = PAL.d3;
g.fillRect(0, 0, W, H);
g.font = '8px "Courier New", monospace';
g.textBaseline = 'top';
// HUD
text(g, 'SC ' + pad(state === 'title' ? 0 : score, 4), 3, 4, PAL.d0);
text(g, 'HI ' + pad(hi, 4), W - 62, 4, PAL.d0);
g.fillStyle = PAL.d1;
g.fillRect(0, HUD_H - 2, W, 1);
if (state === 'title') {
g.textAlign = 'center';
g.font = 'bold 20px "Courier New", monospace';
text(g, 'SNAKE', W / 2, 46, PAL.d0);
g.font = '9px "Courier New", monospace';
text(g, 'PRESS A TO START', W / 2, 84, PAL.d1);
text(g, 'CONTRABAND', W / 2, 118, PAL.d1);
g.textAlign = 'left';
return;
}
// food
if (food) {
g.fillStyle = PAL.d1;
g.fillRect(food.x * CELL + 1, OY + food.y * CELL + 1, CELL - 2, CELL - 2);
}
// snake
for (var s = 0; s < snake.length; s++) {
g.fillStyle = (s === 0) ? PAL.d0 : PAL.d1;
g.fillRect(snake[s].x * CELL, OY + snake[s].y * CELL, CELL - 1, CELL - 1);
}
if (state === 'over') {
g.fillStyle = 'rgba(15,56,15,0.72)';
g.fillRect(8, 50, W - 16, 52);
g.textAlign = 'center';
g.font = 'bold 13px "Courier New", monospace';
text(g, 'GAME OVER', W / 2, 58, PAL.d3);
g.font = '9px "Courier New", monospace';
text(g, 'SCORE ' + score, W / 2, 78, PAL.d3);
text(g, 'A = RETRY', W / 2, 90, PAL.d2);
g.textAlign = 'left';
}
}
};
}
// Shared tiny render helpers used by the cartridges below.
function _pad(n, len) { n = '' + n; while (n.length < len) n = '0' + n; return n; }
function _text(g, s, x, y, color) { g.fillStyle = color; g.fillText(s, x, y); }
// ── BREAKOUT ──────────────────────────────────────────────────────────────
function makeBreakout() {
var W = 160, H = 144, HUD = 14;
var PADW = 30, PADH = 5, PADY = H - 12;
var COLS = 10, BW = 16, BH = 7, ROWS = 4, BX = 0, BY = HUD + 6;
var KEY = 'contraband.breakout.hiscore';
var api = null, state = 'title', hi = 0;
var padX, ball, bricks, left, score, lives, speed, justHigh;
function resetBricks() {
bricks = []; left = 0;
for (var r = 0; r < ROWS; r++) {
bricks[r] = [];
for (var c = 0; c < COLS; c++) { bricks[r][c] = true; left++; }
}
}
function serve() {
padX = (W - PADW) / 2;
ball = { x: W / 2, y: PADY - 4, vx: 0, vy: 0, stuck: true };
}
function newGame() { score = 0; lives = 3; speed = 0.10; justHigh = false; resetBricks(); serve(); state = 'play'; }
function launchBall() {
ball.stuck = false;
var ang = Math.random() * 0.5 - 0.25; // ±~14° from vertical, constant speed
ball.vx = speed * Math.sin(ang);
ball.vy = -speed * Math.cos(ang);
api.sound.play('launch');
}
function loseLife() {
lives--;
if (lives <= 0) { state = 'over'; api.sound.play('over'); api.haptic.hit(); if (score > hi) { hi = score; api.storage.set(KEY, hi); justHigh = true; } }
else { api.sound.play('hit'); serve(); }
}
function collide() {
if (ball.x < 2) { ball.x = 2; ball.vx = Math.abs(ball.vx); }
if (ball.x > W - 2) { ball.x = W - 2; ball.vx = -Math.abs(ball.vx); }
if (ball.y < HUD + 1) { ball.y = HUD + 1; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && ball.y >= PADY - 2 && ball.y <= PADY + PADH &&
ball.x >= padX - 1 && ball.x <= padX + PADW + 1) {
ball.y = PADY - 2;
var hp = (ball.x - (padX + PADW / 2)) / (PADW / 2); // -1..1
if (hp < -1) hp = -1; else if (hp > 1) hp = 1;
var ang = hp * 1.05; // cap to ~60° off vertical (no near-horizontal shots)
ball.vx = speed * Math.sin(ang);
ball.vy = -speed * Math.cos(ang); // always a solid upward component
}
if (ball.y >= BY && ball.y < BY + ROWS * BH) {
var c = Math.floor((ball.x - BX) / BW), r = Math.floor((ball.y - BY) / BH);
if (r >= 0 && r < ROWS && c >= 0 && c < COLS && bricks[r][c]) {
bricks[r][c] = false; left--; score += 10; ball.vy = -ball.vy; api.sound.play('hit');
if (left <= 0) { resetBricks(); speed += 0.015; serve(); }
}
}
if (ball.y > H + 2) loseLife();
}
function step(dt) {
var i = api.input, pv = 0.13 * dt;
if (i.isDown('left')) padX -= pv;
if (i.isDown('right')) padX += pv;
if (padX < 0) padX = 0;
if (padX > W - PADW) padX = W - PADW;
if (ball.stuck) {
ball.x = padX + PADW / 2; ball.y = PADY - 4;
if (i.justPressed('a') || i.justPressed('start')) launchBall();
return;
}
var dist = speed * dt, steps = Math.max(1, Math.ceil(dist / 2));
var sx = ball.vx * dt / steps, sy = ball.vy * dt / steps;
for (var s = 0; s < steps; s++) {
ball.x += sx; ball.y += sy; collide();
if (state !== 'play') return;
}
}
return {
id: 'breakout', name: 'BREAKOUT',
init: function (a) { api = a; hi = parseInt(api.storage.get(KEY, 0), 10) || 0; state = 'title'; },
reset: function () { state = 'title'; },
isPlaying: function () { return state === 'play'; },
tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
update: function (dt) {
var i = api.input;
if (state === 'title' || state === 'over') {
if (i.justPressed('a') || i.justPressed('start')) newGame();
return;
}
step(dt);
},
render: function (g) {
g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
g.font = '8px "Courier New", monospace'; g.textBaseline = 'top';
_text(g, 'SC ' + _pad(state === 'title' ? 0 : score, 4), 3, 3, PAL.d0);
_text(g, 'HI ' + _pad(hi, 4), W - 62, 3, PAL.d0);
if (state !== 'title') _text(g, 'LIVES ' + lives, W / 2 - 22, 3, PAL.d1);
g.fillStyle = PAL.d1; g.fillRect(0, HUD - 2, W, 1);
if (state === 'title') {
g.textAlign = 'center';
g.font = 'bold 16px "Courier New", monospace'; _text(g, 'BREAKOUT', W / 2, 44, PAL.d0);
g.font = '9px "Courier New", monospace';
_text(g, '← → MOVE A LAUNCH', W / 2, 82, PAL.d1);
_text(g, 'A = START', W / 2, 108, PAL.d1);
g.textAlign = 'left'; return;
}
for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {
if (bricks[r][c]) {
g.fillStyle = (r < 2) ? PAL.d0 : PAL.d1;
g.fillRect(BX + c * BW + 1, BY + r * BH + 1, BW - 2, BH - 2);
}
}
g.fillStyle = PAL.d0;
g.fillRect(padX, PADY, PADW, PADH);
g.fillRect(ball.x - 2, ball.y - 2, 4, 4);
if (state === 'over') {
g.fillStyle = 'rgba(15,56,15,0.72)'; g.fillRect(8, 54, W - 16, 44);
g.textAlign = 'center';
g.font = 'bold 13px "Courier New", monospace'; _text(g, 'GAME OVER', W / 2, 62, PAL.d3);
g.font = '9px "Courier New", monospace'; _text(g, 'A = RETRY', W / 2, 82, PAL.d2);
g.textAlign = 'left';
}
}
};
}
// ── TETRIS ────────────────────────────────────────────────────────────────
function makeTetris() {
var W = 160, H = 144, COLS = 10, ROWS = 18, CELL = 7;
var OX = 6, OY = 6, PX = OX + COLS * CELL + 8; // well origin + side panel x
var KEY = 'contraband.tetris.hiscore';
var SHAPES = [
[[1, 1, 1, 1]], // I
[[1, 1], [1, 1]], // O
[[0, 1, 0], [1, 1, 1]], // T
[[0, 1, 1], [1, 1, 0]], // S
[[1, 1, 0], [0, 1, 1]], // Z
[[1, 0, 0], [1, 1, 1]], // J
[[0, 0, 1], [1, 1, 1]] // L
];
var api = null, state = 'title', hi = 0, FLASH = 260;
var board, piece, nextIdx, score, lines, level, acc, dropEvery, dasL, dasR, softAcc, justHigh;
var flashRows = [], flashT = 0; // rows blinking before they clear
function rotate(m) {
var R = m.length, C = m[0].length, out = [];
for (var c = 0; c < C; c++) { out[c] = []; for (var r = R - 1; r >= 0; r--) out[c].push(m[r][c]); }
return out;
}
function collides(m, px, py) {
for (var r = 0; r < m.length; r++) for (var c = 0; c < m[r].length; c++) {
if (!m[r][c]) continue;
var x = px + c, y = py + r;
if (x < 0 || x >= COLS || y >= ROWS) return true;
if (y >= 0 && board[y][x]) return true;
}
return false;
}
function spawn() {
var m = SHAPES[nextIdx].map(function (row) { return row.slice(); });
nextIdx = (Math.random() * SHAPES.length) | 0;
piece = { m: m, x: ((COLS - m[0].length) / 2) | 0, y: 0 };
if (collides(piece.m, piece.x, piece.y)) {
state = 'over'; api.sound.play('over'); api.haptic.hit();
if (score > hi) { hi = score; api.storage.set(KEY, hi); justHigh = true; }
}
}
function newGame() {
board = [];
for (var r = 0; r < ROWS; r++) { board[r] = []; for (var c = 0; c < COLS; c++) board[r][c] = 0; }
score = 0; lines = 0; level = 0; acc = 0; dropEvery = 600; dasL = 0; dasR = 0; softAcc = 0; flashRows = []; justHigh = false;
nextIdx = (Math.random() * SHAPES.length) | 0;
spawn(); state = 'play';
}
function fullRows() {
var rows = [];
for (var y = 0; y < ROWS; y++) {
var full = true;
for (var x = 0; x < COLS; x++) if (!board[y][x]) { full = false; break; }
if (full) rows.push(y);
}
return rows;
}
function lock() {
for (var r = 0; r < piece.m.length; r++) for (var c = 0; c < piece.m[r].length; c++) {
if (piece.m[r][c]) { var y = piece.y + r; if (y >= 0) board[y][piece.x + c] = 1; }
}
var fr = fullRows();
if (fr.length) { flashRows = fr; flashT = 0; api.sound.play('clear'); } // defer clear for the flash
else { api.sound.play('lock'); spawn(); }
}
function performClear() {
var cleared = flashRows.length, keep = [], isFlash = {}, i, k;
for (i = 0; i < flashRows.length; i++) isFlash[flashRows[i]] = true;
for (var y = 0; y < ROWS; y++) if (!isFlash[y]) keep.push(board[y]);
while (keep.length < ROWS) { var nr = []; for (k = 0; k < COLS; k++) nr.push(0); keep.unshift(nr); }
board = keep;
var tbl = [0, 40, 100, 300, 1200];
score += tbl[cleared] * (level + 1);
lines += cleared; level = (lines / 10) | 0;
dropEvery = Math.max(90, 600 - level * 45);
flashRows = [];
spawn();
}
function softStep() { if (!collides(piece.m, piece.x, piece.y + 1)) piece.y++; else lock(); }
function tryMove(dx) { if (!collides(piece.m, piece.x + dx, piece.y)) piece.x += dx; }
function tryRotate() {
var r = rotate(piece.m), kicks = [0, -1, 1, -2, 2];
for (var k = 0; k < kicks.length; k++) {
if (!collides(r, piece.x + kicks[k], piece.y)) { piece.m = r; piece.x += kicks[k]; api.sound.play('rotate'); return; }
}
}
function hardDrop() { while (!collides(piece.m, piece.x, piece.y + 1)) piece.y++; lock(); }
function step(dt) {
var i = api.input;
if (i.justPressed('left')) { tryMove(-1); dasL = 170; }
else if (i.isDown('left')) { dasL -= dt; if (dasL <= 0) { tryMove(-1); dasL = 60; } }
if (i.justPressed('right')) { tryMove(1); dasR = 170; }
else if (i.isDown('right')) { dasR -= dt; if (dasR <= 0) { tryMove(1); dasR = 60; } }
if (i.justPressed('a') || i.justPressed('up')) tryRotate();
if (i.justPressed('b')) hardDrop();
// Gravity: steady, and capped so a big dt (tab throttle) can't dump rows.
acc += dt;
var gsteps = 0;
while (acc >= dropEvery && gsteps < 2) { acc -= dropEvery; gsteps++; softStep(); if (state !== 'play') return; }
if (acc > dropEvery) acc = dropEvery;
// Soft drop: one cell every 45ms while Down is held — steady, never a leap.
if (i.isDown('down')) {
softAcc += dt;
if (softAcc >= 45) { softAcc = 0; softStep(); score += 1; if (state !== 'play') return; }
} else softAcc = 0;
}
function blk(g, x, y, color) { g.fillStyle = color; g.fillRect(x + 1, y + 1, CELL - 1, CELL - 1); }
return {
id: 'tetris', name: 'TETRIS',
init: function (a) { api = a; hi = parseInt(api.storage.get(KEY, 0), 10) || 0; state = 'title'; },
reset: function () { state = 'title'; },
isPlaying: function () { return state === 'play'; },
tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
update: function (dt) {
var i = api.input;
if (state === 'title' || state === 'over') {
if (i.justPressed('a') || i.justPressed('start')) newGame();
return;
}
if (flashRows.length) { flashT += dt; if (flashT >= FLASH) performClear(); return; } // blink then clear
step(dt);
},
render: function (g) {
g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
g.fillStyle = PAL.d1; g.fillRect(OX - 2, OY - 2, COLS * CELL + 4, ROWS * CELL + 4);
g.fillStyle = PAL.d3; g.fillRect(OX, OY, COLS * CELL, ROWS * CELL);
g.textBaseline = 'top';
if (state === 'title') {
g.textAlign = 'center';
g.font = 'bold 15px "Courier New", monospace'; _text(g, 'TETRIS', W / 2, 44, PAL.d0);
g.font = '8px "Courier New", monospace';
_text(g, 'A/↑ ROTATE B DROP', W / 2, 82, PAL.d1);
_text(g, 'A = START', W / 2, 104, PAL.d1);
g.textAlign = 'left'; return;
}
var blink = flashRows.length && (Math.floor(flashT / 40) % 2) === 0;
for (var r = 0; r < ROWS; r++) for (var c = 0; c < COLS; c++) {
if (board[r][c]) {
var col = PAL.d1;
if (flashRows.length && flashRows.indexOf(r) >= 0) col = blink ? PAL.d3 : PAL.d0;
blk(g, OX + c * CELL, OY + r * CELL, col);
}
}
if (state === 'play' && !flashRows.length) {
for (var pr = 0; pr < piece.m.length; pr++) for (var pc = 0; pc < piece.m[pr].length; pc++) {
if (piece.m[pr][pc]) { var yy = piece.y + pr; if (yy >= 0) blk(g, OX + (piece.x + pc) * CELL, OY + yy * CELL, PAL.d0); }
}
}
g.font = '8px "Courier New", monospace';
_text(g, 'SCORE', PX, 8, PAL.d0); _text(g, '' + score, PX, 18, PAL.d1);
_text(g, 'LINES', PX, 36, PAL.d0); _text(g, '' + lines, PX, 46, PAL.d1);
_text(g, 'LEVEL', PX, 64, PAL.d0); _text(g, '' + level, PX, 74, PAL.d1);
_text(g, 'HI', PX, 92, PAL.d0); _text(g, '' + hi, PX, 102, PAL.d1);
_text(g, 'NEXT', PX, 118, PAL.d0);
var nm = SHAPES[nextIdx];
for (var a = 0; a < nm.length; a++) for (var b = 0; b < nm[a].length; b++) {
if (nm[a][b]) blk(g, PX + b * 6 + 1, 128 + a * 6, PAL.d0);
}
if (state === 'over') {
g.fillStyle = 'rgba(15,56,15,0.8)'; g.fillRect(OX - 2, 54, COLS * CELL + 4, 42);
g.textAlign = 'center';
g.font = 'bold 11px "Courier New", monospace';
_text(g, 'GAME', OX + COLS * CELL / 2, 58, PAL.d3);
_text(g, 'OVER', OX + COLS * CELL / 2, 70, PAL.d3);
g.font = '8px "Courier New", monospace'; _text(g, 'A=RETRY', OX + COLS * CELL / 2, 84, PAL.d2);
g.textAlign = 'left';
}
}
};
}
// ── 2048 ──────────────────────────────────────────────────────────────────
function make2048() {
var W = 160, H = 144, HUD = 16, N = 4, PAD = 2;
var AREA = 128, OX = (W - AREA) / 2, OY = HUD, CELL = AREA / N; // 32
var ANIM = 90, POP = 90; // ms: tile slide + spawn/merge pop durations
var KEY = 'contraband.2048.hiscore';
var api = null, state = 'title', hi = 0, grid, score, won, justHigh, prevHi = 0;
var animating = false, anim = null, animT = 0, pendingGrid = null, pendingGained = 0;
var popCells = [], mergeCells = [], popT = 0; // new-tile grow-in + merged-tile pop
function emptyGrid() { var a = []; for (var i = 0; i < N * N; i++) a.push(0); return a; }
function ix(r, c) { return r * N + c; }
function place() {
var free = [];
for (var i = 0; i < grid.length; i++) if (!grid[i]) free.push(i);
if (free.length) grid[free[(Math.random() * free.length) | 0]] = Math.random() < 0.9 ? 2 : 4;
}
function newGame() {
grid = emptyGrid(); score = 0; won = false; prevHi = hi; justHigh = false;
animating = false; anim = null; popCells = []; mergeCells = []; popT = POP;
place(); place(); state = 'play';
}
function movesLeft() {
for (var i = 0; i < grid.length; i++) if (!grid[i]) return true;
for (var r = 0; r < N; r++) for (var c = 0; c < N; c++) {
var v = grid[ix(r, c)];
if (c + 1 < N && grid[ix(r, c + 1)] === v) return true;
if (r + 1 < N && grid[ix(r + 1, c)] === v) return true;
}
return false;
}
// Cell order for one line; index 0 is the destination end tiles pile toward.
function lineCoords(dir, k) {
var a = [];
for (var t = 0; t < N; t++) {
if (dir === 3) a.push({ r: k, c: t }); // left
else if (dir === 1) a.push({ r: k, c: N - 1 - t }); // right
else if (dir === 0) a.push({ r: t, c: k }); // up
else a.push({ r: N - 1 - t, c: k }); // down
}
return a;
}
// Plan a move WITHOUT mutating grid: returns each tile's from→to + merged board.
function computeMove(dir) {
var moves = [], gained = 0, newGrid = emptyGrid();
for (var k = 0; k < N; k++) {
var cells = lineCoords(dir, k), seq = [];
for (var t = 0; t < cells.length; t++) {
var v = grid[ix(cells[t].r, cells[t].c)];
if (v) seq.push({ r: cells[t].r, c: cells[t].c, val: v });
}
var wi = 0;
for (var j = 0; j < seq.length; j++) {
var d = cells[wi];
if (j + 1 < seq.length && seq[j].val === seq[j + 1].val) {
var mv = seq[j].val * 2; gained += mv; if (mv === 2048) won = true;
newGrid[ix(d.r, d.c)] = mv;
moves.push({ fr: seq[j].r, fc: seq[j].c, tr: d.r, tc: d.c, val: seq[j].val, merge: false });
moves.push({ fr: seq[j + 1].r, fc: seq[j + 1].c, tr: d.r, tc: d.c, val: seq[j + 1].val, merge: true });
wi++; j++;
} else {
newGrid[ix(d.r, d.c)] = seq[j].val;
moves.push({ fr: seq[j].r, fc: seq[j].c, tr: d.r, tc: d.c, val: seq[j].val, merge: false });
wi++;
}
}
}
var moved = false;
for (var m = 0; m < moves.length; m++) {
var o = moves[m];
if (o.fr !== o.tr || o.fc !== o.tc || o.merge) { moved = true; break; }
}
return { moves: moves, gained: gained, moved: moved, newGrid: newGrid };
}
function move(dir) {
if (animating) return;
var res = computeMove(dir);
if (!res.moved) return;
anim = res.moves; pendingGrid = res.newGrid; pendingGained = res.gained;
animT = 0; animating = true;
}
function finishMove() {
grid = pendingGrid; score += pendingGained;
if (score > hi) { hi = score; api.storage.set(KEY, hi); }
mergeCells = []; popCells = [];
for (var m = 0; m < anim.length; m++) if (anim[m].merge) mergeCells.push(ix(anim[m].tr, anim[m].tc));
var free = [];
for (var i = 0; i < grid.length; i++) if (!grid[i]) free.push(i);
if (free.length) { var s = free[(Math.random() * free.length) | 0]; grid[s] = Math.random() < 0.9 ? 2 : 4; popCells.push(s); }
api.sound.play(mergeCells.length ? 'merge' : 'move');
animating = false; anim = null; popT = 0;
if (!movesLeft()) { state = 'over'; api.sound.play('over'); api.haptic.hit(); justHigh = score > prevHi; }
}
function ln2(v) { return Math.round(Math.log(v) / Math.LN2); }
function shade(v) { var e = ln2(v); return e <= 2 ? PAL.d2 : (e <= 5 ? PAL.d1 : PAL.d0); }
function txt(v) { return ln2(v) <= 2 ? PAL.d0 : PAL.d3; }
function drawTile(g, x, y, v, scale) {
var s = CELL - PAD * 2, w = s * scale, cx = x + CELL / 2, cy = y + CELL / 2;
g.fillStyle = shade(v); g.fillRect(cx - w / 2, cy - w / 2, w, w);
if (scale >= 0.7) {
g.font = 'bold ' + (v < 100 ? 15 : v < 1000 ? 12 : 9) + 'px "Courier New", monospace';
g.textAlign = 'center'; g.textBaseline = 'middle';
_text(g, '' + v, cx, cy, txt(v));
g.textAlign = 'left'; g.textBaseline = 'top';
}
}
return {
id: '2048', name: '2048',
init: function (a) { api = a; hi = parseInt(api.storage.get(KEY, 0), 10) || 0; state = 'title'; },
reset: function () { state = 'title'; },
isPlaying: function () { return state === 'play'; },
tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
update: function (dt) {
var i = api.input;
if (state === 'title' || state === 'over') {
if (i.justPressed('a') || i.justPressed('start')) newGame();
return;
}
if (animating) { animT += dt; if (animT >= ANIM) finishMove(); return; }
if (popT < POP) popT += dt;
if (i.justPressed('up')) move(0);
else if (i.justPressed('right')) move(1);
else if (i.justPressed('down')) move(2);
else if (i.justPressed('left')) move(3);
},
render: function (g) {
g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
g.font = '8px "Courier New", monospace'; g.textBaseline = 'top';
_text(g, 'SCORE ' + (state === 'title' ? 0 : score), 3, 4, PAL.d0);
_text(g, 'HI ' + hi, W - 56, 4, PAL.d0);
if (state === 'title') {
g.textAlign = 'center';
g.font = 'bold 22px "Courier New", monospace'; _text(g, '2048', W / 2, 42, PAL.d0);
g.font = '9px "Courier New", monospace';
_text(g, 'D-PAD TO SLIDE', W / 2, 84, PAL.d1);
_text(g, 'A = START', W / 2, 108, PAL.d1);
g.textAlign = 'left'; return;
}
// faint empty slots so the board frame stays readable
for (var r = 0; r < N; r++) for (var c = 0; c < N; c++) {
var ex = OX + c * CELL, ey = OY + r * CELL;
g.fillStyle = PAL.d2; g.globalAlpha = 0.22;
g.fillRect(ex + PAD, ey + PAD, CELL - PAD * 2, CELL - PAD * 2); g.globalAlpha = 1;
}
if (animating) {
var p = Math.min(1, animT / ANIM), e = p * (2 - p); // easeOutQuad slide
for (var m = 0; m < anim.length; m++) {
var mo = anim[m];
var fx = OX + mo.fc * CELL, fy = OY + mo.fr * CELL;
var tx = OX + mo.tc * CELL, ty = OY + mo.tr * CELL;
drawTile(g, fx + (tx - fx) * e, fy + (ty - fy) * e, mo.val, 1);
}
} else {
var pop = popT < POP ? popT / POP : 1, ep = pop * (2 - pop);
var overshoot = 1 + 0.22 * Math.sin(Math.PI * pop); // merged tile "punch"
for (var r2 = 0; r2 < N; r2++) for (var c2 = 0; c2 < N; c2++) {
var v = grid[ix(r2, c2)]; if (!v) continue;
var id = ix(r2, c2), sc = 1;
if (pop < 1) {
if (mergeCells.indexOf(id) >= 0) sc = overshoot; // merge: pop out then settle
else if (popCells.indexOf(id) >= 0) sc = 0.35 + 0.65 * ep; // new tile: grow in
}
drawTile(g, OX + c2 * CELL, OY + r2 * CELL, v, sc);
}
}
if (state === 'over') {
g.fillStyle = 'rgba(15,56,15,0.78)'; g.fillRect(8, 56, W - 16, 40);
g.textAlign = 'center';
g.font = 'bold 12px "Courier New", monospace'; _text(g, 'NO MOVES', W / 2, 62, PAL.d3);
g.font = '9px "Courier New", monospace'; _text(g, 'A = RETRY', W / 2, 80, PAL.d2);
g.textAlign = 'left';
}
}
};
}
// ── FLAPPY (one button) ─────────────────────────────────────────────────────
function makeFlappy() {
var W = 160, H = 144, GND = H - 12, GAP = 54, PIPEW = 16, BIRDX = 44;
// Gentler than classic: soft gravity, a small controllable flap, and a
// terminal fall speed so it never plummets. SPAWN = ms between pipes.
var GRAV = 0.00095, FLAP = -0.215, TERM = 0.30, SPD = 0.052, SPAWN = 1650;
var KEY = 'contraband.flappy.hiscore';
var api = null, state = 'title', hi = 0, bird, pipes, spawnT, score, justHigh;
// Start with a gentle upward drift + a grace delay before the first pipe.
function newGame() { bird = { y: H / 2, vy: FLAP * 0.5 }; pipes = []; spawnT = 1000; score = 0; justHigh = false; state = 'play'; }
function addPipe() {
var margin = 16, gap = margin + Math.random() * (GND - GAP - margin * 2);
pipes.push({ x: W + 4, gap: gap, passed: false });
}
function over() { state = 'over'; api.sound.play('over'); api.haptic.hit(); if (score > hi) { hi = score; api.storage.set(KEY, hi); justHigh = true; } }
function step(dt) {
var i = api.input;
if (i.justPressed('a') || i.justPressed('start') || i.justPressed('up')) { bird.vy = FLAP; api.sound.play('flap'); }
bird.vy += GRAV * dt;
if (bird.vy > TERM) bird.vy = TERM; // cap fall speed so it never plummets
bird.y += bird.vy * dt;
if (bird.y < 3) { bird.y = 3; bird.vy = 0; }
if (bird.y > GND - 4) { bird.y = GND - 4; return over(); }
spawnT -= dt;
if (spawnT <= 0) { addPipe(); spawnT = SPAWN; }
for (var p = pipes.length - 1; p >= 0; p--) {
var pi = pipes[p];
pi.x -= SPD * dt;
if (!pi.passed && pi.x + PIPEW < BIRDX) { pi.passed = true; score++; api.sound.play('score'); }
if (BIRDX + 4 > pi.x && BIRDX - 4 < pi.x + PIPEW &&
(bird.y - 4 < pi.gap || bird.y + 4 > pi.gap + GAP)) return over();
if (pi.x + PIPEW < -4) pipes.splice(p, 1);
}
}
return {
id: 'flappy', name: 'FLAPPY',
init: function (a) { api = a; hi = parseInt(api.storage.get(KEY, 0), 10) || 0; state = 'title'; },
reset: function () { state = 'title'; },
isPlaying: function () { return state === 'play'; },
tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
update: function (dt) {
var i = api.input;
if (state === 'title' || state === 'over') {
if (i.justPressed('a') || i.justPressed('start')) newGame();
return;
}
step(dt);
},
render: function (g) {
g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
g.fillStyle = PAL.d1; g.fillRect(0, GND, W, H - GND);
g.textBaseline = 'top';
if (state === 'title') {
g.textAlign = 'center';
g.font = 'bold 17px "Courier New", monospace'; _text(g, 'FLAPPY', W / 2, 40, PAL.d0);
g.font = '9px "Courier New", monospace';
_text(g, 'A = FLAP', W / 2, 78, PAL.d1);
_text(g, 'A = START', W / 2, 104, PAL.d1);
g.textAlign = 'left'; return;
}
g.fillStyle = PAL.d0;
for (var p = 0; p < pipes.length; p++) {
var pi = pipes[p];
g.fillRect(pi.x, 0, PIPEW, pi.gap);
g.fillRect(pi.x, pi.gap + GAP, PIPEW, GND - (pi.gap + GAP));
}
g.fillStyle = PAL.d0; g.fillRect(BIRDX - 4, bird.y - 4, 8, 8);
g.fillStyle = PAL.d3; g.fillRect(BIRDX, bird.y - 2, 2, 2); // eye
g.textAlign = 'center';
g.font = 'bold 14px "Courier New", monospace'; _text(g, '' + score, W / 2, 6, PAL.d0);
g.textAlign = 'left';
if (state === 'over') {
g.fillStyle = 'rgba(15,56,15,0.78)'; g.fillRect(8, 54, W - 16, 44);
g.textAlign = 'center';
g.font = 'bold 13px "Courier New", monospace'; _text(g, 'GAME OVER', W / 2, 62, PAL.d3);
g.font = '9px "Courier New", monospace'; _text(g, 'SCORE ' + score + ' A=RETRY', W / 2, 82, PAL.d2);
g.textAlign = 'left';
}
}
};
}
// ── PONG (vs CPU) ───────────────────────────────────────────────────────────
function makePong() {
var W = 160, H = 144, HUD = 12, PW = 3, PH = 24, BALL = 4;
var PXL = 6, PXR = W - 6 - PW;
var KEY = 'contraband.pong.hiscore';
var api = null, state = 'title', hi = 0, justHigh = false;
var py, ay, ball, spd, score;
function serve(dir) { ball = { x: W / 2, y: H / 2, vx: spd * dir, vy: spd * (Math.random() * 0.6 - 0.3) }; }
function newGame() { py = (H - PH) / 2; ay = (H - PH) / 2; spd = 0.10; score = 0; justHigh = false; serve(Math.random() < 0.5 ? -1 : 1); state = 'play'; }
function over() { state = 'over'; api.sound.play('over'); api.haptic.hit(); if (score > hi) { hi = score; api.storage.set(KEY, hi); justHigh = true; } }
function step(dt) {
var i = api.input, pv = 0.16 * dt;
if (i.isDown('up')) py -= pv;
if (i.isDown('down')) py += pv;
if (py < HUD) py = HUD; if (py > H - PH) py = H - PH;
var cv = 0.108 * dt; // CPU slightly slower than max ball speed → it can miss
if (ay + PH / 2 < ball.y - 2) ay += cv; else if (ay + PH / 2 > ball.y + 2) ay -= cv;
if (ay < HUD) ay = HUD; if (ay > H - PH) ay = H - PH;
var dist = Math.max(Math.abs(ball.vx), Math.abs(ball.vy)) * dt, steps = Math.max(1, Math.ceil(dist / 2));
var sx = ball.vx * dt / steps, sy = ball.vy * dt / steps;
for (var s = 0; s < steps; s++) {
ball.x += sx; ball.y += sy;
if (ball.y < HUD) { ball.y = HUD; ball.vy = Math.abs(ball.vy); }
if (ball.y > H - BALL) { ball.y = H - BALL; ball.vy = -Math.abs(ball.vy); }
if (ball.vx < 0 && ball.x <= PXL + PW && ball.x >= PXL - 3 && ball.y + BALL >= py && ball.y <= py + PH) {
ball.x = PXL + PW; spd = Math.min(0.20, spd + 0.004); ball.vx = spd;
ball.vy = spd * ((ball.y - (py + PH / 2)) / (PH / 2)); api.sound.play('hit');
}
if (ball.vx > 0 && ball.x + BALL >= PXR && ball.x <= PXR + PW && ball.y + BALL >= ay && ball.y <= ay + PH) {
ball.x = PXR - BALL; ball.vx = -spd;
ball.vy = spd * ((ball.y - (ay + PH / 2)) / (PH / 2)); api.sound.play('hit');
}
if (ball.x < -2) return over();
if (ball.x > W + 2) { score++; api.sound.play('score'); serve(-1); return; }
}
}
return {
id: 'pong', name: 'PONG',
init: function (a) { api = a; hi = parseInt(api.storage.get(KEY, 0), 10) || 0; state = 'title'; },
reset: function () { state = 'title'; },
isPlaying: function () { return state === 'play'; },
tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
update: function (dt) { var i = api.input; if (state === 'title' || state === 'over') { if (i.justPressed('a') || i.justPressed('start')) newGame(); return; } step(dt); },
render: function (g) {
g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
g.font = '8px "Courier New", monospace'; g.textBaseline = 'top';
_text(g, 'SC ' + _pad(state === 'title' ? 0 : score, 3), 3, 3, PAL.d0);
_text(g, 'HI ' + _pad(hi, 3), W - 50, 3, PAL.d0);
g.fillStyle = PAL.d1; g.fillRect(0, HUD - 2, W, 1);
for (var yy = HUD + 2; yy < H; yy += 8) { g.fillStyle = PAL.d2; g.fillRect(W / 2 - 1, yy, 2, 4); }
if (state === 'title') {
g.textAlign = 'center'; g.font = 'bold 20px "Courier New", monospace'; _text(g, 'PONG', W / 2, 44, PAL.d0);
g.font = '9px "Courier New", monospace'; _text(g, 'UP / DOWN', W / 2, 82, PAL.d1); _text(g, 'A = START', W / 2, 104, PAL.d1);
g.textAlign = 'left'; return;
}
g.fillStyle = PAL.d0;
g.fillRect(PXL, py, PW, PH); g.fillRect(PXR, ay, PW, PH); g.fillRect(ball.x, ball.y, BALL, BALL);
if (state === 'over') {
g.fillStyle = 'rgba(15,56,15,0.72)'; g.fillRect(8, 54, W - 16, 44);
g.textAlign = 'center'; g.font = 'bold 13px "Courier New", monospace'; _text(g, 'GAME OVER', W / 2, 62, PAL.d3);
g.font = '9px "Courier New", monospace'; _text(g, 'A = RETRY', W / 2, 82, PAL.d2); g.textAlign = 'left';
}
}
};
}
// ── SIMON (memory) ──────────────────────────────────────────────────────────
function makeSimon() {
var W = 160, H = 144, HUD = 12;
var KEY = 'contraband.simon.hiscore';
var api = null, state = 'title', hi = 0, justHigh = false;
var seq = [], showIdx = 0, showOn = false, timer = 0, inIdx = 0, score = 0, activePad = -1, flashT = 0;
var PADS = [
{ x: W / 2 - 24, y: HUD + 3, w: 48, h: 30, dir: 'up' },
{ x: W - 46, y: HUD + 38, w: 40, h: 30, dir: 'right' },
{ x: W / 2 - 24, y: H - 33, w: 48, h: 30, dir: 'down' },
{ x: 6, y: HUD + 38, w: 40, h: 30, dir: 'left' }
];
var SND = ['flap', 'score', 'hit', 'launch']; // 4 distinct pitches
function padOf(name) { for (var i = 0; i < PADS.length; i++) if (PADS[i].dir === name) return i; return -1; }
function flash(p) { activePad = p; flashT = 260; if (p >= 0) api.sound.play(SND[p]); }
function nextRound() { seq.push((Math.random() * 4) | 0); state = 'show'; showIdx = 0; showOn = false; timer = 450; }
function newGame() { seq = []; score = 0; justHigh = false; nextRound(); }
function over() { state = 'over'; api.sound.play('over'); api.haptic.hit(); if (score > hi) { hi = score; api.storage.set(KEY, hi); justHigh = true; } }
return {
id: 'simon', name: 'SIMON',
init: function (a) { api = a; hi = parseInt(api.storage.get(KEY, 0), 10) || 0; state = 'title'; },
reset: function () { state = 'title'; },
isPlaying: function () { return state !== 'title' && state !== 'over'; },
tookHigh: function () { if (justHigh) { justHigh = false; return score; } return -1; },
update: function (dt) {
var i = api.input;
if (flashT > 0) { flashT -= dt; if (flashT <= 0) activePad = -1; }
if (state === 'title' || state === 'over') { if (i.justPressed('a') || i.justPressed('start')) newGame(); return; }
if (state === 'show') {
timer -= dt;
if (timer <= 0) {
if (!showOn) { flash(seq[showIdx]); showOn = true; timer = 300; }
else { activePad = -1; showOn = false; showIdx++; timer = 170; if (showIdx >= seq.length) { state = 'input'; inIdx = 0; } }
}
return;
}
if (state === 'wait') { timer -= dt; if (timer <= 0) nextRound(); return; }
var p = -1;
if (i.justPressed('up')) p = padOf('up');
else if (i.justPressed('right')) p = padOf('right');
else if (i.justPressed('down')) p = padOf('down');
else if (i.justPressed('left')) p = padOf('left');
if (p >= 0) {
flash(p);
if (p === seq[inIdx]) {
inIdx++;
if (inIdx >= seq.length) { score = seq.length; api.sound.play('confirm'); state = 'wait'; timer = 520; }
} else over();
}
},
render: function (g) {
g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
g.font = '8px "Courier New", monospace'; g.textBaseline = 'top';
_text(g, 'RND ' + _pad(state === 'title' ? 0 : seq.length, 2), 3, 3, PAL.d0);
_text(g, 'HI ' + _pad(hi, 2), W - 42, 3, PAL.d0);
g.fillStyle = PAL.d1; g.fillRect(0, HUD - 2, W, 1);
if (state === 'title') {
g.textAlign = 'center'; g.font = 'bold 18px "Courier New", monospace'; _text(g, 'SIMON', W / 2, 44, PAL.d0);
g.font = '9px "Courier New", monospace'; _text(g, 'REPEAT THE PATTERN', W / 2, 80, PAL.d1); _text(g, 'A = START', W / 2, 104, PAL.d1);
g.textAlign = 'left'; return;
}
for (var k = 0; k < PADS.length; k++) {
var pd = PADS[k];
g.fillStyle = (k === activePad) ? PAL.d3 : (k % 2 ? PAL.d1 : PAL.d0);
g.fillRect(pd.x, pd.y, pd.w, pd.h);
g.fillStyle = (k === activePad) ? PAL.d0 : PAL.d2;
g.fillRect(pd.x + 2, pd.y + 2, pd.w - 4, pd.h - 4);
}
g.textAlign = 'center'; g.textBaseline = 'middle'; g.font = '8px "Courier New", monospace';
var m = state === 'show' ? 'WATCH' : (state === 'input' ? 'YOUR TURN' : (state === 'wait' ? 'GOOD!' : ''));
_text(g, m, W / 2, H / 2 + 2, PAL.d0);
g.textAlign = 'left'; g.textBaseline = 'top';
if (state === 'over') {
g.fillStyle = 'rgba(15,56,15,0.72)'; g.fillRect(8, 54, W - 16, 44);
g.textAlign = 'center'; g.font = 'bold 13px "Courier New", monospace'; _text(g, 'GAME OVER', W / 2, 62, PAL.d3);
g.font = '9px "Courier New", monospace'; _text(g, 'A = RETRY', W / 2, 82, PAL.d2); g.textAlign = 'left';
}
}
};
}
// Cartridge registry — Snake first (default menu selection).
// ══════════════════════════════════════════════ emulator core (binjgb) ══
// binjgb by Ben Smith (MIT, https://github.com/binji/binjgb), compiled to WASM
// with -sDYNAMIC_EXECUTION=0 (CSP-safe) and beautified (non-minified). The wasm
// itself is fetched at runtime from jsDelivr and SHA-256-verified (see makeEmulator).
var Binjgb = (() => {
var _scriptName = globalThis.document?.currentScript?.src;
return async function(moduleArg = {}) {
var Module = moduleArg;
var ENVIRONMENT_IS_WEB = true;
var ENVIRONMENT_IS_WORKER = false;
var programArgs = [];
var thisProgram = "./this.program";
var quit_ = (status, toThrow) => {
throw toThrow
};
var scriptDirectory = "";
function locateFile(path) {
if (Module["locateFile"]) {
return Module["locateFile"](path, scriptDirectory)
}
return scriptDirectory + path
}
var readAsync, readBinary;
if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) {
try {
scriptDirectory = new URL(".", _scriptName).href
} catch {} {
readAsync = async url => {
var response = await fetch(url, {
credentials: "same-origin"
});
if (response.ok) {
return response.arrayBuffer()
}
throw new Error(response.status + " : " + response.url)
}
}
} else {}
var out = console.log.bind(console);
var err = console.error.bind(console);
var wasmBinary;
var ABORT = false;
var EXITSTATUS;
class EmscriptenEH {}
class EmscriptenSjLj extends EmscriptenEH {}
var runtimeInitialized = false;
function getMemoryBuffer() {
try {
var b = wasmMemory.toResizableBuffer();
return b
} catch {}
return wasmMemory.buffer
}
function updateMemoryViews() {
if (HEAP8?.buffer?.resizable) return;
var b = getMemoryBuffer();
Module["HEAP8"] = HEAP8 = new Int8Array(b);
Module["HEAP16"] = HEAP16 = new Int16Array(b);
Module["HEAPU8"] = HEAPU8 = new Uint8Array(b);
Module["HEAPU16"] = HEAPU16 = new Uint16Array(b);
Module["HEAP32"] = HEAP32 = new Int32Array(b);
Module["HEAPU32"] = HEAPU32 = new Uint32Array(b);
Module["HEAPF32"] = HEAPF32 = new Float32Array(b);
Module["HEAPF64"] = HEAPF64 = new Float64Array(b);
HEAP64 = new BigInt64Array(b);
HEAPU64 = new BigUint64Array(b)
}
function preRun() {
var preRun = Module["preRun"];
if (preRun) {
if (typeof preRun == "function") preRun = [preRun];
onPreRuns.push(...preRun)
}
callRuntimeCallbacks(onPreRuns)
}
function initRuntime() {
runtimeInitialized = true;
wasmExports["e"]()
}
function postRun() {
var postRun = Module["postRun"];
if (postRun) {
if (typeof postRun == "function") postRun = [postRun];
onPostRuns.push(...postRun)
}
callRuntimeCallbacks(onPostRuns)
}
function abort(what) {
Module["onAbort"]?.(what);
what = `Aborted(${what})`;
err(what);
ABORT = true;
what += ". Build with -sASSERTIONS for more info.";
var e = new WebAssembly.RuntimeError(what);
throw e
}
var wasmBinaryFile;
function findWasmBinary() {
return locateFile("binjgb.wasm")
}
function getBinarySync(file) {
if (readBinary) {
return readBinary(file)
}
throw "both async and sync fetching of the wasm failed"
}
async function getWasmBinary(binaryFile) {
if (!wasmBinary) {
try {
var response = await readAsync(binaryFile);
return new Uint8Array(response)
} catch {}
}
return getBinarySync(binaryFile)
}
async function instantiateArrayBuffer(binaryFile, imports) {
try {
var binary = await getWasmBinary(binaryFile);
var instance = await WebAssembly.instantiate(binary, imports);
return instance
} catch (reason) {
err(`failed to asynchronously prepare wasm: ${reason}`);
abort(reason)
}
}
async function instantiateAsync(binary, binaryFile, imports) {
if (!binary) {
try {
var response = fetch(binaryFile, {
credentials: "same-origin"
});
var instantiationResult = await WebAssembly.instantiateStreaming(response, imports);
return instantiationResult
} catch (reason) {
err(`wasm streaming compile failed: ${reason}`);
err("falling back to ArrayBuffer instantiation")
}
}
return instantiateArrayBuffer(binaryFile, imports)
}
function getWasmImports() {
var imports = {
a: wasmImports
};
return imports
}
async function createWasm() {
function receiveInstance(instance) {
wasmExports = instance.exports;
assignWasmExports(wasmExports);
updateMemoryViews();
return wasmExports
}
function receiveInstantiationResult(result) {
return receiveInstance(result["instance"])
}
var info = getWasmImports();
var instantiateWasm = Module["instantiateWasm"];
if (instantiateWasm) {
return new Promise(resolve => {
instantiateWasm(info, inst => resolve(receiveInstance(inst)))
})
}
wasmBinaryFile ??= findWasmBinary();
var result = await instantiateAsync(wasmBinary, wasmBinaryFile, info);
var exports = receiveInstantiationResult(result);
return exports
}
class ExitStatus {
name = "ExitStatus";
constructor(status) {
this.message = `Program terminated with exit(${status})`;
this.status = status
}
}
var HEAP16;
var HEAP32;
var HEAP64;
var HEAP8;
var HEAPF32;
var HEAPF64;
var HEAPU16;
var HEAPU32;
var HEAPU64;
var HEAPU8;
var callRuntimeCallbacks = callbacks => {
while (callbacks.length > 0) {
callbacks.shift()(Module)
}
};
var onPostRuns = [];
var onPreRuns = [];
var noExitRuntime = true;
var getHeapMax = () => 2147483648;
var alignMemory = (size, alignment) => Math.ceil(size / alignment) * alignment;
var growMemory = size => {
var oldHeapSize = wasmMemory.buffer.byteLength;
var pages = (size - oldHeapSize + 65535) / 65536 | 0;
try {
wasmMemory.grow(pages);
updateMemoryViews();
return 1
} catch (e) {}
};
var _emscripten_resize_heap = requestedSize => {
var oldSize = HEAPU8.length;
requestedSize >>>= 0;
var maxHeapSize = getHeapMax();
if (requestedSize > maxHeapSize) {
return false
}
for (var cutDown = 1; cutDown <= 4; cutDown *= 2) {
var overGrownHeapSize = oldSize * (1 + .2 / cutDown);
overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296);
var newSize = Math.min(maxHeapSize, alignMemory(Math.max(requestedSize, overGrownHeapSize), 65536));
var replacement = growMemory(newSize);
if (replacement) {
return true
}
}
return false
};
var runtimeKeepaliveCounter = 0;
var keepRuntimeAlive = () => noExitRuntime || runtimeKeepaliveCounter > 0;
var _proc_exit = code => {
EXITSTATUS = code;
if (!keepRuntimeAlive()) {
Module["onExit"]?.(code);
ABORT = true
}
quit_(code, new ExitStatus(code))
};
var exitJS = (status, implicit) => {
EXITSTATUS = status;
_proc_exit(status)
};
var _exit = exitJS;
var printCharBuffers = [null, [],
[]
];
var UTF8Decoder = globalThis.TextDecoder && new TextDecoder;
var findStringEnd = (heapOrArray, idx, maxBytesToRead, ignoreNul) => {
var maxIdx = idx + maxBytesToRead;
if (ignoreNul) return maxIdx;
while (heapOrArray[idx] && !(idx >= maxIdx)) ++idx;
return idx
};
var UTF8ArrayToString = (heapOrArray, idx = 0, maxBytesToRead, ignoreNul) => {
var endPtr = findStringEnd(heapOrArray, idx, maxBytesToRead, ignoreNul);
if (endPtr - idx > 16 && heapOrArray.buffer && UTF8Decoder) {
return UTF8Decoder.decode(heapOrArray.subarray(idx, endPtr))
}
var str = "";
while (idx < endPtr) {
var u0 = heapOrArray[idx++];
if (!(u0 & 128)) {
str += String.fromCharCode(u0);
continue
}
var u1 = heapOrArray[idx++] & 63;
if ((u0 & 224) == 192) {
str += String.fromCharCode((u0 & 31) << 6 | u1);
continue
}
var u2 = heapOrArray[idx++] & 63;
if ((u0 & 240) == 224) {
u0 = (u0 & 15) << 12 | u1 << 6 | u2
} else {
u0 = (u0 & 7) << 18 | u1 << 12 | u2 << 6 | heapOrArray[idx++] & 63
}
if (u0 < 65536) {
str += String.fromCharCode(u0)
} else {
var ch = u0 - 65536;
str += String.fromCharCode(55296 | ch >> 10, 56320 | ch & 1023)
}
}
return str
};
var printChar = (stream, curr) => {
var buffer = printCharBuffers[stream];
if (curr === 0 || curr === 10) {
(stream === 1 ? out : err)(UTF8ArrayToString(buffer));
buffer.length = 0
} else {
buffer.push(curr)
}
};
var _fd_write = (fd, iov, iovcnt, pnum) => {
var num = 0;
for (var i = 0; i < iovcnt; i++) {
var ptr = HEAPU32[iov >> 2];
var len = HEAPU32[iov + 4 >> 2];
iov += 8;
for (var j = 0; j < len; j++) {
printChar(fd, HEAPU8[ptr + j])
}
num += len
}
HEAPU32[pnum >> 2] = num;
return 0
};
{
if (Module["noExitRuntime"]) noExitRuntime = Module["noExitRuntime"];
if (Module["print"]) out = Module["print"];
if (Module["printErr"]) err = Module["printErr"];
if (Module["arguments"]) programArgs = Module["arguments"];
if (Module["thisProgram"]) thisProgram = Module["thisProgram"];
var preInit = Module["preInit"];
if (preInit) {
if (typeof preInit == "function") Module["preInit"] = preInit = [preInit];
while (preInit.length > 0) {
preInit.shift()()
}
}
}
var _malloc, _file_data_delete, _free, _emulator_set_builtin_palette, _emulator_was_ext_ram_updated, _emulator_read_state, _emulator_write_state, _emulator_read_ext_ram, _emulator_write_ext_ram, _emulator_delete, _emulator_get_PC, _emulator_get_A, _emulator_get_BC, _emulator_get_DE, _emulator_get_HL, _emulator_get_F, _emulator_get_SP, _emulator_set_PC, _emulator_get_wram_ptr, _emulator_get_hram_ptr, _emulator_read_mem, _emulator_write_mem, _emulator_set_breakpoint, _emulator_clear_breakpoints, _emulator_render_vram, _emulator_render_background, _emulator_get_banked_PC, _set_audio_channel_mute, _joypad_new, _joypad_delete, _rewind_append, _rewind_delete, _emulator_new_simple, _emulator_get_ticks_f64, _emulator_run_until_f64, _rewind_get_newest_ticks_f64, _rewind_get_oldest_ticks_f64, _emulator_set_default_joypad_callback, _emulator_set_bw_palette_simple, _rewind_new_simple, _rewind_begin, _emulator_set_rewind_joypad_callback, _rewind_to_ticks_wrapper, _rewind_end, _set_joyp_up, _set_joyp_down, _set_joyp_left, _set_joyp_right, _set_joyp_B, _set_joyp_A, _set_joyp_start, _set_joyp_select, _get_frame_buffer_ptr, _get_frame_buffer_size, _get_sgb_frame_buffer_ptr, _get_sgb_frame_buffer_size, _get_audio_buffer_ptr, _get_audio_buffer_capacity, _ext_ram_file_data_new, _state_file_data_new, _get_file_data_ptr, _get_file_data_size, _set_log_apu_writes, _get_apu_log_data_size, _get_apu_log_data_ptr, _reset_apu_log, memory, __indirect_function_table, wasmMemory;
function assignWasmExports(wasmExports) {
_malloc = Module["_malloc"] = wasmExports["f"];
_file_data_delete = Module["_file_data_delete"] = wasmExports["g"];
_free = Module["_free"] = wasmExports["h"];
_emulator_set_builtin_palette = Module["_emulator_set_builtin_palette"] = wasmExports["i"];
_emulator_was_ext_ram_updated = Module["_emulator_was_ext_ram_updated"] = wasmExports["j"];
_emulator_read_state = Module["_emulator_read_state"] = wasmExports["k"];
_emulator_write_state = Module["_emulator_write_state"] = wasmExports["l"];
_emulator_read_ext_ram = Module["_emulator_read_ext_ram"] = wasmExports["m"];
_emulator_write_ext_ram = Module["_emulator_write_ext_ram"] = wasmExports["n"];
_emulator_delete = Module["_emulator_delete"] = wasmExports["o"];
_emulator_get_PC = Module["_emulator_get_PC"] = wasmExports["p"];
_emulator_get_A = Module["_emulator_get_A"] = wasmExports["q"];
_emulator_get_BC = Module["_emulator_get_BC"] = wasmExports["r"];
_emulator_get_DE = Module["_emulator_get_DE"] = wasmExports["s"];
_emulator_get_HL = Module["_emulator_get_HL"] = wasmExports["t"];
_emulator_get_F = Module["_emulator_get_F"] = wasmExports["u"];
_emulator_get_SP = Module["_emulator_get_SP"] = wasmExports["v"];
_emulator_set_PC = Module["_emulator_set_PC"] = wasmExports["w"];
_emulator_get_wram_ptr = Module["_emulator_get_wram_ptr"] = wasmExports["x"];
_emulator_get_hram_ptr = Module["_emulator_get_hram_ptr"] = wasmExports["y"];
_emulator_read_mem = Module["_emulator_read_mem"] = wasmExports["z"];
_emulator_write_mem = Module["_emulator_write_mem"] = wasmExports["A"];
_emulator_set_breakpoint = Module["_emulator_set_breakpoint"] = wasmExports["B"];
_emulator_clear_breakpoints = Module["_emulator_clear_breakpoints"] = wasmExports["C"];
_emulator_render_vram = Module["_emulator_render_vram"] = wasmExports["D"];
_emulator_render_background = Module["_emulator_render_background"] = wasmExports["E"];
_emulator_get_banked_PC = Module["_emulator_get_banked_PC"] = wasmExports["F"];
_set_audio_channel_mute = Module["_set_audio_channel_mute"] = wasmExports["G"];
_joypad_new = Module["_joypad_new"] = wasmExports["H"];
_joypad_delete = Module["_joypad_delete"] = wasmExports["I"];
_rewind_append = Module["_rewind_append"] = wasmExports["J"];
_rewind_delete = Module["_rewind_delete"] = wasmExports["K"];
_emulator_new_simple = Module["_emulator_new_simple"] = wasmExports["L"];
_emulator_get_ticks_f64 = Module["_emulator_get_ticks_f64"] = wasmExports["M"];
_emulator_run_until_f64 = Module["_emulator_run_until_f64"] = wasmExports["N"];
_rewind_get_newest_ticks_f64 = Module["_rewind_get_newest_ticks_f64"] = wasmExports["O"];
_rewind_get_oldest_ticks_f64 = Module["_rewind_get_oldest_ticks_f64"] = wasmExports["P"];
_emulator_set_default_joypad_callback = Module["_emulator_set_default_joypad_callback"] = wasmExports["Q"];
_emulator_set_bw_palette_simple = Module["_emulator_set_bw_palette_simple"] = wasmExports["R"];
_rewind_new_simple = Module["_rewind_new_simple"] = wasmExports["S"];
_rewind_begin = Module["_rewind_begin"] = wasmExports["T"];
_emulator_set_rewind_joypad_callback = Module["_emulator_set_rewind_joypad_callback"] = wasmExports["U"];
_rewind_to_ticks_wrapper = Module["_rewind_to_ticks_wrapper"] = wasmExports["V"];
_rewind_end = Module["_rewind_end"] = wasmExports["W"];
_set_joyp_up = Module["_set_joyp_up"] = wasmExports["X"];
_set_joyp_down = Module["_set_joyp_down"] = wasmExports["Y"];
_set_joyp_left = Module["_set_joyp_left"] = wasmExports["Z"];
_set_joyp_right = Module["_set_joyp_right"] = wasmExports["_"];
_set_joyp_B = Module["_set_joyp_B"] = wasmExports["$"];
_set_joyp_A = Module["_set_joyp_A"] = wasmExports["aa"];
_set_joyp_start = Module["_set_joyp_start"] = wasmExports["ba"];
_set_joyp_select = Module["_set_joyp_select"] = wasmExports["ca"];
_get_frame_buffer_ptr = Module["_get_frame_buffer_ptr"] = wasmExports["da"];
_get_frame_buffer_size = Module["_get_frame_buffer_size"] = wasmExports["ea"];
_get_sgb_frame_buffer_ptr = Module["_get_sgb_frame_buffer_ptr"] = wasmExports["fa"];
_get_sgb_frame_buffer_size = Module["_get_sgb_frame_buffer_size"] = wasmExports["ga"];
_get_audio_buffer_ptr = Module["_get_audio_buffer_ptr"] = wasmExports["ha"];
_get_audio_buffer_capacity = Module["_get_audio_buffer_capacity"] = wasmExports["ia"];
_ext_ram_file_data_new = Module["_ext_ram_file_data_new"] = wasmExports["ja"];
_state_file_data_new = Module["_state_file_data_new"] = wasmExports["ka"];
_get_file_data_ptr = Module["_get_file_data_ptr"] = wasmExports["la"];
_get_file_data_size = Module["_get_file_data_size"] = wasmExports["ma"];
_set_log_apu_writes = Module["_set_log_apu_writes"] = wasmExports["na"];
_get_apu_log_data_size = Module["_get_apu_log_data_size"] = wasmExports["oa"];
_get_apu_log_data_ptr = Module["_get_apu_log_data_ptr"] = wasmExports["pa"];
_reset_apu_log = Module["_reset_apu_log"] = wasmExports["qa"];
memory = wasmMemory = wasmExports["d"];
__indirect_function_table = wasmExports["__indirect_function_table"]
}
var wasmImports = {
c: _emscripten_resize_heap,
b: _exit,
a: _fd_write
};
async function run() {
preRun();
var setStatus = Module["setStatus"];
if (setStatus) {
setStatus("Running...");
await new Promise(resolve => setTimeout(resolve, 1));
setTimeout(setStatus, 1, "")
}
if (ABORT) return;
initRuntime();
Module["onRuntimeInitialized"]?.();
postRun()
}
var wasmExports;
wasmExports = await createWasm();
await run();;
return Module
}
})();
if (typeof exports === "object" && typeof module === "object") {
module.exports = Binjgb;
module.exports.default = Binjgb
} else if (typeof define === "function" && define["amd"]) define([], () => Binjgb);
// ─────────────────────────────────────────── emulator cartridge (binjgb) ──
// An 8th "cartridge" that runs a real Game Boy / Game Boy Color ROM on the
// binjgb WASM core above. The core JS is inline (beautified, non-minified);
// the wasm itself is fetched once from a version-pinned jsDelivr URL, checked
// against a pinned SHA-256, and cached in GM storage for offline replay.
// Bring your own .gb/.gbc — the last ROM you load is remembered across flights.
// Exit back to the game menu with START+SELECT (Select is a real GB button here).
function makeEmulator() {
var W = 160, H = 144;
var CPU_TICKS = 4194304, MAX_UPDATE_SEC = 5 / 60, AUDIO_FRAMES = 4096;
var EVENT_NEW_FRAME = 1, EVENT_AUDIO_BUFFER_FULL = 2, EVENT_UNTIL_TICKS = 4;
var WASM_URL = 'https://cdn.jsdelivr.net/gh/TheBreadHerring/[email protected]/binjgb.wasm';
var WASM_SHA256 = '2613765623da527ee1525ec326835df8e5cc608f46a51f4c1604484fcc50606b';
var WASM_CACHE_KEY = 'contraband.emu.wasm.' + WASM_SHA256;
var ROM_DATA_KEY = 'contraband.emu.rom.data';
var ROM_NAME_KEY = 'contraband.emu.rom.name';
var INDEX_KEY = 'contraband.emu.index';
var SPEED_KEY = 'contraband.emu.speed';
var factory = (typeof Binjgb === 'function') ? Binjgb : (typeof window !== 'undefined' ? window.Binjgb : null);
var api = null, mod = null, e = 0, joypadPtr = 0;
var state = 'idle'; // idle | loading | needrom | running | menu | loadnew | restore | error
var status = 'BOOTING';
var imageData = null, fbPtr = 0, fbSize = 0;
var leftover = 0, romId = '', romKey = 'contraband.emu.default', romName = 'ROM', saveAcc = 0, pickEl = null;
var menuScreen = 'main', menuCursor = 0, comboLatch = false, speed = 1, palApplied = -1;
var audioCtx = null, audioGain = null, audioTime = 0, sampleRate = 44100, audioBufPtr = 0, audioBufCap = 0;
function setStatus(s) { status = s; }
// ── GM / byte helpers ──
function xhr() { try { if (typeof GM_xmlhttpRequest === 'function') return GM_xmlhttpRequest; } catch (x) {} try { if (window.GM && window.GM.xmlHttpRequest) return window.GM.xmlHttpRequest; } catch (x) {} return null; }
function fetchBinary(url, cb, err) {
var x = xhr(); if (!x) return err('no xhr');
x({ method: 'GET', url: url, responseType: 'arraybuffer', timeout: 30000,
onload: function (r) {
if (r.response && r.response.byteLength) return cb(r.response);
var s = r.responseText || '', u = new Uint8Array(s.length);
for (var k = 0; k < s.length; k++) u[k] = s.charCodeAt(k) & 0xff;
cb(u.buffer);
}, onerror: function () { err('failed'); }, ontimeout: function () { err('timeout'); } });
}
function u8ToB64(u8) { var s = '', C = 0x8000; for (var k = 0; k < u8.length; k += C) s += String.fromCharCode.apply(null, u8.subarray(k, k + C)); return btoa(s); }
function b64ToU8(b64) { var bin = atob(b64), n = bin.length, u8 = new Uint8Array(n); for (var k = 0; k < n; k++) u8[k] = bin.charCodeAt(k); return u8; }
function sha256hex(u8) {
try {
if (!(window.crypto && window.crypto.subtle)) return Promise.resolve(null);
var ab = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
return window.crypto.subtle.digest('SHA-256', ab).then(function (d) {
var a = new Uint8Array(d), h = ''; for (var k = 0; k < a.length; k++) h += (a[k] >> 4).toString(16) + (a[k] & 15).toString(16); return h;
}, function () { return null; });
} catch (x) { return Promise.resolve(null); }
}
function verifyAndUse(u8, cb) {
sha256hex(u8).then(function (hex) {
if (hex && hex !== WASM_SHA256) { state = 'error'; setStatus('WASM HASH BAD'); return; }
cb(u8);
});
}
// ── wasm load: cache -> pinned CDN (sha256-checked) -> cache ──
function loadWasm() {
var c = null; try { c = api.storage.get(WASM_CACHE_KEY, null); } catch (x) {}
if (c) { setStatus('LOADING CORE'); return verifyAndUse(b64ToU8(c), bootCore); }
setStatus('FETCHING CORE');
fetchBinary(WASM_URL, function (buf) {
var u8 = new Uint8Array(buf);
verifyAndUse(u8, function (v) { try { api.storage.set(WASM_CACHE_KEY, u8ToB64(v)); } catch (x) {} bootCore(v); });
}, function () { state = 'error'; setStatus('CORE FETCH FAIL'); });
}
function bootCore(bytes) {
setStatus('STARTING CORE');
factory({
wasmBinary: bytes,
instantiateWasm: function (imports, cb) {
try { WebAssembly.instantiate(bytes, imports).then(function (r) { cb(r.instance); }, function () { state = 'error'; setStatus('WASM ERR'); }); }
catch (er) { state = 'error'; setStatus('WASM ERR'); }
return {};
}
}).then(function (m) { mod = m; afterCore(); }, function () { state = 'error'; setStatus('CORE INIT FAIL'); });
}
function afterCore() {
var romB64 = null; try { romB64 = api.storage.get(ROM_DATA_KEY, null); } catch (x) {}
if (romB64) startRom(b64ToU8(romB64).buffer, api.storage.get(ROM_NAME_KEY, 'ROM'), false);
else { state = 'needrom'; setStatus('NO ROM'); openPicker('rom'); }
}
// ── audio (shared with the toy's AudioContext; interleaved-stereo u8) ──
function initAudio() {
try { api.sound.unlock(); audioCtx = (api.sound.context ? api.sound.context() : null); } catch (x) { audioCtx = null; }
sampleRate = (audioCtx && audioCtx.sampleRate) ? audioCtx.sampleRate : 44100;
if (audioCtx && !audioGain) { try { audioGain = audioCtx.createGain(); audioGain.gain.value = 0.5; audioGain.connect(audioCtx.destination); } catch (x) { audioGain = null; } }
audioTime = 0;
}
function pushAudio() {
if (!audioCtx || !audioGain || speed !== 1 || !api.sound.isEnabled()) return;
try {
if (audioCtx.state === 'suspended') audioCtx.resume();
var ab = audioCtx.createBuffer(2, AUDIO_FRAMES, sampleRate);
var c0 = ab.getChannelData(0), c1 = ab.getChannelData(1);
var src = new Uint8Array(mod.HEAP8.buffer, audioBufPtr, audioBufCap);
for (var i = 0; i < AUDIO_FRAMES; i++) { c0[i] = src[2 * i] / 255; c1[i] = src[2 * i + 1] / 255; }
var node = audioCtx.createBufferSource();
node.buffer = ab; node.connect(audioGain);
var now = audioCtx.currentTime;
if (audioTime < now + 0.01) audioTime = now + 0.02; // fell behind -> resync
node.start(audioTime);
audioTime += AUDIO_FRAMES / sampleRate;
} catch (x) {}
}
// ── DMG palette: map the toy's PAL (dark d0 -> light d3) to binjgb's BW palette ──
function hexToU32(hex) {
var h = hex.charAt(0) === '#' ? hex.slice(1) : hex;
var r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
return ((0xff << 24) | (b << 16) | (g << 8) | r) >>> 0;
}
function applyEmuPalette() {
if (!mod || !e) return;
try {
var w = hexToU32(PAL.d3), lg = hexToU32(PAL.d2), dg = hexToU32(PAL.d1), bk = hexToU32(PAL.d0);
for (var t = 0; t < 3; t++) mod._emulator_set_bw_palette_simple(e, t, w, lg, dg, bk); // BGP / OBP0 / OBP1
} catch (x) {}
}
// ── ROM lifecycle ──
function hashRom(u8) { var h = 5381; for (var k = 0; k < u8.length; k += 997) h = ((h << 5) + h + u8[k]) | 0; return (h >>> 0).toString(36) + '_' + u8.length; }
function startRom(buffer, name, remember) {
try {
stopEmu();
var rom = new Uint8Array(buffer);
romId = hashRom(rom);
romKey = 'contraband.emu.' + romId + '.sram';
romName = name || 'ROM';
initAudio(); // context sample rate -> correct audio pitch
var size = (rom.byteLength + 0x7fff) & ~0x7fff;
var romPtr = mod._malloc(size);
new Uint8Array(mod.HEAP8.buffer, romPtr, size).fill(0).set(rom);
e = mod._emulator_new_simple(romPtr, size, sampleRate, AUDIO_FRAMES, 2);
if (e === 0) { state = 'needrom'; setStatus('BAD ROM'); openPicker('rom'); return; }
joypadPtr = mod._joypad_new();
mod._emulator_set_default_joypad_callback(e, joypadPtr);
audioBufPtr = mod._get_audio_buffer_ptr(e); audioBufCap = mod._get_audio_buffer_capacity(e);
applyEmuPalette(); palApplied = FX.palette;
if (!imageData) imageData = document.createElement('canvas').getContext('2d').createImageData(W, H);
fbPtr = mod._get_frame_buffer_ptr(e); fbSize = mod._get_frame_buffer_size(e);
loadSram();
if (remember !== false) { try { api.storage.set(ROM_DATA_KEY, u8ToB64(rom)); api.storage.set(ROM_NAME_KEY, romName); } catch (x) {} }
addRomToIndex(romId, romName);
leftover = 0; saveAcc = 0; state = 'running'; setStatus('');
removePicker();
} catch (er) { state = 'error'; setStatus('ROM ERR'); }
}
function stopEmu() {
if (mod && e) { try { if (mod._emulator_was_ext_ram_updated(e)) saveSram(); } catch (x) {} try { mod._emulator_delete(e); } catch (x) {} }
if (mod && joypadPtr) { try { mod._joypad_delete(joypadPtr); } catch (x) {} }
e = 0; joypadPtr = 0;
}
function saveSram() {
try {
var fdp = mod._ext_ram_file_data_new(e);
var ptr = mod._get_file_data_ptr(fdp), sz = mod._get_file_data_size(fdp);
mod._emulator_write_ext_ram(e, fdp);
var bytes = Array.prototype.slice.call(new Uint8Array(mod.HEAP8.buffer, ptr, sz));
mod._file_data_delete(fdp);
api.storage.set(romKey, bytes);
} catch (x) {}
}
function loadSram() {
try {
var saved = api.storage.get(romKey, null); if (!saved || !saved.length) return;
var fdp = mod._ext_ram_file_data_new(e);
var ptr = mod._get_file_data_ptr(fdp), sz = mod._get_file_data_size(fdp);
if (sz === saved.length) { new Uint8Array(mod.HEAP8.buffer, ptr, sz).set(saved); mod._emulator_read_ext_ram(e, fdp); }
mod._file_data_delete(fdp);
} catch (x) {}
}
// ── save states (3 slots per ROM) ──
function stateKey(slot) { return 'contraband.emu.' + romId + '.st' + slot; }
function slotUsed(slot) { try { var v = api.storage.get(stateKey(slot), null); return !!(v && v.length); } catch (x) { return false; } }
function saveState(slot) {
if (!mod || !e) return;
try {
var fdp = mod._state_file_data_new(e);
mod._emulator_write_state(e, fdp);
var ptr = mod._get_file_data_ptr(fdp), sz = mod._get_file_data_size(fdp);
var bytes = Array.prototype.slice.call(new Uint8Array(mod.HEAP8.buffer, ptr, sz));
mod._file_data_delete(fdp);
api.storage.set(stateKey(slot), bytes);
setStatus('SAVED SLOT ' + slot);
} catch (x) { setStatus('SAVE FAILED'); }
}
function loadState(slot) {
if (!mod || !e) return false;
try {
var saved = api.storage.get(stateKey(slot), null);
if (!saved || !saved.length) { setStatus('SLOT ' + slot + ' EMPTY'); return false; }
var fdp = mod._state_file_data_new(e);
var ptr = mod._get_file_data_ptr(fdp), sz = mod._get_file_data_size(fdp), ok = false;
if (sz === saved.length) { new Uint8Array(mod.HEAP8.buffer, ptr, sz).set(saved); mod._emulator_read_state(e, fdp); ok = true; }
mod._file_data_delete(fdp);
setStatus(ok ? 'LOADED SLOT ' + slot : 'SLOT ' + slot + ' MISMATCH');
return ok;
} catch (x) { setStatus('LOAD FAILED'); return false; }
}
// ── ROM index (GMforPDA has no listValues, so track keys ourselves) ──
function getIndex() { try { var v = api.storage.get(INDEX_KEY, null); return (v && v.length) ? v : []; } catch (x) { return []; } }
function addRomToIndex(id, name) {
try {
var idx = getIndex(), found = false;
for (var k = 0; k < idx.length; k++) if (idx[k].id === id) { idx[k].name = name; found = true; break; }
if (!found) idx.push({ id: id, name: name });
api.storage.set(INDEX_KEY, idx);
} catch (x) {}
}
// ── backup / restore: SRAM + save states for every known ROM (not the ROM or wasm) ──
function buildBackup() {
var idx = getIndex(), data = {};
for (var k = 0; k < idx.length; k++) {
var id = idx[k].id;
var sram = api.storage.get('contraband.emu.' + id + '.sram', null);
if (sram && sram.length) data['contraband.emu.' + id + '.sram'] = sram;
for (var s = 1; s <= 3; s++) {
var st = api.storage.get('contraband.emu.' + id + '.st' + s, null);
if (st && st.length) data['contraband.emu.' + id + '.st' + s] = st;
}
}
return JSON.stringify({ app: 'contraband', v: 1, when: Date.now(), roms: idx, data: data });
}
function downloadFile(name, text) {
var blob = new Blob([text], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a'); a.href = url; a.download = name; a.rel = 'noopener';
(document.body || document.documentElement).appendChild(a); a.click();
setTimeout(function () { try { a.parentNode && a.parentNode.removeChild(a); URL.revokeObjectURL(url); } catch (x) {} }, 1500);
}
function exportBackup() {
var json = buildBackup(), ok = [];
try { downloadFile('contraband-saves.json', json); ok.push('FILE'); } catch (x) {}
try { if (json.length < 300000 && typeof GM_setClipboard === 'function') { GM_setClipboard(json); ok.push('CLIP'); } } catch (x) {}
setStatus(ok.length ? 'BACKUP: ' + ok.join('+') : 'BACKUP FAILED');
}
function importBackup(text) {
try {
var obj = JSON.parse(text);
if (!obj || obj.app !== 'contraband' || !obj.data) { setStatus('BAD BACKUP FILE'); return; }
var n = 0, key;
for (key in obj.data) { if (obj.data.hasOwnProperty(key) && key.indexOf('contraband.emu.') === 0 && key.indexOf('.wasm.') < 0 && key.indexOf('.rom.') < 0) { api.storage.set(key, obj.data[key]); n++; } }
if (obj.roms && obj.roms.length) {
var idx = getIndex();
for (var k = 0; k < obj.roms.length; k++) { var r = obj.roms[k], f = false; for (var j = 0; j < idx.length; j++) if (idx[j].id === r.id) { f = true; break; } if (!f) idx.push(r); }
api.storage.set(INDEX_KEY, idx);
}
setStatus('RESTORED ' + n + ' SAVES');
if (mod && e && romId) loadSram();
} catch (x) { setStatus('RESTORE FAILED'); }
}
// ── file picker overlay (mode 'rom' | 'backup'); tap the LCD to load ──
function openPicker(mode) {
if (pickEl) return;
var root = document.getElementById(ROOT_ID); if (!root) return;
var scr = root.querySelector('.cb-screen');
var host = (scr && scr.parentNode) ? scr.parentNode : root;
try { if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; } catch (x) {}
var inp = document.createElement('input');
inp.type = 'file';
inp.accept = (mode === 'backup') ? '.json,application/json,*/*' : '.gb,.gbc,application/octet-stream';
inp.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;opacity:0;cursor:pointer;z-index:50;';
inp.addEventListener('change', function () {
var f = inp.files && inp.files[0]; if (!f) return;
var fr = new FileReader();
if (mode === 'backup') { fr.onload = function () { importBackup(fr.result); removePicker(); state = 'menu'; menuScreen = 'main'; menuCursor = 0; }; fr.readAsText(f); }
else { fr.onload = function () { startRom(fr.result, f.name, true); }; fr.readAsArrayBuffer(f); }
});
host.appendChild(inp); pickEl = inp;
}
function removePicker() { if (pickEl && pickEl.parentNode) pickEl.parentNode.removeChild(pickEl); pickEl = null; }
// ── emulation step (speed multiplies the tick budget; audio only at 1x) ──
function run(dt) {
var seconds = Math.min(dt / 1000, MAX_UPDATE_SEC) * speed;
var until = mod._emulator_get_ticks_f64(e) + seconds * CPU_TICKS - leftover;
var guard = 0;
while (guard++ < 64) {
var ev = mod._emulator_run_until_f64(e, until);
if (ev & EVENT_NEW_FRAME) imageData.data.set(new Uint8Array(mod.HEAP8.buffer, fbPtr, fbSize));
if (ev & EVENT_AUDIO_BUFFER_FULL) pushAudio();
if (ev & EVENT_UNTIL_TICKS) break;
}
leftover = (mod._emulator_get_ticks_f64(e) - until) | 0;
}
// ── pause menu model ──
function menuItems() {
if (menuScreen === 'save') return ['SLOT 1 ' + (slotUsed(1) ? '[USED]' : '[----]'), 'SLOT 2 ' + (slotUsed(2) ? '[USED]' : '[----]'), 'SLOT 3 ' + (slotUsed(3) ? '[USED]' : '[----]'), 'BACK'];
if (menuScreen === 'load') return ['SLOT 1 ' + (slotUsed(1) ? '[USED]' : '[EMPTY]'), 'SLOT 2 ' + (slotUsed(2) ? '[USED]' : '[EMPTY]'), 'SLOT 3 ' + (slotUsed(3) ? '[USED]' : '[EMPTY]'), 'BACK'];
return ['RESUME', 'SAVE STATE', 'LOAD STATE', 'LOAD ROM', 'SPEED ' + speed + 'x', 'BACKUP', 'RESTORE', 'EXIT'];
}
function clearJoyp() { if (!mod || !e) return; try { mod._set_joyp_up(e, 0); mod._set_joyp_down(e, 0); mod._set_joyp_left(e, 0); mod._set_joyp_right(e, 0); mod._set_joyp_A(e, 0); mod._set_joyp_B(e, 0); mod._set_joyp_start(e, 0); mod._set_joyp_select(e, 0); } catch (x) {} }
function enterMenu() { clearJoyp(); menuScreen = 'main'; menuCursor = 0; setStatus(''); state = 'menu'; }
function exitToGames() {
try { if (mod && e && mod._emulator_was_ext_ram_updated(e)) saveSram(); } catch (x) {}
removePicker(); clearJoyp();
if (state === 'menu' || state === 'loadnew' || state === 'restore') state = 'running';
if (api && api.exitMenu) api.exitMenu();
}
function chooseMain(i) {
if (i === 0) state = 'running';
else if (i === 1) { menuScreen = 'save'; menuCursor = 0; setStatus(''); }
else if (i === 2) { menuScreen = 'load'; menuCursor = 0; setStatus(''); }
else if (i === 3) { state = 'loadnew'; setStatus('LOAD ROM'); openPicker('rom'); }
else if (i === 4) { speed = speed >= 3 ? 1 : speed + 1; try { api.storage.set(SPEED_KEY, speed); } catch (x) {} setStatus('SPEED ' + speed + 'x'); }
else if (i === 5) exportBackup();
else if (i === 6) { state = 'restore'; setStatus('LOAD BACKUP'); openPicker('backup'); }
else exitToGames();
}
function chooseSlot(i) {
if (i === 3) { menuScreen = 'main'; menuCursor = 0; setStatus(''); return; }
var slot = i + 1;
if (menuScreen === 'save') { saveState(slot); menuScreen = 'main'; menuCursor = 0; }
else if (loadState(slot)) state = 'running';
}
function drawMenu(g) {
g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
g.textAlign = 'center'; g.textBaseline = 'top';
g.fillStyle = PAL.d0; g.font = 'bold 12px "Courier New", monospace';
g.fillText(menuScreen === 'save' ? 'SAVE STATE' : menuScreen === 'load' ? 'LOAD STATE' : 'EMULATOR', W / 2, 4);
g.fillStyle = PAL.d1; g.fillRect(14, 18, W - 28, 1);
var items = menuItems(), top = 22, rowH = (items.length > 5 ? 12 : 15);
g.font = '9px "Courier New", monospace';
for (var k = 0; k < items.length; k++) {
var y = top + k * rowH;
if (k === menuCursor) { g.fillStyle = PAL.d1; g.fillRect(8, y - 2, W - 16, 11); g.fillStyle = PAL.d3; }
else g.fillStyle = PAL.d0;
g.fillText(items[k], W / 2, y);
}
g.fillStyle = PAL.d1; g.font = '7px "Courier New", monospace';
g.fillText(status ? status : 'A OK B BACK START+SEL EXIT', W / 2, H - 8);
g.textAlign = 'left';
}
function drawStatus(g) {
g.fillStyle = PAL.d3; g.fillRect(0, 0, W, H);
g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillStyle = PAL.d0; g.font = 'bold 12px "Courier New", monospace';
g.fillText('EMULATOR', W / 2, H / 2 - 22);
g.fillStyle = PAL.d1; g.font = '9px "Courier New", monospace';
g.fillText(status || '', W / 2, H / 2);
if (state === 'needrom' || state === 'error' || state === 'loadnew' || state === 'restore') {
g.fillStyle = PAL.d2; g.font = '8px "Courier New", monospace';
g.fillText(state === 'restore' ? 'TAP: LOAD BACKUP FILE' : 'TAP SCREEN TO LOAD', W / 2, H / 2 + 18);
g.fillText((state === 'loadnew' || state === 'restore') ? 'B = BACK' : '.gb / .gbc ROM', W / 2, H / 2 + 30);
}
g.textAlign = 'left'; g.textBaseline = 'top';
}
return {
id: 'emu', name: 'EMULATOR', usesSelect: true,
init: function (a) {
api = a;
try { speed = a.storage.get(SPEED_KEY, 1) | 0; if (speed < 1 || speed > 3) speed = 1; } catch (x) { speed = 1; }
if (typeof factory !== 'function') { state = 'error'; setStatus('CORE MISSING'); return; }
state = 'loading'; setStatus('LOADING'); loadWasm();
},
reset: function () { comboLatch = false; if (state === 'needrom') openPicker('rom'); },
isPlaying: function () { return state === 'running'; },
onExit: function () { try { if (mod && e && mod._emulator_was_ext_ram_updated(e)) saveSram(); } catch (x) {} removePicker(); },
update: function (dt) {
var i = api.input;
var combo = i.isDown('start') && i.isDown('select');
if (state === 'running') {
if (combo && !comboLatch) { comboLatch = true; enterMenu(); return; } // START+SELECT -> pause menu
if (!combo) comboLatch = false;
if (FX.palette !== palApplied) { applyEmuPalette(); palApplied = FX.palette; } // live palette change
mod._set_joyp_up(e, i.isDown('up') ? 1 : 0);
mod._set_joyp_down(e, i.isDown('down') ? 1 : 0);
mod._set_joyp_left(e, i.isDown('left') ? 1 : 0);
mod._set_joyp_right(e, i.isDown('right') ? 1 : 0);
mod._set_joyp_A(e, i.isDown('a') ? 1 : 0);
mod._set_joyp_B(e, i.isDown('b') ? 1 : 0);
mod._set_joyp_start(e, i.isDown('start') ? 1 : 0);
mod._set_joyp_select(e, i.isDown('select') ? 1 : 0);
run(dt);
saveAcc += dt;
if (saveAcc >= 4000) { saveAcc = 0; try { if (mod._emulator_was_ext_ram_updated(e)) saveSram(); } catch (x) {} }
return;
}
if (combo && !comboLatch) { comboLatch = true; exitToGames(); return; }
if (!combo) comboLatch = false;
if (state === 'menu') {
var items = menuItems();
if (i.justPressed('up')) { menuCursor = (menuCursor + items.length - 1) % items.length; Sound.play('select'); }
if (i.justPressed('down')) { menuCursor = (menuCursor + 1) % items.length; Sound.play('select'); }
if (i.justPressed('b') && menuScreen !== 'main') { menuScreen = 'main'; menuCursor = 0; setStatus(''); Sound.play('select'); }
if (i.justPressed('a') || i.justPressed('start')) {
Sound.play('confirm');
if (menuScreen === 'main') chooseMain(menuCursor); else chooseSlot(menuCursor);
}
} else if (state === 'loadnew' || state === 'restore') {
if (i.justPressed('b')) { removePicker(); state = 'menu'; menuScreen = 'main'; menuCursor = 0; Sound.play('select'); }
} else if (state === 'needrom') {
if (i.justPressed('a') || i.justPressed('start')) openPicker('rom');
}
},
render: function (g) {
if (state === 'running' && imageData) g.putImageData(imageData, 0, 0);
else if (state === 'menu') drawMenu(g);
else drawStatus(g);
}
};
}
var CARTRIDGES = [makeSnake(), makeBreakout(), makeTetris(), make2048(), makeFlappy(), makePong(), makeSimon(), makeEmulator()];
// ────────────────────────────────────────────────────────────── engine ──
// Drives the active cartridge: fixed input read, update(dt), render, clear edges.
function createEngine(canvas, input, setBrand, applyFx) {
var screen = canvas.getContext('2d');
screen.imageSmoothingEnabled = false;
var buf = document.createElement('canvas');
buf.width = canvas.width; buf.height = canvas.height;
var g = buf.getContext('2d'); // games/menu draw here; blitted below with LCD ghost
g.imageSmoothingEnabled = false;
var carts = CARTRIDGES;
var raf = 0, last = 0, running = false, booted = false;
// current: -5 hi-scores, -4 initials, -3 splash, -2 settings, -1 menu, >=0 game
var current = -1, cursor = 0, setCursor = 0, initedSet = {}, paused = false;
var splashT = 0, msg = '', msgT = 0;
var initials = [0, 0, 0], iCursor = 0, hiIdx = -1, hiScore = 0;
var LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ.-! ';
var SPLASH_MS = 1300, LAST_KEY = 'contraband.lastGame', SNAKE_WRAP = 'contraband.snake.wrap';
var api = { W: canvas.width, H: canvas.height, input: input, storage: Storage, sound: Sound, haptic: Haptics, exitMenu: toMenu };
function idxOfId(id) { for (var i = 0; i < carts.length; i++) if (carts[i].id === id) return i; return -1; }
function toMenu() { current = -1; paused = false; if (setBrand) setBrand('SELECT GAME'); }
function toSplash() { current = -3; splashT = 0; }
function toSettings() { current = -2; setCursor = 0; if (setBrand) setBrand('SETTINGS'); }
function toScores() { current = -5; }
function toInitials(idx, score) {
current = -4; hiIdx = idx; hiScore = score; iCursor = 0; initials = [0, 0, 0];
var prev = ('' + Storage.get('contraband.' + carts[idx].id + '.hiname', 'AAA')).toUpperCase();
for (var k = 0; k < 3; k++) { var p = LETTERS.indexOf(prev.charAt(k)); initials[k] = p < 0 ? 0 : p; }
}
function launch(idx) {
current = idx; paused = false;
var c = carts[idx];
if (!initedSet[idx]) { c.init(api); initedSet[idx] = true; }
else if (c.reset) c.reset();
Storage.set(LAST_KEY, c.id); // remember for next boot
if (setBrand) setBrand(c.name);
}
function resetScores() {
for (var i = 0; i < carts.length; i++) {
Storage.set('contraband.' + carts[i].id + '.hiscore', 0);
Storage.set('contraband.' + carts[i].id + '.hiname', 'AAA');
}
initedSet = {}; // force re-init so cached hi-scores reload as 0
}
function anyPressed() {
var A = ['a', 'b', 'start', 'select', 'up', 'down', 'left', 'right'];
for (var i = 0; i < A.length; i++) if (input.justPressed(A[i])) return true;
return false;
}
// ── game menu (games + a SETTINGS row) ──
function menuCount() { return carts.length + 1; }
function updateMenu() {
if (input.justPressed('up')) { cursor = (cursor - 1 + menuCount()) % menuCount(); Sound.play('select'); }
if (input.justPressed('down')) { cursor = (cursor + 1) % menuCount(); Sound.play('select'); }
if (input.justPressed('a') || input.justPressed('start')) {
Sound.play('confirm');
if (cursor === carts.length) toSettings(); else launch(cursor);
}
}
function renderMenu() {
g.fillStyle = PAL.d3; g.fillRect(0, 0, api.W, api.H);
g.textBaseline = 'top'; g.textAlign = 'center';
g.fillStyle = PAL.d0; g.font = 'bold 14px "Courier New", monospace';
g.fillText('CONTRABAND', api.W / 2, 5);
g.fillStyle = PAL.d1; g.fillRect(12, 20, api.W - 24, 1);
g.font = '9px "Courier New", monospace';
var top = 22, rowH = 12, n = menuCount(); // tighter so 8 games + SETTINGS clear the footer
for (var i = 0; i < n; i++) {
var y = top + i * rowH, label = (i === carts.length) ? 'SETTINGS' : carts[i].name;
if (i === cursor) { g.fillStyle = PAL.d1; g.fillRect(14, y - 2, api.W - 28, 12); g.fillStyle = PAL.d3; }
else g.fillStyle = PAL.d0;
g.fillText(label, api.W / 2, y);
}
g.fillStyle = PAL.d1; g.font = '8px "Courier New", monospace';
g.fillText('A PLAY SELECT MENU', api.W / 2, api.H - 10);
g.textAlign = 'left';
}
// ── settings ──
var SETLABELS = ['SOUND', 'LCD FX', 'PALETTE', 'HAPTICS', 'SNAKE', 'HI-SCORES', 'RESET SCORES', 'BACK'];
function settingsVal(i) {
if (i === 0) return Sound.isEnabled() ? 'ON' : 'OFF';
if (i === 1) return FX.crt ? 'ON' : 'OFF';
if (i === 2) return PALETTES[FX.palette].name;
if (i === 3) return Haptics.isEnabled() ? 'ON' : 'OFF';
if (i === 4) return Storage.get(SNAKE_WRAP, false) ? 'WRAP' : 'WALLS';
return '';
}
function updateSettings() {
var n = SETLABELS.length;
if (input.justPressed('up')) { setCursor = (setCursor - 1 + n) % n; Sound.play('select'); }
if (input.justPressed('down')) { setCursor = (setCursor + 1) % n; Sound.play('select'); }
if (input.justPressed('b') || input.justPressed('select')) { Sound.play('select'); toMenu(); return; }
if (input.justPressed('a') || input.justPressed('start')) {
Sound.play('confirm');
if (setCursor === 0) Sound.setEnabled(!Sound.isEnabled());
else if (setCursor === 1) { FX.crt = !FX.crt; Storage.set('contraband.crt', FX.crt); if (applyFx) applyFx(); }
else if (setCursor === 2) { FX.palette = (FX.palette + 1) % PALETTES.length; Storage.set('contraband.palette', FX.palette); applyPalette(FX.palette); }
else if (setCursor === 3) Haptics.setEnabled(!Haptics.isEnabled());
else if (setCursor === 4) Storage.set(SNAKE_WRAP, !Storage.get(SNAKE_WRAP, false));
else if (setCursor === 5) toScores();
else if (setCursor === 6) { resetScores(); msg = 'SCORES CLEARED'; msgT = 1100; }
else toMenu();
}
}
function renderSettings() {
g.fillStyle = PAL.d3; g.fillRect(0, 0, api.W, api.H);
g.textBaseline = 'top'; g.textAlign = 'center';
g.fillStyle = PAL.d0; g.font = 'bold 13px "Courier New", monospace';
g.fillText('SETTINGS', api.W / 2, 3);
g.fillStyle = PAL.d1; g.fillRect(10, 17, api.W - 20, 1);
var top = 23, rowH = 13;
g.font = '9px "Courier New", monospace';
for (var i = 0; i < SETLABELS.length; i++) {
var y = top + i * rowH, sel = i === setCursor;
if (sel) { g.fillStyle = PAL.d1; g.fillRect(6, y - 2, api.W - 12, 12); }
g.textAlign = 'left'; g.fillStyle = sel ? PAL.d3 : PAL.d0; g.fillText(SETLABELS[i], 12, y);
g.textAlign = 'right'; g.fillText(settingsVal(i), api.W - 12, y);
}
g.textAlign = 'center'; g.font = '8px "Courier New", monospace'; g.fillStyle = PAL.d1;
g.fillText(msgT > 0 ? msg : 'A TOGGLE SEL BACK', api.W / 2, api.H - 9);
g.textAlign = 'left';
}
// ── hi-scores table ──
function updateScores() {
if (input.justPressed('a') || input.justPressed('b') || input.justPressed('start') || input.justPressed('select')) { Sound.play('select'); toSettings(); }
}
function renderScores() {
g.fillStyle = PAL.d3; g.fillRect(0, 0, api.W, api.H);
g.textBaseline = 'top';
g.textAlign = 'center'; g.fillStyle = PAL.d0; g.font = 'bold 13px "Courier New", monospace';
g.fillText('HI-SCORES', api.W / 2, 3);
g.fillStyle = PAL.d1; g.fillRect(10, 17, api.W - 20, 1);
g.font = '9px "Courier New", monospace';
var top = 24, rowH = 14;
for (var i = 0; i < carts.length; i++) {
if (carts[i].usesSelect) continue; // emulator isn't a scored game
var y = top + i * rowH;
var sc = Storage.get('contraband.' + carts[i].id + '.hiscore', 0) | 0;
var nm = ('' + Storage.get('contraband.' + carts[i].id + '.hiname', 'AAA')).toUpperCase();
g.textAlign = 'left'; g.fillStyle = PAL.d0; g.fillText(carts[i].name, 12, y);
g.textAlign = 'right'; g.fillStyle = PAL.d1; g.fillText(nm + ' ' + sc, api.W - 12, y);
}
g.textAlign = 'center'; g.font = '8px "Courier New", monospace'; g.fillStyle = PAL.d1;
g.fillText('ANY KEY = BACK', api.W / 2, api.H - 9);
g.textAlign = 'left';
}
// ── initials entry (shown when a game reports a new record) ──
function commitInitials() {
var name = LETTERS.charAt(initials[0]) + LETTERS.charAt(initials[1]) + LETTERS.charAt(initials[2]);
Storage.set('contraband.' + carts[hiIdx].id + '.hiname', name);
Sound.play('confirm');
current = hiIdx; // back to the game's over screen
}
function updateInitials() {
if (input.justPressed('up')) { initials[iCursor] = (initials[iCursor] + 1) % LETTERS.length; Sound.play('select'); }
if (input.justPressed('down')) { initials[iCursor] = (initials[iCursor] - 1 + LETTERS.length) % LETTERS.length; Sound.play('select'); }
if (input.justPressed('left')) { iCursor = (iCursor + 2) % 3; Sound.play('select'); }
if (input.justPressed('right')) { iCursor = (iCursor + 1) % 3; Sound.play('select'); }
if (input.justPressed('a') || input.justPressed('start')) {
if (iCursor < 2) { iCursor++; Sound.play('select'); } else commitInitials();
}
}
function renderInitials() {
g.fillStyle = PAL.d0; g.fillRect(0, 0, api.W, api.H);
g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillStyle = PAL.d3; g.font = 'bold 13px "Courier New", monospace';
g.fillText('NEW RECORD!', api.W / 2, 26);
g.fillStyle = PAL.d2; g.font = '9px "Courier New", monospace';
g.fillText('SCORE ' + hiScore, api.W / 2, 44);
g.font = 'bold 26px "Courier New", monospace';
for (var k = 0; k < 3; k++) {
var x = api.W / 2 + (k - 1) * 30;
g.fillStyle = (k === iCursor) ? PAL.d3 : PAL.d2;
g.fillText(LETTERS.charAt(initials[k]), x, api.H / 2 + 6);
if (k === iCursor) { g.fillStyle = PAL.d3; g.fillRect(x - 10, api.H / 2 + 24, 20, 2); }
}
g.fillStyle = PAL.d1; g.font = '7px "Courier New", monospace';
g.fillText('UP/DN LETTER L/R MOVE A OK', api.W / 2, api.H - 12);
g.textAlign = 'left'; g.textBaseline = 'top';
}
// ── bootleg boot splash ──
function updateSplash(dt) {
splashT += dt;
if (splashT >= SPLASH_MS || anyPressed()) {
var id = Storage.get(LAST_KEY, null), li = id ? idxOfId(id) : -1;
if (li >= 0) launch(li); else toMenu();
}
}
function renderSplash() {
g.fillStyle = PAL.d0; g.fillRect(0, 0, api.W, api.H);
g.textAlign = 'center'; g.textBaseline = 'middle';
var lit = FX.reduced ? true : (Math.floor(splashT / 110) % 9) !== 0; // occasional flicker
g.fillStyle = lit ? PAL.d3 : PAL.d1;
g.font = 'bold 20px "Courier New", monospace';
g.fillText('CONTRABAND', api.W / 2, api.H / 2 - 12);
g.fillStyle = PAL.d2; g.font = '7px "Courier New", monospace';
g.fillText('* UNLICENSED * NOT FOR RESALE', api.W / 2, api.H / 2 + 12);
g.fillText('(C) 20XX BREADH IND.', api.W / 2, api.H / 2 + 26);
g.textAlign = 'left'; g.textBaseline = 'top';
}
function renderPause() {
g.fillStyle = 'rgba(15,56,15,0.72)'; g.fillRect(0, 0, api.W, api.H);
g.textAlign = 'center'; g.textBaseline = 'middle';
g.fillStyle = PAL.d3; g.font = 'bold 16px "Courier New", monospace';
g.fillText('PAUSED', api.W / 2, api.H / 2 - 6);
g.fillStyle = PAL.d2; g.font = '8px "Courier New", monospace';
g.fillText('START = RESUME', api.W / 2, api.H / 2 + 12);
g.textAlign = 'left'; g.textBaseline = 'top';
}
function frame(now) {
if (!running) return;
raf = window.requestAnimationFrame(frame);
try {
var dt = now - last; last = now;
if (dt < 0) dt = 0; if (dt > 100) dt = 100; // clamp after tab-throttle
if (msgT > 0) msgT -= dt;
if (current === -3) updateSplash(dt);
else if (current === -2) updateSettings();
else if (current === -1) updateMenu();
else if (current === -4) updateInitials();
else if (current === -5) updateScores();
else {
var cart = carts[current];
if (cart.usesSelect) { cart.update(dt); } // emulator owns all input incl. its own menu + exit
else if (input.justPressed('select')) { Sound.play('select'); toMenu(); } // eject to menu
else if (paused) { if (input.justPressed('start')) { paused = false; Sound.play('pause'); } }
else if (input.justPressed('start') && cart.isPlaying && cart.isPlaying()) { paused = true; Sound.play('pause'); }
else {
cart.update(dt); // Start pauses only in play
if (cart.tookHigh) { var hs = cart.tookHigh(); if (hs >= 0) toInitials(current, hs); } // record → initials
}
}
if (current === -3) renderSplash();
else if (current === -2) renderSettings();
else if (current === -1) renderMenu();
else if (current === -4) renderInitials();
else if (current === -5) renderScores();
else { carts[current].render(g); if (paused) renderPause(); }
// composite the buffer onto the visible LCD; a soft ghost trail when LCD FX is on
screen.globalAlpha = (FX.crt && !FX.reduced) ? 0.6 : 1;
screen.drawImage(buf, 0, 0);
screen.globalAlpha = 1;
} catch (err) {
try { if (window.console && console.warn) console.warn('[Contraband] frame error (contained):', err); } catch (e2) {}
} finally {
input.endFrame(); // always clear input edges so nothing sticks
}
}
function pause() {
running = false;
if (raf) window.cancelAnimationFrame(raf);
raf = 0;
}
function resume() {
if (running) return;
running = true;
last = performance.now();
raf = window.requestAnimationFrame(frame);
}
function onVisibility() {
if (document.hidden && current >= 0) {
var c = carts[current];
if (!c.usesSelect && c.isPlaying && c.isPlaying()) paused = true; // emulator halts naturally when RAF stops
}
}
document.addEventListener('visibilitychange', onVisibility);
return {
start: function () { if (!booted) { booted = true; toSplash(); } resume(); },
pause: pause, // stop the loop but keep game state (used when hidden)
resume: resume,
stop: pause,
destroy: function () { document.removeEventListener('visibilitychange', onVisibility); pause(); }
};
}
// ─────────────────────────────────────────────────────────────── styles ──
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
var css = [
'#' + ROOT_ID + '{position:fixed;right:12px;bottom:16px;z-index:2147483000;',
'font-family:"Courier New",monospace;-webkit-user-select:none;user-select:none;',
'touch-action:none;color:#e8ecd6;transform:rotate(-1.1deg) scale(var(--cb-scale,1));transform-origin:50% 50%;}',
'@media (max-width:784px){#' + ROOT_ID + '{right:8px;',
'bottom:calc(env(safe-area-inset-bottom,0px) + 64px);}}',
'#' + ROOT_ID + ' .cb-titlebar{display:flex;align-items:center;justify-content:space-between;',
'background:#1f2419;border:2px solid #0d1109;border-bottom:none;border-radius:10px 10px 0 0;',
'padding:4px 8px;cursor:grab;letter-spacing:1px;font-size:11px;color:' + PAL.d3 + ';}',
'#' + ROOT_ID + ' .cb-titlebar:active{cursor:grabbing;}',
'#' + ROOT_ID + ' .cb-title{font-weight:bold;}',
'#' + ROOT_ID + ' .cb-collapse{background:none;border:none;color:' + PAL.d2 + ';',
'font-size:14px;line-height:1;cursor:pointer;padding:0 2px;}',
// Weathered, grimy bootleg shell.
'#' + ROOT_ID + ' .cb-gb{position:relative;width:196px;',
'background:radial-gradient(130% 90% at 28% 8%,rgba(255,255,255,.16),rgba(255,255,255,0) 42%),',
'linear-gradient(158deg,#c6c8b1 0%,#b4b69d 52%,#a2a48d 100%);',
'border:2px solid #0d1109;border-top:none;border-radius:0 0 14px 34px 14px;padding:10px 12px 14px;',
'box-shadow:0 9px 26px rgba(0,0,0,.5),inset 0 0 18px rgba(50,52,34,.35),inset 0 2px 0 rgba(255,255,255,.12);}',
// grime smudges (behind the controls)
'#' + ROOT_ID + ' .cb-gb::before{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;',
'background:radial-gradient(10px 4px at 22% 26%,rgba(40,40,25,.18),transparent 70%),',
'radial-gradient(16px 6px at 72% 15%,rgba(40,40,25,.14),transparent 70%),',
'radial-gradient(22px 9px at 58% 86%,rgba(40,40,25,.16),transparent 70%),',
'radial-gradient(6px 6px at 86% 64%,rgba(40,40,25,.20),transparent 70%),',
'radial-gradient(5px 5px at 13% 74%,rgba(40,40,25,.18),transparent 70%);}',
// hairline scratches across the face
'#' + ROOT_ID + ' .cb-gb::after{content:"";position:absolute;inset:0;border-radius:inherit;pointer-events:none;',
'background:linear-gradient(66deg,transparent 40.6%,rgba(255,255,255,.18) 41%,transparent 41.4%),',
'linear-gradient(57deg,transparent 65.7%,rgba(0,0,0,.14) 66%,transparent 66.3%);}',
'#' + ROOT_ID + ' .cb-bezel{position:relative;background:linear-gradient(160deg,#4c5142,#3a3e31);',
'border-radius:8px 8px 20px 8px;padding:12px 14px 16px;',
'box-shadow:inset 0 2px 8px rgba(0,0,0,.65),inset 0 0 0 1px rgba(0,0,0,.4);}',
'#' + ROOT_ID + ' .cb-screen{display:block;width:100%;height:auto;image-rendering:pixelated;',
'image-rendering:crisp-edges;background:' + PAL.d3 + ';border:2px solid #1a1e15;border-radius:3px;}',
// greasy/cracked screen glass overlay (over the LCD, non-interactive, kept faint)
'#' + ROOT_ID + ' .cb-bezel::after{content:"";position:absolute;left:14px;right:14px;top:12px;bottom:16px;',
'pointer-events:none;border-radius:3px;',
'background:radial-gradient(120% 120% at 88% 90%,rgba(0,0,0,.26),transparent 46%),',
'linear-gradient(48deg,transparent 79%,rgba(15,20,10,.22) 79.4%,transparent 79.8%),',
'linear-gradient(122deg,transparent 24%,rgba(15,20,10,.15) 24.3%,transparent 24.6%),',
'radial-gradient(1.4px 1.4px at 31% 61%,rgba(15,20,10,.7),transparent);}',
// hand-scrawled label taped to the shell
'#' + ROOT_ID + ' .cb-brand{position:relative;text-align:center;color:#2a2c1e;font-size:9px;',
'letter-spacing:2px;width:122px;margin:9px auto 3px;padding:3px 0;background:#d7d5c0;',
'transform:rotate(-1.5deg);box-shadow:0 1px 2px rgba(0,0,0,.4);}',
'#' + ROOT_ID + ' .cb-brand::before,#' + ROOT_ID + ' .cb-brand::after{content:"";position:absolute;',
'top:-4px;width:18px;height:9px;background:rgba(222,226,196,.5);box-shadow:0 1px 1px rgba(0,0,0,.25);}',
'#' + ROOT_ID + ' .cb-brand::before{left:-6px;transform:rotate(-24deg);}',
'#' + ROOT_ID + ' .cb-brand::after{right:-6px;transform:rotate(20deg);}',
// CRT scanlines — hidden unless the shell has .cb-crt (LCD FX = on)
'#' + ROOT_ID + ' .cb-scanlines{display:none;position:absolute;left:14px;right:14px;top:12px;',
'bottom:16px;pointer-events:none;border-radius:3px;',
'background:repeating-linear-gradient(to bottom,rgba(0,0,0,.16) 0,rgba(0,0,0,.16) 1px,transparent 1px,transparent 3px);}',
'#' + ROOT_ID + '.cb-crt .cb-scanlines{display:block;}',
// reduced-motion: drop the janky tilt
'#' + ROOT_ID + '.cb-reduced{transform:scale(var(--cb-scale,1));}', // resize still works, no tilt
// resize grip (bottom-right of the shell)
'#' + ROOT_ID + ' .cb-resize{position:absolute;right:2px;bottom:2px;width:15px;height:15px;',
'cursor:nwse-resize;touch-action:none;opacity:.45;',
'background:linear-gradient(135deg,transparent 48%,#0d1109 48%,#0d1109 58%,transparent 58%,',
'transparent 70%,#0d1109 70%,#0d1109 80%,transparent 80%);}',
'#' + ROOT_ID + ' .cb-resize:hover{opacity:.85;}',
'#' + ROOT_ID + ' .cb-controls{display:flex;align-items:center;justify-content:space-between;',
'margin-top:8px;}',
// D-pad (3x3 grid; N/E/S/W are the live zones)
'#' + ROOT_ID + ' .cb-dpad{display:grid;grid-template-columns:repeat(3,22px);',
'grid-template-rows:repeat(3,22px);}',
'#' + ROOT_ID + ' .cb-dpad button{background:linear-gradient(160deg,#2c2c24,#201f19);border:none;',
'color:#7c7c6c;font-size:10px;cursor:pointer;touch-action:none;',
'box-shadow:inset 0 1px 1px rgba(255,255,255,.06),0 1px 2px rgba(0,0,0,.4);}',
'#' + ROOT_ID + ' .cb-dpad .cb-up{grid-area:1/2;border-radius:4px 4px 0 0;}',
'#' + ROOT_ID + ' .cb-dpad .cb-left{grid-area:2/1;border-radius:4px 0 0 4px;}',
'#' + ROOT_ID + ' .cb-dpad .cb-mid{grid-area:2/2;background:#26261f;}',
'#' + ROOT_ID + ' .cb-dpad .cb-right{grid-area:2/3;border-radius:0 4px 4px 0;}',
'#' + ROOT_ID + ' .cb-dpad .cb-down{grid-area:3/2;border-radius:0 0 4px 4px;}',
'#' + ROOT_ID + ' .cb-dpad button:active{background:#3a3a2e;}',
// A / B
'#' + ROOT_ID + ' .cb-ab{display:flex;gap:8px;align-items:center;transform:rotate(-18deg);}',
'#' + ROOT_ID + ' .cb-ab button{width:30px;height:30px;border-radius:50%;border:none;',
'background:radial-gradient(circle at 38% 32%,#9b3a55,#7a2740 68%,#5f1f33);color:#e9cdd4;',
'font-weight:bold;font-size:12px;cursor:pointer;touch-action:none;',
'box-shadow:0 2px 3px rgba(0,0,0,.5),inset 0 -2px 3px rgba(0,0,0,.35),inset 0 1px 1px rgba(255,255,255,.15);}',
'#' + ROOT_ID + ' .cb-ab button:active{background:#6f2439;transform:translateY(1px);}',
// Start / Select
'#' + ROOT_ID + ' .cb-ss{display:flex;gap:14px;justify-content:center;margin-top:10px;}',
'#' + ROOT_ID + ' .cb-ss button{background:none;border:none;color:#6a6d58;font-size:8px;',
'letter-spacing:1px;cursor:pointer;touch-action:none;}',
'#' + ROOT_ID + ' .cb-ss .pill{display:block;width:26px;height:7px;border-radius:4px;',
'background:#7d7f68;margin:0 auto 3px;transform:rotate(-18deg);}',
'#' + ROOT_ID + ' .cb-ss button:active .pill{background:#5a5c48;}',
// Collapsed pill
'#' + ROOT_ID + '.cb-collapsed .cb-gb{display:none;}',
'#' + ROOT_ID + '.cb-collapsed .cb-titlebar{border-radius:10px;border-bottom:2px solid #0d1109;}',
// Debug panel
'#' + ROOT_ID + ' .cb-debug{margin-top:6px;max-width:196px;background:#0d1109;color:' + PAL.d2 + ';',
'font-size:9px;line-height:1.4;padding:6px 8px;border-radius:6px;white-space:pre-wrap;',
'word-break:break-word;}',
// Header toggle button — sits inline with Travel Table / Computer (unscoped).
'#contraband-toggle{display:inline-flex;align-items:center;gap:5px;vertical-align:middle;',
'margin:0 0 0 12px;padding:3px 9px;border:1px solid #0d1109;border-radius:6px;',
'background:#1f2419;color:' + PAL.d3 + ';font:bold 11px "Courier New",monospace;',
'letter-spacing:1px;cursor:pointer;line-height:1.4;-webkit-user-select:none;user-select:none;}',
'#contraband-toggle.cb-tg-float{float:right;margin-top:1px;}', // fallback when no icon row
'#contraband-toggle:hover{background:#2a3222;}',
'#contraband-toggle:active{transform:translateY(1px);}',
'#contraband-toggle .cb-tg-dot{width:7px;height:7px;border-radius:2px;background:#3a3a2e;}',
'#contraband-toggle[data-on="1"] .cb-tg-dot{background:' + PAL.d3 + ';box-shadow:0 0 5px ' + PAL.d2 + ';}'
].join('');
var s = document.createElement('style');
s.id = STYLE_ID;
s.textContent = css;
(document.head || document.documentElement).appendChild(s);
}
// ─────────────────────────────────────────────────────────────── shell ──
// Builds the Game Boy DOM, wires every control to the input bus, handles
// collapse + drag. Returns { root, canvas, destroy() }.
function buildShell(input) {
injectStyles();
var root = document.createElement('div');
root.id = ROOT_ID;
root.style.setProperty('--cb-scale', '' + (Storage.get('contraband.scale', 1) || 1)); // persisted size
var collapsed = !!Storage.get('contraband.collapsed', false);
if (collapsed) root.classList.add('cb-collapsed');
// Title bar
var titlebar = document.createElement('div');
titlebar.className = 'cb-titlebar';
var title = document.createElement('span');
title.className = 'cb-title';
title.textContent = '▸ CONTRABAND';
var collapseBtn = document.createElement('button');
collapseBtn.className = 'cb-collapse';
collapseBtn.type = 'button';
collapseBtn.textContent = collapsed ? '▢' : '—';
titlebar.appendChild(title);
titlebar.appendChild(collapseBtn);
// Game Boy body
var gb = document.createElement('div');
gb.className = 'cb-gb';
var bezel = document.createElement('div');
bezel.className = 'cb-bezel';
var canvas = document.createElement('canvas');
canvas.className = 'cb-screen';
canvas.width = 160; canvas.height = 144;
bezel.appendChild(canvas);
var scan = document.createElement('div'); // CRT scanline overlay (shown when LCD FX on)
scan.className = 'cb-scanlines';
bezel.appendChild(scan);
var brand = document.createElement('div');
brand.className = 'cb-brand';
brand.textContent = 'CONTRABAND';
var controls = document.createElement('div');
controls.className = 'cb-controls';
// D-pad
var dpad = document.createElement('div');
dpad.className = 'cb-dpad';
var dmap = [['cb-up', 'up', '▲'], ['cb-left', 'left', '◀'], ['cb-mid', null, ''],
['cb-right', 'right', '▶'], ['cb-down', 'down', '▼']];
dmap.forEach(function (d) {
var b = document.createElement('button');
b.type = 'button';
b.className = d[0];
b.textContent = d[2];
if (d[1]) bindHold(b, d[1]);
dpad.appendChild(b);
});
// A / B
var ab = document.createElement('div');
ab.className = 'cb-ab';
['b', 'a'].forEach(function (act) {
var b = document.createElement('button');
b.type = 'button';
b.textContent = act.toUpperCase();
bindHold(b, act);
ab.appendChild(b);
});
controls.appendChild(dpad);
controls.appendChild(ab);
// Start / Select
var ss = document.createElement('div');
ss.className = 'cb-ss';
[['select', 'SELECT'], ['start', 'START']].forEach(function (p) {
var b = document.createElement('button');
b.type = 'button';
b.innerHTML = '<span class="pill"></span>' + p[1];
bindHold(b, p[0]);
ss.appendChild(b);
});
gb.appendChild(bezel);
gb.appendChild(brand);
gb.appendChild(controls);
gb.appendChild(ss);
// resize grip — drag to scale the whole handheld (persisted)
var resizeGrip = document.createElement('div');
resizeGrip.className = 'cb-resize';
resizeGrip.title = 'Drag to resize';
gb.appendChild(resizeGrip);
root.appendChild(titlebar);
root.appendChild(gb);
(function enableResize() {
var rz = false, sx = 0, sy = 0, sScale = 1;
function curScale() { return parseFloat(getComputedStyle(root).getPropertyValue('--cb-scale')) || 1; }
resizeGrip.addEventListener('pointerdown', function (e) {
e.preventDefault(); e.stopPropagation();
rz = true; sx = e.clientX; sy = e.clientY; sScale = curScale();
try { resizeGrip.setPointerCapture(e.pointerId); } catch (x) {}
});
resizeGrip.addEventListener('pointermove', function (e) {
if (!rz) return;
var d = ((e.clientX - sx) + (e.clientY - sy)) / 200; // outward = bigger
var s = Math.max(0.7, Math.min(2.6, sScale + d));
root.style.setProperty('--cb-scale', s.toFixed(3));
});
function stop() { if (!rz) return; rz = false; Storage.set('contraband.scale', curScale()); }
resizeGrip.addEventListener('pointerup', stop);
resizeGrip.addEventListener('pointercancel', stop);
resizeGrip.addEventListener('touchstart', function (e) { e.preventDefault(); }, { passive: false });
})();
// ── control wiring: pointer events cover mouse + touch uniformly ──
function bindHold(el, action) {
function onDown(e) {
e.preventDefault();
Sound.unlock(); // first real gesture starts the AudioContext
Haptics.tap();
input.press(action);
try { el.setPointerCapture(e.pointerId); } catch (x) {}
}
function onUp() { input.release(action); }
el.addEventListener('pointerdown', onDown);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onUp);
el.addEventListener('pointerleave', function () { input.release(action); });
// Stop iOS long-press callout / scroll.
el.addEventListener('touchstart', function (e) { e.preventDefault(); }, { passive: false });
el.addEventListener('contextmenu', function (e) { e.preventDefault(); });
}
// ── collapse ──
function setCollapsed(v) {
collapsed = v;
root.classList.toggle('cb-collapsed', v);
collapseBtn.textContent = v ? '▢' : '—';
Storage.set('contraband.collapsed', v);
}
collapseBtn.addEventListener('click', function (e) {
e.stopPropagation();
setCollapsed(!collapsed);
});
// Tapping the collapsed pill re-opens.
titlebar.addEventListener('click', function () { if (collapsed) setCollapsed(false); });
// ── drag by the title bar (pointer events → touch + mouse) ──
(function enableDrag() {
var dragging = false, sx = 0, sy = 0, ox = 0, oy = 0;
titlebar.addEventListener('pointerdown', function (e) {
if (e.target === collapseBtn) return;
dragging = true;
var r = root.getBoundingClientRect();
ox = r.left; oy = r.top;
sx = e.clientX; sy = e.clientY;
root.style.right = 'auto';
root.style.bottom = 'auto';
root.style.left = ox + 'px';
root.style.top = oy + 'px';
try { titlebar.setPointerCapture(e.pointerId); } catch (x) {}
});
titlebar.addEventListener('pointermove', function (e) {
if (!dragging) return;
var nx = ox + (e.clientX - sx);
var ny = oy + (e.clientY - sy);
var maxX = window.innerWidth - root.offsetWidth;
var maxY = window.innerHeight - 40;
root.style.left = Math.max(0, Math.min(maxX, nx)) + 'px';
root.style.top = Math.max(0, Math.min(maxY, ny)) + 'px';
});
function stop() { dragging = false; }
titlebar.addEventListener('pointerup', stop);
titlebar.addEventListener('pointercancel', stop);
})();
// ── keyboard (desktop) → same bus ──
var KEYMAP = {
ArrowUp: 'up', KeyW: 'up',
ArrowDown: 'down', KeyS: 'down',
ArrowLeft: 'left', KeyA: 'left',
ArrowRight: 'right', KeyD: 'right',
KeyZ: 'a', KeyJ: 'a', Space: 'a', Enter: 'start',
KeyX: 'b', KeyK: 'b', ShiftRight: 'select'
};
function isTyping() {
var el = document.activeElement;
if (!el) return false;
var t = (el.tagName || '').toLowerCase();
return t === 'input' || t === 'textarea' || el.isContentEditable;
}
function onKeyDown(e) {
var act = KEYMAP[e.code];
// don't hijack keys when collapsed or hidden via the header toggle
if (!act || collapsed || root.style.display === 'none' || isTyping()) return;
e.preventDefault(); // stop arrow/space page scroll while playing
Sound.unlock();
input.press(act);
}
function onKeyUp(e) {
var act = KEYMAP[e.code];
if (!act) return;
input.release(act);
}
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
return {
root: root,
canvas: canvas,
setBrand: function (name) { brand.textContent = 'CONTRABAND ▸ ' + name; },
destroy: function () {
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('keyup', onKeyUp, true);
if (root.parentNode) root.parentNode.removeChild(root);
}
};
}
// ──────────────────────────────────────────────────────── detection ──
// Torn's travel page (page.php?sid=travel) shows either the departure/agency
// UI or the in-flight countdown. We only mount during the in-flight state.
// Since the exact React markup can shift, detection is heuristic + tunable.
function isTravelUrl() {
return /page\.php/i.test(location.pathname) &&
/(?:^|[?&])sid=travel(?:&|$)/i.test(location.search);
}
function getTravelRoot() {
return document.getElementById('travel-root') ||
document.querySelector('.content-wrapper.travelling') ||
document.getElementById('mainContainer') || document.body;
}
// In-flight detection uses stable Torn markers confirmed from the live DOM:
// • <body data-traveling="true">
// • <div class="content-wrapper ... travelling">
// • #travel-root flight scene / progress (React hashed-class prefixes)
// Any one strong signal is enough; hashes in class names can change between
// Torn builds, so we match by prefix, never the full hashed class.
function detectInFlight() {
if (!isTravelUrl()) return { inFlight: false, score: 0, hits: ['not-travel-url'] };
var hits = [], score = 0;
if (document.body && document.body.getAttribute('data-traveling') === 'true') {
score += 2; hits.push('+body-traveling');
}
if (document.querySelector('.content-wrapper.travelling')) {
score += 2; hits.push('+wrapper-travelling');
}
if (document.querySelector(
'#travel-root [class*="airspaceScene"],' +
' #travel-root [class*="flightProgressSection"],' +
' #travel-root [class*="progressText"]')) {
score += 2; hits.push('+flight-scene');
}
return { inFlight: score >= 2, score: score, hits: hits };
}
// ─────────────────────────────────────────────────────────── harness ──
var mounted = null; // { shell, engine } when active
var input = createInput();
// Release all held buttons when focus/visibility is lost (prevents stuck touch/keys after minimize or app-switch).
window.addEventListener('blur', function () { input.resetAll(); });
document.addEventListener('visibilitychange', function () { if (document.hidden) input.resetAll(); });
var force = 'auto'; // 'auto' | 'on' | 'off'
var visible = Storage.get('contraband.visible', true) !== false; // header-toggle state
var detectTimer = 0;
var urlTimer = 0;
var observer = null;
var debugEl = null;
var lastSignals = null;
function shouldMount() {
if (force === 'on') return true;
if (force === 'off') return false;
lastSignals = detectInFlight();
return lastSignals.inFlight;
}
// Place the overlay centered on screen (top-left coords, same system as drag).
function centerOverlay(root) {
root.style.right = 'auto'; root.style.bottom = 'auto';
var w = root.offsetWidth || 200, h = root.offsetHeight || 340;
root.style.left = Math.max(4, (window.innerWidth - w) / 2) + 'px';
root.style.top = Math.max(4, (window.innerHeight - h) / 2) + 'px';
}
// Toggle the CRT/scanline + reduced-motion classes on the shell (called by the
// engine when LCD FX changes, and on mount).
function applyFx() {
if (!mounted) return;
var root = mounted.shell.root;
root.classList.toggle('cb-crt', FX.crt && !FX.reduced);
root.classList.toggle('cb-reduced', FX.reduced);
}
function mount() {
if (mounted) return;
try {
var shell = buildShell(input);
document.body.appendChild(shell.root);
centerOverlay(shell.root); // open centered; user can still drag it anywhere
var engine = createEngine(shell.canvas, input, shell.setBrand, applyFx);
engine.start();
mounted = { shell: shell, engine: engine };
if (debugEl) shell.root.appendChild(debugEl);
applyFx();
applyVisibility();
} catch (err) {
// Never let a toy error escape into Torn's page. Roll back a partial mount.
try { if (window.console && console.warn) console.warn('[Contraband] mount failed (contained):', err); } catch (e2) {}
try { teardown(); } catch (e3) {}
}
}
function teardown() {
if (!mounted) return;
mounted.engine.destroy();
mounted.shell.destroy();
input.resetAll();
mounted = null;
removeToggleButton();
}
// ── header toggle button (injected into Torn's travel titleContainer) ──
function updateToggleButton() {
var btn = document.getElementById('contraband-toggle');
if (!btn) return;
btn.setAttribute('data-on', visible ? '1' : '0');
btn.title = visible ? 'Hide the in-flight mini game' : 'Show the in-flight mini game';
btn.innerHTML = '<span class="cb-tg-dot"></span>' + (visible ? 'HIDE GAME' : 'PLAY GAME');
}
function ensureToggleButton() {
injectStyles();
// Prefer the Travel Table / Computer icon row so the button sits inline with
// them; fall back to the bare title bar (float right) if that row isn't present.
var icons = document.querySelector('[class*="titleContainer___"] .tt-top-icons');
var host = icons || document.querySelector('[class*="titleContainer___"]');
if (!host) return;
var btn = document.getElementById('contraband-toggle');
if (btn) {
if (btn.parentNode !== host) host.appendChild(btn); // re-home after a re-render
btn.classList.toggle('cb-tg-float', !icons);
updateToggleButton();
return;
}
btn = document.createElement('button');
btn.id = 'contraband-toggle';
btn.type = 'button';
if (!icons) btn.classList.add('cb-tg-float');
btn.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
setVisible(!visible);
});
host.appendChild(btn);
updateToggleButton();
}
function removeToggleButton() {
var btn = document.getElementById('contraband-toggle');
if (btn && btn.parentNode) btn.parentNode.removeChild(btn);
}
function applyVisibility() {
if (mounted) {
mounted.shell.root.style.display = visible ? '' : 'none';
if (visible) mounted.engine.resume(); else mounted.engine.pause();
}
updateToggleButton();
}
function setVisible(v) {
visible = v;
Storage.set('contraband.visible', v);
applyVisibility();
}
function evaluate() {
var want = shouldMount();
if (want && !mounted) mount();
else if (!want && mounted) teardown();
if (want) ensureToggleButton(); // re-inject header button after React re-renders
renderDebug();
}
function startDetector() {
if (detectTimer) return;
// Re-evaluate on DOM churn (React re-renders on arrival) + a slow backstop poll.
observer = new MutationObserver(debounce(evaluate, 250));
try {
// attributes so the <body data-traveling> flip is caught immediately on arrival
observer.observe(document.body, {
childList: true, subtree: true, characterData: true,
attributes: true, attributeFilter: ['data-traveling', 'class']
});
} catch (e) {}
detectTimer = window.setInterval(evaluate, 1200);
evaluate();
}
function stopDetector() {
if (observer) { observer.disconnect(); observer = null; }
if (detectTimer) { window.clearInterval(detectTimer); detectTimer = 0; }
teardown();
}
function onLocationChanged() {
if (isTravelUrl()) startDetector();
else stopDetector();
}
function debounce(fn, ms) {
var t = 0;
return function () {
window.clearTimeout(t);
t = window.setTimeout(fn, ms);
};
}
// ── debug panel ──
function makeDebugEl() {
var el = document.createElement('div');
el.className = 'cb-debug';
return el;
}
function renderDebug() {
if (!debugEl) return;
var sig = force === 'auto' ? (lastSignals || detectInFlight()) : { score: '-', hits: [] };
debugEl.textContent =
'force: ' + force + '\n' +
'travelUrl: ' + isTravelUrl() + '\n' +
'mounted: ' + !!mounted + '\n' +
'score: ' + sig.score + '\n' +
'hits: ' + (sig.hits && sig.hits.length ? sig.hits.join(' ') : '(none)');
}
// ── public console API (also used by URL hash shortcuts) ──
window.__CONTRABAND__ = {
version: '1.5.0',
force: function (mode) {
if (['auto', 'on', 'off'].indexOf(mode) === -1) {
console.warn('[Contraband] force(mode) expects "auto" | "on" | "off"');
return force;
}
force = mode;
evaluate();
return force;
},
debug: function () {
if (!debugEl) debugEl = makeDebugEl();
if (mounted && debugEl.parentNode !== mounted.shell.root) mounted.shell.root.appendChild(debugEl);
renderDebug();
console.log('[Contraband] debug panel on. Use __CONTRABAND__.force("on") to force-mount.');
return this.signals();
},
signals: function () {
var s = detectInFlight();
console.log('[Contraband] travelUrl=' + isTravelUrl() + ' inFlight=' + s.inFlight +
' score=' + s.score + ' hits=' + s.hits.join(' '));
return s;
},
mount: mount,
teardown: teardown,
show: function () { setVisible(true); },
hide: function () { setVisible(false); },
toggle: function () { setVisible(!visible); }
};
// ── boot ──
function boot() {
// Cosmetic prefs + palette.
FX.palette = parseInt(Storage.get('contraband.palette', 0), 10) || 0;
FX.crt = Storage.get('contraband.crt', false) === true;
try { FX.reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
applyPalette(FX.palette);
try {
var mq = window.matchMedia('(prefers-reduced-motion: reduce)');
var onMq = function () { FX.reduced = mq.matches; applyFx(); };
if (mq.addEventListener) mq.addEventListener('change', onMq);
else if (mq.addListener) mq.addListener(onMq);
} catch (e) {}
// URL hash shortcuts for quick tuning without the console.
var h = location.hash || '';
if (h.indexOf('cb-on') !== -1) force = 'on';
else if (h.indexOf('cb-off') !== -1) force = 'off';
if (h.indexOf('cb-debug') !== -1) debugEl = makeDebugEl();
// Watch SPA navigation (Torn patches history for some transitions) + hash.
['pushState', 'replaceState'].forEach(function (m) {
var orig = history[m];
if (typeof orig === 'function') {
history[m] = function () {
var r = orig.apply(this, arguments);
window.dispatchEvent(new Event('cb:locationchange'));
return r;
};
}
});
window.addEventListener('popstate', onLocationChanged);
window.addEventListener('hashchange', onLocationChanged);
window.addEventListener('cb:locationchange', onLocationChanged);
// Backstop poll in case history isn't patched cleanly.
urlTimer = window.setInterval(onLocationChanged, 1500);
onLocationChanged();
}
if (document.body) boot();
else document.addEventListener('DOMContentLoaded', boot);
})();