NEOLearn

Build a door puzzle

Build a puzzle that is solved by opening three doors, in JavaScript or Python, including the Rust exposure and neo.toml grants it needs.

Updated October 17, 2026

On this page

In this tutorial you build a small puzzle: players open doors, and once three doors have been opened the script announces that the puzzle is solved. You write the script in JavaScript or Python (the two versions behave identically), declare the Door kind in Rust, and grant the script access in neo.toml.

You'll use: an exposed kind with a method, an event from the game, @on with a tag filter, this.state, and emit.

Step 1: declare the Door kind#

Scripts can only reach what the game exposes. Declare a Door component that scripts can see, with a read-only field and one method:

Rust
use neo_script::{ScriptExpose, script_methods};

/// A door players can open.
#[derive(Component, ScriptExpose)]
#[script(kind = "Door", methods)]
pub struct Door {
    /// Whether the door is open.
    #[script(get)]
    pub is_open: bool,
}

#[script_methods]
impl Door {
    /// Opens the door.
    #[script(call)]
    fn open(&mut self) {
        self.is_open = true;
    }
}

Register it with the app next to the scripting plugin:

Rust
app.add_plugins(NeoScriptPlugin { neo_toml, scripts, seed, modules })
    .expose_script_kind::<Door>();

Scripts see the field as isOpen in JavaScript and is_open in Python, and the method as open() in both. Because the field is declared get only, no script can write it: the only way to open a door is the method.

Step 2: send the interact event#

The puzzle reacts to an interact event whose arguments are the player and the door. Your game decides when a player interacts (a button press near a door, for example) and writes the event from a system that runs before the scripts:

Rust
use neo_script::Value;
use neo_script::bridge::ScriptEvent;

events.write(
    ScriptEvent::new("interact")
        .value(Value::map(&[("id", Value::Int(player_id))]))
        .entity(door),
);
  • .value(...) passes the player as a map with an id, so the script can read player.id.
  • .entity(door) passes the door's entity. The script receives it as an entity object.
  • Order the system .before(ScriptSystems) in FixedUpdate so the event reaches the scripts on the same tick.

Step 3: grant access in neo.toml#

Add the script and the grants to neo.toml, with the language you'll write the script in:

neo.toml
[script]
language = "js"                               # or "py"
scripts = ["assets/scripts/door_puzzle.js"]   # or door_puzzle.py

[script.expose.Door]
fields = { is_open = "get" }
methods = ["open"]

Names in neo.toml are always snake_case, whatever the script language. If you leave out methods = ["open"], the script's door.open() call is refused, even though Rust declares the method.

Step 4: write the script#

Create the script in assets/scripts:

door_puzzle.js
import { World, on } from "neo";

export default class DoorPuzzle extends World {
  state = { opened: 0, lastOpener: null, solved: false };

  @on("tick")
  tick(dt) {
    if (this.state.opened >= 3 && !this.state.solved) {
      this.state.solved = true;
      this.emit("puzzle_solved", { by: this.state.lastOpener });
    }
  }

  @on("interact", { tag: "Door" })
  open(player, door) {
    door.open();
    this.state.opened += 1;
    this.state.lastOpener = player.id;
  }
}

How it works:

  • state starts with opened at 0, nobody as the last opener, and solved false. lastOpener (JavaScript) and last_opener (Python) are the same key.
  • open handles interact. The tag: "Door" filter means it only runs when the second argument is a Door, so an interact on anything else is ignored. It calls the door's open() method, counts the opening and remembers who did it.
  • tick checks every tick whether three doors have been opened. The solved flag makes it emit puzzle_solved exactly once.

door.open() runs the Rust method right after the script's turn, so the door's is_open field becomes true from the next tick on.

Step 5: react to the solved puzzle#

this.emit("puzzle_solved", ...) reaches your game's Rust systems as a ScriptEmitted message. Read it in a system ordered .after(ScriptSystems):

Rust
use neo_script::bridge::ScriptEmitted;

fn on_puzzle_solved(mut emitted: MessageReader<ScriptEmitted>) {
    for e in emitted.read() {
        if e.event == "puzzle_solved" {
            // e.script is "door_puzzle"; e.payload holds { by: <player id> }
        }
    }
}

Step 6: run and test it#

Run the game and open the Scripts panel (The Scripts panel). You should see door_puzzle running with no faults. Each door opening changes its state hash.

Things to try:

  • Open the same door twice. It counts twice: the puzzle counts openings, not doors. To count each door once, keep a list of opened door ids in state. Remember to write a new list each time: this.state.doors = [...this.state.doors, door.id] (Python: self.state.doors = [*self.state.doors, door.id]).
  • Try to force a door. Add door.isOpen = true (Python: door.is_open = True) to open. The write is refused with a TypeError (Python: PermissionError) because the field is read-only, and the handler records a fault. Catch it with try to handle it yourself.
  • Edit and save while it runs. The script hot-reloads and keeps opened, so a half-solved puzzle stays half-solved.

Next steps#