Phase 4 of 10

🏛️ Game Patterns

Patterns that make games addictive

10 sub-phases
59 pros
59 cons
80 gotchas
50 anti-patterns
17782 chars code
4.1

⏱️ Game Loop (60fps + Delta Time)

Intermediate⏱ 2 hours

Update with dt seconds (NOT per-frame constants). Decouple sim Hz from render Hz via fixed-step accumulator.

🎯 When to use: ทุกเกมที่มี animation/movement/physics | Every game with animation, movement or physics; sims with time scale
⛔ When NOT to use
• Static page ไม่มี animation
• < 10 entities
• pure UI ที่ใช้ CSS transitions อย่างเดียว
✅ Pros (5)
• Frame-rate independent — same physics outcome on 60Hz phone and 144Hz monitor because dt normalizes time
• Decoupled sim/render Hz — physics ticks at fixed 60Hz while render interpolates at any monitor refresh
• Pause/slow-mo trivial — multiply DT by 0 (pause) or 0.5 (slow-mo); no special code paths needed
• Deterministic replays — fixed DT + seeded RNG = byte-identical playback for netcode or sharing
• Tab-background safe — clamp dt to 0.25s prevents physics explosion when user returns from background tab
⚠️ Cons (5)
• Accumulator math non-obvious — beginners copy-paste without understanding why spiral-of-death happens
• Multiple update calls per render wastes CPU on slow devices — 144Hz render + 60Hz sim = 2.4x physics calls
• Float drift over long sessions — hours 12+ see sub-millisecond desync, breaks frame-perfect games
• Time scaling breaks input if DT not normalized — character moves slower when paused mid-jump
• Interpolation adds 1-frame visual lag — visible at 30Hz render; competitive fighting games skip interpolation
⚡ Gotchas (8)
• ALWAYS clamp dt to ~0.25s — tab background return can deliver 5+ seconds dt = physics blow-up on first tick
• requestAnimationFrame pauses when tab hidden — never trust frame count for time, always use performance.now()
• Fixed timestep (Glenn Fiedler's pattern) prevents non-determinism across framerates — without it, replays differ
• Accumulator pattern: `while (acc >= DT) { update(DT); acc -= DT; }` — if overshoot, queue leftover for next frame
• Decimal time (ms) accumulates float error over 24h — use modular snapshots or reset delta periodically
• Audio scheduling must use AudioContext.currentTime not rAF — clock skew between audio and game thread possible
• Slow-mo: scale BOTH the realDt input AND the DT reference, else physics desyncs from rendered position
• First frame dt = 0 — guard with `if (last === 0) last = now` or physics does nothing on frame 1
🚫 Anti-patterns
• Update everything per-frame without dt — character speed differs wildly on 60Hz vs 144Hz monitors
• Use setInterval instead of requestAnimationFrame — drifts, runs in background tabs, can't pause cleanly
• Clamp dt to 'make it look right' instead of fixing physics step — hides bug, breaks determinism
• Skip interpolation when rendering at fixed Hz — visible stutter every 16ms on 30Hz devices
• Trust wall-clock for game time — Date.now() jumps on NTP sync; use performance.now() monotonic clock
🔀 Alternatives
vs 4.2 ECS (system tick order matters)
vs 1.4 localStorage (debounce save on dt accumulation)
➡️ Next: 4.2, 4.3, 4.4, 4.5
💻 Minimal (236 chars) ▶ Full example (651 chars)
const DT=1/60;let acc=0,last=performance.now();function tick(){const now=performance.now();const dt=Math.min((now-last)/1000,0.25);last=now;acc+=dt*timeScale;while(acc>=DT){update(DT);acc-=DT;}render(acc/DT);requestAnimationFrame(tick)}
const DT = 1/60; // 60Hz fixed physics step\nlet acc = 0, last = performance.now(), timeScale = 1;\nfunction tick() {\n  const now = performance.now();\n  // Clamp dt: tab background return can be 5+ seconds\n  const realDt = Math.min((now - last) / 1000, 0.25);\n  last = now;\n  // Slow-mo / pause: scale realDt input only, NOT the DT reference\n  acc += realDt * timeScale;\n  // Fixed-step physics: deterministic regardless of render Hz\n  while (acc >= DT) { update(DT); acc -= DT; }\n  // Interpolation factor: smooth render between physics ticks\n  render(acc / DT);\n  requestAnimationFrame(tick);\n}\nrequestAnimationFrame(tick); // bootstrap
loopfpsdelta-timefixed-timestepgame-loopเกมลูปเฟรมเรท
4.2

🧩 Entity-Component System (ECS)

Advanced⏱ 3 days

Entity (just an ID) + Component (pure data) + System (pure logic). Compose behaviors by mixing components.

🎯 When to use: Game engine, buff/debuff systems, particle systems, simulation > 100 entities | เกมที่ entity หลายร้อย+ และต้องการ mix behaviors แบบ runtime
⛔ When NOT to use
• < 100 entities with stable class hierarchy
• one-off scripts
• tiny prototypes where class inheritance is clearer
✅ Pros (6)
• Composition > inheritance — add Burnable component to ANY entity (player, NPC, tree) without class hierarchy change
• Cache-friendly SoA storage — components in contiguous TypedArrays, CPU prefetch works, 10-100x faster than OOP
• Parallel systems — pure-function systems run on Web Workers without locks (Unity DOTS proves this at scale)
• Hot-swap behaviors — add/remove Shielded component at runtime, no recompile, designer-friendly inspector
• Reuse across archetypes — same Move system iterates player and enemy, no code duplication
• Deterministic for netcode — systems read all components, no method dispatch variance across machines
⚠️ Cons (6)
• Boilerplate — every behavior = component type + system function + iteration code, 3 places to edit per change
• Debug trail scattered — entity 'the player' is 5 entries across component maps, not one object to inspect
• Premature for small games — < 100 entities: just use class instances, easier to grep, faster to ship
• Query complexity — 'find all entities with Position AND Velocity' requires iteration, not free
• Refactor cost — moving data between components breaks every save file that referenced the old structure
• Event passing awkward — entity A needs to talk to entity B across system boundaries, no direct method call
⚡ Gotchas (8)
• Components MUST be pure data — never put methods on component objects (Unity DOTS rule, breaks archetype SoA)
• Systems MUST be pure logic — read components, write components, no side effects outside assigned components
• Entity ID is opaque token — UUID or int, never reuse after delete (stale references in saves silently corrupt)
• Sparse set vs archetype storage — sparse flexible, archetype cache-fast (10-100x); Unity DOTS uses archetypes
• Don't store entity references in components — store IDs; entity refs break serialization and create cycles
• System execution order matters — Physics → Movement → Animation → Render → UI; explicit pipeline, not implicit
• Serialization = list components per entity — `[{id, comps: [Pos, Vel, Health]}]` round-trips through JSON
• Archetype churn is expensive — adding/removing a component moves entity to new archetype group; batch changes
🚫 Anti-patterns
• Inheritance 'GameObject extends Entity' — defeats composition; go back to OOP if you need this
• Systems that touch DOM directly — side effects across ticks = race conditions, debug hell
• Components holding references to other entities — breaks serialization, creates GC cycles, refactor nightmare
• Method on component object — turns component into mini-class, defeats data-oriented design and SoA caching
• Single mega-system doing everything — splits physics/render coupling into god-object, hard to parallelize
🔀 Alternatives
vs OOP inheritance (simpler for <100 entities)
vs 4.4 Behavior Trees (orthogonal; can be a system)
📚 Prereqs: 4.1
💻 Minimal (355 chars) ▶ Full example (1146 chars)
class World{create(){return this.nextId++}add(id,t,d){(this.comps[t]=this.comps[t]||new Map).set(id,d)}tick(dt){this.systems.forEach(s=>s(this,dt))}} const w=new World();const p=w.create();w.add(p,'Pos',{x:0,y:0});w.add(p,'Vel',{dx:10,dy:0});w.systems.push((w,dt)=>{for(const[id,pos]of w.comps.Pos||[]){const v=w.comps.Vel?.get(id);if(v)pos.x+=v.dx*dt}});
class World {\n  constructor() {\n    this.nextId = 1;\n    this.comps = new Map();   // type -> Map<id, data>\n    this.systems = [];         // (world, dt) => void, ordered pipeline\n  }\n  create() { return this.nextId++; }\n  add(id, type, data) {\n    if (!this.comps.has(type)) this.comps.set(type, new Map());\n    this.comps.get(type).set(id, data);\n    return id;\n  }\n  get(type, id) { return this.comps.get(type)?.get(id); }\n  // Explicit pipeline order — no implicit dependency guessing\n  tick(dt) { for (const sys of this.systems) sys(this, dt); }\n}\nconst w = new World();\nconst player = w.create();\nw.add(player, 'Pos',    { x: 0, y: 0 });\nw.add(player, 'Vel',    { dx: 10, dy: 0 });\nw.add(player, 'Health', { hp: 100 });\n// Move system: iterates only entities with BOTH Pos and Vel\nw.systems.push((w, dt) => {\n  for (const [id, pos] of w.comps.get('Pos') || []) {\n    const vel = w.get('Vel', id);\n    if (vel) { pos.x += vel.dx * dt; pos.y += vel.dy * dt; }\n  }\n});\n// Render system: depends on Pos only\nw.systems.push((w) => { for (const [id, pos] of w.comps.get('Pos') || []) draw(id, pos); });\nw.tick(1/60);
ecsdata-orientedcompositionentity-componentarchitectureสถาปัตยกรรมเกม
4.3

🗺️ Pathfinding A*

Intermediate⏱ 1 day

Shortest path on grid via open/closed sets + admissible heuristic. Optimal when heuristic never overestimates.

🎯 When to use: NPC path, tower defense creep path, RTS unit move, tile-based game AI | เกมที่ entity เดินบน grid และต้องการ shortest path
⛔ When NOT to use
• Continuous world with smooth movement (use NavMesh)
• < 10 entities (BFS suffices)
• realtime 1000+ units (use flow fields)
✅ Pros (6)
• Optimal with admissible heuristic — Manhattan 4-dir, Octile 8-dir; never returns longer path than true optimum
• Composable heuristics — swap Manhattan→Euclidean→Octile based on movement cost model without algorithm change
• JPS (Jump Point Search) — 2-10x faster by skipping symmetric neighbors, same optimality, same API
• Binary heap priority queue — openSet stays O(log n) per insert/extract vs naive array O(n)
• Cache-able per query — re-plan only on grid change, not every move; flow field pre-compute for 1000+ units
• Tie-breaking for speed — prefer goal-direction when f-scores tie, 2-3x speedup without losing optimality
⚠️ Cons (6)
• Memory O(n²) worst case — 1000x1000 grid = 1M nodes × 100B = 100MB; chunk grid or use hierarchical A*
• Re-planning on dynamic obstacles expensive — D* Lite handles but adds complexity; consider flow fields for static
• Not smooth on grid — paths have 8-way zigzag, need string-pulling / funnel algorithm post-process
• CPU spike on first plan — pre-compute flow fields for 1000+ unit RTS games (Tom Clancy's EndWar pattern)
• Heuristic tuning is project-specific — wrong heuristic = non-optimal path or 10x slower search
• Tile cost changes break admissibility — cost=2 tile + Manhattan = overestimates goal, returns non-optimal
⚡ Gotchas (8)
• Manhattan for 4-dir movement — Octile `(D*(dx+dy) + (D2-2D)*min(dx,dy))` for 8-dir where D2=√2
• Binary heap (min-heap) for openSet — `Array.shift()` is O(n), heap extract is O(log n); 100x difference at 10k nodes
• f = g + h; g = actual cost so far; h = estimated cost to goal; h must NEVER overestimate (admissible)
• Closed set ≠ 'visited' — it's 'we found OPTIMAL g for this node'; revisit if better g found later
• Tie-breaking: prefer nodes with smaller h when f ties — pulls search toward goal, 2-3x speedup
• Path reconstruction via parent pointers — null parent = start; chain back to start to get path array
• Diagonal corner-cutting forbidden when EITHER adjacent cardinal is blocked — prevents squeezing through walls
• Reset closed set between queries — reusing across queries with different goals = suboptimal paths
🚫 Anti-patterns
• Store node object in openSet without hashing — O(n²) linear scan per pop, kills performance at 1000+ nodes
• Use heuristic = 0 — becomes Dijkstra, 5-10x slower than A* with good heuristic
• Forget to rebuild path via parent pointers — silent null returns, NPC stands still with no error
• Re-plan full A* every frame — use path cache, invalidate only on map change; 100x perf improvement
• Misuse Euclidean on grid — overestimates for 8-dir movement, returns SUBOPTIMAL path (silent bug)
🔀 Alternatives
vs Dijkstra (slower but always optimal)
vs JPS Jump Point Search (10x faster)
vs Flow fields (better for 1000+ units)
📚 Prereqs: 4.1
➡️ Next: 4.4, 4.5
💻 Minimal (520 chars) ▶ Full example (2108 chars)
function aStar(g,s,goal){const h=(x,y)=>Math.abs(x-goal.x)+Math.abs(y-goal.y);const open=[{x:s.x,y:s.y,g:0,f:h(s.x,s.y)}];const closed=new Set();while(open.length){let bi=0;for(let i=1;i<open.length;i++)if(open[i].f<open[bi].f)bi=i;const c=open.splice(bi,1)[0];if(c.x===goal.x&&c.y===goal.y)return c;closed.add(c.x+','+c.y);for(const[dx,dy]of[[1,0],[-1,0],[0,1],[0,-1]]){const nx=c.x+dx,ny=c.y+dy;if(g[ny]?.[nx])continue;if(closed.has(nx+','+ny))continue;open.push({x:nx,y:ny,g:c.g+1,f:c.g+1+h(nx,ny),p:c})}}return null}
// Min-heap: openSet stays O(log n) vs Array.shift O(n)\nclass MinHeap {\n  constructor() { this.h = []; }\n  push(x) { this.h.push(x); this._up(this.h.length - 1); }\n  pop() {\n    const top = this.h[0]; const last = this.h.pop();\n    if (this.h.length) { this.h[0] = last; this._down(0); }\n    return top;\n  }\n  _up(i) { while (i > 0) { const p = (i-1) >> 1; if (this.h[p].f <= this.h[i].f) break; [this.h[p], this.h[i]] = [this.h[i], this.h[p]]; i = p; } }\n  _down(i) { const n = this.h.length; while (true) { let s = i; const l = 2*i+1, r = 2*i+2; if (l<n && this.h[l].f<this.h[s].f) s=l; if (r<n && this.h[r].f<this.h[s].f) s=r; if (s===i) break; [this.h[s], this.h[i]] = [this.h[i], this.h[s]]; i = s; } }\n}\nfunction aStar(grid, start, goal) {\n  // Octile heuristic: 8-dir with diagonal cost = sqrt(2)\n  const D = 1, D2 = Math.SQRT2;\n  const h = (x, y) => { const dx = Math.abs(x - goal.x), dy = Math.abs(y - goal.y); return D * (dx + dy) + (D2 - 2*D) * Math.min(dx, dy); };\n  const open = new MinHeap();\n  const gScore = new Map();\n  const key = (n) => n.x + ',' + n.y;\n  gScore.set(key(start), 0);\n  open.push({ x: start.x, y: start.y, g: 0, f: h(start.x, start.y), parent: null });\n  const dirs = [[1,0,D],[-1,0,D],[0,1,D],[0,-1,D],[1,1,D2],[1,-1,D2],[-1,1,D2],[-1,-1,D2]];\n  while (open.h.length) {\n    const cur = open.pop();\n    if (cur.x === goal.x && cur.y === goal.y) {\n      const path = []; let n = cur;\n      while (n) { path.unshift({ x: n.x, y: n.y }); n = n.parent; }\n      return path;\n    }\n    for (const [dx, dy, cost] of dirs) {\n      const nx = cur.x + dx, ny = cur.y + dy;\n      if (nx < 0 || ny < 0 || ny >= grid.length || nx >= grid[0].length) continue;\n      if (grid[ny][nx] === 1) continue; // wall\n      const tentG = cur.g + cost;\n      const k = nx + ',' + ny;\n      // Only update if this path is BETTER than any previous\n      if (tentG < (gScore.get(k) ?? Infinity)) {\n        gScore.set(k, tentG);\n        open.push({ x: nx, y: ny, g: tentG, f: tentG + h(nx, ny), parent: cur });\n      }\n    }\n  }\n  return null; // unreachable\n}
pathfindingaia-stargraphalgorithmpathfindingai-pathfinding
4.4

🌳 Behavior Trees (NPC AI)

Advanced⏱ 1 week

Hierarchical nodes: Selector (OR), Sequence (AND), Decorator. Designer-editable visual tree for NPC decisions.

🎯 When to use: NPC AI หลาย actions, Boss AI phases, Squad tactics, complex reactive behavior | Boss/เควส NPC ที่มีหลาย state
⛔ When NOT to use
• < 3 actions (FSM simpler)
• single-script AI
• simple state machine suffices
• performance-critical tick-every-frame for 10k NPCs
✅ Pros (6)
• Visual structure = designer edits without code — JSON export/import, Unreal/Unity plugins, no engineering round-trip
• Composable nodes — Inverter/Repeater/Cooldown/UntilFail decorators instead of writing each pattern
• Reuse across 100 NPC archetypes — same 'Flee' subtree in goblin and dragon via shared subtree reference
• Event-driven beats tick-every-frame — 100x less CPU when NPC idle (subscribe to OnTakeDamage, not tick)
• Decoupled from entity data — blackboard pattern separates tree structure from entity state
• Reactive to external events — 'OnSeeEnemy' instantly triggers attack branch without polling
⚠️ Cons (6)
• Tick overhead even when no change — solution: external trigger list + tick only on event subscription
• Stack depth errors — recursion depth = tree depth, > 1000 levels crashes; iterative traversal for deep trees
• State machine in disguise — pure tick-every-frame trees are FSM with extra steps; events needed for value
• Debugging harder than FSM — 'why did goblin walk into wall?' = trace parent chain through tree state
• Designer learning curve — Sequence vs Selector semantics ≠ intuitive AND/OR; 1-day training needed
• Blackboard naming conflicts — two NPC trees reading same 'target' key cross-contaminate state
⚡ Gotchas (8)
• Selector (OR): tick children left-to-right, first SUCCESS/RUNNING wins; if all FAIL → return FAILURE
• Sequence (AND): tick children left-to-right, any FAILURE/RUNNING short-circuits; all SUCCESS → SUCCESS
• Decorators wrap single child: Inverter (Success↔Failure), Repeater (loop N times), UntilFail (loop until FAIL)
• Action returns 3 states: Success / Failure / Running — Running means 'still working, tick me next frame'
• Event-driven > tick-every-frame — subscribe to events, queue triggers, tick on wake; 100x less CPU
• Blackboard = shared state — namespace by entity or global; conflicts are silent bugs, version key prefixes help
• Priority order in Selector matters — cheap checks FIRST (cooldown check before pathfind), 10x speedup
• Re-entry mid-tick breaks — Sequence returns Running then immediate re-tick = state corruption; protect with mutex
🚫 Anti-patterns
• Tick the tree every frame for ALL NPCs regardless — wastes CPU; use event trigger + wake list
• Action node reads entity state directly — couples tree to data shape; use blackboard for testability
• Sequence with no Failure path — entire NPC stuck forever once first child fails (deadlock)
• Same tree INSTANCE shared across entities — blackboard reads cross-contaminate; clone or per-entity BB
• Decorator recursion without base case — Repeater with no Failure returns = infinite loop, tab freezes
🔀 Alternatives
vs FSM (simpler for <5 states)
vs GOAP planning (more flexible but slower)
vs Utility AI (numerical scoring, harder to visualize)
📚 Prereqs: 4.1, 4.3
➡️ Next: 4.7, 4.8
💻 Minimal (414 chars) ▶ Full example (1655 chars)
class Selector{tick(c){for(const ch of this.ch){const r=ch.tick(c);if(r==='success'||r==='running')return r}return 'failure'}} class Sequence{tick(c){for(const ch of this.ch){const r=ch.tick(c);if(r==='failure'||r==='running')return r}return 'success'}} const bt=new Selector([new Sequence([new Cond(c=>c.hp<30),new Action(c=>{c.state='flee';return 'success'})]),new Action(c=>{c.state='work';return 'success'})]);
// 3-state result: Success / Failure / Running\nconst S = 'success', F = 'failure', R = 'running';\n// Composite OR: try children until one succeeds\nclass Selector {\n  constructor(children) { this.children = children; }\n  tick(ctx) {\n    for (const c of this.children) {\n      const r = c.tick(ctx);\n      if (r === S || r === R) return r; // short-circuit\n    }\n    return F;\n  }\n}\n// Composite AND: all children must succeed\nclass Sequence {\n  constructor(children) { this.children = children; }\n  tick(ctx) {\n    for (const c of this.children) {\n      const r = c.tick(ctx);\n      if (r === F || r === R) return r; // short-circuit\n    }\n    return S;\n  }\n}\n// Decorator: invert child's result (Success<->Failure)\nclass Inverter {\n  constructor(child) { this.child = child; }\n  tick(ctx) {\n    const r = this.child.tick(ctx);\n    return r === S ? F : r === F ? S : R;\n  }\n}\n// Leaf: predicate\nclass Cond {\n  constructor(fn) { this.fn = fn; }\n  tick(ctx) { return this.fn(ctx) ? S : F; }\n}\n// Leaf: action; can return Running for multi-tick ops like pathfind\nclass Action {\n  constructor(fn) { this.fn = fn; }\n  tick(ctx) { return this.fn(ctx) || S; }\n}\n// Villager AI: flee if low HP, else eat if hungry, else work\nconst villagerBT = new Selector([\n  new Sequence([ new Cond(c => c.hp < 30), new Action(c => { c.state = 'flee'; return S; }) ]),\n  new Sequence([ new Cond(c => c.hunger > 80), new Action(c => { c.state = 'eat';  return S; }) ]),\n  new Inverter(new Cond(c => c.atWork)),\n  new Action(c => { c.state = 'work'; return S; })\n]);\n// Event-driven tick: only call villagerBT.tick when ctx changes
aibehavior-treenpcdecisionbtai-behaviornpc-ai
4.5

🎲 Procedural Generation

Intermediate⏱ 3 days

Random content via seeded RNG. Same seed = identical output. Combine algorithms for natural-feeling variety.

🎯 When to use: Roguelike levels, sandbox worlds, test data, daily challenges, shareable seeds | เกมที่ต้องการ infinite content
⛔ When NOT to use
• Story-driven linear game
• hand-crafted levels required
• art direction needs precise control
✅ Pros (6)
• Infinite content without storage — 1KB seed generates GB of world; procedural saves disk, ships bigger games
• Shareable seeds — 'try seed 8675309' = reproducible competition (Spelunky Daily Challenge, No Man's Sky portals)
• Always-fresh gameplay — no memorization reduces boredom in roguelikes; 1000 hours vs 10 hours
• Combined algorithms — Perlin + Voronoi + Poisson gives natural-feeling output (terrain/biomes/trees)
• Easy unit testing — fixed seed = deterministic output = regression-testable; CI golden fixtures
• Player perception of 'more game' — 1000 generated levels feel like 1000 hand-crafted to most players
⚠️ Cons (6)
• Quality variance — bad seeds produce broken/unfun levels; validation pass mandatory, not optional
• Combinatorial explosion — 100 rooms × 10 templates = 1000 cache slots; LRU cache or compute-on-demand
• AI-slop feel — humans detect patterns in 5-10 minutes; pure noise feels hollow, needs curation
• Designer override needed — hand-crafted set pieces must override algorithm (boss rooms, story beats)
• Performance — 100k tile Perlin = 50ms freeze on slow phones; pre-compute or Web Worker offload
• Seed bug reproducibility — wrong RNG = different output, hard to debug without exact seed string
⚡ Gotchas (8)
• ALWAYS seed RNG (mulberry32 / sfc32) — Math.random() is unseedable in browsers; same seed MUST give same output
• Validate generated output — connected rooms? spawn reachable? boss arena exists? no isolated islands?
• Combine techniques: Perlin for heightmap + Poisson for tree placement + Voronoi for biome boundaries
• Layer noise frequencies — 1 octave smooth, 8 octaves realistic but 8x cost; tune to content type
• Player-progression fairness — generation must adapt to player level (Angry Birds stage difficulty curve)
• Determinism needs consistent iteration order — never Set, always sorted Array or insertion-ordered Map
• RNG state pollution — each generator reseeds with own seed, not reuse parent RNG (coupling bug)
• Output quality check: count dead-ends, isolated islands, unreachable regions; regenerate if fail
🚫 Anti-patterns
• Pure Math.random() — unseedable, breaks shareable seeds, no reproducible bugs
• Single algorithm for everything — samey output, players spot pattern in minutes; layer techniques
• No validation pass — ship broken levels; players find seeds that crash or trap them
• Generation on render thread — main thread freezes on Perlin compute; Web Worker or pre-compute
• Same RNG instance threaded through parallel async — race conditions in output, hard-to-repro bugs
🔀 Alternatives
vs Wave Function Collapse (constraint-based, better for tilesets)
vs Hand-authored levels (quality ceiling higher but 100x dev cost)
📚 Prereqs: 4.1
💻 Minimal (261 chars) ▶ Full example (1681 chars)
function mulberry32(s){return function(){let t=s+=0x6D2B79F5;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296}} const rng=mulberry32(seed); const map=Array.from({length:30},()=>Array.from({length:50},()=>rng()<0.45?1:0));
// mulberry32: 5-line seedable RNG, 2^32 period, good for games\nfunction mulberry32(seed) {\n  return function() {\n    let t = (seed += 0x6D2B79F5);\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n// 2D value noise: cheap, deterministic per (x,y) for given seed\nfunction valueNoise(seed, x, y) {\n  const xi = Math.floor(x), yi = Math.floor(y);\n  const xf = x - xi, yf = y - yi;\n  const a = mulberry32(seed + xi * 7  + yi * 13)();\n  const b = mulberry32(seed + (xi+1)*7 + yi * 13)();\n  const c = mulberry32(seed + xi * 7  + (yi+1)*13)();\n  const d = mulberry32(seed + (xi+1)*7 + (yi+1)*13)();\n  // smoothstep interpolation: smooth derivatives at integer boundaries\n  const u = xf * xf * (3 - 2 * xf);\n  const v = yf * yf * (3 - 2 * yf);\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;\n}\n// 40x60 cave: multi-octave noise thresholded to wall/floor\nfunction generateCave(seed, w = 40, h = 60) {\n  const map = Array.from({ length: h }, (_, y) =>\n    Array.from({ length: w }, (_, x) => {\n      let n = 0, amp = 1, freq = 0.05;\n      // 4 octaves: large features + medium + small + detail\n      for (let o = 0; o < 4; o++) {\n        n += valueNoise(seed + o * 1000, x * freq, y * freq) * amp;\n        amp *= 0.5; freq *= 2;\n      }\n      return n > 0.5 ? 1 : 0; // 1 = wall, 0 = floor\n    })\n  );\n  // Validation: flood-fill from (1,1); if reachable < 30% of floors, regenerate\n  return validate(map) ? map : generateCave(seed + 1, w, h);\n}\n// Usage: same seed = byte-identical output, share-able\nconst cave = generateCave(8675309);
proceduralrandompcgseednoiseperlinสุ่ม
4.6

🎒 Inventory System (Slot/Stack)

Intermediate⏱ 4 hours

Slot-based array with maxStack per item, stack-first then empty-slot fill, Object.freeze templates.

🎯 When to use: RPG inventory, tycoon resource stock, survival crafting, equipment loadout | เกมที่ player เก็บของได้หลายชิ้น
⛔ When NOT to use
• Single-item pickup
• key-only progression
• simple carry counter
• no UI for items
✅ Pros (6)
• Clear state — `slots[i] = {itemId, qty}` debuggable in DevTools, JSON-serializable for save
• Bounded memory — max slots cap prevents OOM from drop-spam exploit; 20 slots × 50B = 1KB
• Stack semantics match player mental model — 99 potions = 1 stack slot, not 99 slots
• Easy serialization — `JSON.stringify(slots)` round-trips through localStorage, no Map/Set gotchas
• Drop/swap semantics obvious — null slot = empty, swap = exchange pointers, partial-stack split
• UI flexibility — same data drives grid, list, hotbar, mini-map representations without change
⚠️ Cons (6)
• Slot limit feels artificial — 20-slot bag + 100 herbs = frustrating without 'upgrade bag' mechanic
• Partial-stack logic verbose — 50 arrows into 2 stacks of 30+20 + empty slot = 3 branches to handle
• Drag-drop on touch unreliable — HTML5 DnD fires dragend on iOS inconsistently; need click-to-move fallback
• Object.freeze hurts perf in hot loops — copy-on-read vs reference trade-off; freeze templates only
• Sort/filter requires stable sort — Array.sort mutates + locale-aware; deep copy before sort
• Crafting dependencies — recipe needs 3x item X but inventory has 1 stack of 30 = ambiguous 'remove 3' vs 'split'
⚡ Gotchas (8)
• Always Object.freeze the item catalog — mutating ITEMS.potion.heal corrupts ALL inventories referencing it
• HTML5 DnD: dragstart requires event.dataTransfer.setData — iOS Safari fires twice, may need dedup
• Max stack per item type — gold stacks 999, swords stack 1, configurable per template (not hardcoded)
• Auto-stack on add — check existing stacks first before consuming empty slots; 99 herb add = no new slot
• Partial fill: qty=150, maxStack=99 → fills stack A (99), starts stack B (51), succeeds with 2 slots used
• Drop semantics: held item in cursor slot, not in inventory until drop event fires (atomic)
• Sort stability: compare by itemId first, rarity second, qty desc third — localeCompare for names
• Save serialization: array of slots smaller than Map — always array for storage, convert at runtime if needed
🚫 Anti-patterns
• Mutate the global ITEMS catalog at runtime — all inventories reference same object, one bug spreads
• Use plain object as slot — `{itemId, qty}` lacks maxStack metadata, separate lookup fragile
• Trust HTML5 DnD without fallbacks — mobile web fails 20% of time, need click-to-move or touch handlers
• One slot per item, no stacking — UI wastes 20 slots on 99 herbs; player rage-quits
• Allow negative qty on subtract — silent overflow corrupts save file; clamp at 0 minimum
🔀 Alternatives
vs Tetris-style tetris inventory (fixed shape)
vs Tetris/tetromino-bag (Tetris-like shape constraints)
vs Weight-based (no slots, total weight cap)
📚 Prereqs: 4.1
💻 Minimal (480 chars) ▶ Full example (2052 chars)
const ITEMS=Object.freeze({potion:{maxStack:99,icon:'🧪'},sword:{maxStack:1,icon:'⚔️'},arrow:{maxStack:99,icon:'➶'}});class Inv{constructor(n=20){this.slots=new Array(n).fill(null)}add(id,q=1){const t=ITEMS[id];for(const s of this.slots)if(s?.id===id&&s.q<t.maxStack){const take=Math.min(q,t.maxStack-s.q);s.q+=take;q-=take;if(q<=0)return true}for(let i=0;i<this.slots.length&&q>0;i++)if(!this.slots[i]){this.slots[i]={id,q:Math.min(q,t.maxStack)};q-=this.slots[i].q}return q===0}}
// Item template IMMUTABLE — all inventories reference same object\nconst ITEMS = Object.freeze({\n  potion: { name: 'Potion',   maxStack: 99, icon: '🧪' },\n  sword:  { name: 'Sword',    maxStack: 1,  icon: '⚔️' },\n  arrow:  { name: 'Arrow',    maxStack: 99, icon: '➶' },\n  wood:   { name: 'Wood',     maxStack: 99, icon: '🪵' }\n});\nclass Inventory {\n  constructor(maxSlots = 20) {\n    this.max = maxSlots;\n    this.slots = new Array(maxSlots).fill(null);\n  }\n  add(itemId, qty = 1) {\n    const tpl = ITEMS[itemId];\n    if (!tpl) throw new Error('Unknown item: ' + itemId);\n    // Phase 1: fill existing PARTIAL stacks first (auto-merge)\n    for (const s of this.slots) {\n      if (s && s.itemId === itemId && s.qty < tpl.maxStack) {\n        const take = Math.min(qty, tpl.maxStack - s.qty);\n        s.qty += take; qty -= take;\n        if (qty <= 0) return true;\n      }\n    }\n    // Phase 2: empty slots for new stacks\n    for (let i = 0; i < this.slots.length && qty > 0; i++) {\n      if (!this.slots[i]) {\n        const put = Math.min(qty, tpl.maxStack);\n        this.slots[i] = { itemId, qty: put };\n        qty -= put;\n      }\n    }\n    return qty === 0; // false = some items didn't fit\n  }\n  remove(itemId, qty = 1) {\n    let need = qty;\n    // Reverse-iterate: remove from newest stacks first (LIFO)\n    for (let i = this.slots.length - 1; i >= 0 && need > 0; i--) {\n      const s = this.slots[i];\n      if (s && s.itemId === itemId) {\n        const take = Math.min(need, s.qty);\n        s.qty -= take; need -= take;\n        if (s.qty === 0) this.slots[i] = null;\n      }\n    }\n    return need === 0;\n  }\n  // Stable sort: itemId then rarity then qty desc; nulls last\n  sort() {\n    this.slots.sort((a, b) => {\n      if (!a && !b) return 0;\n      if (!a) return 1; if (!b) return -1;\n      if (a.itemId !== b.itemId) return a.itemId.localeCompare(b.itemId);\n      return b.qty - a.qty;\n    });\n  }\n}\n// Usage: const inv = new Inventory(20); inv.add('arrow', 150); // auto-splits into 2 stacks
inventoryslotstackrpgitemไอเทมช่องเก็บของ
4.7

💬 Dialogue System (Branching)

Intermediate⏱ 3 hours

JSON tree of nodes, conditions = function refs evaluated lazily, side effects on choice.

🎯 When to use: NPC dialogues, quest giver text, tutorial steps, branching story | เกมที่มี NPC คุยกับ player และเลือกตอบได้
⛔ When NOT to use
• Linear monologue
• single cutscene
• loading screen tips
• system messages
✅ Pros (6)
• Writers edit JSON — no engineer round-trip per line change; non-programmers ship content
• Localization trivial — `data[locale][nodeId].text` swap per language; 10x faster than string extraction
• Variable substitution — `{{playerName}}` resolves from blackboard; no template engine needed
• Conditions as function refs — `{condition: c => c.hasQuest('dragon')}` evaluated lazily at display time
• Save minimal — just `{currentId, history[]}` round-trips full conversation state in <100B
• Branching depth matches JSON nesting — designers see tree structure visually, no mental translation
⚠️ Cons (6)
• State management — back button must pop history stack, or player stuck mid-conversation
• Variable interpolation risk — raw text injection = XSS if text from user; escape HTML in renderText
• Memory: deep JSON tree parses each conversation; 1000-node tree = 500KB; lazy-load by chapter
• Side effects (giveItem, startQuest) scattered in choices — hard to audit, hard to test in isolation
• No undo of destructive choice — chose 'kill NPC' without save = reload required; checkpoint system needed
• Localization drift — `start` updated in EN, missing in TH silently; lint script catches missing keys
⚡ Gotchas (8)
• Unique ID per node — auto-rename `node_001`, `node_002`; never trust author-provided IDs (typos)
• Save state = `{currentId, history: [n1, n2, n3]}` — minimal payload; reset history on conversation end
• Conditions evaluated LAZILY — `fn(blackboard)` runs at choice-display time, not parse time (fresh state)
• Speaker attribution: each node has `speaker: 'npc'|'narrator'|'player'` for bubble color/position
• Typewriter effect needs `await sleep(30)` per char — frame-budget on long lines (100 chars = 3s)
• Variable interpolation: `{{player.name}}` lookup; escape HTML to prevent XSS (`<` → `&lt;`)
• Endless loop detection: `currentId === lastChoice.nextId` cycle guard, force-progress after N
• Localization keys not nested in `text` — `node.text` is final string, `node.textKey` is i18n lookup key
🚫 Anti-patterns
• Hardcode dialogue in JS strings — writers can't edit without redeploy; productivity killer
• Skip history stack — 'back' button impossible to implement, players rage-quit
• Evaluate conditions at JSON parse time — side effects fire BEFORE player sees choice
• One mega-tree for entire game — 10,000 nodes slow to parse, slow to grep, merge conflicts
• Mutate JSON tree at runtime — save file references by ID, but ID now means different text (silent bug)
🔀 Alternatives
vs Yarn Spinner / Ink markup (designer-friendly syntax)
vs YAML linear scripts (no branching)
vs Visual novel engines (Ren'Py/Twine)
📚 Prereqs: 4.1
➡️ Next: 4.8
💻 Minimal (531 chars) ▶ Full example (2056 chars)
const dlg={start:{speaker:'npc',text:'Hi {{name}}',choices:[{text:'Buy',next:'shop',cond:c=>c.gold>=5},{text:'Bye',next:'end'}]},shop:{speaker:'npc',text:'Potions 5g',choices:[{text:'Buy 1',next:'bought',effect:c=>{c.gold-=5;c.pots=(c.pots||0)+1}}]},end:{speaker:'npc',text:'Bye'}};class Run{constructor(t,bb){this.t=t;this.bb=bb;this.id='start';this.h=[]}get cur(){return this.t[this.id]}choose(i){const c=this.cur.choices[i];this.h.push(this.id);if(c.effect)c.effect(this.bb);this.id=c.next}back(){this.id=this.h.pop()||'start'}}
// Dialogue as JSON: writers edit, code consumes\nconst dialogue = {\n  start: {\n    speaker: 'npc', text: 'Welcome, {{playerName}}.',
    choices: [\n      { text: 'Buy potions', next: 'shop', condition: c => c.gold >= 5 },\n      { text: 'Tell me about dragons', next: 'dragon_lore' },\n      { text: 'Goodbye', next: 'end' }\n    ]\n  },\n  shop: {\n    speaker: 'npc', text: 'Potions are 5 gold each.',\n    choices: [\n      { text: 'Buy one', next: 'bought', effect: c => { c.gold -= 5; c.potions = (c.potions||0) + 1; } },\n      { text: 'Back', next: 'start' }\n    ]\n  },\n  dragon_lore: {\n    speaker: 'npc', text: 'They nest in the north mountains.',\n    choices: [{ text: 'Thanks', next: 'start' }]\n  },\n  bought: { speaker: 'npc', text: 'Here you go!', choices: [{ text: 'Thanks', next: 'start' }] },\n  end:    { speaker: 'npc', text: 'Farewell.' }\n};\nclass DialogueRunner {\n  constructor(tree, blackboard) {\n    this.tree = tree; this.bb = blackboard;\n    this.currentId = 'start'; this.history = [];\n  }\n  get current() { return this.tree[this.currentId]; }\n  // Filter choices by condition at RENDER time (lazy eval)\n  get availableChoices() {\n    return (this.current.choices || [])\n      .map((c, i) => ({ c, i }))\n      .filter(({ c }) => !c.condition || c.condition(this.bb));\n  }\n  choose(index) {\n    const choice = this.current.choices[index];\n    this.history.push(this.currentId);\n    // Side effects fire on PICK, not on display\n    if (choice.effect) choice.effect(this.bb);\n    this.currentId = choice.next;\n    return this.currentId !== 'end';\n  }\n  back() { return (this.currentId = this.history.pop()) || 'start'; }\n  // XSS-safe interpolation: escape {{playerName}} lookup\n  renderText(text) {\n    return text.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (_, path) => {\n      const v = path.split('.').reduce((o, k) => o?.[k], this.bb);\n      const esc = String(v == null ? '' : v).replace(/[<>&]/g, c =>\n        ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' })[c]);\n      return esc;\n    });\n  }\n}
dialoguenpcbranchingjson-treelocalizationบทสนทนาไดอะล็อก
4.8

📜 Quest System

Intermediate⏱ 2 hours

Mission = objective + progress + reward. ONE central trigger function mutates all quest state.

🎯 When to use: RPG/tycoon/MMO with missions, daily challenges, tutorial steps, milestones | เกมที่ player ทำภารกิจเพื่อ reward
⛔ When NOT to use
• Linear tutorial with no completion tracking
• story-only game with no side objectives
• single-objective games
✅ Pros (6)
• Engagement loop — short quests = dopamine hits = retention metric, proven across MMOs and mobile games
• Centralized trigger — ONE `updateQuestProgress(type, amount)` updates 50 quests, no scattered logic
• Reward immediacy — gold/exp given on `complete()` not on next scene load, players notice delay otherwise
• Composable objectives — `gather_wood` + `kill_orc` chain to `defeat_boss` via prereqs field
• UI auto-render — `activeQuests.map(q => renderHUD(q))` always in sync with state, no manual sync
• Analytics-friendly — `analytics.track('quest_complete', {id, time})` metrics out of box for tuning
⚠️ Cons (6)
• State per quest — 100 active quests × 10 fields = memory footprint; compact schema needed
• Trigger coordination — `kill_orc` fires from combat, gathering from collection, must debounce duplicate fires
• Save/load serialization complexity — Map<id, Quest> needs toJSON/fromJSON, version migration on schema change
• Race conditions — two simultaneous triggers (pickup + quest complete) = double reward if not atomic
• Prerequisite chain explosion — quest A needs B needs C needs D = linear lock-in, frustrating if stuck
• Failure UX — player can't complete quest, no error message, just stuck; need progress hint UI
⚡ Gotchas (8)
• Central trigger: `updateQuestProgress(type, amount)` is the ONLY function that mutates quest state
• Reward MUST fire IMMEDIATE — don't batch until quest turn-in, players notice delay = bad UX
• Quest type enum: 'gather', 'kill', 'deliver', 'talk', 'reach' — single string per quest, dispatch in switch
• Progress capped at target — `Math.min(target, progress + amount)` prevents overflow on event spam
• Auto-complete when progress >= target — single check in updateQuestProgress, no race condition possible
• Quest chains: `prereqs: ['quest_id_1', 'quest_id_2']` blocks UI button until done; gate accept()
• Save format: Array.from(activeQuests), not Map — JSON-friendly; load: `new Map(loaded.map(q => [q.id, q]))`
• Side objectives (optional) — separate `bonus` field, doesn't block main quest completion (engagement boost)
🚫 Anti-patterns
• Update quest state from 10 different places — race conditions, double-credit bugs, debug nightmare
• Reward on next scene load — player confused if gold doesn't appear; trust lost, churn up
• Linear quest chains with no alternative — 1 stuck quest = entire game blocked; offer 2-3 parallel paths
• No progress indicator — 'kill 5 orcs' with no counter = player forgets they have quest, abandons
• Hardcoded quest IDs in code — refactor breaks save files; use string const table exported from data
🔀 Alternatives
vs Achievement system (4.9 — longer-term, less structured)
vs Daily challenges (timer-based subset)
vs MMO quest trackers (more complex UI)
📚 Prereqs: 4.7
➡️ Next: 4.9
💻 Minimal (254 chars) ▶ Full example (1828 chars)
function updateQuestProgress(type,amount=1){for(const[id,q]of active)if(q.type===type){q.progress=Math.min(q.target,q.progress+amount);if(q.progress>=q.target&&!q.done)complete(id)}} updateQuestProgress('gather_wood',5);updateQuestProgress('kill_orc',1);
// Quest definition as data, code consumes\nconst QUESTS = {\n  wood_collector: {\n    title: 'Gather Wood', type: 'gather', target: 'wood', count: 10,\n    reward: { gold: 50, xp: 100 },\n    prereqs: []\n  },\n  orc_slayer: {\n    title: 'Slay Orcs', type: 'kill', target: 'orc', count: 5,\n    reward: { gold: 100, xp: 200 },\n    prereqs: ['wood_collector']  // must finish wood first\n  },\n  dragon_slayer: {\n    title: 'Slay the Dragon', type: 'kill', target: 'dragon', count: 1,\n    reward: { gold: 1000, xp: 5000, items: ['dragon_sword'] },\n    prereqs: ['orc_slayer']\n  }\n};\nclass QuestMgr {\n  constructor() { this.active = new Map(); this.completed = new Set(); }\n  // THE central trigger — ONLY this function mutates quest state\n  update(type, amount = 1) {\n    for (const [id, q] of this.active) {\n      if (q.type !== type) continue;\n      // Cap at target; prevents overflow on event spam\n      q.progress = Math.min(q.count, (q.progress || 0) + amount);\n      if (q.progress >= q.count && !q.done) this._complete(id, q);\n    }\n  }\n  accept(id) {\n    const q = QUESTS[id];\n    if (!q) throw new Error('Unknown quest: ' + id);\n    // Gate by prereqs; prevent accept of blocked quests\n    if (q.prereqs.some(p => !this.completed.has(p))) return false;\n    this.active.set(id, { ...q, progress: 0, done: false });\n    return true;\n  }\n  _complete(id, q) {\n    q.done = true;\n    // IMMEDIATE reward — not deferred, not batched\n    if (q.reward.gold)  player.gold += q.reward.gold;\n    if (q.reward.xp)    player.xp   += q.reward.xp;\n    if (q.reward.items) q.reward.items.forEach(i => player.inv.add(i));\n    this.completed.add(id);\n    this.active.delete(id);\n    toast(`✅ Quest complete: ${q.title}`);\n  }\n}\n// Usage: QuestMgr.update('kill_orc', 1); // called from combat system
questmissionobjectiverewardrpgเควสภารกิจ
4.9

🏆 Achievement System

Beginner⏱ 1 hour

Discrete accomplishments with idempotent unlock check. Categories for UI grouping. Event-triggered evaluation.

🎯 When to use: ทุกเกมที่ต้องการ replayability | Games with long-term progression, completionist goals, daily streaks
⛔ When NOT to use
• Tutorial signposts (use quests instead)
• one-shot narrative moments
• server-validated competitive ranks
✅ Pros (6)
• Idempotent check — `if (state.achs.includes(id)) return` prevents double-unlock toast spam
• Long-term goals — 'Reach level 50' runs in background of normal play, no separate UI to manage
• Replayability hook — completionist goes for 100% on second playthrough, proven retention driver
• Trivial serialization — `state.achs = ['first_blood', 'rich_1k']` is 1 line of JSON round-trip
• Cheap to evaluate — check on event (kill, levelup), not per-frame; 100 achievements = <1ms total
• Social proof — 'X% of players have this' badge drives engagement, visible leaderboard psychology
⚠️ Cons (6)
• Users ignore achievements — 70% never look at the screen, dev effort vs retention unclear
• Notification spam — unlocking 5 at once buries important feedback; queue + throttle needed
• Edge cases — offline progress should still unlock if criteria met; reconcile on first tick
• Cheating — localStorage editable, 'achievements' not authoritative; server validation for competitive
• Tracking the untrackable — 'be polite to NPCs' needs behavior classification, ML or heuristics
• ROI unclear — dev time vs retention lift often negative; instrument and measure before expanding
⚡ Gotchas (8)
• `if (state.achs.includes(id)) return` — idempotency is THE bug to avoid; toast fires 5x/sec otherwise
• Categories: 'combat', 'economy', 'social', 'exploration', 'meta' — UI grouping for 100+ entries
• Notification queue: enqueue, show 1 per 3s, not all at once; queue length cap (drop oldest)
• Server-validated achievements for competitive games — Steam, GOG, PSN each have own APIs, dup effort
• Date-based achievements — 'play 7 days in a row' needs streak counter, not just count; reset on miss
• Hidden achievements — `visible: false` for surprise reveals, surfaces only on unlock (engagement)
• Cross-platform sync — Steam achievements ≠ PSN achievements, write platform adapter layer
• Test achievement triggers with debug menu — manual playthrough 100x is too slow; CLI unlock-all for QA
🚫 Anti-patterns
• No idempotency check — toast fires 5x per second, log flooded, player dismisses notification
• Award on frame tick — wastes CPU; award on event (kill/levelup/quest_complete), batch check on save
• Hardcoded English strings — no localization, Thai players see 'Kill 100 enemies' in wrong script
• 100 achievements for trivial actions — 'walk 1 step' = meaningless padding; player tunes out
• Server trusts client achievement data — cheaters unlock everything in 30s; sign with HMAC
🔀 Alternatives
vs Quest system (4.8 — structured, has progress)
vs Steam achievements (server-validated, dup platform work)
vs Daily challenges (timer-based subset)
📚 Prereqs: 4.8
💻 Minimal (402 chars) ▶ Full example (2283 chars)
class AchMgr{constructor(s){this.s=s;s.achs=s.achs||[];this.q=[]}unlock(id){if(this.s.achs.includes(id))return;this.s.achs.push(id);this.q.push(id)}check(s){if(s.kills>=1)this.unlock('first_blood');if(s.gold>=1000)this.unlock('rich_1k');if(s.day_streak>=7)this.unlock('weekly_warrior')}render(){if(this.q.length===0)return;const n=this.q.shift();showToast(`🏆 ${n}`);setTimeout(()=>this.render(),3000)}}
// Achievement definitions as data, single check function\nconst ACHIEVEMENTS = {\n  first_blood:     { name: 'First Blood',    desc: 'Defeat your first enemy',  cat: 'combat',      icon: '⚔️' },\n  rich_1k:         { name: 'Wealthy',        desc: 'Accumulate 1000 gold',     cat: 'economy',     icon: '💰' },\n  wood_legend:     { name: 'Lumberjack',     desc: 'Gather 1000 wood',         cat: 'economy',     icon: '🪵' },\n  explorer:        { name: 'Cartographer',   desc: 'Visit all map regions',    cat: 'exploration', icon: '🗺️' },\n  social_butterfly:{ name: 'Friendly',       desc: 'Talk to 50 NPCs',          cat: 'social',      icon: '💬' },\n  weekly_warrior:  { name: 'Streak Master',  desc: 'Play 7 days in a row',     cat: 'meta',        icon: '🔥' }\n};\nclass AchievementMgr {\n  constructor(state) {\n    this.state = state;\n    this.state.achs = state.achs || [];\n    this.queue = []; // notification queue\n  }\n  // THE critical line: idempotency guard\n  unlock(id) {\n    if (this.state.achs.includes(id)) return; // <-- the bug-avoider\n    const ach = ACHIEVEMENTS[id];\n    if (!ach) return;\n    this.state.achs.push(id);\n    this.queue.push(ach);\n    if (navigator.onLine) this._syncToServer(id); // optional server-validated\n  }\n  // Batch check on game state changes — NOT per-frame\n  check(state) {\n    if (state.enemiesKilled >= 1)        this.unlock('first_blood');\n    if (state.gold >= 1000)               this.unlock('rich_1k');\n    if (state.woodGathered >= 1000)       this.unlock('wood_legend');\n    if (state.npcsTalked >= 50)           this.unlock('social_butterfly');\n    if (state.dayStreak >= 7)             this.unlock('weekly_warrior');\n  }\n  // Throttled notification display: 1 per 3s, not all at once\n  renderToasts() {\n    if (this.queue.length === 0) return;\n    const next = this.queue.shift();\n    showToast(`🏆 ${next.icon} ${next.name}: ${next.desc}`);\n    setTimeout(() => this.renderToasts(), 3000);\n  }\n  _syncToServer(id) {\n    // POST /api/achievements { userId, id, timestamp }\n    // Server validates, returns updated global % for 'X% of players'\n    fetch('/api/ach', { method: 'POST', body: JSON.stringify({ id }) });\n  }\n}\n// Hook into game loop: only check on state change events, not every frame
achievementtrophyunlockidempotentrewardความสำเร็จรางวัล
4.10

🔄 Save Migration

Intermediate⏱ 1 hour

Multi-version backward-compat with version field. Forward-only migrations. Default values for missing fields.

🎯 When to use: เกมที่ ship หลายเวอร์ชัน, mobile apps with updates, browser games with patches | Any app persisted across deploys
⛔ When NOT to use
• App with single version and never updates
• server-only state with full DB migrations
• ephemeral session data
✅ Pros (6)
• User keeps progress across updates — biggest retention metric in mobile games (Angry Birds saved years)
• Forward + backward compat — old save loads in new build, new save loads in old build (with defaults)
• Version field is mandatory — every save records `_ver` integer, never trust absent (defensive)
• Migration is one-way per upgrade — v1→v2→v3, no skipped steps; idempotent if re-run
• Default values for missing fields — `data.quests ||= []` handles partial saves, never crashes
• Easy rollback — keep migration code in branches, revert = revert migration + restore old schema
⚠️ Cons (6)
• Migration code per version — 5 versions × 100 fields = 500 lines of transform functions
• Bug amplification — bad migration corrupts ALL saves, not just new ones; CI fixtures MANDATORY
• Testing matrix — must test v1, v2, v3, v4, v5 loads in v6 build = combinatorial; automate
• Storage fragmentation — old format fields linger forever, can't GC; compression helps
• Non-deterministic migrations — Date.now() in migration = different output per run, save file unstable
• Schema vs data confusion — adding UI field ≠ save field; schema migration ≠ data migration, separate
⚡ Gotchas (8)
• Keep `_ver` field always — never trust `parseInt(undefined)` defaults, must default to 1 explicitly
• One-way migration: v1 → v2 (never v2 → v1) — idempotent if you re-run; chain migrations in order
• Forward compat: new code reads old save + applies migrations to current version; old code reads new = defaults
• Backward compat: old code reads new save via `data.foo ?? defaultValue` everywhere; defensive reads
• Default values via `??` not `||` — `gold=0` and `name=''` are valid, `||` substitutes them wrong
• Test all version loads in CI — automated fixtures v1.json...v5.json + load test on each PR
• Never mutate the input — clone first; shared object reference = silent corruption of localStorage
• Migration failures should NOT crash — log + fallback to fresh save + report bug via analytics
🚫 Anti-patterns
• Skip versioning — one day you ship 'remove field X', every existing save crashes on load
• Mutate input data during migration — original localStorage value corrupted, cannot retry
• Use `||` instead of `??` for defaults — `gold=0` becomes `gold=defaultValue`, save data lost
• Migration that depends on time/random — output differs per load, save file unstable across reloads
• No testing for old version loads — ship update, all players with old save crash, 1-star reviews
🔀 Alternatives
vs IndexedDB (more storage, async, complex API)
vs Server-side DB migrations (Liquibase/Flyway)
vs Backward-compat only (read latest, ignore older)
📚 Prereqs: 1.4
💻 Minimal (440 chars) ▶ Full example (2322 chars)
const MIGS={2:d=>({...d,achs:d.achs||[]}),3:d=>({...d,weather:d.weather??'cloudy'}),4:d=>{let n={...d};n.player={...n.player,loc:n.player.pos||{x:0,y:0}};delete n.player.pos;return n}};function load(){let raw=null;for(let v=CURRENT_VER;v>=1;v--){raw=localStorage.getItem(`app_v${v}`);if(raw)break}if(!raw)return fresh();let d=JSON.parse(raw);for(let v=(d._ver||1);v<CURRENT_VER;v++){const m=MIGS[v+1];if(m){d=m({...d});d._ver=v+1}}return d}
// Versioned migrations: each fn transforms save to NEXT version\nconst CURRENT_VER = 5;\nconst MIGRATIONS = {\n  // v1 -> v2: achievements array added\n  2: d => ({ ...d, achievements: d.achievements || [] }),\n  // v2 -> v3: weather field added with default\n  3: d => ({ ...d, weather: d.weather ?? 'cloudy' }),\n  // v3 -> v4: quests array + activeQuest separate from achievements\n  4: d => ({ ...d, quests: d.quests || [], activeQuest: d.activeQuest || null }),\n  // v4 -> v5: player.position renamed to player.loc (refactor)\n  5: d => {\n    const next = { ...d };\n    if (next.player) {\n      next.player = { ...next.player, loc: next.player.position || { x: 0, y: 0 } };\n      delete next.player.position; // remove old key, do NOT keep both\n    }\n    return next;\n  }\n};\nclass SaveMgr {\n  static save(state) {\n    const payload = { _ver: CURRENT_VER, ...state, savedAt: Date.now() };\n    localStorage.setItem('app_v' + CURRENT_VER, JSON.stringify(payload));\n  }\n  static load() {\n    // Try newest key first, fall back to older versions\n    let raw = null;\n    for (let v = CURRENT_VER; v >= 1; v--) {\n      raw = localStorage.getItem('app_v' + v);\n      if (raw) break;\n    }\n    if (!raw) return this._fresh();\n    let data;\n    try { data = JSON.parse(raw); }\n    catch (e) {\n      console.error('Save corrupted, starting fresh', e);\n      return this._fresh();\n    }\n    // Forward-only migration: apply each step IN ORDER from current to target\n    const startVer = data._ver || 1;\n    for (let v = startVer; v < CURRENT_VER; v++) {\n      const mig = MIGRATIONS[v + 1];\n      if (mig) {\n        // Clone first to avoid mutating the parsed input\n        data = mig({ ...data });\n        data._ver = v + 1;\n      }\n    }\n    return data;\n  }\n  static _fresh() {\n    return { _ver: CURRENT_VER, player: { loc: { x: 0, y: 0 } }, achievements: [], quests: [], gold: 0 };\n  }\n  // CI test: load every version fixture, verify shape\n  static _runMigrationTests() {\n    const fixtures = { 1: { /* v1 shape */ }, 2: { /* v2 shape */ } };\n    for (const v of Object.keys(fixtures)) {\n      const result = SaveMgr._applyMigrations({ _ver: +v, ...fixtures[v] });\n      assert(result._ver === CURRENT_VER);\n      assert(result.player && result.player.loc);\n    }\n  }\n}
savemigrationversioninglocalstoragepersistenceซaveเวอร์ชัน

🤖 AI-Readable JSON for this phase

All 10 sub-phases of Game Patterns as JSON — perfect for AI agents.

{
  "phase": {
    "id": 4,
    "name": "Game Patterns",
    "icon": "🏛️",
    "tagline": "Patterns that make games addictive",
    "color": "#f97316",
    "slug": "game-patterns"
  },
  "sub_phases": [
    {
      "id": "4.1",
      "title": "Game Loop (60fps + Delta Time)",
      "icon": "⏱️",
      "description": "Update with dt seconds (NOT per-frame constants). Decouple sim Hz from render Hz via fixed-step accumulator.",
      "when_to_use": "ทุกเกมที่มี animation/movement/physics | Every game with animation, movement or physics; sims with time scale",
      "when_not_to_use": "Static page ไม่มี animation; < 10 entities; pure UI ที่ใช้ CSS transitions อย่างเดียว",
      "pros": [
        "Frame-rate independent — same physics outcome on 60Hz phone and 144Hz monitor because dt normalizes time",
        "Decoupled sim/render Hz — physics ticks at fixed 60Hz while render interpolates at any monitor refresh",
        "Pause/slow-mo trivial — multiply DT by 0 (pause) or 0.5 (slow-mo); no special code paths needed",
        "Deterministic replays — fixed DT + seeded RNG = byte-identical playback for netcode or sharing",
        "Tab-background safe — clamp dt to 0.25s prevents physics explosion when user returns from background tab"
      ],
      "cons": [
        "Accumulator math non-obvious — beginners copy-paste without understanding why spiral-of-death happens",
        "Multiple update calls per render wastes CPU on slow devices — 144Hz render + 60Hz sim = 2.4x physics calls",
        "Float drift over long sessions — hours 12+ see sub-millisecond desync, breaks frame-perfect games",
        "Time scaling breaks input if DT not normalized — character moves slower when paused mid-jump",
        "Interpolation adds 1-frame visual lag — visible at 30Hz render; competitive fighting games skip interpolation"
      ],
      "gotchas": [
        "ALWAYS clamp dt to ~0.25s — tab background return can deliver 5+ seconds dt = physics blow-up on first tick",
        "requestAnimationFrame pauses when tab hidden — never trust frame count for time, always use performance.now()",
        "Fixed timestep (Glenn Fiedler's pattern) prevents non-determinism across framerates — without it, replays differ",
        "Accumulator pattern: `while (acc >= DT) { update(DT); acc -= DT; }` — if overshoot, queue leftover for next frame",
        "Decimal time (ms) accumulates float error over 24h — use modular snapshots or reset delta periodically",
        "Audio scheduling must use AudioContext.currentTime not rAF — clock skew between audio and game thread possible",
        "Slow-mo: scale BOTH the realDt input AND the DT reference, else physics desyncs from rendered position",
        "First frame dt = 0 — guard with `if (last === 0) last = now` or physics does nothing on frame 1"
      ],
      "anti_patterns": [
        "Update everything per-frame without dt — character speed differs wildly on 60Hz vs 144Hz monitors",
        "Use setInterval instead of requestAnimationFrame — drifts, runs in background tabs, can't pause cleanly",
        "Clamp dt to 'make it look right' instead of fixing physics step — hides bug, breaks determinism",
        "Skip interpolation when rendering at fixed Hz — visible stutter every 16ms on 30Hz devices",
        "Trust wall-clock for game time — Date.now() jumps on NTP sync; use performance.now() monotonic clock"
      ],
      "prereqs": [],
      "next_steps": [
        "4.2",
        "4.3",
        "4.4",
        "4.5"
      ],
      "examples": [
        "stronghold-3d",
        "noodle-shop",
        "sims-lite",
        "street-fighter-thai"
      ],
      "snippet": "const DT=1/60;let acc=0,last=performance.now();function tick(){const now=performance.now();const dt=Math.min((now-last)/1000,0.25);last=now;acc+=dt*timeScale;while(acc>=DT){update(DT);acc-=DT;}render(acc/DT);requestAnimationFrame(tick)}",
      "snippet_full": "const DT = 1/60; // 60Hz fixed physics step\\nlet acc = 0, last = performance.now(), timeScale = 1;\\nfunction tick() {\\n  const now = performance.now();\\n  // Clamp dt: tab background return can be 5+ seconds\\n  const realDt = Math.min((now - last) / 1000, 0.25);\\n  last = now;\\n  // Slow-mo / pause: scale realDt input only, NOT the DT reference\\n  acc += realDt * timeScale;\\n  // Fixed-step physics: deterministic regardless of render Hz\\n  while (acc >= DT) { update(DT); acc -= DT; }\\n  // Interpolation factor: smooth render between physics ticks\\n  render(acc / DT);\\n  requestAnimationFrame(tick);\\n}\\nrequestAnimationFrame(tick); // bootstrap",
      "tags": [
        "loop",
        "fps",
        "delta-time",
        "fixed-timestep",
        "game-loop",
        "เกมลูป",
        "เฟรมเรท"
      ],
      "difficulty": "Intermediate",
      "time_to_learn": "2 hours",
      "docs_url": "https://gafferongames.com/post/fix_your_timestep/",
      "compare_with": [
        "4.2 ECS (system tick order matters)",
        "1.4 localStorage (debounce save on dt accumulation)"
      ]
    },
    {
      "id": "4.2",
      "title": "Entity-Component System (ECS)",
      "icon": "🧩",
      "description": "Entity (just an ID) + Component (pure data) + System (pure logic). Compose behaviors by mixing components.",
      "when_to_use": "Game engine, buff/debuff systems, particle systems, simulation > 100 entities | เกมที่ entity หลายร้อย+ และต้องการ mix behaviors แบบ runtime",
      "when_not_to_use": "< 100 entities with stable class hierarchy; one-off scripts; tiny prototypes where class inheritance is clearer",
      "pros": [
        "Composition > inheritance — add Burnable component to ANY entity (player, NPC, tree) without class hierarchy change",
        "Cache-friendly SoA storage — components in contiguous TypedArrays, CPU prefetch works, 10-100x faster than OOP",
        "Parallel systems — pure-function systems run on Web Workers without locks (Unity DOTS proves this at scale)",
        "Hot-swap behaviors — add/remove Shielded component at runtime, no recompile, designer-friendly inspector",
        "Reuse across archetypes — same Move system iterates player and enemy, no code duplication",
        "Deterministic for netcode — systems read all components, no method dispatch variance across machines"
      ],
      "cons": [
        "Boilerplate — every behavior = component type + system function + iteration code, 3 places to edit per change",
        "Debug trail scattered — entity 'the player' is 5 entries across component maps, not one object to inspect",
        "Premature for small games — < 100 entities: just use class instances, easier to grep, faster to ship",
        "Query complexity — 'find all entities with Position AND Velocity' requires iteration, not free",
        "Refactor cost — moving data between components breaks every save file that referenced the old structure",
        "Event passing awkward — entity A needs to talk to entity B across system boundaries, no direct method call"
      ],
      "gotchas": [
        "Components MUST be pure data — never put methods on component objects (Unity DOTS rule, breaks archetype SoA)",
        "Systems MUST be pure logic — read components, write components, no side effects outside assigned components",
        "Entity ID is opaque token — UUID or int, never reuse after delete (stale references in saves silently corrupt)",
        "Sparse set vs archetype storage — sparse flexible, archetype cache-fast (10-100x); Unity DOTS uses archetypes",
        "Don't store entity references in components — store IDs; entity refs break serialization and create cycles",
        "System execution order matters — Physics → Movement → Animation → Render → UI; explicit pipeline, not implicit",
        "Serialization = list components per entity — `[{id, comps: [Pos, Vel, Health]}]` round-trips through JSON",
        "Archetype churn is expensive — adding/removing a component moves entity to new archetype group; batch changes"
      ],
      "anti_patterns": [
        "Inheritance 'GameObject extends Entity' — defeats composition; go back to OOP if you need this",
        "Systems that touch DOM directly — side effects across ticks = race conditions, debug hell",
        "Components holding references to other entities — breaks serialization, creates GC cycles, refactor nightmare",
        "Method on component object — turns component into mini-class, defeats data-oriented design and SoA caching",
        "Single mega-system doing everything — splits physics/render coupling into god-object, hard to parallelize"
      ],
      "prereqs": [
        "4.1"
      ],
      "next_steps": [],
      "examples": [
        "stronghold-3d",
        "sims-lite",
        "mars-colony",
        "fps-cops-vs-robbers"
      ],
      "snippet": "class World{create(){return this.nextId++}add(id,t,d){(this.comps[t]=this.comps[t]||new Map).set(id,d)}tick(dt){this.systems.forEach(s=>s(this,dt))}} const w=new World();const p=w.create();w.add(p,'Pos',{x:0,y:0});w.add(p,'Vel',{dx:10,dy:0});w.systems.push((w,dt)=>{for(const[id,pos]of w.comps.Pos||[]){const v=w.comps.Vel?.get(id);if(v)pos.x+=v.dx*dt}});",
      "snippet_full": "class World {\\n  constructor() {\\n    this.nextId = 1;\\n    this.comps = new Map();   // type -> Map<id, data>\\n    this.systems = [];         // (world, dt) => void, ordered pipeline\\n  }\\n  create() { return this.nextId++; }\\n  add(id, type, data) {\\n    if (!this.comps.has(type)) this.comps.set(type, new Map());\\n    this.comps.get(type).set(id, data);\\n    return id;\\n  }\\n  get(type, id) { return this.comps.get(type)?.get(id); }\\n  // Explicit pipeline order — no implicit dependency guessing\\n  tick(dt) { for (const sys of this.systems) sys(this, dt); }\\n}\\nconst w = new World();\\nconst player = w.create();\\nw.add(player, 'Pos',    { x: 0, y: 0 });\\nw.add(player, 'Vel',    { dx: 10, dy: 0 });\\nw.add(player, 'Health', { hp: 100 });\\n// Move system: iterates only entities with BOTH Pos and Vel\\nw.systems.push((w, dt) => {\\n  for (const [id, pos] of w.comps.get('Pos') || []) {\\n    const vel = w.get('Vel', id);\\n    if (vel) { pos.x += vel.dx * dt; pos.y += vel.dy * dt; }\\n  }\\n});\\n// Render system: depends on Pos only\\nw.systems.push((w) => { for (const [id, pos] of w.comps.get('Pos') || []) draw(id, pos); });\\nw.tick(1/60);",
      "tags": [
        "ecs",
        "data-oriented",
        "composition",
        "entity-component",
        "architecture",
        "สถาปัตยกรรมเกม"
      ],
      "difficulty": "Advanced",
      "time_to_learn": "3 days",
      "docs_url": "",
      "compare_with": [
        "OOP inheritance (simpler for <100 entities)",
        "4.4 Behavior Trees (orthogonal; can be a system)"
      ]
    },
    {
      "id": "4.3",
      "title": "Pathfinding A*",
      "icon": "🗺️",
      "description": "Shortest path on grid via open/closed sets + admissible heuristic. Optimal when heuristic never overestimates.",
      "when_to_use": "NPC path, tower defense creep path, RTS unit move, tile-based game AI | เกมที่ entity เดินบน grid และต้องการ shortest path",
      "when_not_to_use": "Continuous world with smooth movement (use NavMesh), < 10 entities (BFS suffices), realtime 1000+ units (use flow fields)",
      "pros": [
        "Optimal with admissible heuristic — Manhattan 4-dir, Octile 8-dir; never returns longer path than true optimum",
        "Composable heuristics — swap Manhattan→Euclidean→Octile based on movement cost model without algorithm change",
        "JPS (Jump Point Search) — 2-10x faster by skipping symmetric neighbors, same optimality, same API",
        "Binary heap priority queue — openSet stays O(log n) per insert/extract vs naive array O(n)",
        "Cache-able per query — re-plan only on grid change, not every move; flow field pre-compute for 1000+ units",
        "Tie-breaking for speed — prefer goal-direction when f-scores tie, 2-3x speedup without losing optimality"
      ],
      "cons": [
        "Memory O(n²) worst case — 1000x1000 grid = 1M nodes × 100B = 100MB; chunk grid or use hierarchical A*",
        "Re-planning on dynamic obstacles expensive — D* Lite handles but adds complexity; consider flow fields for static",
        "Not smooth on grid — paths have 8-way zigzag, need string-pulling / funnel algorithm post-process",
        "CPU spike on first plan — pre-compute flow fields for 1000+ unit RTS games (Tom Clancy's EndWar pattern)",
        "Heuristic tuning is project-specific — wrong heuristic = non-optimal path or 10x slower search",
        "Tile cost changes break admissibility — cost=2 tile + Manhattan = overestimates goal, returns non-optimal"
      ],
      "gotchas": [
        "Manhattan for 4-dir movement — Octile `(D*(dx+dy) + (D2-2D)*min(dx,dy))` for 8-dir where D2=√2",
        "Binary heap (min-heap) for openSet — `Array.shift()` is O(n), heap extract is O(log n); 100x difference at 10k nodes",
        "f = g + h; g = actual cost so far; h = estimated cost to goal; h must NEVER overestimate (admissible)",
        "Closed set ≠ 'visited' — it's 'we found OPTIMAL g for this node'; revisit if better g found later",
        "Tie-breaking: prefer nodes with smaller h when f ties — pulls search toward goal, 2-3x speedup",
        "Path reconstruction via parent pointers — null parent = start; chain back to start to get path array",
        "Diagonal corner-cutting forbidden when EITHER adjacent cardinal is blocked — prevents squeezing through walls",
        "Reset closed set between queries — reusing across queries with different goals = suboptimal paths"
      ],
      "anti_patterns": [
        "Store node object in openSet without hashing — O(n²) linear scan per pop, kills performance at 1000+ nodes",
        "Use heuristic = 0 — becomes Dijkstra, 5-10x slower than A* with good heuristic",
        "Forget to rebuild path via parent pointers — silent null returns, NPC stands still with no error",
        "Re-plan full A* every frame — use path cache, invalidate only on map change; 100x perf improvement",
        "Misuse Euclidean on grid — overestimates for 8-dir movement, returns SUBOPTIMAL path (silent bug)"
      ],
      "prereqs": [
        "4.1"
      ],
      "next_steps": [
        "4.4",
        "4.5"
      ],
      "examples": [
        "stronghold-3d",
        "noodle-shop",
        "sims-lite",
        "mars-colony",
        "street-fighter-thai"
      ],
      "snippet": "function aStar(g,s,goal){const h=(x,y)=>Math.abs(x-goal.x)+Math.abs(y-goal.y);const open=[{x:s.x,y:s.y,g:0,f:h(s.x,s.y)}];const closed=new Set();while(open.length){let bi=0;for(let i=1;i<open.length;i++)if(open[i].f<open[bi].f)bi=i;const c=open.splice(bi,1)[0];if(c.x===goal.x&&c.y===goal.y)return c;closed.add(c.x+','+c.y);for(const[dx,dy]of[[1,0],[-1,0],[0,1],[0,-1]]){const nx=c.x+dx,ny=c.y+dy;if(g[ny]?.[nx])continue;if(closed.has(nx+','+ny))continue;open.push({x:nx,y:ny,g:c.g+1,f:c.g+1+h(nx,ny),p:c})}}return null}",
      "snippet_full": "// Min-heap: openSet stays O(log n) vs Array.shift O(n)\\nclass MinHeap {\\n  constructor() { this.h = []; }\\n  push(x) { this.h.push(x); this._up(this.h.length - 1); }\\n  pop() {\\n    const top = this.h[0]; const last = this.h.pop();\\n    if (this.h.length) { this.h[0] = last; this._down(0); }\\n    return top;\\n  }\\n  _up(i) { while (i > 0) { const p = (i-1) >> 1; if (this.h[p].f <= this.h[i].f) break; [this.h[p], this.h[i]] = [this.h[i], this.h[p]]; i = p; } }\\n  _down(i) { const n = this.h.length; while (true) { let s = i; const l = 2*i+1, r = 2*i+2; if (l<n && this.h[l].f<this.h[s].f) s=l; if (r<n && this.h[r].f<this.h[s].f) s=r; if (s===i) break; [this.h[s], this.h[i]] = [this.h[i], this.h[s]]; i = s; } }\\n}\\nfunction aStar(grid, start, goal) {\\n  // Octile heuristic: 8-dir with diagonal cost = sqrt(2)\\n  const D = 1, D2 = Math.SQRT2;\\n  const h = (x, y) => { const dx = Math.abs(x - goal.x), dy = Math.abs(y - goal.y); return D * (dx + dy) + (D2 - 2*D) * Math.min(dx, dy); };\\n  const open = new MinHeap();\\n  const gScore = new Map();\\n  const key = (n) => n.x + ',' + n.y;\\n  gScore.set(key(start), 0);\\n  open.push({ x: start.x, y: start.y, g: 0, f: h(start.x, start.y), parent: null });\\n  const dirs = [[1,0,D],[-1,0,D],[0,1,D],[0,-1,D],[1,1,D2],[1,-1,D2],[-1,1,D2],[-1,-1,D2]];\\n  while (open.h.length) {\\n    const cur = open.pop();\\n    if (cur.x === goal.x && cur.y === goal.y) {\\n      const path = []; let n = cur;\\n      while (n) { path.unshift({ x: n.x, y: n.y }); n = n.parent; }\\n      return path;\\n    }\\n    for (const [dx, dy, cost] of dirs) {\\n      const nx = cur.x + dx, ny = cur.y + dy;\\n      if (nx < 0 || ny < 0 || ny >= grid.length || nx >= grid[0].length) continue;\\n      if (grid[ny][nx] === 1) continue; // wall\\n      const tentG = cur.g + cost;\\n      const k = nx + ',' + ny;\\n      // Only update if this path is BETTER than any previous\\n      if (tentG < (gScore.get(k) ?? Infinity)) {\\n        gScore.set(k, tentG);\\n        open.push({ x: nx, y: ny, g: tentG, f: tentG + h(nx, ny), parent: cur });\\n      }\\n    }\\n  }\\n  return null; // unreachable\\n}",
      "tags": [
        "pathfinding",
        "ai",
        "a-star",
        "graph",
        "algorithm",
        "pathfinding",
        "ai-pathfinding"
      ],
      "difficulty": "Intermediate",
      "time_to_learn": "1 day",
      "docs_url": "https://en.wikipedia.org/wiki/A*_search_algorithm",
      "compare_with": [
        "Dijkstra (slower but always optimal)",
        "JPS Jump Point Search (10x faster)",
        "Flow fields (better for 1000+ units)"
      ]
    },
    {
      "id": "4.4",
      "title": "Behavior Trees (NPC AI)",
      "icon": "🌳",
      "description": "Hierarchical nodes: Selector (OR), Sequence (AND), Decorator. Designer-editable visual tree for NPC decisions.",
      "when_to_use": "NPC AI หลาย actions, Boss AI phases, Squad tactics, complex reactive behavior | Boss/เควส NPC ที่มีหลาย state",
      "when_not_to_use": "< 3 actions (FSM simpler), single-script AI, simple state machine suffices, performance-critical tick-every-frame for 10k NPCs",
      "pros": [
        "Visual structure = designer edits without code — JSON export/import, Unreal/Unity plugins, no engineering round-trip",
        "Composable nodes — Inverter/Repeater/Cooldown/UntilFail decorators instead of writing each pattern",
        "Reuse across 100 NPC archetypes — same 'Flee' subtree in goblin and dragon via shared subtree reference",
        "Event-driven beats tick-every-frame — 100x less CPU when NPC idle (subscribe to OnTakeDamage, not tick)",
        "Decoupled from entity data — blackboard pattern separates tree structure from entity state",
        "Reactive to external events — 'OnSeeEnemy' instantly triggers attack branch without polling"
      ],
      "cons": [
        "Tick overhead even when no change — solution: external trigger list + tick only on event subscription",
        "Stack depth errors — recursion depth = tree depth, > 1000 levels crashes; iterative traversal for deep trees",
        "State machine in disguise — pure tick-every-frame trees are FSM with extra steps; events needed for value",
        "Debugging harder than FSM — 'why did goblin walk into wall?' = trace parent chain through tree state",
        "Designer learning curve — Sequence vs Selector semantics ≠ intuitive AND/OR; 1-day training needed",
        "Blackboard naming conflicts — two NPC trees reading same 'target' key cross-contaminate state"
      ],
      "gotchas": [
        "Selector (OR): tick children left-to-right, first SUCCESS/RUNNING wins; if all FAIL → return FAILURE",
        "Sequence (AND): tick children left-to-right, any FAILURE/RUNNING short-circuits; all SUCCESS → SUCCESS",
        "Decorators wrap single child: Inverter (Success↔Failure), Repeater (loop N times), UntilFail (loop until FAIL)",
        "Action returns 3 states: Success / Failure / Running — Running means 'still working, tick me next frame'",
        "Event-driven > tick-every-frame — subscribe to events, queue triggers, tick on wake; 100x less CPU",
        "Blackboard = shared state — namespace by entity or global; conflicts are silent bugs, version key prefixes help",
        "Priority order in Selector matters — cheap checks FIRST (cooldown check before pathfind), 10x speedup",
        "Re-entry mid-tick breaks — Sequence returns Running then immediate re-tick = state corruption; protect with mutex"
      ],
      "anti_patterns": [
        "Tick the tree every frame for ALL NPCs regardless — wastes CPU; use event trigger + wake list",
        "Action node reads entity state directly — couples tree to data shape; use blackboard for testability",
        "Sequence with no Failure path — entire NPC stuck forever once first child fails (deadlock)",
        "Same tree INSTANCE shared across entities — blackboard reads cross-contaminate; clone or per-entity BB",
        "Decorator recursion without base case — Repeater with no Failure returns = infinite loop, tab freezes"
      ],
      "prereqs": [
        "4.1",
        "4.3"
      ],
      "next_steps": [
        "4.7",
        "4.8"
      ],
      "examples": [
        "stronghold-3d",
        "sims-lite",
        "mars-colony",
        "street-fighter-thai"
      ],
      "snippet": "class Selector{tick(c){for(const ch of this.ch){const r=ch.tick(c);if(r==='success'||r==='running')return r}return 'failure'}} class Sequence{tick(c){for(const ch of this.ch){const r=ch.tick(c);if(r==='failure'||r==='running')return r}return 'success'}} const bt=new Selector([new Sequence([new Cond(c=>c.hp<30),new Action(c=>{c.state='flee';return 'success'})]),new Action(c=>{c.state='work';return 'success'})]);",
      "snippet_full": "// 3-state result: Success / Failure / Running\\nconst S = 'success', F = 'failure', R = 'running';\\n// Composite OR: try children until one succeeds\\nclass Selector {\\n  constructor(children) { this.children = children; }\\n  tick(ctx) {\\n    for (const c of this.children) {\\n      const r = c.tick(ctx);\\n      if (r === S || r === R) return r; // short-circuit\\n    }\\n    return F;\\n  }\\n}\\n// Composite AND: all children must succeed\\nclass Sequence {\\n  constructor(children) { this.children = children; }\\n  tick(ctx) {\\n    for (const c of this.children) {\\n      const r = c.tick(ctx);\\n      if (r === F || r === R) return r; // short-circuit\\n    }\\n    return S;\\n  }\\n}\\n// Decorator: invert child's result (Success<->Failure)\\nclass Inverter {\\n  constructor(child) { this.child = child; }\\n  tick(ctx) {\\n    const r = this.child.tick(ctx);\\n    return r === S ? F : r === F ? S : R;\\n  }\\n}\\n// Leaf: predicate\\nclass Cond {\\n  constructor(fn) { this.fn = fn; }\\n  tick(ctx) { return this.fn(ctx) ? S : F; }\\n}\\n// Leaf: action; can return Running for multi-tick ops like pathfind\\nclass Action {\\n  constructor(fn) { this.fn = fn; }\\n  tick(ctx) { return this.fn(ctx) || S; }\\n}\\n// Villager AI: flee if low HP, else eat if hungry, else work\\nconst villagerBT = new Selector([\\n  new Sequence([ new Cond(c => c.hp < 30), new Action(c => { c.state = 'flee'; return S; }) ]),\\n  new Sequence([ new Cond(c => c.hunger > 80), new Action(c => { c.state = 'eat';  return S; }) ]),\\n  new Inverter(new Cond(c => c.atWork)),\\n  new Action(c => { c.state = 'work'; return S; })\\n]);\\n// Event-driven tick: only call villagerBT.tick when ctx changes",
      "tags": [
        "ai",
        "behavior-tree",
        "npc",
        "decision",
        "bt",
        "ai-behavior",
        "npc-ai"
      ],
      "difficulty": "Advanced",
      "time_to_learn": "1 week",
      "docs_url": "https://www.gamedeveloper.com/programming/behavior-trees-for-ai-how-they-work",
      "compare_with": [
        "FSM (simpler for <5 states)",
        "GOAP planning (more flexible but slower)",
        "Utility AI (numerical scoring, harder to visualize)"
      ]
    },
    {
      "id": "4.5",
      "title": "Procedural Generation",
      "icon": "🎲",
      "description": "Random content via seeded RNG. Same seed = identical output. Combine algorithms for natural-feeling variety.",
      "when_to_use": "Roguelike levels, sandbox worlds, test data, daily challenges, shareable seeds | เกมที่ต้องการ infinite content",
      "when_not_to_use": "Story-driven linear game, hand-crafted levels required, art direction needs precise control",
      "pros": [
        "Infinite content without storage — 1KB seed generates GB of world; procedural saves disk, ships bigger games",
        "Shareable seeds — 'try seed 8675309' = reproducible competition (Spelunky Daily Challenge, No Man's Sky portals)",
        "Always-fresh gameplay — no memorization reduces boredom in roguelikes; 1000 hours vs 10 hours",
        "Combined algorithms — Perlin + Voronoi + Poisson gives natural-feeling output (terrain/biomes/trees)",
        "Easy unit testing — fixed seed = deterministic output = regression-testable; CI golden fixtures",
        "Player perception of 'more game' — 1000 generated levels feel like 1000 hand-crafted to most players"
      ],
      "cons": [
        "Quality variance — bad seeds produce broken/unfun levels; validation pass mandatory, not optional",
        "Combinatorial explosion — 100 rooms × 10 templates = 1000 cache slots; LRU cache or compute-on-demand",
        "AI-slop feel — humans detect patterns in 5-10 minutes; pure noise feels hollow, needs curation",
        "Designer override needed — hand-crafted set pieces must override algorithm (boss rooms, story beats)",
        "Performance — 100k tile Perlin = 50ms freeze on slow phones; pre-compute or Web Worker offload",
        "Seed bug reproducibility — wrong RNG = different output, hard to debug without exact seed string"
      ],
      "gotchas": [
        "ALWAYS seed RNG (mulberry32 / sfc32) — Math.random() is unseedable in browsers; same seed MUST give same output",
        "Validate generated output — connected rooms? spawn reachable? boss arena exists? no isolated islands?",
        "Combine techniques: Perlin for heightmap + Poisson for tree placement + Voronoi for biome boundaries",
        "Layer noise frequencies — 1 octave smooth, 8 octaves realistic but 8x cost; tune to content type",
        "Player-progression fairness — generation must adapt to player level (Angry Birds stage difficulty curve)",
        "Determinism needs consistent iteration order — never Set, always sorted Array or insertion-ordered Map",
        "RNG state pollution — each generator reseeds with own seed, not reuse parent RNG (coupling bug)",
        "Output quality check: count dead-ends, isolated islands, unreachable regions; regenerate if fail"
      ],
      "anti_patterns": [
        "Pure Math.random() — unseedable, breaks shareable seeds, no reproducible bugs",
        "Single algorithm for everything — samey output, players spot pattern in minutes; layer techniques",
        "No validation pass — ship broken levels; players find seeds that crash or trap them",
        "Generation on render thread — main thread freezes on Perlin compute; Web Worker or pre-compute",
        "Same RNG instance threaded through parallel async — race conditions in output, hard-to-repro bugs"
      ],
      "prereqs": [
        "4.1"
      ],
      "next_steps": [],
      "examples": [
        "stronghold-3d",
        "mars-colony",
        "sims-lite",
        "fps-cops-vs-robbers"
      ],
      "snippet": "function mulberry32(s){return function(){let t=s+=0x6D2B79F5;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296}} const rng=mulberry32(seed); const map=Array.from({length:30},()=>Array.from({length:50},()=>rng()<0.45?1:0));",
      "snippet_full": "// mulberry32: 5-line seedable RNG, 2^32 period, good for games\\nfunction mulberry32(seed) {\\n  return function() {\\n    let t = (seed += 0x6D2B79F5);\\n    t = Math.imul(t ^ (t >>> 15), t | 1);\\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\\n  };\\n}\\n// 2D value noise: cheap, deterministic per (x,y) for given seed\\nfunction valueNoise(seed, x, y) {\\n  const xi = Math.floor(x), yi = Math.floor(y);\\n  const xf = x - xi, yf = y - yi;\\n  const a = mulberry32(seed + xi * 7  + yi * 13)();\\n  const b = mulberry32(seed + (xi+1)*7 + yi * 13)();\\n  const c = mulberry32(seed + xi * 7  + (yi+1)*13)();\\n  const d = mulberry32(seed + (xi+1)*7 + (yi+1)*13)();\\n  // smoothstep interpolation: smooth derivatives at integer boundaries\\n  const u = xf * xf * (3 - 2 * xf);\\n  const v = yf * yf * (3 - 2 * yf);\\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;\\n}\\n// 40x60 cave: multi-octave noise thresholded to wall/floor\\nfunction generateCave(seed, w = 40, h = 60) {\\n  const map = Array.from({ length: h }, (_, y) =>\\n    Array.from({ length: w }, (_, x) => {\\n      let n = 0, amp = 1, freq = 0.05;\\n      // 4 octaves: large features + medium + small + detail\\n      for (let o = 0; o < 4; o++) {\\n        n += valueNoise(seed + o * 1000, x * freq, y * freq) * amp;\\n        amp *= 0.5; freq *= 2;\\n      }\\n      return n > 0.5 ? 1 : 0; // 1 = wall, 0 = floor\\n    })\\n  );\\n  // Validation: flood-fill from (1,1); if reachable < 30% of floors, regenerate\\n  return validate(map) ? map : generateCave(seed + 1, w, h);\\n}\\n// Usage: same seed = byte-identical output, share-able\\nconst cave = generateCave(8675309);",
      "tags": [
        "procedural",
        "random",
        "pcg",
        "seed",
        "noise",
        "perlin",
        "สุ่ม",
        "มัลเบอร์รี่"
      ],
      "difficulty": "Intermediate",
      "time_to_learn": "3 days",
      "docs_url": "https://github.com/MauledbyYermi/PCG-Book",
      "compare_with": [
        "Wave Function Collapse (constraint-based, better for tilesets)",
        "Hand-authored levels (quality ceiling higher but 100x dev cost)"
      ]
    },
    {
      "id": "4.6",
      "title": "Inventory System (Slot/Stack)",
      "icon": "🎒",
      "description": "Slot-based array with maxStack per item, stack-first then empty-slot fill, Object.freeze templates.",
      "when_to_use": "RPG inventory, tycoon resource stock, survival crafting, equipment loadout | เกมที่ player เก็บของได้หลายชิ้น",
      "when_not_to_use": "Single-item pickup, key-only progression, simple carry counter, no UI for items",
      "pros": [
        "Clear state — `slots[i] = {itemId, qty}` debuggable in DevTools, JSON-serializable for save",
        "Bounded memory — max slots cap prevents OOM from drop-spam exploit; 20 slots × 50B = 1KB",
        "Stack semantics match player mental model — 99 potions = 1 stack slot, not 99 slots",
        "Easy serialization — `JSON.stringify(slots)` round-trips through localStorage, no Map/Set gotchas",
        "Drop/swap semantics obvious — null slot = empty, swap = exchange pointers, partial-stack split",
        "UI flexibility — same data drives grid, list, hotbar, mini-map representations without change"
      ],
      "cons": [
        "Slot limit feels artificial — 20-slot bag + 100 herbs = frustrating without 'upgrade bag' mechanic",
        "Partial-stack logic verbose — 50 arrows into 2 stacks of 30+20 + empty slot = 3 branches to handle",
        "Drag-drop on touch unreliable — HTML5 DnD fires dragend on iOS inconsistently; need click-to-move fallback",
        "Object.freeze hurts perf in hot loops — copy-on-read vs reference trade-off; freeze templates only",
        "Sort/filter requires stable sort — Array.sort mutates + locale-aware; deep copy before sort",
        "Crafting dependencies — recipe needs 3x item X but inventory has 1 stack of 30 = ambiguous 'remove 3' vs 'split'"
      ],
      "gotchas": [
        "Always Object.freeze the item catalog — mutating ITEMS.potion.heal corrupts ALL inventories referencing it",
        "HTML5 DnD: dragstart requires event.dataTransfer.setData — iOS Safari fires twice, may need dedup",
        "Max stack per item type — gold stacks 999, swords stack 1, configurable per template (not hardcoded)",
        "Auto-stack on add — check existing stacks first before consuming empty slots; 99 herb add = no new slot",
        "Partial fill: qty=150, maxStack=99 → fills stack A (99), starts stack B (51), succeeds with 2 slots used",
        "Drop semantics: held item in cursor slot, not in inventory until drop event fires (atomic)",
        "Sort stability: compare by itemId first, rarity second, qty desc third — localeCompare for names",
        "Save serialization: array of slots smaller than Map — always array for storage, convert at runtime if needed"
      ],
      "anti_patterns": [
        "Mutate the global ITEMS catalog at runtime — all inventories reference same object, one bug spreads",
        "Use plain object as slot — `{itemId, qty}` lacks maxStack metadata, separate lookup fragile",
        "Trust HTML5 DnD without fallbacks — mobile web fails 20% of time, need click-to-move or touch handlers",
        "One slot per item, no stacking — UI wastes 20 slots on 99 herbs; player rage-quits",
        "Allow negative qty on subtract — silent overflow corrupts save file; clamp at 0 minimum"
      ],
      "prereqs": [
        "4.1"
      ],
      "next_steps": [],
      "examples": [
        "stronghold-3d",
        "sims-lite",
        "noodle-shop",
        "street-fighter-thai"
      ],
      "snippet": "const ITEMS=Object.freeze({potion:{maxStack:99,icon:'🧪'},sword:{maxStack:1,icon:'⚔️'},arrow:{maxStack:99,icon:'➶'}});class Inv{constructor(n=20){this.slots=new Array(n).fill(null)}add(id,q=1){const t=ITEMS[id];for(const s of this.slots)if(s?.id===id&&s.q<t.maxStack){const take=Math.min(q,t.maxStack-s.q);s.q+=take;q-=take;if(q<=0)return true}for(let i=0;i<this.slots.length&&q>0;i++)if(!this.slots[i]){this.slots[i]={id,q:Math.min(q,t.maxStack)};q-=this.slots[i].q}return q===0}}",
      "snippet_full": "// Item template IMMUTABLE — all inventories reference same object\\nconst ITEMS = Object.freeze({\\n  potion: { name: 'Potion',   maxStack: 99, icon: '🧪' },\\n  sword:  { name: 'Sword',    maxStack: 1,  icon: '⚔️' },\\n  arrow:  { name: 'Arrow',    maxStack: 99, icon: '➶' },\\n  wood:   { name: 'Wood',     maxStack: 99, icon: '🪵' }\\n});\\nclass Inventory {\\n  constructor(maxSlots = 20) {\\n    this.max = maxSlots;\\n    this.slots = new Array(maxSlots).fill(null);\\n  }\\n  add(itemId, qty = 1) {\\n    const tpl = ITEMS[itemId];\\n    if (!tpl) throw new Error('Unknown item: ' + itemId);\\n    // Phase 1: fill existing PARTIAL stacks first (auto-merge)\\n    for (const s of this.slots) {\\n      if (s && s.itemId === itemId && s.qty < tpl.maxStack) {\\n        const take = Math.min(qty, tpl.maxStack - s.qty);\\n        s.qty += take; qty -= take;\\n        if (qty <= 0) return true;\\n      }\\n    }\\n    // Phase 2: empty slots for new stacks\\n    for (let i = 0; i < this.slots.length && qty > 0; i++) {\\n      if (!this.slots[i]) {\\n        const put = Math.min(qty, tpl.maxStack);\\n        this.slots[i] = { itemId, qty: put };\\n        qty -= put;\\n      }\\n    }\\n    return qty === 0; // false = some items didn't fit\\n  }\\n  remove(itemId, qty = 1) {\\n    let need = qty;\\n    // Reverse-iterate: remove from newest stacks first (LIFO)\\n    for (let i = this.slots.length - 1; i >= 0 && need > 0; i--) {\\n      const s = this.slots[i];\\n      if (s && s.itemId === itemId) {\\n        const take = Math.min(need, s.qty);\\n        s.qty -= take; need -= take;\\n        if (s.qty === 0) this.slots[i] = null;\\n      }\\n    }\\n    return need === 0;\\n  }\\n  // Stable sort: itemId then rarity then qty desc; nulls last\\n  sort() {\\n    this.slots.sort((a, b) => {\\n      if (!a && !b) return 0;\\n      if (!a) return 1; if (!b) return -1;\\n      if (a.itemId !== b.itemId) return a.itemId.localeCompare(b.itemId);\\n      return b.qty - a.qty;\\n    });\\n  }\\n}\\n// Usage: const inv = new Inventory(20); inv.add('arrow', 150); // auto-splits into 2 stacks",
      "tags": [
        "inventory",
        "slot",
        "stack",
        "rpg",
        "item",
        "ไอเทม",
        "ช่องเก็บของ"
      ],
      "difficulty": "Intermediate",
      "time_to_learn": "4 hours",
      "docs_url": "",
      "compare_with": [
        "Tetris-style tetris inventory (fixed shape)",
        "Tetris/tetromino-bag (Tetris-like shape constraints)",
        "Weight-based (no slots, total weight cap)"
      ]
    },
    {
      "id": "4.7",
      "title": "Dialogue System (Branching)",
      "icon": "💬",
      "description": "JSON tree of nodes, conditions = function refs evaluated lazily, side effects on choice.",
      "when_to_use": "NPC dialogues, quest giver text, tutorial steps, branching story | เกมที่มี NPC คุยกับ player และเลือกตอบได้",
      "when_not_to_use": "Linear monologue, single cutscene, loading screen tips, system messages",
      "pros": [
        "Writers edit JSON — no engineer round-trip per line change; non-programmers ship content",
        "Localization trivial — `data[locale][nodeId].text` swap per language; 10x faster than string extraction",
        "Variable substitution — `{{playerName}}` resolves from blackboard; no template engine needed",
        "Conditions as function refs — `{condition: c => c.hasQuest('dragon')}` evaluated lazily at display time",
        "Save minimal — just `{currentId, history[]}` round-trips full conversation state in <100B",
        "Branching depth matches JSON nesting — designers see tree structure visually, no mental translation"
      ],
      "cons": [
        "State management — back button must pop history stack, or player stuck mid-conversation",
        "Variable interpolation risk — raw text injection = XSS if text from user; escape HTML in renderText",
        "Memory: deep JSON tree parses each conversation; 1000-node tree = 500KB; lazy-load by chapter",
        "Side effects (giveItem, startQuest) scattered in choices — hard to audit, hard to test in isolation",
        "No undo of destructive choice — chose 'kill NPC' without save = reload required; checkpoint system needed",
        "Localization drift — `start` updated in EN, missing in TH silently; lint script catches missing keys"
      ],
      "gotchas": [
        "Unique ID per node — auto-rename `node_001`, `node_002`; never trust author-provided IDs (typos)",
        "Save state = `{currentId, history: [n1, n2, n3]}` — minimal payload; reset history on conversation end",
        "Conditions evaluated LAZILY — `fn(blackboard)` runs at choice-display time, not parse time (fresh state)",
        "Speaker attribution: each node has `speaker: 'npc'|'narrator'|'player'` for bubble color/position",
        "Typewriter effect needs `await sleep(30)` per char — frame-budget on long lines (100 chars = 3s)",
        "Variable interpolation: `{{player.name}}` lookup; escape HTML to prevent XSS (`<` → `&lt;`)",
        "Endless loop detection: `currentId === lastChoice.nextId` cycle guard, force-progress after N",
        "Localization keys not nested in `text` — `node.text` is final string, `node.textKey` is i18n lookup key"
      ],
      "anti_patterns": [
        "Hardcode dialogue in JS strings — writers can't edit without redeploy; productivity killer",
        "Skip history stack — 'back' button impossible to implement, players rage-quit",
        "Evaluate conditions at JSON parse time — side effects fire BEFORE player sees choice",
        "One mega-tree for entire game — 10,000 nodes slow to parse, slow to grep, merge conflicts",
        "Mutate JSON tree at runtime — save file references by ID, but ID now means different text (silent bug)"
      ],
      "prereqs": [
        "4.1"
      ],
      "next_steps": [
        "4.8"
      ],
      "examples": [
        "stronghold-3d",
        "sims-lite",
        "mars-colony",
        "realm-of-heroes"
      ],
      "snippet": "const dlg={start:{speaker:'npc',text:'Hi {{name}}',choices:[{text:'Buy',next:'shop',cond:c=>c.gold>=5},{text:'Bye',next:'end'}]},shop:{speaker:'npc',text:'Potions 5g',choices:[{text:'Buy 1',next:'bought',effect:c=>{c.gold-=5;c.pots=(c.pots||0)+1}}]},end:{speaker:'npc',text:'Bye'}};class Run{constructor(t,bb){this.t=t;this.bb=bb;this.id='start';this.h=[]}get cur(){return this.t[this.id]}choose(i){const c=this.cur.choices[i];this.h.push(this.id);if(c.effect)c.effect(this.bb);this.id=c.next}back(){this.id=this.h.pop()||'start'}}",
      "snippet_full": "// Dialogue as JSON: writers edit, code consumes\\nconst dialogue = {\\n  start: {\\n    speaker: 'npc', text: 'Welcome, {{playerName}}.',\n    choices: [\\n      { text: 'Buy potions', next: 'shop', condition: c => c.gold >= 5 },\\n      { text: 'Tell me about dragons', next: 'dragon_lore' },\\n      { text: 'Goodbye', next: 'end' }\\n    ]\\n  },\\n  shop: {\\n    speaker: 'npc', text: 'Potions are 5 gold each.',\\n    choices: [\\n      { text: 'Buy one', next: 'bought', effect: c => { c.gold -= 5; c.potions = (c.potions||0) + 1; } },\\n      { text: 'Back', next: 'start' }\\n    ]\\n  },\\n  dragon_lore: {\\n    speaker: 'npc', text: 'They nest in the north mountains.',\\n    choices: [{ text: 'Thanks', next: 'start' }]\\n  },\\n  bought: { speaker: 'npc', text: 'Here you go!', choices: [{ text: 'Thanks', next: 'start' }] },\\n  end:    { speaker: 'npc', text: 'Farewell.' }\\n};\\nclass DialogueRunner {\\n  constructor(tree, blackboard) {\\n    this.tree = tree; this.bb = blackboard;\\n    this.currentId = 'start'; this.history = [];\\n  }\\n  get current() { return this.tree[this.currentId]; }\\n  // Filter choices by condition at RENDER time (lazy eval)\\n  get availableChoices() {\\n    return (this.current.choices || [])\\n      .map((c, i) => ({ c, i }))\\n      .filter(({ c }) => !c.condition || c.condition(this.bb));\\n  }\\n  choose(index) {\\n    const choice = this.current.choices[index];\\n    this.history.push(this.currentId);\\n    // Side effects fire on PICK, not on display\\n    if (choice.effect) choice.effect(this.bb);\\n    this.currentId = choice.next;\\n    return this.currentId !== 'end';\\n  }\\n  back() { return (this.currentId = this.history.pop()) || 'start'; }\\n  // XSS-safe interpolation: escape {{playerName}} lookup\\n  renderText(text) {\\n    return text.replace(/\\{\\{(\\w+(?:\\.\\w+)*)\\}\\}/g, (_, path) => {\\n      const v = path.split('.').reduce((o, k) => o?.[k], this.bb);\\n      const esc = String(v == null ? '' : v).replace(/[<>&]/g, c =>\\n        ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' })[c]);\\n      return esc;\\n    });\\n  }\\n}",
      "tags": [
        "dialogue",
        "npc",
        "branching",
        "json-tree",
        "localization",
        "บทสนทนา",
        "ไดอะล็อก"
      ],
      "difficulty": "Intermediate",
      "time_to_learn": "3 hours",
      "docs_url": "",
      "compare_with": [
        "Yarn Spinner / Ink markup (designer-friendly syntax)",
        "YAML linear scripts (no branching)",
        "Visual novel engines (Ren'Py/Twine)"
      ]
    },
    {
      "id": "4.8",
      "title": "Quest System",
      "icon": "📜",
      "description": "Mission = objective + progress + reward. ONE central trigger function mutates all quest state.",
      "when_to_use": "RPG/tycoon/MMO with missions, daily challenges, tutorial steps, milestones | เกมที่ player ทำภารกิจเพื่อ reward",
      "when_not_to_use": "Linear tutorial with no completion tracking, story-only game with no side objectives, single-objective games",
      "pros": [
        "Engagement loop — short quests = dopamine hits = retention metric, proven across MMOs and mobile games",
        "Centralized trigger — ONE `updateQuestProgress(type, amount)` updates 50 quests, no scattered logic",
        "Reward immediacy — gold/exp given on `complete()` not on next scene load, players notice delay otherwise",
        "Composable objectives — `gather_wood` + `kill_orc` chain to `defeat_boss` via prereqs field",
        "UI auto-render — `activeQuests.map(q => renderHUD(q))` always in sync with state, no manual sync",
        "Analytics-friendly — `analytics.track('quest_complete', {id, time})` metrics out of box for tuning"
      ],
      "cons": [
        "State per quest — 100 active quests × 10 fields = memory footprint; compact schema needed",
        "Trigger coordination — `kill_orc` fires from combat, gathering from collection, must debounce duplicate fires",
        "Save/load serialization complexity — Map<id, Quest> needs toJSON/fromJSON, version migration on schema change",
        "Race conditions — two simultaneous triggers (pickup + quest complete) = double reward if not atomic",
        "Prerequisite chain explosion — quest A needs B needs C needs D = linear lock-in, frustrating if stuck",
        "Failure UX — player can't complete quest, no error message, just stuck; need progress hint UI"
      ],
      "gotchas": [
        "Central trigger: `updateQuestProgress(type, amount)` is the ONLY function that mutates quest state",
        "Reward MUST fire IMMEDIATE — don't batch until quest turn-in, players notice delay = bad UX",
        "Quest type enum: 'gather', 'kill', 'deliver', 'talk', 'reach' — single string per quest, dispatch in switch",
        "Progress capped at target — `Math.min(target, progress + amount)` prevents overflow on event spam",
        "Auto-complete when progress >= target — single check in updateQuestProgress, no race condition possible",
        "Quest chains: `prereqs: ['quest_id_1', 'quest_id_2']` blocks UI button until done; gate accept()",
        "Save format: Array.from(activeQuests), not Map — JSON-friendly; load: `new Map(loaded.map(q => [q.id, q]))`",
        "Side objectives (optional) — separate `bonus` field, doesn't block main quest completion (engagement boost)"
      ],
      "anti_patterns": [
        "Update quest state from 10 different places — race conditions, double-credit bugs, debug nightmare",
        "Reward on next scene load — player confused if gold doesn't appear; trust lost, churn up",
        "Linear quest chains with no alternative — 1 stuck quest = entire game blocked; offer 2-3 parallel paths",
        "No progress indicator — 'kill 5 orcs' with no counter = player forgets they have quest, abandons",
        "Hardcoded quest IDs in code — refactor breaks save files; use string const table exported from data"
      ],
      "prereqs": [
        "4.7"
      ],
      "next_steps": [
        "4.9"
      ],
      "examples": [
        "stronghold-3d",
        "sims-lite",
        "mars-colony",
        "realm-of-heroes"
      ],
      "snippet": "function updateQuestProgress(type,amount=1){for(const[id,q]of active)if(q.type===type){q.progress=Math.min(q.target,q.progress+amount);if(q.progress>=q.target&&!q.done)complete(id)}} updateQuestProgress('gather_wood',5);updateQuestProgress('kill_orc',1);",
      "snippet_full": "// Quest definition as data, code consumes\\nconst QUESTS = {\\n  wood_collector: {\\n    title: 'Gather Wood', type: 'gather', target: 'wood', count: 10,\\n    reward: { gold: 50, xp: 100 },\\n    prereqs: []\\n  },\\n  orc_slayer: {\\n    title: 'Slay Orcs', type: 'kill', target: 'orc', count: 5,\\n    reward: { gold: 100, xp: 200 },\\n    prereqs: ['wood_collector']  // must finish wood first\\n  },\\n  dragon_slayer: {\\n    title: 'Slay the Dragon', type: 'kill', target: 'dragon', count: 1,\\n    reward: { gold: 1000, xp: 5000, items: ['dragon_sword'] },\\n    prereqs: ['orc_slayer']\\n  }\\n};\\nclass QuestMgr {\\n  constructor() { this.active = new Map(); this.completed = new Set(); }\\n  // THE central trigger — ONLY this function mutates quest state\\n  update(type, amount = 1) {\\n    for (const [id, q] of this.active) {\\n      if (q.type !== type) continue;\\n      // Cap at target; prevents overflow on event spam\\n      q.progress = Math.min(q.count, (q.progress || 0) + amount);\\n      if (q.progress >= q.count && !q.done) this._complete(id, q);\\n    }\\n  }\\n  accept(id) {\\n    const q = QUESTS[id];\\n    if (!q) throw new Error('Unknown quest: ' + id);\\n    // Gate by prereqs; prevent accept of blocked quests\\n    if (q.prereqs.some(p => !this.completed.has(p))) return false;\\n    this.active.set(id, { ...q, progress: 0, done: false });\\n    return true;\\n  }\\n  _complete(id, q) {\\n    q.done = true;\\n    // IMMEDIATE reward — not deferred, not batched\\n    if (q.reward.gold)  player.gold += q.reward.gold;\\n    if (q.reward.xp)    player.xp   += q.reward.xp;\\n    if (q.reward.items) q.reward.items.forEach(i => player.inv.add(i));\\n    this.completed.add(id);\\n    this.active.delete(id);\\n    toast(`✅ Quest complete: ${q.title}`);\\n  }\\n}\\n// Usage: QuestMgr.update('kill_orc', 1); // called from combat system",
      "tags": [
        "quest",
        "mission",
        "objective",
        "reward",
        "rpg",
        "เควส",
        "ภารกิจ"
      ],
      "difficulty": "Intermediate",
      "time_to_learn": "2 hours",
      "docs_url": "",
      "compare_with": [
        "Achievement system (4.9 — longer-term, less structured)",
        "Daily challenges (timer-based subset)",
        "MMO quest trackers (more complex UI)"
      ]
    },
    {
      "id": "4.9",
      "title": "Achievement System",
      "icon": "🏆",
      "description": "Discrete accomplishments with idempotent unlock check. Categories for UI grouping. Event-triggered evaluation.",
      "when_to_use": "ทุกเกมที่ต้องการ replayability | Games with long-term progression, completionist goals, daily streaks",
      "when_not_to_use": "Tutorial signposts (use quests instead), one-shot narrative moments, server-validated competitive ranks",
      "pros": [
        "Idempotent check — `if (state.achs.includes(id)) return` prevents double-unlock toast spam",
        "Long-term goals — 'Reach level 50' runs in background of normal play, no separate UI to manage",
        "Replayability hook — completionist goes for 100% on second playthrough, proven retention driver",
        "Trivial serialization — `state.achs = ['first_blood', 'rich_1k']` is 1 line of JSON round-trip",
        "Cheap to evaluate — check on event (kill, levelup), not per-frame; 100 achievements = <1ms total",
        "Social proof — 'X% of players have this' badge drives engagement, visible leaderboard psychology"
      ],
      "cons": [
        "Users ignore achievements — 70% never look at the screen, dev effort vs retention unclear",
        "Notification spam — unlocking 5 at once buries important feedback; queue + throttle needed",
        "Edge cases — offline progress should still unlock if criteria met; reconcile on first tick",
        "Cheating — localStorage editable, 'achievements' not authoritative; server validation for competitive",
        "Tracking the untrackable — 'be polite to NPCs' needs behavior classification, ML or heuristics",
        "ROI unclear — dev time vs retention lift often negative; instrument and measure before expanding"
      ],
      "gotchas": [
        "`if (state.achs.includes(id)) return` — idempotency is THE bug to avoid; toast fires 5x/sec otherwise",
        "Categories: 'combat', 'economy', 'social', 'exploration', 'meta' — UI grouping for 100+ entries",
        "Notification queue: enqueue, show 1 per 3s, not all at once; queue length cap (drop oldest)",
        "Server-validated achievements for competitive games — Steam, GOG, PSN each have own APIs, dup effort",
        "Date-based achievements — 'play 7 days in a row' needs streak counter, not just count; reset on miss",
        "Hidden achievements — `visible: false` for surprise reveals, surfaces only on unlock (engagement)",
        "Cross-platform sync — Steam achievements ≠ PSN achievements, write platform adapter layer",
        "Test achievement triggers with debug menu — manual playthrough 100x is too slow; CLI unlock-all for QA"
      ],
      "anti_patterns": [
        "No idempotency check — toast fires 5x per second, log flooded, player dismisses notification",
        "Award on frame tick — wastes CPU; award on event (kill/levelup/quest_complete), batch check on save",
        "Hardcoded English strings — no localization, Thai players see 'Kill 100 enemies' in wrong script",
        "100 achievements for trivial actions — 'walk 1 step' = meaningless padding; player tunes out",
        "Server trusts client achievement data — cheaters unlock everything in 30s; sign with HMAC"
      ],
      "prereqs": [
        "4.8"
      ],
      "next_steps": [],
      "examples": [
        "noodle-shop",
        "sims-lite",
        "stronghold-3d",
        "habit-streak"
      ],
      "snippet": "class AchMgr{constructor(s){this.s=s;s.achs=s.achs||[];this.q=[]}unlock(id){if(this.s.achs.includes(id))return;this.s.achs.push(id);this.q.push(id)}check(s){if(s.kills>=1)this.unlock('first_blood');if(s.gold>=1000)this.unlock('rich_1k');if(s.day_streak>=7)this.unlock('weekly_warrior')}render(){if(this.q.length===0)return;const n=this.q.shift();showToast(`🏆 ${n}`);setTimeout(()=>this.render(),3000)}}",
      "snippet_full": "// Achievement definitions as data, single check function\\nconst ACHIEVEMENTS = {\\n  first_blood:     { name: 'First Blood',    desc: 'Defeat your first enemy',  cat: 'combat',      icon: '⚔️' },\\n  rich_1k:         { name: 'Wealthy',        desc: 'Accumulate 1000 gold',     cat: 'economy',     icon: '💰' },\\n  wood_legend:     { name: 'Lumberjack',     desc: 'Gather 1000 wood',         cat: 'economy',     icon: '🪵' },\\n  explorer:        { name: 'Cartographer',   desc: 'Visit all map regions',    cat: 'exploration', icon: '🗺️' },\\n  social_butterfly:{ name: 'Friendly',       desc: 'Talk to 50 NPCs',          cat: 'social',      icon: '💬' },\\n  weekly_warrior:  { name: 'Streak Master',  desc: 'Play 7 days in a row',     cat: 'meta',        icon: '🔥' }\\n};\\nclass AchievementMgr {\\n  constructor(state) {\\n    this.state = state;\\n    this.state.achs = state.achs || [];\\n    this.queue = []; // notification queue\\n  }\\n  // THE critical line: idempotency guard\\n  unlock(id) {\\n    if (this.state.achs.includes(id)) return; // <-- the bug-avoider\\n    const ach = ACHIEVEMENTS[id];\\n    if (!ach) return;\\n    this.state.achs.push(id);\\n    this.queue.push(ach);\\n    if (navigator.onLine) this._syncToServer(id); // optional server-validated\\n  }\\n  // Batch check on game state changes — NOT per-frame\\n  check(state) {\\n    if (state.enemiesKilled >= 1)        this.unlock('first_blood');\\n    if (state.gold >= 1000)               this.unlock('rich_1k');\\n    if (state.woodGathered >= 1000)       this.unlock('wood_legend');\\n    if (state.npcsTalked >= 50)           this.unlock('social_butterfly');\\n    if (state.dayStreak >= 7)             this.unlock('weekly_warrior');\\n  }\\n  // Throttled notification display: 1 per 3s, not all at once\\n  renderToasts() {\\n    if (this.queue.length === 0) return;\\n    const next = this.queue.shift();\\n    showToast(`🏆 ${next.icon} ${next.name}: ${next.desc}`);\\n    setTimeout(() => this.renderToasts(), 3000);\\n  }\\n  _syncToServer(id) {\\n    // POST /api/achievements { userId, id, timestamp }\\n    // Server validates, returns updated global % for 'X% of players'\\n    fetch('/api/ach', { method: 'POST', body: JSON.stringify({ id }) });\\n  }\\n}\\n// Hook into game loop: only check on state change events, not every frame",
      "tags": [
        "achievement",
        "trophy",
        "unlock",
        "idempotent",
        "reward",
        "ความสำเร็จ",
        "รางวัล"
      ],
      "difficulty": "Beginner",
      "time_to_learn": "1 hour",
      "docs_url": "",
      "compare_with": [
        "Quest system (4.8 — structured, has progress)",
        "Steam achievements (server-validated, dup platform work)",
        "Daily challenges (timer-based subset)"
      ]
    },
    {
      "id": "4.10",
      "title": "Save Migration",
      "icon": "🔄",
      "description": "Multi-version backward-compat with version field. Forward-only migrations. Default values for missing fields.",
      "when_to_use": "เกมที่ ship หลายเวอร์ชัน, mobile apps with updates, browser games with patches | Any app persisted across deploys",
      "when_not_to_use": "App with single version and never updates, server-only state with full DB migrations, ephemeral session data",
      "pros": [
        "User keeps progress across updates — biggest retention metric in mobile games (Angry Birds saved years)",
        "Forward + backward compat — old save loads in new build, new save loads in old build (with defaults)",
        "Version field is mandatory — every save records `_ver` integer, never trust absent (defensive)",
        "Migration is one-way per upgrade — v1→v2→v3, no skipped steps; idempotent if re-run",
        "Default values for missing fields — `data.quests ||= []` handles partial saves, never crashes",
        "Easy rollback — keep migration code in branches, revert = revert migration + restore old schema"
      ],
      "cons": [
        "Migration code per version — 5 versions × 100 fields = 500 lines of transform functions",
        "Bug amplification — bad migration corrupts ALL saves, not just new ones; CI fixtures MANDATORY",
        "Testing matrix — must test v1, v2, v3, v4, v5 loads in v6 build = combinatorial; automate",
        "Storage fragmentation — old format fields linger forever, can't GC; compression helps",
        "Non-deterministic migrations — Date.now() in migration = different output per run, save file unstable",
        "Schema vs data confusion — adding UI field ≠ save field; schema migration ≠ data migration, separate"
      ],
      "gotchas": [
        "Keep `_ver` field always — never trust `parseInt(undefined)` defaults, must default to 1 explicitly",
        "One-way migration: v1 → v2 (never v2 → v1) — idempotent if you re-run; chain migrations in order",
        "Forward compat: new code reads old save + applies migrations to current version; old code reads new = defaults",
        "Backward compat: old code reads new save via `data.foo ?? defaultValue` everywhere; defensive reads",
        "Default values via `??` not `||` — `gold=0` and `name=''` are valid, `||` substitutes them wrong",
        "Test all version loads in CI — automated fixtures v1.json...v5.json + load test on each PR",
        "Never mutate the input — clone first; shared object reference = silent corruption of localStorage",
        "Migration failures should NOT crash — log + fallback to fresh save + report bug via analytics"
      ],
      "anti_patterns": [
        "Skip versioning — one day you ship 'remove field X', every existing save crashes on load",
        "Mutate input data during migration — original localStorage value corrupted, cannot retry",
        "Use `||` instead of `??` for defaults — `gold=0` becomes `gold=defaultValue`, save data lost",
        "Migration that depends on time/random — output differs per load, save file unstable across reloads",
        "No testing for old version loads — ship update, all players with old save crash, 1-star reviews"
      ],
      "prereqs": [
        "1.4"
      ],
      "next_steps": [],
      "examples": [
        "stronghold-3d",
        "noodle-shop",
        "sims-lite",
        "flowboard"
      ],
      "snippet": "const MIGS={2:d=>({...d,achs:d.achs||[]}),3:d=>({...d,weather:d.weather??'cloudy'}),4:d=>{let n={...d};n.player={...n.player,loc:n.player.pos||{x:0,y:0}};delete n.player.pos;return n}};function load(){let raw=null;for(let v=CURRENT_VER;v>=1;v--){raw=localStorage.getItem(`app_v${v}`);if(raw)break}if(!raw)return fresh();let d=JSON.parse(raw);for(let v=(d._ver||1);v<CURRENT_VER;v++){const m=MIGS[v+1];if(m){d=m({...d});d._ver=v+1}}return d}",
      "snippet_full": "// Versioned migrations: each fn transforms save to NEXT version\\nconst CURRENT_VER = 5;\\nconst MIGRATIONS = {\\n  // v1 -> v2: achievements array added\\n  2: d => ({ ...d, achievements: d.achievements || [] }),\\n  // v2 -> v3: weather field added with default\\n  3: d => ({ ...d, weather: d.weather ?? 'cloudy' }),\\n  // v3 -> v4: quests array + activeQuest separate from achievements\\n  4: d => ({ ...d, quests: d.quests || [], activeQuest: d.activeQuest || null }),\\n  // v4 -> v5: player.position renamed to player.loc (refactor)\\n  5: d => {\\n    const next = { ...d };\\n    if (next.player) {\\n      next.player = { ...next.player, loc: next.player.position || { x: 0, y: 0 } };\\n      delete next.player.position; // remove old key, do NOT keep both\\n    }\\n    return next;\\n  }\\n};\\nclass SaveMgr {\\n  static save(state) {\\n    const payload = { _ver: CURRENT_VER, ...state, savedAt: Date.now() };\\n    localStorage.setItem('app_v' + CURRENT_VER, JSON.stringify(payload));\\n  }\\n  static load() {\\n    // Try newest key first, fall back to older versions\\n    let raw = null;\\n    for (let v = CURRENT_VER; v >= 1; v--) {\\n      raw = localStorage.getItem('app_v' + v);\\n      if (raw) break;\\n    }\\n    if (!raw) return this._fresh();\\n    let data;\\n    try { data = JSON.parse(raw); }\\n    catch (e) {\\n      console.error('Save corrupted, starting fresh', e);\\n      return this._fresh();\\n    }\\n    // Forward-only migration: apply each step IN ORDER from current to target\\n    const startVer = data._ver || 1;\\n    for (let v = startVer; v < CURRENT_VER; v++) {\\n      const mig = MIGRATIONS[v + 1];\\n      if (mig) {\\n        // Clone first to avoid mutating the parsed input\\n        data = mig({ ...data });\\n        data._ver = v + 1;\\n      }\\n    }\\n    return data;\\n  }\\n  static _fresh() {\\n    return { _ver: CURRENT_VER, player: { loc: { x: 0, y: 0 } }, achievements: [], quests: [], gold: 0 };\\n  }\\n  // CI test: load every version fixture, verify shape\\n  static _runMigrationTests() {\\n    const fixtures = { 1: { /* v1 shape */ }, 2: { /* v2 shape */ } };\\n    for (const v of Object.keys(fixtures)) {\\n      const result = SaveMgr._applyMigrations({ _ver: +v, ...fixtures[v] });\\n      assert(result._ver === CURRENT_VER);\\n      assert(result.player && result.player.loc);\\n    }\\n  }\\n}",
      "tags": [
        "save",
        "migration",
        "versioning",
        "localstorage",
        "persistence",
        "ซave",
        "เวอร์ชัน"
      ],
      "difficulty": "Intermediate",
      "time_to_learn": "1 hour",
      "docs_url": "",
      "compare_with": [
        "IndexedDB (more storage, async, complex API)",
        "Server-side DB migrations (Liquibase/Flyway)",
        "Backward-compat only (read latest, ignore older)"
      ]
    }
  ]
}
🔗 /api/skill (all 100) 💾 Download game-patterns.json