Skip to content

The game server is one Bun process. Its abuse controls are layered from cheapest to most specific: Arcjet at the edge of each request, connection caps at WebSocket upgrade, a per-connection flood cap, then per-action limits keyed by player. All counters live in process memory. A deploy or restart clears them, and two server instances would not share them.

Name moderation is separate. Usernames and room names are the only free text players can show to others. Both run through one normalizer and one block list in @flags/shared, on the client for instant feedback and again on the server or in Convex before the value is stored or broadcast.

HTTP routes get Arcjet through withMiddleware, then whatever route-specific limit applies. /api/game-results has its own sliding-log limiter, described in §3.

Every per-action WebSocket limit, MCP_REQUEST and ROOM_INVITE_LOOKUP go through SlidingWindowCounter. Despite the name it does not keep a log of timestamps. It keeps two counts per key, one for the current fixed bucket and one for the previous bucket, and blends them:

apps/server/src/lib/utils/security/rate-limiter.ts
private getOrUpdateWindow(key: string, windowSizeMs: number, now: number): WindowData {
const currentWindowStart = Math.floor(now / windowSizeMs) * windowSizeMs;
let window = this.windows.get(key);
if (!window || window.windowSizeMs !== windowSizeMs) {
window = {
currentCount: 0,
previousCount: 0,
currentWindowStart,
windowSizeMs,
lastTouchedMs: now,
};
this.windows.set(key, window);
return window;
}
if (now >= window.currentWindowStart + windowSizeMs) {
const windowsElapsed = Math.floor((now - window.currentWindowStart) / windowSizeMs);
window.previousCount = windowsElapsed === 1 ? window.currentCount : 0;
window.currentCount = 0;
window.currentWindowStart = currentWindowStart;
}
return window;
}
private calculateWeightedCount(window: WindowData, now: number): number {
const elapsedInCurrentWindow = now - window.currentWindowStart;
const previousWindowWeight = 1 - elapsedInCurrentWindow / window.windowSizeMs;
const weightedPreviousCount =
Math.max(0, Math.min(1, previousWindowWeight)) * window.previousCount;
return window.currentCount + weightedPreviousCount;
}

consume denies when the weighted count is at or above the limit and does not count denied calls. Every thousandth consume triggers a sweep that deletes keys idle for more than three windows.

Keys come from the action config. An action with perUser is keyed ACTION:user:<id>; one with perIP is keyed ACTION:ip:<ip>. If the identifier is missing, buildKey returns null and the call is allowed:

apps/server/src/lib/utils/security/rate-limiter.ts
private buildKey(action: ActionKey, identifiers: RateLimitIdentifiers): string | null {
const config = SECURITY_CONFIG.RATE_LIMITS.ACTIONS[action];
if (!config) {
return null;
}
if (config.perUser && identifiers.userId) {
return `${action}:user:${identifiers.userId}`;
}
if (config.perIP && identifiers.ipAddress) {
return `${action}:ip:${identifiers.ipAddress}`;
}
return null;
}
ScopeKeyLimitWindowOver the limit
Concurrent socketsIP55n/aupgrade refused
Concurrent socketssession token3n/aupgrade refused
Concurrent socketsunresolved IP, shared75n/aupgrade refused
Frame sizeconnection128 KBn/aclose 1009
All messagesconnection15010 serror message
HEARTBEAT_RESPONSEuser3030 s3 violations, close 1008
CREATE_ROOMuser560 serror message
JOIN_ROOMuser2060 serror message
LEAVE_ROOMuser3060 serror message
START_GAME, RESTART_GAME, STOP_GAMEuser10 each60 serror message
SUBMIT_ANSWERuser5010 serror message
UPDATE_ROOM_SETTINGSuser3060 serror message
UPDATE_AVATARuser2060 serror message
SESSION_TRUST_CONTEXTuser1060 serror message
KICK_USERuser2060 serror message
SEND_REACTIONuser6010 serror message
MCP_REQUEST (/mcp)IP6060 s429, checked before auth
ROOM_INVITE_LOOKUP (/api/rooms/:inviteCode)IP3060 s429
/api/game-resultssession1260 s rolling429, retry-after: 60
/api/game-resultsIP4060 s rolling429, retry-after: 60
Arcjet token bucketIP30 burst, 10 per 60 s refilln/a403 in live mode only
/api/auth/turnstile (web app)IP3060 s fixed429

