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

gatho

v0.0.0-alpha-3

Published

javascript multiplayer toolkit for building real-time games and applications

Readme

cover

Version GitHub Workflow Status (with event) Downloads

> npm install gatho

gatho

gatho is a javascript multiplayer toolkit for building real-time games and applications.

⚠️ gatho is in early alpha, browse and experiment to your heart's content, but expect breaking changes for the time being.

Features

  • 🕹️ Multiplayer WebSocket rooms
  • 🔄 Built-in reconnection with reliable message buffering
  • 🧱 Separate process per room by default, with configurable room runners
  • 📡 Horizontal scaling with Redis
  • 🎯 Flexible SDK for matchmaking and room management
  • 🔐 Seat tokens
  • 🪶 Unopinionated, adds value where it counts, stays out of your way everywhere else

API Documentation

This readme provides curated explanations, guides, and examples to help you get started with gatho.

Auto-generated API documentation can be found at gatho.dev/docs.

Changelog

See the CHANGELOG.md for a detailed list of changes in each version.

Table of Contents

Concepts

A room (gatho/room) is a shared multiplayer session — a game match, a lobby, a collaborative space. Organise your application and state however you like, use start to initialize the room.

A server (gatho/server) hosts rooms. You run one or more — each registers itself with the driver so the SDK knows it exists and can place rooms on it. You tell the server how to run rooms — the built-in subprocess() helper spawns each room as its own child process, but you can run rooms in the same process, in a container, whatever you want. Rooms report their health and status back to the server over a Unix domain socket. Running multiple servers gives you horizontal scale.

Your backend uses the SDK (gatho/sdk) to manage rooms — create, query, and destroy them, tag them for filtering, and call join() to mint a short-lived token URL you hand to your client. Tags and client data make it flexible enough to build whatever matchmaking logic you need.

The driver (gatho/driver) is the shared state store — Redis, Postgres, or in-memory — that lets multiple server instances coordinate.

Quick Start

First, write a simple room that counts connections and messages:

// counter-room.ts
import { auth, start } from 'gatho/room';

let count = 0;

await start({
    onAuth: () => auth.ok(),

    onJoin: (room, client) => {
        room.send(client, JSON.stringify({ type: 'count', count }));
    },

    onMessage: (room, _client, message) => {
        if (typeof message !== 'string') return;

        const parsed = JSON.parse(message) as { type: 'increment' | 'decrement' };

        if (parsed.type === 'increment') {
            count++;
        } else if (parsed.type === 'decrement') {
            count--;
        }

        room.broadcast(JSON.stringify({ type: 'count', count }));
    },
});

Start a gatho server with a driver and tell it how to run your rooms:

// server.ts
import { createRedisDriver } from 'gatho/driver';
import { runner, start, subprocess } from 'gatho/server';

const driver = createRedisDriver({ url: 'redis://localhost:6379' });

await start({
    rooms: {
        counter: runner((ctx) => subprocess(ctx, ['bun', 'run', './counter-room.ts'])),
    },
    driver,
    roomEndpoint: ({ port }) => `ws://localhost:${port}`,
});

Then you can start rooms using the gatho/sdk:

// my-backend.ts
import { createRedisDriver } from 'gatho/driver';
import { createGathoSDK } from 'gatho/sdk';

const gatho = createGathoSDK({ driver: createRedisDriver() });

const servers = await gatho.getServers({ roomTypes: ['counter'] });

if (servers.length === 0) {
    throw new Error('no servers available to run a counter room');
}

const room = await gatho.createRoom({
    type: 'counter',
    serverId: servers[0].serverId,
    data: {
        /* any custom data you want to start the room with */
    },
    tags: {
        /* any tags you want to give the room */
    },
});

const seat = await gatho.join({ roomId: room.roomId, ttl: 30_000 });

console.log(seat.url);

And you can connect to URLs returned by join() with gatho/client:

// client.ts
import { connect } from 'gatho/client';

const url = new URLSearchParams(window.location.search).get('url')!;
const room = connect(url);

