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, in a browser on a computer or a phone. Phone and desktop apps are coming, with no date yet, and the same rules will hold in them. 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 what the platform keeps of a game, 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 the backend checks each bundle as it is uploaded. 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
App -- "session: seat tickets, the save head" --> Backend
App -- "save pass: single-player saves" --> Saves["save worker"]
Page -- "seat ticket" --> Room
Page -- "bundle" --> CDN
Room -- "room credential" --> Backend
Room -- "save pass: seated players' saves" --> Saves
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 the play domain,
<id>.spawnite.net, the game’s id, which never changes, as its one label. The play domain is a registered domain of its own, apart from the app’s, as itch.io serves games fromitch.zoneand Google serves user content fromgoogleusercontent.com. One site for every game says what that protects and what it does not. - In the phone and desktop apps, which are coming with no date yet, from the app itself, under an origin per game that no Tauri capability lists.
The page imports the engine, which the site serves apart from the bundle. It loads the bundle’s scripts module with an import map that points @spawnite/engine at that engine release. The engine never comes from the bundle: the bundle’s build keeps it external. The site writes that import map for the engine release the game’s version resolves to, as Publishing says.
A game’s files reach the games bucket only through URLs the backend signs. The games bucket, an R2 bucket, holds every uploaded version, and the backend holds the one credential that writes it. Once a bundle’s listing and manifest pass its check, the backend signs one URL per file: each writes one key in the version’s own folder, for an hour, and only the bytes whose SHA-256 the listing named. The creator’s machine sends each file straight to the bucket with its URL, and no byte passes through the backend. The backend records the version once the bucket holds every file. The site’s Worker reads the bucket through a read binding and serves only the version the game’s row names as published, so a folder whose upload never finished serves nobody. A creator holds no Cloudflare credential, no bucket key and no deploy token.
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; microphone"></iframe>One site for every game
Section titled “One site for every game”A browser groups origins into sites by registered domain. Every game’s origin and the engine’s address, engine.spawnite.net, are under spawnite.net, so they are one site, and that site is not the app’s. The play domain is never submitted to the Public Suffix List, which would make each game a site of its own. The following rules hold because of it:
- The app is protected from every game. A game cannot set a cookie that
spawnite.comreceives, and a request from a game’s page to the app is cross-site, so it carries noSameSitecookie of the app’s. The app sets no cookie at all, held by a test in the app. - Games are not protected from each other by cookies. One game can set a cookie on
spawnite.netthat every other host under it receives. So nothing served on the play domain trusts a cookie: the Worker reads none on a game’s origin or the engine’s address, and the rooms, which are also underspawnite.net, admit a player by seat ticket alone. A game’s page keeps what it knows in its own origin’s storage, which no other game reads. - Every browser keeps one copy of the engine for all games. Chrome keys its cache by the top page’s site and the frame’s site, and Firefox and Safari by the top page’s site alone. With every game on one site, the engine’s files at
engine.spawnite.net/<release>/are downloaded once and reused by each game a player opens. A site per game would make Chrome and Edge download the engine again for every game. - No game can hold a reserved host. A game’s id is digits alone, so it is never a label the platform keeps, such as
engine,wwwor a room machine’sm-<n>. The Worker serves the engine atengineand a game at a label that is an id. A label the platform granted a game as its vanity name answers a redirect to the game’s page on the app and serves nothing; any other label answers a 404.
The shared cache is a channel between games: one game can time whether another loaded a file of the engine’s. Each file of each release is one bit a game can set by loading it, so two games that agree on a code can pass a small value, such as an id, between them. The engine’s address refuses a query or an escaped character, so a game cannot add entries of its own.
In Safari, a frame from another site than the top page keeps its localStorage and IndexedDB in a partition that lasts until the browser quits, as WebKit’s tracking prevention says. So in Safari, progress a page keeps only in its own storage is lost when the browser quits. A save the backend keeps, as Who owns a save describes, and the settings the app carries are not.
The frame keeps allow-same-origin so the page runs at the game’s origin. Without the flag the origin would be opaque: the page would lose its storage, and every request it makes, the room’s socket included, would carry the origin null. 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.
A player can still open the game’s origin in a tab of its own, since its address shows in the browser. The page plays only in the app’s frame, which hands it the player, the seat and the insets, so the site sends a tab that opens it to the app’s page for the game, where Play opens the frame. The browser says which is which: a tab’s navigation carries Sec-Fetch-Dest: document, the frame’s carries iframe, and the page’s own files carry their kind, such as script. Every tab that opens the origin goes to the app’s page, whatever its path, except in these cases, where the site serves the address as it did before:
- The address names a room in
room, asspawnite play join --urland a room opened by hand do. - The address carries
profile, as every page the cli measures does. - The browser sends no
Sec-Fetch-Dest, as Safari before 16.4 andcurldo.
A game’s dev server and the local server of spawnite build --serve run no site, so a page there plays at the top level, as it always has.
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 no capability names a game’s origin, so the frame has no route to a Tauri command; the isolation test that shows it comes with the iOS shell.
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 age gate.
- The report and block controls.
- The platform’s chat.
- The purchase sheet.
init tells the game the insets the app’s controls take, insets tells it again each time they move, and the UI slots and modals stay inside them.
The app’s opening screen sits under the frame rather than over it: the game’s icon and name, from Play until the page says ready. A frame shows what is under it until its page paints, so the page’s own loading screen covers the app’s from its first paint, and a player sees one loading screen at a time, the game’s for as long as the game loads. While a room game’s room starts, the frame already loads, and the app draws the same screen over it with “Starting a room…”, since the page’s screen would cover the line for the whole start. The frame takes no input under it. Once the room opens, the screen goes back under the frame, and the player sees the game’s screen, often already near its end. Roblox shows its own screen while a place loads, and a game that has its own removes Roblox’s from ReplicatedFirst; every game here has the engine’s, so the app’s covers only the wait before the page can draw.
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 the save worker, and nothing else.
- It holds no secret beyond its room credential and a save pass for each seated player, each of which reaches that player’s save in that game while her seat holds it.
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 @spawnite/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.
Two window messages open the channel, in this order:
- The engine, once
<Game>mounts in a frame, postsspawnite:helloto its parent with the channel version it speaks and the engine release it runs. A page whose own bundle holds the engine, as a repository game’s does, runs no release and names none. - The app answers the first hello from its frame’s window at the game’s origin with
spawnite:connect, its own version and the port. It answers no second hello, so a page that reloaded or navigated never gets a secondinit.
The page speaks first because it knows when its listener is up, which the frame’s load does not: a game that loads its code late would miss a port sent on load. A framed page that hears no connect within 2 seconds of its hello takes the frame for someone else’s and plays as a page outside it does, its save kept in the browser; once the app has answered, the page waits for init, however long it takes. A page outside a frame, on its dev server or under spawnite play, says no hello and runs as before, and usePlayer names nobody there. A player who opens a published game’s origin in a tab of its own, in a current browser, lands on the app’s page for the game instead, as Where a game runs says. A published page runs outside a frame only at an address that names a room or a profile, or in a browser that does not say it opened a tab.
The version is one whole number, raised by one with each change that adds a message or a field. The channel changes only by addition while any engine release that speaks it is served, so an old page and a new app keep talking. A side sends what came in at version N only to a side that named N or more, a receiver keeps the fields it knows and drops the rest, and a message whose type it does not know it drops with a warning in its console. Discord’s Activities open their frame the same way, the frame’s handshake carrying v: 1 and the host answering, and penpal hands its MessageChannel port over the same way.
The game sends the following messages:
| Message | Means |
|---|---|
ready |
The first scene drew. The app removes its opening screen, which the page’s own loading screen has covered since its first paint. |
fullscreen |
The player asked the game for fullscreen, as its Play does. The app fills the screen with its own play screen, the frame and the app’s controls together. |
settings |
The player changed her carried settings in the game: the ones she set, and the ones she put back to the game’s default. The app keeps them for every game. |
menu |
The engine’s menu opened or closed. On a computer the app shows its controls only while the menu is open, since Escape opens it. |
left |
The answer to leave: whether the game’s room heard that the player left, and freed her seat. |
save |
A record of the player’s save, { number, body, versions }, the body gzipped JSON within the save’s size limits. Every record goes to the journal on the device, and one with cloud to the platform too. |
saveLoadFailed |
The save in init did not load, or the room refused her join over her save: the key that failed, its owner, the reason, and pastLimit for a record past the size limit. The game stopped and wrote nothing more, and the app shows its load-failure screen. |
connection |
Where the page’s connection to its room stands: connecting, joined, reconnecting, rejoining or closed, sent each time it changes. |
join |
The game asks for a room, by the game or a room’s code. The app asks the backend and replaces the frame where the room’s version differs. |
switch |
The game learned from its room that it runs another version. The app replaces the frame at that version. |
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. |
Three of these messages carry more than the table holds:
save. A null record answerssaveNowwhen nothing is newer than the last record sent for the cloud.saveLoadFailed. In a game with rooms, the room refuses her join over her save by closing it with 4009 or 4010, whose reason says a later try would not load it either.pastLimitmarks a record past the size limit, as it opened or once its one commit past the limit landed. A refusal a later try may load, such as a store that did not answer, sends nothing: the room freed her seat, and the app shows its general message and takes a new one.connection. While the page reconnects or rejoins, the app keeps the frame though the seat is freed. Onrejoining, the room keeps her character no longer, and the app takes the seat its tab holds back from the backend, or a new one, and answersseat.
The app sends the following messages:
| Message | Means |
|---|---|
init |
The player’s per-game id and public display name, the seat ticket in a game with rooms, and the insets the app’s controls take. In a game with no rooms, the player’s save too. In a game whose room is starting, sent once the room opens. |
saved |
The platform stored the page’s record of that number. |
saveFailed |
The platform did not store the page’s record of that number; the page sends its newest again at its next write, under the same number where it is the same record. |
saveNow |
Send the newest record now, as the player leaves or another session asks for the save. The page answers with exactly one save, before its save has loaded and after a load that failed included. |
settings |
The player’s carried settings, the ones she chose, as the app keeps them on this device. Sent as the channel opens, before init, and again when another tab of the app changes them. |
pause |
The app went to the background or opened an overlay. |
resume |
The app came back. |
insets |
The insets the app’s controls take, again, once they move, as a phone that rotates or a window resized across the phone and desktop layouts moves them, and once they hide or show: hidden controls take none. |
leave |
The player left the game, with the app’s Leave or by going elsewhere in the app. The page tells its room, which frees her character and her seat at once, and answers left. With moved, her seat went to her session elsewhere. |
seat |
The answer to rejoining: the new seat’s credential, on which the page joins again as a new player whose save the room loads; none where the app took no seat, and the page stops trying. |
fullscreen |
The answer to the page’s fullscreen, once the app’s screen has settled: whether its play screen fills the screen. The game’s Play captures the cursor as it hears it. |
teardown |
The app is about to remove the frame. |
ping |
The watchdog. |
Two of these messages carry more than the table holds:
init. In a game with no rooms, the save it carries is the newest body, or none for a new game, and the number the page’s first record takes.leave. The app removes the frame once it has the page’sleft, or after a second. Withmoved, the page closes its room as moved, and the room hands her save over at once.
Each version of the channel carries the following:
- Version 1 carries
readyandinit. - Version 2 adds the seat credential,
{ kind: "seat", ticket, room }, the ticket and the room’s address, which the app sends only to a page whose hello named 2 or more. - Version 3 adds
insets, which the app sends only to a page whose hello named 3 or more. It also adds the release in the hello, which every contract of an engine release carries, so the platform can count the pages of each release still played before it drops a shape they speak. - Version 4 adds
fullscreen, which the page sends only to an app whose connect named 4 or more; in an app from before, the game’s Play does not fill the screen. - Version 5 adds
settingsboth ways, which each side sends only to a side that named 5 or more; an older page and an older app keep every setting in the page, as Settings says. - Version 6 adds
menu, which the page sends only to an app whose connect named 6 or more. The engine counts its loading screen as an open menu, since Escape opens nothing under it, and a page with no menu mounted says it is open. An app that hears nomenukeeps its controls shown. - Version 7 adds
leaveandleft, which the app sends only to a page whose hello named 7 or more; an older page’s room holds the player as for any drop. - Version 8 adds the save in
init,saveandsaveLoadFailedfrom the page, andsaved,saveFailedandsaveNowfrom the app. - Version 9 adds
movedtoleave: the player’s seat moved to her session in another window or on another device, so the page closes its room’s socket with the code that says so, 4008, and the room stores her save and frees her seat at once. A page from before it leaves as for any leave. - Version 10 adds
connectionfrom the page, which it sends only to an app whose connect named 10 or more, andseatfrom the app. An older app removes the frame once a dropped page’s seat is freed, and an older page’s room holds the player as for any drop. - Version 11 adds
fullscreenfrom the app, the answer to the page’s request, which the app sends only to a page whose hello named 11 or more. A page in an app from before it captures the cursor first and asks for fullscreen after, as before.
Each other message arrives with the issue that builds it. ready means the first scene committed and no file is loading. init names the player by a per-game id and a display name, and nothing in it says whether the player is an account or a guest. Its credential is one object with a kind, a seat ticket in a game with rooms, and its insets are the CSS pixels the app’s controls take from each edge of the frame, which the engine sets on the screen layer’s --game-inset-* variables.
purchase asks the app to show its purchase sheet, and the app answers with the result. In a game with no rooms, saves pass through the app: the page builds each record and hands it over the port, and the app keeps the journal and sends the body to the save worker with the session’s pass, so the page holds no credential for it. In a game with rooms the room saves, and the page holds no save at all. Seats do: the app asks the backend for one and passes it in init, because a seat names an account and the app holds the session.
What a game reaches
Section titled “What a game reaches”Network
Section titled “Network”The site serves every response from a game’s origin with a Content Security Policy, and the game cannot loosen it. Every file loads from the game’s own origin, from the engine’s address for the engine release, or from the shared assets site, https://assets.spawnite.com, for a shared model, picture or sound, and the policy names no other host but the rooms’. The engine’s address and the shared assets site serve the platform’s own files and read nothing a request carries. It holds the following directives:
| Directive | Sources | What they allow |
|---|---|---|
default-src |
'self' |
Anything no other directive names, from the game’s origin alone |
script-src |
'self', a nonce made for the response, 'unsafe-eval', 'wasm-unsafe-eval', blob:, the engine’s address |
The bundle’s and the engine’s scripts; the import map the site writes, and the scripts Cloudflare injects, under the nonce; the engine’s generated code; Rapier’s WebAssembly; workers made from blobs |
style-src |
'self', 'unsafe-inline' |
The bundle’s stylesheet, and style written in the page |
img-src, font-src, media-src |
'self', blob:, data:, and for images and media the engine’s address and the shared assets site |
The game’s files, the engine’s and the shared pictures and sounds, and a file the page made itself, such as a texture three reads through a blob, or a file under 4 kB that the build wrote into a data: URL |
connect-src |
'self', blob:, data:, the engine’s address, the shared assets site, wss://*.<rooms domain> |
Fetches of the same files, the engine’s models and WebAssembly, the shared models, clips and textures, and the room’s socket |
frame-ancestors |
the app’s origins | The app’s frame alone |
Nothing else loads. A font, an image, a sound, a stylesheet or a script on another host is refused, and so is any fetch or socket but the room’s. A blob: or data: URL and style written in the page fetch nothing from any host, so they open no route out: a url() in an inline style still passes the directive of what it loads. A worker loads under script-src. A frame nested in the page loads nothing from an address: it may ask only the game’s origin, and every response from it names the app’s origins alone in frame-ancestors. The policy names no backend yet, because the engine does not call it from the page. A game with no rooms hands its save to the app, which sends it to the save worker, as Who owns a save describes, so the page never reaches the backend or the worker itself. The frame’s sandbox, which grants no allow-forms, allow-popups or allow-top-navigation, stops a form, a popup and a navigation of the app’s page.
A published page’s errors leave the same way, through its own origin: the engine posts each report to /__spawnite/errors, which 'self' allows, and the site forwards it to the platform’s error tracker only when it names the platform’s own project. The tracker sees the site’s address, never the player’s. The site tags each published page with where its reports go, and a page without the tag, such as a dev server’s or spawnite play’s, sends none.
Refusing every other host closes two routes. A load cannot carry what the page holds to another site, and it cannot plant a file in the browser’s cache at another game’s address, which another game could time, since every game under the play domain shares one cache. The engine’s address is the one host every game loads from, and it serves the platform’s own files alone. Discord’s Activities hold the same line: their page’s policy names its own origin, and every other host is reached through Discord’s proxy on that origin. Poki refuses every outside host unless it approves one for the game. A creator who loaded a font or a picture from another site moves the file into the game’s folder, so the bundle carries it. spawnite build warns of each address on another site that the page’s HTML, stylesheets and scripts load, as Publishing says, and the browser’s console names each load the policy refused.
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: it removes the frame and says the game stopped. The app cannot tell a reload from a navigation, since it cannot see into the frame’s origin, so a page that reloads itself ends the same way.
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.
The build’s warning is an early notice. 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.
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 nickname the player picked, which every other player sees. It is never the name the sign-in provider gave.
- In a game with rooms, a seat ticket, in the shape of Colyseus’s seat reservation, which the app asked the backend for and passed in
init. It seats one player in one room once, and it expires after three minutes. Its room reads and writes the save.
A page in a game with no rooms holds no credential: it hands its save to the app, which holds the save’s pass, as Who owns a save says.
The player behind them is an account or a guest, and the page cannot tell which. A guest is an identity the backend issues itself, beside WorkOS’s accounts, so a player plays at once with no sign-up. A guest has lower caps than an account and cannot buy, publish or use voice, and its first sign-in carries its saves into the account. Sign-in happens in the app, never in the game’s frame, and the frame does not reload for it, but in one case: a guest whose account already holds a save in a game with no rooms, where the account’s save wins and the app loads the frame again with it.
The page never holds the player’s session, a save pass, a payment credential, the developer’s publish token, or another player’s private records, such as their save. It does hold what a room streams to every player, other players’ characters among it.
The following diagram shows the credentials in order, from the sign-in to a room’s save write. Each arrow names the credential it carries:
sequenceDiagram
participant P as Player's browser
participant A as The app (spawnite.com)
participant B as Backend
participant G as The game's page (id.spawnite.net, in a frame)
participant R as Room
P->>A: Sign in through AuthKit
A->>B: Every call carries the session
P->>A: Play
A->>B: join(game), with the session, one call
B-->>A: per-game id and display name, and a seat ticket, the room's address and its version for a multiplayer game
A->>G: init { id, name, seat ticket, insets }, in a frame at the room's version
Note over A,G: The page holds the ticket, never the session. A seat not connected within three minutes of its room opening is freed.
G->>R: Connect, presenting the seat ticket
R->>B: Confirm the ticket, with the room credential
B-->>R: Who is seated, and a save pass for each
R->>B: Heartbeats, with the room credential, answered with renewed passes
Note over G,B: A game with no rooms opens its save in the app: the app holds the pass, and the page hands it each record.
G->>A: purchase(item)
A->>B: the purchase, with the session, after the app's own sheet
B-->>A: the result and a receipt
A-->>G: the result and the receipt id
G->>R: the receipt id, as an engine message
R->>B: confirm and acknowledge the receipt, with the room credential, in the save write that holds the grant
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, gamepad and, for now, microphone, and nothing else. Not fullscreen: a frame gone fullscreen sits above the app’s controls, so Leave and Report would be out of reach. The app goes fullscreen itself instead: the pill’s Fullscreen, and the engine’s Play through the fullscreen message, fill the screen with the app’s play screen, which holds the frame and the pill together, and Escape leaves it. The browser allows it only within a moment of the player’s press, in the app or in the frame, so a page cannot fill the screen on its own. The microphone is for voice, which the engine runs inside the frame until the app takes voice over; the browser still asks the player before any page in the frame hears anything, and the grant goes when voice moves. A game gets no camera, location, notifications or clipboard read.
Limits
Section titled “Limits”A published game runs under the following limits:
| Limit | Value | Enforced by |
|---|---|---|
| Scripts module | JavaScript and WebAssembly; no native code | the browser |
| Initial download | 50 MB of HTML, scripts and styles | the upload check |
| Whole bundle | 1 GB, with a warning past 100 MB | the upload check |
| Files | 5,000 | the upload check |
| Account storage | 10 GB of versions, store pictures and shared assets | the backend |
Time to ready |
20 seconds, from the room’s opening when the frame loaded while it started | the app’s error screen |
| Save body | 4 MiB gzipped, 16 MiB unpacked | the save worker |
| Save writes | 20 a minute per player, across games | the save worker |
| Save sessions | 60 started at once, then 120 an hour per player, across games | the backend |
Silence after ping |
10 seconds | the app removes the frame |
| Game name | 50 characters; the text filter, no Spawnite, no invisible character | the backend |
| Store page text | The text filter, on the tagline, the description and each alt text | the backend |
The load figure comes from CrazyGames’ technical requirements. The save body’s cap is Roblox’s limit on a data store value.
The bundle limits stop only abuse and the absurd, and a creator learns of a large bundle from a warning rather than a refusal. Storage is cheap, and a player downloads only the files a game loads, so the whole bundle’s 1 GB costs a player nothing on its own; the warning past 100 MB asks the creator to check that nothing unused ships. The initial download, the page’s HTML, scripts and styles uncompressed, is refused past 50 MB as a guard against a bundle that ships data as code.
How long a player waits before the game starts is a separate number, the gzipped size of the game’s code that spawnite build prints, which refuses nothing, as Publishing says. No file has a limit of its own, since one request to the bucket carries 5 GiB, past the whole bundle’s 1 GB. The 5,000 files are what one upload’s listing holds. The account’s 10 GB holds about 770 versions of a Holdfast-sized game and costs 15 cents a month in R2, and the platform raises it per account. The backend checks each limit before a byte moves.
A game’s name and its store page’s text pass the same text filter a nickname does, each time they are written: games.create, games.rename and a store page upload. The filter is one module of the schema package, @spawnite/schema/text-filter. It reads through lookalike letters, accents, invisible characters and punctuation, so f/u/c/k and sh!t count. A name is also read with its words run together and for hate codes, so F u c k and Player 14/88 are refused, and for personal information and links, so a name such as snap xyz123 or freerobux.xyz is refused too. A mild word, such as “crap”, passes in a name.
Long text, the tagline, the description and each alt text, is read word by word, so “this hit” and “Score 14,880” pass, while a hate group or slogan of several words, such as “white power”, is still refused. Long text is the creator’s own, so a mild word such as “crap”, a link and an address in it pass.
A name also cannot hold Spawnite in any of its words, however spelled, so “Sp4wnite Racing” is refused and “Spawn Items” passes. It cannot hold an invisible or direction-changing character either, except the joiner and the selector inside an emoji such as ❤️; any script and any punctuation may stand in it. A refusal names the field and quotes the creator’s own passage that holds the word, such as The description holds a word the store does not allow: "sh1t". Reword it., so an agent can find and fix it; it never prints the list. A game already on the store keeps its text until its maker next writes it. Roblox filters a game’s name and description the same way when they are set.
A bundle may hold WebAssembly because a game runs on an origin of its own, apart from the app’s session and sign-in, as Where a game runs says, so a game’s WebAssembly reaches nothing its JavaScript could not. The policy already allows WebAssembly, wasm-unsafe-eval, for the engine’s Rapier physics, which every game runs.
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 hands the app its last record while the app waits one second, and the app sends it to the save worker.
- 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.
What the platform keeps
Section titled “What the platform keeps”The platform keeps the builds a creator publishes and the saves its players write, never a game’s source. The project stays in the creator’s folder, and the creator backs it up: spawnite create starts the folder as a git repository, and the creator’s agent offers to push it to GitHub.
Who owns a save
Section titled “Who owns a save”The platform keeps one save per game and player. Each save the room or the engine writes for the game is a new body in an object store, which a small save worker stores under a key it writes once; the backend keeps the save’s head, which names the current body and the one holder that may move it:
- 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. Against Spawnite, a game’s saves are its creator’s, as If you leave says; against the creator, each player controls their own.
- 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.
A save is never half-written. Each save is a new body, stored whole, and a commit moves the save to it in one transaction, or changes nothing. A room reads every player’s record at one tick and commits them all together, so whatever the game moved between players, such as a trade, is in every save or in none.
Who writes a save depends on whether the game has rooms, which its manifest declares:
- In a game with rooms, only the room reads and writes. The backend gives it a save pass for each seated player, in the seat’s confirmation and each heartbeat’s answer, and the room uploads the bodies to the save worker with it and commits each checkpoint to the backend. Its page holds no save.
- In a game without rooms, the engine builds each record in the page, checks its size, and hands it to the app over the channel. The app keeps the newest in its journal on the device and sends it to the save worker with the pass the backend signed for its session, then commits the worker’s receipt to the backend. The backend refuses to open a page session for a game with rooms.
One holder writes a player’s save at a time: a page session or a room’s seat. A new session asks a live one to commit its last save and release, and counts it out after 3 seconds; a seat’s room lets her go with its next checkpoint. A pass names the head’s generation, which rises each time the holder changes, and lasts an hour, and the backend never renews a pass for a holder another replaced. A body a replaced holder stores before its pass expires is stored and never read: a commit takes only a receipt of the head’s current generation, so no head ever names that body. The worker checks the pass with a secret the backend holds too, signs a receipt the backend checks, and never calls the backend. A pass names its player by an id drawn for each game, and carries the account id, which the worker’s rate limit counts across games, sealed under a key only that secret derives. A room holds the pass, and a creator’s code that reads it cannot tell that two games have the same player.
A player can therefore edit their own single-player save, and nothing in it has value beyond that player. A purchase’s receipt never lives in a save: the backend holds it. A save keeps what the game granted from a purchase, and the receipts it granted, so a grant is delivered once, as Earning says. An edited save changes that player’s game and no record of what they paid. 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, each player’s own character included. Entity lists the defaults for each kind of entity. A room holds to these rules:
- A client sends its inputs, never its state: her moves, where she walks, her shots and the game’s own messages. The room runs each one under the game’s rules, and a page that disagrees takes the room’s result. Multiplayer says what that keeps a modified page from doing.
- The room judges each shot by the game’s rules: the engine’s own by default, which hold a shot to the weapons the shooter holds, to the weapon’s rate, to her body and to what her screen showed, and any the game adds. A game may trust each player’s page to say what her shots hit. The page then names targets, never an amount of damage, and the rules still hold her to her rate and her body. Weapon lists the rules.
- The room ignores any client write to an entity the server owns. Clients never write the wallet, inventory, score, timers or round state.
- A published game’s room knows a player by the seat ticket the backend issued, never by what the client says. A room in development seats whoever joins, under the name the page sends.
- The room’s credential and its save passes reach the players seated in that room and nothing else: a pass reaches one player’s save in one game.
- 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.
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. It applies to the phone app, which is coming with no date yet. Only 4.7.2 has a test: the isolation test. The other rules rest on the page’s policy, the upload check, 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 moderation after a game goes public, which acts on each report |
| 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, enforced by moderation after a report, and the app’s age gate before the frame loads |