| title | Integration Notes: Live Data Shapes |
|---|---|
| description | Hard-won notes for building against the live chain and Guild API: numeric fields arrive as JSON strings, event detail has two shapes, and more. |
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.
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 — a 501 is often a wrong URL, not an unimplemented query. The gRPC-gateway returns gRPC code 12 ("Not Implemented") for routes that do not match a registered annotation. Verified on both local LCD (http://localhost:1317) and public testnet:
| Wrong (501) | Correct (200) |
|---|---|
/structs/params |
/structs/structs/params (the only route that keeps the doubled structs) |
/structs/structs/struct_type/{id} |
/structs/struct_type/{id} |
/structs/guild_rank_permission_by_object/{objectId} |
/structs/guild_rank_permission/object/{object_id} |
/structs/guild_rank_permission_by_object_and_guild/{objectId}/{guildId} |
/structs/guild_rank_permission/object/{object_id}/guild/{guild_id} |
/structs/player_halted |
(no such query — removed from the module) |
Authoritative path list: option (google.api.http).get annotations in .references/structsd/proto/structs/structs/query.proto. When an LCD route 501s, check that file before assuming the feature is missing. Fall back to the CLI (structsd query structs ...) or the Guild Stack PostgreSQL mirror if the annotation really is absent.
The query docs under api/queries/ use http://localhost:1317 as a generic base — substitute the live HTTPS base above for testnet.
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").
The same activity event reaches you in two different encodings depending on the transport:
| Source | detail encoding |
How to read |
|---|---|---|
Guild API planet-activity catalog (/all, /planet/, /category/) |
JSON-encoded string | JSON.parse(row.detail) before use |
Guild API GET /api/planet-activity/player/{id}/page/{n} |
string and object | row.detail is still the string; row.detail_json is the parsed object (or null) |
| 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 arestructs.grid.{object_type}.{object_id}.{player_id}andstructs.planet.{planet_id}.{player_id}(literalnoPlayerwhen the owner can't be resolved), and every such payload includes a top-levelplayer_idfield — 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) orstructs.planet.>/structs.grid.>(all). A barestructs.planet.*matches nothing now. Verified in structs-pgdeploy/trigger-grass-planet-activity-20260707-add-owner-player-id.sqlanddeploy/trigger-grass-grid-20260707-add-owner-player-id.sql; the webapp listener matches the suffixed subjects insrc/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).
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.)
Notes:
- Chain
EventAttackdoes include health on the proto:attackerHealthBefore/After/MaxoneventAttackDetail, and per-shottargetHealth*/blockerHealth*oneventAttackShotDetail. Catalog: chain-events.md. - The Guild API / GRASS activity feed may still runtime-enrich the same names for animation. Health values may arrive as strings (coerce).
- Evasion is per-target (the whole volley); hit/miss is per-projectile (per
eventAttackShotDetailentry). - A struct counters at most once per
struct-attackinvocation; defender counters nest undereventAttackDefenderCounterDetail[]inside each shot.
See combat.md for the resolution order, chain-events.md for the Tendermint event, and api/streaming/event-schemas.md for the GRASS catalog.
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; missing attribute is 32 if is_destroyed, else 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. Bespoke reads keep recently destroyed structs until STRUCT_SWEEP_DELAY elapses.
See api/webapp/struct.md for full response shapes.
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.
Every live number about an object — capacity, load, ore, power, lastAction — is a grid attribute, stored separately from the object record. The single-entity query joins them; the *All list query does not.
// single: record + grid attributes
gridAttributes := k.GetGridAttributesByObject(ctx, req.Id)
return &types.QueryGetAllocationResponse{Allocation: allocation, GridAttributes: &gridAttributes}, nil
// *All: bare records only
return &types.QueryAllAllocationResponse{Allocation: allocations, Pagination: pageRes}, nilVerified in x/structs/keeper/query_*.go for player, struct, substation, planet, reactor, provider, and allocation: GetGridAttributesByObject appears in the single-entity handler and in no *All handler.
The practical consequences:
allocation-allnever reportspower. Allocation power is a grid attribute, so a list row looks like a zero-power allocation. Fetch the individual allocation for its real value. (This is why Structs Desktop'sguild_power.rs::find_dynamic_allocationlists to find the ID, then GETs it.)- You cannot read charge from
player-all. Charge is derived from thelastActiongrid attribute — see Charge is per-player. - Do not build a scan-and-decide loop on list output. Listing is for discovering IDs; commit only after a single-entity read. For galaxy-wide numeric scans use the Guild Stack, where grid attributes are already projected into columns — see database-schema.md.
Compounding this: grid attributes are uint64 with omitempty, so a zero-valued attribute is absent from the JSON entirely. Treat a missing key as 0 rather than as an error or an unsupported field.
- The
rateinfix. Weapon shot-success fields areprimaryWeaponShotSuccessRate{Numerator,Denominator}/secondaryWeaponShotSuccessRate{Numerator,Denominator}— noteRatein the middle. Mis-keying asprimaryWeaponShotSuccess{Numerator,Denominator}readsundefined/null and silently zeroes your combat math. Verified inproto/structs/structs/struct.proto(fields 22/23, 34/35). - Casing varies by source. The chain LCD/CLI proto JSON uses camelCase (
primaryWeaponShotSuccessRateNumerator). The Guild Stack PostgreSQL / Guild API uses snake_case column names (primary_weapon_shot_success_rate_numerator). Key your parser to the source you are reading. - Guaranteed shots is
omitempty.primaryWeaponGuaranteedShots/secondaryWeaponGuaranteedShotsexist onStructType(fields 67/68) but are0for every type except the Starfighter secondary (= 1), and zero values are omitted from JSON. Astruct_typepayload with no guaranteed-shots key does not mean the field was removed — it means the value is 0. See combat.md.
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 = 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.
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.
The guild proxy signup flow (sign GUILD{id}ADDRESS{addr}NONCE0 → POST /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.
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, and the two /api/auth/player-address* onboarding routes |
/api/guild/this |
This guild's metadata |
/api/timestamp |
Server time |
/api/setting |
Live tunables |
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.
Power/grid integrations trip on where the numbers live and how the messages are shaped.
- Power fields live in a
gridAttributessub-object, not the entity wrapper. Forplayer,substation, andreactorLCD entities,capacity,load,structsLoad,connectionCapacity, andconnectionCountare undergridAttributes. There is nocapacitySecondarykey on the wire — secondary capacity is the connected substation’sconnectionCapacity(see energy.md). Top-level wrapper fields are the non-grid ones — reactordefaultCommissionandvalidator, andowneronReactor/Substation/Guild. - LCD numerics are JSON strings (
"1512960000"), including everything ingridAttributes. Parse before doing math — a unit is milliwatts (see energy.md — Units). guildentity carries the power entry points:primaryReactorId(field 9) andentrySubstationId(field 10) — start here to resolve a player's power infrastructure. Verified inproto/structs/structs/guild.proto.- Build-state is LCD-only. The Guild API struct list exposes
location_type,operating_ambit,slot,is_destroyed,type/type_namebut notisBuilt/blockStartBuild— read the chainstructentity (structAttributes) for those (see "Where struct HP and status live" above).
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. |
MsgAllocationUpdate |
creator, allocationId, power |
Only dynamic allocations are updatable. Growing an existing allocation releases its own current power before the capacity check — an increase on a live allocation no longer false-errors as capacity_exceeded (create-vs-update asymmetry, allocation_cache.go SetDynamicPower). |
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.
Two struct_type shape changes in the Guild Stack (structs-pg) that tools read:
generating_rateis a formatted column. The raw chain value now lives ingenerating_rate_p, andgenerating_rateis a generated column equal togenerating_rate_p * 1000(same_p/formatted split as ledger/infusion precision columns — notstructs.grid, which stores a singleval). For the raw per-gram rate the gameplay docs quote (Field Generator 2, Continental Power Plant 5, World Engine 10), readgenerating_rate_p;generating_rateis the scaled representation. Verified in structs-pgdeploy/table-struct-type-20260602-add-generating-rate-precision.sql.- Armour-piercing is explicit booleans.
struct_typeexposesprimary_weapon_armour_piercingandsecondary_weapon_armour_piercing(chainStructType.primaryWeaponArmourPiercing/secondaryWeaponArmourPiercing, structsd v0.18.0) — read these rather than inferring from the struct class. Only the Battleship sets them today. Verified in structs-pgdeploy/table-struct-type-20260612-add-armour-piercing.sql. See combat.md.
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.
Every permission failure renders the same string regardless of which flag was actually checked:
...calling player (1-1) has no administrate permission on object (1-61)...
The word "administrate" is hardcoded — it is a fixed literal passed to NewPermissionError on every denial path, not a report of the bit that failed (verified: PermissionCheck in x/structs/keeper/permissions_context.go passes "administrate" in all four return-error branches). So an "administrate" error does not mean PermAdmin (2) was required. To find the real missing bit, map the failing message handler to its required permission via the handler permission table. Example: a substation-player-migrate denial on a player object is a missing PermSubstationConnection (1024) on that player, not PermAdmin — see energy.md.
- hashing.md — Proof-of-work input format and which block to anchor on
- combat.md — Combat math, ambit reach, guaranteed shots
- api/webapp/struct.md — Struct endpoint response shapes
- api/streaming/event-schemas.md — Event catalog
- reference/api-quick-reference.md — Endpoint quick lookup
- protocols/webapp-api-protocol.md — Envelope and pagination conventions