Skip to content

Otter Camp Game Systems

This document covers the architecture and APIs for Otter Camp's game systems built with Phaser.js.

System Architecture

┌─────────────────────────────────────────────────────────────────┐
│                    Otter Camp Game Layer                         │
│                                                                  │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │                      Phaser Scenes                           ││
│  │  BootScene → CampHubScene ⟷ CombatScene                     ││
│  │                 ↓                                            ││
│  │            Exploration Zones                                 ││
│  └─────────────────────────────────────────────────────────────┘│
│                           │                                      │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │                     Game Systems                             ││
│  │  CombatSystem │ QuestChainSystem │ ProgressionSystem │ ...  ││
│  └─────────────────────────────────────────────────────────────┘│
│                           │                                      │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │                      Services                                ││
│  │  IndexedDBCombatService │ LocalStorage │ ...                ││
│  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘

CombatSystem API

Overview

The CombatSystem manages turn-based combat encounters. It follows a singleton pattern and uses an event-driven architecture.

Location: frontend/app/game/src/systems/CombatSystem.ts

Types

typescript
// Combat phase lifecycle
type CombatPhase = 'idle' | 'intro' | 'active' | 'victory' | 'defeat' | 'retreat';

// Combat actions available to player
type CombatActionType = 'attack' | 'defend' | 'wait' | 'retreat' | 'ability';

// Actor in combat (player or enemy)
interface CombatActor {
  id: string;
  name: string;
  health: number;
  maxHealth: number;
  energy: number;
  maxEnergy: number;
  speed: number;
  attack: number;
  defense: number;
  isDefending: boolean;
}

// Combat action input
interface CombatAction {
  type: CombatActionType;
  actorId: string;
  targetId?: string;
  abilityId?: string;  // For ability actions
}

// Action result
interface ActionResult {
  action: CombatAction;
  damage?: number;
  energyCost?: number;
  energyGain?: number;
  message: string;
}

// Full combat state
interface CombatState {
  isActive: boolean;
  phase: CombatPhase;
  player: CombatActor;
  enemy: CombatActor;
  currentTurn: number;
  turnOrder: string[];
  lastAction: ActionResult | null;
  preCombatPosition: { x: number; y: number } | null;
  zone: string;
}

Constructor

typescript
constructor(persistenceService: CombatPersistenceService)

Parameters:

  • persistenceService - Service for IndexedDB persistence (crash recovery)

Example:

typescript
const combatService = new IndexedDBCombatService();
const combatSystem = new CombatSystem(combatService);

Methods

startCombat

Initiates a new combat encounter.

typescript
async startCombat(
  playerId: string,
  enemyId: string,
  zone: string,
  options?: {
    playerStats?: Partial<CombatActor>;
    enemyStats?: Partial<CombatActor>;
  }
): Promise<void>

Parameters:

  • playerId - Player identifier
  • enemyId - Enemy identifier
  • zone - Zone where combat is occurring
  • options.playerStats - Override default player stats
  • options.enemyStats - Override default enemy stats

Example:

typescript
await combatSystem.startCombat('player-1', 'wild-raccoon', 'forest', {
  enemyStats: {
    name: 'Wild Raccoon',
    health: 50,
    attack: 8
  }
});

Behavior:

  1. Creates player and enemy actors with default or custom stats
  2. Calculates turn order based on speed (higher speed goes first)
  3. Transitions phase: idleintroactive
  4. Persists state to IndexedDB for crash recovery

executeAction

Executes a combat action for an actor.

typescript
async executeAction(action: CombatAction): Promise<ActionResult>

Parameters:

  • action - The action to execute (attack, defend, wait, retreat)

Returns: ActionResult with damage/energy changes and message

Example:

typescript
const result = await combatSystem.executeAction({
  type: 'attack',
  actorId: 'player',
  targetId: 'enemy'
});

console.log(result.message); // "Player attacks Wild Raccoon for 12 damage"
console.log(result.damage);  // 12

Action Behaviors:

ActionEffectEnergy Cost
attackDeal damage based on attack stat10 energy
defendReduce incoming damage by 50%5 energy
waitSkip turn, regenerate energy0 (gains 10)
retreatEnd combat, return to exploration25% of max
abilityUse archetype ability (see below)25 energy

Ability System

The ability system allows players to use special combat abilities based on their dominant archetype.

Location: frontend/app/game/src/systems/AbilityRegistry.ts

Ability Registry

typescript
interface CombatAbility {
  id: string;
  name: string;
  description: string;
  archetype: Archetype;
  energyCost: number;      // Always 25
  cooldownTurns: number;   // Always 3
  effect: AbilityEffect;
}

interface AbilityEffect {
  type: 'buff';
  target: 'self' | 'enemy';
  buffType: 'defense_buff' | 'damage_buff' | 'absorb_hit' | 'ignore_defense';
  magnitude: number;       // Percentage (0-100)
  duration: number;        // Turns (0 for one-shot effects)
}

Available Abilities

ArchetypeAbility IDEffectDuration
Rulerruler-barrier+50% defense2 turns
Artistartist-inspire+25% damage2 turns
Caregivercaregiver-sootheAbsorb next hitOne-shot
Explorerexplorer-weaknessIgnore defenseOne-shot

CombatSystem Ability Methods

getPlayerAbility

Get the player's available combat ability.

typescript
getPlayerAbility(): CombatAbility | null

Returns the ability for the player's current archetype, or null if the archetype has no combat ability.

canUseAbility

Check if the player can use their ability.

typescript
canUseAbility(): boolean

Returns true if:

  • Player has an ability (archetype with combat ability)
  • Combat is active and in 'active' phase
  • It's the player's turn
  • Ability is not on cooldown
  • Player has enough energy (25)

getAbilityCooldown

Get the remaining cooldown for the player's ability.

typescript
getAbilityCooldown(): number

Returns the number of turns remaining until the ability can be used again (0 = ready).

Using Abilities

typescript
// Check and use ability
if (combatSystem.canUseAbility()) {
  const ability = combatSystem.getPlayerAbility();
  if (ability) {
    const result = await combatSystem.executeAction({
      type: 'ability',
      actorId: 'player',
      abilityId: ability.id
    });
    console.log(result.message); // "Player used Construct Barrier!"
  }
}

Buff System

Buffs are tracked in the combat state and applied automatically:

