Releases / v0.85.0-rc6

v0.85.0-rc6 Web World

2026-09-07

This is a beta release. It is on the beta channel, not stable. To try it: Floptle Hub ⏵ Settings ⏵ Channel: beta, then install 0.85.0-rc6. Your stable install stays where it is.

Everything in 0.85.0-rc5 — your game in a browser, voice chat, a real player under every export, and a dedicated server — plus a batch of work aimed squarely at things you could not previously check: multiplayer without a second machine, a screenshot of a game that builds its own world, and errors that named Lua instead of the cause. There is also a prebuilt relay for small Linux servers, coloured text, and a trigger flag you can afford again.

Multiplayer you can test on one machine

floptle run could already host a real session with no window and no GPU. Nothing could join it, so the half of a multiplayer game that is only true across the wire — a client's mirror, targeted RPCs, late joiners, and above all what a client is not sent — could only be checked by a person clicking in the editor, or by two machines.

floptle run . --seconds 10 --ghosts 2

joins that many headless clients to the session your project hosts. Each gets its own world and real snapshots, and the run says how many connected and how many replicated nodes each one was actually sent:

2 of 2 ghost client(s) connected — each is sent [1, 2] replicated node(s).

A count that differs between clients is interest management doing its job. That is what makes net.setRelevant(node, peer, false) checkable at all: the cheat-resistance of a hidden-role game is defined by what a client is not sent, and there was no way to be a client and look.

For the client half of your own scripts, be the client:

floptle serve .
floptle run . --join 127.0.0.1:7777 --seconds 10

--join is a real network connection rather than the in-process harness, and your own scripts run as the client — net.isServer() answers false, and your game does its own checking.

FLOPTLE_NET_IMPAIR=50ms,2% now degrades all of it, including the in-process harness, which it never used to touch. "Does this hold up on a bad connection" is a question one machine can answer.

Screenshots of a game that builds its own world

floptle shot draws your scene as authored — nothing has moved, no start has run. That is the right picture for "what did my edit do", and the wrong one entirely for a game whose planet, terrain, buildings and props are made by scripts on the first frames of play. For those, every screenshot was an empty room.

floptle shot . --after 30s --out shot.png

plays the project first and then takes the picture. Time is fixed and never read off the clock, so two runs give the same image, and --seed pins the randomness for a game that generates its cast. The camera is chosen after the span, so a game that takes over the view is photographed through the camera it chose; --camera still names one and still wins.

Both verbs' help now says plainly which frame you get. run plays and reports numbers; it writes no picture.

One trigger on a mesh no longer costs the whole level

A single node with a Collider and its trigger switch on, over a thousand-triangle mesh, could put a 57 ms stall on a fifth of your frames, while thirty other mesh props in the same scene cost nothing at all. The geometry was never the problem: trigger overlap asked every physics body in the world for an exact distance to that mesh, at any range.

Triggers reject by bounds first now. If you turned trigger off on a node to get your frame rate back, you can turn it on again.

Colour one word in a line, and move one letter

A text element carried a single colour for the whole string, so highlighting the bound key inside the sentence that tells you to press it meant splitting the line into separate elements and positioning them by hand — which re-wraps wrong at every window size and breaks the moment the string is translated.

lua
label:setTextSpans{ { len = 10 }, { len = 3, color = { 1, 0.3, 0.3 } } }

len is in characters of your string, and spans run end to end from the start; a span with no colour is how you spell a gap. Characters no span reaches keep the element's own colour.

Spans style, they never lay out: a two-colour run wraps exactly where the same string in one colour does. That is also why a span cannot change size or font — those would move the text.

lua
label:setGlyphOffsets{ vec2(0, 0), vec2(0, -3), vec2(0, 1) }

displaces individual characters at draw time, one entry per character, applied after layout — so a moved glyph never re-wraps its line or nudges its neighbours. Wobble, shake and per-glyph reveal are yours to write. Your string stays your string through both, so you can still measure it and count its characters.

A crowd can wander around one place cheaply

nav.random gathers and measures every polygon its window covers, and does all of it again on the next call — so a dozen agents redrawing a destination in one frame cost about 4 ms.

lua
local s = nav.sampler(node.position, 20)
local p = s:point(math.random(), math.random())

gathers once and answers many draws. Quadrupling the radius no longer costs sixteen times as much. A sampler is a snapshot: rebuild it if you re-bake that part of the mesh.

