ccalebcarter

zustand-game-patterns

1
0
# Install this skill:
npx skills add ccalebcarter/purria-skills --skill "zustand-game-patterns"

Install specific skill from multi-skill repository

# Description

Zustand state management patterns optimized for games including persistence, undo/redo, time-travel debugging, subscriptions, and performance optimization. Use when designing game state architecture, implementing save/load, optimizing re-renders, or debugging state issues. Triggers on requests involving Zustand stores, game state management, state persistence, or React performance in games.

# SKILL.md


name: zustand-game-patterns
description: Zustand state management patterns optimized for games including persistence, undo/redo, time-travel debugging, subscriptions, and performance optimization. Use when designing game state architecture, implementing save/load, optimizing re-renders, or debugging state issues. Triggers on requests involving Zustand stores, game state management, state persistence, or React performance in games.


Zustand Game Patterns

Production-ready patterns for managing complex game state with Zustand.

Store Architecture

Modular Store Pattern

Split large game state into focused slices:

// stores/slices/timeSlice.ts
import { StateCreator } from 'zustand';
import { GameState } from '../types';

export interface TimeSlice {
  time: GameTime;
  advancePhase: () => void;
  setDay: (day: number) => void;
}

export const createTimeSlice: StateCreator<
  GameState,
  [['zustand/immer', never]],
  [],
  TimeSlice
> = (set) => ({
  time: { season: 1, day: 1, phase: 'morning' },

  advancePhase: () => set((state) => {
    const phases = ['morning', 'action', 'resolution', 'night'];
    const idx = phases.indexOf(state.time.phase);
    state.time.phase = phases[(idx + 1) % 4];
    if (idx === 3) state.time.day = Math.min(state.time.day + 1, 42);
  }),

  setDay: (day) => set((state) => {
    state.time.day = Math.max(1, Math.min(day, 42));
  }),
});

Combining Slices

// stores/gameStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { subscribeWithSelector } from 'zustand/middleware';
import { createTimeSlice, TimeSlice } from './slices/timeSlice';
import { createResourceSlice, ResourceSlice } from './slices/resourceSlice';
import { createHexSlice, HexSlice } from './slices/hexSlice';

type GameState = TimeSlice & ResourceSlice & HexSlice;

export const useGameStore = create<GameState>()(
  subscribeWithSelector(
    immer((...args) => ({
      ...createTimeSlice(...args),
      ...createResourceSlice(...args),
      ...createHexSlice(...args),
    }))
  )
);

Persistence

Local Storage Save/Load

import { persist, createJSONStorage } from 'zustand/middleware';

export const useGameStore = create<GameState>()(
  persist(
    subscribeWithSelector(
      immer((set, get) => ({
        // ... state and actions
      }))
    ),
    {
      name: 'game-save',
      storage: createJSONStorage(() => localStorage),

      // Only persist specific fields
      partialize: (state) => ({
        time: state.time,
        resources: state.resources,
        score: state.score,
        // Exclude transient state like selectedHex
      }),

      // Handle version migrations
      version: 1,
      migrate: (persisted, version) => {
        if (version === 0) {
          // Migration from v0 to v1
          return { ...persisted, newField: 'default' };
        }
        return persisted;
      },
    }
  )
);

Multiple Save Slots

interface SaveSlot {
  id: string;
  name: string;
  timestamp: number;
  data: Partial<GameState>;
}

const SAVE_SLOTS_KEY = 'game-saves';

export const saveToSlot = (slotId: string, name: string) => {
  const state = useGameStore.getState();
  const saves = JSON.parse(localStorage.getItem(SAVE_SLOTS_KEY) || '[]');

  const saveData: SaveSlot = {
    id: slotId,
    name,
    timestamp: Date.now(),
    data: {
      time: state.time,
      resources: state.resources,
      score: state.score,
      hexes: Object.fromEntries(state.hexes),
    },
  };

  const idx = saves.findIndex((s: SaveSlot) => s.id === slotId);
  if (idx >= 0) saves[idx] = saveData;
  else saves.push(saveData);

  localStorage.setItem(SAVE_SLOTS_KEY, JSON.stringify(saves));
};

export const loadFromSlot = (slotId: string) => {
  const saves = JSON.parse(localStorage.getItem(SAVE_SLOTS_KEY) || '[]');
  const slot = saves.find((s: SaveSlot) => s.id === slotId);

  if (slot) {
    useGameStore.setState({
      ...slot.data,
      hexes: new Map(Object.entries(slot.data.hexes || {})),
    });
  }
};

Undo/Redo (Time Travel)

import { temporal } from 'zundo';

