Skip to content

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.

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.

Each has a test named against it.

  1. Module unavailability is staleness, never emptiness. No Team subsystem may apply a destructive result derived from a failed, timed-out or unanswered module call.
  2. 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.
  3. Non-contamination. A manual forum grant never writes the membership projection, in either direction, ever. Both facts coexist; neither migrates into the other.
  4. A Team’s name is immutable for the life of its record. A rename is an archive plus a create.
  5. 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.
  6. 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.

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”.

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.

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.

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.

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.

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.

TEAMS.md is normative — Part 1 for the invariants, Part 2 for the core, Parts 3–4 for pages and the activity feed.