Integration Notes: Live Data Shapes & Gotchas

Purpose: Hard-won, verified notes for anyone building against the live Structs chain and Guild API (bots, MCP servers, dashboards). These are the data-shape and endpoint details that cause silent integration bugs. Each note is tagged with where it was verified.

Scope: this page is about the game API / proto / data shapes. For the proof-of-work mechanism see hashing.md; for combat math see combat.md.


Live Endpoints (public testnet)

The public testnet exposes the standard Cosmos surfaces over TLS. There is no port 1317 on the public host — that port is local-devnet only.

Surface URL Notes
REST (LCD) https://public.testnet.structs.network Standard HTTPS, no port. http://...:1317 is dead (connection refused).
Tendermint RPC https://public.testnet.structs.network:26657 Block, tx, status
gRPC public.testnet.structs.network:9090 If exposed for your tooling
Local devnet LCD http://localhost:1317 Only when you run your own node (see local-devnet.md)

Caveat — not every LCD query is implemented. Some module queries return gRPC code 12 (“Not Implemented”) on the deployed testnet build (observed for GET /structs/structs/struct_type/...). When an LCD route 501s, fall back to the CLI (structsd query structs ...) or the Guild Stack PostgreSQL mirror. Do not assume a 501 means a bad URL.

The query docs under api/queries/ use http://localhost:1317 as a generic base — substitute the live HTTPS base above for testnet.


Guild API: numeric fields are JSON strings

The Guild API serializes numeric fields as JSON strings, not numbers. Naive numeric use (a + b, comparisons) silently misbehaves.

{ "health": "6", "last_action_block_height": "1337217", "ore": "5", "load": "25000", "capacity": "1000000" }

Always coerce: parseInt(x, 10) for counts/heights, and BigInt for precise energy/capacity values (the client divides load/capacity/structs_load by an energy precision factor). Verified in webapp src/js/factories/PlayerFactory.js (Number(BigInt(numberString) / BigInt(PRECISION))) and src/js/api/GuildAPI.js (parseInt(...) on block heights and counts).

Blanket rule: treat every Guild API numeric as a string at the wire and convert explicitly.

Note this also applies to chain LCD responses: protobuf JSON serializes uint64 fields as strings (e.g. address permissions below comes back as "1").


Event detail has two representations

The same activity event reaches you in two different encodings depending on the transport:

Source detail encoding How to read
Guild API planet-activity feed (REST) JSON-encoded string JSON.parse(row.detail) before use
NATS / GRASS realtime stream already-parsed object use message.detail.* directly

Verified: GRASS frames are parsed once via message.json() in webapp src/js/framework/GrassManager.js, and listeners access messageData.detail.* as an object. The REST planet-activity rows come straight from a PostgreSQL detail column and arrive as a JSON string. An integrator consuming both must branch on the source.

GRASS grid/planet subjects carry the owner player_id (added 2026-07-07). Subjects are structs.grid.{object_type}.{object_id}.{player_id} and structs.planet.{planet_id}.{player_id} (literal noPlayer when the owner can’t be resolved), and every such payload includes a top-level player_id field — so you can filter by owner from the subject without parsing. Because NATS * matches exactly one token, subscribe with the trailing wildcard: structs.planet.{planet_id}.* (one planet) or structs.planet.> / structs.grid.> (all). A bare structs.planet.* matches nothing now. Verified in structs-pg deploy/trigger-grass-planet-activity-20260707-add-owner-player-id.sql and deploy/trigger-grass-grid-20260707-add-owner-player-id.sql; the webapp listener matches the suffixed subjects in src/js/framework/GrassManager.js.

There is also a field-casing split inside detail: most struct events use snake_case keys (struct_id, defender_struct_id), but struct_attack uses camelCase (see next section).


struct_attack event detail schema

Attacker context is flat at the top of detail; the per-shot outcomes are in a nested array eventAttackShotDetail[]. (Verified against webapp src/js/ts/structs.structs/types/structs/structs/events.ts EventAttackDetail / EventAttackShotDetail.)