export const useGameStore = create<GameState>()(
  temporal(
    immer((set) => ({
      // ... state and actions
    })),
    {
      // Limit history size
      limit: 50,

      // Only track specific changes
      partialize: (state) => ({
        hexes: state.hexes,
        resources: state.resources,
      }),

      // Equality check to prevent duplicate history
      equality: (a, b) => JSON.stringify(a) === JSON.stringify(b),
    }
  )
);

// Usage
const { undo, redo, pastStates, futureStates } = useGameStore.temporal.getState();

Subscriptions & Side Effects

Subscribe to State Changes

// Subscribe outside React
const unsubscribe = useGameStore.subscribe(
  (state) => state.time.phase,
  (phase, prevPhase) => {
    console.log(`Phase changed: ${prevPhase} → ${phase}`);

    // Trigger side effects
    if (phase === 'morning') {
      useGameStore.getState().spawnTrouble();
    }
  }
);

// Subscribe to multiple selectors
useGameStore.subscribe(
  (state) => ({ day: state.time.day, phase: state.time.phase }),
  ({ day, phase }) => {
    // Analytics tracking
    analytics.track('game_progress', { day, phase });
  },
  { equalityFn: shallow }
);

React Subscription Hook

import { useEffect } from 'react';
import { useShallow } from 'zustand/react/shallow';

// Optimized selector with shallow comparison
export const useGameTime = () => useGameStore(
  useShallow((state) => ({
    day: state.time.day,
    phase: state.time.phase,
    season: state.time.season,
  }))
);

// Effect on state change
export const usePhaseEffects = () => {
  const phase = useGameStore((s) => s.time.phase);

  useEffect(() => {
    if (phase === 'resolution') {
      // Play resolution animation
      playSound('phase_change');
    }
  }, [phase]);
};

Performance Optimization

Selector Memoization

// ❌ Bad: Creates new object every render
const { time, resources } = useGameStore((state) => ({
  time: state.time,
  resources: state.resources,
}));

// ✅ Good: Use shallow comparison
import { useShallow } from 'zustand/react/shallow';

const { time, resources } = useGameStore(
  useShallow((state) => ({
    time: state.time,
    resources: state.resources,
  }))
);

// ✅ Best: Separate selectors for independent updates
const time = useGameStore((s) => s.time);
const resources = useGameStore((s) => s.resources);

Computed Selectors

// Create memoized selectors for derived state
import { createSelector } from 'reselect';

const selectTroubles = (state: GameState) => state.troubles;
const selectGridSize = (state: GameState) => state.gridSize;

export const selectTroubleCount = createSelector(
  [selectTroubles],
  (troubles) => Object.keys(troubles).length
);

export const selectTotalTroubleHexes = createSelector(
  [selectTroubles],
  (troubles) => Object.values(troubles)
    .reduce((sum, t) => sum + t.hexCoords.length, 0)
);

// Usage
const troubleCount = useGameStore(selectTroubleCount);

Batched Updates

// Batch multiple state changes
const endDay = () => {
  useGameStore.setState((state) => {
    // All updates in single render
    state.metaPots.activeBets = [];
    state.time.phase = 'morning';
    state.time.day += 1;
    state.resources.stamina = 100;
  });
};

Devtools Integration

import { devtools } from 'zustand/middleware';

export const useGameStore = create<GameState>()(
  devtools(
    subscribeWithSelector(
      immer((set) => ({
        // ... state and actions

        // Named actions for devtools
        advancePhase: () => set(
          (state) => { /* ... */ },
          false,
          'time/advancePhase' // Action name in devtools
        ),
      }))
    ),
    {
      name: 'GameStore',
      enabled: process.env.NODE_ENV === 'development',
    }
  )
);

Testing Patterns

// Reset store between tests
beforeEach(() => {
  useGameStore.setState({
    time: { season: 1, day: 1, phase: 'morning' },
    resources: { tulipBulbs: 10, coins: 3000, stamina: 100 },
    // ... initial state
  });
});

// Test actions
test('advancePhase cycles through phases', () => {
  const { advancePhase } = useGameStore.getState();

  expect(useGameStore.getState().time.phase).toBe('morning');
  advancePhase();
  expect(useGameStore.getState().time.phase).toBe('action');
  advancePhase();
  expect(useGameStore.getState().time.phase).toBe('resolution');
});

// Test subscriptions
test('spawns trouble on morning phase', () => {
  const spawnTrouble = vi.spyOn(useGameStore.getState(), 'spawnTrouble');

  // Advance to morning
  useGameStore.setState({ time: { ...initialTime, phase: 'night' } });
  useGameStore.getState().advancePhase();

  expect(spawnTrouble).toHaveBeenCalled();
});

# Supported AI Coding Agents

This skill is compatible with the SKILL.md standard and works with all major AI coding agents:

Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.