Purpose: How the reference client consumes the live event stream — connection, the listener contract, the full listener catalogue, and what the stream will not do for you.
For the event schema and subject taxonomy from the server side, see the structs-streaming skill and api/streaming/event-types.md. This page is how a client wires it up.
NATS over WebSocket via @nats-io/nats-core 3.0.0, using wsconnect:
this.nc = await natsCore.wsconnect({
servers: this.grassServerUrl,
maxReconnectAttempts: -1, // never give up on transient drops
reconnectTimeWait: 2000,
waitOnFirstConnect: true,
});
The client runs two managers, both against ws://{hostname}:1443:
| Manager | NATS subject | Listeners |
|---|---|---|
grassManager |
structs.> |
all 25 game listeners |
blockGrassManager |
consensus |
BlockListener only |
No authentication is sent. No token, no credentials. The scheme is ws://, not
wss:// — production TLS, if any, is handled by a reverse proxy outside this code.
The NATS client retries forever on its own, and an outer supervisor loop catches anything that escapes it, with exponential backoff from 1s to a 30s ceiling:
while (this.running) {
try {
this.nc = await natsCore.wsconnect({ … });
this.backoffMs = 1000; // reset on a good connect
this.subscription = this.nc.subscribe(this.subject);
for await (const message of this.subscription) { /* decode, fan out */ }
} catch (e) {
console.warn('[GrassManager] connection error:', this.subject, e);
}
await this.nc?.close();
if (!this.running) break;
await new Promise((resolve) => setTimeout(resolve, this.backoffMs));
this.backoffMs = Math.min(this.backoffMs * 2, this.backoffMax);
}
Registered listeners live in a Map that persists across reconnects, so nothing needs
re-registering. Malformed JSON is skipped; a listener that throws is caught and logged
without killing the loop.
Connection state never reaches the UI. Drops are
console.warnand nothing else. A user can sit in front of a silently disconnected client watching stale numbers. If you build anything operational on GRASS, surface the connection state — it is the single highest-value addition to this subsystem.
Note also that despite comments referring to them, there is no public close() or
reconnect() on GrassManager. Only init().
This is the architectural decision to understand: there is one NATS subscription per manager, and every listener sees every message. Listeners do not subscribe to subjects. They filter, in JavaScript, on fields in the decoded payload.
getMessageData(message) {
return message.json();
}
shouldIgnoreMessage(messageData) {
return !messageData.hasOwnProperty('subject')
|| !messageData.hasOwnProperty('category');
}
Messages are JSON with subject and category as required envelope fields; anything
missing either is dropped before listeners run.
Subject strings inside the payload follow these shapes:
| Pattern | Carries |
|---|---|
consensus |
block height |
structs.player.{guildId}.{playerId} |
player consensus, address approve/revoke |
structs.grid.player.{playerId}.{playerId} |
alpha, load, capacity, ore, lastAction |
structs.grid.substation.{substationId} |
connection capacity |
structs.grid.struct.{structId}.{playerId} |
struct fuel |
structs.planet.{planetId}.{playerId} |
structs, combat, raids, shields, fleet, mining |
structs.inventory.ualpha.{guildId}.{playerId} |
alpha sent / received / refined |
structs.inventory.ualpha.infused.{guildId}.{playerId} |
infusion |
structs.inventory.ualpha.defusing.{guildId}.{playerId} |
defusion |
structs.address.register.{activationCode} |
pending device registration |
Fan-out cost grows with listener count, since every listener runs its filter on every message. At 26 listeners this is fine. If you are building something that registers hundreds, subscribe to narrower NATS subjects instead.
export class AbstractGrassListener {
constructor(name) { this.name = name; }
handler(messageData) { throw new NotImplementedError(); }
shouldUnregister() { return false; }
}
Three obligations: a unique name, a handler, and optionally shouldUnregister.
registerListener(listener) { this.listeners.set(listener.name, listener); }
unregisterListener(name) { this.listeners.delete(name); }
nameis aMapkey, so duplicates silently overwrite.AlphaInfusedChangeListeneralways names itselfALPHA_INFUSED_CHANGE, so registering it for both infuse and defuse replaces the first with the second. Namespace your listener names per instance — several in the codebase already do, e.g.CONSUME_ALPHA_CHANGE_{structId}.
Many listeners exist to await exactly one event: a signup confirming, a device being approved, a transfer landing. The manager checks after each dispatch:
this.listeners.forEach((listener) => {
try {
listener.handler(messageData);
if (listener.shouldUnregister()) {
this.unregisterListener(listener.name);
}
} catch (e) {
console.warn('[GrassManager] listener error:', listener.name, e);
}
});
Two idioms are in use. Most one-shot listeners overwrite the method on the instance:
this.shouldUnregister = () => true;
StructListener overrides it as a real method, tying its lifetime to a raid:
shouldUnregister() {
if (this.gameState.keyPlayers[this.targetPlayerType].isRaidDependent()) {
return !this.gameState.getPlanetRaidInfoForKeyPlayer(this.targetPlayerType)?.isRaidActive();
}
return false;
}
The check is synchronous, immediately after handler() returns. A listener that sets the
flag inside a .then() will not be removed until the next message arrives — harmless
here, but worth knowing if you build on it.
All on structs.> except BlockListener, which is on consensus.
| Listener | Fires on | Does |
|---|---|---|
BlockListener |
block |
Sets block height; dispatches BLOCK_HEIGHT_CHANGED. This is the client’s clock — the signing queue and charge bar both hang off it |
PlayerCreatedListener |
player_consensus, matching primary address |
Logs in, finds a first planet. One-shot |
NewPlanetListener |
player_consensus with planet_id |
Fetches planet/fleet/structs, configures the map, navigates. One-shot |
PlayerAlphaListener |
alpha |
setAlpha |
AlphaChangeListener |
sent / received / refined |
Adjusts alpha by delta |
AlphaInfusedChangeListener |
infused / defusion_started |
Refreshes player, navigates to reactor. One-shot |
ConsumeAlphaChangeListener |
infused for one struct |
Refreshes alpha. One-shot |
TransferSentListener |
sent, matching counterparty and amount |
Navigates to the transaction page. One-shot |
PlayerLoadListener |
load |
setLoad |
PlayerCapacityListener |
capacity |
setPlayerCapacity |
PlayerStructsLoadListener |
structsLoad |
setStructsLoad |
ConnectionCapacityListener |
connectionCapacity |
setConnectionCapacity |
KeyPlayerLastActionListener |
lastAction |
Updates the confirmed charge base. The signing queue depends on this |
KeyPlayerOreListener |
ore |
setOre, optional planet refresh |
KeyPlayerShieldChangeStatusListener |
shield_change, block_raid_start |
Updates shield info |
StructListener |
struct_block_build_start, struct_status, struct_move, struct_defense_add/remove, struct_attack |
The big one: refreshes structs, enqueues combat animations, spawns and kills build tasks |
StructMineStatusListener |
struct_block_ore_mine_start |
Spawns or kills the MINE proof-of-work task |
StructRefineStatusListener |
struct_block_ore_refine_start |
Spawns or kills the REFINE task |
GridStructListener |
fuel for one struct |
Updates fuel, releases the action-bar lock. One-shot |
RaidStatusListener |
raid_status, fleet_depart/arrive on the target |
Full offensive raid lifecycle |
PlanetRaidStatusListener |
raid_status on your own planet |
Defensive raid lifecycle |
PlayerAddressApprovedListener |
player_address approved |
Refreshes devices, navigates. One-shot |
PlayerAddressApprovedLoginListener |
player_address approved for the signing address |
Navigates to device activation. One-shot |
PlayerAddressPendingCreatedListener |
player_address_pending |
Prompts to approve a new device. One-shot |
PlayerAddressRevokedListener |
player_address revoked |
Logs out or refreshes devices. One-shot |
RecoverAccountAddressApprovedListener |
player_address approved for recovery |
Completes mnemonic login. One-shot |
Registration is spread across index.js (block), AuthManager (auth and device flows),
RaidManager, and several view models that register a one-shot listener for the event
they are waiting on and then navigate.
Three patterns, in increasing order of side effects.
GRASS → GameState setter → CustomEvent → component. The common case:
// PlayerAlphaListener
this.gameState.keyPlayers[PLAYER_TYPES.PLAYER].setAlpha(parseInt(messageData.value));
// → KeyPlayer.setAlpha dispatches SaveGameStateEvent + AlphaCountChangedEvent
// → AlphaOwnedComponent updates its text node
GRASS → direct CustomEvent, for things with no natural model home — block height.
GRASS → side effects, where a listener calls the REST API, navigates the router, or
pushes onto the animation queue.
StructListener does all three.
| No replay | Messages sent while you were disconnected are gone. There is no backfill and no sequence gap detection |
| No deduplication | Nothing keyed on a message ID |
| No ordering guarantee | Handled sequentially in one loop, but async work inside handlers is fire-and-forget and can interleave |
| Lag behind chain | GRASS trails the chain by one to two blocks. The signing queue accounts for this; your code should too |
| Silence on disconnect | No indicator, no event |
Which is why the architecture in index.md matters: REST is authoritative, GRASS keeps you current. Re-fetch on login and after anything significant. A client that treats the stream as its source of truth will drift, and it will drift silently — the worst failure mode there is, because everything keeps looking fine.
Verified against structs-webapp 6eec7f7 (2026-07-21): src/js/framework/GrassManager.js,
src/js/framework/AbstractGrassListener.js, src/js/grass_listeners/.