A custom balloon-runner tool for the GEHACK programming contest. Replaces DOMjudge's built-in balloon tool with two features it doesn't have: a first-solve highlight and printed tickets (IPP or thermal/ESC-POS), with a per-ticket QR code that lets a runner mark the balloon delivered from their phone.
DOMjudge ──► event-feed ──► Hub ──► gRPC stream ──► Web UI (operator)
│
├──► Printer (IPP / ESC-POS)
│ └─ Typst ticket w/ QR
│
└──► /scan?id=<n> ◄── runner's phone (scan)
- Single binary, single port. Go server on
:8080serves both the connectRPC API and the static frontend. - Real-time updates. A long-lived event-feed connection to DOMjudge triggers refreshes; subscribers get diff events (
ADDED/UPDATED/FREEZE) over a server-streaming RPC. - First-solve flag, derived from
/balloonsitself (DOMjudge's/awardsendpoint is empty during a live contest). - Group filters: hide balloons for certain DOMjudge groups (
HIDE_GROUP_IDS) and / or strip the first-solve flag for company / sponsor teams (NO_FIRST_SOLVE_GROUP_IDS). - Pluggable printer subsystem:
escpos(Typst → PNG → 1-bit raster over TCP to a thermal printer at port 9100) oripp(Typst → PDF → IPP). Reprint dedupe is backed by a local SQLite store, so restarts don't reprint already-printed tickets. - Scan-to-deliver flow: every printed ticket carries a QR linking to
/scan?id=<n>. Scanning shows "Delivered ✓" with a 5-second Undo timer, then commits aMarkDoneto DOMjudge. The "undo before commit" model is deliberate — DOMjudge'sdoneis one-way. - Freeze-aware UI: scoreboard freeze state is broadcast as a dedicated event and reflected in the operator view.
proto/balloons/v1/ schema (single file)
gen/ generated Go (gitignored)
web/src/gen/ generated TS (gitignored)
cmd/server/ main.go — env config + http.Server + hub.Run()
internal/domjudge/ REST + event-feed client; mirrors DOMjudge JSON shapes
internal/server/ connectRPC handlers + Hub
internal/printer/ Printer interface + IPP / ESCPOS impls
internal/state/ SQLite-backed ticket-state store (printed_at, delivered_at)
internal/config/ tiny env-var helpers
templates/ Typst ticket template (single file, themed by --input)
web/ Tailwind v4 + esbuild + connect-web frontend
web/scan.html stand-alone runner-facing scan page
Everything — Go, buf, the protoc plugins, Node, and Typst — is pinned in shell.nix:
nix-shell(Or wrap one-off commands with nix-shell --run "..." if you'd rather not enter the shell.)
Copy .env.example to .env and fill in the four required DOMjudge fields:
cp .env.example .env
$EDITOR .envRequired:
| Variable | Description |
|---|---|
DOMJUDGE_URL |
Base URL of the DOMjudge API, e.g. https://judge.example.com |
DOMJUDGE_USER |
API user with balloon access |
DOMJUDGE_PASS |
API password |
DOMJUDGE_CONTEST_ID |
Numeric contest id |
Optional — see .env.example for the full annotated list. The most commonly tweaked:
| Variable | Default | Purpose |
|---|---|---|
ADDR |
:8080 |
Listen address |
HIDE_GROUP_IDS |
— | CSV of group ids whose balloons disappear entirely |
NO_FIRST_SOLVE_GROUP_IDS |
— | CSV of group ids whose teams get balloons but never the first-solve flag |
PRINTER_KIND |
escpos |
escpos or ipp |
PRINTER_IPP_URI |
— | Full ipp://host:port/queue URI (required for ipp) |
PRINTER_ESCPOS_ADDR |
— | host:port of the thermal printer's raw socket (required for escpos) |
PRINTER_ESCPOS_WIDTH |
576 |
Head width in dots (576 = 80mm @ 203dpi; 384 for 58mm printers) |
PRINTER_TEMPLATE |
templates/balloon.typ |
Typst template path. The same file handles both themes — the printer driver passes --input theme=color (IPP) or --input theme=thermal (ESC/POS) |
STATE_DB |
balloons.db |
SQLite file tracking printed_at / delivered_at |
CONTEST_TZ |
time.Local |
IANA timezone (e.g. Europe/Amsterdam) used to render the ticket datetime. Set explicitly when the server runs in a container/systemd unit that inherits UTC. |
SCAN_BASE_URL |
http://<hostname><ADDR> |
Public base URL used to build the per-ticket QR code |
just bootstrap # npm install + buf generate + build the frontendjust run # loads .env automatically (set dotenv-load is on)Open http://localhost:8080 for the operator view. The UI opens a streaming RPC and renders straight from event deltas, so balloons appear within one DOMjudge round-trip.
just lists everything:
| Recipe | Purpose |
|---|---|
just bootstrap |
First-time setup (deps + codegen + web build) |
just gen |
Regenerate Go + TS from balloons.proto (run after editing the schema) |
just build-web |
Build the frontend bundle once |
just watch |
Rebuild CSS + JS on change |
just run |
Run the server |
just build |
Build everything for release (bin/server + web/dist/*) |
just fmt |
Format protobuf and Go |
just lint |
buf lint on the protos |
just vet |
go vet ./... |
just tidy |
go mod tidy |
just clean |
Wipe generated + built artifacts |
If a printed ticket gets lost in transit, use the Reprint button in the UI: it clears the local printed_at row and re-dispatches the print goroutine.
The Hub is the cache + fan-out:
Run(ctx)does an initialrefresh()then spawnsrunEventFeed()and serializes subsequent refreshes off a bufferedtriggerchannel.refresh()fetches/balloons,/teams,/state, and/problemsfrom DOMjudge, applies group filters, computes first-solve set, builds asnapshot, diffs it against the existing in-memory state, and broadcasts events to subscribers. Per-team delivery / in-delivery sets and the full problem-label strip are precomputed in the snapshot so dispatched print goroutines don't need the hub lock.runEventFeed()holds a long-lived NDJSON read on/api/v4/contests/{cid}/event-feed?stream=true&types=judgements,balloons,stateand callsTriggerRefresh()on every line. Reconnect with exponential backoff up to 30s.Subscribe()returns a snapshot + a buffered channel. Slow subscribers get force-closed and will reconnect into a fresh snapshot.MarkDone(id)POSTs to DOMjudge, records local delivery, thenTriggerRefresh()so the UI sees the change in one round-trip instead of waiting for the next event-feed tick.
Printer { Print(ctx, Ticket) error } with two implementations:
- IPP — renders
templates/balloon.typwith--input theme=colorto PDF via thetypstCLI, then submits it to an IPP queue viaphin1x/go-ipp. - ESCPOS (default) — renders
templates/balloon.typwith--input theme=thermalto a PNG at 2× supersampling, area-filters down to the configured dot width, converts each pixel to 1-bit using a chroma-aware ink-density pass (saturated colors → solid black; near-grayscale → Floyd-Steinberg), then streams the result asGS v 0raster chunks plus a partial-cut over a TCP raw socket (typically port 9100). The Typst page width is derived aswidth / 203dpiand passed to the template via--input page_width_mm=..., so adjustingPRINTER_ESCPOS_WIDTHdoesn't require template edits.
Reprints are gated by the state store (internal/state, a small SQLite table): Hub.print calls Store.IsPrinted(id) before printing and Store.RecordPrinted(id) after. Restarting the server never reprints already-printed balloons, and concurrent refreshes can't double-print. Deleting STATE_DB resets the dedupe.
Every printed ticket carries a QR encoding <SCAN_BASE_URL>/scan?id=<n>. web/scan.html is intentionally a stand-alone page (no shared bundle, no connect-web SDK) so a phone with weak signal can load it fast — it calls ListBalloons and MarkDone directly as plain Connect-protocol JSON POSTs (fetch("/balloons.v1.BalloonService/MarkDone", ...)).
On scan:
- The page shows Delivered ✓ immediately and starts a 5-second countdown.
- If the runner taps Undo within 5s, nothing is sent.
- Otherwise
MarkDonefires once the timer expires.
The 5-second buffer matters because DOMjudge's done is one-way (no unmark in the API) — once we POST the mark, we can't take it back, so the only honest "undo" is "cancel before we commit." A balloon that's already done at scan time shows Already delivered with no countdown.
If SCAN_BASE_URL is unset, it falls back to http://<os.Hostname()><ADDR> — useful on a contest LAN where runner phones can reach the server by hostname. Set it explicitly behind a reverse proxy or when phones can't resolve the host.
Single HTML page + bundled TS (web/src/main.ts), Tailwind v4 via the @tailwindcss/cli, bundled with esbuild. Opens a server-streaming StreamBalloons RPC and renders straight from event deltas (no ListBalloons call in the happy path). On stream error it reconnects with the same exponential backoff. State is a Map<string, Balloon> keyed by id-as-string (connect-protocol JSON encodes int64 as a string).
These cost time to figure out, so they're documented here:
- First-solve must be derived from
/balloons./awardsis empty during a live contest. Per problem, the balloon with the earliesttimeis the first solve. Thetimefield is a fixed-width seconds.nanoseconds string, so lexical compare is correct. Teams inNO_FIRST_SOLVE_GROUP_IDSare skipped — if a sponsor team solves first, the next eligible team gets the flag. - Team groups come from
/teams, not/balloons. The balloon JSON hascategoryid: nulleven when the team has a category. Group filters match if any of a team'sgroup_idsis in the filter set. doneis one-way. DOMjudge only exposesPOST /balloons/{id}/done; there's no unmark endpoint. The 5-second "undo" on the scan page is "cancel before we commit," not a reversal.teamis"{label}: {name}". DOMjudge prepends the team's label (number or string) to the display name; stripped server-side intoProtowith^\S+:\s+.- Event-feed events are triggers, not deltas. The code treats any event as "something changed, refetch and diff." Don't try to interpret event payloads —
/balloons,/teams, and/stateare canonical. - Freeze detection comes from
/state. Scoreboard freeze is active whenfrozen != null && thawed == null. The Hub broadcasts aKIND_FREEZEevent on transitions and on every new subscription so reloads pick up the current state.
proto/balloons/v1/balloons.proto is deliberately minimal: 6 fields on Balloon (id, problem_label, problem_rgb, team_name, done, first_solve). StreamBalloonsResponse carries a Kind (ADDED / UPDATED / FREEZE) plus an optional balloon and a frozen bool used only on KIND_FREEZE. The server holds more DOMjudge data internally but doesn't put it on the wire. Add fields when a consumer needs them — not preemptively.
Generated code (gen/ for Go, web/src/gen/ for TS) is gitignored — always run just gen (or buf generate) after editing the proto.
| RPC | Direction | Purpose |
|---|---|---|
ListBalloons |
unary | Full snapshot, used by scan.html and on cold reconnects |
StreamBalloons |
server-stream | Snapshot + live diff events |
MarkDone |
unary | Mark a balloon delivered (proxies to DOMjudge) |
Reprint |
unary | Re-dispatch a print without waiting for a fresh submission |
Internal contest tool. Adapt freely for your own event.