Transmission #066: WebSocket event loop — achievements over AsyncAPI
Helerion’s gameplay event path used to be a dormant REST stub: POST /characters/{id}/events in openapi.yaml with no real server handler and no Unity client. That was fine for scaffolding, but achievement triggers need server-initiated updates — unlock ids pushed after the DB row exists — not a request/response poll loop.
This Transmission records a Prompt-Driven Development pass that replaced that stub with a spec-first WebSocket channel on the same port as Jersey REST. v1 scope: evaluate HARVEST / KILL / SKILL events, persist achievement progress in PostgreSQL, push achievement_ids only over the socket, and merge locally in Unity with lazy GET /achievements/{id} for display names. Full planning artifacts live under .agents/planning/2026-06-10-websocket-event-loop/.
Why this shape
| Choice | Rationale |
|---|---|
| AsyncAPI at repo root | Mirrors openapi.yaml: one contract, two codegen pipelines (Java Tyrus + Unity C#). |
| Tyrus on shared Grizzly | Same GlassFish ecosystem as Jersey; single listener on JERSEY_PORT (default 8080). Javalin sidecar rejected. |
| JSON envelope v1 | {"v":1,"type":"…","payload":{…}} — debuggable today; Protobuf can swap the codec later without changing handler interfaces. |
| Custom asyncapi-generator | No off-the-shelf Tyrus template; we generate CharacterEventsApi + @ServerEndpoint adapter like OpenAPI → JAX-RS. |
Id-only eventResult |
WS stays a notification buffer; Unity merges into HydratedCharacter.achievements and fetches catalog detail once per new id. |
| NativeWebSocket (UPM) | Coroutine-friendly Unity client with main-thread dispatch; outbound queue while offline. |
Architecture
flowchart LR
U[Unity client]
G[Grizzly :8080]
J[Jersey /v1 REST]
T[Tyrus WebSocket]
A[CharacterEventsApiImpl]
E[AchievementEngine]
P[(PostgreSQL)]
U -->|HTTP OpenAPI| J
U -->|WS AsyncAPI envelope| T
J --> P
T --> A
A --> E
E --> P
A -->|eventResult| T
Tyrus registers a WebSocketAddOn on Jersey’s existing Grizzly NetworkListener so upgrades are handled before the HTTP filter chain:
flowchart TB
subgraph listener["Grizzly NetworkListener"]
WS[Tyrus WebSocketAddOn]
HTTP[Jersey /v1]
end
REQ[TCP :8080] --> WS
WS -->|HTTP| HTTP
WS -->|Upgrade| EP["/v1/characters/{id}/events"]
EP --> API[CharacterEventsApiImpl]
End-to-end lifecycle (verified in Play Mode — chop a tree, see unlock):
sequenceDiagram
participant CS as CharacterSession
participant EB as HelerionEventBridge
participant ES as HelerionEventSession
participant S as Server
participant PB as HelerionEventProgressionBridge
CS->>CS: SessionReady
ES->>S: WS connect
Note over EB: Player kills harvestable
EB->>ES: reportEvents HARVEST
ES->>S: JSON envelope
S->>S: AchievementEngine + persist
S-->>ES: eventResult achievement_ids
ES->>PB: EventResultReceived
PB->>PB: ProgressionStateMerger
PB->>S: GET /achievements/id (new ids only)
Wire format (v1)
Channel: ws(s)://host/v1/characters/{characterId}/events
{
"v": 1,
"type": "reportEvents",
"payload": {
"events": [{ "type": "HARVEST", "quantity": 1 }]
}
}
Server replies with type: "eventResult" and payload.achievement_ids: [1, …], or type: "error" for invalid events (socket stays open).
Try it locally
Server (from server/):
./gradlew shadowJar
make integration-environment-setup # Postgres + JAR on :8080
# or: DB_* + JERSEY_PORT=8080 ./gradlew run
curl -s http://localhost:8080/v1/items | head
Unity: set HelerionGameData.apiBaseUrl to http://localhost:8080, enter Play Mode, create/select a hero, punch a harvestable tree. Console should show [HelerionEventSession] eventResult received and [HelerionEventProgression] unlocked "First Harvest".
Integration test: ./gradlew integrationTest — CharacterEventsWsIntegrationTest covers REST create → WS → DB row.
What shipped (Steps 1–12)
All checklist items in implementation/plan.md are complete.
- Contract — repo-root
asyncapi.yaml; GradleasyncApiValidate. - Models — Modelina Java + C#;
scripts/regenerate-helerion-events-client.sh. - Codec — hand-written
JsonWireCodec+EnvelopeCodecTest. - Persistence — Flyway
player_achievement;PlayerAchievementRepository. - Engine —
AchievementEngine+ trigger JSONB; unit tests. - Codegen —
server/asyncapi-generator/→ Tyrus stubs + UnityICharacterEventsApi. - Tyrus bootstrap — shared Grizzly;
SessionRegistry; Dagger wiring. - Handler —
CharacterEventsApiImplpersist-before-push; WS integration test. - OpenAPI cleanup — removed HTTP
POST …/events; regen REST clients. - Unity session —
HelerionEventSession(connect, reconnect, queue, dispatch). - Progression merge —
ProgressionStateMerger,AchievementDetailCache,HelerionEventProgressionBridge. - Gameplay bridge —
HelerionEventBridgefrom harvest / kill / skill hooks.
Unity notes
HelerionEventSessionwaits onOnOpen, notConnect()task completion (NativeWebSocket keeps the task alive for the session).- Modelina + Unity: post-regen patch rewrites
dynamic→objectin generated models (apply-unity-metas-helerion-events.py). - Components on
HelerionServer:HelerionGameData,CharacterSession,HelerionEventSession,HelerionEventProgressionBridge.
Out of scope (v1)
Protobuf on the wire, WS auth tokens, server replay, achievement item drops over WS, full progression deltas (quests/stats/items) — reserved in AsyncAPI as phase C.
Planning chronicle
Earlier PDD steps (research, Q1–Q12 requirements, detailed design) are unchanged in spirit; see research/synthesis.md and design/detailed-design.md for the full decision log. Research initially leaned Javalin sidecar; implementation landed Tyrus on Grizzly after owner review.