State & Actions ποΈ
gameplate's state model is intentionally boring: immutable state + pure action functions.
No proxies, no decorators, no useState ceremony. Just two functions and a TypeScript trick.
The shapeβ
import { createGame, defineActions } from 'gameplate';
// 1. Define your state type.
type State = {
player: { x: number; y: number; hp: number };
enemies: { id: string; x: number; y: number }[];
score: number;
};
// 2. Define actions as pure (state, ...args) => newState functions.
const actions = defineActions<State>()({
move: (s, dx: number, dy: number) => ({
...s,
player: { ...s.player, x: s.player.x + dx, y: s.player.y + dy },
}),
hurt: (s, dmg: number) => ({
...s,
player: { ...s.player, hp: Math.max(0, s.player.hp - dmg) },
}),
addScore: (s, points: number) => ({ ...s, score: s.score + points }),
});
// 3. Wire it up.
const game = createGame({
state: { player: { x: 0, y: 0, hp: 100 }, enemies: [], score: 0 },
actions,
});
// 4. Dispatch.
game.actions.move(10, 0); // β
game.actions.hurt(25); // β
game.actions.addScore(100); // β
game.actions.hurt('a lot'); // β TS error
Why defineActions<S>()(...) β the double call?β
To get the best of both worlds:
- You fix
Sonce (the first call:defineActions<State>()). - TypeScript infers each action's argument tuple from the action map you pass to the second call.
Without the split, you'd either have to retype S in front of every actionβ¦
// π with single-call: state type needs to be repeated:
const actions = {
move: (s: State, dx: number, dy: number) => ({ ... }),
hurt: (s: State, dmg: number) => ({ ... }),
};
β¦or accept a widened unknown state in your action bodies. The two-call form keeps the call
site clean while preserving inference.
State is DeepReadonlyβ
The state argument inside each action is typed as DeepReadonly<S>. This means:
const actions = defineActions<State>()({
move: (s, dx: number) => {
s.player.x = dx; // β Cannot assign to 'x' because it is a read-only property.
return s;
},
});
Compile-time protection against accidental mutation. The compiler nudges you toward producing new objects:
const actions = defineActions<State>()({
move: (s, dx: number) => ({
...s,
player: { ...s.player, x: s.player.x + dx }, // β
new objects, all the way down
}),
});
Runtime freeze (opt-in)β
For development, pass dev: true to also Object.freeze every state value at runtime, so
indirect mutation through some other module fails loud:
const game = createGame({ state, actions, dev: true });
Don't enable in production β Object.freeze is cheap but not free.
Dispatching returns voidβ
The dispatched form strips the state argument and returns void:
type DispatchOf<typeof actions> = {
move: (dx: number, dy: number) => void;
hurt: (dmg: number) => void;
addScore: (points: number) => void;
};
game.actions.move(10, 0); // returns void β state was updated as a side effect.
You read the new state via game.state():
game.actions.addScore(100);
console.log(game.state().score);
Subscribing to changesβ
game.subscribe() fires on every state change, with (current, previous):
const unsubscribe = game.subscribe((current, previous) => {
if (current.score !== previous.score) {
ui.updateScore(current.score);
}
});
// Later:
unsubscribe();
For performance-sensitive comparisons, prefer selectors which short-circuit on reference equality.
Working with libraries (Immer, Mutative, etc.)β
You can use any immutability library β actions just need to return a new reference:
import { produce } from 'immer';
const actions = defineActions<State>()({
hurt: (s, dmg: number) =>
produce(s, (draft) => {
draft.player.hp = Math.max(0, draft.player.hp - dmg);
}),
});
gameplate itself stays dependency-free; you bring Immer if you want it.
Without createGame β just the storeβ
createStore is also exported. Use it for non-game UIs, server-side simulations, or anywhere
you want the store without the loop:
import { createStore } from 'gameplate';
const store = createStore({ n: 0 });
store.subscribe((next, prev) => console.log(prev.n, 'β', next.n));
store.setState((s) => ({ ...s, n: s.n + 1 }));