Actions Overview
Actions are how you change game state in martini-kit. They are the bridge between player input and state mutations.
What is an Action?
An action is a named, typed function that modifies state:
interface ActionDefinition<TState, TInput> {
apply: (state: TState, context: ActionContext, input: TInput) => void;
} Example:
actions: {
move: {
apply: (state, context, input: { x: number; y: number }) => {
state.players[context.targetId].x = input.x;
state.players[context.targetId].y = input.y;
}
}
} Action Anatomy
Full Action Definition
actions: {
actionName: {
// Optional: Validate or transform input
input?: (raw: any) => TInput,
// Required: Mutate state
apply: (state: TState, context: ActionContext, input: TInput) => void
}
} Apply Function Signature
apply: (
state: TState, // Current game state (mutable)
context: ActionContext, // Who submitted, who to affect, etc.
input: TInput // Action payload
) => void Action Context
The most important parameter is context, which tells you who is involved:
interface ActionContext {
playerId: string; // Who called submitAction()
targetId: string; // Who should be affected (defaults to playerId)
isHost: boolean; // Whether this action runs on the host
random: SeededRandom; // Deterministic RNG (seeded per action)
} The #1 mistake: Using playerId instead of targetId in actions.
playerId= Who submitted the actiontargetId= Who the action affects
99% of the time, you want targetId.
Submitting Actions
Basic Submission
runtime.submitAction('actionName', input); Example:
runtime.submitAction('move', { x: 100, y: 200 }); With Target ID
runtime.submitAction('actionName', input, targetId); Example:
// Player A heals Player B
runtime.submitAction('heal', { amount: 20 }, 'player-B'); Action Flow
Understanding the complete flow:
1. Player presses key
↓
2. InputManager (if used) or manual submitAction()
↓
3. runtime.submitAction('move', { x: 100, y: 200 })
↓
4. Create action context:
- playerId: transport.getPlayerId()
- targetId: targetId || playerId
- random: new SeededRandom(actionSeed)
↓
5. If host: Apply action immediately to state
↓
6. Broadcast action message to all peers via transport
↓
7. All peers (including host) receive action message
↓
8. Clients apply action to their local state
↓
9. Notify onChange listeners
↓
10. UI/sprites update The host applies actions immediately, then broadcasts. Clients receive the action and apply it. This ensures the host is always authoritative.
Action Helpers
martini-kit provides helpers to reduce boilerplate:
createInputAction()
Stores input in state[key][targetId] automatically:
import { createInputAction } from '@martini-kit/core';
actions: {
move: createInputAction('inputs')
// Equivalent to:
// move: {
// apply: (state, context, input) => {
// if (!state.inputs) state.inputs = {};
// state.inputs[context.targetId] = input; // ✅ Uses targetId!
// }
// }
} createTickAction()
Host-only action for game loops:
import { createTickAction } from '@martini-kit/core';
actions: {
tick: createTickAction((state, delta, context) => {
// This only runs on the host!
updatePhysics(state, delta);
checkCollisions(state);
spawnEnemies(state, context.random);
})
} Best Practices
1. Keep Actions Pure
Actions should be deterministic and side-effect-free:
// ✅ Good - pure, deterministic
actions: {
move: {
apply: (state, context, input) => {
state.players[context.targetId].x = input.x;
state.players[context.targetId].y = input.y;
}
}
}
// ❌ Bad - side effects
actions: {
move: {
apply: (state, context, input) => {
state.players[context.targetId].x = input.x;
// Don't do this!
fetch('/api/log-move', { ... }); // API call
localStorage.setItem('lastMove', ...); // Side effect
}
}
} 2. Use Helper Functions
Keep actions readable:
// ✅ Good - helper function
function updateProjectiles(state: GameState, delta: number) {
state.projectiles.forEach(proj => {
proj.x += proj.vx * delta;
proj.y += proj.vy * delta;
});
}
actions: {
tick: createTickAction((state, delta) => {
updateProjectiles(state, delta);
checkCollisions(state);
})
} 3. Always Use context.targetId
Even when playerId === targetId, use targetId for consistency:
// ✅ Always correct
state.players[context.targetId].x = input.x;
// ❌ Avoid
state.players[context.playerId].x = input.x; Deep Dive: playerId vs targetId
The #1 beginner mistake in martini-kit is confusing playerId and targetId. This section explains the difference and when to use each.
The Difference
// ✅ CORRECT: Use targetId
actions: {
move: {
apply: (state, context, input) => {
state.players[context.targetId].x = input.x; // ✅
}
}
}
// ❌ WRONG: Using playerId
actions: {
move: {
apply: (state, context, input) => {
state.players[context.playerId].x = input.x; // ❌
}
}
} When They’re the Same
Most of the time, playerId === targetId:
// Player A moves their own character
runtime.submitAction('move', { x: 100, y: 200 });
// Inside action:
context.playerId // 'player-A'
context.targetId // 'player-A' (defaults to playerId) In this common case, both would work. But always use targetId for consistency.
When They’re Different
Sometimes, one player affects another player:
// Player A shoots Player B
runtime.submitAction('takeDamage', { amount: 10 }, 'player-B');
// ^^^^^^^^^^
// targetId
// Inside action:
apply: (state, context, input) => {
console.log(context.playerId); // 'player-A' (who shot)
console.log(context.targetId); // 'player-B' (who got hit)
// ✅ Correct: Damage player-B
state.players[context.targetId].health -= input.amount;
// ❌ Wrong: Would damage player-A instead!
state.players[context.playerId].health -= input.amount;
} Use playerId for:
- Logging: “Player A triggered action X”
- Permissions: “Can this player do this?”
- Scoring: “Player A scored a kill”
Use targetId for:
- State mutations: Always!
Real-World Examples
Example 1: Player Movement
actions: {
move: {
apply: (state, context, input: { x: number; y: number }) => {
// ✅ Use targetId - affects the moving player
const player = state.players[context.targetId];
if (player) {
player.x = input.x;
player.y = input.y;
}
}
}
}
// Usage:
runtime.submitAction('move', { x: 150, y: 200 }); Example 2: Combat (One Player Affects Another)
actions: {
attack: {
apply: (state, context, input: { damage: number; victimId: string }) => {
// ✅ context.playerId = attacker
// ✅ But we use input.victimId to know who to damage
const victim = state.players[input.victimId];
if (victim) {
victim.health -= input.damage;
// Log who attacked
console.log(`${context.playerId} attacked ${input.victimId}`);
}
}
}
}
// Player A attacks Player B
runtime.submitAction('attack', {
damage: 25,
victimId: 'player-B'
}); In this case, targetId defaults to the attacker’s ID, but we use input.victimId to know who to damage. This is a valid pattern when the action affects multiple entities.
Example 3: Using targetId Explicitly
actions: {
heal: {
apply: (state, context, input: { amount: number }) => {
// ✅ Use targetId - heals the specified player
const player = state.players[context.targetId];
if (player) {
player.health = Math.min(100, player.health + input.amount);
}
}
}
}
// Healer (player-A) heals teammate (player-B)
runtime.submitAction('heal', { amount: 20 }, 'player-B');
// ^^^^^^^^^^^
// targetId
// Inside action:
// context.playerId = 'player-A' (healer)
// context.targetId = 'player-B' (patient) Common Mistakes
Mistake 1: Using playerId for State Mutations
// ❌ WRONG
actions: {
move: {
apply: (state, context, input) => {
state.players[context.playerId].x = input.x;
}
}
}
// ✅ CORRECT
actions: {
move: {
apply: (state, context, input) => {
state.players[context.targetId].x = input.x;
}
}
} Mistake 2: Confusing Who Gets Affected
// ❌ WRONG - Healer heals themselves!
runtime.submitAction('heal', { amount: 20 });
actions: {
heal: {
apply: (state, context, input) => {
state.players[context.playerId].health += input.amount;
// This heals the healer, not the target!
}
}
}
// ✅ CORRECT - Healer heals target
runtime.submitAction('heal', { amount: 20 }, 'player-B');
actions: {
heal: {
apply: (state, context, input) => {
state.players[context.targetId].health += input.amount;
// This heals player-B
}
}
} Rule of Thumb
When in doubt, use targetId.
targetId= Who the action affectsplayerId= Who triggered the action
99% of state mutations should use targetId.
Next Steps
- defineGame() API → - Full reference
- Helpers API → - createInputAction(), createTickAction()