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

@martini-kit/transport-ws

v0.2.0

Published

WebSocket transport for @martini-kit/core - production-ready client-server networking

Readme

@martini-kit/transport-ws

Production-ready WebSocket transport for martini-kit multiplayer games.

Use this transport when you need:

  • Server-authoritative games
  • Scalable infrastructure
  • Room management
  • Backend integration (auth, persistence, matchmaking)

Quick Start

1. Install

pnpm add @martini-kit/transport-ws @martini-kit/core @martini-kit/phaser

2. Start the Server

Create a relay server using the example from examples/server.ts:

pnpm tsx examples/server.ts

Or use the provided example:

import { WebSocketServer, WebSocket } from 'ws';

class martini-kitServer {
  private wss: WebSocketServer;
  private players = new Map<WebSocket, Player>();
  private rooms = new Map<string, Room>();

  constructor(port: number) {
    this.wss = new WebSocketServer({ port });
    console.log(`[martini-kitServer] Listening on port ${port}`);

    this.wss.on('connection', (ws) => {
      ws.on('message', (data) => {
        const message = JSON.parse(data.toString());
        this.handleMessage(ws, message);
      });
    });
  }

  private handleMessage(ws: WebSocket, message: any): void {
    const { type } = message;

    switch (type) {
      case 'handshake':
        this.handleHandshake(ws, message);
        break;
      case 'join_room':
        this.handleJoinRoom(ws, message);
        break;
      default:
        // Relay messages to appropriate recipients
        this.forwardMessage(ws, message);
        break;
    }
  }
}

const PORT = parseInt(process.env.PORT || '8080');
new martini-kitServer(PORT);

3. Connect Clients

import { defineGame } from '@martini-kit/core';
import { PhaserAdapter } from '@martini-kit/phaser';
import { WebSocketTransport } from '@martini-kit/transport-ws';

const game = defineGame({
  setup: ({ playerIds }) => ({
    players: Object.fromEntries(
      playerIds.map(id => [id, { x: 100, y: 100, score: 0 }])
    )
  }),

  actions: {
    move: {
      input: { x: 'number', y: 'number' },
      apply(state, context, input) {
        state.players[context.targetId].x = input.x;
        state.players[context.targetId].y = input.y;
      }
    }
  }
});

// Create transport
const transport = new WebSocketTransport('ws://localhost:8080', {
  playerId: 'player-123', // Optional - auto-generated if not provided
  reconnect: true,
  reconnectDelay: 1000,
  maxReconnectAttempts: 10
});

// Wait for connection
await transport.waitForReady();

// Join a room
transport.send({ type: 'join_room', roomId: 'room-abc' });

// Start Phaser adapter
PhaserAdapter.start({
  game,
  transport,
  scenes: [GameScene]
});

API Reference

WebSocketTransport

class WebSocketTransport implements Transport {
  constructor(url: string, config?: WebSocketTransportConfig);

  // Transport interface
  send(message: WireMessage, targetId?: string): void;
  onMessage(handler: MessageHandler): () => void;
  onPeerJoin(handler: PeerHandler): () => void;
  onPeerLeave(handler: PeerHandler): () => void;
  getPlayerId(): string;
  getPeerIds(): string[];
  isHost(): boolean;

  // Additional methods
  waitForReady(): Promise<void>;
  onError(handler: ErrorHandler): () => void;
  disconnect(): void;
}

WebSocketTransportConfig

interface WebSocketTransportConfig {
  /** Player ID (generated if not provided) */
  playerId?: string;

  /** Enable automatic reconnection (default: true) */
  reconnect?: boolean;

  /** Reconnection delay in ms (default: 1000) */
  reconnectDelay?: number;

  /** Max reconnection attempts (default: Infinity) */
  maxReconnectAttempts?: number;
}

Server Implementation

The WebSocket server acts as a relay - it forwards messages between clients without running game logic. The host client runs the actual game logic.

Message Protocol

Client → Server:

// Handshake (sent automatically on connection)
{ type: 'handshake', playerId: 'player-123' }

