npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@crew-games/sdk

v0.7.0

Published

TypeScript SDK for building multiplayer games on Crew Games platform

Readme

@crew-games/sdk

Official SDK for building multiplayer games on the Crew Games platform.

Installation

npm install @crew-games/sdk
npm install --save-dev @crew-games/dev-server

Quick Start

1. Create Your Game

import { CrewGame, Player, GameState, RoomAssignmentRequest, StateUpdateRequest } from '@crew-games/sdk';

interface TriviaState extends GameState {
  question: string;
  options: string[];
  correctAnswer: string;
  answers: Record<string, string>;
  scores: Record<string, number>;
  currentRound: number;
}

class TriviaGame extends CrewGame<TriviaState> {
  async onStart() {
    if (!this.isHost) return;

    // Initialize game state
    await this.setState({
      question: 'What is the capital of France?',
      options: ['Paris', 'London', 'Berlin', 'Madrid'],
      correctAnswer: 'Paris',
      answers: {},
      scores: {},
      currentRound: 1,
    });

    // Show notification
    await this.showNotification('Trivia game started!', 'success');
  }

  async onPlayerJoined(player: Player) {
    await this.sendChat(`${player.name} joined the game!`);
  }

  async submitAnswer(answer: string) {
    const playerId = this.currentPlayer?.id;
    if (!playerId) return { accepted: false, reason: 'Player is unavailable' };
    if (this.isHost) return this.applyAnswer(playerId, answer);
    return this.requestStateUpdate({ answers: { [playerId]: answer } });
  }

  async onStateUpdateRequest({ participantId, state }: StateUpdateRequest<TriviaState>) {
    const answer = state.answers?.[participantId];
    if (Object.keys(state).length !== 1 || typeof answer !== 'string') {
      return { accepted: false, reason: 'Invalid answer request' };
    }
    return this.applyAnswer(participantId, answer);
  }

  private async applyAnswer(playerId: string, answer: string) {
    if (
      !this.isHost ||
      !this.state.options.includes(answer) ||
      this.state.answers[playerId] !== undefined
    ) {
      return { accepted: false, reason: 'Answer is not valid' };
    }

    const scores = { ...this.state.scores };
    if (answer === this.state.correctAnswer) {
      scores[playerId] = (scores[playerId] ?? 0) + 1;
    }
    await this.setState({
      answers: { ...this.state.answers, [playerId]: answer },
      scores,
    });
    return { accepted: true };
  }
}

// Initialize the game
const game = new TriviaGame({
  debug: true,
  enableScorePersistence: true,
  showParticipantScores: true,
});

Upgrading to SDK 0.6.0

Install the SDK and dev server together:

npm install @crew-games/[email protected]
npm install --save-dev @crew-games/[email protected]

setState() is now host-only. Guard shared-state initialization and host-owned transitions with if (this.isHost). Replace player-side setState() calls with requestStateUpdate(); implement onStateUpdateRequest() on the host to validate the player's action, commit accepted state with setState(), and return { accepted: true } or { accepted: false, reason }. Requests resolve with the host's decision, or reject after a 15-second timeout.

2. Test Locally

Terminal 1 - Start Dev Server:

npx crew-games-dev

Terminal 2 - Start Your Game:

npx vite

Browser - Open Multiple Tabs:

http://localhost:5173?session=test&player=player-1
http://localhost:5173?session=test&player=player-2
http://localhost:5173?session=test&player=player-3

Each tab represents a different player. State syncs in real-time via the dev server!

External store and React integration (0.7.0)

Install SDK 0.7.0 to use the native store and optional React adapter:

npm install @crew-games/[email protected]

Dev server 0.6.0 remains compatible; these client-side APIs introduce no new wire messages and do not require a dev-server upgrade.

The core SDK is framework-independent and does not import React. Every CrewGame implements the typed CrewGameStore<State> contract:

const { getSnapshot, subscribeSnapshot } = game; // Stable, instance-bound methods
const unsubscribe = subscribeSnapshot(() => render(getSnapshot()));
render(getSnapshot());
unsubscribe(); // Idempotent; removes only this subscription

getSnapshot() returns a cached CrewGameSnapshot<State> containing:

  • state, players, currentPlayer, host, hostParticipantId, and isHost;
  • SDK phase, isReady, and isInitialized;
  • activePlayers, spectators, activePlayerIds, spectatorIds, and canCurrentPlayerPlay;
  • gameInstance metadata and a monotonically increasing revision.

isInitialized means an INIT has supplied instance metadata. isReady is true after INIT (or after the development transport connects), and becomes false when the development transport disconnects. It does not mean the host has started play.

The SDK publishes the new snapshot before calling store listeners. It publishes initialization even when game state is unchanged, as well as state, phase, roster, identity, spectator, reset, and transport-readiness updates. Repeated INIT for the same instance refreshes identity and roster without rerunning onReady/onStart or resetting active game state. A reconnecting roster entry refreshes player metadata while preserving join and spectator status.

Snapshot reads and subscriptions never send messages or execute game actions. Unchanged reads return the same object, so React does not enter an update loop. Legacy SDK events do not create another snapshot when the transition has already been published. Explicit UI-only publishSnapshot() calls still advance revision even when game-state values are unchanged.

Optional React 18+ entry point

Install React in your game and import only the optional adapter:

import { useCrewGame } from '@crew-games/sdk/react';

export function App({ game }: { game: MyGame }) {
  const { state, players, currentPlayer, host, isHost, isInitialized } = useCrewGame(game);
  if (!isInitialized) return <p>Connecting to the game...</p>;
  return <MyGameScreen state={state} players={players} isHost={isHost} />;
}

The hook uses useSyncExternalStore, which rechecks the snapshot after subscribing and closes the render-to-subscription initialization gap for every client. There is no polling, repeated INIT, or game-specific participant fallback. React is an optional peer dependency: non-React consumers do not need it. The root SDK and CDN bundle do not import React.

Create a game instance once, outside React render, after entering the browser. The existing SDK constructor requires window and starts its platform connection. SSR/hydration is not supported by this adapter: it deliberately does not supply a server snapshot. In server-rendered apps, load the game screen client-side without SSR; do not construct a CrewGame on the server. Strict Mode may subscribe, unsubscribe, and resubscribe safely, but constructing a game inside render is not safe. Changing the game prop detaches the old store and reads the new instance.

State ownership and subclass-owned UI data

Snapshots are detached, recursively frozen copies of JSON-compatible state, player records, arrays, and instance metadata. Treat them as read-only. The SDK does not freeze or mutate game-owned state while copying it. Use plain objects, arrays, and scalar values (not classes, functions, cyclic structures, or mutable collections) for snapshot data.

Unchanged nested objects/arrays, player records, and roster collections are reused between snapshots. A feedback-only publication reuses all unchanged snapshot data; only the snapshot envelope and revision change. Cached copies use weak references to their source objects so the cache does not retain discarded game-state branches.

Host setState(), incoming state replacements, and lobby reset defaults are validated before changing state or reset lifecycle fields. Unsupported data is rejected with its property path. Failed local updates do not send platform messages; failed incoming updates follow the SDK's existing error event path. These data checks also apply to games that have not subscribed to snapshots.

Use setState() to commit host state; replace changed nested objects/arrays. Once an object has been used in state, never mutate it in place: its immutable snapshot copy is cached by source identity. Silent nested mutations are not tracked, and publishing feedback does not force a recopy of mutated nested objects. Subclasses that update protected fields directly or maintain UI-only data outside game state must call the protected publishSnapshot() method after the change:

private _feedback = '';
get feedback(): string { return this._feedback; }

dismissFeedback(): void {
  this._feedback = '';
  this.publishSnapshot(); // Rerenders subscribers even when state is unchanged
}

Keep feedback in typed game state if it must be read directly from snapshot.state. Otherwise, a subscribed React component may read a subclass getter during render; the explicit publication advances revision and causes that render.

