(() => { const { Engine, Render, Runner, World, Bodies, Constraint, Events, Query, Vector, } = Matter; const config = { gravity: 1, spawnIntervalMs: 520, minChain: 3, palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"], ballRadius: 18, link: { stiffness: 0.9, lengthScale: 1, damping: 0.05, lineWidth: 3 }, }; const scenes = [ { id: "scene1", name: "Balanced (default)", config: { gravity: 1, spawnIntervalMs: 520, minChain: 3, palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"], ballRadius: 18, link: { stiffness: 0.9, lengthScale: 1, damping: 0.05, lineWidth: 3 }, }, createBodies: (w, h) => [ Bodies.rectangle(w / 2, h + 40, w, 80, { isStatic: true, restitution: 0.8, }), Bodies.rectangle(-40, h / 2, 80, h * 2, { isStatic: true }), Bodies.rectangle(w + 40, h / 2, 80, h * 2, { isStatic: true }), Bodies.rectangle(w * 0.25, h * 0.55, 160, 20, { isStatic: true, angle: -0.3, }), Bodies.rectangle(w * 0.7, h * 0.4, 220, 24, { isStatic: true, angle: 0.26, }), ], }, { id: "scene2", name: "Low-G terraces", config: { gravity: 0.65, spawnIntervalMs: 600, minChain: 3, palette: ["#fb7185", "#fbbf24", "#34d399", "#38bdf8"], ballRadius: 22, link: { stiffness: 0.6, lengthScale: 1.2, damping: 0.01, lineWidth: 4 }, }, createBodies: (w, h) => [ Bodies.rectangle(w / 2, h + 50, w, 100, { isStatic: true, restitution: 0.9, }), Bodies.rectangle(-50, h / 2, 100, h * 2, { isStatic: true }), Bodies.rectangle(w + 50, h / 2, 100, h * 2, { isStatic: true }), Bodies.rectangle(w * 0.2, h * 0.45, 200, 18, { isStatic: true, angle: 0.08, }), Bodies.rectangle(w * 0.5, h * 0.6, 260, 18, { isStatic: true, angle: -0.04, }), Bodies.rectangle(w * 0.8, h * 0.42, 180, 18, { isStatic: true, angle: 0.14, }), ], }, { id: "scene3", name: "Fast drop maze", config: { gravity: 1.25, spawnIntervalMs: 420, minChain: 3, palette: ["#e879f9", "#38bdf8", "#f97316", "#22c55e"], ballRadius: 16, link: { stiffness: 1, lengthScale: 0.85, damping: 0.15, lineWidth: 3 }, }, createBodies: (w, h) => { const bodies = [ Bodies.rectangle(w / 2, h + 40, w, 80, { isStatic: true, restitution: 0.75, }), Bodies.rectangle(-40, h / 2, 80, h * 2, { isStatic: true }), Bodies.rectangle(w + 40, h / 2, 80, h * 2, { isStatic: true }), ]; for (let i = 0; i < 5; i += 1) { const x = (w * (i + 1)) / 6; const y = h * 0.35 + (i % 2 === 0 ? 40 : -30); bodies.push( Bodies.circle(x, y, 18, { isStatic: true, restitution: 0.9 }), ); } bodies.push( Bodies.rectangle(w * 0.3, h * 0.55, 140, 16, { isStatic: true, angle: -0.3, }), Bodies.rectangle(w * 0.7, h * 0.58, 160, 16, { isStatic: true, angle: 0.28, }), ); return bodies; }, }, ]; const sceneEl = document.getElementById("scene-wrapper"); const activeColorEl = document.getElementById("active-color"); const chainLenEl = document.getElementById("chain-length"); const spawnRateEl = document.getElementById("spawn-rate"); const minLinkEl = document.getElementById("min-link"); const paletteLegendEl = document.getElementById("palette-legend"); const scoreEl = document.getElementById("score"); const highScoreEl = document.getElementById("high-score"); const sceneSelectEl = document.getElementById("scene-select"); const gameOverEl = document.getElementById("game-over"); const finalScoreEl = document.getElementById("final-score"); const restartBtn = document.getElementById("restart-btn"); const pauseBtn = document.getElementById("pause-btn"); const pauseOverlay = document.getElementById("pause-overlay"); let width = sceneEl.clientWidth; let height = sceneEl.clientHeight; const engine = Engine.create(); engine.gravity.y = config.gravity; const world = engine.world; const render = Render.create({ element: sceneEl, engine, options: { width, height, wireframes: false, background: "transparent", showAngleIndicator: false, pixelRatio: window.devicePixelRatio || 1, }, }); Render.run(render); const runner = Runner.create(); Runner.run(runner, engine); // Static boundaries and scene-specific obstacles. let boundaries = []; let currentScene = scenes[0]; const rebuildSceneBodies = () => { boundaries.forEach((b) => World.remove(world, b)); boundaries = currentScene.createBodies(width, height); World.add(world, boundaries); }; const balls = []; let spawnTimer = null; let score = 0; let highScore = 0; let gameOver = false; let isPaused = false; const makeStorageKey = (sceneId) => `physilinks-highscore-${sceneId}`; const loadHighScore = (sceneId) => { try { const raw = localStorage.getItem(makeStorageKey(sceneId)); const parsed = parseInt(raw, 10); return Number.isFinite(parsed) ? parsed : 0; } catch (err) { return 0; } }; const saveHighScore = () => { try { localStorage.setItem(makeStorageKey(currentScene.id), String(highScore)); } catch (err) { // ignore write failures (private mode or blocked storage) } }; const populateSceneSelect = () => { if (!sceneSelectEl) return; sceneSelectEl.innerHTML = ""; scenes.forEach((scene) => { const opt = document.createElement("option"); opt.value = scene.id; opt.textContent = scene.name; sceneSelectEl.appendChild(opt); }); }; const applyScene = (sceneId) => { const next = scenes.find((s) => s.id === sceneId) || scenes[0]; currentScene = next; if (sceneSelectEl) sceneSelectEl.value = next.id; Object.assign(config, next.config); config.link = { ...next.config.link }; engine.gravity.y = config.gravity; highScore = loadHighScore(next.id); rebuildSceneBodies(); buildLegend(); restartGame(); updateHud(); }; const chain = { active: false, color: null, bodies: [], constraints: [], pointer: null, }; const spawnBall = () => { if (gameOver) return; const color = config.palette[Math.floor(Math.random() * config.palette.length)]; const x = Math.max( config.ballRadius + 10, Math.min(width - config.ballRadius - 10, Math.random() * width), ); const y = -config.ballRadius * 2; const ball = Bodies.circle(x, y, config.ballRadius, { restitution: 0.72, friction: 0.01, frictionAir: 0.015, render: { fillStyle: color, strokeStyle: "#0b1222", lineWidth: 2, }, }); ball.plugin = { color, hasEntered: false, entryCheckId: null }; balls.push(ball); World.add(world, ball); ball.plugin.entryCheckId = setTimeout(() => { ball.plugin.entryCheckId = null; if (gameOver) return; if (!ball.plugin.hasEntered && Math.abs(ball.velocity.y) < 0.2) { triggerGameOver(); } }, 1500); }; const startSpawner = () => { if (spawnTimer) clearInterval(spawnTimer); spawnTimer = setInterval(spawnBall, config.spawnIntervalMs); }; const stopSpawner = () => { if (spawnTimer) { clearInterval(spawnTimer); spawnTimer = null; } }; const cleanupBall = (ball) => { if (ball.plugin && ball.plugin.entryCheckId) { clearTimeout(ball.plugin.entryCheckId); ball.plugin.entryCheckId = null; } }; const triggerGameOver = () => { if (gameOver) return; gameOver = true; isPaused = false; resetChainVisuals(); stopSpawner(); engine.timing.timeScale = 0; pauseOverlay.classList.remove("visible"); pauseBtn.textContent = "Pause"; finalScoreEl.textContent = score; gameOverEl.classList.add("visible"); }; const restartGame = () => { gameOver = false; isPaused = false; score = 0; resetChainVisuals(); balls.forEach((ball) => { cleanupBall(ball); World.remove(world, ball); }); balls.length = 0; gameOverEl.classList.remove("visible"); pauseOverlay.classList.remove("visible"); pauseBtn.textContent = "Pause"; engine.timing.timeScale = 1; updateHud(); startSpawner(); }; const setHighlight = (body, on) => { body.render.lineWidth = on ? 4 : 2; body.render.strokeStyle = on ? "#f8fafc" : "#0b1222"; }; const setPaused = (state) => { if (gameOver) return; if (state === isPaused) return; isPaused = state; pauseBtn.textContent = isPaused ? "Resume" : "Pause"; pauseOverlay.classList.toggle("visible", isPaused); if (isPaused) { resetChainVisuals(); stopSpawner(); engine.timing.timeScale = 0; } else { startSpawner(); engine.timing.timeScale = 1; } }; const resetChainVisuals = () => { chain.bodies.forEach((b) => setHighlight(b, false)); chain.constraints.forEach((c) => World.remove(world, c)); chain.active = false; chain.color = null; chain.bodies = []; chain.constraints = []; chain.pointer = null; updateHud(); }; const removeLastFromChain = () => { const removedConstraint = chain.constraints.pop(); if (removedConstraint) { World.remove(world, removedConstraint); } const removedBody = chain.bodies.pop(); if (removedBody) setHighlight(removedBody, false); updateHud(); }; const addToChain = (body) => { const last = chain.bodies[chain.bodies.length - 1]; const dist = Vector.magnitude(Vector.sub(last.position, body.position)); const linkCfg = config.link || {}; const constraint = Constraint.create({ bodyA: last, bodyB: body, length: dist * (linkCfg.lengthScale ?? 1), stiffness: linkCfg.stiffness ?? 0.9, damping: linkCfg.damping ?? 0, render: { strokeStyle: chain.color, lineWidth: linkCfg.lineWidth ?? 3, }, }); chain.constraints.push(constraint); chain.bodies.push(body); setHighlight(body, true); World.add(world, constraint); updateHud(); }; const spawnScorePopup = (point, amount, color) => { if (!point) return; const el = document.createElement("div"); el.className = "floating-score"; el.textContent = `+${amount}`; el.style.left = `${point.x}px`; el.style.top = `${point.y}px`; el.style.color = color || "#e0f2fe"; sceneEl.appendChild(el); setTimeout(() => el.remove(), 950); }; const finishChain = (releasePoint) => { if (!chain.active || gameOver || isPaused) return; if (chain.bodies.length >= config.minChain) { const gain = 10 * Math.pow(chain.bodies.length, 2); score += gain; if (score > highScore) { highScore = score; saveHighScore(); } spawnScorePopup(releasePoint || chain.pointer, gain, chain.color); chain.constraints.forEach((c) => World.remove(world, c)); chain.bodies.forEach((body) => { cleanupBall(body); World.remove(world, body); }); // Remove cleared balls from tracking list. for (let i = balls.length - 1; i >= 0; i -= 1) { if (chain.bodies.includes(balls[i])) { balls.splice(i, 1); } } } else { chain.constraints.forEach((c) => World.remove(world, c)); chain.bodies.forEach((b) => setHighlight(b, false)); } chain.active = false; chain.color = null; chain.bodies = []; chain.constraints = []; chain.pointer = null; updateHud(); }; const pickBody = (point) => { const hits = Query.point(balls, point); return hits[0]; }; const getPointerPosition = (evt) => { const rect = render.canvas.getBoundingClientRect(); const clientX = evt.touches ? evt.touches[0].clientX : evt.clientX; const clientY = evt.touches ? evt.touches[0].clientY : evt.clientY; return { x: clientX - rect.left, y: clientY - rect.top, }; }; const handlePointerDown = (evt) => { if (gameOver || isPaused) return; const point = getPointerPosition(evt); const body = pickBody(point); if (!body) return; chain.active = true; chain.color = body.plugin.color; chain.bodies = [body]; chain.constraints = []; chain.pointer = point; setHighlight(body, true); updateHud(); }; const handlePointerMove = (evt) => { if (!chain.active) return; if (gameOver || isPaused) return; const point = getPointerPosition(evt); chain.pointer = point; const body = pickBody(point); if (!body) return; const alreadyInChain = chain.bodies.includes(body); if (alreadyInChain) { const targetIndex = chain.bodies.indexOf(body); if (chain.bodies.length > 1 && targetIndex === chain.bodies.length - 2) { removeLastFromChain(); } return; } if (body.plugin.color !== chain.color) return; addToChain(body); }; const handlePointerUp = () => finishChain(chain.pointer); render.canvas.addEventListener("mousedown", handlePointerDown); render.canvas.addEventListener("mousemove", handlePointerMove); window.addEventListener("mouseup", handlePointerUp); render.canvas.addEventListener( "touchstart", (e) => { e.preventDefault(); handlePointerDown(e); }, { passive: false }, ); render.canvas.addEventListener( "touchmove", (e) => { e.preventDefault(); handlePointerMove(e); }, { passive: false }, ); render.canvas.addEventListener( "touchend", (e) => { e.preventDefault(); handlePointerUp(e); }, { passive: false }, ); const updateHud = () => { spawnRateEl.textContent = `${config.spawnIntervalMs} ms`; minLinkEl.textContent = config.minChain; chainLenEl.textContent = chain.bodies.length; scoreEl.textContent = score; highScoreEl.textContent = highScore; if (chain.color) { activeColorEl.textContent = ""; activeColorEl.style.display = "inline-block"; activeColorEl.style.width = "14px"; activeColorEl.style.height = "14px"; activeColorEl.style.borderRadius = "50%"; activeColorEl.style.background = chain.color; activeColorEl.style.border = "1px solid rgba(255,255,255,0.3)"; } else { activeColorEl.removeAttribute("style"); activeColorEl.textContent = "—"; } }; const buildLegend = () => { paletteLegendEl.innerHTML = ""; config.palette.forEach((color) => { const swatch = document.createElement("span"); swatch.style.background = color; paletteLegendEl.appendChild(swatch); }); }; const handleResize = () => { width = sceneEl.clientWidth; height = sceneEl.clientHeight; render.canvas.width = width; render.canvas.height = height; render.options.width = width; render.options.height = height; Render.lookAt(render, { min: { x: 0, y: 0 }, max: { x: width, y: height }, }); rebuildSceneBodies(); }; Events.on(engine, "afterUpdate", () => { // Keep stray balls within the play area horizontally. balls.forEach((ball) => { if ( !ball.plugin.hasEntered && ball.position.y > config.ballRadius * 1.5 ) { ball.plugin.hasEntered = true; } if ( ball.position.x < -100 || ball.position.x > width + 100 || ball.position.y > height + 500 ) { cleanupBall(ball); ball.plugin.hasEntered = true; Matter.Body.setPosition(ball, { x: Math.random() * width, y: -40 }); Matter.Body.setVelocity(ball, { x: 0, y: 0 }); } }); }); Events.on(render, "afterRender", () => { if (!chain.active || !chain.pointer || chain.bodies.length === 0) return; const last = chain.bodies[chain.bodies.length - 1]; const ctx = render.context; ctx.save(); ctx.strokeStyle = chain.color || "#fff"; ctx.lineWidth = 3; ctx.setLineDash([6, 6]); ctx.beginPath(); ctx.moveTo(last.position.x, last.position.y); ctx.lineTo(chain.pointer.x, chain.pointer.y); ctx.stroke(); ctx.restore(); }); window.addEventListener("resize", handleResize); restartBtn.addEventListener("click", restartGame); pauseBtn.addEventListener("click", () => setPaused(!isPaused)); window.addEventListener("keydown", (e) => { if (e.key === "Escape") { setPaused(!isPaused); } }); if (sceneSelectEl) { sceneSelectEl.addEventListener("change", (e) => applyScene(e.target.value)); } populateSceneSelect(); applyScene(currentScene.id); })();