Skip to content

With Computed State

The withComputedState hook creates derived state that automatically recomputes when its source state changes. Think of it like Vue’s computed properties or React’s useMemo - perfect for calculations, transformations, and derived values.

withComputedState takes a source state and a selector function, and creates a new state that:

  • Auto-updates - Recalculates whenever the source state changes
  • Read-only - The computed state itself cannot be directly modified
  • Reactive - Triggers its own 'change' listeners when the value changes
  • Type-safe - Full TypeScript inference from source to result
const player = withLocalState(this, 'player', { hp: 100, maxHp: 100 });
// Create computed state
const healthPercent = withComputedState(
this,
'healthPercent',
player,
(p) => Math.round((p.hp / p.maxHp) * 100)
);
console.log(healthPercent.get()); // 100
// When source changes, computed updates automatically
player.patch({ hp: 50 });
console.log(healthPercent.get()); // 50

Combine computed state with custom hooks for powerful derived values:

hooks/withPlayer.ts
import { type HookState, withLocalState, withComputedState } from 'phaser-hooks';
type Player = {
hp: number;
maxHp: number;
mp: number;
maxMp: number;
level: number;
exp: number;
expToNextLevel: number;
};
type PlayerState = HookState<Player> & {
healthPercent: HookState<number>;
manaPercent: HookState<number>;
levelProgress: HookState<number>;
isLowHealth: HookState<boolean>;
isDead: HookState<boolean>;
takeDamage: (amount: number) => void;
heal: (amount: number) => void;
};
export function withPlayer(scene: Phaser.Scene): PlayerState {
const state = withLocalState<Player>(scene, 'player', {
hp: 100,
maxHp: 100,
mp: 50,
maxMp: 50,
level: 1,
exp: 0,
expToNextLevel: 100,
});
// Computed values
const healthPercent = withComputedState(
scene,
'player:healthPercent',
state,
(p) => Math.round((p.hp / p.maxHp) * 100)
);
const manaPercent = withComputedState(
scene,
'player:manaPercent',
state,
(p) => Math.round((p.mp / p.maxMp) * 100)
);
const levelProgress = withComputedState(
scene,
'player:levelProgress',
state,
(p) => Math.round((p.exp / p.expToNextLevel) * 100)
);
const isLowHealth = withComputedState(
scene,
'player:isLowHealth',
state,
(p) => p.hp < p.maxHp * 0.25
);
const isDead = withComputedState(
scene,
'player:isDead',
state,
(p) => p.hp <= 0
);
return {
...state,
healthPercent,
manaPercent,
levelProgress,
isLowHealth,
isDead,
takeDamage: (amount: number) => {
const current = state.get();
state.patch({ hp: Math.max(0, current.hp - amount) });
},
heal: (amount: number) => {
const current = state.get();
state.patch({ hp: Math.min(current.maxHp, current.hp + amount) });
},
};
}

Use computed values to drive UI and game logic:

GameScene.ts
import { withPlayer } from './hooks/withPlayer';
class GameScene extends Phaser.Scene {
create() {
const player = withPlayer(this);
// Create UI elements
const healthBar = this.add.rectangle(10, 10, 200, 20, 0xff0000);
const warningText = this.add.text(10, 40, '', { color: '#ff0000' });
// Update health bar when percentage changes
player.healthPercent.on('change', (percent) => {
healthBar.width = (200 * percent) / 100;
});
// Show warning when low health
player.isLowHealth.on('change', (isLow) => {
warningText.setText(isLow ? 'LOW HEALTH!' : '');
});
// Game over when dead
player.isDead.on('change', (dead) => {
if (dead) {
this.scene.start('GameOverScene');
}
});
// Simulate taking damage
this.input.keyboard?.on('keydown-SPACE', () => {
player.takeDamage(25);
});
}
}
// HealthBar component
class HealthBar extends Phaser.GameObjects.Container {
private bar: Phaser.GameObjects.Rectangle;
private text: Phaser.GameObjects.Text;
constructor(scene: Phaser.Scene) {
super(scene, 0, 0);
const player = withPlayer(scene);
// Create visuals
this.bar = scene.add.rectangle(0, 0, 200, 20, 0xff0000);
this.text = scene.add.text(0, 25, '', { color: '#ffffff' });
this.add([this.bar, this.text]);
// Update on changes
player.healthPercent.on('change', (percent) => {
this.bar.width = (200 * percent) / 100;
this.text.setText(`${player.get().hp} / ${player.get().maxHp}`);
});
// Initial update
const percent = player.healthPercent.get();
this.bar.width = (200 * percent) / 100;
this.text.setText(`${player.get().hp} / ${player.get().maxHp}`);
}
}