Existing wrapper-event hooks can re-export this hook under their current public name once they depend on a released SDK containing this API. They must forward UI-only changes to publishSnapshot() rather than relying solely on a private wrapper event bus. Migrate and rebuild individual games separately.

The store cannot repair a missing bridge roster or missing READY/INIT messages. Validate uploaded builds through the real bridge as both host and guest in addition to store and React tests.

Events

The SDK fires events at key moments to keep your UI in sync with game state. Use these events instead of manually calling render functions to ensure framework independence.

Available Events

// Phase changes (lobby → active → ended)
game.on('phaseChange', (newPhase: GamePhase, oldPhase: GamePhase) => {
  console.log(`Phase changed from ${oldPhase} to ${newPhase}`);
  renderUI(); // React: trigger re-render, Vanilla: update DOM
});

// State updates from other players
game.on('stateChange', (newState, oldState) => {
  console.log('State updated:', newState);
  renderUI();
});

// Players joining/leaving
game.on('playerJoined', (player: Player) => {
  console.log(`${player.name} joined`);
  renderUI();
});

game.on('playerLeft', (player: Player) => {
  console.log(`${player.name} left`);
  renderUI();
});

// Messages from other players
game.on('message', (message, senderId) => {
  console.log('Message from', senderId, ':', message);
});

// Errors
game.on('error', (error: string) => {
  console.error('Game error:', error);
});

Example: Vanilla JavaScript

class TriviaGame extends CrewGame<TriviaState> {
  constructor() {
    super({ debug: true });
    
    // Listen to phase changes
    this.on('phaseChange', (newPhase) => {
      renderUI(this);
    });
    
    // Listen to state changes
    this.on('stateChange', () => {
      renderUI(this);
    });
  }
}

Example: React

function TriviaGameComponent() {
  const [game] = useState(() => new TriviaGame());
  const [phase, setPhase] = useState(game.phase);
  const [state, setState] = useState(game.state);
  
  useEffect(() => {
    // React to phase changes
    game.on('phaseChange', (newPhase) => {
      setPhase(newPhase);
    });
    
    // React to state changes
    game.on('stateChange', (newState) => {
      setState(newState);
    });
  }, [game]);
  
  if (phase === GamePhase.LOBBY) return <LobbyScreen game={game} />;
  if (phase === GamePhase.ACTIVE) return <ActiveGame game={game} state={state} />;
  if (phase === GamePhase.ENDED) return <GameOver game={game} state={state} />;
}

Host-Controlled Game Logic

This is the most important pattern to understand when building multiplayer games. Getting it wrong leads to every player independently trying to advance the game phase simultaneously, causing race conditions and double-transitions.

The Rule: Host Drives, Everyone Renders

| Responsibility | Who does it | |---|---| | Phase transitions (lobby → active → ended) | Host only | | Advancing rounds, picking next prompt, etc. | Host only | | Submitting scores, drawings, votes | Every player (their own data) | | Rendering the current state | Every player |

The onStateChange Gotcha

setState() broadcasts the new state to all players — meaning every player's onStateChange fires whenever state changes (including changes triggered by other players). If you put phase-advance logic inside onStateChange without an isHost guard, every player will try to advance the phase at the same time.

// ❌ WRONG — every player fires this when state arrives, causing duplicate phase transitions
async onStateChange(newState: MyState, oldState: MyState) {
  const allSubmitted = this.activePlayers.every(p => !!newState.answers[p.id]);
  if (allSubmitted) {
    await this.moveToResults(); // Runs on ALL clients simultaneously — race condition!
  }
}

// ✅ CORRECT — only the host reacts to state changes that should advance the game
async onStateChange(newState: MyState, oldState: MyState) {
  if (!this.isHost) return; // Non-host players just re-render via their event listener

  const allSubmitted = this.activePlayers.every(p => !!newState.answers[p.id]);
  if (allSubmitted) {
    await this.moveToResults(); // Runs exactly once, on the host
  }
}

Message Routing Reference

Understanding where messages go helps you reason about who runs what:

