@crew-games/sdk
v0.7.0
Published
TypeScript SDK for building multiplayer games on Crew Games platform
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, 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-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!
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 subscriptiongetSnapshot() returns a cached CrewGameSnapshot<State> containing:
state,players,currentPlayer,host,hostParticipantId, andisHost;- SDK
phase,isReady, andisInitialized; activePlayers,spectators,activePlayerIds,spectatorIds, andcanCurrentPlayerPlay;gameInstancemetadata and a monotonically increasingrevision.
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 playerFull 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 (or uses auto-score persistence when enabled) - 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
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
enableScorePersistenceis enabled - updates participant video score overlays as state changes when
showParticipantScoresis 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 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.
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
- 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