const player = withLocalState(this, 'player', { hp: 100, maxHp: 100 });
const healthPercent = withComputedState(
this,
'healthPercent',
player,
(p) => (p.hp / p.maxHp) * 100
);
healthBar.on('update', () => {
healthBar.setScale(healthPercent.get() / 100, 1);
});
const isAlive = withComputedState(
this,
'isAlive',
player,
(p) => p.hp > 0
);
const canLevelUp = withComputedState(
this,
'canLevelUp',
player,
(p) => p.exp >= p.expToNextLevel
);
canLevelUp.on('change', (can) => {
if (can) {
this.showLevelUpButton();
}
});
const totalAttack = withComputedState(
this,
'totalAttack',
player,
(p) => p.baseAttack + p.weaponAttack + p.buffAttack
);
const critChance = withComputedState(
this,
'critChance',
player,
(p) => Math.min(100, p.luck * 0.5 + p.critBonus)
);
const playerNameLevel = withComputedState(
this,
'playerNameLevel',
player,
(p) => `${p.name} (Lv. ${p.level})`
);
playerNameLevel.on('change', (text) => {
nameText.setText(text);
});
const combatStatus = withComputedState(
this,
'combatStatus',
player,
(p) => {
if (p.hp === 0) return 'dead';
if (p.hp < p.maxHp * 0.25) return 'critical';
if (p.hp < p.maxHp * 0.5) return 'wounded';
return 'healthy';
}
);
combatStatus.on('change', (status) => {
this.updatePlayerColor(status);
});

FeatureDescription
Auto-updatesRecomputes when source state changes
ReactiveEmits 'change' events when value changes
Read-onlyCannot directly modify computed state (modify source instead)
Type-safeTypeScript infers result type from selector
EfficientOnly recalculates when source actually changes

Computed state is read-only. To change it, modify the source state:

const healthPercent = withComputedState(this, 'hp%', player, p => p.hp / p.maxHp * 100);
healthPercent.set(50); // ❌ Don't do this! (will set the computed value directly)
player.patch({ hp: 50 }); // ✅ Correct - modify source, computed updates automatically

The computed state only emits 'change' when the result value changes, not when the source changes:

const isLowHealth = withComputedState(
this,
'isLowHealth',
player,
(p) => p.hp < 25
);
isLowHealth.on('change', () => console.log('Low health status changed!'));
player.patch({ hp: 30 }); // No log (result is still false)
player.patch({ hp: 20 }); // Logs! (result changed from false to true)
player.patch({ hp: 10 }); // No log (result is still true)

For deriving from multiple states, create an intermediate combined state:

const player = withLocalState(this, 'player', { level: 5 });
const equipment = withLocalState(this, 'equipment', { weaponDamage: 10 });
// Combine sources first
const combatStats = withComputedState(
this,
'combatStats',
player,
(p) => ({ playerLevel: p.level, equipment: equipment.get() })
);
// Then compute final value
const totalDamage = withComputedState(
this,
'totalDamage',
combatStats,
(stats) => stats.playerLevel * 2 + stats.equipment.weaponDamage
);

Use withComputedState for:

  • Calculations and percentages
  • Boolean flags derived from state
  • Formatted strings for display
  • Conditional logic based on multiple properties
  • Performance-sensitive derived values (computed once, used many times)

Don’t use withComputedState for:

  • Simple property access (just use .get())
  • Values that change independently of state
  • Heavy computations (consider memoization patterns instead)

function withComputedState<T, U>(
scene: Phaser.Scene,
key: string,
sourceState: HookState<T>,
selector: (sourceValue: T) => U
): HookState<U>
  • scene - The Phaser scene instance
  • key - Unique identifier for the computed state
  • sourceState - The source hook state to derive from
  • selector - Function that computes the derived value from source

A read-only HookState<U> that automatically updates when source changes.