Skip to content

Canvas 2D गेम लूप की बुनियादी बातें

गेम लूप आपके गेम की धड़कन है। यह लगातार चलता रहता है, गेम स्टेट को अपडेट करता है और ब्राउज़र जितनी तेजी से अनुमति दे उतनी तेजी से फ्रेम बनाता है। इसे सही कर लें तो बाकी सब कुछ आसान हो जाता है। गलत कर दें तो आप ऐसे बग के पीछे भागते रहेंगे जो सिर्फ कुछ मशीनों पर ही दिखते हैं।

इसे आज़माएं (arrow keys या WASD का इस्तेमाल करें):

सबसे सरल वर्ज़न

यहां एक गेम लूप है जिसे उसकी सबसे ज़रूरी चीज़ों तक घटा दिया गया है:

js
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

function gameLoop() {
  update()
  render()
  requestAnimationFrame(gameLoop)
}

function update() {
  // Game logic here
}

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  // Drawing here
}

gameLoop()

यह काम तो करता है, पर इसमें एक समस्या है। update फंक्शन को नहीं पता कि पिछले फ्रेम के बाद से कितना समय बीत चुका है। एक 144Hz मॉनिटर पर यह 60Hz मॉनिटर की तुलना में दोगुनी तेजी से चलता है। आपके गेम की फिजिक्स अलग-अलग मशीनों पर अलग-अलग व्यवहार करेगी।

डेल्टा टाइम फ्रेम रेट की समस्याएं ठीक करता है

अपने गेम को हर जगह एक ही रफ्तार से चलाने के लिए, आपको यह ट्रैक करना होगा कि फ्रेम के बीच कितना समय बीता। इसे डेल्टा टाइम कहते हैं, जिसे आम तौर पर dt लिखा जाता है।

js
let lastTime = 0

function gameLoop(currentTime) {
  const dt = (currentTime - lastTime) / 1000 // convert to seconds
  lastTime = currentTime
  
  update(dt)
  render()
  
  requestAnimationFrame(gameLoop)
}

function update(dt) {
  player.x += player.speed * dt // now works at any frame rate
}

requestAnimationFrame(gameLoop)

requestAnimationFrame कॉलबैक को मिलीसेकंड में मौजूदा टाइमस्टैम्प मिलता है। मैं इसे 1000 से भाग देकर सेकंड में बदल देता हूं क्योंकि इससे गणित आसान हो जाता है। 200 की speed का मतलब है 200 पिक्सेल प्रति सेकंड, जो समझने में सहज है।

एक चीज़ का ध्यान रखें: पहले फ्रेम पर lastTime 0 होता है, इसलिए dt बहुत बड़ा हो जाएगा। कुछ लोग लूप शुरू करने से पहले lastTime को performance.now() से इनिशियलाइज़ कर देते हैं, या पहले फ्रेम के अपडेट को छोड़ देते हैं।

setInterval की जगह requestAnimationFrame क्यों

आप सोच सकते हैं कि हम setInterval की जगह requestAnimationFrame क्यों इस्तेमाल करते हैं। इसके कुछ कारण हैं।

पहला, requestAnimationFrame डिस्प्ले की रिफ्रेश रेट के साथ सिंक होता है, इसलिए आपको बिना टियरिंग के स्मूद एनिमेशन मिलता है। दूसरा, टैब छिप जाने पर यह अपने आप रुक जाता है, जिससे बैटरी और CPU की बचत होती है। तीसरा, ब्राउज़र इसे बेहतर ऑप्टिमाइज़ कर सकता है।

वह रुकने वाला व्यवहार आम तौर पर आपके काम का होता है, पर इसका मतलब है कि जब यूज़र टैब बदलता है तो आपका गेम फ्रीज़ हो जाता है। अगर आप मल्टीप्लेयर गेम या ऐसा कुछ बना रहे हैं जिसे चलते रहना ज़रूरी है, तो आपको एक अलग तरीका अपनाना होगा।

फिजिक्स के लिए फिक्स्ड टाइमस्टेप

वेरिएबल डेल्टा टाइम सरल मूवमेंट के लिए काम करता है, पर फिजिक्स सिमुलेशन में यह दिक्कतें पैदा करता है। अगर एक फ्रेम में बहुत ज़्यादा समय लग जाए, तो आपका कैरेक्टर किसी दीवार के आर-पार निकल सकता है क्योंकि कोलिज़न चेक टकराव के पल को चूक गया।

इसका समाधान है फिक्स्ड टाइमस्टेप। आप समय जमा करते जाते हैं और फिजिक्स अपडेट को एक तय रफ्तार से चलाते हैं, चाहे फ्रेम कितनी भी तेजी से रेंडर हो रहे हों।

js
const FIXED_DT = 1 / 60 // 60 physics updates per second
let accumulator = 0
let lastTime = 0

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  accumulator += frameTime
  
  while (accumulator >= FIXED_DT) {
    update(FIXED_DT)
    accumulator -= FIXED_DT
  }
  
  render()
  requestAnimationFrame(gameLoop)
}

वह Math.min(..., 0.25) ज़रूरी है। अगर टैब छिपा हुआ था और फिर वापस आता है, तो frameTime बहुत बड़ा हो सकता है। बिना इस कैप के, while लूप पकड़ने की कोशिश में सैकड़ों अपडेट चला देगा, जिससे गेम फ्रीज़ हो जाएगा। इसे 0.25 सेकंड पर कैप करने का मतलब है कि आप प्रति फ्रेम 15 से ज़्यादा फिजिक्स अपडेट कभी नहीं चलाएंगे।

गेम स्टेट को व्यवस्थित करना

जैसे-जैसे आपका गेम बढ़ता है, आपको सारा डेटा कहीं रखना होता है। मुझे एक ही ऑब्जेक्ट इस्तेमाल करना पसंद है जो सब कुछ अपने पास रखे:

js
const game = {
  state: 'playing', // 'menu', 'playing', 'paused', 'gameover'
  score: 0,
  player: {
    x: 100,
    y: 100,
    vx: 0,
    vy: 0,
    speed: 200,
    width: 32,
    height: 32,
  },
  entities: [],
}

इससे गेम स्टेट को सेव और लोड करना, प्लेयर के मरने पर रीसेट करना, और game को कंसोल में लॉग करके डीबग करना आसान हो जाता है। कुछ लोग classes पसंद करते हैं, पर छोटे से मध्यम आकार के गेम के लिए एक साधारण ऑब्जेक्ट ठीक काम करता है।

सब कुछ एक साथ जोड़ना

js
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')

const FIXED_DT = 1 / 60
let accumulator = 0
let lastTime = 0

const input = { left: false, right: false, up: false, down: false }

const game = {
  player: { x: 100, y: 100, speed: 200 },
}

function update(dt) {
  const p = game.player
  
  if (input.left) p.x -= p.speed * dt
  if (input.right) p.x += p.speed * dt
  if (input.up) p.y -= p.speed * dt
  if (input.down) p.y += p.speed * dt
  
  // Keep in bounds
  p.x = Math.max(0, Math.min(canvas.width - 32, p.x))
  p.y = Math.max(0, Math.min(canvas.height - 32, p.y))
}

function render() {
  ctx.fillStyle = '#1a1a2e'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  
  ctx.fillStyle = '#4ade80'
  ctx.fillRect(game.player.x, game.player.y, 32, 32)
}

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  accumulator += frameTime
  
  while (accumulator >= FIXED_DT) {
    update(FIXED_DT)
    accumulator -= FIXED_DT
  }
  
  render()
  requestAnimationFrame(gameLoop)
}

// Input handling
window.addEventListener('keydown', (e) => {
  if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = true
  if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = true
  if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = true
  if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = true
})

window.addEventListener('keyup', (e) => {
  if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = false
  if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = false
  if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = false
  if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = false
})

requestAnimationFrame(gameLoop)

यह एक पूरा, चलने लायक गेम लूप है। प्लेयर arrow keys या WASD से चलता है, सीमाओं के अंदर रहता है, और फ्रेम रेट चाहे जो हो, सब कुछ एक जैसी रफ्तार से चलता है। आप किसी भी Canvas 2D गेम के लिए इसे शुरुआती बिंदु के तौर पर कॉपी कर सकते हैं।

ध्यान दें कि इनपुट हैंडलिंग गेम लूप से अलग है। इवेंट लिसनर बस flags सेट करते हैं, और update फंक्शन उन flags को पढ़ता है। यह अलगाव ज़रूरी है क्योंकि कीबोर्ड इवेंट अनिश्चित समय पर फायर होते हैं, और आप चाहते हैं कि आपकी गेम लॉजिक एक तय रफ्तार से चले।

स्प्राइट्स लोड करना और बनाना

js
const sprites = {}

async function loadSprite(name, url) {
  return new Promise((resolve) => {
    const img = new Image()
    img.onload = () => {
      sprites[name] = img
      resolve(img)
    }
    img.src = url
  })
}

function drawSprite(name, x, y, width, height) {
  const sprite = sprites[name]
  if (sprite) {
    ctx.drawImage(sprite, x, y, width || sprite.width, height || sprite.height)
  }
}

इमेज एसिंक्रोनस तरीके से लोड होती हैं, इसलिए आपको गेम शुरू करने से पहले उनका इंतज़ार करना होता है। एक आम पैटर्न यह है कि सभी स्प्राइट्स को एक loadAssets फंक्शन में लोड किया जाए जो एक Promise लौटाए, और फिर उसके रिज़ॉल्व होने के बाद ही गेम लूप शुरू किया जाए।

drawSprite फंक्शन बनाने से पहले जांचता है कि स्प्राइट मौजूद है या नहीं। इससे ऐसे एरर रुक जाते हैं जो तब आते जब आप कोई ऐसी चीज़ बनाने की कोशिश करते जो अभी तक लोड ही नहीं हुई।

एनिमेटेड स्प्राइट्स

js
class AnimatedSprite {
  constructor(image, frameWidth, frameHeight, frameCount, fps = 10) {
    this.image = image
    this.frameWidth = frameWidth
    this.frameHeight = frameHeight
    this.frameCount = frameCount
    this.frameDuration = 1 / fps
    this.currentFrame = 0
    this.elapsed = 0
  }
  
  update(dt) {
    this.elapsed += dt
    if (this.elapsed >= this.frameDuration) {
      this.currentFrame = (this.currentFrame + 1) % this.frameCount
      this.elapsed = 0
    }
  }
  
  draw(ctx, x, y) {
    ctx.drawImage(
      this.image,
      this.currentFrame * this.frameWidth, 0,
      this.frameWidth, this.frameHeight,
      x, y,
      this.frameWidth, this.frameHeight
    )
  }
}

यह class मानती है कि आपकी स्प्राइट शीट में फ्रेम एक ही पंक्ति में आड़े (horizontally) रखे गए हैं। हर फ्रेम एक ही आकार का है। update मेथड बीते समय के आधार पर फ्रेम आगे बढ़ाता है, और draw मौजूदा फ्रेम को काटकर निकालने के लिए drawImage के नौ-आर्गुमेंट वाले वर्ज़न का इस्तेमाल करता है।

आप इसे इस तरह इस्तेमाल करेंगे:

js
const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)

// In update:
playerWalk.update(dt)

// In render:
playerWalk.draw(ctx, player.x, player.y)

कोलिज़न डिटेक्शन

js
function rectCollision(a, b) {
  return (
    a.x < b.x + b.width &&
    a.x + a.width > b.x &&
    a.y < b.y + b.height &&
    a.y + a.height > b.y
  )
}

function circleCollision(a, b) {
  const dx = a.x - b.x
  const dy = a.y - b.y
  const distance = Math.sqrt(dx * dx + dy * dy)
  return distance < a.radius + b.radius
}

// In update:
for (const enemy of game.enemies) {
  if (rectCollision(game.player, enemy)) {
    handleCollision(game.player, enemy)
  }
}

रेक्टैंगल कोलिज़न (जिसे AABB भी कहते हैं, यानी axis-aligned bounding box) सबसे सरल और सबसे तेज विकल्प है। ज़्यादातर चीज़ों के लिए इसी का इस्तेमाल करें। गोल वस्तुओं के लिए, या जब आप चाहते हैं कि कोलिज़न थोड़े कम सख्त लगें, तब सर्कल कोलिज़न बेहतर है।

कम संख्या में entities के लिए, हर जोड़ी को जांचना ठीक है। अगर आपके पास सैकड़ों entities हैं, तो आपको स्पेशियल पार्टीशनिंग (quadtrees या एक ग्रिड) की ज़रूरत होगी, पर वह किसी और ट्यूटोरियल का विषय है।

एक साधारण कैमरा

js
const camera = {
  x: 0,
  y: 0,
  
  follow(target) {
    this.x = target.x - canvas.width / 2
    this.y = target.y - canvas.height / 2
  }
}

function render() {
  ctx.save()
  ctx.translate(-camera.x, -camera.y)
  
  // Draw world
  drawWorld()
  drawEntities()
  
  ctx.restore()
  
  // Draw UI (not affected by camera)
  drawUI()
}

असली तरकीब है ctx.save() और ctx.restore()। आप मौजूदा ट्रांसफॉर्म स्टेट को सेव करते हैं, translate के साथ कैमरा ऑफसेट लगाते हैं, सब कुछ वर्ल्ड स्पेस में बनाते हैं, और फिर रीस्टोर कर देते हैं ताकि UI एलिमेंट कैमरे से प्रभावित न हों।

कैमरे की चाल और स्मूद करने के लिए, आप सीधे टारगेट पर स्नैप करने के बजाय उसकी ओर lerp कर सकते हैं। this.x += (targetX - this.x) * 0.1 जैसा कुछ आपको वह बढ़िया पीछे-पीछे चलने वाला असर देता है।

गेम को पॉज़ करना

js
let paused = false

window.addEventListener('keydown', (e) => {
  if (e.code === 'Escape') {
    paused = !paused
  }
})

function gameLoop(currentTime) {
  const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
  lastTime = currentTime
  
  if (!paused) {
    accumulator += frameTime
    while (accumulator >= FIXED_DT) {
      update(FIXED_DT)
      accumulator -= FIXED_DT
    }
  }
  
  render()
  
  if (paused) {
    renderPauseOverlay()
  }
  
  requestAnimationFrame(gameLoop)
}

function renderPauseOverlay() {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  
  ctx.fillStyle = '#fff'
  ctx.font = '32px sans-serif'
  ctx.textAlign = 'center'
  ctx.fillText('PAUSED', canvas.width / 2, canvas.height / 2)
}

मुख्य बात यह है कि पॉज़ होने पर आप समय जमा करना बंद कर देते हैं, पर रेंडर करते रहते हैं। इस तरह पॉज़ ओवरले तुरंत दिखता है और गेम अनपॉज़ इनपुट के प्रति प्रतिक्रिया देता रहता है।

आप इस पैटर्न को मेन्यू, कटसीन और गेम ओवर स्क्रीन जैसी कई गेम स्टेट्स संभालने तक बढ़ा सकते हैं। बस मौजूदा स्टेट जांचें और अलग-अलग update/render फंक्शन की ओर रूट कर दें।

आम गलतियां

कुछ चीज़ें जिन्हें मैंने लोगों को उलझाते देखा है:

कैनवस क्लियर करना भूल जाना। अगर आप render की शुरुआत में clearRect नहीं बुलाते, तो आप पिछले फ्रेम के ऊपर ही बना देंगे और धुंधलापन (smearing) आ जाएगा।

डेल्टा टाइम को कैप न करना। अगर कोई टैब बदलकर वापस आता है, तो एक बहुत बड़ा डेल्टा टाइम आपकी फिजिक्स को तोड़ सकता है या गेम को कई सेकंड तक "पकड़ने" पर मजबूर कर सकता है।

इमेज लोड होने से पहले बनाना। कैनवस ऐसी इमेज वाले drawImage कॉल को चुपचाप नज़रअंदाज़ कर देता है जो अभी लोड नहीं हुई। आपके स्प्राइट्स बस दिखेंगे ही नहीं, और कोई एरर मैसेज भी नहीं आएगा।

लूप के लिए setInterval का इस्तेमाल। यह डिस्प्ले के साथ सिंक नहीं होता और टैब छिपने पर रुकता नहीं। requestAnimationFrame इस्तेमाल करें।

वर्ल्ड और स्क्रीन कोऑर्डिनेट्स को मिला देना। जब आप कैमरा जोड़ें, तो यह ट्रैक करते रहें कि कोई पोज़िशन वर्ल्ड स्पेस में है या स्क्रीन स्पेस में। माउस क्लिक स्क्रीन स्पेस में आते हैं और उन्हें बदलना ज़रूरी होता है।

आगे क्या

यह ट्यूटोरियल बुनियादी बातें कवर करता है। जब आप इन पैटर्न के साथ सहज हो जाएं, तो आप इन्हें देखना चाह सकते हैं:

अलग-अलग स्क्रीन साइज़ और हाई DPI डिस्प्ले संभालने के लिए रिस्पॉन्सिव गेम कैनवस

टच कंट्रोल, गेमपैड और ज़्यादा परिष्कृत इनपुट सिस्टम के लिए गेम इनपुट हैंडलिंग

बिना धुंधलेपन के साफ-सुथरे पिक्सेल ग्राफिक्स के लिए पिक्सेल आर्ट रेंडरिंग

अपने गेम लूप में रिजिड बॉडी फिजिक्स, कोलिज़न और जॉइंट जोड़ने के लिए गेम फिजिक्स लाइब्रेरीज़

अगर आप अपने प्रोजेक्ट के लिए Canvas 2D, WebGL और WebGPU के बीच फैसला कर रहे हैं, तो 2026 में वेब गेम्स टेक स्टैक गाइड इन ट्रेड-ऑफ को विस्तार से समझाती है। वेब गेम इंजन तुलना उन फ्रेमवर्क को कवर करती है जो आपके लिए गेम लूप संभाल लेते हैं।

बाहरी संसाधन

  • MDN: requestAnimationFrame — ब्राउज़र API रेफरेंस
  • MDN: Canvas API — पूरा Canvas 2D डॉक्युमेंटेशन
  • MDN: KeyboardEvent — key codes और इवेंट प्रॉपर्टीज़
  • Fix Your Timestep! — फिक्स्ड टाइमस्टेप गेम लूप पर Glenn Fiedler का क्लासिक लेख