| API call | Who receives it | |---|---| | setState(state) | Host writes; all players receive the update via onStateChange / stateChange | | requestStateUpdate(patch) | Host receives the proposal; caller receives the acceptance result | | broadcast(msg) | All players except the sender (via message event) | | sendTo(playerId, msg) | Only that specific player (via message event) | | changePhase(phase) | All players including the sender (via phaseChange event) |

Host-authoritative state

Only the host can commit shared game state with setState(). Non-host calls throw; player actions must be sent to the host with requestStateUpdate(). The host validates each request in onStateUpdateRequest(), commits accepted state with setState(), and returns { accepted: true }.

// A non-host asks the host to apply a player action.
const result = await game.requestStateUpdate({ votes: nextVotes });

// On the host game:
async onStateUpdateRequest({ participantId, state }: StateUpdateRequest<MyState>) {
  if (!this.activePlayers.some((player) => player.id === participantId)) {
    return { accepted: false, reason: 'Player is not active' };
  }

  await this.setState(state);
  return { accepted: true };
}

requestStateUpdate() and requestRoomAssignment() resolve with { accepted, reason? }. If no host response arrives within 15 seconds, the SDK resolves with accepted: false and a timeout reason. Keep validation and game-specific rules in the game code.

Meeting UI Controls (Video Overlays, Reactions, Rooms)

Games can now control specific meeting UI behaviors through the SDK:

  • Per-participant video overlay text (for scores, status, etc.)
  • Emoji reactions on behalf of a participant
  • Room assignments for room-based visibility/media filtering in the host app
interface MyState extends GameState {
  roomAssignments: Record<string, string>;
}

class MyGame extends CrewGame<MyState> {
  async updateScoreboard(scores: Record<string, number>) {
    const overlays = Object.fromEntries(
      this.players.map((player) => [player.id, `Score: ${scores[player.id] ?? 0}`]),
    );
    await this.setParticipantVideoOverlays(overlays);
  }

  async celebrateWinner(winnerId: string) {
    await this.sendReaction('party', winnerId);
  }

  async splitIntoRooms(assignments: Record<string, string>) {
    if (!this.isHost) return;
    await this.setRoomAssignments(assignments);
  }

  async onRoomAssignmentRequest({ participantId, roomId }: RoomAssignmentRequest) {
    const assignments = { ...(this.state.roomAssignments ?? {}), [participantId]: roomId };
    await this.setState({ roomAssignments: assignments });
    await this.setRoomAssignments(assignments);
    return { accepted: true };
  }
}

Available APIs

  • setParticipantVideoOverlay(participantId, text)
  • setParticipantVideoOverlays(overlays)
  • clearParticipantVideoOverlay(participantId)
  • setRoomAssignments(assignments) (host only)
  • clearRoomAssignments() (host only; clears all assignments)
  • requestRoomAssignment(roomId) (requests a room change for the current player)

Room assignments are host-authoritative. Non-hosts request a room change; the host receives onRoomAssignmentRequest({ requestId, participantId, roomId }), validates it, updates its canonical map, then publishes that complete map with setRoomAssignments() and returns { accepted: true }. Requests are not automatically accepted. The request promise resolves with the host's decision; it resolves as rejected if the host does not respond within 15 seconds. Use a designated shared room ID (for example, main) when a player leaves a private room; clearRoomAssignments() removes grouping for everyone. The current map is replayed to late joiners. If the host leaves, the game ends and the meeting clears room visibility; host handoff is not supported.

Reactions

MeetingReaction is a typed union of 'like' | 'love' | 'laugh' | 'clap' | 'surprised' | 'party' | 'fire'. sendReaction(reaction, participantId?) targets the current player when participantId is omitted. It sends the meeting wire message meeting:sendReaction with payload { reaction, participantId }.

await this.sendReaction('party'); // React as the current player
await this.sendReaction('clap', winner.id); // React on behalf of a specific player

Full Example: Rounds-Based Game

class MyGame extends CrewGame<MyState> {
  async onStart() {
    // Host sets initial state — all players receive it and render
    await this.setState({ round: 1, answers: {}, phase: 'answering' });
  }

