Creator guide / SDK 1
Build a world
Creator tools are not available yet
Build a world in JavaScript with Plain, an editable starter that already handles rendering, movement, mouse look, and cleanup. You can prepare a package now; accounts, uploads and publishing are unavailable in the public preview.
The first two steps cover building and customizing a package. The publishing steps are a reference for when creator tools open. 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. Accounts and publishing are not enabled during the explore preview; you can build your package while those features are pending.
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. These descriptions appear in the player's Controls panel and are available to agents.
3. Preview and publish
Not available in the public preview. These steps apply when accounts and publishing open.
- 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, tagline, description, thumbnail, and one to five hashtags. 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 hashtags, 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 hashtags are separate from world.json.
To release an update once publishing opens, 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 and wait until the world's Controls panel is available.
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;
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 |
|---|---|
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. |
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. Duration follows browser timing, not a fixed number of rendered frames. Await each command before starting another. Closing the world or losing focus releases agent input; a host timeout requests a stop. Calls made before the world is ready or after it closes return an error.
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 called, with no polling while unused. Agents without page JavaScript access can use ordinary keyboard and mouse tools.
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 to files inside the ZIP. Bundle libraries such as Three.js before uploading. |
The downloadable world source and mouse helper are useful for inspection. They still need the starter's build step before upload.
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 must pass |
|---|---|
| Package | Valid paths, file types, sizes, manifest, imports, and an exported mount. |
| Startup | api.ready() arrives within five seconds. |
| Rendering | At least 30 frames in the ten seconds after readiness. |
| 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 60-second time budget and a measured memory budget below 3,000,000,000 bytes for itself and Chromium. Results are passed, failed, or skipped. A skipped check means it could not run; fix earlier failures first.
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 | Choose Load preview and screenshots again. |
| 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.