room.on('message', (msg) => {
    if (typeof msg !== 'string') return;
    const { count } = JSON.parse(msg) as { count: number };
    console.log('count:', count);
});

room.send(JSON.stringify({ type: 'increment' }));

Client

gatho/client is a thin WebSocket wrapper that handles the things you'd otherwise build yourself:

  • Automatic reconnection — on unexpected disconnect the client enters a reconnecting state and retries with exponential backoff and jitter.
  • Reliable messaging — messages sent while reconnecting are buffered (up to 1MB by default) and flushed in order once the connection is restored. Mark a message as { reliable: false } to drop it instead. Future features around backpressure and handling and WebTransport will build on this.
  • Session continuity — the server issues a session token on first connect. On reconnect the client presents it automatically, so the server sees the same clientId and can resume where it left off.
  • Clean closeclose() sends a protocol-level leave message so the server knows the disconnect was intentional and skips the reconnection window.

On the server side, opt in to reconnection by calling room.allowReconnection(client, windowMs) inside onDrop. Reliable messages sent to a disconnected client are buffered (up to maxBufferBytes, default 1MB) and flushed automatically on reconnect. If the buffer overflows or the window expires, the client is evicted and onLeave fires.

import { auth, start } from 'gatho/room';

await start({
    onAuth: () => auth.ok(),

    onDrop: (room, client) => {
        room.allowReconnection(client, 30_000); // hold seat for 30s
    },

    onReconnect: (room, client) => {
        room.send(client, JSON.stringify({ type: 'welcome-back' }));
    },
});

Messages

gatho is unopinionated about message format — room.send() and room.broadcast() accept string | ArrayBuffer | ArrayBufferView, and onMessage receives string | ArrayBuffer. For JSON, call JSON.stringify() / JSON.parse() yourself — gatho stays out of the way.

If you want good performance without sacrificing developer experience, packcat plays well with gatho. Define schemas once, share them between client and server, and get compact binary encoding with full TypeScript types — no code generation, no IDL files.

// shared/protocol.ts

// define your message schemas once, use them on both client and server

import * as p from 'packcat';

// client → server
const PlayerInput = p.object({
    type: p.literal('input'),
    movement: p.list(p.float32()),
});

// server → client
const GameState = p.object({
    type: p.literal('snapshot'),
    tick: p.varuint(),
    players: p.list(
        p.object({
            id: p.varuint(),
            position: p.list(p.float32(), 2), // [x, y]
        }),
    ),
});

const ServerMessage = p.union('type', [GameState]);
const ClientMessage = p.union('type', [PlayerInput]);

export type ServerMessage = p.SchemaType<typeof ServerMessage>;
// { type: 'snapshot', tick: number, players: { id: number, position: [number, number] }[] }

export type ClientMessage = p.SchemaType<typeof ClientMessage>;
// { type: 'input', movement: [number, number] }

const ServerMessageSerDes = p.build(ServerMessage);
const ClientMessageSerDes = p.build(ClientMessage);

const exampleServerMessage: Uint8Array<ArrayBufferLike> = ServerMessageSerDes.pack({
    type: 'snapshot',
    tick: 123,
    players: [
        { id: 1, position: [10, 20] },
        { id: 2, position: [30, 40] },
    ],
});

console.log('packed server message:', exampleServerMessage);

const unpackedServerMessage: ServerMessage = ServerMessageSerDes.unpack(exampleServerMessage);
console.log('unpacked server message:', unpackedServerMessage.tick, unpackedServerMessage.players);

const exampleClientMessage: Uint8Array<ArrayBufferLike> = ClientMessageSerDes.pack({
    type: 'input',
    movement: [1, 0],
});

console.log('packed client message:', exampleClientMessage);

const unpackedClientMessage: ClientMessage = ClientMessageSerDes.unpack(exampleClientMessage);
console.log('unpacked client message:', unpackedClientMessage.movement);

Room Lifecycle

import { auth, start } from 'gatho/room';