  async onStateChange(newState: MyState, oldState: MyState) {
    // ✅ Guard: only host drives phase transitions
    if (!this.isHost) return;

    // When all players have answered, advance the round
    const allAnswered = this.activePlayers.every(p => !!newState.answers[p.id]);
    if (allAnswered && newState.phase === 'answering') {
      if (newState.round >= MAX_ROUNDS) {
        await this.endGame();
      } else {
        await this.setState({
          round: newState.round + 1,
          answers: {},
          phase: 'answering',
        });
      }
    }
  }

  // Every player calls this for themselves — no host guard needed
  async submitAnswer(answer: string) {
    const playerId = this.currentPlayer?.id;
    if (!playerId) return;
    await this.setState({
      ...this.state,
      answers: { ...this.state.answers, [playerId]: answer },
    });
  }
}

Simplified Game Ending

The SDK automatically handles result submission when games end. Just implement getFinalResults() and the SDK does the rest.

Basic Pattern

class MyGame extends CrewGame<MyGameState> {
  // SDK calls this automatically when game ends (host only)
  async getFinalResults() {
    const players = this.activePlayers;
    const results = players
      .map(p => ({
        playerId: p.id,
        playerName: p.name,
        score: this.state.scores[p.id] || 0,
        rank: 0, // Assign ranks below
      }))
      .sort((a, b) => b.score - a.score);
    
    // Assign ranks (handle ties)
    let currentRank = 1;
    for (let i = 0; i < results.length; i++) {
      if (i > 0 && results[i].score < results[i - 1].score) {
        currentRank = i + 1;
      }
      results[i].rank = currentRank;
    }
    
    return results;
  }
  
  async endGame() {
    // Announce winner
    const results = await this.getFinalResults();
    const winner = results?.find(r => r.rank === 1);
    if (winner) {
      await this.sendChat(`🏆 ${winner.playerName} wins!`);
    }
    
    // SDK automatically submits results and transitions to ENDED phase
    await super.endGame();
  }
}

How It Works

  1. When endGame() is called, SDK checks if host
  2. If host, SDK calls your getFinalResults() method (or uses auto-score persistence when enabled)
  3. SDK submits results to platform automatically
  4. SDK broadcasts phase change to all players
  5. All players receive ENDED phase and render game over screen

Benefits:

  • No manual submitResults() calls needed
  • No risk of duplicate submissions
  • Cleaner, more declarative code
  • Works the same in dev and production

Automatic Result Submission from Game Scores

If your state uses a score map (default key: scores), you can let the SDK persist results and update participant score overlays automatically:

const game = new TriviaGame({
  enableScorePersistence: true,
  showParticipantScores: true,
  scoreStateKey: 'scores',
  participantScoreLabel: 'Score',
});

showParticipantScores controls live meeting overlays independently of enableScorePersistence, which controls automatic final-result submission. With the default getFinalResults() implementation, the SDK:

  • builds ranked final results from the configured score map during endGame()
  • submits those results automatically (host only) when enableScorePersistence is enabled
  • updates participant video score overlays as state changes when showParticipantScores is enabled

Selecting nested scores and formatting overlays

scoreSelector returns the numeric score map used by both overlays and automatic result persistence. It takes precedence over scoreStateKey; without a selector, the SDK reads the flat map at state[scoreStateKey ?? 'scores']. This supports nested state without duplicating scores at the top level. The game owns and updates its score state; the SDK reads those values but does not calculate or maintain game scores.

Without a formatter, the overlay text is {participantScoreLabel}: {score} (default label: Score). A participantScoreFormatter receives the selected number and its Player, then returns the complete plain-text overlay. It runs in the game SDK; the meeting host receives only the returned string and does not execute game-provided functions or render game-provided markup. The formatter affects display only, not the numeric score used for final-result ranking or persistence.

interface NestedScoreGameState extends GameState {
  participantsById?: Record<string, { stats: { points: number } }>;
}

