Onceworlds
Engine · Scenes

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:

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)
});

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);
    },
  },
});