// Join room
{ type: 'join_room', roomId: 'room-abc' }

// Leave room
{ type: 'leave_room' }

// Game messages (relayed to other players)
{
  type: 'action',
  senderId: 'player-123',
  targetId: 'player-456', // Optional - omit for broadcast
  payload: { actionName: 'move', input: { x: 100, y: 200 } }
}

Server → Client:

// Handshake confirmation
{ type: 'handshake_ack', playerId: 'player-123' }

// Current peers in room
{ type: 'peers_list', payload: { peers: ['player-456', 'player-789'] } }

// Host announcement
{ type: 'host_announce', hostId: 'player-123' }

// Player joined
{ type: 'player_join', payload: { playerId: 'player-456' } }

// Player left
{ type: 'player_leave', payload: { playerId: 'player-456' } }

// Relayed game messages
{
  type: 'action',
  senderId: 'player-456',
  payload: { actionName: 'move', input: { x: 100, y: 200 } }
}

Host Election

The server automatically elects the first player in a room as the host:

private handleJoinRoom(ws: WebSocket, message: any): void {
  const room = this.rooms.get(roomId);

  // First player becomes host
  if (!room.hostId) {
    room.hostId = player.id;
    console.log(`Player ${player.id} is now host`);
  }

  // Announce host to all players
  this.send(ws, {
    type: 'host_announce',
    hostId: room.hostId
  });
}

When the host disconnects, the server elects a new host:

private removePlayerFromRoom(player: Player, room: Room): void {
  const wasHost = room.hostId === player.id;

  room.players.delete(player.id);

  // Notify remaining players
  this.broadcast(room, {
    type: 'player_leave',
    payload: { playerId: player.id }
  });

  // Re-elect host if needed
  if (wasHost && room.players.size > 0) {
    const newHostId = Array.from(room.players.keys())[0];
    room.hostId = newHostId;

    this.broadcast(room, {
      type: 'host_announce',
      hostId: newHostId
    });
  }
}

Error Handling

The transport provides error callbacks for connection issues:

const transport = new WebSocketTransport('ws://localhost:8080');

transport.onError((error) => {
  console.error('WebSocket error:', error);

  // Handle connection failures
  if (error.message.includes('ECONNREFUSED')) {
    alert('Cannot connect to server. Please try again later.');
  }
});

Reconnection

By default, the transport automatically reconnects on disconnect:

const transport = new WebSocketTransport('ws://localhost:8080', {
  reconnect: true,
  reconnectDelay: 1000, // Wait 1 second between attempts
  maxReconnectAttempts: 10 // Give up after 10 attempts
});

The transport maintains the same playerId across reconnections, so the server can restore the player's session.


Production Deployment

Scaling Considerations

Horizontal Scaling:

  • Use a load balancer (e.g., Nginx, HAProxy) to distribute connections
  • Each server instance manages its own set of rooms
  • Consider sticky sessions to keep players on the same instance

Room Distribution:

  • Store room state in Redis for multi-instance deployments
  • Use pub/sub to relay messages between server instances

Example with Redis:

import { createClient } from 'redis';

class Scalablemartini-kitServer {
  private redis = createClient();

  async handleJoinRoom(ws: WebSocket, message: any): Promise<void> {
    const { roomId } = message;

    // Subscribe to room channel
    await this.redis.subscribe(`room:${roomId}`, (message) => {
      // Forward messages to local players
      this.broadcast(roomId, JSON.parse(message));
    });

    // Store player in room
    await this.redis.sAdd(`room:${roomId}:players`, player.id);
  }

  private forwardMessage(ws: WebSocket, message: any): void {
    const { roomId } = this.players.get(ws);

    // Publish to Redis for all server instances
    this.redis.publish(`room:${roomId}`, JSON.stringify(message));
  }
}

Security

Authentication:

this.wss.on('connection', async (ws, req) => {
  const token = new URL(req.url, 'ws://localhost').searchParams.get('token');

  // Verify JWT token
  const user = await verifyToken(token);
  if (!user) {
    ws.close(1008, 'Unauthorized');
    return;
  }

  // Associate player with authenticated user
  const player: Player = {
    id: user.id,
    ws,
    roomId: null
  };

  this.players.set(ws, player);
});