The flood cap is sized against the per-action limits, as the config comment says:

apps/server/src/lib/utils/security/config.ts
RATE_LIMITS: {
// Shared NAT (schools, offices): up to 40 players + host + refresh headroom.
MAX_CONNECTIONS_PER_IP: 55,
MAX_CONNECTIONS_PER_SESSION_TOKEN: 3,
MAX_CONNECTIONS_UNKNOWN_IP_BUCKET: 75,
MESSAGE_SIZE_LIMIT: 10000, // 10KB
// Must stay above the sum of per-action allowances for one busy window
// (reactions 60 + answers 50 + heartbeats) so it only catches floods.
GLOBAL_MESSAGES_PER_CONNECTION: {
limit: 150,
resetIntervalMs: 10000,
},

/api/game-results uses a different limiter that stores every hit timestamp and prunes those older than 60 s. It checks the session first and rolls that hit back if the IP check then fails, so a rejected request does not use up the session’s allowance:

apps/server/src/lib/utils/game-results-rate-limit.ts
function tryRecordHit(
rateLimitKey: string,
windowMs: number,
maxHitsPerWindow: number,
nowMs: number
): boolean {
const previous = hitsByRateLimitKey.get(rateLimitKey) ?? [];
const pruned = pruneTimestampsWithinWindow(previous, windowMs, nowMs);
if (pruned.length >= maxHitsPerWindow) {
return false;
}
pruned.push(nowMs);
hitsByRateLimitKey.set(rateLimitKey, pruned);

When the map grows past 25 000 keys it prunes every entry. retry-after is always the full 60 s, not the time until the oldest hit expires.

Arcjet wraps every HTTP route through withMiddleware and the WebSocket upgrade. All three rules share one mode switch:

apps/server/src/lib/utils/security/arcjet.ts
function arcjetRuleMode(): "DRY_RUN" | "LIVE" {
return env.ARCJET_MODE === "live" ? "LIVE" : "DRY_RUN";
}
function isHighRiskArcjetPath(req: Request): boolean {
try {
const pathname = new URL(req.url).pathname;
return pathname === "/api/game-results" || pathname === "/ws";
} catch {
return false;
}
}
export const aj = env.ARCJET_KEY
? arcjet({
key: env.ARCJET_KEY,
proxies: [cloudflare()],
rules: [
shield({ mode: arcjetRuleMode() }),
detectBot({
mode: arcjetRuleMode(),
allow: ["CATEGORY:SEARCH_ENGINE", "CATEGORY:MONITOR", "CATEGORY:PREVIEW"],
}),
tokenBucket({
mode: arcjetRuleMode(),
refillRate: 10,
interval: 60,
capacity: 30,
}),
],
})
: null;

In dry-run mode Arcjet evaluates the rules and the result can be logged, but the decision is not a denial, so protectRequest returns allowed: true. It still resolves the client’s country from the decision, which feeds country stats. Development skips Arcjet entirely. If protect throws, the request is allowed unless ARCJET_FAIL_CLOSED_ON_ERROR is set and the path is /api/game-results or /ws.

The web app’s /api/auth/turnstile route verifies a Cloudflare Turnstile token and returns { ok }. The auth modal calls it before sign-in, sign-up, OAuth start and password reset:

apps/web/src/lib/navigation/auth-modal-runtime.svelte.ts
async requireTurnstile(): Promise<boolean> {
if (!this.turnstileRequired) {
return true;
}
if (!this.turnstileToken) {
this.turnstileError = "Please complete the verification.";
return false;
}
const ok = await verifyAuthTurnstileToken(this.turnstileToken);
this.resetTurnstile();
if (!ok) {
this.turnstileError = "Please complete the verification.";
return false;
}

The verification result is not bound to the sign-in request that follows. A script that calls the auth provider directly skips Turnstile. The contact form is different: contact/+page.server.ts verifies cf-turnstile-response on the server in the same request that sends the message.

Short terms must equal a whole segment. Longer terms can appear inside a segment only when no letter touches either end:

packages/shared/src/moderation/content-moderation.ts
function segmentMatchesBlockedTerm(segment: string, term: string): boolean {
const collapsedSegment = normalizeModerationToken(segment);
const collapsedTerm = normalizeModerationToken(term);
if (collapsedTerm.length === 0 || collapsedSegment.length === 0) {
return false;
}
if (collapsedTerm.length <= SHORT_BLOCKED_TERM_MAX_LEN) {
return collapsedSegment === collapsedTerm;
}
let index = collapsedSegment.indexOf(collapsedTerm);
while (index !== -1) {
const before = index === 0 ? "" : collapsedSegment[index - 1];
const afterIndex = index + collapsedTerm.length;
const after =
afterIndex >= collapsedSegment.length ? "" : collapsedSegment[afterIndex];
const boundedBefore = before === "" || !LOWERCASE_LETTER.test(before);
const boundedAfter = after === "" || !LOWERCASE_LETTER.test(after);
if (boundedBefore && boundedAfter) {
return true;
}
index = collapsedSegment.indexOf(collapsedTerm, index + 1);
}
return false;
}

Usernames split on - and _. Room names split on /[\s\-_.!]+/, allow 3–50 characters, and accept an empty value. Where each check runs:

SurfaceClientServer
Account usernameUsernameSchema superRefineConvex players.ts returns the moderation rejection message
Lobby display nameresolveLobbyDisplayName swaps a blocked name for Adjective + Noun + 4 digits (16 attempts)WebSocket schema rejects blocked names
Room nameRoomNameSchema superRefineroom-management.ts updateRoomName calls validateRoomName
Reactionsfixed palette in LobbyRoundResults.sveltez.string().min(1).max(32) only

For window length WW, bucket start s=⌊t/W⌋⋅Ws = \lfloor t / W \rfloor \cdot W, current-bucket count cc and previous-bucket count pp, the estimate at time tt is

n^(t)=c+clamp⁡[0,1] ⁣(1−t−sW)⋅p\hat{n}(t) = c + \operatorname{clamp}_{[0,1]}\!\left(1 - \frac{t - s}{W}\right) \cdot p

A call is allowed when n^(t)<L\hat{n}(t) < L. The estimate assumes the previous bucket’s hits were spread evenly. When they were not, the error can go either way.

Interactive

Sliding-window estimate

The mirror of the game server's two-bucket counter. The exact count is how many accepted hits actually fell in the last window.

t = 0.0s · bucket 0% elapsed · current 0 · previous 0

Estimate 0.00 / 5
Exact hits in last window 0 / 5

Take L=5L = 5 and put all five hits at the very end of the previous bucket. At fraction f=(t−s)/Wf = (t - s)/W into the next bucket, the next hit is allowed while c<Lfc < L f. So hit kk (counting from 1) in the new bucket is allowed once f>(k−1)/Lf > (k - 1)/L:

f1>0,f2>0.2,f3>0.4,f4>0.6,f5>0.8f_1 > 0,\quad f_2 > 0.2,\quad f_3 > 0.4,\quad f_4 > 0.6,\quad f_5 > 0.8

All ten hits land within about 0.8W0.8W. In general a key can get close to 2L2L accepted calls in any real interval of length WW. For SUBMIT_ANSWER that is roughly 100 answers in 10 s, still far below anything a real round produces.

On denial, retryAfter is the time until the current bucket ends. That is an upper bound on the real wait. If the denial came partly from the previous bucket’s weight, the estimate decays while the current bucket runs. With c=3c = 3, p=5p = 5, L=5L = 5, a call at f=0.4f = 0.4 sees 3+0.6⋅5=63 + 0.6 \cdot 5 = 6 and is denied with retryAfter =0.6W= 0.6W. At f=0.61f = 0.61 the estimate is 3+0.39⋅5=4.953 + 0.39 \cdot 5 = 4.95 and the call would pass. At the bucket boundary itself (f=0f = 0) the new estimate is 0+1⋅c0 + 1 \cdot c, so a key that filled its bucket to exactly LL is denied for that one millisecond and allowed right after.

If two or more windows pass with no calls, previousCount resets to 0, so an idle key starts clean. The cleanup sweep removes keys idle for more than 3W3W, which caps memory at the number of keys seen in the last three windows plus up to 999 consumes of lag.

The game-results limiter is exact: at time tt the session is allowed if

∣{ h∈Hsession:t−h<60 000 }∣<12\left|\{\, h \in H_{\text{session}} : t - h < 60\,000 \,\}\right| < 12

and likewise for the IP with 40. The cost is one timestamp per accepted hit per key, bounded by 12 or 40 entries.

5. Threat model, failure modes & edge cases

Section titled “5. Threat model, failure modes & edge cases”
  • Session rotation. Per-action limits key on the user ID tied to the session. A script that mints new anonymous sessions gets a fresh bucket each time. The per-IP connection cap (55) bounds how many can be live at once from one address.
  • Shared NAT. The 55-per-IP cap is sized for a classroom of 40 plus a host and reloads. A larger school behind one address would hit it. Unresolved IPs share a single bucket of 75, so a proxy misconfiguration that hides client IPs puts every player in that bucket.
  • Fail-open keys. A missing user ID or IP makes buildKey return null, and the action is allowed with infinite remaining. WebSocket handlers always have a user ID after upgrade, so this mostly matters for IP-keyed HTTP limits if IP resolution fails.
  • Process-local state. Every counter, including the game-results log and the Turnstile route limit on Vercel, lives in one process. Serverless instances in the web app each have their own map, so the Turnstile limit applies per instance.
  • Reaction text. The client shows a fixed emoji palette, but the server accepts any 1–32 character string and broadcasts it to the room and the replay recorder. A modified client can send short text, including slurs, that bypasses name moderation entirely.
  • Username confusables. The normalizer maps a fixed confusables table. Characters outside it pass through NFKC and are then dropped by the [a-z0-9] filter, which can split a blocked word into pieces that no longer match. The whole-string segment catches the common cases.
  • Reserved names. Room names go through the same reserved-word check as usernames, so a room called Admin is rejected.

In-process limits are for a single Bun server where a restart clearing counters is acceptable and latency matters more than precision.

They are not for a horizontally scaled deployment. A second instance would need a shared store (Redis or similar) or sticky sessions keyed by player.

ChoiceAlternativeWhy
Two-bucket weighted counter for WebSocket actionsSliding log per keyConstant memory per key. The up-to-2L2L burst is acceptable for game actions.
Sliding log for game resultsReuse the weighted counterOnly 12 or 40 timestamps per key, and results feed stats, so exact counting is worth it.
Arcjet in dry runLive enforcementBot detection on a game with shared school networks risks blocking real classes. Dry run collects decisions first.
Turnstile checked by a separate endpointPass the token to the auth providerSimpler to add to an existing auth flow. The cost is that a direct API call skips it.
Block list with normalizationThird-party moderation APINames are short and few. A local list has no latency, no per-call cost, and runs in Convex and the browser.
No chatModerated chatReactions cover in-game expression without a moderation queue. The reaction field still needs a server-side palette check.