Skip to content

jengine-net

WebSocket transport, connection lifecycle and a script-reachable message bus.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-net
Version0.1.0
Entry pointjengine_net::NetPackage
Script namespaceNet
Listed byprojects/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

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.

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.

CallFlat 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.

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:

Terminal window
./build-project-viewport.ps1 -Project projects/my-game

projects/engine-test-lab is the worked example; scene 66 exercises it.

Everything goes through a Net custom component on any entity. There are deliberately no script globals — see “The parity constraint” below.

FieldWritten byMeaning
urlscriptserver to be connected to; "" disconnects
outboxscriptstrings to send; the package sends and clears them
inboxpackagestrings received; the script clears them
statepackagedisconnected / connecting / connected / closed / failed
droppedpackagemessages lost to a full inbox this session
requestsscriptHTTP requests to make; the package clears them
responsespackageHTTP results; the script clears them
ownscripttrue: 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) end
Entity.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
end
end
Entity.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.

use jengine_net::{connect, http_client, HttpRequest, NetConnection};
let mut conn = NetConnection::new();
connect(&mut conn, "wss://example.com/game");
conn.pump(); // once per frame
for msg in conn.drain() { /* … */ }
let mut http = http_client(); // native threads, or `fetch` in a browser
http.send(HttpRequest::get("scores", "https://api.example.com/scores"));
http.pump(); // once per frame
for 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 adapter
Entity.set_custom(net, "Net", "url", "js:other:lobby") -- a named adapter

Everything 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.

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.

  • Reconnection. A failed connection stays failed until the url changes. 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 of online_slots shown from a jengine-server room’s sync frames). NetworkIdentity used to be carried for it and was read by nothing; it is gone.

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.