@smoregg/sdk
v2.6.0
Published
S'MORE Game SDK - Simplified interface for building party games
Maintainers
Readme
@smoregg/sdk
SDK for building multiplayer party games on the S'MORE platform.
v2.3.0 | TypeScript | Zero runtime dependencies | ESM, CJS, UMD
Overview
S'MORE is a multiplayer party game platform where a shared display (the Screen, typically a TV or computer) runs the game while each player uses their phone as a Controller -- an input device and personal display. Think Jackbox-style games with full developer control.
This SDK provides type-safe APIs for communication between the Screen and Controllers. Define your game's event types once in a shared interface, and get full compile-time checking on every send(), broadcast(), and on() call. The SDK handles connection management, reconnection, lifecycle events, and message delivery.
The core architectural principle is the Stateless Controller Pattern: Controllers are display + input devices only. The Screen holds all game state and is the single source of truth. When a player reconnects, the Screen simply re-pushes the current view -- no state synchronization needed.
Installation
npm install @smoregg/sdk
# or
pnpm add @smoregg/sdk
# or
yarn add @smoregg/sdkQuick Start
1. Define Your Events
Create a shared event map that both Screen and Controller will use:
// events.ts (shared between Screen and Controller)
interface GameEvents {
// Screen -> Controller (view state)
'game-state': { phase: string; score: number };
// Controller -> Screen (input)
'tap': { timestamp: number };
}2. Screen (TV / Shared Display)
import { createScreen } from '@smoregg/sdk';
const screen = createScreen<GameEvents>();
// Listen for player input
screen.on('tap', (playerIndex, data) => {
console.log(`Player ${playerIndex} tapped at ${data.timestamp}`);
// Update game state and push to all controllers
screen.broadcast('game-state', { phase: 'playing', score: 10 });
});
// Re-push view to reconnecting players
screen.onControllerReconnect((playerIndex) => {
screen.sendToController(playerIndex, 'game-state', getCurrentState());
});
await screen.ready;3. Controller (Phone)
import { createController } from '@smoregg/sdk';
const controller = createController<GameEvents>();
// Render what Screen sends (stateless -- no local game state)
controller.on('game-state', (data) => {
renderUI(data.phase, data.score);
});
// Send input to Screen
function handleTap() {
controller.send('tap', { timestamp: Date.now() });
}
await controller.ready;Architecture
┌─────────┐ events ┌─────────┐ relay ┌──────────────┐
│ Screen │ <----------> │ Server │ <--------> │ Controller │
│ (TV) │ │ (relay) │ │ (Phone) │
│ │ │ │ │ │
│ Game │ broadcast │ No game │ on() │ Display only │
│ Logic │ -----------> │ logic │ ---------> │ + Input │
│ State │ │ │ │ │
│ Source │ sendToCtrl │ │ send() │ No game │
│ of Truth │ -----------> │ │ <--------- │ state │
└─────────┘ └─────────┘ └──────────────┘Data flow:
- Controller to Screen: Input only via
controller.send() - Screen to all Controllers: View state via
screen.broadcast() - Screen to one Controller: Targeted view state via
screen.sendToController() - Reconnection: Screen re-pushes view in
onControllerReconnectcallback
The server is a stateless relay -- it forwards messages without game logic. All game state lives on the Screen.
API Reference
Screen
Creating a Screen
import { createScreen } from '@smoregg/sdk';
const screen = createScreen<MyEvents>(config?);Config Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| debug | boolean \| DebugOptions | false | Enable debug logging |
| parentOrigin | string | '*' | Parent window origin for message validation |
| timeout | number | 10000 | Connection timeout in milliseconds |
| autoReady | boolean | true | Automatically signal ready after initialization |
Properties
| Property | Type | Description |
|----------|------|-------------|
| controllers | readonly ControllerInfo[] | All connected controllers (shallow copy per access) |
| roomCode | string | Room code for this game session |
| isReady | boolean | Whether the screen is initialized |
| isDestroyed | boolean | Whether the screen has been destroyed |
| isConnected | boolean | Whether the connection is active |
| ready | Promise<void> | Resolves when the screen is ready |
Communication
| Method | Description |
|--------|-------------|
| broadcast(event, data) | Send to all controllers. Rate limit: 60/sec (shared with sendToController). Max payload: 64KB. |
| sendToController(playerIndex, event, data) | Send to one controller. Shares the 60/sec rate limit with broadcast. |
| gameOver(results?) | End the game. Accepts optional GameResults with scores, winner, rankings. |
| signalReady() | Signal ready to the server. Auto-called if autoReady is true. |
Lifecycle Callbacks
All lifecycle methods return an unsubscribe function.
| Method | Callback Signature | Description |
|--------|--------------------|-------------|
| onAllReady(cb) | () => void | All participants are ready. Fires immediately if already ready. |
| onControllerJoin(cb) | (playerIndex, info) => void | A player joined the room |
| onControllerLeave(cb) | (playerIndex) => void | A player left the room |
| onControllerDisconnect(cb) | (playerIndex) => void | A player temporarily disconnected |
| onControllerReconnect(cb) | (playerIndex, info) => void | A player reconnected |
| onCharacterUpdated(cb) | (playerIndex, appearance) => void | A player's character appearance changed |
| onError(cb) | (error: SmoreError) => void | An SDK error occurred |
| onConnectionChange(cb) | (connected: boolean) => void | Connection status changed |
Event Subscription
| Method | Description |
|--------|-------------|
| on(event, handler) | Subscribe to an event. Handler receives (playerIndex, data). Returns unsubscribe function. |
| once(event, handler) | Subscribe once. Auto-removes after first call. |
| off(event, handler?) | Remove a specific handler, or all handlers for an event. |
| removeAllListeners(event?) | Remove all user event listeners, or all for a specific event. |
Utilities
| Method | Description |
|--------|-------------|
| getController(playerIndex) | Get a ControllerInfo by player index, or undefined |
| getControllerCount() | Number of currently connected controllers |
| destroy() | Clean up all resources and disconnect |
Controller
Creating a Controller
import { createController } from '@smoregg/sdk';
const controller = createController<MyEvents>(config?);Config options are the same as Screen.
Properties
| Property | Type | Description |
|----------|------|-------------|
| myPlayerIndex | number | This player's index (0, 1, 2, ...) |
| me | ControllerInfo \| undefined | This player's info |
| roomCode | string | Room code for this game session |
| controllers | readonly ControllerInfo[] | All known controllers in the room |
| isReady | boolean | Whether the controller is initialized |
| isDestroyed | boolean | Whether the controller has been destroyed |
| isConnected | boolean | Whether the connection is active |
| ready | Promise<void> | Resolves when the controller is ready |
Communication
| Method | Description |
|--------|-------------|
| send(event, data) | Send to Screen. Rate limit: 60/sec. Max payload: 64KB. |
| signalReady() | Signal ready to the server. Auto-called if autoReady is true. |
Controller has no broadcast() -- all communication goes through Screen. Controller-to-Controller messaging is not supported; route through Screen instead.
Lifecycle Callbacks
Same as Screen, plus:
| Method | Callback Signature | Description |
|--------|--------------------|-------------|
| onGameOver(cb) | (results?) => void | The game has ended (Screen called gameOver()) |
Event Subscription
Same API as Screen. Handler receives (data) only -- no playerIndex parameter.
controller.on('game-state', (data) => {
// data is type-safe: { phase: string; score: number }
renderUI(data.phase, data.score);
});Utilities
| Method | Description |
|--------|-------------|
| getController(playerIndex) | Get a ControllerInfo by player index, or undefined |
| getControllerCount() | Number of currently connected controllers |
| destroy() | Clean up all resources and disconnect |
Types
import type {
EventMap,
ControllerInfo,
GameResults,
SmoreError,
SmoreErrorCode,
CharacterAppearance,
PlayerIndex,
Screen,
Controller,
} from '@smoregg/sdk';
import { SmoreSDKError, LifecycleEvent } from '@smoregg/sdk';ControllerInfo
interface ControllerInfo {
readonly playerIndex: number;
readonly nickname: string;
readonly connected: boolean;
readonly appearance?: CharacterAppearance | null;
}GameResults
interface GameResults {
scores?: Record<number, number>;
winner?: number;
rankings?: number[];
custom?: Record<string, unknown>;
}SmoreSDKError
class SmoreSDKError extends Error {
readonly code: SmoreErrorCode;
readonly cause?: Error;
readonly details?: Record<string, unknown>;
}Error codes: TIMEOUT, NOT_READY, DESTROYED, INVALID_EVENT, INVALID_PLAYER, CONNECTION_LOST, INIT_FAILED, RATE_LIMITED, PAYLOAD_TOO_LARGE, UNKNOWN
LifecycleEvent Constants
Subscribe to lifecycle events via on() using $-prefixed constants:
import { LifecycleEvent } from '@smoregg/sdk';
// These are equivalent:
screen.onControllerJoin((playerIndex, info) => { /* ... */ });
screen.on(LifecycleEvent.CONTROLLER_JOIN, (playerIndex, info) => { /* ... */ });Available constants: ALL_READY, CONTROLLER_JOIN, CONTROLLER_LEAVE, CONTROLLER_DISCONNECT, CONTROLLER_RECONNECT, CHARACTER_UPDATED, ERROR, GAME_OVER, CONNECTION_CHANGE
EventMap
Event data values must be plain objects, not primitives. The fields playerIndex and targetPlayerIndex are reserved by the SDK.
// Good
interface MyEvents {
'tap': { x: number; y: number };
'answer': { choice: number };
}
// Bad -- primitives are not allowed
interface MyEvents {
'tap': number; // Will break type safety
'answer': string; // Use { value: string } instead
}Testing
Import test utilities from @smoregg/sdk/testing:
import { createMockScreen, createMockController } from '@smoregg/sdk/testing';Example Test
import { describe, it, expect } from 'vitest';
import { createMockScreen } from '@smoregg/sdk/testing';
interface GameEvents {
'tap': { x: number; y: number };
'score-update': { scores: Record<number, number> };
}
describe('My Game', () => {
it('broadcasts score on tap', () => {
const screen = createMockScreen<GameEvents>({
controllers: [
{ playerIndex: 0, nickname: 'Alice', connected: true },
],
});
screen.triggerReady();
// Register game logic
screen.on('tap', (playerIndex, data) => {
screen.broadcast('score-update', { scores: { [playerIndex]: 10 } });
});
// Simulate player input
screen.simulateEvent(0, 'tap', { x: 100, y: 200 });
// Assert game logic response
const broadcasts = screen.getBroadcasts();
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toEqual({
event: 'score-update',
data: { scores: { 0: 10 } },
});
});
});MockScreen Methods
| Method | Description |
|--------|-------------|
| triggerReady() | Manually trigger the ready state (synchronous) |
| simulateEvent(playerIndex, event, data) | Simulate a controller sending an event |
| simulateControllerJoin(info) | Simulate a player joining |
| simulateControllerLeave(playerIndex) | Simulate a player leaving |
| simulateControllerDisconnect(playerIndex) | Simulate a player disconnecting |
| simulateControllerReconnect(playerIndex) | Simulate a player reconnecting |
| simulateAllReady() | Trigger the all-ready event |
| simulateCharacterUpdate(playerIndex, appearance) | Simulate a character appearance change |
| simulateConnectionChange(connected) | Simulate connection status change |
| simulateError(error) | Simulate an error event |
| getBroadcasts() | Get all recorded broadcast() calls |
| getSentToController(playerIndex) | Get recorded sendToController() calls for a player |
| getAllSentToController() | Get all recorded sendToController() calls |
| clearRecordedEvents() | Clear all recorded broadcasts and sends |
MockController Methods
| Method | Description |
|--------|-------------|
| triggerReady() | Manually trigger the ready state (synchronous) |
| simulateEvent(event, data) | Simulate the Screen sending an event |
| simulateGameOver(results?) | Simulate the game ending |
| simulatePlayerJoin(playerIndex, info) | Simulate a player joining |
| simulatePlayerLeave(playerIndex) | Simulate a player leaving |
| simulatePlayerDisconnect(playerIndex) | Simulate a player disconnecting |
| simulatePlayerReconnect(playerIndex, info) | Simulate a player reconnecting |
| simulateAllReady() | Trigger the all-ready event |
| simulateCharacterUpdate(playerIndex, appearance) | Simulate a character appearance change |
| simulateConnectionChange(connected) | Simulate connection status change |
| simulateError(error) | Simulate an error event |
| getSentEvents() | Get all recorded send() calls |
| clearRecordedEvents() | Clear all recorded sends |
Note: Default autoReady is false in mocks. Use triggerReady() for synchronous test control. If you pass autoReady: true, ready fires asynchronously on the next tick.
Event Naming Rules
| Rule | Example |
|------|---------|
| Must start with a letter | tap (valid), 123tap (invalid) |
| Letters, numbers, hyphens, underscores only | player-move (valid), player.move (invalid) |
| No colons | my-event (valid), my:event (invalid, reserved for platform) |
| Max 128 characters | - |
Limits and Constraints
| Constraint | Value |
|------------|-------|
| Rate limit | 60 events/sec per socket (shared across all send methods) |
| Max payload | 64KB per event |
| Message ordering | Guaranteed for a single sender |
| Event data | Must be objects, not primitives |
| Reserved fields | playerIndex and targetPlayerIndex in event data |
Events exceeding the rate limit or payload size are silently dropped by the server.
License
MIT