typescript
interface ActiveBuff {
  id: string;
  abilityId: string;
  name: string;
  effectType: 'defense_buff' | 'damage_buff' | 'absorb_hit' | 'ignore_defense';
  magnitude: number;
  turnsRemaining: number;
  target: 'player' | 'enemy';
  consumed?: boolean;  // For one-shot buffs
}

Buff Processing:

  • Timed buffs (defense_buff, damage_buff) decrement each turn and expire when turnsRemaining reaches 0
  • One-shot buffs (absorb_hit, ignore_defense) are consumed when their effect triggers
  • Buffs are applied automatically during damage calculation

Events

New events for the ability system:

EventDataDescription
ability-used{ ability, result }Ability was used
buff-applied{ buff }Buff was applied to entity
buff-expired{ buff }Buff expired or was consumed

subscribe

Subscribe to combat events.

typescript
subscribe(event: CombatEvent, callback: (data: CombatEventData) => void): () => void

Events:

  • combat-started - Combat encounter begins
  • combat-ended - Combat ends (victory/defeat/retreat)
  • action-executed - An action was performed
  • turn-changed - Turn advanced to next actor
  • phase-changed - Combat phase changed
  • state-changed - Any state change

Returns: Unsubscribe function

Example:

typescript
const unsubscribe = combatSystem.subscribe('combat-ended', (data) => {
  console.log('Combat ended:', data.reason); // 'victory', 'defeat', or 'retreat'
  if (data.reason === 'retreat') {
    // Return player to pre-combat position
    this.playerSprite.setPosition(data.preCombatPosition.x, data.preCombatPosition.y);
  }
});

// Later: clean up
unsubscribe();

getState

Returns the current combat state.

typescript
getState(): CombatState

getPhase

Returns the current combat phase.

typescript
getPhase(): CombatPhase

isPlayerTurn

Check if it's currently the player's turn.

typescript
isPlayerTurn(): boolean

selectEnemyAction

AI method to select an enemy action.

typescript
selectEnemyAction(): CombatAction

Returns: Action for the enemy AI to take (currently simple: attack if has energy)

Configuration

Combat balance is configured via COMBAT_CONFIG:

typescript
const COMBAT_CONFIG = {
  DEFAULT_PLAYER_STATS: {
    health: 100,
    maxHealth: 100,
    energy: 100,
    maxEnergy: 100,
    speed: 10,
    attack: 12,
    defense: 5,
  },
  DEFAULT_ENEMY_STATS: {
    health: 50,
    maxHealth: 50,
    energy: 50,
    maxEnergy: 50,
    speed: 8,
    attack: 8,
    defense: 3,
  },
  ATTACK_ENERGY_COST: 10,
  DEFEND_ENERGY_COST: 5,
  WAIT_ENERGY_REGEN: 10,
  RETREAT_ENERGY_PERCENT: 0.25,
  DEFEND_DAMAGE_REDUCTION: 0.5,
  INTRO_PHASE_DURATION_MS: 500,
};

Persistence

Combat state is automatically persisted to IndexedDB via CombatPersistenceService:

typescript
interface CombatPersistenceService {
  saveCombat(state: CombatState): Promise<void>;
  loadCombat(): Promise<CombatState | null>;
  clearCombat(): Promise<void>;
}

Recovery Flow:

  1. On page load, check for persisted combat state
  2. If found, resume combat with recovered state
  3. On combat end, clear persisted state

CombatScene

The Phaser scene that renders the combat interface.

Location: frontend/app/game/src/scenes/CombatScene.ts

Scene Data

When transitioning to CombatScene, pass the combat system:

typescript
this.scene.start('CombatScene', { combatSystem: this.combatSystem });

UI Components

The scene includes:

  • Player sprite (left side)
  • Enemy sprite (right side)
  • Health bars (green, above sprites)
  • Energy bars (blue, below health)
  • Turn indicator (center top)
  • Action buttons (Attack, Defend, Wait, Retreat)

Animations

  • Damage numbers: Float up from target with tween
  • Attack: Sprite moves toward target and back
  • Damage flash: Health bar flashes red
  • Transitions: Fade in/out between scenes

Integration with CampHubScene

Setup

typescript
// In CampHubScene.create()
private setupCombatSystem(): void {
  const combatService = new IndexedDBCombatService();
  this.combatSystem = new CombatSystem(combatService);

  // Subscribe to combat end to transition back
  this.combatSystem.subscribe('combat-ended', (data) => {
    this.transitionFromCombat(data);
  });
}

Starting Combat

typescript
// Trigger test combat with C key
this.input.keyboard?.on('keydown-C', () => {
  this.startTestCombat();
});

private startTestCombat(): void {
  this.combatSystem.startCombat('player', 'test-enemy', 'camp-hub', {
    enemyStats: {
      name: 'Wild Raccoon',
      health: 30,
      maxHealth: 30,
    }
  });
  this.transitionToCombat();
}

Scene Transitions

typescript
private transitionToCombat(): void {
  this.cameras.main.fadeOut(300, 0, 0, 0);
  this.time.delayedCall(300, () => {
    this.scene.start('CombatScene', { combatSystem: this.combatSystem });
  });
}

private transitionFromCombat(data: CombatEndData): void {
  if (data.reason === 'retreat' && data.preCombatPosition) {
    // Restore player position
    this.playerSprite.setPosition(
      data.preCombatPosition.x,
      data.preCombatPosition.y
    );
  }
}

Testing

Combat system tests are located at: frontend/app/game/tests/combat-system.test.ts

Running Tests

bash
cd frontend/app/game
pnpm vitest run tests/combat-system.test.ts

Test Patterns

Use fake timers for async operations:

typescript
beforeEach(() => {
  vi.useFakeTimers();
  combatSystem = new CombatSystem(mockService);
});

// Helper to start combat and wait for active phase
async function startCombatAndWaitForActive(): Promise<void> {
  await combatSystem.startCombat('player', 'enemy', 'zone');
  vi.advanceTimersByTime(600); // Past intro phase
  expect(combatSystem.getPhase()).toBe('active');
}

EnemyRegistry API (Story 10-15c)

Location: frontend/app/game/src/systems/EnemyRegistry.ts

Types

typescript
type EnemyRarity = 'common' | 'uncommon' | 'rare';
type CombatZoneId = 'camp-hub' | 'forest' | 'caves';

interface EnemyDefinition {
  id: string;
  name: string;
  zone: CombatZoneId;
  stats: Omit<CombatEntity, 'id' | 'name'>;
  flavorText: string;
  spriteKey: string;
  rarity: EnemyRarity;
  xpReward: [number, number];  // [min, max]
  resourceType: 'wood' | 'stone' | 'shells';
  resourceReward: [number, number];  // [min, max]
}

