Scenes and scripts
Projects of scenes and scripts (what onceworlds.com/build makes), with a complete one.
A game can also be a project: scenes as JSON files, small scripts and a project.json. It is what the Onceworlds editor (onceworlds.com/build) opens and saves, so a creator can take what you wrote and go on by hand, and you can go on from what they made. Write a project when the game lives in the editor or the creator asks for one; otherwise start from a kit. Both run on the same engine, and everything in this guide (Make it fun, the platform's rules, multiplayer) applies to both.
project.json the game's settings: name, main scene, space, input map, physics, multiplayer, autoloads
index.html loads main.js as a module
main.js import { runProject } from '@onceworlds/engine/modules'; runProject('./project.json');
scenes/*.scene.json scenes: trees of nodes
scripts/*.js scripts: export default script({ ... })
assets/** pictures, sounds, models, fonts (scenes name them by path: "assets/hero.png")
onceworlds.json as for any engine game ("engine": "1"); `controls` come from project.json's input
Start from a template. npx @onceworlds/cli new my-game --template <name> makes a project folder (with this guide as AGENTS.md);
Build's New project starts from the same ones. --list shows them.
| Template | What it is |
|---|---|
platformer |
A race up a mountain: the course as nodes (blocks and slabs as bodies with collision shapes, spikes and lava as areas in the group hazards, moving platforms and flags as instanced scenes, spawn points), racers with Platformer2D |
arena |
A top-down brawl in a shrinking safe zone: fighters with TopDown, the fight's numbers as the fighter script's props, hits judged by the host |
build/party |
Stand on the Color: a floor of tiles that drop, several short rounds (party alone is the SDK starter) |
third-person |
A 3D arena brawl for shards: the arena as nodes (floor tiles, walls, the dais and its ramps, instanced platforms), shard spots as markers whose value prop sets what appears, a fighter's speeds and shove as scripts/fighter.js props |
racing |
Kart racing: the track is the markers under the Track node's Points (a closed loop; scripts/track.js lays the road along them when the game starts), the start line, boost pads and scenery as nodes, laps counted on the host |
tactics |
Turn-based squads on Flow.turns: the board as nodes (floor tiles, walls and crates standing on tiles; the map is read from where they stand), units' numbers as the props of scenes/knight.scene.json and scenes/archer.scene.json |
empty-2d, empty-3d |
A camera and one thing to look at |
The games are the kits made into projects: the same lobby, bots, touch controls, sound and finished matches. Everything a creator tunes by
hand is in the scenes (a level's layout, colours, a controller's speeds) or in script props; the scripts hold the rules
(scripts/rules.js is what multiplayer.rules names). Change them like a kit: rules and numbers first, then the look.
A scene is a tree of nodes. A node has an id (6 to 12 lowercase letters and digits, unique in the file), a name (unique among
its siblings, no /, : or .), a kind, and in components only what differs from its kind. Kinds have Godot's names: Node2D,
Sprite2D, Shape2D, Label2D, Camera2D, StaticBody2D, RigidBody2D, CharacterBody2D, Area2D, CollisionShape2D,
Node3D, MeshInstance3D, Model3D, Camera3D, DirectionalLight3D, WorldEnvironment, the 3D bodies, SpawnPoint, CameraRig
(all of them: the API reference, "Node kinds"). Components are the engine's, by name, with their fields as JSON:
- Vectors are arrays (
"position": [1, 2, 0]); colours"#ffd23f"; a rotation is a quaternion[x, y, z, w](2D, an anglea:[0, 0, sin(a/2), cos(a/2)]); a field that names another node is{ "$node": "../Player" }, a path from the node; files are project paths. "components": { "Sprite": null }drops a component the kind has;"Platformer2D": {}adds one with its defaults.children(in order),groups,script({ "src": "scripts/coin.js", "props": { "value": 5 } }), andconnections: a signal this node sends runs a handler in another node's script ({ "signal": "collected", "to": "../Score", "call": "add" })."instance": "scenes/coin.scene.json"makes the node that scene's root (nokind): itscomponents,scriptandgroupschange the root, and"overrides": { "Body/Sprite": { "components": { ... } } }change nodes inside it, by their path.- Physics: a body kind with its collider in its own components (
"Collider2D": { "size": [10, 1] }), orCollisionShape2Dchildren. AnArea2Dis a trigger: its script hearsbodyEntered. A character is aCharacterBody2Dwith a capsuleCollider2DandPlatformer2DorTopDown(3D:CharacterBody3D, a capsuleCollider3D,Character3D); its script writesIntentfrom the input. Keep bodies under nodes at the origin: physics moves them in world space.
A script is a module whose default export is script({...}); it runs for each node that has it.
import { script, t } from '@onceworlds/engine';
export default script({
props: { speed: t.f32(6, { min: 0, max: 20 }) }, // node.props.speed; a scene sets it in "script": { "src", "props" }
signals: ['died'], // signals it sends
preload: ['scenes/bullet.scene.json'], // scenes it spawns
ready(node) {}, // once; children before their parent
fixed(node, dt) {}, // 60 times a second, before controllers and physics: the rules go here
update(node, dt) {}, // every frame: looks only
exit(node) {}, // before the node goes
on: { bodyEntered(node, other) {}, died(node) {} }, // signals it receives (its own, built-in ones, and connected calls)
});
node:name,props,parent,children,root,find('Body/Sprite')(find once, inready),position,rotation,scale(live:node.position.x += 1),angle(2D),get(Component),set(Component, values),has,emit(signal, ...args),connect,inGroup,group(name),spawn('scenes/bullet.scene.json', { position })(its sibling; preload it),free(),timer(seconds, fn),tween(Component, { field: to }, seconds, ease), andinput,audio,physics,net,flow,feel,ui,time,rng('name').- Built-in signals:
ready,exiting,bodyEntered/bodyExited(a body touched; a body came into an area),areaEntered/areaExited;pressed,toggled(on),changed(value or text),submitted(text)(UI nodes),timeout(Timer),finished(AudioPlayer),animationFinished(name)(AnimationPlayer). An animation's call keys runon.<call>in the target's script. - The rules of systems hold: rules in
fixedwithdt,node.rng('name')neverMath.random,authority: 'host'for what only the host decides (scores). In a networked game a node's hooks run where it is simulated: a player's node on their page, host-owned andauthority: 'host'ones on the host, a node that isn't networked (the level) on every page. multiplayerin project.json makes the game shell:"multiplayer": { "players": { "max": 8 }, "shape": "rounds", "rounds": 3, "roundTime": 90, "bots": true, "rules": "scripts/rules.js", "replicate": ["Racer", "scenes/player.scene.json"] }. The rules script exports what that shape ofFlowtakes:export const round = { spawn(ctx, seat) {...}, isOver(ctx) {...}, rank(ctx) {...} }for rounds and coop,turnfor turns,spawnfor drop-in, androster,settings,scoring,resultsasFlowhas them. A seat's body is a replicated scene:spawnScene(ctx.world, 'scenes/player.scene.json', { owner: seat.bot ? 'host' : seat.id, position }), atspawnPoint(ctx.world, seat); return it fromspawn(Flow makes a bot's body a bot). Its script adds the physics body and the controller infixed, which runs only on the page that moves it: every other page sees it where the network puts it. Its scripts are readied as it spawns, before what the rules add to it after, so read those infixedandupdate.
Check it headless, like a kit: runProject with the files from disk, then play.
import { runProject } from '@onceworlds/engine/modules';
import { projectDir } from '@onceworlds/engine/test';
import { expect, test } from 'vitest';
test('the player collects both coins', async () => {
const game = await runProject('project.json', { files: projectDir('.'), headless: true });
game.services.input.setAxis('move', 1, 0);
game.run(3);
expect(game.world.group('coins')).toHaveLength(0);
expect(game.errors).toEqual([]);
});
A scene's mistakes (an unknown kind or component, a field that doesn't fit, a path that leads nowhere) don't stop the game: they are
reported once and skipped. Read them in scenesOf(game).problems (from '@onceworlds/engine/modules') and keep it empty. Whole matches
play the same way: simulate(await projectConfig('project.json', { files: projectDir('.') }), { humans: 2, bots: 2 }), where
projectConfig is the game runProject makes, as a config.
Play matches the same way: projectConfig is the config runProject starts, so simulate(await projectConfig('project.json', { files: projectDir('.'), quick: true }), { humans: 2, bots: 2 }) plays the project as several pages; quick makes a match one round with quick
screens. When the lobby picks how long a match is (laps, a score to reach), the rules script says what a quick game changes as well:
export const quick = { settings: [{ ...laps, default: 1 }] } (where laps is that lobby setting) is laid over its rules, a group such
as round a key at a time. npx @onceworlds/cli check does exactly this with its chaos runs, and fails on a scene's problems.
A complete project: Coin Dash
A runner collects two coins; the score counts them. Six files (with index.html, main.js and onceworlds.json as above).
project.json
{
"format": "onceworlds.project",
"version": 1,
"name": "Coin Dash",
"main": "scenes/main.scene.json",
"space": "2d",
"display": { "background": "#10131c" },
"input": {
"move": { "type": "axis2d", "keys": "wasd arrows", "stick": true, "label": "Move" },
"jump": { "type": "button", "keys": "Space W Up", "touch": { "label": "Jump", "big": true } }
}
}
scenes/main.scene.json
{
"format": "onceworlds.scene",
"version": 1,
"root": {
"id": "main01", "name": "Main", "kind": "Node2D",
"children": [
{ "id": "cam001", "name": "Camera", "kind": "Camera2D", "components": { "Camera": { "height": 12, "clear": "#10131c" } } },
{ "id": "grnd01", "name": "Ground", "kind": "StaticBody2D", "components": {
"Transform": { "position": [0, -3, 0] }, "Collider2D": { "size": [30, 1] }, "Shape2D": { "size": [30, 1], "fill": "#2b3a55" } } },
{ "id": "plyr01", "name": "Player", "kind": "CharacterBody2D", "groups": ["players"], "script": { "src": "scripts/player.js" }, "components": {
"Transform": { "position": [-6, -1.9, 0] },
"Collider2D": { "shape": "capsule", "radius": 0.3, "height": 1.2 },
"Platformer2D": { "speed": 6 },
"Shape2D": { "shape": "roundRect", "size": [0.6, 1.2], "radius": 0.25, "fill": "#4ee1ff" } } },
{ "id": "coin01", "name": "Coin1", "instance": "scenes/coin.scene.json", "components": { "Transform": { "position": [-2, -2, 0] } },
"connections": [{ "signal": "collected", "to": "../Score", "call": "add" }] },
{ "id": "coin02", "name": "Coin2", "instance": "scenes/coin.scene.json", "components": { "Transform": { "position": [2, -2, 0] } },
"connections": [{ "signal": "collected", "to": "../Score", "call": "add" }] },
{ "id": "score1", "name": "Score", "kind": "Label2D", "script": { "src": "scripts/score.js" },
"components": { "Transform": { "position": [0, 4, 0] }, "Text": { "size": 1, "outline": 0.1 } } }
]
}
}
scenes/coin.scene.json
{
"format": "onceworlds.scene",
"version": 1,
"root": {
"id": "coin01", "name": "Coin", "kind": "Area2D", "groups": ["coins"], "script": { "src": "scripts/coin.js" },
"components": {
"Collider2D": { "shape": "circle", "radius": 0.35 },
"Shape2D": { "shape": "circle", "radius": 0.35, "fill": "#ffd23f", "stroke": "#0b0f1a", "strokeWidth": 0.08 }
}
}
}
scripts/player.js
import { script } from '@onceworlds/engine';
import { Intent } from '@onceworlds/engine/modules';
// The player's own input becomes what its Platformer2D controller does.
export default script({
fixed(node) {
const intent = node.get(Intent);
intent.move.x = node.input.axis('move').x;
intent.jump = node.input.held('jump');
},
});
scripts/coin.js
import { script } from '@onceworlds/engine';
// A coin: when a player touches it, it says so and goes.
export default script({
signals: ['collected'],
on: {
bodyEntered(node, other) {
if (!other.inGroup('players')) return;
node.audio.play('coin');
node.emit('collected');
node.free();
},
},
});
scripts/score.js
import { script, t } from '@onceworlds/engine';
import { Text } from '@onceworlds/engine/modules';
// The score on screen. Each coin's `collected` is connected to `add` in the scene.
const show = (node) => {
node.get(Text).text = 'COINS ' + node.props.count;
};
export default script({
props: { count: t.u32(0) },
ready: show,
on: {
add(node) {
node.props.count += 1;
show(node);
},
},
});