structs-streaming

Connects to the GRASS real-time event system via NATS WebSocket. Use when you need real-time game updates, want to react to events as they happen, need to monitor raids or attacks,…

Josh McDougall

@abstrct

Install

$ openclaw skills install @abstrct/structs-streaming

Structs Streaming (GRASS)

GRASS (Game Real-time Application Streaming Service) delivers real-time game events over NATS. Instead of polling queries repeatedly, subscribe to GRASS and react to events the moment they happen.

When to Use GRASS

SituationUse GRASSUse Polling
Detect incoming raidYes — instant alertToo slow
Wait for player creation after guild signupYes — listen for address_registerPolling every 10s works too
Monitor fleet arriving at your planetYes — fleet_arrive eventMight miss it
Track struct health during combatYes — planet_activity with struct_healthToo slow
Check your own resource balanceNoYes — one-off query
Read struct type statsNoYes — static data

Rule of thumb: If you need to react to something, use GRASS. If you need to read something, use a query.


Finding Your GRASS Endpoint

The GRASS WebSocket URL is not hardcoded — it comes from the guild configuration.

  1. Query the guild list: curl https://public.testnet.structs.network/structs/guild
  2. Follow the guild's endpoint URL to get its config
  3. Look for services.grass_nats_websocket

Example (Orbital Hydro guild):

{
  "services": {
    "grass_nats_websocket": "ws://crew.oh.energy:1443",
    "guild_api": "http://crew.oh.energy/api/",
    "reactor_api": "https://public.testnet.structs.network/"
  }
}

The grass_nats_websocket value is your NATS WebSocket endpoint. Not all guilds provide this service — check before relying on it.

A reliable reference endpoint: ws://crew.oh.energy:1443 (Orbital Hydro / Slow Ninja). GRASS is hosted by individual guilds and is currently HTTP only — do not rewrite to wss://.

Tendermint WebSocket vs GRASS

These are two different streams. GRASS at ws://crew.oh.energy:1443 carries game-level events. Tendermint's own WebSocket at wss://public.testnet.structs.network:26657/websocket carries chain events (txs, blocks, validator updates). Use Tendermint subscriptions when you need to audit ugc_moderated or other untyped chain events that GRASS does not republish.


Discovery First

Before subscribing to specific subjects, subscribe to the > wildcard to see all traffic flowing through the GRASS server. This reveals the actual subject patterns in use, which may differ from documentation.

const sub = nc.subscribe(">");
for await (const msg of sub) {
  console.log(`[${msg.subject}]`, new TextDecoder().decode(msg.data));
}

Watch the output for 30-60 seconds. You will see subjects like structs.planet.2-1.1-11, consensus, healthcheck, etc. Once you know what subjects carry the events you need, narrow your subscriptions to those specific subjects.

Important: Struct events (attacks, builds, status changes) often arrive on the planet subject rather than the struct subject. If you are not receiving expected struct events, subscribe to the struct's planet subject instead.


Subject Patterns

Subscribe to subjects matching the entities you care about:

EntityWildcardSpecificExample
Playerstructs.player.>structs.player.{guild_id}.{player_id}structs.player.0-1.1-11
Planetstructs.planet.>structs.planet.{planet_id}.{player_id}structs.planet.2-1.1-11
Guildstructs.guild.*structs.guild.{guild_id}structs.guild.0-1
Structstructs.struct.*structs.struct.{struct_id}structs.struct.5-1
Fleetstructs.fleet.*structs.fleet.{fleet_id}structs.fleet.9-1
Addressstructs.address.register.*structs.address.register.{code}--
Inventorystructs.inventory.>structs.inventory.{denom}.{guild_id}.{player_id}.{address}Token movements
Gridstructs.grid.>structs.grid.{object_type}.{object_id}.{player_id}Attribute changes (ore, power, load, etc.)
Globalstructs.globalstructs.globalBlock updates
ConsensusconsensusconsensusChain consensus events
HealthcheckhealthcheckhealthcheckNode health status

