The Companion App talks to one service: api-server, a Spring Boot module of its own next to login-server and zone-server. It serves a REST API (JSON) and a WebSocket, and it never touches the game’s database. Every question about the world goes to the zone over a message bus, and the zone answers it. The game client gets its map here too: the api-server is the world’s one HTTP surface, and the zone runs no web server.

Why a separate service

  • A web fault must not stop the game. A crash, a slow phone or an attack on the API touches no tick.
  • There will be several zone nodes. One service looks up which node holds an account; the nodes do not each run the same web code.
  • The game owns its data. The api-server has no read access to the zone’s tables. A web tier that reads a game’s tables breaks quietly with every update of the game, so it asks instead, and the zone answers from its repositories and its live world.

This service is not the gateway role. That role owns the game socket. The api-server owns HTTP and WebSocket for the app. They share no process.

Shape

graph TB
  A["Companion app<br/>PWA · phone"] -->|"/api/v1/auth/companion"| L["<b>login-server</b><br/>passkeys · companion token"]
  A -->|"/v1/companion · REST + WebSocket"| API["<b>api-server</b><br/>auth · cache · push"]
  G["Game client"] -->|"map and emblems, with its socket's ticket"| API
  L -->|"kick orders"| V
  API <-->|"requests, replies,<br/>events, directory"| V[("Valkey<br/>owned by the zone")]
  Z["Zone nodes"] <-->|"answer, publish,<br/>register sessions"| V
  API --> DB[("bestia_api<br/>push subscriptions")]
  Z --> ZDB[("bestia_zone")]

A reverse proxy puts the app, /api/v1/auth/companion (login-server) and /v1/companion (api-server) on one origin, so nothing is cross-origin and the refresh cookie is first-party. The dev server of companion-app/ plays that proxy on localhost:5173.

Every API path starts with its version: /v1/companion/....

The bus

Valkey belongs to the zone: zone-server/compose.yaml runs it, and zone-server/valkey/users.acl gives each service a user that reaches only its own keys and channels. A cluster of zone nodes needs it anyway; the api-server and the login server are its clients. The contract both sides speak lives in the bus module: Kotlin classes for every request, answer and event, serialized as JSON, with a map tile’s PNG framed beside the JSON rather than in base64.

WhatValkeyWritten byRead by
Who is in the worlddir:account:{id}, a key with a 30 s TTLzoneapi-server
Questionspub/sub channel zone:{node}:requestsapi-serverthat zone node
Answerspub/sub channel api:{instance}:replieszonethe asking api-server
Eventsstream events, the newest 10 000 keptzoneapi-server, twice (see below)
Writes already donebus:idem:{account}:{key}, 10 minuteszonezone
Kicksstream kicks, the newest 10 000 keptlogin-serverevery zone node, a group each

The tick never waits for Valkey. The zone appends events from a bounded outbox on its own thread; a full outbox drops and counts. Requests are answered on virtual threads, at most 64 at a time per node; one more is answered “busy” at once rather than queued.

A question to the zone

  1. The api-server looks the account up in the directory. No entry means the account is not in the world, and the question goes to the realm’s default node (one node today).
  2. It publishes {correlationId, replyTo, deadline, accountId, idempotencyKey?, query}. The account is the one the api-server authenticated; nothing in the query can name another.
  3. The zone checks every master in the query against master.account_id. Absent and not yours read the same: NOT_FOUND.
  4. It answers on replyTo. A question nobody listens to fails at once (503); one with no answer in five seconds is a 504.
ReplyHTTP
OK200
NOT_MODIFIED304
NOT_FOUND404
INVALID400
BUSY503 with Retry-After

A Master being played is read where it stands in the world; any other from its row, with the time of its last save as asOf. Bestia stay in the world when their Master leaves, so they are mostly read live.

Writes

A write carries an Idempotency-Key. The zone stores its reply under that key for ten minutes, and a repeat gets the same reply without running again, so an app can safely resend after a 504.

