Skip to content

Health (game component)

🏷️ state data
🏷️ copy data on transition
🏷️ event payload
🏷️ specific state + event transition
🏷️ any state + event transition
🏷️ conditional transitions
🏷️ immediate (always) transitions
🏷️ non-reenter state transition
🏷️ delayed transition
🏷️ state entry side-effect
🏷️ side-effect cleanup
🏷️ send event to self

About

Models a health component from a game.

Uses a decision state (checkHealth) and immediate (always) transitions with conditions to determine which one of the health states (thriving, moderate, surviving, critical, expired) to go to next.

Health deteriorates as the machine receives DAMAGE events and improves when it receives FIRST_AID events.

If the machine receives a GOD_LIKE event (with human-compatible condition), the health component enters the invincible state. On entering this state we have an onEnter() side-effect, which after 10s sends a HUMAN_AGAIN event to the machine instance, and returns it to one of the health states.

While invincible, the machine effectively ignores any DAMAGE events by having a state-specific transition, and using reenter: false to avoid exiting-and-entering the state (which would stop and restart the invincibility timer), and not updating state data. (This is more for demonstration than necessity, since the state data stores the time that invincibilityStarted, so other events do trigger exit and re-entry into this state, and so they do cancel the timer and re-start it with the remaining time.)

The GOD_LIKE event is handled in any state so it’s possible to keep extending the invincibility timer by receiving more GOD_LIKE events after entering that state.

This machine’s state data is homogenous and we’re using enableCopyDataOnTransition to simplify some transitions that don’t update the data.

Definition

healthMachine.ts
import { defineMachine } from "yay-machine";
type HealthState = Readonly<{
/**
* checkHealth: transient decision state
* invincible: DAMAGE events do not hurt, temporarily
* thriving: strength + stamina are between 20 and 15
* moderate: strength + stamina are between 15 and 10
* surviving: strength + stamina are between 10 and 5
* critical: strength + stamina are between 5 and 0
*/
name:
| "checkHealth"
| "invincible"
| "thriving"
| "moderate"
| "surviving"
| "critical"
| "expired";
strength: number; // 0..10 inclusive
stamina: number; // 0..10 inclusive
invincibilityStarted: number;
}>;
type HealthEvent = Readonly<
| { type: "DAMAGE"; strength: number; stamina: number }
| { type: "FIRST_AID"; strength: number; stamina: number }
| { type: "GOD_LIKE"; compatibleWith: "human" | "reptile" }
| { type: "HUMAN_AGAIN" }
>;
const applyFirstAid = (
state: HealthState,
event: Extract<HealthEvent, { type: "FIRST_AID" }>,
): Omit<HealthState, "name"> => ({
...state,
strength: Math.min(state.strength + event.strength, 10),
stamina: Math.min(state.stamina + event.stamina, 10),
});
/**
* Player health component from a game
*/
export const healthMachine = defineMachine<HealthState, HealthEvent>({
enableCopyDataOnTransition: true,
initialState: {
name: "checkHealth",
strength: 10,
stamina: 10,
invincibilityStarted: 0,
},
states: {
checkHealth: {
always: [
{
to: "thriving",
when: ({ state }) => state.strength + state.stamina > 15,
},
{
to: "moderate",
when: ({ state }) => state.strength + state.stamina > 10,
},
{
to: "surviving",
when: ({ state }) => state.strength + state.stamina > 5,
},
{
to: "critical",
when: ({ state }) => state.strength + state.stamina > 0,
},
{
to: "expired",
},
],
},
invincible: {
onEnter: ({ state, send }) => {
const timer = setTimeout(
() => send({ type: "HUMAN_AGAIN" }),
performance.now() + 10_000 - state.invincibilityStarted,
);
return () => clearTimeout(timer);
},
on: {
FIRST_AID: {
to: "invincible",
data: ({ state, event }) => applyFirstAid(state, event),
},
DAMAGE: { to: "invincible", reenter: false },
HUMAN_AGAIN: {
to: "checkHealth",
data: ({ state }) => ({ ...state, invincibilityStarted: 0 }),
},
},
},
},
on: {
GOD_LIKE: {
to: "invincible",
when: ({ event }) => event.compatibleWith === "human",
data: ({ state }) => ({
...state,
invincibilityStarted: performance.now(),
}),
},
DAMAGE: {
to: "checkHealth",
data: ({ state, event }) => ({
strength: Math.max(state.strength - event.strength, 0),
stamina: Math.max(state.stamina - event.stamina, 0),
invincibilityStarted: 0,
}),
},
FIRST_AID: {
to: "checkHealth",
data: ({ state, event }) => applyFirstAid(state, event),
},
},
});

Usage

import assert from "assert";
import { healthMachine } from "./healthMachine";
const health = healthMachine.newInstance().start();
health.subscribe(({ state }) => {
if (state.name === "expired") {
console.log("GAME OVER");
}
});
assert.deepStrictEqual(health.state, {
name: "thriving",
strength: 10,
stamina: 10,
invincibilityStarted: 0,
});
health.send({ type: "DAMAGE", stamina: 1, strength: 3 });
health.send({ type: "DAMAGE", stamina: 2, strength: 1 });
health.send({ type: "DAMAGE", stamina: 1, strength: 1 });
assert.deepStrictEqual(health.state, {
name: "moderate",
strength: 5,
stamina: 6,
invincibilityStarted: 0,
});
health.send({ type: "FIRST_AID", stamina: 5, strength: 5 });
assert.deepStrictEqual(health.state, {
name: "thriving",
strength: 10,
stamina: 10,
invincibilityStarted: 0,
});
health.send({ type: "DAMAGE", stamina: 4, strength: 3 });
health.send({ type: "DAMAGE", stamina: 5, strength: 4 });
assert.deepStrictEqual(health.state, {
name: "critical",
strength: 3,
stamina: 1,
invincibilityStarted: 0,
});
health.send({ type: "GOD_LIKE", compatibleWith: "human" });
const invincibilityStarted = health.state.invincibilityStarted; // performance.now()
assert(invincibilityStarted > 0);
assert.deepStrictEqual(health.state, {
name: "invincible",
strength: 3,
stamina: 1,
invincibilityStarted,
});
health.send({ type: "DAMAGE", stamina: 9, strength: 7 });
health.send({ type: "DAMAGE", stamina: 7, strength: 6 });
assert.deepStrictEqual(health.state, {
name: "invincible",
strength: 3,
stamina: 1,
invincibilityStarted,
}); // still
health.send({ type: "FIRST_AID", stamina: 5, strength: 5 });
health.send({ type: "HUMAN_AGAIN" }); // test usage - it's supposed to be sent from a side-effect via a timer
assert.deepStrictEqual(health.state, {
name: "moderate",
strength: 8,
stamina: 6,
invincibilityStarted: 0,
});
// etc ...