# Glop SDK / vanilla export Create a folder containing `index.html`, your scripts and assets. Include `glop-sdk.mjs` from this project if you want lifecycle, layout and progress integration. Upload the folder with **+**. ```js import {createGlop} from './glop-sdk.mjs'; const glop = createGlop({ onViewport({viewport, obstructions}) { /* place controls outside rectangles */ }, onState({active, seed, progress, reducedMotion}) { /* start/stop local RAF */ }, onDispose() { /* cancel RAF/timers, disconnect observers, close audio */ } }); const random = glop.random(123); // seeded PRNG; use the release seed from onState saveButton.onclick = () => glop.save({level: 3}); ``` Use normal Web Audio or audio/video elements. Glop provides a remembered Mute/Unmute control in the feed and draft preview, and pauses audio when you leave. You do not need a separate sound-enable button. The host retries browser-blocked audio on normal interaction or Unmute; browser autoplay rules still apply. This controls Glop output, not microphone permission. ## Host contract, v1 The iframe fragment contains only `parentOrigin` and a fresh per-mount random `channel`. It never receives a user/session token. Its asset URL is either a separate five-minute private bundle capability path or the generic public path for a READY bundle attached to a currently published non-beta project. Every public asset rechecks publication eligibility; neither path is an account credential. Send to `parentOrigin`; accept only `event.source === parent`, exact `event.origin === parentOrigin`, and the matching channel. - `glop:viewport-request` / `glop:ready`: request initial geometry/lifecycle. Host limits replies to ten per second per mount. - `glop:viewport`: `version:1`, `units:'css-px'`, actual iframe-local `viewport:{width,height}` and `obstructions:[{id,x,y,width,height}]`. These include visible feed chrome, navigation, tips, progress controls and draft preview toolbar. Header, bottom dock, and caption rows reserve full-width horizontal bands so edge-avoidance layouts do not misread them as sidebars. The iframe itself stays edge-to-edge. Resize and chrome changes send new rectangles. Geometry is advisory, not automatic adaptation. The host sets `fullscreen:true` while the viewer expands a glop; the iframe then receives the full window viewport and an empty obstruction list. The host-owned fullscreen toggle stays in its original position and becomes the exit button. Feed navigation is disabled until fullscreen is closed; iframe state is preserved. - `glop:discover`: a legacy `handle` reference to a project in the current feed. The host accepts only the mounted opaque frame and channel, rate-limits requests, and ignores hidden/offscreen senders or unavailable targets. It uses the same downward feed transition and shared five-second cooldown as `glop.navigate`. - `glop:lifecycle`: `active`, uint32 `seed`, `progress` (JSON or null), `reducedMotion`, and optional public `widget` context when mounted as a Street widget. Each published post receives a fixed seed. Remount/reset uses that same seed. Ordinary feed/draft mounts have no widget context. - `glop:dispose`: cooperative cleanup before removal. Also clean up on pagehide. Offscreen uploads may be unmounted; the host cannot guarantee cooperative teardown executes before frame destruction. - `glop:save-proposal`: optional JSON `value`, maximum **4096 UTF-8 bytes**, accepted at most once per second. This only offers a save. The viewer must click the host's **Save progress** button to persist it. No automatic storage permission or blanket future-save consent is granted. Progress lives in D1 under **viewer account + immutable release**. It is restored on remount and reload. Reset deletes that viewer's saved state and remounts at the original seed. A draft has no persistent progress namespace. API saves also require session/CSRF and `consent:true`; unauthorized or revoked cohort members cannot read or save. Progress is an app assertion, never a trusted score or authorization signal. Host navigation accepts the existing bounded `glop:swipe` protocol only from the mounted frame with null origin and the matching channel. The injected helper reserves vertical touch and wheel scrolling for feed navigation outside fullscreen, including over nested scroll areas and form controls. Host clamps motion, enforces phase/range/rate limits, timeout and cooldown, and owns the destination. Enter fullscreen to scroll or pan vertically inside a Glop. Horizontal gestures and pinch zoom remain local; `data-glop-gestures="local"` does not override vertical feed navigation. No navigation message gains account capabilities. ## Files and isolation Use relative modules/assets, including dynamic import and noncredentialed local fetch. Include the viewport meta tag. Both iframe and response CSP use `sandbox allow-scripts` without same-origin. Every capability asset request checks expiry, an active viewer session and ownership, publication access, or a current version-scoped testing link; only validated capability bytes receive noncredentialed CORS. Account APIs reject null-origin reads and have no wildcard CORS. Game assets may include `.wasm`, `.wad`, `.data`, `.pak`, `.pck`, and `.bin`; binary data is served as `application/octet-stream`, and Wasm as `application/wasm`. Use relative bundle URLs for packaged assets. Emscripten builds should use single-threaded output without dynamic JavaScript execution (`-sDYNAMIC_EXECUTION=0`); browser storage remains unavailable in the sandbox. Bounds: 256 files, 64 MiB/file, 128 MiB/build. No uploaded server processes, CDNs, direct external APIs, eval or Web Workers. Single-threaded WebAssembly compilation and instantiation are supported. Optional creator-hosted servers use the consent-gated host relay described in [HOMESERVERS.md](/HOMESERVERS.md). Isolation cannot enforce CPU/memory budgets or all external iframe self-navigation. Already loaded/downloaded bytes cannot be recalled. ## Complete example packages See [examples/README.md](examples/README.md). The seven operator-owned examples upload their playable files through ordinary APIs. Sign-in is required for browsing, playback and downloads. Host Reset remounts locally; publication never widens private beta or draft access. ## Street widget instances An eligible public uploaded Glop can also be a general building widget. Include this SDK and your own interface/assets in the upload, have the owner approve publication, then use **Remodel → Widgets** to register the public release and place its definition at an owned constructed building or outdoor side street. Registration may use another owner's public upload, but not a draft, beta, hidden/suspended source, external page, or non-READY bundle. Definition source and defaults are immutable; replace by registering a new definition. See [Street API](street/api.md#general-building-widgets) for the exact registration, instance CRUD, local axes, dimensions, scale, limits and permissions. The same upload can appear in the feed and as multiple independent widget instances. Widget state is keyed by the placed UUID, not the project, release or feed room. `glop.widget` is present on the SDK object, but its calls reject clearly when the active lifecycle has no widget context (including feed and draft preview). In a widget mount, methods wait for the mount to be active: | Method | Result | | --- | --- | | `await glop.widget.getInfo()` | Public context `{id,plot,type,kind,name,placement,config,revision,definition}` | | `await glop.widget.getState()` | `{revision,value}`, initially `{revision:0,value:null}` | | `await glop.widget.setState(value, expectedRevision)` | Updated `{revision,value}` after atomic state CAS | `getInfo().definition` includes `id,name,description,kind,dimensions,defaultScale`, not source IDs or credentials. `glop.state.widget` and `onState` carry the same context, refreshed when the owner edits placement/configuration. Its `revision` is the **instance/config** revision; `getState().revision` is the separate **state** revision. `setState` requires an explicit nonnegative expected state revision and automatically sends the current context revision to fence owner changes. A 409 means re-read context/state and reconcile; do not blindly overwrite. For example, include a button `#increment` and output `#count` in your HTML: ```js import {createGlop} from './glop-sdk.mjs'; const glop = createGlop(); const button = document.querySelector('#increment'); const output = document.querySelector('#count'); const info = await glop.widget.getInfo(); button.textContent = info.config.label ?? 'Add one'; async function refresh() { const state = await glop.widget.getState(); output.textContent = String(state.value?.count ?? 0); return state; } await refresh(); button.addEventListener('click', async () => { button.disabled = true; try { const state = await refresh(); const saved = await glop.widget.setState( {count: (state.value?.count ?? 0) + 1}, state.revision); output.textContent = String(saved.value.count); } catch (error) { if (error.status === 409) await refresh(); // Show current value; user may try again. else output.textContent = error.message; } finally { button.disabled = false; } }); ``` Configuration and state are public. Config is a JSON object up to 8 KiB UTF-8; shared state is any JSON value up to 32 KiB UTF-8. Never put secrets in either. Reads need no sign-in at the public API; writes require a signed-in visitor with fresh Street presence near the object (inside widgets require its building area). This is cooperative app data, not authoritative scores, roles or authorization. Chess/jukebox built-ins use server-authoritative per-instance adapters instead. The widget mount uses the exact eligible publication's capability-backed opaque iframe, `sandbox="allow-scripts"` without same-origin, credentialless and with no referrer. Camera, microphone, geolocation and fullscreen are denied. The host supplies an edge-to-edge iframe inside the Street's native dialog, actual viewport dimensions, and a full-width `widget-controls` obstruction for its overlaid title and Close button. Respect this band in `onViewport`; opening a widget does not open a new browser window. It sends the fixed publication seed, null progress and active/reduced-motion lifecycle. It exposes widget state RPC, not the feed's room/storage/network/browser/navigation RPCs: the existence of another SDK method does not grant that host capability. Closing/removing the instance disposes the mount; source eligibility revocation hides public instances and prevents subsequent state/actions, although downloaded bytes cannot be recalled. Owners can still list unavailable instances with `available:false` and remove them. Deletion cascades instance state; reopening an existing instance retains state. ## Communication between instances Include `glop-sdk.mjs` in your upload. A connection waits until this instance is active in the feed; paused/preloaded instances do not join or poll. The host keeps credentials and derives the Glop from the mounted publication. The frame cannot select another Glop or use account APIs. ```js const glop = createGlop(); const room = await glop.connect({ channel: 'lobby-v1', onPeers(peers) { renderPeople(peers); }, onMessage({from, value}) { showMessage(from, value); }, onState(value, {revision}) { renderSharedState(value); }, onError(error) { showConnectionError(error.message); }, onHistoryGap() { /* recover from room.state or request a fresh snapshot */ } }); await room.send({kind: 'hello'}); // everyone, including sender await room.send({kind: 'hello'}, {to: peerId}); // a particular peer await room.setState({counter: 1}, room.revision); // conditional shared update await room.refresh(); await room.close(); ``` Connections share a room only when **Glop project + channel + room mode** match. Different publications of the same Glop can communicate; use a versioned channel when their protocols are incompatible. Different Glops are isolated by default; cross-Glop persistent data requires explicit [shared spaces](#cross-glop-shared-spaces). Rooms remain project-scoped. Draft previews have private test rooms scoped to **uploaded draft version + channel + room mode**, isolated from every other draft version and all published rooms. No publication is needed. In draft review, **Share for testing** creates and copies a link for signed-in testers; **Revoke** invalidates it and disconnects its members. Creating a new link replaces the old one. A link grants only that version's preview and rooms, not editing, publishing, original source downloads, or other drafts. Runtime code sent to a browser can still be inspected. Tokens have 256 bits of entropy and only hashes are stored; the host keeps the share token outside the sandbox. Draft testers must sign in, even though published public Glops support guest viewing. Shared permanent storage, views, progress saving and voice remain unavailable in drafts. Automated uploads remain private drafts. `connect()` returns a promise for a connection with getters `id`, `playerId`, `roomId`, `peers`, `state`, `revision`, and `locked`. Each peer has `{id, playerId, name, online, lastSeen, departed}`. `id` identifies an instance/seat. `playerId` identifies an account only within this Glop; the raw account ID and session credentials are never exposed. Shared rooms give separate instances separate IDs, including tabs of the same account. Messages are JSON, up to 4 KiB, with `{id, from, to, value, time}`. The current SDK polls every 500 ms while active, with no overlapping polls, an immediate refresh on resume, and backoff during outages. Existing uploads that bundle an older SDK retain their polling interval; host-side reconnection and request-completion fixes apply to them without a new upload. IDs are server-assigned and ordered; sender identity is derived from the authenticated connection. A direct message is returned only to its sender and recipient. `send(value, {nonce})` allows a caller-supplied UUID for retries; reuse the same nonce and content after an uncertain response. A changed payload with the same retained nonce is rejected. The host retries temporary transport failures for connections, polls and messages with the same identity/nonce, within a 12-second deadline. It does not blindly retry an uncertain state write: refresh and reconcile its revision first. Repeating an already recorded message does not consume additional write quota. Message history retains the latest 512 events for at most five minutes. `onHistoryGap` reports when your cursor falls behind retained history. For durable current room data, use `state`: up to 16 KiB of JSON, initially `null`, with a revision initially `0`. `setState(value, expectedRevision)` atomically replaces it only if the revision matches. Omit the second argument to use the connection's current revision. A conflict rejects with `error.status === 409`; refresh and reconcile before retrying. Any member can update state. Applications implement and validate their own message/game rules; shared state does not provide authoritative anti-cheat logic. Presence expires after 15 seconds without a heartbeat. The SDK pauses polling when inactive and cleans up on disposal. The host reconnects expired connections before polling, sending messages, or writing state. It preserves the original channel and room options, and rejects a queued write with `code: "room_changed"` if reconnection finds a different room. Refresh and reconcile before retrying that write. Completed requests are acknowledged even if the frame becomes inactive while waiting. Rooms/state expire after 24 hours of inactivity. A frame can have four connections, a shared room can have 64 live instances, and each ordinary room peer can make 600 writes per minute (voice signaling remains at 120). The host also limits total frame requests. A 429 error includes `retryAfterMs`; write cooldowns do not stop presence polling. The SDK bounds its operation queue to eight requests. These bounds keep uploaded apps within the host's service limits. ### Matchmaking ```js const match = await glop.connect({ channel: 'my-game-v1', mode: 'match', capacity: 2, onPeers(peers) { showPlayers(peers); }, onState(state, {locked}) { if (locked) renderGame(state); } }); ``` Matching is atomic and scoped to the Glop/channel/capacity. `locked` becomes true when the room fills (2–64 players). A full room never receives replacement players. Match seats belong to distinct accounts; another tab or refresh by the same person resumes the seat within 60 seconds. `.close()` explicitly leaves the seat so the next connection can find a new match. A connection suspended by scrolling away remains resumable during that grace period. Game code decides how to handle departure and disconnect timeouts. The complete `examples/chess` upload uses this general connection and shared state. No chess-specific host endpoint is required. ## Realtime rooms (WebSocket) ```js const room = await glop.realtime('tower-v1', { onMessage({seq, from, playerId, name, time, value}) { applyInput(value); }, onPeers(peers) { renderQueue(peers); }, onClose() { /* reconnect when active and request an application snapshot */ }, onError(error) { showError(error.message); } }); await room.send({kind: 'input', frame: 120, action: 'grab', block: 4}); await room.send({kind: 'cursor', x: 10, y: 20}, {reliable: false}); // fast console.log(room.fastTransport, room.now(), room.clock); console.log(room.id, room.playerId, room.peers, room.closed); await room.close(); ``` This is a separate **project + versioned channel** namespace, not a faster poll of `connect()`. It supports 2048 concurrent instances per room, including separate tabs of the same account, and four channels per mounted frame. Peers have `{id, playerId, name}` in join order. Above 50 peers, presence carries the first eight queue entries plus `count`, `capacity` and `compact` metadata, not a full roster. Use `turns:true` to opt into the first-come rotating queue with 20-second turns; `onPeers(peers, info)` receives `info.turn` and the holder can call `room.advanceTurn()`. The host derives the published project; the authenticated Worker derives identities exactly as ordinary rooms do. The sandbox never gets cookies, session tokens, URLs containing credentials, or a WebSocket object. Drafts join their private version-scoped test rooms, including reliable relay and fast UDP when enabled. Revocation disconnects link members immediately on Node and within the next one-second authorization tick on the Worker relay. Exact Origin is required at the upgrade endpoint; null-origin frames cannot upgrade directly. Only the mounted frame and matching host channel can request operations. A Worker Durable Object (or the VPS's equivalent room instance) owns each room's single serial queue and reliable WebSocket fanout. It reserves durable sequence ranges at activation, then assigns increasing `seq` numbers in memory and immediately broadcasts each input, including to its sender. There is no polling, batching/flush timer or per-input disk write in the relay path. It never simulates physics or authorizes application moves. All connected peers receive the same reliable message order. `time` is server receipt time. `send()` acknowledges host buffering, **not delivery**; the sender's echoed sequence is the delivery acknowledgement. No retries, deduplication, retained message history or automatic reconnect are implied. Sequence gaps close the SDK handle and report an error. Applications must resynchronize after every reconnect. Sequence numbers remain increasing across object restarts (unused reserved numbers are skipped); live room state and sockets do not. Welcome establishes the reconnect baseline. Limits: 16 KiB UTF-8 per JSON wire message (including the small send envelope), 60 messages/second/peer with a burst of 120, and 64 KiB/second/peer with a 256 KiB burst. Overflow or malformed traffic closes the connection. The host bounds sends to eight pending operations and 256 KiB WebSocket backpressure. Heartbeats every five seconds recheck session/publication/beta authorization; fifteen seconds without traffic expires presence. Revocation takes effect within the next check. Sockets close on inactivity, hiding the tab, navigation and disposal; reconnect only after the next active lifecycle callback. This is cooperative shared state, not server-authoritative anti-cheat. ### Fast delivery and room clock `room.send(value, {reliable:false, to})` opts into **fast** delivery. Reliable remains the default. Fast messages arrive through `onMessage` with `delivery:'fast'` and no `seq`; they may disappear or arrive out of order. Missing fast messages never trigger the reliable channel's sequence-gap recovery. Keep application sequence/frame numbers in the payload to discard stale updates. `to` has the same membership-checked targeting and sender echo as reliable sends. The send promise acknowledges local submission, not remote delivery; a locally discarded update resolves `{sent:false, dropped:true}`. There is no retry. `room.fastTransport` reports `webrtc-server` only once the host's unordered, zero-retransmit data channel is open. Both hops terminate on Glop's Node server; there is **no peer-to-peer connection** or exchange of player ICE addresses. The iframe receives only JSON through the existing authenticated postMessage bridge, never sockets, SDP, credentials, or transport URLs. Reliable traffic, membership, clock pings and signaling stay on the authenticated WebSocket. Both modes share room authorization, 16 KiB ingress envelopes and per-sender rate/byte limits. Use small updates: large fragmented SCTP messages still incur congestion and reassembly costs. Fast is not a guarantee of a latency bound or delivery. During negotiation, on unsupported browsers/runtimes, or if UDP fails, `room.fastTransport` reports `websocket-fallback`. The host keeps at most one waiting fast update per connection, replacing the oldest under backpressure and expiring it after 100 ms; the SDK bounds outstanding fast RPCs separately from reliable sends. **This fallback is TCP, not UDP:** dropping unsent updates avoids an application retry queue, but cannot remove already-buffered TCP bytes or eliminate transport head-of-line blocking. A destination that has not opened its server data channel also receives this fallback; the property describes the local host-to-server leg, not every recipient. Applications that require genuine fast transport should wait for `webrtc-server` and handle later fallback. `room.now()` estimates server Unix time in milliseconds using the host's ping round trips and a monotonic local clock. Startup sends five samples, then one every five seconds; the lowest RTT among the last eight samples younger than 12 seconds estimates the midpoint offset. `room.clock` exposes `{synced, rttMs, ageMs}`; `synced` is false until a sample arrives, after 15 seconds without a usable sample, and after close. Before synchronization the welcome time is only an approximate baseline. Estimates can step as samples change; this is not an anti-cheat clock or a globally synchronized physics loop. Network asymmetry and server wall-clock changes cause error; RTT/2 is a rough uncertainty, not a hard accuracy promise. Wait for `synced`, select an application tolerance, and maintain your own rollback history/frame mapping. Transport decision: Cloudflare's documented Workers interfaces accept HTTP(S) and WebSockets and expose outbound TCP, but not a WebTransport datagram endpoint or server-side `RTCPeerConnection`; HTTP/3 support alone does not supply either. Durable Objects can terminate WebSockets. That is **not** the production hosting constraint: Glop's Docker/Node runtime on the New Jersey VPS can own UDP sockets. It therefore uses a server-side WebRTC data channel (`ordered:false`, `maxRetransmits:0`) implemented by pinned `werift` (pure TypeScript ICE/DTLS/SCTP, no native compiler, TURN service or separate daemon); the existing `ws` handles signaling and reliable relay. This adds one direct dependency with its locked transitives rather than a second language/runtime or QUIC/TLS server. Cloudflare and UDP-blocked environments retain the explicitly labeled WebSocket fallback. See [Workers protocols](https://developers.cloudflare.com/workers/reference/protocols/), [TCP sockets](https://developers.cloudflare.com/workers/runtime-apis/tcp-sockets/), [Durable Object WebSockets](https://developers.cloudflare.com/durable-objects/best-practices/websockets/), [werift](https://github.com/shinyoshiaki/werift-webrtc), and [data-channel retransmits](https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel/maxRetransmits). VPS setup is in `server/README.md`; no deployment is performed by this change. `npm run test:realtime-fast` starts an isolated local Node server and two headless Chromium SDK clients. A real UDP proxy drops every fifth outbound packet and delays selected packets by 120 ms (others by 5 ms), checking that newer messages arrive first, reliable traffic survives gaps, and the clocks agree within 15 ms. It prints delivered percentage and median/p95 **one-way** latency, including host bridges, using the synced timebase; these are local measurements, not WAN SLAs. See `examples/mmo-jenga`: Three.js + pinned deterministic Rapier, 60 Hz fixed-step physics and turn-only input streaming. Application-owned `config.mjs` sets six input-lead frames plus six simulation-lag frames (200 ms nominal at 60 Hz), with a 50 ms frame-watermark tick. This deliberate scheduling delay is not relay RTT; the SDK adds no input delay. Each Glop chooses its own buffering/prediction policy. `npm run test:realtime-latency` measures 100 round trips between two headless SDK clients (including both host bridges), separately from game simulation/rendering, and asserts a local median below 20 ms. Start local Wrangler on port 4189 first. A peer-owned frame watermark keeps spectators on the same simulation frame without rollback or independent wall-clock stepping. Late input is consistently ignored, never applied at a different frame. Late joiners receive a compressed physics snapshot, turn metadata and input log, then replay ordered inputs after its boundary. The server only relays these opaque messages. ## Voice chat Use `await glop.voice.enable({channel:'lounge-v1', onState})` to add Glop-owned voice controls. The returned handle exposes `requestJoin()`, `mute()`, `leave()` and `state`. Enabling voice never opens the microphone; joining and unmuting require explicit clicks in Glop's trusted UI. The game receives participant identities and speaking indicators, never audio streams. Once joined, voice continues across tab and window switches while the Glop remains active. Leaving voice or leaving the Glop stops capture. Up to eight people can join a project-scoped voice channel. See [Voice chat setup and API](VOICE.md) for consent behavior, state fields, direct-connection limits, and TURN relay configuration. Draft previews cannot join voice. ## Publishing from tools See [Creator API](API.md) for browser authorization, scoped creator tokens, retry-safe uploads and draft links. Publishing credentials belong to the creator's local tool, never to uploaded runtime code. ## Permanent shared storage `glop.storage` stores JSON for the published Glop, shared by all signed-in viewers who can access it. Data has no expiry and survives closed rooms, browser reloads, server restarts, and edits to the Glop. Deleting the parent Glop deletes its storage. This is shared editable data: do not put secrets or trusted scores here. Personal, consent-based `glop.save()` is unchanged. ```js const current = await glop.storage.get('canvas'); try { await glop.storage.set('canvas', updatedCanvas, {revision: current.revision}); } catch (error) { if (error.status === 409) { // Another viewer edited it. Read again, merge your change, and retry. } else throw error; } ``` `get(key)` returns `{key, value, revision}`; missing keys have `value: null` and `revision: null`. `set(key, value, {revision})` creates only with a null revision, or replaces only the exact revision read. `delete(key, {revision})` requires the current revision. Successful mutations return the new record (null revision after deletion). `list()` returns `{keys: [{key, revision, bytes}]}`. Revisions are opaque and change on every write, including recreation after deletion. Keys accept 1–128 ASCII letters, digits, dots, slashes, underscores, or hyphens. Limits: 64 KiB of UTF-8 JSON per value, 256 keys and 1 MiB per Glop, and 120 writes per minute per viewer per Glop. Quota errors use status 413; write rate errors use 429. Calls wait until the frame is active; drafts cannot access published storage. The host derives the Glop from the mounted post, keeps login credentials outside the sandbox, and enforces access for each request. ## Cross-Glop shared spaces Shared spaces let **different Glops** read and update the same persistent JSON. They are opt-in, project-level data, separate from `glop.storage` and personal `glop.save()` progress. Rooms and messages remain scoped to one Glop. ### Connect two Glops 1. Open your Glop's creator editor, choose **Edit → Shared spaces**, and create a named space. Copy its **Space ID** for your app's SDK calls. 2. The other creator opens **Shared spaces** in their Glop and gives you its **project ID**. Invite that project with **Read only** or **Read and write**. 3. The other creator opens their invitations and chooses **Accept connection**. This is required even when both Glops have the same creator. 4. Bundle the current `glop-sdk.mjs` with each app and use the same space ID. Publish both Glops to test runtime access; drafts cannot access stored data. Sharing settings take effect immediately, independently of approving an app update. Signed-in owners and creator-tool tokens with explicit project sharing grants can manage them. Uploaded runtime code cannot create spaces, grant access, or accept invitations. See the Creator API for operator grants. Opening a space handle in code does not grant permission or display a consent prompt. ```js import {createGlop} from './glop-sdk.mjs'; const glop = createGlop(); const world = glop.spaces.open('SPACE_UUID_FROM_CREATOR_SETTINGS'); const current = await world.get('plants'); // Requires read-and-write access. Read-only Glops can use get() and list(). try { await world.set('plants', {...(current.value ?? {}), roses: 3}, { revision: current.revision }); } catch (error) { if (error.status === 409) { // Read again and reconcile with the other Glop's update before retrying. } else throw error; } ``` `open(id)` synchronously returns a handle with the same `get`, `list`, `set`, and `delete` methods and revision semantics as `glop.storage`. Calls wait for an active instance and require a signed-in viewer. The trusted host derives the requesting project from the mounted publication; code cannot nominate another project. Data is fetched on request, with no subscriptions in this version. Poll conservatively while active if your UI needs updates. The space-owning Glop has read/write access. Other projects need both a current grant and acceptance. **Read and write** includes deleting keys, but does not allow changing membership or deleting the space. Any signed-in viewer using an authorized Glop can exercise that Glop's data permissions. Treat the data as collaborative content, not secrets, verified scores, or personal inventory. Private-beta projects cannot create or connect shared spaces. The owner can **Revoke** a project; a recipient can **Disconnect**. These stop subsequent access and writes without erasing the shared data. Reinviting or changing access requires fresh acceptance. An already downloaded value cannot be recalled. Requests recheck permission at the database read/write, including writes racing with revocation. A `403` means access is unavailable; a `409` means the revision or access changed, so refresh before retrying. Data survives reloads, restarts, and release updates without expiry. Deleting the owning Glop or choosing **Delete space and data** permanently removes the space, grants, and data. Deleting a connected Glop only removes its connection. Unpublishing the owning Glop blocks runtime access until it is published again. Limits: 8 owned spaces per Glop, 32 invited Glops per space, 64 KiB JSON per value, 256 keys and 1 MiB per space, and 120 writes per minute per viewer per space across all connected Glops. Keys follow ordinary storage's 1–128 character rules. Spaces share the host's existing frame request limits. `413` indicates a quota and `429` indicates a rate limit. See [the management API](API.md#shared-space-management) for browser-session request details. Viewer-specific cross-Glop saves are not part of this API; they would require a separate explicit viewer-consent flow. ## Background execution safeguards The host injects a lifecycle guard before application scripts. It pauses registered `setInterval` callbacks, `requestAnimationFrame` loops, and tracked audio contexts while the iframe is inactive, and resumes the same iframe when it becomes active. The DOM and in-memory state are retained. Intervals restart on their normal delay; missed background ticks are not replayed. One-shot timeouts remain unchanged. Keep using SDK lifecycle callbacks for application-specific cleanup and work. Switching browser tabs does not suspend or unmount the selected Glop or its SDK connections. Browsers can still throttle background timers and animation. Room and persistent-storage requests share a per-frame allowance of 20 requests per second with a burst of 40, in addition to existing concurrency and server limits. Debounce writes; handle status 429 with backoff rather than immediate retries. These safeguards are best effort, not a CPU or memory sandbox. They cannot interrupt synchronous infinite loops, prevent all allocations or GPU work, or stop an upload from replacing the wrapped APIs. They do not impose a frame-rate cap on the active Glop, disable autoplay, or reset cached neighboring Glops. ## Creator-hosted game servers Add an optional `glop.json` manifest with one HTTPS homeserver origin. After the player approves Glop’s own Connect control, `await glop.homeserver.request(value)` sends JSON to that server. The host retains credentials and enforces consent, lifecycle and request limits. See [HOMESERVERS.md](/HOMESERVERS.md) for the protocol, security boundaries and runnable multiplayer example. ## Previewing phone layouts and feed obstructions The draft review has **Phone**, **Phone landscape**, **Desktop**, and **Current window** sizes. These set the iframe's actual CSS viewport, so media queries behave as they do on that device. The preview may be scaled to fit the dialog; the displayed percentage does not change the dimensions sent to `onViewport`. Enable **Simulate feed chrome** to display representative feed controls and receive their measured iframe-local obstruction rectangles. Turning it off sends an empty obstruction list. Switching sizes or chrome does not reload the playable, reset its local state, or grant published storage access. Private test rooms are available in draft review; the inline Studio chat preview remains local-only. The simulation covers typical navigation, caption, action rail and swipe-edge areas; actual device safe areas and dynamic runtime controls can vary. Upload the single-file [viewport check example](/examples/viewport-check/index.html) to inspect the reported dimensions, highlighted obstruction rectangles, and input handling before publication. ## Fixed seeds and replay variety The host's post seed stays fixed so people can share the same starting run. Host Reset deliberately returns to that starting seed. For an in-game **New run** button, derive a different seed from the base seed and a replay counter: ```js let replay = 0; function startRun() { const base = glop.state.seed; const runSeed = (base + Math.imul(replay, 0x9e3779b9)) >>> 0; const random = glop.random(runSeed); // Build this run using random(). Display runSeed if runs should be shareable. } newRunButton.onclick = () => { replay++; startRun(); }; // Call startRun() after the first active lifecycle message supplies the base seed. ``` Keep the counter in memory for per-visit variety. To restore a particular personal run later, include its counter or derived seed in the progress proposed through `glop.save()`; saving still requires the viewer's consent. A changing replay seed does not change the host seed or another person's game. Embedded documents occupy one viewport: the host clips full-page vertical overflow in the feed and restores the document’s normal scrolling in fullscreen. Use responsive layouts that fit the viewport. Nested scroll containers (for example chat history) remain scrollable. The Fullscreen button above Star removes feed chrome; Escape or the exit button restores the feed. ## Fullscreen mouse capture Mouse capture is available to every Glop, independent of project, release, or creator identity. Studio chat previews and draft review use the same host bridge: choose **Fullscreen** on that preview, then click its surface. Automated hidden Studio test frames cannot capture. Previewing a phone layout does not grant capture outside fullscreen. In Glop fullscreen, click the active, visible playable surface to capture the mouse. Outside fullscreen, capture is denied. Form controls and touch input do not automatically capture. Escape, leaving fullscreen, hiding the page, or disposing the playable releases capture. Existing iframe state and connections are preserved. The host owns native pointer lock and forwards relative mouse/pointer movement, buttons, and wheel input into the sandbox. Games can use `document.pointerLockElement`, `pointerlockchange`, `element.requestPointerLock()` and `document.exitPointerLock()`. Requests require an active fullscreen Glop and a current user gesture. Forwarded events are synthetic; they do not grant trusted browser privileges. Native pointer-lock permission remains disabled inside the uploaded iframe. ## Shared view totals `const {total} = await glop.views.get()` reads the published Glop's cumulative, project-wide view total. Every viewer and release of that project shares this number. The call waits until the playable is active; draft previews reject it with status 409. Poll about every five seconds while active if you need updates. Reading never counts a view, and the SDK cannot set or increment the total. Only the aggregate is exposed, not viewer identities or browsing history. A view is credited when Glop finalizes a qualified visible feed visit (For You, Following, or Starred, including direct release links). The host first requires one second of stable visibility, then at least one second of viewing with its server receipt. Credit normally arrives when the viewer swipes away, hides the tab, or reaches a minute of viewing. The same signed-in account counts at most once per project per 30 minutes, across tabs and releases. Preloaded frames, drafts and brief flybys do not count. These are bounded client-reported visits, not a tamper-proof audience measurement. Counts start with this feature; past views cannot be reconstructed from the temporary recommendation history. History pruning and release updates do not reset the total; deleting the project does. For a collaborative drawing that wears away as people pass, save the drawing and its starting view total together in `glop.storage`, using its revision checks to merge concurrent edits. Render wear from the difference: ```js const {value: drawing} = await glop.storage.get('drawing'); const {total} = await glop.views.get(); if (drawing) { const visitsSincePaint = Math.max(0, total - drawing.paintedAtViews); renderDrawing(drawing, {wear: visitsSincePaint}); } ``` Set `paintedAtViews` to the current total when paint is added. Derive erosion from the saved baseline on each render; do not subtract and write the same decay once per connected viewer. This makes the effect persist while nobody has the Glop open and avoids double-applying it when several collaborators are present. ## Internet (public production) Internet access is available through the current SDK on glopit.lol, including owner draft previews. Download the current `/glop-sdk.mjs` with your upload; existing bundled SDK copies do not gain new methods automatically. ```js const glop = createGlop(); const response = await glop.fetch('https://api.example.com/data'); const data = await response.json(); const imageResponse = await glop.fetch('https://images.example.com/photo.png'); const imageURL = URL.createObjectURL(await imageResponse.blob()); image.src = imageURL; // Revoke the URL when finished. const socket = await glop.socket('wss://game.example.com', { onMessage: text => console.log(text), onClose: () => console.log('closed'), onError: error => console.error(error) }); await socket.send(JSON.stringify({action:'join'})); socket.close(); ``` `glop.fetch(url, {method, headers, body, signal})` returns a standard `Response`. It waits for the glop to become active. The host aborts pending requests when it is scrolled away from or disposed; sockets close and must be reopened when active again. No requests are made by preloading. Already delivered requests cannot be recalled. A lost tab's sockets expire after 10 seconds without polling. The relay permits public HTTPS/WSS domain names on port 443, and rejects private, loopback, reserved and Glop-server IPs after DNS resolution. The connection uses the validated address; redirects are rejected. Glop cookies, session tokens, usernames and browser IPs are not forwarded. Destinations see the relay's IP and whatever data your code explicitly sends. Browser CORS is not required because this is a server relay. It is not a browser cookie jar or a way to keep creator API keys secret: anything shipped in a glop can be read by its players. HTTP supports GET, HEAD, POST, PUT, PATCH and DELETE; text request bodies up to 64 KiB; Accept, Content-Type, Authorization and X-API-Key headers; responses up to 64 MiB; and a 120-second timeout. HTTP error responses retain their status. Use the current SDK for the longer timeout. The VPS queues upload/relay transfers to bound memory; handle 429 or 503 with backoff when rate or capacity limits are reached. The SDK returns content-type, cache-control, etag, last-modified and retry-after headers. It does not forward Set-Cookie or automatically follow redirects. Use blobs for external images/audio/video; direct remote tags and browser fetch remain outside the SDK permission model and blocked by the frame's CSP. Sockets accept text sends up to 64 KiB and return text or Uint8Array messages. Incoming messages are relayed in batches about once per second, so this API is for chat, shared state and live updates rather than latency-sensitive action games. Two sockets and four pending operations are allowed per mount. Incoming queues are capped at 64 KiB/64 messages; overflow closes the socket. Connections expire after 30 minutes. API operations share a 180-per-minute account limit; relay concurrency is also bounded. Applications must handle 429 and closed connections. The separate Sites beta currently reports this API unavailable. Creators must explain what external services receive, minimize personal data, and comply with Glop's rules. The existing explicitly approved homeserver API remains available for its opt-in protocol. ## Browser popups and location Call these methods from a click while the Glop is active. Both work in draft previews and published Glops. Glop displays a host-owned confirmation popup; Cancel, leaving the Glop, hiding the tab, or closing the preview cancels the request. Hidden/preloaded Glops cannot request these capabilities. Requests are not queued for later activation. Only one confirmation may be open at once. ```js const glop = createGlop(); openButton.onclick = async () => { try { await glop.openURL('https://www.google.com/'); } catch (error) { status.textContent = error.message; } }; locationButton.onclick = async () => { try { const {latitude, longitude, accuracy} = await glop.location.get(); // Use coordinates with glop.fetch; accuracy is in meters. } catch (error) { status.textContent = error.message; } }; ``` `openURL` accepts an absolute HTTPS URL without credentials and resolves with `{opened: true}` after opening a tab. It does not assert the destination loaded. The new tab cannot access Glop through `window.opener`. Browser popup blocking rejects the request with a retry message. `location.get()` shares one fresh location after explicit approval for that request, even if browser permission was previously granted. Browser location permission must also be granted. Glop does not persist coordinates. A canceled or inactive request never delivers coordinates to the upload. A location fix has a 15-second timeout; the whole confirmation expires after 60 seconds. The uploaded iframe still has no native popup or geolocation permission. Use these SDK methods rather than `window.open` or `navigator.geolocation`. ## Open another Glop ```js const glop = createGlop(); await glop.navigate('https://glop.it/#release=01234567-89ab-cdef-0123-456789abcdef'); // A release ID alone also works. ``` Places a published release immediately below the current Glop and visibly scrolls down into it, including releases outside the loaded feed. Reduced-motion users get the same downward destination without animation. Available to guests. Only the active feed Glop can navigate; hidden, preloaded, and draft preview frames cannot. External URLs are not accepted. The host enforces one navigation every **five seconds across all Glops**. Loading a destination does not reset the cooldown. Requests during cooldown reject with `status: 429` and `retryAfterMs`; they are not queued or automatically retried. Handle rejection in your UI instead of retrying automatically. Manual feed navigation is unaffected. Legacy `glop:discover` shares this cooldown. The promise returns `{opened: true}` when navigation is accepted, before the destination finishes loading. Missing or unavailable releases reject the promise and leave the visitor at the source Glop. Navigating to the current release returns `{opened: false}`. Without the SDK, send `{type: 'glop:navigation-request', channel, requestId, target}` to the parent origin supplied in the frame hash. The host replies with `glop:navigation-response`, the same channel/requestId, and `result` or `error`, `status`, and (for cooldown) `retryAfterMs`. ## Accept a visitor's Glop (portals) Call `await glop.pickGlop()` from a button click. Glop opens its own picker of the signed-in visitor's published Glops, with pagination. The app receives only the chosen `{releaseId, name, url}`, never the visitor's list or account credentials. Cancel returns `null`. A guest is prompted to sign in and the request rejects with `status: 401`; they can click again after signing in. Picking also works in draft previews; navigation requires a published feed Glop. ```js let destination; chooseButton.onclick = async () => { try { const chosen = await glop.pickGlop(); if (chosen) { destination = chosen; portalLabel.textContent = chosen.name; } } catch (error) { statusLabel.textContent = error.message; } }; enterButton.onclick = async () => { if (!destination) return; try { await glop.navigate(destination.releaseId); } catch (error) { statusLabel.textContent = error.message; } }; ``` This example keeps the chosen destination in memory. Store the returned reference for a shared persistent portal, or send it through a room to other participants: ```js const {revision} = await glop.storage.get('portal'); await glop.storage.set('portal', destination, {revision}); // Restore later: (await glop.storage.get('portal')).value ``` Shared storage requires sign-in. Apply your app's usual editing rules; selecting a Glop does not by itself persist or publish a portal. The visitor can choose only published public Glops, not private drafts or beta content. A reference does not grant source access or run the destination inside the source Glop; `navigate` opens it in the host feed. The raw picker RPC is `glop:browser-request` with `action: 'pick-glop'`, the frame's `channel`, and a unique `requestId`. The existing `glop:browser-response` returns `result` or an error. It requires a user gesture and the active frame. ## External hosting Publish a page hosted on your own public HTTPS domain without uploading its assets. Use the creator's external URL field, or `node cli/glop.mjs external https://your-domain.com/glop/ --name "My Glop"`. The existing `POST /api/projects/:id/playable-bundles` endpoint accepts `{"externalURL":"https://your-domain.com/glop/"}` instead of `entry` and `files`. Ownership, idempotency, draft privacy, owner approval, and version count limits still apply. The response includes `bundle.externalURL`; publication uses the returned bundle ID as usual. Capabilities return `{entry, external:true, progress}` only to authorized viewers. Load `` before your app scripts (or host a downloaded copy), allow Glop in your `frame-ancestors` policy, and serve modules/fonts/fetched assets/APIs with credential-free CORS (`Access-Control-Allow-Origin: *`). The frame keeps `sandbox="allow-scripts"`; localStorage, cookies and service workers are unavailable. URL fragments are reserved for the host channel. The SDK works through the existing authenticated host messaging channel. Content loads directly from your server; Glop stores only its URL and optional cover. Glop upload byte limits do not apply. Your hosting provider controls transfer limits. Draft privacy does not protect the external website, and hidden source downloads do not hide externally hosted files. Updates at that URL appear without re-uploading; use versioned URLs for stable releases. See `/docs#external-hosting` for setup. ## Glop factory hook `glop.factory.create({name, description, files})` opens the host's creation form with generated template files. Call it from a **Create my Glop** button after letting the visitor customize the template. It resolves to `{opened: true}` when the form opens, not when a draft is uploaded or published. ```js await glop.factory.create({ name: 'My homepage', description: 'My personal corner of the web', files: [{path: 'index.html', content: '