The writes run in the account’s inbox, so they are ordered with whatever the game client sends at the same moment.

  • A Bestia’s standing order takes effect at once, as in the game: the zone stores it and rebuilds the creature’s agent, through the same service the game’s own handler uses.
  • A line of party or guild chat, POST /v1/companion/masters/{id}/chat with {channel, text}, goes out through the same code as a line typed in the game. Members being played see it in their chat window, and every member gets a ChatLine event. The rules are the game’s: Basic Skill Lv. 2, read from the Master’s last save when nobody plays it (403); a Master in no such party or guild is refused (409); a line is 1 to 200 characters, with control characters stripped. Local say has no app counterpart, because the Master is not in the world to be heard. The answer is empty, and the line itself comes back over the socket like everybody else’s.
  • A Bestia’s journey, PUT /v1/companion/masters/{id}/bestias/{bestiaId}/journey with waypoints in metres, or a saved route’s id and reverse, sends it off as a map click in the game does; DELETE stops it. A Bestia out of the world after a restart answers 404 until its Master is selected again. The roster lists where each Bestia is travelling. See Long-Distance Travel.
  • Saved routes, GET/POST /v1/companion/masters/{id}/routes and PATCH/DELETE .../routes/{routeId}, keep a Master’s routes by name. Every write answers with the whole list; 400 ROUTE_LIMIT means it keeps as many as it may.

Events

The zone appends to the events stream:

EventWhen
HomeFellA Master’s home town fell and it was given a new one
BestiaDiedAwayAn owned Bestia died while nobody played its Master
ConstructionDoneA player’s construction site became a structure
StanceChangedA Bestia was given a new standing order, from the game or the app
PresenceAn account entered or left the world (from the directory heartbeat)
ChartsChangedWhat a Master can see of the map may have changed
CompendiumChangedA Master saw or caught a species for the first time
ChatLineA line of party or guild chat, from the game or the app: one per member, played or not, the sender included
JourneyEndedA Bestia’s journey ended: arrived, or stuck, without a known way, or dead, and where

The api-server reads the stream twice:

  • Every instance follows it and sends each event to the account’s open sockets, and drops the account’s cached answers, except on ChatLine, which changes none. A socket that reconnects sends the last event id it saw and gets what it missed, as far as the stream still holds. That is the only chat history there is: the zone keeps none, and the app keeps what it heard in the browser.
  • One consumer group, webpush, turns events into notifications for accounts that are not in the world and have no socket open. An event is acknowledged once handled, so each is notified once however many instances run. Chat is never a notification, and neither is a journey’s end the player saw in the game.

The WebSocket

/v1/companion/ws. A browser cannot put a header on a WebSocket handshake, so the first frame authenticates: {"type":"auth","token":...,"lastEventId":...}. The app never names what it wants to hear; it hears its own account’s events. A socket lives as long as its token and is then closed with code 4001, and the app reconnects with a fresh token.

Push notifications

Web Push, with the payload encrypted to the browser’s keys (RFC 8291) and the sender proven by VAPID (RFC 8292), both in the api-server itself on the JDK’s own cryptography. Subscriptions and preferences are the only data the api-server stores, in bestia_api.

  • A subscription may only point at a known push service (FCM, Mozilla, Apple, Windows). It is a URL this service will POST to, so without that list any player could aim it at an internal address.
  • Each account chooses its categories and quiet hours, in its own time zone.
  • At most six notifications an hour per account; the rest of the hour’s events are dropped.
  • A push service that answers 404 or 410 has forgotten the subscription, and it is deleted.

On iPhone and iPad a web app receives notifications only once it is on the Home Screen. The app says so.

The map

The map is asked of the zone, because the world is not static: the zone renders tiles from the live world and the Master’s charts, with the same rules as the game client’s map. A fully charted tile is the same bytes for everyone and immutable; a partly charted one revalidates against an ETag that carries the coverage digest; a 404 is ground the Master has not charted.

The api-server keeps tile bytes by ETag. A repeat request goes to the zone as a conditional one, and a 304 from the zone is enough to answer from the cache, so a PNG crosses the bus once. A ChartsChanged event drops the Master’s cached ETags.

