fuzzyworld

herd / commit

Scale all boids and entity sizes to screen dimensions

21437a01f03c59f60cb83f61905f8a3731a8deb4 · Pavel Safronov · 2026-05-17 20:39:23 -0700 · parents 76d574cc0ede

Scale all boids and entity sizes to screen dimensions

All pixel-based constants (sheep radius, boids radii, speeds, forces,
dog speed, torch radius) are now multiplied by scale = min(W,H) / 600
at level start, so the game feels identical regardless of screen size.
This also fixes levels where the pen was too small to physically fit
all sheep on small screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 src/ai/Boids.ts       | 92 ++++++++++++++++++++++++++++++---------------------
 src/entities/Dog.ts   |  7 ++--
 src/entities/Pen.ts   | 20 +++++------
 src/entities/Sheep.ts | 23 ++++++-------
 src/game/GameState.ts | 40 +++++++++++++---------
 5 files changed, 105 insertions(+), 77 deletions(-)

diff --git a/src/ai/Boids.ts b/src/ai/Boids.ts
index e2dcc65..ee570ca 100644
--- a/src/ai/Boids.ts
+++ b/src/ai/Boids.ts
@@ -3,61 +3,57 @@ import type { Sheep } from '../entities/Sheep.ts'
 import type { Dog } from '../entities/Dog.ts'
 import type { Pen } from '../entities/Pen.ts'
 
-// ─── Tuning constants ────────────────────────────────────────────────────────
-// Radii in world pixels, speeds in px/s, forces in px/s²
+// ─── Base tuning constants (at reference scale = 1) ─────────────────────────
+// Radii in world pixels, speeds in px/s, forces in px/s².
+// All physical values are multiplied by the runtime `scale` factor inside
+// updateBoids so the game feels identical across screen sizes.
 
-const SEPARATION_RADIUS_SQ  = 35 * 35
-const ALIGN_RADIUS_SQ       = 80 * 80
-const COHESION_RADIUS_SQ    = 100 * 100
-const LONG_RANGE_RADIUS_SQ  = 300 * 300  // beyond cohesion — lone sheep spot a distant group
+const BASE_SEPARATION_RADIUS = 35
+const BASE_ALIGN_RADIUS      = 80
+const BASE_COHESION_RADIUS   = 100
+const BASE_LONG_RANGE_RADIUS = 300  // beyond cohesion — lone sheep spot a distant group
 
-const FLEE_RADIUS    = 220           // sheep start reacting at this distance
-const FLEE_RADIUS_SQ = FLEE_RADIUS * FLEE_RADIUS
+const BASE_FLEE_RADIUS = 220        // sheep start reacting at this distance
 
 // Flee formula is LINEAR: full W_FLEE at d=0, zero at FLEE_RADIUS.
-// This gives a smooth, readable gradient — sheep visibly start moving when the
-// dog enters range, not only when it's almost on top of them.
-const W_FLEE        = 80
-
-const W_SEPARATION  = 30
-const W_ALIGNMENT   = 12
-const W_COHESION    = 12
-const W_LONG_RANGE  = 5   // gentle pull toward a distant group
-const W_PEN         = 3
+const BASE_W_FLEE       = 80
+const BASE_W_SEPARATION = 30
+const BASE_W_ALIGNMENT  = 12
+const BASE_W_COHESION   = 12
+const BASE_W_LONG_RANGE = 5         // gentle pull toward a distant group
+const BASE_W_PEN        = 3
 
 // MAX_FORCE controls how fast sheep accelerate. At dt=16ms:
 //   velocity gain per tick  = MAX_FORCE * 0.016
 //   time to reach MAX_SPEED = MAX_SPEED / MAX_FORCE  ≈ 0.9 s at these values
-const MAX_SPEED  = 90    // px/s (panic multiplier can push this up to ~162)
-const MAX_FORCE  = 100   // px/s² — previous value of 6 meant ~15 s to full speed
+const BASE_MAX_SPEED = 90           // px/s (panic multiplier can push this up to ~162)
+const BASE_MAX_FORCE = 100          // px/s²
 
-const FEAR_DECAY = 0.95  // per fixed tick (~60 Hz)
+const FEAR_DECAY = 0.95             // per fixed tick (~60 Hz) — dimensionless, no scaling
 
 // Wall repulsion: quadratic, peaks at BOUNDARY_PEAK right at the wall
-const BOUNDARY_MARGIN = 90
-const BOUNDARY_PEAK   = 140  // must exceed flee force near wall
+const BASE_BOUNDARY_MARGIN = 90
+const BASE_BOUNDARY_PEAK   = 140    // must exceed flee force near wall
 
 // Stubborn sheep: smaller flee radius so they require close, sustained pressure
