Dicey Table

Script Safety

Every mod entry script (entry.script) and every scene script embedded in an edit-scene setup file is checked against 5 banned patterns before it is allowed to run. This is the first, coarsest layer of the sandbox — it runs on raw source text, before the script ever executes, and it runs every time the script is scanned: at registration, at serve time (see the serve-time re-scan risk), and continuously while you edit a draft in Edit Mode.

Read this before you hit one#

These are whole-word regexes with no lexing. The scanner does not parse your script as JavaScript — it runs \b(...)\b patterns over the raw source text. It cannot tell a comment from code, a string literal from an identifier, or a property access (api.window) from a bare global (window). Concretely, all of these reject the mod:

  • a comment that says // don't touch the window state (contains the standalone word window)
  • a bare property access like card.parent.destroy(), or a variable declared as let top = deck.pop(); (contains the standalone word parent/top)
  • a JSDoc line @see Function.prototype.bind (contains the standalone word Function)
  • a string literal "reconnect via WebSocket if needed" (contains the standalone word WebSocket)
  • a comment // don't fetch the next card, draw it (contains the standalone word fetch)

All five verified against the live patterns, 2026-07-27 (node -e against the exact regex source, not guessed). Note what does not trip a rule, because it matters for the workaround below: topCard, parentZone, deckTop, fetchCount, containerZone and localStorageFallback are all safe — see why in the workaround for each pattern.

The scanner's own doc comment states this trade-off explicitly (apps/server/src/githubScanner.ts):

"the scanner intentionally uses conservative regex checks as a first-pass gate: false positives are acceptable (mod authors can adjust scripts), while false negatives are treated as boundary risks and should trigger a rule update."

That is a deliberate, permanent design choice, not a bug to be filed. This page tells you how to word your way around a false positive by renaming or rephrasing — it does not suggest disabling, narrowing, or bypassing a rule, and no such workaround exists or should be attempted. Every one of these 5 patterns is also a live security boundary against scripts that actually do try to escape the sandbox; treat a rejection as a prompt to rename, not as an obstacle to route around.

The 5 patterns#

Identical in both SANDBOX_SCANNER_RULES (apps/server/src/githubScanner.ts) and bannedScriptPatterns (packages/shared/src/modManifest.ts) — see the duplication note for why there are two copies.

The mechanic behind every workaround below#

\b only matches at a transition between a "word" character (letter, digit, or underscore — count as a word character regardless of case, but the patterns themselves are case-sensitive: Function matches, function does not) and a non-word character (space, punctuation, quote, parenthesis, dot, start/end of string). It does not match inside an unbroken run of word characters. So parent standing alone is banned, but attaching more letters, digits, or an underscore directly onto either side — parentZone, parent_zone, myParent, parentId, topCard, deckTop, fetchCount, containerZone, localStorageFallback — removes the boundary on that side and the whole-word match fails. All nine were verified (2026-07-27, against the live regex source) to not trip any rule. The only thing that still trips a rule is the banned word appearing with a non-word character (space, dot, quote, bracket, parenthesis, or string start/end) on both sides — i.e. as its own token.

dom-access#

  • Pattern: /\b(document|window|parent|top|opener)\b/
  • Message: Scripts cannot access DOM/window globals.
  • Why: mods run in an isolated iframe and must not inspect or mutate host/browser UI state.
  • Real trigger: using window, document, parent, top, or opener from the sandbox — never legitimate for a mod script.
  • False-positive trigger: a variable, parameter or property used standalonelet top = deck.pop();, card.parent.destroy();, function parent() {} — or a comment mentioning "the browser window." (top/parent are common short names for "the containing thing" and "the top of a stack" in game logic, and both are real matches here — verified.)
  • Workaround: rename to a compound identifier: parentparentZone / parentId / ownerZone; topdeckTop / stackTop / topCard. Rephrase comments to avoid "window," "top," "parent," "document," or "opener" as standalone words — e.g. "don't touch global state" instead of "don't touch the window."
  • The one API value that could not be renamed away got an alias instead. A quoted "top-left" trips this rule — " and - are both non-word characters, so the boundary fires on each side of top — which made the three top-row screen anchors unwritable in a mod script. Narrowing the pattern to skip quoted text was rejected: matching inside string literals is precisely what catches self["top"], and turning a false positive into a false negative is the failure mode this rule exists to prevent. Instead presentation.anchor accepts upper-left, upper-center and upper-right as input and normalizes each to its canonical top-* value before storing it (packages/shared/src/tableObjects.ts, TABLE_UI_SCREEN_ANCHOR_ALIASES). Write the alias, expect the canonical value back, and never compare a read-back anchor against the alias. This is the shape a genuine, unrenameable false positive is fixed in: the value grows a second accepted spelling, the rule does not move. See Table UI widget types.