Functions

typescript
// Get enemy by ID
getEnemyById(id: string): EnemyDefinition | null

// Get all enemies for a zone
getEnemiesForZone(zone: CombatZoneId): EnemyDefinition[]

// Get random enemy weighted by rarity
getRandomEnemyForZone(zone: CombatZoneId): EnemyDefinition

// Get all enemies
getAllEnemies(): EnemyDefinition[]

MVP Enemies

IDNameZoneHPAttackDefenseRarity
river-ratRiver Ratcamp-hub3082common
shadow-snapperShadow Snapperforest60125uncommon
crystal-crabCrystal Crabcaves1001510rare

LootTable API (Story 10-15c)

Location: frontend/app/game/src/systems/LootTable.ts

Types

typescript
type LootType = 'xp' | 'resource' | 'fragment';
type LootRarity = 'common' | 'uncommon' | 'rare';
type ResourceType = 'wood' | 'stone' | 'shells';

interface LootDrop {
  type: LootType;
  id: string;
  name: string;
  quantity: number;
  rarity: LootRarity;
  resourceType?: ResourceType;
  questId?: string;
}

interface LootResult {
  drops: LootDrop[];
  totalXp: number;
  overflowXp: number;
  enemyId: string;
  timestamp: number;
}

Functions

typescript
// Generate loot from defeated enemy
generateLoot(
  enemy: EnemyDefinition,
  playerResources?: Record<ResourceType, number>,
  resourceCaps?: Record<ResourceType, number>
): LootResult

// Simulate multiple loot drops for testing
simulateLootDrops(enemy: EnemyDefinition, iterations: number): SimulationResult

Drop Rates

  • XP: Always drops (100%)
  • Resources: Always drops, quantity varies by enemy
  • Fragments: 5% chance (RARE_FRAGMENT_DROP_RATE)

Resource Overflow

If player resources are at cap, overflow is converted to bonus XP:

typescript
overflowXp = overflowQuantity * OVERFLOW_XP_BONUS  // 5 XP per overflow resource

CombatZoneRegistry API (Story 10-15c)

Location: frontend/app/game/src/systems/CombatZoneRegistry.ts

Types

typescript
interface CombatZoneDefinition {
  id: CombatZoneId;
  name: string;
  description: string;
  resourceType: ResourceType;
  dangerLevel: number;  // 1-5
  color: number;
  backgroundKey: string;
}

Functions

typescript
// Get zone by ID
getZoneById(id: CombatZoneId): CombatZoneDefinition | null

// Map ExplorationSystem zone ID to CombatZoneId
mapExplorationToCombatZone(explorationZone: string): CombatZoneId

// Get zone metadata
getZoneName(zoneId: CombatZoneId): string
getZoneDescription(zoneId: CombatZoneId): string
getZoneDangerLevel(zoneId: CombatZoneId): number
getZoneResourceType(zoneId: CombatZoneId): ResourceType

// Get random encounter for zone
getRandomEncounter(zoneId: CombatZoneId): EnemyDefinition

Zone Mapping

ExplorationSystem zones map to combat zones as follows:

Exploration ZoneCombat Zone
camp, zone-2, zone-3camp-hub
zone-4, zone-5, zone-6caves
zone-7, zone-8, zone-9forest

TutorialSystem API (Story 10-17)

Overview

The TutorialSystem manages Otto's interactive tutorials for new players. It follows the singleton + subscribe pattern and uses IndexedDB for persistence.

Locations:

  • frontend/app/game/src/systems/TutorialSystem.ts - Core state management
  • frontend/app/game/src/systems/TutorialController.ts - Scene integration
  • frontend/app/game/src/systems/TutorialRegistry.ts - Tutorial step definitions
  • frontend/app/game/src/ui/TutorialOverlay.ts - Vignette and highlight UI
  • frontend/app/game/src/ui/TutorialDialogBox.ts - Otto's dialog box

Types

typescript
// Tutorial trigger types
type TutorialTriggerType = 'first-visit' | 'first-action' | 'level-up' | 'quest-complete';

// Trigger configuration
interface TutorialTrigger {
  type: TutorialTriggerType;
  sceneId?: string;       // For first-visit triggers
  actionId?: string;      // For first-action triggers
  archetypeId?: string;   // For level-up triggers
  questId?: string;       // For quest-complete triggers
}

// Required actions for progression
interface RequiredAction {
  type: 'keypress' | 'move' | 'click' | 'interact';
  key?: string;           // e.g., 'SPACE', 'E', 'WASD'
  targetId?: string;      // For click/interact
  promptText: string;     // e.g., "Press SPACE to continue"
  completedText: string;  // e.g., "Great job!"
}

// Highlight configuration
interface HighlightConfig {
  targets: HighlightTarget[];
  arrowDirection?: 'up' | 'down' | 'left' | 'right';
  pulseAnimation?: boolean;
}

// Otto dialog configuration
interface OttoConfig {
  portrait: 'happy' | 'thinking' | 'excited' | 'concerned';
  text: string;
  typingSpeed?: number;   // ms per character (default: 30)
}

// Tutorial step
interface TutorialStep {
  id: string;
  trigger: TutorialTrigger;
  otto: OttoConfig;
  highlight?: HighlightConfig;
  action?: RequiredAction;
  nextStep?: string;      // Chain to next step
  dismissable?: boolean;  // Can advance without action
}

// Tutorial sequence
interface TutorialSequence {
  id: string;
  name: string;
  steps: Record<string, TutorialStep>;
  entryStepId: string;
}

// Tutorial state
interface TutorialState {
  isActive: boolean;
  currentSequence: TutorialSequence | null;
  currentStep: TutorialStep | null;
  completedSteps: Set<string>;
  disabledTutorials: boolean;
  actionCompleted: boolean;
}

TutorialSystem (Singleton)

typescript
import { getTutorialSystem, resetTutorialSystem } from './systems/TutorialSystem';

// Get singleton instance
const tutorialSystem = getTutorialSystem();

// Reset for testing
resetTutorialSystem();

Methods

registerSequence

Register a tutorial sequence.

typescript
registerSequence(sequence: TutorialSequence): void
setService

Set the persistence service.

typescript
setService(service: TutorialService): void
loadProgress

Load completed steps from storage.

typescript
async loadProgress(): Promise<void>
subscribe

