Paired examples
Tested scripts written in both JavaScript and Python that leave exactly the same state and effects, each explained.
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:
| Entity | Id | Exposed to scripts |
|---|---|---|
| Door | 1 | open field (read only), open() method |
| Door | 2 | the same |
| Lamp | 3 | intensity field (read and write) |
| Secret | 4 | nothing: 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.
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;
}
}from neo import World, on
class DoorPuzzle(World):
state = {"opened": 0, "last_opener": None, "solved": False}
@on("tick")
def tick(self, dt):
if self.state.opened >= 3 and not self.state.solved:
self.state.solved = True
self.emit("puzzle_solved", {"by": self.state.last_opener})
@on("interact", tag="Door")
def open(self, player, door):
door.open()
self.state.opened += 1
self.state.last_opener = player.id
default = DoorPuzzletag: "Door"(Python:tag="Door") meansopenruns only when the second argument is a Door. In the test, one of the fourinteractevents targets the lamp, soopenedends at 3, not 4.- The
solvedflag 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.lastOpenerwould 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.
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) });
}
}from neo import World, on, sleep, gather
class Countdown(World):
state = {"said": [], "got": None}
@on("round_start")
async def start(self):
for n in (3, 2, 1):
self.state.said = [*self.state.said, f"{n}…@{round(self.time * 1000)}"]
await sleep(0.1)
async def now():
return "now"
async def after(s, v):
await sleep(s)
return v
got = await gather(after(0.05, "late"), now(), after(0.016, "soon"))
self.state.got = list(got)
self.emit("round_go", {"at": round(self.time * 1000)})
default = Countdown- With 16 ms ticks,
saidends as["3…@0", "2…@112", "1…@224"]: eachsleep(0.1)resumes on the first tick at or past 100 ms later. gotis["late", "now", "soon"]: results come back in argument order, not finishing order.saidis 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.
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]; }
}
}
}from neo import World, on
class Lights(World):
state = {"denied": []}
@on("go")
def go(self):
for lamp in self.query("Lamp"):
lamp.intensity = 0.25 + 0.5 * self.rng.random()
for door in self.query("Door"):
try:
door.open = True
except PermissionError:
self.state.denied = [*self.state.denied, door.id]
default = Lightsdeniedends as[1, 2]: both writes are refused, aTypeErrorin JavaScript and aPermissionErrorin 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__.
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 };
}
}from neo import World, on
class Watched:
def __init__(self, target, on_write):
object.__setattr__(self, "_t", target)
object.__setattr__(self, "_w", on_write)
def __getattr__(self, name):
return self._t[name]
def __setattr__(self, name, value):
self._w(name, value)
self._t[name] = value
class P(World):
state = {"log": [], "final": None}
@on("go")
def go(self):
log = []
w = Watched({}, lambda k, v: log.append(f"{k}={v}"))
w.hp = 10
w.name = "orc"
w.hp = w.hp - 3
self.state.log = log
self.state.final = {"hp": w.hp, "name": w.name}
default = Plogends as["hp=10", "name=orc", "hp=7"]andfinalas{ hp: 7, name: "orc" }.- The Python
__init__usesobject.__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.stateare already guarded by the engine.
Functional style#
Scoring a list of players with sort, reduce, map and filter.
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);
}
}from functools import reduce
from neo import World, on
class F(World):
state = {}
@on("scores")
def scores(self, players):
top3 = sorted(players, key=lambda p: (-p.score, p.name))[:3]
self.state.total = reduce(lambda acc, p: acc + p.score, players, 0)
self.state.names = ", ".join(p.name for p in top3)
self.state.evens = [p.id for p in players if p.score % 2 == 0]
default = F- With five players scoring 30, 50, 30, 50 and 7,
totalis167,namesis"ann, bob, cy"andevensis[1, 2, 3, 4]. - Ties are broken by name in both versions. JavaScript needs an explicit comparator; Python sorts by a key tuple.
functools.reduceis one of NeoPy's standard modules.
Integer arithmetic#
Floor division and remainder written so both languages agree (see JavaScript and Python differences).
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;
}
}from neo import World, on
class I(World):
state = {}
@on("go")
def go(self):
pairs = [(7, 2), (-7, 2), (7, -2), (-7, -2), (0, 3)]
self.state.div = [a // b for a, b in pairs]
self.state.mod = [a % b for a, b in pairs]
self.state.pow = 3 ** 4
self.state.big = 2 ** 52 + 1
default = Idivis[3, -4, -4, 3, 0]andmodis[1, 1, -1, -1, 0]in both.floordivandmodare local helpers in the JavaScript script; JavaScript's own%would give-1for-7 % 2.2 ** 52 + 1is exact in both because it's within ±2^53.
Strings#
Text handled by code points so emoji and accents count the same.
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");
}
}from neo import World, on
class S(World):
state = {}
@on("go")
def go(self):
s = "héllo 😀 wörld"
self.state.len = len(s)
self.state.upper = s.upper()
self.state.words = s.split(" ")
self.state.rev = s[::-1]
self.state.find = s.find("w")
default = Slenis13andfindis8in both. Plain JavaScripts.lengthands.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.
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");
}
}import json
from neo import World, on
class E(World):
state = {"caught": None, "after": False}
@on("go")
def go(self):
try:
json.loads("{")
except ValueError:
self.state.caught = "parse"
self.state.after = True
raise Exception("unhandled")
default = Ecaughtis"parse"andafteristrue: 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.