Architecture & Core Concept
The badminton court is rendered in pseudo-3D perspective. Court coordinates: - X: Normalized -1 (left boundary) to +1 (right boundary) - Y: 0 (opponent baseline) to 1 (player baseline) - Z: Shuttlecock height above ground in pixels Projection formula: const half = 150 + 210 * y; return [480 + nx * half, 145 + y * 330]; Shuttlecock motion updates in 3 axes: x += vx*dt, y += vy*dt, z += vz*dt, with vz -= 520*dt (gravity). If Z reaches 0, the shuttle hits the floor: whichever side it lands on determines the point!
Key Game Mechanics Taught:
- 2.5D perspective coordinate projection: world (nx, y) to screen [px, py]
- 3D shuttlecock physics: X (width), Y (depth), Z (elevation height)
- Player racket reach timing windows and smash altitude thresholds
- AI tracking algorithms with artificial reaction delays
- Match scoring and alternating serving rules
Step-by-Step Implementation
Project 2.5D Perspective Court Coordinates
A perspective projection maps normalized court coordinates (nx from -1 to 1, and y from 0 to 1) into screen pixels. Lines that are further away converge toward the vanishing center.
function projectCourt(nx, y) {
// At far baseline (y=0), court half-width is 150px.
// At near baseline (y=1), court half-width is 360px.
const half = 150 + 210 * y;
const screenX = 480 + nx * half;
const screenY = 145 + y * 330;
return [screenX, screenY];
}
Model Shuttlecock Trajectory with Height (Z)
Unlike 2D games where Y is vertical, here Y is depth across the court, and Z is elevation above the floor. Gravity reduces Z over time.
const shuttle = {
x: 0, // -1 to 1
y: 0.82, // 0 to 1
z: 55, // Height in pixels
vx: 0, vy: 0, vz: 0,
live: false
};
function updateShuttle(dt) {
if (!shuttle.live) return;
shuttle.x += shuttle.vx * dt;
shuttle.y += shuttle.vy * dt;
shuttle.z += shuttle.vz * dt;
shuttle.vz -= 520 * dt; // Gravity
// Net collision & ground hit
if (shuttle.z <= 0) {
shuttle.z = 0;
shuttle.live = false;
// If landed in player's half (y > 0.5), opponent scores
awardPoint(shuttle.y > 0.5 ? 'opponent' : 'player');
}
}
Hit Detection: Lob vs Smash
When the player swings, check if the shuttlecock is within reaching distance. If the shuttlecock is high (z > 55) and the player pressed Smash, launch a steep, high-velocity downward smash.
function swingRacket(isSmash) {
const inReach = shuttle.y > 0.73 && shuttle.y < 1.05 &&
shuttle.z > 12 && shuttle.z < 145 &&
Math.abs(shuttle.x - player.x) < 0.36;
if (inReach) {
shuttle.live = true;
const targetX = (Math.random() * 1.5 - 0.75); // Target on opponent's side
shuttle.vx = (targetX - shuttle.x) * (isSmash ? 1.9 : 1.25);
shuttle.vy = isSmash ? -1.25 : -0.72; // Deep trajectory toward opponent
shuttle.vz = isSmash ? 55 : 255; // Smash shoots down, lob arches high
playRacketSound(isSmash);
}
}
Complete Standalone Source Code
Here is the full, self-contained implementation. Save this as an index.html file on your computer and open it in any web browser to run your game immediately:
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Badminton 2.5D Demo</title>
<style>body{margin:0;background:#153448;display:flex;justify-content:center;align-items:center;height:100vh;}canvas{background:#b65535;}</style>
</head><body><canvas id="c" width="960" height="540"></canvas>
<script>
const c = document.querySelector('#c'), g = c.getContext('2d');
const P = { x:0, score:0 }, AI = { x:0, score:0 };
const S = { x:0, y:0.8, z:50, vx:0, vy:-0.7, vz:200, live:1 };
let last = performance.now();
function court(nx, y) {
const half = 150 + 210 * y;
return [480 + nx * half, 145 + y * 330];
}
window.onkeydown = e => {
if(['ArrowLeft','a'].includes(e.key)) P.x = Math.max(-0.85, P.x - 0.15);
if(['ArrowRight','d'].includes(e.key)) P.x = Math.min(0.85, P.x + 0.15);
if(e.key === ' ' || e.key === 'j') hit(0);
if(e.key === 'k') hit(1); // Smash
};
function hit(smash) {
if(S.y > 0.7 && S.y < 1.05 && S.z > 10 && S.z < 140 && Math.abs(S.x - P.x) < 0.4) {
S.vx = (Math.random()*1.4 - 0.7 - S.x) * (smash?1.8:1.2);
S.vy = smash ? -1.2 : -0.7;
S.vz = smash ? 60 : 250;
}
}
function loop(t) {
const dt = Math.min((t-last)/1000, 0.03); last = t;
S.x += S.vx * dt; S.y += S.vy * dt; S.z += S.vz * dt; S.vz -= 520 * dt;
AI.x += (S.x - AI.x) * dt * 3.0; // AI tracking
if(S.y < 0.25 && S.y > 0.0 && S.z > 10 && S.z < 140 && Math.abs(S.x - AI.x) < 0.4) {
S.vx = (P.x - S.x) * 1.2; S.vy = 0.72; S.vz = 220;
}
if(S.z <= 0) {
if(S.y > 0.5) AI.score++; else P.score++;
S.x = 0; S.y = 0.8; S.z = 50; S.vx = 0; S.vy = -0.7; S.vz = 200;
}
g.fillStyle = '#b65535'; g.fillRect(0,0,960,540);
g.fillStyle = '#176d48';
g.beginPath();
g.moveTo(...court(-1,0)); g.lineTo(...court(1,0)); g.lineTo(...court(1,1)); g.lineTo(...court(-1,1));
g.fill();
g.strokeStyle = '#fff'; g.lineWidth = 3;
g.stroke();
// Net
g.fillStyle = 'rgba(255,255,255,0.7)'; g.fillRect(55, 306, 850, 6);
// Players
const [px, py] = court(P.x, 0.89);
g.fillStyle = '#147f72'; g.fillRect(px - 15, py - 30, 30, 30);
const [ax, ay] = court(AI.x, 0.11);
g.fillStyle = '#df4e49'; g.fillRect(ax - 10, ay - 20, 20, 20);
// Shuttlecock
const [sx, sy] = court(S.x, S.y);
g.fillStyle = '#fff'; g.beginPath(); g.arc(sx, sy - S.z, 6, 0, 7); g.fill();
g.fillStyle = '#fff'; g.font = 'bold 18px sans-serif';
g.fillText(`PLAYER: ${P.score} | AI: ${AI.score}`, 20, 40);
g.fillText('Controls: A/D (Move), Space/J (Clear), K (Smash)', 20, 70);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body></html>
Ideas to Expand Your Game
Now that you have the core mechanics running, consider adding:
- Local High Scores: Use
localStorage.setItem()andlocalStorage.getItem()to persist personal bests. - Visual Polish: Add screen shake on impacts by displacing
ctx.translate(shakeX, shakeY). - Mobile Touch Controls: Bind on-screen directional buttons using
onpointerdownandonpointerup.