Subscribe to state changes.

typescript
subscribe(callback: (state: TutorialState) => void): () => void

Returns: Unsubscribe function

subscribeToEvents

Subscribe to tutorial events.

typescript
subscribeToEvents(callback: (event: TutorialEvent) => void): () => void

Events:

  • step-started - New step began
  • step-completed - Step finished
  • step-skipped - Step was skipped
  • sequence-completed - All steps in sequence done
  • tutorials-disabled - User skipped all tutorials
onSceneVisit

Trigger first-visit tutorials for a scene.

typescript
async onSceneVisit(sceneId: string): Promise<void>
onActionPerformed

Trigger first-action tutorials.

typescript
async onActionPerformed(actionId: string): Promise<void>
onRequiredActionCompleted

Mark required action as completed.

typescript
onRequiredActionCompleted(): void
advance

Advance to next step or complete sequence.

typescript
advance(): void
disableTutorials

Disable all future tutorials.

typescript
disableTutorials(): void
isActive / areTutorialsDisabled / getCurrentStep

State accessors.

typescript
isActive(): boolean
areTutorialsDisabled(): boolean
getCurrentStep(): TutorialStep | null

TutorialController

Scene-level integration that ties together TutorialSystem, TutorialOverlay, and TutorialDialogBox.

typescript
import { createCampHubTutorialController } from './systems/TutorialController';

// In scene create()
this.tutorialController = createCampHubTutorialController(
  this,                                    // Phaser.Scene
  () => this.player.getPosition(),         // Position getter
  () => ({ x: body.velocity.x, y: body.velocity.y })  // Velocity getter
);

// Initialize (async)
await this.tutorialController.init();

// In scene update()
this.tutorialController.update(time, delta);

// External action trigger
this.tutorialController.triggerAction('building-interact');

// Check if tutorial is blocking
if (!this.tutorialController.isActive()) {
  // Allow other interactions
}

Factory Functions

typescript
// Camp Hub scene
createCampHubTutorialController(scene, getPlayerPosition, getPlayerVelocity)

// Quest Board overlay
createQuestBoardTutorialController(scene)

// Ritual Circle overlay
createRitualCircleTutorialController(scene)

// Combat scene
createCombatTutorialController(scene)

TutorialRegistry

Pre-defined tutorial sequences.

typescript
import { ALL_TUTORIALS, CAMP_HUB_TUTORIAL, QUEST_BOARD_TUTORIAL } from './systems/TutorialRegistry';

Available Tutorials:

IDSceneStepsDescription
camp-hub-tutorialcamp-hub4Welcome, movement, buildings, NPCs
quest-board-tutorialquest-board3Overview, details, voting
ritual-circle-tutorialritual-circle2Introduction, contributing
combat-tutorialcombat-start2Turn order, abilities
archetype-levelup-tutorial-1Level-up trigger

UI Components

TutorialOverlay

Renders vignette (background dim) with highlight cutouts.

typescript
const overlay = new TutorialOverlay({ scene });

// Show for a step
overlay.show(tutorialStep);

// Hide with callback
overlay.hide(() => console.log('Hidden'));

// Update highlight positions (for moving targets)
overlay.updateHighlights();

TutorialDialogBox

Renders Otto's dialog with portrait, typewriter text, and action prompts.

typescript
const dialogBox = new TutorialDialogBox({
  scene,
  onAdvance: () => tutorialSystem.advance(),
  onSkipTutorials: () => tutorialSystem.disableTutorials(),
});

// Show dialog
dialogBox.show(ottoConfig, requiredAction);

// Mark action as completed
dialogBox.setActionCompleted();

// Hide
dialogBox.hide();

Persistence

Tutorial progress is stored in IndexedDB via TutorialService:

typescript
interface TutorialService {
  saveProgress(record: TutorialProgressRecord): Promise<void>;
  loadProgress(): Promise<TutorialProgressRecord | null>;
  markStepCompleted(stepId: string): Promise<void>;
  clearProgress(): Promise<void>;
  disableTutorials(): Promise<void>;
  areTutorialsDisabled(): Promise<boolean>;
}

Factory:

typescript
import { getTutorialService, createMockTutorialService } from './services/tutorialService';

// Production (IndexedDB)
const service = getTutorialService();

// Testing
const mockService = createMockTutorialService();

Integration Example

typescript
// In CampHubScene.ts
import { TutorialController, createCampHubTutorialController } from '../systems/TutorialController';

export class CampHubScene extends Phaser.Scene {
  private tutorialController: TutorialController | null = null;

  async create() {
    // ... other setup ...
    this.setupTutorialSystem();
  }

  private setupTutorialSystem(): void {
    this.tutorialController = createCampHubTutorialController(
      this,
      () => this.otter.getPosition(),
      () => {
        const body = this.otter.body as Phaser.Physics.Arcade.Body;
        return body ? { x: body.velocity.x, y: body.velocity.y } : { x: 0, y: 0 };
      }
    );

    this.tutorialController.init().catch((error) => {
      console.error('Failed to initialize tutorial system:', error);
    });
  }

  update(time: number, delta: number) {
    // Update tutorial (tracks movement, updates highlights)
    if (this.tutorialController) {
      this.tutorialController.update(time, delta);
    }

    // Block other input during tutorials
    if (this.tutorialController?.isActive()) {
      return; // Skip normal input handling
    }

    // ... normal update logic ...
  }

  shutdown() {
    this.tutorialController?.destroy();
  }
}

Testing

Tests are located at: frontend/app/game/tests/tutorial-system.test.ts

bash
cd frontend/app/game
pnpm vitest run tests/tutorial-system.test.ts

Test Coverage (56 tests):

  • Type definitions
  • TutorialRegistry sequences
  • TutorialSystem state management
  • Trigger evaluation
  • Step flow and chaining
  • Event system
  • Persistence service

Avatar System API (Story 10-18)

Overview

The Avatar System manages player character customization including fur color, eye style, accessories, and name. It provides types, validation, and persistence.

Files:

  • frontend/app/game/src/types/avatar.ts - Type definitions and validation
  • frontend/app/game/src/services/avatarService.ts - Persistence and state management
  • frontend/app/game/src/scenes/CharacterCreationScene.ts - Character creation UI

Types

typescript
// Core avatar configuration
interface AvatarConfig {
  furColor: string;      // Hex color from FUR_COLOR_PALETTE
  eyeStyle: string;      // Asset key from EYE_STYLE_OPTIONS
  accessories: string[]; // Array of accessory keys
  name: string;          // Player-chosen name (2-20 chars)
}

