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

@smoregg/sdk

v2.6.0

Published

S'MORE Game SDK - Simplified interface for building party games

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

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

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