Marble Game - Day 23
Intermediate LevelThe marble follows realistic rolling physics with friction, acceleration, and velocity clamping. The physics model simulates a ball rolling on a tilted surface.
const FRICTION = 0.98; // Velocity multiplier each frame
const MAX_SPEED = 8; // Maximum pixels per frame
const ACCELERATION = 0.5; // Input force multiplier
const GRAVITY_MULTIPLIER = 0.08; // Motion tilt sensitivity
Every frame, we apply input forces, friction, update position, and handle wall bounces.
function updatePhysics() {
// Get input (joystick or motion)
let inputX = motionEnabled ? tiltX * GRAVITY_MULTIPLIER : joystickX;
let inputY = motionEnabled ? tiltY * GRAVITY_MULTIPLIER : joystickY;
// Apply acceleration from input
marble.vx += inputX * ACCELERATION;
marble.vy += inputY * ACCELERATION;
// Apply friction (slows down over time)
marble.vx *= FRICTION;
marble.vy *= FRICTION;
// Clamp speed to maximum
const speed = Math.sqrt(marble.vx**2 + marble.vy**2);
if (speed > MAX_SPEED) {
marble.vx = (marble.vx / speed) * MAX_SPEED;
marble.vy = (marble.vy / speed) * MAX_SPEED;
}
// Update position
marble.x += marble.vx;
marble.y += marble.vy;
// Visual rotation based on horizontal movement
marble.rotation += marble.vx * 0.05;
}
When the marble hits a wall, we reverse and dampen its velocity for a realistic bounce effect.
// Wall collision with bounce
if (marble.x - marble.radius < 0) {
marble.x = marble.radius;
marble.vx = -marble.vx * 0.5; // Reverse + dampen
}
if (marble.x + marble.radius > CANVAS_SIZE) {
marble.x = CANVAS_SIZE - marble.radius;
marble.vx = -marble.vx * 0.5;
}
// Same for Y axis...
Lower friction (0.95) makes the marble feel slippery like ice. Higher friction (0.99) makes it feel heavy and sluggish. Find the sweet spot for your game!
The marble is rendered with multiple gradient layers to create a realistic glass/crystal appearance with reflections.
We stack multiple radial gradients to build up the illusion of 3D lighting and reflection.
function drawMarble() {
ctx.save();
ctx.translate(marble.x, marble.y);
ctx.rotate(marble.rotation);
// 1. Shadow (offset ellipse)
ctx.beginPath();
ctx.ellipse(4, 4, radius * 0.9, radius * 0.5, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fill();
// 2. Main marble body gradient
const mainGradient = ctx.createRadialGradient(
-radius * 0.3, -radius * 0.3, 0, // Light source offset
0, 0, radius
);
mainGradient.addColorStop(0, '#64b5f6'); // Brightest
mainGradient.addColorStop(0.4, '#2196f3');
mainGradient.addColorStop(0.8, '#1565c0');
mainGradient.addColorStop(1, '#0d47a1'); // Darkest edge
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2);
ctx.fillStyle = mainGradient;
ctx.fill();
ctx.restore();
}
The key to realistic marbles is adding white highlights that simulate light reflection.
// Primary highlight (top-left, simulates light source)
const highlight = ctx.createRadialGradient(
-radius * 0.35, -radius * 0.35, 0,
-radius * 0.35, -radius * 0.35, radius * 0.5
);
highlight.addColorStop(0, 'rgba(255,255,255,0.9)');
highlight.addColorStop(0.5, 'rgba(255,255,255,0.3)');
highlight.addColorStop(1, 'rgba(255,255,255,0)');
ctx.beginPath();
ctx.arc(-radius * 0.35, -radius * 0.35, radius * 0.5, 0, Math.PI * 2);
ctx.fillStyle = highlight;
ctx.fill();
// Secondary highlight (bottom-right, rim lighting)
const rimLight = ctx.createRadialGradient(
radius * 0.25, radius * 0.25, 0,
radius * 0.25, radius * 0.25, radius * 0.3
);
rimLight.addColorStop(0, 'rgba(255,255,255,0.3)');
rimLight.addColorStop(1, 'rgba(255,255,255,0)');
ctx.beginPath();
ctx.arc(radius * 0.25, radius * 0.25, radius * 0.3, 0, Math.PI * 2);
ctx.fillStyle = rimLight;
ctx.fill();
For extra realism, we add a subtle checker pattern reflection inside the marble.
// Subtle checker reflection
ctx.globalAlpha = 0.15; // Very transparent
const checkerSize = radius * 0.3;
for (let cy = -radius; cy < radius; cy += checkerSize) {
for (let cx = -radius; cx < radius; cx += checkerSize) {
const dist = Math.sqrt(cx**2 + cy**2);
if (dist < radius * 0.8) { // Only inside marble
const row = Math.floor((cy + radius) / checkerSize);
const col = Math.floor((cx + radius) / checkerSize);
if ((row + col) % 2 === 0) {
ctx.fillStyle = '#5c3d21';
ctx.fillRect(cx, cy, checkerSize * 0.8, checkerSize * 0.8);
}
}
}
}
ctx.globalAlpha = 1;
Modern phones have gyroscopes that report tilt angles. We use the DeviceOrientationEvent API to read these values and convert them into game input.
iOS 13+ requires explicit user permission for motion data. This must be triggered by a user action (like a button click).
async function enableMotion() {
// Check if permission API exists (iOS)
if (typeof DeviceOrientationEvent !== 'undefined' &&
typeof DeviceOrientationEvent.requestPermission === 'function') {
try {
const permission = await DeviceOrientationEvent.requestPermission();
if (permission === 'granted') {
window.addEventListener('deviceorientation', handleMotion);
return true;
}
} catch (err) {
console.error('Motion permission denied', err);
return false;
}
} else {
// Non-iOS browsers don't need permission
window.addEventListener('deviceorientation', handleMotion);
return true;
}
}
The DeviceOrientationEvent provides gamma (left/right tilt) and beta (forward/back tilt) in degrees.
function handleMotion(e) {
// gamma: left/right tilt (-90 to 90 degrees)
// beta: front/back tilt (-180 to 180 degrees)
let tiltX = e.gamma || 0;
let tiltY = e.beta || 0;
// Clamp to reasonable range
tiltX = Math.max(-45, Math.min(45, tiltX));
tiltY = Math.max(-45, Math.min(45, tiltY));
// Normalize to -1 to 1 range
tiltX = tiltX / 45;
// Offset beta so holding phone ~45 degrees is neutral
tiltY = (tiltY - 45) / 45;
}
Phone Orientation
================
gamma (X): beta (Y):
← Phone → Phone tilted
tilted forward/back
left/right
↗ beta increases
← gamma +
gamma -→ ↙ beta decreases
Device orientation axes for motion controls
For devices without motion sensors (or user preference), we implement a touch-friendly virtual joystick using CSS transforms and touch events.
<div class="joystick-container" id="joystickContainer">
<div class="joystick-base"></div>
<div class="joystick-handle" id="joystickHandle"></div>
</div>
We track touch position relative to the joystick center and calculate normalized X/Y values.
let joystickActive = false;
let joystickOrigin = { x: 0, y: 0 };
const maxDist = 35; // Max handle displacement in pixels
function handleJoystickStart(e) {
e.preventDefault();
joystickActive = true;
// Store the joystick center position
const rect = joystickContainer.getBoundingClientRect();
joystickOrigin.x = rect.left + rect.width / 2;
joystickOrigin.y = rect.top + rect.height / 2;
}
function handleJoystickMove(e) {
if (!joystickActive) return;
e.preventDefault();
// Get touch/mouse position
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
// Calculate offset from center
let dx = clientX - joystickOrigin.x;
let dy = clientY - joystickOrigin.y;
// Clamp to max distance
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > maxDist) {
dx = (dx / dist) * maxDist;
dy = (dy / dist) * maxDist;
}
// Move handle visually
joystickHandle.style.transform =
`translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
// Normalize to -1 to 1 for game input
joystickX = dx / maxDist;
joystickY = dy / maxDist;
}
Use { passive: false } for touchstart/touchmove if you need preventDefault(). Otherwise use passive for better scroll performance.
The game uses circle-to-circle collision for detecting marble interaction with holes and the goal.
The marble falls if its center gets close enough to a hole's center.
function checkHoleCollisions() {
holes.forEach(hole => {
// Hole center in canvas coordinates
const hx = hole.x * TILE_SIZE + TILE_SIZE / 2;
const hy = hole.y * TILE_SIZE + TILE_SIZE / 2;
// Distance from marble to hole center
const dx = marble.x - hx;
const dy = marble.y - hy;
const dist = Math.sqrt(dx * dx + dy * dy);
// Fall in if center is inside hole
// (using 60% of hole radius for forgiving gameplay)
if (dist < hole.radius * 0.6) {
handleFallInHole();
}
});
}
Reaching the goal uses similar circle collision but triggers level completion.
function checkGoalCollision() {
const gx = goal.x * TILE_SIZE + TILE_SIZE / 2;
const gy = goal.y * TILE_SIZE + TILE_SIZE / 2;
const goalRadius = TILE_SIZE * 0.35;
const dx = marble.x - gx;
const dy = marble.y - gy;
const dist = Math.sqrt(dx * dx + dy * dy);
// Win when marble touches goal
if (dist < goalRadius + marble.radius * 0.5) {
handleLevelComplete();
}
}
Collision Detection
===================
Hole Collision: Goal Collision:
hole.radius goal.radius
↓ ↓
┌─────┐ ╭─────╮
│ o │ marble │ * │ marble
│ ╭─╮ │ center │ ╭─╮ │ edge
│ │●│ │ inside ╰─│●│─╯ touching
│ ╰─╯ │ = FALL ╰─╯ = WIN
└─────┘
Different collision thresholds for holes vs. goal
Each level is generated procedurally with increasing difficulty - more holes and a farther goal position.
function generateLevel(lvl) {
holes = [];
// More holes at higher levels (caps at 15)
const numHoles = Math.min(3 + Math.floor(lvl * 1.5), 15);
// Reset marble to start position
marble.x = TILE_SIZE * 1.5; // Second tile, centered
marble.y = TILE_SIZE * 1.5;
marble.vx = 0;
marble.vy = 0;
// Goal gets farther at higher levels
goal.x = Math.min(4 + Math.floor(lvl / 2), 6);
goal.y = Math.min(4 + Math.floor(lvl / 2), 6);
// Generate holes avoiding start and goal areas
while (holes.length < numHoles) {
const hx = Math.floor(Math.random() * GRID_SIZE);
const hy = Math.floor(Math.random() * GRID_SIZE);
// Safe zones
const nearStart = (hx <= 2 && hy <= 2);
const nearGoal = (Math.abs(hx - goal.x) <= 1 &&
Math.abs(hy - goal.y) <= 1);
const exists = holes.some(h => h.x === hx && h.y === hy);
if (!nearStart && !nearGoal && !exists) {
holes.push({
x: hx,
y: hy,
radius: TILE_SIZE * 0.35
});
}
}
}
The formula ensures difficulty ramps up smoothly:
// Level 1: 4 holes, goal at (4,4)
// Level 2: 6 holes, goal at (4,4)
// Level 3: 7 holes, goal at (5,5)
// Level 4: 9 holes, goal at (5,5)
// Level 5: 10 holes, goal at (6,6)
// Level 8+: 15 holes (max), goal at (6,6)
const numHoles = Math.min(3 + Math.floor(level * 1.5), 15);
const goalPos = Math.min(4 + Math.floor(level / 2), 6);
The safe zones around start and goal ensure every level is beatable. Without these, random generation could create impossible levels!