Rate Limiting:

private rateLimiter = new Map<string, number>();

private handleMessage(ws: WebSocket, message: any): void {
  const player = this.players.get(ws);

  // Simple rate limit (100 messages per second)
  const now = Date.now();
  const count = this.rateLimiter.get(player.id) || 0;

  if (count > 100 && now - (count as any).timestamp < 1000) {
    ws.close(1008, 'Rate limit exceeded');
    return;
  }

  this.rateLimiter.set(player.id, count + 1);

  this.handleMessageInternal(ws, message);
}

Comparison: WebSocket vs P2P

| Feature | WebSocket (@martini-kit/transport-ws) | P2P (@martini-kit/transport-p2p) | |---------|-------------------------------------|--------------------------------| | Architecture | Client-server (relay) | Peer-to-peer (WebRTC) | | Latency | Higher (2 hops: client → server → client) | Lower (direct connection) | | NAT Traversal | Not needed (server has public IP) | Required (STUN/TURN servers) | | Scalability | Scales horizontally (add servers) | Limited by peer bandwidth | | Backend Integration | Easy (auth, persistence, matchmaking) | Hard (needs coordination server) | | Infrastructure | Requires server | Can work without server | | Connection Reliability | High (server is stable) | Medium (peers may disconnect) | | Use Cases | Production games, competitive games | Prototypes, local multiplayer, casual games |

Rule of thumb:

  • Use WebSocket for production games, games with backend needs, or games requiring >4 players
  • Use P2P for prototypes, game jams, or simple local multiplayer

Integration with Colyseus

You can use Colyseus for room management and martini-kit for game logic:

Server (Colyseus):

import { Room } from 'colyseus';

class GameRoom extends Room {
  onCreate() {
    // Just relay martini-kit actions
    this.onMessage('action', (client, action) => {
      this.broadcast('action', action, { except: client });
    });
  }
}

Client (martini-kit):

import { Client } from 'colyseus.js';

const client = new Client('ws://localhost:2567');
const room = await client.joinOrCreate('game_room');

// Bridge Colyseus to WebSocketTransport
class ColyseusTransport implements Transport {
  constructor(private room: Room) {
    room.onMessage('action', (message) => {
      this.notifyMessage(message, message.senderId);
    });
  }

  send(message: WireMessage, targetId?: string): void {
    this.room.send('action', { ...message, senderId: this.getPlayerId(), targetId });
  }

  // ... implement other Transport methods
}

const transport = new ColyseusTransport(room);
PhaserAdapter.start({ game, transport });

See 08-platform-comparison.md for more integration patterns.


Troubleshooting

"WebSocket connection failed"

Cause: Server is not running or wrong URL

Fix:

# Start the example server
cd @martini-kit/transport-ws
pnpm tsx examples/server.ts

# Check server is listening
curl http://localhost:8080 # Should fail (WebSocket only)

"Cannot connect after disconnect"

Cause: Reconnection disabled or max attempts reached

Fix:

const transport = new WebSocketTransport('ws://localhost:8080', {
  reconnect: true,
  maxReconnectAttempts: Infinity // Never give up
});

"Messages not received by other players"

Cause: Server not relaying messages, or players in different rooms

Fix:

// Ensure all players join the same room
transport.send({ type: 'join_room', roomId: 'room-123' });

// Wait for peers_list before sending game messages
transport.onMessage((message) => {
  if (message.type === 'peers_list') {
    console.log('Connected peers:', message.payload.peers);
  }
});

Examples

See examples/ for:

  • server.ts - Basic relay server with room management
  • client.html - Browser client example
  • integration.test.ts - End-to-end test with real server

Contributing

Contributions welcome! Please:

  1. Add tests for new features
  2. Update this README
  3. Run pnpm test before submitting PR

See CONTRIBUTING.md for guidelines.


License

MIT