// Fur color option
interface FurColorOption {
  key: string;
  name: string;
  hex: string;
}

// Eye style option
interface EyeStyleOption {
  key: string;
  name: string;
  description: string;
}

// Accessory option
interface AccessoryOption {
  key: string;
  name: string;
  description: string;
  category: 'head' | 'neck' | 'face' | 'other';
}

// Name validation result
interface NameValidationResult {
  isValid: boolean;
  errors: string[];
}

Constants

typescript
// 8 natural otter fur colors
FUR_COLOR_PALETTE: readonly FurColorOption[]

// 5 eye style options
EYE_STYLE_OPTIONS: readonly EyeStyleOption[]

// 5 accessory options
ACCESSORY_OPTIONS: readonly AccessoryOption[]

Functions

typescript
// Create default avatar config
function createDefaultAvatarConfig(): AvatarConfig

// Generate random appearance (for "Randomize" button)
function getRandomAvatarConfig(): Omit<AvatarConfig, 'name'>

// Validate name
function validateAvatarName(name: string): NameValidationResult

// Validate entire config
function validateAvatarConfig(config: AvatarConfig): AvatarConfigValidationResult

// Utility functions
function hexToNumber(hex: string): number
function isValidFurColor(hex: string): boolean
function isValidEyeStyle(key: string): boolean
function isValidAccessory(key: string): boolean
function getFurColorByKey(key: string): FurColorOption | null
function getEyeStyleByKey(key: string): EyeStyleOption | null
function getAccessoryByKey(key: string): AccessoryOption | null

Avatar Service

typescript
interface AvatarService {
  saveAvatarConfig(config: AvatarConfig): Promise<void>;
  loadAvatarConfig(): Promise<AvatarConfig | null>;
  hasAvatarConfig(): Promise<boolean>;
  clearAvatarConfig(): Promise<void>;
}

// Factory function
function getAvatarService(): AvatarService

// For testing
function createMockAvatarService(initialConfig?: AvatarConfig): MockAvatarService

State Atoms (Nanostores)

typescript
// Current avatar configuration
const $avatarConfig: Atom<AvatarConfig>

// Computed atoms
const $hasCustomizedAvatar: Atom<boolean>
const $furColor: Atom<string>
const $eyeStyle: Atom<string>
const $accessories: Atom<string[]>
const $avatarName: Atom<string>

// Update functions
function updateAvatarConfig(updates: Partial<AvatarConfig>): void
function setAvatarConfig(config: AvatarConfig): void
function resetAvatarConfig(): void
function loadAvatarFromService(): Promise<AvatarConfig | null>
function saveAvatarToService(): Promise<void>

Character Creation Flow

  1. BootScene checks avatarService.hasAvatarConfig()
  2. If no config exists, routes to CharacterCreationScene
  3. User customizes appearance and enters name
  4. On confirm, saves to IndexedDB and transitions to CampHubScene
  5. Avatar config loaded into global state for use by Otter entity

Integration with Entities

The Otter and RemoteOtter entities apply avatar customization:

typescript
// In Otter.ts
setAvatarConfig(config: AvatarConfig): void {
  this.avatarConfig = { ...config };
  this.applyAvatarVisuals();
}

private applyAvatarVisuals(): void {
  // Apply fur color tint
  const colorNum = hexToNumber(this.avatarConfig.furColor);
  this.setTint(colorNum);
  // Future: eye style and accessory sprites
}

Testing

57 tests covering:

  • Constant validation (palettes have unique keys)
  • Name validation (length, characters, profanity)
  • Default config generation
  • Random config generation
  • Utility functions
  • Mock service operations

AgeProgressionSystem API (Story 10-19)

Overview

The AgeProgressionSystem manages avatar age progression based on security stage. Avatar age determines visual appearance (scale, tint, glow effects) and is synchronized across multiplayer.

Files:

  • frontend/app/game/src/config/avatarAge.ts - Age config and security mapping
  • frontend/app/game/src/services/securityStageService.ts - Security stage state atoms
  • frontend/app/game/src/systems/AgeProgressionSystem.ts - Event-driven age tracking
  • frontend/app/game/src/ui/AgeUpAnimation.ts - Celebration effects

Types

typescript
// Avatar age tied to security stage
type AvatarAge = 'pup' | 'scout' | 'ranger' | 'elder' | 'guardian';

// Visual configuration per age
interface AgeVisualConfig {
  scale: number;              // Sprite scale (0.7 - 1.1)
  collisionScale: number;     // Physics body scale
  tintModifier?: number;      // Hex color tint (elder/guardian only)
  glowEffect?: boolean;       // Golden glow (guardian only)
  badge: string;              // Emoji for UI display
  displayName: string;        // Human-readable name
  description: string;        // Description for profile overlay
}

// Progression state
interface AgeProgressionState {
  currentAge: AvatarAge;
  previousAge: AvatarAge;
  securityStage: number;
  isAgingUp: boolean;
  isMaxAge: boolean;
}

// Event types
type AgeProgressionEventType =
  | 'age-up'            // Age increased
  | 'age-changed'       // Age changed (any direction)
  | 'stage-changed'     // Security stage changed
  | 'transition-complete';  // Visual transition finished

// Event payload
interface AgeProgressionEvent {
  type: AgeProgressionEventType;
  timestamp: number;
  data?: {
    oldAge?: AvatarAge;
    newAge?: AvatarAge;
    oldStage?: number;
    newStage?: number;
  };
}

Security Stage Mapping

Security StageAvatar AgeDescription
0PupNot authenticated (guest)
1PupSingle device passkey
2ScoutMulti-device passkey
3RangerHardware key or recovery phrase
4ElderFull self-custody verification
5GuardianHardware key + multi-sig

Age Visual Config

AgeScaleCollisionTintGlowBadge
Pup0.70.7--🍼
Scout0.850.85--🏕️
Ranger1.01.0--🏹
Elder1.051.00xf5f0e6-📜
Guardian1.11.00xe8e8f0Yes👑

Configuration Functions

typescript
import {
  SECURITY_TO_AGE,
  calculateAvatarAge,
  getSecurityStageRange,
  AGE_VISUAL_CONFIG,
  getAgeVisualConfig,
  AVATAR_AGES,
  getNextAge,
  isMaxAge,
  compareAges,
} from '../config/avatarAge';

