Otomo: player plane plan (M1)
For: the network engineer building the player-facing service layer (Auth, Session,
Allocator, game-server hand-off, player gateway).
Companion docs: 12-godot-sdk-guide.md (what the client does, the other side of every
contract here), 03-patch-minimal.md, 04-session-minimal.md, 06-auth-identity-contract.md,
07-auth-techspec.md, 05-gateway-techspec.md, 10-communication-schema.md (every packet,
with handler locations).
This is the player-side counterpart of 11-admin-plane-plan.md: where things stand, the
agreed flow, what is still undecided, and every remaining M1 task with acceptance criteria.
§8 maps each task to its Jira ticket.
1. Where things stand (staging 9b868e6, 2026-09-28)
| Piece |
State |
Player gateway (services/gateway) |
Done for M1 routing: JWKS verification of both domains, route policy, rate limits (general 20 rps/burst 40, /auth/ 5 rps/burst 10), streaming routes. Deployed, but bound to 127.0.0.1:8080 (no public domain or TLS yet). |
| Patch |
Done and deployed. live manifest and blobs public; dev/staging manifests need a staff token. |
| Auth |
Live (SCRUM-81…84, 267…269, merged 2026-09-28): device login, refresh rotation with family revocation, logout, and the services hand-off payload. Contract in docs 10 §3 and 12 §6. |
| Session |
Foundation: route table, player + staff verifiers, guard, Valkey event primitives, full schema (profiles, friendships, blocks, party, party_member, party_invite, audit_log). Deployed (SCRUM-268); every route answers 501 behind its guards. |
| Config |
Done for client content. Server-audience namespaces exist but cannot be put in a release and no server manifest is built. |
| Allocator / Matchmaker |
9-line placeholders. Not in compose. |
| Game servers |
Nothing server-side yet. |
2. The agreed flow
Client ─► Gateway ─► 1. Patch public; always first; content (.pck) + client config
2. Auth device ID → access token (15 min) + rotating refresh token (30 d)
3. Session profile, presence, friends, party = lobby, events (long-poll)
│ leader launches
▼
4. Allocator (internal only) reserves a game server, issues join tickets
│
▼ address + ticket delivered to each member as a Session event
Game server headless Godot; verifies the ticket; runs the expedition
Decided:
- Patch first, public. The client patches before login. Patch cannot update the game
executable;
X-Min-Client-Version stops old executables.
- Auth issues, the gateway verifies. Nobody calls Auth to check a token; the gateway and
Session verify signatures locally from Auth's JWKS. The client only knows the gateway's
base URL.
- Session owns lobbies. The party is the lobby: settings, ready flags, launch.
Membership has one owner.
- Level 1 rules only. Game rules (party size, name rules, lobby option values) are data
in server-audience Config namespaces, enforced by Session's Go code. No server-side
scripting in M1.
- Allocator after Session. Session calls the Allocator when a lobby launches; the
Allocator manages a small fixed pool of headless Godot game servers. No matchmaking in M1.
- Clients report their content release. Session refuses clients whose loaded
release_id is older than the live channel's.
3. Decisions still open
Each has a recommendation; tasks that depend on one say so.
| ID |
Decision |
Recommendation |
Blocks |
| D1 |
Player gateway public domain and TLS (who terminates, cert source) |
TLS at a small reverse proxy (Caddy or nginx with Let's Encrypt) in front of gateway, on the team host; record the domain in doc 06 §12 |
GW-1, production SDK |
| D2 |
Drop Auth's services hand-off payload (SCRUM-84)? |
Currently kept: SCRUM-84 shipped it as doc 06 §12 specifies (the Auth owner's decision). Still open to revisit with the Auth owner; the SDK guide falls back to {gateway}/api/player/session either way |
— |
| D3 |
Gameplay Proxy in M1, or direct connection? |
Settled 2026-09-28: proxy in M1 (GS-3, SCRUM-304). Game servers stay private behind it; the proxy and the game server both check the join ticket |
GS-1, GS-3 |
| D4 |
Service credential for internal calls (Session → Patch server manifest, Session → Allocator, game server → Allocator) |
A per-caller static key mounted as a secret, checked on the callee's internal listener only; never routable through a gateway |
CF-3, AL-3, GS-2 |
| D5 |
Error for an outdated content release |
409 with code release_outdated; the client re-runs Patch and retries |
SE-8 |
4. Contracts to write before building the launch path
PL-1 (design doc, first task): write docs/14-launch-handoff.md covering:
- Lobby state machine:
forming → launching → in_game → forming, and what each member
action (leave, kick, settings change, ready toggle) does in each state.
- Session → Allocator request/response (internal API), idempotency, timeouts.
- Join ticket: format (Ed25519-signed token like the auth JWTs), claims (player, allocation,
game server, expiry ≤ 60 s, single use), who signs (Allocator), how game servers get the
public key.
- Game server ↔ Allocator: registration, heartbeat, "expedition ended", crash detection.
- Failure paths: no capacity, allocation timeout, a member never connects, game server dies
mid-expedition, Session restarts during a launch.
- The events clients receive (
party.launching, party.launch_failed, party.returned)
with payloads, so 12-godot-sdk-guide.md §7.5/§8 can be finalised.
5. Tasks
IDs are new; the "Existing" column names an older Jira ticket the task refines or belongs to.
Phase AU: Auth (owner: SCRUM-81…84, SCRUM-267…269)
These are already in Jira and in progress; listed so the plan is complete.
| ID |
Task |
Acceptance criteria |
Existing |
| AU-1 |
Device login POST /auth/anonymous {device_id}: validate, find-or-create account via identity_binding (ON CONFLICT), issue access + refresh token |
Same device → same sub; 10 concurrent first logins for one device → one account; token accepted by the player gateway's real middleware |
SCRUM-81, SCRUM-82 |
| AU-2 |
Refresh POST /auth/refresh: atomic rotation, reuse revokes the family, 30-day sliding expiry; POST /auth/logout revokes the family (204, idempotent) |
Reused token → 401 and the whole family dead; concurrent refresh with one token → exactly one success |
SCRUM-83 |
| AU-3 |
Pin the HTTP contract (bodies, lifetimes, error codes) in docs 10 and 12; apply D2 |
12-godot-sdk-guide.md §6 has no "proposed" labels left |
SCRUM-267, SCRUM-84 |
| AU-4 |
End-to-end: login through the gateway, token passes Session's guard, refresh-then-retry |
Scripted smoke passes in the compose stack |
SCRUM-268 |
| AU-5 |
Tests: stable sub, claims/signature vs JWKS, rotation, reuse, logout |
Green in Jenkins with Postgres |
SCRUM-269 |
| AU-6 |
Metrics and log hygiene: auth_logins_total{result,new_account}, auth_refresh_total{result} (incl. reuse_detected); device IDs and tokens never logged |
Metrics on :9090; a grep of logs for a known device ID finds nothing |
new |
Phase SE: Session core
| ID |
Task |
Acceptance criteria |
Depends on |
Existing |
| SE-1 |
Deploy Session: session-migrate + session in compose (Valkey is already there), player and staff JWKS env, readiness; Jenkins job green |
Host /readyz 200; a player call through the gateway returns Session's answer, not 502 |
— |
SCRUM-117 |
| SE-2 |
Profiles: POST /me/init (idempotent, provisional name, discriminator with unique-violation retry), GET /me, PATCH /me (name rules, 24 h rename limit → 429) |
10 concurrent init for one player → one profile; rename twice in a day → 429 |
SE-1 |
SCRUM-143 |
| SE-3 |
Presence: POST /presence/heartbeat (hash TTL 60 s, presence:online zset, ≤1 per 10 s → 429, trim loop); export otomo_online_players (the Dashboard overview already sums it) |
Player shows offline 60 s after the last beat; Dashboard overview shows the online count |
SE-1 |
SCRUM-143 |
| SE-4 |
Events: atomic producer (Lua: INCR/RPUSH/LTRIM/EXPIRE/PUBLISH), one pub/sub connection per instance with fan-out, GET /events?after= long-poll (25 s hold, replaces an older poll, {"resync":true} past the window) |
An event produced on instance B wakes a poll held on instance A within 100 ms; no goroutine growth after SE-9's load test |
SE-1 |
SCRUM-146 |
| SE-5 |
Friends and blocks: request by name#discriminator, accept/decline/remove, list with presence (one pipeline), block/unblock in one transaction; events |
Blocked player cannot request or invite; friends list is one Valkey round trip |
SE-2, SE-4 |
SCRUM-144 |
| SE-6 |
Parties: create/get/invite/accept/decline/leave/kick/promote with revision; events published only after commit |
Nobody can be in two parties; a rolled-back join notifies nobody; leader leaving promotes the longest member |
SE-2, SE-4 |
SCRUM-145 |
| SE-7 |
Staff routes: player lookup, force disband (live_ops, audited), GET /api/admin/session/audit; add Session as a Dashboard audit source |
Disband appears in the admin Audit page with source session |
SE-6 |
— |
| SE-8 |
Content release enforcement: clients send X-Otomo-Release: <release_id>; Session compares it with the live channel head and refuses older ones per D5 |
A client on an old release gets the D5 error on every player route; the current release passes |
SE-1, D5 |
— |
| SE-9 |
Tests: DB + Valkey integration tests, Hurl smoke through the gateway, k6 with 2,000 clients heartbeating and long-polling |
p95 heartbeat < 50 ms; no leaked goroutines; green in Jenkins |
SE-2…SE-8 |
— |
Phase CF: server-only Config for Level 1 rules
| ID |
Task |
Acceptance criteria |
Depends on |
| CF-1 |
Releases may include server-audience namespaces. Publishing builds two manifests: the client manifest (unchanged) and a server manifest (same format, server namespaces only). Rollback and promote carry both |
A release without server namespaces produces a byte-identical client manifest; server namespaces never appear in the client manifest |
— |
| CF-2 |
Admin UI release composer: pick server namespaces; the diff and preview show both manifests |
Composing a release with session.rules shows it under "server" |
CF-1 |
| CF-3 |
Patch serves the server manifest and its blobs on its internal listener only, authenticated per D4 |
Not reachable through either gateway (404); wrong key → 401 |
CF-1, D4 |
| CF-4 |
Seed namespace session.rules with its schema: max party size, name length and charset, rename cooldown, allowed lobby setting values (e.g. expedition IDs) |
config seed creates it; the admin UI edits it with validation |
CF-1 |
Phase LB: lobby (Session)
| ID |
Task |
Acceptance criteria |
Depends on |
| LB-1 |
Rules loader: fetch the live server manifest at start and every 60 s (ETag), load session.rules into typed rules with compiled-in defaults; a bad document keeps the last good one and bumps a metric |
Publishing a new max party size changes invite limits within 60 s |
CF-3, CF-4 |
| LB-2 |
Lobby model: party state (forming/launching/in_game), settings validated against the rules, per-member ready (cleared when settings change); PATCH /party/settings (leader), POST /party/ready; migration; party.updated carries them |
Invalid settings → 400; non-leader settings → 403; SDK sees ready changes via events |
PL-1, SE-6, LB-1 |
| LB-3 |
Launch: POST /party/launch (leader, all ready, forming) → launching, call the Allocator, deliver party.launching with address, port and a per-member ticket; on failure return to forming with party.launch_failed; idempotent per party revision |
Two launch presses start one allocation; no capacity → every member gets launch_failed and the lobby is usable again |
PL-1, LB-2, AL-3 |
| LB-4 |
Return from game: Allocator reports the expedition ended (or the server died) → party back to forming, party.returned; rules for leaving/kicking while launching/in_game |
After a game ends every member is back in the same lobby |
LB-3, AL-5 |
Phase AL: Allocator
| ID |
Task |
Acceptance criteria |
Depends on |
| AL-1 |
Service skeleton like the other Go services (config, internal listener only, /healthz, /readyz, /metrics, JSON logs), state in Postgres or Valkey, compose + Jenkins |
Deployed; no route on either gateway |
— |
| AL-2 |
Server registry: game servers register (id, public address, port, capacity) and heartbeat every 5 s; 3 missed → dead; states free/reserved/busy; survives an Allocator restart |
Killing a game server marks it dead within 15 s |
AL-1, PL-1 |
| AL-3 |
POST /internal/allocations {party_id, player_ids} → reserve a free server, return {allocation_id, address, port, tickets{player_id: ticket}}; idempotent per party_id; 503 no_capacity; authenticated per D4 |
Concurrent allocations never share a server; retries return the same allocation |
AL-2, AL-4, D4 |
| AL-4 |
Join tickets: Ed25519 key (genkey subcommand, like Auth), claims per PL-1, public key endpoint for game servers |
A ticket for another server, expired, or reused is rejected by the verifier tests |
AL-1, PL-1 |
| AL-5 |
Release and timeouts: game server reports "ended" → free; reserved but nobody connected in 60 s → free and Session told; notify Session of ends and deaths |
A reserved server with no joins is back in the pool after 60 s |
AL-3 |
| AL-6 |
Metrics: servers by state, allocations by result, time to allocate; visible in the Dashboard |
Dashboard service detail shows allocator series |
AL-3 |
Phase GS: game servers
| ID |
Task |
Acceptance criteria |
Depends on |
| GS-1 |
Headless Godot (.NET) dedicated-server image from the game's server export; env for Allocator URL, public address, port, server ID; a fixed pool (e.g. 3) in compose on the internal network only (the Gameplay Proxy, GS-3, is the public entry point) |
Three servers register with the Allocator on up.sh |
AL-2, D3 |
| GS-2 |
Game-server side of the hand-off (C#, with the gameplay programmer): register/heartbeat with the Allocator, verify the join ticket on connect, reject otherwise, report "ended" |
A client with a valid ticket joins; a forged or reused ticket is refused |
GS-1, AL-4 |
| GS-3 |
Gameplay Proxy: single public UDP entry point that verifies join tickets and forwards each connection to the assigned game server |
A valid ticket reaches its server through the proxy; invalid, expired or reused tickets are dropped; game servers publish no public ports |
AL-4, GS-1 |
Phase GW: player edge
| ID |
Task |
Acceptance criteria |
Depends on |
| GW-1 |
Public player edge per D1: domain, TLS termination, bind, ufw; update docs 06 §12 and 12 §3.4 |
curl https://<domain>/patch/v1/live/manifest works from outside; plain HTTP redirects or is closed |
D1 |
| GW-2 |
Route review for M1 traffic: long-poll timeout ≥ 35 s end to end; separate rate-limit buckets so heartbeats and long-polls don't starve other calls; X-Otomo-Release passes through |
A client heartbeating and long-polling never hits 429 on normal calls; route tests updated |
SE-3, SE-4 |
Phase OP: acceptance and operations
| ID |
Task |
Acceptance criteria |
Depends on |
| OP-1 |
Observability: Session, Auth and Allocator metrics in Prometheus and the Dashboard; Loki labels; M1 alerts (Session down, no free game servers) |
Each service has a Dashboard detail page with data |
SE-1, AL-6 |
| OP-2 |
End-to-end acceptance client (scripted, in Jenkins): patch → login → me/init → party → ready → launch → connect to the game server with the ticket |
Runs green against the compose stack |
LB-3, GS-2 |
| OP-3 |
Load: k6 login burst (Auth), 2,000 clients heartbeat + long-poll (Session), allocation contention (Allocator) |
Numbers recorded in docs/testing; no errors at target load |
SE-9, AL-3 |
| OP-4 |
Docs to final shapes: 04, 06, 07, 10 and 12 (remove every "proposed"/"design pending" that is now built) |
The SDK guide has no pending sections for M1 features |
all |
6. Order of work
- Now, in parallel: AU (in progress), SE-1…SE-4, PL-1 (design doc), and the decisions D1–D5.
- Then: SE-5…SE-7, CF-1/CF-3/CF-4, AL-1/AL-2/AL-4.
- Then: LB-1/LB-2, AL-3/AL-5, GS-1/GS-2, SE-8, GW-2, CF-2.
- Then: LB-3/LB-4, AL-6, GW-1, OP-1/OP-2.
- Last: SE-9, OP-3, OP-4.
The Godot SDK can start on Patch immediately and on Auth/Session against fakes
(12-godot-sdk-guide.md §10.3), switching to the real services as SE-1 and AU land.
7. Out of scope for M1
- Matchmaking with strangers (Matchmaker).
- Server-side scripting of game rules (Level 2): revisit only if data-driven rules fall short.
- Platform login (Steam etc.), email accounts, account recovery.
- Shop, loadouts and inventory (later milestones; the rules/data boundary in LB-1 is where
they will plug in).
- A staff "players" page in the admin UI (SE-7 provides the API only).
8. Owners (assigned 2026-09-28)
| Owner |
Area |
Tickets |
| Isaac Foo |
Auth, Session (incl. lobby), Patch |
SCRUM-81…84, 267…269, 272…281, 284, 286…289 |
| Tai Xiao Xuan |
Allocator, service orchestration, reverse proxy / TLS / Let's Encrypt; design doc, gateway review, Config/UI rules pipeline, acceptance and ops (pending reassignment) |
SCRUM-271, 282, 283, 285, 290…295, 298…303 |
| Lew Zheng Song |
Gameplay Proxy, game server images, Godot client SDK |
SCRUM-304, 296, 297, 140, 148 |
9. Jira mapping (stututudiotomo.atlassian.net, project SCRUM)
New tickets were created in the backlog (To Do, no sprint) on 2026-09-28. Auth tasks map
to the tickets already in progress.
| Plan ID |
Ticket |
Plan ID |
Ticket |
Plan ID |
Ticket |
| PL-1 |
SCRUM-271 |
CF-1 |
SCRUM-282 |
AL-1 |
SCRUM-290 |
| AU-1 |
SCRUM-81, SCRUM-82 |
CF-2 |
SCRUM-283 |
AL-2 |
SCRUM-291 |
| AU-2 |
SCRUM-83 |
CF-3 |
SCRUM-284 |
AL-3 |
SCRUM-292 |
| AU-3 |
SCRUM-267, SCRUM-84 |
CF-4 |
SCRUM-285 |
AL-4 |
SCRUM-293 |
| AU-4 |
SCRUM-268 |
LB-1 |
SCRUM-286 |
AL-5 |
SCRUM-294 |
| AU-5 |
SCRUM-269 |
LB-2 |
SCRUM-287 |
AL-6 |
SCRUM-295 |
| AU-6 |
SCRUM-272 |
LB-3 |
SCRUM-288 |
GS-1 |
SCRUM-296 |
| SE-1 |
SCRUM-273 |
LB-4 |
SCRUM-289 |
GS-2 |
SCRUM-297 |
| SE-2 |
SCRUM-274 |
GW-1 |
SCRUM-298 |
GS-3 |
SCRUM-304 |
| SE-3 |
SCRUM-275 |
GW-2 |
SCRUM-299 |
OP-1 |
SCRUM-300 |
| SE-4 |
SCRUM-276 |
|
|
OP-2 |
SCRUM-301 |
| SE-5 |
SCRUM-277 |
|
|
OP-3 |
SCRUM-302 |
| SE-6 |
SCRUM-278 |
|
|
OP-4 |
SCRUM-303 |
| SE-7 |
SCRUM-279 |
|
|
|
|
| SE-8 |
SCRUM-280 |
|
|
|
|
| SE-9 |
SCRUM-281 |
|
|
|
|
Older umbrella tickets these refine: SCRUM-78 (Auth), SCRUM-117 (Session) with SCRUM-143…146,
SCRUM-148 (SDK foundation, see doc 12), SCRUM-188 (networked gameplay).