// detail (flat attacker block)
{
  "attackerStructId": "5-1",
  "attackerStructType": 2,
  "attackerStructOperatingAmbit": 4,        // ambit ENUM (space=4) — see Ambit note below
  "weaponSystem": 0,
  "weaponControl": 1,
  "recoilDamage": 0,
  "recoilDamageToAttacker": false,
  "planetaryDefenseCannonDamage": 0,
  "attackerPlayerId": "1-11",
  "targetPlayerId": "1-22",
  "eventAttackShotDetail": [                 // nested: one entry PER PROJECTILE
    {
      "targetStructId": "5-3",
      "targetStructType": 3,
      "evaded": false,
      "evadedCause": 0,
      "blocked": false,
      "blockedByStructId": "",
      "damageDealt": 2,
      "damage": 2,
      "damageReduction": 0,
      "targetDestroyed": false,
      "targetCountered": false,
      "targetCounteredDamage": 0
    }
  ]
}

Notes:

See combat.md for the resolution order and api/streaming/event-schemas.md for the event catalog.


Where struct HP and status live

Struct health and the numeric status bitmask are NOT on the base struct row. They live in struct_attribute rows and are only joined by some endpoints.

Source Returns health? Returns numeric status?
Guild API catalog GET /api/struct/list/{all\|owner\|location}/... No No
Guild API bespoke GET /api/struct/player/{id}, GET /api/struct/{id} Yes (joined, default 0) Yes (joined, default 0)
Chain LCD struct entity (GET /structs/struct/{id}) Yes (structAttributes.health) Yes (status)

The catalog list endpoints return only base columns: id, index, type, creator, owner, location_type, location_id, operating_ambit, slot, is_destroyed, destroyed_block, created_at, updated_at (verified in webapp TableReadManager::structListAll/ByOwner/ByLocation). To get HP, built-state, or the build clock you must use a bespoke endpoint or the chain entity, where structAttributes exposes health, isBuilt, blockStartBuild, and status.

The numeric status is a StructState bit-flag, not an enum. Decode it with the canonical table in building.md — Status field (numeric) (Online = status & 4, Destroyed = status & 32; e.g. 35 is a destroyed struct). The catalog list’s is_destroyed boolean is the only destruction signal on the base row.

See api/webapp/struct.md for full response shapes.


/structs/address/{addr} response shape

The chain LCD address query returns a flat object with playerId top-level (camelCase) — not nested under an Address wrapper:

{ "address": "structs1...", "playerId": "1-11", "permissions": "1" }

Verified in proto/structs/structs/query.proto (QueryAddressResponse { string address; string playerId; uint64 permissions; }, route /structs/address/{address}). permissions is a uint64 → serialized as a string. A common polling bug is looking for the player id under a nested key; it is top-level.


Field-name traps


Ambit: enum vs reach bitmask

There are two different ambit numbering schemes; conflating them produces an invalid int32 error on build.

Scheme Values Used by
Enum none=0, water=1, land=2, air=3, space=4, local=5 Transaction messages: MsgStructBuildInitiate.operatingAmbit, MsgStructMove.ambit; a struct’s stored operatingAmbit
Reach bitmask none=1, water=2, land=4, air=8, space=16, local=32 StructType.possibleAmbit, primaryWeaponAmbits, secondaryWeaponAmbits (weapon reach masks)

When you build or move, pass the enum (the CLI accepts the lowercase name space|air|land|water, which maps to enum 4/3/2/1). The bitmask values (2/4/8/16) are only for interpreting possibleAmbit and weapon-reach fields. Verified in proto/structs/structs/keys.proto (enum) and x/structs/types/keys.go (Ambit_flag bitmask). See building.md.


Charge is not a pool

charge = currentBlock - lastActionBlock, per-player, and any charge-consuming action resets it to 0. The per-action “cost” is a minimum threshold, not a balance you draw down or bank. You cannot stockpile charge to burst several expensive actions; idling past your next action’s cost gains nothing. Plan combat as single actions spaced ~1 block per charge apart. A dashboard “Charge: N” is the current currentBlock - lastActionBlock, not a wallet. See building.md.


