My-First-Space / game.html
mendeld's picture
Update game.html
f652856 verified
Raw History Blame Contribute Delete
80.3 kB
<!DOCTYPE html>
<!-- VERSION: PERMANENT-MENORAH-AUTO-LANDING-MELT + MULTI-UPGRADE-SHOP + ACCOUNT-UI v16 -->
<!-- VERSION: DOUBLE-JUMP-FIX + POLISH-PASS-1 v11 -->
<!-- VERSION: FIXED-GREEN-PLATFORM-SPEED + FIXED-RUNTIME + FIXED-BOOK-PATTERN + INFINITE-STABLE-BOOKCASE + FULLSCREEN + TALL-AUTO-EXTENDING-WORLD + ALWAYS-VISIBLE-JUMP v8 -->
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Rabbi Bounce - Candy Land</title>
<style>
html, body { width:100%; height:100%; overflow:hidden; }
body { margin:0; background:#1a1025; font-family:'Comic Sans MS', Arial, sans-serif; touch-action:none; }
canvas { display:block; width:100vw; height:100vh; width:100dvw; height:100dvh; background:#ffe0f2; touch-action:none; }
#backToSite {
position: fixed;
top: 12px;
left: 15px;
z-index: 4000;
padding: 7px 12px;
border: none;
border-radius: 8px;
font-family: "Poppins", Arial, sans-serif;
font-size: 13px;
cursor: pointer;
}
#tiltButton {
position: fixed;
top: 50px;
left: 15px;
z-index: 4000;
padding: 7px 12px;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
display: none;
}
#welcomeUserGame {
position: fixed;
top: 12px;
right: 15px;
z-index: 4000;
font-family: "Poppins", Arial, sans-serif;
font-size: 14px;
font-weight: 600;
color: white;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
}
#logoutGameButton {
position: fixed;
top: 38px;
right: 15px;
z-index: 4000;
padding: 7px 12px;
border: none;
border-radius: 8px;
font-family: "Poppins", Arial, sans-serif;
font-size: 13px;
cursor: pointer;
}
.customContextMenu {
position: fixed;
z-index: 6000;
display: none;
min-width: 220px;
padding: 8px;
background: rgba(255, 255, 255, 0.97);
border: 1px solid rgba(35, 40, 80, 0.16);
border-radius: 16px;
box-shadow: 0 16px 42px rgba(35, 40, 80, 0.22);
backdrop-filter: blur(10px);
text-align: left;
font-family: "Poppins", Arial, sans-serif;
}
.customContextMenu.show {
display: block;
}
.customContextMenu button {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 11px 12px;
border: 0;
border-radius: 10px;
background: transparent;
color: #2f344d;
font-size: 15px;
text-align: left;
cursor: pointer;
}
.customContextMenu button:hover,
.customContextMenu button:focus-visible {
background: #eef3ff;
outline: none;
}
.contextIcon {
width: 24px;
text-align: center;
}
.contextDivider {
height: 1px;
margin: 6px 4px;
background: rgba(35, 40, 80, 0.12);
}
</style>
</head>
<body>
<noscript>
<style>
body > * { display: none !important; }
#javascriptRequired {
display: block !important;
color: white;
font-family: Arial, sans-serif;
text-align: center;
margin-top: 100px;
font-size: 20px;
}
</style>
<div id="javascriptRequired">JavaScript is required to play Rabbi Bounce.</div>
</noscript>
<button
id="backToSite"
onclick="window.location.href='index.html'"
>
← Back
</button>
<button id="tiltButton" type="button">Enable tilt</button>
<div id="welcomeUserGame"></div>
<button id="logoutGameButton" type="button">Log out</button>
<div
id="gameContextMenu"
class="customContextMenu"
role="menu"
aria-hidden="true"
>
<button type="button" role="menuitem" data-action="home">
<span class="contextIcon">🏠</span>
<span>Game menu</span>
</button>
<button type="button" role="menuitem" data-action="clicker">
<span class="contextIcon">🖱️</span>
<span>Play Clicker</span>
</button>
<button type="button" role="menuitem" data-action="rabbi">
<span class="contextIcon">📚</span>
<span>Rabbi Bounce menu</span>
</button>
<div class="contextDivider"></div>
<button type="button" role="menuitem" data-action="logout">
<span class="contextIcon">↪️</span>
<span>Log out</span>
</button>
</div>
<canvas id="game"></canvas>
<script>
if (
localStorage.getItem("loggedIn") !== "true" ||
!localStorage.getItem("username")
) {
window.location.href = "index.html";
}
function logoutGame(){
localStorage.removeItem("loggedIn");
localStorage.removeItem("username");
window.location.href = "index.html";
}
document.getElementById("logoutGameButton").addEventListener("click", logoutGame);
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
// The game keeps the original 800x600 scale as its minimum logical size,
// but the logical WORLD expands to match the shape of the actual screen.
// That means there are no empty side bars on a wide monitor and no empty
// top/bottom bands on a tall phone: shelves, platforms and hazards can use
// every visible part of the screen without stretching the artwork.
const LOGICAL_WIDTH = 800;
const LOGICAL_HEIGHT = 600;
let viewScale = 1;
let visibleWorldWidth = LOGICAL_WIDTH;
let visibleWorldHeight = LOGICAL_HEIGHT;
let WORLD_HALF = LOGICAL_WIDTH / 2;
let VIEW_HALF_HEIGHT = LOGICAL_HEIGHT / 2;
function resizeGameCanvas(){
const cssWidth = Math.max(1, window.innerWidth);
const cssHeight = Math.max(1, window.innerHeight);
const dpr = Math.max(1, window.devicePixelRatio || 1);
canvas.style.width = cssWidth + 'px';
canvas.style.height = cssHeight + 'px';
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
// Use one uniform scale so circles stay round and the game never stretches.
// Whichever dimension has extra room simply reveals MORE of the game world.
viewScale = Math.min(
canvas.width / LOGICAL_WIDTH,
canvas.height / LOGICAL_HEIGHT
);
visibleWorldWidth = canvas.width / viewScale;
visibleWorldHeight = canvas.height / viewScale;
WORLD_HALF = visibleWorldWidth / 2;
VIEW_HALF_HEIGHT = visibleWorldHeight / 2;
}
resizeGameCanvas();
window.addEventListener('resize', resizeGameCanvas);
window.addEventListener('orientationchange', () => {
setTimeout(resizeGameCanvas, 100);
tiltNeutral = null;
});
const screen = {
clear(color){
ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle = color;
ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.setTransform(
viewScale,
0,
0,
-viewScale,
canvas.width / 2,
canvas.height / 2
);
},
drawRect(x,y,w,h,color){
ctx.fillStyle = color;
ctx.fillRect(x-w/2, y-h/2, w, h);
},
drawCircle(x,y,r,color){
ctx.beginPath();
ctx.arc(x,y,r,0,Math.PI*2);
ctx.fillStyle = color;
ctx.fill();
},
drawText(text,x,y,size,color){
ctx.save();
ctx.scale(1,-1);
ctx.fillStyle = color;
ctx.font = `bold ${size}px 'Comic Sans MS', Arial, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, x, -y);
ctx.restore();
}
};
const keys = {};
window.addEventListener('keydown', e => { keys[e.key] = true; if(e.key===' ') e.preventDefault(); });
window.addEventListener('keyup', e => keys[e.key] = false);
const mouse = { x:0, y:0, pressed:false };
function updatePointer(clientX, clientY){
const r = canvas.getBoundingClientRect();
const backingX = (clientX - r.left) * (canvas.width / r.width);
const backingY = (clientY - r.top) * (canvas.height / r.height);
mouse.x = (backingX - canvas.width / 2) / viewScale;
mouse.y = -((backingY - canvas.height / 2) / viewScale);
}
canvas.addEventListener('mousedown', e => { if(e.button !== 0) return; mouse.pressed = true; updatePointer(e.clientX, e.clientY); });
window.addEventListener('mouseup', () => mouse.pressed = false);
canvas.addEventListener('mousemove', e => updatePointer(e.clientX, e.clientY));
canvas.addEventListener('touchstart', e => { e.preventDefault(); mouse.pressed = true; updatePointer(e.touches[0].clientX, e.touches[0].clientY); }, {passive:false});
canvas.addEventListener('touchmove', e => { e.preventDefault(); updatePointer(e.touches[0].clientX, e.touches[0].clientY); }, {passive:false});
canvas.addEventListener('touchend', e => { e.preventDefault(); mouse.pressed = false; }, {passive:false});
// -----------------------------
// PHONE TILT CONTROLS
// -----------------------------
const tiltButton = document.getElementById('tiltButton');
let tiltEnabled = false;
let tiltListening = false;
let tiltNeutral = null;
let tiltMove = 0;
function isProbablyMobile(){
return window.matchMedia('(pointer: coarse)').matches ||
/Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
}
function getSideTilt(event){
const rawAngle = (window.screen.orientation && typeof window.screen.orientation.angle === 'number')
? window.screen.orientation.angle
: (typeof window.orientation === 'number' ? window.orientation : 0);
const angle = ((rawAngle % 360) + 360) % 360;
if(angle === 90) return event.beta ?? 0;
if(angle === 270) return -(event.beta ?? 0);
if(angle === 180) return -(event.gamma ?? 0);
return event.gamma ?? 0;
}
function handleDeviceOrientation(event){
const sideways = getSideTilt(event);
if(tiltNeutral === null){
tiltNeutral = sideways;
tiltMove = 0;
return;
}
const difference = sideways - tiltNeutral;
const deadZone = 4;
if(Math.abs(difference) <= deadZone){
tiltMove = 0;
} else {
tiltMove = Math.max(-1, Math.min(1, difference / 22));
}
}
function startTiltListening(){
if(tiltListening) return;
window.addEventListener('deviceorientation', handleDeviceOrientation, true);
tiltListening = true;
tiltEnabled = true;
tiltNeutral = null;
tiltButton.textContent = 'Tilt on';
setTimeout(() => {
if(tiltEnabled) tiltButton.style.display = 'none';
}, 1200);
}
async function enableTiltControls(){
if(!('DeviceOrientationEvent' in window)){
tiltButton.textContent = 'Tilt unavailable';
return;
}
try{
if(typeof DeviceOrientationEvent.requestPermission === 'function'){
const result = await DeviceOrientationEvent.requestPermission();
if(result !== 'granted'){
tiltButton.textContent = 'Tilt blocked';
return;
}
}
startTiltListening();
} catch(error){
tiltButton.textContent = 'Tilt blocked';
}
}
if(isProbablyMobile() && 'DeviceOrientationEvent' in window){
if(typeof DeviceOrientationEvent.requestPermission === 'function'){
tiltButton.style.display = 'block';
} else {
startTiltListening();
}
}
tiltButton.addEventListener('click', enableTiltControls);
// =====================================
// SOUND SETTINGS - SAVED PER USER
// =====================================
const SOUND_SETTINGS_PREFIX =
"rabbiBounceSound_v1_";
function getSoundSettingsKey() {
const username =
localStorage.getItem("username") || "guest";
return SOUND_SETTINGS_PREFIX + username;
}
let soundMuted = false;
let soundVolume = 100;
function loadSoundSettings() {
try {
const raw =
localStorage.getItem(
getSoundSettingsKey()
);
if (!raw) return;
const saved =
JSON.parse(raw);
soundMuted =
saved.muted === true;
if (
Number.isFinite(
Number(saved.volume)
)
) {
soundVolume =
Math.max(
0,
Math.min(
100,
Number(saved.volume)
)
);
}
} catch(error) {
// Use defaults if saved settings are damaged.
}
}
function saveSoundSettings() {
try {
localStorage.setItem(
getSoundSettingsKey(),
JSON.stringify({
muted: soundMuted,
volume: soundVolume
})
);
} catch(error) {}
}
loadSoundSettings();
// =====================================
// GAME SOUNDS
// =====================================
let audioCtx;
function playBeep(freq, duration) {
if (
soundMuted ||
soundVolume <= 0
) {
return;
}
if (!audioCtx) {
audioCtx =
new (
window.AudioContext ||
window.webkitAudioContext
)();
}
if (
audioCtx.state === "suspended"
) {
audioCtx.resume().catch(
function() {}
);
}
const osc =
audioCtx.createOscillator();
const gain =
audioCtx.createGain();
osc.frequency.value = freq;
osc.type = "square";
const actualVolume =
0.15 *
(soundVolume / 100);
gain.gain.setValueAtTime(
actualVolume,
audioCtx.currentTime
);
gain.gain.exponentialRampToValueAtTime(
0.001,
audioCtx.currentTime + duration
);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(
audioCtx.currentTime + duration
);
}
// =====================================
// SOUND SETTINGS BUTTON
// =====================================
function createSoundSettingsUI() {
const soundButton =
document.createElement("button");
soundButton.type = "button";
const soundPanel =
document.createElement("div");
const soundTitle =
document.createElement("div");
const muteButton =
document.createElement("button");
muteButton.type = "button";
const volumeLabel =
document.createElement("div");
const volumeSlider =
document.createElement("input");
volumeSlider.type = "range";
volumeSlider.min = "0";
volumeSlider.max = "100";
volumeSlider.step = "1";
volumeSlider.value =
String(soundVolume);
// -----------------------------
// SOUND BUTTON STYLE
// -----------------------------
Object.assign(
soundButton.style,
{
position: "fixed",
top: "100px",
right: "12px",
zIndex: "2000",
padding: "8px 12px",
border: "none",
borderRadius: "8px",
fontSize: "14px",
cursor: "pointer",
background: "white"
}
);
// -----------------------------
// SOUND PANEL STYLE
// -----------------------------
Object.assign(
soundPanel.style,
{
position: "fixed",
top: "142px",
right: "12px",
zIndex: "2001",
width: "210px",
padding: "14px",
borderRadius: "12px",
background: "white",
boxShadow:
"0 6px 20px rgba(0,0,0,0.3)",
textAlign: "center",
display: "none",
touchAction: "auto"
}
);
Object.assign(
soundTitle.style,
{
fontSize: "17px",
fontWeight: "bold",
marginBottom: "10px"
}
);
soundTitle.textContent =
"Sound Settings";
Object.assign(
muteButton.style,
{
padding: "7px 12px",
marginBottom: "12px",
border: "none",
borderRadius: "7px",
cursor: "pointer"
}
);
Object.assign(
volumeLabel.style,
{
fontSize: "14px",
marginBottom: "6px"
}
);
volumeSlider.style.width =
"170px";
volumeSlider.style.cursor =
"pointer";
function updateSoundDisplay() {
soundButton.textContent =
soundMuted
? "🔇 Sound"
: "🔊 Sound";
muteButton.textContent =
soundMuted
? "Unmute"
: "Mute";
volumeLabel.textContent =
"Volume: " +
Math.round(soundVolume) +
"%";
}
// Open / close settings
soundButton.addEventListener(
"click",
function() {
soundPanel.style.display =
soundPanel.style.display ===
"none"
? "block"
: "none";
}
);
// Mute / unmute
muteButton.addEventListener(
"click",
function() {
soundMuted =
!soundMuted;
saveSoundSettings();
updateSoundDisplay();
if (!soundMuted) {
playBeep(
700,
0.08
);
}
}
);
// Change volume
volumeSlider.addEventListener(
"input",
function() {
soundVolume =
Number(
volumeSlider.value
);
saveSoundSettings();
updateSoundDisplay();
}
);
// Small test beep after moving slider
volumeSlider.addEventListener(
"change",
function() {
if (
!soundMuted &&
soundVolume > 0
) {
playBeep(
700,
0.08
);
}
}
);
soundPanel.appendChild(
soundTitle
);
soundPanel.appendChild(
muteButton
);
soundPanel.appendChild(
volumeLabel
);
soundPanel.appendChild(
volumeSlider
);
document.body.appendChild(
soundButton
);
document.body.appendChild(
soundPanel
);
updateSoundDisplay();
}
createSoundSettingsUI();
function hitRect(px, py, r){
return px > r.x - r.w/2 && px < r.x + r.w/2 && py > r.y - r.h/2 && py < r.y + r.h/2;
}
const currentUsername =
localStorage.getItem("username");
document.getElementById("welcomeUserGame").textContent =
"Hello, " + currentUsername + "!";
const SAVE_KEY =
"rabbiBounceSave_v2_" + currentUsername;
// Keep the old key available so existing coins / best score can migrate once.
const OLD_SAVE_KEY =
"rabbiBounceSave_v1_" + currentUsername;
function loadSave(){
try{
const newRaw = localStorage.getItem(SAVE_KEY);
if(newRaw) return JSON.parse(newRaw);
const oldRaw = localStorage.getItem(OLD_SAVE_KEY);
return oldRaw ? JSON.parse(oldRaw) : {};
}catch(e){
return {};
}
}
const savedData = loadSave();
function saveGame(){
try{
localStorage.setItem(SAVE_KEY, JSON.stringify({
coins: coinsCollected,
best,
doubleJumpSeconds,
slowFallSeconds,
highBounceSeconds,
coinMagnetSeconds,
menorahsOwned,
candlesOwned
}));
}catch(e){}
}
const START_BTN = {x:0, y:-180, w:220, h:60};
const HELP_BTN = {x:0, y:-240, w:180, h:48};
const BACK_BTN = {x:0, y:-220, w:220, h:60};
const HELP_PREV_BTN = {x:-360, y:30, w:64, h:64};
const HELP_NEXT_BTN = {x:360, y:30, w:64, h:64};
let helpPage = 0;
let helpAnimTimer = 0;
let helpLeftWasDown = false, helpRightWasDown = false, helpSpaceWasDown = false;
let helpReturnState = 'title';
const PAUSE_BTN = {x:365, y:220, w:64, h:46};
const PAUSE_RESUME_BTN = {x:-140, y:-110, w:230, h:56};
const PAUSE_MENU_BTN = {x:140, y:-110, w:230, h:56};
const PAUSE_SHOP_BTN = {x:-140, y:-190, w:230, h:56};
const PAUSE_HELP_BTN = {x:140, y:-190, w:230, h:56};
const PLAY_AGAIN_BTN = {x:0, y:-80, w:240, h:60};
// Shop prices. Timed upgrades begin immediately and only count down while playing.
const DOUBLE_JUMP_COST_PER_MINUTE = 8;
const SLOW_FALL_COST_PER_MINUTE = 6;
const HIGH_BOUNCE_COST_PER_MINUTE = 10;
const COIN_MAGNET_COST_PER_MINUTE = 5;
const MENORAH_COST = 150;
const CANDLE_COST = 1;
const MAX_CANDLES = 18;
const SHOP_DOUBLE_JUMP_BTN = {x:-180, y:150, w:310, h:72};
const SHOP_SLOW_FALL_BTN = {x:180, y:150, w:310, h:72};
const SHOP_HIGH_BOUNCE_BTN = {x:-180, y:55, w:310, h:72};
const SHOP_COIN_MAGNET_BTN = {x:180, y:55, w:310, h:72};
const SHOP_MENORAH_BTN = {x:-180, y:-40, w:310, h:72};
const SHOP_CANDLES_BTN = {x:180, y:-40, w:310, h:72};
const JUMP_BTN = {x:330, y:-225, w:96, h:96};
const HELP_PAGES = [
{
title: 'Purple Platform',
kind: 'start',
lines: [
'This is where you begin your climb.',
'The dashed lines mark safe ground — nothing dangerous here!'
]
},
{
title: 'Blue Platforms',
kind: 'normal',
lines: [
'Totally safe — just land on it and you bounce automatically.',
'No spikes, no movement, nothing to worry about.'
]
},
{
title: 'Green Platforms',
kind: 'moving',
lines: [
'Safe to land on, but they slide side to side.',
'Time your jump so you land on it, not next to it!'
]
},
{
title: 'Yellow Platforms',
kind: 'spiked',
lines: [
'Spikes on top make these dangerous to land on.',
'Bump it from below first to clear the spikes — then it\'s safe!'
]
},
{
title: 'Orange Platforms',
kind: 'trapdoor',
lines: [
'These hide their spikes underneath instead of on top.',
'Land on top to clear it safely — don\'t hit the bottom!'
]
},
{
title: 'Red Platforms',
kind: 'moving-spiked',
lines: [
'Like yellow platforms, but they slide side to side too.',
'Bump it from below to clear the spikes before you land.'
]
},
{
title: 'Gray Platforms',
kind: 'allspike',
lines: [
'Spikes cover every side of this platform.',
'Never touch it at all — jump around it instead.'
]
},
{
title: 'Black Platforms',
kind: 'allspike-moving',
lines: [
'Just as dangerous as gray platforms, and it slides side to side too.',
'Spikes on every side — never touch it, just jump around it.'
]
},
{
title: 'Laser Beams',
kind: 'laser',
lines: [
'Red lasers switch on and off as you climb higher.',
'Watch the timing and only pass through while the beam is off.'
]
}
];
const GRAVITY = 0.45, FALL_GRAVITY = 0.18, BOUNCE_V = 15, MOVE_SPEED = 6.5;
const CANDY_COLORS = ['#8b2e2e','#2e4a7d','#2e6b4f','#a67c27','#5a2e6b'];
const BOOK_SPINE_COLORS = ['#0e1830','#4d1a1a','#3b2415'];
const CAMERA_SMOOTH = 0.08, DESIRED_SCREEN_Y = -150, START_PLATFORM_Y = -20;
// The climb is pre-generated very far upward so it feels like one enormous
// continuous tower. When the player gets close to the currently generated
// ceiling, another equally large section is created automatically.
const INITIAL_WORLD_TOP = 50000; // about 83 original 600px screens tall
const WORLD_EXTENSION = 50000; // each extension adds another huge section
const WORLD_EXTENSION_MARGIN = 8000; // start extending long before the player sees the top
let generatedWorldTop = INITIAL_WORLD_TOP;
let state = 'title';
let player, camera, platforms, nextPlatformY, score, best = savedData.best || 0, wobble = 0, baseline = 0, landingCount = 0, scoringStarted = false, lastLandedY = 0, lasers = [];
let lastPlatformX = 0;
let lastWasUnlandable = false;
let coins = [], coinsCollected = savedData.coins || 0, coinAnimTimer = 0;
const COIN_FLOAT_Y = 26, COIN_R = 11;
// Timed shop upgrades and inventory are saved separately for each username.
// Old banked Double Jump minutes are converted into active Double Jump time once.
const migratedOldDoubleJumpSeconds =
!savedData.doubleJumpSeconds && savedData.minutesBank
? Number(savedData.minutesBank) * 60
: 0;
let doubleJumpSeconds = Math.max(0, Number(savedData.doubleJumpSeconds) || migratedOldDoubleJumpSeconds || 0);
let slowFallSeconds = Math.max(0, Number(savedData.slowFallSeconds) || 0);
let highBounceSeconds = Math.max(0, Number(savedData.highBounceSeconds) || 0);
let coinMagnetSeconds = Math.max(0, Number(savedData.coinMagnetSeconds) || 0);
let menorahsOwned = Math.max(0, Math.floor(Number(savedData.menorahsOwned) || 0));
let candlesOwned = Math.max(0, Math.min(MAX_CANDLES, Math.floor(Number(savedData.candlesOwned) || 0)));
let doubleJumpReady = false;
let jumpInputWasDownPlaying = false;
let lastFrameTime = null;
let lastTimedSaveAt = 0;
let menorahUse = null;
let menorahStatus = '';
let menorahStatusUntil = 0;
// Polish / animation state
let countdownStartedAt = 0;
const COUNTDOWN_MS = 3200;
let landingParticles = [];
let gameOverStartedAt = 0;
let gameOverAngle = 0;
function init(){
baseline = -20 + 20/2;
player = { x:0, y:baseline, vx:0, vy:0, facing:1 };
camera = { y: baseline - DESIRED_SCREEN_Y };
lastLandedY = baseline;
platforms = [];
nextPlatformY = -20;
platforms.push({ x:0, y:-20, w:120, h:20, kind:'normal', vx:0, cleared:false, color:'#8a3fd6', start:true, meltedSpikes:[] });
lastPlatformX = 0;
lastWasUnlandable = false;
coins = [];
lasers = [];
nextLaserY = 300;
// Build a huge amount of tower immediately. There is no visible top.
generatedWorldTop = INITIAL_WORLD_TOP;
genPlatformsUpTo(generatedWorldTop);
genLasersUpTo(generatedWorldTop);
score = 0;
landingCount = 0;
scoringStarted = false;
doubleJumpReady = doubleJumpSeconds > 0;
landingParticles = [];
menorahUse = null;
menorahStatus = '';
menorahStatusUntil = 0;
gameOverAngle = 0;
}
function updateBest(){
if(score > best){ best = score; saveGame(); }
}
function spawnLandingParticles(x, y){
for(let i=0; i<12; i++){
const angle = Math.PI * (0.15 + Math.random()*0.7);
const speed = 1.8 + Math.random()*3.2;
landingParticles.push({
x: x + (Math.random()*18 - 9),
y: y + 3,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 28 + Math.floor(Math.random()*18),
maxLife: 46,
size: 2 + Math.random()*3
});
}
}
function updateLandingParticles(){
for(const p of landingParticles){
p.x += p.vx;
p.y += p.vy;
p.vy -= 0.16;
p.vx *= 0.97;
p.life--;
}
landingParticles = landingParticles.filter(p => p.life > 0);
}
function triggerGameOver(){
if(state === 'gameover') return;
state = 'gameover';
gameOverStartedAt = performance.now();
gameOverAngle = 0;
if(player) player.vx = 0;
updateBest();
saveGame();
playBeep(150, 0.4);
}
function getBounceVelocity(){
return highBounceSeconds > 0 ? 18 : BOUNCE_V;
}
function getFallGravity(){
return slowFallSeconds > 0 ? 0.10 : FALL_GRAVITY;
}
function formatTime(seconds){
const total = Math.max(0, Math.ceil(seconds));
const mm = Math.floor(total / 60);
const ss = total % 60;
return mm + ':' + String(ss).padStart(2, '0');
}
function attemptDoubleJump(){
if(state !== 'playing' || doubleJumpSeconds <= 0 || !doubleJumpReady || !player) return false;
doubleJumpReady = false;
player.vy = getBounceVelocity();
wobble = 8;
playBeep(700, 0.12);
setTimeout(()=>playBeep(950, 0.1), 60);
return true;
}
function promptPositiveWholeNumber(message){
const input = window.prompt(message);
if(input === null) return null;
const n = Math.floor(Number(input));
if(!Number.isFinite(n) || n <= 0){
window.alert('Please enter a whole number greater than 0.');
return null;
}
return n;
}
function buyTimedUpgrade(kind, label, costPerMinute){
const minutes = promptPositiveWholeNumber(
'How many minutes of ' + label + ' do you want?\n' +
costPerMinute + ' coins per minute. You have ' + coinsCollected + ' coins.'
);
if(minutes === null) return;
const totalCost = minutes * costPerMinute;
if(totalCost > coinsCollected){
window.alert('That costs ' + totalCost + ' coins, but you only have ' + coinsCollected + '.');
playBeep(150, 0.2);
return;
}
coinsCollected -= totalCost;
const addSeconds = minutes * 60;
if(kind === 'doubleJump'){
doubleJumpSeconds += addSeconds;
doubleJumpReady = true;
} else if(kind === 'slowFall'){
slowFallSeconds += addSeconds;
} else if(kind === 'highBounce'){
highBounceSeconds += addSeconds;
} else if(kind === 'coinMagnet'){
coinMagnetSeconds += addSeconds;
}
saveGame();
playBeep(1000, 0.15);
}
function buyMenorahs(){
const quantity = promptPositiveWholeNumber(
'How many menorahs do you want?\n' +
MENORAH_COST + ' coins each. Menorahs are permanent and never get used up. You have ' + coinsCollected + ' coins.'
);
if(quantity === null) return;
const totalCost = quantity * MENORAH_COST;
if(totalCost > coinsCollected){
window.alert('That costs ' + totalCost + ' coins, but you only have ' + coinsCollected + '.');
playBeep(150, 0.2);
return;
}
coinsCollected -= totalCost;
menorahsOwned += quantity;
saveGame();
playBeep(1000, 0.15);
}
function buyCandles(){
const roomLeft = MAX_CANDLES - candlesOwned;
if(roomLeft <= 0){
window.alert('You already have the maximum of ' + MAX_CANDLES + ' candles.');
playBeep(150, 0.2);
return;
}
const quantity = promptPositiveWholeNumber(
'How many candles do you want?\n' +
CANDLE_COST + ' coin each. You can buy up to ' + roomLeft + ' more.'
);
if(quantity === null) return;
if(quantity > roomLeft){
window.alert('You can only have ' + MAX_CANDLES + ' candles at once. You can buy ' + roomLeft + ' more right now.');
playBeep(150, 0.2);
return;
}
const totalCost = quantity * CANDLE_COST;
if(totalCost > coinsCollected){
window.alert('That costs ' + totalCost + ' coins, but you only have ' + coinsCollected + '.');
playBeep(150, 0.2);
return;
}
coinsCollected -= totalCost;
candlesOwned += quantity;
saveGame();
playBeep(1000, 0.15);
}
// -----------------------------
// MENORAH SPIKE MELTING
// -----------------------------
function setMenorahStatus(message, duration = 1500){
menorahStatus = message;
menorahStatusUntil = performance.now() + duration;
}
function ensureMeltedSpikeList(p){
if(!Array.isArray(p.meltedSpikes)) p.meltedSpikes = [];
return p.meltedSpikes;
}
function isSpikeMelted(p, id){
return ensureMeltedSpikeList(p).includes(id);
}
function getPlatformSpikeDescriptors(p){
const spikes = [];
const count = 5;
const startX = p.x - p.w/2 + 8;
const spanW = p.w - 16;
const allSides = p.kind === 'allspike' || p.kind === 'allspike-moving';
const topSpiked = ((p.kind === 'spiked' || p.kind === 'moving-spiked') && !p.cleared) || allSides;
const bottomSpiked = (p.kind === 'trapdoor' && !p.cleared) || allSides;
if(topSpiked){
for(let i=0; i<count; i++){
const x = startX + (spanW / (count - 1)) * i;
spikes.push({
id:'top' + i,
side:'top',
baseX:x,
baseY:p.y + p.h/2,
tipX:x,
tipY:p.y + p.h/2 + 12
});
}
}
if(bottomSpiked){
for(let i=0; i<count; i++){
const x = startX + (spanW / (count - 1)) * i;
spikes.push({
id:'bottom' + i,
side:'bottom',
baseX:x,
baseY:p.y - p.h/2,
tipX:x,
tipY:p.y - p.h/2 - 12
});
}
}
if(allSides){
spikes.push({
id:'left', side:'left',
baseX:p.x - p.w/2, baseY:p.y,
tipX:p.x - p.w/2 - 10, tipY:p.y
});
spikes.push({
id:'right', side:'right',
baseX:p.x + p.w/2, baseY:p.y,
tipX:p.x + p.w/2 + 10, tipY:p.y
});
}
return spikes;
}
function remainingPlatformSpikes(p){
return getPlatformSpikeDescriptors(p).filter(spike => !isSpikeMelted(p, spike.id));
}
function remainingSpikeNearX(p, side, x, tolerance = 10){
return remainingPlatformSpikes(p).some(spike =>
spike.side === side && Math.abs(spike.tipX - x) <= tolerance
);
}
function playerTouchesRemainingSpike(p){
for(const spike of remainingPlatformSpikes(p)){
// A permanent menorah is automatic: when Rabbi is descending onto TOP
// spikes and has at least one candle, let him reach the platform surface.
// The landing collision below will start the melting animation instead of
// killing him at the spike tip. Side/bottom spikes are still dangerous.
if(
spike.side === 'top' &&
player.vy <= 0 &&
menorahsOwned > 0 &&
candlesOwned > 0 &&
player.x > p.x - p.w/2 - 10 &&
player.x < p.x + p.w/2 + 10
){
continue;
}
if(spike.side === 'top' || spike.side === 'bottom'){
if(Math.abs(player.x - spike.tipX) <= 8 && Math.abs(player.y - spike.tipY) <= 11){
return true;
}
} else {
if(Math.abs(player.x - spike.tipX) <= 10 && Math.abs(player.y - spike.tipY) <= 8){
return true;
}
}
}
return false;
}
function beginMenorahLanding(p, platTop){
// The menorah has NO manual activation. The only way to use it is to land
// on top of a platform where dangerous top spikes are still present.
if(state !== 'playing' || menorahUse || menorahsOwned <= 0 || candlesOwned <= 0){
return false;
}
const spikes = remainingPlatformSpikes(p)
.sort((a, b) =>
Math.hypot(player.x - a.tipX, player.y - a.tipY) -
Math.hypot(player.x - b.tipX, player.y - b.tipY)
);
if(!spikes.some(spike => spike.side === 'top')) return false;
const numberToMelt = Math.min(candlesOwned, spikes.length);
if(numberToMelt <= 0) return false;
// Menorahs last forever. ONLY candles are consumed. One candle melts one
// spike, and if there are not enough candles the closest spikes melt first.
candlesOwned -= numberToMelt;
menorahUse = {
platform: p,
spikeIds: spikes.slice(0, numberToMelt).map(spike => spike.id),
startedAt: performance.now(),
meltedCount: 0,
landingY: platTop,
oldVx: player.vx
};
// Rabbi has just landed. Freeze him on the platform while he bends the
// menorah downward and melts the selected spikes.
player.y = platTop;
player.vx = 0;
player.vy = 0;
saveGame();
setMenorahStatus('Melting ' + numberToMelt + ' spike' + (numberToMelt === 1 ? '' : 's') + '...', 1800);
playBeep(760, 0.12);
return true;
}
function updateMenorahUse(){
if(!menorahUse) return false;
pointerWasDown = mouse.pressed;
const elapsed = performance.now() - menorahUse.startedAt;
const firstMeltAt = 260;
const perSpikeMs = 150;
const total = menorahUse.spikeIds.length;
const shouldBeMelted = Math.min(
total,
Math.max(0, Math.floor((elapsed - firstMeltAt) / perSpikeMs) + 1)
);
while(menorahUse.meltedCount < shouldBeMelted){
const id = menorahUse.spikeIds[menorahUse.meltedCount];
const list = ensureMeltedSpikeList(menorahUse.platform);
if(!list.includes(id)) list.push(id);
menorahUse.meltedCount++;
playBeep(980 + menorahUse.meltedCount * 45, 0.07);
}
const finishAt = firstMeltAt + total * perSpikeMs + 260;
if(elapsed >= finishAt){
const landingY = menorahUse.landingY;
const oldVx = menorahUse.oldVx;
menorahUse = null;
// Finish the landing normally after the melting animation, then bounce.
player.y = landingY;
player.vx = oldVx;
player.vy = getBounceVelocity();
wobble = 8;
spawnLandingParticles(player.x, landingY);
lastLandedY = landingY;
doubleJumpReady = doubleJumpSeconds > 0;
landingCount++;
if(landingCount === 2){ scoringStarted = true; baseline = player.y; }
setMenorahStatus('Spikes melted!', 1000);
playBeep(540 + Math.random()*150, 0.1);
saveGame();
return false;
}
return true;
}
const LASER_UNLOCK_AMOS = 2100, HARD_START_AMOS = LASER_UNLOCK_AMOS + 300;
function difficultyLevel(amos){
return amos >= HARD_START_AMOS ? Math.floor((amos - HARD_START_AMOS) / 1000) + 1 : 0;
}
function pickPlatformKind(amos){
const boost = 1 + difficultyLevel(amos) * 0.25;
const options = [{kind:'normal', w:0.6}];
if(amos >= 300) options.push({kind:'moving', w:0.18*boost});
if(amos >= 600) options.push({kind:'spiked', w:0.16*boost});
if(amos >= 900) options.push({kind:'trapdoor', w:0.15*boost});
if(amos >= 1200) options.push({kind:'moving-spiked', w:0.14*boost});
if(amos >= 1500) options.push({kind:'allspike', w:0.13*boost});
if(amos >= 1800) options.push({kind:'allspike-moving', w:0.12*boost});
const total = options.reduce((s,o) => s + o.w, 0);
let r = Math.random() * total;
for(const o of options){
if(r < o.w) return o.kind;
r -= o.w;
}
return 'normal';
}
const GREEN_PLATFORM_SPEED = 2.25;
function greenPlatSpeed(){ return (Math.random()<0.5 ? -1 : 1) * GREEN_PLATFORM_SPEED; }
function randPlatSpeed(){ return (Math.random()<0.5?-1:1) * (1.5+Math.random()*1.5); }
function platformGapAmos(amos){
if(amos < 100) return 3 + Math.random()*2.5;
if(amos < 200) return 5 + Math.random()*3;
return 6.875 + Math.random()*5.625;
}
const MILESTONE_CLEAR_AMOS = 10; // keep platforms from landing on a milestone's marker line
function genPlatformsUpTo(targetY){
while(nextPlatformY < targetY){
const amosNow = nextPlatformY / 8;
let gapAmos = platformGapAmos(amosNow);
const level = difficultyLevel(amosNow);
if(level > 0) gapAmos = Math.max(4, gapAmos - level*0.6);
nextPlatformY += gapAmos * 8;
let amos = nextPlatformY / 8;
for(const m of MILESTONES){
const d = Math.abs(amos - m.amos);
if(d < MILESTONE_CLEAR_AMOS){
// Jumping all the way to m.amos+MILESTONE_CLEAR_AMOS ignored how far
// that actually was from the LAST platform (amosNow) — a candidate
// that had barely entered the dead zone could get pushed to a total
// gap of ~20+gapAmos amos (up to ~260px), past a single bounce's max
// reach (~250px, from BOUNCE_V=15 decelerating at GRAVITY=0.45).
// That's the impossible jump right at the start of each new zone.
// Capping the snap to a safe reachable gap from amosNow fixes it —
// it may land a bit closer to the milestone line than before, which
// was only a cosmetic concern, not a gameplay one.
const SAFE_MAX_GAP_AMOS = 22; // ~176px, comfortably under the ~250px max reach
amos = Math.min(m.amos + MILESTONE_CLEAR_AMOS, amosNow + SAFE_MAX_GAP_AMOS);
nextPlatformY = amos * 8;
}
}
// Keep each new platform within comfortable jumping distance of the
// previous one (instead of a fully independent random x) — a fully
// random x could occasionally land a platform at the opposite edge of
// the world right after the previous one with barely any vertical gap
// to build up horizontal travel time, which felt like an impossible jump.
// A pure random walk ((Math.random()*2-1)*MAX) picks a near-zero shift
// fairly often — with no floor on the step size, a run of small shifts
// stacks several platforms almost directly above each other, most
// noticeable early on (amos < 300) where the vertical gaps are also
// small, so a lot of platforms are visible on screen at once. Forcing
// a minimum step keeps every platform meaningfully left/right of the
// last one, while the sign stays random so the path still wanders.
const MAX_PLATFORM_DX = 180, MIN_PLATFORM_DX = 70;
const dxMag = MIN_PLATFORM_DX + Math.random()*(MAX_PLATFORM_DX-MIN_PLATFORM_DX);
let dir = Math.random()<0.5 ? -1 : 1;
// The playable left/right edge now follows the actual screen width.
// Keep a small margin so platforms never spawn half off-screen.
const platformEdge = Math.max(320, WORLD_HALF - 80);
// Near a world edge, reflect the next step inward instead of clamping it
// back onto the same x position.
if(lastPlatformX + dir*dxMag < -platformEdge || lastPlatformX + dir*dxMag > platformEdge) dir = -dir;
let x = lastPlatformX + dir*dxMag;
x = Math.max(-platformEdge, Math.min(platformEdge, x));
lastPlatformX = x;
let kind = pickPlatformKind(amos);
// allspike/allspike-moving can never be landed on (touching = death), so
// never let two of them land back-to-back — that would force the player
// to clear two gaps in a single jump, which can exceed max jump height.
if((kind === 'allspike' || kind === 'allspike-moving') && lastWasUnlandable) kind = 'normal';
lastWasUnlandable = (kind === 'allspike' || kind === 'allspike-moving');
let vx = 0, w = 72, color = '#3f8fd6';
if(kind === 'moving'){ vx = greenPlatSpeed(); color = '#3fae4a'; }
else if(kind === 'spiked'){ color = '#3f8fd6'; }
else if(kind === 'trapdoor'){ color = '#3f8fd6'; }
else if(kind === 'moving-spiked'){ vx = randPlatSpeed(); color = '#3fae4a'; }
else if(kind === 'allspike'){ color = '#8a8a92'; }
else if(kind === 'allspike-moving'){ vx = randPlatSpeed(); color = '#2a2a30'; }
const plat = { x, y: nextPlatformY, w, h:18, kind, vx, cleared:false, color, meltedSpikes:[] };
platforms.push(plat);
// Coins only sit on kinds that are safe to stand on right away — a coin
// hovering over a spike platform would either be unreachable or bait the
// player into a hazard, neither of which is fun.
if((kind === 'normal' || kind === 'moving') && Math.random() < 0.35){
coins.push({ platform: plat, dx: (Math.random()*2-1)*(w/2-14), taken:false, bobPhase: Math.random()*Math.PI*2, spinPhase: Math.random()*Math.PI*2 });
}
}
}
function laserGapAmos(amos){
const sinceUnlock = amos - LASER_UNLOCK_AMOS;
let gap;
if(sinceUnlock < 100) gap = 60 + Math.random()*35;
else if(sinceUnlock < 200) gap = 25 + Math.random()*15;
else gap = 12 + Math.random()*10;
const level = difficultyLevel(amos);
if(level > 0) gap = Math.max(6, gap - level*2);
return gap;
}
let nextLaserY = 300;
function genLasersUpTo(targetY){
while(nextLaserY < targetY){
const amosNow = nextLaserY / 8;
nextLaserY += laserGapAmos(amosNow) * 8;
if(nextLaserY / 8 >= LASER_UNLOCK_AMOS){
lasers.push({ y: nextLaserY, timer: Math.random()*160 });
}
}
}
// Keep a giant generated region above the player at all times. If the camera
// approaches the current generated ceiling, extend the tower by another huge
// chunk before that ceiling could ever become visible.
function ensureTallWorld(){
if(!camera) return;
const topOfView = camera.y + VIEW_HALF_HEIGHT;
const safetyMargin = Math.max(
WORLD_EXTENSION_MARGIN,
visibleWorldHeight * 4
);
while(generatedWorldTop - topOfView < safetyMargin){
generatedWorldTop += WORLD_EXTENSION;
}
genPlatformsUpTo(generatedWorldTop);
genLasersUpTo(generatedWorldTop);
}
let pointerWasDown = false;
function update(){
syncResponsiveUi();
if(state === 'title'){
const clicked = mouse.pressed && !pointerWasDown;
pointerWasDown = mouse.pressed;
if(keys[' ']){ startGame(); return; }
if(clicked){
if(hitRect(mouse.x, mouse.y, START_BTN)) startGame();
else if(hitRect(mouse.x, mouse.y, HELP_BTN)){ state = 'help'; helpPage = 0; helpReturnState = 'title'; }
}
return;
}
if(state === 'countdown'){
pointerWasDown = mouse.pressed;
const elapsed = performance.now() - countdownStartedAt;
if(elapsed >= COUNTDOWN_MS){
state = 'playing';
jumpInputWasDownPlaying = false;
playBeep(980, 0.10);
}
return;
}
if(state === 'help'){
const clicked = mouse.pressed && !pointerWasDown;
pointerWasDown = mouse.pressed;
helpAnimTimer++;
if(clicked){
if(hitRect(mouse.x, mouse.y, BACK_BTN)) state = helpReturnState;
else if(hitRect(mouse.x, mouse.y, HELP_PREV_BTN)) helpPage = Math.max(0, helpPage - 1);
else if(hitRect(mouse.x, mouse.y, HELP_NEXT_BTN)) helpPage = Math.min(HELP_PAGES.length - 1, helpPage + 1);
}
const leftPressed = keys['ArrowLeft'] && !helpLeftWasDown;
const rightPressed = keys['ArrowRight'] && !helpRightWasDown;
const spacePressed = keys[' '] && !helpSpaceWasDown;
helpLeftWasDown = keys['ArrowLeft'];
helpRightWasDown = keys['ArrowRight'];
helpSpaceWasDown = keys[' '];
if(leftPressed) helpPage = Math.max(0, helpPage - 1);
if(rightPressed || spacePressed) helpPage = Math.min(HELP_PAGES.length - 1, helpPage + 1);
return;
}
if(state === 'gameover'){
const clicked = mouse.pressed && !pointerWasDown;
pointerWasDown = mouse.pressed;
const elapsed = performance.now() - gameOverStartedAt;
if(elapsed < 700){
gameOverAngle += 0.055;
}
updateLandingParticles();
if(
elapsed > 700 &&
(keys[' '] || (clicked && hitRect(mouse.x, mouse.y, PLAY_AGAIN_BTN)))
){
startGame();
}
return;
}
if(state === 'paused'){
const clicked = mouse.pressed && !pointerWasDown;
pointerWasDown = mouse.pressed;
if(clicked){
if(hitRect(mouse.x, mouse.y, PAUSE_RESUME_BTN)) state = 'playing';
else if(hitRect(mouse.x, mouse.y, PAUSE_MENU_BTN)) state = 'title';
else if(hitRect(mouse.x, mouse.y, PAUSE_SHOP_BTN)) state = 'shop';
else if(hitRect(mouse.x, mouse.y, PAUSE_HELP_BTN)){ state = 'help'; helpPage = 0; helpReturnState = 'paused'; }
}
return;
}
if(state === 'shop'){
const clicked = mouse.pressed && !pointerWasDown;
pointerWasDown = mouse.pressed;
if(clicked){
if(hitRect(mouse.x, mouse.y, BACK_BTN)){
state = 'paused';
} else if(hitRect(mouse.x, mouse.y, SHOP_DOUBLE_JUMP_BTN)){
buyTimedUpgrade('doubleJump', 'Double Jump', DOUBLE_JUMP_COST_PER_MINUTE);
} else if(hitRect(mouse.x, mouse.y, SHOP_SLOW_FALL_BTN)){
buyTimedUpgrade('slowFall', 'Slower Falling', SLOW_FALL_COST_PER_MINUTE);
} else if(hitRect(mouse.x, mouse.y, SHOP_HIGH_BOUNCE_BTN)){
buyTimedUpgrade('highBounce', 'Higher Bounce', HIGH_BOUNCE_COST_PER_MINUTE);
} else if(hitRect(mouse.x, mouse.y, SHOP_COIN_MAGNET_BTN)){
buyTimedUpgrade('coinMagnet', 'Coin Magnet', COIN_MAGNET_COST_PER_MINUTE);
} else if(hitRect(mouse.x, mouse.y, SHOP_MENORAH_BTN)){
buyMenorahs();
} else if(hitRect(mouse.x, mouse.y, SHOP_CANDLES_BTN)){
buyCandles();
}
}
return;
}
// playing
if(menorahUse){
updateMenorahUse();
return;
}
const pauseClicked = mouse.pressed && !pointerWasDown;
pointerWasDown = mouse.pressed;
if(pauseClicked && hitRect(mouse.x, mouse.y, PAUSE_BTN)){ state = 'paused'; return; }
if(pauseClicked && hitRect(mouse.x, mouse.y, JUMP_BTN)){ attemptDoubleJump(); }
const jumpInputDown = keys[' '] || keys['w'] || keys['W'] || keys['ArrowUp'];
const jumpInputEdge = jumpInputDown && !jumpInputWasDownPlaying;
jumpInputWasDownPlaying = jumpInputDown;
if(jumpInputEdge) attemptDoubleJump();
let dir = 0;
if(keys['ArrowLeft'] || keys['a'] || keys['A']) dir = -1;
if(keys['ArrowRight'] || keys['d'] || keys['D']) dir = 1;
const overUiButton = hitRect(mouse.x, mouse.y, PAUSE_BTN) ||
hitRect(mouse.x, mouse.y, JUMP_BTN);
if(mouse.pressed && !overUiButton){
dir = mouse.x < 0 ? -1 : 1;
} else if(dir === 0 && tiltEnabled){
dir = tiltMove;
}
if(dir !== 0) player.facing = dir < 0 ? -1 : 1;
// Smooth horizontal movement instead of instantly snapping to full speed.
const targetVx = dir * MOVE_SPEED;
const accel = dir === 0 ? 0.18 : 0.26;
player.vx += (targetVx - player.vx) * accel;
if(dir === 0 && Math.abs(player.vx) < 0.05) player.vx = 0;
player.x += player.vx;
if(player.x > WORLD_HALF) player.x = -WORLD_HALF;
if(player.x < -WORLD_HALF) player.x = WORLD_HALF;
updateLandingParticles();
coinAnimTimer++;
const COIN_COLLECT_R = coinMagnetSeconds > 0 ? 110 : 24;
for(const c of coins){
if(c.taken) continue;
const cx = c.platform.x + c.dx;
const cy = c.platform.y + c.platform.h/2 + COIN_FLOAT_Y;
if(Math.hypot(player.x - cx, player.y - cy) < COIN_COLLECT_R){
c.taken = true;
coinsCollected++;
saveGame();
playBeep(1100, 0.08, 'sine', 0.2);
setTimeout(()=>playBeep(1400, 0.1, 'sine', 0.18), 60);
}
}
player.vy -= (player.vy > 0 ? GRAVITY : getFallGravity());
const prevY = player.y;
player.y += player.vy;
// Only the individual spikes that are still present are dangerous.
// Melted spikes leave real safe gaps that Rabbi can use.
for(const p of platforms){
if(playerTouchesRemainingSpike(p)){
triggerGameOver();
return;
}
}
for(const l of lasers){
l.timer++;
const phase = l.timer % 170;
l.on = phase < 75;
if(l.on && Math.abs(player.y - l.y) < 8){
triggerGameOver();
return;
}
}
if(player.vy > 0){
for(const p of platforms){
if((p.kind === 'spiked' || p.kind === 'moving-spiked') && !p.cleared){
const platBottom = p.y - p.h/2;
if(prevY <= platBottom && player.y >= platBottom &&
player.x > p.x - p.w/2 - 10 && player.x < p.x + p.w/2 + 10){
p.cleared = true;
playBeep(800, 0.08);
}
}
if(p.kind === 'trapdoor' && !p.cleared){
const platBottom = p.y - p.h/2;
if(prevY <= platBottom && player.y >= platBottom &&
player.x > p.x - p.w/2 - 10 && player.x < p.x + p.w/2 + 10 &&
remainingSpikeNearX(p, 'bottom', player.x, 10)){
triggerGameOver();
return;
}
}
}
}
if(player.vy <= 0){
for(const p of platforms){
const platTop = p.y + p.h/2;
if(prevY >= platTop && player.y <= platTop &&
player.x > p.x - p.w/2 - 10 && player.x < p.x + p.w/2 + 10){
player.y = platTop;
if(remainingSpikeNearX(p, 'top', player.x, 10)){
// Landing on a spiked platform is the ONLY way the menorah activates.
// If Rabbi owns a permanent menorah and has candles, melt the nearest
// spikes first. Otherwise this landing is still fatal.
if(beginMenorahLanding(p, platTop)){
return;
}
triggerGameOver();
break;
}
if(p.kind === 'trapdoor') p.cleared = true;
player.vy = getBounceVelocity();
wobble = 8;
spawnLandingParticles(player.x, platTop);
lastLandedY = platTop;
doubleJumpReady = doubleJumpSeconds > 0;
playBeep(500 + Math.random()*200, 0.1);
landingCount++;
if(landingCount === 2){ scoringStarted = true; baseline = player.y; }
break;
}
}
}
for(const p of platforms){
if(p.kind === 'moving' || p.kind === 'moving-spiked' || p.kind === 'allspike-moving'){
p.x += p.vx;
if(p.x > WORLD_HALF - p.w/2 || p.x < -WORLD_HALF + p.w/2) p.vx *= -1;
}
}
const fallingBelowPlatform = player.vy < 0 && player.y < lastLandedY;
if(fallingBelowPlatform){
// Track the rabbi exactly while he's falling — any smoothing here lets
// him fall faster than the camera scrolls and he visually outruns it.
camera.y = player.y - DESIRED_SCREEN_Y;
} else {
const targetCameraY = lastLandedY - DESIRED_SCREEN_Y;
camera.y += (targetCameraY - camera.y) * CAMERA_SMOOTH;
}
// The tower is intentionally enormous. As the player approaches the highest
// generated section, silently create tens of thousands more pixels above it.
ensureTallWorld();
if(scoringStarted) score = Math.max(score, Math.floor((player.y - baseline) / 8));
if(player.y < START_PLATFORM_Y - 30){
triggerGameOver();
}
if(wobble > 0) wobble--;
}
function drawLandingParticles(){
for(const p of landingParticles){
const screenY = p.y - camera.y;
const alpha = Math.max(0, Math.min(1, p.life / p.maxLife));
ctx.save();
ctx.globalAlpha = alpha;
screen.drawCircle(p.x, screenY, p.size, '#fff0b3');
ctx.restore();
}
}
function drawLimb(x1, y1, angleDeg, length, thickness, color){
const rad = angleDeg * Math.PI / 180;
const dx = Math.cos(rad), dy = Math.sin(rad);
for(let i=0; i<=4; i++){
const t = i/4;
screen.drawCircle(x1 + dx*length*t, y1 + dy*length*t, thickness/2, color);
}
}
function drawHeldMenorah(rabbiX, rabbiBaseY, facing){
if(!menorahUse || !player) return;
const bodyY = rabbiBaseY + 31;
const handX = rabbiX + facing * 18;
const handY = bodyY - 9;
const menorahX = handX + facing * 4;
const menorahY = bodyY - 28;
// Rabbi lowers one arm and holds the menorah beneath him toward the platform.
drawLimb(rabbiX + facing*14, bodyY + 5, 258, 26, 5, '#2b2b3a');
screen.drawCircle(handX, handY - 13, 4, '#f0c8a0');
const gold = '#f2c230';
screen.drawRect(menorahX, menorahY, 4, 28, gold);
screen.drawRect(menorahX, menorahY - 14, 24, 4, gold);
screen.drawRect(menorahX, menorahY + 10, 30, 3, gold);
screen.drawRect(menorahX - 12, menorahY + 5, 3, 12, gold);
screen.drawRect(menorahX + 12, menorahY + 5, 3, 12, gold);
screen.drawRect(menorahX - 6, menorahY + 8, 3, 9, gold);
screen.drawRect(menorahX + 6, menorahY + 8, 3, 9, gold);
// Little flames while it is actively melting.
for(const fx of [-12,-6,0,6,12]){
screen.drawCircle(menorahX + fx, menorahY + 13, 2.3, '#ffb347');
}
const currentIndex = Math.min(menorahUse.meltedCount, menorahUse.spikeIds.length - 1);
const currentId = menorahUse.spikeIds[currentIndex];
const spike = getPlatformSpikeDescriptors(menorahUse.platform).find(item => item.id === currentId);
if(spike){
const targetY = spike.tipY - camera.y;
ctx.save();
ctx.strokeStyle = '#ffcf40';
ctx.lineWidth = 3;
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.moveTo(menorahX, menorahY - 8);
ctx.lineTo(spike.tipX, targetY);
ctx.stroke();
ctx.restore();
screen.drawCircle(spike.tipX, targetY, 7 + Math.sin(performance.now()*0.025)*2, '#ff9f3f88');
}
}
function drawRabbi(x, y, facing){
const baseY = y;
y += 31;
screen.drawRect(x-9, y-20, 5, 22, '#2b2b3a');
screen.drawRect(x+9, y-20, 5, 22, '#2b2b3a');
screen.drawCircle(x-9, y-31, 6, '#1a1a24');
screen.drawCircle(x+9, y-31, 6, '#1a1a24');
screen.drawCircle(x, y, 17 + wobble*0.3, '#2b2b3a');
drawLimb(x-20, y+6, 270, 16, 5, '#2b2b3a');
drawLimb(x+20, y+6, 270, 16, 5, '#2b2b3a');
screen.drawCircle(x, y+24, 12, '#f0c8a0');
screen.drawCircle(x, y+13, 8, '#e8e8e8');
screen.drawCircle(x-7, y+13, 6, '#e8e8e8');
screen.drawCircle(x+7, y+13, 6, '#e8e8e8');
screen.drawCircle(x + facing*4, y+26, 1.8, '#2d3142');
screen.drawCircle(x - facing*4, y+26, 1.8, '#2d3142');
screen.drawRect(x, y+36, 26, 4, '#161616');
screen.drawCircle(x, y+42, 9, '#161616');
screen.drawRect(x, y+38, 20, 2, '#3a3a3a');
if(menorahUse && state === 'playing' && player && Math.abs(x - player.x) < 0.5){
drawHeldMenorah(x, baseY, facing);
}
}
function drawSpikeRow(cx, edgeY, w, dir, color){
const spikeCount = 5;
const startX = cx - w/2 + 8;
const spanW = w - 16;
for(let i=0; i<spikeCount; i++){
const sx = startX + (spanW / (spikeCount-1)) * i;
ctx.beginPath();
ctx.moveTo(sx - 6, edgeY);
ctx.lineTo(sx + 6, edgeY);
ctx.lineTo(sx, edgeY + dir*12);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#8f939c';
ctx.lineWidth = 1;
ctx.stroke();
}
}
function drawSpikeCol(edgeX, cy, h, dir, color, spikeCount){
spikeCount = spikeCount || 3;
const startY = cy - h/2 + 6;
const spanH = h - 12;
for(let i=0; i<spikeCount; i++){
const sy = spikeCount === 1 ? cy : startY + (spanH / (spikeCount-1)) * i;
ctx.beginPath();
ctx.moveTo(edgeX, sy - 5);
ctx.lineTo(edgeX, sy + 5);
ctx.lineTo(edgeX + dir*10, sy);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#8f939c';
ctx.lineWidth = 1;
ctx.stroke();
}
}
// Camera-independent preview art for the help pages — draws a platform
// sample at a fixed screen position (no character), reusing the same
// colors/spike logic as the real in-game platforms for visual consistency.
function drawHelpPlatformPreview(kind, cx, cy){
const isStart = kind === 'start';
// The real start platform (see init()) is wider than every other kind's
// 72x18 — so previewing it at the generic size made it look the same
// proportions as everything else instead of the bigger, safer starting
// ledge it actually is.
const w = isStart ? 118 : 72;
const h = 18;
const SCALE = 1.7;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(SCALE, SCALE);
ctx.translate(-cx, -cy);
const allSides = kind === 'allspike' || kind === 'allspike-moving';
const topSpiked = kind === 'spiked' || kind === 'moving-spiked' || allSides;
const bottomSpiked = kind === 'trapdoor' || allSides;
let color = '#3f8fd6';
if(kind === 'start') color = '#8a3fd6';
if(kind === 'moving') color = '#3fae4a';
if(kind === 'spiked') color = '#f2c230';
if(kind === 'moving-spiked') color = '#e0392f';
if(kind === 'trapdoor') color = '#ff8c1a';
if(kind === 'allspike') color = '#8a8a92';
if(kind === 'allspike-moving') color = '#2a2a30';
screen.drawRect(cx, cy, w, h, color);
screen.drawRect(cx, cy+h/2-2, w, 3, '#00000033');
screen.drawRect(cx, cy-h/2+2, w, 3, '#ffffff22');
screen.drawRect(cx - w/2 + 5, cy, 4, h - 5, '#fdf6e3');
if(topSpiked) drawSpikeRow(cx, cy + h/2, w, 1, '#d8dbe0');
if(bottomSpiked) drawSpikeRow(cx, cy - h/2, w, -1, '#d8dbe0');
if(allSides){
drawSpikeCol(cx - w/2, cy, h, -1, '#d8dbe0', 1);
drawSpikeCol(cx + w/2, cy, h, 1, '#d8dbe0', 1);
}
ctx.restore();
}
// A real embedded video isn't possible in this single-file game, so the
// laser help page gets an animated on/off preview instead — the closest
// achievable equivalent, cycling on the same timing as the real hazard.
function drawHelpLaserPreview(cx, cy){
const phase = Math.floor(helpAnimTimer) % 170;
const on = phase < 75;
screen.drawRect(cx, cy+38, 280, 14, '#3a2a1a');
screen.drawRect(cx, cy-38, 280, 14, '#3a2a1a');
if(on){
screen.drawRect(cx, cy, 280, 10, '#ff3a3a');
screen.drawRect(cx, cy, 280, 4, '#ffe0a0');
} else {
screen.drawRect(cx, cy, 280, 2, '#ff3a3a55');
}
screen.drawText(on ? 'ON — danger!' : 'off — safe to pass', cx, cy-62, 15, on ? '#ff5c5c' : '#5fc768');
}
// Each line's color marks the kind that was just introduced in the zone
// you're finishing (e.g. the 1800 line closes out the 1500-1800 zone,
// where gray/allspike platforms start appearing) — 1800 was set to purple,
// which didn't match anything; gray (matching allspike) is correct.
const MILESTONES = [
{amos:300, color:'#3f8fd6'},
{amos:600, color:'#3fae4a'},
{amos:900, color:'#f2c230'},
{amos:1200, color:'#ff8c1a'},
{amos:1500, color:'#e0392f'},
{amos:1800, color:'#8a8a92'},
{amos:2100, color:'#2a2a30'}
];
function drawMilestone(amos, color){
const worldY = amos * 8;
const y = worldY - camera.y;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.setLineDash([8, 8]);
ctx.beginPath();
ctx.moveTo(-WORLD_HALF, y);
ctx.lineTo(WORLD_HALF, y);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
screen.drawText(amos + ' amos', 0, y + 16, 18, color);
}
function drawSinglePlatformSpike(spike){
const baseY = spike.baseY - camera.y;
const tipY = spike.tipY - camera.y;
ctx.beginPath();
if(spike.side === 'top' || spike.side === 'bottom'){
ctx.moveTo(spike.baseX - 6, baseY);
ctx.lineTo(spike.baseX + 6, baseY);
ctx.lineTo(spike.tipX, tipY);
} else {
ctx.moveTo(spike.baseX, baseY - 5);
ctx.lineTo(spike.baseX, baseY + 5);
ctx.lineTo(spike.tipX, tipY);
}
ctx.closePath();
ctx.fillStyle = '#d8dbe0';
ctx.fill();
ctx.strokeStyle = '#8f939c';
ctx.lineWidth = 1;
ctx.stroke();
}
function drawPlatform(p){
const y = p.y - camera.y;
const allSides = p.kind === 'allspike' || p.kind === 'allspike-moving';
const remaining = remainingPlatformSpikes(p);
const topSpiked = remaining.some(spike => spike.side === 'top');
const bottomSpiked = remaining.some(spike => spike.side === 'bottom');
let color = p.color;
if(topSpiked && !allSides) color = p.kind === 'moving-spiked' ? '#e0392f' : '#f2c230';
if(p.kind === 'trapdoor' && bottomSpiked) color = '#ff8c1a';
screen.drawRect(p.x, y, p.w, p.h, color);
screen.drawRect(p.x, y+p.h/2-2, p.w, 3, '#00000033');
screen.drawRect(p.x, y-p.h/2+2, p.w, 3, '#ffffff22');
screen.drawRect(p.x - p.w/2 + 5, y, 4, p.h - 5, '#fdf6e3');
for(const spike of remaining){
drawSinglePlatformSpike(spike);
}
if(p.start){
ctx.save();
ctx.strokeStyle = '#8a3fd6';
ctx.lineWidth = 3;
ctx.setLineDash([8, 8]);
ctx.beginPath();
ctx.moveTo(p.x - p.w/2, y);
ctx.lineTo(-WORLD_HALF, y);
ctx.moveTo(p.x + p.w/2, y);
ctx.lineTo(WORLD_HALF, y);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
}
function drawCoin(c){
if(c.taken) return;
const cx = c.platform.x + c.dx;
const cy = c.platform.y + c.platform.h/2 + COIN_FLOAT_Y + Math.sin(coinAnimTimer*0.05 + c.bobPhase)*4;
const y = cy - camera.y;
// Squashing the horizontal radius with a cosine wave (while the outline
// stays a full ellipse) is a cheap 2D stand-in for a coin spinning on its
// vertical axis — same trick as a classic Mario-style coin.
const spinScale = Math.abs(Math.cos(coinAnimTimer*0.08 + c.spinPhase));
const rx = Math.max(2, COIN_R * spinScale);
ctx.save();
ctx.translate(cx, y);
ctx.beginPath();
ctx.ellipse(0, 0, rx, COIN_R, 0, 0, Math.PI*2);
ctx.fillStyle = '#f2c230';
ctx.fill();
ctx.strokeStyle = '#c89a1a';
ctx.lineWidth = 2;
ctx.stroke();
if(spinScale > 0.5){
ctx.fillStyle = '#ffe27a';
ctx.beginPath();
ctx.ellipse(0, 0, rx*0.5, COIN_R*0.5, 0, 0, Math.PI*2);
ctx.fill();
}
ctx.restore();
}
function drawLaser(l){
const y = l.y - camera.y;
const laserWidth = Math.max(770, visibleWorldWidth - 30);
if(l.on){
screen.drawRect(0, y, laserWidth, 10, '#ff3a3a');
screen.drawRect(0, y, laserWidth, 4, '#ffe0a0');
} else {
screen.drawRect(0, y, laserWidth, 2, '#ff3a3a55');
}
}
function drawLibraryBackground(){
// Draw a procedurally infinite bookcase that is ANCHORED TO THE GAME WORLD.
// The old version moved the shelves at 40% of camera speed and then wrapped
// them with modulo math. That created the strange drifting / popping effect
// visible while climbing. This version moves the shelves at exactly the same
// rate as platforms and Rabbi, and determines each row from its absolute
// world position, so rows never teleport or change pattern when they recycle.
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const px = viewScale;
const bw = 40 * px;
const bh = 140 * px;
const shelfGapWorld = 150;
const shelfThickness = Math.max(1, 10 * px);
// Wood backing covers every physical pixel of the canvas.
ctx.fillStyle = '#7a5233';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Find the actual world-space range currently visible on screen.
const visibleTopWorld = camera.y + VIEW_HALF_HEIGHT;
const visibleBottomWorld = camera.y - VIEW_HALF_HEIGHT;
// Include extra rows just beyond the screen edges so there can never be a gap
// during scrolling or resizing.
const firstRow = Math.floor(visibleBottomWorld / shelfGapWorld) - 2;
const lastRow = Math.ceil(visibleTopWorld / shelfGapWorld) + 2;
const bookCount = Math.ceil(canvas.width / bw) + 2;
for(let worldRow = firstRow; worldRow <= lastRow; worldRow++){
const shelfWorldY = worldRow * shelfGapWorld;
// Convert the shelf's permanent world Y coordinate into a physical canvas Y.
// World Y increases upward; canvas Y increases downward.
const shelfY = canvas.height / 2 - (shelfWorldY - camera.y) * px;
let prevCi = -1;
for(let col = -1; col < bookCount; col++){
const x = col * bw;
// Keep the horizontal book pattern IDENTICAL on every shelf row.
// The previous version included worldRow in this calculation, which meant
// each new shelf row started with a different color arrangement. While
// climbing, that made the whole background look like it was changing every
// few amos. Using only the permanent column number keeps the book pattern
// visually locked while the shelves scroll naturally through the world.
let ci = (col * 2) % BOOK_SPINE_COLORS.length;
if(ci < 0) ci += BOOK_SPINE_COLORS.length;
if(ci === prevCi) ci = (ci + 1) % BOOK_SPINE_COLORS.length;
prevCi = ci;
// Books sit immediately above each shelf.
const bookTop = shelfY - bh;
ctx.fillStyle = BOOK_SPINE_COLORS[ci];
ctx.fillRect(x, bookTop, bw + 1, bh);
// Spine shadow.
ctx.fillStyle = '#00000030';
ctx.fillRect(
x + bw - Math.max(1, 2 * px),
bookTop,
Math.max(1, 2 * px),
bh
);
// Spine highlight.
ctx.fillStyle = '#ffffff30';
ctx.fillRect(
x + 4 * px,
bookTop + 6 * px,
Math.max(1, bw - 8 * px),
Math.max(1, 2 * px)
);
}
// Shelf board.
ctx.fillStyle = '#4a2f1a';
ctx.fillRect(
0,
shelfY - shelfThickness / 2,
canvas.width,
shelfThickness
);
}
// Permanent bookcase side rails at the true viewport edges.
const railW = Math.max(12, 30 * px);
ctx.fillStyle = '#3a2413';
ctx.fillRect(0, 0, railW, canvas.height);
ctx.fillRect(canvas.width - railW, 0, railW, canvas.height);
ctx.restore();
// Restore the normal world transform for gameplay drawing.
ctx.setTransform(
viewScale,
0,
0,
-viewScale,
canvas.width / 2,
canvas.height / 2
);
}
function syncResponsiveUi(){
const top = VIEW_HALF_HEIGHT;
const bottom = -VIEW_HALF_HEIGHT;
const left = -WORLD_HALF;
const right = WORLD_HALF;
PAUSE_BTN.x = right - 38;
// Lower than before so it sits below Hello + Log out.
PAUSE_BTN.y = top - 92;
JUMP_BTN.x = right - 74;
JUMP_BTN.y = bottom + 78;
}
function draw(){
screen.clear('#7a5233');
syncResponsiveUi();
drawLibraryBackground();
if(state === 'title'){
screen.drawText('RABBI BOUNCE', 0, 150, 50, '#ff5c8a');
drawRabbi(0, 0, 1);
screen.drawRect(START_BTN.x, START_BTN.y, START_BTN.w, START_BTN.h, '#4caf50');
screen.drawText('START', START_BTN.x, START_BTN.y, 34, '#ffffff');
screen.drawRect(HELP_BTN.x, HELP_BTN.y, HELP_BTN.w, HELP_BTN.h, '#3f8fd6');
screen.drawText('HELP', HELP_BTN.x, HELP_BTN.y, 22, '#ffffff');
return;
}
if(state === 'countdown'){
for(const p of platforms) drawPlatform(p);
for(const c of coins) drawCoin(c);
drawRabbi(player.x, player.y - camera.y, player.facing);
const elapsed = performance.now() - countdownStartedAt;
const remaining = Math.max(0, COUNTDOWN_MS - elapsed);
let label = '3';
if(remaining <= 2200) label = '2';
if(remaining <= 1200) label = '1';
if(remaining <= 350) label = 'GO!';
const pulse = 1 + 0.08*Math.sin(performance.now()*0.018);
ctx.save();
ctx.scale(pulse, pulse);
screen.drawCircle(0, 20, 70, '#00000088');
screen.drawText(label, 0, 20, label === 'GO!' ? 42 : 58, '#ffffff');
ctx.restore();
return;
}
if(state === 'help'){
const page = HELP_PAGES[helpPage];
screen.drawText('HOW TO PLAY', 0, 220, 38, '#ff5c8a');
screen.drawRect(0, 30, 620, 340, '#ffffffee');
screen.drawText(page.title, 0, 165, 24, '#333');
if(page.kind === 'laser') drawHelpLaserPreview(0, 95);
else drawHelpPlatformPreview(page.kind, 0, 95);
page.lines.forEach((line, i) => {
screen.drawText(line, 0, 15 - i*32, 16, '#333');
});
screen.drawText((helpPage+1) + ' / ' + HELP_PAGES.length, 0, -95, 15, '#888');
const atFirst = helpPage === 0, atLast = helpPage === HELP_PAGES.length - 1;
screen.drawRect(HELP_PREV_BTN.x, HELP_PREV_BTN.y, HELP_PREV_BTN.w, HELP_PREV_BTN.h, atFirst ? '#8a5cff55' : '#8a5cff');
screen.drawText('<', HELP_PREV_BTN.x, HELP_PREV_BTN.y, 30, atFirst ? '#ffffff88' : '#ffffff');
screen.drawRect(HELP_NEXT_BTN.x, HELP_NEXT_BTN.y, HELP_NEXT_BTN.w, HELP_NEXT_BTN.h, atLast ? '#8a5cff55' : '#8a5cff');
screen.drawText('>', HELP_NEXT_BTN.x, HELP_NEXT_BTN.y, 30, atLast ? '#ffffff88' : '#ffffff');
screen.drawRect(BACK_BTN.x, BACK_BTN.y, BACK_BTN.w, BACK_BTN.h, '#4caf50');
screen.drawText('BACK', BACK_BTN.x, BACK_BTN.y, 30, '#ffffff');
return;
}
if(state === 'paused'){
screen.clear('#efe3cc');
screen.drawText('Score: ' + score, -WORLD_HALF + 120, VIEW_HALF_HEIGHT - 42, 26, '#8a5cff');
screen.drawText('Best: ' + best, WORLD_HALF - 120, VIEW_HALF_HEIGHT - 42, 26, '#ff5c8a');
screen.drawRect(-18, 60, 22, 72, '#5a4a3a');
screen.drawRect(18, 60, 22, 72, '#5a4a3a');
screen.drawText('PAUSED', 0, -40, 30, '#5a4a3a');
screen.drawRect(PAUSE_RESUME_BTN.x, PAUSE_RESUME_BTN.y, PAUSE_RESUME_BTN.w, PAUSE_RESUME_BTN.h, '#4caf50');
screen.drawText('RESUME', PAUSE_RESUME_BTN.x, PAUSE_RESUME_BTN.y, 22, '#ffffff');
screen.drawRect(PAUSE_MENU_BTN.x, PAUSE_MENU_BTN.y, PAUSE_MENU_BTN.w, PAUSE_MENU_BTN.h, '#ff5c8a');
screen.drawText('MENU', PAUSE_MENU_BTN.x, PAUSE_MENU_BTN.y, 22, '#ffffff');
screen.drawRect(PAUSE_SHOP_BTN.x, PAUSE_SHOP_BTN.y, PAUSE_SHOP_BTN.w, PAUSE_SHOP_BTN.h, '#f2a63f');
screen.drawText('SHOP', PAUSE_SHOP_BTN.x, PAUSE_SHOP_BTN.y, 22, '#ffffff');
screen.drawRect(PAUSE_HELP_BTN.x, PAUSE_HELP_BTN.y, PAUSE_HELP_BTN.w, PAUSE_HELP_BTN.h, '#3f8fd6');
screen.drawText('HELP', PAUSE_HELP_BTN.x, PAUSE_HELP_BTN.y, 22, '#ffffff');
return;
}
if(state === 'shop'){
screen.clear('#efe3cc');
screen.drawText('SHOP', 0, 260, 36, '#f2a63f');
screen.drawCircle(-330, 258, 11, '#f2c230');
ctx.save(); ctx.strokeStyle = '#c89a1a'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(-330, 258, 11, 0, Math.PI*2); ctx.stroke();
ctx.restore();
screen.drawText(String(coinsCollected), -296, 258, 24, '#c89a1a');
screen.drawRect(0, 45, 740, 425, '#ffffffee');
const drawShopButton = (btn, title, detail, color) => {
screen.drawRect(btn.x, btn.y, btn.w, btn.h, color);
screen.drawText(title, btn.x, btn.y + 12, 17, '#ffffff');
screen.drawText(detail, btn.x, btn.y - 15, 12, '#ffffff');
};
drawShopButton(
SHOP_DOUBLE_JUMP_BTN,
'DOUBLE JUMP - ' + DOUBLE_JUMP_COST_PER_MINUTE + '/MIN',
formatTime(doubleJumpSeconds) + ' left',
'#8a5cff'
);
drawShopButton(
SHOP_SLOW_FALL_BTN,
'SLOWER FALLING - ' + SLOW_FALL_COST_PER_MINUTE + '/MIN',
formatTime(slowFallSeconds) + ' left',
'#3f8fd6'
);
drawShopButton(
SHOP_HIGH_BOUNCE_BTN,
'HIGHER BOUNCE - ' + HIGH_BOUNCE_COST_PER_MINUTE + '/MIN',
formatTime(highBounceSeconds) + ' left',
'#e56b3f'
);
drawShopButton(
SHOP_COIN_MAGNET_BTN,
'COIN MAGNET - ' + COIN_MAGNET_COST_PER_MINUTE + '/MIN',
formatTime(coinMagnetSeconds) + ' left',
'#4caf50'
);
drawShopButton(
SHOP_MENORAH_BTN,
'MENORAH - ' + MENORAH_COST,
'Owned: ' + menorahsOwned + ' • permanent',
'#c7922f'
);
drawShopButton(
SHOP_CANDLES_BTN,
'CANDLES - ' + CANDLE_COST + ' EACH',
candlesOwned + '/' + MAX_CANDLES + ' • 1 candle = 1 spike',
'#d35478'
);
screen.drawRect(BACK_BTN.x, BACK_BTN.y, BACK_BTN.w, BACK_BTN.h, '#4caf50');
screen.drawText('BACK', BACK_BTN.x, BACK_BTN.y, 30, '#ffffff');
return;
}
for(const p of platforms) drawPlatform(p);
for(const c of coins) drawCoin(c);
for(const l of lasers) drawLaser(l);
for(const m of MILESTONES) drawMilestone(m.amos, m.color);
drawLandingParticles();
if(state === 'gameover' && performance.now() - gameOverStartedAt < 700){
const t = (performance.now() - gameOverStartedAt) / 700;
const px = player.x;
const py = player.y - camera.y - t*35;
ctx.save();
ctx.translate(px, py);
ctx.rotate(gameOverAngle);
ctx.scale(1 - t*0.18, 1 - t*0.18);
drawRabbi(0, 0, player.facing);
ctx.restore();
} else {
drawRabbi(player.x, player.y - camera.y, player.facing);
}
const hudLeftX = -WORLD_HALF + 120;
const hudTopY = VIEW_HALF_HEIGHT - 42;
screen.drawText('Score: ' + score, hudLeftX, hudTopY, 26, '#8a5cff');
screen.drawCircle(hudLeftX - 26, hudTopY - 34, 9, '#f2c230');
ctx.save(); ctx.strokeStyle = '#c89a1a'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(hudLeftX - 26, hudTopY - 34, 9, 0, Math.PI*2); ctx.stroke();
ctx.restore();
screen.drawText(String(coinsCollected), hudLeftX, hudTopY - 34, 22, '#c89a1a');
if(state === 'playing'){
screen.drawCircle(PAUSE_BTN.x+2, PAUSE_BTN.y-3, 24, '#00000028');
screen.drawCircle(PAUSE_BTN.x, PAUSE_BTN.y, 24, '#ffffff');
screen.drawCircle(PAUSE_BTN.x, PAUSE_BTN.y, 21, '#8a5cff');
screen.drawRect(PAUSE_BTN.x-6, PAUSE_BTN.y, 5, 18, '#ffffff');
screen.drawRect(PAUSE_BTN.x+6, PAUSE_BTN.y, 5, 18, '#ffffff');
// Double Jump is always visible. Gray means no purchased time remains.
const jumpButtonColor = doubleJumpSeconds <= 0
? '#777777'
: (doubleJumpReady ? '#ff9f3f' : '#b97842');
screen.drawCircle(JUMP_BTN.x+2, JUMP_BTN.y-3, 44, '#00000028');
screen.drawCircle(JUMP_BTN.x, JUMP_BTN.y, 44, '#ffffff');
screen.drawCircle(JUMP_BTN.x, JUMP_BTN.y, 38, jumpButtonColor);
screen.drawText('DOUBLE', JUMP_BTN.x, JUMP_BTN.y+10, 12, '#ffffff');
screen.drawText('JUMP', JUMP_BTN.x, JUMP_BTN.y-5, 13, '#ffffff');
let jumpStatus = 'NO TIME';
if(doubleJumpSeconds > 0 && doubleJumpReady) jumpStatus = 'READY ' + formatTime(doubleJumpSeconds);
else if(doubleJumpSeconds > 0) jumpStatus = 'USED ' + formatTime(doubleJumpSeconds);
screen.drawText(jumpStatus, JUMP_BTN.x, JUMP_BTN.y-24, 8, '#ffffff');
// The menorah is permanent and automatic, so there is deliberately NO
// Menorah button. This is only an inventory readout. It activates when
// Rabbi lands on dangerous top spikes.
const menorahHudX = -WORLD_HALF + 130;
const menorahHudY = -VIEW_HALF_HEIGHT + 36;
screen.drawRect(menorahHudX, menorahHudY, 220, 34, '#00000055');
screen.drawText(
(menorahsOwned > 0 ? 'MENORAH ✓' : 'NO MENORAH') +
' CANDLES ' + candlesOwned + '/' + MAX_CANDLES,
menorahHudX,
menorahHudY,
11,
'#ffffff'
);
if(menorahStatus && performance.now() < menorahStatusUntil){
screen.drawText(menorahStatus, 0, -VIEW_HALF_HEIGHT + 30, 13, '#ffffff');
}
// Compact active-upgrade timers above the permanent Menorah inventory.
const active = [];
if(slowFallSeconds > 0) active.push('Slow ' + formatTime(slowFallSeconds));
if(highBounceSeconds > 0) active.push('High ' + formatTime(highBounceSeconds));
if(coinMagnetSeconds > 0) active.push('Magnet ' + formatTime(coinMagnetSeconds));
if(active.length){
const x = -WORLD_HALF + 125;
let y = -VIEW_HALF_HEIGHT + 160;
for(const line of active){
screen.drawText(line, x, y, 13, '#ffffff');
y += 19;
}
}
}
if(state === 'gameover'){
const elapsed = performance.now() - gameOverStartedAt;
// Quick impact flash first.
if(elapsed < 260){
const flashAlpha = 0.36 * (1 - elapsed/260);
ctx.save();
ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle = `rgba(255,80,110,${flashAlpha})`;
ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.restore();
}
// Let the little spin/fall animation happen before the panel appears.
if(elapsed >= 520){
const panelT = Math.min(1, (elapsed - 520) / 300);
const ease = 1 - Math.pow(1-panelT, 3);
const s = 0.78 + ease*0.22;
ctx.save();
ctx.scale(s, s);
screen.drawRect(0, 0, 500, 300, '#ffffffee');
screen.drawText('GAME OVER', 0, 100, 44, '#ff5c8a');
screen.drawText('You climbed ' + score + ' amos!', 0, 40, 24, '#333');
screen.drawText('Best: ' + best, 0, 0, 22, '#8a5cff');
screen.drawRect(PLAY_AGAIN_BTN.x, PLAY_AGAIN_BTN.y, PLAY_AGAIN_BTN.w, PLAY_AGAIN_BTN.h, '#4caf50');
screen.drawText('PLAY AGAIN', PLAY_AGAIN_BTN.x, PLAY_AGAIN_BTN.y, 26, '#ffffff');
ctx.restore();
}
}
}
function startGame(){
init();
state = 'countdown';
countdownStartedAt = performance.now();
pointerWasDown = mouse.pressed;
playBeep(520, 0.08);
setTimeout(()=>playBeep(620, 0.08), 1000);
setTimeout(()=>playBeep(740, 0.08), 2000);
}
function loop(now){
if(lastFrameTime === null) lastFrameTime = now;
const dtSec = (now - lastFrameTime) / 1000;
lastFrameTime = now;
if(state === 'playing' && !menorahUse){
if(doubleJumpSeconds > 0){
doubleJumpSeconds = Math.max(0, doubleJumpSeconds - dtSec);
if(doubleJumpSeconds === 0) doubleJumpReady = false;
}
if(slowFallSeconds > 0) slowFallSeconds = Math.max(0, slowFallSeconds - dtSec);
if(highBounceSeconds > 0) highBounceSeconds = Math.max(0, highBounceSeconds - dtSec);
if(coinMagnetSeconds > 0) coinMagnetSeconds = Math.max(0, coinMagnetSeconds - dtSec);
if(now - lastTimedSaveAt >= 1000){
lastTimedSaveAt = now;
saveGame();
}
}
update();
draw();
requestAnimationFrame(loop);
}
// =====================================
// RELIABLE DOUBLE-JUMP KEY INPUT
// =====================================
window.addEventListener('keydown', function(event){
const isJumpKey =
event.code === 'Space' ||
event.code === 'KeyW' ||
event.code === 'ArrowUp';
if(!isJumpKey) return;
if(event.code === 'Space') event.preventDefault();
// One physical key press = one attempt. Holding the key cannot spend
// multiple recharges because browser auto-repeat is ignored.
if(!event.repeat && state === 'playing'){
attemptDoubleJump();
}
});
// =====================================
// CUSTOM RIGHT-CLICK MENU
// =====================================
const gameContextMenu = document.getElementById('gameContextMenu');
function hideGameContextMenu(){
gameContextMenu.classList.remove('show');
gameContextMenu.setAttribute('aria-hidden', 'true');
}
function showGameContextMenu(x, y){
gameContextMenu.classList.add('show');
gameContextMenu.setAttribute('aria-hidden', 'false');
gameContextMenu.style.left = x + 'px';
gameContextMenu.style.top = y + 'px';
requestAnimationFrame(function(){
const rect = gameContextMenu.getBoundingClientRect();
const safeLeft = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8));
const safeTop = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8));
gameContextMenu.style.left = safeLeft + 'px';
gameContextMenu.style.top = safeTop + 'px';
});
}
document.addEventListener('contextmenu', function(event){
event.preventDefault();
// The custom menu works before START and while PAUSED, exactly as requested.
if(state === 'title' || state === 'paused'){
showGameContextMenu(event.clientX, event.clientY);
} else {
hideGameContextMenu();
}
});
gameContextMenu.addEventListener('click', function(event){
const item = event.target.closest('button[data-action]');
if(!item) return;
const action = item.dataset.action;
hideGameContextMenu();
if(action === 'home'){
window.location.href = 'index.html';
return;
}
if(action === 'clicker'){
localStorage.setItem('openAfterReturn', 'clicker');
window.location.href = 'index.html';
return;
}
if(action === 'rabbi'){
init();
state = 'title';
pointerWasDown = false;
return;
}
if(action === 'logout'){
logoutGame();
}
});
document.addEventListener('pointerdown', function(event){
if(!gameContextMenu.contains(event.target)){
hideGameContextMenu();
}
});
window.addEventListener('resize', hideGameContextMenu);
// =====================================
// BLOCK COMMON SOURCE / INSPECT SHORTCUTS
// =====================================
document.addEventListener('keydown', function(event){
const key = String(event.key || '').toLowerCase();
if(event.key === 'Escape'){
hideGameContextMenu();
}
if(event.key === 'F12'){
event.preventDefault();
return;
}
if((event.ctrlKey || event.metaKey) && !event.shiftKey && key === 'u'){
event.preventDefault();
return;
}
if(event.ctrlKey && event.shiftKey && (key === 'i' || key === 'j' || key === 'c' || key === 'k')){
event.preventDefault();
return;
}
if(event.metaKey && event.altKey && (key === 'i' || key === 'j' || key === 'c')){
event.preventDefault();
}
});
init();
requestAnimationFrame(loop);
</script>
</body>
</html>