Grid and planet subjects end with the owning player_id (added 2026-07-07): grid is structs.grid.{object_type}.{object_id}.{player_id} and planet is structs.planet.{planet_id}.{player_id}. When the owner can't be resolved the segment is the literal noPlayer. This lets you filter by owner from the subject alone — but mind the NATS wildcard rule: * matches exactly one token, > matches one or more trailing tokens. So structs.planet.* no longer matches (that's only two tokens); use structs.planet.{planet_id}.* for one planet (any owner) or structs.planet.> for all. The player subject (structs.player.{guild_id}.{player_id}) is likewise three tokens — use structs.player.>, not structs.player.*.

Use wildcards to discover what events exist. Narrow to specific subjects once you know what you need. Use > to see everything (see "Discovery First" above).


Event Types

Planet Events

EventDescriptionReact By
raid_statusRaid lifecycle on planet — status values include shieldsVulnerable (defender's shields down, raid can now complete), ongoing, and completedRestore shields (Command Ship online, fleet on station); activate defenses, alert
shield_changePlanetary shield value changed (planetary_shield / planetary_shield_old in detail)Recompute raid feasibility against the target
block_raid_startThe planet's raid vulnerability clock (blockStartRaid) armedNote the raid window opened (yours: defend; theirs: a raid may be incoming)
struct_healthA struct's HP changed (health / health_old)Track combat damage live
struct_statusA struct went online / offline / destroyedRebuild, reallocate power
fleet_arriveFleet arrived at planetPrepare defense or welcome
fleet_departFleet left planetUpdate threat assessment

All of the above (and the struct categories below) are planet_activity rows that arrive on the planet subject structs.planet.{planet_id}.{player_id} (subscribe with structs.planet.{planet_id}.*). struct_health, struct_status, shield_change, raid_status, and fleet_arrive/fleet_depart are the effect events that fire during combat — see the stub note under Combat Event Payloads for why these, not struct_attack detail, are what you reliably get live.

Struct Events

Note: Struct events frequently arrive on the planet subject (structs.planet.{planet_id}.{player_id}, i.e. subscribe structs.planet.{planet_id}.*) rather than the struct subject. Subscribe to both if you need complete coverage.

EventDescriptionReact By
struct_attackStruct was attackedCounter-attack, repair
struct_statusStruct status changed (online/offline/destroyed)Rebuild, reallocate power
struct_defense_add / struct_defense_removeDefense assignments changedUpdate defense map
struct_defender_clearAll defense relationships clearedRe-assign defenders
struct_block_build_startBuild operation initiatedTrack in job list
struct_block_ore_mine_startMine operation initiatedTrack in job list
struct_block_ore_refine_startRefine operation initiatedTrack in job list

Player Events

EventDescriptionReact By
player_consensusPlayer state updated (including username/pfp on structs.player after chain UGC)Update intel
player_addressAn address was added to / changed on a playerTrack multi-address / delegate setup
player_address_pendingA pending address registration appeared (awaiting confirmation)Watch for registration completion

Guild Events

EventDescriptionReact By
guild_consensusGuild chain data updatedUpdate guild status
guild_metaOff-chain guild metadata changed (description, tag, logo, services on structs.guild_meta)Update intel
guild_membershipMember joined/left guildUpdate relationship map

UGC Moderation Events

UGC name/pfp updates emit two distinct streams:

  1. GRASS DB-trigger events (the table above): player_consensus fires when sync-state commits player UGC (username/pfp on structs.player). guild_meta fires for off-chain guild config updates. Chain UGC name/pfp on guilds live on structs.guild. Planet and substation UGC reach observers via chain events and planet_activity entries.
  2. Cosmos chain event ugc_moderated — emitted by the keeper directly (untyped sdk.Event, not GRASS). Fires only when the actor of the update is not the target object's owner (i.e. only on guild-moderation overrides, never on self-service updates).

Subscribe to chain events via Tendermint's tx.events or block_events subscription (separate from GRASS) when you want a complete audit trail of moderation activity. Schema:

AttributeDescription
actor_player_idPlayer ID of the moderator who performed the override
actor_addressSigning address that authored the tx
target_object_idPlayer / planet / substation / guild ID being moderated
target_owner_player_idOwner player ID at the time of the update
fieldname or pfp
old_valueField value before the update
new_valueField value after the update

Use this stream as a moderation audit log — see knowledge/mechanics/ugc-moderation.md for context.

Inventory Events

Subject: structs.inventory.{denom}.{guild_id}.{player_id}.{address}

Track token movements — Alpha Matter, guild tokens, ore, etc.

CategoryDescriptionReact By
sentTokens sent from this playerUpdate balance tracking
receivedTokens received by this playerUpdate balance tracking
seizedTokens seized via raidTrigger counter-raid or refine alert
minedOre minedStart refining immediately
refinedOre refined into AlphaUpdate wealth tracking
mintedGuild tokens mintedTrack guild economy
infusedAlpha infused into reactor/generatorUpdate capacity tracking
forfeitedTokens lost (penalties, etc.)Investigate cause

Grid Events

Subject: structs.grid.{object_type}.{object_id}.{player_id}

Track attribute changes on any game object (players, structs, planets). Every grid payload also carries a top-level player_id field (the resolved owner, or noPlayer).

CategoryDescriptionReact By
capacityPower capacity changedCheck if approaching offline
connectionCapacityConnection capacity changedUpdate power routing
connectionCountConnection count changedUpdate power routing
fuelFuel level changedMonitor generator/reactor
lastActionLast action timestamp updatedTrack activity
loadPower load changedCheck if approaching offline
noncePlayer nonce incrementedDetect activity (useful for scouting)
oreOre balance changedRefine immediately if yours; raid target if theirs
player_consensusPlayer consensus data updatedUpdate intel
powerPower level changedMonitor energy infrastructure
proxyNonceProxy nonce changedDetect proxy activity
structsLoadStructs load changedAssess fleet strength changes
allocationPointerStart / allocationPointerEndEnergy allocation range pointers changedTrack allocation/substation routing
readyObject readiness flag changedTrack object availability
checkpointBlockCheckpoint block updatedTrack grid bookkeeping

Grid categories are the attribute name itself (e.g. ore, load), carried on structs.grid.{object_type}.{object_id}.{player_id} — not grass_category values. For an object_type of player the owner is the object itself, so the subject reads structs.grid.player.{id}.{id}.

Combat Event Payloads

The stub: why combat looks like effects, not attacks. struct_attack is a published category, but the NATS NOTIFY payload has an ~8000-byte ceiling. When a planet_activity row's full payload (e.g. a struct_attack detail with its eventAttackShotDetail[] shot log) exceeds 7995 bytes — which any multi-shot, multi-defender fight does — the stream sends a stub instead. The stub keeps the routing/identity fields and drops the heavy detail: { "subject": "structs.planet.{planet_id}.{player_id}", "planet_id": "...", "player_id": "...", "seq": ..., "category": "struct_attack", "time": "...", "stub": "true" } (note stub is the string "true"). So for real combat you cannot rely on the live struct_attack payload for the blow-by-blow. Detect combat from the effect events that always stream in full (struct_health, struct_status, shield_change, raid_status, fleet_arrive/fleet_depart), then pull the full shot detail from the Guild API planet-activity feed (or the chain) keyed by the stub's seq/planet_id. Small attacks ship full detail inline; large ones arrive stubbed. The canonical struct_attack detail schema is in api/integration-notes.md — struct_attack event detail schema.

struct_attack events (when not stubbed) include shot-by-shot resolution. Example payload (observed on planet subject; the full planet payload also carries subject and player_id alongside the row fields):

{
  "category": "struct_attack",
  "player_id": "1-11",
  "attackingStructId": "5-100",
  "targetStructId": "5-200",
  "weaponSystem": "primary",
  "eventAttackShotDetail": [
    {
      "shotIndex": 0,
      "damage": 2,
      "evaded": false,
      "blocked": false,
      "blockerStructId": "",
      "counterAttackDamage": 1,
      "counterAttackerStructId": "5-200"
    }
  ],
  "attackerHealthRemaining": 2,
  "targetHealthRemaining": 1,
  "targetDestroyed": false,
  "attackerDestroyed": false
}

Key fields in eventAttackShotDetail:

  • evaded -- true if the shot missed (defense type interaction)
  • blocked -- true if a defender intercepted
  • blockerStructId -- which struct blocked (if any)
  • counterAttackDamage / counterAttackerStructId -- counter-attack info per shot

struct_health events track HP changes:

{
  "category": "struct_health",
  "structId": "5-200",
  "health": 1,
  "maxHealth": 3,
  "destroyed": false
}

Noise Filtering

The consensus and healthcheck subjects fire constantly (every few seconds). When using the > wildcard for discovery, filter these out to see actual game events:

const sub = nc.subscribe(">");
for await (const msg of sub) {
  if (msg.subject === "consensus" || msg.subject === "healthcheck") continue;
  console.log(`[${msg.subject}]`, new TextDecoder().decode(msg.data));
}

Global Events

EventDescriptionReact By
blockNew block producedTick game loop, update charge calculations

Building Event Listener Tools

Agents should build custom tools that connect to GRASS when they need event-driven behavior. Here are patterns to follow.

Minimal Node.js Listener

Install the NATS WebSocket client:

npm install nats.ws
import { connect } from "nats.ws";

const nc = await connect({ servers: "ws://crew.oh.energy:1443" });

// Subjects end with the owner player_id, so match any owner with a trailing *
const sub = nc.subscribe("structs.planet.2-1.*");
for await (const msg of sub) {
  const event = JSON.parse(new TextDecoder().decode(msg.data));
  console.log(JSON.stringify(event));
}

Minimal Python Listener

Install the NATS client:

pip install nats-py
import asyncio, json, nats

async def main():
    nc = await nats.connect("ws://crew.oh.energy:1443")
    sub = await nc.subscribe("structs.planet.2-1.*")  # trailing * = any owner player_id
    async for msg in sub.messages:
        event = json.loads(msg.data.decode())
        print(json.dumps(event))

asyncio.run(main())

Raid Alert Tool (example pattern)

A tool that watches for raids on your planet and outputs an alert:

import { connect } from "nats.ws";

const PLANET_ID = process.argv[2]; // e.g. "2-1"
const nc = await connect({ servers: "ws://crew.oh.energy:1443" });
const sub = nc.subscribe(`structs.planet.${PLANET_ID}.*`); // trailing * = any owner player_id

for await (const msg of sub) {
  const event = JSON.parse(new TextDecoder().decode(msg.data));
  if (event.category === "raid_status") {
    console.log(JSON.stringify({ alert: "RAID", planet: PLANET_ID, data: event }));
  }
  if (event.category === "fleet_arrive") {
    console.log(JSON.stringify({ alert: "FLEET_ARRIVAL", planet: PLANET_ID, data: event }));
  }
}

Player Creation Watcher (example pattern)

Instead of polling structsd query structs address after guild signup, watch for the address registration event:

import { connect } from "nats.ws";

const nc = await connect({ servers: "ws://crew.oh.energy:1443" });
const sub = nc.subscribe("structs.address.register.*");

for await (const msg of sub) {
  const event = JSON.parse(new TextDecoder().decode(msg.data));
  console.log(JSON.stringify(event));
  break; // exit after first match
}
await nc.close();

When to Build a Custom Tool

Build a GRASS listener tool when:

  • You need to wait for an event — guild signup completion, fleet arrival, raid detection
  • You need continuous monitoring — threat detection during vulnerable ore window, combat tracking
  • You want an event-driven game loop — react to block events instead of polling on a timer
  • You're managing multiple players — one GRASS connection can monitor all your entities simultaneously

Store custom tools in your workspace (e.g., scripts/ or alongside the relevant skill).


Connection Best Practices

  • Use specific subjects once you know what you need. Wildcards are for discovery.
  • Limit to 10-20 subscriptions per connection to avoid overwhelming the client.
  • Implement reconnection with exponential backoff — NATS connections can drop.
  • Parse JSON defensively — not all messages may match expected schema.
  • Close connections when done. Don't leave idle GRASS connections open.

Procedure

Quick Setup

  1. Get the GRASS endpoint from your guild config (or use ws://crew.oh.energy:1443). For chain queries you can use the SSL endpoint https://public.testnet.structs.network/structs/guild.
  2. Record the endpoint in TOOLS.md under Servers
  3. Choose your language (Node.js or Python)
  4. Install the NATS client library (nats.ws for Node, nats-py for Python)
  5. Write a listener script for your specific use case
  6. Run it in a background terminal

For Ongoing Monitoring

  1. Subscribe to your planet(s): structs.planet.{id}.* — raid alerts, fleet arrivals (trailing * matches any owner player_id)
  2. Subscribe to your structs: structs.struct.{id} — attack/status alerts
  3. Subscribe to global: structs.global — block tick for game loop timing
  4. Log events to memory/ for cross-session awareness

Automation Patterns (Defence Contractor)

The Structs permission system and GRASS event stream were designed for AI agents to automate game responses. The design docs call this the "Defence Contractor" pattern — an agent that monitors events and acts on behalf of players within scoped permissions.

Common Automation Triggers

EventActionPermission Needed
struct_ore_mine_complete on your extractorImmediately start struct-ore-refine-computeSigner key for the player
struct_ore_refine_complete on your refineryImmediately start next struct-ore-mine-computeSigner key for the player
planet_raid_start on your planetAlert, activate stealth, reposition defendersSigner key or delegated permission
struct_attack targeting your structLog attacker, assess threat, counter-attack if ableSigner key or delegated permission
struct_health showing HP dropPrioritize defense, consider fleet retreatSigner key for fleet-move
fleet_move to your planet from unknown fleetIdentify incoming player, assess threat levelRead-only (query)

Permission Scoping for Automated Agents

When delegating actions to an automation agent (separate key or service):

  1. Grant minimal permissions: Use permission-grant-on-object to allow specific actions on specific structs, not blanket access.
  2. Separate keys: The automation agent should use its own signing key, registered as a secondary address on the player via address-register.
  3. Scope by struct: Grant mine/refine permissions on extractors and refineries only. Grant defense permissions on fleet structs only.
  4. Revoke when not needed: Use permission-revoke-on-object to remove automation access during sensitive operations.

Example: Refine-on-Mine-Complete Loop

Subscribe to: structs.struct.{extractor-id}
On event: struct_ore_mine_complete
  → Run: structsd tx structs struct-ore-refine-compute -D 1 --from [key] --gas auto -y -- [refinery-id]

Subscribe to: structs.struct.{refinery-id}
On event: struct_ore_refine_complete
  → Run: structsd tx structs struct-ore-mine-compute -D 1 --from [key] --gas auto -y -- [extractor-id]

This creates a continuous mine-refine loop that runs unattended. Ore is never left unrefined.

Example: Defend-on-Raid-Detected

Subscribe to: structs.planet.{planet-id}.*
On event: planet_raid_start
  → Activate stealth on vulnerable structs
  → Set defenders on high-value structs
  → Log raid to memory/intel/threats.md
  → Alert commander if available

Safe Boundaries

  • Never auto-spend Alpha without commander approval (infusion, guild-bank operations)
  • Never auto-move fleets away from defended planets without threat assessment
  • Always log actions to memory/ for cross-session audit trail
  • Rate-limit reactions — one transaction per ~6 seconds per key (sequence number constraint)

See Also

Top skills in this category