@crew-games/sdk
v0.3.0
Published
TypeScript SDK for building multiplayer games on Crew Games platform
Downloads
42
Maintainers
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-serverQuick 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-devTerminal 2 - Start Your Game:
npx viteBrowser - 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-3Each 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
- When
endGame()is called, SDK checks if host - If host, SDK calls your
getFinalResults()method - SDK submits results to platform automatically
- SDK broadcasts phase change to all players
- 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 PlayersDevelopment:
Your Game → WebSocket → Dev Server → WebSocket → Other TabsThe 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:
- player-1: Alice Developer ([email protected])
- player-2: Bob Tester ([email protected])
- player-3: Carol QA ([email protected])
- player-4: Dave Engineer ([email protected])
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 helpSDK 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 submittedgetSubmissionProgress()- Get submission statsformatSubmissionProgress()- Format as stringhasPlayerSubmitted()- Check individual playerclearSubmissions()- 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
- Host-only submission: Only the host should call
submitResults()to avoid duplicate entries - Handle ties properly: Players with the same score should have the same rank
- Include meaningful metadata: Additional stats help with achievements and analytics
- Error handling: Always wrap submission in try/catch blocks
- 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
