RPC and Protobuf
Audience: Engine Developer Status: ✅ Ready
The services talk to each other over gRPC with Protocol Buffers, generated by buf. Four proto packages define the whole inter-service surface. A guiding principle runs through them: the wire types enumerate no game concept — stats, resources, and items are maps of content-defined keys, so the protocol never needs to change when a pack invents a new attribute.
Related: Edge & Protocol (the Play stream), Accounts & Auth Internals (the Account service), Cross-Shard Handoff (the Handoff service).
The four proto packages
api/proto/telosmud/{common,play,account,handoff}/v1/:
common/v1— shared value types, no game concept baked in:CoreStats{map<string,sint64> attributes},Vitals{map<string,Resource>},Resource{cur, max},Item{proto_ref, delta_json, persist_id, contents[]}(the flyweight + copy-on-write delta on the wire),Equipped{slot, item},Affect{id, duration_pulses, magnitude, stacks, source},SkillState{id, proficiency}.play/v1— thePlayservice and its frames (Edge & Protocol).account/v1— theAccountservice.handoff/v1— theHandoffservice (importscommon).
The Account service
telos-account is the only service that touches OAuth and credentials; the gate calls it, and the world never does on the hot path (it trusts the signed session assertion). RPCs:
| RPC | Purpose |
|---|---|
ListCharacters |
the character-select menu |
ReserveName |
unique (CITEXT) name check/reserve |
CreateCharacter |
create from bundle refs (race/class/background) |
IssueSessionAssertion |
mint the Ed25519 {account, character, session, exp} token the gate carries in Attach (empty when signing is unconfigured); the response also returns a manage_tiers visibility bit the gate uses to hide promote/demote from non-staff (resolved even without a signing key, fail-safe to false) |
StartDeviceAuth |
begin the brokered OAuth device flow → {device_code, verification_uri, expires_in, interval} |
PollDeviceAuth |
{status, account_id, characters[]}, status ∈ pending/authed/expired |
GetChargenFlow |
the content-driven chargen flow the gate walks as prompts |
CreateChargenCharacter |
validate a prompt-driven submission (the point-buy cost curve stays server-side) |
SetAccountTier |
promote/demote — authorization is enforced here (the actor’s tier is read from the authoritative store; admin-only; audited; empty new_tier is the demote-to-baseline sentinel) |
GetAccountPrefs / SetAccountPrefs |
cross-session edge prefs (color); each field is proto3 optional for an absent/false/true tri-state; self-scoped, non-privileged only — a future privilege-bearing pref must not ride this message |
See Accounts & Auth Internals and Trust Tier Model.
The Handoff service
Cross-shard migration, two-phase (Prepare/Commit) plus an ownership epoch to preserve the single-writer invariant under failure:
| RPC | Purpose |
|---|---|
Prepare |
rehydrate the player on the destination in a pending state; returns {handoff_token, target_shard_addr, pending_ttl_ms} |
Commit |
activate after the gate re-attaches. Signed + destination-bound on a keyed shard (below) |
Abort |
roll back on failure/timeout. Signed + destination-bound on a keyed shard (below) |
AdoptZone |
graceful-drain helper: build-only host + start renewal so the caller can then flip the lease and hand players off; idempotent. Signed on a keyed shard (below) |
AdoptZoneis authenticated and fenced. A keyed shard once verified the signature onPreparewhile adopting a zone on a wholly unauthenticatedAdoptZone— so anyone with network reach to a world’s gRPC port could force it to build and run an arbitrary zone, then race the lease. The request is now signed with the shared cluster keypair over a digest bindingzone_id, the destination shard, andlease_gen— the zone lease’s monotonic generation, as the source observed it while still holding the lease. The destination checks that value against the directory’s current generation, and the source’s own lease flip increments it. So a captured request is neither transferable to another shard nor usable once the handover it authorized has landed: a replay is unrepresentable, not merely time-bounded. (issued_at_unix_mswith a 60s skew window was the earlier, weaker design; the field is deprecated and unread.) The destination verifies the signature before reading the directory, so unauthenticated traffic can never amplify into load on the shared Redis.from_shard_idrides the digest as an audit subject, not an authorization input — the generation match already proves the owner has not changed. As a correctness (not security) precondition, the destination also requiresfrom_shard_idto be the zone’s live owner and refuses before building the zone, so a misnamed source can’t leave an orphan; that check is free because it reads the owner in the same directory round-trip the fence already makes. The keyless path keeps its refusal, andTELOS_ALLOW_INSECUREcannot loosen a keyed shard. This matters because the gRPC mesh is plaintext — its trust is entirely app-layer.
Commit/Abortare signed and destination-bound too. The two phase-2 messages were once authenticated only by thehandoff_token— but the token is deterministic (sha256(character/epoch)) over public inputs (a name viawho, the epoch a small int), so anyone with network reach to a world’s gRPC port could recompute it andAborta player mid-handoff; the private port was the only barrier, the same trusted-network assumptionAdoptZoneabove removed. Each phase-2 message now carries an Ed25519 signature over a digest binding the token and the destination shard id under a per-operation domain separator, checked against the receiver’s own id. Binding the destination is load-bearing: on the plaintext mesh a signedAbortcaptured in flight could otherwise be replayed against a second destination in a split-brain same-epoch handoff — both derive the identical token — discarding the winner’s live pending. The same-destination replay is a pre-existing token-idempotency interaction (tracked separately). A keyless shard keeps the same refusal asPrepare/AdoptZone, so there is no unsigned phase-2 path.
The Prepare request carries a PlayerSnapshot — the authoritative in-memory state so the destination resumes with zero DB round-trips — plus epoch, from_shard_id, and snapshot_sig (Ed25519 over a canonical digest binding character/epoch/target/state, verified when the destination has a key). The snapshot includes state_version (the optimistic-concurrency base), applied_seq (the freeze-point input high-water that seeds the destination’s dedup watermark), persist_id (so the destination CASes the same durable row), the comms subtree, the remaining state JSON, and tier — the account trust tier, which is the secure replacement for carrying reserved capability flags across the wire (it’s trusted only because snapshot_sig binds it). The optional claims (tier, account) are folded into the digest unconditionally rather than appended-when-non-empty: with two such fields, an append-if-non-empty encoding made digest(tier="", account=X) and digest(tier=X, account="") byte-identical, a field-confusion collision an on-path attacker could exploit on the plaintext mesh. See Cross-Shard Handoff and Trust Tier Model.
The buf toolchain
buf.yaml(v2): one module atapi/proto; lintSTANDARDwith a few documented exceptions for the single-bidi-stream Play service and the intentional non-*Serviceservice names; breaking-change checkFILE.buf.gen.yaml(v2): managed mode on,go_package_prefix = github.com/double-nibble/telosmud/api/gen; pluginsprotocolbuffers/goandgrpc/go, both emitting toapi/genwithpaths=source_relative.- Generated
*.pb.gois gitignored. Runbuf generate(viamake proto) on a fresh checkout before building; never stageapi/gen. Generated code lands atapi/gen/telosmud/<pkg>/v1/.