The game client’s map comes from here as well, on the same routes, under the Master the player has selected. It has no companion token: it sends Authorization: Ticket <ticket>, the opaque value the zone handed it in AuthenticationSuccess, bound to its socket and forgotten when the socket closes. The api-server accepts a ticket on the map and emblem routes only, never for a standing order or push, and asks the zone whose it is (ZoneQuery.WhoseTicket); the answer is kept for 30 seconds and dropped when the account leaves the world. The zone then checks the Master is that account’s, as for the app.

A habitat overlay, map/h/{species}/{level}/{x}/{y}.png, lies over a tile of the same path and shows where a species lives (see Bestia Compendium). It is masked by the charts like the tile, and the zone answers 404 for a species the Master has not seen. It is always private, no-cache: who may see it depends on the Master’s compendium, so it is never cached publicly and never kept by the api-server.

Guild emblems

GET /v1/companion/emblems/{hash}.png is a guild’s emblem, named by the SHA-256 of its PNG. The zone keeps emblems with the guilds; the api-server asks for one by hash (ZoneQuery.GuildEmblem, the PNG in the reply’s body, as for a tile) and keeps it, so it crosses the bus once. A hash names one picture forever, so the answer is public and immutable, and every client keeps its copy for good: a new emblem is a new hash.

The emblem is not checked against the asker: it shows on every member’s name tag, so whoever can see one may see the picture. The leader uploads it over the game socket, not here, so the zone checks the rank and the Guild Emblem skill and stores the picture in one transaction. A 32 × 32 PNG of at most 8 KiB is decoded and written out again, so nothing hidden in the upload is ever served.

GET /v1/companion/masters/{id}/guild is the Master’s guild as the game’s guild window shows it: name, level, message of the day, emblem hash, and every member with rank, level, and, for one offline, where and when it left.

Sign-in

The companion app signs in like the game: PKCE, the login server’s own passkey page, a single-use code. See Authentication. What comes out is a companion token, audience companion, signed with a key of its own (jwt.companion-secret) that only the login server and the api-server hold. The api-server therefore cannot mint a zone token.

Stores and ports

ServiceHTTPDatabaseValkey user
login-server8080bestia_login, 3307login, which may only append kicks
zone-servermetrics only, 8092 on loopback (game socket 8090)bestia_zone, 3306zone
api-server8095bestia_api, 3308api
Valkey6379from zone-server/compose.yaml

The api-server and the login server need the zone’s Valkey, so in development the zone starts first. The login server connects on its first kick, so it starts without Valkey; its kicks are lost until Valkey is up.

Availability

Valkey has the same failover as Redis, Sentinel: one primary, one or two replicas and three Sentinels. When a majority agree the primary is down, they promote a replica. Replication is asynchronous, so a failover can lose the newest writes: directory entries heal on the next heartbeat, a lost question times out, and a lost write is safe to resend because of its idempotency key. Development runs a single instance.

Licence

The stack keeps to open source licences. That rules out Redis 7.4 to 7.8 (RSALv2 and SSPLv1). Redis 8 added AGPLv3, which is open source but copyleft with a network clause. Valkey is the fork of Redis 7.2.4 under BSD-3-Clause, run by the Linux Foundation, and everything the bus uses - TTL keys, pub/sub, streams, consumer groups, ACL users - is part of that baseline. Lettuce talks to it unchanged.

See the Redis licences and the history of Valkey.

Next

  1. Whispers in the app, both ways. The zone has them; the bus and the game client’s /w do not yet.
  2. A read model fed by the events, if questions to the zone ever grow heavy.

Open problems

  • Notifications across instances. Whether an account has a socket open is known per instance, so with several api-server instances an account connected to another one can still be notified.
  • A refresh lost in flight. Refresh tokens rotate on every use and a spent one ends the session. An app killed between the server’s rotation and storing the new cookie is signed out and signs in again with its passkey.
  • Chat in a shared stream. Every group line is one event per member on the events stream, which keeps the newest 10 000. A busy guild can push a sleeping phone’s other events out before it reconnects.
  • The session epoch after a failover. The epoch of the cluster design lives in the directory, and a failover can count it back.

Last updated 09 Nov 2026, 23:57 +0100 . history