fuzzyworld

herd / commit

Add Canvas wrapper and fixed-timestep game loop

1001e9efc31ad48ba070ce838481eaf0b6048a6d · Pavel Safronov · 2026-05-16 22:03:24 -0700 · parents 5edd30c70c62

Add Canvas wrapper and fixed-timestep game loop

Canvas pre-bakes a static grass texture to avoid per-frame Math.random()
flicker. GameLoop runs physics at 16ms fixed steps and passes interpolated
alpha to the render callback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 src/game/Canvas.ts   | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/game/GameLoop.ts | 43 ++++++++++++++++++++++++++++++
 2 files changed, 117 insertions(+)

diff --git a/src/game/Canvas.ts b/src/game/Canvas.ts
new file mode 100644
index 0000000..43e9d07
--- /dev/null
+++ b/src/game/Canvas.ts
@@ -0,0 +1,74 @@
+const GRASS_STEP = 40
+
+export class Canvas {
+  readonly el: HTMLCanvasElement
+  readonly ctx: CanvasRenderingContext2D
+  private _width = 0
+  private _height = 0
+  private _dpr = 1
+
+  // offscreen canvas for the static grass texture — regenerated on resize
+  private grassCanvas: HTMLCanvasElement | null = null
+
+  constructor(id: string) {
+    const el = document.getElementById(id)
+    if (!(el instanceof HTMLCanvasElement)) throw new Error(`#${id} is not a canvas`)
+    this.el = el
+    this.ctx = el.getContext('2d')!
+    this.resize()
+    window.addEventListener('resize', () => this.resize())
+  }
+
+  get width() { return this._width }
+  get height() { return this._height }
+  get dpr() { return this._dpr }
+
+  private resize() {
+    this._dpr = window.devicePixelRatio || 1
+    this._width = window.innerWidth
+    this._height = window.innerHeight
+    this.el.width = this._width * this._dpr
+    this.el.height = this._height * this._dpr
+    this.el.style.width = `${this._width}px`
+    this.el.style.height = `${this._height}px`
+    this.ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0)
+    this.buildGrassTexture()
+  }
+
+  private buildGrassTexture() {
+    const oc = document.createElement('canvas')
+    oc.width = this._width
+    oc.height = this._height
+    const ctx = oc.getContext('2d')!
+
+    ctx.strokeStyle = 'rgba(60,110,30,0.3)'
+    ctx.lineWidth = 1
+    for (let x = 0; x < this._width; x += GRASS_STEP) {
+      for (let y = 0; y < this._height; y += GRASS_STEP) {
+        // two blades per cell, randomized once at build time
+        for (let b = 0; b < 2; b++) {
+          const bx = x + Math.random() * GRASS_STEP
+          const by = y + Math.random() * GRASS_STEP
+          ctx.beginPath()
+          ctx.moveTo(bx, by)
+          ctx.lineTo(bx + (Math.random() - 0.5) * 6, by - 4 - Math.random() * 5)
+          ctx.stroke()
+        }
+      }
+    }
+    this.grassCanvas = oc
+  }
+
+  drawField() {
+    const { ctx } = this
+    ctx.fillStyle = '#4a8c2a'
+    ctx.fillRect(0, 0, this._width, this._height)
+    if (this.grassCanvas) {
+      ctx.drawImage(this.grassCanvas, 0, 0)
+    }
+  }
+
+  clear() {
+    this.ctx.clearRect(0, 0, this._width, this._height)
+  }
+}
diff --git a/src/game/GameLoop.ts b/src/game/GameLoop.ts
new file mode 100644
index 0000000..b3976d0
--- /dev/null
+++ b/src/game/GameLoop.ts
@@ -0,0 +1,43 @@
+const FIXED_STEP_MS = 16 // ~60 Hz physics
+
+export class GameLoop {
+  private rafId = 0
+  private lastTime = 0
+  private accumulator = 0
+  private running = false
+  private onUpdate: (dt: number) => void
+  private onRender: (alpha: number) => void
+
+  constructor(onUpdate: (dt: number) => void, onRender: (alpha: number) => void) {
+    this.onUpdate = onUpdate
+    this.onRender = onRender
+  }
+
+  start() {
+    if (this.running) return
+    this.running = true
+    this.lastTime = performance.now()
+    this.rafId = requestAnimationFrame(this.tick)
+  }
+
+  stop() {
+    this.running = false
+    cancelAnimationFrame(this.rafId)
+  }
+
+  private tick = (now: number) => {
+    if (!this.running) return
+    this.rafId = requestAnimationFrame(this.tick)
+
+    const elapsed = Math.min(now - this.lastTime, 200) // cap at 200ms to avoid spiral of death
+    this.lastTime = now
+    this.accumulator += elapsed
+
+    while (this.accumulator >= FIXED_STEP_MS) {
+      this.onUpdate(FIXED_STEP_MS / 1000) // pass seconds
+      this.accumulator -= FIXED_STEP_MS
+    }
+
+    this.onRender(this.accumulator / FIXED_STEP_MS)
+  }
+}