class ExampleGame extends CrewGame<NestedScoreGameState> {
  constructor() {
    super({
      showParticipantScores: true,
      scoreSelector: (state) =>
        Object.fromEntries(
          Object.entries(state.participantsById ?? {}).map(([playerId, participant]) => [
            playerId,
            participant.stats.points,
          ]),
        ),
      participantScoreFormatter: (points) =>
        `Points: ${new Intl.NumberFormat('en-US').format(points)}`,
    });
  }
}

The formatter can also present an integer score in cents as currency, for example with new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(cents / 100). If automatic persistence is enabled, selected scores must be signed 32-bit integers; formatting does not alter the submitted value. Live overlays are host-only, refreshed on initial sync/reconnect or participant-set changes, and otherwise sent only when formatted text changes. Stale overlays are cleared on reset or participant departure. A custom endGameWithResults() submission is not replaced or double-submitted because overlays are enabled.

Local Development

Why You Need the Dev Server

Multiplayer games need a backend to sync state and messages between players. In production, Crew Games uses LiveKit. For local development, @crew-games/dev-server provides the same functionality via WebSocket, allowing multiple browser tabs to communicate as if they were different players.

How It Works

Production:

Your Game → iframe → postMessage → Platform → LiveKit → Other Players

Development:

Your Game → WebSocket → Dev Server → WebSocket → Other Tabs

The SDK automatically detects dev mode from URL parameters (?session=X&player=Y) and switches communication methods.

Mock Players

The dev server provides 4 mock players:

Each has a unique avatar and realistic profile data.

Dev Server Options

crew-games-dev [options]

Options:
  -p, --port <port>       HTTP server port (default: 3030)
  -w, --ws-port <port>    WebSocket server port (default: 3031)
  -q, --quiet             Suppress verbose logging
  -h, --help              Show help

SDK Helper Utilities

The SDK includes helper utilities inspired by popular game development frameworks to simplify common patterns:

HostTimer (SDK-1)

A host-authoritative countdown timer. All clients tick locally (for smooth UI), but only the host fires onExpire — eliminating the common bug where non-host clients call setState inside a timer callback:

import { HostTimer } from '@crew-games/sdk';

class MyGame extends CrewGame<MyGameState> {
  private _timer = new HostTimer(this, () => {
    // No isHost guard needed — HostTimer only fires this on the host
    this._beginVoting();
  });

  private async _beginRound() {
    this._timer.start(30); // all clients tick locally
    await this.setState({ timeRemaining: 30 }); // host broadcasts initial value
  }

  async onEnd() {
    this._timer.clear();
  }
}

Why not GameTimer? GameTimer only runs on the host entirely. HostTimer runs the interval on all clients so the countdown is smooth for everyone, but restricts onExpire to the host only — preventing the "non-host setState flooding" bug.

Timer Management (GameTimer)

Built-in timer utilities with automatic host-managed synchronization:

import { createSyncedTimer, GameTimer } from '@crew-games/sdk';

class MyGame extends CrewGame<MyGameState> {
  private timer?: GameTimer;
  
  async onStart() {
    // Create a timer that automatically updates state.timeRemaining
    this.timer = createSyncedTimer(
      this,
      30, // duration in seconds
      'timeRemaining', // state property to update
      async () => {
        // Called when timer completes (host only)
        await this.nextRound();
      }
    );
  }
}

endGameWithResults (SDK-2)

Replaces the ~12-line end-game boilerplate. Sorts players by score, submits results, and ends the game in one call (host only):

class MyGame extends CrewGame<MyState> {
  private async _endGame() {
    // ✅ replaces ~12 lines in every game
    await this.endGameWithResults(this.activePlayers, this._state.scores);
  }
}

Note: Do NOT also override getFinalResults() when using endGameWithResults — it skips the auto-submit to avoid double-counting.

resetToLobby (SDK-3)

Resets the game to lobby state with initial defaults. Broadcasts them to all clients so onReady() fires with correct values (host only):

class MyGame extends CrewGame<MyState> {
  async playAgain() {
    if (!this.isHost) return;
    this._timer.clear();
    await this.resetToLobby({
      scores: {},
      round: 0,
      gamePhase: 'lobby',
    });
  }
}

