Skip to content

Instantly share code, notes, and snippets.

@tisaconundrum2
Last active September 30, 2026 01:08
Show Gist options
  • Select an option

  • Save tisaconundrum2/15c9bc0f973bec4abee5c4ff4d1bb5a8 to your computer and use it in GitHub Desktop.

Select an option

Save tisaconundrum2/15c9bc0f973bec4abee5c4ff4d1bb5a8 to your computer and use it in GitHub Desktop.
page-pinball.js
/*
Add the following to a bookmark
javascript: (async () => { try { const url = "https://gist.githubusercontent.com/tisaconundrum2/15c9bc0f973bec4abee5c4ff4d1bb5a8/raw/page-pinball.js"; const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const source = await response.text(); (0, eval)(source); } catch (error) { console.error("Page Pinball failed:", error); alert("Could not load Page Pinball. See the console for details."); }})();
*/
(() => {
"use strict";
const KEY = "__PAGE_PINBALL__";
if (window[KEY]) {
window[KEY].destroy();
return;
}
if (!document.body) {
return;
}
const CONFIG = {
gravity: 1000,
ballRadius: 9,
maxSpeed: 1500,
wallBounce: 0.94,
targetBounce: 1.02,
flipperBounce: 0.85,
flipperSpeed: 17,
physicsStep: 1 / 180,
targetRefresh: 650,
};
const canvas = document.createElement("canvas");
Object.assign(canvas.style, {
position: "fixed",
inset: "0",
width: "100vw",
height: "100vh",
zIndex: "2147483647",
pointerEvents: "none",
});
canvas.setAttribute("aria-hidden", "true");
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d");
if (!ctx) {
canvas.remove();
return;
}
const keys = new Set();
const pointers = new Map();
const removals = [];
const particles = [];
const trail = [];
const listeners = [];
const ball = {
x: 0,
y: 0,
vx: 0,
vy: 0,
radius: CONFIG.ballRadius,
active: false,
};
let width = 0;
let height = 0;
let pixelRatio = 1;
let flippers = [];
let targets = [];
let score = 0;
let ballsLost = 0;
let stopped = false;
let paused = document.hidden;
let frameId = 0;
let lastTime = performance.now();
let lastTargetScan = -Infinity;
let respawnDelay = 0;
let targetsDirty = true;
const clamp = (value, min, max) => {
return Math.max(min, Math.min(max, value));
};
function listen(target, type, handler, options) {
target.addEventListener(type, handler, options);
listeners.push(() => {
target.removeEventListener(type, handler, options);
});
}
function makeFlipper(x, side) {
const length = Math.min(125, width * 0.115);
const rest = side === "left" ? 0.36 : Math.PI - 0.36;
const raised = side === "left" ? -0.52 : Math.PI + 0.52;
return {
x,
y: height - Math.min(100, height * 0.2),
length,
radius: Math.max(7, Math.min(11, width * 0.025)),
side,
rest,
raised,
angle: rest,
omega: 0,
};
}
function resize() {
width = window.innerWidth;
height = window.innerHeight;
pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
flippers = [
makeFlipper(width * 0.38, "left"),
makeFlipper(width * 0.62, "right"),
];
ball.x = clamp(ball.x, ball.radius, width - ball.radius);
ball.y = clamp(ball.y, ball.radius, height - ball.radius);
targetsDirty = true;
}
function launch() {
ball.x = width * 0.5;
ball.y = Math.max(50, height * 0.4);
ball.vx = (Math.random() < 0.5 ? -1 : 1) * 230;
ball.vy = -650;
ball.active = true;
respawnDelay = 0;
trail.length = 0;
}
function flipperPressed(side) {
const keyboard =
side === "left"
? keys.has("ArrowLeft") || keys.has("KeyA")
: keys.has("ArrowRight") || keys.has("KeyD");
return keyboard || [...pointers.values()].includes(side);
}
const ignoredTags = new Set([
"SCRIPT",
"STYLE",
"LINK",
"META",
"NOSCRIPT",
"TEMPLATE",
"BR",
"WBR",
"OPTION",
"OPTGROUP",
]);
const mediaTags = new Set([
"IMG",
"SVG",
"CANVAS",
"VIDEO",
"IFRAME",
"INPUT",
"TEXTAREA",
"SELECT",
"BUTTON",
"HR",
]);
function isVisibleRect(rect) {
return (
rect.width >= 3 &&
rect.height >= 3 &&
rect.right > 0 &&
rect.bottom > 0 &&
rect.left < width &&
rect.top < height
);
}
function scanTargets() {
targetsDirty = false;
lastTargetScan = performance.now();
targets = [];
const elements = document.body.querySelectorAll("*");
for (const element of elements) {
const tag = element.tagName.toUpperCase();
if (
element === canvas ||
ignoredTags.has(tag) ||
!element.isConnected ||
element.closest("[data-pinball-ignore]")
) {
continue;
}
// Treat SVG graphics as one target, not individual SVG paths.
const svg = element.closest("svg");
if (svg && svg !== element) {
continue;
}
const rect = element.getBoundingClientRect();
if (
!isVisibleRect(rect) ||
rect.width * rect.height > width * height * 0.65
) {
continue;
}
const style = getComputedStyle(element);
if (
style.display === "none" ||
style.visibility !== "visible" ||
Number(style.opacity) === 0
) {
continue;
}
// Prefer visible leaf elements, rather than deleting containers
// full of content after a single hit.
if (!mediaTags.has(tag)) {
const hasVisibleChild = [...element.children].some((child) => {
if (ignoredTags.has(child.tagName.toUpperCase())) {
return false;
}
const childRect = child.getBoundingClientRect();
const childStyle = getComputedStyle(child);
return (
childRect.width > 0 &&
childRect.height > 0 &&
childStyle.display !== "none" &&
childStyle.visibility === "visible"
);
});
if (hasVisibleChild) {
continue;
}
const hasText = element.textContent.trim().length > 0;
const hasBackground =
style.backgroundImage !== "none" ||
!["transparent", "rgba(0, 0, 0, 0)"].includes(
style.backgroundColor,
);
if (!hasText && !hasBackground) {
continue;
}
}
targets.push({
element,
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
});
}
}
function burst(x, y) {
for (let i = 0; i < 18; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 60 + Math.random() * 230;
particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 0.5 + Math.random() * 0.3,
color: Math.random() > 0.5 ? "#40e0ff" : "#ffce54",
});
}
}
function breakTarget(target) {
const element = target.element;
if (!element.parentNode) {
return;
}
// A comment remembers the exact location for best-effort restoration.
const marker = document.createComment("page-pinball");
element.replaceWith(marker);
removals.push({ element, marker });
score += 100;
burst(ball.x, ball.y);
targetsDirty = true;
}
function reflect(nx, ny, restitution, surfaceVX = 0, surfaceVY = 0) {
const relativeVX = ball.vx - surfaceVX;
const relativeVY = ball.vy - surfaceVY;
const normalSpeed = relativeVX * nx + relativeVY * ny;
if (normalSpeed >= 0) {
return;
}
const impulse = -(1 + restitution) * normalSpeed;
ball.vx += impulse * nx;
ball.vy += impulse * ny;
}
function collideWithTarget(target) {
const nearestX = clamp(ball.x, target.left, target.right);
const nearestY = clamp(ball.y, target.top, target.bottom);
let dx = ball.x - nearestX;
let dy = ball.y - nearestY;
let distance = Math.hypot(dx, dy);
let penetration;
if (distance >= ball.radius) {
return false;
}
if (distance > 0.0001) {
dx /= distance;
dy /= distance;
penetration = ball.radius - distance;
} else {
// If layout changes place a target around the ball, resolve
// against the nearest edge instead of dividing by zero.
const edges = [
{ distance: ball.x - target.left, nx: -1, ny: 0 },
{ distance: target.right - ball.x, nx: 1, ny: 0 },
{ distance: ball.y - target.top, nx: 0, ny: -1 },
{ distance: target.bottom - ball.y, nx: 0, ny: 1 },
];
edges.sort((a, b) => a.distance - b.distance);
dx = edges[0].nx;
dy = edges[0].ny;
penetration = ball.radius + edges[0].distance;
}
ball.x += dx * (penetration + 0.5);
ball.y += dy * (penetration + 0.5);
reflect(dx, dy, CONFIG.targetBounce);
breakTarget(target);
return true;
}
function updateFlipper(flipper, dt) {
const desired = flipperPressed(flipper.side)
? flipper.raised
: flipper.rest;
const oldAngle = flipper.angle;
const maxChange = CONFIG.flipperSpeed * dt;
flipper.angle += clamp(
desired - flipper.angle,
-maxChange,
maxChange,
);
flipper.omega = (flipper.angle - oldAngle) / dt;
}
function collideWithFlipper(flipper) {
const segmentX = Math.cos(flipper.angle) * flipper.length;
const segmentY = Math.sin(flipper.angle) * flipper.length;
const t = clamp(
((ball.x - flipper.x) * segmentX +
(ball.y - flipper.y) * segmentY) /
(flipper.length * flipper.length),
0,
1,
);
const closestX = flipper.x + segmentX * t;
const closestY = flipper.y + segmentY * t;
let nx = ball.x - closestX;
let ny = ball.y - closestY;
const distance = Math.hypot(nx, ny);
const minDistance = ball.radius + flipper.radius;
if (distance >= minDistance) {
return;
}
if (distance > 0.0001) {
nx /= distance;
ny /= distance;
} else {
nx = Math.sin(flipper.angle);
ny = -Math.cos(flipper.angle);
if (ny > 0) {
nx = -nx;
ny = -ny;
}
}
ball.x = closestX + nx * (minDistance + 0.5);
ball.y = closestY + ny * (minDistance + 0.5);
// Velocity of the rotating paddle at the contact point.
const contactX = closestX + nx * flipper.radius;
const contactY = closestY + ny * flipper.radius;
const surfaceVX = -flipper.omega * (contactY - flipper.y);
const surfaceVY = flipper.omega * (contactX - flipper.x);
reflect(
nx,
ny,
CONFIG.flipperBounce,
surfaceVX,
surfaceVY,
);
}
function collideWithWalls() {
const radius = ball.radius;
if (ball.x < radius) {
ball.x = radius;
ball.vx = Math.abs(ball.vx) * CONFIG.wallBounce;
} else if (ball.x > width - radius) {
ball.x = width - radius;
ball.vx = -Math.abs(ball.vx) * CONFIG.wallBounce;
}
if (ball.y < radius) {
ball.y = radius;
ball.vy = Math.abs(ball.vy) * CONFIG.wallBounce;
}
// The bottom is open: a missed ball drains and respawns.
if (ball.y > height + radius * 3) {
ball.active = false;
ballsLost++;
respawnDelay = 0.8;
trail.length = 0;
}
}
function getGuideRails() {
const startY = Math.min(
height * 0.56,
flippers[0].y - 90,
);
const endY = flippers[0].y - 16;
const leftEndX =
flippers[0].x -
flippers[0].radius -
ball.radius -
4;
return { startY, endY, leftEndX };
}
function collideWithGuideRails() {
const { startY, endY, leftEndX } = getGuideRails();
if (ball.y < startY) {
return;
}
const onSlope = ball.y < endY;
const slope = onSlope
? leftEndX / (endY - startY)
: 0;
const leftX = onSlope
? slope * (ball.y - startY)
: leftEndX;
const rightX = width - leftX;
// Allow for the ball's radius against the sloped wall.
const clearance =
ball.radius * Math.sqrt(1 + slope * slope);
if (ball.x < leftX + clearance) {
ball.x = leftX + clearance;
const scale = Math.sqrt(1 + slope * slope);
reflect(
1 / scale,
-slope / scale,
CONFIG.wallBounce,
);
} else if (ball.x > rightX - clearance) {
ball.x = rightX - clearance;
const scale = Math.sqrt(1 + slope * slope);
reflect(
-1 / scale,
-slope / scale,
CONFIG.wallBounce,
);
}
}
function physics(dt) {
for (const flipper of flippers) {
updateFlipper(flipper, dt);
}
if (!ball.active) {
respawnDelay -= dt;
if (respawnDelay <= 0) {
launch();
}
return;
}
ball.vy += CONFIG.gravity * dt;
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
collideWithWalls();
if (!ball.active) {
return;
}
collideWithGuideRails();
for (const flipper of flippers) {
collideWithFlipper(flipper);
}
// Stop using old rectangles as soon as a removal changes layout.
// A fresh index will be built at the start of the next frame.
if (!targetsDirty) {
for (let i = targets.length - 1; i >= 0; i--) {
const target = targets[i];
if (!target.element.isConnected) {
targets.splice(i, 1);
continue;
}
if (collideWithTarget(target)) {
targets.splice(i, 1);
break;
}
}
}
const speed = Math.hypot(ball.vx, ball.vy);
if (speed > CONFIG.maxSpeed) {
ball.vx *= CONFIG.maxSpeed / speed;
ball.vy *= CONFIG.maxSpeed / speed;
}
}
function updateEffects(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const particle = particles[i];
particle.life -= dt;
particle.vy += 400 * dt;
particle.x += particle.vx * dt;
particle.y += particle.vy * dt;
if (particle.life <= 0) {
particles.splice(i, 1);
}
}
if (ball.active) {
trail.push({ x: ball.x, y: ball.y });
if (trail.length > 12) {
trail.shift();
}
}
}
function drawFlipper(flipper) {
const tipX =
flipper.x + Math.cos(flipper.angle) * flipper.length;
const tipY =
flipper.y + Math.sin(flipper.angle) * flipper.length;
ctx.lineCap = "round";
ctx.strokeStyle = "#102033";
ctx.lineWidth = flipper.radius * 2 + 5;
ctx.beginPath();
ctx.moveTo(flipper.x, flipper.y);
ctx.lineTo(tipX, tipY);
ctx.stroke();
ctx.strokeStyle = flipperPressed(flipper.side)
? "#ffce54"
: "#40e0ff";
ctx.lineWidth = flipper.radius * 2;
ctx.stroke();
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(flipper.x, flipper.y, 4, 0, Math.PI * 2);
ctx.fill();
}
function draw() {
ctx.clearRect(0, 0, width, height);
// Three-sided playfield; the bottom stays open.
ctx.lineCap = "butt";
ctx.lineWidth = 4;
ctx.strokeStyle = "rgba(64, 224, 255, 0.75)";
ctx.beginPath();
ctx.moveTo(2, height);
ctx.lineTo(2, 2);
ctx.lineTo(width - 2, 2);
ctx.lineTo(width - 2, height);
ctx.stroke();
for (const particle of particles) {
ctx.globalAlpha = clamp(particle.life * 2, 0, 1);
ctx.fillStyle = particle.color;
ctx.fillRect(particle.x - 2, particle.y - 2, 4, 4);
}
ctx.globalAlpha = 1;
const { startY, endY, leftEndX } = getGuideRails();
ctx.strokeStyle = "#ffce54";
ctx.lineWidth = 6;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(0, startY);
ctx.lineTo(leftEndX, endY);
ctx.lineTo(leftEndX, height);
ctx.moveTo(width, startY);
ctx.lineTo(width - leftEndX, endY);
ctx.lineTo(width - leftEndX, height);
ctx.stroke();
for (const flipper of flippers) {
drawFlipper(flipper);
}
trail.forEach((point, index) => {
ctx.globalAlpha = (index / trail.length) * 0.35;
ctx.fillStyle = "#40e0ff";
ctx.beginPath();
ctx.arc(
point.x,
point.y,
ball.radius * (index / trail.length),
0,
Math.PI * 2,
);
ctx.fill();
});
ctx.globalAlpha = 1;
if (ball.active) {
ctx.shadowColor = "#40e0ff";
ctx.shadowBlur = 16;
ctx.fillStyle = "#ffffff";
ctx.strokeStyle = "#102033";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
}
const panelWidth = Math.min(460, width - 24);
ctx.fillStyle = "rgba(9, 18, 32, 0.9)";
ctx.fillRect(12, 12, panelWidth, 74);
ctx.fillStyle = "#ffffff";
ctx.font = "bold 16px system-ui, sans-serif";
ctx.fillText(
`PINBALL ${score.toLocaleString()} pts | Lost: ${ballsLost}`,
24,
38,
panelWidth - 24,
);
ctx.fillStyle = "#bcefff";
ctx.font = "12px system-ui, sans-serif";
ctx.fillText(
"A / D or ← / →: flippers · Space: launch · Esc: exit",
24,
59,
panelWidth - 24,
);
ctx.fillStyle = "#ffce54";
ctx.fillText(
targets.length
? `${targets.length} visible targets`
: "Screen cleared! Scroll to find more targets.",
24,
76,
panelWidth - 24,
);
}
function frame(time) {
if (stopped) {
return;
}
const dt = Math.min((time - lastTime) / 1000, 0.04);
lastTime = time;
if (!paused) {
if (
targetsDirty ||
time - lastTargetScan > CONFIG.targetRefresh
) {
scanTargets();
}
// Small substeps reduce tunneling through thin page elements.
const steps = Math.max(
1,
Math.ceil(dt / CONFIG.physicsStep),
);
const step = dt / steps;
if (step > 0) {
for (let i = 0; i < steps; i++) {
physics(step);
}
}
updateEffects(dt);
draw();
}
frameId = requestAnimationFrame(frame);
}
function destroy() {
if (stopped) {
return;
}
stopped = true;
cancelAnimationFrame(frameId);
for (const removeListener of listeners) {
removeListener();
}
// Restore in reverse order in case a removed parent contains
// markers belonging to previously removed children.
for (let i = removals.length - 1; i >= 0; i--) {
const { element, marker } = removals[i];
if (marker.parentNode) {
marker.replaceWith(element);
}
}
canvas.remove();
delete window[KEY];
}
const controlKeys = new Set([
"ArrowLeft",
"ArrowRight",
"KeyA",
"KeyD",
"Space",
"Escape",
]);
function handleKeyboard(event) {
if (!controlKeys.has(event.code)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
if (event.type === "keyup") {
keys.delete(event.code);
return;
}
keys.add(event.code);
if (event.code === "Escape") {
destroy();
} else if (event.code === "Space" && !event.repeat) {
launch();
}
}
listen(window, "keydown", handleKeyboard, true);
listen(window, "keyup", handleKeyboard, true);
listen(
window,
"pointerdown",
(event) => {
if (event.pointerType === "mouse") {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
pointers.set(
event.pointerId,
event.clientX < width / 2 ? "left" : "right",
);
},
{ capture: true, passive: false },
);
listen(
window,
"pointermove",
(event) => {
if (!pointers.has(event.pointerId)) {
return;
}
event.preventDefault();
pointers.set(
event.pointerId,
event.clientX < width / 2 ? "left" : "right",
);
},
{ capture: true, passive: false },
);
const releasePointer = (event) => {
pointers.delete(event.pointerId);
};
listen(window, "pointerup", releasePointer, true);
listen(window, "pointercancel", releasePointer, true);
listen(
window,
"touchmove",
(event) => {
if (pointers.size) {
event.preventDefault();
}
},
{ passive: false },
);
listen(window, "blur", () => {
keys.clear();
pointers.clear();
});
listen(document, "visibilitychange", () => {
paused = document.hidden;
keys.clear();
pointers.clear();
lastTime = performance.now();
});
listen(window, "resize", resize);
listen(
window,
"scroll",
() => {
targetsDirty = true;
},
true,
);
window[KEY] = {
destroy,
launch,
refresh() {
targetsDirty = true;
},
get score() {
return score;
},
};
resize();
scanTargets();
launch();
frameId = requestAnimationFrame(frame);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment