TECH DETAILS

Marble Game - Day 23

Intermediate Level

Table of Contents

01 / Marble Physics

The marble follows realistic rolling physics with friction, acceleration, and velocity clamping. The physics model simulates a ball rolling on a tilted surface.

Physics Constants

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

Physics Update Loop

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; }

Wall Bounce Physics

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...
Physics Tuning

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!

02 / Reflective Marble Rendering

The marble is rendered with multiple gradient layers to create a realistic glass/crystal appearance with reflections.

Layered Gradient Technique

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(); }

Highlight Reflections

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();

Environment Reflection

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;

03 / Device Orientation API

Modern phones have gyroscopes that report tilt angles. We use the DeviceOrientationEvent API to read these values and convert them into game input.

Requesting Permission (iOS)

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; } }

Reading Tilt Angles

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

04 / Virtual Joystick

For devices without motion sensors (or user preference), we implement a touch-friendly virtual joystick using CSS transforms and touch events.

Joystick HTML Structure

<div class="joystick-container" id="joystickContainer"> <div class="joystick-base"></div> <div class="joystick-handle" id="joystickHandle"></div> </div>

Touch Event Handling

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; }
Touch Performance

Use { passive: false } for touchstart/touchmove if you need preventDefault(). Otherwise use passive for better scroll performance.

05 / Collision Detection

The game uses circle-to-circle collision for detecting marble interaction with holes and the goal.

Hole Collision (Fall Detection)

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(); } }); }

Goal Collision (Win Detection)

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

06 / Procedural Level Generation

Each level is generated procedurally with increasing difficulty - more holes and a farther goal position.

Level Generation Algorithm

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 }); } } }

Difficulty Progression

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);
Fairness Guarantee

The safe zones around start and goal ensure every level is beatable. Without these, random generation could create impossible levels!