Requires allowReset: true in GameConfig. Non-host clients call onReady() automatically when they receive the reset broadcast.

buildRankedResults (SDK-4)

Standalone helper to build a sorted, ranked results array ready for submitResults():

import { buildRankedResults } from '@crew-games/sdk';

const results = buildRankedResults(this.activePlayers, this.state.scores);
// [{ playerId, playerName, score, rank }] — sorted desc, dense ranks (ties share rank)
await this.submitResults(results);

whenAllSubmitted / transitionWhen (SDK-5)

Two complementary helpers that implement the idempotent dual-trigger submission gate pattern:

class MyGame extends CrewGame<MyState> {
  // transitionWhen: wraps a transition so it's a no-op once the phase moves on
  private _beginScoring = this.transitionWhen('writing', async () => {
    await this.setState({ ...this.state, gamePhase: 'scoring' });
  });

  async submitAnswer(answer: string) {
    const playerId = this.currentPlayer!.id;
    const newAnswers = { ...this.state.answers, [playerId]: answer };
    await this.setState({ ...this.state, answers: newAnswers });
    // Inline check (host's own submission)
    this.whenAllSubmitted(newAnswers, this.activePlayers, this._beginScoring);
  }

  async onStateChange(next: MyState) {
    // Remote submission check (other players' submissions)
    this.whenAllSubmitted(next.answers, this.activePlayers, this._beginScoring);
  }
}

whenAllSubmitted fires the callback when every player has an entry in the submissions record. transitionWhen wraps a function so it no-ops if the game has already moved past expectedPhase — preventing double-transitions from the dual-trigger pattern.

allPlayersVoted (SDK-6)

Simple alias for allVotesCast(..., 0, ...) covering the common single-matchup scenario:

import { allPlayersVoted } from '@crew-games/sdk';

const eligible = this.activePlayers
  .filter(p => !matchup.includes(p.id))
  .map(p => p.id);

if (allPlayersVoted(eligible, this.state.votes)) {
  await this._tallyVotes();
}

assignTargets (SDK-7)

Random 1:1 player-to-player assignment with guaranteed no self-assignment (using the Sattolo derangement algorithm). Supports optional seeding for deterministic shuffles:

import { assignTargets } from '@crew-games/sdk';

// In _beginTwistPhase (host only):
const assignments = assignTargets(this.players);
// { alice: 'charlie', bob: 'alice', charlie: 'bob' }
await this.setState({ assignments });

// Seeded (reproducible, e.g. for reconnections):
const assignments = assignTargets(this.players, { seed: this.state.round });

Returns: Record<assigneeId, targetId> — every player maps to exactly one other player. With 2 players, each gets the other.

Leaderboard Generation

Automatically generate ranked leaderboards with tie handling:

import { createLeaderboardWithStatus } from '@crew-games/sdk';

class MyGame extends CrewGame<MyGameState> {
  getLeaderboard() {
    return createLeaderboardWithStatus(
      this.state.scores,        // { playerId: score }
      this.state.answers,       // { playerId: submission }
      this.getPlayer.bind(this) // Function to get player by ID
    );
    // Returns: [{ playerId, playerName, score, rank, hasAnswered }]
  }
}

Features:

  • Automatic ranking with tie handling
  • Includes submission status (hasAnswered, hasSubmitted, etc.)
  • Winner extraction with getWinners()
  • Formatted announcements with formatWinnerAnnouncement()

Player Submission Tracking

Utilities for tracking which players have submitted answers, votes, or moves:

import { allPlayersSubmitted, getSubmissionProgress } from '@crew-games/sdk';

class MyGame extends CrewGame<MyGameState> {
  async checkProgress() {
    const playingPlayers = this.activePlayers.filter(p => !p.isSpectator);
    
    // Check if all players have submitted
    if (allPlayersSubmitted(this.state.answers, playingPlayers)) {
      await this.nextQuestion();
    }
    
    // Get progress stats
    const { submitted, total, percentage } = getSubmissionProgress(
      this.state.answers,
      playingPlayers
    );
    console.log(`${submitted}/${total} players answered (${percentage}%)`);
  }
}