capacity_exceeded covers two different build checks

struct-build-initiate raises one error string — cannot handle new load requirements (required: X, available: Y) (structured error key capacity_exceeded) — for two unrelated gates. The numbers tell them apart:

required / available look like Gate Meaning
Tiny integers, often equal (e.g. 1 / 1) Per-player build limit required is the struct type’s build limit, available is how many you already own. Most planet structs and the Command Ship cap at 1; only Orbital Shield Generator, Ore Bunker, and fleet combat structs stack.
Large values (hundreds of thousands to millions) Power capacity (milliwatts) required is the struct’s BuildDraw, available is remaining capacity (capacity + capacitySecondary) - (load + structsLoad).

Do not assume the error is always about power — a 1/1 is the build-count limit, not a power shortage. Verified in x/structs/keeper/msg_server_struct_build_initiate.go (build-count and CanSupportLoadAddition checks) and x/structs/types/errors_structured.go (capacity_exceeded). See building.md.


Proxy signup is idempotent

The guild proxy signup flow (sign GUILD{id}ADDRESS{addr}NONCE0POST /api/auth/signup → guild fronts MsgGuildMembershipJoinProxy → poll /structs/address/{addr} for the player id) is safe to re-run. Re-running for an address that already joined returns {resource_already_exists}. Treat this as success (adopt the existing player) rather than a hard failure. See structs-onboarding.


Guild API auth scope (public vs authenticated)

Almost everything under /api/ requires an authenticated session (the PlayerAuthenticator checks player_id in the PHP session; missing → 401 {"authentication_error":"Login required"}). Only these prefixes are public (firewall security: false), verified in webapp config/packages/security.yaml:

Public prefix Purpose
/api/auth/ signup, login, logout, activation-code/{code}
/api/guild/this This guild’s metadata
/api/timestamp Server time
/api/setting Live tunables
/api/pfp Profile images

Correction to a common assumption: struct reads and the planet-activity feed are not public — /api/struct/list/..., /api/struct/{id}, /api/planet-activity/..., etc. all require a session. Authenticate first (see api/webapp/auth.md) before any catalog or bespoke read.


Energy and grid data shapes

Power/grid integrations trip on where the numbers live and how the messages are shaped.

Message field shapes (verified in proto/structs/structs/tx.proto):

Message Fields Gotcha
MsgReactorInfuse creator, delegatorAddress, validatorAddress, amount amount is a single Coin (not a list); infusing is a delegation to the reactor’s validator address.
MsgAllocationCreate creator, controller, sourceObjectId, allocationType, power No destinationId — the destination is set later via connect.
MsgSubstationAllocationConnect creator, allocationId, destinationId The substation id goes in destinationId — there is no substationId field.

See energy.md for the grid mechanics behind these shapes.


struct_type DB field shapes

Two struct_type shape changes in the Guild Stack (structs-pg) that tools read:

Player UGC: PFP is a first-class attribute

Profile pictures are a moderated player UGC attribute alongside username, and the pending-player signup flow now threads pfp render hints too. PLAYER_PENDING_JOIN_PROXY builds the signed guild-membership-join-proxy ugc argument from up to three keys: player-name (from username), player-pfp (from pfp), and player-pfp-client-render-attributes (from the pfp_client_render_attributes column — the column was renamed from pfp_cr_attributes, and the ugc key was lengthened from the short player-pfp-cr-attributes form to match). Committed username/pfp surface on structs.player via GRASS player_consensus. Verified in structs-pg deploy/trigger-player-pending-20260612-pfp-cr-attributes-inclusion.sql, …-20260617-rename-pfp-cr-attributes.sql, …-20260617-ugc-key-long-name.sql. The pfpClientRenderAttributes field is self-service (not guild-moderatable); moderation of name/pfp follows the ugc_moderated path — see ugc-moderation.md.


See Also