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.3.0

Published

TypeScript SDK for building multiplayer games on Crew Games platform

Downloads

42

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 } from '@crew-games/sdk';

interface TriviaState extends GameState {
  question: string;
  answers: string[];
  scores: { [playerId: string]: number };
  currentRound: number;
}

class TriviaGame extends CrewGame<TriviaState> {
  async onStart() {
    // Initialize game state
    await this.setState({
      question: 'What is the capital of France?',
      answers: ['Paris', 'London', 'Berlin', 'Madrid'],
      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 isCorrect = answer === 'Paris';
    
    if (isCorrect && this.currentPlayer) {
      const newScores = { ...this.state.scores };
      newScores[this.currentPlayer.id] = (newScores[this.currentPlayer.id] || 0) + 1;
      
      await this.setState({ scores: newScores });
    }
  }
}

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

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!

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) | All players (via onStateChange / stateChange event) | | 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) |

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
  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

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.

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 score
  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