Wine Bottle Pyramid - Day 11
Physics SimulationA physics-based throwing game built with pure JavaScript and Canvas - no physics libraries required.
// Core technologies
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// Physics constants - tune these for game feel
const GRAVITY = 0.35; // Downward acceleration per frame
const FRICTION = 0.98; // Air resistance (1 = none)
const BOUNCE = 0.6; // Energy retained on bounce (1 = perfect)
For a simple projectile game, custom physics gives you precise control over game feel without the overhead of libraries like Matter.js or Box2D.
Every physics object needs position, velocity, and we apply forces each frame. This is the foundation of any physics simulation.
const ball = {
x: 80, // Position X
y: GROUND_Y - 20, // Position Y
radius: 18, // Size for collision
vx: 0, // Velocity X (pixels/frame)
vy: 0, // Velocity Y (pixels/frame)
launched: false // State flag
};
const bottle = {
x: 0, y: 0,
width: 22, height: 60,
vx: 0, vy: 0,
rotation: 0, // Current angle (radians)
rotationSpeed: 0, // Angular velocity
fallen: false, // Hit by ball?
grounded: false // Stopped moving?
};
function updatePhysics() {
// Apply gravity (acceleration)
ball.vy += GRAVITY;
// Apply velocity to position
ball.x += ball.vx;
ball.y += ball.vy;
// Apply air friction
ball.vx *= FRICTION;
// Ground collision
if (ball.y + ball.radius > GROUND_Y) {
ball.y = GROUND_Y - ball.radius; // Prevent sinking
ball.vy *= -BOUNCE; // Reverse & reduce velocity
ball.vx *= 0.9; // Ground friction
}
}
Detecting when the ball hits bottles, and making bottles knock each other over - this creates the satisfying chain reaction effect.
function checkCollisions() {
bottles.forEach(bottle => {
if (bottle.grounded) return;
// Find bottle center
const bottleCenterX = bottle.x + bottle.width / 2;
const bottleCenterY = bottle.y + bottle.height / 2;
// Distance from ball to bottle center
const dx = ball.x - bottleCenterX;
const dy = ball.y - bottleCenterY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Collision threshold (simplified as circle-to-circle)
if (distance < ball.radius + bottle.width / 2) {
// Mark as hit
if (!bottle.fallen) {
bottle.fallen = true;
score += 100;
}
// Calculate collision angle
const angle = Math.atan2(dy, dx);
// Transfer momentum to bottle
const force = Math.sqrt(ball.vx**2 + ball.vy**2) * 0.5;
bottle.vx += Math.cos(angle + Math.PI) * force;
bottle.vy += Math.sin(angle + Math.PI) * force * 0.5;
bottle.rotationSpeed = (Math.random() - 0.5) * 0.3;
// Ball loses momentum
ball.vx *= 0.7;
ball.vy *= 0.7;
}
});
}
When bottles collide with each other, they transfer momentum - creating satisfying domino effects.
// Inside the bottles loop, check bottle-to-bottle collisions
bottles.forEach(other => {
if (other === bottle || other.grounded) return;
const odx = bottleCenterX - (other.x + other.width / 2);
const ody = bottleCenterY - (other.y + other.height / 2);
const odist = Math.sqrt(odx * odx + ody * ody);
// If bottles are close and one is falling
if (odist < bottle.width && bottle.fallen) {
if (!other.fallen) {
other.fallen = true;
score += 100;
}
// Transfer partial momentum
other.vx += bottle.vx * 0.3;
other.vy += bottle.vy * 0.3;
other.rotationSpeed = (Math.random() - 0.5) * 0.2;
}
});
We use circle-to-circle collision instead of true rectangle collision. It's less accurate but much simpler and works well for this game style.
Showing the ball's predicted path before throwing helps players aim - it simulates the physics forward in time.
function drawAimLine() {
if (!isDragging || ball.launched) return;
// Calculate initial velocity from drag
const dx = dragStart.x - dragEnd.x;
const dy = dragStart.y - dragEnd.y;
const power = Math.min(Math.sqrt(dx*dx + dy*dy), 150);
const angle = Math.atan2(dy, dx);
// Simulate trajectory
let previewX = ball.x;
let previewY = ball.y;
let previewVX = Math.cos(angle) * power * 0.15;
let previewVY = Math.sin(angle) * power * 0.15;
ctx.setLineDash([5, 5]);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.beginPath();
ctx.moveTo(ball.x, ball.y);
// Simulate 30 frames ahead
for (let i = 0; i < 30; i++) {
previewX += previewVX;
previewY += previewVY;
previewVY += GRAVITY; // Apply same gravity
ctx.lineTo(previewX, previewY);
if (previewY > GROUND_Y) break; // Stop at ground
}
ctx.stroke();
ctx.setLineDash([]); // Reset dash
}
Keep the prediction loop short (30 iterations here). Each prediction recalculates every frame during drag, so it needs to be fast.
The slingshot-style aiming: drag back to set power and angle, release to throw. Works identically on mouse and touch.
// Convert any event (mouse or touch) to canvas coordinates
function getEventPos(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
if (e.touches) {
return {
x: (e.touches[0].clientX - rect.left) * scaleX,
y: (e.touches[0].clientY - rect.top) * scaleY
};
}
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY
};
}
canvas.addEventListener('mouseup', (e) => {
if (!isDragging || ball.launched) return;
// Vector from end to start (pull back direction)
const dx = dragStart.x - dragEnd.x;
const dy = dragStart.y - dragEnd.y;
// Power clamped to max 150
const power = Math.min(Math.sqrt(dx*dx + dy*dy), 150);
// Angle in radians
const angle = Math.atan2(dy, dx);
// Minimum power threshold prevents accidental taps
if (power > 10) {
// Convert polar (angle, power) to cartesian (vx, vy)
ball.vx = Math.cos(angle) * power * 0.15;
ball.vy = Math.sin(angle) * power * 0.15;
ball.launched = true;
}
isDragging = false;
});
The color-changing power bar (rgba(255, 255-power, 0)) gives instant visual feedback - green for weak throws, red for maximum power.
Drawing bottles with rotation requires Canvas transforms. Save state, translate, rotate, draw, restore.
function drawBottle(bottle) {
ctx.save(); // Save current transform state
// Move origin to bottle center
ctx.translate(
bottle.x + bottle.width / 2,
bottle.y + bottle.height / 2
);
// Apply rotation around center
ctx.rotate(bottle.rotation);
// Draw bottle body (now centered at origin)
const gradient = ctx.createLinearGradient(
-bottle.width/2, 0,
bottle.width/2, 0
);
gradient.addColorStop(0, bottle.color);
gradient.addColorStop(0.5, lightenColor(bottle.color, 50));
gradient.addColorStop(1, bottle.color);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.roundRect(
-bottle.width/2,
-bottle.height/2 + 15,
bottle.width,
bottle.height - 15,
3
);
ctx.fill();
// Neck, cork, label... (all relative to origin)
ctx.restore(); // Restore original transform
}
// Subtle highlight that makes the bottle look like glass
ctx.fillStyle = 'rgba(255,255,255,0.2)';
ctx.beginPath();
ctx.ellipse(
-bottle.width/4, // Left side
0, // Center vertically
3, // Width
bottle.height/3, // Height - tall oval
0, // No rotation
0, Math.PI * 2
);
ctx.fill();
Always translate before rotate. This ensures rotation happens around the bottle's center, not the canvas origin.