-const STUBBORN_FLEE_RADIUS    = 100
-const STUBBORN_FLEE_RADIUS_SQ = STUBBORN_FLEE_RADIUS * STUBBORN_FLEE_RADIUS
-const STUBBORN_COHESION_WEIGHT = 0.15  // relative to W_COHESION
+const BASE_STUBBORN_FLEE_RADIUS = 100
+const STUBBORN_COHESION_WEIGHT  = 0.15  // relative to W_COHESION — dimensionless
 
 // Leader sheep: others weight their position/velocity more heavily
-const LEADER_NEIGHBOR_WEIGHT = 3.0
+const LEADER_NEIGHBOR_WEIGHT = 3.0      // dimensionless
+
+const BASE_SHEEP_RADIUS = 9         // must match Sheep.draw arc radius
 
 // ─── Helpers ─────────────────────────────────────────────────────────────────
 
 interface Obstacle { x: number; y: number; r: number }
 
-function wallPush(dist: number): number {
-  if (dist >= BOUNDARY_MARGIN) return 0
-  const t = 1 - dist / BOUNDARY_MARGIN
-  return t * t * BOUNDARY_PEAK
+function wallPush(dist: number, margin: number, peak: number): number {
+  if (dist >= margin) return 0
+  const t = 1 - dist / margin
+  return t * t * peak
 }
 
-const SHEEP_RADIUS = 9        // must match Sheep.draw arc radius
-const MIN_SHEEP_DIST = SHEEP_RADIUS * 2
-
 // ─── Main update ─────────────────────────────────────────────────────────────
 
 export function updateBoids(
@@ -69,9 +65,31 @@ export function updateBoids(
   obstacles: Obstacle[],
   dt: number,
   speedMult = 1,
+  scale = 1,
 ) {
-  const OBSTACLE_MARGIN = 20
-  const WALL = 10
+  // Derive all physical constants for this screen scale.
+  const SHEEP_RADIUS          = BASE_SHEEP_RADIUS * scale
+  const MIN_SHEEP_DIST        = SHEEP_RADIUS * 2
+  const SEPARATION_RADIUS_SQ  = (BASE_SEPARATION_RADIUS * scale) ** 2
+  const ALIGN_RADIUS_SQ       = (BASE_ALIGN_RADIUS * scale) ** 2
+  const COHESION_RADIUS_SQ    = (BASE_COHESION_RADIUS * scale) ** 2
+  const LONG_RANGE_RADIUS_SQ  = (BASE_LONG_RANGE_RADIUS * scale) ** 2
+  const FLEE_RADIUS           = BASE_FLEE_RADIUS * scale
+  const FLEE_RADIUS_SQ        = FLEE_RADIUS ** 2
+  const STUBBORN_FLEE_RADIUS    = BASE_STUBBORN_FLEE_RADIUS * scale
+  const STUBBORN_FLEE_RADIUS_SQ = STUBBORN_FLEE_RADIUS ** 2
+  const W_FLEE        = BASE_W_FLEE * scale
+  const W_SEPARATION  = BASE_W_SEPARATION * scale
+  const W_ALIGNMENT   = BASE_W_ALIGNMENT * scale
+  const W_COHESION    = BASE_W_COHESION * scale
+  const W_LONG_RANGE  = BASE_W_LONG_RANGE * scale
+  const W_PEN         = BASE_W_PEN * scale
+  const MAX_SPEED     = BASE_MAX_SPEED * scale
+  const MAX_FORCE     = BASE_MAX_FORCE * scale
+  const BOUNDARY_MARGIN = BASE_BOUNDARY_MARGIN * scale
+  const BOUNDARY_PEAK   = BASE_BOUNDARY_PEAK * scale
+  const OBSTACLE_MARGIN = 20 * scale
+  const WALL            = 10 * scale
 
   for (const s of sheep) {
     const fleeRadius    = s.stubborn ? STUBBORN_FLEE_RADIUS    : FLEE_RADIUS
@@ -152,8 +170,8 @@ export function updateBoids(
 
     // ── boundary repulsion (quadratic, peaks at BOUNDARY_PEAK) ───────────
     steering = steering.add(new Vec2(
-      wallPush(s.pos.x) - wallPush(canvasW - s.pos.x),
-      wallPush(s.pos.y) - wallPush(canvasH - s.pos.y),
+      wallPush(s.pos.x, BOUNDARY_MARGIN, BOUNDARY_PEAK) - wallPush(canvasW - s.pos.x, BOUNDARY_MARGIN, BOUNDARY_PEAK),
+      wallPush(s.pos.y, BOUNDARY_MARGIN, BOUNDARY_PEAK) - wallPush(canvasH - s.pos.y, BOUNDARY_MARGIN, BOUNDARY_PEAK),
     ))
 
     // ── obstacle repulsion ────────────────────────────────────────────────
diff --git a/src/entities/Dog.ts b/src/entities/Dog.ts
index 1239081..f24981d 100644
--- a/src/entities/Dog.ts
+++ b/src/entities/Dog.ts
@@ -29,7 +29,7 @@ export class Dog {
     this.pos = this.pos.add(this.vel.scale(dt))
   }
 
-  draw(ctx: CanvasRenderingContext2D) {
+  draw(ctx: CanvasRenderingContext2D, scale = 1) {
     const { x, y } = this.pos
     const facing = this.vel.lengthSq() > 0
       ? Math.atan2(this.vel.y, this.vel.x)
@@ -38,6 +38,7 @@ export class Dog {
     ctx.save()
     ctx.translate(x, y)
     ctx.rotate(facing)
+    ctx.scale(scale, scale)
 
     // tail — curves up from rear, drawn behind body
     ctx.strokeStyle = '#111'
@@ -104,7 +105,7 @@ export class Dog {
 
     ctx.restore()
 
-    // target indicator — world space
+    // target indicator — world space (not scaled, always thin)
     if (this.target) {
       ctx.strokeStyle = 'rgba(255,255,255,0.25)'
       ctx.lineWidth = 1
@@ -118,7 +119,7 @@ export class Dog {
       ctx.strokeStyle = 'rgba(255,255,255,0.5)'
       ctx.lineWidth = 1.5
       ctx.beginPath()
-      ctx.arc(this.target.x, this.target.y, 5, 0, Math.PI * 2)
+      ctx.arc(this.target.x, this.target.y, 5 * scale, 0, Math.PI * 2)
       ctx.stroke()
     }
   }
diff --git a/src/entities/Pen.ts b/src/entities/Pen.ts
index 3b5f97f..120b3f1 100644
--- a/src/entities/Pen.ts
+++ b/src/entities/Pen.ts
@@ -17,7 +17,7 @@ export class Pen {
     return new Vec2(this.x + this.w / 2, this.y + this.h / 2)
   }
 
-  draw(ctx: CanvasRenderingContext2D, filledFraction: number) {
+  draw(ctx: CanvasRenderingContext2D, filledFraction: number, scale = 1) {
     const { x, y, w, h, gateSide } = this
 
     // filled interior — shifts from transparent to green as sheep enter
@@ -27,9 +27,9 @@ export class Pen {
 
     // fence posts + rails on three sides
     ctx.strokeStyle = '#7a5c2e'
-    ctx.lineWidth = 3
+    ctx.lineWidth = 3 * scale
 
-    const postR = 5
+    const postR = 5 * scale
     const sides: Array<[number, number, number, number]> = [
       [x,     y,     x + w, y    ], // top
       [x + w, y,     x + w, y + h], // right
@@ -73,14 +73,14 @@ export class Pen {
     })
 
     // gate indicator arrow pointing inward
-    this.drawGateArrow(ctx)
+    this.drawGateArrow(ctx, scale)
   }
 
-  private drawGateArrow(ctx: CanvasRenderingContext2D) {
+  private drawGateArrow(ctx: CanvasRenderingContext2D, scale = 1) {
     const { x, y, w, h, gateSide } = this
     const cx = x + w / 2
     const cy = y + h / 2
-    const arrowLen = 14
+    const arrowLen = 14 * scale
     let ax: number, ay: number, dx: number, dy: number
 
     switch (gateSide) {
@@ -91,19 +91,19 @@ export class Pen {
     }
 
     ctx.strokeStyle = 'rgba(255,255,255,0.6)'
-    ctx.lineWidth = 2
+    ctx.lineWidth = 2 * scale
     ctx.beginPath()
     ctx.moveTo(ax, ay)
     ctx.lineTo(ax + dx * arrowLen, ay + dy * arrowLen)
     ctx.stroke()
 
     // arrowhead
-    const perpX = -dy * 5, perpY = dx * 5
+    const perpX = -dy * 5 * scale, perpY = dx * 5 * scale
     const tipX = ax + dx * arrowLen, tipY = ay + dy * arrowLen
     ctx.beginPath()
     ctx.moveTo(tipX, tipY)
-    ctx.lineTo(tipX - dx * 8 + perpX, tipY - dy * 8 + perpY)
-    ctx.lineTo(tipX - dx * 8 - perpX, tipY - dy * 8 - perpY)
+    ctx.lineTo(tipX - dx * 8 * scale + perpX, tipY - dy * 8 * scale + perpY)
+    ctx.lineTo(tipX - dx * 8 * scale - perpX, tipY - dy * 8 * scale - perpY)
     ctx.closePath()
     ctx.fillStyle = 'rgba(255,255,255,0.6)'
     ctx.fill()
diff --git a/src/entities/Sheep.ts b/src/entities/Sheep.ts
index b80e8cf..be4a852 100644
--- a/src/entities/Sheep.ts
+++ b/src/entities/Sheep.ts
@@ -39,9 +39,7 @@ export class Sheep {
     this.subFlock = props.subFlock ?? 0
   }
 
-  draw(ctx: CanvasRenderingContext2D, dark = false) {
-    if (dark) return // caller handles visibility in dark mode
-
+  draw(ctx: CanvasRenderingContext2D, scale = 1) {
     const { x, y } = this.pos
 
     // fear tint: white → orange-red
@@ -58,41 +56,42 @@ export class Sheep {
     ctx.fillStyle = bodyColor
     for (const [dx, dy, r] of WOOL_BLOBS) {
       ctx.beginPath()
-      ctx.arc(dx, dy, r, 0, Math.PI * 2)
+      ctx.arc(dx * scale, dy * scale, r * scale, 0, Math.PI * 2)
       ctx.fill()
     }
 
     // Head always at the front (+x side in local space)
     ctx.fillStyle = '#d4c5a0'
     ctx.beginPath()
-    ctx.arc(9, 0, 4.5, 0, Math.PI * 2)
+    ctx.arc(9 * scale, 0, 4.5 * scale, 0, Math.PI * 2)
     ctx.fill()
 
     // Eyes on either side of the head centre
     ctx.fillStyle = '#1a1a1a'
     for (const side of [-1, 1]) {
       ctx.beginPath()
-      ctx.arc(9, side * 2.5, 1.2, 0, Math.PI * 2)
+      ctx.arc(9 * scale, side * 2.5 * scale, 1.2 * scale, 0, Math.PI * 2)
       ctx.fill()
     }
 
     ctx.restore()
 
     // Indicators stay upright in world space regardless of facing
+    const indicatorY = y - 16 * scale
     if (this.leader) {
       ctx.fillStyle = '#f5c518'
-      ctx.font = '10px sans-serif'
+      ctx.font = `${Math.round(10 * scale)}px sans-serif`
       ctx.textAlign = 'center'
-      ctx.fillText('★', x, y - 16)
+      ctx.fillText('★', x, indicatorY)
     }
 
     if (this.stubborn) {
       ctx.strokeStyle = '#e05'
-      ctx.lineWidth = 1.5
-      const s = 4
+      ctx.lineWidth = 1.5 * scale
+      const s = 4 * scale
       ctx.beginPath()
-      ctx.moveTo(x - s, y - 16 - s); ctx.lineTo(x + s, y - 16 + s)
-      ctx.moveTo(x + s, y - 16 - s); ctx.lineTo(x - s, y - 16 + s)
+      ctx.moveTo(x - s, indicatorY - s); ctx.lineTo(x + s, indicatorY + s)
+      ctx.moveTo(x + s, indicatorY - s); ctx.lineTo(x - s, indicatorY + s)
       ctx.stroke()
     }
   }
diff --git a/src/game/GameState.ts b/src/game/GameState.ts
index ed31d5b..d9a6fe4 100644
--- a/src/game/GameState.ts
+++ b/src/game/GameState.ts
@@ -20,8 +20,8 @@ export class GameState {
   private pen!: Pen
   private obstacles: Array<{ x: number; y: number; r: number }> = []
 
+  private scale = 1          // min(W,H) / BASE_DIM — all pixel constants multiply by this
   private elapsed = 0        // seconds since level start
-  private penFraction = 0    // fraction of sheep inside pen
   private endMessageTimer = 0
 
   // pen shrink tracking
@@ -44,6 +44,9 @@ export class GameState {
     const cfg = this.config
     const { width: W, height: H } = this.canvas
 
+    // Reference dimension: all boids/entity constants were tuned at minDim=600.
+    this.scale = Math.min(W, H) / 600
+
     // resolve pen rect from fractions
     const pen = cfg.penRect
     this.pen = new Pen(
@@ -78,14 +81,13 @@ export class GameState {
     }
 
     // place dog at a neutral position away from the pen
-    this.dog = new Dog(W * 0.5, H * 0.75, cfg.dogSpeed ?? 160)
+    this.dog = new Dog(W * 0.5, H * 0.75, (cfg.dogSpeed ?? 160) * this.scale)
     if (cfg.sheepSpeedMult) {
       // speed mult is applied in boids via a per-level constant injected below
     }
 
     this.elapsed = 0
     this.lastShrinkAt = 0
-    this.penFraction = 0
     this.endMessageTimer = 0
     this.phase = 'PLAYING'
   }
@@ -135,14 +137,13 @@ export class GameState {
     this.dog.update(dt)
 
     const speedMult = cfg.sheepSpeedMult ?? 1
-    updateBoids(this.sheep, this.dog, this.pen, W, H, this.obstacles, dt, speedMult)
+    updateBoids(this.sheep, this.dog, this.pen, W, H, this.obstacles, dt, speedMult, this.scale)
 
     // count sheep in pen
     const inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length
-    this.penFraction = inPen / this.sheep.length
 
     // check win
-    if (this.penFraction >= cfg.winThreshold) {
+    if (inPen >= cfg.winThreshold) {
       this.phase = 'WIN'
       return
     }
@@ -153,7 +154,7 @@ export class GameState {
     }
 
     // check lose — pen too small for last-pasture
-    if (cfg.penShrinksEverySeconds !== null && this.pen.w * this.pen.h < 30 * 30) {
+    if (cfg.penShrinksEverySeconds !== null && this.pen.w * this.pen.h < (30 * this.scale) ** 2) {
       this.phase = 'FAIL'
     }
   }
@@ -183,7 +184,7 @@ export class GameState {
 
     // pen
     const inPen = this.sheep.filter(s => this.pen.contains(s.pos)).length
-    this.pen.draw(ctx, inPen / this.sheep.length)
+    this.pen.draw(ctx, Math.min(1, inPen / this.config.winThreshold), this.scale)
 
     // dark mode overlay
     if (this.config.darkMode) {
@@ -194,13 +195,13 @@ export class GameState {
     for (const s of this.sheep) {
       if (this.config.darkMode) {
         const dSq = s.pos.distanceSqTo(this.dog.pos)
-        if (dSq > 120 * 120) continue // invisible outside torch radius
+        if (dSq > (120 * this.scale) ** 2) continue // invisible outside torch radius
       }
-      s.draw(ctx)
+      s.draw(ctx, this.scale)
     }
 
     // dog
-    this.dog.draw(ctx)
+    this.dog.draw(ctx, this.scale)
 
     // HUD
     this.drawHUD(ctx, W, H, inPen, alpha)
@@ -212,7 +213,8 @@ export class GameState {
 
   private applyDarkMode(ctx: CanvasRenderingContext2D, W: number, H: number) {
     const { x, y } = this.dog.pos
-    const gradient = ctx.createRadialGradient(x, y, 0, x, y, 120)
+    const torchR = 120 * this.scale
+    const gradient = ctx.createRadialGradient(x, y, 0, x, y, torchR)
     gradient.addColorStop(0, 'rgba(0,0,0,0)')
     gradient.addColorStop(0.6, 'rgba(0,0,0,0.55)')
     gradient.addColorStop(1, 'rgba(0,0,0,0.92)')
@@ -228,7 +230,7 @@ export class GameState {
     ctx.globalCompositeOperation = 'destination-out'
     ctx.fillStyle = gradient
     ctx.beginPath()
-    ctx.arc(x, y, 120, 0, Math.PI * 2)
+    ctx.arc(x, y, torchR, 0, Math.PI * 2)
     ctx.fill()
 
     ctx.restore()
@@ -244,15 +246,23 @@ export class GameState {
     const cfg = this.config
 
     // sheep counter
+    const hasSlack = cfg.winThreshold < this.sheep.length
+    const counterW = hasSlack ? 185 : 130
     ctx.fillStyle = 'rgba(0,0,0,0.45)'
     ctx.beginPath()
-    roundRect(ctx, 12, 12, 130, 38, 8)
+    roundRect(ctx, 12, 12, counterW, 38, 8)
     ctx.fill()
 
     ctx.fillStyle = '#fff'
     ctx.font = 'bold 16px system-ui'
     ctx.textAlign = 'left'
-    ctx.fillText(`🐑 ${inPen} / ${this.sheep.length}`, 20, 36)
+    ctx.fillText(`🐑 ${inPen} / ${cfg.winThreshold}`, 20, 36)
+
+    if (hasSlack) {
+      ctx.fillStyle = 'rgba(255,255,255,0.45)'
+      ctx.font = '11px system-ui'
+      ctx.fillText(`(${this.sheep.length} total)`, 108, 36)
+    }
 
     // timer bar
     if (cfg.timeLimitSeconds !== null) {