Pulse

26 August 2026 · 5 min read

A self-hosted server for live, multi-party sessions: rooms in a single Go binary with NATS built in, and nothing else to run.

  • Golang
  • NATS
  • JetStream
  • Multiplayer
  • Templ
  • Datastar

Pulse is a self-hosted server for live, multi-party sessions: rooms that people, or programs, join, publish to and receive from. Games are where it started - turn-based, simple .io-style, anything that's fundamentally clients publishing and receiving on a channel - but the same shape fits chat, live events and collaborative apps.

Motivation

For a small team building multiplayer, the options sit at two ends. Photon is easy to start with, but you rent it, pay per concurrent user, and shape your game around its model. Nakama is powerful and self-hostable, but self-hosting it means Postgres, load balancing, backups and a cluster to look after. I wanted the middle: something you run yourself, with nothing else to stand up.

Approach

  • A single Go binary with NATS and JetStream embedded, so messaging and storage are the same process
  • Clients talk to it over WebSocket - nothing outside the server speaks NATS
  • Custom server logic will arrive as WASM modules, so it can be written in any language that compiles to WASM
one binaryclients, one public protocolbrowserGo clientpulse CLIgatewayroomsdashboardNATSmessages + eventsJetStream KVrooms + presence

Inside the binary, only two packages import NATS: the embedded node and the room layer. The gateway and dashboard build on rooms and never see a NATS type.

Rooms

A room is one abstraction with a lifecycle policy: a lobby stays open, and an ephemeral room drains and dies once everyone has left. Room records and presence live in JetStream's key-value store, so they're real state rather than one process's memory, and you can inspect them with the standard nats kv tools. Messages go over plain NATS pub/sub, so fan-out is just NATS and the room service isn't in the hot path.

  • Lifecycle is driven by a reaper that re-reads stored state on every sweep, so a restart carries on where it left off
  • Who sent a message is stamped by the gateway from the connection, never claimed by the client
  • Observers, such as the CLI or the dashboard, can watch a room without keeping it alive

Lifecycle events - joins, leaves, state changes - go over core NATS, fire-and-forget, so one can be lost. That's a deliberate trade-off: nothing in the server depends on them for correctness, because the reaper works from the stored state, not the events. A lost event costs an observer one update and never breaks a room. The subjects are laid out so a JetStream stream can later sit over the same ones, making events durable without changing anything that publishes or subscribes.

One protocol

The CLI speaks the same public protocol as the browser and the Go client. If the protocol regresses, my own tooling breaks first, and there's no privileged internal path that only my code can use.

$ pulse rooms create --policy ephemeral --id table-1
room_id  table-1
tenant   local
policy   ephemeral
state    active

$ pulse rooms watch table-1
joined table-1 as v7j455fwxhn4inty - watching (Ctrl+C to leave)
< [zqp56kdnkri37wr4] {"move":"e4"}

The browser side is pulse.js, one dependency-free file loaded with a plain <script> tag:

const client = await Pulse.connect({
  onMessage: (room, msg) => console.log(msg.from, msg.json()),
});
const room = await client.create({ policy: "ephemeral" });
await client.join(room.room_id);
client.publishJSON(room.room_id, { move: "e4" });

The client that will push it hardest is native: I'm building a PC game in C with Vulkan on Pulse, so the protocol has to stay simple enough for a C SDK. Frames are JSON with payloads base64'd, so every language handles them identically, and a raw TCP transport with length-prefixed frames is planned so a native game can skip WebSocket.

Dashboard

A read-only dashboard shows rooms, members and the live event stream. It's built the same way as Quire's live pages: the server renders the HTML with Templ, and Datastar patches it over server-sent events as things change.

WASM

Custom logic will be WASM modules that listen to a room's messages and events and emit new ones. I chose WASM so that logic can be written in anything that compiles to it, and because wazero is pure Go, the binary stays one file with no CGO. A side effect is that modules run sandboxed, which makes it safe to run logic you didn't write, such as player-made mods.

What it is not

Pulse is not a general application platform, and not a substitute for a full game backend suite. It is a narrow server for room-shaped sessions, with an ops surface a small team, or one person, can hold in their head.

Where it is

The core works end to end: create, join, publish, receive, leave, and empty rooms drain on their own. A noughts-and-crosses example and the dashboard exercise it from outside. Next is chat with message history, so a late joiner can catch up, then WASM, then a release you can download. Dependencies are NATS, a WebSocket library, Templ for the dashboard, and not much else.