fuzzyworld

herd / commit

Add 10-level system with thematic level configs

4e15f13cfbedb98c710adccfc9644f41602cb643 · Pavel Safronov · 2026-05-16 22:04:35 -0700 · parents 5dab649ac00e

Add 10-level system with thematic level configs

Each level has a real-world parallel (rural depopulation, herd immunity,
polarisation, habitat loss, trauma, influence dynamics, industrial farming,
information asymmetry, dark mode night shift, shrinking pen). Supports
stubborn/leader sheep, dynamic obstacles, pen shrink, dark mode, and per-
level dog/sheep speed multipliers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 src/levels/LevelConfig.ts | 301 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 301 insertions(+)

diff --git a/src/levels/LevelConfig.ts b/src/levels/LevelConfig.ts
new file mode 100644
index 0000000..7a95ea2
--- /dev/null
+++ b/src/levels/LevelConfig.ts
@@ -0,0 +1,301 @@
+import type { GateSide } from '../entities/Pen.ts'
+import type { SheepProps } from '../entities/Sheep.ts'
+
+export interface SpawnCluster {
+  cx: number // fraction 0–1 of canvas width
+  cy: number // fraction 0–1 of canvas height
+  r: number  // spread radius in fraction of min(w,h)
+  count: number
+}
+
+export interface ObstacleDef {
+  x: number; y: number; r: number // fractions of canvas size; r fraction of min(w,h)
+}
+
+export interface DynamicObstacleDef extends ObstacleDef {
+  appearsAtSeconds: number
+}
+
+export interface SheepOverride extends SheepProps {
+  index: number
+}
+
+export interface LevelConfig {
+  id: number
+  title: string
+  subtitle: string
+
+  sheepCount: number
+  penRect: { x: number; y: number; w: number; h: number } // fractions of canvas
+  penGateSide: GateSide
+  timeLimitSeconds: number | null
+  winThreshold: number
+
+  spawnClusters: SpawnCluster[]
+  obstacles: ObstacleDef[]
+  dynamicObstacles: DynamicObstacleDef[]
+  sheepOverrides: SheepOverride[]
+
+  darkMode: boolean
+  penShrinksEverySeconds: number | null
+
+  // per-level dog speed override (null = default 160 px/s)
+  dogSpeed: number | null
+  // per-level sheep max speed multiplier (null = 1.0)
+  sheepSpeedMult: number | null
+}
+
+export const LEVELS: LevelConfig[] = [
+  {
+    id: 1,
+    title: 'First Lesson',
+    subtitle: '',
+    sheepCount: 6,
+    penRect: { x: 0.68, y: 0.05, w: 0.25, h: 0.25 },
+    penGateSide: 'bottom',
+    timeLimitSeconds: 120,
+    winThreshold: 0.8,
+    spawnClusters: [{ cx: 0.5, cy: 0.55, r: 0.08, count: 6 }],
+    obstacles: [],
+    dynamicObstacles: [],
+    sheepOverrides: [],
+    darkMode: false,
+    penShrinksEverySeconds: null,
+    dogSpeed: null,
+    sheepSpeedMult: null,
+  },
+  {
+    id: 2,
+    title: 'The Scatter',
+    subtitle: 'Rural depopulation — communities spread too thin to self-organize.',
+    sheepCount: 12,
+    penRect: { x: 0.38, y: 0.78, w: 0.22, h: 0.18 },
+    penGateSide: 'top',
+    timeLimitSeconds: 150,
+    winThreshold: 1.0,
+    spawnClusters: [
+      { cx: 0.08, cy: 0.1,  r: 0.04, count: 3 },
+      { cx: 0.92, cy: 0.1,  r: 0.04, count: 3 },
+      { cx: 0.08, cy: 0.82, r: 0.04, count: 3 },
+      { cx: 0.92, cy: 0.82, r: 0.04, count: 3 },
+    ],
+    obstacles: [],
+    dynamicObstacles: [],
+    sheepOverrides: [],
+    darkMode: false,
+    penShrinksEverySeconds: null,
+    dogSpeed: null,
+    sheepSpeedMult: null,
+  },
+  {
+    id: 3,
+    title: 'The Holdouts',
+    subtitle: 'Herd immunity — the last few require disproportionate effort.',
+    sheepCount: 15,
+    penRect: { x: 0.05, y: 0.35, w: 0.22, h: 0.28 },
+    penGateSide: 'right',
+    timeLimitSeconds: 180,
+    winThreshold: 1.0,
+    spawnClusters: [
+      { cx: 0.55, cy: 0.5, r: 0.12, count: 12 },
+      { cx: 0.88, cy: 0.12, r: 0.03, count: 1 },
+      { cx: 0.88, cy: 0.88, r: 0.03, count: 1 },
+      { cx: 0.5,  cy: 0.12, r: 0.03, count: 1 },
+    ],
+    obstacles: [],
+    dynamicObstacles: [],
+    sheepOverrides: [
+      { index: 12, stubborn: true },
+      { index: 13, stubborn: true },
+      { index: 14, stubborn: true },
+    ],
+    darkMode: false,
+    penShrinksEverySeconds: null,
+    dogSpeed: null,
+    sheepSpeedMult: null,
+  },
+  {
+    id: 4,
+    title: 'Two Tribes',
+    subtitle: 'Political polarization — two groups that actively repel each other.',
+    sheepCount: 20,
+    penRect: { x: 0.37, y: 0.35, w: 0.26, h: 0.28 },
+    penGateSide: 'left',
+    timeLimitSeconds: 180,
+    winThreshold: 1.0,
+    spawnClusters: [
+      { cx: 0.12, cy: 0.5, r: 0.08, count: 10 },
+      { cx: 0.88, cy: 0.5, r: 0.08, count: 10 },
+    ],
+    obstacles: [],
+    dynamicObstacles: [],
+    sheepOverrides: [
+      ...Array.from({ length: 10 }, (_, i) => ({ index: i,      subFlock: 0 })),
+      ...Array.from({ length: 10 }, (_, i) => ({ index: i + 10, subFlock: 1 })),
+    ],
+    darkMode: false,
+    penShrinksEverySeconds: null,
+    dogSpeed: null,
+    sheepSpeedMult: null,
+  },
+  {
+    id: 5,
+    title: 'Shrinking Pasture',
+    subtitle: 'Habitat loss — the environment erodes in real time.',
+    sheepCount: 15,
+    penRect: { x: 0.05, y: 0.05, w: 0.22, h: 0.22 },
+    penGateSide: 'right',
+    timeLimitSeconds: 210,
+    winThreshold: 0.85,
+    spawnClusters: [{ cx: 0.55, cy: 0.55, r: 0.15, count: 15 }],
+    obstacles: [
+      { x: 0.88, y: 0.3,  r: 0.04 },
+      { x: 0.88, y: 0.6,  r: 0.04 },
+      { x: 0.88, y: 0.88, r: 0.04 },
+    ],
+    dynamicObstacles: [
+      { x: 0.75, y: 0.15, r: 0.04, appearsAtSeconds: 30  },
+      { x: 0.75, y: 0.85, r: 0.04, appearsAtSeconds: 60  },
+      { x: 0.6,  y: 0.88, r: 0.04, appearsAtSeconds: 90  },
+      { x: 0.62, y: 0.12, r: 0.04, appearsAtSeconds: 120 },
+      { x: 0.5,  y: 0.82, r: 0.04, appearsAtSeconds: 150 },
+      { x: 0.5,  y: 0.18, r: 0.04, appearsAtSeconds: 180 },
+    ],
+    sheepOverrides: [],
+    darkMode: false,
+    penShrinksEverySeconds: null,
+    dogSpeed: null,
+    sheepSpeedMult: null,
+  },
+  {
+    id: 6,
+    title: 'Already Spooked',
+    subtitle: 'Displaced populations — heavy-handed intervention makes things worse.',
+    sheepCount: 18,
+    penRect: { x: 0.7, y: 0.7, w: 0.26, h: 0.24 },
+    penGateSide: 'top',
+    timeLimitSeconds: 240,
+    winThreshold: 0.8,
+    spawnClusters: [
+      { cx: 0.15, cy: 0.2,  r: 0.05, count: 3 },
+      { cx: 0.5,  cy: 0.15, r: 0.05, count: 3 },
+      { cx: 0.82, cy: 0.2,  r: 0.05, count: 3 },
+      { cx: 0.15, cy: 0.75, r: 0.05, count: 3 },
+      { cx: 0.5,  cy: 0.8,  r: 0.05, count: 3 },
+      { cx: 0.82, cy: 0.4,  r: 0.05, count: 3 },
+    ],
+    obstacles: [],
+    dynamicObstacles: [],
+    sheepOverrides: Array.from({ length: 18 }, (_, i) => ({ index: i, startFear: 1.0 })),
+    darkMode: false,
+    penShrinksEverySeconds: null,
+    dogSpeed: 110,
+    sheepSpeedMult: null,
+  },
+  {
+    id: 7,
+    title: 'The Influencers',
+    subtitle: 'Opinion dynamics — redirect the few and the many follow.',
+    sheepCount: 22,
+    penRect: { x: 0.7, y: 0.3, w: 0.24, h: 0.38 },
+    penGateSide: 'left',
+    timeLimitSeconds: 180,
+    winThreshold: 0.9,
+    spawnClusters: [{ cx: 0.25, cy: 0.5, r: 0.18, count: 22 }],
+    obstacles: [
+      { x: 0.5,  y: 0.25, r: 0.04 },
+      { x: 0.5,  y: 0.75, r: 0.04 },
+    ],
+    dynamicObstacles: [],
+    sheepOverrides: [
+      { index: 0, leader: true },
+      { index: 1, leader: true },
+      { index: 2, leader: true },
+      { index: 3, leader: true },
+    ],
+    darkMode: false,
+    penShrinksEverySeconds: null,
+    dogSpeed: null,
+    sheepSpeedMult: null,
+  },
+  {
+    id: 8,
+    title: 'Race to Market',
+    subtitle: 'Industrial agriculture — volume, speed, efficiency above all else.',
+    sheepCount: 30,
+    penRect: { x: 0.38, y: 0.05, w: 0.24, h: 0.3 },
+    penGateSide: 'bottom',
+    timeLimitSeconds: 75,
+    winThreshold: 0.8,
+    spawnClusters: [
+      { cx: 0.2, cy: 0.65, r: 0.12, count: 15 },
+      { cx: 0.8, cy: 0.65, r: 0.12, count: 15 },
+    ],
+    obstacles: [],
+    dynamicObstacles: [],
+    sheepOverrides: [],
+    darkMode: false,
+    penShrinksEverySeconds: null,
+    dogSpeed: 200,
+    sheepSpeedMult: 1.2,
+  },
+  {
+    id: 9,
+    title: 'Lights Out',
+    subtitle: 'Information asymmetry — you can only see what is directly in front of you.',
+    sheepCount: 16,
+    penRect: { x: 0.38, y: 0.38, w: 0.24, h: 0.24 },
+    penGateSide: 'bottom',
+    timeLimitSeconds: 270,
+    winThreshold: 1.0,
+    spawnClusters: [
+      { cx: 0.1,  cy: 0.1,  r: 0.06, count: 2 },
+      { cx: 0.5,  cy: 0.05, r: 0.06, count: 2 },
+      { cx: 0.9,  cy: 0.1,  r: 0.06, count: 2 },
+      { cx: 0.1,  cy: 0.9,  r: 0.06, count: 2 },
+      { cx: 0.5,  cy: 0.9,  r: 0.06, count: 2 },
+      { cx: 0.9,  cy: 0.9,  r: 0.06, count: 2 },
+      { cx: 0.1,  cy: 0.5,  r: 0.06, count: 1 },
+      { cx: 0.9,  cy: 0.5,  r: 0.06, count: 1 },
+    ],
+    obstacles: [
+      { x: 0.25, y: 0.5, r: 0.03 },
+      { x: 0.75, y: 0.5, r: 0.03 },
+    ],
+    dynamicObstacles: [],
+    sheepOverrides: [],
+    darkMode: true,
+    penShrinksEverySeconds: null,
+    dogSpeed: null,
+    sheepSpeedMult: null,
+  },
+  {
+    id: 10,
+    title: 'The Last Pasture',
+    subtitle: 'Climate crisis — the window closes slowly, then all at once.',
+    sheepCount: 25,
+    penRect: { x: 0.65, y: 0.05, w: 0.3, h: 0.35 },
+    penGateSide: 'bottom',
+    timeLimitSeconds: null,
+    winThreshold: 1.0,
+    spawnClusters: [
+      { cx: 0.2, cy: 0.45, r: 0.12, count: 13 },
+      { cx: 0.45, cy: 0.8, r: 0.12, count: 12 },
+    ],
+    obstacles: [
+      { x: 0.5, y: 0.3, r: 0.1 }, // dried-out lake blocking direct route
+    ],
+    dynamicObstacles: [],
+    sheepOverrides: [
+      { index: 0,  stubborn: true },
+      { index: 1,  stubborn: true },
+      { index: 2,  stubborn: true },
+      { index: 23, leader: true },
+      { index: 24, leader: true },
+    ],
+    darkMode: false,
+    penShrinksEverySeconds: 25,
+    dogSpeed: null,
+    sheepSpeedMult: null,
+  },
+]