From 3b84a6f569e9ba8cc841e147578ec120ed266eb9 Mon Sep 17 00:00:00 2001
From: Zixer1 <99333209+Zixer1@users.noreply.github.com>
Date: Wed, 24 Jun 2026 10:54:44 -0400
Subject: [PATCH] Feat/anonymize names (#4318)
**Add approved & assigned issue number here:**
Resolves #4296
## Description:
Adds an "Anonymous players" option to private lobbies (host toggle, off
by default).
When it is on, the server sends each client anonymized usernames for
everyone except themselves. The lobby creator and admins still see real
names so they can moderate. Names are hidden on every player-facing
surface: the game start message, lobby info, /api/game/:id, and the link
preview. It is enforced server-side, so a client extension cannot read
real names off the wire. Initially added as part of our overhaul of
OpenFront masters, but this feature can very well be useful for content
creators, and other tournament hosts.
Anonymized names reuse the existing tribe word lists (no emoji), so they
pass UsernameSchema, and they are seeded per user, so a player looks
different to different users but stays consistent from the lobby into
the game.
The saved game record keeps real names (anonymization is a per-send
transform, gameStartInfo is never mutated), so replays and stats are
unaffected. Nothing changes for normal games.
New option selection:
The host, when enabling the option, gets a little eye icon next to the
players(including himself to enable/disable the anon names for himself,
and/or other player)
By default(the names everyone will see are random and unique):
Toggling the eye ON for yourself (the host, or any given player, will
allow them to see the real names of everyone, in the lobby and in game):
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
## Please put your Discord username so you can be contacted if a bug or
regression is found:
zixer._
---
resources/lang/en.json | 2 +
src/client/HostLobbyModal.ts | 25 +++
src/client/components/LobbyPlayerView.ts | 20 ++
src/core/Schemas.ts | 4 +
src/core/Util.ts | 12 ++
src/server/GameServer.ts | 88 +++++++--
tests/AnonymousUsername.test.ts | 25 +++
tests/server/AnonymizeNames.test.ts | 229 +++++++++++++++++++++++
8 files changed, 394 insertions(+), 11 deletions(-)
create mode 100644 tests/AnonymousUsername.test.ts
create mode 100644 tests/server/AnonymizeNames.test.ts
diff --git a/resources/lang/en.json b/resources/lang/en.json
index fb48a8819..be5ce87c5 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -697,6 +697,7 @@
"video_tutorial_title": "OpenFront.io Tutorial"
},
"host_modal": {
+ "anonymous_players": "Anonymous players",
"assigned_teams": "Assigned Teams",
"bots": "Tribes: ",
"bots_disabled": "Disabled",
@@ -744,6 +745,7 @@
"teams_Quads": "Quads (teams of 4)",
"teams_Trios": "Trios (teams of 3)",
"title": "Create Private Lobby",
+ "toggle_name_reveal": "Toggle whether this player can see real names",
"waiting": "Waiting for players...",
"water_nukes": "Water nukes"
},
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index c13c13874..878aa8084 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -79,6 +79,8 @@ export class HostLobbyModal extends BaseModal {
@state() private startingGold: boolean = false;
@state() private startingGoldValue: number | undefined = undefined;
@state() private disableAlliances: boolean = false;
+ @state() private anonymizeNames: boolean = false;
+ @state() private nameReveals: string[] = [];
@state() private whitelistEnabled: boolean = false;
@state() private allowedPublicIds: string = "";
@state() private waterNukes: boolean = false;
@@ -413,6 +415,10 @@ export class HostLobbyModal extends BaseModal {
labelKey: "host_modal.disable_alliances",
checked: this.disableAlliances,
},
+ {
+ labelKey: "host_modal.anonymous_players",
+ checked: this.anonymizeNames,
+ },
{
labelKey: "host_modal.water_nukes",
checked: this.waterNukes,
@@ -466,6 +472,10 @@ export class HostLobbyModal extends BaseModal {
.teamCount=${this.teamCount}
.nationCount=${this.nations}
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
+ .onToggleNameReveal=${(clientID: string) =>
+ this.toggleNameReveal(clientID)}
+ .nameReveals=${this.nameReveals}
+ .anonymizeNames=${this.anonymizeNames}
>
@@ -595,6 +605,8 @@ export class HostLobbyModal extends BaseModal {
this.startingGold = false;
this.startingGoldValue = undefined;
this.disableAlliances = false;
+ this.anonymizeNames = false;
+ this.nameReveals = [];
this.whitelistEnabled = false;
this.allowedPublicIds = "";
this.waterNukes = false;
@@ -685,6 +697,10 @@ export class HostLobbyModal extends BaseModal {
this.disableAlliances = checked;
this.putGameConfig();
break;
+ case "host_modal.anonymous_players":
+ this.anonymizeNames = checked;
+ this.putGameConfig();
+ break;
case "host_modal.water_nukes":
this.waterNukes = checked;
this.putGameConfig();
@@ -1072,6 +1088,8 @@ export class HostLobbyModal extends BaseModal {
? Math.round(this.startingGoldValue * 1_000_000)
: null,
disableAlliances: this.disableAlliances || null,
+ anonymizeNames: this.anonymizeNames,
+ nameReveals: this.nameReveals,
allowedPublicIds: this.whitelistEnabled
? (this.parseAllowedPublicIds() ?? [])
: [],
@@ -1099,6 +1117,13 @@ export class HostLobbyModal extends BaseModal {
);
}
+ private toggleNameReveal(clientID: string) {
+ this.nameReveals = this.nameReveals.includes(clientID)
+ ? this.nameReveals.filter((c) => c !== clientID)
+ : [...this.nameReveals, clientID];
+ this.putGameConfig();
+ }
+
private async toggleGameStartTimer() {
await this.putGameConfig();
console.log(
diff --git a/src/client/components/LobbyPlayerView.ts b/src/client/components/LobbyPlayerView.ts
index ad7f232f4..a336d5a71 100644
--- a/src/client/components/LobbyPlayerView.ts
+++ b/src/client/components/LobbyPlayerView.ts
@@ -34,6 +34,9 @@ export class LobbyTeamView extends LitElement {
@property({ type: String }) currentClientID: string = "";
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
+ @property({ type: Function }) onToggleNameReveal?: (clientID: string) => void;
+ @property({ type: Array }) nameReveals: string[] = [];
+ @property({ type: Boolean }) anonymizeNames: boolean = false;
@property({ type: Number }) nationCount: number = 0;
@property({ type: Boolean }) isPublicGame: boolean = false;
@@ -167,6 +170,21 @@ export class LobbyTeamView extends LitElement {
`;
}
+ // Host-only per-player toggle for who may see real names under anonymizeNames.
+ private renderRevealToggle(clientID: string) {
+ if (!this.onToggleNameReveal || !this.anonymizeNames) return html``;
+ const on = this.nameReveals.includes(clientID);
+ return html``;
+ }
+
private renderFreeForAll() {
return html`${repeat(
this.clients,
@@ -179,6 +197,7 @@ export class LobbyTeamView extends LitElement {
: ""}"
>
${displayName}
+ ${this.renderRevealToggle(client.clientID)}
${client.clientID === this.lobbyCreatorClientID
? html`(${translateText("host_modal.host_badge")})
${displayName}
+ ${this.renderRevealToggle(p.clientID)}
${p.clientID === this.lobbyCreatorClientID
? html`(${translateText("host_modal.host_badge")})= 3 ? name : "Player";
+}
+
export const emojiTable = [
["π", "π", "π₯°", "π", "π"],
["π", "π₯Ί", "π", "π±", "π‘"],
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index 2672c87b7..43b7b5d55 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -25,7 +25,7 @@ import {
StampedIntent,
Turn,
} from "../core/Schemas";
-import { createPartialGameRecord } from "../core/Util";
+import { anonymousUsername, createPartialGameRecord } from "../core/Util";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter";
@@ -136,6 +136,31 @@ export class GameServer {
: undefined;
}
+ // anonymizeNames: only players the host granted (nameReveals) see real names.
+ // Nobody is exempt by default, not even the host, until he grants them.
+ private viewerSeesAllNames(viewer: ClientID | undefined): boolean {
+ return (
+ viewer !== undefined &&
+ (this.gameConfig.nameReveals?.includes(viewer) ?? false)
+ );
+ }
+
+ // Same (viewer, target) -> same name in the lobby and in-game.
+ private anonName(viewer: ClientID | undefined, target: ClientID): string {
+ return anonymousUsername(target + (viewer ?? ""));
+ }
+
+ // Whether `viewer` should see `target`'s real identity: when names aren't
+ // anonymized, when looking at themselves, or when the host granted the
+ // viewer reveal access (nameReveals).
+ private seesReal(viewer: ClientID | undefined, target: ClientID): boolean {
+ return (
+ !this.gameConfig.anonymizeNames ||
+ target === viewer ||
+ this.viewerSeesAllNames(viewer)
+ );
+ }
+
public updateGameConfig(gameConfig: Partial): void {
if (gameConfig.gameMap !== undefined) {
this.gameConfig.gameMap = gameConfig.gameMap;
@@ -205,6 +230,12 @@ export class GameServer {
if (gameConfig.waterNukes !== undefined) {
this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined;
}
+ if (gameConfig.anonymizeNames !== undefined) {
+ this.gameConfig.anonymizeNames = gameConfig.anonymizeNames;
+ }
+ if (gameConfig.nameReveals !== undefined) {
+ this.gameConfig.nameReveals = gameConfig.nameReveals;
+ }
// Unconditional on purpose: the host clears cheats by omitting hostCheats
// (the full config it sends has hostCheats: undefined when the toggle is
// off), so `undefined` here means "clear", not "leave unchanged".
@@ -715,12 +746,13 @@ export class GameServer {
}
private broadcastLobbyInfo() {
- const lobbyInfo = this.gameInfo();
+ // Off: same payload for everyone (build once). On: per-recipient.
+ const shared = this.gameConfig.anonymizeNames ? null : this.gameInfo();
this.activeClients.forEach((c) => {
if (c.ws.readyState === WebSocket.OPEN) {
const msg = JSON.stringify({
type: "lobby_info",
- lobby: lobbyInfo,
+ lobby: shared ?? this.gameInfo(c.clientID),
myClientID: c.clientID,
} satisfies ServerLobbyInfoMessage);
c.ws.send(msg);
@@ -787,6 +819,30 @@ export class GameServer {
this.intents.push(intent);
}
+ // Per-viewer start info. The real gameStartInfo is untouched, so the
+ // archived record keeps real identities. clanTag and friends feed the
+ // deterministic team assignment (TeamAssignment.ts), so they are blanked
+ // for every player here, identical on every client, never per-viewer, or
+ // clients desync. Only the username of players this viewer can't see is
+ // anonymized, and their cosmetics hidden, neither of which the simulation
+ // reads.
+ private startInfoFor(viewer: ClientID): GameStartInfo {
+ if (!this.gameConfig.anonymizeNames) return this.wireGameStartInfo;
+ return {
+ ...this.wireGameStartInfo,
+ players: this.wireGameStartInfo.players.map((p) => {
+ const real = this.seesReal(viewer, p.clientID);
+ return {
+ ...p,
+ username: real ? p.username : this.anonName(viewer, p.clientID),
+ clanTag: null,
+ friends: undefined,
+ cosmetics: real ? p.cosmetics : undefined,
+ };
+ }),
+ };
+ }
+
private sendStartGameMsg(ws: WebSocket, lastTurn: number) {
// Find which client this websocket belongs to
const client = this.activeClients.find((c) => c.ws === ws);
@@ -813,7 +869,7 @@ export class GameServer {
JSON.stringify({
type: "start",
turns: this.turns.slice(lastTurn),
- gameStartInfo: this.wireGameStartInfo,
+ gameStartInfo: this.startInfoFor(client.clientID),
lobbyCreatedAt: this.createdAt,
myClientID: client.clientID,
} satisfies ServerStartGameMessage),
@@ -954,17 +1010,27 @@ export class GameServer {
return this._hasStarted || this._hasPrestarted;
}
- public gameInfo(): GameInfo {
+ // Omitting viewer (e.g. the HTTP /api/game/:id and link-preview routes)
+ // anonymizes all names when the option is on.
+ public gameInfo(viewer?: ClientID): GameInfo {
const friendsFor = this.buildFriendsLookup();
const hideClanTags = this.gameConfig.disableClanTags ?? false;
return {
gameID: this.id,
- clients: this.activeClients.map((c) => ({
- username: c.username,
- clanTag: hideClanTags ? null : (c.clanTag ?? null),
- clientID: c.clientID,
- friends: friendsFor(c),
- })),
+ clients: this.activeClients.map((c) =>
+ this.seesReal(viewer, c.clientID)
+ ? {
+ username: c.username,
+ clanTag: hideClanTags ? null : (c.clanTag ?? null),
+ clientID: c.clientID,
+ friends: friendsFor(c),
+ }
+ : {
+ username: this.anonName(viewer, c.clientID),
+ clanTag: null,
+ clientID: c.clientID,
+ },
+ ),
lobbyCreatorClientID: this.lobbyCreatorID,
gameConfig: this.gameConfig,
startsAt: this.startsAt,
diff --git a/tests/AnonymousUsername.test.ts b/tests/AnonymousUsername.test.ts
new file mode 100644
index 000000000..a202bd306
--- /dev/null
+++ b/tests/AnonymousUsername.test.ts
@@ -0,0 +1,25 @@
+import { UsernameSchema } from "../src/core/Schemas";
+import { anonymousUsername } from "../src/core/Util";
+
+describe("anonymousUsername", () => {
+ it("always produces a wire-valid username", () => {
+ for (let i = 0; i < 1000; i++) {
+ const name = anonymousUsername(`client${i}` + `viewer${i % 13}`);
+ expect(UsernameSchema.safeParse(name).success).toBe(true);
+ }
+ });
+
+ it("is deterministic per seed", () => {
+ expect(anonymousUsername("abc")).toBe(anonymousUsername("abc"));
+ });
+
+ it("varies by viewer for the same player", () => {
+ const player = "playerA";
+ const names = new Set(
+ Array.from({ length: 25 }, (_, v) =>
+ anonymousUsername(player + "viewer" + v),
+ ),
+ );
+ expect(names.size).toBeGreaterThan(1);
+ });
+});
diff --git a/tests/server/AnonymizeNames.test.ts b/tests/server/AnonymizeNames.test.ts
new file mode 100644
index 000000000..e0d5649f0
--- /dev/null
+++ b/tests/server/AnonymizeNames.test.ts
@@ -0,0 +1,229 @@
+import { GameType } from "../../src/core/game/Game";
+import { UsernameSchema } from "../../src/core/Schemas";
+import { Client } from "../../src/server/Client";
+import { GameServer } from "../../src/server/GameServer";
+
+function makeMockWs() {
+ return {
+ on: () => {},
+ removeAllListeners: () => {},
+ send: vi.fn(),
+ close: vi.fn(),
+ readyState: 1,
+ };
+}
+
+function makeClient(
+ clientID: string,
+ persistentID: string,
+ username: string,
+ clanTag: string | null,
+ role: string | null = null,
+ publicId: string | undefined = undefined,
+ friends: string[] = [],
+): Client {
+ return new Client(
+ clientID,
+ persistentID,
+ null,
+ role,
+ undefined,
+ "127.0.0.1",
+ username,
+ clanTag,
+ makeMockWs() as any,
+ undefined,
+ publicId,
+ friends,
+ );
+}
+
+// creator = lobby host, admin = admin role, alice + bob = regular players.
+function makeGame(
+ anonymizeNames: boolean,
+ disableClanTags = false,
+ nameReveals: string[] = [],
+) {
+ const logger: any = {
+ child: vi.fn().mockReturnThis(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ };
+ const game = new GameServer(
+ "g1",
+ logger,
+ Date.now(),
+ {
+ gameType: GameType.Private,
+ anonymizeNames,
+ disableClanTags,
+ nameReveals,
+ } as any,
+ "creator-pid",
+ );
+ [
+ makeClient("creator", "creator-pid", "CreatorReal", "HOST"),
+ makeClient("admin", "admin-pid", "AdminReal", "ADM", "admin"),
+ makeClient("alice", "alice-pid", "AliceReal", "AAA", null, "alice-pub", [
+ "bob-pub",
+ ]),
+ makeClient("bob", "bob-pid", "BobReal", "BBB", null, "bob-pub"),
+ ].forEach((c) => game.joinClient(c));
+ return game;
+}
+
+const REAL_NAMES = ["CreatorReal", "AdminReal", "AliceReal", "BobReal"];
+const byId = (info: any, id: string) =>
+ info.clients.find((c: any) => c.clientID === id);
+
+describe("anonymizeNames: gameInfo (lobby / HTTP / preview)", () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ it("off: everyone sees real names and clan tags", () => {
+ const info = makeGame(false).gameInfo("alice");
+ expect(byId(info, "bob").username).toBe("BobReal");
+ expect(byId(info, "creator").clanTag).toBe("HOST");
+ });
+
+ it("on: a regular player sees themselves but not others", () => {
+ const info = makeGame(true).gameInfo("alice");
+ expect(byId(info, "alice").username).toBe("AliceReal"); // self
+ const bob = byId(info, "bob");
+ expect(bob.username).not.toBe("BobReal");
+ expect(REAL_NAMES).not.toContain(bob.username);
+ expect(bob.clanTag).toBeNull();
+ expect(bob.friends).toBeUndefined();
+ expect(UsernameSchema.safeParse(bob.username).success).toBe(true);
+ });
+
+ it("on: nobody is exempt by default, not even the host", () => {
+ const info = makeGame(true).gameInfo("creator");
+ expect(byId(info, "creator").username).toBe("CreatorReal"); // own name
+ const bob = byId(info, "bob");
+ expect(bob.username).not.toBe("BobReal"); // host does NOT see others
+ expect(REAL_NAMES).not.toContain(bob.username);
+ });
+
+ it("on: a granted viewer (nameReveals) sees everyone's real names", () => {
+ const info = makeGame(true, false, ["alice"]).gameInfo("alice");
+ for (const id of ["creator", "admin", "bob"]) {
+ expect(REAL_NAMES).toContain(byId(info, id).username);
+ }
+ });
+
+ it("on: a non-granted viewer still sees only themselves", () => {
+ const info = makeGame(true, false, ["alice"]).gameInfo("bob");
+ expect(byId(info, "bob").username).toBe("BobReal"); // self
+ expect(REAL_NAMES).not.toContain(byId(info, "alice").username);
+ });
+
+ it("on: no viewer (HTTP / preview) anonymizes everyone", () => {
+ const info = makeGame(true).gameInfo();
+ for (const id of ["creator", "admin", "alice", "bob"]) {
+ expect(REAL_NAMES).not.toContain(byId(info, id).username);
+ expect(byId(info, id).clanTag).toBeNull();
+ }
+ });
+
+ it("on: a viewer's view of a player is stable across calls", () => {
+ const game = makeGame(true);
+ expect(byId(game.gameInfo("alice"), "bob").username).toBe(
+ byId(game.gameInfo("alice"), "bob").username,
+ );
+ });
+});
+
+describe("anonymizeNames: config updates propagate", () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ it("turning it off un-anonymizes (not stuck on)", () => {
+ const game = makeGame(true);
+ game.updateGameConfig({ anonymizeNames: false });
+ expect(byId(game.gameInfo("alice"), "bob").username).toBe("BobReal");
+ });
+
+ it("clearing nameReveals revokes the grant", () => {
+ const game = makeGame(true, false, ["alice"]);
+ expect(byId(game.gameInfo("alice"), "bob").username).toBe("BobReal"); // granted
+ game.updateGameConfig({ nameReveals: [] });
+ expect(byId(game.gameInfo("alice"), "bob").username).not.toBe("BobReal"); // revoked
+ });
+});
+
+describe("anonymizeNames: startInfoFor (in-game start payload)", () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.useRealTimers();
+ });
+
+ function withStartInfo(anonymizeNames: boolean) {
+ const game = makeGame(anonymizeNames);
+ const players = [
+ {
+ clientID: "creator",
+ username: "CreatorReal",
+ clanTag: "HOST",
+ isLobbyCreator: true,
+ friends: [],
+ },
+ {
+ clientID: "alice",
+ username: "AliceReal",
+ clanTag: "AAA",
+ cosmetics: { flag: "fr" },
+ friends: ["bob"],
+ },
+ { clientID: "bob", username: "BobReal", clanTag: "BBB", friends: [] },
+ ];
+ const startInfo = { gameID: "g1", lobbyCreatedAt: 0, config: {}, players };
+ (game as any).gameStartInfo = startInfo;
+ (game as any).wireGameStartInfo = JSON.parse(JSON.stringify(startInfo));
+ return game;
+ }
+
+ const player = (info: any, id: string) =>
+ info.players.find((x: any) => x.clientID === id);
+
+ it("anonymizes others, keeps self, strips clan/cosmetics/friends", () => {
+ const info = (withStartInfo(true) as any).startInfoFor("bob");
+ expect(player(info, "bob").username).toBe("BobReal"); // self
+ const alice = player(info, "alice");
+ expect(alice.username).not.toBe("AliceReal");
+ expect(UsernameSchema.safeParse(alice.username).success).toBe(true);
+ expect(alice.clanTag).toBeNull();
+ expect(alice.cosmetics).toBeUndefined();
+ expect(alice.friends).toBeUndefined();
+ });
+
+ it("shows the same anonymized name in-game as in the lobby", () => {
+ const game = withStartInfo(true);
+ const inGame = player((game as any).startInfoFor("bob"), "alice").username;
+ expect(inGame).toBe(byId(game.gameInfo("bob"), "alice").username);
+ });
+
+ it("never mutates gameStartInfo (the archived record stays real)", () => {
+ const game = withStartInfo(true);
+ (game as any).startInfoFor("bob");
+ const rec = player((game as any).gameStartInfo, "alice");
+ expect(rec.username).toBe("AliceReal");
+ expect(rec.clanTag).toBe("AAA");
+ expect(rec.cosmetics).toEqual({ flag: "fr" });
+ });
+
+ it("off: returns the shared wire start info unchanged", () => {
+ const game = withStartInfo(false);
+ expect((game as any).startInfoFor("bob")).toBe(
+ (game as any).wireGameStartInfo,
+ );
+ });
+});