await start({
    // return auth.ok(data) to accept, auth.fail(reason) to reject
    onAuth: (room, joinData: { displayName: string }) => {
        if (room.clients.count() >= 10) return auth.fail('room is full');
        return auth.ok({ displayName: joinData.displayName });
    },

    // client is authenticated and in the room
    onJoin: (room, client) => {
        room.broadcast(JSON.stringify({ type: 'joined', id: client.id }));
    },

    // client sent a message
    onMessage: (room, client, message) => {
        if (typeof message !== 'string') return;
        room.broadcast(JSON.stringify({ type: 'echo', from: client.id, message }));
    },

    // non-consented disconnect — call allowReconnection to hold the seat
    onDrop: (room, client) => {
        room.allowReconnection(client, 30_000);
    },

    // client reconnected within the window — buffered messages already flushed
    onReconnect: (room, client) => {
        room.send(client, JSON.stringify({ type: 'welcome-back' }));
    },

    // client permanently left — consented close, eviction, or window expired
    onLeave: (room, client) => {
        room.broadcast(JSON.stringify({ type: 'left', id: client.id }));
    },

    // SIGTERM or room.stop()
    onShutdown: () => {
        console.log('shutting down');
    },
});

Runners

Runners control how room processes are started and stopped. The server maps each room type to a runner.

subprocess() — local processes

The built-in subprocess() helper spawns a child process from inside a runner() callback. It forwards the standard GATHO_* env vars from ctx.env, wires exit signalling, and handles graceful shutdown (SIGTERM → SIGKILL escalation). Use options.env to pass extra env vars or forward fields from ctx.data.

import { createMemoryDriver } from 'gatho/driver';
import { runner, start, subprocess } from 'gatho/server';

await start({
    rooms: {
        game: runner((ctx) =>
            subprocess(ctx, ['bun', 'run', './game-room.ts'], {
                env: {
                    GAMEMODE: ctx.data.gamemode as string,
                },
            }),
        ),
    },
    driver: createMemoryDriver(),
    roomEndpoint: ({ port }) => `ws://localhost:${port}`,
});

runner() — custom runners

If you need more control over how rooms should be executed on the server (e.g. if you want to run rooms with Docker, in-process, inside a microVM, whatever else) — use the runner() api. You provide a function that:

  1. Receives a context with room metadata, a stopped callback
  2. Sets up the room (sync or async)
  3. Returns a destructor that the server calls to stop the room

Call ctx.stopped(code) when the room exits for any reason (crash, natural exit, killed). The destructor owns the full shutdown strategy — graceful escalation, a single API call, whatever fits your runtime.

ctx.env contains the standard GATHO_* environment variables pre-built from the spawn context, ready to spread into a process env or pass as docker -e flags.

Docker example:

// runner-docker.ts — custom runner using the runner() factory
import { spawn } from 'node:child_process';
import { createRedisDriver } from 'gatho/driver';
import { runner, start } from 'gatho/server';

const dockerRunner = runner((ctx) => {
    const gameMode = String(ctx.data.gameMode ?? 'classic');

    const child = spawn('docker', [
        'run', '--rm', '--network=host',
        '--name', `room-${ctx.roomId}`,
        '--memory', '512m',
        '-v', '/tmp/gatho-ipc:/tmp/gatho-ipc',
        ...Object.entries(ctx.env).flatMap(([k, v]) => ['-e', `${k}=${v}`]),
        '-e', `GAME_MODE=${gameMode}`,
        'my-game-image:latest',
    ], { stdio: ['ignore', 'inherit', 'inherit'] });

    child.on('exit', (code) => ctx.stopped(code));

    return () => {
        child.kill('SIGTERM');
        const timer = setTimeout(() => child.kill('SIGKILL'), 10_000);
        timer.unref();
    };
});

await start({
    rooms: { game: dockerRunner },
    driver: createRedisDriver({ url: 'redis://localhost:6379' }),
    roomEndpoint: ({ port }) => `wss://my-host/${port}`,
    tags: { region: 'us-east-1' },
});

Drivers

Drivers provide the shared state backend used by the server and SDK.

  • createMemoryDriver() — useful for local dev, tests, and onebox deployments
  • createRedisDriver({ url }) — requires Redis
  • createPostgresDriver({ connectionString }) — requires Postgres (Experimental!)