network-access#

  • Pattern: /\b(fetch|XMLHttpRequest|WebSocket|EventSource)\b/
  • Message: Scripts cannot open arbitrary network connections.
  • Why: all mod communication must stay on the host-controlled RPC surface (the api object) — a mod must never open its own network connection.
  • Real trigger: calling fetch(...), constructing new XMLHttpRequest(), new WebSocket(...) or new EventSource(...) — those four names are the whole rule.
  • False-positive trigger: a string or comment mentioning "fetch the next card," "poll like a WebSocket," or a variable named fetchCount.
  • Workaround: rephrase — "draw the next card" instead of "fetch the next card"; rename fetchCountdrawCount.

storage-access#

  • Pattern: /\b(localStorage|sessionStorage|indexedDB|cookie)\b/
  • Message: Scripts cannot access browser storage or cookies.
  • Why: persistence must go through the snapshot-synchronized saved-data API (api.getSavedData / api.setSavedData, capability saved-data), never browser storage.
  • Real trigger: referencing localStorage, sessionStorage, indexedDB, or cookie.
  • False-positive trigger: rare in practice — these are not common English words, but a comment explaining why you're using api.setSavedData instead of localStorage will still trip the rule, because the word appears regardless of context.
  • Workaround: don't name the browser API in comments at all; say "persistent per-player state" instead of "instead of localStorage."

dynamic-code#

  • Pattern: /\b(eval|Function|importScripts)\b/
  • Message: Scripts cannot use dynamic code loading.
  • Why: dynamic evaluation bypasses static review and increases sandbox-escape risk.
  • Real trigger: eval(...), the Function constructor, importScripts(...).
  • False-positive trigger: capital-F Function as a JSDoc type annotation (@param {Function} callback), or as a plain English word ("call this function," capitalized at a sentence start — note the pattern is case-sensitive and requires the literal capitalized word Function, so lowercase "function" is safe).
  • Workaround: lowercase it. Use @param {function} callback or @param {() => void} callback instead of {Function}; avoid starting a sentence in a comment with the word "Function" if it would otherwise read as the identifier — rephrase to lowercase "the function does X."

timer-loop#

  • Pattern: /\b(setInterval|requestAnimationFrame)\b/
  • Message: Scripts must be deterministic and event-driven.
  • Why: unbounded timers can cause non-deterministic, host-diverging behavior and resource abuse. (Note setTimeout is not in this pattern — a mod script may use a bounded, one-shot setTimeout; only the two repeating/frame-loop APIs are banned.)
  • Real trigger: setInterval(...), requestAnimationFrame(...).
  • False-positive trigger: uncommon as an accidental word match, since neither is an English word — this one is almost always a real usage. If you're hitting it, the fix is architectural, not lexical: move the recurring work onto the event-driven api.on(...) hooks (capability subscribe-events) instead of a client-side loop.

What "the same 5" means elsewhere#

scanScriptText (githubScanner.ts) applies SANDBOX_SCANNER_RULES during GitHub scan and re-scan. scanModScriptSafety (modManifest.ts) applies bannedScriptPatterns during local draft validation, and is reused by validateSceneScriptSafety (modManifest.ts) for every script embedded in an edit-scene setup document. Every documented Surface B api example on this site is run through scanModScriptSafety in CI — see /docs/scripting-api/api — so a published example can never be one these rules would reject.

See also#