Utilities:

  • allPlayersSubmitted() - Check if all players have submitted
  • getSubmissionProgress() - Get submission stats
  • formatSubmissionProgress() - Format as string
  • hasPlayerSubmitted() - Check individual player
  • clearSubmissions() - Reset for next round

Features

  • Zero Backend Required: State sync and messaging handled automatically
  • Real-time Multiplayer: Built-in support for broadcasting and direct messaging
  • Type-safe API: Full TypeScript support with generics for game state
  • Lifecycle Hooks: onStart, onPlayerJoined, onPlayerLeft, onEnd, onStateChange
  • Player Management: Access to player list, current player, and host status
  • Event System: Subscribe to state changes, messages, and player events
  • Helper Utilities: Timer management, leaderboards, submission tracking
  • Result Submission: Submit game results with rankings to the platform
  • Dev Mode: Seamless local testing with WebSocket-based dev server

Submitting Game Results

When your game ends, you can submit player results including scores and rankings to the Crew Games platform. This allows the platform to track player stats, leaderboards, and achievements. Scores persisted by the platform must be integers; store currency in minor units (such as cents) and format it for display with participantScoreFormatter.

Result Submission Types

Note: For most games, use the getFinalResults() pattern described above. Manual submission is only needed for advanced scenarios.

interface PlayerResult {
  playerId: string;      // Unique player identifier
  playerName: string;    // Display name
  score: number;         // Final integer score (use cents for persisted currency amounts)
  rank: number;          // Final rank (1 = winner, 2 = second place, etc.)
  metadata?: {           // Optional game-specific data
    [key: string]: any;
  };
}

interface GameResults {
  gameId: string;        // Your game's unique ID
  sessionId: string;     // Current session ID
  completedAt: Date;     // Timestamp when game ended
  players: PlayerResult[]; // Array of player results
  metadata?: {           // Optional game-specific metadata
    [key: string]: any;
  };
}

Manual Submission Example

class MyGame extends CrewGame<MyGameState> {
  async onEnd() {
    // Only needed if NOT using getFinalResults() pattern
    // Calculate final scores and rankings
    const players = Array.from(this.players.values());
    const sortedByScore = players
      .map(p => ({
        playerId: p.id,
        playerName: p.name,
        score: this.state.scores[p.id] || 0,
      }))
      .sort((a, b) => b.score - a.score);

    // Assign ranks (handle ties)
    let currentRank = 1;
    const results: PlayerResult[] = sortedByScore.map((player, index) => {
      if (index > 0 && player.score < sortedByScore[index - 1].score) {
        currentRank = index + 1;
      }
      return {
        ...player,
        rank: currentRank,
      };
    });

    // Submit results (host only to avoid duplicates)
    if (this.isHost) {
      try {
        await this.submitResults(results);
        console.log('Results submitted successfully');
      } catch (error) {
        console.error('Failed to submit results:', error);
      }
    }
  }
}

With Metadata

You can include additional game-specific data:

const results: PlayerResult[] = sortedByScore.map((player, index) => ({
  playerId: player.playerId,
  playerName: player.playerName,
  score: player.score,
  rank: currentRank,
  metadata: {
    accuracy: this.state.accuracy[player.playerId],
    correctAnswers: this.state.correctAnswers[player.playerId],
    timeBonus: this.state.timeBonuses[player.playerId],
  },
}));

await this.submitResults(results);

Development vs Production

  • Development: Results are logged by the dev server for debugging
  • Production: Results are stored in the platform database and shown in player profiles

The SDK automatically routes results to the correct destination based on the environment.

Best Practices

  1. Host-only submission: Only the host should call submitResults() to avoid duplicate entries
  2. Handle ties properly: Players with the same score should have the same rank
  3. Include meaningful metadata: Additional stats help with achievements and analytics
  4. Error handling: Always wrap submission in try/catch blocks
  5. Call from onEnd: Submit results in your onEnd() lifecycle hook for consistency

Documentation

See the full documentation for detailed API reference and examples.

License

MIT