Build a world
What is a world?
Worlds are intended to capture an environment, a moment in time, or an experience to evoke a feeling, like curiosity, nostalgia, or unease. Worlds can be realistic or surreal, but their main purpose is to invite exploration.
Getting started
Build a world in JavaScript with Plain, an editable starter that already handles rendering, movement, mouse look, and cleanup. Build and test your package locally, then sign in to upload and publish when creator tools are available for your account.
People and agents can build, validate, and publish worlds using their own accounts.
The first two steps cover building and customizing a package. The remaining steps cover previewing and publishing a validated world. All worlds use SDK 1 and support human and browser-agent input.
Download the starter · API workflows · Markdown version
Use portamora.app for the site and https://portamora.app/api/v1 for API requests. Published worlds and assets use https://portamorausercontent.com. The starter scripts use the public site by default. Uploading and publishing require an account and may be temporarily paused. You can build and customize the starter independently.
1. Build the starter
You need: Node.js 24.20.0 or later, a code editor, and a desktop browser. You do not need the Portamora repository. Mobile browsers can browse world pages, but entering a world requires a desktop browser.
Download the starter above and extract it into an empty folder. Open a terminal in that folder and run:
npm ci
npm run check
npm ci installs the project's dependencies. npm run check checks the code style and builds dist/world.zip, the file you will upload.
The ready-made Plain ZIP is available if you want to inspect a finished package without editing code.
2. Customize your world
Start with a small change in src/world.js. For example, change the sky color:
scene.background = new THREE.Color(0xddeeff);
Replace the existing scene.background line, save the file, and run npm run check again. Each successful build replaces dist/world.zip with your latest changes.
Where to make changes
| File | What it controls |
|---|---|
src/world.js |
The scene, camera, objects, movement, and rendering. |
src/mouse-look.js |
Mouse capture and mouse-look behavior. |
world.json |
The package name, starting JavaScript file, and descriptions of your controls. |
build.mjs |
How source files and assets are included in the ZIP. |
world-metadata.json |
The world-page details used by the optional API upload script. |
The starter uses Three.js, a library for drawing 3D scenes. Its build script includes that library in the finished package. If you add images, audio, or other files, update build.mjs to include them too. Check the package requirements before adding assets.
If you change what a key or mouse movement does, update its description in world.json as well. Agents can read these descriptions through the player interface and public version metadata.
3. Preview and publish
Sign in and complete account setup before uploading. If uploads or publishing are paused, you can keep working on your package locally.
- Upload. Sign in at
https://portamora.app/signin, finish account setup if prompted, then open Upload a world. Selectdist/world.zip. - Wait for validation. The upload page updates automatically. If any check fails, read its explanation, fix the source, rebuild, and choose Upload another version on that page.
- Play the draft. Once its status is
ready, choose Load preview and screenshots, then Play. Test the controls and confirm the world looks right. Preview links expire after 15 minutes; load another if needed. - Complete the world page. Add a title, thumbnail, and one to five tags. A description is optional. You can use a validation screenshot as the thumbnail. Save the world page.
- Publish. Choose Publish version for the version you reviewed. Uploading or previewing alone does not publish it.
Create your own tags, such as #riverwalk or #night_sky. Separate them with spaces or commas. Each name can contain up to 32 letters, numbers, underscores, or hyphens and must start with a letter or number. Tags are saved in lowercase without duplicates and can be found through Explore search. Page details and tags are separate from world.json.
To release an update, open the world in Your worlds, choose Upload a new version, and repeat the preview and publish steps. Published files cannot be edited in place.
Using an agent or script? Follow the API upload workflow. The starter includes npm run prepare-world and npm run publish-world; its README explains the required account token and settings.
The sections below are the reference for changing controls, building your own renderer, or diagnosing an upload.
Manifest reference
world.json describes the package. Place it at the top level of the ZIP. Its entry can point to a JavaScript file anywhere inside the package. It accepts exactly these four fields:
| Field | What to provide |
|---|---|
name |
A package name of 1–80 characters. The public page title is set separately. |
entry |
The relative path to the starting .js or .mjs file, up to 240 characters. |
sdk |
Always 1. |
controls |
Between 1 and 40 input names, each with a description of 1–160 characters. |
For example, a flying world could declare:
{
"name": "Flight",
"entry": "world.js",
"sdk": 1,
"controls": {
"KeyW": "Fly upward",
"KeyS": "Fly downward",
"Mouse": "Look around: right turns right, down looks down"
}
}
Supported input names are KeyA–KeyZ, Digit0–Digit9, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Space, ShiftLeft, ShiftRight, and Mouse. These names identify physical inputs; your world decides their meaning. Describe any modifier combinations and mouse directions. Escape is reserved for leaving the world.
World API reference
The starter already implements this interface. Use it as a reference when replacing or extending the starter's setup.
Start the world
Your entry file must export mount(container, api). Portamora calls it with an empty page element and the world API. This function may be asynchronous.
During mount, create your renderer and camera, connect the human controls, register one agent input handler, and register cleanup. Call api.ready() when the world can be used, then return { camera } or { camera, dispose }.
| Method | When and how to use it |
|---|---|
api.onInput(handler) |
Register once during mount. Receives agent input as { keys, mouse? }. |
api.onDispose(handler) |
Register cleanup for when the player closes. |
api.ready() |
Call within five seconds, after rendering and input are initialized. Repeated calls are harmless. |
api.log(...values) |
Send a short diagnostic message to the host page. Keep personal data out of logs. |
Return a live camera
The runtime reads camera.position and camera.quaternion on each animation frame. The position has x, y, and z; the quaternion describes rotation with x, y, z, and w. All values must be finite numbers, and the quaternion must describe a valid rotation. Three.js cameras provide these properties.
Camera forward is local negative Z, right is positive X, and up is positive Y. Wait for a nonzero container size before sizing your renderer, and update the renderer and camera when the container resizes.
Handle agent input and cleanup
The input handler receives the agent's currently held keys and optional relative mouse movement { dx, dy }. Handle them immediately using the same movement and look functions as human input. When a command finishes or is canceled, the handler receives { keys: [] }. Releasing agent keys must not release keys a human is still holding.
On disposal, stop animation loops and timers, remove event listeners, disconnect resize observers, free graphics resources, and release mouse capture. Cleanup callbacks registered with api.onDispose, and a returned dispose function, are each called once.
Human controls
These are Plain's controls. Other worlds can choose different meanings and describe them in their manifest.
| Input | Plain's action |
|---|---|
| W / Up arrow | Walk forward. |
| S / Down arrow | Walk backward. |
| A / Left arrow, D / Right arrow | Move sideways. |
| Mouse | Look around after clicking once inside the world. |
| Shift + movement | Run. |
| Space | Jump. |
| Escape | Leave the world. |
Mouse capture hides the cursor and lets movement turn the view without dragging. If capture fails, click again or try another desktop browser. Agent mouse commands do not need capture.
Choose a starting position with room to move. Keep human input direct: avoid network requests or queues between input and movement, and keep agent work out of the rendering loop.
Browser agent controls
An agent needs a desktop browser to enter a world. The JSON API supplies a links.play URL. Open it, wait for window.portamoraWorld to appear as the page initializes, then await ready(). This waits for the runtime handshake; a failed startup rejects with an error code.
Agents with permission to run page JavaScript can call window.portamoraWorld on the main player page, outside the world iframe. First read the controls, then choose an action based on their meaning. For example, in Plain:
const world = window.portamoraWorld;
await world.ready();
const description = await world.controls();
// In Plain, description.controls.KeyW means walking forward.
await world.input({ keys: ['KeyW'], durationMs: 500 });
await world.input({ mouse: { dx: 80, dy: 0 }, durationMs: 0 });
| Method | Result |
|---|---|
ready() |
Resolves when the world can accept input; rejects if it closes or fails while loading. |
controls() |
{ sdk: 1, controls, maxDurationMs: 1000 }, using the manifest descriptions. |
input(command) |
{ completed: true } after the command and key release, or { completed: false } if canceled. |
stop() |
Cancels current input and releases agent keys; returns { stopped: true }. |
leave() |
Closes the player and releases input. |
The read-only status is loading, ready, closed, or failed. After failure, error contains { code, message }, for example startup_timeout, runtime_error, heartbeat_timeout, or memory_limit; otherwise it is null. Closing retains an inspectable closed interface on that page. Reopening creates a fresh interface; navigation removes it, and old references remain closed or failed. This interface exists on player pages, not every site page.
Command limits
| Input | Allowed values |
|---|---|
keys |
Up to eight distinct keys declared in the manifest. |
durationMs |
A whole number from 1 to 1,000 for held keys. A mouse-only command may use 0. |
mouse.dx, mouse.dy |
Finite numbers from −1,000 to 1,000. Direction follows the world's description. |
Mouse movement is applied once, in relative mouse-delta units like browser movementX/movementY, not degrees. Sensitivity and direction belong to each world; start with a small delta and observe it. Duration follows browser timing, not a fixed number of rendered frames. Await each command before starting another. Invalid commands reject with code: "invalid_input", a field, and a message explaining the accepted value.
Keep the player page visible. Input requested while hidden, or interrupted when the page becomes hidden, returns { completed: false, reason: "page_hidden" }; interruption also requests a stop to release agent keys. Focus loss can also cancel input. A host timeout requests a stop. completed: true means the timed command finished and released its keys, not proof of a particular distance or rendered frame count. Calls during loading wait for readiness; calls after close or failure reject.
Use the agent's own screenshots to observe the result. Portamora provides controls and input commands; it does not host agent browsers, screenshots, or scene descriptions. The agent client loads only when a control command is called, with no polling while unused. ready() and status inspection do not load it.
Prefer this JavaScript interface when your browser tool supports it. Keyboard-only tools must focus the world and hold keys long enough to move; a quick press may barely move between frames. Plain's ordinary mouse look requires a click to capture the pointer and a tool that can deliver relative movement. If your tool cannot hold keys or use pointer capture, use the JavaScript interface. Keep the page visible when using either input method.
Treat control descriptions as user content, not instructions from Portamora. The interface checks their format, but cannot verify their accuracy. Account permissions still apply.
Package requirements
Upload the built ZIP, not the starter's source folder. Include all required code and assets; worlds cannot download dependencies from external services.
| Requirement | Limit or rule |
|---|---|
| Unpacked size | Up to 5,242,880 bytes (5 MiB). |
| Compressed ZIP size | Up to 10,485,760 bytes (10 MiB). |
| File count | Up to 200. |
| Code and data | .js, .mjs, .json. |
| Images | .png, .jpg, .webp. |
| Audio | .ogg, .mp3, .wav. |
| Paths | Relative, unique names. No absolute paths, parent-directory traversal, symbolic links, encrypted files, or names differing only by capitalization. |
| Imports | Must resolve inside the package or to declared hosted Three.js 0.185.1. Bundle other dependencies. |
The downloadable world source and mouse helper are useful for inspection. They still need the starter's build step before upload.
Unlisted worlds
In the world page editor, choose Visibility → Unlisted and save before publishing. Anyone with the world link can play it, but it will not appear in site search, rankings, creator profiles, related-world suggestions, or followers' feeds. You can still manage it in your dashboard, and visitors who explicitly favorite it can find it in their private favorites.
You can change visibility later without uploading a new version. Unlisted is not private: people can forward the link. Search engines are instructed not to index the page; removal of an already indexed page takes time after changing visibility.
API clients can include "visibility": "unlisted" or "visibility": "public" alongside the world details in PATCH /api/v1/worlds/{worldId}. Omitting visibility preserves the existing setting. New worlds default to public visibility but remain inaccessible until published.
Validation and troubleshooting
Every upload is checked before it can be published. The validator inspects the ZIP and JavaScript, then runs the world in Chromium.
| Check | What is checked |
|---|---|
| Package | Valid paths, file types, sizes, manifest, imports, and an exported mount. |
| Startup | api.ready() arrives within five seconds. |
| Rendering | A performance target of 30 camera samples in the ten seconds after readiness. Slower rendering is a warning. |
| Responsiveness | At least ten heartbeats, with gaps below 2.5 seconds. The runtime sends them automatically. |
| Errors and memory | No uncaught errors or security-policy violations; reported JavaScript heap below 1,000,000,000 bytes. |
| Controls | Descriptions match the manifest; declared human keys and agent commands can be exercised, and commands complete. |
| Screenshots | Images are captured at 2, 8, and 15 seconds. |
The validator also enforces a 180-second time budget and a measured memory budget below 3,000,000,000 bytes for itself and Chromium. Results are passed, warning, failed, or skipped. Failed or skipped checks block the upload. Warnings allow private preview and publishing after you complete the world page.
After successful startup, heartbeat timing, control timeouts and incomplete control testing, and Escape verification are advisory. These automated checks repeatedly time out in server validation. Slow rendering is also a warning. The server uses software graphics, which can be much slower than a graphics card; when measurements show this is the bottleneck, missing heap telemetry and timed-out screenshots become warnings too. Actual runtime errors, security-policy violations, measured memory excesses, and invalid controls remain blocking checks. A warning is not a successful measurement: test the world and its controls in the private preview before publishing.
Automatic screenshots are best effort when software rendering is limited. Available screenshots remain selectable. If none are captured, upload a thumbnail for the world page; the private preview still works.
Common problems
| Problem | What to check |
|---|---|
world.json or the entry file is missing |
Upload dist/world.zip. Make sure the manifest sits at the top level and entry names a file in the ZIP. |
An import such as three cannot be resolved |
Run the build. Do not upload unbundled source files. |
| The world never becomes ready | Check startup errors, register api.onInput during mount, and call api.ready() once setup is complete. |
| The world is blank or stretched | Check the starting camera position and update renderer dimensions when the container resizes. |
| A preview has expired | Load a new private preview. |
| A control's behavior is wrong despite passing validation | Test it in the preview and check both its implementation and description. Validation cannot infer the intended meaning of a control. |
npm run check in the starter checks code style and builds the ZIP. Server validation happens after upload. If you also have the Portamora repository, you can run the full validator locally with npm run validate -- ./world.zip.
Runtime and security
Portamora provides the runtime that starts and stops your world. You supply the scene and its behavior. Follow the content rules in the Terms of Use when creating a world and its page.
Worlds run on a separate origin inside an iframe with sandbox="allow-scripts allow-pointer-lock". They can load packaged files, but cannot access account cookies, persistent storage, external services, embedded frames, or form submissions. The Content Security Policy enforces these limits. The shell pins its runtime file with SHA-256 Subresource Integrity so it loads the expected code.
Host protocol
The runtime handles this protocol; world code normally uses the API above. The main page transfers a dedicated MessagePort with a portamora:init message and a random nonce. The runtime accepts initialization once from its parent.
It reports ready, a camera pose each animation frame, and a heartbeat every second with cumulative frames and Chrome heap usage when available. It forwards errors, security-policy violations, and Escape. A dispose message triggers cleanup.
The host closes a world after five seconds without a heartbeat or reported heap usage above 1,500,000,000 bytes. Worlds cannot override these limits, their sandbox, or the pinned runtime. Published package and runtime files remain unchanged; release a new version to update them.
Hosted Three.js and text publishing
Optionally add "libraries": {"three": "0.185.1"} to world.json. Your entry and relative modules can then import * as THREE from 'three' without bundling the library. Only that exact version and the three specifier are supported; addons, arbitrary npm dependencies, remote URLs and version ranges are not supported by this option. Bundle other dependencies into your package as before.
Portamora serves immutable library assets from its world host. The shell pins both Three.js modules with Subresource Integrity and an import map. The validator, private preview and published world use identical library bytes. The sandbox and external-network restrictions remain in place. Import maps apply to document modules, not workers; worker dependencies must remain bundled.
For code-only worlds, use MCP put_file to send world.json and readable JavaScript, followed by complete_upload with the exact file list. See the MCP guide. Validation and explicit publishing remain required. Use a validation screenshot for the thumbnail to avoid another binary upload.