NEOLearn

Exposure and permissions

Declare in Rust what scripts may touch, narrow it per script and per role in neo.toml, and control who may load live scripts.

Updated October 17, 2026

On this page

A script can only reach what your game exposes. Exposure has two layers, and a script gets the overlap of both:

  1. Rust declares the most that can be exposed, with #[derive(ScriptExpose)] on a component and #[script_methods] on its methods.
  2. neo.toml grants part of that, separately for the game's own scripts and for each player role.

A grant can narrow what Rust declares but never widen it. A grant that names something Rust doesn't declare is an error, so a typo can't silently expose nothing or the wrong thing.

Declaring a kind in Rust#

Rust
use neo_script::{ScriptExpose, script_methods};
use neo_script::expose::ScriptCtx;

/// A moving platform. Scripts move it and may boost it.
#[derive(Component, ScriptExpose)]
#[script(kind = "Platform", transform(get, set), methods)]
pub struct Platform {
    /// Laps of its route per second.
    #[script(get, set, range = 0.0..=1.0)]
    pub speed: f32,
    /// A label for UI.
    #[script(get)]
    pub label: String,
    /// Only admins may rename the platform.
    #[script(get, set, roles = ["admin"])]
    pub display_name: String,
    /// Not marked: invisible to scripts.
    pub boosts: u32,
}

#[script_methods]
impl Platform {
    /// Speeds the platform up; players need the `builder` role.
    #[script(call, rate = 2)]
    fn boost(&mut self, by: f32, ctx: &ScriptCtx) -> Result<f32, String> {
        if !ctx.is_creator_or("builder") {
            return Err(format!("{} can't boost platforms", ctx.player().unwrap_or("?")));
        }
        self.speed += by.clamp(0.0, 0.2);
        Ok(self.speed)
    }
}

Then register the kind with the app, plus a spawner for any kind scripts may create:

Rust
app.add_plugins(NeoScriptPlugin { neo_toml, scripts, seed, modules })
    .expose_script_kind::<Platform>()
    .expose_script_kind::<Cube>()
    .script_spawner("Cube", spawn_cube);

The /// doc comments become the descriptions in the generated neo.d.ts and neo.pyi files and in script.api.

Options on the type#

OptionMeaning
kind = "Name"The kind name scripts use (query("Platform"), tag: "Platform"). Defaults to the type's name.
transform(get), transform(get, set)Exposes the entity's position (from its Transform) as { x, y, z }. A field can't also be called position.
methodsSays the type has a #[script_methods] impl block.

Options on a field#

Only fields marked with #[script(...)] exist for scripts.

OptionMeaning
get (or read)Scripts may read it.
set (or write)Scripts may write it.
range = a..=bRefuses numeric writes outside the range (inclusive).
roles = ["..."]Only players holding one of these roles may use it. The game's own scripts aren't affected.
rename = "name"The name scripts see, instead of the Rust field name.

Supported field types include integers, floats, bool, String, Option<T>, Vec<T> and Vec3.

Options on a method#

Mark a method in a #[script_methods] block with #[script(call, ...)]. It takes &self or &mut self.

OptionMeaning
callScripts may call it.
rate = nAt most n calls per simulation second, per script. Further calls throw.
roles = ["..."]Only players holding one of these roles may call it.

A method that returns Result<T, E> turns Err into the calling script's fault, with the error's text. In a running game, the method runs right after the calling script's turn, so the script sees the call return null.

ScriptCtx: who is calling#

A method can take ctx: &ScriptCtx in any position after self. Scripts don't pass it and can't fake it: an extra argument a script passes is ignored.

MemberMeaning
ctx.principalWho the script runs as: the game's own scripts (Principal::Creator), or a player with their roles (Principal::Player { id, roles }).
ctx.player()The calling player's id, or None for the game's own scripts.
ctx.is_creator_or("role")true for the game's own scripts, or for a player holding role.
ctx.scriptThe calling script's name.
ctx.tickThe simulation tick of the call.

Use it for rules that depend on the caller, such as ownership checks.

Granting in neo.toml#

The game's own scripts get the grants under [script.expose.<Kind>]:

neo.toml
[script.expose.Platform]
fields = { position = "get set", speed = "get", label = "get" }
methods = ["boost"]

[script.expose.Cube]
fields = { position = "get set", size = "get set" }
spawn = true
  • fields maps each field to "get", "set" or "get set".
  • methods lists the methods scripts may call.
  • spawn = true lets scripts create the kind with this.spawn.
  • A kind with no section doesn't exist for the game's scripts, and a field you leave out isn't visible.

Here speed is declared get, set in Rust but granted only get, so the game's scripts can read the speed but not change it.

Player roles#

Players can load their own live scripts only if the game allows it (next section). A player's scripts start with nothing, then get:

  • the grants of the player role, which every player holds;
  • plus the grants of each other role the player holds.
neo.toml
[script.roles.player]
grant = ["Platform.position:get", "Player.position:get"]

[script.roles.builder]
grant = ["Cube:spawn", "Cube.position:set", "Cube.size:set", "Platform.boost:call"]

[script.roles.admin]
grant = ["Platform.display_name:get set"]

A grant is one string:

GrantAllows
Kind.field:getReading the field.
Kind.field:setWriting the field.
Kind.field:get setBoth.
Kind.method:callCalling the method.
Kind:spawnSpawning the kind.

Fields and methods marked with roles = [...] in Rust are removed for players who don't hold one of those roles, even if a grant names them.

Who may load live scripts#

[script.players] allowed decides which players may load live scripts into a running session:

ValueWho
"nobody" (the default)No one.
"owner"The game's owner.
"friends"The owner and the owner's friends.
"role:<name>"The owner and players holding that role.
"everyone"Every player.

Player scripts also have their own budgets (fuel_per_tick, calls_per_tick, memory in the same section), the same for every player and device. A player script must also pass the game's moderation check before it runs; by default that refuses sources over 64 KB. Refusals are written to the script log. See The [script] manifest for every key.

What denied access looks like#

SituationJavaScriptPython
Querying a kind you can't seeempty listempty list
Reading a field you can't readTypeErrorPermissionError
Writing a field you can't writeTypeErrorPermissionError
Calling a method you can't callTypeErrorPermissionError
Spawning a kind you can't spawn, or setting a field you can't write at spawnTypeErrorPermissionError
Probing names such as then or toJSON on an entityundefined (no error)n/a
Reaching interpreter internals (__class__, __globals__, constructor)n/acompile error, or AttributeError at run time

Each error can be caught. Left uncaught, it ends the handler with a fault. If you change grants while the game runs, the change applies at the script's next access.

Checking your setup#

The Scripts panel's check validates [script] against the kinds your game exposes and writes neo.d.ts and neo.pyi next to your scripts, so your editor can autocomplete exactly what your scripts may use. script.types and script.api produce the same declarations as commands. See The Scripts panel and Script commands.

See also#