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
// 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
constructor(persistenceService: CombatPersistenceService)Parameters:
persistenceService- Service for IndexedDB persistence (crash recovery)
Example:
const combatService = new IndexedDBCombatService();
const combatSystem = new CombatSystem(combatService);Methods
startCombat
Initiates a new combat encounter.
async startCombat(
playerId: string,
enemyId: string,
zone: string,
options?: {
playerStats?: Partial<CombatActor>;
enemyStats?: Partial<CombatActor>;
}
): Promise<void>Parameters:
playerId- Player identifierenemyId- Enemy identifierzone- Zone where combat is occurringoptions.playerStats- Override default player statsoptions.enemyStats- Override default enemy stats
Example:
await combatSystem.startCombat('player-1', 'wild-raccoon', 'forest', {
enemyStats: {
name: 'Wild Raccoon',
health: 50,
attack: 8
}
});Behavior:
- Creates player and enemy actors with default or custom stats
- Calculates turn order based on speed (higher speed goes first)
- Transitions phase:
idle→intro→active - Persists state to IndexedDB for crash recovery
executeAction
Executes a combat action for an actor.
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:
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); // 12Action Behaviors:
| Action | Effect | Energy Cost |
|---|---|---|
attack | Deal damage based on attack stat | 10 energy |
defend | Reduce incoming damage by 50% | 5 energy |
wait | Skip turn, regenerate energy | 0 (gains 10) |
retreat | End combat, return to exploration | 25% of max |
ability | Use 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
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
| Archetype | Ability ID | Effect | Duration |
|---|---|---|---|
| Ruler | ruler-barrier | +50% defense | 2 turns |
| Artist | artist-inspire | +25% damage | 2 turns |
| Caregiver | caregiver-soothe | Absorb next hit | One-shot |
| Explorer | explorer-weakness | Ignore defense | One-shot |
CombatSystem Ability Methods
getPlayerAbility
Get the player's available combat ability.
getPlayerAbility(): CombatAbility | nullReturns 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.
canUseAbility(): booleanReturns 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.
getAbilityCooldown(): numberReturns the number of turns remaining until the ability can be used again (0 = ready).
Using Abilities
// 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:
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 whenturnsRemainingreaches 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:
| Event | Data | Description |
|---|---|---|
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.
subscribe(event: CombatEvent, callback: (data: CombatEventData) => void): () => voidEvents:
combat-started- Combat encounter beginscombat-ended- Combat ends (victory/defeat/retreat)action-executed- An action was performedturn-changed- Turn advanced to next actorphase-changed- Combat phase changedstate-changed- Any state change
Returns: Unsubscribe function
Example:
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.
getState(): CombatStategetPhase
Returns the current combat phase.
getPhase(): CombatPhaseisPlayerTurn
Check if it's currently the player's turn.
isPlayerTurn(): booleanselectEnemyAction
AI method to select an enemy action.
selectEnemyAction(): CombatActionReturns: Action for the enemy AI to take (currently simple: attack if has energy)
Configuration
Combat balance is configured via COMBAT_CONFIG:
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:
interface CombatPersistenceService {
saveCombat(state: CombatState): Promise<void>;
loadCombat(): Promise<CombatState | null>;
clearCombat(): Promise<void>;
}Recovery Flow:
- On page load, check for persisted combat state
- If found, resume combat with recovered state
- 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:
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
// 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
// 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
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
cd frontend/app/game
pnpm vitest run tests/combat-system.test.tsTest Patterns
Use fake timers for async operations:
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
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
// 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
| ID | Name | Zone | HP | Attack | Defense | Rarity |
|---|---|---|---|---|---|---|
| river-rat | River Rat | camp-hub | 30 | 8 | 2 | common |
| shadow-snapper | Shadow Snapper | forest | 60 | 12 | 5 | uncommon |
| crystal-crab | Crystal Crab | caves | 100 | 15 | 10 | rare |
LootTable API (Story 10-15c)
Location: frontend/app/game/src/systems/LootTable.ts
Types
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
// 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): SimulationResultDrop 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:
overflowXp = overflowQuantity * OVERFLOW_XP_BONUS // 5 XP per overflow resourceCombatZoneRegistry API (Story 10-15c)
Location: frontend/app/game/src/systems/CombatZoneRegistry.ts
Types
interface CombatZoneDefinition {
id: CombatZoneId;
name: string;
description: string;
resourceType: ResourceType;
dangerLevel: number; // 1-5
color: number;
backgroundKey: string;
}Functions
// 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): EnemyDefinitionZone Mapping
ExplorationSystem zones map to combat zones as follows:
| Exploration Zone | Combat Zone |
|---|---|
| camp, zone-2, zone-3 | camp-hub |
| zone-4, zone-5, zone-6 | caves |
| zone-7, zone-8, zone-9 | forest |
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 managementfrontend/app/game/src/systems/TutorialController.ts- Scene integrationfrontend/app/game/src/systems/TutorialRegistry.ts- Tutorial step definitionsfrontend/app/game/src/ui/TutorialOverlay.ts- Vignette and highlight UIfrontend/app/game/src/ui/TutorialDialogBox.ts- Otto's dialog box
Types
// 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)
import { getTutorialSystem, resetTutorialSystem } from './systems/TutorialSystem';
// Get singleton instance
const tutorialSystem = getTutorialSystem();
// Reset for testing
resetTutorialSystem();Methods
registerSequence
Register a tutorial sequence.
registerSequence(sequence: TutorialSequence): voidsetService
Set the persistence service.
setService(service: TutorialService): voidloadProgress
Load completed steps from storage.
async loadProgress(): Promise<void>subscribe
Subscribe to state changes.
subscribe(callback: (state: TutorialState) => void): () => voidReturns: Unsubscribe function
subscribeToEvents
Subscribe to tutorial events.
subscribeToEvents(callback: (event: TutorialEvent) => void): () => voidEvents:
step-started- New step beganstep-completed- Step finishedstep-skipped- Step was skippedsequence-completed- All steps in sequence donetutorials-disabled- User skipped all tutorials
onSceneVisit
Trigger first-visit tutorials for a scene.
async onSceneVisit(sceneId: string): Promise<void>onActionPerformed
Trigger first-action tutorials.
async onActionPerformed(actionId: string): Promise<void>onRequiredActionCompleted
Mark required action as completed.
onRequiredActionCompleted(): voidadvance
Advance to next step or complete sequence.
advance(): voiddisableTutorials
Disable all future tutorials.
disableTutorials(): voidisActive / areTutorialsDisabled / getCurrentStep
State accessors.
isActive(): boolean
areTutorialsDisabled(): boolean
getCurrentStep(): TutorialStep | nullTutorialController
Scene-level integration that ties together TutorialSystem, TutorialOverlay, and TutorialDialogBox.
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
// 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.
import { ALL_TUTORIALS, CAMP_HUB_TUTORIAL, QUEST_BOARD_TUTORIAL } from './systems/TutorialRegistry';Available Tutorials:
| ID | Scene | Steps | Description |
|---|---|---|---|
camp-hub-tutorial | camp-hub | 4 | Welcome, movement, buildings, NPCs |
quest-board-tutorial | quest-board | 3 | Overview, details, voting |
ritual-circle-tutorial | ritual-circle | 2 | Introduction, contributing |
combat-tutorial | combat-start | 2 | Turn order, abilities |
archetype-levelup-tutorial | - | 1 | Level-up trigger |
UI Components
TutorialOverlay
Renders vignette (background dim) with highlight cutouts.
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.
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:
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:
import { getTutorialService, createMockTutorialService } from './services/tutorialService';
// Production (IndexedDB)
const service = getTutorialService();
// Testing
const mockService = createMockTutorialService();Integration Example
// 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
cd frontend/app/game
pnpm vitest run tests/tutorial-system.test.tsTest 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 validationfrontend/app/game/src/services/avatarService.ts- Persistence and state managementfrontend/app/game/src/scenes/CharacterCreationScene.ts- Character creation UI
Types
// 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
// 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
// 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 | nullAvatar Service
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): MockAvatarServiceState Atoms (Nanostores)
// 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
- BootScene checks
avatarService.hasAvatarConfig() - If no config exists, routes to CharacterCreationScene
- User customizes appearance and enters name
- On confirm, saves to IndexedDB and transitions to CampHubScene
- Avatar config loaded into global state for use by Otter entity
Integration with Entities
The Otter and RemoteOtter entities apply avatar customization:
// 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 mappingfrontend/app/game/src/services/securityStageService.ts- Security stage state atomsfrontend/app/game/src/systems/AgeProgressionSystem.ts- Event-driven age trackingfrontend/app/game/src/ui/AgeUpAnimation.ts- Celebration effects
Types
// 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 Stage | Avatar Age | Description |
|---|---|---|
| 0 | Pup | Not authenticated (guest) |
| 1 | Pup | Single device passkey |
| 2 | Scout | Multi-device passkey |
| 3 | Ranger | Hardware key or recovery phrase |
| 4 | Elder | Full self-custody verification |
| 5 | Guardian | Hardware key + multi-sig |
Age Visual Config
| Age | Scale | Collision | Tint | Glow | Badge |
|---|---|---|---|---|---|
| Pup | 0.7 | 0.7 | - | - | 🍼 |
| Scout | 0.85 | 0.85 | - | - | 🏕️ |
| Ranger | 1.0 | 1.0 | - | - | 🏹 |
| Elder | 1.05 | 1.0 | 0xf5f0e6 | - | 📜 |
| Guardian | 1.1 | 1.0 | 0xe8e8f0 | Yes | 👑 |
Configuration Functions
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'); // trueState Atoms (Nanostores)
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 stageSecurityStageService
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 maxAgeProgressionSystem
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
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)
// 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)
// 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
// 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)
// 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
// 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
cd frontend/app/game
npm test -- tests/avatar-age.test.tsTest 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:
// 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 constantsfrontend/app/game/src/services/inventoryService.ts- Inventory CRUD and persistencefrontend/app/game/src/services/currencyService.ts- Currency managementfrontend/app/game/src/services/fragmentService.ts- Fragment tracking integrationfrontend/app/game/src/ui/overlays/InventoryOverlay.ts- Inventory panel UIfrontend/app/game/src/ui/InventoryHUD.ts- HUD currency display
Types
// 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
// 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)
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
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
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
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
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
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)
// 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:
// Database: 'otter-camp-inventory'
// Stores:
// - 'inventory' - InventoryItem[]
// - 'currencies' - CurrencyState
// Automatic persistence on state changes via services
// Manual save/load available via service functionsFuture: For members, inventory will sync with user-service canister. DOM token balance queries dom-token canister.
Testing
Tests use mock services and fake timers:
import { createMockInventoryService } from '../services/inventoryService';
const mockService = createMockInventoryService();
// Use for testing without IndexedDBFile 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)