Script a moving platform
Walk through the script playground sample, a platform that follows a square route, drops cubes and reacts to the player, step by step.
On this page
This tutorial walks through the script playground, the smallest scripted NEO sample. One JavaScript script moves a platform around a square, drops a cube every two seconds (five at most), and boosts the platform when the player touches it. Along the way you use exposure, a shared module, query, spawn, emit, @on with tag and every, and sleep.
The sample's files:
| File | What it is |
|---|---|
neo.toml | The project file. Its [script] section lists the script and what it may touch. |
assets/scripts/paths.js | A module: the square route. |
assets/scripts/platform.js | The script. |
| The game's Rust code | Declares the Player, Platform and Cube kinds and sends the touch event. |
Step 1: what the game exposes#
The game declares three kinds in Rust. This is the most any script could ever touch:
| Kind | Declared for scripts |
|---|---|
Player | position (read), name (read). The player walks back and forth on its own. |
Platform | position (read, write), speed (read, write, range 0 to 1 laps per second), label (read), display_name (read, write, admins only), and the method boost(by), limited to 2 calls per second. |
Cube | position (read, write), size (read, write, range 0.1 to 2.0 metres). Scripts may spawn cubes. |
The game also sends a touch event, with the player and the platform as arguments, when the player comes within 1.5 m of the platform.
How to declare kinds like these is covered in Exposure and permissions.
Step 2: grant access in neo.toml#
[script]
language = "js"
scripts = ["assets/scripts/platform.js"]
fuel_per_tick = 1_000_000
fuel_per_call = 500_000
calls_per_tick = 2_000
memory = "16MB"
[script.expose.Platform]
fields = { position = "get set", speed = "get", label = "get", display_name = "get" }
methods = ["boost"]
[script.expose.Player]
fields = { position = "get", name = "get" }
[script.expose.Cube]
fields = { position = "get set", size = "get set" }
spawn = trueRead it section by section:
[script]picks JavaScript, lists the one script, and gives it smaller budgets than the defaults.[script.expose.Platform]lets the script move the platform and callboost, but only readspeed, even though Rust allows writing it. The script can only change the speed throughboost, which has its own rules.[script.expose.Player]makes the player read-only.[script.expose.Cube]lets the script spawn cubes and set their position and size.
The sample's neo.toml also has [script.players] and [script.roles.*] sections for players' live scripts. Players are off (allowed = "nobody"), so they don't affect this tutorial. See The [script] manifest.
Step 3: the route module#
The route lives in a module, so other scripts could share it:
// A point on a square of side 8 around the origin, `t` laps per second
// (arithmetic only, so it is bit-identical on every platform).
export function square(t) {
const u = (t % 1) * 4; // 0..4 along the four sides
const side = Math.floor(u);
const f = u - side;
const a = -4 + 8 * f;
if (side === 0) return { x: a, y: 1, z: -4 };
if (side === 1) return { x: 4, y: 1, z: a };
if (side === 2) return { x: -a, y: 1, z: 4 };
return { x: -4, y: 1, z: -a };
}square(t) takes a number of laps and returns a position { x, y, z } on a square 8 m wide, 1 m up. The fractional part of t picks where on the lap you are; each quarter is one side.
A module exports names and has no export default. See Modules.
Step 4: the script#
import { World, on, sleep } from "neo";
import { square } from "./paths";
export default class PlatformMover extends World {
state = { lap: 0, spawned: 0, touches: 0, boosts: 0, playerX: 0 };
@on("tick")
move(dt) {
const platform = this.query("Platform")[0];
if (!platform) return;
this.state.lap += (dt / 1000) * platform.speed;
platform.position = square(this.state.lap);
const player = this.query("Player")[0];
if (player) this.state.playerX = Math.round(player.position.x * 100) / 100;
}
@on("tick", { every: 2 })
drop() {
if (this.state.spawned >= 5) return;
const n = this.state.spawned;
const cube = this.spawn("Cube", { size: 0.25 + 0.125 * n, position: { x: -4 + 2 * n, y: 3, z: 0 } });
this.state.spawned = n + 1;
this.emit("cube_spawned", { id: cube.id, n: n + 1 });
}
@on("touch", { tag: "Platform" })
async touched(player, platform) {
this.state.touches += 1;
if (this.state.boosts < 3) {
platform.boost(0.05);
this.state.boosts += 1;
}
await sleep(0.5);
this.emit("touch_settled", { by: player.name, touches: this.state.touches });
}
}The state#
state declares five keys. lap is how far along the route the platform is; spawned, touches and boosts are counters; playerX records the player's x position. Because they're in state, a hot reload keeps them: the platform doesn't jump back to the start and cubes don't spawn twice.
Moving every tick#
move runs every tick:
this.query("Platform")[0]gets the platform. If there isn't one, it returns early.dtis in milliseconds, so(dt / 1000) * platform.speedis the laps covered this tick. The script readsspeed(it's grantedget) and adds it tostate.lap.platform.position = square(this.state.lap)writes a whole new position. The game moves the entity right after the script's turn.- It reads the player's position and stores its x coordinate, rounded to two decimals.
positionis a frozen{ x, y, z }copy, which is fine for reading.
Dropping cubes#
@on("tick", { every: 2 }) runs drop at most once every two simulation seconds. Each run spawns a cube with a size and position that depend on how many came before: the first is 0.25 m at x = -4, the fifth is 0.75 m at x = 4. Both values are inside the ranges Rust declares, so the spawn succeeds. spawn returns the new cube's object at once, so the script can emit its id. After five cubes, drop returns early.
Reacting to a touch#
@on("touch", { tag: "Platform" }) runs touched only when the event's second argument is a Platform. The handler counts the touch, calls boost(0.05) up to three times, then waits half a second of simulation time with await sleep(0.5) and emits touch_settled with the player's name.
boost is a Rust method. It speeds the platform up by at most 0.2 laps per second at a time, and refuses once the platform reaches 0.5. It also checks who's calling: the game's own scripts and players with the builder role may boost; anyone else's call is refused and recorded as a fault. Its rate limit allows 2 calls per simulation second per script.
Step 5: run it and change it#
Run the game and open the Scripts panel (The Scripts panel). The platform script shows its fuel each tick, no faults, and a state hash that changes as the platform moves.
Then try some changes and save after each one. The script hot-reloads with its state kept:
- Change
every: 2toevery: 1. Cubes come faster, and the count continues from where it was. - Try to write the speed directly, with
platform.speed = 0.5inmove. The write throwsTypeError: Platform.speed isn't writable by scripts, andmovefaults every tick, becauseneo.tomlgrantsspeedonlyget. Put it back. - Spawn a cube that's too big, by changing the size to
3. The spawn throws because 3 is outside the declared range (must be in 0.1..=2).
What you learned#
- Rust declares what scripts may touch;
neo.tomlnarrows it. - Shared code goes in a module and is imported with
./name. @on("tick")withdt,@on(..., { every })and@on(..., { tag })cover most game logic.- Positions are
{ x, y, z }maps you write whole. - Exposed methods can enforce their own rules and rate limits.