game-engineering-team
55
总安装量
55
周安装量
#3939
全站排名
安装命令
npx skills add https://github.com/ccalebcarter/purria-skills --skill game-engineering-team
Agent 安装分布
claude-code
46
opencode
43
gemini-cli
42
antigravity
38
github-copilot
33
Skill 文档
Game Engineering Team
A multidisciplinary council of AAA-caliber engineers dedicated to building production-quality game systems for Farming in Purria.
The Engineering Council
Core Game Engineers (6)
| Engineer | Specialization | Technical Lens |
|---|---|---|
| Game Systems Architect | State machines, ECS, core loops | “How do the pieces fit together?” |
| Casino Logic Engineer | Probability, RNG, payout systems | “Is the math provably fair?” |
| Card Game Specialist | Hand evaluation, deck management | “How do cards flow through states?” |
| Board/Grid Engineer | Spatial algorithms, pathfinding | “How does the hex grid compute?” |
| Progression Engineer | XP curves, unlock systems, gating | “How does growth feel right?” |
| Real-Time Systems Lead | Timing, animation sync, frame budgets | “Does it feel responsive?” |
UI/UX Engineers (5)
| Engineer | Specialization | Technical Lens |
|---|---|---|
| Game UI Architect | Component systems, layout engines | “How is the UI structured?” |
| Interaction Engineer | Touch, gestures, accessibility | “How do players physically interact?” |
| Animation Programmer | Tweens, particles, juice | “Does it feel alive?” |
| Typography Specialist | Font rendering, readability, style | “Is text beautiful and readable?” |
| Responsive Design Lead | Mobile-first, cross-platform | “Does it work everywhere?” |
Data & Infrastructure (5)
| Engineer | Specialization | Technical Lens |
|---|---|---|
| Data Architect | Schema design, relationships, queries | “How is data organized?” |
| Telemetry Engineer | Logging, analytics, events | “What do we need to measure?” |
| Security Engineer | Auth, validation, anti-cheat | “Is this exploitable?” |
| Performance Engineer | Profiling, optimization, budgets | “Will it run on low-end devices?” |
| DevOps Specialist | CI/CD, deployment, monitoring | “How do we ship reliably?” |
Quality & Craft (4)
| Engineer | Specialization | Technical Lens |
|---|---|---|
| Code Quality Lead | Patterns, refactoring, reviews | “Is this maintainable?” |
| Technical Writer | Documentation, comments, APIs | “Can others understand this?” |
| Test Architect | Unit, integration, E2E strategies | “How do we verify correctness?” |
| Tutorial Systems Engineer | Onboarding flows, contextual help | “How do players learn this?” |
Integration Specialists (4)
| Engineer | Specialization | Technical Lens |
|---|---|---|
| API Designer | tRPC, REST, contract design | “How do client and server talk?” |
| State Management Lead | Zustand, persistence, sync | “Where does state live?” |
| Plugin/Mod Architect | Extensibility, configuration | “Can this be extended safely?” |
| Cross-System Integrator | System coupling, event buses | “How do systems communicate?” |
Part I: Core Engineering Principles
The Engineering Manifesto
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â FARMING IN PURRIA ENGINEERING PRINCIPLES â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ¤
â â
â 1. PLAYER FIRST â
â Every technical decision serves the player experience. â
â 60fps on mobile. Instant feedback. No jank. â
â â
â 2. TYPE SAFETY END-TO-END â
â From database to UI, types are the contract. â
â If it compiles, it should work. â
â â
â 3. EXPLICIT OVER CLEVER â
â Readable code beats clever code. â
â The next developer is you in 6 months. â
â â
â 4. SMALL, COMPOSABLE PIECES â
â Functions do one thing. Components render one thing. â
â Composition over inheritance. â
â â
â 5. FAIL FAST, RECOVER GRACEFULLY â
â Validate inputs immediately. Handle errors at boundaries. â
â Players should never see stack traces. â
â â
â 6. MEASURE EVERYTHING â
â If we can't measure it, we can't improve it. â
â Telemetry is not optional. â
â â
â 7. OPTIMIZE LAST â
â Make it work. Make it right. Make it fast. â
â Profile before optimizing. â
â â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
The Technical Stack
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â TECH STACK â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ¤
â â
â FRONTEND BACKEND â
â ââââââââ âââââââ â
â React 18+ Hono â
â TanStack Router tRPC â
â Zustand Drizzle ORM â
â Tailwind CSS SQLite/Turso â
â shadcn/ui Bun runtime â
â Framer Motion Zod validation â
â â
â TOOLING INFRASTRUCTURE â
â âââââââ ââââââââââââââ â
â TypeScript 5+ Cloudflare Pages â
â Biome (lint/format) Cloudflare Workers â
â Vitest GitHub Actions â
â Playwright (E2E) PostHog analytics â
â â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Part II: Game Programming Patterns
Pattern 1: Finite State Machines (FSM)
When to Use: Phase management, UI states, game mode transitions
// Type-safe FSM with explicit transitions
type GamePhase = 'morning' | 'action' | 'resolution' | 'night';
interface PhaseTransitions {
morning: 'action';
action: 'resolution';
resolution: 'night';
night: 'morning';
}
type NextPhase<T extends GamePhase> = PhaseTransitions[T];
class GamePhaseMachine {
private phase: GamePhase = 'morning';
transition(): void {
const transitions: Record<GamePhase, GamePhase> = {
morning: 'action',
action: 'resolution',
resolution: 'night',
night: 'morning',
};
this.phase = transitions[this.phase];
this.onPhaseEnter(this.phase);
}
private onPhaseEnter(phase: GamePhase): void {
// Phase-specific initialization
}
}
Pattern 2: Command Pattern (Undo/Redo)
When to Use: Bet placement, farm actions, any reversible operation
interface Command {
execute(): void;
undo(): void;
description: string;
}
class PlaceBetCommand implements Command {
constructor(
private potId: string,
private betType: BetType,
private amount: number,
private gameState: GameState
) {}
execute(): void {
this.gameState.placeBet(this.potId, this.betType, this.amount);
}
undo(): void {
this.gameState.removeBet(this.potId);
this.gameState.refundCoins(this.amount);
}
get description() {
return `Bet ${this.amount} on ${this.potId}`;
}
}
class CommandHistory {
private history: Command[] = [];
private pointer = -1;
execute(command: Command): void {
// Clear redo stack
this.history = this.history.slice(0, this.pointer + 1);
command.execute();
this.history.push(command);
this.pointer++;
}
undo(): void {
if (this.pointer >= 0) {
this.history[this.pointer].undo();
this.pointer--;
}
}
redo(): void {
if (this.pointer < this.history.length - 1) {
this.pointer++;
this.history[this.pointer].execute();
}
}
}
Pattern 3: Observer Pattern (Event Bus)
When to Use: Cross-system communication, triggers, achievements
type GameEvent =
| { type: 'POT_THRESHOLD_REACHED'; potId: string; threshold: number }
| { type: 'TROUBLE_SPAWNED'; hexId: string; troubleType: TroubleType }
| { type: 'SIMULIN_LEVELED'; simulinId: string; newLevel: number }
| { type: 'TRIGGER_EARNED'; trigger: TriggerType }
| { type: 'DAY_COMPLETED'; dayNumber: number };
type EventHandler<T extends GameEvent['type']> = (
event: Extract<GameEvent, { type: T }>
) => void;
class GameEventBus {
private handlers = new Map<GameEvent['type'], Set<EventHandler<any>>>();
on<T extends GameEvent['type']>(type: T, handler: EventHandler<T>): () => void {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set());
}
this.handlers.get(type)!.add(handler);
// Return unsubscribe function
return () => this.handlers.get(type)?.delete(handler);
}
emit(event: GameEvent): void {
const handlers = this.handlers.get(event.type);
if (handlers) {
handlers.forEach(handler => handler(event));
}
}
}
// Usage
const events = new GameEventBus();
// Trigger system listens for pot events
events.on('POT_THRESHOLD_REACHED', ({ potId, threshold }) => {
if (threshold === 100) {
checkForPerfectReadTrigger();
}
});
Pattern 4: Object Pool (Performance)
When to Use: Particles, projectiles, frequently created/destroyed objects
class ObjectPool<T> {
private pool: T[] = [];
private activeCount = 0;
constructor(
private factory: () => T,
private reset: (obj: T) => void,
initialSize = 10
) {
for (let i = 0; i < initialSize; i++) {
this.pool.push(factory());
}
}
acquire(): T {
if (this.activeCount >= this.pool.length) {
this.pool.push(this.factory());
}
return this.pool[this.activeCount++];
}
release(obj: T): void {
const index = this.pool.indexOf(obj);
if (index !== -1 && index < this.activeCount) {
this.reset(obj);
// Swap with last active
[this.pool[index], this.pool[this.activeCount - 1]] =
[this.pool[this.activeCount - 1], this.pool[index]];
this.activeCount--;
}
}
releaseAll(): void {
for (let i = 0; i < this.activeCount; i++) {
this.reset(this.pool[i]);
}
this.activeCount = 0;
}
}
Pattern 5: Strategy Pattern (Interchangeable Algorithms)
When to Use: AI behaviors, scoring algorithms, difficulty modes
interface ScoringStrategy {
calculateScore(results: DayResults): number;
getName(): string;
}
class StandardScoring implements ScoringStrategy {
calculateScore(results: DayResults): number {
return results.potsWon * 100 + results.troubleCleared * 50;
}
getName() { return 'Standard'; }
}
class HarvestPhaseScoring implements ScoringStrategy {
calculateScore(results: DayResults): number {
// 3x multiplier during harvest phase
return (results.potsWon * 100 + results.troubleCleared * 50) * 3;
}
getName() { return 'Harvest Bonus'; }
}
class GameScorer {
constructor(private strategy: ScoringStrategy) {}
setStrategy(strategy: ScoringStrategy): void {
this.strategy = strategy;
}
score(results: DayResults): number {
return this.strategy.calculateScore(results);
}
}
Part III: Casino & Card Game Implementation
Random Number Generation
Critical: Use cryptographically secure, seedable RNG for fair play.
class SeededRNG {
private seed: number;
constructor(seed: number = Date.now()) {
this.seed = seed;
}
// Mulberry32 - fast, good distribution
next(): number {
let t = this.seed += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
// Range helpers
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min + 1)) + min;
}
nextFloat(min: number, max: number): number {
return this.next() * (max - min) + min;
}
// Shuffle array (Fisher-Yates)
shuffle<T>(array: T[]): T[] {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = this.nextInt(0, i);
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
// Weighted random selection
weightedChoice<T>(items: T[], weights: number[]): T {
const totalWeight = weights.reduce((a, b) => a + b, 0);
let random = this.next() * totalWeight;
for (let i = 0; i < items.length; i++) {
random -= weights[i];
if (random <= 0) return items[i];
}
return items[items.length - 1];
}
}
// Usage with verifiable seed
const daySeed = hashString(`${seasonId}-day-${dayNumber}`);
const rng = new SeededRNG(daySeed);
Card Deck Management
interface Card {
suit: 'hearts' | 'diamonds' | 'clubs' | 'spades';
rank: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13;
}
class Deck {
private cards: Card[] = [];
private discardPile: Card[] = [];
constructor(private rng: SeededRNG) {
this.reset();
}
reset(): void {
this.cards = [];
this.discardPile = [];
const suits: Card['suit'][] = ['hearts', 'diamonds', 'clubs', 'spades'];
for (const suit of suits) {
for (let rank = 1; rank <= 13; rank++) {
this.cards.push({ suit, rank: rank as Card['rank'] });
}
}
this.shuffle();
}
shuffle(): void {
this.cards = this.rng.shuffle(this.cards);
}
draw(count = 1): Card[] {
if (this.cards.length < count) {
// Reshuffle discard pile into deck
this.cards = [...this.cards, ...this.rng.shuffle(this.discardPile)];
this.discardPile = [];
}
return this.cards.splice(0, count);
}
discard(cards: Card[]): void {
this.discardPile.push(...cards);
}
get remaining(): number {
return this.cards.length;
}
peek(count = 1): Card[] {
return this.cards.slice(0, count);
}
}
Poker Hand Evaluation
type HandRank =
| 'high-card'
| 'pair'
| 'two-pair'
| 'three-of-a-kind'
| 'straight'
| 'flush'
| 'full-house'
| 'four-of-a-kind'
| 'straight-flush'
| 'royal-flush';
interface HandResult {
rank: HandRank;
value: number; // For comparing same-rank hands
cards: Card[]; // The 5 cards making the hand
kickers: Card[]; // Remaining cards for tiebreakers
}
class PokerHandEvaluator {
evaluate(cards: Card[]): HandResult {
if (cards.length < 5) {
throw new Error('Need at least 5 cards');
}
// If more than 5 cards, find best 5-card hand
if (cards.length > 5) {
return this.findBestHand(cards);
}
const sorted = [...cards].sort((a, b) => b.rank - a.rank);
const isFlush = this.checkFlush(sorted);
const straightRank = this.checkStraight(sorted);
const groups = this.groupByRank(sorted);
// Check for each hand type
if (isFlush && straightRank === 14) {
return { rank: 'royal-flush', value: 1000, cards: sorted, kickers: [] };
}
if (isFlush && straightRank > 0) {
return { rank: 'straight-flush', value: 900 + straightRank, cards: sorted, kickers: [] };
}
if (groups[0].length === 4) {
return this.buildResult('four-of-a-kind', 800, groups, sorted);
}
if (groups[0].length === 3 && groups[1]?.length === 2) {
return this.buildResult('full-house', 700, groups, sorted);
}
if (isFlush) {
return { rank: 'flush', value: 600 + this.highCardValue(sorted), cards: sorted, kickers: [] };
}
if (straightRank > 0) {
return { rank: 'straight', value: 500 + straightRank, cards: sorted, kickers: [] };
}
if (groups[0].length === 3) {
return this.buildResult('three-of-a-kind', 400, groups, sorted);
}
if (groups[0].length === 2 && groups[1]?.length === 2) {
return this.buildResult('two-pair', 300, groups, sorted);
}
if (groups[0].length === 2) {
return this.buildResult('pair', 200, groups, sorted);
}
return {
rank: 'high-card',
value: 100 + this.highCardValue(sorted),
cards: sorted,
kickers: sorted.slice(1)
};
}
private checkFlush(cards: Card[]): boolean {
return cards.every(c => c.suit === cards[0].suit);
}
private checkStraight(cards: Card[]): number {
const ranks = cards.map(c => c.rank).sort((a, b) => b - a);
// Check for wheel (A-2-3-4-5)
if (ranks[0] === 13 && ranks[1] === 5 && ranks[2] === 4 &&
ranks[3] === 3 && ranks[4] === 2) {
return 5; // Low straight
}
// Check regular straight
for (let i = 0; i < ranks.length - 1; i++) {
if (ranks[i] - ranks[i + 1] !== 1) return 0;
}
return ranks[0]; // High card of straight
}
private groupByRank(cards: Card[]): Card[][] {
const groups = new Map<number, Card[]>();
for (const card of cards) {
if (!groups.has(card.rank)) groups.set(card.rank, []);
groups.get(card.rank)!.push(card);
}
return Array.from(groups.values()).sort((a, b) => {
if (a.length !== b.length) return b.length - a.length;
return b[0].rank - a[0].rank;
});
}
private highCardValue(cards: Card[]): number {
return cards.reduce((acc, c, i) => acc + c.rank * Math.pow(15, 4 - i), 0);
}
private buildResult(
rank: HandRank,
baseValue: number,
groups: Card[][],
sorted: Card[]
): HandResult {
const mainCards = groups[0];
const value = baseValue + mainCards[0].rank;
return { rank, value, cards: sorted, kickers: groups.slice(1).flat() };
}
private findBestHand(cards: Card[]): HandResult {
let best: HandResult | null = null;
// Generate all 5-card combinations
const combinations = this.combinations(cards, 5);
for (const combo of combinations) {
const result = this.evaluate(combo);
if (!best || result.value > best.value) {
best = result;
}
}
return best!;
}
private *combinations<T>(arr: T[], k: number): Generator<T[]> {
if (k === 0) { yield []; return; }
if (arr.length < k) return;
const [first, ...rest] = arr;
for (const combo of this.combinations(rest, k - 1)) {
yield [first, ...combo];
}
yield* this.combinations(rest, k);
}
}
Pot/Betting System
type BetType = 'fold' | 'call' | 'raise' | 'all-in';
interface Pot {
id: string;
name: string;
currentFill: number; // 0-100
thresholds: number[]; // [50, 80, 100]
payoutMultipliers: Record<BetType, number[]>; // Per threshold
}
interface Bet {
potId: string;
betType: BetType;
amount: number;
placedAt: Date;
}
class BettingSystem {
private bets: Bet[] = [];
placeBet(potId: string, betType: BetType, amount: number): Bet {
// Validate bet
if (betType !== 'fold' && amount <= 0) {
throw new Error('Bet amount must be positive');
}
// Check for existing bet on this pot
const existing = this.bets.find(b => b.potId === potId);
if (existing) {
throw new Error('Already bet on this pot');
}
const bet: Bet = {
potId,
betType,
amount: betType === 'fold' ? 0 : amount,
placedAt: new Date(),
};
this.bets.push(bet);
return bet;
}
resolveBets(pots: Pot[]): BetResult[] {
const results: BetResult[] = [];
for (const bet of this.bets) {
const pot = pots.find(p => p.id === bet.potId);
if (!pot) continue;
const result = this.resolveSingleBet(bet, pot);
results.push(result);
}
this.bets = [];
return results;
}
private resolveSingleBet(bet: Bet, pot: Pot): BetResult {
// Find highest threshold reached
let thresholdIndex = -1;
for (let i = pot.thresholds.length - 1; i >= 0; i--) {
if (pot.currentFill >= pot.thresholds[i]) {
thresholdIndex = i;
break;
}
}
// Fold always returns nothing
if (bet.betType === 'fold') {
return { bet, won: false, payout: 0, thresholdReached: thresholdIndex };
}
// No threshold reached = loss
if (thresholdIndex < 0) {
return { bet, won: false, payout: 0, thresholdReached: -1 };
}
// Calculate payout
const multiplier = pot.payoutMultipliers[bet.betType][thresholdIndex];
const payout = Math.floor(bet.amount * multiplier);
return {
bet,
won: true,
payout,
thresholdReached: thresholdIndex,
multiplier
};
}
}
interface BetResult {
bet: Bet;
won: boolean;
payout: number;
thresholdReached: number;
multiplier?: number;
}
Part IV: Reward & Progression Systems
Experience Curves
// Common XP curve formulas
const XP_CURVES = {
// Linear: Simple, predictable
linear: (level: number, base: number) => base * level,
// Quadratic: Steeper growth
quadratic: (level: number, base: number) => base * level * level,
// Exponential: Slow start, rapid growth
exponential: (level: number, base: number, rate: number) =>
base * Math.pow(rate, level),
// Polynomial: Customizable curve
polynomial: (level: number, base: number, power: number) =>
Math.floor(base * Math.pow(level, power)),
// Fibonacci-like: Natural feeling progression
fibonacci: (level: number, base: number) => {
if (level <= 1) return base;
let [a, b] = [base, base * 2];
for (let i = 2; i < level; i++) {
[a, b] = [b, a + b];
}
return b;
},
};
class ProgressionSystem {
constructor(
private xpForLevel: (level: number) => number,
private maxLevel: number = 100
) {}
getLevel(totalXP: number): number {
let level = 1;
let xpNeeded = 0;
while (level <= this.maxLevel) {
xpNeeded += this.xpForLevel(level);
if (totalXP < xpNeeded) break;
level++;
}
return Math.min(level, this.maxLevel);
}
getProgress(totalXP: number): { level: number; current: number; needed: number; percent: number } {
const level = this.getLevel(totalXP);
const xpForCurrent = this.getTotalXPForLevel(level);
const xpForNext = this.xpForLevel(level + 1);
const current = totalXP - xpForCurrent;
return {
level,
current,
needed: xpForNext,
percent: Math.floor((current / xpForNext) * 100),
};
}
private getTotalXPForLevel(level: number): number {
let total = 0;
for (let i = 1; i < level; i++) {
total += this.xpForLevel(i);
}
return total;
}
}
// Simulin bonding uses gentle curve
const simulinProgression = new ProgressionSystem(
(level) => XP_CURVES.polynomial(level, 100, 1.5),
25 // Max bond level
);
Trigger & Combo System
interface DailyTrigger {
type: TriggerType;
earnedAt: Date;
dayNumber: number;
metadata?: Record<string, any>;
}
type TriggerType =
| 'first_bloom'
| 'perfect_read'
| 'trouble_slayer'
| 'growth_spurt'
| 'simulin_sync';
type ComboHandType =
| 'pair'
| 'two_pair'
| 'three_of_a_kind'
| 'straight'
| 'full_house'
| 'flush'
| 'four_of_a_kind'
| 'straight_flush'
| 'royal_flush';
class ComboEngine {
private readonly HAND_VALUES: Record<ComboHandType, number> = {
'pair': 2,
'two_pair': 3,
'three_of_a_kind': 5,
'straight': 10,
'full_house': 15,
'flush': 20,
'four_of_a_kind': 25,
'straight_flush': 50,
'royal_flush': 100,
};
evaluateWeek(triggers: DailyTrigger[]): ComboResult {
// Group triggers by type
const byType = new Map<TriggerType, number>();
for (const trigger of triggers) {
byType.set(trigger.type, (byType.get(trigger.type) || 0) + 1);
}
const counts = Array.from(byType.values()).sort((a, b) => b - a);
const uniqueTypes = byType.size;
const totalTriggers = triggers.length;
// Evaluate hand (highest first)
let handType: ComboHandType | null = null;
// Royal Flush: All 5 types, perfect execution
if (uniqueTypes === 5 && counts.every(c => c >= 1) && this.isPerfectSequence(triggers)) {
handType = 'royal_flush';
}
// Straight Flush: 5 consecutive days, same category
else if (this.isConsecutiveDays(triggers, 5) && this.hasCategoryMatch(triggers)) {
handType = 'straight_flush';
}
// Four of a Kind
else if (counts[0] >= 4) {
handType = 'four_of_a_kind';
}
// Flush: 5 of same type
else if (counts[0] >= 5) {
handType = 'flush';
}
// Full House: 3 + 2
else if (counts[0] >= 3 && counts[1] >= 2) {
handType = 'full_house';
}
// Straight: 5 different in sequence
else if (this.isConsecutiveDays(triggers, 5) && uniqueTypes >= 5) {
handType = 'straight';
}
// Three of a Kind
else if (counts[0] >= 3) {
handType = 'three_of_a_kind';
}
// Two Pair
else if (counts[0] >= 2 && counts[1] >= 2) {
handType = 'two_pair';
}
// Pair
else if (counts[0] >= 2) {
handType = 'pair';
}
return {
triggers,
handType,
multiplier: handType ? this.HAND_VALUES[handType] : 1,
breakdown: Object.fromEntries(byType),
};
}
private isConsecutiveDays(triggers: DailyTrigger[], required: number): boolean {
const days = [...new Set(triggers.map(t => t.dayNumber))].sort((a, b) => a - b);
let consecutive = 1;
for (let i = 1; i < days.length; i++) {
if (days[i] - days[i-1] === 1) {
consecutive++;
if (consecutive >= required) return true;
} else {
consecutive = 1;
}
}
return consecutive >= required;
}
private hasCategoryMatch(triggers: DailyTrigger[]): boolean {
// Define categories
const categories: Record<string, TriggerType[]> = {
betting: ['first_bloom', 'perfect_read'],
farming: ['growth_spurt', 'trouble_slayer'],
companion: ['simulin_sync'],
};
// Check if all triggers are from same category
for (const types of Object.values(categories)) {
if (triggers.every(t => types.includes(t.type))) {
return true;
}
}
return false;
}
private isPerfectSequence(triggers: DailyTrigger[]): boolean {
// Perfect sequence: all 5 trigger types in optimal order
const idealOrder: TriggerType[] = [
'first_bloom', 'growth_spurt', 'trouble_slayer', 'perfect_read', 'simulin_sync'
];
const byDay = new Map<number, TriggerType>();
for (const t of triggers) {
if (!byDay.has(t.dayNumber)) {
byDay.set(t.dayNumber, t.type);
}
}
const sequence = Array.from(byDay.entries())
.sort(([a], [b]) => a - b)
.map(([_, type]) => type);
return sequence.length === 5 &&
sequence.every((type, i) => type === idealOrder[i]);
}
}
interface ComboResult {
triggers: DailyTrigger[];
handType: ComboHandType | null;
multiplier: number;
breakdown: Record<TriggerType, number>;
}
Part V: UI Engineering Patterns
Component Architecture
// Compound Component Pattern for complex UI
interface SlotMachineContext {
spinning: boolean;
result: SlotResult | null;
spin: () => void;
}
const SlotMachineContext = createContext<SlotMachineContext | null>(null);
function SlotMachine({ children }: { children: React.ReactNode }) {
const [spinning, setSpinning] = useState(false);
const [result, setResult] = useState<SlotResult | null>(null);
const spin = useCallback(async () => {
setSpinning(true);
const newResult = await api.spin();
setResult(newResult);
setSpinning(false);
}, []);
return (
<SlotMachineContext.Provider value={{ spinning, result, spin }}>
<div className="slot-machine">{children}</div>
</SlotMachineContext.Provider>
);
}
// Sub-components
SlotMachine.Reel = function Reel({ index }: { index: number }) {
const ctx = useContext(SlotMachineContext);
// ...render reel
};
SlotMachine.SpinButton = function SpinButton() {
const ctx = useContext(SlotMachineContext);
return (
<button onClick={ctx?.spin} disabled={ctx?.spinning}>
{ctx?.spinning ? 'Spinning...' : 'SPIN'}
</button>
);
};
// Usage
<SlotMachine>
<SlotMachine.Reel index={0} />
<SlotMachine.Reel index={1} />
<SlotMachine.Reel index={2} />
<SlotMachine.SpinButton />
</SlotMachine>
Animation Patterns
import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion';
// Juice: Satisfying feedback animations
const JUICE_VARIANTS = {
// Pop in
pop: {
initial: { scale: 0, opacity: 0 },
animate: { scale: 1, opacity: 1 },
exit: { scale: 0, opacity: 0 },
transition: { type: 'spring', stiffness: 500, damping: 30 },
},
// Bounce
bounce: {
animate: {
y: [0, -10, 0],
transition: { duration: 0.3, times: [0, 0.5, 1] }
},
},
// Shake (for errors/wrong)
shake: {
animate: {
x: [0, -10, 10, -10, 10, 0],
transition: { duration: 0.4 }
},
},
// Pulse (for attention)
pulse: {
animate: {
scale: [1, 1.05, 1],
transition: { duration: 0.3, repeat: 2 }
},
},
};
// Number counter animation
function AnimatedNumber({ value }: { value: number }) {
const spring = useSpring(value, { stiffness: 100, damping: 30 });
const display = useTransform(spring, (v) => Math.floor(v).toLocaleString());
useEffect(() => {
spring.set(value);
}, [value, spring]);
return <motion.span>{display}</motion.span>;
}
// Stagger children animation
function StaggeredList({ items }: { items: React.ReactNode[] }) {
return (
<motion.ul
initial="hidden"
animate="visible"
variants={{
visible: { transition: { staggerChildren: 0.1 } },
}}
>
{items.map((item, i) => (
<motion.li
key={i}
variants={{
hidden: { opacity: 0, x: -20 },
visible: { opacity: 1, x: 0 },
}}
>
{item}
</motion.li>
))}
</motion.ul>
);
}
Responsive Game UI
// Hook for responsive game scaling
function useGameScale() {
const [scale, setScale] = useState(1);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const updateScale = () => {
if (!containerRef.current) return;
const container = containerRef.current;
const parent = container.parentElement;
if (!parent) return;
const DESIGN_WIDTH = 390; // iPhone 12/13 width
const DESIGN_HEIGHT = 844;
const scaleX = parent.clientWidth / DESIGN_WIDTH;
const scaleY = parent.clientHeight / DESIGN_HEIGHT;
// Use minimum to ensure everything fits
setScale(Math.min(scaleX, scaleY, 1.5)); // Cap at 1.5x
};
updateScale();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, []);
return { scale, containerRef };
}
// Usage
function GameScreen() {
const { scale, containerRef } = useGameScale();
return (
<div className="game-container" ref={containerRef}>
<div
className="game-content"
style={{ transform: `scale(${scale})`, transformOrigin: 'top center' }}
>
{/* Fixed-size game UI designed for 390x844 */}
</div>
</div>
);
}
Part VI: Data Architecture
Schema Design Principles
// Use branded types for IDs
type SeasonId = string & { readonly brand: unique symbol };
type SimulinId = string & { readonly brand: unique symbol };
type UserId = string & { readonly brand: unique symbol };
function createSeasonId(): SeasonId {
return `season_${crypto.randomUUID()}` as SeasonId;
}
// Drizzle schema with relations
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
export const seasons = sqliteTable('seasons', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
seasonNumber: integer('season_number').notNull(),
currentDay: integer('current_day').notNull().default(1),
phase: text('phase', { enum: ['planting', 'growth', 'harvest'] }).notNull(),
jackpotContribution: real('jackpot_contribution').notNull().default(0),
startedAt: integer('started_at', { mode: 'timestamp' }).notNull(),
completedAt: integer('completed_at', { mode: 'timestamp' }),
});
export const seasonsRelations = relations(seasons, ({ one, many }) => ({
user: one(users, {
fields: [seasons.userId],
references: [users.id],
}),
triggers: many(dailyTriggers),
simulins: many(simulins),
}));
export const dailyTriggers = sqliteTable('daily_triggers', {
id: text('id').primaryKey(),
seasonId: text('season_id').notNull().references(() => seasons.id),
dayNumber: integer('day_number').notNull(),
triggerType: text('trigger_type').notNull(),
earnedAt: integer('earned_at', { mode: 'timestamp' }).notNull(),
metadata: text('metadata', { mode: 'json' }),
});
export const simulins = sqliteTable('simulins', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
seasonId: text('season_id').references(() => seasons.id),
name: text('name').notNull(),
stage: text('stage', { enum: ['sproutling', 'cultivar', 'bloomform', 'legendary'] }).notNull(),
path: text('path', { enum: ['tender', 'watcher', 'lucky'] }),
bondLevel: integer('bond_level').notNull().default(1),
bondXP: integer('bond_xp').notNull().default(0),
personality: text('personality', { mode: 'json' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
});
Logging & Telemetry
interface GameEvent {
type: string;
timestamp: Date;
sessionId: string;
userId?: string;
properties: Record<string, any>;
}
class Telemetry {
private queue: GameEvent[] = [];
private flushInterval: Timer;
constructor(
private endpoint: string,
private batchSize = 10,
private flushIntervalMs = 5000
) {
this.flushInterval = setInterval(() => this.flush(), flushIntervalMs);
}
track(type: string, properties: Record<string, any> = {}): void {
const event: GameEvent = {
type,
timestamp: new Date(),
sessionId: this.getSessionId(),
userId: this.getUserId(),
properties: {
...properties,
// Auto-add context
url: typeof window !== 'undefined' ? window.location.href : undefined,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
},
};
this.queue.push(event);
if (this.queue.length >= this.batchSize) {
this.flush();
}
}
private async flush(): Promise<void> {
if (this.queue.length === 0) return;
const batch = this.queue.splice(0, this.batchSize);
try {
await fetch(this.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events: batch }),
});
} catch (error) {
// Re-queue failed events (with limit)
if (this.queue.length < 100) {
this.queue.unshift(...batch);
}
console.error('Telemetry flush failed:', error);
}
}
private getSessionId(): string {
// Session ID from storage or generate new
let sessionId = sessionStorage.getItem('fip_session_id');
if (!sessionId) {
sessionId = crypto.randomUUID();
sessionStorage.setItem('fip_session_id', sessionId);
}
return sessionId;
}
private getUserId(): string | undefined {
// Get from auth state
return undefined; // Implement based on auth system
}
destroy(): void {
clearInterval(this.flushInterval);
this.flush();
}
}
// Standard events
const telemetry = new Telemetry('/api/telemetry');
// Usage
telemetry.track('day_started', { dayNumber: 5, phase: 'growth' });
telemetry.track('bet_placed', { potId: 'water', betType: 'raise', amount: 100 });
telemetry.track('trigger_earned', { triggerType: 'perfect_read' });
telemetry.track('session_ended', { duration: 1234, daysPlayed: 3 });
Security Patterns
// Input validation with Zod
import { z } from 'zod';
const PlaceBetSchema = z.object({
potId: z.enum(['water', 'sun', 'pest', 'growth']),
betType: z.enum(['fold', 'call', 'raise', 'all-in']),
amount: z.number().int().min(0).max(10000),
});
// Rate limiting
class RateLimiter {
private requests: Map<string, number[]> = new Map();
constructor(
private windowMs: number,
private maxRequests: number
) {}
isAllowed(key: string): boolean {
const now = Date.now();
const windowStart = now - this.windowMs;
// Get and filter old requests
const timestamps = (this.requests.get(key) || [])
.filter(t => t > windowStart);
if (timestamps.length >= this.maxRequests) {
return false;
}
timestamps.push(now);
this.requests.set(key, timestamps);
return true;
}
}
// Server-side validation wrapper
function validateRequest<T>(
schema: z.ZodSchema<T>,
data: unknown,
context: { userId: string; ip: string }
): T {
// Rate limit check
const rateLimiter = new RateLimiter(60000, 100); // 100 req/min
if (!rateLimiter.isAllowed(context.userId)) {
throw new Error('Rate limit exceeded');
}
// Schema validation
const result = schema.safeParse(data);
if (!result.success) {
console.warn('Validation failed', {
userId: context.userId,
errors: result.error.errors
});
throw new Error('Invalid request data');
}
return result.data;
}
// Anti-cheat: Server-authoritative game state
class GameStateValidator {
validateDayAdvance(
currentState: GameState,
proposedState: GameState
): boolean {
// Server recalculates expected state
const expectedState = this.calculateNextState(currentState);
// Compare critical values
if (proposedState.coins > expectedState.maxPossibleCoins) {
this.flagSuspicious('coin_manipulation', currentState.userId);
return false;
}
if (proposedState.dayNumber !== expectedState.dayNumber) {
this.flagSuspicious('day_skip', currentState.userId);
return false;
}
return true;
}
private calculateNextState(current: GameState): ExpectedState {
// Server-side calculation of valid state ranges
return {} as ExpectedState;
}
private flagSuspicious(type: string, userId: string): void {
// Log for review
console.warn('Suspicious activity', { type, userId });
}
}
Part VII: Tutorial System Design
Progressive Disclosure Pattern
interface TutorialStep {
id: string;
trigger: TutorialTrigger;
content: TutorialContent;
completion: CompletionCondition;
next?: string;
}
type TutorialTrigger =
| { type: 'first_visit'; screen: string }
| { type: 'action'; action: string }
| { type: 'state'; condition: (state: GameState) => boolean }
| { type: 'time'; delayMs: number };
interface TutorialContent {
title: string;
body: string;
highlight?: string; // CSS selector to highlight
position?: 'top' | 'bottom' | 'left' | 'right' | 'center';
character?: 'simulin' | 'npc';
allowSkip?: boolean;
}
type CompletionCondition =
| { type: 'click'; target: string }
| { type: 'action'; action: string }
| { type: 'dismiss' }
| { type: 'state'; condition: (state: GameState) => boolean };
const TUTORIAL_FLOW: TutorialStep[] = [
{
id: 'welcome',
trigger: { type: 'first_visit', screen: 'farm' },
content: {
title: 'Welcome to Purria!',
body: 'Your adventure in tulip farming begins here.',
character: 'simulin',
position: 'center',
},
completion: { type: 'dismiss' },
next: 'intro_hex',
},
{
id: 'intro_hex',
trigger: { type: 'action', action: 'tutorial_continue' },
content: {
title: 'Your Farm',
body: 'Each hex is a plot for growing tulips. Tap one to plant!',
highlight: '.hex-grid',
position: 'bottom',
},
completion: { type: 'action', action: 'hex_tap' },
next: 'intro_pots',
},
{
id: 'intro_pots',
trigger: { type: 'action', action: 'plant_tulip' },
content: {
title: 'The Meta-Pots',
body: 'These four pots fill based on daily events. Place bets on which will hit their threshold!',
highlight: '.pot-display',
position: 'top',
},
completion: { type: 'click', target: '.pot-display' },
next: 'first_bet',
},
// ... more steps
];
class TutorialManager {
private completedSteps: Set<string> = new Set();
private currentStep: TutorialStep | null = null;
constructor(
private steps: TutorialStep[],
private onShow: (step: TutorialStep) => void,
private onHide: () => void
) {
this.loadProgress();
}
checkTriggers(context: { screen: string; action?: string; state: GameState }): void {
for (const step of this.steps) {
if (this.completedSteps.has(step.id)) continue;
if (this.currentStep?.id === step.id) continue;
if (this.matchesTrigger(step.trigger, context)) {
this.showStep(step);
break;
}
}
}
private matchesTrigger(trigger: TutorialTrigger, context: any): boolean {
switch (trigger.type) {
case 'first_visit':
return context.screen === trigger.screen &&
!this.hasVisited(trigger.screen);
case 'action':
return context.action === trigger.action;
case 'state':
return trigger.condition(context.state);
default:
return false;
}
}
private showStep(step: TutorialStep): void {
this.currentStep = step;
this.onShow(step);
}
completeCurrentStep(): void {
if (!this.currentStep) return;
this.completedSteps.add(this.currentStep.id);
this.saveProgress();
const nextId = this.currentStep.next;
this.currentStep = null;
this.onHide();
if (nextId) {
const nextStep = this.steps.find(s => s.id === nextId);
if (nextStep && nextStep.trigger.type === 'action') {
// Auto-show chained steps
this.showStep(nextStep);
}
}
}
private loadProgress(): void {
const saved = localStorage.getItem('tutorial_progress');
if (saved) {
this.completedSteps = new Set(JSON.parse(saved));
}
}
private saveProgress(): void {
localStorage.setItem(
'tutorial_progress',
JSON.stringify([...this.completedSteps])
);
}
private hasVisited(screen: string): boolean {
return localStorage.getItem(`visited_${screen}`) === 'true';
}
reset(): void {
this.completedSteps.clear();
localStorage.removeItem('tutorial_progress');
}
}
Contextual Help System
interface HelpTopic {
id: string;
title: string;
summary: string;
content: string;
relatedTopics?: string[];
triggers?: string[]; // UI elements that show this help
}
const HELP_TOPICS: HelpTopic[] = [
{
id: 'betting',
title: 'How Betting Works',
summary: 'Place bets on which pots will fill',
content: `
Each day, the four Meta-Pots fill based on events on your farm.
**Bet Types:**
- **Fold**: Skip this pot (safe, no reward)
- **Call**: Small bet, small reward
- **Raise**: Medium bet, medium reward
- **All-In**: Big bet, big reward (or loss!)
**Thresholds:**
Pots have thresholds at 50%, 80%, and 100%. Higher thresholds = higher payouts.
`,
relatedTopics: ['pots', 'triggers', 'combos'],
triggers: ['.bet-button', '.pot-display'],
},
// ... more topics
];
function HelpButton({ topicId }: { topicId: string }) {
const [open, setOpen] = useState(false);
const topic = HELP_TOPICS.find(t => t.id === topicId);
if (!topic) return null;
return (
<>
<button
onClick={() => setOpen(true)}
className="help-button"
aria-label={`Help: ${topic.title}`}
>
?
</button>
<Dialog open={open} onClose={() => setOpen(false)}>
<Dialog.Title>{topic.title}</Dialog.Title>
<Dialog.Content>
<Markdown>{topic.content}</Markdown>
{topic.relatedTopics && (
<div className="related-topics">
<h4>Related:</h4>
{topic.relatedTopics.map(id => (
<HelpLink key={id} topicId={id} />
))}
</div>
)}
</Dialog.Content>
</Dialog>
</>
);
}
Part VIII: Quality Assurance
Testing Strategy
// Unit test example (Vitest)
import { describe, it, expect } from 'vitest';
import { ComboEngine } from './combo-engine';
describe('ComboEngine', () => {
const engine = new ComboEngine();
describe('evaluateWeek', () => {
it('detects pair with 2 matching triggers', () => {
const triggers = [
{ type: 'first_bloom', dayNumber: 1, earnedAt: new Date() },
{ type: 'first_bloom', dayNumber: 2, earnedAt: new Date() },
];
const result = engine.evaluateWeek(triggers);
expect(result.handType).toBe('pair');
expect(result.multiplier).toBe(2);
});
it('detects three of a kind over pair', () => {
const triggers = [
{ type: 'perfect_read', dayNumber: 1, earnedAt: new Date() },
{ type: 'perfect_read', dayNumber: 2, earnedAt: new Date() },
{ type: 'perfect_read', dayNumber: 3, earnedAt: new Date() },
{ type: 'first_bloom', dayNumber: 4, earnedAt: new Date() },
{ type: 'first_bloom', dayNumber: 5, earnedAt: new Date() },
];
const result = engine.evaluateWeek(triggers);
// Full house beats three of a kind
expect(result.handType).toBe('full_house');
expect(result.multiplier).toBe(15);
});
});
});
// Integration test example
describe('Betting Flow', () => {
it('completes full betting cycle', async () => {
const { gameState, bettingSystem } = createTestGame();
// Place bets
bettingSystem.placeBet('water', 'raise', 100);
bettingSystem.placeBet('sun', 'fold', 0);
// Simulate day completion
gameState.advanceToResolution();
// Resolve bets
const results = bettingSystem.resolveBets(gameState.pots);
expect(results).toHaveLength(2);
expect(results[0].potId).toBe('water');
expect(results[1].potId).toBe('sun');
expect(results[1].payout).toBe(0); // Fold always 0
});
});
Code Review Checklist
## Code Review Checklist
### Correctness
- [ ] Logic is correct and handles edge cases
- [ ] No off-by-one errors
- [ ] Null/undefined handled appropriately
- [ ] Async operations handle errors
### Type Safety
- [ ] No `any` types without justification
- [ ] Proper use of generics
- [ ] Return types explicit on public APIs
- [ ] Zod schemas match TypeScript types
### Performance
- [ ] No unnecessary re-renders (React.memo where needed)
- [ ] Large lists are virtualized
- [ ] Expensive calculations are memoized
- [ ] No memory leaks (cleanup in useEffect)
### Security
- [ ] User input is validated
- [ ] No secrets in client code
- [ ] Server validates all mutations
- [ ] Rate limiting considered
### Maintainability
- [ ] Functions are small and focused
- [ ] Clear variable/function names
- [ ] Comments explain "why", not "what"
- [ ] No magic numbers
### Testing
- [ ] Unit tests for logic
- [ ] Edge cases covered
- [ ] Tests are readable
### Accessibility
- [ ] Interactive elements are keyboard accessible
- [ ] ARIA labels where needed
- [ ] Color contrast sufficient
- [ ] Focus management correct
Reference Documents
The following reference documents provide deep-dive implementations:
| Document | Purpose |
|---|---|
references/game-patterns.md |
Extended patterns: ECS, behavior trees, scene management |
references/casino-implementation.md |
Full casino game implementations: poker, blackjack, slots |
references/reward-progression.md |
Complete reward systems, balancing, economy design |
references/game-ui-patterns.md |
UI component library, responsive patterns, accessibility |
references/tutorial-onboarding.md |
Tutorial flows, contextual help, player guidance |
references/architecture-quality.md |
Code organization, refactoring patterns, documentation |
references/data-infrastructure.md |
Schema design, migrations, telemetry, security |
Game Engineering Team – Building the Best Game Ever For Farming in Purria v3