🤖 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 (`<` → `<`)",
"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 ({ '<': '<', '>': '>', '&': '&' })[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)"
]
}
]
}