Architecture
What This Is
Goblin Shark is the build service in a small family of services around Pogona, Joel Holmes’s world-authoring tool for text/CYOA-style games:
- Pogona β where a game’s
Game,Scene,Option, andItemrecords are authored. - Goblin Shark (this repo) β compiles a Pogona world definition into a packaged, versioned game file, stores it, and announces the build.
- Ibis β the catalog that indexes built games after they’re announced.
- Rook β an external chess engine/move-history service the chess client talks to (not part of the build pipeline).
Beyond the build service, this repo also hosts the Go/WebAssembly game clients that actually play the compiled files in a browser (via Ebiten), plus a couple of standalone game prototypes (tiny-battle, sprite/tileset viewers) that share the same rendering stack but aren’t wired into the build pipeline.
High-Level Topology
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Client Applications β
β (Pogona editor, ConnectRPC clients, browser) β
βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββ
β
ββββββββββββΌβββββββββββ
β API Server :9000 β
β BuildService β
β (Connect RPC h2c) β
ββββββββββββ¬βββββββββββ
β
ββββββββββββββββββΌβββββββββββββββββ¬ββββββββββββββ
β β β β
ββββββΌβββββββ βββββββΌβββββ ββββββββΌβββ ββββββββΌββββ
β Compilers β βPostgreSQLβ β Blob β β Kafka β
β CYOA v1 β β(game_ β β Storage β β(game. β
β Labyrinth β β records) β β(S3/MinIOβ β built, β
β v1(βv2) β β β β ) β β opt-in) β
β Chess v1 β ββββββββββββ βββββββββββ ββββββββββββ
βββββββββββββ
Separate binaries:
Browser β Web UI Server :8000 (go-app WASM shell for the CYOA text player)
Browser β Static WASM game runners (Ebiten), built by `make build-wasm-*`
and uploaded to a *second* MinIO bucket (`game-runners`)
Terminal β text-adventure-cli (stdin/stdout)
Note the two distinct uses of blob storage: BLOB_BUCKET_URL is where the
API server writes compiled game files (one object per build, keyed by
UUID); the game-runners MinIO bucket referenced by the Makefile’s
upload-wasm-* targets is a separate, manually-published bucket holding the
static Ebiten/WASM binaries that render those game files in a browser.
The build service itself never touches the game-runners bucket.
Binary Entrypoints
cmd/api/main.go β API Server
Serves a ConnectRPC BuildService on :9000 over HTTP/2 h2c (behind a
permissive CORS policy).
Responsibilities:
- Accept
BuildCYOAV1,BuildLabyrinthV1, andBuildChessV1requests: compile, upload to blob storage, persist aGameRecord, and (if the caller setspublish = true) publish aGameBuiltevent to Kafka - Serve
ListGameRecordsandGetGameRecordqueries - Serve
/healthand/info(service name, commit, build time)
Dependencies:
- Blob storage (required) β bucket URL via
BLOB_BUCKET_URL(opens viagocloud.dev/blob, registered with thes3blobdriver β works against both AWS S3 and an S3-compatible endpoint like MinIO) - PostgreSQL (required) β connection string via
DATABASE_URL - Kafka (optional) β brokers via
KAFKA_BROKERS; if unset, builds still succeed but noGameBuiltevents are published and a warning is logged - OTel tracing/metrics via
github.com/holmes89/archaea/telemetryβ a graceful no-op ifOTEL_EXPORTER_OTLP_ENDPOINTis unset
Builds are not announced by default: publish on each request defaults
to false so test/preview builds don’t show up in the Ibis catalog. Only
requests that explicitly set publish = true trigger a Kafka publish.
cmd/server/main.go β Web UI Server
Serves a go-app WebAssembly application on :8000
that hosts the CYOA text-adventure player in-browser.
Routes:
/goblin-shark/pogona/v1 β CYOA V1 game player UI (go-app WASM shell)
/goblin-shark/assets/* β Static go-app assets
/goblin-shark/files/* β Static file downloads (./static)
This is distinct from the Ebiten-based WASM runners described below β this binary renders the simpler text-only CYOA experience, not the graphical labyrinth/chess/tiny-battle clients.
cmd/text-adventure-cli/main.go β Text Adventure CLI
Reads an embedded protobuf-encoded CYOAGameFile (sample.proto, embedded
via go:embed) and provides interactive text-based gameplay through
lib/cya.
Input: stdin (numeric selection, i for inventory, q to quit)
Output: stdout (scene text, option list)
WASM game runners (examples/*, built via Makefile/Dockerfile.wasm)
Each of examples/text-adventure, examples/labyrinth, examples/chess,
examples/chess-replay, and examples/sprite-preview builds to
GOOS=js GOARCH=wasm and is uploaded via mc cp to
minio/game-runners/<game-type>/runner.wasm. These are the actual clients a
browser loads to play a compiled game file β they use
Ebiten for rendering rather than go-app. Dockerfile.wasm
builds all four core runners (cyoa-v1, labyrinth-v1, chess-v1,
chess-replay-v1) into a scratch image for CI/deploy.
Layer Structure
lib/
goblinshark/build/
kafka.go β KafkaPublisher (wraps archaea/kafka producer)
grpc/
service.go β BuildService Connect RPC handler
(BuildCYOAV1, BuildLabyrinthV1, BuildChessV1,
ListGameRecords, GetGameRecord)
compiler/
cyoa/v1/compiler.go β Pogona Game+Scenes+Items β CYOAGameFile
labyrinth/v1/compiler.go β hero/monster rosters + complexity β LabyrinthGameFile
(maze itself is generated at play time from
LabyrinthState.maze_seed, not at compile time)
chess/v1/compiler.go β name + initial FEN β ChessGameFile
repo/
conn.go β Database connection + goose migrations (embedded)
game_record.go β game_records CRUD (raw SQL via database/sql)
migrations/ β goose SQL migrations
cya/ β CYOA V1 runtime: state machine, engine, text
and Ebiten renderers, go-app web player
labyrinth/, labyrinthroster/ β Labyrinth v1 runtime, character rosters, sprites
labyrinth-online/ β WebRTC-networked labyrinth (multiplayer signaling,
fog of war, shared map) β client-side, not part
of the build/compile pipeline
board/, piece/, core/chess/ β Chess board model and rendering
tinybattle/ β Standalone tiny-battle prototype (not built via
BuildService)
engine/openworld/ β Shared open-world/grid movement engine
schemas/
goblinshark/v1/ β Generated: CYOAGameFile, LabyrinthGameFile,
ChessGameFile, GameRecord, GameBuilt,
BuildService (this repo owns these)
rook/v1/ β Generated client stubs mirroring the external
`rook` chess-engine service's proto (move
history + engine "next move" RPC)
schemas/
goblinshark/v1/*.proto β Source protos generated into lib/schemas/goblinshark/v1
rook/v1/*.proto β Source protos generated into lib/schemas/rook/v1
pogona/v1/*.proto, ibis/v1/*.proto
β Vendored copies of the Pogona and Ibis schemas,
kept here for reference/sync. NOT generated
into lib/schemas β their go_package points at
the pogona and ibis repos respectively. The
actual Go types the compilers use come from
the external module github.com/holmes89/pogona.
External Dependencies
| Dependency | Purpose | Config |
|---|---|---|
| PostgreSQL | game_records build/version tracking | DATABASE_URL |
| S3 / MinIO | Compiled game file storage (BLOB_BUCKET_URL) and, separately, static WASM game-runner hosting (game-runners bucket, published manually via make upload-wasm-*) | BLOB_BUCKET_URL |
| Kafka / Redpanda | GameBuilt event publishing (opt-in per request) | KAFKA_BROKERS |
github.com/holmes89/pogona | Go module providing the authoritative Game/Scene/Option/Item types the CYOA compiler consumes | go.mod dependency |
github.com/holmes89/archaea | Shared telemetry + Kafka producer/consumer helpers | go.mod dependency |
Event Flow: Game Build
Each of the three build RPCs follows the same shape:
1. Client β API: Build{CYOA,Labyrinth,Chess}V1Request
2. API β Compiler: validate + compile β {CYOA,Labyrinth,Chess}GameFile
3. API β Blob: WriteAll("goblin-shark/{cyoa|labyrinth|chess}/v1/{game_uuid}",
proto.Marshal(GameFile))
4. API β PostgreSQL: INSERT game_records (uuid, name, game_type, path,
source_game_uuid, version, built_at, start_scene_uuid)
Version = MAX(version)+1 for this source_game_uuid, computed inside a
transaction (CYOA builds key off the Pogona game UUID; Labyrinth/Chess
builds β which have no Pogona source β use the new game_uuid as its own
source_game_uuid, so every build gets version 1)
5. If request.publish == true and a Kafka publisher is configured:
API β Kafka: Publish("game.built" topic, GameBuilt{game_uuid, game_type,
path, name, built_at, start_scene_uuid})
6. API β Client: Build*V1Response(game_uuid, path, built_at, version*)
(*CYOA responses also include version and start_scene_uuid; Labyrinth
and Chess responses do not)
Note the current game_type constants embedded in the API code:
cyoa-v1, labyrinth-v2, chess-v1. Labyrinth builds are stamped
labyrinth-v2 even though the compiler package is labyrinth/v1 β v2 added
real sprite-sheet rendering (see LabyrinthCharacter’s sprite_* fields)
without a wire-format break, so the existing compiler package was kept and
only the emitted game_type changed. Already-published labyrinth-v1
records keep playing unchanged against the v1 WASM runner.
Compilation Algorithms
CYOA V1 (lib/compiler/cyoa/v1)
Input: Game{name, start_scene}, Scene[]{uuid, options, scene_type}, Item[]
1. Assert game != nil, game.name != "", game.start_scene != ""
2. Assert game.start_scene β {s.uuid for s in scenes}
3. For each scene β option β action:
if action.type == ACTION_TYPE_MOVE:
assert action.result β {s.uuid for s in scenes}
4. Clone the start scene and stamp it SCENE_TYPE_START (input scenes come
from the DB as SCENE_TYPE_UNSPECIFIED; the runtime engine needs the
start scene tagged after round-tripping through the binary format)
Output: CYOAGameFile{game_uuid: uuid.New(), name, start_scene_uuid, scenes, items}
Labyrinth V1βV2 (lib/compiler/labyrinth/v1)
Pure validation/packaging β the maze layout itself is generated at play time
from LabyrinthState.maze_seed, not by this compiler.
Input: name, hero_roster[], monster_roster[], complexity, items[]
1. Assert name != ""
2. Assert len(hero_roster) > 0, len(monster_roster) > 0
3. Assert complexity != LABYRINTH_COMPLEXITY_UNSPECIFIED
4. Validate every character in both rosters (validateCharacter)
Output: LabyrinthGameFile{game_uuid: uuid.New(), name, hero_roster,
monster_roster, complexity, items}
Chess V1 (lib/compiler/chess/v1)
Input: name, initial_fen
1. Assert name != ""
(initial_fen is NOT validated here β an empty string is accepted,
presumably defaulting to the standard starting position downstream)
Output: ChessGameFile{game_uuid: uuid.New(), name, initial_fen}
Database
lib/repo/conn.go opens a *sql.DB (via github.com/lib/pq) and applies
embedded goose migrations on startup:
00000001_game_records.up.sqlβ createsgame_records(uuid, name, game_type, path, source_game_uuid, version, built_at)00000002_game_records_start_scene.up.sqlβ addsstart_scene_uuid
game_record.go uses raw parameterized database/sql queries (not a query
builder) for insert/select β the version-increment happens inside a
transaction that computes MAX(version)+1 for the given source_game_uuid
before inserting.