Errors that named Lua instead of the cause

synced in a script with no replicated table. This failed as attempt to index nil — Lua's words for a cause that lives at the top of a different file, while the node's Networked component, the live session and net.role() all said replication was on. It now names the script, the variable and the fix, on the first read as well as the first write. The read was the quieter half: it came back nil and failed silently, so a client mirror just never updated.

local n = createNode("Stain"). createNode and spawn are queued — the node is made after the pass — so neither can return one, and the handle only ever arrives through the callback. Writing the obvious thing gave you nil, and failed a line later as attempt to index nil with 'position'. Both now hand back something that says so the moment you touch it, and shows the line that works:

createNode("Stain") does not return a node — the node does not exist yet (it is
made after this pass), so `.position` has nothing to read. The handle arrives in
the callback: createNode("Stain", function(n) n.position = ... end).

A relay you can run on a small x86 box

The managed relay — the one that checks lobby keys against Floptle Cloud rather than letting anyone through — now ships as a prebuilt Linux x86_64 binary alongside the engine bundles:

floptle-relay-0.85.0-rc6-linux-x86_64
floptle-relay-0.85.0-rc6-linux-x86_64.sha256

It is a bare executable and its checksum, not an archive, because installing it is four lines on a server with nothing on it. It links no windowing, audio or input libraries — only the C runtime — so it starts on a minimal Ubuntu image, and it is under 4 MB.

floptle-relay --help now prints the flag table instead of refusing, which is also how you tell this binary from an older relay before installing it on a live box — an older one answers --help with anything but a flag table.

Running a relay of your own is unchanged and needs none of this: with no --control and no --token it is the open relay it has always been — no keys, no control plane, no accounting.

One behaviour change if you run a managed relay: it now reports usage on every interval even when it has no lobbies at all. An idle relay used to say nothing, which made a quiet region indistinguishable from a region that had fallen over.

Also in this release

  • Editor packages can ask a scene what its gravity isscene.gravity([at]) gives the vector at a point, plus whether one number is the whole answer. A level with no gravity volume has zero gravity rather than the engine default, so anything assuming −9.81 was guessing in both directions.
  • An exported game is 22 MB smaller than the editor — 30 MB against 52 — now measured rather than estimated.

Upgrading

Nothing to do. Open your project and it works.

Two notes if they apply to you:

  • synced is now a table rather than nil in a script that declares no replicated table. If you guarded a write with if synced then, that guard was silently doing nothing, and you will now see the error it was hiding.
  • createNode and spawn now return a value instead of nil. If you tested one for truthiness — if spawn("bullet") then — it now reads as true. Touching it raises with the explanation above.

Floptle Hub

Linux x86_64 7.5 MB Download → sha256 da02b693cc981d4a042a680c19a4ccff58016dce3e276ea1c72ddcf1ba095232
macOS Apple Silicon 5.7 MB Download → sha256 5ce62988c378365dbf0693580a8762d5186ef7cb4d9ff438e2e6e640092df3a2
macOS Intel 6.2 MB Download → sha256 1f86ddf3b5b513576dba53af8123d3ec15d5390c386e607ecefa35f96c07b9e5
Windows x86_64 7.1 MB Download → sha256 de9d4e6a48ca0069dccda7ce3b5e3784b90ed851cafc21be1601ebc59b02d428

Engine

Linux x86_64 33.4 MB Download → sha256 e656d1fc6e9722f28d1ec2f60e3b4e135b42605cb1ab25cdd8610054a585ca62
macOS Apple Silicon 27.1 MB Download → sha256 7ef15690010a7569c6fd15997795978a7794bc4fa30e031f4a3e67fbc1573605
macOS Intel 29.2 MB Download → sha256 ad8025e3e06b7066b3c6b14576fb8f29dab7a46f7325e334a5c7850f20098cf8
web 4.2 MB Download → sha256 26c98bdd2faa2e8719654280021aeb88787f81dd6e203fb3de892b925bc0e7a2
Windows x86_64 33.0 MB Download → sha256 36614a88bf7aec4a0dd7a5f0f90f1f811e6b7f0c669288ce3d1a44e0bd99f0b9

The binaries are not signed yet. On macOS, right-click the app and pick Open the first time. On Windows, if SmartScreen appears, pick More info then Run anyway.