NEOLearn

Paired examples

Tested scripts written in both JavaScript and Python that leave exactly the same state and effects, each explained.

Updated October 17, 2026

On this page

Each example on this page is one program written twice, once in JavaScript and once in Python. NEO's engine tests run both versions with the same inputs and check that they leave the same state, the same effects and the same state hash. They're a good starting point for your own scripts, and a reference for how idioms translate between the languages.

The world they run in#

The examples share a small test world:

EntityIdExposed to scripts
Door1open field (read only), open() method
Door2the same
Lamp3intensity field (read and write)
Secret4nothing: the kind isn't exposed

Events like interact arrive with a player map ({ id, name, score }) and an entity.

Door puzzle#

Three door openings solve the puzzle. The game sends interact(player, door); the script opens the door, counts, and emits puzzle_solved once.

door_puzzle.js
import { World, on } from "neo";
export default class DoorPuzzle extends World {
  state = { opened: 0, last_opener: 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.last_opener });
    }
  }
  @on("interact", { tag: "Door" })
  open(player, door) {
    door.open();
    this.state.opened += 1;
    this.state.last_opener = player.id;
  }
}
  • tag: "Door" (Python: tag="Door") means open runs only when the second argument is a Door. In the test, one of the four interact events targets the lamp, so opened ends at 3, not 4.
  • The solved flag makes the emit happen once, on the first tick after the third opening.
  • The JavaScript version uses the snake_case key last_opener; that works because names without capitals pass through unchanged. lastOpener would be the same key.

The door puzzle tutorial builds this step by step.

Countdown with sleep#

A round start counts down with sleep, then waits on three things at once.

countdown.js
import { World, on, sleep, all } from "neo";
export default class Countdown extends World {
  state = { said: [], got: null };
  @on("round_start")
  async start() {
    for (const n of [3, 2, 1]) {
      this.state.said = [...this.state.said, `${n}…@${Math.round(this.time * 1000)}`];
      await sleep(0.1);
    }
    const got = await all([sleep(0.05).then(() => "late"), Promise.resolve("now"), sleep(0.016).then(() => "soon")]);
    this.state.got = got;
    this.emit("round_go", { at: Math.round(this.time * 1000) });
  }
}
  • With 16 ms ticks, said ends as ["3…@0", "2…@112", "1…@224"]: each sleep(0.1) resumes on the first tick at or past 100 ms later.
  • got is ["late", "now", "soon"]: results come back in argument order, not finishing order.
  • said is rebuilt each time because lists read from state are frozen copies.

Capabilities#

The script turns the lamps to a random brightness and tries to force the doors open by writing their read-only open field.

lights.js
import { World, on } from "neo";
export default class Lights extends World {
  state = { denied: [] };
  @on("go")
  go() {
    for (const lamp of this.query("Lamp")) lamp.intensity = 0.25 + 0.5 * this.rng.random();
    for (const door of this.query("Door")) {
      try { door.open = true; } catch (e) { this.state.denied = [...this.state.denied, door.id]; }
    }
  }
}
  • denied ends as [1, 2]: both writes are refused, a TypeError in JavaScript and a PermissionError in Python.
  • Both versions draw the same random number, because they share the world's seeded stream.
  • The Secret entity never appears: its kind isn't exposed, so no query returns it.

Proxies#

A wrapper that logs every write. JavaScript uses a Proxy; Python uses __getattr__ and __setattr__.

proxies.js
import { World, on } from "neo";
const watched = (target, onWrite) => new Proxy(target, {
  get: (t, k) => t[k],
  set: (t, k, v) => { onWrite(k, v); t[k] = v; return true; },
});
export default class P extends World {
  state = { log: [], final: null };
  @on("go")
  go() {
    const log = [];
    const w = watched({}, (k, v) => log.push(`${k}=${v}`));
    w.hp = 10; w.name = "orc"; w.hp = w.hp - 3;
    this.state.log = log;
    this.state.final = { hp: w.hp, name: w.name };
  }
}
  • log ends as ["hp=10", "name=orc", "hp=7"] and final as { hp: 7, name: "orc" }.
  • The Python __init__ uses object.__setattr__ so that setting up the wrapper's own fields doesn't go through its logging __setattr__.
  • Proxies on your own objects are fine. Entity objects and this.state are already guarded by the engine.

Functional style#

Scoring a list of players with sort, reduce, map and filter.

functional.js
import { World, on } from "neo";
export default class F extends World {
  state = {};
  @on("scores")
  scores(players) {
    const top3 = players.toSorted((a, b) => b.score - a.score || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)).slice(0, 3);
    this.state.total = players.reduce((acc, p) => acc + p.score, 0);
    this.state.names = top3.map((p) => p.name).join(", ");
    this.state.evens = players.filter((p) => p.score % 2 === 0).map((p) => p.id);
  }
}
  • With five players scoring 30, 50, 30, 50 and 7, total is 167, names is "ann, bob, cy" and evens is [1, 2, 3, 4].
  • Ties are broken by name in both versions. JavaScript needs an explicit comparator; Python sorts by a key tuple.
  • functools.reduce is one of NeoPy's standard modules.

Integer arithmetic#

Floor division and remainder written so both languages agree (see JavaScript and Python differences).

integers.js
import { World, on } from "neo";
const floordiv = (a, b) => Math.floor(a / b);
const mod = (a, b) => ((a % b) + b) % b;
export default class I extends World {
  state = {};
  @on("go")
  go() {
    const pairs = [[7, 2], [-7, 2], [7, -2], [-7, -2], [0, 3]];
    this.state.div = pairs.map(([a, b]) => floordiv(a, b));
    this.state.mod = pairs.map(([a, b]) => mod(a, b));
    this.state.pow = 3 ** 4;
    this.state.big = 2 ** 52 + 1;
  }
}
  • div is [3, -4, -4, 3, 0] and mod is [1, 1, -1, -1, 0] in both.
  • floordiv and mod are local helpers in the JavaScript script; JavaScript's own % would give -1 for -7 % 2.
  • 2 ** 52 + 1 is exact in both because it's within ±2^53.

Strings#

Text handled by code points so emoji and accents count the same.

strings.js
import { World, on } from "neo";
export default class S extends World {
  state = {};
  @on("go")
  go() {
    const s = "héllo 😀 wörld";
    this.state.len = [...s].length;
    this.state.upper = s.toUpperCase();
    this.state.words = s.split(" ");
    this.state.rev = [...s].reverse().join("");
    this.state.find = [...s].indexOf("w");
  }
}
  • len is 13 and find is 8 in both. Plain JavaScript s.length and s.indexOf("w") would give 14 and 9, because the emoji is two UTF-16 units.

Errors become faults#

A caught error is handled; an uncaught one becomes a fault.

errors.js
import { World, on } from "neo";
export default class E extends World {
  state = { caught: null, after: false };
  @on("go")
  go() {
    try { JSON.parse("{"); } catch (e) { this.state.caught = "parse"; }
    this.state.after = true;
    throw new Error("unhandled");
  }
}
  • caught is "parse" and after is true: changes made before the uncaught error are kept.
  • The handler records exactly one fault, with the same text in both languages.

Shared modules#

Both languages can split code into modules. The Modules page shows the Python version of a three-file scoring game (util, scoring and the game script). NEO's tests run the same game written in JavaScript too, and both versions give the same effects and state hash.

See also#