Yeti Penguin Launcher - Day 22
Intermediate LevelThe penguin follows realistic projectile motion with gravity, air resistance, ground friction, and bounce damping. These constants define how the penguin flies and stops.
const GRAVITY = 0.15; // Downward acceleration per frame
const AIR_RESISTANCE = 0.998; // Velocity multiplier (slight slowdown)
const GROUND_FRICTION = 0.96; // When rolling on ground
const BOUNCE_DAMPING = 0.5; // Energy lost on bounce (50%)
Every frame, we apply forces and update the penguin's position.
function update() {
// Apply gravity (increases downward velocity)
penguin.vy += GRAVITY;
// Apply air resistance (reduces both velocities slightly)
penguin.vx *= AIR_RESISTANCE;
penguin.vy *= AIR_RESISTANCE;
// Update position based on velocity
penguin.x += penguin.vx;
penguin.y += penguin.vy;
// Rotation for visual tumbling effect
penguin.rotation += penguin.rotationSpeed;
penguin.rotationSpeed *= 0.99; // Slow rotation over time
}
When the penguin hits the ground, we reverse and dampen the vertical velocity.
// Ground collision check
if (penguin.y >= GROUND_Y - penguin.height / 2) {
penguin.y = GROUND_Y - penguin.height / 2;
if (Math.abs(penguin.vy) > 1) {
// Bounce: reverse Y velocity, reduce by damping
penguin.vy = -penguin.vy * BOUNCE_DAMPING;
penguin.vx *= 0.9; // Lose some horizontal speed too
bounceCount++;
// Random spin on bounce
penguin.rotationSpeed = (Math.random() - 0.5) * 0.2;
} else {
// Too slow to bounce - start rolling
penguin.vy = 0;
penguin.isOnGround = true;
penguin.vx *= GROUND_FRICTION;
penguin.rotation = 0; // Upright when rolling
}
}
Adjust GRAVITY for arc height, AIR_RESISTANCE for max distance, and BOUNCE_DAMPING for bounce behavior. Small changes make big differences!
The charge bar creates a satisfying power-up mechanic. Longer hold = more power, up to a 2-second maximum.
let chargeStart = 0;
let chargePower = 0;
function handleStart(e) {
if (gameState === 'ready') {
gameState = 'charging';
chargeStart = Date.now(); // Record start time
}
}
function updateCharge() {
// Calculate power: 0-1 over 2 seconds
const chargeTime = Math.min(Date.now() - chargeStart, 2000);
chargePower = chargeTime / 2000;
}
On release, we convert the charge power into initial velocity using trigonometry.
function handleEnd(e) {
if (gameState === 'charging') {
// Base power + bonus from charge
const launchPower = 15 + chargePower * 20; // 15-35 range
// Launch angle: higher charge = slightly lower angle
// Base angle: 45 degrees up (-PI/4)
const launchAngle = -Math.PI / 4 - (chargePower * Math.PI / 8);
// Convert polar (angle, power) to Cartesian (vx, vy)
penguin.vx = Math.cos(launchAngle) * launchPower;
penguin.vy = Math.sin(launchAngle) * launchPower;
// Add some rotation for visual flair
penguin.rotationSpeed = 0.1 + Math.random() * 0.1;
gameState = 'flying';
}
}
Players can tap during flight to get extra boosts, adding skill to the game.
const FLAP_BOOST = -3; // Upward velocity boost
const MAX_FLAPS = 3; // Limited flaps per launch
// In handleStart, when already flying:
if (gameState === 'flying' && penguin.flapsLeft > 0 && !penguin.isOnGround) {
penguin.vy += FLAP_BOOST; // Boost upward
penguin.vx *= 1.05; // Small speed boost
penguin.flapsLeft--;
}
Launch Vector Breakdown:
vy (negative = up)
^
| /
| / launchPower
|/
+-------> vx (positive = right)
launchAngle
vx = cos(angle) * power
vy = sin(angle) * power
Trigonometry converts angle and power to velocity components
Different obstacles have different collision responses - ramps boost you up, seals give big bounces, and ice blocks slow you down.
function checkCollision(penguin, obstacle) {
// Convert penguin center to top-left corner
const px = penguin.x - penguin.width / 2;
const py = penguin.y - penguin.height / 2;
// Axis-Aligned Bounding Box overlap test
return px < obstacle.x + obstacle.width &&
px + penguin.width > obstacle.x &&
py < obstacle.y + obstacle.height &&
py + penguin.height > obstacle.y;
}
function handleObstacleCollision(obs) {
if (obs.type === 'ramp') {
// Ramp: converts horizontal momentum to vertical
if (penguin.vy > 0) { // Only if falling
penguin.vy = -Math.abs(penguin.vx) * 0.6;
penguin.vx *= 1.1; // Speed boost
penguin.rotationSpeed = 0.15;
bounceCount++;
}
}
else if (obs.type === 'seal') {
// Seal: big bouncy surface
penguin.vy = -12; // Strong upward boost
penguin.vx *= 1.15; // Speed boost
penguin.rotationSpeed = 0.2;
bounceCount++;
}
else if (obs.type === 'snowpile') {
// Snow pile: small bounce
if (penguin.vy > 0) {
penguin.vy = -5;
bounceCount++;
}
}
else if (obs.type === 'iceblock') {
// Ice block: wall - slows you down
penguin.vx *= 0.5; // Lose half speed
penguin.vy = -3; // Small bounce up
}
}
The ramp's velocity conversion (horizontal to vertical) creates satisfying "launch" moments. The seal's large bounce encourages players to aim for it.
The camera follows the penguin but stays slightly behind, creating a smooth parallax effect and showing where you're going.
let cameraX = 0; // World X position of camera's left edge
function updateCamera() {
// Keep penguin at 30% from left of screen
const targetX = canvas.width * 0.3;
if (penguin.x > cameraX + targetX) {
cameraX = penguin.x - targetX;
}
}
All world positions must be converted to screen positions for drawing.
// Drawing the penguin
const screenX = penguin.x - cameraX;
drawPenguin(screenX, penguin.y);
// Drawing obstacles (only visible ones)
for (const obs of obstacles) {
const obsScreenX = obs.x - cameraX;
// Culling: skip objects outside view
if (obsScreenX > -100 && obsScreenX < canvas.width + 100) {
drawObstacle(obs, obsScreenX);
}
}
// Mountains scroll slower (parallax factor 0.1)
const mountainX = (i * 200 - cameraX * 0.1) % (canvas.width + 200) - 100;
// Clouds scroll at half speed (parallax factor 0.5)
const cloudScreenX = cloud.x - cameraX * 0.5;
Obstacles are generated procedurally at game start with random types and positions, creating variety in each playthrough.
function generateObstacles() {
obstacles = [];
// Generate from 400 to 50000 units away
for (let x = 400; x < 50000; x += Math.random() * 300 + 150) {
const type = Math.random();
if (type < 0.3) {
// 30% chance: Ice ramp (boosts up)
obstacles.push({
type: 'ramp',
x: x,
y: GROUND_Y,
width: 60,
height: 25
});
}
else if (type < 0.5) {
// 20% chance: Snow pile (small bounce)
obstacles.push({ type: 'snowpile', ... });
}
else if (type < 0.7) {
// 20% chance: Ice block (wall)
obstacles.push({ type: 'iceblock', ... });
}
else {
// 30% chance: Bouncy seal
obstacles.push({ type: 'seal', ... });
}
}
}
// Spacing: random 150-450 units apart
x += Math.random() * 300 + 150;
// This creates variable density:
// - Min gap: 150 units (dense)
// - Max gap: 450 units (sparse)
// - Average: 300 units between obstacles
Seals and ramps are "good" obstacles (30% + 30% = 60%), while ice blocks are "bad" (20%). Snow piles are neutral. This ratio ensures satisfying gameplay.
Characters are drawn using basic Canvas primitives - ellipses, arcs, and rectangles - creating a cute, stylized look without external assets.
function drawPenguin(x, y) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(penguin.rotation); // Tumbling effect
// Body (black oval)
ctx.fillStyle = '#1a1a2e';
ctx.beginPath();
ctx.ellipse(0, 0, 12, 15, 0, 0, Math.PI * 2);
ctx.fill();
// Belly (white oval)
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.ellipse(0, 2, 8, 10, 0, 0, Math.PI * 2);
ctx.fill();
// Head, eyes, beak, wings, feet...
ctx.restore();
}
if (gameState === 'flying' && !penguin.isOnGround) {
// Calculate flap angle using sine wave
const flapAngle = Math.sin(Date.now() / 50) * 0.5;
// Left wing
ctx.save();
ctx.rotate(flapAngle);
ctx.fillRect(-18, -5, 8, 15);
ctx.restore();
// Right wing (opposite angle)
ctx.save();
ctx.rotate(-flapAngle);
ctx.fillRect(10, -5, 8, 15);
ctx.restore();
}
// Yeti's arm holding club rotates during charge
ctx.save();
ctx.translate(x + 30, y - 10); // Shoulder position
ctx.rotate(yeti.clubAngle + Math.PI / 4);
// Arm
ctx.fillStyle = '#f5f5f5'; // White fur
ctx.fillRect(-8, 0, 16, 50);
// Club handle
ctx.fillStyle = '#8b4513'; // Brown wood
ctx.fillRect(-10, 40, 20, 60);
// Club head
ctx.fillStyle = '#5d3a1a';
ctx.beginPath();
ctx.arc(0, 95, 18, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
Always use save() and restore() around transformations. This prevents rotation and translation from affecting other drawings.