npm package: @smoregg/sdk
Description: S'MORE Game SDK - Simplified interface for building party games
Author: S'MORE Team
Latest version: 2.6.0

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

```bash
npm install @smoregg/sdk
# or
pnpm add @smoregg/sdk
# or
yarn add @smoregg/sdk
```

## Quick Start

### 1. Define Your Events

Create a shared event map that both Screen and Controller will use:

```typescript
// 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)

```typescript
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)

```typescript
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 `onControllerReconnect` callback

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

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

```typescript
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.

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

```typescript
import type {
  EventMap,
  ControllerInfo,
  GameResults,
  SmoreError,
  SmoreErrorCode,
  CharacterAppearance,
  PlayerIndex,
  Screen,
  Controller,
} from '@smoregg/sdk';

import { SmoreSDKError, LifecycleEvent } from '@smoregg/sdk';
```

#### ControllerInfo

```typescript
interface ControllerInfo {
  readonly playerIndex: number;
  readonly nickname: string;
  readonly connected: boolean;
  readonly appearance?: CharacterAppearance | null;
}
```

#### GameResults

```typescript
interface GameResults {
  scores?: Record<number, number>;
  winner?: number;
  rankings?: number[];
  custom?: Record<string, unknown>;
}
```

#### SmoreSDKError

```typescript
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:

```typescript
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.

```typescript
// 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`:

```typescript
import { createMockScreen, createMockController } from '@smoregg/sdk/testing';
```

### Example Test

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