API Server
How the companion app’s REST API and WebSocket run as their own service next to the zone - the Valkey bus they talk over, how a question reaches the zone and an event reaches a phone, the map it also serves the game, the push notifications and the stores.
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.
Version 1 is built: sign-in, Masters, Bestia, bags, the map, live events and push notifications, and changing a Bestia’s standing order. What the Companion App page describes beyond that waits for the game systems it needs.
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.
| What | Valkey | Written by | Read by |
|---|---|---|---|
| Who is in the world | dir:account:{id}, a key with a 30 s TTL | zone | api-server |
| Questions | pub/sub channel zone:{node}:requests | api-server | that zone node |
| Answers | pub/sub channel api:{instance}:replies | zone | the asking api-server |
| Events | stream events, the newest 10 000 kept | zone | api-server, twice (see below) |
| Writes already done | bus:idem:{account}:{key}, 10 minutes | zone | zone |
| Kicks | stream kicks, the newest 10 000 kept | login-server | every 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
- 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).
- It publishes
{correlationId, replyTo, deadline, accountId, idempotencyKey?, query}. The account is the one the api-server authenticated; nothing in the query can name another. - The zone checks every master in the query against
master.account_id. Absent and not yours read the same:NOT_FOUND. - It answers on
replyTo. A question nobody listens to fails at once (503); one with no answer in five seconds is a504.
| Reply | HTTP |
|---|---|
OK | 200 |
NOT_MODIFIED | 304 |
NOT_FOUND | 404 |
INVALID | 400 |
BUSY | 503 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}/chatwith{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 aChatLineevent. 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}/journeywith waypoints in metres, or a saved route’s id andreverse, sends it off as a map click in the game does;DELETEstops it. A Bestia out of the world after a restart answers404until 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}/routesandPATCH/DELETE .../routes/{routeId}, keep a Master’s routes by name. Every write answers with the whole list;400 ROUTE_LIMITmeans it keeps as many as it may.
Events
The zone appends to the events stream:
| Event | When |
|---|---|
HomeFell | A Master’s home town fell and it was given a new one |
BestiaDiedAway | An owned Bestia died while nobody played its Master |
ConstructionDone | A player’s construction site became a structure |
StanceChanged | A Bestia was given a new standing order, from the game or the app |
Presence | An account entered or left the world (from the directory heartbeat) |
ChartsChanged | What a Master can see of the map may have changed |
CompendiumChanged | A Master saw or caught a species for the first time |
ChatLine | A line of party or guild chat, from the game or the app: one per member, played or not, the sender included |
JourneyEnded | A 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
| Service | HTTP | Database | Valkey user |
|---|---|---|---|
| login-server | 8080 | bestia_login, 3307 | login, which may only append kicks |
| zone-server | metrics only, 8092 on loopback (game socket 8090) | bestia_zone, 3306 | zone |
| api-server | 8095 | bestia_api, 3308 | api |
| Valkey | 6379 | from 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
- Whispers in the app, both ways. The zone has them; the bus and the game client’s
/wdo not yet. - 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
eventsstream, 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 .