ADR-0007: Multi-game dashboard design¶
Status¶
Accepted
Context¶
The initial dashboard was designed as a single-game control plane. Every
entity that required Roblox-side integration — feature flags, bans, mutes,
matches, metric points — had no gameId foreign key and was therefore
game-blind. The Open Cloud bridges (featureflags-bridge.ts,
moderation-bridge.ts) routed every call to the single ROBLOX_UNIVERSE_ID
environment variable, making it impossible to manage multiple title
experiences from one dashboard without code changes.
The platform roadmap calls for shipping several distinct Roblox experiences (obby, test-park, future titles) all operated by the same team with the same tooling. The dashboard must therefore act as a multi-game control plane — registering each experience and routing all actions to the correct universe.
Constraints¶
- Backward compatibility: existing rows with
gameId = NULLmust continue to work without a migration that rewrites every record. - The Flags API is consumed by in-game
HttpServicecalls; changing its interface must be additive. - RBAC already covers all action types and must simply gain game-management permissions.
Decision¶
1 Game registry model¶
A new Game Prisma model is the root anchor for all per-experience
configuration:
model Game {
id String @id @default(cuid())
name String
slug String @unique
description String?
iconUrl String?
// Per-environment Roblox identifiers
universeIdDev BigInt?
universeIdStage BigInt?
universeIdProd BigInt?
placeIdDev BigInt?
placeIdStage BigInt?
placeIdProd BigInt?
isActive Boolean @default(true)
createdById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Roblox IDs are per-environment so that one slug can span a dev place, a staging universe, and the production universe with independent routing.
2 Optional gameId foreign key on every controllable entity¶
All entities that flow to Roblox receive an optional FK:
| Entity | gameId semantics |
|---|---|
FeatureFlag |
Scoped flag; null = global (applies to all games) |
Ban |
Game-specific ban; null = platform-wide ban |
Mute |
Game-specific mute; null = platform-wide mute |
Match |
Always game-scoped (null only for legacy rows) |
MetricPoint |
Scoped metric; null = platform-wide / non-game metric |
AuditLog |
Contextual; null = admin action not tied to a game |
Using nullable rather than required keeps all pre-existing rows valid without a destructive migration.
3 Flag key uniqueness per game¶
The FeatureFlag.key column changes from @unique to @@unique([key,
gameId]):
- A global flag (
gameId = null) and a game-specific flag may share the same key — the game-specific value shadows the global one in API responses. - Two game-specific flags for different games may share the same key.
4 Flags API additive game resolution¶
GET /api/flags/[environment] accepts two optional query parameters:
| Parameter | Resolution strategy |
|---|---|
?gameId= |
Direct CUID lookup in the Game table |
?universeId= |
Looks up the universeId{Env} column for the given env |
When a game is resolved the response merges:
- All global flags (
gameId = null) - All game-specific flags for the resolved game
Game-specific flags win on key conflicts. If no query parameter is supplied the response contains only global flags (identical to the legacy behavior).
5 Per-game bridge routing¶
Both Open Cloud bridge modules accept an optional universeId / universeIds
argument instead of always reading ROBLOX_UNIVERSE_ID from the environment:
featureflags-bridge:bridgeSyncFeatureFlagsToRoblox(flags, universeIds?)— the caller resolves the game'suniverseId{Env}values and passes them; the bridge falls back toROBLOX_UNIVERSE_IDwhen the argument is absent.moderation-bridge: every function acceptsuniverseId?: numberwith the same fallback semantics.
This keeps the bridges environment-variable-driven for global/legacy use while enabling per-game routing for new calls.
6 RBAC additions¶
Four new permission tokens are added to the capability matrix:
| Permission | Granted to |
|---|---|
games:view |
VIEWER, SUPPORT, MODERATOR, ENGINEER, ADMIN |
games:create |
ADMIN only |
games:manage |
ENGINEER, ADMIN |
games:delete |
ADMIN only |
7 Dashboard surfaces¶
New UI surfaces added to the dashboard:
/games— game card grid with per-game flag/match/ban counts and linked environment badges./games/[id]— per-game control page with Roblox identifier table, stat cards, and quick links to game-scoped views of flags, matches, moderation, players, and audit log.- Dashboard home — "Games" stat card (count of active experiences) and a game overview grid replacing the old "Mod Actions" card.
Consequences¶
Positive¶
- A single dashboard instance manages all current and future Roblox experiences.
- Flag namespacing is unambiguous: the same key can have different values per game without collision.
- Bridge routing is data-driven (stored in the DB) rather than hard-coded in env vars, removing the need to re-deploy when Roblox IDs change.
- Backward compatible:
NULLgame IDs continue to work as "global/platform" scope on all entities.
Negative / trade-offs¶
- A schema migration is required to add the
Gametable andgameIdcolumns. Existing rows are unaffected (columns default toNULL). - Callers of the Flags API that relied on a flat, unique key namespace must be
reviewed if they may now receive game-shadowed values unexpectedly (they
won't unless they pass
?gameIdor?universeId, so the default path is unchanged). - Introducing a nullable FK on
Ban/Mutecreates two conceptual scopes (global vs. game-local); operators must understand the distinction when issuing moderation actions.
Alternatives considered¶
Hard-code one universe per environment via env vars (status quo)¶
Simple but does not scale beyond one experience. Ruled out immediately given the roadmap.
Separate dashboard instance per game¶
Operationally expensive (N deployments, N databases, N config sets) and makes cross-game analytics impossible. Ruled out.
Require gameId (non-nullable) on all entities¶
Forces a destructive migration and breaks the concept of platform-wide (cross-game) bans and global feature flags. Ruled out in favor of nullable.