// Get age from security stage
const age = calculateAvatarAge(3); // 'ranger'

// Get security stage range for age
const [min, max] = getSecurityStageRange('scout'); // [2, 2]

// Get visual config
const config = getAgeVisualConfig('guardian');
console.log(config.scale);  // 1.1
console.log(config.badge);  // '👑'

// Age comparison
const isOlder = compareAges('elder', 'scout') > 0; // true

// Progression helpers
const next = getNextAge('ranger'); // 'elder'
const atMax = isMaxAge('guardian'); // true

State Atoms (Nanostores)

typescript
import {
  $securityStage,
  $avatarAge,
  $previousAvatarAge,
  $isAgingUp,
} from '../services/securityStageService';

// Subscribe to age changes
const unsubscribe = $avatarAge.subscribe((age) => {
  console.log('Current age:', age);
});

// Get current values
const stage = $securityStage.get();
const age = $avatarAge.get();  // Computed from stage

SecurityStageService

typescript
import { getSecurityStageService } from '../services/securityStageService';

const service = getSecurityStageService();

// Initialize (loads from IndexedDB)
await service.initialize();

// Refresh from auth state
await service.refreshFromAuth();

// Set stage manually (returns true if age-up occurred)
const ageUp = await service.setSecurityStage(3);

// Query state
const stage = service.getSecurityStage();
const age = service.getAvatarAge();
const config = service.getAgeVisualConfig();
const atMax = service.isMaxStage();
const nextStage = service.getNextStage(); // undefined if at max

AgeProgressionSystem

typescript
import { getAgeProgressionSystem } from '../systems/AgeProgressionSystem';

const ageSystem = getAgeProgressionSystem();

// Initialize (requires scene context for service)
await ageSystem.initialize();

// Get state
const state = ageSystem.getState();
const age = ageSystem.getCurrentAge();
const inTransition = ageSystem.isInTransition();

// Subscribe to state
const unsubState = ageSystem.subscribe((state) => {
  console.log('Age:', state.currentAge);
  console.log('Is aging up:', state.isAgingUp);
});

// Subscribe to events
const unsubEvents = ageSystem.subscribeToEvents((event) => {
  if (event.type === 'age-up') {
    console.log(`Age up: ${event.data.oldAge} → ${event.data.newAge}`);
    // Play celebration animation
  }
});

// Mark transition complete (called by animation system)
ageSystem.completeTransition();

// Manual stage change (for testing/admin)
await ageSystem.setSecurityStage(5);

// Cleanup
ageSystem.destroy();

Age-Up Animation

typescript
import {
  playAgeUpAnimation,
  showAgeUpNotification,
  getAgeUpOttoDialog,
} from '../ui/AgeUpAnimation';

// Play celebration animation on sprite
playAgeUpAnimation({
  scene: this,
  target: this.otter,  // Phaser sprite
  oldAge: 'scout',
  newAge: 'ranger',
  onComplete: () => {
    ageSystem.completeTransition();
  },
});

// Show notification banner
const notification = showAgeUpNotification(this, 'ranger');
// Auto-dismisses after 3 seconds

// Get Otto dialog text
const dialog = getAgeUpOttoDialog('ranger');
// "A Ranger at last! 🏹\nYour account is well-protected..."

Entity Integration

Otter (Local Player)

typescript
// In Otter.ts
setAvatarAge(age: AvatarAge): void {
  this.avatarAge = age;
  this.applyAgeVisuals();
}

private applyAgeVisuals(): void {
  const config = AGE_VISUAL_CONFIG[this.avatarAge];

  // Apply scale
  this.setScale(this.baseScale * config.scale);

  // Apply collision box
  const body = this.body as Phaser.Physics.Arcade.Body;
  const size = Math.round(24 * config.collisionScale);
  body.setSize(size, size);

  // Apply tint (if no fur color override)
  if (config.tintModifier && !this.avatarConfig.furColor) {
    this.setTint(config.tintModifier);
  }

  // Apply glow effect for Guardian
  if (config.glowEffect) {
    this.glowEffect = this.scene.add.ellipse(...);
  }
}

RemoteOtter (Other Players)

typescript
// In RemoteOtter.ts
constructor(scene: Phaser.Scene, player: PlayerPresence) {
  this.avatarAge = player.avatarAge;
  this.applyAgeVisuals();
}

setAvatarAge(age: AvatarAge): void {
  this.avatarAge = age;
  this.applyAgeVisuals();
}

Multiplayer Sync

typescript
// In presence.ts
interface UpdateProfileMessage {
  type: 'update-profile';
  avatarAge?: AvatarAge;
  avatarConfig?: AvatarConfig;
}

// In MultiplayerSystem.ts
$avatarAge.subscribe((age) => {
  this.presenceClient.sendProfileUpdate({ avatarAge: age });
});

this.presenceClient.onPlayerProfileUpdate((playerId, avatarAge, avatarConfig) => {
  const remoteOtter = this.remotePlayers.get(playerId);
  if (remoteOtter && avatarAge) {
    remoteOtter.setAvatarAge(avatarAge);
  }
});

Scene Integration (CampHubScene)

typescript
// In CampHubScene.ts
private setupAgeProgressionSystem(): void {
  const ageSystem = getAgeProgressionSystem();

  this.ageProgressionUnsubscribe = ageSystem.subscribeToEvents((event) => {
    if (event.type === 'age-up' && event.data?.oldAge && event.data?.newAge) {
      this.handleAgeUp(event.data.oldAge, event.data.newAge);
    }
  });
}

private handleAgeUp(oldAge: AvatarAge, newAge: AvatarAge): void {
  if (!this.otter) return;

  // Update otter visuals
  this.otter.setAvatarAge(newAge);

  // Play celebration animation
  playAgeUpAnimation({
    scene: this,
    target: this.otter,
    oldAge,
    newAge,
    onComplete: () => {
      getAgeProgressionSystem().completeTransition();
    },
  });

  // Show notification
  showAgeUpNotification(this, newAge);
}

Profile Overlay Integration

typescript
// In ProfileOverlay.ts
import { $securityStage, $avatarAge } from '../services/securityStageService';

// Subscribe to changes
this.stageUnsubscribe = $securityStage.subscribe(() => {
  this.updateSecurityStageSection();
});

private updateSecurityStageSection(): void {
  const stage = $securityStage.get();
  const age = $avatarAge.get();
  const config = AGE_VISUAL_CONFIG[age];

  // Display badge, name, stage number, description
  // Show "Upgrade Security" button if not at max
}

