defineGame()
The defineGame() function is the foundation of every martini-kit multiplayer game. It provides a declarative API for defining your game’s state, actions, and player lifecycle hooks with full TypeScript type safety.
API Reference
function defineGame<TState>(definition: GameDefinition<TState>): GameDefinition<TState> GameDefinition Interface
interface GameDefinition<TState> {
setup?: (context: SetupContext) => TState;
actions?: Record<string, ActionDefinition<TState>>;
onPlayerJoin?: (state: TState, playerId: string) => void;
onPlayerLeave?: (state: TState, playerId: string) => void;
} Properties
setup()
Optional - Creates the initial game state when the game runtime starts.
setup?: (context: SetupContext) => TState Parameters:
context.playerIds- Array of initial player IDscontext.random- Deterministic SeededRandom instance
When it’s called:
- Once when the
GameRuntimeis created - On both host and clients (with the same seed)
- Before any actions are processed
Returns: The initial game state
Example:
const game = defineGame({
setup: ({ playerIds, random }) => ({
players: Object.fromEntries(
playerIds.map(id => [id, {
x: random.range(100, 700), // Random spawn position
y: random.range(100, 500),
health: 100,
score: 0
}])
),
projectiles: [],
gameStatus: 'waiting' as const
})
}); actions
Optional - Defines all the ways state can be modified. Actions are the only way to modify game state (besides sprite auto-sync).
actions?: Record<string, ActionDefinition<TState>> Each action has this structure:
interface ActionDefinition<TState, TInput> {
input?: any; // Optional validation schema
apply: (state: TState, context: ActionContext, input: TInput) => void;
} ActionContext:
interface ActionContext {
playerId: string; // Who submitted the action
targetId: string; // Who the action affects (⚠️ USE THIS for state mutations!)
isHost: boolean; // Whether this is running on the host
random: SeededRandom; // Deterministic RNG
} :::warning[Critical: playerId vs targetId] Always use context.targetId for state mutations!
playerId= Who submitted the action (pressed the button)targetId= Who should be affected by the action
These are usually the same, but not always. For example, when Player A shoots Player B, playerId is Player A but targetId should be Player B for the damage action.
:::
Example:
const game = defineGame({
actions: {
// Simple movement action
move: {
apply(state, context, input: { x: number; y: number }) {
const player = state.players[context.targetId]; // ✅ Correct!
if (player) {
player.x = input.x;
player.y = input.y;
}
}
},
// Shooting action with RNG
shoot: {
apply(state, context, input: { angle: number }) {
const shooter = state.players[context.targetId];
if (!shooter) return;
// ✅ Use context.random for determinism
const spread = context.random.float(-0.1, 0.1);
state.projectiles.push({
id: `proj-${Date.now()}-${context.random.range(0, 9999)}`,
x: shooter.x,
y: shooter.y,
angle: input.angle + spread,
ownerId: context.targetId,
damage: 10
});
}
},
// Taking damage (targetId is the victim)
takeDamage: {
apply(state, context, input: { amount: number }) {
const victim = state.players[context.targetId]; // Target = who gets hurt
if (victim) {
victim.health -= input.amount;
if (victim.health <= 0) {
victim.health = 0;
// Handle death...
}
}
}
}
}
}); Calling actions:
// Move myself
runtime.submitAction('move', { x: 200, y: 300 });
// Shoot (also myself)
runtime.submitAction('shoot', { angle: Math.PI / 4 });
// Player 1 shoots Player 2
runtime.submitAction('shoot', { angle: 0 }, 'player-1');
// Player 2 takes damage
runtime.submitAction('takeDamage', { amount: 10 }, 'player-2'); onPlayerJoin()
Optional - Called when a player joins mid-game (after setup).
onPlayerJoin?: (state: TState, playerId: string) => void When it’s called:
- When a new player connects after the game has started
- Not called for initial players (those are handled in
setup()) - Only runs on the host
Example:
const game = defineGame({
onPlayerJoin(state, playerId) {
// Add new player to the game
state.players[playerId] = {
x: 400,
y: 300,
health: 100,
score: 0
};
// Log the join
console.log(`${playerId} joined the game`);
}
}); Using with createPlayerManager:
import { createPlayerManager } from '@martini-kit/core';
const playerManager = createPlayerManager({
factory: (playerId, index) => ({
x: 100 + index * 100,
y: 200,
health: 100
}),
roles: ['fire', 'ice'], // Optional role assignment
spawnPoints: [ // Optional spawn points
{ x: 200, y: 400 },
{ x: 600, y: 400 }
]
});
const game = defineGame({
onPlayerJoin: playerManager.createHandlers().onPlayerJoin
// Or manually:
// onPlayerJoin: (state, playerId) => {
// playerManager.handleJoin(state.players, playerId);
// }
}); onPlayerLeave()
Optional - Called when a player disconnects.
onPlayerLeave?: (state: TState, playerId: string) => void When it’s called:
- When a player’s transport disconnects
- Only runs on the host
- The player’s state is not automatically removed (you must do this yourself)
Example:
const game = defineGame({
onPlayerLeave(state, playerId) {
// Clean up player state
delete state.players[playerId];
delete state.inputs[playerId];
// Award points to remaining players?
// Transfer resources?
// etc.
console.log(`${playerId} left the game`);
}
}); Type Safety
defineGame() is fully type-safe when you provide a state interface:
interface GameState {
players: Record<string, {
x: number;
y: number;
health: number;
score: number;
}>;
projectiles: Array<{
id: string;
x: number;
y: number;
vx: number;
vy: number;
}>;
gameStatus: 'waiting' | 'playing' | 'ended';
}
const game = defineGame<GameState>({
setup: ({ playerIds }) => ({
players: {}, // ✅ Autocomplete works!
projectiles: [], // ✅ Type-checked!
gameStatus: 'waiting'
}),
actions: {
move: {
apply(state, context, input: { x: number; y: number }) {
// ✅ state.players is fully typed
// ✅ input.x and input.y have autocomplete
state.players[context.targetId].x = input.x;
}
}
}
}); Complete Example
Here’s a complete working game definition from the Fire & Ice demo:
import { defineGame, createPlayerManager, createInputAction } from '@martini-kit/core';
// Define state shape
interface FireAndIceState {
players: Record<string, {
x: number;
y: number;
role: 'fire' | 'ice';
}>;
inputs: Record<string, any>;
}
// Create player manager
const playerManager = createPlayerManager({
roles: ['fire', 'ice'],
factory: (playerId, index) => ({
x: index === 0 ? 200 : 600,
y: 400,
role: index === 0 ? 'fire' as const : 'ice' as const,
}),
});
// Define the game
export const fireAndIceGame = defineGame<FireAndIceState>({
setup: ({ playerIds }) => ({
players: playerManager.initialize(playerIds),
inputs: {},
}),
actions: {
// Use helper for input handling
move: createInputAction('inputs'),
},
onPlayerJoin: (state, playerId) => {
playerManager.handleJoin(state.players, playerId);
},
onPlayerLeave: (state, playerId) => {
playerManager.handleLeave(state.players, playerId);
},
}); Best Practices
✅ Do
- Use TypeScript - Define your state interface for autocomplete and type safety
- Use
context.targetId- For all state mutations in actions - Use
context.random- For any randomness (notMath.random()) - Keep state serializable - No functions, classes, or circular references
- Use helpers -
createPlayerManager(),createInputAction(), etc. - Keep actions pure - No side effects, API calls, or DOM manipulation
❌ Don’t
- Don’t use
Math.random()- Usecontext.randominstead - Don’t use
Date.now()- Different on each client; use deterministic alternatives - Don’t mutate input - It’s shared across all clients
- Don’t call external APIs - Actions should be deterministic
- Don’t store classes in state - Only plain objects, arrays, primitives
- Don’t use
context.playerIdfor mutations - Usecontext.targetIdinstead
Common Patterns
Game Loop with Tick Action
import { createTickAction } from '@martini-kit/core';
const game = defineGame({
actions: {
tick: createTickAction((state, context) => {
// Update physics
for (const player of Object.values(state.players)) {
player.y += player.vy;
player.vy += 0.5; // Gravity
}
// Update projectiles
state.projectiles = state.projectiles.filter(proj => {
proj.x += proj.vx;
proj.y += proj.vy;
return proj.x >= 0 && proj.x <= 800; // Remove off-screen
});
})
}
});
// Call tick regularly
setInterval(() => {
runtime.submitAction('tick');
}, 16); // 60 FPS Input Tracking
import { createInputAction } from '@martini-kit/core';
const game = defineGame({
setup: () => ({
players: {},
inputs: {} // Store each player's input
}),
actions: {
// Automatically stores input in state.inputs[targetId]
setInput: createInputAction('inputs')
}
});
// Usage:
runtime.submitAction('setInput', { left: true, jump: false }); Role-Based Player Assignment
const playerManager = createPlayerManager({
roles: ['tank', 'healer', 'damage'],
factory: (playerId, index, role) => ({
x: 100,
y: 100,
role,
health: role === 'tank' ? 200 : 100,
damage: role === 'damage' ? 20 : 10
})
}); See Also
- GameRuntime - Running your game
- Actions Guide - Deep dive into actions
- State Management - State best practices
- SeededRandom - Deterministic randomness
- Helper Functions - Utility functions