State Management
In martini-kit, state is the single source of truth for your game. Understanding how state works is crucial to building robust multiplayer games.
What is State?
State is a plain JavaScript object that represents everything about your game at a given moment:
interface GameState {
players: Record<string, Player>;
projectiles: Projectile[];
score: Record<string, number>;
gameStatus: 'waiting' | 'playing' | 'ended';
timer: number;
} Key principle: If it affects gameplay, it belongs in state.
State Definition
Setup Function
The setup() function initializes your state when the game starts:
import { defineGame } from '@martini-kit/core';
export const game = defineGame({
setup: ({ playerIds, random }) => ({
// Initialize players
players: Object.fromEntries(
playerIds.map((id, index) => [
id,
{
x: 100 + index * 200,
y: 300,
health: 100,
score: 0
}
])
),
// Initialize game entities
projectiles: [],
powerups: [],
// Game metadata
gameStatus: 'playing',
timer: 60,
round: 1
})
}); The setup() function runs on both host and clients to initialize their local state. This is why you must use random instead of Math.random() - to ensure all clients start with identical state.
Setup Context
The setup function receives a context object:
interface SetupContext {
playerIds: string[]; // All connected player IDs
random: SeededRandom; // Deterministic RNG (same seed on all clients)
} Example:
setup: ({ playerIds, random }) => {
const spawnPoints = [
{ x: 100, y: 300 },
{ x: 700, y: 300 }
];
return {
players: Object.fromEntries(
playerIds.map((id, index) => [
id,
{
...spawnPoints[index],
// Random color (same on all clients!)
color: random.choice(['red', 'blue', 'green', 'yellow'])
}
])
)
};
} State Structure Best Practices
1. Keep State Flat
❌ Avoid deep nesting:
// Bad - too nested
interface BadState {
game: {
session: {
players: {
data: {
[id: string]: {
stats: {
health: number;
mana: number;
}
}
}
}
}
}
}
// Accessing deeply nested data is cumbersome
state.game.session.players.data[playerId].stats.health -= 10; ✅ Prefer flat structures:
// Good - flat and organized
interface GoodState {
players: Record<string, Player>;
health: Record<string, number>; // Player ID → health
mana: Record<string, number>; // Player ID → mana
projectiles: Projectile[];
powerups: Powerup[];
}
// Much cleaner
state.health[playerId] -= 10; 2. Use Records for Collections
Use Record<string, T> for player-indexed data:
interface GameState {
// ✅ Good - easy to look up by player ID
players: Record<string, Player>;
scores: Record<string, number>;
// ✅ Good - arrays for ordered collections
projectiles: Projectile[];
events: GameEvent[];
} 3. Keep State Serializable
State must be JSON-serializable (no functions, class instances, or circular references):
// ❌ Bad - not serializable
interface BadState {
players: Map<string, Player>; // Maps don't serialize
myFunction: () => void; // Functions don't serialize
sprite: Phaser.GameObjects.Sprite; // Class instances don't serialize
}
// ✅ Good - all plain data
interface GoodState {
players: Record<string, Player>; // Plain objects
projectiles: Projectile[]; // Plain arrays
config: { speed: number; damage: number }; // Plain values
} State is sent over the network as JSON. Non-serializable data will be lost or cause errors during synchronization.
4. Separate Rendering State from Game State
Keep Phaser sprites, textures, and UI separate from game state:
// ❌ Bad - mixing game logic with rendering
interface BadState {
players: Record<string, {
x: number;
y: number;
sprite: Phaser.GameObjects.Sprite; // Don't store sprites in state!
}>;
}
// ✅ Good - game state only
interface GoodState {
players: Record<string, {
x: number;
y: number;
health: number;
}>;
// Rendering happens in Phaser scene
// Sprites are created/updated based on state
} The PhaserAdapter handles syncing state → sprites automatically.
State Synchronization
How Sync Works
martini-kit uses a diff/patch algorithm to minimize bandwidth:
- Host generates diff: Compare old state to new state
- Create patches: Minimal set of changes
- Broadcast patches: Send only the changes
- Clients apply patches: Update their local state
Patch Format
Patches use a JSON Patch-like format:
interface Patch {
op: 'replace' | 'add' | 'remove';
path: string[]; // Path to the changed property
value?: any; // New value (for replace/add)
} Example: Player moves from (100, 200) to (150, 200)
// Before:
{ players: { p1: { x: 100, y: 200 } } }
// After:
{ players: { p1: { x: 150, y: 200 } } }
// Patch generated:
[
{ op: 'replace', path: ['players', 'p1', 'x'], value: 150 }
]
// Only 1 field changed, so only 1 patch sent! Example: New projectile added
// Before:
{ projectiles: [] }
// After:
{ projectiles: [{ id: '1', x: 100, y: 100, vx: 5, vy: 0 }] }
// Patch generated:
[
{ op: 'add', path: ['projectiles', '0'], value: { id: '1', x: 100, y: 100, vx: 5, vy: 0 } }
] Example: Player disconnects
// Before:
{ players: { p1: { ... }, p2: { ... } } }
// After:
{ players: { p1: { ... } } }
// Patch generated:
[
{ op: 'remove', path: ['players', 'p2'] }
] Sync Frequency
By default, state syncs every 50ms (20 FPS):
const runtime = new GameRuntime(game, transport, {
isHost: true,
playerIds: ['p1', 'p2'],
syncInterval: 50 // Sync every 50ms (20 FPS)
}); Tuning sync rate:
| Game Type | Recommended Sync Rate |
|---|---|
| Turn-based | 200-500ms (2-5 FPS) |
| Slow-paced (puzzle, card) | 100ms (10 FPS) |
| Medium-paced (platformer) | 50ms (20 FPS) - default |
| Fast-paced (shooter, racing) | 30ms (33 FPS) |
Lower sync interval = smoother updates, but higher bandwidth. Balance based on your game’s needs.
Mutability in Actions
Unlike Redux or other state management libraries, actions can directly mutate state:
actions: {
move: {
apply(state, context, input: { x: number; y: number }) {
// ✅ Direct mutation is OK and encouraged!
state.players[context.targetId].x = input.x;
state.players[context.targetId].y = input.y;
}
}
} Performance: Mutating state directly is faster than creating new objects.
Simplicity: No need for immer, spread operators, or reducers.
Isolation: Actions run in isolation, so mutation is safe.
martini-kit internally snapshots state before/after actions to generate diffs, so you don’t need to worry about immutability.
State Validation (Development)
In development mode, martini-kit validates state structure:
const runtime = new GameRuntime(game, transport, {
isHost: true,
playerIds: ['p1', 'p2'],
// Throw errors for invalid state (development only)
strict: true,
// Validate that all playerIds are initialized in setup()
strictPlayerInit: true,
// Key in state where players are stored
playersKey: 'players'
}); Validation checks:
- All
playerIdsare initialized insetup() - State is JSON-serializable
- Actions don’t create circular references
Bandwidth Optimization
1. Minimize State Size
Smaller state = less bandwidth:
// ❌ Wasteful
interface WastefulState {
players: Record<string, {
id: string; // Redundant (already the key)
position: { x: number; y: number }; // Extra nesting
velocity: { x: number; y: number };
metadata: {
createdAt: string; // Unnecessary for gameplay
lastUpdate: string;
};
}>;
}
// ✅ Optimized
interface OptimizedState {
players: Record<string, {
x: number; // Flat
y: number;
vx: number; // Velocity inline
vy: number;
}>;
} 2. Use Compact Data Types
// ❌ Wasteful
{
health: 100.0,
angle: 3.141592653589793,
isAlive: true
}
// ✅ Optimized
{
health: 100, // Integer instead of float
angle: 3.14, // Round to 2 decimals
alive: 1 // Use 1/0 instead of boolean (saves bytes)
} 3. Avoid Redundant Data
// ❌ Wasteful - storing derived data
{
players: {
p1: { x: 100, y: 200 },
p2: { x: 300, y: 400 }
},
playerCount: 2, // Redundant - can calculate from players
playerIds: ['p1', 'p2'] // Redundant - can get from Object.keys(players)
}
// ✅ Optimized
{
players: {
p1: { x: 100, y: 200 },
p2: { x: 300, y: 400 }
}
// Derive playerCount and playerIds on the client
} 4. Quantize Large Numbers
For large worlds, quantize coordinates:
// ❌ Full precision
{ x: 1234.5678, y: 9876.5432 }
// ✅ Quantized (rounded to nearest integer)
{ x: 1235, y: 9877 }
// Or use fixed-point math (multiply by 10)
{ x: 12345, y: 98765 } // Store as integers, divide by 10 on client State Inspection (DevTools)
Use StateInspector to debug state during development:
import { StateInspector } from '@martini-kit/devtools';
const inspector = new StateInspector({
maxSnapshots: 100,
snapshotThrottleMs: 500
});
inspector.attach(runtime);
// View snapshots
console.log(inspector.getSnapshots());
// View action history
console.log(inspector.getActionHistory()); See StateInspector API for details.
Common Patterns
Pattern 1: Player-Indexed Data
Store data per player using their ID as key:
interface GameState {
players: Record<string, Player>;
health: Record<string, number>;
inventory: Record<string, Item[]>;
cooldowns: Record<string, number>;
}
// Access by player ID
state.health[playerId] -= damage;
state.inventory[playerId].push(newItem); Pattern 2: Entity Lists with IDs
For dynamic entities (projectiles, enemies), use arrays with unique IDs:
interface GameState {
projectiles: Array<{
id: string;
x: number;
y: number;
ownerId: string; // Who shot it
}>;
}
// Add projectile
state.projectiles.push({
id: crypto.randomUUID(),
x: playerX,
y: playerY,
ownerId: playerId
});
// Remove projectile
const index = state.projectiles.findIndex(p => p.id === targetId);
if (index !== -1) state.projectiles.splice(index, 1); Pattern 3: Separate Input from State
Use an inputs object to store player input separately:
interface GameState {
players: Record<string, Player>;
inputs: Record<string, PlayerInput>; // Separate input state
}
// In action:
actions: {
move: {
apply(state, context, input) {
state.inputs[context.targetId] = input; // Store input
// Physics loop will read inputs and update player positions
}
}
} This pattern is useful for physics-based games where input is processed in a separate tick action.
Next Steps
- Actions - Learn how to modify state with actions
- Determinism - Why seeded random is critical for state consistency
- Sync API - Deep dive into diff/patch algorithm
- GameRuntime API - Full API for state management