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.
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.
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.
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 is —
scene.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:
syncedis now a table rather than nil in a script that declares noreplicatedtable. If you guarded a write withif synced then, that guard was silently doing nothing, and you will now see the error it was hiding.createNodeandspawnnow 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
Engine
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.