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, and Item records 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, and BuildChessV1 requests: compile, upload to blob storage, persist a GameRecord, and (if the caller sets publish = true) publish a GameBuilt event to Kafka
  • Serve ListGameRecords and GetGameRecord queries
  • Serve /health and /info (service name, commit, build time)

Dependencies:

  • Blob storage (required) β€” bucket URL via BLOB_BUCKET_URL (opens via gocloud.dev/blob, registered with the s3blob driver β€” 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 no GameBuilt events are published and a warning is logged
  • OTel tracing/metrics via github.com/holmes89/archaea/telemetry β€” a graceful no-op if OTEL_EXPORTER_OTLP_ENDPOINT is 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

DependencyPurposeConfig
PostgreSQLgame_records build/version trackingDATABASE_URL
S3 / MinIOCompiled 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 / RedpandaGameBuilt event publishing (opt-in per request)KAFKA_BROKERS
github.com/holmes89/pogonaGo module providing the authoritative Game/Scene/Option/Item types the CYOA compiler consumesgo.mod dependency
github.com/holmes89/archaeaShared telemetry + Kafka producer/consumer helpersgo.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 β€” creates game_records (uuid, name, game_type, path, source_game_uuid, version, built_at)
  • 00000002_game_records_start_scene.up.sql β€” adds start_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.