Trust boundary
This page states what a published game may do inside the player app, which the page calls the app. The app runs on the web and on iOS, macOS and Windows. The page answers four questions:
- Where does the game run?
- What do the game and the app say to each other?
- What does the game reach over the network, and with which credentials?
- What limits does the game run under, and how does it end?
It also states who owns a save and what a room decides. The screen layer follows from the first answer. The game’s page, its policy and the app around it follow from the rest, and check_bundle checks each bundle at publish. The table under Apple 4.7 says which part meets which rule. A game in development runs under none of these rules: its own dev server serves it from the developer’s machine.
The boundary is the game’s origin
Section titled “The boundary is the game’s origin”Each game runs in its own page, at an origin that neither the app nor any other game shares, and the app embeds that page in a frame. The browser keeps the game’s code on its side of the frame. The code cannot read the app’s page, session or storage, and it cannot call a Tauri command.
The engine is not the boundary. A game’s code shares a JavaScript realm with the engine and can call anything the engine can. So the app, the backend and the rooms trust nothing the game’s page says. A rule that lived inside that realm would be a convention, and no check at publish can hold obfuscated code to a convention.
flowchart LR
App["the app<br/>session, age gate, report, chat"]
Page["the game's page<br/>engine and game scripts"]
App -- "frame, MessageChannel" --> Page
App -- "session" --> Backend
Page -- "game token: saves, seat tickets" --> Backend
Page -- "seat ticket" --> Room
Page -- "bundle" --> CDN
Room -- "room credential" --> Backend
Each arrow is a route the boundary allows, labelled with what it carries. The game’s page has no other route.
Where a game runs
Section titled “Where a game runs”The platform serves each game’s page from one of these places:
- On the web, from a subdomain per game under a play domain registered apart from the platform’s own, so no platform cookie reaches a game. Discord runs its Activities on
discordsays.com, and Google serves user content fromgoogleusercontent.com, for the same reason. - In the native app, from the app itself, under an origin per game that no Tauri capability lists. The scheme is still open.
The page carries the engine. It loads the bundle’s scripts module with an import map that points @daniel-zarinski/engine at the engine the page carries. The engine never comes from the bundle: the bundle’s build keeps it external.
The app embeds the page in a frame:
<iframe src="https://<game>.<play domain>/" sandbox="allow-scripts allow-same-origin allow-pointer-lock" allow="autoplay; gamepad"></iframe>The frame keeps allow-same-origin so the page runs at the game’s origin. Without the flag the origin would be opaque, and the backend could not check a request’s origin against its token. The flag is safe only because the game’s origin is never the app’s: a frame on the app’s own origin could lift its own sandbox. The page’s frame-ancestors lists only the app’s origins, so no other site can embed a game.
Tauri’s capabilities cannot tell a frame’s request from its window’s on Linux and Android, so a build for either runs the game’s page in a webview of its own. On iOS, macOS and Windows, an isolation test shows that the frame has no route to a Tauri command.
What draws where
Section titled “What draws where”Everything a game draws stays inside its frame: the canvas, the screen layer, panels and modals. <Game> creates the screen layer inside the game’s own document, and every portal targets it. The same hoist therefore works in a frame and on the game’s dev server, where no frame exists. Nothing a game renders reaches the app’s document.
The app draws these over the frame, where a game cannot cover them:
- The loading screen.
- The age gate.
- The report and block controls.
- The platform’s chat.
- The purchase sheet, once coins exist.
init tells the game the insets the app’s controls take, and the UI slots stay inside them.
The server half
Section titled “The server half”A game with rooms also runs its server half in a room process. A room process follows these rules:
- It holds rooms of one game version only.
- It reaches the backend and nothing else.
- It holds no secret beyond its room credential.
What a room decides states the rest.
What a game and the app say to each other
Section titled “What a game and the app say to each other”The app hands the game’s page one MessageChannel port at start, and every message travels over it. The lists below are closed. Each side checks each message against its zod schema in @daniel-zarinski/schema and drops anything else. The engine owns the channel and a game calls the engine’s hooks, so a game that posts to the port itself gets nothing more.
The game sends the following messages:
| Message | Means |
|---|---|
ready |
The first scene drew. The app removes its loading screen. |
exit |
The game asks to close. The app tears it down. |
error |
The game failed. The app shows its error screen and logs the error. |
pong |
The answer to ping. |
The app sends the following messages:
| Message | Means |
|---|---|
init |
The player’s per-game id and public display name, the game token, and the insets the app’s controls take. |
pause |
The app went to the background or opened an overlay. |
resume |
The app came back. |
teardown |
The app is about to remove the frame. |
ping |
The watchdog. |
token |
A new game token, sent before the old one expires. |
purchase is reserved for when coins exist. It asks the app to show its purchase sheet, and the app answers with the result. Saves and room seats never pass through the app: the engine asks the backend for them with the game token.
What a game reaches
Section titled “What a game reaches”Network
Section titled “Network”The game’s page sets a Content Security Policy in its response, and the game cannot loosen it:
- Scripts, styles, images, fonts and media load from the engine’s origin and the bundle’s path on the CDN.
- Fetches go to the engine’s origin, the bundle’s path, the backend and the room endpoint.
- Sockets go to the backend and the room endpoint.
- Nothing else loads: no other host, no nested frame, no form and no popup.
The frame never navigates. A navigated frame would leave the policy behind, so the app treats a second load of the frame as a violation and removes the frame.
The policy limits what a game loads, not every request it can make. A navigation sends one request before the app sees the second load, and a WebRTC connection falls outside the policy. Either can carry out only what the page holds, which Credentials lists.
check_bundle flags network calls at publish as an early warning. The policy is what enforces the rule, because no scan of the source sees window["fe" + "tch"].
A developer’s own server is not reachable at launch.
Credentials
Section titled “Credentials”The game’s page holds the following:
- The player’s per-game id. It differs for each game, so two games cannot join what they know about one player.
- The display name the player made public.
- A game token. It reads that player’s save for that game, writes it when the game has no rooms, and asks for a seat in that game’s rooms. It does nothing else. It expires after a day, and the app sends a new one in
tokenbefore then. - A seat ticket, in the shape of Colyseus’s seat reservation. It seats one player in one room once, and it expires after a minute.
The page never holds the player’s session, a payment credential, the developer’s publish token, or another player’s data. The backend also checks that a browser’s request comes from the origin of the game its token names.
Storage and permissions
Section titled “Storage and permissions”A game does not rely on browser storage, because browsers block or partition storage in a frame from another site. What a game keeps goes into its save.
The frame’s allow list grants autoplay and gamepad and nothing else. A game gets no camera, microphone, location, notifications or clipboard read.
Limits
Section titled “Limits”A published game runs under the following limits:
| Limit | Value | Enforced by |
|---|---|---|
| Scripts module | JavaScript only: no WebAssembly or native code | check_bundle |
| Initial download | 20 MB | check_bundle |
| Whole bundle | 250 MB and 1,500 files | check_bundle |
Time to ready |
20 seconds | the app’s error screen |
| Save record | 512 KiB of JSON | the backend |
| Save writes | six a minute per player and game | the backend |
Silence after ping |
10 seconds | the app removes the frame |
The download, file and load figures come from CrazyGames’ technical requirements. 20 MB is their figure for a game on the mobile homepage. The save cap is half of the 1 MiB document limit in Convex’s limits.
WebAssembly stays out of a bundle because guideline 4.7 names HTML5 and JavaScript. The engine carries Rapier’s WebAssembly in the app. The policy has to allow WebAssembly for that physics, so check_bundle and moderation are what keep a game’s own WebAssembly out.
The web gives a frame no memory or CPU cap. A game that runs out of memory loses its WebGL context or its process, and the app shows its crash screen. Where a browser runs the frame on the app’s thread, a game stuck in a loop freezes the app as well, and the watchdog cannot fire. Only a separate process for the frame lifts that limit.
Teardown
Section titled “Teardown”The app ends a game in three steps:
- The app sends
teardown. - In a game without rooms, the engine writes its last save while the app waits one second.
- The app removes the frame.
Removing the frame frees everything the game held: its GPU context, audio, timers and sockets. Nothing a game started outlives its frame.
On pause, the engine stops the loop. In a game without rooms, it also saves, because a phone may suspend the app without a teardown.
In a room, a player leaves when their socket closes, and the room writes that player’s save first. An empty room writes its saves and closes.
Who owns a save
Section titled “Who owns a save”The backend keeps one save record per game and player:
- The player owns the record. They can delete it, and deleting their account deletes every save they have, as Apple’s guideline 5.1.1(v) requires.
- The game owns the record’s shape: the game’s slice schema, which carries a version. A new version of a game reads the saves its earlier versions wrote.
- A game reads its own save for the current player and no other.
Who writes a save depends on whether the game has rooms, which its manifest declares:
- In a game with rooms, only the room writes, through the backend with its room credential. The backend refuses a write that comes with a game token.
- In a game without rooms, the engine writes with the game token and coalesces to the newest record within the rate limit.
A player can therefore edit their own single-player save. Nothing in it has value beyond that player: platform coins and bought items never live in a save. The backend holds them, and a game reads them. Roblox’s data stores are the precedent: kept per experience and per player, and written from the server.
What a room decides
Section titled “What a room decides”A room runs the same simulation as the client, headless, and has the final say over every entity whose authority is the server. Entity lists the defaults for each kind of entity. A room holds to these rules:
- A client sends its inputs and the state of the entities it owns, such as its own character. The room’s sanity filter clamps that state every step.
- The room ignores any client write to an entity the server owns. Clients never write the wallet, inventory, score, timers or round state.
- The room knows a player by the seat ticket the backend issued, never by what the client says.
- The room’s credential writes saves for the players seated in that room and nothing else.
- Text from one player to another goes through the platform’s chat, which the app draws over the frame. The chat filters the text and lets a player report and block. A game has no channel of its own for that text.
The contract holds wherever rooms run. Where they run is still open.
Apple 4.7
Section titled “Apple 4.7”Guideline 4.7 lets an app offer HTML5 and JavaScript games that are not embedded in its binary, under the rules of 4.7.1 to 4.7.5. Only 4.7.2 has a test: the isolation test. The other rules rest on the page’s policy, check_bundle, the app’s UI and moderation. The following table shows what meets each rule:
| Rule | Met by |
|---|---|
| 4.7: HTML5 and JavaScript | A scripts module of JavaScript only |
| 4.7.1: privacy | No personal data beyond a per-game id and a public name |
| 4.7.1: filter, report and block | The app’s controls and chat over the frame, and every publish moderated by hand |
| 4.7.1: in-app purchase | The purchase message and the app’s purchase sheet |
| 4.7.2: no native APIs | A game’s page with no route to a Tauri command |
| 4.7.3: no data or permissions without consent | A per-game id and an allow list with no personal permission. Open: whether the public display name needs the player’s consent in each game |
| 4.7.4: an index with universal links | The library and each game’s share link |
| 4.7.5: age restriction | An age rating declared at publish and checked in moderation, and the app’s age gate before the frame loads |