04 — Social Library & Service (lattice-social)¶
Part of the Lattice networking suite design set. Read 00-overview.md and 01-high-level-design.md first for vocabulary and the data-plane / control-plane split. This document specifies the social layer: friends, presence, parties, invites, and the optional chat/activity features — and, critically, how it ships both as a standalone library and as a deeply integrated part of the suite.
Cross-references: 02-netcode-lld.md (connect/session flow), 03-auth-service.md (identity & tokens), 05-engine-integration.md (binding strategy), 06-implementation-roadmap.md (delivery phasing).
1. Goals & Value Propositions¶
lattice-social is a standalone-first social SDK + backend. It is designed so a team can drop
friends/presence/parties into any product — even one that uses none of the rest of Lattice — and
also so that, when the full suite is present, social state lights up the netcode (join-a-friend's-game,
party-to-matchmaking, rich presence with joinability).
1.1 Standalone mode¶
A product brings its own identity (BYO JWT verified against a configured JWKS, or a simple API-key + user-id model) and gets:
- Drop-in social graph: friends, requests, blocking/mute, search-by-handle.
- Realtime presence (online/offline/away/in-game) with a free-form rich-presence payload.
- Parties/groups with leadership, invites, and membership lifecycle.
- Invites (to friend / to party / to an arbitrary opaque "session" token the host product defines).
- Optional pluggable text chat and activity feed.
- Engine-agnostic SDK exposed via the same binding strategy as the netcode core (Unity, Unreal, Godot, web) — one mental model across the whole suite.
Value prop (standalone): ship a production-grade friends system in days, not quarters, with no opinion about your identity provider, your game engine, or whether you even have multiplayer.
1.2 Integrated mode (full Lattice suite present)¶
When deployed alongside the rest of the suite, additional capabilities switch on automatically:
- Zero-config identity via
lattice-authtokens (no JWKS to wire up). - Rich presence carries joinability: a friend's presence advertises a resolvable session.
- "Join friend's game": presence/party → resolve a session id via
lattice-director→ hand off to the netcode connect flow inlattice-core(see 02-netcode-lld.md). - Party → matchmaking: a party leader queues the whole party through
lattice-director.
Value prop (integrated): the social graph becomes the front door to multiplayer — friends, parties, and "join game" all resolve to real netcode sessions with no glue code.
1.3 Non-negotiable constraint¶
No hard dependency on the rest of the suite. lattice-social must compile, deploy, and run
with only PostgreSQL + Redis (NATS optional). All suite integrations are capability-gated behind
configuration: if lattice-auth / lattice-director are not configured, the corresponding features
are simply absent — they never become a runtime requirement.
1.4 Non-goals¶
- Not a general chat platform (chat is intentionally minimal/pluggable; bridge to a dedicated provider for heavy use).
- Not a moderation/trust-and-safety product (we expose hooks, not a review console).
- Not an identity provider (that is
lattice-auth; standalone mode delegates to BYO identity).
2. Feature Set¶
| Domain | Capabilities |
|---|---|
| Friends list | List friends, paginated; relationship status; mutual-friend hints (optional). |
| Friend requests | send, accept, decline, cancel; idempotent; reciprocal-request auto-accept. |
| Blocking / mute | Block (hard: hides presence both ways, removes friendship, blocks invites/requests); mute (soft: suppresses chat/notifications, keeps friendship). |
| Presence | States online / away / busy / dnd / in-game / offline; multi-device sessions (explicit busy/dnd override the auto aggregate); free-form rich presence payload (game id, map, party, joinability, session handle). |
| Parties / groups | create, invite, join, leave, kick, transfer-leader, set-privacy; max-size policy; auto-disband on empty. Lobby gating: invite/join blocked (403) when host is busy/dnd or lobby is Private. Lobby presence override: members mirror the host's status to their friends while in the lobby. |
| User notes | Private, author-only note about another user (set / get / delete); surfaced inline in the author's friend list; never visible to anyone else. |
| Invites | To friend (friend request), to party, to game session (opaque token in standalone; director-resolvable in integrated mode). TTL + accept/decline/expire. |
| Activity feed (optional) | Append-only per-user feed (achievements, "started playing X", "joined party"); fan-out to friends. |
| Text chat (optional/pluggable) | 1:1 DM and party channels via WSS; pluggable backend (built-in store, or bridge adapter to an external chat provider). |
| Search / find | Find user by exact handle/discriminator; prefix search (rate-limited, privacy-gated). |
Optional features are build/deploy flags — they add tables and WS events only when enabled, so a minimal deployment stays lean.
3. Architecture¶
flowchart TB
subgraph Clients["Game clients (engine-agnostic)"]
SDKU["lattice-social SDK (Unity)"]
SDKE["lattice-social SDK (Unreal)"]
SDKG["lattice-social SDK (Godot)"]
SDKW["lattice-social SDK (web)"]
end
LB["Load balancer / TLS term."]
subgraph SocialTier["lattice-social (stateless nodes, 'act as one')"]
N1["Node A : REST + WSS"]
N2["Node B : REST + WSS"]
N3["Node C : REST + WSS"]
end
subgraph Backbone["Fanout backbone"]
REDIS["Redis (presence TTL, Pub/Sub)"]
NATS["NATS (optional, durable fanout)"]
end
PG[("PostgreSQL (social graph, parties)")]
subgraph SuiteOptional["Optional suite integrations (capability-gated)"]
AUTH["lattice-auth (8443) : token + JWKS"]
DIR["lattice-director (8444) : session resolve / matchmaking"]
end
SDKU & SDKE & SDKG & SDKW -->|"HTTPS (CRUD) + WSS 9443 (realtime)"| LB
LB --> N1 & N2 & N3
N1 & N2 & N3 --> PG
N1 & N2 & N3 -->|"presence set/TTL + publish"| REDIS
N1 & N2 & N3 -.->|"durable streams (opt.)"| NATS
REDIS -->|"subscribe : presence/notify"| N1 & N2 & N3
N1 & N2 & N3 -.->|"verify token / JWKS (opt.)"| AUTH
N1 & N2 & N3 -.->|"resolve session / queue party (opt.)"| DIR
3.1 Components¶
- Client SDK — thin, engine-agnostic. A small C/C++ portable core (HTTP client + WS client + state cache + reconnection/backoff) wrapped with idiomatic per-engine bindings using the same strategy as the netcode core (see 05-engine-integration.md): P/Invoke for Unity, a UE module for Unreal, GDExtension for Godot, and a WASM/JS build for web. The SDK exposes async calls (friends, parties, invites) and an event stream (presence, notifications, chat). It holds no authority — it is a cache + transport over the backend.
lattice-socialbackend — .NET 8 / C# (Go is the noted suite alternative). Stateless nodes behind a load balancer. Each node serves both the REST/HTTPS API (CRUD: friends, parties, profile, search) and a WebSocket endpoint on WSS 9443 (realtime: presence, notifications, chat). Nodes share nothing locally; all shared state lives in Redis/Postgres/NATS.- PostgreSQL — durable system of record: users/profiles, relationships, parties, party members, invites, (optional) chat history and activity feed.
- Redis — presence sessions (with TTL), the Pub/Sub fanout bus, and ephemeral hot caches (friend-id sets, online sets). Redis is the default fanout backbone.
- NATS (optional) — drop-in replacement/upgrade for the fanout bus when durable delivery, larger fanout, or cross-region streaming is required.
3.2 "Act as one" — the stateless-node pattern¶
lattice-social mirrors the authentication service's horizontal model (see
03-auth-service.md): any node can serve any request, including any WS
connection. A client's WS may land on Node A while its friend's WS lands on Node C. Cross-node
delivery is solved by the Pub/Sub backbone, not by sticky sessions or node-to-node RPC: when
Node A updates a user's presence, it publishes to per-recipient channels; each node subscribes for
the users currently connected to it and pushes down the relevant sockets. This keeps nodes
stateless and independently scalable — add nodes to add capacity; lose a node and clients simply
reconnect (the LB routes them to a survivor).
4. Data Model¶
classDiagram
class User {
+UUID userId "INTERNAL only — never exposed"
+string publicId "opaque public id (exposed)"
+string externalId "BYO identity subject"
+string handle
+int discriminator
+datetime createdAt
}
class UserNote {
+UUID ownerId "author (only viewer)"
+UUID subjectId "user the note is about"
+string text
+datetime updatedAt
}
class Profile {
+UUID userId
+string displayName
+string avatarUrl
+string status "user-set status text"
+PresenceVisibility visibility
+json settings
}
class Relationship {
+UUID id
+UUID userId "owner / actor"
+UUID otherUserId
+RelationshipState state "pending | accepted | blocked"
+RelationshipDir direction "outgoing | incoming"
+bool muted
+datetime updatedAt
}
class Party {
+UUID partyId
+UUID leaderUserId
+int maxSize
+PartyVisibility visibility
+string sessionHandle "set when in a game (opt.)"
+datetime createdAt
}
class PartyMember {
+UUID partyId
+UUID userId
+PartyRole role "leader | member"
+datetime joinedAt
}
class Invite {
+UUID inviteId
+InviteKind kind "friend | party | game"
+UUID fromUserId
+UUID toUserId
+UUID targetId "partyId or null"
+string targetHandle "session/game token (opt.)"
+InviteState state "pending | accepted | declined | expired | cancelled"
+datetime expiresAt
}
class PresenceSession {
+string sessionId "per-device connection"
+UUID userId
+PresenceState state "online|away|busy|dnd|in-game|offline"
+bool isExplicit "user-set (busy/dnd) vs auto"
+string nodeId "WS node currently serving"
+datetime lastHeartbeat
+int ttlSeconds
}
class RichPresence {
+UUID userId
+string gameId
+string activity "e.g. 'In Match — Dust'"
+UUID partyId
+bool joinable
+string sessionHandle "director-resolvable (opt.)"
+json extra
}
class ChatMessage {
+UUID messageId
+ChatChannelKind channel "dm | party"
+UUID channelId "otherUserId or partyId"
+UUID fromUserId
+string body
+datetime sentAt
}
User "1" --> "1" Profile : has
User "1" --> "0..*" Relationship : owns
Relationship "1" --> "1" User : otherUser
Party "1" --> "1..*" PartyMember : contains
User "1" --> "0..*" PartyMember : memberships
User "1" --> "0..*" Invite : sent
User "1" --> "0..*" PresenceSession : sessions
User "1" --> "0..1" RichPresence : advertises
Party "1" --> "0..*" Invite : targetOf
User "1" --> "0..*" ChatMessage : authored
User "1" --> "0..*" UserNote : authors
4.1 Storage mapping & notes¶
- Durable (PostgreSQL):
User,Profile,Relationship,Party,PartyMember,Invite, and (optional)ChatMessage+ activity feed rows. - Ephemeral (Redis):
PresenceSession(keypresence:{userId}:{sessionId}with TTL) andRichPresence(keyrp:{userId}, refreshed on update). A derived online-friends set is cached per user (onlinefriends:{userId}) and rebuilt lazily. - Relationship is directional but a single accepted friendship is stored as a matched pair (one
row per direction) so each side's view/mute/visibility is independent.
state = blockedalways takes precedence overpending/acceptedon the blocker's side. externalIdcarries the BYO identity subject (or thelattice-authsubject). The(provider, externalId)pair is unique;userIdis Lattice-internal and stable across handle changes.sessionHandleinRichPresence/Partyis opaque in standalone mode (the host product defines it) and director-resolvable in integrated mode.
5. Identity Integration¶
lattice-social authenticates every REST call and WS upgrade with a bearer token, but it is
deliberately identity-provider-agnostic. Three modes are supported, selected by configuration:
| Mode | How it works | Use when |
|---|---|---|
lattice-auth (integrated) |
Verify the suite's Ed25519-signed PASETO/JWT against lattice-auth's published keys (JWKS); sub → externalId. |
The full suite is deployed — zero extra config. |
| BYO JWT (standalone) | Verify a customer-supplied JWT against a configured JWKS URL (issuer + audience checks); a claim mapping yields externalId + handle. |
The product already has its own auth (Firebase, Auth0, custom). |
| API-key + user-id (standalone) | Server-to-server: the product's backend presents a shared API key and asserts the acting user-id. The SDK never holds the key; the product mints a short-lived social session token. |
Simple products, server-brokered trust, prototypes. |
Key points:
- First contact provisions a user. On first authenticated request, an unknown
(provider, externalId)is auto-provisioned into aUser/Profile(handle chosen by the product or claimed by the user). No pre-sync step is required. - No hard dependency. Verification is fully local (cached JWKS) —
lattice-socialnever callslattice-authon the hot path and runs perfectly withlattice-authabsent. - Token scope. Social tokens are independent of netcode session tokens; "join game" still
resolves a separate session token via
lattice-director(see §8), keeping concerns decoupled.
flowchart LR
subgraph IdentityModes
A["lattice-auth token"]
B["BYO customer JWT"]
C["API-key + user-id"]
end
V["lattice-social : token verifier (local JWKS cache / key check)"]
A -->|"JWKS from lattice-auth"| V
B -->|"configured JWKS URL"| V
C -->|"shared key + asserted user-id"| V
V --> U["resolve/provision User by (provider, externalId)"]
6. Realtime Presence¶
6.1 Connection model & heartbeat¶
- A client opens a WSS connection to port 9443 after authenticating. The connection is bound to
a
PresenceSessionkeyed by(userId, sessionId)— multi-device is first-class (a user may be online on phone + PC simultaneously; the aggregate presence is the "most active" state). - On connect the node writes
presence:{userId}:{sessionId}to Redis with a TTL (default 30s) and recordsnodeId. The client sends a heartbeat/ping every ~10s; each heartbeat refreshes the TTL. WS-level pings double as liveness. - Disconnect / crash: if heartbeats stop, the Redis key expires, and a keyspace-expiry
handler (or a lazy read) transitions the device to
offline. When the last device session expires, the user isofflineand a presence-down event is fanned out. - Reconnect: on reconnect (possibly to a different node), the SDK resumes with backoff and
re-asserts presence + rich presence; the new
nodeIdis recorded.
6.2 Fanout across backend nodes¶
Presence changes must reach a user's friends, who may be connected to any node. We use Redis Pub/Sub (or NATS) as the bus:
- Node updates
presence:*/rp:*and computes the friend recipient set (cachedfriends:{userId}id set). - The node publishes the presence delta. Each node subscribes for the users currently holding a socket on it and pushes the event down those sockets.
6.3 Scaling to large friend graphs — fanout-on-write vs read¶
The core tension is fan-out cost. Two strategies, chosen per user by a degree threshold:
| Strategy | Mechanism | Best for | Cost |
|---|---|---|---|
| Fan-out-on-write (push) | On a presence change, publish to each online friend's channel immediately. | Typical users (small/medium friend lists). | O(online-friends) writes per change; instant. |
| Fan-out-on-read (pull) | Don't push to everyone; friends poll/aggregate presence on demand (e.g., when they open the friends list) by reading presence:* for their friend set. |
Hot users (streamers, very high-degree accounts) where push would storm the bus. | O(friends) reads per viewer, only when viewing; no broadcast storm. |
Hot-user handling: accounts above a configurable degree threshold (e.g. > 2,000 friends) are
flagged hot. Their presence changes are not broadcast; instead viewers fold in the hot user's
presence on read (with a short Redis cache, e.g. 5s, to coalesce). This caps worst-case fan-out and
protects the bus from celebrity-account thundering herds. Coalescing/debounce: rapid presence
flaps (away↔online) are debounced (~2s) before publishing to avoid event storms; rich-presence
updates are rate-limited per user.
6.4 Presence update fanout — sequence¶
sequenceDiagram
actor U as "User (device)"
participant NA as "social Node A (U's WS)"
participant R as "Redis (TTL + Pub/Sub)"
participant NC as "social Node C (friend's WS)"
actor F as "Friend (device)"
U->>NA: "WS: presenceUpdate(state=in-game, rich=...)"
NA->>R: "SET presence:U:sess (TTL=30s) + SET rp:U"
NA->>R: "PUBLISH per-friend channels (fan-out-on-write)"
Note over NA,R: "if U is 'hot' -> skip publish; friends read on demand"
R-->>NC: "deliver presence delta for friend F"
NC->>NC: "find F's socket(s) on this node"
NC-->>F: "WS: friendPresenceChanged(U, in-game, rich)"
Note over U,NA: "heartbeat every ~10s refreshes TTL; silence -> key expires -> offline"
7. Flows¶
7.1 Send + accept a friend request¶
sequenceDiagram
actor A as "User A"
participant SA as "SDK A"
participant SVC as "lattice-social (any node)"
participant DB as "PostgreSQL"
participant BUS as "Redis Pub/Sub"
participant SB as "SDK B (B online)"
actor B as "User B"
A->>SA: "addFriend(handle#1234)"
SA->>SVC: "POST /v1/friends/requests {toHandle}"
SVC->>DB: "resolve B; check block/limits; upsert Relationship(pending)"
Note over SVC,DB: "if reciprocal pending exists -> auto-accept both sides"
SVC->>BUS: "PUBLISH notify:B {friendRequest from A}"
SVC-->>SA: "201 {requestId, state:pending}"
BUS-->>SB: "friendRequestReceived(A)"
SB-->>B: "incoming request UI"
B->>SB: "accept(requestId)"
SB->>SVC: "POST /v1/friends/requests/{id}/accept"
SVC->>DB: "set both Relationship rows -> accepted"
SVC->>BUS: "PUBLISH notify:A {accepted} + presence exchange A<->B"
SVC-->>SB: "200 {state:accepted}"
BUS-->>SA: "friendRequestAccepted(B) + B's presence"
SA-->>A: "B now in friends list (with presence)"
7.2 Party create → invite friend → friend joins¶
sequenceDiagram
actor L as "Leader"
participant SL as "SDK L"
participant SVC as "lattice-social"
participant DB as "PostgreSQL"
participant BUS as "Redis Pub/Sub"
participant SF as "SDK Friend"
actor F as "Friend"
L->>SL: "createParty(maxSize=4)"
SL->>SVC: "POST /v1/parties"
SVC->>DB: "INSERT Party + PartyMember(L, leader)"
SVC-->>SL: "201 {partyId}"
L->>SL: "inviteToParty(partyId, friendUserId)"
SL->>SVC: "POST /v1/parties/{id}/invites {toUserId}"
SVC->>DB: "INSERT Invite(kind=party, ttl)"
SVC->>BUS: "PUBLISH notify:F {partyInvite}"
BUS-->>SF: "partyInviteReceived(partyId, from=L)"
SF-->>F: "invite UI"
F->>SF: "joinParty(inviteId)"
SF->>SVC: "POST /v1/parties/{id}/join {inviteId}"
SVC->>DB: "validate invite + capacity; INSERT PartyMember(F)"
SVC->>BUS: "PUBLISH party:{id} {memberJoined F} + rich presence update"
SVC-->>SF: "200 {party roster}"
BUS-->>SL: "partyMemberJoined(F)"
SL-->>L: "roster updated"
7.3 "Join friend's game" — integrated path (+ standalone subset)¶
sequenceDiagram
actor U as "User"
participant SU as "SDK U (social + netcode)"
participant SVC as "lattice-social"
participant DIR as "lattice-director (8444)"
participant GS as "lattice-gameserver / host"
actor F as "Friend (in-game, joinable)"
Note over F: "Friend's rich presence: joinable=true, sessionHandle=H"
U->>SU: "joinFriendsGame(friendUserId)"
SU->>SVC: "GET /v1/presence/{friendUserId}"
SVC-->>SU: "RichPresence {joinable:true, sessionHandle:H, partyId?}"
alt Integrated mode (suite present)
SU->>DIR: "POST /resolve {sessionHandle:H} (+ social token)"
DIR->>DIR: "validate joinability / capacity / ACL"
DIR-->>SU: "{endpoint, sessionToken} (or relay info)"
Note over SU,GS: "Hand off to netcode connect flow — see 01 & 02-netcode-lld"
SU->>GS: "lattice-core connect(endpoint, sessionToken)"
GS-->>SU: "joined session (prediction/replication begin)"
else Standalone mode (no director)
Note over SU: "sessionHandle is opaque to lattice-social"
SU-->>SU: "return RichPresence to host app"
Note over SU,GS: "Host product resolves H with ITS OWN matchmaking/connect"
end
Standalone subset: §7.1 and §7.2 work fully standalone. In §7.3,
lattice-socialonly ever carries the opaquesessionHandlein rich presence/invites and hands it to the host app; the director resolve + netcode connect legs exist only in integrated mode. The social layer never needs to understand session semantics to deliver the standalone value.
8. Integration with Netcode & Director¶
These integrations are capability-gated; absent the suite, they no-op gracefully.
- Rich presence carries joinability.
RichPresenceadvertisesjoinable,partyId, and an opaquesessionHandle. This is the contract that makes "join game" possible without coupling social state to netcode internals. - "Join game" → session resolve → connect. The SDK reads a friend's rich presence, posts the
sessionHandletolattice-director(8444) to resolve an endpoint + a netcode session token (with joinability/capacity/ACL checks), then hands off to thelattice-coreconnect flow (dedicated server, listen-server host, or relay) — see 01-high-level-design.md and 02-netcode-lld.md. - Party → matchmaking handoff. A party leader queues the whole party through
lattice-director:lattice-socialprovides the authoritative roster (member ids + skill hints fromextra) and the director matches/places the group, returning asessionHandlethatlattice-socialthen writes into the party'sRichPresenceso members can join. The director, not social, owns matchmaking and fleet placement. - Decoupling guarantee. Social tokens and netcode session tokens are distinct;
lattice-socialholds no game-session authority and stores only the opaque handle. This is what lets the same social backend serve both Lattice games and entirely external products.
9. API Surface¶
Base: https://<host>/v1 (REST) and wss://<host>:9443/v1/ws (realtime). All require a bearer
token (§5). Responses are JSON; list endpoints are cursor-paginated; mutating endpoints are
idempotent where noted.
9.1 REST (CRUD)¶
| Method & Path | Purpose |
|---|---|
GET /me |
Current user + profile + settings (returns publicId + handle, never the account GUID). |
PATCH /me/profile |
Update avatar, status, presence visibility (+ displayName → name change). |
POST /me/name |
Change display name {displayName} (policy-gated; free by default — see §13.7). |
GET /users/search?handle= |
Find by handle / prefix (rate-limited, privacy-gated). |
GET /friends |
List friends (with presence summary + the caller's private note, §13.4). |
POST /friends/requests |
Send request {toHandle|toPublicId}. |
GET /friends/requests?dir=incoming|outgoing |
List pending requests. |
POST /friends/requests/{publicId}/accept |
Accept. |
POST /friends/requests/{publicId}/decline |
Decline. |
DELETE /friends/requests/{publicId} |
Cancel (sender). |
DELETE /friends/{publicId} |
Remove friend. |
POST /blocks/{publicId} / DELETE /blocks/{publicId} |
Block / unblock. |
POST /mutes/{publicId} / DELETE /mutes/{publicId} |
Mute / unmute. |
GET/PUT/DELETE /notes/{publicId} |
Private note about a user (author-only, §13.4). |
POST /parties |
Create party {maxSize, visibility}. |
GET /parties/{id} |
Party + roster (members by publicId/handle). |
POST /parties/{id}/invites |
Invite {toHandle|toPublicId} (gated, §13.2). |
POST /parties/{id}/join |
Join {inviteId} → JoinResultDto{party,join}; gated at admit (§13.2/§13.9). |
POST /parties/{id}/leave |
Leave. |
POST /parties/{id}/kick |
Kick {handle|publicId} (leader only). |
POST /parties/{id}/leader |
Transfer leader {toHandle|toPublicId} (leader only). |
POST /parties/{id}/visibility |
Set lobby privacy {visibility} (leader only, §13.2). |
POST /parties/{id}/join-metadata |
Set lobby join metadata {lobbyId, bag} (leader only, §13.9). |
POST /invites/game |
Send game invite {toHandle|toPublicId, sessionHandle?, join?} (§13.9). |
POST /invites/game/{id}/accept |
Accept a game invite → {join} join metadata (§13.9). |
GET /presence/{publicId} |
Read a friend's presence + rich presence (visibility-gated; lobby-overridden, §13.3). |
POST /reports |
Abuse report hook {targetHandle|targetPublicId, reason, context}. |
GET /me/export / DELETE /me |
Data export / deletion (privacy, §10). |
GET /chat/{kind}/{channelId}/messages (opt.) |
Chat history (DM or party). |
GET /me/feed (opt.) |
Activity feed. |
Identity in payloads (§13.5): path/body identifiers for users are the full handle (
Name#1234) or the opaquepublicId— never the internal account GUID. Resource ids (partyId,inviteId,requestId,messageId) remain GUIDs; they are not the account id.
9.2 WebSocket events (WSS 9443)¶
| Direction | Event | Payload |
|---|---|---|
| C→S | presenceUpdate |
{state, richPresence} — state ∈ online/away/busy/dnd/in-game/offline |
| C→S | heartbeat |
{} (refreshes presence TTL) |
| C→S | chatSend (opt.) |
{channel, channelId, body} |
| S→C | friendPresenceChanged |
{publicId, state, richPresence} (state is lobby-overridden, §13.3) |
| S→C | friendProfileChanged |
{publicId, handle, displayName} — rename/avatar; key on publicId, update displayed name (§13.8) |
| S→C | friendRequestReceived / friendRequestAccepted |
{publicId, requestId} |
| S→C | partyInviteReceived |
{partyId, fromPublicId, inviteId} |
| S→C | partyMemberJoined / partyMemberLeft / partyLeaderChanged |
{partyId, publicId} |
| S→C | gameInviteReceived |
{fromPublicId, sessionHandle, join:{lobbyId,bag}, inviteId} (§13.9) |
| S→C | chatMessage (opt.) |
{messageId, channel, channelId, fromPublicId, body, sentAt} |
| S→C | error |
{code, message} |
All user references in WS payloads are the opaque
publicId/fromPublicId— never the account GUID (§13.5).
10. Privacy & Safety¶
- Blocking semantics. A block is bidirectional in effect: it removes any friendship, cancels pending requests, prevents new requests/invites in either direction, and hides presence both ways. Mute is one-way and soft: it suppresses chat/notifications from the muted user while keeping the friendship intact.
- Presence visibility settings. Per-user
PresenceVisibility(everyone/friends/nobody) and aninvisible/appear-offlinemode (online to the suite for joinability but shown as offline to friends). All presence reads (REST and fanout) are gated by the target's visibility and the viewer's block/mute state. Rich presence has its own coarser gate so a user can be shown online without leaking what they are playing. - Report / abuse hook.
POST /reportsrecords a structured report (target, reason, captured context such as a chat window or party id) and emits it to a configurable sink (NATS subject, webhook, or table) for the host product's moderation pipeline.lattice-socialprovides the hook, not the review tooling. - Data deletion & export.
GET /me/exportreturns the user's social data;DELETE /meperforms a GDPR-style erasure: removes profile/relationships/party memberships, tombstones the user id so dangling references resolve to a redacted placeholder, purges Redis presence, and optionally hard-deletes or anonymizes chat. Deletion is propagated to friends as a friend-removed event.
11. Scaling & High Availability¶
- Stateless horizontal scale. Add
lattice-socialnodes behind the LB to add REST + WS capacity; nodes share nothing locally (§3.2). Each node maintains many WS connections (target tens of thousands per node) and subscribes to the bus only for its connected users. - Pub/Sub backbone. Redis Pub/Sub is the default fanout bus; NATS is the drop-in upgrade for durable delivery, larger fan-out, and multi-region streaming. Hot-user fan-out-on-read (§6.3) caps worst-case broadcast cost; debounce/coalescing protects the bus from flap storms.
- Store HA. PostgreSQL runs with a primary + streaming replicas (reads can be served from replicas; writes go to the primary) and automated failover. Redis runs clustered/replicated (Sentinel or Cluster) so presence/pub-sub survives a node loss; presence is ephemeral by design — a Redis blip degrades to "everyone reconnects and re-asserts presence," never data loss of the durable graph.
- Graceful node loss / rollout. On node drain, WS clients are disconnected and reconnect through the LB to a survivor; presence keys expire/refresh naturally. Stateless nodes make rolling deploys and autoscaling trivial — the same "act as one" property the auth service relies on (see 03-auth-service.md).
- Regionalization. Social is latency-tolerant (it is not the netcode hot path), so a single region with read replicas suffices for most products; NATS enables cross-region fanout if a global friends graph is required. See 06-implementation-roadmap.md for phasing (standalone library first, suite integrations and chat/feed as later phases).
13. Extended Social Rules (presence states, lobby gating/override, notes, identity)¶
These rules extend §2/§4/§6/§9 with the precise semantics implemented in lattice-social.
13.1 Presence states & precedence (DnD / Busy)¶
PresenceState is Offline < Away < Online < Busy < DoNotDisturb < InGame; the numeric order is the
auto "most active" rank used to pick a default across devices. Busy and DoNotDisturb (DnD)
are explicit, user-set states (a device session carries isExplicit). The multi-device aggregate:
- If any live device carries an explicit Busy/DnD, that is the user's intended displayed state —
it overrides the auto rank, including auto-
Away(idle) and evenInGameon another device. - Among explicit states, DnD outranks Busy.
- Otherwise the auto "most active" wins (
InGame > Online > Away > Offline).
Rationale: an explicit "do not disturb" is a deliberate signal and must not be silently upgraded to
InGame/Onlinejust because another device is more active.
13.2 Lobby/party privacy + invite/join gating¶
A party (lobby) has a privacy setting — Public / FriendsOnly / Private — set by the leader
(POST /v1/parties/{id}/visibility), and a host (the leader) with a presence status. Entry into
a lobby fails with HTTP 403 when the host's status is DnD/Busy (reason lobby_host_unavailable)
or the lobby privacy is Private (reason lobby_private). The reason code is returned in the
response error field; a pre-existing block is checked first and reports blocked.
Confirmed rule — the host is the exception:
- Host invite = always allowed. The host (leader) can send invites regardless of their own DnD/Busy status or the lobby privacy. ("Only the host should be able to invite if set to DnD or Busy.")
- Member invite = gated. A non-host member's invite is rejected (403 + reason) when the host is DnD/Busy OR the lobby is Private; it is allowed only when the host is Online/Away/InGame and the lobby is not Private.
- Join (admit time) = always gated.
POST /v1/parties/{id}/joinre-checks the gate at admission, so nobody enters a DnD/Busy/Private lobby. Joining requires a valid invite addressed to the joiner; an uninvited join is rejected outright. Net effect: under DnD/Busy/Private you can only get in via an explicit invite — which only the host can send.
Why the host is exempt at invite time: a host populating their own private/DnD lobby is legitimate, and the joiner-facing guarantee — you cannot get into a gated lobby — is preserved unconditionally by the Join gate. The invite is only the announcement; admission decides entry. Tests cover host-exception (
Invite_Host_*_Allowed), the member gate (Invite_Member_*_Blocked/Invite_Member_WhenHostAvailable_AndNotPrivate_Allowed), and the join gate (Join_GatedWhenHostBecomesDnd_AfterInvite,Join_GatedWhenLobbyPrivate_EvenViaHostInvite,UninvitedJoin_WithoutInvite_IsRejected).
13.3 In-lobby presence override¶
Default rule (implemented): while a user is in a lobby, the presence shown to their friends is overridden to mirror the host's status (host = DnD ⇒ every member appears DnD to their friends).
- Reverts to the member's own presence when they leave / are kicked / the lobby disbands / leadership transfers (the new host's status then applies).
- An offline member stays offline — the override never resurrects presence.
- A user viewing themselves always sees their own un-overridden state.
- It is propagated via the existing presence fanout (per-recipient channels), so a friend subscribed
over WS sees the overridden value; it is computed live in one place (
GetDisplayedState) and is therefore easy to change.
Note the two are independent: the override applies whenever in a lobby (members mirror the host); the invite/join block additionally triggers on host DnD/Busy or Private privacy.
13.4 Private user notes¶
UserNote {ownerId, subjectId, text, updatedAt} is a private, author-only note about another
user. GET/PUT/DELETE /v1/notes/{publicId} manage it (empty text clears it); it appears inline in the
author's GET /v1/friends response (note field) and is never visible to anyone else and never
fanned out.
13.5 Three-tier identity (hidden GUID, stable public id, mutable handle)¶
Identity uses three distinct identifiers so that references survive a rename:
- Internal account GUID (
User.userId, the "sub") — backend-only storage key: the unique(provider, externalId)mapping target and the key under which relationships, notes and party memberships are stored. It never appears in any public API response or WS payload. - Stable public id (
User.publicId) — a separate, immutable, opaque id generated per account (base64url of a fresh random GUID, with no relation to the account GUID, so exposing it never leaks the account GUID). This is the canonical id all public references use — friend-list entries, presence/join-routing, party invites, blocks, andUserNote.subjectId. It is immutable and does not change on rename. - Handle
Name#1234(display name + discriminator) — display + search/discovery only, mutable.
Rule of thumb: references/keys in payloads = the stable public id; human display = handle; friend
search / friend-request-by-name = handle; internal storage key = the hidden GUID. Do not use the
account GUID for references, and do not key references on the mutable handle. All
friend/party/presence/notes/invite responses and all WS payloads carry publicId (+ handle); lookups
accept a handle or publicId. Resource ids (partyId, inviteId, requestId, messageId) remain
GUIDs — they identify resources, not the account.
Because every reference keys on the immutable
publicId(and internally on the immutable account GUID), a display-name change cannot break a friendship, pending invite, block, or note — only the displayed name changes. See §13.8.
13.6 Public handle (Name#1234)¶
Every user is provisioned with displayName + '#' + 4 random digits (a discriminator, zero-padded to
4 digits in the address form). The discriminator is best-effort-unique per displayName: the
4-digit roll retries on collision within the same displayName. The full handle is exposed publicly and
supports lookup / friend-request by handle. The handle is display + discovery only — never a
reference key (§13.5).
13.7 Display-name change + policy seam¶
POST /v1/me/name changes the display name. It is gated by an INameChangePolicy seam whose
default implementation always allows the change for free. A future build can require a
purchase/entitlement by registering a different policy in DI — with no rework to the endpoint or
service. Discriminator rule: keep the current discriminator if NewName#disc is still free,
otherwise re-roll a fresh one for the new name (a no-op rename keeps it). The publicId and account
GUID are stable across name changes, so all existing references continue to resolve (§13.8).
13.8 Realtime profile/rename updates¶
When a user changes their display name (or avatar/profile), the service fans out a
friendProfileChanged WS event to their friends (RelationshipService.FanOutProfileChangedAsync,
driven by POST /v1/me/name and PATCH /v1/me/profile). The payload is {publicId, handle,
displayName} — the unchanged stable public id plus the new handle/displayName — so clients
update the displayed name live while continuing to key the friend (and every friendship / invite /
note / block) on the immutable publicId. Block-gated like other friend fanout.
13.9 Join metadata (lobbyId + opaque bag)¶
RichPresence and the party/lobby carry game-defined join metadata so a joining game knows
how to actually connect (JoinMetadata): a first-class lobbyId plus an extensible, fully
opaque bag (Dictionary<string,string>) the game fills with whatever its networking tech needs.
Netcode-agnostic by design. Because lattice-social is shippable standalone (§1.3), the join
payload hard-depends on no Lattice/director field. The service never interprets the bag,
requires no field, never calls lattice-director, and never validates bag contents — it only
stores, transports, and returns it verbatim, size-limited (MaxEntries = 32, MaxTotalChars = 8 KiB;
overflow → 400). lobbyId is kept distinct/first-class; everything else lives in the generic bag.
- Set it:
joinonPOST /v1/parties(create),POST /v1/parties/{id}/join-metadata(leader),joinonPOST /v1/presenceand thepresenceUpdateWS frame, andjoinonPOST /v1/invites/game. - Returned on join — gated: a successful
POST /v1/parties/{id}/joinreturnsJoinResultDto { party, join };POST /v1/invites/game/{id}/acceptreturns{ join }. The lobby gate (§13.2) is enforced at admit time before any metadata is returned, so a gated join fails with 403 and yields no metadata — the bag never leaks to someone who cannot enter. Game invites carry the same metadata, so accepting an invite alone is sufficient to join.
Two connect paths (both supported; neither is required):
- Lattice default (blessed convention). A suite game puts a director-resolvable handle under the
optional well-known key
JoinMetadata.LatticeSessionHandleKey="lattice.sessionHandle". The joiner reads it →lattice-director POST /resolve→ endpoint + session token → netcode connect (§7.3/§8). The legacysessionHandleargument onPOST /v1/invites/gameis mirrored into the bag under this same key for back-compat. The service still never reads or requires it. - Bring-your-own-netcode (standalone, no Lattice). A game on any stack — Steam lobbies, Epic
EOS, Photon, Mirror/FishNet, a raw
host:port, or anything custom — puts its own connect info in the bag under its own keys (e.g.steam.lobbyId,endpoint,photon.room,password). It round-trips verbatim through set-presence/invite/join with zero Lattice involvement; no Lattice key is present or needed.
Tests cover both: ByoNetcode_SteamLobby_RoundTripsThroughPresence_NoLatticeField and
ByoNetcode_RawHostPort_RoundTripsThroughInviteAndAccept (no Lattice field anywhere) alongside
LatticeConvention_WellKnownKey_RoundTrips.
14. Multi-tenancy & cross-game visibility (P2)¶
lattice-social runs on the shared Lattice hosted platform: many games (tenants), registered in
lattice-console, share one social service. P2 makes the friend graph global while letting each
game control how far its players' social graph reaches across the other games.
14.1 Global friend graph¶
There is one account per person (keyed by the immutable account sub / publicId, §13.5) and a
single global friend graph: a friendship is not per-game. Sending/accepting one friend request
makes two accounts friends regardless of which game either was playing — friend requests cross games
freely. Presence, parties, blocks and notes are unchanged; tenancy is layered on top.
14.2 Per-game registration awareness & the originating gameId¶
A social request is made in the context of a game — the caller's game, resolved from its
console-issued credentials presented as X-Lattice-Game-Key / X-Lattice-Game-Secret. The
TenancyMiddleware resolves them to a gameId (via ILatticeConsoleClient), records that the
authenticated user is registered with that game (the per-user set of gameIds), and stashes a
GameContext { GameId, Visibility } on the request. Presence (and rich presence) carry the
originating gameId (PresenceSession.OriginGameId / RichPresence.OriginGameId) so a client can
tell same-game from different-game friends; the friends list flags each entry with sameGame.
With no game key the request is untenanted (single-tenant / standalone) and visibility is
unrestricted — so the service still ships standalone.
14.3 Cross-game visibility enforcement¶
Each game has a CrossGameVisibility setting obtained from lattice-console:
- All — see friends active/registered in any game.
- None — see only same-game friends (isolated tenant).
- Specific {gameIds} — same-game friends plus friends registered in the allow-listed games.
Same-game friends are always visible (a friend co-registered in the caller's own game). The filter is enforced in both directions:
- Friends list (
GET /v1/friends) — each global friend is listed only if the caller game's setting admits the game(s) that friend is registered in (TenancyService.IsFriendVisible). - Presence fanout — when a user's presence changes, it is delivered to a friend only if that
recipient's game(s) setting admits the sender's games (
TenancyService.IsPresenceVisibleToAsync).
The safe default for an unknown/misconfigured game is None (never over-share across tenants).
14.4 Console integration (consumer only)¶
lattice-social is a consumer of lattice-console; it never modifies it. The seam is
ILatticeConsoleClient with two operations: ResolveGameKeyAsync(apiKey, apiSecret) → gameId? and
GetCrossGameVisibilityAsync(gameId) → CrossGameVisibility. Two implementations:
StubLatticeConsoleClient— in-process model of the console game registry; the dev/test default and the standalone path.HttpLatticeConsoleClient— calls the real console over HTTP (POST /auth/validate-keyfor key resolution;GET /games/{id}for the visibility setting). Selected by settingSocial:Console:BaseUrl(one line inProgram.cs); console stays untouched.
Tests: TenancyTests (service-level: global graph; originating gameId on presence; All/None/Specific
filtering; fanout All delivers cross-game, None suppresses other-game but keeps same-game) and
TenancyHttpTests (full HTTP pipeline: cross-game friend request; None hides / All shows; same-game
always visible + sameGame flag; Specific = same-game + allow-listed; presence carries originGameId;
untenanted is unfiltered).