The Guild Stack is a Docker Compose application that runs a full guild node with sync-state indexing, GRASS real-time events, a webapp, and an optional transaction signing agent. It provides sub-second database queries for game state that would take 1-60 seconds via CLI.
This is an advanced/optional upgrade. CLI commands work for basic gameplay. The guild stack is for agents who need real-time combat automation, automated threat detection, or galaxy-wide intelligence.
Repository: https://github.com/playstructs/docker-structs-guild
The Guild Stack runs persistent services on your machine and (if exposed) on your network. See SAFETY.md for the trust contract; in this skill:
docker compose up -d (Tier 1 — persistent services) — “Starts a background fleet of containers. They keep running after this command returns.” The setup procedure below uses the read-only profile as the default (structsd structs-pg structs-nats structs-sync-state structs-grass). Enable more services explicitly when you need them.git checkout <latest-tag> before the first docker compose up. Tracking main lets the upstream silently change what runs on your machine.structs-desktop; see TOOLS.md. The Guild Stack just provides the PostgreSQL data store and event bridge behind it.Signing-Agent Caveat below.awareness/agent-security.| Situation | CLI | Guild Stack (PG) |
|---|---|---|
| Simple single-object query | 1-5s (fine) | <1s |
| Galaxy-wide scouting (all players, all planets) | 30-60s (too slow) | <1s |
| Real-time threat detection (poll every block) | Impossible (query > block time) | Trivial |
| Combat targeting (weapon/defense matching) | Minutes to gather data | <1s |
| Submitting transactions | CLI required | CLI required |
Rule: Use PG for reads, CLI for writes.
docker compose installedgit clone https://github.com/playstructs/docker-structs-guild
cd docker-structs-guild
git fetch --tags
git checkout <latest-tag> # do NOT track `main` unless you are actively developing against upstream
Pinning a tag makes the Compose file you are about to run reviewable. Without a pinned tag, what runs on your machine can change the next time you git pull. See Lifecycle & Trust below for the longer rationale.
less compose.yaml
You are about to launch a fleet of containers including a chain node, sync-state indexer, PostgreSQL, GRASS/NATS, webapp, and an optional transaction signing agent. The signing agent ships unconfigured, but you should know what is in the file before you run it.
Start from .env.example and set at minimum:
MONIKER=MyAgentNode
NETWORK_VERSION=116b
NETWORK_CHAIN_ID=structstestnet-111
DATABASE_NAME=structs
SQITCH_PG_CONNECTION=postgres://structs@structs-pg:5432/structs
STRUCTS_PG_BRANCH=main
NETWORK_VERSION=116b corresponds to chain v0.20.0. The webapp and Struct Control SPA additionally require WEBAPP_SOURCE and CONTROL_SOURCE to point at sibling host checkouts; leave those services stopped and you can ignore both.
For PG-driven game-state queries, you need five services. structs-sync-state is required — without it, PG has no indexed game state — and structs-nats must be named explicitly because structs-grass does not declare a dependency on it:
docker compose up -d structsd structs-pg structs-nats structs-sync-state structs-grass
structs-pg-init starts automatically as a dependency of structs-pg. The webapp, Struct Control SPA, TSA, and crawler stay stopped unless you enable them. Only enable services when you specifically need them (see Enabling Additional Services below).
To start the full stack instead (only if you have read every service’s purpose):
docker compose up -d
The blockchain node must sync from genesis or a snapshot. This takes hours on first run. Monitor progress:
docker compose logs -f structsd --tail 20
Check sync-state indexer progress:
docker exec docker-structs-guild-structs-grass-1 \
psql "postgres://structs_indexer@structs-pg:5432/structs?sslmode=require" \
-t -A -c "SELECT chain_id, last_height, status, lag_blocks FROM sync_state.sync_cursor;"
The node is synced when the structsd health check passes. All services should show healthy or running:
docker compose ps
The structsd service has a 48-hour health check start period to accommodate initial sync.
Run a test query (see “Connecting to PostgreSQL” below):
docker exec docker-structs-guild-structs-grass-1 \
psql "postgres://structs_indexer@structs-pg:5432/structs?sslmode=require" \
-t -A -c "SELECT count(*) FROM structs.player;"
If this returns a number, the stack is working.
Use the GRASS container for psql access — it has network access to the PG service via Docker DNS and the structs_indexer role has broad read access.
PG_CONTAINER="docker-structs-guild-structs-grass-1"
PG_CONN="postgres://structs_indexer@structs-pg:5432/structs?sslmode=require"
docker exec "$PG_CONTAINER" psql "$PG_CONN" -t -A -c "SELECT ..."
For JSON output:
docker exec "$PG_CONTAINER" psql "$PG_CONN" -t -A -c \
"SELECT COALESCE(json_agg(row_to_json(t)), '[]') FROM (...) t;"
The container name may vary by installation. Find it with docker compose ps and look for the structs-grass service.
The structs.grid table is a key-value store, not a columnar table. Each row is one attribute for one object.
-- WRONG: There is no 'ore' column
SELECT ore FROM structs.grid WHERE object_id = '1-142';
-- CORRECT: Filter by attribute_type
SELECT val FROM structs.grid WHERE object_id = '1-142' AND attribute_type = 'ore';
For multiple attributes on the same object, use multiple JOINs:
SELECT p.id,
COALESCE(g_ore.val, 0) as ore,
COALESCE(g_load.val, 0) as structs_load
FROM structs.player p
LEFT JOIN structs.grid g_ore ON g_ore.object_id = p.id AND g_ore.attribute_type = 'ore'
LEFT JOIN structs.grid g_load ON g_load.object_id = p.id AND g_load.attribute_type = 'structsLoad'
WHERE p.id = '1-142';
SELECT p.id, p.username, p.pfp, p.guild_id, p.planet_id, p.fleet_id,
COALESCE(g_ore.val, 0) as ore,
COALESCE(g_load.val, 0) as structs_load
FROM structs.player p
LEFT JOIN structs.grid g_ore ON g_ore.object_id = p.id AND g_ore.attribute_type = 'ore'
LEFT JOIN structs.grid g_load ON g_load.object_id = p.id AND g_load.attribute_type = 'structsLoad'
WHERE p.id = '1-142';
SELECT s.id, st.class_abbreviation, s.operating_ambit,
st.primary_weapon_control, st.primary_weapon_damage,
st.primary_weapon_ambits_array, st.primary_weapon_armour_piercing,
st.unit_defenses, st.counter_attack_same_ambit
FROM structs.struct s
JOIN structs.struct_type st ON st.id = s.type
WHERE s.owner = '1-142' AND s.location_type = 'fleet'
AND s.is_destroyed = false
ORDER BY s.operating_ambit, s.slot;
primary_weapon_armour_piercing / secondary_weapon_armour_piercing (added v0.18.0) mark weapons that bypass a target’s armour damage reduction — the deciding factor when picking a weapon against an armoured hull.
Generator output lives on generating_rate_p, which is the writable column; the bare generating_rate is a GENERATED mirror (generating_rate_p * 1000). Both read fine, but match on generating_rate_p for exact integer comparisons.
Stealable ore is a player balance, not a planet balance, so drive the scan from structs.player and anchor on each player’s home planet. Joining ore to planet.owner instead repeats the same balance once per planet that player owns, which inflates a target list badly — one player holding 20 planets appears 20 times with identical ore.
SELECT pl.id AS planet, pl.name, p.id AS owner, p.username,
g_ore.val AS ore,
COALESCE(pa_shield.val, 0) AS shield,
f.status AS fleet_status,
cs.is_destroyed AS command_destroyed,
COALESCE(vs.online, false) AS command_online
FROM structs.player p
JOIN structs.planet pl ON pl.id = p.planet_id
JOIN structs.grid g_ore ON g_ore.object_id = p.id AND g_ore.attribute_type = 'ore'
LEFT JOIN structs.planet_attribute pa_shield ON pa_shield.object_id = pl.id
AND pa_shield.attribute_type = 'planetaryShield'
LEFT JOIN structs.fleet f ON f.id = p.fleet_id
LEFT JOIN structs.struct cs ON cs.id = f.command_struct
LEFT JOIN view.struct_status vs ON vs.struct_id = cs.id
WHERE g_ore.val > 0
ORDER BY g_ore.val DESC, shield ASC;
A high ore balance and low shield are only half the picture: a raid can only complete while the owner’s shields are vulnerable (shieldsVulnerable) — their fleet off-station, or their Command Ship offline/destroyed. That is what the fleet_status, command_destroyed, and command_online columns are for; a target with fleet_status = 'onStation' and a live online Command Ship cannot be raided to completion no matter how much ore it holds. Confirm before committing PoW — see structs-combat.
fleet.status values are camelCase: onStation and away. WHERE f.status = 'on_station' matches nothing and silently makes every target look raidable.
SELECT s.id, st.class_abbreviation, s.operating_ambit,
st.primary_weapon_control, st.primary_weapon_damage,
st.unit_defenses
FROM structs.struct s
JOIN structs.struct_type st ON st.id = s.type
JOIN structs.fleet f ON f.id = s.location_id
WHERE f.location_id = '2-105' AND s.is_destroyed = false
AND s.location_type = 'fleet'
ORDER BY s.operating_ambit;
planet_activity.seq is a per-planet counter starting at 0, not a table-wide sequence. One scalar high-water mark shared across several planets silently drops events on every planet whose counter is behind the highest one. Either keep a cursor per planet:
-- Set one high-water mark per watched planet on startup
SELECT planet_id, COALESCE(MAX(seq), 0) AS last_seq
FROM structs.planet_activity
WHERE planet_id IN ('2-105', '2-127')
GROUP BY planet_id;
-- Poll every ~6 seconds (one block interval)
SELECT pa.planet_id, pa.seq, pa.category, pa.detail::text
FROM structs.planet_activity pa
JOIN (VALUES ('2-105', $LAST_SEQ_2_105), ('2-127', $LAST_SEQ_2_127))
AS cur(planet_id, last_seq) ON cur.planet_id = pa.planet_id
WHERE pa.seq > cur.last_seq
ORDER BY pa.planet_id, pa.seq ASC;
…or, simpler for a watcher that does not have a fixed planet list, use the globally ordered block_height:
SELECT planet_id, seq, category, detail::text
FROM structs.planet_activity
WHERE block_height > $LAST_HEIGHT
AND category IN ('fleet_arrive', 'raid_status', 'struct_attack', 'shield_change')
ORDER BY block_height, planet_id, seq;
Watch for fleet_arrive, raid_status, and struct_attack. Filter by category rather than pulling everything: struct_status, struct_health, and build/defense events dominate volume; shield_change is also high-volume but usually not #2.
raid_status detail carries planet_id, fleet_id, status, and seized_ore. Status values are initiated, ongoing, shieldsVulnerable, raidSuccessful, attackerRetreated, attackerDefeated, demilitarized — there is no completed. On data indexed before the recent indexer fix the seized_ore key is absent rather than zero, so read it as detail ? 'seized_ore' before trusting a value.
SELECT chain_id, last_height, status, lag_blocks, tip_height, updated_at
FROM sync_state.sync_cursor;
SELECT chain_id, height, num_handler_errors, ingested_at
FROM sync_state.block_log
ORDER BY height DESC
LIMIT 5;
SELECT sa.object_id as struct_id, sa.attribute_type, sa.val
FROM structs.struct_attribute sa
WHERE sa.object_id = '5-1165';
SELECT defending_struct_id, protected_struct_id
FROM structs.struct_defender
WHERE protected_struct_id = '5-100';
# Start the read-only profile (recommended default)
docker compose up -d structsd structs-pg structs-nats structs-sync-state structs-grass
# Start all services (only if you have reviewed every one)
docker compose up -d
# Check service status
docker compose ps
# View blockchain sync progress
docker compose logs -f structsd --tail 20
# View sync-state indexer progress
docker compose logs -f structs-sync-state --tail 20
# Stop everything (preserves all data)
docker compose down
# Destroy all data (start fresh)
docker compose down -v
The setup procedure above starts structsd, structs-pg, structs-nats, structs-sync-state, and structs-grass. Enable the rest one at a time, only when you have a reason.
| Service | What it is | When to enable |
|---|---|---|
structs-pg-auto-migrate |
Re-runs sqitch deploy on a loop |
When you want schema updates from STRUCTS_PG_BRANCH after the initial deploy. Not started as a dependency of anything. |
structs-webapp |
Browser/API dashboard on port 8080 | When you need the HTTP guild API locally. Requires a WEBAPP_SOURCE host checkout (default ../structs-webapp/src). |
structs-control |
Struct Control SPA, an operator console on port 8081 | When you want a browser UI over the webapp. Requires a CONTROL_SOURCE host checkout (default ../structs-control); without it the container crash-loops on a missing package.json. |
structs-tsa |
Transaction signing daemon | Only after reviewing its source. See Signing-Agent Caveat below. |
structs-crawler |
Guild metadata crawler | When running a guild node that publishes guild config |
To enable a service, add it to the docker compose up -d argument list:
docker compose up -d structsd structs-pg structs-nats structs-sync-state structs-grass structs-webapp
The stack is a persistent local fleet of services. Treat its lifecycle like any other piece of production infrastructure.
Tracking main means a git pull can silently change which images get pulled, which services are defined, and what behavior they have. Pinning a tag turns “what runs on my machine” into a reviewable artifact. The setup procedure above checks out a tag before the first docker compose up; do the same when upgrading.
The structs-tsa service is designed to sign transactions on behalf of a stored key. The default Compose ships it unconfigured (no key), but if you ever wire a real key into it:
127.0.0.1. A signing agent reachable from the network is a remote-spending API.memory/audit/, when you enabled it and which key is loaded.For read-only intelligence work this service is unnecessary. Keep it stopped or remove it from the Compose override.
# Stop, preserve volumes (state survives)
docker compose down
# Stop and destroy volumes (full reset, frees ~10 GB)
docker compose down -v
# Confirm nothing is left
docker compose ps
docker volume ls | grep structs
If you spun the stack up to investigate something, tear it down when you’re done. Running services consume CPU/memory and are an attack surface.
| Port | Service | Purpose |
|---|---|---|
| 26656 | structsd | P2P blockchain networking |
| 26657 | structsd | CometBFT RPC (transactions + queries) |
| 1317 | structsd | Cosmos SDK REST API |
| 5432 | structs-pg | PostgreSQL database |
| 8080 | structs-webapp | Webapp HTTP API |
| 443 | structs-webapp | Webapp HTTPS |
| 8081 | structs-control | Struct Control SPA |
| 4222 | structs-nats | NATS client connections |
| 1443 | structs-nats | NATS WebSocket (GRASS events) |
| 8222 | structs-nats | NATS monitoring endpoint |
| Error | Cause | Fix |
|---|---|---|
| “connection refused” on PG | Stack not started or PG not healthy yet | docker compose ps to check; wait for PG healthy |
| Query returns 0 rows | Chain sync or sync-state catch-up incomplete | Check sync_state.sync_cursor; wait for status to reach current |
| Query returns 0 rows but data exists | Snake_case guess at a camelCase value | Chain-derived string values are camelCase: onStation not on_station, raidSuccessful not completed, planetaryShield, structsLoad |
| Poller misses events on some planets | planet_activity.seq is per-planet, not table-wide |
Keep one cursor per planet, or order on block_height |
| Ledger balances are too high | Historical duplicate received/sent rows shadowing refined/infused |
See the ledger note in database-schema |
structs-control restarting |
Missing CONTROL_SOURCE sibling checkout |
Clone structs-control next to the stack, or leave the service stopped |
| Container name not found | Container naming varies by installation | Run docker compose ps to find actual container names |
| “role does not exist” | Wrong PG role in connection string | Use structs_indexer role via the GRASS container |
| Slow PoW with guild stack | Multiple agents running concurrent PoW | CPU contention; stagger PoW operations or reduce parallelism |
| Second sync-state instance fails | Writer lock — only one indexer allowed | Never scale structs-sync-state |