Moderate names and chat to stay in the stores
3 min read
The fastest way to get a game pulled from the App Store is to let a player set their username to a slur and screenshot it. Any game with usernames, chat, or user-generated content is one bad name away from a review team's attention. And you can't regex your way out of it: players route around naive filters with leetspeak, homoglyphs, spaced-out letters, and repeats faster than you can add rules.
tg.moderation.check is one call that handles all of that, tuned per surface.
Check names before you ever display them
The player_name surface is strict: any hit blocks. That's deliberate: a username is persistent and public, so there's no "mask and allow" middle ground. Check it on submit, before the name is stored or shown anywhere.
const { verdict, categories } = await tg.moderation.check('player_name', requestedName);
if (verdict === 'block') {
return showError('That name isn\'t available. Try another.', categories);
}
await saveUsername(requestedName); // only reached when clean
Check before display, not after a report. The whole point is that the bad name never renders: not in a lobby, not on a leaderboard, not in a screenshot headed for a review team. The categories tell you why it was blocked if you want to log it, but keep the player-facing message generic so you're not writing a bypass guide.
Mask chat instead of blocking it
Chat is different. Blocking a whole message on one bad word feels broken and kills conversation. The chat surface leans on mask: it returns the message with just the offending span replaced, so the chat keeps flowing and the slur doesn't land. Reserve full block for the genuinely severe.
const res = await tg.moderation.check('chat', message);
switch (res.verdict) {
case 'allow':
return send(message);
case 'mask':
return send(res.masked_text); // "you're **** at this", conversation survives
case 'block':
return notifySender('Message not sent.');
}
Send masked_text on a mask verdict and the room reads clean without you playing word police on every line. This defeats the evasion tricks a static blocklist misses: n1ce, n í c e, n.i.c.e, niiiice all normalize before matching.
The third surface: UGC
For user-generated text like level titles, descriptions, and clan names, use the ugc surface, which leans toward review rather than an instant verdict. That routes borderline content to a human queue instead of guessing. If you publish UGC through tg.ugc.submit, this moderation already runs on the title and description for you; call check('ugc', text) directly when you want a verdict before publish, like live-validating a clan name field.
const { verdict, masked_text } = await tg.moderation.check('ugc', clanName);
if (verdict === 'review' || verdict === 'block') {
return flagForReview(clanName);
}
Gotchas
A few things that matter in practice. Moderation is on by default. That's the feature, because "we'll add a filter later" is how games get delisted. Custom terms and an allowlist are configurable, so add your game's specific no-gos and rescue false positives (a town called "Scunthorpe", a real player named "Dick"). Match your UI to the surface: block on a name should reject the form field, while block on chat should quietly drop the one message, not disconnect the player. And every error carries an agentHint, so an AI wiring this up gets told exactly what to do on failure.
Takeaway
One check call, three surfaces, evasion-resistant by default: that's what stands between a UGC game and a store takedown. Check names before they render, mask chat instead of killing it, and route UGC to review.