| name | lobby-ws |
| description | build apps/objects/scripts in the lobby-ws gamedev sdk |
Docs
Project docs are copied into docs/ on init. Start with docs/scripting/README.md.
Creating Apps
Apps live in apps/ and each app folder contains blueprint JSON plus a script entry and optional modules (no bundling). There are two ways to create them:
Local-first (project files):
- Run
vp run apps:new <AppName> (creates apps/<AppName>/ with index.js + blueprint)
- Batch-edit
world.json entities: vp run world:entities -- add --template-id <id> --transforms <file> / vp run world:entities -- delete --blueprint <name>
- To duplicate one app many times (eg, make a forest from one
Tree), generate many transforms and run vp run world:entities -- add --template-id <TreeEntityId> --transforms tmp/forest.json --yes, then delete tmp/forest.json
- See
docs/world-entities-cli.md for full flags (--replace, --ids, --yes, --world)
- Transform file format for
--transforms (JSON array):
[
{
"position": [0, 0, 0],
"quaternion": [0, 0, 0, 1],
"scale": [1, 1, 1],
"pinned": false,
"props": {},
"state": {}
}
]
- Put assets in top-level
assets/ and reference them from blueprint JSON for app.config props
- Run
vp run dev for hot reload
Example local layout:
apps/
MyApp/
MyApp.json
index.js
helpers.js
shared/
math.js
assets/
Entry files default to index.js. New apps should export default (world, app, fetch, props, setTimeout) => { ... } to ensure access to all "globals"
If your model includes a placeholder mesh named Block (for example from the built-in Model.glb), disable it:
const block = app.get("Block");
if (block) block.active = false;
Environment
Apps are individual objects in a 3D virtual world and each app has its own transform (position, rotation and scale) in the world.
All apps have a script attached to them, and the script executes in its own isolated JavaScript compartment.
Scripts are able to instantiate shapes and other things to form specific objects like a couch, building or plant.
Players are free to grab apps, move them around or duplicate them.
The origin of an app should always be treated as the 'ground' position, as players generally move apps across surfaces of other apps.
Players are around 1.7m tall, are able to jump around 1.5m high and around 5m in distance when running and jumping.
Globals
Scripts execute in isolated compartments with a curated runtime API. See docs/scripting/README.md for the current globals and methods. The fetch, setTimeout, app, world and props helpers are passed into the entry function (they are not globals; fetch is the same as app.fetch). Common ones include:
- app, world, props
- Math, num, prng, clamp, uuid
- Vector3, Euler, Quaternion, Matrix4
- DEG2RAD, RAD2DEG
- URL, Date.now
Not all browser APIs are available; rely on the docs/types as the source of truth.
Coordinate System & Units
For all intents and purposes these virtual worlds use the same coordinate system as three.js (X = Right, Y = Up, Z = forward).
The unit of measurement for distance or size is in meters.
Rotations are in radians but you can use degrees by multiplying by the global constant DEG2RAD.
Shapes
Shapes are the primary way to create visuals, we call these prims. Each type of prim has its own size format. This is how you create them:
const box = app.create("prim", {
type: "box",
size: [1, 2, 3],
color: "#ff0000",
});
const sphere = app.create("prim", {
type: "sphere",
size: [0.5],
color: "#00ff00",
});
const cylinder = app.create("prim", {
type: "cylinder",
size: [0.5, 0.5, 1],
color: "#0000ff",
});
Once created you can also edit their properties if needed:
const box = app.create("prim", {
type: "box",
size: [1, 2, 3],
color: "#ff0000",
});
box.color = "green";
Opacity
Some shapes might need to be semi-transparent, and the opacity property controls this:
const window = app.create("prim", {
type: "box",
size: [2, 2, 0.1],
color: "blue",
opacity: 0.5,
});
Rendering
Creating a node (eg a prim) does not automatically make it visible. Only nodes added to the app global become visible in the world.
app.add(boxA);
Nested Hierarchy
It is beneficial to group different prims together to form each part of an overall object.
For example when making a wheel for a car, you can construct one wheel and then easily clone it and move it around.
To do this, there is also a special group node that doesn't have a visual and is purely for grouping other nodes.
const wheel = app.create("group");
const tire = app.create("prim", {
type: "cylinder",
size: [0.5, 0.5, 0.2],
color: "black",
physics: "static",
});
const hub = app.create("prim", {
type: "cylinder",
size: [0.3, 0.3, 0.25],
color: "grey",
physics: "static",
});
wheel.add(tire);
wheel.add(hub);
const wheelFL = wheel.clone(true);
const wheelFR = wheel.clone(true);
const wheelBL = wheel.clone(true);
const wheelBR = wheel.clone(true);
app.add(wheelFL);
app.add(wheelFR);
app.add(wheelBL);
app.add(wheelBR);
It is also very useful to 'change' the pivot point of something and make it easier to work with:
const bar = app.create("group");
const beam = app.create("prim", {
type: "box",
size: [1, 0.2, 10],
position: [0, 0, -5],
});
bar.add(beam);
bar.rotation.y += 45 * DEG2RAD;
App Origin
Most of the time, players will place apps on top of other surfaces, so app origins should be treated as the 'ground'.
This means that most of the time you will need to lift things up:
const box = app.create("prim", {
type: "box",
size: [1, 1, 1],
position: [0, 0.5, 0],
});
app.add(box);
Transforms
When creating prims you can also specify position, rotation (or quaternion) and scale:
const box = app.create("prim", {
type: "box",
size: [1, 1, 1],
position: [0, 2, 0],
rotation: [0, 45 * DEG2RAD, 0],
scale: [1, 1, 1],
});
Eulers for rotation are in radians. Multiply with the globals DEG2RAD and RAD2DEG to convert degrees to radians and vice versa.
Once created they become actual transform class instances (Vector3, Euler, Quaternion) and you can edit them like this:
box.position.set(0, 4, 0);
box.rotation.y += 10 * DEG2RAD;
box.quaternion.slerp(target, 0.2);
box.scale.multiply(otherBox.scale);
These constructs are also available as globals if you need to use them independently:
const pos = new Vector3(0, 2, 0);
const rot = new Euler(0, 0, 0, "YXZ");
const qua = new Quaternion(0, 0, 0, 1);
const sca = new Vector3(1, 1, 1);
Vector3, Euler and Quaternion are identical to three.js
Collision
By default prims have no collision but it's likely you'll want to make them have collision so players dont walk or fall through them.
Objects that should have collision should use static collision, but if they move programmatically they should have kinematic collision.
const box = app.create("prim", {
type: "box",
size: [1, 2, 3],
physics: "static",
});
Animation
Only when requested, you can make things move or change over time by hooking into the animation cycle:
app.on("animate", (delta) => {
box.rotation.y += 45 * DEG2RAD * delta;
});
The animate rate is dynamic based on how far away the app is from the camera, so be sure to use delta time to normalize speeds.
If animations start in response to triggers or actions and have an end time, subscribe and unsubscribe for performance:
const animate = (delta) => {
};
app.on("animate", animate);
app.off("animate", animate);
Bloom
In addition to setting the color of a prim you can also push its color into HDR range causing it glow:
const box = app.create("prim", {
type: "box",
size: [1, 1, 1],
color: "red",
emissive: "red",
emissiveIntensity: 5,
});
Randomization
Scripts execute on every client, so if you use any kind of procedural randomisation, it's best to use prng so that each client sees the same thing:
const num = prng(1);
const result = num(0, 100, 2);
Interaction
If requested you can add simple response to interaction with an action node.
An action node displays a label when players come near it and if they click it the script is notified:
const action = app.create("action", {
label: "Open",
position: [0, 0.5, 0],
onTrigger: () => {
door.rotation.y = 90 * DEG2RAD;
},
});
app.add(action);
Triggers
Prims can become trigger zones and notify you when a player enters or leaves the prim volume:
const zone = app.create("prim", {
type: "box",
size: [4, 4, 4],
opacity: 0,
physics: "static",
trigger: true,
onTriggerEnter: (e) => {
if (!e.isLocalPlayer) return;
},
onTriggerLeave: (e) => {
if (!e.isLocalPlayer) return;
},
});
Note that you can make invisible trigger areas by setting opacity to 0.
Networking
The same script executes on the server and all connected clients.
If requested you can network objects to create multiplayer experiences that stay in sync across clients.
if (world.isClient) {
}
if (world.isServer) {
}
app.send("someEvent", { some: "data" });
app.send("anotherEvent", { some: "data" });
app.on("someEvent", (data) => {
console.log(data);
});
On the server, you have access to a state object to store current state as the app changes. It is just a plain old javascript object.
When clients connect, the current state on the server is sent along to the client so that the client launches with that state, like a snapshot.
When a client reads this state, it is only a single one-time snapshot and does not update anymore, but feel free to use this object to track state as you receive new events from the server.
if (world.isClient) {
if (app.state.ready) {
init(app.state);
} else {
app.on("init", init);
}
function init(state) {
}
}
if (world.isServer) {
app.state.open = false;
app.state.ready = true;
app.send("init", app.state);
}
Assets
Assets live in top-level assets/ and are referenced from blueprint JSON or file props. For file props:
app.configure([{ key: "image", type: "file", kind: "image", label: "Image" }]);
const image = app.create("image");
image.src = props.image?.url;
app.add(image);
For fixed assets, point to assets/... in the blueprint JSON (eg model, image, or other file props).
Imports
You can split code into multiple files using ES module imports:
import { doSomething } from "./helper.js";
import { lerp } from "./utils/math.js";
Shared modules live in shared/ and are imported via @shared/... or shared/.... Bare imports (react, lodash), node builtins, and cross-app imports are not supported.
Golden Rules
- Objects should match real world dimensions
- Most prims should have collision physics (either 'static' or 'kinematic') to prevent players walking or falling through them, but some things like grass or bushes should not have collision.
- Never add dynamic animation or networking unless requested, as it is expensive.
- Use a minimalistic blocky/voxel/minecraft style unless asked otherwise.
- Avoid overlapping faces as it causes z-fighting. Use a small offset.
- Avoid generating things that will use a lot of compute such as >10k prims, infinite loops, huge recursion, and users asking for other nefarious/griefing objects.