Teams architecture
Most games have groups: guilds, clans, corporations, tribes, crews. Runic Gateway supports them as a core platform primitive, while core itself never learns what yours is called.
The administrator’s view is Teams.
The sentence the design turns on
Section titled “The sentence the design turns on”Teams is a contract, not a surface.
Core owns the tables, the sync, the access rules and the activity feed. It does not own the word for a Team, and therefore does not own the Team page. The module that owns the vocabulary owns the page.
That was not the first design. Core originally rendered Team pages with slots a module
filled. It was inverted, and the inversion is the interesting part: instead of core naming
places for a module’s content, a module declares a place on its own page for core to
fill — registry.declareModuleSlot(id, name, { core }), with core offering contributions
rather than naming slots.
Six invariants
Section titled “Six invariants”Each has a test named against it.
- Module unavailability is staleness, never emptiness. No Team subsystem may apply a destructive result derived from a failed, timed-out or unanswered module call.
- Four authority paths stay four. Game membership, leadership, forum access and external-platform access are separate tables answering separate questions, resolved by separate predicates. No predicate reads another’s table.
- Non-contamination. A manual forum grant never writes the membership projection, in either direction, ever. Both facts coexist; neither migrates into the other.
- A Team’s name is immutable for the life of its record. A rename is an archive plus a create.
- Core never interprets module vocabulary. Activity kinds, Team metadata and capability strings are opaque. Core stores, gates and displays; it never branches on content it does not own.
- The game never touches the website. Everything crosses the sidecar.
Invariant 1 deserves emphasis, because it is the one a naive implementation gets wrong: if the module fails to answer “who is in this Team?”, the answer is not “nobody”. Treating a timeout as an empty roster would silently disband every Team on the site.
The rename rule
Section titled “The rename rule”Core’s key is (module_id, external_id, name) taken together — not external_id
alone.
| Situation | What core does |
|---|---|
New external_id |
Create a Team |
| Known id, same name | Update in place |
| Known id, different name | Archive the row and create a new one |
| Id absent from an authoritative full list | Archive as disbanded, subject to invariant 1 |
The archived Team keeps its forum, activity history, grants and integration record; all
become read-only. It stays reachable at its old slug, noindex, with a banner linking to
the successor — so a Discord message from before the rename lands somewhere that explains
itself instead of 404-ing.
This puts the whole of “is this a rename or a different group?” inside the module. If
your game has no persistent group id, synthesise external_id from whatever is stable, or
fold the name into it so every rename is a fresh id. Core only ever sees “an id appeared /
an id’s name changed / an id is gone”.
The module-facing interface
Section titled “The module-facing interface”A module registers a provider:
api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })and pushes through ctx.teams:
| Call | What it does |
|---|---|
ctx.teams.publish(event) |
An optimisation — makes a membership change visible at once |
ctx.teams.reconcile({ reason }) |
A debounced request; returns immediately |
ctx.teams.activity.push(items) |
Writes the per-Team feed |
ctx.teams is push-only, and that is the contract. There is no reader. A module
answers questions about Teams; it does not ask them. A getTeamRoster would be core
offering to read back the module’s own answer — which the module already holds.
All three are fire-and-forget and never reject, because they are called from inside game-event handlers and a storage problem of core’s must not become the module’s control flow. Correctness comes from reconciliation either way.
The six event kinds
Section titled “The six event kinds”team.created · team.disbanded · team.member.added · team.member.removed ·
team.leader.added · team.leader.removed
Six rather than four because leadership is its own authority path: a leadership change has to be expressible without pretending someone joined or left.
team.created and team.disbanded only ask for a reconciliation. Core will not invent
a Team from a delta — it would have no name, no roster and no leaders — and will not archive
one from a delta either, because an archive driven by a message that may simply have been
repeated is destruction on no evidence.
The activity feed
Section titled “The activity feed”Each item carries an already-rendered summary, which core stores verbatim. Core cannot
phrase “gained 15,000 gold” for a game whose vocabulary it does not know, and a core that
templated it would have re-acquired exactly the semantics the module system exists to
remove.
visibility defaults to 'members' — fail closed. The module chooses it per item; core
enforces it on read.
A dedupeKey collision is a successful no-op, which is what makes a sidecar reconnect
backfill safe to replay.
Untrusted game data becomes a public page
Section titled “Untrusted game data becomes a public page”This is the sharpest edge in the whole subsystem: a group name chosen by a player becomes a page on a public website.
So game-sourced names go through reserved-name screening, and game-sourced overrides through an approval gate. Neither is optional, and neither is something a module can waive.
What is deliberately out of scope
Section titled “What is deliberately out of scope”Multi-module namespacing, Team hierarchies and alliances, cross-Team messaging, and platform-only Teams with no game backing.
Matrix is research, not a roadmap item. Of the five capabilities a shared interface would name, a Matrix implementation could honestly provide two — it has no channel-with-overwrites, no role object, no voice channel, and no slash-command registration. The settled outcome was a capability contract, not an integration.
Canonical document
Section titled “Canonical document”TEAMS.md
is normative — Part 1 for the invariants, Part 2 for the core, Parts 3–4 for pages and the
activity feed.