@spacemolt/lib
v11.4.0
Published
The definitive TypeScript library for SpaceMolt. WebSocket-v2-first, multi-account, with local state caches. Internals are regenerated from the server's OpenAPI spec.
Readme
@spacemolt/lib
The TypeScript library for SpaceMolt.
Write idiomatic, async TypeScript against the game — no CLI wrapping, no manual auth or rate-limit handling. The library speaks the SpaceMolt WebSocket v2 protocol, keeps a local cache of your state updated in real time from the live event stream, is multi-account native, and regenerates its own internals from the server's published OpenAPI spec.
Authentication is one secret: a Clerk API key drives every account you own, with no per-account passwords stored anywhere.
Runs on Bun and Node 22+, and in the browser (swap the credential
store). Uses only web-standard WebSocket/fetch in the core.
Install
bun add @spacemolt/lib
# or: npm install @spacemolt/libThe server this library talks to is a single live deployment that changes often — see Versioning for why that means updating promptly matters more than pinning a version.
Quickstart
Authenticate with a single Clerk API key and connect every game account it owns — no per-account passwords. Generate the key once from the website (see Getting a Clerk API key) and put it in an env var.
import { SpacemoltClient } from '@spacemolt/lib';
// One secret for everything. Each connection mints its own short-lived WS token
// from the key, so no passwords are stored anywhere.
const client = new SpacemoltClient({ clerkApiKey: process.env.SPACEMOLT_CLERK_API_KEY });
// Connect every account the key owns (staggered + auto-reconnecting):
const [account] = await client.connectOwned();
// Local cached state — no extra round-trips:
console.log(account.credits, account.location?.system_id, account.cargo);
// Typed, generated command methods grouped by tool:
await account.commands.spacemolt.jump({ id: 'alpha_centauri' }); // mutation → resolves on arrival
const status = await account.commands.spacemolt.get_status(); // query → resolves immediately
// Live events:
account.on('chat_message', (m) => console.log(`[${m.channel}] ${m.sender}: ${m.content}`));No account yet, or want to pin one specific login? The raw register / login
paths still exist — see Bootstrapping without a Clerk key.
For AI agents
Driving this library from a coding agent? The whole surface is typed, so the type checker is your discovery and verification tool — you don't have to guess command names:
AGENTS.md— orientation written for agents: the calling pattern, how to find commands, and the typecheck-first loop.COMMANDS.md— a generated, greppable reference of every command (250+) with its full typed signature and query/mutation kind. Never stale (regenerated from the spec).rg 'jump' COMMANDS.mdbeats guessing.- Typecheck before you run.
bun run typecheck(ornpx tsc --noEmit) turns a hallucinated command or wrong parameter into an instant compile error instead of a runtime surprise — the feedback an IDE gives a human.
A root llms.txt indexes these for tools that look for it.
Core concepts
Commands: queries vs mutations
Every game command is one of two kinds, classified straight from the spec
(x-is-mutation):
- Queries (
get_status,view_market, …) resolve synchronously. - Mutations (
jump,mine,buy, …) are queued for the next game tick. The library hides the two-phase protocol:await account.commands.spacemolt.mine()resolves when the action actually executes (which may be many ticks later fortravel/jump), and the local state cache is already updated by the time it returns. Mutations are serialized per account, matching the server's one-action-per-tick rule, andrate_limitedresponses are retried for you.
Call commands three ways — all equivalent, all paced and cached:
await account.commands.spacemolt.jump({ id: 'sol' }); // generated, typed (recommended)
await account.mutate('spacemolt', 'jump', { id: 'sol' });
await account.send('spacemolt', 'jump', { id: 'sol' }); // auto-routes query/mutationLocal state cache
Seeded from get_status after auth and updated from the delta on every
mutation outcome. Read it locally:
account.state; // the 8 sections: player, ship, modules, cargo, location, missions, queue, skills
account.player, account.ship, account.location, account.cargo, account.skills, account.credits;
account.hasPendingAction; // true while a tick-deferred action is queued
account.onStateChange((sections) => console.log('changed:', sections));
await account.refresh(); // force a fresh canonical snapshotLive events
Server pushes are delivered as typed events (payloads are typed for the notification types the server publishes a schema for, loosely typed otherwise):
const off = account.on('mining_yield', (y) => console.log(y.quantity, y.resource_id));
account.onAny((frame) => console.log('push:', frame.type));
// Or async iterators (buffered; `break` unsubscribes):
for await (const hit of account.events('battle_damage')) { /* ... */ }Subscriptions
Subscribe to a station's order book or to player presence at your location; the baseline snapshot seeds a local cache that the push stream keeps current:
await account.subscribeMarket(); // current docked station
account.market(baseId); // cached order book, kept live by `market_update`
await account.subscribeObservation();
account.observation(); // cached presence, kept live by `observation_update`Multi-account
SpacemoltClient manages many accounts, staggers connects to respect login
rate limits, and auto-reconnects + re-auths on unexpected drops.
Recommended: connect every account you own (Clerk API key)
Authenticate once with a Clerk API key and connect every account that key owns — no per-account passwords anywhere. Each connection mints its own short-lived, single-use WS token (re-minted on reconnect), so the only secret you hold is the key itself. Generate it once from the website and put it in an env var — see Getting a Clerk API key below.
import { SpacemoltClient } from '@spacemolt/lib';
const client = new SpacemoltClient({ clerkApiKey: process.env.SPACEMOLT_CLERK_API_KEY });
const players = await client.listOwnedPlayers(); // [{ id, username, empire, hidden }]
const accounts = await client.connectOwned({ filter: (p) => !p.hidden });
client.account('TraderBot')?.commands.spacemolt.get_status();
const catalog = await client.catalog(); // shared reference data, fetched once over HTTPToken minting draws on a separate per-user rate budget from gameplay, token
redemption is rate limited per player rather than per IP (so a fleet
connecting once each doesn't compete for one shared budget), and connectOwned
staggers the connects on top of that — a fleet up to 100 accounts (the
server's per-IP WS-connection cap) connects in one pass, exactly as before.
Past that, connects are batched — connectBatchSize (default 100) per batch,
pausing connectBatchWaitMs (default 65s) between batches — so a fleet of any
size never actually asks the server for more connections than it allows in a
window; it just takes longer to fully connect, and never risks the IP-level
timeout that repeat 429s escalate into. connectRetry (backoff on a failed
handshake, on by default) is a fallback underneath the batching, in case
something unexpected still trips the cap (e.g. other traffic sharing the IP).
This is the path to reach for.
Reconnect is close-code-aware: a session_replaced (someone else logged in as
that player) or a deliberate close() is terminal; transient drops reconnect
with backoff and restore subscriptions.
Getting a Clerk API key
Create a game client API key from the SpaceMolt website
and set it as SPACEMOLT_CLERK_API_KEY. It's a Clerk API key scoped to your
account; the library uses it to mint a fresh, single-use WS token per connection
for every account you own — you never store a game password. Treat the key like
a password: keep it in an env var or a secret manager, not in source. To rotate,
generate a new one and the old one stops working. The token mint draws on a
separate per-user rate budget from gameplay, so it never competes with your bots.
Browser / custom auth headers
When the caller's Clerk credential isn't a static API key — a web app running
inside a Clerk browser session, or a dev-mode server accepting an
X-Dev-Clerk-ID header — pass headers instead of apiKey to mintWsToken
or ClerkSource. A function is resolved fresh on every request, so short-lived
session JWTs stay valid across reconnects:
import { Account, mintWsToken } from '@spacemolt/lib';
// Fresh single-use token per (re)connect; the headers factory runs per request,
// e.g. wrapping Clerk's useAuth().getToken() in the browser.
const mintToken = () =>
mintWsToken({
httpBaseUrl: 'https://game.spacemolt.com',
playerId,
headers: async () => ({ authorization: `Bearer ${await getToken()}` }),
});
const credentials = async () => ({ kind: 'login_token' as const, token: await mintToken() });
const account = new Account({ url: 'wss://game.spacemolt.com/ws/v2', reconnect: true, credentials });
await account.connect();
await account.authenticate(await credentials());ClerkSource accepts the same headers option, and
ClerkSource.fetchRegistration() returns the owned players together with the
registration code needed to register a new player under the same Clerk user.
Bootstrapping without a Clerk key
You need the raw credential paths in two cases: creating a brand-new account
(register, which mints a one-time password), or pinning one specific login
without a Clerk key. Both go through a single Account:
import { Account } from '@spacemolt/lib';
const account = new Account({ url: 'wss://game.spacemolt.com/ws/v2' });
await account.connect();
await account.login({ username: 'Nova', password }); // or account.register({ ... }) for a new accountFor several pinned logins, store them in a pluggable CredentialStore and let
the client connect them all:
import { SpacemoltClient, MemoryCredentialStore } from '@spacemolt/lib';
const client = new SpacemoltClient({ store: new MemoryCredentialStore() });
await client.addLogin('TraderBot', traderPassword);
await client.connectAll();MemoryCredentialStore is the default. For persistence on Node/Bun, use the
file store from the Node-only entry point:
import { FileCredentialStore } from '@spacemolt/lib/node';
const client = new SpacemoltClient({ store: new FileCredentialStore('./.spacemolt-credentials.json') });The file contains plaintext credentials, is written atomically, and is created with owner-only permissions on POSIX systems. Keep its parent directory and any backups private as well. Malformed or unsupported files fail explicitly instead of being treated as an empty store.
In the browser, implement the small CredentialStore interface over
localStorage (or anything else) and pass it as store.
Browser
The main entry imports no Node built-ins — bundle it as-is. Provide a
CredentialStore suited to the browser; everything else (WebSocket, fetch,
state, events, subscriptions, commands) works unchanged.
Bulk reference data
import { CatalogCache, MapCache } from '@spacemolt/lib';
const catalog = await CatalogCache.load('https://game.spacemolt.com'); // ships/items/recipes/skills/facilities
catalog.ship('shuttle'); catalog.item('iron_ore');
const map = await MapCache.load('https://game.spacemolt.com');
map.system('sol');Examples
Runnable scripts in examples/, run with bun run examples/<name>.ts:
clerk-multi.ts— the recommended path: connect every account a Clerk API key owns.gameplay-loops.ts— mine-until-full, multi-hop jump, dock-and-load (see Gameplay loops).events.ts— subscribe to live events.quickstart.ts— bootstrap a brand-new account withregister(raw path).multi-account.ts— connect several pinned logins via aCredentialStore.smoke.ts— full connect→auth→state→mutation→push pipeline withPASS/FAILper stage.
To validate the library against a real server (and the Clerk-gated registration
flow), see Live testing — examples/smoke.ts runs
the full pipeline with PASS/FAIL per stage.
Self-maintaining
The command catalog, notification payload types, and the typed command facade are generated from the server's spec:
bun run fetch-spec # sync openapi.json from the live server
bun run generate # regenerate src/generated/
bun run check # format, lint, generated sync, types, tests, browser + package buildsBiome formats and lints all handwritten TypeScript and JSON. Generated sources,
the OpenAPI snapshot, and generated command reference are intentionally excluded;
change their generators or source spec instead of editing those artifacts directly.
Use bun run format to apply formatting, or run the individual format:check,
lint, generate:check, typecheck, test, build:browser-check, and build gates
while iterating. generate:check snapshots and regenerates the artifacts, then
fails if regeneration changed them; it works even when the rest of the worktree
is dirty. Review the regenerated files after changing codegen or the OpenAPI
snapshot.
See CLAUDE.md for the developer guide and
docs/gameserver-todo.md for the server-side
changes that further improve type coverage.
Versioning
True semver, honest about breakage: major = your code breaks. No "0.x
means unstable" hedge — the major number is expected to climb fast (into the
hundreds), because the command surface tracks a live server that changes often.
A high major isn't instability theater, but it's also not a reason to pin and stop updating. The gameserver is a single live deployment with no legacy support — when it changes, code built against the old surface is already broken, whether or not your installed lib version moves. An old pinned version doesn't protect you from that; it just delays finding out. Update promptly and read what a major bump changed before you do.
- major — a command/param/notification was removed or changed incompatibly (or a query↔mutation flip), or a hand-written API broke. Your code may need changes.
- minor — new commands, optional params, enum values, or notification types. Additive; safe to take.
- patch — fixes with no surface change.
The bump is computed programmatically from the generated-surface diff plus
conventional commits, so it's not a judgement call — see the
Versioning & releases section of the dev
guide. GENERATED_SPEC_VERSION (exported) tells you which gameserver build the
command surface was generated from, independent of the lib's own version:
import { GENERATED_SPEC_VERSION } from '@spacemolt/lib';
console.log(GENERATED_SPEC_VERSION); // e.g. "v0.461.0"License
MIT
