Ongoing

Pulse — a multiplayer server for indie games

Designer & Sole Engineer·2026·6 min read

A single self-hostable Go binary that gives indie games a pub/sub room abstraction over NATS JetStream, extensible with WASM modules — sitting between Photon's simplicity and Nakama's weight.

Overview

Pulse is a multiplayer server aimed at indie developers building the class of game a room abstraction fits: turn-based games, simple .io-style web games, anything that is fundamentally clients publishing and receiving on a channel. One precompiled Go binary contains everything — embedded NATS/JetStream, an HTTP/WebSocket gateway, a CLI, and a read-only web dashboard. Users run it locally or on their own servers.

Problem

The multiplayer backend market is barbelled. Photon is easy to start with but you rent it and shape your game around its model. Nakama is powerful and self-hostable but is a substantial system to learn, operate, and extend. For a solo or small indie team building a turn-based or lightweight real-time game, both ends are a poor fit — one costs control, the other costs weeks.

Constraints

  • Solo developer, built alongside contract work
  • Single binary, self-hostable — no managed service to depend on
  • Minimise dependencies; every addition has to justify itself
  • Must serve both a real game and external users, without forking into per-game features

Approach

Model everything as rooms over JetStream subjects, then keep NATS strictly contained: one internal package imports NATS types and everything else depends on the abstraction. Custom server-side logic arrives as WASM modules rather than a plugin ABI, so users write it in any language that compiles to WASM. Each capability is proven by an example rather than shipped as bare infrastructure.

Key Decisions

Rooms as a thin layer over NATS JetStream, not a bespoke game server

Reasoning:

JetStream already provides durable streams, replay, consumer groups and a KV/object store. A room layer keeps game code ignorant of subject naming and consumer topology while inheriting all of that. Replay in particular means a client can answer 'what did I miss?' without a separate history system.

Alternatives considered:
  • Dedicated game server with a custom TCP or WebSocket protocol
  • Raw JetStream with no room layer
  • Off-the-shelf multiplayer SaaS

Embed NATS in the binary rather than requiring an external cluster

Reasoning:

A user should be able to download one file and have a working server. The fabric package runs an embedded NATS node and is the only package permitted to import NATS types, so the containment is architectural rather than conventional.

Alternatives considered:
  • Require an external NATS deployment
  • Write a bespoke transport and drop NATS entirely

The CLI speaks the public client protocol, importing no NATS

Reasoning:

The command set goes through the same gateway and the same protocol as the browser and JS SDKs. That makes the CLI a continuous integration test of the public API — if the protocol regresses, the tooling breaks first, and there is no privileged internal path that only the author's code can use.

Alternatives considered:
  • CLI with direct NATS access for convenience
  • Separate admin protocol

WASM modules via Wazero for user logic

Reasoning:

Users write custom server logic in any WASM-compatible language, conceptually like a Lambda listening on channels and emitting messages. Wazero runs it in-process with no CGO, keeping the single-binary property intact.

Alternatives considered:
  • Embedded scripting language such as Lua
  • Native plugin ABI
  • No extensibility — configuration only

Tech Stack

  • Golang
  • NATS JetStream
  • Wazero
  • Templ
  • Datastar
  • WebSockets

Result & Impact

  • One self-hosted binary
    Deployment
  • WASM modules via Wazero
    Extensibility

The core slice works end-to-end: create, join, publish, receive, leave, and the room drains. An emptied ephemeral room transitions active → draining → dead under a lifecycle reaper that re-derives state from the registry, so transitions survive a restart, while a lobby stays active. A turn-based example game and a read-only web dashboard exercise the protocol from outside. Dependency discipline has held: NATS, a WebSocket library, and little else.

Learnings

  • Containing a dependency to a single package is worth doing architecturally, not by convention — it is the difference between swapping NATS being a refactor and being a rewrite
  • Making your own tooling use the public protocol turns dogfooding into a test rather than a slogan
  • For a solo project, tying each new capability to a working example is the guardrail against building infrastructure that never ships

Two jobs at once

Pulse is the multiplayer substrate for the tycoon sim, and it is also intended to stand on its own for other developers. That dual purpose is a design constraint, not a marketing line: the rule is to build capabilities and examples that both a real game and an external user can use, rather than vertical features that only serve one game.

The same discipline that makes a contract deliverable survive handover — containing dependencies, testing through the public interface, proving each capability with a worked example — is what makes a solo product buildable at all.

Where the room layer sits

The room is a thin translation over JetStream, not a server of its own. That is the whole design: game code never learns subject naming or consumer topology, and everything underneath it — durability, replay, presence — is inherited rather than built.

Clients connect over WebSocket to a gateway, which maps them onto rooms, which map onto NATS JetStream subjects and streamsgame clientbrowser SDKCLIall speak the samepublic protocolone self-hosted binary — NATS includedgatewayHTTP / WSroomsthin layerJetStreamdurable streamsreplayKV + presencethe only packagethat imports NATS

Everything to the right of the room layer is inherited from JetStream rather than written. That is what keeps the room abstraction thin enough to be worth having.