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)
}
Critical: playerId vs targetId

The #1 mistake: Using playerId instead of targetId in actions.

  • playerId = Who submitted the action
  • targetId = 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
Host Applies Immediately

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;
}
When to Use playerId

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 affects
  • playerId = Who triggered the action

99% of state mutations should use targetId.

Next Steps