Testing

Tests located at: frontend/app/game/tests/avatar-age.test.ts

bash
cd frontend/app/game
npm test -- tests/avatar-age.test.ts

Test Coverage (71 tests):

  • AC-19.1: Security stage to age mapping (20 tests)
  • AC-19.2: Age visual configuration (15 tests)
  • AC-19.3: Age transition support (12 tests)
  • AC-19.4: Multiplayer type validation (2 tests)
  • AC-19.5: Profile overlay config (3 tests)
  • AgeProgressionSystem logic (11 tests)
  • SecurityStageService state logic (8 tests)

Persistence

Security stage is persisted to IndexedDB for guests:

typescript
// Database: 'otter-camp-security'
// Store: 'security-stage'
// Key: 'current-stage'

// Automatic on stage change via setSecurityStage()
// Loaded on service.initialize()

Future: For members, security stage will be queried from auth-service canister. Currently mocked: logged-in users = stage 1, guests = stage 0.

InventorySystem API (Story 10-20)

Overview

The InventorySystem manages player inventory, currencies, and integrates with the fragment tracking system. It uses nanostores for reactive state management and IndexedDB for persistence.

Files:

  • frontend/app/game/src/types/inventory.ts - Type definitions and constants
  • frontend/app/game/src/services/inventoryService.ts - Inventory CRUD and persistence
  • frontend/app/game/src/services/currencyService.ts - Currency management
  • frontend/app/game/src/services/fragmentService.ts - Fragment tracking integration
  • frontend/app/game/src/ui/overlays/InventoryOverlay.ts - Inventory panel UI
  • frontend/app/game/src/ui/InventoryHUD.ts - HUD currency display

Types

typescript
// Item types
type ItemType = 'currency' | 'material' | 'artifact' | 'fragment';

// Inventory item
interface InventoryItem {
  id: string;
  type: ItemType;
  name: string;
  quantity: number;
  icon: string;
  description: string;
}

// Currency state
interface CurrencyState {
  fish: number;    // In-game currency
  shells: number;  // Premium currency
  dom: number;     // DAO governance tokens
}

// Currency change event (for animations)
interface CurrencyChangeEvent {
  type: 'fish' | 'shells' | 'dom';
  delta: number;
  newValue: number;
  oldValue: number;
}

// Fragment display item
interface FragmentDisplayItem {
  id: string;
  name: string;
  archetype: string;
  loreText: string;
  locationDescription: string;
  collected: boolean;
  locked: boolean;
}

// Fragment progress
interface FragmentProgress {
  collected: number;
  total: number;
  percentage: number;
  displayString: string;  // e.g., "4/12"
  questComplete: boolean;
}

Constants

typescript
// Currency configuration
const CURRENCY_CONFIG = {
  fish: { icon: '🐟', name: 'Fish', color: 0x64b5f6 },
  shells: { icon: '🐚', name: 'Shells', color: 0xff9800 },
  dom: { icon: '💎', name: 'DOM', color: 0x9c27b0 },
};

// Item type display names
const ITEM_TYPE_DISPLAY_NAMES: Record<ItemType, string> = {
  currency: 'Currency',
  material: 'Materials',
  artifact: 'Artifacts',
  fragment: 'Fragments',
};

// Total fragments (from fragments.ts)
const TOTAL_FRAGMENTS = 12;

State Atoms (Nanostores)

typescript
import {
  $inventory,
  $inventoryByType,
  $inventoryCount,
  $currencies,
} from '../services/inventoryService';

import {
  $fragmentProgress,
  $fragmentDisplayItems,
  $collectedFragments,
  $uncollectedFragments,
  $lockedFragments,
} from '../services/fragmentService';

// Subscribe to inventory changes
const unsubscribe = $inventory.subscribe((items) => {
  console.log('Inventory updated:', items.length, 'items');
});

// Get items by type
const materials = $inventoryByType.get().material;

// Get currency values
const currencies = $currencies.get();
console.log('Fish:', currencies.fish);

// Get fragment progress
const progress = $fragmentProgress.get();
console.log(`Fragments: ${progress.displayString}`);

InventoryService API

typescript
import {
  addInventoryItem,
  removeInventoryItem,
  getInventoryItem,
  updateInventoryItem,
  clearInventory,
  loadInventoryFromService,
  saveInventoryToService,
} from '../services/inventoryService';

// Add item (stacks if same ID exists)
await addInventoryItem({
  id: 'wood-001',
  type: 'material',
  name: 'Oak Wood',
  quantity: 5,
  icon: '🪵',
  description: 'Common crafting material',
});

// Remove item (reduces quantity or removes entirely)
const removed = await removeInventoryItem('wood-001', 3);
console.log('Removed:', removed); // true if successful

// Get single item
const item = getInventoryItem('wood-001');

// Update item properties
updateInventoryItem('wood-001', { quantity: 10 });

// Load from IndexedDB
await loadInventoryFromService();

// Save to IndexedDB
await saveInventoryToService();

// Clear all items
await clearInventory();

CurrencyService API

typescript
import {
  addFish,
  spendFish,
  addShells,
  spendShells,
  setDomBalance,
  onCurrencyChange,
  loadCurrenciesFromService,
  saveCurrenciesToService,
} from '../services/currencyService';

// Add currency
await addFish(100);
await addShells(10);

// Spend currency (returns false if insufficient)
const success = await spendFish(50);
if (!success) {
  console.log('Not enough fish!');
}

// Set DOM balance (from canister)
setDomBalance(BigInt(1000));

// Subscribe to currency changes (for HUD animations)
const unsubscribe = onCurrencyChange((event) => {
  console.log(`${event.type}: ${event.delta > 0 ? '+' : ''}${event.delta}`);
  // Trigger pulse animation
});

// Persistence
await loadCurrenciesFromService();
await saveCurrenciesToService();

FragmentService API

typescript
import {
  $fragmentProgress,
  $fragmentDisplayItems,
  connectToQuestChainSystem,
  getFragmentProgress,
  getAllFragments,
  getFragmentById,
  isFragmentCollected,
  TOTAL_FRAGMENTS,
} from '../services/fragmentService';

// Connect to QuestChainSystem (required for reactive updates)
const unsubscribe = connectToQuestChainSystem(questChainSystem);

// Get progress
const progress = getFragmentProgress();
console.log(`${progress.collected}/${progress.total} fragments`);

