jengine-net
WebSocket transport, connection lifecycle and a script-reachable message bus.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-net |
| Version | 0.1.0 |
| Entry point | jengine_net::NetPackage |
| Script namespace | Net |
| Listed by | projects/arcade-classics, projects/car-classics, projects/common-room, projects/cozy-corner, projects/engine-test-lab, projects/game-night, projects/hearthwood, projects/kotodama, projects/lumeria, projects/rust-bowl, projects/saltwind, projects/shooter-classics, projects/undermere |
Using it
Section titled “Using it”List it in the top-level "packages" array of project.jengine:
{ "packages": [ { "name": "jengine-net", "source": { "type": "path", "path": "../../packages/jengine-net" } } ]}Then rebuild the editor viewport for the project (jengine viewport-build projects/<name>, or the
editor’s Rebuild viewport banner). Native and web builds link it on their own.
A script can check that the package is linked with if Net == nil then return end.
Script commands
Section titled “Script commands”Commands run in every runtime and return nothing; a package publishes what scripts read onto components (see the notes below). The flat name also works.
| Call | Flat name |
|---|---|
Net.connect(url) | net_connect |
Net.disconnect() | net_disconnect |
Net.send(message) | net_send |
Net.http(id, url, method, body) | net_http |
From packages/jengine-net/README.md.
WebSocket transport, HTTP(S) requests, and a message bus game scripts can reach. No rooms, no replication, no server — this is the layer those sit on, not a replacement for them.
Opting in
Section titled “Opting in”Not linked into the stock runtime, so nothing compiles tungstenite unless a
project asks for it. Declare it in project.jengine:
{ "packages": [ { "name": "jengine-net", "source": { "type": "path", "path": "../../packages/jengine-net" } } ], "settings": { "enabled_packages": ["jengine-physics", "jengine-net"] }}Then rebuild the viewport so the editor carries it:
./build-project-viewport.ps1 -Project projects/my-gameprojects/engine-test-lab is the worked example; scene 66 exercises it.
Using it from a script
Section titled “Using it from a script”Everything goes through a Net custom component on any entity. There are
deliberately no script globals — see “The parity constraint” below.
| Field | Written by | Meaning |
|---|---|---|
url | script | server to be connected to; "" disconnects |
outbox | script | strings to send; the package sends and clears them |
inbox | package | strings received; the script clears them |
state | package | disconnected / connecting / connected / closed / failed |
dropped | package | messages lost to a full inbox this session |
requests | script | HTTP requests to make; the package clears them |
responses | package | HTTP results; the script clears them |
own | script | true: a connection of this entity’s own (see below) |
local net = Entity.find_by_name("Net")
-- Connect. Declarative: state the URL you want, the package reconciles.Entity.set_custom(net, "Net", "url", "ws://127.0.0.1:2567")
-- Send.local out = Entity.get_custom(net, "Net", "outbox") or {}table.insert(out, '{"type":"move","x":3}')Entity.set_custom(net, "Net", "outbox", out)
-- Receive. The package appends; clearing is yours.for _, msg in ipairs(Entity.get_custom(net, "Net", "inbox") or {}) do handle(msg) endEntity.set_custom(net, "Net", "inbox", {})-- Queue a request. `id` is how you match the answer back.local reqs = Entity.get_custom(net, "Net", "requests") or {}table.insert(reqs, { id = "scores", url = "https://api.example.com/scores", method = "GET", headers = { ["User-Agent"] = "my-game" },})Entity.set_custom(net, "Net", "requests", reqs)
-- Read answers.for _, r in ipairs(Entity.get_custom(net, "Net", "responses") or {}) do if r.error then warn(r.error) -- never reached a server elseif r.ok then use(r.body) -- 2xx else warn(r.status .. ": " .. r.body) -- 404 etc. still carry a body endendEntity.set_custom(net, "Net", "responses", {})A status is not a failure: a 404 is an answer, and its body is usually
where the API explains the problem. Only error means the request never
reached a server — DNS, refused, TLS, CORS.
At most 16 requests may be in flight; past that a request is refused with a response, so a script waiting on an id is never left waiting forever. Response bodies are capped at 8 MB, and are text — a game calling a web API wants JSON, not a video.
CORS applies in the browser. An API that works natively can fail in a web build for that reason alone, and the browser deliberately will not say why.
url is a field rather than a connect() call on purpose: a script that
issues commands has to remember whether it already issued one, which goes
wrong the moment a scene reload runs init() again against a live connection.
Using it from Rust
Section titled “Using it from Rust”use jengine_net::{connect, http_client, HttpRequest, NetConnection};
let mut conn = NetConnection::new();connect(&mut conn, "wss://example.com/game");conn.pump(); // once per framefor msg in conn.drain() { /* … */ }
let mut http = http_client(); // native threads, or `fetch` in a browserhttp.send(HttpRequest::get("scores", "https://api.example.com/scores"));http.pump(); // once per framefor r in http.drain() { /* … */ }Rust keeps binary frames; the script channel is text-only, because carrying bytes through a Lua table means inventing an encoding.
Using a JS library in a web build (Colyseus, etc.)
Section titled “Using a JS library in a web build (Colyseus, etc.)”Some networking is only practical as JavaScript. Colyseus ships a browser SDK with its own framing and schema decoding; reimplementing that protocol in Rust would be a large job permanently chasing upstream, and the SDK is right there.
So the engine binds no library. It defines a contract, and a few lines of
JS adapt whichever one you want. Load both through settings.web_scripts:
"settings": { "web_scripts": [ "https://unpkg.com/@colyseus/sdk@^0.17.0/dist/colyseus.js", "colyseus-adapter.js" ]}The builder emits these as classic scripts, in order, ahead of the module
that boots the wasm — the same arrangement lua-runtime.js already relies on,
so the library’s globals exist before the engine’s first frame. A :// entry
is treated as a URL; anything else is a path under the web root, so you can
vendor a copy instead of depending on a CDN at runtime.
The adapter installs globalThis.jengineNet with four methods:
globalThis.jengineNet = { connect(url) { return handle; }, // begin connecting send(handle, text) {}, poll(handle) { return events; }, // drain, never peek close(handle) {},};Events are {type:"open"}, {type:"message", data},
{type:"close", code, reason}, {type:"error", message}.
Then connect with a js: URL instead of a ws:// one:
Entity.set_custom(net, "Net", "url", "js:my_room") -- default adapterEntity.set_custom(net, "Net", "url", "js:other:lobby") -- a named adapterEverything after the scheme goes to the adapter untouched, because a library’s
addressing is its own business — Colyseus takes a room name where a WebSocket
takes a path. js:wss://host is not misread as an adapter named wss.
A working Colyseus adapter is in examples/colyseus-adapter.js.
It is the whole of the coupling, and it is yours to edit.
This is web-only. A js: URL on native fails with a message saying so,
rather than handing js:... to a DNS resolver. A game that wants the same
protocol in a native build needs a Rust client for it.
The parity constraint
Section titled “The parity constraint”A package can register real script functions, but install_script_api is
wired only into ScriptSystem — the native Luau and QuickJS bridges. The
editor viewport and web runtime execute Lua through Fengari against a
hardcoded BRIDGE_FUNCTIONS allowlist that knows nothing about packages, so a
Net.send() global would be nil in the runtime this engine is developed in.
Entity.get_custom / Entity.set_custom are core bridge functions present in all four
runtimes, so the channel above behaves identically everywhere. If package
script APIs are ever installed by the browser hosts, a global wrapper becomes
worth adding — until then it would be a trap.
Every Net component shares one connection, mirrored to all of them:
url on any of them connects it, inbox and state are the same on each.
A component with own: true gets a connection of its own instead - its
url, its outbox, its inbox - which is what a scene with two sessions
(a split screen, two servers) needs; jengine-online sets it on its entity.
What is deliberately missing
Section titled “What is deliberately missing”- Reconnection. A failed connection stays failed until the
urlchanges. A silent reconnect would replay game state the caller never asked to resend. - A server. This is a client; in a browser it could not be anything else.
- Replication. No entity sync, authority, or interest management here:
that is
jengine-online(a pool ofonline_slots shown from ajengine-serverroom’ssyncframes).NetworkIdentityused to be carried for it and was read by nothing; it is gone.
Building on it
Section titled “Building on it”Both a Colyseus client and a hand-rolled protocol are users of Transport,
not replacements for it: both speak WebSocket underneath. Implement your
protocol over NetConnection, or over Transport directly if you need to
frame messages yourself. MockTransport lets the whole thing be tested
without a socket — every test in this package works that way.