// Get all fragments with display state
const fragments = getAllFragments();
fragments.forEach((f) => {
  console.log(`${f.name}: ${f.collected ? 'Found' : 'Missing'}`);
});

// Check specific fragment
const found = isFragmentCollected('fragment-sage');

// Cleanup
unsubscribe();

InventoryHUD Component

typescript
import { InventoryHUD, InventoryHUDConfig } from '../ui/InventoryHUD';

// Create HUD in scene
const inventoryHUD = new InventoryHUD({
  scene: this,
  onInventoryClick: () => this.openInventory(),
});

// Show new item indicator
inventoryHUD.showNewItemIndicator();

// Check for new items
const hasNew = inventoryHUD.getHasNewItems();

// Handle resize
inventoryHUD.handleResize(width, height);

// Set visibility
inventoryHUD.setVisible(false);

// Cleanup
inventoryHUD.destroy();

InventoryOverlay Component

typescript
import { InventoryOverlay } from '../ui/overlays/InventoryOverlay';
import { OverlayFactory } from '../ui/overlays/OverlayFactory';

// Create via factory (recommended)
const overlay = OverlayFactory.create('InventoryOverlay', {
  scene: this,
  container: this.overlayContainer,
  onClose: () => this.closeInventory(),
});

// Or direct instantiation
const overlay = new InventoryOverlay({
  scene: this,
  container: this.overlayContainer,
  onClose: () => this.closeInventory(),
});

// Get selected fragment
const fragment = overlay.getSelectedFragment();

// Cleanup
overlay.destroy();

Scene Integration (CampHubScene)

typescript
// In CampHubScene.ts

// Import
import { InventoryHUD } from '../ui/InventoryHUD';
import { connectToQuestChainSystem } from '../services/fragmentService';

// Members
private inventoryHUD!: InventoryHUD;
private fragmentServiceUnsubscribe: (() => void) | null = null;
private isInventoryOpen: boolean = false;

// Setup in create()
private setupInventoryHUD(): void {
  this.inventoryHUD = new InventoryHUD({
    scene: this,
    onInventoryClick: () => this.toggleInventory(),
  });

  // Connect fragment service to quest chain system
  if (this.questChainSystem) {
    this.fragmentServiceUnsubscribe = connectToQuestChainSystem(
      this.questChainSystem
    );
  }
}

// Input handling
this.input.keyboard!.on('keydown-I', () => {
  this.toggleInventory();
});

// Toggle method
private toggleInventory(): void {
  if (this.isInventoryOpen) {
    this.closeInventory();
  } else {
    this.openInventory();
  }
}

// Cleanup
if (this.inventoryHUD) {
  this.inventoryHUD.destroy();
}
if (this.fragmentServiceUnsubscribe) {
  this.fragmentServiceUnsubscribe();
}

Persistence

Inventory and currencies are stored in IndexedDB for guests:

typescript
// Database: 'otter-camp-inventory'
// Stores:
//   - 'inventory' - InventoryItem[]
//   - 'currencies' - CurrencyState

// Automatic persistence on state changes via services
// Manual save/load available via service functions

Future: For members, inventory will sync with user-service canister. DOM token balance queries dom-token canister.

Testing

Tests use mock services and fake timers:

typescript
import { createMockInventoryService } from '../services/inventoryService';

const mockService = createMockInventoryService();
// Use for testing without IndexedDB

File Structure

frontend/app/game/src/
├── systems/
│   ├── CombatSystem.ts         # Core combat logic
│   ├── AbilityRegistry.ts      # Archetype ability definitions
│   ├── EnemyRegistry.ts        # Enemy definitions (Story 10-15c)
│   ├── LootTable.ts            # Loot drop calculations (Story 10-15c)
│   ├── CombatZoneRegistry.ts   # Zone-to-enemy mapping (Story 10-15c)
│   ├── TutorialSystem.ts       # Tutorial state management (Story 10-17)
│   ├── TutorialController.ts   # Scene integration (Story 10-17)
│   ├── TutorialRegistry.ts     # Tutorial step definitions (Story 10-17)
│   └── AgeProgressionSystem.ts # Age progression tracking (Story 10-19)
├── scenes/
│   ├── CampHubScene.ts       # Main hub with combat/zone/tutorial integration
│   └── CombatScene.ts        # Combat arena UI
├── components/
│   └── combat/
│       └── LootPopup.ts      # Post-victory loot display (Story 10-15c)
├── ui/
│   ├── ZoneIndicator.ts      # Zone HUD element (Story 10-15c)
│   ├── EnemySilhouette.ts    # Pre-combat warning (Story 10-15c)
│   ├── TutorialOverlay.ts    # Tutorial vignette/highlights (Story 10-17)
│   ├── TutorialDialogBox.ts  # Otto dialog UI (Story 10-17)
│   ├── AgeUpAnimation.ts     # Age-up celebration effects (Story 10-19)
│   ├── InventoryHUD.ts       # HUD currency display (Story 10-20)
│   └── overlays/
│       └── InventoryOverlay.ts  # Inventory panel UI (Story 10-20)
├── services/
│   ├── combatService.ts         # Combat IndexedDB persistence
│   ├── tutorialService.ts       # Tutorial IndexedDB persistence (Story 10-17)
│   ├── securityStageService.ts  # Security stage atoms/persistence (Story 10-19)
│   ├── inventoryService.ts      # Inventory CRUD and persistence (Story 10-20)
│   ├── currencyService.ts       # Currency management (Story 10-20)
│   └── fragmentService.ts       # Fragment tracking integration (Story 10-20)
├── types/
│   ├── combat.ts             # Combat type definitions
│   ├── archetype.ts          # Archetype types (combat-retreated activity)
│   ├── tutorial.ts           # Tutorial type definitions (Story 10-17)
│   └── inventory.ts          # Inventory type definitions (Story 10-20)
└── config/
    ├── game.config.ts        # Scene registration
    └── avatarAge.ts          # Age config and security mapping (Story 10-19)

frontend/packages/state/src/atoms/
└── combat.ts                 # Nanostores atoms for React bridge

frontend/app/game/tests/
├── combat-system.test.ts      # Combat system tests (59 tests)
├── enemies-loot-zones.test.ts # Enemy/loot/zone tests (43 tests, Story 10-15c)
├── tutorial-system.test.ts    # Tutorial system tests (56 tests, Story 10-17)
└── avatar-age.test.ts         # Age progression tests (71 tests, Story 10-19)

Hello World Co-Op DAO