Every version, and what changed in it.
The full notes for every Floptle release, straight from the release itself. Read the install notes here before you download anything — you do not need the Hub to see them.
The multiplayer tutorial ends with a friend actually playing; the 🌐 panel and a build's F1 menu take a Floptle Cloud code as typed; a joiner's avatar has its body from its first tick; a script that hosts while it is already in a session is told so; and the reference says the two things every action game needs to know. Plus, in the editor, a click into the viewport takes the keyboard off whatever field you were typing in.
The tutorial's friend gets to play
Make it multiplayer, step 11, used to end with the friend spectating: the
platformer has one Player, and it is the host's. The step now gives the
friend a body — duplicate Player as Player2, the second slot — makes each
machine's camera follow the player it steers, catches both players in the
respawn, and puts the host call behind a key so the friend's copy of the same
script does not host a lobby of its own before it can join yours. It ends when
their capsule moves on your screen and yours on theirs. The join line is the
one the engine accepts: net.join("cloud://" .. code). The 🎓 Learn tab and
the page agree.
A cloud code works from the panel
The 🌐 panel's relay field reads cloud now. Join by code sends a
six-character Floptle Cloud code as typed — the first letter names the region,
so the code is the whole address — and Host — get a lobby code hosts on
Floptle Cloud with the project's game key. A host:port in the field is your
own floptle-relay, with its five-letter codes, exactly as before. A build's
F1 menu is the same panel and behaves the same way. A code whose first letter
names no region is refused with the sentence net.join prints.
Hosting while joined is refused
net.host{} on a machine that is already joined, joining or hosting is
refused with one Console line that says which session this is and that
net.leave() ends it. Before, the in-editor loopback host went ahead beside
the live session, and from then on that client predicted somebody else's
avatar while its own never ran — nothing named the cause. The same door
covers the 🌐 panel's host buttons. net.join with an address in no known
form now lists cloud:// alongside the other three.
A joiner's avatar has its body from its first tick
A rig spawned for a joiner while their client was still loading the scene ran
its controller once before its body existed: node.vel read nil on that
one tick, a red line on every friend's Console, in a script that was right
everywhere else. The body is registered before the avatar is bound now, and
node.vel documents the promise: a body's fields are there from the first
tick its scripts run.
The "no Predicted node is yours" line is a note rather than a warning when the
scene authors no slots — a game that spawns avatars spectates for one round
trip by design. It stays a warning when the slots exist and are taken, which
is the one-Player scene with a friend in it. And when the server's scene
binds you to a different node than the Welcome did, the Console says so.
What the reference now says
docs/scripting/networking.md:
cloud://in thenet.joinrow and the lobby-codes section, beside the self-hostedrelay://form, with the six-versus-five-character rule.net.spawnstates what it takes: a prefab by name, a prefab path or a scene path;x, y, zas three numbers;ownerfor an avatar. Thefloptle api net.spawnentry says the same.- What a Predicted node's owner sees. On the owning client a script has two
copies of its state — its locals are the prediction,
syncedis the server — and anything only the server does reaches the owner throughsyncedalone. The publish/view pattern is on the page, with which copy a HUD, an animator and a camera read.docs/multiplayer.md§3 carries the same section. - Effects, sounds and drawing stay where they were made.
spawnEffect,audio.playanddraw.*never cross the wire; the synced-counter pattern that drives them from state is shown once, with a hit as the example. Thefloptle apientries for both calls say so in a clause. - The lag-compensation example reads a named action, as the page's own rule
requires, and
getScript/getscriptare documented as one call. sync animatoris scoped: this node's Animation Controller only.floptle servehands the authored slots out from #1, so aserve+run --jointest of a peer-hosted layout gives the first client the host's avatar; §6d says how to test that layout.
docs/export-builds.md describes joining by a cloud code from the F1 menu.
A click into the viewport ends typing
Type a value into the Inspector, right-click into the Scene view to look around, and the fly keys you press next went into the Inspector field. Any mouse button pressed over the Scene view, the Game view or the bare window now takes the keyboard off the field — and commits the value you typed. A press on a panel is still the panel's.
Upgrading
The 🌐 panel's relay field defaults to cloud; type a host:port for your own
relay. A script that calls net.host unconditionally in start now gets a
refusal on a machine that has joined — host from a key or a setting on the one
machine that should, as the tutorial does. Everything else is words on a page.
Particles squash and stretch, and leave trails behind anything that moves;
the Animating tab draws curves and its keys ease; a multiplayer game keeps
every spawn across a scene change; and the headless verbs — run, shot,
serve — tell the truth about a client, a viewport and a flag. Everything
from the two betas, played and promoted.
Trails
A particle trail records the particle's own motion, which is nothing for a particle sitting still on a moving node — and a sword trail is exactly that. Every trail now has follows the emitter (under the trail row in the Inspector): the ribbon is recorded along the node's path through the world, so one still particle at a blade tip leaves a sword trail, a wing tip a streak, a wheel a tyre mark — all in one effect asset, no script. It is depth-tested like every particle, so a trail behind a pillar stays behind it. The Particles tab's ∞ sweep moves the preview through a figure-eight so you can see it, and a world-space track or inherited velocity, without leaving the tab.
For a ribbon in the blade's own plane, scripts have draw.quad: one
textured quad in the world, depth-tested, blended with the image's alpha,
visible from both sides. Give it four corners, a colour and a slice of a
streak texture per segment, and a ribbon between two marker nodes is a dozen
lines — floptle api draw.quad has the copyable one.
A spawn survives a scene change
The server's scene.load tells every client to load the same scene, and each
takes its own time over it. Anything the server spawned in the new scene
meanwhile — the player rigs a scene.onLoaded handler spawns on the very
next frame — was dropped by a client still loading, and never re-sent: the
first rig after every map change was missing on someone, and which one
depended on who joined first. Those spawns are now held through the swap and
applied the moment the client's load finishes, in order. No warm-up spawn,
hold timer or "ask for a body" call is needed any more; docs/multiplayer.md
says what you may assume.
Headless checks that tell the truth
floptle run --join keeps real time. A joined run is a client of a server
ticking on the wall clock; stepping as fast as the CPU allowed sent an input
clock the server watched race ahead until it dropped the peer, while the
client believed it was still in. Each step now waits for its tick, so a
client run stays connected for the whole --seconds.
floptle shot tells its scripts the picture is the viewport. From the
first update, camera.exists() is true and camera.screenRect() is the
--size you asked for, so a HUD that sizes itself from the screen can be
checked at 1440p, an ultrawide or a portrait window without opening a window.
floptle run renders nothing and still reports no camera.
floptle serve passes every flag it takes. --status-file,
--max-players, --lobby-code and --script-budget-ms were accepted on the
command line and dropped on the way to the server. They arrive now.
Particles squash and stretch
Every particle track has a squash property, under 📈 Over each particle's life in the Inspector next to size. It keeps the particle's area while changing its proportions: the width is multiplied by the value and the height divided by it. Above 1 flattens, below 1 lengthens, and a curve from 0.6 through 1.4 back to 1 is the classic pop — a drop that lands, a coin that bounces. Like size and colour it animates over each particle's life, can be randomised per particle with 🎲, and has its own lane on the timeline. A mesh particle squashes the same way along its own axis.
A velocity-aligned billboard also stretches + by speed: extra length per unit of speed, so a fast spark draws a long streak and a slow one stays a dot, where before every particle on the track was stretched the same.
And a new emit shape, box: particles are born anywhere inside a box of the size you give — rain over a field, dust through a room, snow. The emitter gizmo draws its outline.
Particles fade where they meet the scene
A sprite crossing a floor or a wall was cut along a hard line, the one tell that a puff of smoke is a flat picture. Each particle now fades over the last few world units in front of whatever is behind it — soft edges under 🎨 Look, 0.5 by default, 0 for the hard cut.
The Particles tab fits its panel: fields sized to the width, a random range on two lines, forces as labelled rows, and the selected clip as its own section under ✳ Emission with a pool you can set. A looping effect whose particles outlive the loop no longer refuses every second generation's births. An effect is named by its file, so a renamed file is a renamed effect.
The Animating tab: curves, easing, packed rows
The dopesheet says when; the new 📈 Curves view (Tab) says how much and how fast. A node's position, rotation and scale are drawn as values over time, sampled through the same tracks the game plays, so what you see is the motion you get. Every key is a point on its curve: drag it in time and value, right-click empty space to key a lane there, right-click a point for its interpolation. Rotation is drawn in degrees as one continuous line, without the fold at 90° that Euler angles usually produce.
Right-click a key → ↗ Interpolation now offers Linear, Smooth (a spline through the neighbouring keys, so the motion has no corner), Ease in, Ease out, Ease in-out and Hold; with several keys selected the choice applies to all, and the key's shape says which. A node's row opens with ⏵ into its position, rotation and scale lanes, each key editable on its own lane. With a snap grid set, the ruler counts in frames.
Rows run from 4 to 80 px, typed in the toolbar or Alt+wheel, so a large rig fits the panel; a 🔍 rows filter shows only the rows whose name matches.
Under the hood
The editor's largest functions — the frame loop, the Inspector, the timeline,
the material and post-processing editors, the Play step, the network tick —
are now short outlines that call one function per phase. Every move was
checked against the previous build: floptle shot and floptle run on five
projects give pixel-identical pictures and identical reports before and
after. Comments across the workspace say what the code does today.
floptle shot draws particles textured, as the live frame does. The passes
that read the depth buffer — ambient occlusion, depth of field, motion blur,
2D lighting, soft particles — and authored post shaders build on OpenGL.
Upgrading
Nothing to change. A particle effect written before this release has no squash, no speed stretch, no emitter-path trail and its old shapes, and draws as it did. An animation clip's holds load as Hold keys and are saved in the new form. A game's scene-change workarounds — a warm-up spawn, a hold timer, an "ask for a body" call — can come out at your leisure.
Read the notes
The second beta of v0.94.0. Everything in rc1 — particles that squash and stretch, the Animating tab's curves — plus trails behind anything that moves, and four things a multiplayer game and a headless check found wanting.
Trails
A particle trail records the particle's own motion, which is nothing for a particle sitting still on a moving node — and a sword trail is exactly that. Every trail now has follows the emitter (under the trail row in the Inspector): the ribbon is recorded along the node's path through the world, so one still particle at a blade tip leaves a sword trail, a wing tip a streak, a wheel a tyre mark — all in one effect asset, no script. It is depth-tested like every particle, so a trail behind a pillar stays behind it. The Particles tab's ∞ sweep moves the preview through a figure-eight so you can see it, and a world-space track or inherited velocity, without leaving the tab.
For a ribbon in the blade's own plane, scripts have draw.quad: one
textured quad in the world, depth-tested, blended with the image's alpha,
visible from both sides. Give it four corners, a colour and a slice of a
streak texture per segment, and a ribbon between two marker nodes is a dozen
lines — floptle api draw.quad has the copyable one.
A spawn survives a scene change
The server's scene.load tells every client to load the same scene, and each
takes its own time over it. Anything the server spawned in the new scene
meanwhile — the player rigs a scene.onLoaded handler spawns on the very
next frame — was dropped by a client still loading, and never re-sent: the
first rig after every map change was missing on someone, and which one
depended on who joined first. Those spawns are now held through the swap and
applied the moment the client's load finishes, in order. No warm-up spawn,
hold timer or "ask for a body" call is needed any more; docs/multiplayer.md
says what you may assume.
Headless checks that tell the truth
floptle run --join keeps real time. A joined run is a client of a server
ticking on the wall clock; stepping as fast as the CPU allowed sent an input
clock the server watched race ahead until it dropped the peer, while the
client believed it was still in. Each step now waits for its tick, so a
client run stays connected for the whole --seconds.
floptle shot tells its scripts the picture is the viewport. From the
first update, camera.exists() is true and camera.screenRect() is the
--size you asked for, so a HUD that sizes itself from the screen can be
checked at 1440p, an ultrawide or a portrait window without opening a window.
floptle run renders nothing and still reports no camera.
floptle serve passes every flag it takes. --status-file,
--max-players, --lobby-code and --script-budget-ms were accepted on the
command line and dropped on the way to the server. They arrive now.
Upgrading
Nothing to change. An effect written before this release has no emitter-path trail and draws as it did; a game's scene-change workarounds can come out at your leisure.
Switch the Hub to the beta channel to install this build.
Read the notes
The first beta of v0.94.0. Particles squash and stretch, the Animating tab has a curve view and keys that ease, and the editor's biggest functions are now outlines you can read — verified frame-for-frame against the previous build.
Particles squash and stretch
Every particle track has a squash property, under 📈 Over each particle's life in the Inspector next to size. It keeps the particle's area while changing its proportions: the width is multiplied by the value and the height divided by it. Above 1 flattens, below 1 lengthens, and a curve from 0.6 through 1.4 back to 1 is the classic pop — a drop that lands, a coin that bounces. Like size and colour it animates over each particle's life, can be randomised per particle with 🎲, and has its own lane on the timeline. A mesh particle squashes the same way along its own axis.
A velocity-aligned billboard also stretches + by speed: extra length per unit of speed, so a fast spark draws a long streak and a slow one stays a dot, where before every particle on the track was stretched the same.
And a new emit shape, box: particles are born anywhere inside a box of the size you give — rain over a field, dust through a room, snow. The emitter gizmo draws its outline.
Particles fade where they meet the scene
A sprite crossing a floor or a wall was cut along a hard line, the one tell that a puff of smoke is a flat picture. Each particle now fades over the last few world units in front of whatever is behind it — soft edges under 🎨 Look, 0.5 by default, 0 for the hard cut.
The Particles tab fits its panel: fields sized to the width, a random range on two lines, forces as labelled rows, and the selected clip as its own section under ✳ Emission with a pool you can set. A looping effect whose particles outlive the loop no longer refuses every second generation's births. An effect is named by its file, so a renamed file is a renamed effect.
The Animating tab: curves, easing, packed rows
The dopesheet says when; the new 📈 Curves view (Tab) says how much and how fast. A node's position, rotation and scale are drawn as values over time, sampled through the same tracks the game plays, so what you see is the motion you get. Every key is a point on its curve: drag it in time and value, right-click empty space to key a lane there, right-click a point for its interpolation. Rotation is drawn in degrees as one continuous line, without the fold at 90° that Euler angles usually produce.
Right-click a key → ↗ Interpolation now offers Linear, Smooth (a spline through the neighbouring keys, so the motion has no corner), Ease in, Ease out, Ease in-out and Hold; with several keys selected the choice applies to all, and the key's shape says which. A node's row opens with ⏵ into its position, rotation and scale lanes, each key editable on its own lane. With a snap grid set, the ruler counts in frames.
Rows run from 4 to 80 px, typed in the toolbar or Alt+wheel, so a large rig fits the panel; a 🔍 rows filter shows only the rows whose name matches.
Under the hood
The editor's largest functions — the frame loop, the Inspector, the timeline,
the material and post-processing editors, the Play step, the network tick —
are now short outlines that call one function per phase. Every move was
checked against the previous build: floptle shot and floptle run on five
projects give pixel-identical pictures and identical reports before and
after. Comments across the workspace say what the code does today.
floptle shot draws particles textured, as the live frame does. The passes
that read the depth buffer — ambient occlusion, depth of field, motion blur,
2D lighting, soft particles — and authored post shaders build on OpenGL.
Upgrading
Nothing to change. A particle effect written before this release has no squash, no speed stretch and its old shapes, and draws as it did. An animation clip's holds load as Hold keys and are saved in the new form.
Switch the Hub to the beta channel to install this build.
Read the notes
A patch for a bug that has been in the sky for a long time: while you look around, a wedge of the sky would flash a flat colour for a single frame — a straight-edged slice, usually at a corner or side of the screen, cut off exactly at the horizon. It happened far more often while the camera was moving than while it was still, and it was over before a screenshot could catch it.
What it was
The renderer built each pixel's ray as the line from the near plane to the far plane. With a far plane hundreds of thousands of units out, the far point sits at the very edge of 32-bit floating-point precision — and for some camera poses, the rounding tipped over along a straight line across the screen. Beyond that line the direction came out as not-a-number, and a sky shader given no direction painted its bare horizon colour. Every camera pose had its own line, or none; looking around visited many poses a second.
The ray now runs from the near plane to a point halfway down the depth range. It is the same line, without the precision cliff at the end of it. On a test rig that swung the camera around a project continuously, the flash appeared 156 times in eleven thousand frames before and not once in ten thousand after.
Also in this patch
floptle shot never compiled a project's Sky shader, so every picture of a
scene with a procedural sky showed the flat Skybox colour instead. It draws
the sky now. The shot verb also takes --frames N and --turn DEGREES to
photograph a sequence while the camera sweeps, and FLOPTLE_FRAME_DUMP=<dir>
writes every frame the editor or a build presents to that directory — the
tools that caught this bug. See docs/export-builds.md.
Upgrading
Nothing to change.
Read the notes
A character stands where it looks like it stands. This release closes a report that survived two patches: a character hovering over terrain and clipping into slopes, on a project where every collision measurement said the ground was under its feet.
A body under a group node is drawn where it is
The cause was not the terrain. A physics body is placed from its node's world position, and the sim wrote that position straight back into the node — but a node's position is stored relative to its parent. A body at the scene root was fine. A body under a group node was displaced by the group's whole transform every frame: its model, a child drawn where the node says, stood beside and above the capsule the collider was resolving. On a project whose player sits in a group placed 0.6 units up and two units aside, that was a character hovering exactly 0.6 over flat ground and walking into every slope by two units, while the collision itself was right to the millimetre.
The sim now writes every body and assembly back in its parent's frame. A parented character stands on its capsule, wherever its group is.
See exactly what you collide with
Terrain physics has collided with the triangles you see since v0.92.1, at any voxel size and node scale — but the tools for checking that were wrong. View ▸ Terrain collider wireframe drew a coarse proxy of the field, a surface nothing collided with, in the wrong place for a moved, turned or scaled terrain. It now draws the exact surface physics uses, through the node's full transform: turn it on and the lines lie on the ground. And the ◆ Rigidbody outline draws the live body during Play, so a controller that sets its own stand height no longer leaves the outline floating above where the body meets the ground.
Choose the surface
The terrain node has a collide with picker in the Inspector: the drawn surface (the default, and what every existing scene gets) or the field — smoother than the picture, cheaper, and never meshed, for a server that draws nothing or a project tuned against it. It takes effect on the next Play.
Capsules stand on their feet, along their whole length
A capsule's round bottom touches a slope off to one side, bridges a crease and coasts off a crest, leaving the feet you drew in the air on rough ground. Capsule bodies now stand on the ground straight beneath their centre line, pushed up out of it and held down onto it while standing — following crests and stairs instead of hopping — with jumps, walls, cliffs and ledges untouched. And a capsule collides along its whole length rather than as its two end spheres, so legs no longer sink into a steep face.
floptle shot draws the terrain
The shot verb photographed a terrain project with no ground in it: the field loaded and the character stood on it, but the ground is meshed by the frame loop and a one-frame verb has none. It meshes the ground around the camera before it draws now.
Upgrading
Nothing to change. A character whose model was nudged to compensate for the group offset will now sit low by that nudge — remove it. A project that relied on a capsule coasting off a crest will find it follows the ground.
Read the notes
The second beta of v0.93.0. Terrain collision is now something you can see, and choose; and a capsule collides along its whole length.
See exactly what you collide with
Terrain physics has collided with the triangles you see since v0.92.1, at any voxel size and any node scale. Measured on a real project through the live player, a walking character's feet sit on the drawn surface to within a few millimetres on nearly every tick, and nowhere near the underlying field.
But you could not check that for yourself. View ▸ Terrain collider wireframe drew a coarse proxy of the field — a surface nothing collided with — in the wrong place for a moved, turned or scaled terrain. It now draws the exact surface physics uses, through the node's full transform. Turn it on and the lines lie on the ground, or something is wrong; a screenshot settles it.
The ◆ Rigidbody outline had the same problem during Play: it drew the capsule as authored, while a controller that sets its own stand height had changed the live body and moved its centre to keep the feet planted. The outline sat a hand's width above where the body met the ground, and anything lined up against it lined up wrong. It draws the live body now.
Choose the surface
Some projects want the old behaviour: the underlying field is smoother than the picture, cheaper to query, and never meshes anything — a server that draws nothing, or a project whose physics was tuned against it. The terrain node has a collide with picker in the Inspector: the drawn surface (the default, and what every existing scene gets) or the field. It takes effect on the next Play, and the wireframe follows the choice.
A capsule collides along its whole length
A capsule was tested as its two end spheres, so a face steep enough to reach a character's shins between the feet and the head let the legs walk through it, and a crouching body could put its waist through a low wall. The point of the capsule closest to each surface is tested too now.
Also in this beta
Everything from rc1: capsule bodies stand on their feet — the ground
straight beneath the centre line — following crests and stairs instead of
hopping, with jumps, walls, cliffs and ledges untouched; and floptle shot
draws the terrain, which it never had.
Upgrading
Nothing to change. A scene written before this release collides with the drawn surface, exactly as it did on v0.92.1 and rc1. A character walking along a steep face may now be pushed out of it where its legs used to sink in.
Switch the Hub to the beta channel to install this build.
Read the notes
A character on terrain now stands where it looks like it stands. This is a beta: it changes how every capsule body meets the ground, so it goes out on the beta channel first.
What was wrong
After v0.92.1 the terrain collider matched the picture to a few millimetres — and a character still hovered over a rough hillside, and toed into it. The collider was not the problem any more. The shape was.
A capsule has a round bottom. On a slope, a sphere touches the ground off to one side, so the point straight under the centre — where the character's feet are drawn — hangs above it. In a crease it bridges the two sides and the feet hang over the bottom. On a bump it perches. Walking over a crest, it coasts off the edge and lands some way down the far side. On a coarse terrain, where one voxel is several units across, those creases and bumps are everywhere.
Measured while walking a character around a real project: the feet floated more than five centimetres off the drawn ground on one tick in five, and by up to a third of a unit.
What changed
Capsule bodies stand on their feet: the ground straight beneath the centre line. A body is pushed up out of the ground under its feet, and pulled down onto it while it is standing — so it follows a crest instead of hopping off it, steps down a stair instead of coasting past it, and walks up a ramp onto a flat top without being launched. A jump is untouched: leaving the ground under your own power is exactly what the feet do not interfere with.
Walls, cliffs, slopes past the body's slope limit and ledges still meet the round bottom exactly as before, so a character still hangs by its rim at an edge and still slides off a face it cannot stand on. Moving platforms count as ground too.
There is nothing to set. Every capsule, in every project, stands on its feet. Spheres roll on their curve as they always did.
Measured on the same project afterwards: the feet sit within two millimetres of the drawn ground on nine ticks in ten and within seven on ninety-nine in a hundred, and the only ticks more than five centimetres off are the landing itself.
floptle shot draws the terrain
The shot verb photographed a terrain project with no ground in it: the field loaded, the collider was live, the character stood on it — and the picture showed it standing on the sky, because the ground is meshed by the frame loop and a one-frame verb has none. It now meshes the ground around the camera before it draws, and says so if it could not finish in time.
Upgrading
Nothing to change. A character that was tuned to sit right on rough ground may now stand a few centimetres lower on slopes and in creases; that difference is the correction. A game that relied on a capsule coasting off a crest will find it follows the ground instead.
Switch the Hub to the beta channel to install this build. Once it has been played, it will be promoted to stable as v0.93.0.
Read the notes
A patch for v0.92.0's terrain collision. It said the ground matched the picture. It did not yet — and on a scaled-up terrain the gap was over a metre.
What was wrong
v0.92.0 measured collision against the triangles you see, but decided which side of them you were on by asking the underlying field. Those two surfaces disagree by a fraction of a voxel, and the side is what decides where a body comes to rest — so a character still settled on the field, exactly as before, and the triangles only changed how far away it thought the ground was.
A fraction of a voxel is small until the voxel is not. On a terrain built at a 1.5-unit voxel and scaled up five times, one voxel is 7.5 units across, and the measured gap between where you stood and where the ground was drawn reached 1.15 units — a knight hovering a shin's height over flat ground, and up to his ankles in a slope a few steps away. Both directions on the same hill, and worse the bigger the terrain.
What changed
Collision now decides the side from the drawn surface itself, using the same normals the picture is lit with. On the same terrain the gap is 0.005 units.
Raycasts land on the drawn ground too, which is what a character controller's ground check actually uses: a ray cast at a scaled, rotated terrain stops on the triangles you see, not a voxel above or below them. Digging and sculpting are unaffected, and the cost is unchanged from v0.92.0.
Upgrading
Nothing to change. If a character or a prop was tuned to sit right on v0.92.0's ground, it may now sit a little higher or lower — that difference is the correction, and it is the same everywhere rather than varying from place to place.
Read the notes
Ten things that were quietly not true. The ground you walk on is the ground that is drawn. Fog can be dark. The Console keeps talking after the second time you press Play. And the scripting guide is a set of chapters instead of one very long page.
The ground matches the picture
Terrain collision was measured against the underlying field, and the terrain you look at is triangles extracted from that field. Those are two different surfaces — the triangles cut across every curve, inside a bulge and outside a hollow — so a hillside you could see solid in front of you let you sink into it, and a dip you could see beneath your feet held you above it. The same model, in both directions, on the same hill, which is why it read as collision being vaguely wrong rather than as an offset you could learn to expect.
Physics now collides against the triangles you are looking at, and asks the
field only which side of them you are on. Measured on rough ground at a half-
unit voxel, the old answer was out by up to a fifth of a voxel where the
terrain is busiest — and it scales with the voxel, so terrain authored coarse
was out by more. Digging, sculpting and terrain.* from a script are
unaffected: the surface is remeasured the moment the ground changes.
Fog can be dark
Fog tinted surfaces and left the background alone, which reads as fog only while the fog colour is close to the sky's. Pick anything darker and the distant hills turn to silhouette against air that has not moved: the geometry visibly changes and the fog is nowhere. There was a hard seam at the horizon in every foggy scene.
The distance fog now reaches the sky, weighted toward the horizon so a painted
skybox survives overhead. Lighting ▸ fog ▸ takes the sky is how much, and
0 is the old surfaces-only look if a scene wants it. A night fog, a cave
haze, a dust storm at dusk — all of them are a colour you can now actually
pick.
The Console keeps talking
Press Play a second time without editing anything and the Console went silent. Every diagnostic the engine writes is deliberately said once and then held back — sixty identical lines a second is how a Console stops being read — but the suppression was keyed to the file, not to the run, and pressing Play clears the panel. So the second run emptied the Console and then refused to refill it.
Worse: a script stopped for overrunning its time budget was held back "until its file changes", and after a Stop there was nothing left to change its mind. It never ran again for the rest of the session, and nothing said so.
Every run now starts with a clean slate. A script that cannot load says so on the first frame of every Play; a lookup that finds nothing explains itself again; a script the budget stopped gets another go.
node.scripts
node:getScript("name") answers nil when the name is wrong — and nil is
also what "no such node" and "not running yet" look like. The plural is the
thing you actually want first:
for _, s in ipairs(node.parent.scripts) do print(s.kind) end
Every script on a node, as handles, in the order they were attached. Possibly
empty, never nil.
Posing a model without losing it
Locking the selection exists so a stray click cannot take you off the thing you are working on — and posing is exactly when you want that. But the lock swallowed bone clicks too, so it could not be used with the Animating tab at all. A bone of the held model is part of that model, not a way out of it: those clicks land now, the model stays selected, and its rig stays on screen. A bone belonging to some other model is still refused.
Keyframes that hold, one at a time
A clip could be stepped or smooth, whole. That is not what stepped animation is: it is a clip that holds each pose and snaps on the beats the animator chose, with the odd move eased through. The only way to get a hold in the middle of a smooth lane was to key the same value twice and hope nobody retimed it.
Right-click any key in the Animating tab → ⇥ Hold until next key. It is per key, on transform and property lanes alike, and the lane's own setting is now the default for keys that have not been told otherwise. A clip that does not use it is unchanged, down to the bytes its file is saved as.
Terrain textures: the real picker, and a scale each
The texture slots in the Terrain tab had a plain dropdown listing every image in the project in tree order — no search, no folders, no thumbnails — while every other texture field in the editor had the full picker. They all use the same one now.
Each slot also gets a ▦ × control: how often that texture repeats, relative to the rest of the palette. A fine gravel and a broad rock face only look like the same ground if one of them tiles several times as often, and until now the only way to arrange that was to resample the image.
The edge of the picture keeps up
With motion blur on, a fast camera swing left a band around the edge of the frame that stayed sharp while everything inboard of it smeared — worst on the sky, which is at infinity and so the fastest thing in any pan, and gone the instant the camera stopped. Blur samples that fall outside the frame are dropped now instead of folded back onto the border pixel.
The scripting guide is chapters
The guide was one page of thirty-seven sections — findable by searching for a word you already knew, and no use at all for the thing you did not know existed. It is eleven chapters now, each a page of its own: Start here, Nodes & the scene, Physics, Input, Assets & materials, Animation, particles & sound, Maths & the world, Multiplayer, Data, time & the outside world, Steam and Working in the editor.
The same chapters are in the editor — Scripting ▸ § Docs ▸ 📖 Guides — with the contents down the left and one page at a time, instead of twenty-four collapsing headers in a single scroll. Section numbers have not moved, so the engine's own messages still point where they always did.
Every call that can fail says how
A reference that names a second return value and then never mentions it again
is not something you can write error handling against. Every call that hands a
script an error now lists what fills it — all four assets.read* / write*
calls, json.decode, and every steam.* call — and the reference will not
build without it the day a new one is added.
Upgrading
Nothing in a project needs changing. Scenes saved by this version gain a fog
setting and, if you use them, per-key animation holds; both are written only
when they differ from the default, so an untouched project's files do not move.
Scenes from earlier versions open with the fog reaching the sky — if a scene
was built around the old seam at the horizon, set takes the sky to 0.
Read the notes
A script can read and write its own data files now. A rhythm chart, a dialogue
tree, a table of enemy stats — anything you would rather author as JSON than
as a Lua table — is a file under Assets/, and a script gets it in one call.
Data files, in and out
json.encode and json.decode have been here since v0.20.0, but a script had
no way to open a file: assets.getFile answers a path, which is what a model
or a material wants and nothing a script can read. So a chart authored outside
the game had to be rewritten as Lua, or pushed through save.* a kilobyte at
a time. Four calls close that gap:
local chart, why = assets.readJson("charts/neon.json")
if not chart then return log("no chart: " .. why) end
for _, note in ipairs(chart.notes) do
spawnNote(note.t, note.lane)
end
assets.readJson(path)— a JSON file decoded straight to a Lua value, byjson.decode's rules: objects are tables, arrays are 1-based lists that stay lists on the way back out,nullisnil.assets.writeJson(path, value [, { pretty = true }])— the reverse.prettyindents it for a person, or for a git diff.assets.readText(path)/assets.writeText(path, text)— the same for any text file, when the format is your own.
Writing back is the half that makes a chart editor a scene in your game
rather than a tool outside it: record the hits, call assets.writeJson, and
the file it wrote is one the Asset Browser shows, git diffs and the export
ships. A write creates the folders on the way.
Every one of them answers value, why rather than raising — a data file is
content, and a file that is missing, not text, or not JSON is a message in the
Console and a nil the script can handle, never an error that stops the
script loading. Paths are relative to Assets/ and stay inside it, the same
rule assets.getFile follows; a path that would leave the project is refused
and says so. The same calls work in an exported build and in a browser, where
a read comes from the bundle and a write lands in the page's own storage.
Read the notes
A relay's inbox is sized for the players it serves and says so at startup, the
relay bench measures the shape a real lobby produces and refuses to print a
ceiling off a failing step, floptle shot draws the game's UI so a menu can be
looked at from a terminal, and a shader knob on one part of a model can be
driven from a script.
A relay's inbox is sized for the players it serves
A relay on a small box was dropping packets at a hundred players while its link sat under one percent used, its forwarding loop at a millisecond and its CPU idle. The limit was not the machine: a UDP socket left at the kernel's default receive buffer holds a couple of hundred datagrams, and one synchronised burst from a hundred clients filled it. The only symptom was retransmit latency that reads as "the internet".
The relay now asks for an 8 MiB buffer, receive and send, and prints what it
was actually given on its first line of output — because the kernel silently
caps the ask at net.core.rmem_max, and on a stock Linux box that cap is a
fraction of the ask:
socket buffers: asked 8388608 B each; kernel reports rx 8388608 B, tx 8388608 B
floptle-relay: ⚠ socket buffer CLAMPED below the 8388608 B asked — raise
net.core.rmem_max … and net.core.wmem_max … with sysctl, then restart
The sysctl is the operator's half; the relay is the only thing that knows it
was clamped, so it says which one to raise and to what. Linux reports double
what it applied, which is why an unclamped ask reads as sixteen megabytes here
and in ss -m; the line spells out the usable half so it reads honestly beside
the ask. A bigger machine with the same socket drops at the same point, so this
is the fix and a bigger box is not.
Measuring a relay tells the truth about a failing step
floptle-relay-bench drives synthetic lobbies through a relay and reports what
a player costs. Three things about the first walk of that curve were wrong, and
each is fixed.
The host fans out. A game host tells every player what the others did, so
the bench's host now sends each ping to every other member of its lobby, not
only back to its sender — --lobby-size changes what the relay carries, not
just how many lobbies there are. --echo-only keeps the lighter shape.
A ceiling is printed only on a clean step. The old report printed its
highest ceiling on the step where the relay was dropping: stalled senders
lowered the rate, which lowered the per-player figure, which raised the
arithmetic. A step with anything unreturned, or a send rate short of the one
asked for, now prints implied ceiling withheld and marks its per-player cost
as not one. The report also shows how many pings were sent against how many
the rate asked for, so a stalling relay shows as a shortfall before it shows as
loss.
Bytes are called bytes. The relay's usage report named its throughput
egress_bps and ingress_bps and counted bytes. The fields are
egress_bytes_per_sec and ingress_bytes_per_sec now, before the first
reader applied eight the wrong way.
Every simulated player is a socket on the machine driving the bench; the help
and the docs now say to raise ulimit -n before a large run, because the
default limit fails a join past about 400 players with an error that reads
like a relay fault and is not one.
floptle shot draws the UI
floptle shot is how a project is looked at from a terminal, and it drew the
world alone: no UI pass, so a scene whose whole point is a screen — a main
menu, a character creator, a dialogue box, a HUD — came out as its backdrop.
Every enabled UI layer is now in the picture, drawn the way the Game view draws
it: world canvases in the scene, screen-space layers over the finished frame in
their z order at the scale their scale mode gives --size.
Elements a script builds with ui.make in start exist only once the project
has played, so they need --after; hand-authored elements are drawn as
authored. --no-ui gives the world alone, for a lighting comparison or a GI
preview.
node:uiRect() under floptle run used to answer 0, 0, 0, 0, which reads
like a measurement and is not one — nothing lays out without a surface. It
answers nil there now, as the reference always said it did; guard the call
(if x then) in anything that also runs headless, and use shot to see a
screen.
A shader knob on one part of a model
A model's parts can each wear a .flsl shader — skin on the head, a face decal,
a cloth overlay on the torso — with every uniform authored in the scene and not
one of them changeable at runtime: node:setShaderParam writes the node's own
Material, which on such a model usually does not exist, and the part handle had
no spelling for a uniform at all. The handle has one now:
local head = node:material("Head#2")
head:setShaderTexture("face", "faces/02.png") -- the character creator's face swap
head:setShaderParam("blush", 0.6)
if head:shaderTexture("face") ~= "faces/02.png" then ... end -- reads back, same frame
node:material() with no name drives the node's own Material through the same
four calls. A shader write lands only on a part that already has an override
wearing a shader — it never creates one, because an override is a whole
material and a uniform must not be able to blank a part — and a write with
nowhere to land is said once in the Console rather than lost. The node-level
call on a model with part overrides and no node Material fans out to every
part that wears a shader.
Upgrading
If you run a relay, add net.core.rmem_max = 16777216 and net.core.wmem_max = 16777216 to a sysctl file on the box before restarting onto this version, and
read the relay's first line of output afterwards — the buffer is fixed when the
socket is created, and a relay restart ends every lobby on it, so pair the two.
If anything reads the relay's usage report, the two throughput fields are
renamed. If a script reads node:uiRect() and also runs under floptle run,
it gets nil there now. Nothing else needs a change.
Read the notes
The editor, the player and the Hub carry the Floptle logo now, everywhere the desktop will show one.
The logo is the icon
The editor's and the Hub's windows show the logo in the taskbar and title bar, the Windows executables carry it as their file icon, and the Hub's title bar and About page show it too. On Linux the Hub also puts both apps in your application menu with the logo — it writes launcher entries for itself and for the editor it installs, and refreshes them whenever the binary moves. On Wayland desktops that is the only way a window gets an icon at all, which is why both apps used to show the compositor's placeholder.
A macOS bare binary still shows the generic icon in the Dock; an .app bundle
is a later packaging step.
Upgrading
Update the Hub, then install this engine from it — the editor's icon comes with the editor, so an engine version installed before this keeps the old look until you open a project on this one.
Read the notes
A relay can present a real certificate and pick up its renewal without ending a lobby, a dedicated server keeps its lobby code across a restart, a box reports which version it runs, a game key stays out of every world-readable file, and a server bundle says when it was made and what you called it. Nothing here changes how you write a game; it is the release in which the things you run say what they are.
A relay presents a certificate you gave it
floptle-relay --tls-cert fullchain.pem --tls-key privkey.pem presents the
chain in those files instead of minting a self-signed one at startup. PEM, as
certbot writes them; half a pair is refused at startup, and a pair that will not
load is a relay that does not come up, never one that quietly came up
self-signed instead.
A renewal ends no lobby. The relay watches both files; a renewal written to the same paths — which is what certbot does — is presented to new connections within ten seconds, and every connection already up keeps the certificate it agreed. A renewal that will not load (a torn file, a key for a different certificate) is said once on the relay's log and the previous certificate stays. There is no restart to schedule and nothing for a deploy hook to signal.
floptle-relay-bench --verify is how you check it from outside, with the same
handshake a player's build runs: it prints the SHA-256 fingerprint of the leaf
the relay actually presents, in openssl's spelling, and whether the chain
verifies for the relay's name against the public roots. openssl s_client
cannot do this — it speaks TCP, and a relay answers QUIC on UDP.
A dedicated server keeps its lobby code across a restart
A server that restarts onto a new build and asks the relay for the code its players already know was granted it — and, in the same second, the relay ended the lobby for having had nobody in it for half an hour. The restored lobby had inherited the old process's idle clock, and a server that sits empty waiting for players is exactly what a dedicated server is for. The server then went on advertising six characters that answered nothing, with no error anywhere.
A reclaimed lobby now starts a fresh idle window at the reclaim, whether its host is a dedicated server or a player whose connection blipped. A dedicated server's lobby is never reaped for idleness; a player's reclaimed lobby gets the same window a new one gets.
A box says which version it runs
The fleet agent and the managed relay each report their own version on the reports they already send — the version compiled into the binary, never read from a file or a unit, so an upgrade cannot leave a row saying the old number. The two are different binaries on different machines and are kept apart.
The agent also forwards which key a running server was started with — the first twelve characters, never the key — so a developer mid-rotation can see what the process that is running presented.
A game key stays out of every world-readable file
A dedicated server's unit file is world-readable, as unit files are, and it carried the whole game key. On a box that runs more than one developer's server, that was somebody else's credential at rest for anyone to read.
The key now lives in a file of its own under --keys (default
/etc/floptle/keys), one 0600 root-only file per deployment that the unit
names with EnvironmentFile=; the unit itself carries no secret. A rotated key
changes that file and not the unit, and the agent restarts the server for it
all the same. A deployment that is removed takes its key file with it.
A server bundle says when it was made and what you called it
floptle export … server writes exported_at into the bundle's manifest, and
--label "lobby v3" writes a label. The control plane knows when the bytes
arrived; it could not know when the build was made, and a bundle exported on
Tuesday and uploaded on Friday read as Friday's work — which is how somebody
deploys a build they thought they had replaced. The label is what you call the
build, shown beside its checksum and never instead of it. Bundles exported
before this carry neither and work as they did.
A usage report says where a key's lobbies are hosted from
Each per-key sample a managed relay reports now lists the addresses its live lobbies are hosted from and how many at each. It is the number behind "is my key being used by someone who is not me" — and the first thing the control plane does with it is count and show, not refuse: a CI runner, a LAN party and a whole ISP behind one address all look like "many lobbies from one address" and are honest.
Upgrading
If you run a relay, nothing changes until you pass --tls-cert and
--tls-key. If you run a fleet agent, its first reconcile on the new version
writes each server's key file and rewrites each unit to read it, which restarts
every dedicated server on the box once — pick the moment. If you export server
bundles, --label is optional. Nothing else needs a change.
Read the notes
A script stays inside its project, a loop without an exit stops the script rather than the editor, a game reaches public addresses only, the editor no longer goes down with the terminal it was launched from, and a relay keeps its shape under load. Nothing here changes how you write a game; it draws lines a game could previously cross.
A path is relative to the project, and stays inside it
Every path a script or a scene file names — assets.getFile,
assets.getContents, node.model =, a texture, an audio clip, a scene, a script
name on a node, a terrain save folder — is read relative to the project folder
and resolves inside it. One that would land anywhere else, whether spelled as an
absolute path or climbed to through .., resolves to nothing, and the Console
says which rule refused it, once per distinct reference.
A game runs on a machine that is not yours. What is on that machine's disk is not your game's business, and a scene file is data that may have been written by somebody else.
assets.getContents lists at most 20 000 files and says so when it stops, and it
does not follow a linked folder. A linked package's folder still resolves,
because that is where a linked package is.
A loop without an exit stops the script, not the editor
while true do end in update used to freeze the editor, the player and a
dedicated server alike, forever, with nothing to say why.
A pass into your scripts may now run for 2 seconds — 500 ms on a dedicated
server, adjustable with floptle serve --script-budget-ms. Past that, the script
that overran is stopped with an error naming the budget, and it is not called
again until you edit its file. Everything else keeps running.
The scripts share 512 MB between them. A table that only grows hits "not
enough memory" in its own call, and the rest of the game goes on. A print
inside a loop keeps a thousand lines a frame on the Console and reports the rest
as a count, and a save slot holds at most 10 000 keys and 4 MB.
A game reaches public addresses only
http.* from a script connects to public addresses and nothing else. Loopback,
private networks, mDNS names, .internal names and link-local addresses are
refused, judged on what a name resolves to rather than on how it was spelled, and
the refusal is res.error with the rule named.
The editor's Play is the one exception: hitting http://localhost:3000 while
developing is the ordinary case and keeps working, and the Console says once per
session that an exported game and a dedicated server will not allow it.
Redirects are no longer followed. A 3xx arrives as an ordinary reply with
res.location, and the script decides. A request body is capped at 8 MB like a
reply, and Host, Content-Length, Transfer-Encoding and Connection are the
transport's headers to set.
A script acts as the player, not as the developer
account.* from a game reaches the player's surface — /wallet, /missions,
/games/… and /me/… — and nothing else. The token behind those calls belongs
to whoever is signed in to the Hub on that machine, and the developer endpoints
under /cloud/… (keys, builds, usage) are not a game's to call on a player's
behalf. A call to one is refused at the call site with the rule named.
A project's packages ask before they are trusted
A project that arrives with packages inside it — a folder or a zip whose
packages/ carry manifests asking for Files or Network — loads them with
no permissions the first time it opens, and a banner across the top of the
editor names each package and what it asks for. Trust this project grants
them and remembers that set of packages for that project; Keep them restricted
leaves them loaded with nothing for the session. A changed manifest asks again.
A withheld API is still there: http.get in an untrusted package raises with
the trust rule named, not as a package that forgot its manifest. floptle exec
is not held to any of this — a script you named on your own command line is
your own code.
The editor survives losing its terminal
Every line the engine writes to the terminal now survives the terminal going
away — a closed window, a pipe nobody reads. The Hub launches the editor with its
output in a log file of its own (logs/ under the Hub's data folder, the last
five kept), which is also the file to send along with a bug report.
openUrl parses what it opens
openUrl, ed.openUrl and sys.openUrl parse the address before anything
opens it: http or https, a host, no username or password, and nothing in it a
shell could read as its own syntax. An address that fails is refused at the call
with the reason, on every platform. On Windows the link is handed to the shell's
own open call rather than through cmd.
A relay keeps its shape under load
Every relay, the one you self-host included, now refuses a few shapes: one
lobby per connection, at most 4 096 lobbies and 64 clients in each, ten lobby
opens and thirty joins a minute from one address, half a megabyte a second from
one connection, and a lobby nobody has joined for half an hour ends unless its
host is a dedicated server. A reliable message a client pushes at a host is
capped at 64 KB, a host's at 128 KB — four times the largest prefab any shipped
game spawns. A refusal is a sentence on the wire, and each is counted in the
relay's report. floptle-relay --no-address-limits lifts the two per-address
rates for a load test run from one machine.
A host checks what a client sends on receipt, not only at the sender's queue: an RPC value past the wire's guardrails is dropped, a server-only message from a client is ignored, and both are counted.
A managed relay is verified by name. A relay reached under fopull.com
has to present a certificate for that name; a self-hosted relay or a direct
host:port keeps the dev-trust model, where the lobby code is the secret. Until
the managed relay's certificate is live, a connection that cannot verify it goes
ahead on the older model and says so once in the Console. A later release will
refuse instead.
A managed relay that cannot refresh its key list backs off — one second, doubling to its normal half-minute — and says so once when it starts, at most once a minute after, and once on recovery with the window in it. It kept serving through a nine-hour outage exactly as designed; it also retried fifteen times a second and wrote every attempt to a journal that then lost everything before the storm. That rate is gone.
docs/multiplayer.md now says the honest thing about a game key: a stranger
with it can use up your plan's players, it cannot cost you money, and rotating
it is what rotation is for.
For a server you run
A dedicated server's unit keeps its runtime and state directories to its own user, denies the process link-local addresses, and gives up a handful of capabilities a game server never uses. Its status file names the prefix of the game key rather than the key. A deployment row carrying a control character in any field is refused with the field named, rather than written into a unit file.
One VM
The vm-luajit escape hatch — buildable for one release after Luau became the
default, as ADR-0028 scheduled — is removed. Luau is the only VM the engine
embeds, native and browser alike. scripts/vm.sh still switches the code
generator (luau-codegen) for benchmarking.
Upgrading
If a script of yours reads a file by absolute path or through .., it now reads
nothing and the Console says so — move the file into the project and reference it
relatively. If a script reaches localhost from an exported build, it is refused;
in the editor's Play it still works. If http.get was relying on a redirect being
followed, read res.location and make the second request yourself. If a game
calls account.get("/cloud/…"), it is refused — that is a developer call, and
the portal is where it belongs. If you open a project that carries packages
asking for permissions, you will be asked once. If you run a relay and a load
test from one machine, pass --no-address-limits to the relay. Nothing else
needs a change.
Read the notes
The second candidate, on top of rc1: a game's account calls act as the player, a project's packages ask before they are trusted, a relay keeps its shape under load, and the last LuaJIT build is gone. Still beta — play yours on it.
A script acts as the player, not as the developer
account.* from a game reaches the player's surface — /wallet, /missions,
/games/… and /me/… — and nothing else. The token behind those calls belongs
to whoever is signed in to the Hub on that machine, and the developer endpoints
under /cloud/… (keys, builds, usage) are not a game's to call on a player's
behalf. A call to one is refused at the call site with the rule named.
A project's packages ask before they are trusted
A project that arrives with packages inside it — a folder or a zip whose
packages/ carry manifests asking for Files or Network — loads them with
no permissions the first time it opens, and a banner across the top of the
editor names each package and what it asks for. Trust this project grants
them and remembers that set of packages for that project; Keep them restricted
leaves them loaded with nothing for the session. A changed manifest asks again.
A withheld API is still there: http.get in an untrusted package raises with
the trust rule named, not as a package that forgot its manifest. floptle exec
is not held to any of this — a script you named on your own command line is
your own code.
A relay keeps its shape under load
Every relay, the one you self-host included, now refuses a few shapes: one
lobby per connection, at most 4 096 lobbies and 64 clients in each, ten lobby
opens and thirty joins a minute from one address, half a megabyte a second from
one connection, and a lobby nobody has joined for half an hour ends unless its
host is a dedicated server. A reliable message a client pushes at a host is
capped at 64 KB, a host's at 128 KB — four times the largest prefab any shipped
game spawns. A refusal is a sentence on the wire, and each is counted in the
relay's report. floptle-relay --no-address-limits lifts the two per-address
rates for a load test run from one machine.
A host now checks what a client sends on receipt, not only at the sender's queue: an RPC value past the wire's guardrails is dropped, a server-only message from a client is ignored, and both are counted.
A managed relay is verified by name. A relay reached under fopull.com
has to present a certificate for that name; a self-hosted relay or a direct
host:port keeps the dev-trust model, where the lobby code is the secret. Until
the managed relay's certificate is live, a connection that cannot verify it goes
ahead on the older model and says so once in the Console. A later release will
refuse instead.
docs/multiplayer.md now says the honest thing about a game key: a stranger
with it can use up your plan's players, it cannot cost you money, and rotating
it is what rotation is for.
One VM
The vm-luajit escape hatch — buildable for one release after Luau became the
default, as ADR-0028 scheduled — is removed. Luau is the only VM the engine
embeds, native and browser alike. scripts/vm.sh still switches the code
generator (luau-codegen) for benchmarking.
Upgrading
If a game calls account.get("/cloud/…"), it is refused now — that is a
developer call, and the portal is where it belongs. If you open a project that
carries packages asking for permissions, you will be asked once. If you run a
relay and a load test from one machine, pass --no-address-limits to the relay.
Nothing else needs a change; rc1's notes still apply.
Read the notes
A script stays inside its project, a loop without an exit stops the script rather than the editor, and the editor no longer goes down with the terminal it was launched from. A release candidate on the beta channel: nothing in it changes how you write a game, but it draws lines a game could previously cross, so play yours on it before it goes stable.
A path is relative to the project, and stays inside it
Every path a script or a scene file names — assets.getFile,
assets.getContents, node.model =, a texture, an audio clip, a scene, a script
name on a node, a terrain save folder — is read relative to the project folder
and resolves inside it. One that would land anywhere else, whether spelled as an
absolute path or climbed to through .., resolves to nothing, and the Console
says which rule refused it, once per distinct reference.
A game runs on a machine that is not yours. What is on that machine's disk is not your game's business, and a scene file is data that may have been written by somebody else.
assets.getContents lists at most 20 000 files and says so when it stops, and it
does not follow a linked folder. A linked package's folder still resolves,
because that is where a linked package is.
A loop without an exit stops the script, not the editor
while true do end in update used to freeze the editor, the player and a
dedicated server alike, forever, with nothing to say why.
A pass into your scripts may now run for 2 seconds — 500 ms on a dedicated
server, adjustable with floptle serve --script-budget-ms. Past that, the script
that overran is stopped with an error naming the budget, and it is not called
again until you edit its file. Everything else keeps running, and the Console
still says what happened.
The scripts share 512 MB between them. A table that only grows hits "not
enough memory" in its own call, and the rest of the game goes on. A print
inside a loop keeps a thousand lines a frame on the Console and reports the rest
as a count, and a save slot holds at most 10 000 keys and 4 MB.
A game reaches public addresses only
http.* from a script connects to public addresses and nothing else. Loopback,
private networks, mDNS names, .internal names and link-local addresses are
refused, judged on what a name resolves to rather than on how it was spelled, and
the refusal is res.error with the rule named.
The editor's Play is the one exception: hitting http://localhost:3000 while
developing is the ordinary case and keeps working, and the Console says once per
session that an exported game and a dedicated server will not allow it.
Redirects are no longer followed. A 3xx arrives as an ordinary reply with
res.location, and the script decides. A request body is capped at 8 MB like a
reply, and Host, Content-Length, Transfer-Encoding and Connection are the
transport's headers to set.
The editor survives losing its terminal
Every line the engine writes to the terminal now survives the terminal going
away — a closed window, a pipe nobody reads. The Hub launches the editor with its
output in a log file of its own (logs/ under the Hub's data folder, the last
five kept), which is also the file to send along with a bug report.
openUrl parses what it opens
openUrl, ed.openUrl and sys.openUrl parse the address before anything
opens it: http or https, a host, no username or password, and nothing in it a
shell could read as its own syntax. An address that fails is refused at the call
with the reason, on every platform. On Windows the link is handed to the shell's
own open call rather than through cmd.
For a server you run
A dedicated server's unit keeps its runtime and state directories to its own user, denies the process link-local addresses, and gives up a handful of capabilities a game server never uses. Its status file names the prefix of the game key rather than the key. A deployment row carrying a control character in any field is refused with the field named, rather than written into a unit file.
Upgrading
If a script of yours reads a file by absolute path or through .., it now reads
nothing and the Console says so — move the file into the project and reference it
relatively. If a script reaches localhost from an exported build, it is refused;
in the editor's Play it still works. If http.get was relying on a redirect being
followed, read res.location and make the second request yourself. Nothing else
needs a change.
Read the notes
One small fix for anyone running a dedicated server with a fixed lobby code.
A server that could not keep its lobby code now says so
floptle serve --lobby-code ABCDEF asks the relay to hand back the code your
players already know. When the relay does not agree — the code is reserved for a
different game, is already in use, or the relay is too old to understand the
request — the server was handed a fresh code and carried on without a word. The
players holding the old one could not join, and the only trace was on the
control plane, one report later.
The server now prints a line naming both codes and the relay it asked, the moment it happens. A reclaim that worked stays quiet — a line per successful start is a line nobody reads.
Upgrading
Nothing to do.
Read the notes
One fix, and it is worth taking if you make a fast-paced multiplayer game.
A rollback match sent state nobody was reading
A rollback session had every peer simulating the same nodes locally from the same inputs — and the server was also putting all of those nodes' positions into every snapshot. The receiving side threw all of it away, correctly, because it had already worked them out for itself.
Nothing was broken and nothing desynced. It simply cost an order of magnitude more bandwidth than it needed to, and the cost scaled with how much of your game was rollback-simulated.
Measured on the wire: about 43 bytes per rollback node per frame. At 60 Hz that is roughly
| rollback nodes | saved |
|---|---|
| 2 | 41 kbps per player |
| 8 | 165 kbps per player |
| 16 | 330 kbps per player |
A real two-player match was measured at 234 kbps per player before this. Almost all of it was this.
Nothing to change in your game. Rollback nodes still replicate normally before a match starts, which is what puts a late joiner in the right place, and ordinary networked nodes are untouched.
If you want to see where your own traffic goes, net.traffic() breaks it down by
message kind — a large Snapshot row in a rollback match is the thing to look
for.
Upgrading
Nothing to do.
Read the notes
A host whose connection blips for a moment no longer ends everybody's match, and a lobby code can now survive a server restart. Plus a way to see where your bandwidth is actually going.
Your match survives a moment of bad connection
Two players fought over a relay for ten minutes. Three times, the host's connection dropped for a few seconds — and each time the lobby was destroyed and the other player was quietly cut off. For them, everything in the world vanished except the sky and the HUD, and nothing said why.
A lobby now outlives its host's connection by twenty seconds. The players stay in it, and a host that comes back is put straight back into the match it was already in, with the same people. A rebinding router or a Wi-Fi hiccup costs a stutter instead of the game.
If the host really is gone, the lobby still ends and everybody is told what happened — with a sentence that names it rather than a bare disconnect.
If you host a relay yourself, its log now names lobbies and says why each one ended, instead of printing a count. That count was the reason the problem above went unnoticed for so long: it could not say which lobby died, or what killed it.
A lobby code can outlive the server behind it
floptle serve --lobby-code ABCDEF asks to reclaim a code rather than be handed a
new one. Managed servers get this automatically. It means a server that restarts,
or reconnects to a relay that restarted, comes back on the same six characters
your players already wrote down.
A relay only honours the request when the code genuinely belongs to that game, so asking for somebody else's does nothing but get you an ordinary code.
Joining a server that is still starting up
net.joinState() gained "starting" in the last release. It is now wired
through: a join to a server that is still coming up waits instead of failing, the
engine keeps trying on its own, and the second return carries something you can
put on screen.
local state, why = net.joinState()
if state == "starting" then
find("Status").text = why -- "about 30 seconds"
end
Give up sooner if you want to: net.join(addr, {timeout = 30}). The default is
90 seconds, after which it becomes a normal refusal. This only ever applies to a
server that is waking — an ordinary join is answered immediately, as before.
See where your bandwidth goes
for _, r in ipairs(net.traffic()) do
log(r.kind .. ": " .. r.count .. " msgs, " .. r.bytes .. " bytes")
end
net.traffic() breaks down what your game has sent by message kind, biggest
first, and resets each time you read it — so calling it once a second shows you
the last second.
It is worth a look if you have a fast-paced multiplayer game. A rollback match
should be almost all Input, at a few bytes per player per frame. A large
Snapshot row means full state is going out where inputs would do, which is the
single biggest thing you can fix about a game's bandwidth.
Measuring a relay you run
New alongside the relay: floptle-relay-bench, which drives synthetic players
through a relay and reports what one player costs, where packet loss starts, and
the round-trip time your players would feel. See
relay-bench.md.
Also
floptle servedocuments--lobby-codealongside the flags added last release.- A dedicated server now reports the player cap it is enforcing, so a hosting dashboard can show the real number rather than the one it asked for.
Upgrading
Nothing to do. If you run a region box, take the new fleet agent and relay — the match-survival fix is in the relay.
Read the notes
A joining player can be told that a server is still waking up, rather than being told the game does not exist. Plus two flags that were always there and never written down.
A server that is starting is not a server that is missing
net.joinState() has a new answer: "starting".
Until now a join could only be going ("connecting"), done ("joined"), or
never going to work ("refused"). A dedicated server that has been asked for but
is not up yet is none of those, and the only honest-looking option was a refusal
— which tells somebody holding a perfectly good lobby code that their friend's
game is not there.
local state, why = net.joinState()
if state == "starting" then
find("Status").text = why -- "about 20 seconds"
elseif state == "refused" then
find("Status").text = why -- "no lobby QK7RM"
end
The second return carries words you can show a person, not a status word you have
to translate. Treat "starting" as a waiting state like "connecting" — with the
difference that "connecting" is one round trip to the relay and this one can
take tens of seconds, long enough that a bare spinner reads as a hung game.
Games already published keep working exactly as they do now. A build that has
never heard of "starting" sees "connecting" for the whole wait and draws the
spinner it already draws. Nothing to recompile, nothing to guard.
What is not in this release: nothing sends "starting" yet. Sleeping a server
and waking it on demand is being designed and is not here. This is the half a
game is written against, shipped early so that when the rest arrives your lobby
screen is already right.
Two server flags that were real and undocumented
floptle serve has always accepted --status-file and --max-players. Neither
appeared in floptle help, so the only way to know was to read the source.
--status-file PATHwrites live status as JSON on a timer — players connected, uptime, tick timing, the lobby code, and the port or relay the server is actually reachable on. It is how anything supervising a server learns the lobby code, because the relay hands that to the server and nowhere else.--max-players Nrefuses joins past a number you pick.
--budget was documented as a cap on entities per tick. It is a bandwidth
ceiling in bytes per second, and it only does anything alongside --interest.
If you set it from the old description, check the number.
Floptle Cloud sees a region filling up
Region relays now report their own load, so a busy region is something Floptle Cloud can see coming rather than something it learns about from complaints. A server running on a region also reports what it costs to run, which is what the per-region capacity is worked out from.
None of this changes how you host or what you pay. It is the difference between capacity planned on measurements and capacity planned on estimates.
Servers on a region are also told which deployment they are, so their logs can be matched to the right entry on your game's page.
Upgrading
Nothing to do. If you run a region box, take the new fleet agent and relay.
Read the notes
One fix, finishing the one before it.
Your server's address comes from your server
The last release taught a dedicated server to report where it can actually be reached — the port it is listening on, or the relay it went out through — because a server hosted through a relay listens on no port at all, while the address being published for it said otherwise.
The agent that runs your server on a Floptle Cloud region read that and then threw it away, so the correction reached somebody standing at the machine and never reached your game's page. It carries it now.
Nothing about this is visible in the editor. If you host on Floptle Cloud, it is the difference between a published address that means something and one that was guessed.
Upgrading
Nothing to do. If you run a region box, take the new fleet agent.
Read the notes
Five fixes, four of them found by running a game on a real server for a week and one by measuring a fighting match.
The player who joined a rollback match was getting a worse game
Two people play a rollback match. The host sees a clean game. The person who joined has every single one of the host's actions arrive as a correction — a press that lands as a small pop instead of a motion, on every input, for the whole match, even on a perfect connection. Both machines agree about everything, so nothing anywhere says this is happening. It reads as "rollback feels worse for my friend than for me", which is not something anybody would report.
The two machines never started counting from the same moment. The clock banks real time and spends it in fixed steps, and the person joining arrives at the starting gun having banked their whole scene load — then spends it at once, and runs up to eight steps ahead of the host for the rest of the match. Raising the input delay could not touch it, because the gap was bigger than the delay.
A match now starts when it starts. If you have been tuning input delay to fix a joiner's experience, try it again from scratch.
A server on Floptle Cloud comes back by itself
Restarting a relay destroyed every lobby on it, and the servers hosted through it never noticed. They did not reconnect, they said nothing at all, and they carried on handing out the six-character code they were given when they started — which the site published, your game page showed, and your shipped build was told. Everybody who typed it was turned away, and there was nothing anywhere to explain why.
A server now takes the relay's word for its own lobby code rather than
remembering one. If the relay goes away the code goes with it, the server says
so, and it re-hosts itself and tells you the new code when it is back. Your game
can see this too: net.joinState() reads "reconnecting" while the relay is
unreachable, and net.lobbyCode() is nil rather than a code that does not work.
An empty server is not a player
A dedicated server counted itself as somebody playing. Your game's page said "1 in this game right now" about a server nobody was on, and one of your plan's player slots was spent for as long as it ran — so a game with room for twenty seated nineteen people and told the twentieth it was full.
Shipping a server is one command
floptle export <PROJECT> ~/builds/my-game-server.tar.gz server --scene scenes/lobby.ron
Name the output as an archive and you get the file you upload. Before this you
got a folder and then ran a tar line copied off the website, which is the step
that assumes you have a shell and is easy to get wrong. Naming a folder still
gives you a folder.
A port that was never listened on
A dedicated server given both a port and a relay used the relay and ignored the port, without saying so. It says so now, at startup, and it reports where it can actually be reached, so nothing publishes an address that leads nowhere.
Upgrading
Nothing to do. If you run a region box, take the new server and fleet binaries the way you took the last ones.
Read the notes
A patch for two things found on a live server within an hour of 0.86.0.
Changing a dedicated server's settings reaches the server
Change a deployment's scene, its player cap, its tick rate or its port, and the region would write the new configuration and leave the old server running — with no sign that anything had been ignored. The next time that server happened to restart, minutes or days later, it would silently adopt settings you had changed and forgotten about.
The same gap is why 0.86.0's status-file fix needed a server to be restarted by hand before it did anything. It no longer does.
A game key can reach a server through its environment
A service manager cannot safely put a key on a command line, because every
process on the machine can read one and it is copied into the system log. A
dedicated server now reads FLOPTLE_GAME_KEY from its environment, which is how
Floptle Cloud's own region boxes have been passing it all along. An explicit
--game-key still wins.
Upgrading
Nothing to do. If you run a region box, take the new agent the way you took the last one; it will bring your servers onto the new configuration by itself this time.
Read the notes
This one is about the two moments where a multiplayer game meets other people: the server you left running somewhere, and the friend who could not get in.
Your server says who is playing it
A dedicated server hosted on Floptle Cloud now reports its player count, how long it has been up, how fast it is ticking, and — the one that matters — the six-character code people join with. Before this, a server could run all day while its page showed no players and no code to give anybody, which is a strange thing to see about a game that is working.
The server had nowhere it was allowed to write. It runs locked down, as a user of its own with no access to anything it was not handed, and the file it was asked to write its status into belonged to somebody else. It gets a place of its own now.
Nothing to do. Deployments pick this up when the region is updated.
A bundle arrives with all of its files
If a file in your project was readable only by you — which is what some machines do to every file you create — it arrived on the server unreadable, and the server ran without it. Not an error, no failed deployment: a game quietly missing its input bindings, or four of its scripts, and playing on regardless.
Every file in a server bundle is now readable wherever it lands. The export writes them that way, and a server unpacking a bundle makes sure of it — so this is fixed for bundles you have already uploaded, not only for the next one.
Two more things in the same area:
- A build that cannot start now stops trying. It settles as failed, with the reason, instead of restarting forever.
- Exporting a server bundle from a beta engine is refused, with the reason. Floptle Cloud runs stable engine versions, so a bundle pinned to a beta could never have started. The export says so before it writes anything, rather than leaving you to find out from a deployment that will not come up.
A game that fills up is good news
Two different people see a full game, and until now they got the same sentence — the developer's one, which named the game, the number, the plan and a price page.
The person trying to join is your friend holding a lobby code. They cannot buy anything and it is not their problem, so they now read:
This game is full right now. Try again in a minute.
You hear it once — not once per person turned away — with the number, the
fact that nobody already playing was disconnected, and where to raise it. It
reaches the editor console, a dedicated server's output, and your own game
through net.notice(), so you can put it on your lobby screen for the people it
actually concerns.
Nobody is charged for reaching the ceiling, nothing is throttled, and nobody playing is ever dropped for it.
Your game key lives in the editor
Project settings ⏵ Networked ⏵ Game key is where a Floptle Cloud game key
goes. There is a Game name beside it, a link to manage that game or register
a new one, and no .ron file to edit by hand.
Hosting with relay = "cloud" and no key now says so before anything touches
the network, and names where to get one. It used to look like a connection
problem — which was a poor thing to see on a plane, behind a firewall, or in a
headless test, about a project that was simply never connected. It also says
plainly that hosting on your own machine, or your own relay, needs no key at
all.
If you run your own relay, its periodic line now includes how much traffic it has carried, so the figure you see is one you can check against the machine.
Documentation
- Shipping a server bundle is written down, in export-builds.md: the one command, what is left out of a bundle and why, which scene a server actually boots into, and the two things a game needs before it runs well on a box you do not sit at.
- The multiplayer tutorial ends by letting a friend join from their machine, which is the step that makes it a multiplayer game.
- The command line now describes itself to the website, so the page listing every verb — and whether each one needs a graphics card or writes into your project — is generated from the engine instead of kept in step by hand.
Upgrading
Nothing to do. Open your project and it works.
Read the notes
Your game runs in a browser. It has voice chat, a dedicated server you can put on a box and leave, and every export now ships a real player instead of the editor wearing a disguise.
This is the stable release of everything the 0.85.0 betas carried, plus the last round of fixes found by going looking for them.
Your game, in a tab
File ⏵ Export Game… ⏵ Web (browser) stamps a folder you can upload anywhere that serves static files — itch.io, a bucket, your own site. It is the same project, the same scripts and the same renderer; there is no separate "web version" of your game to maintain.
Everything a game does at runtime works there: saves (they live in the browser and survive a reload), audio, input, pointer lock, and multiplayer through the relay. What a page cannot do, it says so about rather than failing quietly.
Sound now works in a browser. It did not in the betas: the feature flag that keeps a Linux server from needing a sound card was also switching audio off in the tab. A browser build with no sound at all is now a browser build with sound.
Clicking Play no longer shows a black screen while the engine boots. The loading panel stays up until there is something to look at, and if the engine cannot start — an older browser, a machine without the GPU flag — the panel says so instead of leaving you with a black rectangle.
A web build fills its window. It used to letterbox itself into a black border at whatever size the page gave it, with no way out. There is a fullscreen control now, and the canvas follows the window.
A server you can leave running
A dedicated server is a server bundle: your project, with everything nobody on a server can see or hear left out, and a manifest naming the scene to host and the engine version to host it with.
floptle export . builds/server server --scene scenes/lobby.ron
There is no binary in it. The machine runs its own copy of the engine version your bundle pins, so you never cross-compile for somebody else's processor, and a bundle of a 45 MB game is 25 MB.
It refuses, with the reason, at your machine rather than as a deployment that fails on a box you cannot see: a project with no networked nodes, a rollback scene (those are simulated by every player, so a server has nothing to drive), or a scene that is not in the bundle.
A dedicated server is no longer a player in its own lobby. This one was
worth the release on its own. A server answers net.isServer() exactly as a
hosting player does — correctly, because the simulation does not care who
started it — so a lobby script would seat the empty machine as a player, deal it
a character, count it toward "enough players to start", and then wait forever for
it to press Ready. A match on a dedicated server could never begin, and nothing
said why.
if net.isDedicated() then return end -- nobody is sitting at this one
net.isDedicated() is the one thing a game cannot work out for itself. Both
kinds of host are the server; only one of them has a person at it.
Voice chat
Players in a session can talk. A remote speaker is an ordinary sound in your world, so it is positioned in space and goes through your mixer like anything else — a voice across the room sounds like it is across the room.
voice.setForward(true) -- open the microphone
Nothing is required of you beyond turning it on. If a speaker's audio drops for a moment, they are heard again when it comes back rather than going silent for the rest of the session.
Multiplayer you can test without a second person
floptle run . --seconds 10 --ghosts 2
joins that many headless clients to the session your project hosts, and reports
how many replicated nodes each one was actually sent — which is the number
that tells you whether hiding a node from a player worked. floptle run --join
connects to a server that is already running, and now prints why it could not
when it could not, instead of pointing at an empty log.
Two knobs a tint could not reach
A tint multiplies, and a multiply can only take light away. Give a character a team colour on a mid-toned, ambient-lit model and "crimson" arrives as a slightly warm grey — the one thing the tint exists to say, which player is this, is the thing it says worst. The way out was always a Material, which says it perfectly and costs the model every texture it was imported with.
So a tint carries the two knobs that add light:
model:setTint{ rim = teamColor, rimStrength = 1.3 } -- an additive edge
model:setTint{ ambient = 1.6 } -- lift it out of the room's shadow
The rim reads on a dark costume and a bright one, from across a room, which is what actually tells two team-coloured fighters apart in motion. Both are on the ◐ tint rows in the Inspector too, and an animation clip can key either.
Fields you leave out keep their value, so a hit flash that rewrites the
colour every frame does not cost the model its rim. setTint(color) positionally
still means exactly what it always did.
Fixes
A tint set only by alpha turned models black. setTint{ alpha = 0.5 } — the
spelling the documentation gives — was read as a colour rather than as options,
and a colour with no red, green or blue in it is black. It fades, now. Anything
in that table that is not a colour or a number says so by name instead of
silently doing nothing.
Hosting on Floptle Cloud could refuse for a day after a region opened. The list of regions is cached so that going online does not wait on a web request — and a cached list with nothing open in it was kept just as long as a useful one. A region that went live was invisible until the cache expired, and relaunching did not help. A list with no open region in it is no longer worth keeping.
"Your plan" is no longer guessed at. When the account service cannot be
reached, the Hub said you were on the free tier, which is indistinguishable from
actually being on it. It now says it could not check, shows the free tier's
limits, and re-checks next time. Nothing is blocked either way, and a script that
reads a player's tier gets "unknown" rather than a wrong answer.
A second net.host or net.join is refused out loud. It was always
refused — a second session must not tear down the first — but it was refused
silently, so a script that hosted twice got a result it could not tell from
success. floptle run --join quic://… also takes the address in the spelling
its own help prints, which previously produced a lookup failure for a hostname
you never typed.
A creature could walk off a sofa into the room below. Baking a navigation mesh treated the eroded gap beside furniture and doorframes as a ledge, found the floor of the storey underneath, and linked a drop straight through the ceiling — 3,170 of them in one two-storey house. A drop now has to have somewhere to fall through. Real ledges and balconies are unaffected.
A light's Inspector says how many of the sixteen are in use. Sixteen lights reach the shader at once; past that the ones contributing most win and the rest are simply not drawn, which reads as "my seventeenth torch does nothing". The count is on every light now, and it warns when something was cut.
Two dozen messages had holes in them — hover text and errors with a run of blank space in the middle of a sentence. All closed.
Documentation
The documentation site now carries only pages that help you use the engine and are true today. Three that were neither — a north star, a superseded design record, and a work plan — have come off it, and the particle system's page, which described an asset format the engine does not have, is rewritten against the one it does.
Upgrading
Nothing to do. Scenes, projects and scripts from 0.84 open unchanged, and a scene that only ever set a tint colour writes the same bytes it always did.
If you use Floptle Cloud, docs/fleet-agent.md is new and is only for people
running a region's box; nothing about hosting a game changed.
Read the notes
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
sha256 2ff924d86ccf4f6a662894934f86696d23d9d1aba487134a882f3b47fb2ac52e
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.
Read the notes
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-rc5. Your stable install stays where it is.
Everything in 0.85.0-rc4 — your game in a browser, voice chat, and a real player under every export — plus a dedicated server you can leave running on a machine nobody is sitting at, and two multiplayer bugs that broke the path the documentation tells you to take. If you have written any networked code at all, read the next two headings.
net.on at the top of a script never fired
Every example in the multiplayer guide registers handlers at the top of a script, outside any function. That form did not work. The handler was created when the script ran and then deleted a few lines later, by the pass that clears out the previous version's handlers — which matched the one just registered.
Nothing was logged, because from the engine's side nothing failed: a handler was
registered, and a handler was removed. Only net.on written inside start
survived, which is why some projects worked and some did not for no visible
reason.
Handlers are now pruned by position rather than by name, so the ones a fresh run registers are kept. No change to your code.
The first player to join was told the server was offline
A joining peer could be on the roster before the host had finished its first
tick — and net.role() was only updated at the end of a tick. playerJoined
therefore ran while the host still believed it was not the server, and
net.spawn, net.kick, net.setOwner and net.setRelevant all check that
role and quietly refuse.
So the first player to arrive got no avatar and no moderation, and everybody after them was fine. That reads exactly like a flaky connection, and it is not: it was deterministic and it was always the first one. The role is now set the moment the session exists.
A server that runs where nobody is listening
floptle-server is a new binary for hosting a world with no window and no local
player. It is not a second engine: it runs the same tick the editor runs when
you press Play, so everything you already rely on behaves the same — every
net.* command, interest management, the rewind history, join policy, scene
switching, gravity volumes, and scripts that arrived from a package.
floptle-server <project> --scene scenes/mp.ron --port 30000 --max-players 8
--max-playersis a real ceiling, refused at the door. A full server turns away the next arrival; nobody already playing is ever dropped for it.--status-filewrites a small JSON document every five seconds — players, uptime, ticks, tick rate, scene — to a temp file and renames it, so whatever is watching never reads half a document.--relay <addr>hosts through a relay instead of a port, exactly as the editor does.
It also starts on a minimal Linux server image with nothing installed. The
editor binary could not: it links a sound library and a device library at load
time, so on an image without them it failed before reaching main, with an
error about audio, on a machine where nobody was going to hear anything.
floptle-server needs only the C and C++ runtimes every image already has.
A scene made of Rollback nodes is refused with an explanation rather than served badly — a rollback match is simulated by every peer and hosted by one of them, so there is nothing for a dedicated server to drive.
A stopping server says goodbye
Stopping a server used to look identical to a crash from the player's side. Now
Ctrl-C, systemctl stop, or any SIGTERM tells every connected player the
world is going away, gives that message half a second to leave, and exits
cleanly. They see "the server is shutting down" where they used to see a
connection that dropped for no reason.
Restarting a populated server is now an ordinary thing to do.
Under the hood
Groundwork for managed hosting, present but not switched on: a relay can be told which games may host it, lobby codes can carry a region letter, and a project can hold a hosting key that it presents when it hosts. A relay started without those options is byte-for-byte the relay you already run, and there is a test that says so.
Read the notes
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-rc4. Your stable install stays where it is.
Everything in 0.85.0-rc3 — your game in a browser, voice chat, and a real player under every export — plus nine fixes found by going back over the release before promoting it, four of which would have been real problems for somebody. If you are on rc3, update: two of these are silent, and a silent problem is one you would have spent an evening on.
Packages work in an exported game again
A package's scripts did not load in an exported build. If your game had a
package attached to a node, that node did nothing once exported — and nothing
was logged, because from the engine's point of view nothing had failed: the
script name simply resolved nowhere. Assets referenced with pkg:// kept
working the whole time, which is exactly what made this read as "my scripts are
broken" rather than "my packages are missing".
This arrived with the editor/player split, so it affects exported builds from this release series only; the editor was never wrong. Export again and the package's scripts are there.
A voice that goes quiet comes back
If a listener missed a run of speech — they muted and unmuted, they left and rejoined a proximity list, or their connection stalled for a second — the speaker never came back. Not a dropout: silence for the rest of the session, from that one player, with everyone else still audible.
The buffer that puts a speaker's audio back in order was trying to walk forwards to catch up, one frame at a time, at exactly the rate new speech was arriving — so it never arrived. A jump that large is a new run of speech rather than a lost packet, and it is now treated as one. The buffer also shrinks back down after a rough patch, which it could not do before: one hiccup early in a match held the extra delay for the whole match.
Hiding a player actually hides them
net.setRelevant(node, peer, false) withheld nothing unless you had also
turned on interest management. It returned success either way. A hidden-role
game that used it and nothing else was sending the hidden player's position to
every other player, every update, while the call that was supposed to stop that
reported it had worked.
A pin now holds whether or not interest management is on. With interest
unset, every client is still told about everything except what it has been
pinned away from — no radius, no budget, nothing else culled. Turning on a
hidden-role filter was never a decision to opt into distance culling as well.
A browser that cannot run your game says so
If the game stopped after it had started — most often a browser without WebGPU switched on, or a graphics card the browser refuses to use — the page went black and said nothing. The message was being written into the panel the page had already hidden the moment your game started. The panel comes back now, with the reason on it.
Esc gives you your cursor back in a build
A game that captures the mouse for a first-person camera took the pointer with
no way to get it back short of killing the window. Esc releases it and a
click gives it back — the behaviour the editor has always had, and that
export-builds.md has been promising. Alt-tabbing away
releases it too, so a build can no longer reach out of an unfocused window and
take the pointer off whatever you switched to. In a browser the same two
gestures work, and the page no longer asks for the cursor sixty times a second
after you press Esc.
The package catalogue works on a beta engine
📦 Packages showed "nothing for Floptle 0.85.0-rc3 yet" for every package on every shelf. rc3 fixed this for installing a package and missed the two places that decide what the catalogue offers you in the first place. Browsing works on a pre-release engine now, and a version marked incompatible in a package's version list is one that really is.
A mistyped allow list is refused, not ignored
net.host{ allow = "abc123" } -- refused: it is a list, not a string
net.host{ allow = { "abc123" } } -- what was meant
The first form used to be read as an empty list — and an empty allow list
admits everyone. One typo in the option whose whole job is to keep people out
turned it off, silently, and the server came up looking exactly as it should.
Both allow and deny now say so instead.
Your email address is not your player name
net.identity(peer).name fell back to the account's email address when an
account had no display name set — and that name goes to every other player in
the session and into their game scripts. It does not any more. An account with
no display name reports no name, the same as a player with no account at all,
which your game already has to handle.
Also in this release
- A web export into a folder that already held a desktop export cleared out that build's loose asset folder. Serving the folder was publishing the whole previous game beside the page as browsable files.
- A shipped web build no longer sends a request per line of log output to a path no host serves. That was a failed request roughly twice a second, in every player's browser, for the benefit of a test harness that was not listening.
The Hierarchy and Inspector changes that never got a note
These shipped earlier in the 0.85 series without being written up:
- Dragging near the top or bottom of the Hierarchy scrolls it, so you can drag a node to a parent that was off screen without letting go.
- Holding a drag over a folded node opens it, after a moment, so you can drop inside it.
- The whole row is a drop target, and it outlines green for a drop it will take and red for one it will refuse, before you let go.
- The Inspector's name row has a lock. With it on, clicking in the Scene or the Hierarchy leaves the selection alone, so you can look at another node's values — or click around the level — without losing the node you were editing. It releases itself if that node goes away.
Upgrading
Nothing to do. Export your game again to pick up the package fix and the cursor fix — both live in the build, not in your project.
Read the notes
This is a beta release. It is on the beta channel, not stable — the browser export is new and has not been through a real playtest yet. To try it: Floptle Hub ⏵ Settings ⏵ Channel: beta, then install 0.85.0-rc3. Your stable install stays where it is. It moves to stable once it has been played.
Two things your game could not do before this release: run in a browser, and let players talk to each other. It can do both now. A third thing changed underneath: an exported build is finally a player, not the editor wearing a disguise — which is what made the browser possible and made every build on every platform smaller.
Your game, in a browser
File ⏵ Export Game… has a new target: Web (browser). It stamps a folder you serve: a page with a loading bar and a Play button, your project packed into one file the page downloads, and the engine as a WebAssembly module. Put it on any static host, or zip it for itch.io. From a terminal it is one line:
floptle export ~/games/MyGame ~/builds/MyGame-web web --title "My Game"
Nothing about your project changes for it. The same scenes, scripts, physics,
audio and UI run in the tab that run on the desktop — it is the same player and
the same engine, reading its files from the bundle instead of a disk. save.*
writes to the browser's own storage, so a slot survives a reload. Sound works,
after the click browsers require before a page may make one; the Play button is
that click.
Three things are different in a browser, and the build says so rather than pretending otherwise:
- The whole project downloads before the game starts. There is no streaming in this version, so a build's size is a player's wait.
- WebGPU is required — current Chrome, Edge and Safari; Firefox is still rolling it out. A browser without it gets a page that names the problem instead of a black canvas.
- Networking, Steam and
http.*are not available, and each refuses in one sentence rather than hanging.
export-builds.md is the page for using it.
What a large game should expect today
The export ships your project as you authored it — no audio re-encoding, no texture downscaling. On a large game that shows, and the export prints the numbers so you can see where the weight is:
game.flpk is 294.0 MB (1275 asset file(s)), the engine module 18.5 MB
— mostly ogg 183.2 MB, png 61.4 MB, glb 23.3 MB
That is a real finished first-person game, and it is too big for the web as it stands — audio is 62% of it. Re-encoding assets at export time is the next piece of this feature rather than part of it today. Small and medium projects are fine now; plan around this if yours is large.
Voice chat
Players in a session can talk, and a remote speaker is an ordinary voice in your mixer — not a parallel audio path bolted on beside it. That one decision is why everything else works: a speaking player is positioned in the world, falls off with distance, routes through your buses and takes your effects, because that is what every other sound in your game already does.
Which means turning the killer into a monster is:
voice.source(peer):setTrack("Voice Monster")
with no new audio API to learn. Proximity is the default: you hear who is near you. The codec is Opus, in pure Rust, so there is no toolchain for you or your contributors to install.
Two things are not verified and are worth knowing: the end-to-end latency has been added up (~75–85 ms) but never measured with a clap on two machines, and the microphone capture path is exercised through injection rather than real hardware. The no-device path is tested and is what most machines take. The 🌐 Multiplayer window's 🎤 test voice from a WAV… plays a file in as though a remote player were speaking it, through the real forwarding rules and jitter buffer, so you can hear the whole chain on one desk.
Netcode: rigs, secrecy, and moderation
A replicated spawn is a whole rig now. net.spawn sends the subtree, so a
player rig — capsule, camera child, arms mesh, a bone-attached item socket —
crosses as one thing instead of as a node that arrives without its parts.
net.setOwner came with it. Two real bugs fell out on the way: a dedicated
server was reserving a predicted slot for a host that does not exist, leaving
an avatar nobody drove; and floptle serve never despawned what a leaving
player had spawned.
The server decides who is told what. net.setRelevant(node, peer, bool)
hides a node from a peer outright, and net.host{ interestOcclusion = "Level" }
does it by line of sight. A hidden-role game can now be made cheat-resistant, because a
client is no longer sent everyone's position and asked not to look. Losing
sight is damped and regaining it is instant, so cover does not flicker.
Sessions have identity and moderation. net.kick, net.identity, and an
allow/deny check consulted before a join is accepted. One deliberate gap: the
verified flag is false for everyone. Checking a claim needs a credential
scoped to the server you are joining, and shipping a token that is not would
let any server you join spend your Fobucks. The engine reports its own
confidence honestly rather than dressing an assertion up as proof.
An exported build is a player now
Until this release, "export" shipped the editor with a flag set — every panel, the dock, the Inspector, the asset browser and the file pickers compiled into a game that could never open them. Now there are two programs over one engine, and a build gets the one with no authoring half in it.
| before | after | |
|---|---|---|
| Shipped binary | 51.2 MB | 29.9 MB |
| Crates compiled in | 535 | 415 |
Nothing about the game changes: it is the same play loop and the same renderer the editor shows you, which is the point of doing it by compiling the editor out rather than by writing a second player that would drift.
Builds stopped carrying what they can't open
Every build — desktop as well as web — used to copy your whole project folder,
including files nothing loads: the .fbx an import already turned into a
.glb, a bought pack's .uasset leftovers, a .blend, a build script. On the
game above that was 857 files and 45 MB.
They stay out now, and the export says what it left and what it weighed.
Nothing is lost — none of those could be opened at runtime before either. The
.glb an import produced ships exactly as it always did, and .meta files
stay, because those belong to the terrain streamer.
The doors were there all along
The first playtest of a browser build found doors, the NPCs and the player's
own arms missing while medkits, pills and the flashlight drew fine. Nothing
was wrong with the models. The difference was how each was referenced: some
were recorded by their full path on the machine that authored them
(/home/…/Forgery/models/door.glb), and a path like that was taken as
written — which inside a bundle, or on any other machine, names nothing. A
missing model simply doesn't draw, and nothing said why.
Two fixes, so a build never leans on either alone. The export now rewrites a full-path reference to the build's own copy of the file whenever it carries one — including a reference written on another disk or on Windows — and lists each one in its report, rather than only fixing paths that happened to start with the folder it was exported from. And the player rescues one it meets anyway, by taking the longest tail of the path that exists in the project, so a project that moves folders, disks or operating systems keeps drawing. A reference that already resolves is never touched, and one that points at a file the build does not have is still named as a warning at export.
The second playtest found the next layer down: with the doors back, the NPCs,
the entity and every particle effect a script spawns were still absent, and
nothing was logged. Files in a browser have no modification time, and the
cache behind spawn() treated "no time" as "no file" and gave up before
reading the prefab. Every scripted spawn in a web build did nothing. A missing
timestamp is now a timestamp like any other — a bundled file never changes, so
the first parse serves the whole run — and the same rule now covers the two
other caches keyed the same way.
Sound that stays smooth
Audio in a browser fluttered, and once it started it never stopped. The cause was in how sound is handed to a page's speakers. The engine keeps a short run of sound queued ahead of the moment you hear it, and in a browser the mixing happens on the very thread that draws your game — so one long frame, like importing a model or baking a shadow, could drain that queue. The queue was then permanently behind: every piece of sound after it was handed over late, which is what turned a single hiccup into a permanent stutter.
The browser build now schedules its own audio, with the rule the old path was missing: when the queue has run dry, restart it ahead of the clock rather than behind it. A stall costs one gap and is then over. The cushion is bigger too, so an ordinarily busy frame costs nothing at all. On a real game the reserve of queued sound held above 88 ms for half a minute of play, where before it collapsed under 10 ms and stayed there. Desktop builds are unaffected — they have always had a thread of their own for this.
Packages load on a beta engine
Cutting this release on the beta channel turned up a bug it was the first to
be able to find: a package that declares which engine versions it works with
(engine: ">=0.55.0") refused to load on any pre-release build, because
the version rule that stops an unfinished package installing itself into a
project was also being applied to the engine itself. It is not the same
question — the engine is not a candidate being chosen, it is the build in your
hands — so a pre-release engine now counts as the release it leads up to. Your
packages work on beta.
A shader fix that only a browser could show
Shaders using noise or hash compiled everywhere the engine had been tested
and were refused by browsers, over one rule about mixing operators that
desktop drivers do not check. A game with a custom sky or water shader drew its
interface and nothing behind it. Fixed — and the engine now compiles every one
of its example shaders through a real browser as part of its own test run, so
this class of problem is caught before a release rather than by a player
looking at a black screen.
Read the notes
A performance patch, and it is a large one. A scripted node cost the engine about 4.5 KB of memory a frame before your script did anything at all, and a body standing on a mesh floor cost far more than it should have. Both are fixed, and the profiler that should have shown you the first one now does.
On the first-person game these numbers come from, a frame went from 6.4–7.6 ms to 3.5–3.7 ms, and the memory its scripts churn through went from 478 KB a frame to 48 KB. Nothing to do on upgrade — open your project and it is faster.
Your scripts were being blamed for the engine's memory
floptle run --alloc reports how much memory a frame's scripts make. On that
game it said 478 KB a frame, and the biggest single line was a pickup script
running on 34 items. Emptying that script's update completely changed the
figure by 4%.
The memory was the engine's. Every scripted node has a node table that the
engine refreshes before each hook and reads back after it, and the read-back was
going the slow way around: about 96 bytes for every field, ten fields, twice a
pass, three passes a frame, on every scripted node in the scene. A node with an
update that does nothing at all was costing 4,522 bytes a frame.
It now costs 375.
Nothing about how you write a script changes. If you have been watching
--alloc and wondering why the numbers were so much larger than the tables your
code builds, this is why, and the number you see now is much closer to yours:
floptle run --frames 600 --alloc --seed 7
Standing on a mesh floor got cheap
Collision against an imported mesh — a level, a terrain, any model you gave a mesh collider — asks the mesh "how far is this point from your surface". That question was searching a fixed block of 125 cells every time it was asked, however close the answer turned out to be, and a body is asking it dozens of times per tick. On the same game, seven moving bodies against a level's meshes were most of the physics budget.
The search now stops when it can prove nothing else is closer, and never looks outside the mesh's own extent. Same answers, down to which triangle — a replay still replays identically — for about a fifth of the work.
The frame profiler adds up now
The ⏱ panel and perf.ms("scripts") used to time only the hooks themselves.
Everything the engine did to reach a hook — the setup per node, the reference
params, the scene sync — was in no bucket at all, so a heavily scripted frame
could report 5.3 ms of a 12.9 ms step and leave you looking in the wrong place.
That is how the memory above stayed hidden for a release.
scripts is now the whole pass, and the scene sync has a bucket of its own:
perf.enable(true)
print(perf.ms("scripts")) -- the whole pass
print(perf.ms("mirror")) -- the ECS-to-Lua sync inside it, counted once
for _, s in ipairs(perf.scripts()) do
print(s.name, s.ms) -- hook time: part of "scripts", not all of it
end
perf.buckets() gains mirror, and the panel says which of its numbers is
which. On a scene of sixty scripted nodes the buckets now account for 99% of the
step where they accounted for 34%.
If you have an assertion like perf.ms("scripts") < 4 in a script, it is now
measuring more than it used to and may need a larger number. That is the point:
it was measuring a fraction of the pass.
Also in this release
- The two design records the engine's own source comments point at — the
command-line design, and the decision to move to one script VM and a
WebGPU-only browser target — are published at
docs/adr/, so following a citation lands somewhere. - The web export page now marks plainly which of its steps shipped in 0.84.0 and which are still plan.
Upgrading
Nothing to do. No project file changes, no script changes, no settings moved.
Read the notes
The clean-up behind 0.84.0's new Lua and its vector setting. A way to see which script a frame's allocation comes from, a seed so two runs of your game can be compared, three warnings that had gone quiet or vague, a home of its own for the vector setting, and a lint that reads more of your code.
You can see which script allocates
floptle run --alloc gave one number. Now it says where the number comes from:
floptle run --frames 600 --alloc
# scripts allocate 538.2 KB per frame of Lua heap — …
# loot_director 211.4 KB/frame
# enemy_brain 148.9 KB/frame
# hud 61.0 KB/frame
# (12 more scripts) 88.7 KB/frame
# (outside any script hook) 28.2 KB/frame
Largest first, in the names you call them, with what falls outside any script
hook as its own line. Under --json the same list is alloc.by_script, and
alloc.unattributed_bytes_per_frame is the remainder.
This exists because of a measurement. A shipped game with a known collector
problem switched its vector to fast — the one that allocates nothing — and its
per-frame allocation moved by 2%. The vector was never where the garbage came
from, and until now nothing could say what was. The setting's description in
⚙ Project Settings, the scripting guide and the web-export plan all said or
implied that vectors are most of what a vector-heavy game spends a frame on;
they now say to measure first, and how.
Two runs can be the same run
floptle run --frames 600 --timing --seed 7
--seed pins math.random and the no-seed rng() form to one whole number, so
a game that re-randomises its cast or loot every run rolls the same game twice.
Without it, an A/B between two settings is mostly measuring which run got the
bigger fight. Each rng() still gets a stream of its own; the seed pins the
run, not the streams. The JSON report carries seed when one was given.
Three warnings that had gone quiet or vague
- A script with only
fixedUpdateorlateUpdateis warned about a param it never reads. Since 0.84.0 a script pays only for the hooks it has, and the "the scene setsspeed, which the script no longer declares" warning had moved with the hooks — a controller with noupdatewas never told about a stale tunable stored on its node. - A
vec3that cannot cross the wire is refused as avec3.net.*used to say "userdata can't replicate" forexactand "vector can't replicate" forfast. It now says "a vec3 can't replicate as a value — send its x, y, z (or a{x=, y=, z=}table)", in both. - A script that raises every frame no longer re-reads its file every frame to quote the line. The quoted line still follows the file when you edit it.
Script vec3 has a home
⚙ Project Settings has a Scripting section, and Script vec3 lives
there, not under Rendering. Searching the ⚙ tab for vec3, lua or script
lands on it. The Console lines that name the setting point at the new place.
floptle lint --vec3 reads more of your code
if a == b then v = vec3() endbindsv. The scan used to give up on any line whose first=was a comparison, so av.x = 1further down was never reported.enemy.pos.x = 1is a node field, writable in both modes, and is no longer reported just because alocal pos = vec3(…)exists elsewhere in the file.
Also
floptle migratewritesscript_vec3: Some(Exact)intoproject.ronthe way opening the project in the editor does, so a command-line upgrade leaves no default to shift under a shipped game later.- Export no longer counts an HTTP endpoint path such as
"/api/login"in a script as a reference outside the project. - The starter templates are checked under both vectors, not only
exact.
Upgrading
Nothing to do. If you upgraded to 0.84.0 with floptle migrate and never
opened the project in the editor, run floptle migrate once more to write the
vector pin.
Read the notes
The performance release. A game that was slow on an older machine is faster on every machine, fullscreen works in every build whether or not your menu offers it, and the engine runs a new Lua underneath — with a choice of vector to go with it.
Your game spends half the time it did
A report of a Floptle game running at 10–15 fps on a 2017 laptop turned out to have nothing to do with the graphics card. The picture cost under two milliseconds; the rest of the frame was the engine getting ready to run your scripts. A level with sixty scripted nodes and a couple of hundred mesh colliders cost 12.5 ms of CPU per frame before this release. It costs 6.4 ms now, on the same machine, running the same scripts.
Where it went, in one sentence each:
- A script only pays for the hooks it has. A file with just
updatein it was doing the full per-frame setup three times a frame — once forupdate, and twice more for thefixedUpdateandlateUpdateit did not define. paramsis built when it changes, not on every call. A script that never writes into it is never scanned for writes.- The scene mirror your handles read from is refreshed, not rebuilt, when only positions moved between two passes — which, with physics running, is nearly always.
- Mesh colliders have a size now. They used to be offered to every body on every tick, wherever in the level the body was, and the collision index was rebuilt every tick whether or not anything had changed.
Scripts allocate about two and a half times less per frame as a result, so the collector pauses less often as well. Nothing in your project changes.
Fullscreen, in every build
F11 and Alt+Enter toggle fullscreen in every exported game — borderless, on the monitor the window is on, no mode switch — with no code on your part. For a Video tab:
app.setFullscreen(true) -- or false to go back to a window
if app.fullscreen() then … -- the real state, including after F11
Like app.quit(), this is honest about where it is running: in the editor the
window is the editor's, so the call leaves it alone and says so once in the
Console. The setting is for this session; persist the player's choice with
save.*.
A new Lua underneath
Scripts now run on Luau, the Lua dialect Roblox maintains, instead of LuaJIT. Two reasons: it is faster at what this engine actually does — a script here spends its time crossing into the engine to read a position or set one, and Luau is quicker at every crossing — and it is the only one of the two that can ever reach a browser (web-export.md is the plan).
The whole scripting reference, every shipped script and every tutorial were run on both and compared before the switch. What you will notice:
- The 60-upvalue ceiling is gone. A long controller file no longer stops
loading because it declared one more file-scope
local. - A runtime error names the typo.
node.postion.xused to fail as "attempt to index nil with 'x'". It now says which expression was nil and quotes the line it happened on. gotono longer compiles. It was a LuaJIT extension. Rewrite the loop withbreakor a function; the error names the line.bit.*is still there and answers the same numbers.
If something in your game behaves differently and you cannot see why, tell us.
For this one release the engine can still be built from source against LuaJIT
(--features vm-luajit) to compare.
Pick your vector
Luau has a vector type of its own: three 32-bit floats carried inside the
value, costing nothing to make and nothing to collect. Floptle positions are
64-bit on purpose — a solar system needs the range — so which one a vec3 is
made of is now a project setting, Script vec3 in ⚙ Project Settings.
exact |
fast |
|
|---|---|---|
| components | 64-bit | 32-bit |
| can be changed in place | yes | no |
| allocates | one small object per vector | nothing |
| useful out to | the solar system | ~131 000 units from the origin |
Every project that exists today is exact, and stays that way until you
change it. New projects start fast. The methods, operators and constructor
forms are identical in both; the one thing you write differently is changing a
component:
v.x = 0 -- works in exact, raises in fast (naming the fix)
v = v:withX(0) -- works in both, and the better habit either way
floptle lint --vec3 lists every line a project would have to change to
switch, with the fix beside each. In fast, a script that builds a vector
past the precision limit is told so in the Console, once, by name — you will
not have to diagnose the jitter.
Measure it yourself
Three flags answer the question "where does the frame go" without a window:
floptle run --frames 600 --timing # p50 / p95 / p99 / worst step, in real ms
floptle run --frames 600 --alloc # how many KB of Lua heap a frame allocates
floptle shot --timing # what each render pass cost on the GPU
--alloc stops the collector across a window in the middle of the run, which
is the only way allocation can be measured — a per-frame reading of
collectgarbage("count") under-reports by a wide margin, because a collection
inside the frame eats part of what the frame allocated.
Upgrading
Nothing to do. The first time an existing project is opened in the editor, one
line is written into its project.ron — script_vec3: Some(Exact) — recording
the vector it has always had, so no later default can change it underneath a
shipped game. If a script of yours used goto, it will name the line.
Read the notes
An exported game now looks like the game you were just playing in the editor. Models a script puts in a character's hand arrive with it, and the editor's own overlays stay behind where they belong.
Your build contains everything it renders
A script that swaps a model at runtime — node.model = "models/items/medkit.glb",
the ordinary way to make one pickup node serve every item in a game — worked in
the editor and quietly did nothing in an exported build. The node stayed in the
world, kept its collider, kept its interaction prompt, and drew as nothing at
all. The same silence covered mesh colliders, mesh shadow occluders and the
navmesh bake: a wall you could walk through, a shadow that never fell, a level
your agents could not find their way across.
The cause is worth one sentence, because it tells you where else to look.
References like models/items/medkit.glb are relative to your project, and the
editor happens to run from inside your project — so reading one as a plain file
path worked there and only there. A build keeps your project in assets/ beside
the executable and starts wherever the player launched it, so every one of those
reads missed, and missed without saying so.
If a model of yours has ever been mysteriously invisible in a build and fine in the editor, this was why. Re-export and it will be there.
Nothing from the editor rides along
Three pieces of editor furniture were showing up in shipped games, permanently: the red PLAY — edits here are discarded on Stop banner across the top, the Esc — free the mouse hint along the bottom, and the no-active-camera warning. All three belong to the editor's Play/Stop cycle, which a build does not have. They are gone from builds and unchanged in the editor.
The Steam overlay
Games can now open the Shift+Tab overlay themselves, and hear when the player opens or closes it — enough to pause a single-player game while somebody reads their messages.
steam.openOverlay("community") -- also "friends", "players", "settings"…
steam.openInviteDialog(lobby.id) -- the invite panel for a lobby
steam.openOverlayStore() -- your store page, or a DLC's by id
steam.openOverlayUrl("https://…") -- the overlay's own browser
steam.onOverlayChanged(function(active)
paused = active
end)
steam.overlayEnabled() tells you whether the player has the overlay switched
on at all, so a Community button can show a plain link instead of doing nothing.
A misspelt page name is refused with the valid names in the message, in any
session — you do not need Steam running to find that typo.
Make it multiplayer
A new follow-along project takes the platformer from the first tutorial and puts two machines in it: what has to move to the host, what each player owns, and what to do about the half-second the network costs you. It is in the Learn tab beside the others, and in the online handbook.
Upgrading
Nothing to do. If you have shipped a build with a missing model, a missing shadow or an editor banner in it, re-export it — no project change is needed.
Read the notes
Your main menu's Quit button works. So does a Video tab: frame pacing and internal resolution are things a script can read and change now, which they were not.
app.quit(), and the rest of a settings screen
Every game has a menu with Quit on it, and until now there was no way to
wire it — the button was either drawn dead or not drawn at all. There is a new
app table:
app.quit() |
end the game |
app.title() / app.version() |
for the top of a menu, and an about box |
app.vsync() / app.setVsync(mode) |
"On", "Adaptive" or "Off" |
app.retro() / app.setRetro(on) |
the retro presentation |
app.retroHeight() / app.setRetroHeight(px) |
the height it composites at |
app.retroIntegerScale() / app.setRetroIntegerScale(on) |
whole-number upscale, letterboxed |
What quit does depends on where the game is running, and they are honestly
different things. An exported build closes, with your save.* data flushed
first — somebody quitting from a settings menu expects the setting they just
changed to have been kept. The editor stops Play instead and says so, because an
editor that closed because a game under test called quit would take your
unsaved work with it.
The rest of an options menu was already here and is documented together for the
first time: audio through audio.track(name):setVolume(db), accessibility
through access.*, the picture through the Post Processing node's own
component, and rebindable controls through input.startRebind.
function start(node)
app.setVsync(save.get("vsync") or "On")
app.setRetroHeight(save.get("pixelHeight") or app.retroHeight())
end
A setting you change this way lasts for the session. These live in
project.ron, which is the file that ships to everybody who plays, so pressing
Stop puts it back — persist the player's choice yourself with save.*, the same
way access.* works. And a mode setVsync does not recognise is an error
rather than a shrug: a control that silently keeps the old value is a control
that appears to work.
Not here yet: window resolution, fullscreen and monitor choice. Those are one
question and it deserves answering properly. In a pixel-art game
app.setRetroHeight is the "resolution" a player means, and it was already a
live setting.
A broken project.ron says so
A project.ron the engine could not parse was quietly replaced with defaults.
One misplaced bracket and the project opened with no title, the wrong frame
pacing, none of its layers and none of its mixer — looking exactly like a
project nobody had configured, with nothing anywhere saying the file had even
been read.
It now names the file, the line and column, and says the project has opened with
default settings and that nothing has overwritten it. A project with no
project.ron at all stays quiet; that is a real state, not a fault.
The profiler stops blaming the wrong script
perf.scripts() times each script with a wall clock, so anything that happened
while your script was on the CPU — a garbage collection, the operating system
taking the core away — was reported as that script's cost. Scripts with no
update at all were being listed with 12–21 ms peaks, which sends you to
optimise a file that did nothing.
A script with no hook for a pass is no longer listed. A stall can still land on
one that is running — that is what sampling a wall clock means — so
docs/scripting.md now says which number to act on and what makes a worstMs
spike.
While looking at that: if the collector is what you are chasing, the biggest
source of it in most games is vectors. Every vec3 a script builds is an
object, so a + b, v:normalized() and node.pos each allocate, while scalar
reads like node.x and v:length() allocate nothing at all. A hot loop written
in components rather than vectors produces no garbage, and that is usually the
largest win available to a game script today.
Upgrading
Nothing to do.
Read the notes
Your bake is there when you open the project. A query says what it hit and what that thing is made of. And you can look at a particle effect without opening the editor.
Your navmesh survives being closed
Bake a Nav Mesh, save, close the editor, open the project again — and the bake was gone. Not lost: the file had been sitting beside the scene the whole time. It was never loaded, and nothing said so. An unloaded navmesh looks exactly like one you never baked, so the only thing to do was press Bake again, every session.
Light Probes had it too. A scene came back with no bounce light for the same reason, with the same silence.
Opening a project through File ▸ Open Project always worked. Launching the
editor on a project — from the Hub, or from the command line, which is how a
project actually gets opened — did not. Both load the bakes now, and so does a
scene a script switches to with scene.load while the game is running: a game
that changed levels had been pathing on the previous level's navmesh.
A bake written by an older version of Floptle is remade once, on its own, the first time you open the level, and the Console says so when it happens.
What am I standing on?
A footstep, an impact decal, a splash — each of them needs to know what the ground is made of, and the engine could only tell you which node you hit. One building is one node, and its brick, its grass and its floorboards are three materials on it, so a whole mansion answered "brick".
Every query hit now carries material: the name of the material slot it landed
on, exactly as you typed it and as the Inspector shows it.
local hit = spherecast(node.pos, vec3(0, -1, 0), 0.14, 1.2, { layers = "Level" })
if hit and hit.material == "Boards" then
audio.play("audio/footstep_wood.ogg", node)
end
raycast, spherecast, capsulecast and overlapSphere all answer it. It is
nil for anything with one surface — terrain, a Collidable cube, an imported
model, another character — because a name invented for those would be right for
map meshes and quietly wrong everywhere else. Use hit.node:material() there.
Reading it costs a lookup; not reading it costs nothing. A line-of-sight ray
that only wants hit.distance pays for none of this.
raycast also names the node it hit for level geometry now. It used to
answer nil for anything that was not a physics body, while spherecast — same
fields, same documentation — named it. If you tried a ground check with
raycast, got nothing, and concluded the engine could not tell you, it can.
Look at a particle effect without opening the editor
floptle vfx --effect Sparks
One effect, rendered to PNGs across its own timeline, with no window:
Sparks: visible over 0.141s, 5 frame(s) at 480x480
t= 0.028s 1.5% of frame Sparks.t00028ms.png
t= 0.056s 1.9% of frame Sparks.t00056ms.png
t= 0.084s 2.0% of frame Sparks.t00084ms.png
t= 0.113s 1.9% of frame Sparks.t00113ms.png
t= 0.141s 1.4% of frame Sparks.t00141ms.png
all of them: Sparks.sheet.png
A single frame cannot show an effect. A burst is a blank frame before it fires
and drifting smoke after it, and both are correct — so this takes a spread of
moments and tiles them into one contact sheet. The camera is identical in every
frame, so the start really can be compared against the middle. --at 0.084
renders exactly that moment, framed close.
Which moments are worth photographing is decided by looking: the effect is rendered at thumbnail size across its whole timeline first, and the frames are spread over the part where something is actually on screen. A quarter-second burst gets five frames of that quarter second rather than four frames of the empty second after it.
--scene renders it inside a level, at the node that plays it and through that
scene's camera; --background sets the colour behind it otherwise. Each frame
reports how much of the picture the effect covers, measured from the picture
itself — so a frame with nothing in it says so, instead of leaving you to wonder
whether the effect or the renderer was at fault.
Upgrading
Nothing to do.
Read the notes
Characters stop treating every ledge as a wall. The navmesh now works out where they can drop down and jump across, on its own, from the shape of your level.
A navmesh is a surface, and a surface stops at every edge. Until now the only way to say "and you can get down here" was to place a Nav Link by hand — one per ledge, in a level with hundreds, all of them moving the moment you moved the geometry. So nobody placed them, and characters walked the long way round a knee-high step or gave up entirely.
Drops and jumps, found for you
The Nav Mesh node has two new numbers under how the ledges are joined up:
- drop height — the tallest ledge a character steps off deliberately. Any ledge between the step height and this gets a one-way link off the edge.
- jump distance — the widest gap it will clear. That gets a two-way link, because a gap you can jump you can jump coming back.
Both are on by default, at a metre and a half and two metres. Set either to 0 to switch that kind off and go back to placing every crossing by hand.
On a real 23,000 m² level, this found 651 drops and 55 jumps where there had been none.
The bake will not invent a character that climbs: ground above a ledge is still a wall, and a ladder is still a Nav Link you place. It also will not hop over a wall, and it will not cross ground you carved out with a Nav Area — carving is you saying nothing may walk there, and a tool that argued with that would be worse than no tool.
A character that does not leave the floor refuses a whole kind of crossing in the question rather than by needing its own bake:
local cart = nav.agent(node, { filter = { canJump = false, canDrop = false } })
While an agent is crossing, agent.linkKind is "drop", "jump" or
"placed" — so one rule picks the fall animation, the vault and the ladder
climb, rather than six hundred link names.
The overlay answers the question it is for
The one thing a navmesh picture is for is are these two pieces of ground joined? — and it was coloured by how the bake grouped the floor, before any link was counted. A balcony and the floor its drop lands on came out two colours while a character walked freely between them.
It is coloured by what a character can actually reach now. Ground you can get around is one colour; ground you cannot is another. Links are drawn as arcs whose shape is the crossing: a drop leaves the ledge flat and falls away, a jump bows over its gap, a ladder goes straight. Amber for a drop, green for a jump, blue for one you placed, red for one whose end missed the floor.
Agents follow the same curve they are drawn on, so a character dropping off a ledge falls instead of sliding down an invisible ramp.
"N separate areas" in the Inspector counts the same thing — pieces with no way between them at all — and now says how much of the walkable ground the biggest piece holds. A count on its own could be a level with 150 cupboards in it or a bake that has shattered; the share is what tells them apart.
Specks are thrown away
A real level bakes hundreds of scraps of walkable ground — a window sill, the top of a crate, a triangle of floor behind a pillar. Nothing can reach any of them, every one is an island in the count, and each is somewhere a character can be dropped by mistake when you ask for the nearest point on the navmesh.
smallest patch (1 m² by default) throws them away. The biggest piece is never thrown away, however small it is, so a bake of a small level is still a bake of that level. The level above went from 494 separate pieces to 156.
The overlay stopped getting stuck
Turning gizmos off used to leave the navmesh drawn on screen, frozen in place, until you deselected the node. It clears with everything else now.
Big levels also draw far less: anything off screen is skipped rather than sent to the graphics card every frame, so looking closely at a navmesh no longer costs more than looking at all of it.
Settings that quietly did nothing now say so
Two of the new numbers have a threshold below which they are exactly equivalent to zero, and neither is visible from the slider. A drop height inside the step height finds nothing, because the lip is walked over. A jump distance under twice the agent radius finds nothing, because erosion has already pulled the walkable surface back by the radius at each side — jump distance is measured between the edges of the navmesh, which is what you see in the overlay, not across the hole in the floor.
Both are named in the Inspector and the Console, with the number to beat.
Baking a navmesh without opening the editor
floptle bake nav [PROJECT] [--scene S] [--json]
No window and no graphics card needed. It was the one bake with no way to run it from a script, which meant a level whose geometry is generated could never have its navmesh generated too.
Upgrading
Your navmesh is baked again, once, the first time you open each scene. The format changed to carry what kind of crossing each link is. The editor does it for you and says so; nothing is lost.
Two defaults change what an existing level bakes, and both are sliders on the Nav Mesh node if you want the old behaviour:
- Drops and jumps are on. Routes that used to go the long way round a ledge now take it. Set drop height and jump distance to 0 for none.
- Patches under 1 m² are dropped. If your game deliberately puts characters on something smaller than that, set smallest patch to 0.
Read the notes
When a script asks for something that isn't there, the engine says so.
Nearly every hour lost to Floptle scripting has had the same shape: a call
answers nil, nothing is reported, and the symptom turns up three scripts
away as a value that was never set. This release goes through the calls that
do that most and gives each of them a voice — and fixes the two lookups that
were answering nil when they should have found something.
Scripts in folders answer to their own name
If your script lives at scripts/forgery/playermovement.lua, this now works:
local movement = findTagged("Player")[1]:getscript("playermovement")
Before, only the full "forgery/playermovement" found it. The short name —
the one on the editor tab, in the Inspector row, and in front of every line
that script prints to the Console — matched nothing and returned nil
without a word. Both spellings work now, everywhere a script is named by
name: getscript, findScript, findScripts, and scriptref params.
If two folders hold a file with the same name, the short name is refused and both are named, rather than one being picked for you.
The node calls are spelled like the rest of the API
node:getChild, node:getParent, node:getScript, node:getComponent and
node:getSorting — camelCase, like hasTag, lookAt, distanceTo and
every other node call. These five were the only lowercase ones, and typing
the spelling the rest of the API taught you died with "attempt to call a nil
value". The lowercase spellings still work and always will; nothing in your
project needs changing.
Mistype any other node call and it now tells you the one you meant:
a node has no `HasTag` — did you mean `hasTag`?
Lists start at 1, and say so
findTagged("enemy")[0] -- this list is 1-based, so [0] is never one of its
-- elements — the first is [1] and the last is
-- [#list]. It holds 3.
[0] used to hand back nil, which then failed on the next line as
"attempt to index a nil value" — pointing at the wrong place and saying
nothing about the cause. Asking past the end still reads nil, so
if findTagged("boss")[1] then is still how you check whether there are any.
Lookups that find nothing name what IS there
Hero:getscript("helth") found nothing — that node carries health, inventory.
Hero:getcomponent("rigidbody") found nothing — that node carries RigidBody,
Material. (did you mean `RigidBody`?)
findScript("spawner") found nothing — no node in this scene carries a script
by that name. This scene runs: enemy, hud, player_controller.
Each is said once, however many frames ask. A script that is attached but switched off now says that too, instead of reading as a script with no such function — two problems that looked identical and want opposite fixes.
Writing a field a node doesn't have, on a handle from find or getChild,
says so as well: those handles are rebuilt each time you ask for one, so the
value was going nowhere.
Console and terminal
log(...) and print(...) were each printing to the terminal twice, which
reads as your code having run twice. Once now. A dedicated server prints
them at all now — it was the one place a script could talk and nobody was
listening, and its warnings were going the same way.
Items on a bone stay where you put them
A node attached to a bone was placed correctly only while it was also a child of that model in the Hierarchy — which is how the editor sets it up, and nothing held it afterwards. Move it anywhere else in the tree and it still tracked the bone's motion perfectly while sitting in the wrong place. It is placed by world position now, so it lands on the bone wherever you file it.
Selecting a part opens its material
Click a part of a model — in the viewport or in ◈ Objects & Rig — and that part's material editor opens while the others close. A model with a dozen parts no longer means scrolling past eleven open editors to reach the one you just clicked. Open a second to compare, or close the one you're on, and it stays that way until you select something else.
Upgrading
Nothing to do. Every older spelling still works.
Read the notes
A script can now see who's on Steam with them, and let them know what you're up to.
Friends and presence
for _, friend in ipairs(steam.friends()) do
if friend.playingThisGame then
print(friend.name, "is also playing —", friend.state)
end
end
steam.setRichPresence("status", "Boss fight, 2 players")
steam.friends() returns the local user's friend list — each entry's id,
name, online state ("online", "away", "busy", "snooze", "looking to trade", "looking to play", "invisible", or "offline"), and whether
they're currently playing this same game. steam.setRichPresence(key, value) sets a status line friends see on your name in their own friend
list; steam.clearRichPresence() clears it, and steam.friendRichPresence
reads a specific value a friend's own game has set for them.
Not in this release: group and clan membership — Valve's own current toolkit for this engine doesn't support it. Inviting a friend, and having a game react to being launched from an invite, are their own upcoming pieces — this release is about seeing and being seen, not joining yet.
Upgrading
Nothing to do.
Read the notes
Achievements, stats and Cloud saves reach Lua, and a game can ask whether it's running on Steam Deck or in Big Picture mode.
Achievements and stats
if steam.statsReady() then
local ok, err = steam.unlockAchievement("FIRST_WIN")
steam.setStatInt("wins", steam.statInt("wins") + 1)
end
steam.unlockAchievement/clearAchievement/achievementUnlocked cover
unlock/clear/query; steam.achievementGlobalPercent is the share of players
worldwide who've earned one; steam.achievementName/achievementDescription
read Steam's own localized text for it. steam.statInt/setStatInt and
statFloat/setStatFloat cover int/float stats. Every write goes into a
local queue and reaches Steam's servers on an automatic batch every few
seconds — call steam.flushStats() if you want it sent right now instead —
and a failed send is never silently lost; it stays queued and the next batch
tries again. Every write also answers ok, err rather than just failing
quietly, and err names the actual problem — a mistyped achievement ID says
so, rather than leaving you to guess. steam.resetAllStats(achievementsToo)
wipes everything for testing.
Not in this release: average-rate stats and achievement-progress notifications aren't wired up yet — Valve's own toolkit for this engine doesn't currently support either, and closing that gap is its own piece of work. Achievement icons are in the same boat as avatars (see the last release): nothing in the engine can turn raw image bytes into something a script can put on screen at runtime yet.
Cloud saves
local ok, err = steam.cloudWrite("save1.dat", data)
local data, err = steam.cloudRead("save1.dat")
steam.cloudWrite/cloudRead/cloudDelete cover the basics; cloudFiles()
lists everything currently in Cloud storage, cloudFileExists and
cloudFileTimestamp let you check before you read, and cloudForget removes
a save from the Cloud while keeping the local copy — for a player who wants
one save to stop syncing without losing it. steam.cloudEnabled() and
setCloudEnabled toggle Cloud for this game specifically; the account-wide
setting is read-only here, same as it is anywhere else — that one belongs to
the player, not the game. There's no built-in notion of a save "conflict" to
resolve for you: cloudFileTimestamp is there so you can compare it against
your own local save's modification time and decide what "newer" means for
your own game.
Not in this release: Steam's storage-quota numbers aren't exposed yet.
Knowing where you're running
steam.uiLanguage() reads Steam's own current UI language — a reasonable
default for your own localization. steam.isSteamDeck() and
steam.isBigPictureMode() tell you when to assume no keyboard and mouse, or
to lean into a bigger, couch-friendly layout.
Upgrading
Nothing to do.
Read the notes
The first slice of Steam support: a script can check whether the game is actually running through Steam and, when it is, who's signed in. Posing a rigged character also stopped burying the model in bone lines.
Scripts can check whether they're running on Steam
Set a Steam App ID in ⚙ Settings ▸ Game, and floptle run --steam, a
floptle play, or an exported/served build will try to talk to Steam.
steam.available() is always safe to call — it's false, never an error,
in the editor's own Play-mode preview, on a machine with no Steam client
running, or on a project with no App ID set at all. Once it's true:
if steam.available() then
print(steam.personaName(), "is playing, id", steam.localUserId())
end
steam.onPersonaChanged(function()
-- the signed-in user's name changed since the last frame
end)
steam.buildId(), steam.installDir(), steam.betaName(),
steam.isFamilyShared() and steam.isCybercafe() round out what a game
can ask about its own build and license. No App ID set yet? floptle run --steam still works — it falls back to Valve's public test app (Spacewar),
so trying this out never needs a partner account.
This is the foundation, not the whole of Steam support — achievements, the overlay, cloud saves, friends, Workshop and the rest are still ahead. Avatars aren't drawable yet either; the engine has no way yet to turn raw image bytes into something a script can put on screen at runtime, Steam or not.
Posing a bone no longer buries the model in lines
Selecting the Armature row stopped drawing a fan of clickable lines across the whole model back in the last release — but selecting an ordinary bone under it, which is what posing a character actually means doing, still did. Every bone now draws and clicks cleanly on its own, whatever you've selected.
Upgrading
Nothing to do. A project with no Steam App ID set behaves exactly as before.
Read the notes
A freshly placed Shader Graph node can be wired the moment it appears, a selected Armature stops burying the model in bone lines while you're animating, and the Gizmos menu can turn everything off in one click.
A new "combine" node in the Shader Graph can be wired right away
Dropping a combine node in from the node picker used to show a single
locked-looking value with no way to reach its individual lanes — the only
way to build a vec4 from separate values was to hand-type the shader as
text. Each lane (x/y/z/w) is its own wireable input from the moment the node
appears, and the quick number / vec2 / vec3 / vec4 / color type switch is
still there for as long as every lane is left at its default.
Selecting the Armature no longer buries the model in bone lines
Clicking a rig's Armature row — rather than one of its actual bones — drew every bone in the skeleton as a solid fan reaching out from a single point, and those lines could be clicked and dragged like the bones underneath them, making it hard to select the bone you actually meant to pose. The Armature now draws faint and dashed for context only; clicking those lines does nothing, and the Armature itself is only reselected by clicking its own handle at its actual position.
An "All off" button in the Gizmos menu
The Scene view's Gizmos menu (the ⏷ next to Gizmos) had a button to turn every gizmo category on at once, but none to turn them all off. It does now.
Upgrading
Nothing to do.
Read the notes
A part of a model can finally wear its own shader, two Inspector panels stay fully on screen instead of running off the edge, and the shader language picked up the math vocabulary other engines already have.
A part's own shader actually draws now
Giving one part of a model its own material — the override button next to a part in ◑ Model materials, or ◑ Override material for this object in the Inspector — let you pick a custom shader for it, but the shader never took effect: the part kept its plain material look no matter what was set, and the shader panel got stuck reading "compiling…" forever. A part's own shader now compiles and draws correctly, independently of whatever shader (if any) the rest of the model is wearing — so one arm can be glass while the rest of the character stays ordinary.
Sliders stay inside the panel
A slider with a caption — bounce, friction, radius, and dozens more across the Inspector — could draw its label past the edge of a narrow panel instead of shrinking to fit, most visible in the Rigidbody component's stack of sliders. Captions now shrink to an ellipsis before they'd run off the side, the same way every other label in the Inspector already does.
New shader math: lerp, remap, rotate, refract
The shader graph and the .flsl language gained five functions: lerp
(the same blend mix already did, under the name most people reach for
first), inverseLerp and remap for turning one range of values into
another — health into a fade, distance into brightness — rotate2D for
spinning a UV or a point without hand-building the matrix, and refract
alongside the existing reflect for bending a ray through a surface.
Upgrading
Nothing to do.
Read the notes
Four fixes to the tools you use to check what's actually happening in your project — a screenshot, a performance count, a light budget, a frame rate — all told the truth a little less often than they should have.
Screenshots show what's actually there
floptle shot, the command-line tool that renders a picture of a scene
without opening the editor, skipped a level's map-tool geometry entirely — a
level built with walls, floors and ceilings photographed as an empty room
with only its props and characters floating in it. Screenshots now match
what you'd see playing the level.
The editor tells you when a scene has too many lights
A scene can only light with sixteen point lights at once — always true, and now said out loud. Past sixteen, the ones actually contributing light near the camera win the remaining slots and the rest cost you placement time for nothing. The editor now warns you, once per scene and naming the count, instead of quietly picking sixteen and staying silent about the rest.
perf counts you can trust
The perf scripting API — for a project to check its own frame cost from
Lua — could answer 0 for draw calls and lights even while the game was
running normally, because part of the count simply wasn't being kept for
every view. Those numbers now reflect what's actually on screen, in the
editor and from the command line alike.
The editor knows when your display is the slow part
On some machines, a scene that costs almost nothing to render can still show a low, flat frame rate because of how the display is presenting frames — not because anything in your project is slow. The performance panel and the console can now tell the two apart and say which one it is, along with the setting to try (Project Settings ⏵ Rendering ⏵ Frame pacing).
Upgrading
Nothing to do.
Read the notes
A room full of settled props stops costing what a room full of falling ones does, a streamed level actually finishes its first navmesh bake, water reacts the moment it exists, and packages get a read-only look at tilemaps and a warning before you install something.
A room full of resting props costs almost nothing
Two hundred ordinary Dynamic props — mugs, chairs, litter — sitting completely still could cost the entire frame budget several times over, because nothing in the physics solver ever stopped paying for them. A prop mid-flight and a prop that has sat untouched since the level loaded were the same amount of work.
A body that has been slow enough for long enough now goes to sleep: no gravity, no collision solving, nothing, until something actually touches it — a moving platform, a grabbed prop being let go, a script writing its velocity, or the ground underneath it disappearing. A settled room of props now costs close to what the same props cost as static geometry, not what a room of things actively falling does. Nothing changes in how you set a body up; sleeping is automatic and invisible from a script's point of view.
A streamed level finishes its first navmesh bake
On a level that streams its geometry in as you move rather than shipping it all baked ahead of time, automatic navmesh rebaking could get stuck re-measuring the whole scene on nearly every tick — and, separately, a level with no hand-placed geometry to bake at editor time had no way to get a navmesh at all until something forced one.
Automatic rebaking now waits for the level to actually hold still before it re-measures anything, however long that takes, and the very first bake on a level with nothing baked yet builds a mesh instead of refusing. Dragging a wall and waiting now genuinely produces a fresh navmesh; it isn't a special case any more.
Water reacts the moment it's there
Spawning, moving, resizing, or destroying a water volume while a game is running could take a frame or more to reach the physics solver, so a pool that appeared mid-session could be there to look at and not there to swim in — or the other way around.
Water is rebuilt every frame now, the same way gravity already was, so what you see and what the solver knows about agree from the moment a volume exists. A water volume can also carry a Material of its own — its own specular highlight and its own alpha — and can be exempted from a project's retro dithering the same way any other material can.
Packages can read a level's tilemap
An editor package could see everything else about a 2D level except its
tilemap, which meant a level tool couldn't tell where the floor actually was.
tilemap.of(id) hands back a read-only handle — grid size, tile size,
tileset, and per-cell lookups by index or by world position, including
solidity and tags — documented alongside the rest of the package API.
The package browser warns you before you install
The catalogue now carries what a package's manifest declares it needs — network access, file access outside its own folder, opening your browser — and the Browse tab shows it before you install, not only after, when the consent dialog would otherwise be the first you hear of it.
Smaller things
- A script calling
find("Lighting")used to always get back nothing; the Lighting node has a Transform like every other node now, andfindsees it.
Upgrading
Nothing to do. A project with resting props, a streamed level, or water volumes just runs better; existing scripts and packages are unaffected.
Read the notes
The editor stops under-reporting and under-doing. Your navmesh and your baked lighting are still there when you reopen the project, the frame counter reports a number you can act on, mouse look is smooth at high frame rates, and setting a collision layer applies to everything you selected rather than to one node.
Your bakes are still baked when you come back
Bake a navmesh, save, close the editor, open the project again — and the navmesh was gone. Baking a level felt like something you had to redo every session, because it was.
The bake was on disk the whole time, beside the scene, exactly as intended. Opening a scene loaded it; opening a project — which is what happens when the editor starts, and when you switch projects — did not ask for it. The same was true of baked lighting, so a level opened at startup came up with no bounce light either.
Both load with the project now. Nothing to do on upgrade: open your project and the bakes you already made come back. If you have been rebaking every session, you can stop.
The frame counter tells you the truth
A scene that plays like a slideshow could report hundreds of frames a second, and the number was not a rounding error — on a real capture, a genuine 21 frames a second read as 481.
The counter averaged the wrong thing. It smoothed frames-per-second, and the average of a rate is not the rate of an average: it gets dragged toward the fast frames, so the choppier the scene, the more flattering the number. It now measures how long frames take and converts at the end.
The title bar also carries a 1% low beside the average — how long your worst frames take. That is the number that matches what a stutter feels like, and it is the one an average cannot show: a 40 ms hitch once a second adds well under a millisecond to a 60-frame mean.
Finding out why a fast scene feels slow
The ⏱ button opens ⏱ Frame cost, which now opens with a frames arriving line: the average, the 1% low, and what your display is actually doing.
The engine smooths out the small timing noise between frames, because that noise is what turns steady movement into shimmer. Doing that requires knowing your monitor's refresh rate — and on some setups it never got one, so the smoothing was switched off for the whole session with nothing to say so. That is fixed, and when the smoothing genuinely cannot apply, the panel now says so and shows both numbers: what the display reports, and how fast frames are really arriving. Usually it means the window is on a different monitor than the one being reported, or nothing is pacing to the display at all.
The panel also flags it when your worst frames take more than twice the average, which is a stutter whatever the frame rate says.
Mouse look is smooth above 240 fps
The camera got jittery at exactly the frame rates where it should have been smoothest. A mouse axis reports movement per second so that a script multiplying by frame time cancels it back out and a given hand movement turns you the same amount at any frame rate — but that cancellation stopped being exact past 240 frames a second, and uneven frame times went straight into your camera.
A fixed mouse movement now turns you the same amount at 60 fps and at 600, and a single long frame still cannot fling the view. Nothing to change in your scripts.
Setting a collision layer applies to everything you selected
Select twenty crates, pick a collision layer, and one crate moved — even though the Inspector says an edit there applies to all of them.
It applies to all of them now, as one undo step. When the selected nodes do not agree on a layer the dropdown reads — mixed (5) — instead of claiming they all have the primary's; picking a layer from there puts the whole selection on it, including the layer the primary already had.
Changing a layer asks about the children
A collider usually hangs under the node you just changed, so moving only the parent leaves the pieces that actually collide on the old layer — a node that reads as changed and behaves exactly as it did.
Changing the collision layer on a node with children now asks whether the children come too, with the counts in the buttons, and changes nothing until you answer. Nodes with no children are not interrupted.
Upgrading
Nothing to do. Open your project once and your existing navmesh and lighting bakes load with it.
Read the notes
Materials on models, sorted out. A material you put on a model now actually replaces what the model came with, one part of a model can be given its own look from a script, and a model's own textures can be taken out of it as files. Plus a tint that leaves everything else alone, and a sprite you can flip in one line.
A material you apply is the material you get
Putting a Material on an imported model and pointing it at a texture left the model wearing its old picture. Its colour tinted, its emissive glowed, its roughness took — everything except the thing you could see.
A Material on a model now supersedes the materials the model was imported with. Every part draws with it, textures included, so a material naming no texture draws untextured. That is what the component is for: this whole thing is made of THIS. A model that already looks right needs no Material at all.
To change one part instead, use the model's own material list — or, now, a script.
On upgrade: a model wearing a node Material will look different, because it is finally wearing it. If you had one on there to nudge a colour or add a normal map, either move it to the parts you meant (◑ Model materials in the Inspector) or replace it with a tint, below.
Dressing one part of a model, from a script
for _, slot in ipairs(node:materials()) do
print(slot.material, slot.object, slot.textured, slot.overridden)
end
-- Clothing Torso#2 true false
-- Head Head#2 true false
-- Pants RightLeg#2 true false
node:material("Clothing").texture = "art/shirt.png"
node:material("Pants").texture = "art/jeans.png"
node:materials() says what a model's parts are called; node:material(name)
is one of them, read and assigned. Every field of a material is there — the
texture and the surface maps by path, color / emissive / specular / rim
as colours, and alpha, roughness, metallic, emissiveStrength, unlit,
fog, cell as values.
Ask before you address: a model that repeats an object name has it renamed on
import (Torso becomes Torso#2), so a guessed name reaches nothing. Every
part answers to two — its object, which is exactly one part, and its
material, which is every part wearing it. A character's Clothing is
usually its torso and both arms, which is the group a clothing system wants.
A material's fields read back now, too. mat.texture used to answer nil
however many times it had been set, so a script could tell a material what to
wear and never ask.
Taking a model's own textures out of it
A .glb keeps its images inside itself, where nothing else can point at one.
Select a model — or its node — and press ⬇ Extract textures: each material's
image is written beside the model as <model>_textures/<material>.png, and from
then on it is an ordinary project texture. Paint over it in the 🖼 Image tab,
assign it to anything, use it as the base layer a costume is drawn on.
Overriding one part of a textured model extracts that part's texture on the spot and starts the override wearing it, so "override" never means "go blank".
The same model, but red
node:setTint(color(1, 0.3, 0.3)) -- hit flash
node:setTint(teamColor) -- one prefab, four teams
node:setTint(color(1, 1, 1), 0.4) -- ghosted while being placed
node:setTint() -- back to normal
A tint is multiplied over whatever a node already draws, so the model keeps its own textures and each part keeps its own colour. It needs no Material and does not create one. In the Inspector it is the ◐ tint swatch beside a model's material list; white is no tint.
It is a component, so an animation clip can key it — a hit flash is a tint faded
back to white over a fifth of a second — and a script can read it back with
node:getcomponent("Tint").
Flipping a sprite is one line
local sp = node:sprite()
sp.flipX = mx > 0
node:sprite() is a Sprite's own drawing numbers as something you hold:
flipX, flipY, cell, ppu, size, pivotX, pivotY, each readable and
assignable. Reads answer with what you last wrote, so asking which way a
character is facing is a question you can now ask.
vec3 and vec2 also answer to :magnitude(), the name most engines use for
:length().
A table of options written as a list is now refused. node:setSprite{ 8, 1, true } set nothing at all and said nothing about it — every option is read by
name. It now tells you so, and names the keys it reads. This applies to every
call in the API that takes an options table.
Shots and headless runs tell the truth
floptle shot drew a scene in the textures its models were imported with,
whatever their materials said, and photographed planets before their ground had
loaded. It waits for the world now, and says on the error stream when it ran out
of patience rather than handing you a finished-looking picture of an unfinished
world.
floptle run reported the full span of simulated time even when it had
simulated none of it — a paused session steps happily at a dt of zero. It
reports the time it actually advanced, and says when the two differ. Runs of a
project with generated terrain now advance at all.
Games that build their world as the player travels can ask
terrain.busy() whether the background worker is occupied — true from the
moment a fill is queued, so building one system and waiting for quiet before the
next is a loop you can write.
Smaller things
- A library script has its settings before anything calls it. A script with
no
startand noupdate— one that exists so others can call into it — heldparams = niluntil something happened to tick it, so the first call into it raised. Whether it raised depended on the order nodes sat in the scene file. - ⊕ Transform ▸ … ▸ Reset transform puts a node back to the origin, no rotation, scale 1. Copy and paste values were already there.
- The right edge of the panels is no longer under the scrollbar. A slider's
label could be cut to its first letter, and the
…menus sat half over the edge. node:getcomponent("Sprite").flipXreads back as a boolean. It was a number, and0is true in Lua — so "is it flipped" was always yes.- Headless runs no longer warn that perfectly good models have nothing to draw.
Upgrading
Open your project once and the editor updates its editor-support files.
The one thing to check is any model with a Material on the node: it now wears that material instead of blending with what it came with. The Inspector's ◑ Model materials list is where to put it back per part, and ◐ tint is what to reach for if what you wanted all along was "the same model, but coloured".
Read the notes
The loose ends from the command line, closed: baking light probes no longer opens a window, the dedicated server has a verb, and a machine can be asked whether it will run the things that need a screen. Plus a spritesheet animation fix and a renderer that builds on OpenGL.
Animating a sprite's frame works
Attach an animation controller, press ● Record, click through the frames of a spritesheet — the keys landed correctly and playback never changed the picture. Every key was right, every value was right, and nothing moved, which reads as spritesheets being broken.
A ▫ Sprite carries its own frame number and ignores its material's copy. The timeline was keying the material's, so playback wrote a number nothing draws. Clips you already recorded start working; there is nothing to re-record.
Baking light probes no longer needs a window
floptle bake gi said "and exit" and did the opposite — it opened the editor
and baked there, so on a build machine with no display it did not bake at all.
floptle bake gi # bakes and exits
floptle bake gi --scene arena --json
It runs the same bake the editor runs, back to back instead of a slice per frame, and writes the result beside the scene. It still needs a graphics adapter, because it photographs the scene from every probe. A scene with no enabled Light Probes node says so instead of baking nothing.
--bake-gi is unchanged and still opens the editor, so anything driving the
old flag keeps working.
The dedicated server has a verb
floptle serve --port 7777
floptle serve --relay relay.example.com --scene arena --tick 30
The same server floptle-runtime --server runs — this is just where to type it,
so one command line covers the engine. A scene with no Networked nodes, or a
rollback scene (which is hosted by one of the players, not by a dedicated
server), is refused by name rather than served empty.
Ask a machine what it can do
floptle doctor
floptle 0.71.0
graphics NVIDIA GeForce RTX 4060 (discretegpu)
backend Vulkan
rendering yes — shot, bake gi, open and play will run here
It answers by building the renderer, not by asking the adapter what it
supports, because those turn out to be different questions. It exits non-zero
when the machine cannot render, so a build script can check before it reaches
for shot, and when it cannot it lists what still runs without a screen.
The renderer builds on OpenGL
On a machine with only an OpenGL adapter, the renderer could not be created at
all — so shot and the editor itself were unavailable there, and the failure
looked like a crash rather than an answer. Two shaders were sampling one texture
with two samplers, which OpenGL does not allow. Both fixed, with no change to
what anything looks like.
If you are on hardware where Floptle would not start, this is worth another try.
floptle check notices a stale spritesheet grid
A scene saved before its textures were sliced — or one whose materials were
built by a script — carries a sheet grid the project's own import settings
contradict, and it draws as a sprite showing its whole sheet instead of one
frame. check reports it now, naming both grids and the frame it would move to.
It does not correct the files, on purpose: writing a scene back reformats it and drops every comment in it, and a command you are told to run after every edit should not do that. Opening the project in the editor and saving applies the correction, which is what the warning says.
A picture with the light in it
floptle shot was rendering before the scene's baked global illumination had
been uploaded, so every picture was taken with the bounced light missing.
Upgrading
Nothing to do. Scenes, projects, clips and scripts are unchanged, and every existing flag behaves as it did.
Read the notes
Floptle has a command line. You can now find out whether a project loads, whether it runs, what it looks like and what is inside it — and change it correctly — without opening the editor. That means your CI can do those things, and so can an AI assistant working in your project folder alongside you.
Find out whether it works, without opening it
floptle check # does everything still load?
floptle run --frames 120 # play it for two seconds and report what raised
check loads every scene, prefab, effect and material and reports every
reference that does not resolve — a parent pointing at the wrong node, a
material naming a texture that is not there, a node carrying a script with no
file behind it. A .ron file that parses is not a scene that works, and until
now the only thing that could tell you the difference was opening the project.
run is the bigger gap closed. It plays the project for a fixed number of
frames with no window at all — the real scripts, the real physics — and reports
every warning, error and print with its file and line, exiting non-zero if
anything raised. Pointed at a real game it found a script indexing a nil value
on the first try.
Both are safe to put in a build pipeline. Neither needs a display.
See what it looks like
floptle shot --out look.png
floptle shot --scene arena --camera "Chase Cam" --size 1920x1080
One frame through the scene's active camera, straight to a PNG. It goes through the same rendering path the editor's own Game view uses, and it applies the project's post-processing — bloom, vignette, ambient occlusion, posterise, colour grading, depth of field, your own post shaders, and the retro presentation at its own resolution. What lands in the file is what the editor would show you.
Read a project, and change it correctly
floptle inspect # what this project is
floptle inspect --scene first # its nodes, as a tree
floptle inspect --select Player --json # one node's whole document
floptle exec fix.lua # change it, through the editor's own API
inspect reads the files, which is what you want when you are about to edit
them, and it builds the hierarchy the way the engine does rather than the way
the file happens to be ordered.
exec runs a Lua file against the project through the same scripting API the
editor's own extensions use — scene.find, scene.setPos, scene.add,
ed.saveScene(). Use it instead of hand-editing scene files when the change is
structural: node ids, parent links and defaults come out right because the same
code does it. Nothing is written unless the script calls ed.saveScene(), and
if it changes something and forgets, the run tells you rather than losing it.
Look up what a script can call
floptle api setSprite
floptle api "collision" # search the descriptions too
Every name a script can reach, with what it does and an example, searchable by
part of a name or by a word from the description. It exits non-zero when nothing
matches, so floptle api node:doesNotExist answers a yes/no question.
Every command describes itself
floptle help --json
The whole surface as one document: every command, its arguments, the values each one accepts, what comes back, what each exit code means, and two flags worth knowing before you run anything in a container or over SSH — whether the command needs a display, and whether it will write into your project.
It is generated from the same table the parser is built from, so it cannot describe a command that does not exist, and a command cannot exist without appearing in it.
Every flag that worked before still works. --export, --new, --migrate,
--play, --version and the rest are unchanged; the verbs sit alongside them.
If you drive Floptle from a script today, nothing you wrote has to change.
New projects come with a guide
floptle new now writes an AGENTS.md at the project root — a short page
telling an AI assistant which commands exist, what the project's folders are
for, and two things about scene files that otherwise cost an afternoon. It is
written once and never touched again, so edit or delete it freely.
A lit sprite draws one frame, not the whole sheet
With 2D lighting in the scene, a ▫ Sprite on a spritesheet drew the correct frame and a squashed, stretched copy of the entire sheet on top of it — usually crowded into one corner, wherever that sheet's filled cells happened to land. Changing the frame moved the sprite underneath and left the ghost where it was. Turning the lights off made it disappear.
2D lighting works by correcting the frame that was already drawn: it measures the difference between what is on screen and what the lights say should be, and adds that difference back. It was measuring against the whole sheet rather than the one frame, so the difference it added back was the rest of the sheet.
The same reading decided which pixels of a sprite block light, so a sprite's shadow was cast by the shape of the whole sheet rather than by the frame you can see. Both are fixed together.
This is the other half of the spritesheet fix in v0.69.1. If you upgraded to that one and a stretched sheet was still sitting over your sprites, this is what was left. Tilemaps were never affected, and neither is anything in a scene without 2D lights.
What isn't in it
There is no floptle serve yet — the dedicated server still has its own command
line. check reports problems but does not fix them. And --bake-gi still opens
the editor to do its work despite what its help says.
floptle shot needs a Vulkan, Metal or DirectX 12 adapter. On a machine with
only OpenGL it cannot build the renderer, and it now says so and stops instead of
looking like a crash. Everything else on this page works with no graphics adapter
at all.
Upgrading
Nothing to do. Scenes, projects, clips and scripts are unchanged, and every
existing flag behaves as it did. Existing projects do not get an AGENTS.md;
if you want one, floptle help --json is the page it would point at anyway.
Read the notes
The follow-up to the 2D release. Spritesheets reach the sprites wearing them, a 2D camera can land on whole pixels, a sprite's own numbers can be keyframed, and the panels that had run past the edge of a narrow dock no longer do.
Keyframe what a sprite does with its picture
⏱ Animating ▸ ✚ Property ▸ Sprite ▸ Node.
Sprite ▸ frame says which picture. The new lanes beside it say what this node
does with it:
ppuandsize— squash and stretch.pivotX/pivotY— shift the origin, so a crouch keeps its feet on the ground.flipX/flipY— face the other way on a turn.
Every one of those was already reachable from a script and from no clip, which is the wrong way round in a game where the clip is the character. A whole turn-and-swing is now one clip and none of it needs code.
The flips are stepped, like every other on/off lane: 0 is left alone, 1 is mirrored. An eased flip would turn the sprite round exactly halfway between two keys, at a moment nobody authored.
Turn a node you already have into a Sprite
Inspector ▸ Add Component ▸ Type now offers ▫ Sprite, ▩ Tilemap, ▧ Sprite Batch, ⇄ Nav Link and ▨ Nav Area. They were in ✚ New and not in the Type menu, so the only way to make an existing node into a Sprite was to create a fresh one and move the work across.
Panels that fit a narrow dock
Drag ⚙ Settings or ▦ Model as thin as you like:
- ⚙ Settings ▸ Input wraps. An action with several bindings puts the extra
ones on the next line instead of pushing the ✕ that deletes the action off the
edge. A binding too wide for a whole line —
🖱 Motion x0.006 (hold RMB)— is shortened with the full name on hover. A long action name is shortened the same way rather than taking the row with it. - The collision matrix scrolls sideways. It is one checkbox per pair of layers, so its width is decided by how many layers your project has, not by how wide the panel is.
- ▦ Model ▸ per-slot material fits, including its Surface maps and Retro artefacts sections and the spritesheet cell picker.
- Shortcut keys, the reserved-keys list and the how-to text all wrap.
Icons that mean one thing each
▪ was Capsule and Sprite Batch. ▦ was Model Mesh and Tilemap. A
Reflection Probe wore one glyph in the Inspector and a different one in ✚ New.
Tilemap is ▩, Sprite Batch is ▧, Reflection Probe is ◍, everywhere.
VSCode knows the 2D API
The stub library the extension reads was missing 35 node methods — the whole
2D surface (node:setSprite, node:setCamera2D, node:shake, node:sorting,
node:setParallax, node:tilemap, node:sprites, …) plus a dozen older ones
like node:toWorld, node:lookAt and node:animator. Every call to them was
marked as an error on an unknown field, on the one surface you are looking at
while you write the line. Re-open a project to refresh
.floptle/library/floptle.lua.
The 2D camera can snap to whole pixels
Inspector ▸ Camera 2D ▸ snap to whole pixels, or
node:setCamera2D{ pixelSnap = 32 }. The number is pixels per world unit — the
same one a ▫ Sprite's pixels per unit uses.
A camera that stops between two pixels resamples every sprite in the scene by a fraction of one, and pixel art shimmers along its edges while nothing is moving. Every 2D project ends up writing this by hand. The engine's version snaps what is drawn and keeps the follow's sub-pixel place, so the camera can still creep slower than a pixel a frame instead of sticking.
Fixes
- A spritesheet set from the Assets panel did not reach the sprites already
wearing that texture. The grid lives on the texture — slice the
.pngonce and every material using it inherits the cells — but the only thing that copied it onto a material was the Inspector's material editor, running for the one node you had selected. Every other sprite still believed its texture was a single whole cell, so it drew the entire sheet stretched across its quad and came out sized from the whole image. Re-slicing a texture now re-slices every material that wears it, and a cell past the end of the new grid falls back into range. Opening a scene puts its materials in step too — and says so in the Console, leaving the scene unsaved, because an exported build ships the scene file and a grid corrected only in memory never reaches it. - Clicking a frame in the Material's cell picker did nothing on a ▫ Sprite node. A Sprite carries its own cell — that is what lets one sit on a shared material — and the picker was writing the material's. It now drives the node's, so the control you reach for is the one that draws.
- Selecting a sprite laid a wrongly-sized quad over it. The selection outline was built without the material or the texture size, so it fell back to the authored world size instead of measuring one cell.
camera.pixelsPerUnit()was wrong under an orthographic camera — scaled by the camera's distance from the origin, which has nothing to do with it. A camera 9.33 units back answered 9.33× too small, and onlypixelsPerUnit(1)was usable. An orthographic view is the same height at every distance; the argument is now ignored for one.- Pinning a retro width did not change the framing. The scene was composited into the fixed target while the camera still projected at the panel's aspect, so the picture came out squashed inside the letterbox — defeating the reason to pin a width, which is that the framing stops depending on the window.
- A frame with
hold: 0never appeared. It landed on the same instant as the frame after it and lost the tie. Zero, negative and nonsense now mean one frame; a fraction above zero is kept exactly as written. - A clip file whose name was capitalised resolved to nothing.
Walk.SpriteAnim.ronshowed in Assets, dragged onto a controller state, and played silence — the browser matched the extension loosely and the loader matched it exactly. Extensions are matched case-insensitively everywhere now. - A texture path saved on Windows found its import settings only on Windows. Backslashes are normalised, so pixel art keeps its Pixelated filter when the project moves between machines.
- Importing an Aseprite sheet whose tags name frames it does not have is
refused with a sentence naming the tag, instead of quietly producing a clip
that is short. It happens when the
.jsonand the.pngcome from different exports. - Y-sorted nodes standing at the same height stack the same way after a reload — and after a pooled enemy is destroyed and spawned again. The tiebreak is now left-to-right rather than an internal number.
- A 2D camera following a name that two nodes share picks the same one every frame instead of whichever it met first, so the picture no longer jumps between two places with nothing in the scene changing. The lookup also runs once per frame rather than once per camera.
Nothing to do on upgrade
Scenes, projects, clips and scripts are unchanged. If you had reached for
Sprite ▸ frame in a clip it keeps working exactly as it did; the new lanes sit
beside it.
Read the notes
A release about making 2D games. A sprite node that knows what a pixel is, sorting that puts a character in front of the table they are standing at, sprite animation whose frames can come from anywhere, and a camera that follows.
A sprite is a node now
✚ New ▸ ▦ 2D ▸ ▫ Sprite.
You could always build one out of a Plane and a Material, and every 2D project did — each of them working out the same three things: that the plane is 1.4 units across and not one, that flipping means a negative scale, and that the sheet cell lives on the Material. A Sprite answers all three once, and carries two things a Plane cannot:
- A size in pixels.
pixels per unitmeasures one cell, not the sheet, so slicing a sheet finer does not resize every sprite on it. - A pivot. The origin defaults to the middle; the feet button puts it at the bottom, which is what a Y-sorted character wants.
Lower on the screen draws in front
Set a node's sorting layer to Y, and nodes level on layer and order sort by how far down the screen they are. Walk behind a table and you are behind it; walk in front and you are in front.
The order is sorting layer, then order, then Y. Y is the tiebreak, not a replacement — a drop shadow on order −1 stays under a Y-sorted crowd, and a lantern on order 1 stays in front of whoever is carrying it.
Two things to know:
- A node's sorting layer and its collision layer are different things. They were both labelled "layer"; they are not any more. (Nor is the third one, the layers a 2D light reaches.)
- A Y-sorting layer holds about 64 nodes before two of them tie — that is measured, not estimated. Split crowded scenes across sorting layers.
Backgrounds that move less than the world
parallax is a per-axis scroll factor: 1 moves with the world, 0 pins to the
camera, 0.3 is a distant range of hills.
A factor rather than a distance, because the other way of getting parallax — putting a layer further back — only works under a perspective camera, and a flat game wants an orthographic one so its pixels hold still.
Sorting and parallax move the picture. Colliders stay where you put them and
node.x reads back what you set.
Sprite animation, from any sheet
A frame names its own art, so one animation can use frames from different sheets and from plain PNGs that were never on a sheet at all.
Three ways in:
- Right-click a sliced texture in Assets ▸ ▦ New sprite animation — one frame per cell, ready to cut down.
- Right-click an Aseprite
.json▸ ▦ Import Aseprite sheet — the tags become clips, the grid slices the image, and per-frame timing is kept. - ⏱ Animating ▸ ✚ Property ▸ Sprite ▸ frame — a lane beside position and rotation, for when the sprite is only part of what is animated.
A sprite animation is an ordinary clip: drop it in a controller and it crossfades, layers, carries events and plays from Lua like anything else.
// hero_walk.spriteanim.ron
(
fps: 12,
loop: true,
cols: 8, rows: 4,
texture: "art/hero.png",
frames: [
(cell: 0),
(cell: 1),
(texture: "art/hero_extra.png", cols: 4, rows: 4, cell: 9),
(cell: 2, hold: 3.0),
],
)
Record picks up what you actually changed
● Record used to notice numbers and nothing else, so changing a material's texture with record on wrote no key — which is the one gesture sprite animation is for. It writes one now.
Materials also gained the rest of their surface: opacity, base colour, emissive, specular, rim, roughness, metallic and the rest are all keyable, so fading something out is a lane rather than a script.
The ✚ Property menu is grouped by component, with the fields people reach for at the top.
A camera that follows
Tick 2D camera on an orthographic Camera:
| follow | the name of a node to chase |
| dead zone | how far it may move before the camera moves at all |
| smoothing | seconds to close the gap — the same at 30 fps and at 144 |
| limits | a rectangle the camera stays inside |
With no follow target the camera's position is left to whatever else is moving it — a script, a cutscene, your own placement — and only the limits and the shake are applied. So you can drive the camera by hand and still shake it.
node:setCamera2D{ follow = "Player", smoothing = 0.12, deadZoneX = 1.5 }
node:shake(0.4, 0.25)
Shake is added to what is drawn and never fed back into the follow, so it composes with the chase and with the limits instead of fighting them. Calling it again takes the louder amplitude and the longer time, each independently — a bang during a rumble does not cut the rumble short.
All of this applies to an orthographic camera and to nothing else.
Menus that stay short
✚ New is grouped — ▦ 2D, ■ 3D, ▦ Model shape, ● Lighting, Δ World, ⬚ Navigation, 🖼 UI — so adding a node is a glance rather than a scroll.
Panels fit however thin you drag them
Every form panel now shrinks, then wraps, then stacks. Drag the Model, Tiles, Inspector or Settings panel as narrow as you like and nothing goes past the edge.
Smaller things you will notice
- Drag a
.pnginto the Scene view and you get a Sprite wearing it. It used to do nothing at all. - Sorting, parallax and 2D light settings follow a multi-selection, so putting forty tiles on a background layer is one edit.
- Greyed-out menu entries now say why they are greyed out. They had the explanation written and could not show it.
- The ✚ Property menu in the Animating tab is grouped by component, and a material's texture and opacity sit at the top rather than in a wall of surface maps.
- Duplicating or pasting a node keeps its sorting layer, parallax and 2D camera. It used to drop them.
For package authors
json.array(t)/json.isArray(v).{}is both an empty list and an empty object and encodes as an object, so there was no way to send[]at all.json.decodenow marks the arrays it builds, so reading a body, emptying a list and sending it back keeps it a list.ed.copy(text)puts a string on the clipboard, so a panel can have a copy button. Write only — nothing can read the clipboard — and the editor shows how much was copied.
Upgrading
Nothing to do. Everything here is opt-in: a scene that does not use sorting layers, parallax, sprites or a 2D camera saves exactly the file it saved before.
Read the notes
Two additions to the package API, both about text a package paints itself.
A package can ask how wide a string is
gui.measure(text [, size]) → {w, h} — the size that string will be when it is
drawn, in the same font gui.textAt would draw it in.
Painting has never had this. A package laying out a chart axis, a legend, or a
line of prose with one word emphasised has to place each piece at an x it
works out for itself, and with no way to ask, the only option is characters ×
an assumed width:
x = x + #text * 6.9 -- the old guess
That is wrong for every proportional face by a little, and wrong for an i
beside a W by a lot. The error accumulates along a line, so emphasis lands
past the word it belongs to and the right edge goes ragged — which reads as a
layout bug rather than as a missing measurement.
local at = gui.cursor()
local x = at.x
for _, word in ipairs(words) do
gui.textAt(x, at.y, word.text, 13, 1, 1, 1)
x = x + gui.measure(word.text, 13).w -- exactly where it ended
end
Painted text now uses the typeface you asked for
gui.font(name, fn) has always set the face for widgets inside it. gui.textAt
ignored it and drew in the editor's own type, so a package shipping a typeface
could set it for every button and label and then watch its hand-painted chart
labels come out in something else — with nothing to say why.
textAt and measure both follow the enclosing gui.font scope now, so
painted text matches the widgets around it. A measurement taken in a different
face than the drawing is worse than no measurement, because it looks right until
somebody changes the type.
Upgrading
Nothing to do. gui.measure is new, and textAt outside any gui.font scope
draws exactly as it did.
Packages that want the measurement while still supporting older editors can ask for it:
local function width(text)
if gui.measure then return gui.measure(text, 13).w end
return #text * 6.9 -- older editor: the old guess
end
Read the notes
The Assets browser converts models the engine cannot open. Right-click an
.fbx and pick ⇄ Convert to .glb — or just double-click it.
Asset packs are FBX. Scans are PLY. Anything out of CAD is STL. Until now the answer to all of those was "export it as glTF first", which is fine advice if you own the source file and useless if you bought the model.
| From | |
|---|---|
.fbx |
Binary and ASCII, every version anyone still has |
.obj |
With its .mtl |
.stl |
Binary and ASCII |
.ply |
Scans, with their per-vertex colour |
.gltf |
Packed into one file — see below |
The result is written beside the source: one self-contained .glb with the
geometry, materials and textures inside it. Your original is not touched, and
nothing is overwritten — if the .glb is already there it says so instead.
It normalises, it does not just re-encode
Every one of these formats disagrees with glTF about something, and a converter that only re-encodes hands you a model that loads and is wrong:
- Axes. FBX is Z-up out of 3ds Max and Y-up out of Maya. glTF is always Y-up. A model that arrives on its face is the commonest complaint about importing FBX anywhere.
- Units. FBX records its own, and most exporters write centimetres. glTF is metres, and a model a hundred times too big reads as a broken importer rather than as a unit.
- Winding. Fixing handedness — or any mirrored node, which is how anyone makes a left boot from a right one — inverts triangle winding. A model with every face backwards is invisible from outside and solid from within.
- N-gons. FBX and OBJ have quads and worse. glTF has triangles.
All four are settled on the way through.
A loose .gltf becomes one file
Not a format change, and the thing people need most without expecting to. A
.gltf is JSON that points at its geometry and its textures, so it is three to
thirty files that have to travel together — and half of them go missing in a zip
or a move between folders, leaving a model that opens as an empty scene.
Converting one packs all of it into a single .glb.
It tells you what it did
The Console gets the counts, and everything that did not come across:
Convert: hero.fbx → hero.glb — 4 meshes, 12,536 triangles, 2 textures embedded
Convert: The source measured in centimetres; it has been scaled to metres.
Convert: 3 animations, skinning (the mesh is converted in its bind pose) was not
carried across — .glb here holds geometry, materials and textures.
Animation and skinning are dropped, and named rather than left for you to discover. A file with no meshes in it at all — bones only, or a scene of lights — fails with "there is no geometry in that file" instead of writing an empty model that opens fine and shows nothing.
Conversion runs off the main thread, so a large character model does not freeze the editor.
What has not changed
The engine still loads glTF and only glTF at runtime, which is ADR-0006 and is still right: one well-specified load path beats four half-supported ones. This is an authoring step that gets you to that path.
Upgrading
Nothing to do. Existing .glb and .gltf assets are unaffected, and .glb is
deliberately not offered for conversion — it is already the format this converts
to.
Read the notes
A package can read a file's bytes, and a file the user picked is one it can open.
ed.readBytes(path)
Raw bytes as a Lua string, or nil.
ed.read is text. A PNG comes back as nil — the same answer you get for a
file that is not there, which is the most confusing available way for something
to be unreachable. Anything binary was out of reach: an image to upload, a font
to inspect, a .bin to parse.
Lua strings are byte strings, so there is nothing to decode: #bytes is the
file's length and string.byte indexes it.
The picker is the permission
ed.pickFile returns paths from anywhere on the machine. ed.read scopes to
your package and the project, which is right for a path a package makes up —
and wrong for one the user chose in an OS dialog for this exact purpose. So the
picker could tell a package the name of a file it had no way to open.
ed.readBytes accepts any path ed.pickFile handed this package during
this session, on top of the usual scope.
ed.pickFile({ title = "Choose a floor plan", label = "Images",
extensions = { "png", "jpg" } }, function(paths)
if not paths then return end
local bytes = ed.readBytes(paths[1]) -- readable: the user picked it
end)
The grant is recorded when the path is handed over, before your callback runs — reading the file inside that callback is the obvious thing to write, so it is the thing that works.
Matched on the resolved path, so a granted file cannot be spelled with a .. in
the middle to reach a different one. Paths you make up are unchanged: your own
package always, elsewhere in the project with Files, outside it never.
Upgrading
Nothing to do. ed.read is untouched.
Read the notes
Two small additions to the package API, both found the same way as v0.65.0's and v0.65.1's: by writing a real editor tool against it and hitting a wall.
A panel can fill a shape
gui.poly({x1, y1, x2, y2, …}, r, g, b [, a]) fills a convex polygon, in the
same panel coordinates as the rest of the painting calls.
The scene side has had handles.poly since the beginning. A panel could only
outline, which meant a hand-drawn chart filled an area by stacking one-pixel
rectangles under it — a draw call per column, a ragged diagonal edge, and no way
at all to fill a shape that is not a function of x. A radar chart is exactly that
shape.
-- One triangle per axis, fanned from the centre.
for i = 1, n do
local j = i % n + 1
gui.poly({ cx, cy, x[i], y[i], x[j], y[j] }, 0.5, 0.2, 0.8, 0.22)
end
Fewer than three points draw nothing rather than a degenerate sliver, and an odd number of coordinates raises — a flat list is easy to build one element short, and a polygon quietly missing its last vertex looks like the data was wrong rather than the call.
Convex, matching handles.poly: a concave outline fills as its convex hull, so
split one into triangles. Worth knowing before you draw a chart with it, because
the hull of a dented shape is the undented one — it flatters rather than fails.
A field can be cleared, not just set
json.null encodes to JSON null.
Lua nil cannot do this job. t.field = nil does not put a null in the table, it
removes the key, so the encoder never sees it and the field is simply absent
from the output. Any API that reads an absent field as "leave this alone" and an
explicit null as "clear it" was therefore half-usable from a package: you could
set such a field and never unset it.
json.encode({ cleared = json.null, kept = 1, absent = nil })
--> {"cleared":null,"kept":1}
Decoding is deliberately not symmetric: a JSON null still arrives as Lua
nil. Every package already reads an optional field with if body.field then, and
handing them a sentinel that is truthy would silently turn every one of those
tests the wrong way round.
Upgrading
Nothing to do. Both are additive.
Read the notes
Two more places a package's Scene overlay can go, both found the same way as v0.65.0's: by writing a real tool against the API and running out of room.
An overlay can sit at the bottom
corner takes "bottomLeft" and "bottomRight" as well as the two top ones.
The bottom stacks grow upward from the bottom edge and are independent of the
top ones, so a package can put a readout in one corner and a row of buttons in
the other without either moving the other.
ed.overlay("Quick Actions", { corner = "bottomRight", bare = true, width = 250 },
function()
…
end)
A row of buttons wants the bottom of the viewport. Pinned to the top it sat over the level the moment anything above it changed height — and things above it change height constantly, because the overlay above is usually a readout that grows as the analysis fills in.
fill is ignored on a bottom overlay: one pinned to the bottom edge that also
took the whole column would be an overlay pinned to the top edge.
An overlay can take the full height
fill = true gives a Scene overlay the whole column between the toolbar and the
bottom of the viewport.
This was written for v0.65.0 and landed just after the tag, so it is in this
release rather than that one. Without it an overlay is only as tall as whatever
it has drawn so far, which means gui.available() inside one reports the content
rather than the space — so a panel that wants to fill the side of the screen has
no way to find out how much screen there is. A conversation written into one came
out two messages tall.
Upgrading
Nothing to do. Both options are additive, and an overlay that does not ask for either draws exactly where it always did.
Read the notes
Everything a package needed to build a tool that looks like it belongs in the editor rather than bolted onto it: its own dock tabs, overlays it can place and paint itself, the layout cursor, the keyboard, and the file picker.
Plus two fixes worth having whatever you use: a project opened from the command line now loads its packages, and a package's saved settings survive a crash.
Your tools dock like the editor's own
ed.tab(title, drawFn) gives a package a real dock tab — dragged, split
beside the viewport, stacked with the Inspector, closed when you want the room.
It comes back where you left it across a reload of the package and a restart of
the editor.
A package could only float a window before. That is right for something you call up and dismiss, and wrong for anything you keep open beside the scene: settings, a list, a report. Both are available now, and they mean different things.
Overlays that get out of the way
ed.overlay takes options: which corner it sits in, how wide it is, and whether
the editor draws a frame around it at all.
ed.overlay("My HUD", { corner = "topLeft", bare = true, width = 330 }, function()
…
end)
bare is the interesting one. A Scene overlay is a heads-up display about the
level behind it, and a grey panel slab hides the thing it exists to annotate.
With bare the package paints its own look and the editor stays out of it. The
left-hand stack tucks in under the viewport toolbar, and follows it if you drag
the toolbar somewhere else.
Existing overlays are untouched — the two-argument form draws exactly as before.
Painting more than one thing
gui.cursor() returns where the next widget would go, in the same coordinates
the painting calls use.
Without it, painting could only ever draw one thing: the origin is the panel's top-left and it does not move as widgets are added, so a second painted card landed on top of the first however much space you reserved between them. A list of painted rows — a chat log, a stack of cards, a legend — was not expressible.
The keyboard, and asking for a file
gui.keys() reports the modifiers and whether Enter or Escape went down, and
gui.textField now also returns whether it was submitted with Enter. Together
that is enough for the convention every text box uses — Enter for a newline,
Shift+Enter to send — which a package simply could not offer before, because a
returned string looks the same whether you typed a character or pressed the key
that means "send".
ed.pickFile(options, fn) opens the operating system's file picker, optionally
narrowed to a set of extensions:
ed.pickFile({ title = "Choose an image", extensions = { "png", "jpg" } },
function(paths)
if not paths then return end -- cancelled
end)
It needs the Files permission, and is absent rather than refused without one.
A project you opened from the command line loads its packages
Start the editor with a project as an argument — which is how the Hub starts it — and the project came up with its packages missing. No menus, no panels, and no error, because nothing had failed. The only way to get them was to switch a package off and on again in 📦 Packages.
Your package settings survive a crash
A package's saved settings — an API key you typed, a server address, a switch you flipped — were only written to disk when the editor closed cleanly. Anything else threw them away, so you signed in again. They are now written the moment they change.
Upgrading
Nothing to do. Every existing package keeps working: the new overlay options are
additive, and gui.textField's second return value is dropped by Lua wherever
nobody asks for it.
Read the notes
Adding a package from a folder works on Linux. One fix.
Adding a package from a folder works
Window ⏵ 📦 Packages ⏵ Add, then 📂 Choose folder… or 🔗 Link folder…, and the editor shut down the instant you clicked. No file picker ever appeared, no message, no crash report — the window simply went away, which reads like a broken button rather than like something going wrong.
The file picker was asked for in a way that cannot work on a Linux desktop. The system's own dialog is reached through a background service there, and it was being asked for from the thread that draws the editor, which has no way to wait for the answer. Nothing was wrong with your package or your project, and nothing was left half-done: the editor stopped before it read a single file, so there is no stray entry to clean up and no partial install to undo.
Windows and macOS were never affected.
Two things are better than before the bug, too. The editor keeps drawing while a picker is open instead of standing still, and both buttons grey out until you have answered it — one folder question at a time.
Upgrading
Nothing to do.
Read the notes
Spot lights. And a large step for editor tools: a package can now read and write a whole node, read a mesh's actual triangles, use its own typeface, follow a server's progress live, and take you to a place in the level.
Spot lights
A light you aim. Add ⏵ ◤ Spot Light, then rotate the node — the beam runs down the node's forward, the same axis a camera looks down, so pointing a lamp is the same gesture as pointing a camera.
Two numbers:
- cone — the full angle, the number on a real fixture. 45° is a 45° cone.
- edge — how much of that cone is falloff. 0 is a hard circle; 1 fades from the middle out. It is a fraction of the cone, so widening a beam keeps the edge you gave it instead of going hard.
A spot is not a different kind of node. It is a point light that has been aimed, so everything you already know about lights still applies to it: the same emitter shapes (a spot can come from a sphere bulb or a disc), the same range, the same local shadows, the same 2D/3D behaviour, the same sixteen-light budget. The Scene view draws the cone instead of the reach ring, because a circle around a lamp tells you nothing about which way it is facing.
From a script it is the same handle, with two more fields:
local lamp = node:getcomponent("PointLight")
lamp.spotAngle = 12 -- tighten the beam
lamp.spotSoftness = 0.05 -- and harden its edge
node:lookAt(target) -- aim it
spotAngle of 180 or more means no cone at all — an ordinary
omnidirectional lamp. That is what every existing light reads, and it is how you
turn a spot back into a normal one. There is no separate switch to keep in step.
Spot lights also default to casting shadows, where a point light does not. A spot is aimed at one thing, and one that shines through the wall it is pointed at is not much of a spotlight.
Nothing to do on upgrade. Every light in every project you have is untouched, and a scene file saved by this version is byte-for-byte the one you had unless you actually aim something.
Pressing F on a child node goes to the child
Framing the selection used a node's position relative to its parent rather than
where it actually is. Select a door inside a building parked away from the
origin, press F, and the camera flew to wherever (0.4, 0, 1.2) happens to be
in the world — usually nothing, usually nowhere near the door. It read as the key
having missed rather than as it having gone somewhere.
Anything not parented worked correctly, which is why this lasted as long as it did. It now frames the node's real world position, and sizes the distance by its real world scale.
What a package can do now
Five things, all of them general — none is specific to any one tool.
Read and write a whole node
scene.info(id) gave a summary. Now scene.doc(id) reads the whole node — the
same document a .ron scene, a prefab and the clipboard all use — and three
calls write it back:
scene.set(id, { tags = {"cover"}, layer = "props" }) -- change only these keys
scene.add({ name = "Guard Post", children = { { name = "Lamp" } } })
scene.setParent(id, nil)
scene.set is a partial write: keys you do not mention are left alone.
scene.add builds a whole subtree as one undo step. A key the document does not
have is refused by name, and tells you which one you probably meant — a
misspelt field used to be accepted and silently do nothing.
Before this, a tool that worked out where something should go could only leave a marker where it meant to put the thing.
Read a mesh's triangles
scene.bounds gives a box. For measuring a room, exporting a level or uploading
geometry, a box is not an approximation of a shape — it is a different shape.
mesh.read(nodeId, function(m, err)
-- m.positions is {x,y,z,x,y,z,…}; m.indices count from 0
end)
mesh.read("assets/models/chair.glb", function(m) … end)
Works on a model node, a map mesh, a built-in primitive, or a file by path, and reads through the exact same code the navmesh bake uses — so a tool can never measure one shape while the level paths on another. Terrain declines and says why: it is a field meshed per chunk at the detail it is viewed at, so "the terrain's triangles" has no single answer.
Your own typeface
Ship a font in your package and name it:
fonts: [ (name: "Heading", path: "fonts/YourFace-Black.ttf") ]
gui.font("Heading", function() gui.heading("Lumen") end)
.ttf, .otf and .ttc. It needs no permission — the file is inside your own
package folder, which require already reads. A face is chosen for the length of
a closure rather than switched on, so it cannot outlive the panel that wanted it,
and a face that fails to load draws in the editor's type and says so once in the
Console rather than drawing boxes.
Follow a long job live
http.stream reads server-sent events, so a progress bar can move instead of
stepping every two seconds:
local s = http.stream(url, {}, function(name, body)
bar = body.percent
end, function(ok, why)
if not ok then fallBackToPolling() end
end)
The closing callback fires exactly once whatever ended it — you stopped it, the connection died, or the server has no stream at all — because a tool that has to fall back to polling needs telling, once.
Take somebody to a place
ed.lookAt(vec3(12, 0, -4))
ed.lookAt(hit.point, 3)
The same move the F key makes, aimed at a point instead of at the selection.
Any tool with a list of places in it — a search result, a lint hit, a measurement
— needed this; the alternative was selecting a node in order to move a camera,
which is an edit to somebody's selection made behind their back.
And a smaller one
scene.info(id).ui now says what a UI node actually is — button, slider, text,
image, scroll, panel — with its label and whether it is interactive. A tool that
wants to find every button in a screen no longer has to guess from node names.
Under the hood
The scene mirror a package reads is rebuilt only when the scene actually changes, rather than every frame. Panels that walk the node list are cheaper on a large level whether or not they use anything above.
Upgrading
Nothing to do. Scene files, prefabs and scripts all load and save exactly as before; the new light fields are only written when you use them.
Read the notes
A script that builds your level stops getting slower the more level there is — spawning into it, and re-measuring its navmesh a room at a time instead of all of it. Tools can read the navmesh. And two things every package was writing by hand are now one line each.
Build as much as you want
If your game builds its own world — a streamer loading chunks as the player walks, a dungeon generated at the start of a run, a building that comes apart — you may have noticed it getting heavier as the world got bigger. Not at any one moment; just steadily worse, until a level that ran fine at the size you tested it stutters at the size you shipped it.
That was the engine's fault, in four places at once. Spawning a node did work proportional to how much scene already existed — rebuilding a table of every node to add one, re-importing every model in the level to account for one new prop, walking the whole scene to turn an id back into a node. Each is fine for a bullet. A thousand of them to build one chunk is not.
Here is the same thousand-node chunk, spawned into scenes of three different sizes:
| scene | before | after | |
|---|---|---|---|
| 1,000 nodes | 1154 ms | 47 ms | 24× faster |
| 4,000 nodes | 3476 ms | 51 ms | 68× faster |
| 8,000 nodes | 8643 ms | 54 ms | 161× faster |
The point is not the last column, it is the shape of the first two. A chunk is the same thousand nodes every time — it should cost the same whatever it lands in, and now it does.
Nothing to change on your side. If you built any of the usual workarounds —
parenting a chunk to one node so unloading it is a single destroy, baking a
rotation into a prefab so no spawn needs a callback, rationing spawns across
frames — they all still work, and you can start taking them out. spawn with a
callback is no longer the expensive one.
Tools can see where a character can walk
Editor packages get nav: the shape of the walkable surface, what kind of ground
each piece is, where the links are, and every query a running script has.
ed.onSceneDraw(function()
local a, n = nav.areas()
handles.color(0.3, 0.8, 1, 0.5)
for i = 0, n - 1 do
local o = i * nav.AREA_STRIDE
handles.wireCube(vec3(a[o+8], a[o+9], a[o+10]),
vec3(a[o+3] - a[o+1], 0.05, a[o+4] - a[o+2]))
end
end)
It is the reading half only. No agents, no obstacles, no opening and closing links — there is no game running for those to act on, and a hole cut in your project's own navmesh would be a level edit made by a panel.
Two things went with it that a running script wanted as well. nav.ground()
lists what the level calls its kinds of ground, so a filter saying
avoid = {"water"} can be written by reading rather than by guessing — a guess
that misses used to read as "nothing to avoid" rather than as a mistake. And
nav.offLinks() reads back every Nav Link as data: a script could shut a
door, but nothing could list the doors.
A bake reads a level, not a texture library
If your level changes while somebody is walking through it, the navmesh has to be re-measured — and re-measuring it was doing an enormous amount of work that had nothing to do with navigation.
Reading a model's shape used to read the model's pictures too: every .glb
opened, every PNG inside it decoded to raw pixels, and every one of those pixels
thrown away one line later, because a bake only wants vertex positions. On a
level of a few hundred props that is tens of megabytes of decoding, on the thread
that draws your frame, every time the level changes shape.
Now it reads geometry only, and remembers what it read:
| full read, with textures | 6.82 ms |
| shape only | 0.17 ms |
| shape, already read once | 0.020 ms |
That is one model. A level's worth is the same numbers multiplied by however many props you have, and the third row is what a second bake of an unchanged level now costs.
And a model is baked as the thing it collides as
A prop authored as a model with a static box body collides as a box — and the navmesh was cutting it out of the floor at its detailed silhouette instead. So a character routed carefully around the arms of a chair that it actually bumps into as a plain box, and squeezed through gaps that physics does not have.
The rule was always "what you path on is what you bump into"; a model quietly broke it. A node with ▦ Collider and no Rigidbody still paths on its own triangles, exactly as it collides — nothing changes there.
Worth a look if you have a baked navmesh and props with static bodies. Their footprint in the navmesh will change — to the shape your characters were always colliding with. Press Bake and have a look at the overlay.
Re-measure one room, not the whole building
The other half of it. When your level changes shape, its navmesh has to be measured again — and until now "again" meant all of it, however little had moved. So the cost of building a chunk grew with how much level was already loaded, which is backwards: the amount of new level per chunk is the same every time.
buildTheChunk(cx, cz)
nav.rebake(vec3(cx * 32 + 16, 0, cz * 32 + 16), vec3(32, 16, 32))
That re-measures the box and splices the answer into the navmesh in hand, in the same frame, and characters walking through repath onto it.
| one 32 m chunk | the whole level | |
|---|---|---|
| 2×2 chunks | 2.8 ms | 4.1 ms |
| 3×3 chunks | 2.2 ms | 8.1 ms |
| 4×4 chunks | 2.6 ms | 14.0 ms |
The finding is the shape of the last column, not the first. Widen your streaming
radius and a full rebake costs more per chunk; a nav.rebake costs the chunk.
It is not nav.obstacle, and the difference matters. A crate standing in a
corridor is an obstacle: it can be taken away and the floor comes back. A
corridor that has just been built is a rebake: it is the level now. Obstacles
you have carved survive a rebake — re-measuring the ground under a crate does not
move the crate.
You need a navmesh baked once before there is anything to splice into, and the box is snapped outward to whole navmesh cells, so read the result back rather than assuming you got exactly the box you asked for.
Nothing to path on in start()
A note rather than a change, because it costs an afternoon every time somebody
meets it. On a level that builds itself — a streamer, a generated dungeon —
there is no navmesh when start() runs, because there is no geometry yet for
a bake to measure. So this never runs:
function start(node)
if nav.ready() then self.agent = nav.agent(node) end -- not yet, and never
end
…the agent is never made, every routing call falls through to whatever your script does without one, and nothing errors. Ask each frame until you have one.
Waiting, without writing a clock
local poll
poll = ed.every(2, function()
checkTheJob(function(done) if done then poll:cancel() end end)
end)
ed.after(seconds, fn) runs something once, later. ed.every(seconds, fn) runs
it again and again. Both hand back a handle with cancel().
Every package that waited for anything was writing the same four lines: keep a
deadline, compare it against ed.time() from inside onUpdate, remember to take
it down afterwards. A repeat keeps its period rather than drifting a frame at a
time, and it never tries to catch up — a minute spent in a modal dialog costs you
one firing, not a hundred and twenty.
Randomness worth signing in with
ed.randomBytes(n) gives you bytes from the operating system.
math.random is a generator seeded from the clock. That is exactly right for a
puff of smoke and exactly wrong for anything somebody gets to guess at — a
sign-in challenge, a nonce, a token, an id that must not collide. A package could
not build one out of what it had, so the choice was the wrong tool or not doing
the job.
Upgrading
Nothing to change. nav.areas() gained an eleventh number per rectangle — which
kind of ground it is — appended after the ten that were there, so anything
written against nav.AREA_STRIDE keeps working. That is what the constant is
for.
Read the notes
Sun shadows over a plain floor. One fix.
Your floor is not striped
Turn sun shadows on in a scene made of meshes — a plane, a character standing on it — and the ground came out crawling with dark bands. Not a shadow of anything: stripes, ruled across open floor, with no clean edge where the real shadow stopped and the lit ground began. Adding a terrain made it go away, which is the worst kind of clue.
The shadow ray could not tell "nothing is in the way" from "I ran out of road." A ray that gives up partway through its search is treated as blocked — which is right, because a ray that spends its whole life pressed against something is inside something. But a ray that leaves the scene entirely stops early too, and it stops for the opposite reason. Both were read the same way, so a shot of open sky came back as full shadow.
It only showed up in a scene with no terrain, no blobs and no baked level geometry — where a plane and a character are the only things that cast at all, which is to say in an ordinary game scene. Every scene with a hill in it hid the bug completely.
Fixed in the shadow march. Nothing to change on your side: open your project and your floor is even.
Upgrading
Nothing to do. If you turned sun shadows down, off, or reached for dither the penumbra to disguise this, you can put those back where you want them — they were never the cause.
Read the notes
Drop a crate in a corridor and your characters walk round it, without the level being measured again. The Scene view's floating panels stop covering the scene. And the package browser reads like the rest of the engine.
Something is standing there now
Two lines, and the ground under a crate stops being ground:
local crate = nav.obstacle(box.position, vec3(1, 2, 1))
-- …and when it is picked up again
crate:remove()
Every character walking a route through that space works out a new one, in the same frame. Nothing is rebaked, nothing is saved, and nothing has to have been prepared for it.
This is what "bake again when the level changes" was never going to be good at. A rebake measures your whole level — every triangle, every floor, every gap — which is exactly right when the level has genuinely changed shape, and enormous overkill for a barrel. On a 256 m level a rebake takes about 460 ms and a crate takes about 0.6 ms. Both are still here, and they are for different jobs: put a building down, rebake; put a crate down, carve.
nav.obstacles() counts what is currently cut, and nav.clearObstacles() gives
the whole level back in one call — for a wave ending or a level resetting,
without anything having kept a list.
A few things worth knowing before you use it:
- The hole is grown out to whole navmesh cells, so it is never smaller than
the box you asked for and can be up to one cell bigger on each side. Blocking
slightly more than a crate is a crate; blocking slightly less is a character
walking through one. Read
crate.sizeandcrate.positionfor what was actually cut. - A box in mid-air blocks nothing. A crate on a shelf five metres up does not carve the floor underneath it.
nav.reachablekeeps up. A wall dropped across the only corridor really does split your level in two, and asking whether one side can reach the other now answers no.- Removing gives back exactly what was there. Not something very like it — the same navmesh, so the same question gets the same answer it did before.
- Nothing is written to your project. Like a bake taken while your game runs, the holes are a fact about this session. Press Stop and your level is back.
- Obstacles are boxes and they do not move. Something that moves wants a nav agent, or a rebake — carving every frame is rebuilding every frame, which is the cost this exists to avoid.
View ▸ Navmesh shows the holes while your game runs, so a unit that went the long way round is explained by the picture rather than by a guess.
The Scene view's panels get out of the way
The little gizmo strip in the top right was pinned to the corner of the window, not the corner of the Scene view — so unless the viewport happened to run to the edge of your screen, it sat on top of the Inspector, or the tab strip, or whatever else lived up there. It is inside the viewport now, where it belongs.
Both floating strips — the tool palette on the left and the gizmo bar on the right — now do everything you would expect a floating panel to do:
- Drag it anywhere by the
≣grip on its left. - Dock it to a corner — right-click the grip, or use the
▾button, and pick top left, top right, bottom left or bottom right. - Fold it away with
⏶. It shrinks to a small named tab, still one click from coming back, and stops covering the thing you are editing.
Where you put them is remembered between sessions, and neither of them ever moves on its own: a docked panel stays welded to its corner, and a floating one keeps the exact spot you dropped it at, measured from the viewport rather than from the window, so dragging a dock divider carries it along instead of leaving it behind. The ▦ Model tool's status strip rides under the tool palette wherever you have put it.
The package browser looks like the engine
📦 Packages ▸ 🌐 Browse has had a pass over its structure rather than its colours. Four sizes of text instead of the four unrelated ones it had, so a package's name, the section you are in, the description and the fine print each look like what they are. One card treatment, hairline-ruled, with the open one marked in your theme's own accent. Package ids, version numbers, engine ranges, revisions and the catalogue address are set in monospace, because they are things you copy and compare character by character.
And Install is the loudest button on a package again. It was competing with a solid button for editing a review you had already written, which is backwards. Colour now appears for four things and nothing else: a rating, a permission a package is asking for, a version that will not run on the engine you have, and the one primary action.
Everything reads your chosen theme, so this looks right on Floptle Dark, Midnight, Slate, Carbon (OLED) and Light rather than on one of them.
Upgrading
Nothing to do. Existing bakes, scenes and packages are untouched, and no file format changed.
If you have been rebaking during play to account for something being put down,
nav.obstacle is likely what you actually wanted — but the rebake is still
there and still correct, so there is nothing to migrate in a hurry.
Read the notes
Bake a navmesh once. It is saved beside the scene, it comes back when you open the project, and if it ever cannot be read the engine makes it again rather than asking you to. Plus a navmesh that keeps up with a level while you build it, and an overlay that shows your links and your painted ground.
Your bake stays baked
"I have to bake my navmesh again every time I reopen the project." That was real, and it is fixed.
A bake has always been saved beside its scene. v0.60 added areas and links to what that file holds, which changed its layout — so every bake made before v0.60 stopped being readable, and the editor reported it as no bake yet, which is exactly what a scene nobody has ever baked looks like. The work looked undone, so the only thing to do was do it again, every session.
Three things now:
- A bake says which version wrote it, so this can never be silent again. A file that cannot be read is named, with what happened to it.
- The editor makes it again for you, in the background. A bake is a function of the level and the level is open — pressing a button to recompute something the editor could recompute itself is not a fix. One line in the Console says it happened. (A scene you have never baked stays unbaked. That is your call.)
- The Nav Mesh node names the file the bake came from —
saved in scenes/level.1.fnav — it loads with the scene— so "did that actually save?" is answered by looking rather than by experiment.
A navmesh that keeps up while you build
There is a new switch on the Nav Mesh node: bake again when the level changes.
It is off by default, because a finished level's bake is a file that loads with the scene and never needs doing twice. Turn it on while you are building — or in a game that puts buildings down while it runs — and the volume watches what it would bake, waits for it to stop moving, and bakes on another thread. The editor keeps its frame rate, and so does a running game.
That closes the gap v0.60 named: a building placed during play now turns up in the navmesh, and units start walking round it. (Nothing carves a hole in an existing bake in place yet — a rebake is still a whole rebake, which is a fair answer at room and building scale.)
A bake made while your game is running never touches your project. It describes what that session spawned or knocked down. Press Stop and your level's own navmesh is back exactly as it was.
A volume that misses your level says so
A Nav Mesh box smaller than your level bakes perfectly, and gives you a navmesh of one corner of the map. It counts polygons, it draws, characters walk on it — right up to an invisible edge in the middle of the floor, where they stop.
Now the bake compares the box against what it was handed, and says both numbers: the volume covers 24 × 32 m of a level that spans 846 × 538 m, so 12,300 of 12,900 triangles were left out of it. In the Console, and on the node, which is where you look when a character will not go somewhere. A new Nav Mesh node still arrives with fit the box to what it finds ticked — it measures your level instead of trusting a number, which is why it is the default.
In a script, agent.offMesh tells the two apart:
if unit.blocked then
if unit.offMesh then
-- there is no navmesh where you sent it: a bake problem
else
-- it cannot get there: a level problem
end
end
Both look identical from outside — a character standing still — and they have nothing in common.
See your links and your painted ground
View ▸ Navmesh now draws more than the walkable surface.
Nav Links appear where the bake resolved them, not where you dropped them: cyan for a crossing that works, grey for one that is switched off, and red for one whose end missed the floor. That last one does nothing, forever, and used to look exactly like a route that simply preferred the long way round.
Painted areas are drawn in their own colour, so "did my mud volume actually cover the puddle" is a thing you look at rather than bake and guess.
The RTS example pathfinds
rts_unit.lua and rts_commander.lua — click to select, drag a box, right-click
to send a squad — are on the agent layer. The unit keeps the same names
(moveTo, stop, isMoving, selected) and gains isBlocked() and
remaining(), plus a red selection ring when one gives up. Setup instructions
are at the top of each script, where you read them as you attach it.
With no navmesh in the scene they still work, walking straight at the order, exactly as before. Adding a Nav Mesh node should be what makes units clever, not what breaks a scene that has not got one.
Orders now land on the navmesh before they are given, so a squad told to stand near a wall does not hand half its units a destination inside it.
Upgrading
Nothing to do. Open a project whose navmesh was baked by an older version and it will be rebuilt for you, once, in the background.
Read the notes
Units that walk the level themselves, in three lines of Lua. Plus an undo that gives you back the thing you were working on, a save indicator you can always see, and level geometry you can carry between scenes.
Your units find their own way
Floptle could already answer questions about a level — where can I stand, how do I get there, how far is it. None of it walked anybody anywhere, so every project wrote the same follow-the-path loop, and getting it subtly wrong is what makes AI look broken.
Now there is an agent:
function start(node)
unit = nav.agent(node, { speed = 6 })
end
function update(node, dt)
if ordered then unit:moveTo(target) end
end
That is a working unit. It finds its own route, walks it smoothly rather than
corner to corner, goes around the units beside it, slows down at the end, and
tells you what it is doing (unit.state is idle, moving, arrived,
blocked or crossing). There is no step function — the whole crowd is
advanced once a frame, after your update, so an order given this frame is
acted on this frame.
Sixty units through one two-metre doorway all get through. That is the case a crowd layer either handles or deadlocks on, and it is the one this was built against.
Three nodes in the ✚ Add menu (also on the Hierarchy's ✚ New, and as Add child on any node):
-
⬚ Nav Mesh — the volume that gets baked. Tick auto bounds and it measures the level for you. It bakes what a character would collide with, so a wall you built today blocks a path today without anyone tagging it.
-
⇄ Nav Link — a ladder, a jump-down, a door, a lift. Two points with a cost, and a switch:
nav.link("front door", false)closes it and everybody walking to it works out another way, with nothing rebaked. An agent reports which link it is on and how far across (unit.link,unit.linkProgress), so the climb animation has something to run off. Give a link a duration and a slow lift takes the time it should. -
▨ Nav Area — a box that paints ground as a kind:
mud,water,road. One bake serves a whole cast, because the difference between a guard who takes the road and a zombie who wades the river is in the question, not the level:nav.agent(node, { filter = { avoid = { "water" }, cost = { road = 0.5 } } })A box can also carve — a hole nothing walks in, whatever it thinks of the ground.
The queries are all there too, in world coordinates: nav.path, nav.nearest,
nav.onMesh, nav.reachable, nav.distance (how far it is to walk, which is
the number a decision is actually made on), nav.raycast — the walker's answer
rather than the collider's, so a ledge this character would fall off reads as a
wall — and nav.random for wander and patrol points.
There is a tutorial in the 🎓 Learn tab, and the full write-up says plainly what it does and does not do yet.
What it does not do yet
A building placed during play does not appear in the navmesh until you bake again. That is the honest gap for an RTS, and it is the next thing on this list. The bake is also whole-level and synchronous — fractions of a second at the sizes tested, still a pause on a very large one — and agents move on the frame clock, so a rollback-netcode game should drive movement itself and use the queries.
Undo gives you back what you were working on
Undo used to drop your selection every time, which made it feel like it had gone one step too far — you would undo a move and be left holding nothing, unsure whether the move had gone or something else had.
Two things changed. Undoing an edit leaves the node selected, so you can undo and redo a nudge and watch it move. And picking a node is now its own undo step: Ctrl+Z walks back through what you selected as well as what you changed, in the order it happened, so you can retrace your steps rather than jumping over them.
Selecting is history, not an edit. It does not mark your scene unsaved, and it does not throw away your redo — undo a move, click another node to check something, press Ctrl+Y, and the move comes back.
You can always see whether your work is saved
The scene view had a saved indicator, which was no use with the scene view
closed. There is now one at the right of the menu bar, on every tab: a quiet
✔ saved, an amber ● unsaved the moment you change something, and a green
glow when a save lands. Hover it to see which file it means; click it to save.
It also tells the truth about the whole save now. A scene writes several files —
terrain, paint, map geometry — and if any of them failed to write, the indicator
stays ● unsaved and says so, rather than reporting a clean save while an hour
of sculpting sits only in memory.
Take a level's geometry into another scene
The blockout you build with the ▦ Map tool is stored in maps/, and those
files are now proper assets rather than something to scroll past.
Select one and the Inspector shows what it holds: which scene it belongs to, the shapes and their sizes, and a top-down floor plan drawn from the geometry itself. Double-click opens the scene that owns it.
Drag it into the viewport — or right-click ▸ Add to scene — and that geometry arrives in whatever scene is open: every shape a fresh, independent copy with its names, placement, materials and collision intact, grouped under one node and undoable in one step. Build a corridor once and reuse it; lift a room out of a prototype into the real level.
Fixes
A link that could be crossed in half a second took as long as the walk it
replaced. A Nav Link's duration was collected and never used, so a four-second
lift was crossed at walking pace and the animation that followed it ran at the
wrong rate.
agent:teleport moved nothing. It cancelled the order and left the character
where it was — so a respawn put a unit back on its feet exactly where it died.
Changing an agent's filter mid-game did nothing until the next bake. Handing a unit a boat and telling it water was fine now takes effect immediately, rather than silently waiting for a rebake that a shipped game never performs.
A mistyped agent option was accepted and ignored. nav.agent(node, { speeed = 3 }) now says so, and suggests what you meant, instead of quietly walking at the
default speed.
Two copies of a Nav Link shared one id. Duplicating, pasting or prefabbing a
link kept the original's id, so nav.link(...) only ever reached one of them and
a route across the second could arrive at the first one's far end. Copies get
their own ids, and a scene file with a genuine clash says which links clashed.
An area named on a link but not painted by a box did nothing. Tagging twenty
links jump and excluding jump excluded nothing, silently. Naming an area
anywhere now registers it. A stray space no longer makes "water " a second
kind of ground that no filter can find, and a second box that names a different
cost for the same area now says so instead of quietly losing it.
A box that carved a hole could be un-carved by a painted box overlapping it. Blocking is a fact about the level; a decorative paint volume cannot cancel it, whatever order they were made in.
A navmesh from an older version of Floptle read as no navmesh at all — no outline, no paths, every character standing still, and nothing said why. It now tells you the bake could not be read and to press Bake.
Asking for the nearest walkable point with no distance limit searched one
corner of the level. nav.nearest(p, math.huge) could come back empty on ground
it was standing on.
A crowd could jam short of a shared destination. Only one unit can stand on the point you clicked; the rest now settle around it instead of grinding against each other, and a unit pinned by its own crowd rests and tries again rather than giving up for good.
Units walked slightly faster than their own speed, by an amount that changed
with your frame rate — each one was pushing away from where it had been at the
start of the frame.
Upgrading
Nothing to do. Existing projects open unchanged; navigation is opt-in — add a Nav Mesh node from the ✚ Add menu and press Bake.
If you have a navmesh baked with an earlier version, open its scene and press Bake once — the editor will tell you if it needs it.
Read the notes
A kit of real assets to start a project with, and a package browser that can actually show you what a package is.
Something to put on screen
There is a gap between installing an engine and having anything in front of you, and it is usually filled by a grey capsule. Fofighter Sample Kit is the other thing you could put there — characters, music, a display face and a procedural sky, lifted out of a real game and released into the public domain.
Find it under 📦 Packages ▸ 🌐 Browse. In it:
- Five models. Four stylised characters — one of them rigged — and a prop. One of them is authored lying down, in a defeated pose; that is the asset, not a broken import.
- Four music tracks: a fight theme, a menu loop, a story beat and an exposition cue.
- A display face, blocky and pixel-gridded. Latin, digits and common punctuation; check it against your own strings before using it for body text.
- A sky shader driven entirely by one number. Point a Skybox node at
ashfalland dragburnfrom 0 to 1 to go from a cold void, through embers, to a skyline appearing in its own firelight, to flames taking the roofs, to the whole sky alight.
It is CC0: no attribution, no conditions, commercial use fine. Ship it, cut it up, sell it. The kit contains no code of any kind — no editor extension, no game script — so installing it adds files to your project and does nothing else.
A gallery you can actually look at
Package screenshots were being shrunk to thumbnail size before they were drawn, which is fine for a grid cell and useless for a screenshot: a wide picture came out as a smudge. Gallery images are now kept at full detail, and clicking one opens it as large as the window allows — Escape or ✖ to close it again.
An installed package draws its own pictures from your disk, so a gallery
works with no network at all, and — the part that matters if you are writing a
package — you can see your own gallery before it is ever published. Point
thumbnail: and media: at files in your package folder and they show up.
What a package holds is now counted, not just listed. An installed package reads 5 models · 4 audio · 1 shaders · 1 fonts, worked out from the files on your disk rather than from anything it says about itself. That is a different sentence from "Art3D", and it is the one that answers what did I actually just install.
Fixes
A package linked from a relative path could not be found. Linking a package in place — the mode you develop one in — read its path relative to wherever the editor happened to be started from rather than relative to your project. The same link worked when the editor was launched from a terminal inside the project and silently found nothing otherwise. It is relative to the project now. Absolute paths are unchanged.
"Most downloaded" is no longer offered when nothing counts downloads. The catalogue does not report install counts, so the sort was a control that appeared to work and reordered nothing.
Upgrading
Nothing to do.
Read the notes
A wall you built is a wall the navmesh knows about — all of it, whatever it is made of and wherever it happens to sit.
Walls block, and they block all the way along
Bake a navmesh through a room you had built and the walls did not all arrive. Some sections blocked, some blocked in patches, and some were passed straight through as though nothing was there — with no pattern to it that you could act on, because the pattern was where your walls happened to fall against a grid you cannot see.
A wall has no height. The bake worked by asking each piece of geometry how high it was over the middle of each square of ground, which is the right question for a floor and a meaningless one for a wall: seen from above, a wall is a line. So it answered nowhere, and the only parts of your wall that registered at all were its top and its bottom — and only in the squares whose middle happened to land inside them. A thin wall missed every one.
The bake now reads what a piece of geometry occupies rather than what it is over. A wall records the solid it fills, foot to top, in every square it touches, and solid ground inside it is no longer ground. In practice:
- A wall blocks along its whole length, at any thickness — including thinner than the cell size, which used to be invisible.
- Where it sits against the grid stops mattering. The same wall nudged 3 cm gives the same answer.
- A doorway is a doorway: the navmesh runs through the gap and stops at the jambs, and closing the gap closes the route.
- The middle of a thick wall, pillar or plinth is filled in rather than left as a patch of floor stranded inside it.
This is the same for everything you can build a level out of — the Model tool, imported meshes, box and sphere primitives, and terrain. Nothing to configure; there is no "treat this as a wall" setting, because there was never a question about what a wall is.
Ground too narrow to notice
The other half of the same problem: ground thinner than the bake's cell size could fall between the squares and be dropped. A catwalk, a kerb, a narrow ledge along a drop — there, in the level, and absent from the navmesh. They are baked now.
Steps and kerbs are unchanged in behaviour and worth a re-check anyway: a lip under your step height is still walked over rather than around, and one above it is still a ledge.
The Model tool is called the Model tool
The viewport toolbar still said map. It says model, like the ▦ Model tab it
belongs to, and so do the messages that go with it.
Upgrading
Re-bake your navmeshes. Existing bakes are still loaded and still work; they just contain the old answer, and the walls they were missing stay missing until the bake is run again.
One thing to expect when you do: the navmesh is fitted to the bake's grid, so it now reaches up to a cell past the true edge of a floor before the agent radius pulls it back in. With a sensible cell size — half your agent radius or less, which the bake tells you if you have strayed from it — the result sits inside your floor as it always did.
Some rooms will come out with more separate areas than before, because they genuinely have more: geometry that used to be walked through is now walked around. If a path you relied on has stopped existing, the wall it went through is the reason, and the navmesh overlay in the Scene view shows you where.
Read the notes
The navmesh now looks like the ground your characters walk on. The package catalogue shows you what a package actually is. And the editor opens the way you left it.
The navmesh is a surface, not a pile of boxes
Baking a navmesh used to draw every rectangle the bake had cut the floor into. That is honest about the maths and useless to look at: one continuous room came out as dozens of floating outlines, and the question you were actually asking — are these two pieces of ground joined? — was the one it could not answer. Two rectangles a character can walk between looked exactly like two it cannot.
Now it draws the walkable surface. Filled, one colour per connected area, with an outline only where the ground actually ends. The seams where the bake divided one floor are gone, because they were never real; a room reads as a room and a hole reads as a hole.
Where two elevations are genuinely connected — a step, a kerb, a ramp a character can take — that join is drawn as the surface joining them. This is what makes the settings legible. Drop max slope below the angle of a ramp and you watch the mezzanine come away from the floor it was part of: one continuous run becomes two separate elevations, in the Scene view, as you drag the slider. Raise step height past a lip and the two ledges knit together.
If you want the bake's working back — every rectangle it cut, to judge whether your cell size is too coarse — View ▸ Navmesh ▸ ⊞ …and the rectangles it was cut into puts it under the surface.
Asking the navmesh the questions you were actually asking
nav.path answered "how do I get there". These answer the rest:
nav.raycast(from, to) -- can I just walk straight at it? nil means yes;
-- a point means the walk stops there
nav.reachable(a, b) -- is there any way at all
nav.distance(a, b) -- how far it is to WALK, not how far it looks
nav.onMesh(p) -- am I still on the floor
nav.regionOf(p) -- which island of the level this is
nav.random(math.random(), math.random()) -- somewhere to wander
nav.settings() -- the character the mesh was baked for
nav.raycast is the one worth knowing about. A physics ray hits geometry, which
says nothing about whether the ground beyond is somewhere this character could
stand — a ledge it would fall off is empty air to a ray and a wall to a walker.
This gives you the walker's answer, so "close enough to just charge at it"
becomes one call instead of a sweep and a guess.
nav.distance matters more than it looks. The straight-line distance to
something on the far side of a wall is a lie, and "chase the nearest one" built
on it picks the wrong one every time.
nav.random takes your two random numbers rather than drawing its own. That
is deliberate: rollback re-simulates a tick, and a wander destination has to come
from the same seeded stream as everything else that tick decided.
For anything that needs the shape itself, nav.areas() and nav.links() hand
back the polygons and the portals between them as flat arrays of numbers —
nav.AREA_STRIDE and nav.LINK_STRIDE give you the step. Flat rather than one
table per area because a real level is thousands of areas, and that is the
difference between a call that works and one that runs the editor out of room.
A catalogue that can hold art
Packages started as editor extensions, where a name and a paragraph describes one fairly. That is no use for a texture kit, a model pack or a library of sound effects — and a texture kit described in words is a texture kit nobody installs.
A package can now show itself. In package.ron:
(
id: "com.you.brutalistkit",
name: "Brutalist Kit",
version: "1.0.0",
categories: [Art3D],
thumbnail: "media/icon.png",
banner: "media/wide.png",
media: [
(image: "media/overview.png", caption: "Every piece in the kit"),
(video: "https://youtu.be/…", poster: "media/poster.png"),
],
)
categories is one or more of EditorTool, Scripts, Art3D, Art2D, Audio,
Shaders, Vfx, Ui, Fonts, Template, Data — a package can honestly be two things,
and an environment kit shipped with the tool that places it is one package rather
than two. The long tail — "low-poly", "pixel", "PBR" — stays in keywords, which
is searched.
🌐 Browse is a grid. Thumbnails, filtered by category, sorted by recently updated, highest rated, most reviewed or name, with every count telling you how many rows that filter would actually leave. Packages with no picture get their category's mark rather than a broken frame.
Alongside the shelves an author picks, you can filter by what a package demonstrably holds — has models, has audio, has editor tools — worked out from the files it ships rather than from anything it claims. I need SFX is a different question from show me what somebody filed under Audio, and this answers the first one.
"Highest rated" now means it. A single five-star review no longer outranks a package with ninety reviews and a track record. The score on screen is still the plain average of the reviews you can read; only the ordering knows the difference between confident and lucky.
Nothing here is required. A package with none of it lists exactly as it does today, and an older catalogue browses exactly as it did.
One honest note on timing: the editor can read all of this now, and you can put
it in your package.ron today. The catalogue on fopull.com starts carrying
thumbnails and categories shortly — until it does, Browse is the new grid with
the old information in it, and packages draw their category mark rather than a
picture. Nothing you write now is wasted; it is read the moment the catalogue
serves it.
📦 Packages is a tab
It was a floating window over the scene you were judging a package against, which meant closing it to look and opening it to act. It is now a dock tab like any other: drag it beside the viewport, split it under the Hierarchy, leave it where you want it. Window ▸ 📦 Packages opens it.
The editor opens the way you left it
Panel layout and window size, position and maximised state are all remembered between sessions. If you work with the Inspector wide, or the Console pulled out beside the viewport, that is how it comes back.
Two ways out, under Window:
- ⟲ Reset layout — every panel back where it starts, and the saved one forgotten, so it stays reset.
- ⟲ Reset window size — back to a size that is definitely on screen.
A window saved on a second display that is no longer plugged in opens where you can see it, rather than off the edge of the world.
Upgrading
Nothing to do. Your first launch after updating uses the default layout and starts remembering from there. Existing bakes draw the new way immediately — there is no need to re-bake.
Read the notes
A navmesh could quietly miss the floor. And several things decide where they apply from a box or a radius you could not see — now you can see them.
A navmesh no longer loses the floor
With fit the box to what it finds turned off, a bake could come back with the tops of your furniture and almost nothing to walk on between them.
A floor is usually a couple of very large triangles, and their corners are nowhere near the box you would draw around a room. The bake kept a piece of geometry only when one of its corners was inside the volume — so the floor was thrown away for being too big, while every small object standing on it was kept. The result looked like a level that was somehow not walkable.
Anything overlapping the volume is now baked, whatever size it is. If you have a navmesh that came out in scattered patches, re-bake it.
Areas of effect are drawn
View → gizmo menu → Areas of effect draws the volumes that were previously only a number in a panel:
- Reflection probes — the box, and the fade as a second box inside it. The gap between the two is the crossover, which is why one outline was never enough to show it.
- Light probe volumes — the box the baked bounce applies inside.
- Navmesh bounds — the box a bake covers, which matters most when you have turned fit the box to what it finds off to bake one room of a larger level.
Each outline is scaled by its node exactly the way the volume is, so what you see is the volume that is actually used.
Sound range is drawn
Sound range in the same menu draws two rings around every positioned sound: the inner one is full volume, the outer one is silence, and the gap between them is the fade. Placing a sound and knowing how far it carries used to be two numbers and a walk around the level to check.
Sounds set to play flat draw nothing. They ignore position entirely, so a ring around one would say something untrue about it.
Upgrading
Nothing to do. Both are on by default and can be turned off per category in the gizmo menu, like every other gizmo.
Read the notes
Characters can now be told to walk somewhere, and they work out how to get there. And reviewing a package is a button rather than a sign-in.
"Walk to that door"
Add a Nav Mesh node, press Bake, and the engine works out every place your character can stand and how to get between any two of them.
local route = nav.path(self.node.position, door.position)
if route then
for _, corner in ipairs(route) do walkTo(corner) end
end
nav.path hands back the corners to walk — not a step-by-step crawl through the
level, but the few points where the route actually turns. Across an open room
that is a straight line. Around a corner it is one turn, hugging the inside of
the bend the way a person would.
It also tells you whether it reaches the goal:
local route, arrives = nav.path(here, there)
if route and not arrives then
-- as close as the ground allows: the far side of a gap, the near
-- side of a chasm. Walking there and stopping is usually right.
end
That is different from getting nothing back. Nothing means an end was not on the navmesh at all — off the level, or inside a wall. A route that does not arrive means the place exists and you cannot walk to it. A character who walks to the edge of a chasm and stops is behaving; one who stands still because the answer was empty looks broken.
nav.nearest(point) drops a click, a spawn, or a knocked-back character back
onto walkable ground. nav.ready() says whether this scene has a bake at all.
What gets baked
Whatever your character would collide with. That is the rule that stays right as a level changes — a wall you build today blocks a path today, without anybody remembering to tag it. Terrain counts without being marked.
Narrow it two ways:
- Layers on the Nav Mesh node. Tick
Leveland only that layer is baked. Nothing ticked means everything. - Navmesh Exclude, a component you add to one object. It comes out of the bake whatever else is true of it — a glass floor collides, and is not ground.
The bounds size themselves
Fit the box to what it finds is on by default, so you never measure a volume by hand. The bake looks at everything your filter selected, moves the node to the middle of it, and sizes the box to match. Untick it if you want to bake one room of a larger level.
The character it is for
Four numbers, and they are the four that describe walking:
| radius | how wide it is — ground closer than this to a wall or a drop is not walkable, so a path can be walked by something with a body rather than by a point |
| height | how tall it is — ground with less headroom is not walkable |
| max slope | the steepest floor it will walk up |
| step height | the tallest lip it steps over rather than walks around — what makes a staircase one place and a ledge two |
Cell size is the fifth, and the only one about speed rather than about the character: how finely the level is sampled. Halving it quadruples the bake. It also has to stay small next to the radius, and the Inspector tells you in words, with the number to use, when it is not — a coarse cell quietly closes narrow gaps, and "my corridor vanished" otherwise gives no hint which setting did it.
Seeing it
The bake draws in the Scene view whenever its node is selected, or always from View → Navmesh, with a colour per connected area.
That colouring is the useful part. The question you will have is "why won't it walk over there", and two different colours meeting at a doorway answers it instantly: that gap is too narrow for the character this was baked for. Widen the door, or bake a smaller character.
The Inspector counts what a bake would cover before you press anything, so an empty filter and an empty bake are not the same screen.
Reviewing a package is a button
Write a review appears on any package you have installed and enabled, and opens that package's review page with the package already picked. It becomes Edit your review once you have written one, alongside the version you wrote it against — "you reviewed v1.0.0 — you have v2.0.0 now" is worth knowing about your own review.
Reviewing happens on fopull.com now rather than in a panel. That is where your account already is, and it means the button works without signing in twice.
fopull.com/packages is a proper catalogue too: search across name, id, author and keywords, and sort by recently updated, highest rated, most reviewed or name.
Upgrading
Nothing to do. A scene with no Nav Mesh node behaves exactly as it did.
A bake is saved beside the scene and loads with it, so it is worth committing alongside your scenes — and worth re-baking after you move walls, since a bake does not know the level changed.
Read the notes
Anybody can publish a package, and nobody vetted it. So the catalogue now says that plainly, and shows you what people who actually ran a package thought of it.
The catalogue tells you what it is
🌐 Browse now says, in as many words, that packages are made and managed by their authors rather than by Fopull — that listing is automatic, that the checks are structural, and that a package is code that runs in your editor.
It is not a warning sticker. It is the honest description of what a listing means, said once where you are deciding, because "it was on the official catalogue" is otherwise very easy to read as "somebody vouched for this".
Reviews, from people who ran it
Every row shows a score and how many reviews it is made of — or no reviews yet, which is drawn as its own thing, because a package nobody has got to is not a package everybody disliked.
Open reviews on a row to read them. Each one says which version it was written against. That matters more than it sounds: a glowing review of 1.0.0 tells you very little about the 3.0.0 you are about to install.
Writing one
You can review a package once you have it installed and enabled. That is the whole gate, and it is the point — a package sitting in your project switched off has not been tried, and reviewing it would be reviewing something that never ran.
Pick a rating out of five. Words are optional — a rating on its own is a perfectly good review — up to 2000 characters, counted as you approach it rather than refused after you have written past it. The version you have installed goes with it, and posting again replaces your earlier review rather than stacking a second one: you are allowed to change your mind about a package that got better. Remove my review takes it back.
You are already signed in
The editor now knows your Floptle account, because it is the same account as the Hub. One entry in your operating system's keyring is shared by the Hub, the editor and every game you run, so signing in anywhere signs you in everywhere, and staying signed in is the default rather than something you re-do.
The Packages window shows who you are, and lets you sign out or sign in as someone else. Nothing else in the editor requires an account, and nothing sends anything anywhere until you post a review.
Upgrading
Nothing to do. Scores and reviews appear as the catalogue starts carrying them; a package with none shows none.
If you point the catalogue at your own registry, its reviews come from there too. Posting only ever goes to fopull.com — the editor will not send your access token to another host.
Read the notes
A package you installed from somewhere else does not run until you say so.
Installing something is no longer the same as running it
Installing a package from the catalogue, or from a Git URL somebody sent you, used to load and run its code immediately. What the package had asked for — network access, the run of your project's files, your browser — was only visible afterwards, folded away in its row.
That is the wrong order. A package that declares any permission now arrives installed but switched off, with what it asked for shown on its row in 📦 Packages. It starts running when you tick the box, and that tick is the decision.
A package that declares nothing is enabled on arrival, as before. It can read its own folder and nothing else — the same standing every built-in tool already has — and a confirmation nobody can act on only teaches people to click through the ones that matter.
Packages installed from a folder on your own disk, or linked while you write one, are unchanged. You chose those file by file.
Why now
Listing on the catalogue is automatic once a submission passes its checks. That makes the moment you tick that box the last point at which anybody looks at what a package wants — so it needed to be a moment, rather than something that had already happened.
Also
The permissions table in the packages guide claimed the list was shown before a package was installed. It was not; it is now shown before one runs, which is the part that matters. The guide says what actually happens.
Upgrading
Nothing to do. A package you have already installed and enabled stays enabled — this changes what happens when a new one arrives, not what you have already decided.
Read the notes
Renaming a scene used to leave its terrain behind.
A scene keeps its work when you rename it
Terrain fields, the blockout map, vertex paint and autosaves are all stored under
the scene's name. Renaming a scene moved the .ron and nothing else, so all of
it stayed behind under the old name and the scene reopened with an empty terrain.
That is the worst possible way for this to fail, because an empty terrain looks exactly like a terrain you never made. Nothing announced it, and the obvious reading of the situation — the work is gone — was wrong.
A rename now moves all of it together. Two details that matter as much as the fix:
- If anything would land on a file that already exists, the whole rename is refused and names the file in the way. A half-finished rename would leave one scene reading another scene's terrain, which is worse than not renaming at all.
- If the scene you renamed is the one you have open, it follows. Otherwise the next save would write the terrain straight back under the old name.
A terrain with no data says so
Opening a scene whose terrain data is missing now tells you, in the Console, naming the file it looked for — instead of quietly handing you a flat slab.
And if the data is sitting there under a different scene's name, it says that too, and names the file. A rename is not the only way a field can go missing, so this stands on its own; but when a rename is the cause, the message is the answer.
If this already happened to you
Your work is very likely still on disk. In your project's terrain/ folder, look
for files named after what the scene used to be called:
terrain/<old name>.1.cfield the terrain itself
terrain/<old name>.palette its texture slots
maps/<old name>.map.ron the blockout map
paint/<old name>.vpaint vertex paint
Rename each to the scene's current name — <old name> becomes <new name>, the
rest of the filename stays exactly as it is — and reopen the scene. Copy rather
than move if you want to keep the originals until you have checked.
If you are not sure what the scene used to be called, open it and read the Console: it now names the orphaned file for you.
Upgrading
Nothing to do.
Read the notes
Six small additions to the editor API packages use, all found by writing a real package against it.
Widgets a tool actually reaches for
gui.helpBox(text, "info" | "warn" | "error")— a boxed note. A tool with something to tell you should look like it is telling you something.gui.flexibleSpace()— pushes the rest of a row to the far end, for a toolbar with something on each side.gui.height(px, fn)— the partner togui.width. What you use to keep an input row at the bottom of a panel instead of letting a scrolling list walk it down the page.gui.textField(value, hint, grabKeyboard)— passtrueas the third argument and the box takes the keyboard that frame. A panel opened with a shortcut can now be typed into straight away, rather than needing a click first, every time.
Which way a thing is facing
scene.info(id).extents is a node's oriented half-extents in world units.
Read it with rot when orientation matters, which is most of the time for a
placement, measurement or export tool.
scene.bounds is now the world-aligned box around that oriented box rather than
around its bounding sphere — so a crate turned 45° reports a wider bounds than
its extents, correctly, and a square-on wall no longer reports a box big enough
to contain a sphere it never filled.
Hashing
bit — LuaJIT's bit library — is available to packages. It is pure integer
arithmetic with no route out, and it is the difference between a package being
able to compute a hash and not. A tool that uploads something wants to know
whether it has changed since last time; one that signs you in through a browser
needs a challenge.
Upgrading
Nothing to do. Every addition is new; nothing existing changed shape.
One clarification now written down: keep a package's library files outside
editor/. Everything under editor/ runs on its own when the package loads,
so a module kept there executes once as a script and again when something
requires it. require resolves from the package root, so lib/ beside editor/
is the layout that works.
Read the notes
Floptle now has packages: folders of tools, scripts and art that anybody can write, install and share. And the editor has an API of its own, so a package can add menus, panels and viewport tools that sit beside the ones that ship with it.
Install a package
Window ⏵ 📦 Packages.
A package is a folder. Point the editor at one and it is copied into your
project; paste a repository URL and it is cloned and copied the same way; or open
🌐 Browse and install from the catalogue. What you installed is recorded in
packages.ron at the project root, so a teammate who clones the project gets the
same list.
Every package can be switched off without removing it — one checkbox. That is the first thing to try when the editor starts behaving oddly, and it deletes nothing.
A package can hold any of:
- editor tools — menus, panels, viewport overlays, gizmos
- scripts your game attaches to nodes, exactly like your own
- assets — models, textures, prefabs, scenes, effects, shaders, tilesets
- samples — example scenes and demo art, copied in only when you ask, so a package can carry a big demo without every project paying for it
An assets-only package is a perfectly good package. It needs no code at all.
Address anything inside one with pkg://<package id>/<path>, which keeps working
wherever that package's folder ends up — including inside an exported build.
Write a package
📦 Packages ⏵ ✚ Add ⏵ New Package. Give it an id and a name and you get a folder that already runs: a manifest, an editor script that draws a panel, and somewhere to put scripts and art.
While you are working on one, use 🔗 Link folder instead of installing it. Nothing is copied — the project reads the package where you are writing it, so one edit reaches every project you have linked it into. Press ⟲ Reload all and your changes are live.
The manifest is short. Three fields are required:
(
id: "com.you.grasstools",
name: "Grass Tools",
version: "1.2.0",
description: "Scatter and paint grass on terrain.",
engine: ">=0.55.0",
dependencies: [ (id: "com.you.core", version: "^1.0") ],
permissions: [Network],
)
A package says what it may reach for, and you see that before you install it.
Network, Files and Browser — and a package that declared none of them can
read its own folder and nothing else. This is not a label: a capability a package
did not declare is simply absent from its code, not merely refused when it is
used.
Dependencies are worked out for you, including load order. A package that will not load — a missing dependency, an engine version it cannot run on, a mistake in its code — is skipped, loudly: the project still opens, the row in 📦 Packages says what happened, and everything else keeps working.
docs ⏵ Packages is the full guide.
Add your own tools to the editor
A package's editor/*.lua runs in the editor, with an API the game never
sees.
local brush = 2.0
local panel = ed.window("Grass", function()
gui.heading("Brush")
brush = gui.slider(brush, 0.1, 20, "radius")
if gui.button("Scatter here") then
ed.undo()
scene.create("Grass")
end
end)
ed.menu("Grass/Brush…", function() panel:show() end)
ed.shortcut("Ctrl+G", function() panel:toggle() end)
ed.onSceneDraw(function()
handles.color(0.3, 1.0, 0.5)
handles.wireDisc(ed.camera().pos, vec3(0, 1, 0), brush)
end)
What that gets you:
- Panels and menus that sit in the editor's own menu bar, and keyboard shortcuts.
- Overlays pinned inside the Scene view, for a tool you want to hand while you work.
handles.*— lines, boxes, spheres, discs, arrows, filled shapes and text drawn in the world, for regions, paths and measurements.gui.*— buttons, sliders, text fields, dropdowns, colour pickers, collapsing sections, scroll areas, and direct painting for charts and heatmaps.- The scene, readable and editable: names, transforms, hierarchy, bounds, tags, layers, attached scripts — with a real undo point, so Ctrl+Z undoes what your tool did.
scene.raycast, for finding the ground under a point, picking what is in front of the camera, or snapping something to a surface.- Somewhere to remember things: per person, per project, or until the editor quits.
http.*, for a package that declaredNetwork— never blocking, so a slow server cannot freeze the editor — including a loopback listener for signing in through a browser.
An error is reported to the Console once and the callback that raised stops being called, rather than raising sixty times a second. The panel shows what went wrong; fix the file and press ⟲ Reload all.
docs ⏵ Editor scripting is every call. Scene Report in the repository is a small, complete package that uses one of everything — install it from disk and read it.
Publishing
A public Git repository with a package.ron in it can be installed today, by
anybody, from the URL. To appear in 🌐 Browse, submit it at
fopull.com/packages. If you would rather not
publish, point the browser at a catalogue of your own — the URL is a box under
Registry.
Upgrading
Nothing to do. Projects with no packages are unchanged, and no existing file gains a field.
One thing worth knowing if you write scripts: a package can ship Lua your game
attaches to nodes, and your project's own scripts/ folder is always searched
first. Installing a package can never change what a script name in your project
already means.
Read the notes
Mirrors that are actually mirrors, a panel that says which part of a frame is slow, and interiors that render two and a half times faster than they did.
Mirrors can be mirrors now
A polished surface came out frosted, and no setting would fix it. Roughness 0 was sharp; anything above it fell off a cliff. A material at roughness 0.1 — a surface you have asked to be nearly a mirror — was being read from a blur eight times wider than it should have been, so the only way to get a crisp reflection was to sit exactly on zero, and the moment you nudged the slider you were back to frosted glass. It was easy to make something look like brushed steel and near impossible to make something look like a mirror.
The blur a reflection is read through now follows the size of the highlight the surface actually has. Sharp materials stay sharp across the whole polished end of the slider, rough ones blur as before, and the two ends finally meet in the middle.
Two more things came out of the same work:
- A reflection of something close stays sharper than a reflection of something far. The same polished floor should mirror the chair leg beside it and blur the far wall — that difference is how far the light travelled, and it is now part of the picture. Frosted glass gains the same behaviour: a pane held against a wall frosts it less than the same pane held across the room.
- Reflections at a distance no longer fall apart. A mirror ball looked right up close and wrong from across the room, because close up the reflection came from the scene in front of you and further away it fell back to the probe. Both ends of that hand-off are now sharp, so the change of source stops being something you can see.
Reflection detail
Project Settings ⏵ Rendering ⏵ Reflection detail. A ◐ Reflection Probe captures the room around it as a single picture spanning a whole turn, and the size of that picture is the finest thing a mirror standing in that room can possibly show. It was fixed, and it was small — about one and a half degrees per pixel, which puts a doorway across a room on roughly four of them. Below that, no roughness setting helps, because the detail was never captured.
Four levels, high by default and four times the detail it used to have. The cost is paid when a probe captures, not every frame — standing still, all four cost exactly the same — so this is a load-time and memory trade rather than a frame-rate one. ultra is there for a hero mirror.
Two mirrors facing each other
A pair of facing reflective surfaces used to climb into a white blob. Not settle somewhere bright — climb, over about a second, brighter every frame, until whatever they could both see was blown out. A reflection is drawn from the picture the frame before it, and that picture already contains its own reflections, so two mirrors pass the same light back and forth and a polished metal gives almost none of it up on the way.
Lighting ⏵ brightness cap is the ceiling that makes it settle instead. Ordinary highlights sit well underneath and are untouched; set it to 0 if you want the old behaviour back.
Where the time goes
Window ⏵ ⏱ Frame timing. Until now the engine could tell you a frame was slow and nothing about which part of it was — so the only way to find out was to switch features off one at a time and watch the number, which is slow, and which under vsync doesn't work at all, because every answer comes back the same.
The panel measures each part of the frame on the GPU and lists them longest first: the sky, the depth pass, opaque surfaces and lighting, 2D lighting, glass, the post chain, the editor's own drawing. Nothing is measured while the panel is shut, so leaving it closed costs nothing.
The first thing it was pointed at was a Backrooms-style interior, and it named the culprit immediately.
Interiors render 2.6× faster
Volumetric fog was most of the frame — in that scene, six milliseconds out of eight, on a scene with four ceiling lights in it. Three things were wrong, and all three are fixed:
- The fog asked every lamp for a full surface-lighting calculation and then used two numbers out of it. There is no surface in mid-air — nothing to be facing the light, no highlight to place — but the air was paying for both, once per lamp, per step along the ray, per pixel.
- Lamps out of range were evaluated and then discarded. The range test came after the expensive part, so a lamp at the far end of a level cost as much as one overhead.
- The fog's drifting texture was sampled forty times per lump. It is tens of metres across and was being asked for a fresh value every fraction of a metre along every ray.
Fog looks the same. It costs about a seventh of what it did.
Two smaller ones alongside, both worth knowing because they describe scenes rather than settings:
- A scene with the sun turned down still paid for sun shadows — every interior, in other words. The shadow trace ran per pixel and the result was multiplied by black.
- Screen-space reflections ran on every surface, including the ones with no reflectivity at all, because the "how reflective is this" multiplier was applied after the work rather than before it. Rough surfaces now stop tracing early as well, since past a point the result is blurred into what the probe already says.
Children that went missing from the Hierarchy
Anything parented to something that isn't a folder disappeared when you reopened the project — a Reflection Probe under a plane, a light under a mesh — and nothing would bring it back. The row showed no arrow to expand, so it read as though it had nothing inside it, and a child added afterwards vanished the same way.
They were always still there and always saved; there was simply no way left to reach them. Reopen an affected scene and its children are back, in place, untouched.
Upgrading
Nothing to do. Reflection detail defaults to a level four times finer than before — if a project is tight on memory and never looks closely into a probe, low restores the old size.
Read the notes
Two bugs behind "it looks right in one view and wrong in the other", and the answer to "why is this tiny scene running at 20 fps".
The Scene view and the Game view agree again
Reflections, contact shadows, shoreline foam and lamp shadows could be missing from one view while working in the other, and could start working on their own after you resized a panel. All four read the same hidden pass, and the editor was handing it out per view — but not always to the right view.
Two separate faults, both fixed:
- The Scene view could be drawing with the Game panel's depth. Once both views existed, the Scene view stopped refreshing its own copy and used whatever the Game panel had left: another camera, at another size. Reflections landed in the wrong place or vanished, and any resize made them briefly correct again — which is exactly what it looked like.
- The Scene view skipped the pass entirely in some scenes. If your scene had contact shadows on but not reflections or lamp shadows, the Game panel ran the pass and the Scene view did not, so contact shadows appeared in one and not the other.
Nothing to change in a project. If you have been squinting at a difference between the two views, look again.
Why a small scene ran at 20 fps
A scene that costs 8 milliseconds a frame was presenting at 20 fps, and there was no way to see that from inside the editor. The frame was not slow. The display path was accepting one frame in three.
Two things come out of that.
Project Settings ⏵ Rendering ⏵ Frame pacing. How finished frames reach the display is now yours to choose:
- smooth (vsync) — every frame shown, in order, at the display's cadence. The default, unchanged, and the one whose motion is most even.
- uncapped, no tearing — render freely; the display takes the newest frame each refresh. No cap, no tearing, but movement can judder, because the frames you see sampled the world at moments unrelated to when they appear.
- uncapped — present the instant a frame is ready, tearing and all. The setting for "how expensive is this frame, really".
If your frame rate is pinned to a round number no matter what is in the scene, try one of the other two. On the machine this was found on, a window doing nothing but painting itself a solid colour sat at 20 fps under vsync and ran freely under either of the others.
And the window title now shows what a frame actually costs, beside the rate:
Floptle Editor — first — 20 fps (8.2 ms/frame) — 23 nodes …
Those answer different questions. A low rate with a small cost is the display pacing you; a low rate with a large cost is a scene to optimise. Time spent waiting for the display is measured and kept out of the cost, so the two never get confused again.
Worth knowing
Reflections of the scene are a switch you have to turn on. A shiny material shows the sky and the lights without it; showing the room is Lighting ⏵ reflections, and it is off until you set it. If a floor is reflecting less than you expected, that is the first thing to check — and indoors, pair it with a ◐ Reflection Probe so what leaves the frame comes back as the room rather than as daylight.
Upgrading
Nothing to do.
Read the notes
Three things reflections and shadows could not do last month, all of them the same shape: the engine only knew about what the camera could see. A mirror indoors reflected daylight through a sealed ceiling. A torch stopped casting the moment you turned away from the wall doing the blocking. A pane of glass hid everything behind it, including other glass.
A mirror indoors shows the room
Add ⏵ ◐ Reflection Probe.
Turn reflections on and a polished floor shows the table standing on it — but only the part of the table that is on screen. For everything else, until now, it showed the sky. Outdoors that is right: a reflection that leaves the frame is heading for the horizon. Indoors it is daylight arriving through the ceiling of a sealed room, and there was nothing you could do about it.
A reflection probe is what a room reflects. Drop one in, size its box to the walls, and every reflective surface inside it shows the room instead of the sky.
The box does two jobs, and sizing it to the room is what makes both work. It says which surfaces the probe speaks for, and it is what makes a reflected wall land on the wall — without it a reflection is a picture at infinity and slides about as the camera moves.
- size — the room, in metres. Move and scale the node like anything else.
- edge fade — how far outside the box the room gives way to the sky. Give a doorway a metre or two so walking out of it crosses over smoothly.
- strength — how much of the capture to apply. 1 is what was measured; this is the knob for a room that reads too busy or too dim in its reflections.
Up to four probes are live at once and they blend where their boxes overlap, so a corridor opening into a hall does not switch environments in one step. With more than four in a scene the nearest four to the camera are the ones you get.
find("Hall"):getcomponent("ReflectionProbe").intensity = 0.6
Nothing is saved to disk. The picture is taken when the scene loads and again whenever you move or resize the probe, which takes a fraction of a frame — so there is no file to go stale and nothing to remember to rebake. What a probe cannot notice is the room being relit or the furniture being moved: there is a recapture button on the node for that.
A lamp stops at a wall you are not looking at
A Point Light ⏵ casts shadows — same switch, twice the shadow.
Last release gave lamps shadows, with one honest limit stated plainly: an occluder the camera could not see could not cast. Turn away from a wall and the shadow it was throwing stopped existing, which is the most alarming way a shadow can behave.
A casting lamp now also checks the scene's own geometry, not just what is drawn on screen. A wall blocks a torch whether you are facing the wall or facing away from it. Between the two halves, the shadow keeps the exact silhouette of what is in frame — every railing and bolt of it — and stops guessing about the rest.
Nothing to turn on. Any lamp already set to cast picks this up.
The soft edges come for free, and correctly. How sharp a shadow is depends on how big the light looks from the surface, so a wide sphere lamp close up throws a soft edge and a bare point throws a hard one — with no knob for it, because there is a right answer.
What blocks a lamp is anything the scene knows the shape of: terrain, blobs, and anything carrying a collider. A decorative mesh with no collider is not in that list; give it one — or stand any collider shape in for it — and it casts.
One thing worth re-checking: a scene with the sun's shadows switched off and a lamp set to cast was quietly missing half its occluders. It is not any more, so a level lit only by lamps may have shadows in it that were not there yesterday.
Glass behind glass
Lighting ⏵ glass layers.
A see-through surface works by looking at a picture of what is behind it, and that picture has to be taken before the surface is drawn. One picture means one correct layer — the nearest — so a fish tank's back wall stopped existing the moment you looked through its front one. The advice was to build it as one box rather than six.
Glass layers is how many depths of see-through surface can be looked through at once, 1 to 4. At 2 — the new default — a window can have a bottle standing behind it, a tank can be six panes, and a pair of glass doors reads as two doors.
Each layer costs one more pass, and only while something see-through is actually in view. At 1 you get exactly what the engine did before, for exactly what it cost.
find("Lighting"):getcomponent("Light").refractionLayers = 3
Two panes at the same distance still show only the nearer one, so 4 is a ceiling rather than a promise — but the case that sent people to the manual, one pane in front of another, works now.
Upgrading
Nothing to do. Glass layers goes to 2, which costs nothing in a scene with no glass in it. Reflections still show the sky until you place a probe. Lamp shadows are still off per lamp until you turn them on — but a lamp already casting will now be blocked by more than it was, which is the point.
Not in this one
- Probes stream, but only four at a time. A level with twenty rooms wants probes swapped in and out as you walk; today the nearest four are live and the rest wait their turn.
- A probe does not notice a room changing. Relight it or move the furniture and press recapture.
- A reflected object does not reflect. A probe captures the room around it, not the mirrors in it, so a mirror facing a mirror shows the room rather than a corridor of itself.
Read the notes
A floor shows the room standing on it. A crystal ball shows the room through it, upside down. A lamp stops at the wall. And the Game panel finally shows the same game whether it is docked in the corner or filling the window.
Mirrors that show the room
Inspector ⏵ Lighting ⏵ reflections.
Set a material to physical shading, turn metallic up to 1 and roughness down to 0, and you have a mirror. It shows your scene's sky — a skybox image, a sky shader, or the built-in star vault — and, with the scene's reflections switch on, it shows the scene as well. A polished floor reflects the table standing on it. A wet street reflects the sign above it.
Raise the roughness and the same reflection blurs. That one slider is the difference between chrome, brushed steel and a dusty ball bearing.
Reflections are a scene-wide switch on the Lighting node rather than a per-material one, because whether a mirror shows the room is a fact about the scene, not about one surface. What each material still decides is how much it reflects and how sharply — there is a new reflections slider beside metallic for that. It is at 1, the real amount, and it is there for the times physics is not what you want: pull it down to take the sheen off something reading too glassy, or push it past 1 on a hero prop that deserves to be flattered.
node:setMaterial{ shading = "physical", metallic = 1, roughness = 0, reflectivity = 1 }
find("Lighting"):getcomponent("Light").reflections = 1
Three knobs sit under the switch. Reach is how far a reflection sees — a puddle showing a building across the street needs more of it than a floor showing the table on it. Steps is how carefully it looks; raise it with the reach, or reflections start missing thin things. Thickness is how solid the scene is assumed to be: too little and reflections come out speckled with holes, too much and railings and leaves smear over what is really behind them.
Only what is on screen can be reflected. Anything behind the camera, off the edge of the frame, or hidden behind something nearer falls back to the sky. That is the shape of the technique rather than a setting, and it is why the sky reflection underneath still matters: it is what makes a reflection fade into the horizon instead of into a black patch.
This changes how existing physical materials look, and that is the point: they were computing a reflection and had nothing to reflect, so anything metal came out far darker than it should have. Materials on the default (classic) shading are untouched.
Glass, and things you see through
Inspector ⏵ Material ⏵ see-through.
A crystal ball now behaves like one. Turn see-through up and the scene behind the surface comes through it — bent by bend (the index of refraction), frosted by roughness, and tinted by the material's own colour, so green glass makes what is behind it green. A solid ball at a real index of refraction is a lens, and turns the room behind it upside down.
This is a different thing from opacity, and worth keeping straight. Opacity fades a surface away and takes its highlight, its reflection and its bright grazing edge with it — at 0.2 you have almost no surface left. See-through keeps all of that and lets the light through as well, which is what actually makes something read as glass rather than as a ghost.
- bend — 1.0 does not bend at all, 1.33 water, 1.5 window glass, 1.8 heavy crystal, 2.4 diamond.
- thickness — how far light travels inside the material, in metres. Set it to roughly the size of the object. How far the distortion actually throws what is behind also depends on how far away that is: glass against a wall barely shifts it, the same glass held up against a distant one throws it right across.
- roughness frosts it — the same slider that blurs a reflection blurs what you see through.
node:setMaterial{ shading = "physical", transmission = 1, ior = 1.6, thickness = 1.2 }
A scene with no glass in it costs nothing for this: the extra work happens only when something in view is actually see-through. The one limit worth knowing is that only the nearest layer of glass distorts — two overlapping panes show the front one's bending, so a fish tank wants to be one box rather than six.
Lamps that stop at walls
Inspector ⏵ a Point Light ⏵ casts shadows.
Every placeable light in the engine used to shine through everything. A torch in a doorway lit the room behind the door exactly as brightly as the one it was standing in, and there was no setting anywhere that changed it.
Now a lamp can cast. Tick casts shadows on the light and it stops at the walls, crates and characters between it and what it is lighting.
It is per lamp and off by default, because most lights in a level have nothing to be blocked by — a strip under a counter, a glow inside a sign, a fill light placed exactly so — and each casting light costs something. The ones worth turning on are the ones a player can walk around.
Same rule as reflections: it shadows from what is on screen. A wall casts while it is in frame and stops when you turn away from it.
find("Torch"):getcomponent("PointLight").shadows = 1
The Game panel is the game
A docked Game panel showed a different picture from the same game fullscreen. Contact shadows, shoreline foam, reflections and lamp shadows were all missing from it — not dimmed or approximated, simply absent — so the small panel you work in disagreed with the window you test in, and only the window was telling the truth. Every one of them works in both now.
And the border is gone. The panel had a band of the editor's own view of the scene showing all the way around the game, which moved when you orbited the Scene camera, because that is exactly what it was. The game takes its whole panel now, rendered at the size it is shown at.
Water that knows where the shore is
The built-in water shader foams against anything, not just terrain. Rocks you modelled, a jetty, a hull, a crate someone dropped in the shallows — the waterline finds all of it, with nothing to mark up.
Three knobs where there was one: foam (how far it reaches), foamEdge (how crisp the line is) and foamNoise (how much the waves eat into it, which is what stops it reading as a painted-on band). Thin water is see-through now too, so the edge sells itself.
In a scene made only of meshes this did nothing at all. It worked against terrain and quietly gave up on everything else, which is the worst possible half — the shader looked correct in a test level and blank in a real one. Anything that measures the scene behind a surface was affected the same way, including soft particles and contact glow. Fixed.
If you write shaders, this is surfaceGap() — how much room there is between a
surface and whatever solid thing is behind it.
Copying keyframes
Copying a rotation key used to move the object to the world origin. If a key only set a rotation — which is most keys, on most spinning things — copy read a whole pose anyway and filled in the position and scale it could not find. Paste, and the object jumped to nowhere at scale 1.
A copied key now carries exactly the channels it had. While we were in there:
- Property keys copy. Opacity, light intensity, an image swap — Ctrl+C over one of those used to do nothing at all, and say nothing about it.
- The clipboard answers. ⎘ / ✂ / 📋 buttons sit beside undo, greyed out with the reason when there is nothing to copy or nothing to paste, and every copy, cut and paste says what it did.
- The shortcuts work where you are working. Ctrl+C/X/V belong to the dopesheet whenever your pointer is over it, not only when the tab has been clicked in exactly the right place.
Bones you can grab
Bones are drawn as octahedra — wide at the joint, tapering to the next one — instead of bare white sticks. The shape twists when the bone rolls, so you can finally see which way an elbow is facing, and you can click anywhere along a bone rather than hitting a small dot at its end.
Telling which way is which when you rotate
The rotate gizmo drew three full circles on top of each other, which is a ball of overlapping rings and not much else. Now each ring draws solid on the near side and ghosted on the far side, so you can tell them apart and tell which way each one faces. The object's own X, Y and Z are labelled beside it.
The Model tool
- Ctrl+A selects everything in the mesh you are editing. It was on
U, which nobody has ever guessed. - Ctrl+click selects the path between two picks — click one face and Ctrl+click another, and everything on the shortest route joins the selection. Works on vertices and edges too. (Ctrl+drag still subtracts a box, and Shift+Ctrl+click still removes one thing.)
- Loop cut (
Shift+/) inserts an edge loop across a strip of quads, so a shape has somewhere to bend without its silhouette changing at all. - Bevel (
Shift+') takes the sharpness off the selected edges, with a width you set once. - Ring select (
Shift+\) extends a selection across a strip — the edges a loop cut would run through.
Fixed
- The editor crashed when you opened a shader in the graph. Opening one shader, then a second one, could take the whole editor down. It was not any particular shader: the thumbnails could not tell two freshly-opened files apart and drew one graph using the other's compiled preview.
Upgrading
Nothing to do. Physical materials will look different — brighter and more reflective, which is what they were always asking for. Reflections, lamp shadows and glass are all off until you turn them on, so every scene loads doing exactly what it did.
Read the notes
The PS1-era look is now a project setting rather than something you set on every material you own. And any single surface can now stand outside the scene's fog.
The era's artefacts, for the whole project
Project Settings ⏵ Rendering ⏵ Era artefacts.
Vertex jitter, affine textures, vertex lighting and screen-door transparency used to be four checkboxes on each material. For a game whose whole look is of that era, that meant setting them on every material you own — and on every material you import next week. Now you ask once and the project answers.
They reach everything that draws: models, primitives, tilemaps, map geometry, terrain chunks, characters. Including the surfaces that never named a material at all, which is most of a level.
Jitter is offered as named strengths measured against your own pixel resolution, not a number:
| off | no snapping |
| pixels | one cell per pixel row — what the hardware actually did, and the subtlest setting that still shows |
| chunky | cells twice the size of a pixel |
| heavy | four pixels to a cell. Geometry visibly swims |
They are derived from the project's own pixel rows, so pixels means the same thing whether you render at 240 rows or 480 — you don't have to work out which number is right for your resolution. The raw grid-step number is still there under the presets for anything in between.
Nothing changes for a project that doesn't turn this on. All four start off, and a project file that never touched them loads to exactly the look it has now.
Holding one thing steady
A material can take none of it — Retro artefacts ⏵ project artefacts ⏵ opt
out, or retroExempt = true from a script.
That's for the one thing in a wobbling world that has to sit still: a first-person weapon a metre from the eye, a screen-facing card, a sky shell whose seams the snap would tear open.
A material's own settings still work too, and still win. A jitter you dialled in by hand on one surface stays the number you dialled; leaving it at 0 means "follow the project".
About that wobble
Vertex jitter is a snap, not a shimmer. It runs every frame, but a still camera looking at a still object lands in the same grid cell every time and holds perfectly still — which is exactly what the hardware did. The wobble is what motion looks like through the grid, so move something to see it. If you have been staring at a paused scene wondering whether it is on, that is why.
Each vertex also crosses its own cell boundary on its own frame, so a surface doesn't slide in clean steps — it warps. That's the era's look, not a fault.
A surface can stand outside the fog
New on every material: fog — affected by scene fog, on by default. Turn it off and the surface draws at its own colour however far away it is. Both the distance ramp and the volumetric layer leave it alone.
For the things that aren't really in the world at that distance. A first-person weapon sits a metre from your eye and a hundred metres from the level's origin, so the fog treats it as scenery. A backdrop card or sky shell is painted at its own depth, and greying it out fogs the horizon twice. A marker has to stay readable through the weather that is the point of the scene.
From a script:
node:setMaterial{ fog = false }
A planet's atmosphere is a separate effect with its own controls, and still applies — a world seen from orbit still hazes.
Upgrading
Nothing to do. Every new setting starts at the value that changes nothing, and project and material files written before this release load exactly as they are.
Read the notes
A crash fix, and a change so that the next one like it doesn't end your session.
Scenes with a UI shader open again
Opening a scene whose UI uses a stage ui shader killed the editor before it
drew a frame. If you have a project with a custom meter, gauge or instrument
built as a UI shader, this is the one to take.
It had been broken since the release that made the scene render in real light — the shader's world-space pipeline was still built for the window's 8-bit format while the scene it draws into is now HDR — but you would only meet it once v0.50.0 fixed the textured-menu crash that used to happen first.
Nothing to change on your side.
A rejected draw no longer takes the editor with it
When the graphics driver refuses something, the editor now says so in the Console and carries on rendering, instead of ending the session on the spot. The two crashes above both worked that way: one bad draw, and everything unsaved went with it.
The message names what was refused, so it can be reported or acted on. It is said once, not once per frame.
Crash reports also keep the first failure now. A graphics crash rarely arrives alone — it takes the rest of the frame down with it, and the report used to end up describing the last thing to fall over rather than the thing that pushed it. If you sent one that just said "panic in a destructor during cleanup", that is why.
Upgrading
Nothing to do.
Read the notes
A fix for a crash that killed any game with an image in its menu, and a pass over the shader graph so it shows you what your shader is actually doing — starting with textures, which you previously could not see at all.
Games with a textured menu run again
On 0.49.0, a UI element drawing an image from your project crashed the game on the frame that element first appeared. A menu with a logo, a button with a custom face, an inventory slot with an item icon — the first menu frame, and it was gone. If you have a project that worked on 0.41.1 and died on 0.49.0, this is almost certainly why.
Nothing to change on your side. Open the project and the menu draws.
You can see your textures in the shader graph
A texture slot now carries its own image, picked right on the node:
texture ramp = "art/ramp.png"
Before this, a texture slot was a name and nothing else. Every preview
downstream of it — the slot's own thumbnail, the sample() that read it, the
final colour — showed a grey checkerboard, so the one node whose entire job is
to bring a picture in was the one node you could not look at.
The image is not just for the graph. It binds wherever a material leaves the slot empty, so a shader that samples a texture looks right the moment you assign it, with no material setup at all. A material still overrides it per node, and the Inspector now names the shader's image on an empty row instead of saying "none" — so an empty row never implies an empty binding.
Triplanar previews were also wrong on the graph: they came back as noise regardless of what you sampled. They show the texture now.
The graph tells you what each step is
A new ▣ focus panel on the right shows the selected node large: its type, the op's one-line description, where each of its inputs comes from, and — the part that matters when you are learning — how its preview is being drawn. A float is grey from black to white. A vec2 is red and green. An sdf is a distance read-out, blue inside and amber outside. Without that written down, the thumbnails are a puzzle. With nothing selected the panel watches the output, so the finished shader is always one glance away.
Building a graph got faster to do
- Let a wire go over empty canvas and the node palette opens right there, then connects what you pick, in the direction you were dragging.
- Copy and paste between shaders.
Ctrl+C/Ctrl+Vcarries the whole chunk: the steps your selection reads, plus the knobs and texture slots it reads, re-declared in the destination — unless a name already lives there, in which case it binds to yours. - Ctrl+A selects all, F frames the selection, arrow keys nudge it (hold shift for fine).
- Line up / space out for a selection you placed by hand — ⇅ Arrange re-lays out the whole graph, which is the wrong tool once a graph has a shape you like.
- Hover any input and it tells you what it takes and whether it is required. A coloured dot only says "vec3" to somebody who already knows.
- ? in the toolbar lists every gesture.
Baked lighting is in the Add menu
Baked bounce light shipped in 0.49.0, but the node that carries it was never in ✚ Add — the only way to create one was editing a scene file by hand. Add → ☀ Light Probes, size the box over your space, press Bake.
The Shaders tab stops working when you do
With the graph open, the engine was re-rendering every node's preview every
frame, whether or not anything had changed — and a large graph computes every
one of its thumbnails for every pixel of that image. Now it redraws when the
graph changes, when a texture changes, or when the shader reads time and
genuinely has something to animate. A shader sitting still costs nothing.
Upgrading
Nothing to do. Existing shaders parse unchanged, and a texture slot without an image behaves exactly as it did.
Read the notes
This is everything since 0.41.1, in one release: physically-based surfaces, a finished picture with a real tonemap and a colour grade, bounced light you can bake, air you can see through, lights with a size, contact shadows, and motion blur. Plus four things the editor had been missing.
Nothing here is switched on for you. Every effect is off until you turn it on, and a project opened in this build renders what it rendered before — with two exceptions, both spelled out under Upgrading.
Surfaces made of something
Materials have a Physical shading model with roughness and metallic, and four surface maps: normal, roughness, metallic and ambient occlusion. A glTF-style packed map drops straight in — roughness is read from green, metallic from blue, occlusion from red.
The normal map works on geometry that could never carry a tangent attribute: sculpted terrain, primitives, Model-tool meshes, tilemaps and skinned characters all take one. Metal behaves like metal: no diffuse at all, and the highlight takes the base colour rather than staying white.
A picture, finished
The scene is now rendered in real light all the way through and only turned into a picture at the very end, which is what makes everything below possible.
- Tonemap — Clip, Reinhard, ACES or AgX. How light past white lands on a screen, instead of clipping to a flat white shape.
- Colour grade — exposure, contrast, saturation, temperature, tint, lift, gamma, gain.
- Depth of field — focus distance or a node to follow, separate near and far ranges, aperture blades for polygonal bokeh, and highlight bokeh that keeps a glint a bright disc instead of grey mush.
- Lens — chromatic aberration, distortion, sharpen, denoise, film grain.
- Screen shaders — your own full-screen passes, in order, each one getting
the finished frame plus its depth and normals. A scanline CRT, an outline pass,
a heat haze;
crtScanlines.flslships as a worked example.
Light that bounces
Add a Light Probes volume and press Bake. Light coming off a red wall lands red on the floor beside it, a corner the sun never reaches stops being flat black, and none of it costs anything at runtime beyond a texture lookup.
Knobs that matter: bounces (1 is "light comes off surfaces once", which is most of the difference), intensity (physically-correct bounce is often a little too polite for a game), and leak for the one artefact everybody recognises — light from the lit room next door glowing faintly through the wall.
Air you can see
Fog is lit by the scene. Turn one slider up and the sun standing behind a bank lights it, a lamp carried into it glows around itself, and a shadow crossing it stays a shadow — which is where beams through a window come from.
- lit by the scene — 0 is exactly the flat fog you had.
- forward scatter — why looking toward the sun blooms and looking away stays dim. A mote of fog has no facing, so this does the job a surface normal does.
- shafts, quality, max distance.
Lights with a size
A light can be a shape: point, sphere, rect, disk or tube. A window, a strip light, a bulb with an actual bulb — and the room knows the difference, because a four-metre window lights a wall evenly where a point at its centre leaves a hot spot in the middle and darkness at both ends.
Rects and disks are one-sided, so a window lights the room and not the wall it is set into. The Scene view draws the emitter at its real size with an arrow out of its emitting face, which is worth one look: a rect light aimed at the wall behind it lights nothing, and there is no way to see that in the finished picture.
Where things touch
Contact shadows — the small dark line under a foot, in a seam, behind a bolt. Until now a moving mesh cast its shadow through its collider, so a character's shadow was a capsule's; this traces what is actually on screen, so anything gets its true silhouette with nothing to tag and nothing to bake.
Off by default. Short reach is the point — a third of a metre is a foot on a floor.
Motion blur
New in this release. On the PostProcess node: a shutter, and how many samples the streak takes. 0.5 is the 180° shutter a film camera has and is the one that reads as footage rather than as a smear.
It blurs camera motion — a pan, a whip, a dolly, a roll — which is most of what a player ever sees blurred, and a pan is the case it does best. Something crossing a locked-off shot stays sharp: object motion needs a different piece of machinery that isn't in this release.
It shows in the Game view. The Scene view is left sharp on purpose — placing a prop while the camera is still coasting is not something you should have to fight.
Edit twenty things at once
Select twelve crates and set roughness once. Whatever you change in the Inspector is handed to the rest of the selection, and only what you changed — each node keeps its own colour, its own texture, its own everything else.
It works across kinds: a light and a camera in one selection exchange the things they have in common and ignore the rest. Two lights share an intensity; two cameras share a lens but never each other's play-authority or render-target name, because those are identities rather than settings.
Script tunables travel too, by script and by parameter name — retune speed once
for twenty enemies. Tags travel as the change rather than as a list, so adding one
to the selection adds it to all of them without flattening the tags each node
already had. And ✚ / ✖ on a component now applies to the whole selection: add a
rigid body to twelve nodes, drag one script onto all of them.
Click a bone
Select a rigged mesh and its skeleton is drawn in the Scene view. Click a joint to select it and the transform gizmo poses it straight into the open clip — posing without a round trip through the Hierarchy for every bone.
Only the selected mesh's rig is ever drawn, and the whole thing is under Rig bones in the gizmo menu.
Things stay on ramps
Friction is now a real coefficient. A ramp holds a body while tan(its angle) ≤ friction and lets go above it — 0 is ice, 0.3 lets go at about 17°, 1 holds
exactly 45°, and a grippier surface than that goes above 1.
That one sentence replaces three separate wrong behaviours. Nothing used to hold: gravity added a little downhill speed every step and friction only scaled it, so every object on every ramp crept downhill forever and no amount of friction stopped it. A crate touching a floor and two walls was braked six times as hard as the same crate on open ground. And the same number felt different at 60 Hz and 120 Hz.
Friction now opposes motion instead of capping it, so a shoved crate travels and then stops rather than halting in three frames. A hard landing skids where a gentle one sticks. Sliding down a wall you aren't pushing into doesn't slow you.
A rigid body also gets a slope limit (60° by default, which is what this was
fixed at before). Past it the body is not grounded, the surface reads as
wallNormal rather than groundNormal, and it stops holding the body up — so a
character slides off a cliff face however grippy its boots are.
local rb = node:getcomponent("RigidBody")
rb.friction = 0.9 -- holds a 40 degree ramp
rb.slopeLimit = 45 -- but cannot stand on anything steeper than this
Off means off
Shortcuts that stopped working now keep working. Copy, paste, delete, duplicate, F-to-frame and the animator's own keys could all quietly die until you clicked in the right place by accident — the editor was treating any clicked widget as "the user is typing", so everything that yields to a text field also yielded to a button, a checkbox or a dropdown. Only a real text field takes the keyboard now.
A tile floor you painted solid stays solid after you close the project, and switching a node off actually switches it off.
Upgrading
Two things change on their own. Everything else is off until you turn it on.
Physics. Friction means something different, and it is the meaning it should
always have had. Anything you built on a ramp is worth a look: a slope steeper
than atan(friction) now lets go, where before everything stuck to everything.
If a crate needs to sit on a 30° ramp, give it a friction of 0.6 or more. If a
character was relying on being braked to a stop in a few frames, drive it every
frame or raise its friction — it will now coast.
If a controller sets friction = 1 to plant a character while it stands
still, that used to mean "freeze", and it now means "hold up to 45°". Anything
steeper and it slides. Raise it — 2 holds about 63°, 3 about 71° — or set a
slopeLimit and let the surface decide.
Volumetric fog. A scene already using it arrives lit, with beams on, and it will look different and better. Setting lit by the scene to 0 pins the old look exactly, not approximately.
Everything else — surface maps, the grade, depth of field, baked GI, emitter shapes, contact shadows, motion blur — is off, at a default that changes nothing, until you switch it on.
Read the notes
Tile collision you draw by hand, including slopes. Editing many tiles at once. An autotile picker that tells you which piece you are choosing. And a 2D switch on a rigidbody, so a platformer character is one tick rather than a puzzle.
Slopes
Pick shape under TILE and draw the collider straight onto the tile: click an edge to add a point, drag one to move it, right-click to remove it. The four 45° ramps are one button each.
Points snap to the art's own pixel grid, and that is the part that matters. A slope is built from several tiles whose diagonals have to meet; a ramp ending a third of a pixel above where the next one begins is a character catching on every tile boundary, for a reason nothing on screen shows you. Snapping makes "the corner of that pixel" something you can hit exactly, which is how you were thinking about the sheet anyway.
It collides as the shape you drew — not as the box around it. Concave outlines are fine, so an L-bend keeps its notch. A drawn shape turns and mirrors with its square like every other collider, so one ramp tile serves all four diagonals.
This is the case the tileset deliberately did not have. Offering a slope that behaved as its bounding box would have been worse than offering none, and there was no real shape to give it. There is now.
Edit many tiles at once
Everything under TILE applies to every tile you have selected. Drag a box in the palette, or ctrl-click to pick out tiles that are not next to each other, and set the collision once — the whole sheet's worth of ledges, or the six slope tiles scattered across it.
A selection whose tiles do not currently agree says so, rather than showing you one of them and quietly writing to forty. Tiles being edited get a blue ring and a corner tick; the tile the brush will place keeps the orange one, because those two stop being the same thing the moment you ctrl-click a second tile.
The autotile picker says which piece it wants
Two fixes, and the first is a plain bug.
The neighbourhood diagram was upside down. The little 3×3 picture on each rule — and the sentence under it — had north below the centre. A tile that answers "there is more of this group above me" was drawn and described as below. Picking tiles by what the panel showed built a sheet answering the vertically mirrored neighbourhood of the one you meant, which in a level reads as your art being wrong rather than the table.
And every shape is now named. The armed slot says "waiting for: the top-left
corner", not just a diagram you have to translate. bottom edge, left end,
vertical middle, inner corner, notch at top-right — the words you already have
in your head while drawing the sheet, which is what makes "which of my tiles goes
here" a question with an obvious answer.
Switching between a tile and an autotile
There is a brush row at the top of the palette now: Tile, then a chip per
autotile group. One click each way, and it shows which one is armed.
Before, arming an autotile was a side effect of clicking a tile that happened to belong to one, and disarming it was a side effect of clicking one that did not — with nothing on screen saying which had just happened.
2D rigidbodies
Tick 2D on a rigidbody and it stays in the XY plane: it keeps the depth you gave it, can never be pushed out of the layer, and still spins the one way a flat object spins. That is all "this is a 2D object" means to a solver, and it is one switch instead of working out that it means freeze pos z, freeze rot x, freeze rot y.
Everything else is unchanged, and that is the point: a 2D body collides with the same world a 3D one does. A tilemap's colliders, a slope you drew, a Collidable cube, terrain — all of it, because there is no separate 2D physics engine to be missing features. Gravity, raycasts, touch events and layers behave exactly as they do in 3D.
It adds to the freeze boxes rather than replacing them, so ticking it can only
lock more, and unticking it cannot silently release an axis you locked yourself.
From a script: node:getcomponent("RigidBody").two_d = 1.
A level you painted solid now says why it isn't
If a tilemap has solid tiles and the node is not Collidable, pressing Play warns in the console and names it. A level you can see, with tiles you ticked solid, that a character falls straight through looks exactly like a physics bug — and it is a missing switch that nothing pointed at.
Upgrading
Nothing to change. Existing tilesets, collision and autotile groups load and behave as they did.
One thing to look at: if you built an autotile group by following the panel's diagrams, check it. The diagrams were vertically mirrored, so a sheet picked by eye against the art is fine, and one picked by trusting the little dots may have its top and bottom pieces swapped. The panel is right now, and each slot names the piece it wants.
Read the notes
2D light is smooth now, whatever else your scene is set to. Posterize at any band count, dither on or off, retro on or off — the light will not band, and there is no setting you have to find to get that.
Your palette is quantized. Your light isn't.
Posterize quantizes your palette: the set of values your art is allowed to be. A light is not one of those values — it is a multiplier on whatever value your art is. Until now the two were the same setting, because the quantize was the last thing to touch the frame, and by then a lit pixel and a painted pixel were the same number.
That left no configuration that was right:
| bands | dither | the light |
|---|---|---|
| 8 | off | hard concentric rings |
| 8 | on | a stipple — it reads as a dither pattern, not as light |
| off | — | smooth, and you lose the palette you turned posterize on for |
Every row gave something up. So the quantize moved: it now runs over your art, before any light reaches it. Your tiles land on their bands and your lights ride on top of them. A torch is a soft pool again, an explosion flares and fades, and a dark room gets darker smoothly instead of in five steps.
Nothing to switch on. A scene that has never heard of any of this is correct.
The vignette stopped banding too
The same rule fixes something you may have put up with without filing it. A vignette is a smooth radial darkening, and it was quantized along with the palette for exactly the reason a light was — so it arrived as a set of rings in the corners of the screen rather than a fade.
Everything that is shaped like light now comes after the quantize: the vignette, bloom, and ambient occlusion. Your art still lands on its bands. What was never art stops pretending to be.
Dither is for art
dither the bands is now what it always sounded like: a way to hold a gradient your palette can't — a painted sky, a soft-edged sprite — as a stipple instead of a hard step. It has nothing to do with lighting any more. Turning it on will not change how a light looks.
A warm ramp keeps its hue
PostProcess ▸ step brightness, keep colour still matters, and it has a clearer job now. Quantizing each channel on its own means a warm gradient crosses each channel's band boundary at a different value, so a sunset or a torch-lit wall steps through colours nobody picked — olive where red and green have stepped and blue has not, maroon where only red has.
With it on, the step happens once to brightness and the colour rides along. Per channel is a real look and stays the default; an exactly grey pixel comes out identical either way, so switching it on cannot move art that was already neutral.
Upgrading
Nothing to change, and two things you can now undo.
If you turned on dither to smooth your lighting, turn it back off. It was the least-bad of a bad set of options and it is no longer doing that job — leaving it on will stipple your tiles and nothing else.
If you squashed a light's falloff into one band to stop it ringing, you don't have to any more. The last release recommended setting full out to near the light's range for exactly that reason; that recommendation is withdrawn. It replaced soft rings with one hard disc edge, and the edge was still there with posterize switched off — which is how you know it was a workaround rather than a fix. The controls stay, because shaping a light is a real thing to want. Set them to whatever reads best.
One thing to look at: if your scene uses posterize together with a vignette, bloom, or ambient occlusion, those three are smooth now where they used to step. Your art's palette is unchanged.
Read the notes
2D light stops at walls, comes in the shape you give it, and no longer bands a warm lamp into rings of colours you never picked.
A wall has a dark side
A 2D light is occluded now. Before this, a light in the middle of a room lit the floor, the counter, the shelf, the wall behind the shelf, and the floor on the far side of it, all by the same distance from the lamp. What reached the screen was a disc of brightness with the room drawn on top of it — which is a decal. Light is what you get when something interrupts it.
The control was already there. blocks light, on any 2D node, has been in the Inspector explaining what it would do since 2D lighting shipped. It does it now:
- auto — a tilemap casts exactly where it is solid, from the colliders its tileset already declares. A level's collision is its light occlusion, from one piece of data, so the cover that stops a bullet is the cover that stops the light. The dark wedge behind a block is the safe spot, and a player reads it without being told.
- on — anything casts, including a sprite batch that collides with nothing.
- off — a collidable tilemap stops casting.
Two things it gets right that you would notice if it didn't. A light that skips a sorting layer is not blocked by it — a torch you kept off the background must not have the background throwing shadows into the room. And a wall's lit face stays lit; only what is behind an occluder goes dark.
Nothing is rebuilt when a light moves, which is what makes this usable in a game where every light does — the player's follows them, a muzzle flash is at the gun, a death burst is where something died. The cost follows the lit area, so a pixel outside a light's radius does no work for it, and a scene with no casters pays exactly what it paid before.
For a glow that isn't meant to be a light source, untick casts stop this
light on it, or shadows = false from a script.
A light in the shape you want
Two new controls on a 2D light:
- full out to — full brightness out to this radius, and only then falling
away to nothing at the light's range.
0, the default, starts the ramp at the light. - falloff — the exponent of that ramp.
2is the curve every light has always had.
local torch = find("Torch")
torch:setLighting2D{ inner = 6.4, falloff = 2 } -- range 8: a clean, hard pool
If you posterize, this is not a nicety. Quantizing a smooth radial ramp to N levels draws N concentric rings, and the only way out is to shape the ramp so the whole of it lands inside one band — which you cannot do while the ramp always spans the full radius.
A warm light stops turning into coloured rings
This one is a plain bug, and it cost a release.
Posterizing quantizes each colour channel on its own. A light's falloff is a
ramp, so each channel crosses its band boundary at a different radius — and
a mild warm white at {1.0, 0.86, 0.62}, which is the colour of a torch, a
lamp, a fire and a muzzle flash, therefore landed as a stack of rings in colours
nobody chose: an olive one where red and green had stepped and blue had not, a
maroon one where only red had.
Measured across a light's whole radius, that colour produced no clean brightness step at all. Only hue rings. The report it generated was "it just looks like a circle around the player", which is a completely reasonable way to describe three coloured rings and no way at all to find the cause.
PostProcess ▸ step brightness, keep colour (posterizeChroma from a script)
steps brightness once and carries the colour along, so a light steps in
brightness and keeps its hue. Chroma is never quantized, so the failure cannot
happen.
Per channel is a real look and stays the default — but an exactly grey pixel comes out identical either way, so switching this on cannot move art that was already neutral. If your tiles are neutral by rule, as a 1-bit tileset usually is, you can turn it on and only your lights will change.
The global dither the bands switch is not the answer to this. It also dithers the art the posterize was chosen for, and it is screen-space — so on a scrolling camera the pattern is pinned to the display while the world moves under it, and at a low internal resolution that crawls.
Upgrading
One thing to look at, and it is deliberate: if your 2D scene has a collidable
tilemap and a 2D light, that tilemap now casts. That is what auto has always
said it would do, and it is the whole of the change — but it is a change you
will see. Set blocks light to off on any tilemap you would rather kept
letting light through.
Everything else is off by default. A light with no shaping is the light you already had, and posterize still steps per channel until you say otherwise.
Read the notes
Escape gives you your cursor back while a game is playing, and this time it stays given.
Your mouse is yours again in Play mode
A first-person game takes the cursor — that's the point of one. Getting it back
was the problem: Escape released the pointer for a single frame and the game's
next update took it straight back, because a camera script calls
setMouseLocked(true) on every frame, which is the correct way to write one.
So there was no way to reach the Inspector, the Hierarchy or the Console while your game ran. The only thing that worked was tabbing out of the whole application at the OS level, moving the mouse to where you wanted it, and clicking back in — and the click gave the cursor straight back to the game anyway. Editing anything mid-play was somewhere between tedious and impossible.
Now the pointer has an owner, and you can say who it is:
- Escape takes it. Your game keeps asking for it, and keeps not getting it.
- Click the Game view to hand it over. Your camera picks up exactly where it was; nothing has to be re-established.
- Leaving the window takes it too, so alt-tabbing away and back lands you on a usable pointer instead of a grabbed one.
The Game view says which of you has it, along the bottom edge. That's the part that was really missing: a grabbed cursor is invisible, so the one thing that tells you how to get it back can't be the cursor.
Your game doesn't fight you for it
While the editor holds the pointer, your game reads a neutral mouse — no motion, no buttons, no wheel, and no action bound to any of them.
Freeing the cursor without that is half a fix. The camera script is still reading raw motion, so the view spins the entire way across to the Inspector, and every click on a slider is also a click in the game. Now you can tune a value in mid-flight and nothing moves but the value.
Its keyboard and gamepad are untouched. It's still playing.
What didn't change
input.setMouseLocked(true) still means what it always did — your game says it
whenever it likes, as often as it likes. It's a standing request now rather than
an instruction, and the editor is the one holding the answer. In an exported
build there is nobody to hold it, so the request is simply the answer, exactly as
before.
A game that releases the cursor itself — input.setMouseLocked(false), for its
own pause menu — ends any override at the same time. A menu that opens two
minutes after you pressed Escape gets its clicks.
Upgrading
Nothing to do.
Read the notes
The number you typed is the number that reaches the screen. An opacity, a font, a brightness — this release is mostly about the engine no longer quietly changing your mind for you, plus three things that were visible in the Scene view and missing everywhere else.
Your sprite's opacity is the one you set
A tilemap or sprite batch at alpha 0.72 was reaching the screen at 0.92. One
at 0.5 drew at 0.75. This has been true since v0.38.0, in every 2D project, with
no light placed and nothing switched on — the 2D lighting pass was compositing
your flat surfaces a second time on top of the ones already drawn, so every
authored alpha came out as 1 - (1-a)².
Nothing you could look at disagreed. The value in your script was right, the value in the Inspector was right, and only the screen was wrong — which is worse than a wrong number, because a wrong number gets found.
Opacity is now yours, lit or unlit, at every value.
Check any alpha you tuned by eye since v0.38. If you nudged a value down until a glow looked right, it was fighting this, and it will now read lighter than you left it.
Text in your own font
draw.text had no way to name a font, and an unnamed font meant the engine's
built-in one — so a game whose UI is a pixel font could not draw a single
immediate-mode string in it. That includes an entire game's dialogue.
The symptom is not what you'd expect. It doesn't read as "wrong typeface"; it reads as bad letter spacing, because a layout built on a monospace grid is being drawn proportionally. Wide letters overlap their neighbours, narrow ones leave holes, and the same word is spaced differently depending on which letters are in it.
Two ways to fix it, and the first is the one to reach for:
Project Settings ▸ UI font — a project-relative .ttf/.otf used wherever
nothing names a font: every draw.text, every ui.make label, every element
whose style sets none. Say it once instead of at forty call sites.
-- takes the project's font
draw.text(24, 24, "HP " .. hp, 22, 1, 0.4, 0.4)
-- …and one that insists on something else
draw.text(24, 60, "CHAPTER I", 30, 1,1,1, 1, "left", "fonts/Display.otf")
draw.text also measures with the font it draws now, so "center" and
"right" land where you asked rather than being aligned against the built-in
font's widths.
Pixel fonts stay on their own grid
A pixel font's art is a grid, and it only looks like a pixel font when one of its cells is a whole number of screen pixels. What the engine rasterizes at is your text size times the layer's scale — and that scale belongs to the window, not to you. A 1252-pixel-tall window against a 720-unit design is 1.7389, so a size-24 label rasterizes at 42 pixels: 4.2 pixels per cell, every vertical stem softened by a different fraction. There is no size you could have picked instead.
UI Layer ▸ text snap (or layer.textSnap) rounds every rasterized text size
to a whole multiple of the number you give it — set it to the cells in your
font's em, usually 10. Off by default; a layer that leaves it off is unchanged.
This is the one that gets misdiagnosed, so it's worth saying plainly: nothing is mispositioned. Your layout can be exact integers and the text will still read as badly spaced, because the distortion is inside each glyph rather than between them. If you go looking in your positioning code you will find it correct.
Generating your own pixel font? Check the em, not just the cell. A font built at 1024 units-per-em with cells of
1024 // 10has cells of 0.0996 em, and then no pixel size makes a cell whole and this setting cannot save you.
A script can dim the room
The 2D base light — the brightness every flat surface gets before any 2D light reaches it — could be authored once in a scene and never read, animated or restored. So turning it down was a decision with no undo, which is a good reason not to make it.
The scene's Lighting node is now an ordinary component:
local env = find("Lighting"):getcomponent("Light")
local was = { env.ambient2dR, env.ambient2dG, env.ambient2dB }
env.ambient2dR, env.ambient2dG, env.ambient2dB = 0.35, 0.33, 0.42 -- blackout
-- …and back, from what you read, not from a constant you guessed
env.ambient2dR, env.ambient2dG, env.ambient2dB = was[1], was[2], was[3]
That unlocks the three things it was blocking: a quality setting that parks every light can put the room back to white, a brightness slider can exist, and a scene can dim itself for a boss entrance and come back up.
The rest of the node came with it — intensity, colorR/G/B,
directionX/Y/Z for a day cycle, the shadow knobs, and the whole fog set for a
weather system.
Water, painted shapes and switched-off lights
Three things that were right in the Scene view and wrong everywhere else — the Game view, camera previews, render targets:
- A sea or a pool now draws in the game. Water was gathered only for the Scene view, so an ocean was there while you edited and gone the moment you looked through your own camera.
- A vertex-painted primitive is painted everywhere. A painted cube drew painted on screen and plain in every other view.
- Switching a node off switches its light off. A lamp you disabled in the Hierarchy kept lighting the room — and kept one of the sixteen light slots while doing it.
2D lighting costs nothing until you use it
A flat surface is only prepared for lighting if some live 2D light's layer list actually reaches its sorting layer. A scene with no 2D light placed now does no 2D lighting work at all, and a light restricted to one layer costs nothing for the layers it doesn't name.
That was worth doing because the old answer was everything: one bullet-hell project was preparing 366 sprite batches and about 500 sprites every frame against lights that could reach none of them.
perf.counts().flat2d is that number, so you can check rather than infer. It
reads 0 for a scene that hasn't opted in.
Upgrading
Nothing to do.
Two things to look at if they apply to you:
- A translucent tilemap or sprite you tuned by eye since v0.38 will now be as transparent as its number says — which is more transparent than it has been.
- A tilemap or sprite batch that a 2D light reaches is no longer also shaded
by the 3D sun; the 2D lights are what light it. If you wanted the 3D lighting,
set that node's 2D light mode to
3d.
Read the notes
Two fixes to 2D lighting, both from the same report: a scene with one tileset and one light in it, where the tileset went invisible and blinked in and out as the camera moved.
Placing a light no longer darkens your scene
The base brightness every 2D surface got was full white while your scene had no 2D light in it, and dropped to the scene's 3D ambient the moment you placed one. So your first light took a whole level from fully lit to about 12% brightness in a single step — and the cliff sat exactly where somebody tries the feature for the first time.
That's backwards. Adding a light should only ever add light.
The base is now its own setting: 2D base light, on the Lighting node beside
the 3D ambient it was being confused with. It defaults to white, so placing a
light can only make things brighter. Turn it down when you want a dark room for
a torch to carve a circle out of — which is the thing you'd go looking for anyway
when that's what you're building.
Your scenes are unchanged: anything saved before this loads with the base at white.
Tilemaps no longer blink in and out
A lit tilemap could flicker between lit and unlit while the camera moved, in the Scene view especially.
The lighting pass draws your flat surfaces a second time to work out what to light, and then has to agree with the main pass about exactly how far away each one is. The main pass promises its numbers are computed identically every time; the lighting pass didn't make the same promise, so the two could land a hair apart and disagree about which was in front — differently, pixel by pixel, as the camera moved. Both promise it now.
Also
There is no 2D directional light. Only PointLight is on the 2D path — a Lighting node's direction drives 3D shading only. For a flat scene the 2D base light above is what a "sun" would otherwise be, and it has no direction. This was already true; it now says so in the documentation instead of being something you find out.
Upgrading
Nothing to do. If you had turned your scene's 3D ambient down to compensate for
2D lighting darkening things, you can put it back — the two are separate settings
now.
Read the notes
Your sky can answer to your game. Shaders can work in angles. And four things that were quietly costing you something now say so out loud.
The sky is part of your game now
A procedural sky is a shader, and the whole point of it being a shader rather than a picture is that it can change. Until now it could only change as a function of time — so a sky that catches fire during a cutscene ran on a timer, and revealed the burning city in the middle of whatever sentence the reader happened to be on.
find("Skybox"):setShaderParam("burn", story.progress)
That's it. The value lands the same frame you write it, with no recompile. Drive weather from the match, a day cycle from your clock, a boss arena's sky from the phase, a lightning flash from a hit.
The post chain too. node:getcomponent("PostProcess") reads and writes
bloom, bloomIntensity, bloomThreshold, vignette, vignetteStrength,
vignetteRadius, aoStrength, aoRadius, posterizeBands and
posterizeDither — so a cutscene can push a vignette or dim the bloom without
authoring a second scene.
Shaders can read an angle
atan2(y, x) gives you the angle of a vector over the full circle. It sounds
small and it isn't: it's the first thing you reach for in a radial wipe, a
cooldown dial, a radar sweep, a swirl, a whirlpool, a skyline laid out around the
horizon, or an equirect sample done by hand. None of it was possible before.
let p = uv * 2 - 1
let az = atan2(p.y, p.x) / 6.2831853 + 0.5 // 0..1, once around
output color = vec4(vec3(step(az, fill)), 1.0)
atan, asin, acos, exp2 and log2 came along with it. So did mod(x, y), which wraps at any period where fract only ever wraps at 1 — and wraps
correctly for negative numbers, which matters because half the angles atan2
hands back are negative.
Seeing a sky change over its range. A sky whose whole purpose is that it progresses can't be judged from one frame, and the editor shows one value at a time. There's now a contact sheet: point it at a shader and a uniform and it renders the whole range side by side.
cargo run -p floptle-render --example sky_sweep_probe -- <outdir> myshader.flsl burn 8
One thing to know about color uniforms. uniform tint: color = #0B0A16
arrives as the raw hex bytes, and the frame buffer applies its own encoding
afterward — so dark colours render lighter than the swatch you picked, by quite
a lot. Square the value, or scale it by hand. This is what every colour in
Floptle has always done, so a shader's #0B0A16 and a material's agree with each
other; it's now written down in the shader docs rather than being something you
work out.
Two ceilings that used to be invisible
Sixteen lights reach the screen at once. Past that, the ones that survive are the ones contributing most where the camera is — so the choice holds still between frames instead of shifting as things spawn and despawn, which used to show up as a torch going out for no reason the room explained. A light turned down to nothing no longer takes one of the sixteen, so keeping a handful of lights and parking the spares is a pool that works.
node:setPointLight{ color = {1, 0.8, 0.5}, intensity = 2, range = 8 } makes a
light from a script, which you couldn't do at all before — you could only edit
one that was already in the scene.
One-shot effects have a limit — 256 live at once. spawnEffect had none, so
a per-frame spawn budget cost more than twice as much on a 144 Hz monitor as on a
60 Hz one, and nothing said so. Over the limit the oldest effect goes rather than
the newest, because the one you just asked for is the one the player is looking
at.
And you can see both. perf.counts() now answers lights,
lightsDropped, effects, effectsDropped and voices alongside the rest. The
dropped pair is the point: nonzero means the engine is cutting your look, and
that should reach you as a number rather than as a screenshot.
Profiling can see particles and audio
perf.ms("particles") returned a confident 0.0 — forever, while collection was
switched on. There was no such thing as perf.ms("audio") at all. Both are real
now, and perf.buckets() lists audio in frame order, so anything iterating that
list picks it up without changing.
Editing while your game runs
Moving a node during Play looked like it worked and vanished when you pressed Stop — not undoable, never marked unsaved, nothing said. A red ▶ PLAY banner now sits across the top of the viewport for the whole session, saying exactly that.
It's still allowed, deliberately: nudging a camera while watching a cutscene run is how you find the framing. To keep what you found, open the … menu on the Inspector's Transform, pick Copy values, press Stop, then Paste values. The component clipboard survives Stop, so it lands as a real edit you can undo and save.
2D lighting and sorting reach scripts
node:setSorting{ layer = "Characters", order = 3 }
torch:setLighting2D{ mode = "2d", layers = { "Terrain", "Characters" } }
crate:setLighting2D{ blocks = "on" }
Sorting layers shipped without any way for a script to touch them, which ruled
out a character stepping behind a counter or a picked-up card lifting above the
hand. A misspelled mode tells you what the accepted values are instead of quietly
meaning auto.
Smaller things
- Big tilemaps cost less every frame. The grid was being copied for scripts twice a frame whether or not any script looked at it — 160 KB a frame for a 200×200 map. It's only copied when the map actually changes now.
- The documentation stopped promising pages you can't read. Thirty-three links across the docs site pointed at working notes that aren't published, so a page would tell you to see a roadmap with no roadmap to see. They all point somewhere real now.
Upgrading
Nothing to do. Every scene, shader and script behaves as it did.
If you have a sky driving itself from time as a workaround, you can stop: set
its auto-advance to zero and drive it from your game instead.
Read the notes
2D lights. Put a light in a flat scene and it lights your tiles and sprites, with a real falloff, and only the sorting layers you point it at.
Plus: an autotile shape can hold as many tiles as you like, and one tile can draw as many shapes as you like. Both were impossible before, and both are things you need constantly.
2D lighting
Add a PointLight node to a scene whose active camera is orthographic and it is a 2D light. It lights tilemaps and sprite batches; it does not light meshes. A light belongs to one system or the other, so a 3D prop that wanders into a flat scene is not washed over by your torches.
A surface with no light near it is drawn at the scene's ambient, so a dark room is dark and a torch carves a warm circle out of it.
Nothing is guessed twice
Every node has a 2D light setting in the Inspector: auto, 2d, 3d.
Only auto is decided for you, and it tells you what it decided and why —
auto → 2D — the active camera is orthographic. Set it to 2d or 3d and that
is never revisited, whatever else changes about the scene. A light you
deliberately made 3D will not quietly become 2D because somebody added an
orthographic camera later.
The rule is one fact per side, so it fits in your head: a light is 2D when the active camera is orthographic; a tilemap and a sprite batch are 2D, a mesh is not. Mixing the two is something you ask for, never something you discover.
Which layers a light reaches
A 2D light lists the sorting layers it lights. A new light reaches all of them; untick one to keep it out.
That is the one thing every 2D game wants and nothing else here did: a torch passing over a background without lighting it, so the background stays flat while the foreground moves through the light.
This is not the collision layer mask. A background that collides with nothing and a player that does sort — and light — independently of that.
What a light costs
The lighting is computed once per screen pixel rather than once per surface, so stacking parallax layers does not multiply the work. A light stops exactly at its range instead of trailing off across the whole frame.
Sixteen lights reach the screen at once. Past that, the ones that survive are the ones contributing most where the camera is — so the choice holds still between frames rather than changing as things spawn and despawn, which would show up as a torch going out for no reason the room explains. A light turned down to nothing doesn't take one of the sixteen, so keeping a handful of lights and parking the spare ones is a pool that works.
Not in this one
Normal maps, shadows and height. A tilemap already has a blocks light
setting and already says what it will do — on auto, a tilemap will cast from
the collision it already declares, so your level's collision is its light
occlusion — but nothing casts yet.
Scripts can't reach any of it yet, the same gap sorting layers have. Both close together.
Autotiling: duplicates, in both directions
One tile can draw as many shapes as you like. A plain fill square is usually the answer to several neighbourhoods, and a sheet with a single hand-drawn inside corner wants that one tile in four shapes. Click the shape, click the tile, as many times as you need.
Before this, assigning a tile to a second shape silently moved it, and the first shape went blank. Reported as exactly that: "if I've already assigned it to another rule it removes the previous one."
One shape can hold as many tiles as you like. They are variants — four grass squares that all mean "surrounded" — and which one a square gets is decided by where that square is. So a field varies, and it varies the same way every time you open the level, on every machine. Listing the same tile twice makes it twice as likely, which is how you get a rare flower without drawing nine plain squares.
A shape holding alternates is marked ×N in the RULES grid. Arm it and its tiles appear underneath; click one to take it back off.
The next shape after the first tile checkbox is what makes both work. It moves on only when a shape goes from empty to having something, so your second click stays on the shape you are looking at. Turn it off to stay put entirely.
Assign preset understands variants too: select a whole multiple of the preset's length — 32 tiles for the 16-shape preset — and it reads them as pass after pass, giving every shape two.
Selecting a tile now lists every shape it draws, each one clickable. That replaces the old single-neighbourhood editor, which could only move a tile from one shape to another — the control that made duplicates impossible in the first place.
Upgrading
Nothing to do. Tilesets written before this convert when you open them and keep drawing exactly what they drew; they are saved in the new shape from then on.
A scene that has never touched 2D lighting is unchanged in every particular — including its file, which gains nothing until you use the feature.
Read the notes
A scene in a folder saves back to the file it was opened from. Until now it didn't, and the edits went somewhere nothing loads.
If you keep scenes in folders, read the next paragraph — you may have work sitting in a file you have never opened.
The bug
Open scenes/cutscenes/Opening.ron, change something, press Ctrl+S. The editor
wrote scenes/Opening.ron — a different file, at the top of your scenes
folder. Reopen the project and the original loads, unchanged, so every edit
looked like it had been undone.
Nothing warned, because from the engine's point of view nothing failed. The save
refuses to run during Play, it logs failures loudly, it only clears the unsaved
marker when the write succeeds — and the write did succeed. It said saved
and it meant it. It just meant a file the game never reads.
Reported as: "I save it, close the project, reopen, and the lava is too high again and my movement of the map is back to how it was before." Both edits were in the stray file, intact.
Only the folder was lost — the editor kept the file's name and rebuilt the path
from that, so a scene sitting directly in scenes/ was never affected. Scenes in
scenes/maps/, scenes/cutscenes/, scenes/rooms/ and the like all were.
Your work is probably still there
Nothing was deleted. Every edit went into a real file; it was just the wrong
one. If you have been editing a scene in a folder, look for a file of the same
name directly inside scenes/ — that one holds the work you thought you were
losing, and it is almost certainly the newer of the two.
Floptle now tells you. Opening a project names any scene that has a stray copy like this, in the Console and as a notification. It does not move anything: there are two files, both plausibly the one you want, and an editor quietly choosing between them is how this happened in the first place. Compare them and keep the one you mean.
What changed
Scenes save to the path they were opened from, at any depth, and the folder is
created if it does not exist yet. A scene's name is unchanged everywhere it is
shown — the hierarchy header, the window title, and scene.current() in a
script all still say Opening, not cutscenes/Opening — so a game reading that
name keeps working exactly as it did.
Upgrading
Nothing to do, and nothing changes about a project whose scenes all sit directly
in scenes/. If yours don't, check for stray copies once — Floptle will point at
them when you open the project.
Read the notes
A 2D level draws in the Game view. A tileset carries its own art instead of borrowing a Material's, and holds as many sheets as you drop on it. Setting up an autotile is clicking a shape and then the tile that draws it. And what draws in front of what is a named layer rather than a Z you nudge by hand.
Nothing to do on upgrade.
Your 2D game is visible in the Game view
A tilemap drew in the Scene view and the Game view showed an empty background. Nothing was reported and nothing was wrong with the tilemap.
The editor works out what to draw twice — once for the Scene view, and once for every other view: the docked or split Game view, camera previews, render targets. The second one had never learned about tilemaps or sprite batches. So a 2D game was invisible in the one view that is the game, and the Scene view insisted the level was fine.
It has been that way since the 2D layer arrived, and the same thing happened to blockout geometry before it. The list of things that second path knows about is now exhaustive: a new kind of object cannot quietly fall out of it, and a test catches the other half — something drawn on one path and not the other.
If you read v0.36.0's note about an orthographic camera clipping at its own depth: that was a real bug and a different one. This is what was biting.
A tileset carries its own art
A tileset described a spritesheet it did not draw. The picture came from the tilemap node's Material, so every tilemap needed a Material repeating the same image and the same grid, kept in agreement by hand — and a tileset on its own rendered nothing at all. There was even a warning for the two disagreeing, which was a warning about bookkeeping the engine was imposing on you.
Now the tileset is the authority. Point it at an image and it draws. A tileset that names no image still falls back to the node's Material, so nothing you have already built changes.
Sheet 0 sits in the SHEETS list with the others and can be set there, which it never could — that is the whole reason a tileset could not carry art.
Drop images in. Drag one from the asset browser onto a sheet row, or onto the
panel. The grid is worked out for you: from the filename if it names a cell size
(tiles_16x16.png, dungeon-32.png), and from the image's pixel size
otherwise. Each row shows its cell size in pixels and says so when the grid does
not divide the image evenly — which is the seam bug, before it is a seam.
Fixed with it: nothing ever uploaded a tileset's sheet to the graphics card. Only Material textures were, so a tileset's image arrived only if some material happened to name the same file. The extra sheets added in v0.36.0 have never worked on their own.
Setting up an autotile
An autotile is a set of tiles that pick themselves by what is next to them — draw a wall, it grows its own corners. The engine has had them for two releases and the instructions described the idea rather than what to click.
Underneath, the only way to teach a group its rules was to select every tile in the palette, in the preset's own order, and press one button. That is right for a sheet drawn in that order and useless otherwise, and a preset can want up to forty-seven rules. Nothing showed which tile had been given which shape, and one wrong tile meant doing all of them again.
A group now shows its rules as slots — one per neighbourhood, each drawing the shape it matches over the tile currently assigned to it. Click a slot, click a tile: that rule is set, and the next empty slot arms itself. Filling a preset is one run of alternating clicks, every rule is visible as art rather than a count, and stopping halfway leaves something that works for the parts you did.
Painting is just painting. Click any tile of a group and you are painting the group; it retiles as you draw. Click a tile that is not in a group and you are back to ordinary painting. That used to depend on a checkbox elsewhere in the panel — and with it unticked, selecting an autotile silently placed one fixed corner piece everywhere, which is most of what "autotiling doesn't work" looks like.
The bulk assign-a-whole-preset button stays. It was never the wrong tool, only the only one.
Sorting layers
What draws on top of what, in a flat scene, was depth: the floor at 0.001, the
player at 0.002, a prop at 0.003. Wanting something between two of them
meant editing every number above it, and none of the numbers said what they
meant.
Name them instead. Project Settings ▸ Layers ▸ Sorting layers, back to front, and each node picks one in the Inspector with an order inside it.
- Layers are referenced by name, so reordering the project's list can never silently re-sort a scene.
ordercannot climb out of its layer — set it to a million and it still draws behind the next layer up.- Nothing moves. It offsets what is drawn, not the node: the collider stays where you put it and a script reads the position it set.
- A node that has not opted in is on
Defaultat order 0, which is exactly the depth it always had. Scenes that do not use this are unchanged. - A layer you delete leaves nodes that named it drawing in front rather than vanishing behind the background — visibly wrong beats mysteriously missing.
Not in this one
Scripts cannot read or set a sorting layer. It is authoring-side for now, so Y-sorting characters as they walk is still done by moving Z. That works and composes with all of the above; it is just not the nice version.
2D lighting. Nothing yet lets a light be a 2D light. Being designed now.
Sub-emitters and particle collision. Still the two real gaps in what a particle effect can be, and still next.
Upgrading
Nothing to do. Existing scenes, tilesets and images open unchanged. A tilemap that has been getting its sheet from its Material keeps doing so until you give its tileset one.
Read the notes
Animated characters stop flickering. A 2D level built the obvious way now draws in the Game view instead of nothing. A tile layer can be cut from more than one image. Per-tile collision and autotiling stop hiding. The image editor's brush shows the pixels it is about to change, and a tileset's grid can be drawn over the art while you draw it. And a particle effect will tell you what it costs and how many particles it is actually making, instead of only what you typed into it.
Nothing to do on upgrade.
Animated characters stop flickering
v0.35.0 moved skinning onto the graphics card. It also, without saying so, left one of the passes reading the pose from one frame ago.
The depth pass runs first and works out what is in front of what. For a character it was posing the skeleton from the previous frame — so it decided the shape of a character standing where they had just been, and then the pass that actually draws them put them where they now are. Triangles lost that argument one at a time, differently every frame. A character standing still looked perfect, because last frame's pose and this frame's pose were the same one; a character in motion broke up.
It reached further than the characters. That same depth is what the scene's raymarched geometry uses to know when to stop marching, so a corrupted one made the floor and walls behind a moving fighter flash as well.
Every pass now takes its pose from the frame it is drawing, and there is only one way in for them to do it — a pass added later gets this right without anyone remembering to make it.
Your 2D scene draws in the Game view
An orthographic camera sitting in the plane of your art — a camera at zero, a tilemap at zero, which is the obvious way to set up a flat game — showed the map in the Scene view and an empty background in the Game view. Nothing was reported; the node was visible, the material was right, and the two views of the same world disagreed.
The camera was throwing away everything at its own depth. It is fixed, and the Scene view and the Game view now derive that depth from the same place, so they cannot disagree about what is in a scene again. It affected every flat setup, not just tilemaps: sprites, batches and diegetic UI on the camera's own plane were all being clipped.
A camera with perspective is unchanged.
One tile layer, several sheets
A tile layer took its art from one image. Building a level out of a ground sheet, a props sheet and a decoration sheet meant three separate tilemaps — and that is not a workaround, it changes what the level is. A wall on one tilemap is not a neighbour of a wall on another, so nothing autotiles across the join. The collision merge stops at it and leaves exactly the seam it exists to prevent. Bucket fill, rectangle, stamp and retile all stop there too.
A tileset can now hold several sheets. ◫ Tiles ▸ TILESET ▸ SHEETS ▸ + sheet adds one; give it an image and its cols/rows, and the palette grows a tab per sheet. Everything you place from any tab lands on the same layer, so autotiling, collision merging and every tool work across the whole map.
Adding art never renumbers what you already placed. Grow your first sheet from 4×4 to 16×16 and every square placed from a later sheet still means exactly the tile it meant. A project that never adds a second sheet is unchanged in every particular.
Per-tile collision and autotiling were always there
Both of them. Collision per tile — none, the whole square, a half that rotates with the tile, or a hand-set rectangle — and autotiling with presets that draw their own corners and edges. Both were built, both were reachable, and both were invisible: the sections that need a tileset returned before drawing so much as their own heading, so a tile layer without one showed a panel with no TILE and no AUTOTILE in it at all. That is indistinguishable from an engine that has neither, and it was reported as exactly that, twice.
Both sections are always there now. When they can't act they say what they would do and offer the one button that gets you there. A tilemap with no tileset says so in the Inspector, naming both features. The collision overlay says when it has nothing to draw, rather than drawing nothing — which read as "nothing here is solid".
Fixed alongside: selecting a tile layer with no tileset left the tile and autotile editors pointed at the previous layer's tileset, so an edit could land on a tileset the layer does not use.
The brush shows the pixels it is going to change
The cursor drew a circle of the brush radius, whatever the brush was. Paint pixel art with a one-pixel pencil and you got a small circle floating between texels, while a single square texel landed — a different shape, in a different place, at a different size.
The outline is now the brush's own footprint: the texels that will actually change, on the pixel grid. A soft brush shows two contours — where it is, and how far it reaches — so soft reads as soft and a hard brush isn't given a halo it hasn't got. Very large brushes and very low zooms fall back to a plain circle, where the exact texels tell you nothing anyway.
Draw a tileset against its own grid
Laying out a 16×16 tileset meant counting texels by hand to find where tile 3 starts, and finding out you were one off when the map drew seams.
View ▸ This image is a sheet draws the cell grid over the canvas and saves it with the image — close the file, reopen it, the grid is still there, because how the art is cut is a fact about the art. Set it as cols × rows, or click a cell size (8, 16, 24, 32, 48, 64) and the counts follow. A grid that doesn't divide the canvas evenly draws nothing and says so; a 10.6-pixel cell is a mistake to draw against, not a number to round.
The overlays are yours to set
Whether an overlay is visible depends on art nobody can predict, and every one of them was a fixed colour: a grey checker under grey art says nothing, and the pixel grid was 11% white — visible in a screenshot, invisible over real work at exactly the zoom you need it.
There is a View menu now (the checker and grid didn't have one at all; they were on, always, and unreachable). The checker's two colours and square size, the pixel grid's colour, opacity and the zoom it starts at, and the cell grid's colour are all settable, with Reset overlays to defaults to undo the lot. The pixel grid is two-tone by default — dark dashes over a light line — so one of the two shows against art of any colour without being configured for it.
Moving a bit of art
The transform was already good; getting to it cost more actions than the edit.
- Drag inside a selection to move it. No tool change first.
Ctrl+Jduplicates the selection in place and floats the copy, ready to drag. It doesn't touch the clipboard, so stamping the same piece repeatedly no longer means re-copying it every time.- A transform in flight gets numbers: output size in pixels as well as a scale factor, 1:1 / 2× / half, degrees, offset. "Make this 16×16 exactly 32×32" is a typed number, not a drag you squint at.
- The status bar names the selection's origin and size, instead of only saying there is one.
Still one undo, and Cancel is still exact.
A particle effect says what it does
Every part of the Particles tab showed what you authored and none showed the result, which is most of why it was hard to tell what a change would do.
A curve says where zero is. The zero line used to appear only once a curve had already gone negative — the landmark arriving after the mistake. Zero is now drawn and labelled whenever the curve is anywhere near it, and when it is genuinely off-screen the edge says which way. A lane that multiplies marks ×1 instead, because that is its no-change value and it sits a quarter of the way up its strip with nothing else to say so.
The graphs stop moving under you. The value axis re-fitted itself every time you let go, so the same curve was drawn at a different scale after each edit and a key you dragged upward sprang back toward the middle. It fits once, then only grows — a key can never be edited off the graph — and re-fitting is a button. Both graphs label their top and bottom.
The tab tells you what the effect costs. Peak particles alive sits in the transport bar, and the timeline draws the alive count over time under its own ruler, so a change to a rate or a lane reads as a change to the effect and not only to a curve. These are measured by running the effect, not estimated.
A full pool no longer fails quietly. Raising a rate past what a track can hold used to give you fewer particles than you asked for with nothing said. Going over is now visible the moment it happens, and it names the track, how many births it lost and what its limit is.
Curves get shapes in one click — linear, ease in, ease out, both, hold — applied so the values you authored are untouched. A curve's shape can also be copied onto another property and lands at that property's magnitude, because a size ramp and a speed ramp are often the same shape at very different scales.
Not in this one
Sub-emitters and particle collision. Nothing lets a particle spawn anything — no effect on birth, on death, or along its path — so fireworks, a rocket that leaves puffs, and an explosion whose chunks trail sparks are all still out of reach. And particles pass through the world: sparks don't bounce, rain doesn't splash, debris doesn't land. These are the two real gaps left in what a particle effect can be, as opposed to what it can tell you, and both are big enough to deserve doing properly rather than halfway. They are next.
Telling the engine your sheet's grid. An image that knows it's a 16×16 sheet still can't hand those numbers to a material and a tileset, so the three can drift apart. The tile tools still warn when they do.
A smaller exported game. A build still carries the whole editor inside it. It works, it costs about 24 MB per game, and it keeps losing to things you can feel.
Upgrading
Nothing to do. Existing scenes, tilesets and images open unchanged, and images saved by this version still open in the previous one until you give one a cell grid.
Read the notes
Animated characters stopped costing what they used to. A mid-detail character used to spend a slice of every frame being reshaped by the processor; now that happens on the graphics card, and fifty of the same character draw in one go instead of fifty. Prefabs can be opened and edited on their own. And when a Lua script is too big to load, the engine says so in words you can act on, instead of pointing at a line you never touched.
Nothing to do on upgrade. Your existing rigged models get the new path the next time you open the project — no re-import, no setting.
Fifty characters, not five
Every skinned model — anything with a skeleton, weights and a walk cycle — was being reshaped a vertex at a time on the processor, every frame, and the result uploaded to the graphics card. That work scaled with how detailed your character was and how many of them were on screen, and it was the reason a crowd got expensive long before the graphics card was doing anything hard.
A character of average detail cost about a third of a millisecond of processor time per frame. Fifty of them ate a third of a 60 fps frame before a single pixel was drawn.
The reshaping now happens on the graphics card, which is what graphics cards are for. The same character costs the processor roughly a thousandth of what it did.
The second half is quieter and matters just as much: identical characters share their geometry again. They could not before — two copies of one model would have overwritten each other's pose, so each one needed its own private copy of the mesh. Twenty guards of the same model were twenty separate draws; they are now one. That is the difference between a crowd being a thing you budget for and a crowd being a thing you have.
Everything you already do with characters is unchanged: attaching a weapon to a hand still works, animation events still fire, painting on a skinned mesh still sticks. Selection outlines still hug the pose rather than the T-stance.
Two cases still take the old route, and both look identical on screen — a model whose custom shader material owns the drawing, and a scene with an implausible number of distinct skinned meshes in it (past roughly eight million skinned vertices, at which point the engine says so rather than deforming something wrongly).
Double-click a prefab to edit it
A prefab is a reusable piece — a turret, a door, a crate — so the natural thing to
do is open one and work on it by itself. Until now you could not. Double-clicking
a prefab did nothing, and changing one meant dragging it into whatever scene
happened to be open, editing that copy, and using Save as Prefab — which never
overwrites. The obvious route left you with Turret 2.prefab.ron sitting beside
the file you meant to change.
Double-click a prefab now and it opens on its own. The whole editor is there — Hierarchy, Inspector, gizmos, undo, and Play, so you can test a turret without a level around it. Save writes back to that prefab file, in place. Open any scene from the Assets panel to go back to editing a scene.
The Hierarchy header tells you which you are on: ◇ Turret (prefab) in amber, or
⎙ level as before. They save to different places, so they do not look the same.
The unsaved-changes prompt guards the switch in both directions, and crash
recovery works on a prefab the way it works on a scene.
A prefab is nodes and nothing else, which is worth knowing before you go looking: terrain and blockout map geometry live beside a scene file, so a prefab holding a Map Mesh node shows you the node and not its geometry.
A script that will not load says why
Lua has a hard ceiling: any one function may refer to at most 60 file-level
variables. A big controller script reaches it on a completely ordinary edit — one
more local at the top of the file that a long function happens to mention — and
when it does, the whole script fails to load. Nothing in it runs, and every other
script that asked for it silently gets nothing.
The message you got was:
vessel_controller.lua:3669: function at line 2864 has more than 60 upvalues
Line 3669 is not a line you edited. Line 2864 is where the offending function ends, not where the problem is. Nothing tells you a limit exists, or that "upvalue" means "a variable declared at the top of the file".
Now the Console says:
vessel_controller.lua did not load: the function ending at line 2864 closes over more than 60 upvalues, which is Lua's hard limit. Every file-scope
localis an upvalue of every function below it, so the fix is to hold related state in ONE table (local s = { … }read ass.foo) or to split the function — not to move the line the error names, which is only where that function ends.
Three more things changed around it:
You get told before you hit the wall. A script that declares 50 or more
file-level locals says so when it loads, with how many it has left:
nearly.lua declares 55 file-scope locals and Lua allows 60 upvalues per function — 5 to go before the script stops loading at all. The count was
previously invisible unless you had the file open in the editor with the linter
running; now it reaches you in a build too.
A broken script no longer looks like a missing function. Reading anything off a script that failed to load used to give you exactly what asking for a function it doesn't have gives you: nothing. Two completely different bugs, one symptom. The Console now names the difference, once, for each thing you tried to read.
The failure is reported once, not sixty times a second. A script that fails to load fails on every frame, and the Console used to say so on every frame. It now says it once per version of the file, and repeats it the moment you save a new one. The Scripting tab still lists it for as long as it's broken.
Not in this one
Slimming down what an exported game ships. A build still carries the whole editor inside it — the map tools, the image editor, the shader graph, all of it. It works, and it costs about 24 MB per game plus some startup time, which is why it keeps losing to things you can feel. It stays on the list.
Upgrading
Nothing to do.
Read the notes
A release for building 2D levels. There is a tile editor now: paint a level with a brush, tell a tile once that it is solid and every one of them collides, and let a set of tiles pick their own corners. Plus the orthographic camera 2D has been missing.
Nothing to do on upgrade.
Paint a level instead of writing one
Press 9 and paint in the Scene view. The new ◫ Tiles tab has the tools you would expect and they work the way you expect:
| ✏ Brush B | ✖ Erase E | ▬ Rectangle R |
| ▭ Frame F | ╱ Line L | ◍ Fill G |
| ◉ Pick I | ▢ Select S | ✚ Move M |
Drag a block of tiles in the palette and the brush becomes that whole block — a doorway, a tree, a whole cliff face. Select a region of the map, Copy, then Paste and the clipboard becomes the brush, so the next click puts it exactly where you aimed rather than somewhere you then have to undo.
↻ ⇔ ⇕ turn the selection when you have one and the brush when you don't.
Turning a multi-tile block turns the block and every tile in it, so a rotated
pipe corner still points along the pipe.
Ctrl-Z is the ordinary Ctrl-Z. A tile layer is an ordinary node, so hiding, renaming, moving, parenting and deleting one are the things you already know — and the Hierarchy and the Tiles tab can't disagree about whether a layer is showing, because there is only one switch.
Tell a tile it is solid, once
A tileset says what each cell of your spritesheet is — whether it collides, what it is tagged, how it animates — and it belongs to the sheet, not to the map. Press New tileset, tick "full" on the brick, and every brick in every scene collides, including the ones you already placed.
Collision is none, the whole square, a half, or a rect you drag out. The half is named in the tile's own art, so it turns with the tile: the bottom half of a tile rotated a quarter-turn is its left half. Select a run of tiles in the palette and press All solid to do a whole sheet in a minute.
The colliders are merged. A 100×100 solid floor is one box, not ten thousand. That is not tidiness — a character sliding along a row of separate boxes catches on the seams between them, which is the classic 2D platformer bug, and one merged box has no interior seams to catch on. Tick Collision in the tab to draw them over your map; they are the same boxes the physics gets.
Tiles can carry tags too — "ice", "ladder", "damage" — and your game
reads them straight off the map:
local x, y = tm:cellAt(node.worldPos)
if x and tm:hasTag(x, y + 1, 'ice') then slippery = true end
Let the tiles pick their own corners
Add an autotile group, drag out its tiles in the palette, press Assign preset, and painting draws its own edges, corners and ends. Two kinds: Edges (16 tiles, for a path or a wall run) and Blob (47, for terrain with inside corners as well as outside ones).
Every tool orders these sheets differently, and a preset that guesses your artist's order wrong tiles your level plausibly wrong — right shapes, wrong corners, reads as bad art. So each tile's neighbourhood is drawn right on the palette as a little 3×3 diagram: if a preset guessed wrong you can see exactly which tiles disagree, and clicking the diagram fixes one.
Put two groups in each other's joins and grass and dirt meet without either growing an edge against the other.
Two rules worth knowing, because both are very visible when a tool gets them wrong: the edge of the map counts as filled, so a shape painted to the border does not grow a coastline against nothing; and a group with tiles still undrawn leaves those squares exactly as you painted them rather than erasing them. The tab says "12 of 47 drawn" so a half-finished group is something you can see.
Tiles that face a direction, and tiles that move
Every square now carries its own orientation — four rotations, each optionally mirrored — so one arrow tile is four arrows and one corner tile is a whole room. It rides inside the tile value, so nothing about your existing maps changes.
tm:set(4, 2, 7, { rot = 90 })
tm:set(5, 2, 7, { flipX = true })
local cell, rot, flipX = tm:at(4, 2)
Give a tile a few extra frames and a rate and every square using it animates together — torches, water, conveyors. It runs while you are editing, not only in Play, so you can watch a torch flicker while you place torches. The frame is never written into your map, so saving doesn't freeze whichever moment you hit Ctrl-S on.
An orthographic camera
Set a camera's projection to orthographic and it draws everything at the same scale at every distance. Under perspective a parallax layer two units further back is drawn slightly smaller, so it changes size as well as speed and two layers cannot be lined up; this is the fix, and it is also what a strategy or isometric camera wants.
find('Cam'):setCamera{ projection = 'orthographic', orthoHeight = 12 }
orthoHeight is how many world units the shot covers top to bottom — with 1-unit
tiles, how many tiles tall it is. Render targets have it too, which is very often
what a minimap actually wanted.
The editor's Scene view has it as well (⌖ Scene ⏵ ▦ View ⏵ Orthographic), and picking a plane lock turns it on with you. There the wheel zooms the view height instead of moving the camera — it has to, because moving forward changes nothing you can see. A camera gizmo draws a box rather than a cone for an orthographic camera, because the frame really is the same rectangle at every distance.
Your game can ask the map about itself
The tilemap handle grew the things every 2D game was writing by hand:
local x, y = tm:cellAt(player.worldPos) -- which square is it standing on?
local p = tm:worldAt(x, y) -- that square's centre, in the world
tm:fillRect(0, 20, 39, 21, 3) -- a floor
tm:resize{ rows = 30, offsetY = 8 } -- grow eight rows on TOP
tm:autotile(0, 0, 39, 29) -- fix the corners after a run of edits
cellAt goes through the map's own transform, so a level that has been moved,
turned or scaled still answers correctly — which is the part that quietly broke
when a hand-written version met a map that moved. Also tm:at, tm:tileSize,
tm:solid, tm:tags, tm:hasTag and tm:tileset.
A typo in an orientation is refused where you wrote it: { flipx = true } says so
and suggests flipX, rather than placing the tile unturned and leaving you to
find it.
What isn't in this
Written down so you don't go looking. Edits to a tileset (collision, tags,
groups) are saved to their own file and are not on the undo stack — the map's
tiles are. There is no chunked or infinite grid: a very large world is several
tilemap nodes. No slope collision, no one-way platforms, and no .tmx / .ldtk
import yet.
Two engine tasks that were scheduled alongside this — GPU skinning, and exported games running on the small runtime instead of the editor — are not in this release. Both are substantial and neither was worth half-doing.
Upgrading
Nothing to do. Your existing tilemaps load and draw exactly as before; the new per-tile orientation is off on every square that predates it.
docs/tilemaps.md is the whole suite, start to finish.
Read the notes
A release about the engine telling you things. Options that refuse a typo instead of quietly ignoring it, a camera you can point at a texture, four accessibility settings a game can offer its players, and a physics step that stops asking every collider about every body.
Nothing to do on upgrade — except read the one breaking change below, which is short.
A camera can be a texture
Point a camera at a name, and wear its picture anywhere a texture goes:
find('MapEye'):setCamera{ target = 'minimap', width = 256, height = 256, hz = 10 }
find('MinimapPanel'):setMaterial{ texture = 'rt:minimap', unlit = true }
That is the whole mechanism, and it unlocks the four things people ask for — minimaps, mirrors, security monitors, scopes — plus split-screen, which is two targets shown as two UI images side by side.
The two numbers matter more than they look. A render target is a whole extra render of your scene, so a 256×256 minimap at 10 Hz costs about a sixth of what the same minimap cost at 480×270 every frame. A cockpit screen the player glances at does not need 60 either.
It works while you place the screen in the editor, in Play, and in a build. Eight live targets per scene; a ninth says so in the Console rather than vanishing, and so does a second camera claiming a name another one already has.
docs/render-targets.md has all five recipes.
Accessibility your game can actually offer
Four settings, one Lua table:
access.setTextScale(1.5) -- 0.5 – 3.0
access.setColorFilter('deuteranopia', 0.9) -- protanopia / deuteranopia / tritanopia
access.setReducedMotion(true)
access.setCaptions(true)
caption("a door unlocks somewhere below")
Text scale reflows. It is applied before layout runs, so a box that sizes itself to its text grows and everything below it moves down — rather than painting bigger glyphs into the same rectangle and clipping at exactly the sizes somebody turned the setting up to reach.
The colour filter corrects, it does not just simulate. Red and green that arrive as one olive come back as pink and green. There is also a simulate switch in the editor, for you: tick it to see your game the way a colourblind player does and find the red-on-green prompt yourself.
Reduced motion snaps the engine's own UI transitions instead of hurrying them,
because a 40 ms slide is still a slide. Your camera shake has to read
access.reducedMotion() — the engine has no shake of its own and cannot tell
which of your movement is the game.
Captions are drawn by the engine, bottom-centre, at the player's text scale,
and caption(...) does nothing while captions are off — so you write it beside
the sound with no if around it.
⚙ Settings → ♿ Accessibility drives all of it, so you can try your game with each one on without writing a line. These are the player's settings, so persist them in the player's save. docs/accessibility.md covers what only your game can do.
Physics that stays fast as the level grows
Collision used to test every body against every collider, twice a tick. Fine for a room; quadratic for a level. Measured over 120 steps:
| colliders × bodies | before | after |
|---|---|---|
| 169 × 50 | 62 ms | 50 ms |
| 625 × 200 | 859 ms | 248 ms |
| 1,681 × 400 | 4.50 s | 556 ms |
The gap widening as the scene grows is the point. Nothing to configure and nothing to opt into, and your scene simulates identically — the same collision test runs, on the same colliders, in the same order.
The engine refuses typos now — this is the breaking change
An options table used to ignore a key it did not recognise. So this looked fine and did nothing:
scatter.create{ asset = "trees/pine.glb", perchunk = 6 }
Every options table is closed now. That call is an error naming the key and
suggesting perChunk, and every message says three things: the property, the value
it got, and what it accepts.
audio.play: `mode = "spacial"` is not a name I know — it takes spatial, 3d,
distance, flat, 2d
Why this is worth breaking. 43% of every bug ever reported against this engine
was the same shape — the engine answered something it did not understand instead of
refusing it. A collide = true on scattered props that was read by nothing, for
two releases, so a game asked for solid props and got props you walk through. A
pin = "topCenter" that silently meant top-left. And scene.load(name, { addative = true }), which destroyed the running scene instead of layering onto
it, because a misspelling reads as additive = false.
Four more of those are fixed by the same pass: node:setPrimitive("Sphre") made a
cube; a misspelled mode in audio.play left a positional sound playing flat at
full volume everywhere; a misspelled header in http.get sent the request
without it, so the server said 401 and the game reported "the API is down".
What you should do: press Play. A typo you have been carrying now says so on the line that has it, once, immediately. (Checked against every shipped script and both in-house games — none of them passed a key that is now refused, so most projects will see nothing at all.)
A script can export a function called name
materials.name("iron") returning "Iron Ore" is the obvious thing to write, and
it was the one thing you could not: the handle answered name itself, so the
script could call it and nobody else could, and every other script died at the
call with attempt to call field 'name' (a string value). It broke four screens in
one game, each at the first moment it had something to show.
Your name wins now. Three names still belong to the handle — node, kind and
valid — and exporting one of them is reported when the script loads and
underlined in the editor on the line that does it, instead of surfacing later in
somebody else's file. Ask kind when you want to know which script a handle is.
Smaller things
Tab reaches your game. The editor was answering it for panel focus, so
input.pressed("tab") had been false for the whole life of every project. Only
F1, F2 and F3 are still the editor's, and polling one of those now writes a Console
line and flags the line that wrote it, instead of reading false forever.
-1 clears a tile. tm:set(x, y, -1) empties a square, as it does in Tiled,
Godot and LDtk — and so do nil and the EMPTY_TILE the editor's autocomplete had
been offering all along. A cell that is not a whole number in range is now an error
naming what it got, never a neighbouring tile.
Nothing behind you is drawn. Meshes, imported models, sprites, tilemaps and scattered props are all skipped when they are outside the view, which they never were before — only terrain was. A skinned character's bounds grow with its pose, so nothing pops out at the edge of the screen mid-animation.
Your game can see its own frame cost. perf.* reports where a frame went, per
subsystem and per script, with the worst recent frame alongside the average — so a
project can assert its own budget in its own test rather than filing "the engine is
slow". ⏱ in the play toolbar shows the same numbers. Off by default and free while
off; reading a time while it is off is an error, not a zero, because a budget check
that passes on no data is worse than no check.
Upgrading
Nothing to do. Open your project and press Play; if you have been carrying a misspelled option, it will tell you where.
Read the notes
Two fixes for worlds that move: the ground you are standing on now loads before anything else, and a field of props stays on the planet it grows on.
Nothing to do on upgrade.
The ground under you loads first
"I can see through unloaded terrain and it needs to prioritize loading what's right under me."
Terrain arrives nearest-first, which was right, except that "nearest" was counted in each world's own chunks. So a patch of ground three chunks from you on the planet you are standing on counted as exactly as close as a patch three chunks from you on a planet twelve thousand units away — and which one loaded first was arbitrary. On a solar system with several worlds resident, the ground under your feet routinely queued behind a planet you could barely see.
Distance is measured in metres now, which is the only unit two different worlds can be compared in. On top of that, the body you are actually standing on outranks one you are not, so landing somewhere loads the landing site rather than the horizon of the world next door.
…and there is no hole in it while it waits
The other half of the same report. Ground that had been queued but not yet built drew nothing at all, so you saw straight through the planet into space — worst exactly when you were moving fast enough to outrun the loader.
A world that is still loading now fills its own gaps with a plain shaded sphere sized to sit just inside its surface. It cannot show through real ground, and the moment real ground arrives it is hidden. You get the planet's own colour where detail has not landed yet instead of a window into space.
Props stay on the planet they grow on
"The scattered props seem to just be being left behind by the planet traveling in orbit."
Exactly that. A scattered field was pinned to the world when you declared it, and worlds orbit. The spawn planet moves at ninety-nine units a second, so a field reaching two hundred and forty units was entirely behind its own planet in under three seconds — the rocks quietly sliding off the world while you stood on it.
A field can now ride a node:
scatter.create{
parent = "Umunquo", -- the world this field grows on
center = planet.pos, radius = 107,
lod = { { asset = "rock.glb", distance = 190 } },
}
Following costs nothing per prop. Every rock keeps its identity, its place on the surface, and the ground height it settled at — so a rock you harvested stays harvested, a rock you walked past is the same rock when you come back, and none of it is recalculated as the planet moves. It works for anything that moves, not just orbits: a rotating world, a moving platform, a ship's interior.
Fields on ground that never moves need no change and pay nothing.
Under the hood
Two things that will not show up on screen and are worth knowing about.
The tests now run automatically. Every change to the engine is checked against the full suite before it can become a release, rather than depending on somebody remembering. Its very first run caught a real problem.
Performance regressions are caught by shape. A set of guards measures the engine's core operations at two different scene sizes and fails if the cost grows faster than the scene does. Three separate slowdowns have reached players by exactly that route — each one found by somebody whose game had got big enough to notice.
Upgrading
Nothing to do.
If you scatter props on a world that moves, add parent and remove whatever you
were doing to work around it.
Read the notes
Two things that used to answer you instead of telling you. A scatter field now says what it costs before it costs it, and a mistyped UI value stops the screen instead of quietly meaning something else.
Nothing to do on upgrade.
A field of props says what it costs, at the moment you ask for it
scatter.create's knobs read as a look: how far props are drawn, how big a
chunk is, how many per chunk. One of them is really the whole budget.
The outermost lod distance sets how many chunks stay loaded around you, as a
square that grows with it, and that square is walked every frame. So the cost is
about (distance ÷ chunk) squared. A field asked for at 700 metres against a
22-metre chunk came to four and a half thousand chunks and a hundred thousand
props — for one source, of four. The game froze on arrival and dropped frames
continuously after, and nothing in the API, the reference or the Console
suggested that those two numbers were the ones doing it.
Three things changed.
You can ask. scatter.cost(id) hands back what the source wants every
frame:
local field = scatter.create{ center = planet.pos, radius = 107,
chunk = 34, perChunk = 14,
lod = { { asset = "rock.glb", distance = 190 } } }
local c = scatter.cost(field)
log(("%d chunks, %d props"):format(c.chunks, c.props)) -- 121 chunks, 1694 props
And if you don't ask, it tells you. A field big enough to matter prints one line to the Console when you declare it, naming the source, its chunk count, and the two numbers that decided them. Big fields are still allowed — that is your game's look to choose — but you find out in the Console rather than in a playtest.
A planet never costs more than a planet. Asking to see 700 metres of a 214-metre world used to cost thirty times what asking to see 190 metres cost, and piled three quarters of its props on top of each other along one seam. How much is loaded now stops growing once it covers the body you're standing on.
A big field slows down instead of stopping
Props are dropped onto the real ground with a ray, once, when their patch comes into range — so the frame a patch arrives pays for all of its props at once. Walking, or just looking around in third person, moves the camera far enough to pull new patches in, and that arrived as a stutter that got worse the more you looked around.
Ground now arrives nearest first, a few hundred props a frame. A field coming into view fills in from where you stand outwards over a few frames rather than stopping the one frame it appears on. The same ordering means that if a field does overrun what can be drawn, what's dropped is the horizon rather than what's at your feet.
Standing still costs nothing at all now: the list of loaded patches is worked out when you cross from one to the next, not sixty times a second.
ui.make stops answering typos
ui.make has always refused a property NAME it doesn't know, because a
declarative screen that silently ignores a line is worse than one that stops.
A property's VALUE got no such treatment.
There is no topCenter. So this:
label("FLOOR 1 - ROUND 2/3", 8, GREY, { pin = "topCenter" })
pinned to the top-left corner, silently, forever. In one game every bottom-centre and top-centre element in the HUD — the round readout, the controls hint, the "press E" prompt, every shop message — stacked into one corner underneath the panel that legitimately lives there. It reads as a layout bug. It was a spelling mistake the layout answered rather than refused.
Now it raises, naming the property, the value it got, and the values it takes:
ui.make > Text: `pin` does not take `topCenter`; it takes topLeft, top,
topRight, left, center, right, bottomLeft, bottom, bottomRight, topCenter,
bottomCenter, leftCenter, rightCenter
…which brings up the second half. topCenter, bottomCenter, leftCenter
and rightCenter now work, because they are what people write — the other
five anchors are topLeft and bottomRight and friends, and the ones that take
a bare direction are the ones you have to look up. centre works anywhere
center does.
The same silent default was in align, justify, dir, case, overflow,
imageFit, textAlign, textValign, sliderDir, scrollbarAxis and part.
All of them refuse a word they don't know now, and all of them say what they
take.
Upgrading
Nothing to do.
If a screen of yours starts raising on a pin, align or justify it was
happy with before, that line was already not doing what it said — the error
names the property and the words it accepts.
Read the notes
Sprites you draw in update now reach the screen. Terrain resolves instead of
popping. And a scene can hold twenty thousand scripted nodes without taking the
editor down with it.
One thing to change on upgrade, in the last section.
Sprites drawn in update were being thrown away
A player of a 2D game put it this way: "I can't see any enemies or bullets any more, it's like they're behind the level." They were not behind anything. They were never drawn.
A sprite batch was emptied after every script pass, and a frame runs three
of them — update, then fixedUpdate, then lateUpdate. So anything drawn in
update was wiped twice before the frame reached the screen, and lateUpdate
was the only place a draw survived. update is where every tutorial puts
per-frame work; it is where draw.rect and draw.line go, and those clear per
frame, not per pass. The one obvious place to put your renderer was the one
place it could not work.
The failure was silent and total — not a flicker, not a subset. Every batch in the game emptied at once, so a project that had just moved its renderer onto batches saw nothing at all and no reason to suspect the pass it drew in.
The frame is now the unit. Draw from update, fixedUpdate, lateUpdate or
any mix, and everything drawn since the frame began is what renders. It is still
immediate mode: a batch nobody draws to all frame draws nothing, so there is
still no pool to grow and no clear() to forget on the frame a wave dies.
A sprite is the size you asked for
size on a sprite batch is documented as the width of one sprite in world
units. It was multiplied onto a quad that is 1.4 units across, so size = 1
drew a 1.4-unit sprite and every bullet in a game came out 40% too big — which
reads as somebody's tuning change rather than a unit mismatch. size is now the
edge length it says it is.
If you compensated for this, remove the compensation. A batch created with
size = 1.0 / 1.4 (or a PLANE_UNIT constant of your own) should go back to
size = 1.0. Nothing else changes; each sprite's own scale still multiplies
it, per axis.
Terrain resolves instead of popping
"The terrain is appearing in front of me and it's obvious when it pops in."
v0.29.0 cut how many chunks arrive at once on a small planet — 109 down to 9 on a 180-unit world. That fixed the hitch, not the pop: each chunk still switched from absent to fully lit between two frames.
A newly meshed chunk now dissolves in over about a third of a second. Its silhouette and shading are correct from the first frame; what changes is how much of it is there. Landing on a planet reads as the ground resolving rather than as slabs of it appearing in front of you.
A chunk being re-meshed does not dissolve — mining a hole, or a chunk changing detail level as you walk toward it, both stay solid. Only ground arriving from nothing fades, or every bite of a mining laser would dissolve the ground under it.
Thousands of scripted nodes no longer crash the editor
A scene with more than about four thousand nodes carrying scripts would crash outright, with a message naming nothing you could act on and taking your unsaved work with it. In the editor that is the worst failure available.
The engine was holding two live Lua values per script instance, against a limit of roughly eight thousand of them for the whole program. It holds none now. A scene of 20,000 scripted nodes runs — measured, and the ceiling is gone rather than raised.
That is a ceiling removed, not a performance claim: 20,000 scripted nodes is still far too slow to play. The difference is that it is now a frame rate you can see and budget against instead of a crash.
Upgrading
One change, and only if it applies to you: a sprite batch's size is now the
sprite's true width, so drop any 1.4 you were dividing by.
-- before
node:setSpriteBatch{ size = 1.0 / 1.4 }
-- now
node:setSpriteBatch{ size = 1.0 }
Everything else is nothing to do. Sprites drawn in update start appearing, and
if you had moved your draws to lateUpdate to work around this, they keep
working exactly as they are — there is no need to move them back.
Read the notes
Scripts stop searching the whole scene to find each other. A world can have biomes. A prop your own script assembled can be scattered like any other. And three things that used to fail in silence now say something.
Nothing to do on upgrade, with one deliberate exception at the bottom.
One script finding another stopped costing the scene
findScript, findScripts and findTagged walked every node in the scene and
compared strings, on every call. So the cost of one script asking for another
grew with the size of your game — and a project that wires panels to a shared
manager pays it per panel, per frame. One real project counted 126 whole-scene
searches a frame, none of them carelessly written: there is no cheaper way for
sixteen panels to say "the company ledger", and none at all for something
spawned while the game runs.
They read an index now, the same way find by name always has.
| 120 lookups a frame | before | after |
|---|---|---|
| 500-node scene | 2.2 ms | 0.20 ms |
| 5,000-node scene | 25.7 ms | 0.19 ms |
The 25.7 is more than a whole 60 fps frame spent looking for things. The number
that matters, though, is that the two rows are now the same: the cost no longer
depends on how big your game has got. findScript still returns the first in
scene order, which several games rely on.
A world can have biomes
Scattered props grew everywhere, at one density, so a forest could not end and a desert could not be empty. Palms in the ice cap.
scatter.create{
asset = "palm.glb", center = planet, radius = 180,
density = function(x, y, z) return climate.densityAt(x, y, z) end,
}
density returns 0..1 and 0 means nothing is generated there — not hidden,
not drawn and thrown away.
Your rule is asked once, when you declare the source, and what the engine keeps is the answer. That is deliberate: where each prop stands has to stay a pure function of the seed, so that walking away and coming back regrows the same world and two machines agree without sending each other anything. A rule consulted per prop could read the time of day and answer differently on the second visit, which would quietly break all of that.
Scatter a prop you built yourself
asset can now be a prefab, not only a mesh file. Each Mesh node inside it
is drawn at its authored place within the prop, still GPU-instanced, still with
no scene node anywhere.
That is the difference between scattering a tree somebody modelled and scattering a plant your own generator assembled out of a trunk and three fronds — which is what a game with procedural flora actually has.
A planet you can walk around stops hitching when you land
Terrain detail rings were written for worlds where "24 chunks away" is over the horizon. On a walkable planet — 100 to 250 units of radius — it is the far side of the world, so arriving meant rebuilding more than half the planet's surface at full detail through a queue sixteen deep. That is the pause on landing, and the ground filling in afterwards is that queue draining.
The rings now also scale with the body they are on. Standing on a 180-unit world that is 9 chunks of full detail instead of 109. Big worlds are unchanged — this can only tighten.
Not in this one: new terrain still appears rather than fading in. That is the other half of the complaint and it needs a change in how chunks are drawn, not in which ones are queued.
Three silences broken
- A scatter option that did nothing.
collidewas read, defaulted and stored — and used by nothing at all, so a game that asked for solid props got props it walked through, and no error. Scattered props still have no collision; the option that suggested otherwise is gone. An optionscatter.createdoesn't recognise is now an error, which is the only reason the dead one could hide for two releases. A typo was the same silence. - A scene value your script no longer uses. A scene stores tuning per node. Rename or remove the setting in the script and the stored value stays behind — read by nothing, and indistinguishable from a script whose numbers do nothing. You get one line in the Console naming the scene, the node, the script and the value.
- A scene value that is pinning your script's. The harder half, because nothing about it is wrong except its age: the name is real, the value is real, and it silently wins over the number you just edited. The Inspector now marks that row and offers to drop it and use the script's.
Upgrading
One thing to know: if you pass an option to scatter.create that it doesn't
recognise — including collide — it is now an error rather than being ignored.
That is the point, and it is a one-word fix in your call.
Everything else is either faster, additive, or a message where there wasn't one.
Read the notes
A screen built with ui.make no longer disappears because one part of it isn't
showing. That is the fix behind a report we chased for two releases — "after
playing once, the UI never comes back" — and it turned out to have nothing to do
with playing twice.
Also: a script can make a node a sprite batch, and asking a node that isn't one for its sprites now says so instead of throwing your drawing away.
Nothing to do on upgrade.
A section that's switched off stops taking the screen with it
This is how anyone writes a HUD where parts come and go:
local dead = nil
if not player.alive then dead = deathPanel() end
ui.make(node, { vitals, score, hint, dead })
When a section was nil, the whole screen failed to build — every other part of
your HUD gone, and an error naming a position in a list rather than the thing
that was missing. Whether it happened at all depended on where the gap fell,
which is why it looked intermittent and unrelated to anything you'd changed.
Worse when the gap came early: everything after the first absent section was dropped silently, with nothing in the console at all.
A nil now means what it looks like — nothing here — which is what the engine
already did for a section written as a function that returns nothing, and for a
nil child inside an element. The list spelling was the one place it was fatal.
And ui.make(node, {}) now clears a screen. It used to leave one empty
element behind, so a menu that hid itself left something on screen every time it
did.
Clicking one thing stops doing another thing's job
Re-describing a screen installed the handlers it asked for, and never took off the ones it stopped asking for.
Screens reuse elements — that's what makes ui.make cheap to call every frame —
so a row that was a Buy button and is now a Sold out label is the same
element, with no click handler in its new description. It kept answering the old
one. Clicking a row could do the job of whatever used to be in that position,
depending on what the screen last showed.
A description now removes what it no longer asks for.
A script can make a sprite batch
There was node:setTilemap. There was no counterpart, so a game whose sprite
styles live in Lua — one batch per material, one material per style, fifty of
them in a file — had to author fifty matching nodes into the scene by hand and
keep them in sync with the script by remembering to.
node:setSpriteBatch{ size = 1.0 }
node:setMaterial{ texture = "sprites.png", sheetCols = 24, sheetRows = 15 }
batch = node:sprites()
And node:sprites() on a node that isn't a batch is now an error that tells
you which call fixes it. It used to hand back a handle that worked, accepted
every draw, and drew nothing — no error, no warning, no line anywhere to say
why.
Upgrading
Nothing to do. Every change here either accepts something that used to fail, or replaces silence with a message.
One thing worth checking if you hit it before: if you worked around the nil problem by filtering your list before handing it over, you can stop — though leaving it in changes nothing.
Read the notes
A pixel-art game can now look the same in every window size. Two settings — whole-number upscaling and a fixed internal resolution — plus the same rule for UI layers, so a pixel font stops being resampled off its own grid.
Also: work a script queued on the last frame of a Play session no longer lands in the next one.
Nothing to do on upgrade. Both settings are off by default.
Your game looks the same fullscreened or not
A player noticed this before we did: "the game visually looks totally different whether I have the game window fullscreened or not." They were right, and it was two separate things.
The upscale was a fraction. With Retro on, the low-resolution image is stretched to fill whatever rectangle it lands in. Fullscreen at 1440p that is about 6×; in a docked Game panel 486 pixels tall it is 2.025× — so some rows of your art land on two screen pixels and some on three. On 1-bit art with an 8-pixel font that is the difference between crisp and mush, and which rows are fat changes as you drag the window edge.
The internal width followed the panel. The vertical resolution is what you set; the horizontal was derived from the window's shape. A 2.0-aspect window therefore showed 12% more of your level than 16:9 — which, for a game you have balanced, is a difficulty setting nobody chose.
Both are now settings, in Project Settings ▸ Rendering:
- Whole pixels — upscale by a whole number, centre it, and letterbox the remainder. Every pixel the same size, at every window size.
- Pixel columns — a fixed horizontal resolution.
0keeps the old behaviour of following the window.
Turn both on together. A derived width is rounded to a whole pixel, and that rounding can cost you a whole step of scale — 240 rows at 16:9 rounds to 427 columns, and six of those is two pixels too wide for a 2560-wide screen, so it drops to 5× and leaves a sixth of the display black. Pinning the width fixes it.
…and so does your HUD
A UI layer's scale is the window height over the height you designed for — the same fractional number, one layer up. A 240-unit HUD in that 486-pixel panel scaled by 2.025 and its pixel font came out resampled off its own grid.
Tick pixel scale on the layer (Inspector ▸ UI Layer) and it rounds to a whole number.
It rounds down, so the design canvas comes out slightly larger — 243 units instead of 240 — and the extra is margin. Anchored elements stay against their edges, centred elements stay centred, and because nothing is offset, nothing clicks in the wrong place.
The 2D guide has the combination a pixel-art game should ship.
Yesterday's work stops arriving today
Stop lands where it lands: after a script's update has run and asked for
something, and before the engine has done it. Those requests belong to the
session that just ended — the node they name is gone, and the function they'd
call closed over a game that is no longer running.
Most of them were already thrown away at Stop. createNode was not, so one
queued on a session's last frame was carried over and built on the next Play,
parented to whatever node had inherited its number. For a game that creates
nodes constantly — tiles, pools, spawned scenery — that is a stray node and a
callback running against a world that isn't there any more, on every second and
subsequent run.
Upgrading
Nothing to do. Whole pixels and pixel scale are off, and Pixel
columns is 0, so every existing project renders exactly as it did — these
are things to turn on, not things that happened to you.
If your game's UI stops appearing after the first Play session, that one is still open and we would like the report: this release fixes a leak that could cause it, but not one we have been able to reproduce from the symptom.
Read the notes
A big scene got much faster. Finding a node's components was a search through every node that had one, so the cost of a busy scene grew with the square of its size — 5,500 nodes spent 60 ms a frame on lookups alone. It is now a direct lookup, and that scene spends 4 ms. Nothing in your project changes; scenes that were heavy are simply lighter.
Two things a game asked for and couldn't have: the cursor back for its own menus, and sprites that can squash and stretch.
Nothing to do on upgrade.
Your game can get the cursor back
Clicking into the Game view pins the pointer there so playing doesn't let the mouse wander onto the editor's panels — unless your game already has something clickable on screen, because a menu's buttons are the gameplay and pinning the pointer froze them dead.
That question is now asked every frame instead of once, at the click.
Which matters for the game that has nothing clickable while you play — a twin-stick shooter, anything aimed with the mouse. It would pin the pointer on the first click, and then its own shop screen, opened two minutes later, could never be clicked at all. From the player's side that reads as "the UI is broken", and there is nothing on screen to suggest that Escape was the way out. Now the shop takes the pointer back the moment it opens, and hands it over again when it closes.
input.setMouseLocked(false) also releases the pin explicitly — reach for that
if your menu is drawn some way the editor can't recognise as interactive. It
used to set a different flag and leave the pointer exactly where it was.
Free-look is unaffected: a game holding the mouse itself keeps it when a button appears.
A crowded scene stopped costing what it did
Every per-node pass in the engine asks each node what it has on it. That lookup walked the list, so a scene of N nodes did roughly N² comparisons per pass, per frame. Small scenes never noticed. A game with a few thousand nodes paid for it in a way that looked like the engine being slow at everything at once.
Measured on the scripting pass alone, with nothing being rendered:
| nodes, doing nothing | before | after |
|---|---|---|
| 1,500 | 4.4 ms | 1.1 ms |
| 5,500 | 60.3 ms | 4.4 ms |
And with all of them being moved from a script every frame: 6.3 ms → 2.5 ms at 1,500, and 72.6 ms → 10.6 ms at 5,500. The second one is the difference between 13 fps and 60.
This isn't a 2D fix — it's every scene, and it's larger the more nodes you have.
When a 2D game must use a sprite batch
v0.25.0 shipped sprite batches and measured them as no faster than a pool of scene nodes. That was true, and it was the wrong measurement: it timed the graphics card, and 1,400 quads is 1,400 quads however they are gathered. The cost is on the script side, and the 2D guide now gives the number.
The short version, per frame, script side only, after the speed-up above:
| sprites | pooled scene nodes | one batch |
|---|---|---|
| 1,500 | 2.5 ms | 0.8 ms |
| 5,500 | 10.6 ms | 3.0 ms |
A 60 fps frame is 16.7 ms in total, and physics, rendering and the rest of your scripts are in there too. Above a few hundred sprites, batch them.
Worth reading twice: nodes you have stopped using still cost. A pool that
grew for one boss pattern and never shrank is walked every frame forever. If you
are pooling scene nodes, node:destroy() the ones you are done with — a batch
has no pool to release.
Sprites can squash and stretch
b:draw's scale takes a vec2 as well as a number:
-- The wind-up before a lunge: squat and wide, then tall and thin.
b:draw(e.x, e.y, 0, vec2(1 + t * 0.5, 1 - t * 0.4), 0, e.frame)
Squash-and-stretch is how a 2D game telegraphs an attack, and while a batch could only scale both axes together it was the one effect that forced enemies back onto scene nodes — leaving a game maintaining two rendering paths for that alone. One number still means both axes.
Upgrading
Nothing to do. No project files change and no call behaves differently: the
vec2 scale is an addition, and the cursor now does something it should always
have done.
Read the notes
Floptle can now teach you how to use it. There's a new 🎓 Learn tab with five follow-along tutorials that build a real game from an empty project — and because the editor can see your project, each step ticks itself off when you've actually done it. Three of the finished games also ship as starter projects you can create and play in one click.
There's also a 2D layer: a tilemap that can't grow gaps between its tiles, and sprite batches where each sprite carries its own colour.
Nothing to do on upgrade.
Build a game with the editor watching
Open 🎓 Learn (it's beside the Inspector, or Help ▸ 🎓 Learn), pick a tutorial, and work down the steps.
The difference from a written tutorial is that this one knows what you've built. "Add a node called Player" goes green when a node called Player exists. "Attach the script" goes green when it's attached. "Press Play" goes green when you do. So you never get three steps past a mistake without noticing — which is the way most first attempts at an engine actually go wrong.
The checks are never in your way. They can't tell "did it differently" from "didn't do it", so every step can be ticked by hand, nothing is scored, and nothing is locked. Where you got to is remembered per project.
Steps that write a script offer to create the file and open it, or to copy it if you'd rather type it out. A file that already exists is never overwritten.
Five tutorials
- First steps — make something move. For someone who has never programmed. Fifteen minutes: make a cube, spin it, expose the speed as a slider you can drag while the game runs, then drive it around with the keyboard.
- Build a 3D platformer. Run and jump, a camera that follows without making anyone seasick, a moving platform that carries you, coins that count, and a goal that ends the level.
- Build a top-down RPG. A village, a villager you talk to, a key worth having, and a locked door onto a second scene — including how anything survives a scene change at all.
- Build Flappy. The shortest complete game there is: one button, obstacles spawned while you play, a score, and a game over you can restart. About half an hour, and you can export it and hand it to someone.
- Floptle for programmers. No hand-holding: what a node actually is, which of the three update hooks to use and why, how the Inspector two-way binds to your script, and the six habits from other engines that will cost you an afternoon here.
Every line of Lua in them is real, and the whole set is checked on every build — it parses, it passes the editor's own warnings, and it runs.
Three starter projects, ready to play
Platformer, Top-down RPG and Flappy are also complete projects you
can create outright: pick one under Start from on the Hub's New project
screen. Create it, press Play, then take it apart — every behaviour in one is an
ordinary .lua file in scripts/ you can open and change.
They're the finished versions of the tutorials above, so you can build one yourself, read the answer, or both.
From a terminal:
floptle --new my-game --template flappy
floptle --list-templates
Older engine versions don't offer the picker; the Hub only shows it for a version that has one. A project made without choosing a template is exactly the blank project it has always been.
The tutorials are written down too
The same five are in the documentation, so you can read them on a second screen, or before you've installed anything. They're generated from what the editor shows, so the two can't drift apart. The documentation index now opens with them.
Making a 2D game stopped meaning building a renderer
Two new node types, both under Hierarchy ▸ ✚ New, and both driveable from a script. The 2D guide covers them properly.
▦ Tilemap — a level that can't grow seams
A grid of spritesheet cells drawn as one mesh, one draw call.
If you have built a 2D level out of one quad per tile, you have met the bug this removes: hairline gaps between tiles that open and close as the camera moves. It happens because each tile's edge is computed through its own transform, so two touching edges disagree in the last bit and land either side of a pixel boundary independently. Snapping the camera to whole pixels and overlapping the tiles by a few percent both hide it; neither fixes it, and the overlap only works while your tiles are opaque right to the edge.
A tilemap writes each shared edge once, so both sides are exactly equal and there is no gap to show through — at any zoom, from any camera position.
node:setMaterial{ texture = "textures/tiles.png", sheetCols = 8, sheetRows = 8,
filter = "pixelated" }
node:setTilemap{ cols = 20, rows = 12, tile = 1.5 }
local tm = node:tilemap()
tm:fill(0)
tm:set(9, 6, EMPTY_TILE) -- a hole
The sheet is the node's ordinary Material, so a tilemap is dressed like anything
else. tm:set / tm:get / tm:fill / tm:size re-dress a room without
rebuilding the node — and it's one node instead of two hundred.
▪ Sprite Batch — many sprites, one node, a colour each
local b = node:sprites()
for _, bullet in ipairs(bullets) do
b:draw(bullet.x, bullet.y, 0, 1, bullet.angle, bullet.frame)
end
-- this one is flashing, and only this one
b:draw(e.x, e.y, 0, 1.5, 0, e.frame, 1, 0.25, 0.25)
Each sprite has its own position, rotation, scale, sheet cell and tint — without a scene node per sprite and without a pool to grow.
The tint is the part that was genuinely impossible before. Colour lives on the Material, and a pool of quads shares one, so a game that wanted to flash one enemy red had to blink the sprite off on alternate frames instead — a different effect, chosen because the right one couldn't be reached. That's now a tint, and so are fades, status colours and damage numbers.
b:draw is immediate mode, the same as draw.*: what you draw this frame is
what shows, and next frame starts empty. Nothing to clear, nothing to leak.
Sheet cells stopped bleeding, and pixels-per-unit comes from the engine
Every spritesheet window is now pulled in by half a texel, so a cell can no longer pick up a rim of the frame beside it under linear filtering. Plain textures are untouched.
And camera.pixelsPerUnit([distance]) returns what every 2D project used to work
out by hand from the field of view and the camera's Z — the number you snap a
camera to for crisp pixels.
What isn't in it: an orthographic mode on the Camera node. These are built
against a normal camera looking down -Z at a flat plane, which is what
pixelsPerUnit measures for. There's no 2D physics layer, sprite editor or
animation state machine either — those work well enough in a script today.
An additive scene can take over the sky
Loading a scene on top of another one brought its nodes and nothing else — the base scene kept the sun, the fog, the skybox and the post-processing. That was the right default and the wrong only option: a layer carrying its own Skybox became a second skybox, and which one you got was decided by whichever the engine happened to find first.
Now you can hand the environment over, explicitly:
scene.load("weather/storm", { additive = true, environment = true })
For as long as that layer is loaded, its lighting block — sun, shadows and fog —
plus its Skybox and Post Processing nodes are the world's. The base scene's are
switched off, not destroyed, and scene.unload gives everything back
exactly as it was. A storm, a night, a flashback or a boss arena is now a scene
you can drop on and take off again.
Only the nodes the layer actually put to sleep wake up again, so an environment node you disabled yourself stays disabled.
Also in this release
- Unticking a Post Processing node in the Inspector did nothing. The disabled switch was ignored when the frame gathered the scene's post chain and its skybox, so a node you had switched off still decided the look. Both now skip disabled nodes.
- The reference's example for
tweenshowed arguments in an order it doesn't accept. It now shows the real one:tween(seconds, fn, ease).
Upgrading
Nothing to do. No project files change, and nothing that already worked behaves differently — the 2D calls are additions, and the half-texel inset only touches materials that were already sliced into a spritesheet.
If you've rearranged your panels, the new tab won't appear in your saved layout — Help ▸ 🎓 Learn opens it, and Window ▸ ⟲ Reset layout puts it where it ships.
Read the notes
Two dozen buttons in the editor were drawing as empty boxes, and most of the scripting API had no description anywhere you could read it. Both are fixed. Nothing to do on upgrade.
The blank buttons have icons again
Twenty-four symbols the editor uses had no glyph in any font it loads, so they drew as an empty rectangle — at eighty-eight places across the tool. Among them:
- Terrain checkpoint messages in the Console, and the Terrain tool buttons.
- The ▦ Map tool — its Console messages, the marker on a node that carries map geometry, and the note in the Inspector telling you where to edit it.
- ◇ Save as Prefab, in the Hierarchy right-click menu and the Assets browser.
- ✏ Paint and ⊘ Erase in the Paint tab, and the vertex/texture paint messages.
- ⬇ Import… in Assets, ⎘ Duplicate in the shader graph, ⎘ Copy keys in the animation timeline, ⎘ copy example in the docs.
- ⊞ Snap to grid in both the Map tool and the UI canvas.
- ☉ Celestial Body in the Inspector's component list.
- ◎ Key all tracks, ▤ Format, ♻ Clean unused geometry, 🔗 Rig selected object to flow, ✚ Add, ✖ clear, the align buttons on the UI canvas, and the collapse arrows on the Map HUD.
The editor's bundled fonts cover far less than the emoji block suggests, and the check meant to catch this looked at three files out of a hundred. It now reads every piece of text the editor can draw, and it asks the font files themselves rather than a helper that turned out to answer wrongly in both directions — a few icons that had been replaced because that helper called them broken were working the whole time, and they're back.
Every Lua call is documented
188 names had no description anywhere. If you went looking for them, there was nothing to find:
- All of
water.*—water.depthAt,water.at,water.isUnderwater,water.setFrozen,water.volumes. - All of
scatter.*—scatter.create,scatter.near,scatter.remove,scatter.removed,scatter.restore,scatter.destroy. - All of
assembly.*— the held forces,assembly.split,assembly.merge,assembly.info,assembly.impactsand the rest. - The shape queries:
overlapSphere,spherecast,capsulecast. physics.pause,physics.isPaused,physics.step.- Most of the solid
draw.*shapes —draw.box,draw.cone,draw.disc,draw.ring,draw.sphere,draw.tri,draw.rect,draw.rectOutline. - The gamepad calls:
input.pads,input.padCount,input.padButton,input.padAxis, and the rebinding pairinput.commitRebind/input.cancelRebind. scene.unload,scene.onLoaded,space.body,terrain.slotAt,terrain.yields,camera.screenRect,camera.exists.- Most of what
node:getcomponent(...)hands back — every Rigidbody, Point Light, Camera, UI Element, UI Slider, UI Layer and Material field. - Every audio handle: what you can do with the value from
audio.play,audio.trackandnode:sound(). vec3:dot,vec3:cross,vec3:length,vec3:lerp,vec3:distanceand theirvec2counterparts.- Sixteen tables —
water,scatter,assembly,draw,net,ui,save,httpand more — had no overview at all, so nothing told you the area existed.
There is also a new complete reference page, listing every name grouped by what it's for. The reference in the editor, the hover text, the autocomplete and that page are now one thing written once, so they can't tell you different stories.
Searching the reference finds what you typed
Search on the § Docs page used to be a filter: it kept anything that matched
anywhere and drew the survivors in category order, so an entry whose
description happened to contain your word could sit above the call actually
named that. Typing play buried anim:play under everything that said "while
playing".
Now results are ranked — exact name first, then names beginning that way, then names containing it, then descriptions — and shown as one list, best first, with a match count and the group each result belongs to. Browsing with an empty box still gives you the grouped reference to read through.
The written docs are all reachable
Thirty of the forty-one pages in the documentation were linked from nowhere: the UI guides, animation, the web API, and most of the design write-ups. They existed; you just couldn't get to them without knowing the filename.
The index is rewritten around what you're trying to do, with a Find it fast table at the top, and every page is now listed on it.
Upgrading
Nothing to do. No project files change, and no scripting behaviour changes — this release adds descriptions of calls that already worked and fixes symbols that were already there.
Read the notes
Water you can float on, forests that cost a matrix each instead of a node each, and scenes that can be layered instead of only swapped.
Around those, a run of things the engine knew and wouldn't tell you: text that vanished without a word, a stylesheet that failed in silence, a dig that couldn't say what it dug. Plus shape queries, so a sword swing can ask what it hit instead of guessing, and a Scene view you can lock flat for 2D.
Water you can float on
Until now the engine had nothing for water. An ocean was a translucent sphere with a big number in it: no volume, no forces, nothing that knew it was wet. A craft that set down on the sea sank straight through to the seabed.
Water Volume is a node now (≈ in the type menu). Two shapes — a Sea
(a sphere about the node: a planet's ocean, where "up" is different at every
point) and a Pool (an oriented box: a lake, a tank, a flooded room, whose
sides are walls rather than a level you can be standing in from across the
map).
Things float in it, properly:
- Buoyancy is Archimedes, so what floats is a thing's own density against the water's. A wooden crate bobs and a lead ball sinks with no flags to set — the engine already knows both masses and both volumes.
- On a craft the push is applied at each part's own position. A hull that lands flat floats; the same hull nose-down sinks its nose and rights itself. One force at the centre of mass gives you something that bobs but never rights itself, which reads as a trampoline.
- Drag is quadratic, which is what makes a gentle touchdown float and a 60 m/s belly-flop stop hard without either being a special case.
Underwater, the scene's fog is replaced by the volume's own colour and visibility. It goes through the one fog channel every draw path already reads, so meshes, terrain, SDF matter and particles go murky together instead of one of them staying crisp. It works in the editor viewport too, so tuning the colour isn't guesswork.
Freezing is a state, not a second system. A frozen sea applies no buoyancy, no drag and no underwater look; add a collidable surface and it is walkable ground. An ice world's sea used to be a solid white sphere you fell through.
Scripts ask one question and derive the rest — whether you're swimming, whether the engine flooded, whether a gauge goes red:
if water.depthAt(node.pos) > 0 then swimming = true end
local w = water.at(node.pos) -- {depth, density, frozen, node, up}
node.vel = node.vel + w.up * (kick * dt) -- `up` is radial on a sea
water.depthAt and the solver answer from the same geometry, so a swim state
can't disagree with the physics floating it.
Not in this one: the surface has no waves and no shoreline softening against terrain, and underwater is a colour grade rather than refraction. An authorable water shader is still to come.
A forest that isn't a thousand nodes
Building a forest meant createNode per trunk segment — the only construction
API a script had. A plant was 4–14 nodes, so a hard budget of ninety plants made
a "forest" a bubble about fifty-five metres across that moved with you.
scatter.create{...} declares a rule instead, and the engine places and draws
every instance from it, GPU-instanced, with no scene node anywhere in it:
forest = scatter.create{
seed = worldSeed,
center = planet.pos, radius = planet.radius,
perChunk = 32, chunk = 24,
lod = {
{ asset = "models/pine.glb", distance = 60 },
{ asset = "models/pine_far.glb", distance = 220 },
},
fade = 12,
}
Your generator keeps its job — it rolls the species, reads the climate, picks the palette. The engine draws the thousands.
Every instance comes from hash(seed, chunk, index) and nothing else, which is
not a detail but the design. Walk away and back and the same trees stand in the
same places, because a chunk is recomputed rather than remembered. A multiplayer
session never replicates scenery, because every machine derives the same
instances. And "this one is gone" is storable: an instance id is stable, so a
removal set is a handful of numbers rather than the position of every plant you
ever saw.
Props sit on the real ground — settled onto whatever is actually there, taking that ground's normal so a hillside's trees lean with the hill, and dropped rather than left hanging in the air over a canyon. Digging the ground out from under one re-settles it, because the placement was never remembered.
LOD bands cross-dissolve rather than switching. The pop at a band boundary is the thing everyone notices about scatter and nothing else about it.
Harvesting works on ids:
local hits = scatter.near(forest, tipPos, 2.5)
if hits[1] then
scatter.remove(forest, hits[1].id) -- and it stays gone
end
scatter.restore puts them back, for regrowth. scatter.removed(id) is the
list to save — the thing that used to be unstorable.
Not in this one: scattered props have no colliders. You cannot walk into
one and a raycast will not hit one, so aim with scatter.near (a proximity
query, not a ray). Prototypes are mesh assets, not prefabs or script-built
subtrees.
Scenes can be layered, not only swapped
scene.load replaced the world and that was the only thing it could do. Now:
scene.load("rooms/armoury", { additive = true }) -- layer it in
scene.unload("rooms/armoury") -- take it away again
Nothing is torn down: no script restarts, no audio stops, and the new nodes join the live physics the way a spawned prefab does. That's what makes it cheap enough to stream a level in pieces, bring in a UI overlay without losing the world behind it, or keep a hub resident while a mission loads.
An additive scene brings nodes only — no second sun, skybox or post-processing chain. A world has one environment and the base scene owns it. A second lighting node would leave your look decided by query order, which reads as "the additive scene broke my lighting".
Unloading takes back exactly what its load brought, plus anything you parented under it — a projectile fired inside a room leaves with the room rather than becoming a child of nothing. The scene you opened is never a candidate; you can't unload the world out from under yourself.
Nodes that outlive a swap
node.persistent = true
A persistent node keeps its entity, its components, its physics body and its
running script. start does not re-fire, because the node never stopped
existing — the state in your script's locals is still there on the other side.
The equivalent of DontDestroyOnLoad, for a HUD, a music player, a party, a
save-game manager. It carries the whole subtree.
Two edges: a survivor whose parent did not survive is re-rooted and keeps its world pose, and if a survivor brought a lighting/skybox/post node, the incoming scene's wins.
Being told when a load finishes
scene.onLoaded(function(name, additive)
if not additive then hide(loadingScreen) end
end)
The callback fires after the world is whole. A loading screen's job is to go away once the thing it was covering exists, so being told earlier would be a lie. A subscription dies with the script that made it — which is why a screen covering a full swap marks itself persistent first. Something has to outlive the load to be told about it.
Text stops disappearing
Text is drawn from a cache of rendered letters. It could fill up — and when it
did, words quietly started losing letters, for the rest of the session. A move
list came out as uarter crcle w + t. Nothing on screen said anything was wrong,
and the bigger your window, the sooner it happened: a project that was fine
windowed could lose its text in fullscreen.
The cache now grows instead of filling up, drops letters nothing has drawn for a while, and packs what it holds far more tightly — a sixteen-size project that used to need twice the space now fits with room to spare. If it ever does run out, the letters it can't fit draw as ▯ rather than as nothing, because a row of boxes reads as a bug and a blank reads as a label you left empty on purpose. It recovers on the next frame.
A text size is still the thing that costs: every distinct size is a whole
alphabet, at every resolution your game is played at. Animating textSize in
update rasterizes one per intermediate value — a half-second size pop is about
thirty alphabets for one flourish. Animate scale instead; it's free.
Scripting → §7 now says so.
A broken stylesheet says which file, and doesn't take the look with it
A .uistyle.ron, .tokens.ron or input.ron that failed to parse was reported
to the editor Console — which doesn't exist in a running game. In --play and in
every exported build, the failure was completely silent: the game kept running
with an empty sheet, wearing whatever each element was authored with. That is
indistinguishable from "the restyle didn't help".
Warnings and errors now go to the terminal in a build:
floptle: error: ui styles menu.uistyle.ron: 12:25: Expected identifier
Two more, in the same area:
- A parse failure keeps the last sheet that loaded, and says so, instead of falling back to an empty one. One typo used to turn the entire game grey.
- A
style:naming a style that's in no sheet warns, once. This is the commonest thing a rename breaks and it was invisible — the element still draws, it just draws the way it was authored, which looks deliberate.
Every colour in a style can be a token
shadow and glow could name a project token. gradient.to, text_stroke and
text_shadow couldn't — and one token name in a gradient failed the whole file,
taking every style in the project with it. All three now take a token or a
literal, so that's the complete set.
Gradients also got the shorthand they wanted most. Leave to out entirely and it
means the fill, faded to nothing:
gradient: (kind: Linear, to: "bg", angle: 90.0), // fade to a named colour
gradient: (kind: Linear, angle: 90.0), // fade THIS out
It follows the fill, so a button whose hover changes its colour fades correctly without restating anything. Written by hand this needs the fill's exact RGB repeated, and any drift sends the fade through the wrong hue on its way to transparent.
Set a UI image's texture from a script
node.texture = "textures/ui/portrait.png" did nothing. Not an error, not a
warning — nothing. A character-select screen assigned portraits that way for
months and showed the placeholder on every slot.
It works now, from both the node and the component handle, and it reads as
well as writes, so it behaves like node.text rather than being a write-only
corner. Assigning a non-string raises instead of being dropped.
slot.texture = roster[i].portrait
Ask what's inside a volume, not just along a line
raycast answers what is along this line. A melee swing, an explosion or a
"can I fit through there" asks what is inside this volume, and a fan of rays
answers that badly — it misses anything thinner than the fan and can't tell you
how deep the overlap was.
-- Everything within 2 m of the sword, deepest overlap first.
for _, hit in ipairs(overlapSphere(swordTip, 2.0, { layers = "Enemies" })) do
combat.hurt(hit.node, 25)
end
-- A thrown rock: a swept sphere hits what a ray squeaks past.
local h = spherecast(node.pos, vel:normalized(), 0.4, 30, { layers = "Ground" })
-- "Can I actually walk there", asked with the shape that will be walking.
local blocked = capsulecast(node.pos, moveDir, 0.4, 0.9, 1.5)
Hits carry the same fields a raycast hit does — position, normal, distance and
node — and take the same options table, so learning one teaches the others. For
an overlap, distance is the penetration depth.
In a multiplayer game these are lag-compensated like raycasts are: inside
net.rewind, a swing sees every player where the attacker saw them. That was
promised for shape queries from the start and only raycasts had ever done it.
A dig can tell you what it removed
terrain.dig changed the field and returned nothing, and there was no way to ask
what the ground at a point was made of. A mining game had to guess at both — the
volume from a flat rate per dab, the material from its own noise.
sculpt and dig now return an id, and the measured report arrives through
terrain.yields():
local id = terrain.dig(h.x, h.y, h.z, 2.0)
-- a later frame:
for _, y in ipairs(terrain.yields()) do
for slot, volume in pairs(y.slots) do
inventory.add(ORE[slot], volume) -- what it was, and how much
end
end
The report comes a frame or so after the dig because an edit is applied after scripts run — measuring it when it lands is what makes the number exact rather than a prediction. Volumes are additive: sum them over a shaft and you get the volume that actually left. A careful shaft and a sloppy cavern now differ by the truth instead of by the number of dabs.
terrain.slotAt(x, y, z) answers the same question without digging — survey
before you cut, or let a footstep know what it's standing on.
Lock the Scene view to a plane
Building a 2D game in a 3D editor means fighting the camera: every drag nudges you a little off-axis, and "flat" becomes something you keep re-achieving rather than something you have.
The Scene view's toolbar (top right, next to Gizmos) now has a View menu: Front (XY), Side (ZY) or Top (XZ). A locked view is square to its plane and stays there — mouse-look does nothing at all, so nothing you do with the right button can knock it off. Locking doesn't move the camera, so whatever you were looking at stays in front of you.
Movement follows the plane rather than the camera. On a Top view, W slides you north across the map instead of burying the camera in the floor it is pointing at; Space/Ctrl become the deliberate way to step through the plane, a layer at a time. Middle-drag panning and wheel zoom work exactly as they always have — which, on a locked view, is all the navigation you need.
Maximized tabs fill the window properly
Double-clicking a tab to maximize it left a thin band along the top edge of the window with the 3D scene showing through it, whichever tab you had maximized. The restore bar didn't paint a background, so that strip was simply transparent. It's a proper panel now, and the tab below it takes the whole remaining window.
Not in this one
Scattered props have no collision. You can see a forest from a long way off now, and you still walk through every tree in it. The proximity query is enough to harvest with; it is not enough to bump into. This is the next piece of that feature rather than an oversight in it.
Water has no surface detail. No waves, no shoreline softening where the sea meets the ground, no refraction — the surface is a tinted, specular volume the right size and shape, and underwater is a colour grade. It floats things correctly, which was the missing half; making it look like an ocean from a beach is the other.
Ships still pass through each other. Two separated craft have no contacts between them; a station is still scenery you fly through. The design is settled and written down — contacts apply to everything except bodies a rollback session is driving, so a fighting game's replays stay exact — but the work sits behind the rollback release in progress, rather than landing underneath it.
Upgrading
Nothing to do. Existing projects, styles and scripts work unchanged.
Two things you can now delete: any tool that estimated your text budget (the engine grows the cache itself), and any static checker for stylesheet typos (the engine reports them where they happen, naming the file and the position).
Read the notes
Your UI can wear a sprite. A panel's edge, a button's outline, the box around the thing the player has selected — any of them can be a piece of art you drew instead of a rectangle the engine strokes for you.
Frames
A style can now name a 9-sliced sprite as an element's edge:
"panel": (
base: (
fill: "ink",
border_color: "silver",
frame: (
texture: "textures/ui/frames.png",
uv: (0.0, 0.0, 0.25, 0.5),
slice: (0.25, 0.25, 0.25, 0.25),
),
),
),
The corners keep their drawn size and only the middles stretch, so one 48-pixel sprite is a button, a dialog and a full-screen panel without ever smearing.
uv picks a window into the texture, so every frame in your game can live in
one file — one texture, one draw call, and a new panel style costs a line
rather than an asset.
A frame is tinted by border_color. That is the same property a drawn border
already used, which means two things worth knowing: one white sprite becomes a
bright focused edge and a dim idle one with no second asset, and a frame picks up
your style's hover and focus transitions without you doing anything.
From Lua, the same three properties are frame, frameUV and frameSlice.
Watch the size
A corner never stretches, so an element shorter than two corners has no middle
left. When that happens the sprite is drawn as one stretched quad instead — which
does not look like a small frame, it looks like a smear. Frames want roughly
twice your corner size in both directions; anything smaller is happier with a
plain border.
Upgrading
Nothing changes for a project that doesn't use frames — styles without one draw exactly as they did.
Open the Hub and install 0.23.0, then press Engine 0.23.0 on any project you want to move over.
This release updated the Floptle Hub, not the engine. The engine bundle in this tag is a rebuild of the previous one.
Read the notes
A Hub release, about version numbers. The Hub, the engine, and the engine a project is pinned to are three different things that all wear the same number, and the Hub was doing nothing to tell them apart.
The Hub says which version it is
The Hub's own version now sits beside its name at the top of the window, on every tab. It used to appear only on About — so a version number anywhere else in the app had nothing on screen saying which of the three it was.
A Hub release stops pretending to be an engine release
One tag builds the engine and the Hub together, so every release ships both bundles whether or not either changed. v0.22.1 changed only the Hub, and there was no way to tell: it appeared in Installs as a new engine, the banner told you to install it, and every project was offered a migration onto it. Its own notes said the engine was unchanged, three screens below the Install button.
Now a release that changed only the Hub says so:
- In the list, it reads Hub only instead of new.
- Above the Install button, before you decide: "the engine in it is the same one as 0.22.0, so installing it changes nothing about how your projects run."
- The update banner stays quiet. It offers the newest engine, not the newest number.
- Projects aren't offered a pointless migration. The offer now depends on whether the engine actually differs across the gap, not on which number is bigger — so a project on 0.21.2 is still offered the jump to 0.22.1, because 0.22.0 is in between and that one was real.
Releases from before this carry no such marking, and anything unmarked counts as having changed both — so nothing in the back catalogue quietly disappears.
Everything says which one it means
- The update banner and the chip beside the tabs read Engine 0.22.1, not Floptle 0.22.1 — which sat a few pixels from the words "Floptle Hub" and was the confusion in a single line.
- The upgrade button on a project reads Engine 0.22.1 rather than a bare 0.22.1.
- The Installs list says what it lists: the engine your projects run — the Hub updates itself, separately from this list.
Upgrading
Open the Hub and press Update and restart. The engine is unchanged in this release — and this is now the first release where the Hub will tell you that itself, rather than offering you an install that does nothing.
If you have projects still pinned to an older engine, the Projects tab shows an Engine X button on each one.
This release updated the Floptle Hub, not the engine. The engine bundle in this tag is a rebuild of the previous one.
Read the notes
A Hub release. There's a News page, the version list is a list you can actually click, and every release note ever written has been rewritten to be about you rather than about us.
📰 News
A new tab, answering the question a changelog can't: is this thing alive, and where is it going.
What just shipped and why it matters, what's being worked on right now, and what it's being built towards — with the newest release at the top of the page and a button that takes you straight to it. It arrives with the version list the Hub already fetches, so it costs no extra loading and reads fine with the network off.
The version list is a list
The column was 196 pixels wide and only the version number took the click. Both of those were wrong.
- It takes a share of the window now instead of a fixed width, so it reads as a column on a laptop and on an ultrawide.
- The whole row is the target — both lines and the padding, full width. The old hit area was the width of the text "0.21.0" and one line tall, with dead space all around it that looked clickable and wasn't.
- Each row shows the release's name, because "Say It Simply" is what you
remember a version by, not
0.20.0.
Buttons you can hit
Install, Set default, Show files and Uninstall were text with a few pixels of padding around them — about 60×20 for the primary action of the whole tab. They're all a proper size now, with room between them, and Install reads as the main one.
A broken install also gets its own line to say so, and says what to do about it, instead of sitting between two buttons looking like a third.
Every release note, rewritten
The notes the Hub shows you were written like a work log. They carried test counts, internal module names, function names from the source, and long post-mortems about which test should have caught what — none of which tells you whether to install a version.
All thirty-two are rewritten to answer one question: what does this do for the person using Floptle? Symptoms in the words you'd use to report them, the public API, and what to do on upgrade. a style rule writes it down so the next one follows it.
You'll see the whole rewritten history the moment you update — the notes travel with the version list, and every release refreshes all of them.
Upgrading
Open the Hub and press Update and restart. The engine is unchanged in this release, so there's nothing to reinstall.
If your Hub is older than v0.21.2 it can't update itself and won't see this — docs/updating-the-hub.md has the one-time manual step for each platform.
Read the notes
Modelling that behaves, levels that survive a scene change, and shortcuts that keep working.
Your map survives a scene change
Loading one scene from another — a menu into a level, say — used to bring the level in stripped: untextured surfaces, wrong materials, missing paint, and collision that didn't match what you could see. Opening the same scene directly looked perfect, which made it look like the scene file was fine and the game was broken.
It is fixed. A level now loads the same way whichever direction you arrive from.
If you hit this, nothing on disk was damaged — it was only ever a loading problem.
Modelling
Faces no longer fold when you move a vertex. Dragging a corner could crease a face across a diagonal, stretch it into a shape it shouldn't be, or spill it outside its own outline. Faces with more than three corners were being split into triangles the simplest possible way, which is only correct while the face stays flat and convex — and moving any vertex ends both.
Faces are split properly now: four-cornered faces fold along their shorter diagonal, and larger ones are cut to their real outline, so an L-shaped face stays L-shaped instead of filling in its own notch. The same geometry drives clicking and collision, so what you select and what you walk into now match what you see.
Right-click in the viewport for what you can do to the selection. Extrude, inset, subdivide, bridge, flip, detach, delete, weld, snap — plus a Select submenu and a mode switch — all in reach without knowing a shortcut first.
Select ⏵ Shrink takes a ring off your selection, the counterpart to Grow.
Select ⏵ Warped faces finds every face whose corners no longer sit in one plane. A warped face is usually the thing that looks wrong after an edit, and until now you had to spot it by eye.
Two fixes worth naming:
- Selecting every face of a closed shape and pressing Extrude used to move the whole thing sideways and leave a cloud of stray vertices behind. There is no direction to extrude a closed solid, so it now declines instead of guessing.
- Welding two corners that weren't next to each other left an invisible, unclickable face behind that still got in the way of selections. It doesn't any more.
Turn nodes off
Right-click a node in the Hierarchy ⏵ Disable. It stops drawing, stops colliding and its scripts stop running — and so does everything beneath it, so you can switch off a whole room, a variant or a debug rig in one click. Disabled nodes are greyed out in the tree, and it's saved with the scene.
From a script: node.enabled = false.
Shortcuts that stop working
If you held Ctrl and alt-tabbed away — or clicked something that opened your browser — the editor could get stuck believing Ctrl was still down. After that, plain keys behaved like shortcuts, the map tool's keys did nothing, and the fly camera stopped moving, until you restarted.
Losing focus now releases everything, which is what the rest of the editor was already doing.
The scene tree opens folded
Opening a scene expanded every folder at once. It starts folded now, so you see the shape of the scene instead of all of it.
Reporting a problem
There's a Help menu, with the issue tracker in it.
And if the editor crashes, it saves a report — then offers to file it for you the next time you open it, with the version, platform and technical details already filled in. You can read and edit it before posting, and nothing is ever sent automatically.
Also
Signing in from a game now tells you when there's simply nothing to show, rather than saying "loading" forever.
Read the notes
The Hub can update itself now. Until this release it could not, and it never said so.
What was actually wrong
The Hub has installed engine versions since v0.7.0, but it had no way to install itself. Updating meant noticing a release had happened, finding the right archive for your platform on GitHub, and replacing the binary by hand. Most people would simply never have done it.
Nothing compared the running version to the newest one either. About printed
version 0.21.1 and stopped — a fact you can't act on, because it only means
something next to the version that exists.
Update and restart
One button. It downloads the Hub's own bundle, checks it, puts the new binary where the running one is, and reopens the Hub.
It can legitimately be impossible, and then it says so instead of offering a
button that would fail. A Hub in /usr/local/bin or on a read-only drive cannot
rewrite itself without privileges this app deliberately never asks for; you get
the reason and a download link. That check runs before the button is drawn —
finding out after a 6 MB download is a worse way to learn it.
If the swap fails halfway it puts the old Hub back. A Hub that moved itself aside and then failed to land the replacement is the one outcome nobody could recover from inside the app, so it's the case with the most care spent on it.
Being out of date is now unmissable
Three levels, on purpose:
- A banner, on launch. "Later" puts it away for this session only — it's back next time you open the Hub. The engine banner remembers a dismissal per version, which is right for "there is a newer engine you may or may not want"; it's wrong for "the app you are using is old", where one idle click would silence it forever.
- A chip in the tab bar that cannot be dismissed at all and stays until the update is actually installed. It covers the engine too — the Hub outranks it, because an old Hub is what would stop you fixing the other one.
- The About tab answers the question directly: This is the newest Hub, or the new version with its release notes and the button.
Upgrading — this one time, by hand
A Hub at v0.21.1 or older has none of this code and cannot see the update, so download v0.21.2 from the releases page once. Every release after this one is a single click from inside the Hub.
See docs/updating-the-hub.md for step-by-step instructions on each platform.
Read the notes
Four fixes, and one of them had been wrong for a year.
input.pressed("f9") was always false
Not "sometimes". No function key has ever reached a script. Nor a numpad key, a bracket, Home, PageDown or a backtick — so a script asking about any of them got the same answer as a script asking about a key nobody pressed.
The same key was bindable in Project Settings the whole time, because that path went through a different list which has had the full set since v0.8.0. There's one list now.
A warning for the hook signature that quietly kills a script
function update(dt) -- ✖ `dt` is the NODE
Every lifecycle hook is called with the node first. Write update(dt) and Lua
binds the node to dt, and the first piece of arithmetic that touches it fails —
every frame, before anything visible has happened.
From the outside that's not an error, it's a script that does nothing at all, which is a much harder thing to go looking for. The warnings strip now says so, and names the signature to write instead.
The Hub signs in again
Every Hub install that had ever signed in was pointing at a sign-in server that has since been retired, so signing in failed outright. It's rewritten on load now.
Anything else you chose is still yours — a local development server is a supported target.
The sign-in that server left behind is dropped too. It names an account that only exists in a database that's gone, so a window that shows your name and then fails at everything is worse than a sign-in button.
The Hub tells you what a release was
The Installs tab used to show a version, a date and an Install button, which tells somebody deciding whether to upgrade precisely nothing. Now it's a list of versions and, beside it, that release's notes, rendered.
They travel with the version list, so one fetch buys the whole history offline — including the versions already on your disk and the ones published long before this existed. The back catalogue populates itself.
Upgrading
Additive, with one behaviour change worth naming: keys that never worked now
work. If a script polls input.pressed for a key it could not previously see,
it will start seeing it.
Read the notes
A game can ask who is sitting in front of it.
account.*
account.signIn() -- returns immediately; they approve in a browser
account.state() -- "signedOut" | "starting" | "waiting" | "signedIn" | "failed"
account.code() -- while waiting: { code = "WXYZ-9999", url = "…", expiresIn }
account.player() -- when signed in: { id, name, email, tier }
account.error() account.cancel() account.signOut()
account.get("/wallet", function(res) end)
account.post("/games/mygame/events", { event = "boss_killed", event_id = id }, cb)
account.put("/games/mygame/saves/slot1", { data = t }, cb)
account.delete("/games/mygame/saves/slot1", cb)
Foverse accounts, Fobucks, cloud saves, leaderboards and missions — from Lua, in the editor and in an exported build, with or without the Hub installed.
A script asks for a player, never a token. There is no account.token() and
there will not be: a shipped game's Lua is readable, and anything a script can
hold, somebody can read out of the file and post somewhere else.
That's also why the calls take a path rather than a URL. There is exactly one
host they can reach, and that is the whole security model. A URL where a path
belongs fails at the call site with the reason, rather than arriving three frames
later as res.error that reads like the server refused it.
Polled, not called back. Signing in takes as long as a person takes to pick up
their phone and open a browser. A sign-in screen is redrawing every frame anyway,
so state() reports where the flow got to — a callback for something that can sit
at "waiting" for a minute never has to exist.
One session, everywhere. It lives in your OS keyring, in the same place the
Hub uses — sign in from the Hub and your game already knows the player, sign in
from a game and the Hub does. Stop and scene.load abandon an unfinished
sign-in; the session survives, because nobody should have to sign in again for
pressing Play twice.
Nothing happens until it's asked for. account.state() answers "signedOut"
without constructing anything — a project that never signs anybody in never reads
the keyring, and never trips an "an app wants your passwords" prompt on a player
who only wanted to play the game.
The rule this exists to enforce
Floptle Cloud has missions and a Fobucks wallet, and Fobucks are real — they buy real goods on fopull.com. So the wallet is read-only and there is no route that credits it. A game reports what happened and the server owns the rule that turns it into money:
account.post("/games/mygame/events", {
event = "boss_killed", count = 1,
event_id = playerId .. ":boss:" .. n, -- REQUIRED; makes the report idempotent
}, function(res) end)
A modified build can lie about the event. It cannot invent the amount, claim a
once mission twice, or write the balance.
docs/web-api.md has the three answers that surprise a first
test — event_id is mandatory, an empty awarded is not always a failure, and a
mission pays nothing until it's approved.
The wireframe no longer dims when you stand inside a room
v0.20.0's new depth cue asks, per edge, does any face using this point at the camera? Inside a box it answers "all of it" — and building a box and standing in it is the commonest position there is in map editing, so the whole wireframe went faint at once.
The answer was true and useless: the only faces that could hide anything from you in there are the ones behind the camera. Same for the back of a one-sided plane.
Nothing facing you means nothing is in the way, so the cue steps aside and everything draws at full strength. The distance fade is unaffected.
Upgrading
Additive. Nothing to do.
Read the notes
Scripts read as a pile of maths. Two causes, and they needed different fixes: directions had no helpers at all, and everything else had helpers nobody could find. Both are addressed here — plus the knife tool that took three tries, and a map wireframe you can finally read depth in.
The arithmetic you were writing by hand
Every script that needed a direction wrote it out longhand. This is from the engine's own examples, shipped, twice each:
-- "point at where I'm going" -- why the minus signs?
local want_yaw = math.atan2(-want_x, -want_z)
local d = (want_yaw - node.yaw + math.pi) % (math.pi * 2) - math.pi
node.yaw = node.yaw + d * math.min(1.0, params.turn_speed * dt)
-- "forward along the ground, on any planet"
local cy, sy = math.cos(node.yaw), math.sin(node.yaw)
local fx, fy, fz = -sy, 0.0, -cy
local fd = fx * ux + fy * uy + fz * uz
fx, fy, fz = normalize(fx - ux * fd, fy - uy * fd, fz - uz * fd)
Both correct. Both unreadable. Both written from scratch every time. That's the shape of a missing API, so:
node:turnTowards(node.vel, params.turn_speed * dt)
local fwd = dirFromYaw(node.yaw):flatten(node.up)
Directions — node:lookAt(target [, up]),
node:turnTowards(target, maxRadians), dirTo(from, to), yawOf(dir),
pitchOf(dir), dirFromYaw(yaw [, pitch]), lookRotation(dir [, up]).
Vectors — v:flatten(up), v:withX/withY/withZ(n), v:rotatedY(rad),
v:rotatedAround(axis, rad), v:towards(other, maxDelta), v:angleTo(other).
Local ↔ world — node:toWorld(v), node:toLocal(v), node:setWorldPos(v),
node:worldForward() / worldRight() / worldUp(), node:distanceTo(other),
node:distanceFlat(other [, up]).
Movement — moveTowards(node, target, maxDelta), ease(a, b, rate, dt),
smoothDamp(cur, target, vel, time, dt).
Screen — draw.text(x, y, s, size, r,g,b [,a] [,align]), draw.circle,
draw.circleOutline. draw.text is measured and laid out with the same fonts
ui.make uses, so a HUD needs no UI tree and no idea how wide an m is.
Nothing here can produce a NaN: a zero-length direction leaves a facing alone,
yawOf(vec3(0,0,0)) is 0, dirTo(p, p) is vec3(0,0,0).
node:lookAt with an up sets the roll as well, which is the twenty-line
undo-yaw-then-pitch dance a planet camera needs to keep its horizon level.
node:setWorldPos stays exact under a mirrored parent.
The proof: the examples got shorter
An API nobody reaches for is worse than no API, so the test was whether the engine's own scripts shrink. All five are ported in this release:
| before | after | ||
|---|---|---|---|
planet_camera.lua |
264 | 210 | −54 |
rts_camera.lua |
207 | 185 | −22 |
first_person.lua |
181 | 168 | −13 |
rts_unit.lua |
144 | 134 | −10 |
third_person_camera.lua |
143 | 136 | −7 |
| 939 | 833 | −106 |
Six private helpers were deleted with them — each script's own ease, clamp,
norm, cross and normalize.
raycast also accepts the vector form the docs have taught since v0.17:
raycast(node.pos, -up, 1.5). It only became true now.
node.worldX and friends honour the handle's live local position, so
node.pos = p is visible to node.worldX on the very next line instead of one
hook later.
The engine teaches
The Scripting tab's Docs page is three pages instead of one endless scroll: Guides, an API browser, and the shader stdlib. The browser is grouped by namespace, open by default, and every name and every example copies on click.
Reference material you can only reach by searching for a name you already know teaches nobody the thing they didn't know existed — and "the engine has a scheduler and nobody knows" was the complaint that started this work.
Building the new grouping turned up 43 entries that had never been displayed at
all — the entire ui.* surface, the pointer hooks, color, and the
noderef/scriptref/componentref wiring.
Hover docs now resolve what people actually type: target:lookAt and
player.worldPos explain themselves, not just the literal spelling in the
reference. When two namespaces claim the same member name it says nothing rather
than guessing.
Talking to your server: http.* and json.*
There was no HTTP at all in Lua, which meant no account, no card list, no leaderboard, no shop.
http.get(url [, opts], function(res) end)
http.post(url, body [, opts], function(res) end) -- a TABLE body is sent as JSON
-- opts = { headers = {...}, timeout = 10, json = true }
-- res = { ok, status, body, json, error }
json.encode(t) json.decode(s) -- decode returns nil, err rather than raising
openUrl(url) -- the player's own browser
Non-blocking, and only that. The callback runs on a later frame on the main thread, so it's safe to touch nodes from it and a slow server can never stall a frame. There is no blocking form on purpose: it's the one everybody reaches for, and it turns a 300 ms round trip into a 300 ms freeze.
A 4xx is an answer, not an error — res.ok is false and res.body still
holds what the server said, because that's where an API explains itself.
Malformed JSON sets res.error instead of raising. Someone else's server having
a bad day is data, not a bug in your script.
Play only. Stop and scene.load cancel everything in flight, and a reply from the
previous session is dropped rather than delivered into a fresh one. A call from
fixedUpdate warns once: a reply arrives when it arrives, and no replay can
reproduce that. Caps (8 in flight, 20/second, 8 MB, 120 s) announce themselves
exactly once in the Console.
assets/scripts/web_login.lua ships as the worked example: the device-code
flow, so the game never sees a password and needs no secret baked into it — a
shipped game's Lua is readable. docs/web-api.md is the full
page, and it leads with the rule that makes any of it safe:
The server decides what the player owns. The client asks; it never announces. Anything a client can announce, a modified client can announce.
What is not here: signing in to a Foverse account from a script. That provider requires a cryptographic step Lua can't do. It arrives in v0.21.0.
The knife cuts where you aimed
It took several tries, or trying again from the other side. Every click re-picked the face from whatever the ray hit first — so aiming near a box's corner landed the second point on the neighbouring face, and the tool responded by silently throwing the anchor away and starting a new cut. Which face won depended on the camera angle, which is exactly why turning around sometimes appeared to fix it.
The first click chooses the face; after that the aim is locked to it. The second point is solved against that face's own plane, so the cursor can drift past an edge or across a nearer face and the cut still tracks where you mean.
And a cut that can't divide the face now greys out while you're still aiming and says why next to the cursor.
A wireframe you can read depth in
The map overlay drew a box's far rim exactly like its near one, so there was no telling which side of a shape you were looking at or what a click would grab. Two cues now:
- Distance — edges and vertex dots fade and thin with how far away they are, normalised over the mesh's own size, so a doorframe and a hangar read alike.
- Behind the surface — anything the mesh's own front faces hide draws faint, and a vertex round the back draws as a small ring instead of a filled dot.
Occluded geometry is still drawn and still selectable — reaching through a blockout is the point of the see-through modes. Selected elements keep full brightness, so a selection never vanishes into the fade.
Upgrading
Everything is additive; nothing changes meaning, and the old spellings all still work.
Read the notes
The RTS starter kit, fixed where it was wrong and simplified where it was fighting the wrong tool. Plus Escape no longer closes the editor.
The camera that slid away forever
Click into the Game view and the map drifted off in one direction and never came back.
Edge panning compared the cursor's position against the view's size, with no idea where the view actually sits. In the editor the Game view is a docked panel, so the cursor carries the offset of everything to its left: it read as "past the right edge" from the moment the panel opened, and the camera obeyed, forever.
New API: camera.screenRect() → x, y, w, h, the viewport's rectangle in the
same pixels input.mouse() reports. The camera now pans only while the cursor is
genuinely inside the view, and stops at the real edges.
Selection is a screen rectangle now
The marquee used to be four lines projected onto a ground plane — which fights the camera angle, misses anything the plane doesn't pass through, and is a lot of maths for a box. Two new immediate-mode calls, in pixels:
draw.rect(x, y, w, h, r, g, b [, a] [, radius]) -- filled
draw.rectOutline(x, y, w, h, r, g, b [, a] [, px]) -- hollow
They draw over the scene and over the HUD, in the Game view and in a build alike.
rts_commander.lua is now the two corners you dragged between, and a unit is
selected when camera.worldToScreen puts it inside them — which is how every RTS
has ever done marquee selection.
Units that arrive
node.x/y/z are local. A unit parented under a container compared its local x
against a world-space order, never arrived, and walked off into the distance —
the softlock. Two fixes:
node.worldX/node.worldY/node.worldZ/node.worldPos— read-only world position, composed up the whole parent chain. The engine's own composition, so it agrees with the renderer exactly.rts_unit.luameasures itself in world space, and has a watchdog: a unit that hasn't got closer forgive_up_afterseconds (3 by default) stops instead of shoving a wall for the rest of the match.
Checkbox params also arrive as 1/0 once saved on a node — and 0 is true in
Lua, so unticking "ring" left the ring on. Both RTS scripts test them properly
now.
Arrow keys, and a marker where you clicked
- ← / → rotate the isometric view, ↑ / ↓ zoom — added to the starter map's
TurnandZoomaxes, so they're rebindable like everything else. One axis carries both a wheel (a one-frame spike) and held keys (a steady 1), so zoom takes one step per press and then auto-repeats, instead of either ignoring the wheel or crossing the whole zoom range in three frames. - A right-click order fires a particle one-shot at the destination —
marker_effect, defaulting to a newMoveMarkereffect seeded into every project (blank it for none). The drawn ring stays as well, so a missing effect is never a dead click.
Existing projects keep their own
input.ron. A new named entry (Turn) is added on open, but bindings inside an entry you already have (Zoom) are never touched, by design. Add ↑/↓ to Zoom in Project Settings ⏵ Input if you want them there.
Escape no longer closes the editor
Escape is the universal "back out of this" key — the map tool's disarm, the knife's cancel, the cursor release. When there was nothing left to cancel, it quit the application. One stray press with everything already disarmed and the editor was gone.
Escape now does nothing when there is nothing to cancel. Quitting is the window's close button, or Ctrl+Q (which still routes through the unsaved-changes confirm). Builds are unchanged: Escape only ever frees the cursor there.
The editor tells you about actions now
Nothing ever mentioned that the action map exists, so input.pressed("space")
kept getting written — code that can't be rebound, does nothing on a gamepad, and
reads neutral on a networked node. The Scripting tab flags it with a ➜ and
names the replacement:
input.pressed("space")polls the keyboard directly —input.justPressed("Jump")does the same job through the action map, so it can be rebound, works on a gamepad, and survives multiplayer prediction (Project Settings ⏵ Input)
Advice, not an error, and only for keys the shipped map already covers.
Read the notes
Four things that were quietly lying to you: an undo that gave back the shape but not the paint, two animation states that were secretly one animation, a character controller that flew away if you changed its height, and a whole genre with no starting point.
Undo takes the paint back too
v0.19.0 made blockout paint follow geometry through an edit, so moving a vertex or cutting elsewhere leaves every surviving surface painted. A face whose corners genuinely changed — the top of an extrusion, the halves of a cut — is new geometry, and came back unpainted.
Which was fine, until you pressed Ctrl+Z. The shape came back. The paint it was carrying did not. Paint an entire level, extrude one wall by accident, undo, and that wall's shading was simply gone.
Every map edit now banks the paint as well as the mesh, as one undo step. Undo restores the shape and hands the paint back; redo does the same in the other direction. Anything you painted after the edit always wins, so an undo can never overwrite work you did since.
A scene load also adopts map geometry before paint now, so saved paint always has its geometry to attach to. The "stale paint refused" message now only means what it says.
Two states, one clip — now the editor says so
A controller state is a name plus a pointer to a clip file, and two states
may point at the same file. That's a real authoring choice (one Hit clip across
three attacks). It also means those states are one animation: key it under
either name and both change.
Nothing said so. Make a character, generate a stack of states to fill in, start animating, and animations you'd already made silently turned into copies of the one you were working on — because they'd been sharing a clip file the whole time.
The ✎ Animating tab marks a shared clip ⚠ shared in the animation dropdown now, and selecting one shows which states play it, with two one-click fixes:
- Give this state its own copy — copies the clip to a new file named after the state and repoints only this state. The others keep sharing.
- Split every shared state — the first state to use a clip keeps it, every other one gets its own copy. One click makes a whole bulk-generated controller editable one animation at a time.
Neither happens on its own: sharing you meant stays shared.
The controller that flew into the sky
A character controller writes its own capsule height every frame — that's how
crouching works. The engine was reverting it to the authored height every step,
and re-planting the feet against the reverted shape pushed the body upward by the
same amount every frame. Set stand_height to anything other than the
Rigidbody's own height and your character slowly took off.
A script-set height now outranks the authored one for as long as the script keeps
writing it. stand_height and crouch_height are real numbers you can put
whatever you like in.
first_person.lua also refuses to stand up under a ledge now (it checks for
headroom first), and the stale duplicate character.lua is gone — the docs point
at first_person.lua, which is the same controller with the ground probe and the
debug gizmo.
An RTS in three scripts
New in every project's scripts/, and in assets/scripts/:
| Script | What it does |
|---|---|
rts_camera.lua |
Isometric camera: pan with Move (WASD/stick) or by pushing the cursor to a screen edge, zoom on Zoom (wheel/d-pad), swing the view about its focus point with Turn (Q/E or the bumpers). Eases everything, clamps to optional map bounds, and exposes focusOn(x, z) + follow for minimaps and alerts. |
rts_unit.lua |
A commandable unit: moveTo(x, y, z), stop(), isMoving(), selected. Steers a Rigidbody's velocity when the node has one (units collide and ride slopes) and moves the transform when it doesn't. Draws its own selection ring. |
rts_commander.lua |
The mouse half: click to select, drag a box to select many, Sprint to add, right-click to send the selection there in a loose formation with a click marker. |
The camera looks at a focus point on the ground and orbits it, which is what makes
a click land where the player expects at any zoom. Everything goes through
rts_unit's three-function API, so replacing the unit script — the first thing a
real game does — means keeping three names.
Turn is a new axis in the starter input map (Q/E, or the bumpers on a pad).
Existing projects keep their own input map untouched; add Turn in Project
Settings ▸ Input if you want the rotate keys there too.
Also
- A body no longer moves when its height is set to the value it already had.
Read the notes
The ⬢ Map tool grew a knife, learned to box-select from anywhere, put its sub-object modes on screen — and blockout geometry takes the paint brush now. Plus the bug that made per-face materials impossible to actually assign.
The texture dropdown that shut the instant you opened it
Assigning a texture to a face material's slot didn't work. The dropdown opened and closed on the same frame, every time, so the one control the whole per-face material feature exists for could not be used.
Nothing was wrong with the dropdown. The material editor is drawn in two dock panels at once — the Inspector's Material section and the Map tab's per-slot block — and both were on screen together whenever you were texturing a face. Two copies of one control, each treating the other's click as a click outside itself. They shut each other.
This also fixes the same collision between two slots of the same mesh.
✂ Knife
/ arms it. Click one edge or corner of a face, then another on the same
face, and the face splits along that line. The point under the cursor is drawn
live — a filled dot for a new corner mid-edge, a ring for an existing one — so
you aim the cut before you make it rather than discovering it afterwards.
The interesting part is what happens to the neighbours. Splitting an edge without telling the face on its other side leaves a T-junction: the floor still spans the full edge while the wall now has a corner half way along it, and you get a hairline crack down the seam. So the cut carries into every face using those edges — they gain the same corner and stay welded. Both halves inherit the face's material slot.
After a cut the knife keeps going from the corner it just made, so a groove walks across a face — or out of one half into the next — in one gesture. Esc ends the cut, Esc again puts the knife away.
A cut that can't divide a face (two points on one edge, two corners already joined) is refused with the reason and changes nothing.
Selecting stopped being a click-at-a-time job
Box-select now starts wherever you press — including on the mesh. It used to require empty space to start from, and a blockout fills the screen, so most of the time there was nowhere to begin: picking a row of faces meant clicking them one by one. A press now only anchors; the release decides whether the gesture was a click or a drag.
Shift adds, Ctrl removes. Both used to mean "toggle", which is fine for one click and useless for a box — dragging over a region you'd half-selected flipped the overlap back off.
Select All / None / Invert are on the viewport strip, one click away, and the
All button says what it will select — "All faces", "All vertices", "All edges",
following the mode you're in. Invert (\) is new.
The sub-object mode is on screen
Vertex / edge / face was one cycling word in the corner of the viewport strip and
a "Tab cycles" tooltip. It's now three chips — ◆ vertex, ╱ edge, ◼ face — so
the mode you want is one click and the two you aren't in are visible rather than
somewhere in a rotation. Every chip names its own key (J / K / M); the
direct binds existed all along and nothing in the UI mentioned them.
Switching still converts the selection rather than dropping it: pick a face,
press J, and you're holding its four corners.
Blockouts take the paint brush
Map meshes are paintable now, both kinds:
- Vertex paint — colour per corner, the cheap retro look. Shade a corridor darker toward its end, warm a floor near a light, tint one wall.
- Texture paint — a resolution-independent overlay, so detail doesn't depend on how many faces the wall has. A dab paints in world space across faces, so one stroke down a wall-floor seam shades both surfaces at once: painted ambient occlusion, the baked retro look, with no bake.
The hard part isn't painting them; it's that a map mesh rebuilds from scratch every time you pull a face. Applied blind, the old paint lands on whatever surfaces now occupy those positions. Dropping it instead is honest and infuriating: touching one wall would clear a level.
So every painted surface carries a durable name — the face it belongs to, identified by the set of corners it uses. After a rebuild the paint follows. Move a vertex, assign a slot, cut a face somewhere else, delete one — every surviving surface keeps its paint. A face whose corners genuinely changed (the top of an extrusion, the two halves of a cut) comes back unpainted, because it is a new surface.
One thing worth knowing: undo restores geometry, not paint. Undoing an extrude brings the face back; the paint it lost when it became new geometry doesn't come with it. (Fixed in v0.19.1.)
Upgrading
Additive. Existing .map.ron sidecars and paint files load unchanged. The Map
tool's two new commands (/ knife, \ invert) take keys nothing else used, and
every binding is still rebindable in the Map panel.
There's a new user guide: docs/map-tools.md.
Read the notes
A screen's behaviour can now live in one script instead of one script per button. A walk into a cliff no longer fires you into the sky. And the sample project opens with 592 textures in it, so the first hour is spent building something rather than looking for a wall.
A script can answer for a button it doesn't live on
clicked(node) answers for the node its script is on. That's the right default,
and it's also why a menu of eight buttons wanted eight script files — each three
lines long, each really saying tell the menu, none of them holding the state
they change.
function start(node)
ui.on(find("Play"), "clicked", function() scene.load("level1") end)
ui.on(find("Options"), "clicked", function() find("OptionsPanel").visible = true end)
ui.on(find("Quit"), "clicked", function() scene.load("title") end)
end
The handler is called fn(element, hook) — the element that fired and the hook
name — so one function can serve a whole row:
for _, b in ipairs(find("Toolbar"):children()) do
ui.on(b, "clicked", function(el) selectTool(el.name) end)
end
Every UI hook works: clicked, pressed, released, hoverStart, hoverEnd,
changed, submitted, cancelled, focusEnter, focusExit, and the drag
family. Four rules make it safe to write rather than something to be careful
with:
- Registering again replaces. Same script, same element, same hook — the new
closure takes the old one's place. Calling
ui.onfromupdatetherefore costs one closure instead of one per frame, which is the mistake this API would otherwise make very easy. ui.off(element)stops your listeners,ui.off(element, "clicked")stops one — and only yours. Two managers listening to the same button can't unregister each other.- A listener dies with either end: the element it watches, or the script that
registered it. A hot reload re-registers from the fresh code, and a reload that
deletes the
ui.online drops the old one. - Order is specific → general: the element's own
clickedfunction, then aui.makeelement's inlineonClicked, then listeners in registration order.
Listening for something an element can't do — clicked on a plain box — warns in
the Console and names what to switch on. A mistyped hook name raises immediately
with the list of real ones.
Or ask, instead of being called
The same events, polled from an update that already exists:
function update(node, dt)
if ui.clicked(playButton) then start() end
for _, ev in ipairs(ui.events("clicked")) do
log("clicked " .. ev.node.name)
end
end
| Call | Answers |
|---|---|
ui.clicked(el) / pressed / released / changed / submitted |
did it fire this frame? |
ui.event(el, hook) |
any hook, by name |
ui.events([hook]) |
everything that fired this frame: { node = , event = } |
ui.hovered([el]) / ui.held([el]) / ui.focused([el]) |
which element — or, given one, yes/no |
That last row is state, not events: true for as long as it's true, where
hoverStart/hoverEnd are the edges.
Both halves read the same list of events, so a poll in update and a clicked
hook can never disagree about what happened this frame.
The shipped UI demo now does it: the three tabs in ui_demo.ron have no scripts
and no onClicked, and ui_demo.lua listens to all three from the panel.
Slopes: walking into a hill stops launching you
Run at a steep hillside in almost any engine's first-draft character controller and you get fired into the sky. Nothing is bouncing you — the solver resolves the overlap by pushing the capsule out along the surface, on a steep face that push points partly upward, and a controller that keeps driving into it collects that push again every single frame. At a run into a 70° face that is tens of metres per second of free climb.
The body now reports the two surfaces a controller has to tell apart:
node.groundNormal— the floor it is standing on.nilwhile airborne, so it's exactlynode.groundedwith the surface attached.groundNormal:dot(node.up)is the cosine of the slope.node.wallNormal— the steepest surface it is pressed against, when that surface is too steep to stand on.nilwhen there is only floor.
Both are extremes of the step, not "whichever contact resolved last" — standing at the foot of a cliff touches both surfaces at once, and which one arrives last is not something a game should depend on.
With those, refusing to climb is four lines, and what's left of the movement is a slide along the face:
local steep = math.cos(math.rad(params.slope_limit))
local function slide(m, n)
if not n or n:dot(node.up) >= steep then return m end -- absent, or walkable
local into = m:dot(n)
if into >= 0 then return m end -- already moving away
return m - n * into
end
move = slide(slide(move, node.wallNormal), node.groundNormal)
first_person.lua, character.lua and third_person.lua all do exactly this,
with slope_limit (default 50°) in the Inspector. They also drop upward
velocity they didn't ask for while grounded — that speed came from being pushed
out of a slope or a step, and keeping it is the other half of how a walk turns
into a takeoff.
The example scripts
They're the first Lua anyone reads and they get copied into real projects, so they're now held to it:
third_person.lualost a debugging leftover that dropped friction to zero on every moving frame — the reason a character could stick to walls. Standing still now means full friction (no sliding down a gentle slope); asking to move drops it, because friction that fights your own walk speed is friction that makes you stick to whatever you lean on.float.luastashed its per-instance state in globals. It uses file-scope locals now — same behaviour, and it stops being an example of the thing the editor warns about.pulsate.luadropped a node picker it never read.debug_rayis a real checkbox instead of a number you set to 1.- The controllers'
defaultsare annotated — headers, ranges, units, tooltips — so the Inspector explains itself. - Every shipped script is now compile-checked and warning-free. A warning triangle on the engine's own example teaches exactly one lesson: that the warnings don't mean anything.
That gate immediately earned its keep in the other direction — it showed the
warnings strip reporting unused function parameters, which no correct
function update(node, dt) can avoid, because the signature belongs to the
engine. Unused locals still report; parameters no longer do.
592 textures, in the box
assets/textures/ — the sample project — now ships:
Materials/ |
518 seamless 256×256 surfaces: brick, wood, stone, metal, tile, plaster, dirt, cloth, terrain, gems, gratings, marble, concrete, cardboard, clay |
VFXPX/ |
74 pixel-art particle sprites — fire, smoke, shockwaves, slashes, shards, wind — the retro counterpart to the existing soft VFXTEX set |
All CC0 / public domain (Screaming Brain Studios):
usable in anything, commercial or not, with or without credit. Each pack keeps its
License.txt and the author's patron credits.
Three packs, kept in three folders rather than merged by category, because two of
them contain a Stone_01.png and they are different images.
assets/textures/README.md says what is where and how to apply one.
Upgrading
Additive. ui.on and the two normals are new reads, and every existing hook and
field works exactly as before. The example scripts changed, but they're examples —
a copy already in your project is untouched.
Read the notes
A scripting release. Same language, far less of it per idea: tunables that describe themselves to the Inspector, an API that stops making you write vector arithmetic by hand, an editor that formats and warns instead of interrupting, and docs that read like docs.
The complaint this answers
Scripts were turning into arithmetic and bookkeeping. A character controller in
the shipped project opened with a hand-rolled norm(x, y, z), three separate
vx/vy/vz writes to do one thing, a clamp spelled
math.max(0, math.min(1, x)), and a defaults table whose fourteen tunables
reached the Inspector as fourteen anonymous drag values in alphabetical order.
None of that is Lua's fault, and none of it needed a new language — it needed the engine to stop making you spell it out.
Tunables that describe themselves
defaults says what your tunables are. --@ comments now say how they should be
presented, and the Inspector draws a designed panel — in declaration order,
which alone fixes the thing headers were impossible without:
defaults = {
--@header Movement
-- How fast you walk on flat ground. -- a plain comment is the tooltip
--@range 0 20 --@units m/s
walk = 4.5,
--@desc Blend between the walk and run animations.
--@slider 0 1 --@step 0.05
blend = 0.35,
--@options Off|On|Auto
assist = 1, -- a NUMBER + options → a dropdown, value = the index
--@options walk|run|sprint
gait = "walk", -- a STRING + options → a dropdown of those strings
invert = false, -- a boolean default → a checkbox, no annotation needed
--@color
tint = "#ff8800", -- a swatch; the script still reads the hex string
--@hidden
debugScale = 1.0, -- kept out of the Inspector entirely
}
The full vocabulary: --@header, --@desc, --@range, --@slider, --@step,
--@units, --@options, --@color, --@multiline, --@hidden, --@about,
and the --@editorButton that already existed. Nothing is required — an
un-annotated script renders as it always did, only now in the order you wrote it.
Three things worth naming:
- A plain comment above a key is its tooltip. Scripts that already documented their tunables got hover text for free, without editing anything.
- The panel comes from the source, not a sidecar. No metadata file to drift out of sync, and nothing to re-run.
- A boolean default is a real boolean.
flag = falseround-trips as one, soif params.flag thenmeans what it says.
An API that stops making you do the maths
The node's state is available as vectors, not just scalar triplets:
-- before
local l = math.sqrt(ux*ux + uy*uy + uz*uz)
node.vx = node.vx + (ux / l) * params.jump
node.vy = node.vy + (uy / l) * params.jump
node.vz = node.vz + (uz / l) * params.jump
-- after
node.vel = node.vel + node.up * params.jump
node.vel, node.up, node.forward, node.right, node.size — read and
(where it makes sense) write. node.forward is -Z through the node's rotation,
matching the camera, so camera-relative movement is
node.right * mx + node.forward * my instead of a line of trigonometry.
New in math: clamp, saturate, sign, round (with an optional snap step),
lerp / mix, inverseLerp, remap, smoothstep, approach, wrapAngle,
deltaAngle, approachAngle, pingPong. New in table: map, filter, find
(a predicate → value and index), indexOf, count, sum, keys (sorted),
copy, extend, reverse.
The ones that carry real correctness, not just brevity: approach never
overshoots (the hand-rolled version jitters at low frame rates), deltaAngle
and approachAngle cross the ±π seam (350° → 10° is +20°, not −340°), and
inverseLerp / remap return 0 instead of a NaN when their range is empty.
Legacy spellings (node.vx, node.up_x, node.scale_x, rb.lock_rot_x) all
still work and aren't going anywhere. Only the new spellings are documented and
completed.
An editor that gets out of the way
Completion opens by itself only after . or : — the moment you're actually
asking what fields something has. A plain identifier needs Ctrl+Space. And the
keys swapped: Enter accepts, Tab always indents. Tab is the key you press
without looking, and a completion popup that eats it is the reason people turn
completion off.
Formatting — Alt+Shift+F, the ⚏ Format button, or format-on-save. It
re-indents by real block depth and tidies whitespace, and changes nothing else:
no re-flowed expressions, no realigned comments, no moved code. --@noformat
exempts a file, --@keep exempts a line.
Warnings — a strip under the editor lists the mistakes Lua cannot report:
- an undeclared assignment.
sped = speed * dtcompiles, writes a global, readsnilforever, and says nothing. The warning names it and suggests the local you meant. - an unused local — usually a half-finished rename.
- upvalue pressure. LuaJIT allows 60 upvalues per function and every
file-scope
localis one. The error LuaJIT gives ("too many upvalues") names no fix; at 50 you now get a warning whose message does — group related state into one table.
--@nolint silences a line or a file. F12 / Shift+F12 join Ctrl+B and
right-click for definition and references.
Docs that read like docs
The in-engine § Docs page renders structure instead of one monospace slab:
headings, wrapped prose that fits a narrow dock, inline code, and code blocks in
the editor's own syntax highlighting.
35 worked examples now sit under the API entries people actually reach for — and in the hover tooltip, and in the completion popup. Each one is a complete, copyable snippet that's checked to actually run, so an example can never be plausible-looking code that doesn't work.
Two new guide sections (Inspector tunables; the editor's formatting, warnings, completion and keys), an expanded vectors/math/table section, and matching rewrites in docs/scripting.md.
solar/scripts/planet_walker.lua is annotated end to end as a worked example.
Three UX bugs the audit found
- A one-frame key steal. For one frame after focus moved to the find bar, Enter there was swallowed by a completion popup you could no longer see — the once-in-twenty-times key loss that reads as "the editor is flaky".
- Caret drift on format. Typing at
print(|, saving, and finding yourself atpri|nt(. The caret is anchored to the line's content now. - A strip that resized the editor while you typed. The warnings row appeared
and vanished as warnings came and went — a half-written
localis briefly an unused one — nudging the text under your caret. It's always one line now.
Why not Lua 5.4
Asked and answered deliberately: the engine stays on LuaJIT 2.1. 5.4's
additions (//, bitwise operators, <const>, an integer subtype) are cosmetic
next to what this release is about, goto and bit.* are already available, and
losing the JIT would tax exactly the per-frame scripts these projects lean on.
The one genuine 5.4 win — 255 upvalues instead of 60 — is now a warning that
names its own fix. The cost of switching (rewriting every bit.* call, unpack
→ table.unpack, and HUD text where 3 starts printing as 3.0) buys nothing a
script author asked for.
Upgrading
Purely additive. Every legacy field spelling still works, annotations are comments, and the formatter and lints are opt-in per action. Completion's Enter/Tab swap is the one behaviour change you'll feel, and it's the direction every other editor points.
Read the notes
One feature: sprite sheets on materials. A texture sliced into a grid, one cell drawn on a mesh, the cell animated at runtime — the thing UI images have been able to do since v0.14.0, now available to anything with a surface.
The case it exists for
A character's face on a plane. Sixteen expressions in one PNG, and the game picks one per frame: talking, blinking, angry. The same shape covers an animated billboard, a flipbook effect on a quad, a spinning coin, a readout panel on a console.
Before this, the answer on a mesh was to write a shader that did the UV arithmetic itself, or to keep one PNG per frame and swap the material's texture. Both work. Neither is what "use a sprite sheet" should cost.
Slice the texture once
The grid lives on the texture, in its asset settings — columns × rows, with
the cell preview it already had. Everything that uses that texture inherits the
same slicing: a HUD image, a mesh material, a per-face material on map geometry.
Re-slice the .png and every material using it re-slices with it.
On the Material that lands as three fields — sheet_cols, sheet_rows, cell —
and a cell picker under the texture row: the sheet drawn as a clickable grid
with the current cell ringed. It's literally the same control the UI element
inspector uses.
Animate the cell
local face, fps, frames, t = nil, 8, 16, 0
function start(node) face = node:getcomponent("Material") end
function update(node, dt)
t = t + dt
face.cell = math.floor(t * fps) % frames
end
cell is a live field, which buys three things at once: the write above,
node:setMaterial{ cell = n } for setup-time slicing, and a stepped property
track in the Animation tab (+ Property ▸ Material ▸ cell) — including record
mode auto-keying it when you click a different cell with ● REC on.
sheetCols / sheetRows are writable too, for the rare script that slices its
own texture.
Also true of it
- A sheet wins over a tiling block. Repeating or rotating one cell would drag in its neighbours, which is never the intent — the same rule UI images follow. The Inspector says so where the tiling rows would be, rather than leaving controls that quietly do nothing.
- Use the Pixelated filter for pixel art. A smooth filter can pull half a texel from the neighbouring cell at a seam. That's a property of sampling a grid, not of this implementation, and it applies to UI images equally.
- Cells clamp. Walking past the last frame shows the last frame.
- Raster surfaces only — meshes, primitives, map geometry, per-face materials. Blobs and SDF matter have no UVs to window.
- A custom
.flslshader'sbaseTexture()honours the cell without knowing sheets exist.
Docs
- docs/subsystems/materials-and-textures.md — fields, inheritance, the tiling interaction, the filtering caveat.
- docs/scripting.md — a
getcomponent("Material")table next to the other handles, with the loop above. - The EmmyLua stub gains
MaterialHandle, so external editors hover-documentcelltoo.
Upgrading
Purely additive. A material that isn't a sheet saves exactly as before, so no existing file changes, and no script behaviour moved.
Read the notes
One feature: ui.make — a UI tree described as a Lua table, reconciled
against the one on screen.
The case it exists for
A screen whose shape depends on data. Four fighters in the roster or nine, an inventory of whatever the player is carrying, a lobby list that arrives over the wire.
A scene file can't hold a tree that doesn't exist yet, so both projects built on
this engine solved it the same way: a fixed pool of Icon1…Icon8 nodes,
positioned by hand-written centring arithmetic and shown or hidden every frame.
ui.make(find("Crew Panel"), {
"col", inset = 0, style = "panel", gap = 10, pad = 16,
{ "text", text = "CREW · " .. #crew .. " on duty", style = "caption" },
{ "col", w = "100%", gap = 6, items = crew,
function(m)
return {
"button", key = m.id, style = "row", dir = "row", gap = 10,
onClicked = function() standDown(m.id) end,
{ "box", w = 26, h = 26, radius = 13, text = m.name:sub(1, 1) },
{ "col", w = "grow",
{ "text", text = m.name },
{ "text", text = m.role, textSize = 12 },
},
}
end,
},
})
An element is { "kind", prop = value, …, children }. Kinds: box, row,
col, text, image, button, field, slider, scroll — every one of them
reachable from box by setting properties, so the kind is shorthand rather than
a class of thing.
items plus a function child makes one child per item; the function gets
(item, i) and may return nil to skip. A function child without items is a
conditional part of the screen.
onClicked — any UI hook, on plus its name — carries behaviour inline. No
prefab, no second file. A gamepad submit fires it exactly as a mouse click does,
because it is the same event.
It reconciles, which is the whole difference
Calling it again spawns and destroys only the difference. Rows that stay keep their identity, and with it every scrap of runtime state hanging off it: hover, focus, scroll position, in-flight style transitions, what the player typed.
A builder that rebuilt its subtree would produce a screen that flickers and forgets — which is precisely the hand-rolled behaviour it's supposed to replace.
Elements match by key where there is one and by position where there isn't, so
a re-sorted list moves its elements rather than just its labels.
The description is authoritative. A property your table stops mentioning goes back to default — otherwise deleting a line would leave its effect on screen forever. Four things are kept anyway, because none of them is something the description said: a scroll position, what was typed into a field, which chip is selected, and where a draggable slider was left. A display-only meter is driven by the game, so it follows the description.
Elements you placed by hand are never touched. Reconciliation only considers children the builder itself made, so a data-driven list can live inside a designed panel — which is the intended way to use this. Place the frame in the scene where you can see it; fill it from data.
The rules that keep it honest
- One vocabulary, not two. The property names are the ones a script already writes directly, plus the structural ones a live field write can't express.
- A typo raises.
colour = …stops the build and names the property, with a suggestion. A declarative screen that silently ignores a line is worse than one that stops — you'd be looking at the screen wondering why the colour never took. - It doesn't replace anything. The repeater is unchanged and still right for
rows that carry their own art and scripts;
ui.bindis still right for "keep this label showing that number".ui.makeis for when the tree itself comes from data. The three compose.
Also true of it
- Play only, same rule as the repeater: made elements are runtime content, and an editor action that conjured them into the open scene would put engine-built nodes in a file about to be saved. In edit mode the call is dropped with a Console warning rather than silently.
- A made box is invisible until something paints it — otherwise a builder would start every screen as a pile of white slabs, which would be the engine choosing a look.
- A
buttonkind is focusable unless you say otherwise. A behaviour default, not a look: the engine still draws no focus ring. - Made nodes are never saved, and Stop takes them with the rest of the run.
The demo has a new panel
assets/scenes/ui_demo.ron gains a crew roster. The scene file holds one
empty node saying where the panel sits; its heading, its five rows, each row's
badge, name, role, tooltip and click handler are one ui.make call over a table
of five crew members.
Click one and they stand down — watch the others keep their hover, and a RECALL button appear because a function child returned something this time.
Docs
- docs/ui-make.md — the manual: kinds,
items, keys, hooks, every property, and the rules above. ui.makecompletes in the editor's script editor and in the EmmyLua stub, so external editors hover-document it too.
Upgrading
Purely additive. No file format changed, no wire protocol changed, no existing script or scene behaves differently.
Read the notes
The complaint this release answers, in the words it was made in: "it's genuinely really hard to get UI that looks good and not sloppy or AI generated."
That is not a shortage of widgets. The engine already drew rectangles and text. What it had no way to express was "these forty things are the same kind of thing" — so every screen was forty independent rectangles, each one hand-placed and hand-coloured, each one free to drift a pixel and a shade away from its neighbours. Sloppy is what that looks like from across the room.
So the unit of work here is not the button. It's the style: a named look, defined once in a file, worn by every element that asks for it, with the five interaction states and the transitions between them included. Change the file, change every screen.
The engine still has no opinion about your game's look
Stated up front because it constrained every decision below. There is no built-in focus ring, no built-in scrollbar, no drag ghost, no tooltip drawn by the engine, and no default theme. Floptle ships zero styles.
A "widget" is your own elements: a scrollbar is a track element with a handle
child, focus is whatever your style's focus block says it is, a tooltip is an
element you designed. Anything the engine drew for you would be the engine
choosing a look for your game, and a horror game and a puzzle game do not want
the same one.
Where a default was unavoidable, it derives from your project: the UI tab's snap step is your smallest spacing value, not 8 px. Editor chrome uses the editor's palette, never your accent colour — which would vanish the day someone designs in that colour.
Tokens, then styles
*.tokens.ron names the values: colours, a spacing scale, radii, a type scale,
fonts. *.uistyle.ron names the looks, referring to tokens rather than numbers.
Both are merged from anywhere in the project and hot-reload on save — edit a
token, watch every screen in the game repaint.
A style has a base block and up to five state blocks (hover, pressed,
disabled, focus, selected) with a fixed precedence, plus one transition
(duration and easing, 8 curves) that every animatable property rides.
Two rules keep this from becoming CSS:
- One style per element. No cascade, no specificity, no
!important, no wondering which of four rules won. - A property the style doesn't mention is left exactly as you authored it. So a style is safe to apply to an element you already positioned.
The paint box
Styles are only worth having if there's something worth styling. Elements gained gradients (linear, radial and angular), per-corner radii, per-side borders, inset shadows, glow, film grain, four blend modes, 9-slice borders, image tiling/offset/fit, layout-neutral rotate and scale about a pivot, cascading opacity and tint, and text stroke / shadow / tracking / line-height / wrapping / max-lines / ellipsis / case.
◫ A tab for designing screens
The canvas is the real renderer drawing into an offscreen target — not an approximation of your UI, your UI.
Rulers and guides, snapping (guides beat siblings beat grid) with smart-guide lines, align and distribute, marquee multi-select, resize grips, nudge, drag-to-reorder inside a stack with an insertion caret, inline text editing, forced state preview, an outline panel with depth dragging and visibility/lock, copy/paste style, and "make this a style" for when you've designed something by hand and want it named.
The multi-resolution preview re-solves the layer rather than scaling a picture of it, which is the only version of that feature that tells you anything.
Things that are yours now, not the engine's
- Focus and directional navigation. Spatial nearest-ahead with a cross-axis
penalty so a column walks down itself, per-layer wrap and repeat timings, name
overrides where the geometry guesses wrong, and a nav-graph overlay in the UI
tab. Submit fires the same
clickeda mouse fires, so nothing has to be written twice. - Toggles and radio groups, layer-scoped.
- Real scrolling on two axes, with drag-to-pan, and scrollbars made of your own elements.
- Text fields — caret, selection, clipboard, OS key repeat, character (not
byte) length caps, numeric / upper / masked modes, placeholder,
changedandsubmitted. - Drag and drop —
draggableanddrop_target, withui.dragging(). No payload channel, deliberately: the thing being dragged is a node, and a node already carries params, name and tags. - Tooltips — a string on any element, and one tooltip element per layer that you design.
Lua
color(r,g,b[,a]),color.hex("#rrggbb"),color.lerp(a,b,t)— a plain{r,g,b,a}table, and colour fields read and write as whole colours.ui.bind(node, prop, fn)— the property follows the function, evaluated afterupdate. The component is chosen by which one has that field, so"value"finds the slider without being told.repeater— a prefab per row against a count; the engine spawns and destroys the difference, and each row readsnode.index.input.typed()— this frame's characters, resolved by the OS keyboard layout, with paste folded in. Pollinga–zwas getting three lobby codes in four wrong, because eight of the relay's thirty-two symbols are digits.
A demo you can open
assets/scenes/ui_demo.ron — press Play. Every mechanism above on one screen,
with a walkthrough in docs/ui-demo.md.
Two things it's built to prove. Delete the two files in assets/ui/ and it
still runs — grey, flat, and completely functional; the entire look lives in
those files. And the Lua behind it is about 110 lines across five files, with
no layout arithmetic, no hover functions, no list rebuilding and no key polling.
The tab strip and the toggle chips have no script at all.
Netcode
Three reports from a game being built on the engine, closed:
- A client could not point its own fighter. Two faults: driven nodes landed in
a filter that gates every pass including
lateUpdate, and a second, separate answer to "is this node locally driven" could disagree with the driver itself. The driver publishes its own answer now, and a sample that slips past the guard is counted and named in the Console. - The input delay is choosable.
net.host{ inputDelay = n }andnet.setInputDelay(n); with neither, the host derives it from measured ping (2 on a LAN, 5 at 112 ms). The 🌐 panel puts delay and mispredict rate on one line, because neither number means anything alone. - A replay determinism audit, on by default in the editor. It runs a second
replay with provably identical inputs from the same starting point — so any
difference is a value the simulation reads that
snapshot()doesn't carry. It reports locally and names the field.
Things that would have shipped broken
The demo was built to be shown, and showing it is what found these.
- The mouse was offset from the cursor. Three places built a layer's element tree and only one applied styles — so the hit test was solving a layout that had never been on screen: 22 design units out on the demo's buttons, 58 at worst, about 87 physical pixels at 1080p.
- …and worse in a docked editor. The pointer was measured against the whole window while the game rendered into a panel a third that size, with the error growing across the screen.
- Clicking the Inspector pressed game UI behind it, and a context menu floating over the Game view was clicked through.
node:uiRect()documented "the same space asinput.mouse()" and returned a different one. It's window space now, likeinput.mouse(),camera.worldToScreen()andcamera.screenToRay().- A world-space canvas was invisible in the docked Game tab and still clickable.
- Clicking into a long text field put the caret a word or two from the pointer.
Tokens.radiiwas defined and unreachable, and shadow and glow colours could not be tokens.- Booleans in Lua were 1/0, and
0is truthy, soif el.visible thenwas always taken. This is the one non-additive change — see Upgrading.
Also
- Manuals: docs/ui-styles.md, docs/ui-tab.md, docs/ui-navigation.md, docs/ui-demo.md.
ui.makewas deliberately not built. The repeater covers what motivated it, and a third way to author a tree — Lua, alongside the scene and the UI tab — is a third place to look when a screen is wrong.- Not in this version, stated plainly: IME (CJK composition) in text fields, rich-text markup, and starter kits.
Upgrading
No file format changed and the wire protocol is untouched; every existing project opens as before.
One behaviour change. Boolean component fields read back from Lua as booleans
instead of 1/0. If a script compares one numerically — if rb.lockY > 0 then
— change it to if rb.lockY then.
Read the notes
Make the texture where you'll use it. This release adds a full image editor: layers, brushes, selections, adjustments, filters, vector paths, palettes and animation frames, on a 🖼 tab that docks like any other — and, because it lives inside the engine, the mesh in the ⌖ Scene view repaints while you're still holding the brush.
Draw it on the thing it's for
Every texture in a game used to be made somewhere else — leave the engine, draw in Aseprite, save, alt-tab back, find out whether it read correctly on the model. Floptle is the only program that can show you a texture on the object it belongs to, lit the way it will be lit, while you're still editing it.
- New image… → a preset (16²–128² are pixel sizes, 512²+ painterly) → draw.
- Save writes two files side by side:
name.flimg, the layered document you keep editing, andname.png, the flat image scenes and materials use. The runtime never reads.flimg; a project with the documents deleted still builds and ships. - Tick Live, split ⌖ Scene beside the tab, and keep painting. Each quiet moment between strokes re-exports the PNG — never mid-stroke, at most four times a second.
Textures hot-reload now, from any program
Rewriting a PNG on disk used to change nothing on screen until the project was reloaded. Both halves of the fix ship here: the editor invalidates a texture the instant it writes one, and the engine polls for changed files twice a second.
That second half means Aseprite, Krita and Photoshop hot-reload too. Save in your own tool and Floptle picks it up with no import step. If you never open the 🖼 tab at all, you still get the fastest part of this release.
One editor, three ways of working
Mode sets defaults; it isn't a fork in the road. Any document holds any mix of layer kinds.
- Pixel — no anti-aliasing, integer zoom, whole-texel pan,
Pixelatedon export. One image pixel is always an integer number of screen pixels; a pixel editor that shows you a blurry approximation of your own art is worthless. - Painterly — soft brushes, continuous zoom, mipmapped export.
- Vector — the same document with the path tools to hand.
Eighteen tools, keyed the way every paint program keys them (B E G L
U M Q W V I A P T, Ctrl+T), live only while the tab has
focus. The brush has eight modes beyond plain paint — erase, smudge, blur,
sharpen, dodge, burn, clone stamp — over one radius / hardness / flow / spacing /
blend profile.
Selections (box, ellipse, lasso, wand; replace / add / subtract / intersect, feather, grow, shrink, invert) clip every subsequent operation — brush, fill, gradient, filter, adjustment, transform, delete. They're 8-bit, so a feathered lasso and a hard marquee are the same object, and any selection can become a layer mask you paint on directly.
Layers carry 18 blend modes, plus clipping, masks, non-destructive effects (outline, drop shadow, glow, colour overlay) and adjustment layers that re-evaluate forever — levels, curves, HSL, brightness/contrast, colour balance, posterize, threshold, palette quantize, gradient map, invert, desaturate.
Filters are destructive and say so, and every one previews live: blur, sharpen, noise, pixelate, offset, make seamless, normal map from height.
The parts that are here because this is a game engine
- Tiling mode wraps strokes at the canvas edges — the thing that actually makes a texture seamless — with a 3×3 repeat view and a seam finder to check. Blur and normal-map generation read across the seam too, so they can't build a bright rim exactly where the texture repeats.
- Palettes: Sweetie 16, PICO-8, Game Boy and Endesga 8 built in; drop
.gplor.hexfiles into.floptle/palettes/. Lock snaps every colour you place to the nearest entry. - Palette quantize, as an adjustment layer, reduces anything to N colours with ordered or diffusion dithering. That's how a painted texture becomes retro art, and it's why the painterly and pixel halves feed each other.
- Frames with onion skin and playback, exporting to a sprite sheet whose grid is written into your texture settings — so the sheet is exactly the sheet UI images and VFX flipbooks can address. No counting cells by hand. Animated GIF too.
- Vectors are Scratch-shaped: drag a node and the shape follows, double-click to toggle corner ⇄ curve, click an edge to insert. Handles exist for those who want them; you can draw a rounded blob without knowing what one is.
Free transform, text, and the clipboard
Ctrl+Tlifts the selection — or, with no selection, everything on the layer — into a box: drag to move, corners to scale (Shift= uniform), the handle above to rotate (Shift= 15°), with numeric fields for exact values.Enterapplies,Escapecancels byte-exactly, and the whole gesture is one undo step.Ttypes into the image, so text stamped into a texture matches the text beside it in the UI.Ctrl+Enterapplies,Escapecancels.Ctrl+C/X/Vcopy, cut and paste. A paste arrives as a floating block under the cursor with the transform handles already on it — never a blind drop in a corner you then have to hunt for. It doesn't clear what it lands on, one undo takes the whole paste back, and the clipboard outlives the document that filled it.
Things that would have shipped broken
- Two glyphs in the tab were tofu squares (
↔↕on the mirror and flip controls). The build now fails for any character the bundled fonts can't draw. - The tool strip was clipped. Eighteen tools is about 500 px; docked short — beside a Scene view, on a laptop — the tail of the strip simply wasn't there, with no scrollbar and no way to reach the pen or the text tool except by keyboard.
- The brush and the eyedropper rendered as the same silhouette.
- A canvas flip or rotate silently dropped every layer mask and the selection.
- A floating transform followed you to another layer. Switching layer or frame settles it first; undo cancels it outright.
- The text field trapped the keyboard, so nothing else in the tab could be
clicked and
Escapecould never be seen. - Quitting with an unsaved image just quit. The unsaved-changes prompt knows about images now, and won't pretend to "Save & Quit" a document that has never been given a filename.
Also
docs/image-editor.mdis the manual.- Edit ▸ ? Keyboard shortcuts lists every binding, because an 18-tool editor keyed by single letters has to say somewhere what the letters are.
- Not in this version, stated plainly: layer groups, rich text (one font, size and
colour per block), the texture-paint bridge, procedural layers from the ◈ shader
graph, a GPU compositor, and an
image.*Lua API.
Upgrading
Nothing to do. No file format changed and every existing project opens exactly as
before — .flimg is a new format only the editor reads, and the PNGs beside it
are ordinary PNGs.
Read the notes
Five faults from the field, all of the same family: the engine knew, and said nothing. A binding that read wrong, a migration that undid a deliberate edit, a scene rewired by an unrelated insertion, a desync that named nothing, and a controller a script could not see.
Wire protocol 12 → 13. Update both ends of a match.
A gamepad stick could not be scoped to a local player
A keyboard layout could be split between two local players. An analog stick could not.
The obvious two-player map does not do what it looks like:
Stick( id: Slot(0), x: LeftStickX, y: LeftStickY ),
Stick( id: Slot(1), x: LeftStickX, y: LeftStickY ),
That names a device, not a player. Both sticks contributed to both players, so whichever stick was pushed harder drove both characters at once — while the D-pad bindings directly beneath, written a different way, scoped correctly. Same pad, two behaviours.
Stick and Analog take player now, and every existing map loads and behaves
identically. A player-scoped id: Any means that player's own pad, or nothing,
so a second player with no pad reads zero instead of mirroring the first.
Migrating a project silently undid deliberate input edits
Version-bump migration exists to top up bindings a project never had. It couldn't tell that apart from a binding you'd deleted on purpose, or one you'd kept but scoped to one player — so it silently put both back on every version bump.
An unscoped binding serves every local player, so a re-seeded Key(Space) meant
player one's jump button also jumped player two. It rewrote input.ron to do it,
taking the comments explaining the shape with it, and shipped into two builds
before anyone re-read the file.
Migration tops up by name now: a project with an opinion about an action keeps it, whole. And it says what it added, because this rewrites a file you wrote by hand.
A scene's parent links were positional
parent: Some(156) was a position in the node list. Insert a node ahead of it and
every link below names a different node. The file still loads. Nothing warns. The
scene is simply wired to something else.
It happened to a match HUD: two nodes added ahead of a generated block of twenty-two, and the timer, round pips, ROUND/FIGHT/K.O. calls and pause menu all re-parented onto a line of help text inside another panel. An invisible parent hides everything under it, so none of it was ever drawn, in any mode. It reached players as three unrelated bug reports, every one of them a UI complaint that sends you reading UI scripts that were correct throughout.
It's worse than a dangling reference because it's always valid: node 156 exists, it's just not the node you meant, and nothing in the file records what you meant.
Nodes carry a stable id now, and parent links name one. Both forms are written, so older engines still open the file, and an insertion cannot move anything. Scenes without ids load exactly as before.
Plus the checks that are decidable, on every scene load: a parent nothing carries, an out-of-range link, a self-parent, a cycle, two nodes claiming one id — each named. And the lint for the shape that made this invisible rather than obviously broken: a UI element whose parent chain can never be visible is reported, naming both the element and what is hiding it.
A desync named nothing
A cross-platform match (Linux host, Windows joiner) voided itself within seconds, every time, for days. The cause was one Lua number:
st.visYaw = st.visYaw + diff * (1 - math.exp(-18 * dt))
math.exp is not guaranteed to give bit-identical answers on different operating
systems. One ULP, every tick. And the value is how far the model has turned —
presentation, which neither simulation could feel. Both machines were running the
same fight, correctly, and the checksum was still right to fire.
Finding that took a day, because this was everything the game was told:
net.on("desync", function() ... end) -- no payload at all
Three changes:
The desync names the value. Once a mismatch is declared — and only then, so a healthy session pays nothing — every machine publishes a labelled breakdown of the offending tick, and the host prints what actually differs:
⚖ tick 4127 — Player2/fighterController/visYaw differs (peer 0: …a3f1, peer 1: …a3f2)
The handler receives { tick, node }.
snapshot() has a cosmetic half. A correction must put back everything a
replay needs; two machines must agree about rather less. Those are different
sets, and snapshot() was all-or-nothing, so presentation had nowhere to go but
into the checksum. It may now return a second value — restored on rollback,
never hashed. A one-value snapshot() is unchanged.
The portability rule is written down. + - * / and sqrt are identical
everywhere; exp, log, sin, cos, tan, the inverse trig and pow are
not. A gameplay author has no reason to know that, and the failure is a match
that ends itself on a machine they don't own.
A script could not see a gamepad
The Lua input API was entirely action-shaped. Every call answered the resolved question — did this player press Light. None could answer the one underneath it: is there a controller here at all.
So "the pad was never detected", "it went into a slot the map doesn't bind", "the window hasn't got focus" and "something downstream ate it" all reached the developer as one sentence — controllers don't work — which is not actionable. That cost one project three round trips across two playtests with players in another country.
for _, p in ipairs(input.pads()) do
log(("pad %d: %s%s"):format(p.index, p.name, p.connected and "" or " (gone)"))
end
input.pads(), input.padCount(), and a raw read-through —
input.padButton(1, "South") and input.padAxis(1, "LeftStickX") — that bypasses
the action map entirely. That last part is the difference between "your pad
works, your bindings are wrong" and "your pad is not here", and only the raw read
can tell them apart. A game can put it on a controls screen and let the player
diagnose it themselves.
Upgrading
Protocol 13: both ends of a match need this build. Nothing else to do — every
change is back-compatible with existing input.ron and scene files.
docs/scripting.md gains the pad API and two-player binding scopes.
Read the notes
A rollback node never got a lateUpdate.
What it looked like
lateUpdate is documented as the pass that runs after physics — which makes it
the only correct place to write a node's cosmetic transform, since anything
written from fixedUpdate gets overwritten afterwards. So the docs send you
there, and going there silently stopped working the moment a node became a
Rollback node.
Offline it was perfect. Every local test, every bot match, the whole authoring loop. It broke only in a net match, with no error and no log line. In the field it drew both fighters facing the same way for an entire match while every hitbox came out the correct way round.
Why it happened
Two different reasons for skipping a script were sharing one switch, and they were never the same reason:
- A node driven by network snapshots isn't simulated locally at all. Its state arrives over the wire. Skipping every pass is right.
- A node the rollback driver owns is locally simulated — only the scheduling of its ticks moved. Its per-frame cosmetic pass has no reason to stop.
They're two switches now. The rollback one gates fixedUpdate and update and
leaves lateUpdate alone.
And the pass still can't be replayed N times during a correction, which would be worse than not running it: it runs once per rendered frame, after the physics writeback, which is the timing it's documented to have.
Position and velocity writes from lateUpdate are drained for a driven node,
same as anywhere else.
The detector could not have caught this, and now can
The v0.10.3 orphan check asks "does somebody run your ticks" — and for these nodes somebody did. The whole class was invisible to it.
Its new sibling asks "does somebody run your passes", and names any node in the Console that's missing one, with what it costs.
Upgrading
Update both ends of a match.
If you worked around this by moving a fighter's cosmetic pass onto the tick
clock, you can take that back off. It only worked for bodies with no rotation
locks and no up-alignment, and it was never general — a game needing the
position half of lateUpdate had nowhere to go.
Read the notes
Export a build for any platform, from any platform. Windows from Linux, Linux from a Mac, macOS from Windows — in about two seconds, with no compiler and no toolchain to install.
Exporting stopped compiling the engine
Export Game… used to compile the engine for the target platform. That needed a cross-compiler installed by hand, it needed the engine's own source code, and macOS was impossible — Apple's SDK can't leave a Mac, so that target made you run a GitHub workflow yourself.
The middle one was fatal: cross-platform export could never work from a Hub install, which is the ordinary way to run Floptle. It failed before it even looked for a compiler, and said something about a source checkout rather than about that.
The fix is noticing there was nothing to compile in the first place. An exported build is the engine binary plus your assets plus a manifest — nothing about your project is compiled in. So the binary a build needs isn't something to produce, it's something to fetch, and it's exactly the bundle the Hub already installs to run the editor.
So the export downloads it. Once, checksum-verified, cached beside your installed versions. First export of a platform: a few seconds. Every one after: instant.
first export 2.1 s (download 38 MB, verify, unpack, stamp 465 assets)
cached export 0.05 s
This is Godot's model (export templates) and Unity's (build support modules). Unreal is the one that really compiles per target, and it is the one everybody complains about.
A template is pinned to the editor's own version, because mixing them would ship a game whose networking disagrees with the editor that built it.
Running from a source checkout at a version with no published bundles still falls back to compiling, and says so.
Three ways a successful export shipped a broken game
Found by exporting a real project and running it, which is how all three had been surviving.
Absolute asset paths rode along. A build carrying one works only on the
machine that exported it, and fails silently — a model that doesn't load simply
isn't there. One real project shipped 27 files (every animation, both character
models) pointing at /home/<user>/…. Paths inside the project are rewritten
automatically now; paths outside it can't be repaired, because the file isn't in
the build, so they're reported as a warning naming each one.
The developer's save file shipped. The engine writes save/ and replays/
into the project, and a build carrying your copies hands every player a
pre-populated save. In the field this made a build boot straight into a match
instead of its own menu. Both are now excluded — at the project root only, since
a nested save/ is content.
A bad entry scene fell back silently. project.ron wanted a path
(scenes/menu.ron) while Lua's scene.load takes a bare name (menu). Two
conventions for naming one scene, so a perfectly reasonable entry_scene: "menu"
resolved to nothing and quietly fell through to scenes/first.ron — the scene
you playtested was not the scene that shipped. Both spellings work now, and an
entry scene that resolves to nothing fails the export instead of shipping a build
that boots somewhere else.
Also
- Headless export, so builds are scriptable:
floptle --export <PROJECT> <OUT> <PLATFORM> [TITLE], wherePLATFORMishostor a release key (windows-x86_64,macos-aarch64, …). - An exported game printed
Floptle editor v0.11.0on startup. It prints its own title now.
Upgrading
Nothing to do. The first export for a given platform fetches its template; after that it's cached. If you exported before, re-export — the build you have is missing these fixes.
Read the notes
Every online match died at its first checksum, half a second in, with both players told they had desynced.
Wire protocol unchanged (12) — interoperates with v0.10.3 and v0.10.4.
The referee was running the match in freefall
The referee — the impartial simulation that judges whether players agree — was being built with no gravity, no collision, no layers and no terrain. So it ran the match with both fighters falling through an empty void while the players stood on a stage.
The two could not possibly agree past the first tick, and the referee is the sole
judge. At tick 30 it ruled both players out of sync, and since a game typically
ends a match on net.on("desync"), that read as an instant disconnect.
The referee and replays now take the live session's own physics — same gravity, same layers, same terrain, same colliders.
A referee that disagrees with everyone is the one that's wrong
The deeper problem is that a wrong referee could end matches at all.
The referee is the sole judge on purpose: a group of players might all be running the same modified build, so majority rule is not an anti-cheat. But a cheat changes one machine, while a fault in the referee changes only the referee. "Every player disagrees with the referee, and they all agree with each other" is overwhelmingly the second case.
Verdicts now wait until every player has reported for a tick, and that case is called what it is — no desync is raised and the match carries on:
⚖ REFEREE disagrees with EVERY peer at tick 30, while the peers all agree with
each other — so the referee is the one that is wrong, not the match.
A single player out of step is still judged against the referee exactly as before. The anti-cheat property is unchanged.
Not the relay
Worth stating, because it was the natural suspicion. The session handshakes, the scene switch arrives, both machines draw the same seed, and rollback depth reaches 1–6 — which only happens when remote inputs are arriving and correcting predictions. The relay carried everything it was given.
Read the notes
A regression fix. v0.10.3 broke rollback matches after exactly one tick.
Wire protocol unchanged (12) — this interoperates with v0.10.3.
If you are on v0.10.3 and running rollback, take this one.
Fighters ticked once and then stopped
Every rollback match ran a single frame and then went still. Both machines, identically, with no script errors and nothing in the Console — because nothing failed. Nothing ran.
The telemetry made it look like a network problem: rollback depth and stall state sat frozen at whatever the first tick left behind. They weren't lying about the network; they were numbers that had stopped being updated.
v0.10.3's own new orphan detector caught it, on its first live run, on both machines:
🥊 2 Rollback node(s) are being run by NOTHING this match — not the rollback
driver and not the global script passes: Player1, Player2.
Which is the argument for that class of check in general: an invariant worth stating is worth stating out loud at runtime, because the thing that violates it next may well be your own fix.
v0.10.3's actual fix is fine — sessions still tick and inputs still flow.
Read the notes
The first real two-machine rollback match froze on ROUND ONE, on both screens, with a clean console on each. This release is what was wrong.
⚠ The wire protocol went from 11 to 12. Builds refuse mismatched versions at connect, so every machine in a session — and the dedicated server, if you run one — needs this version. There is no partial upgrade.
One lost packet could freeze a match forever
A rollback session never retransmits anything, because a retransmit would arrive after the tick it was needed for. Instead every packet re-carries the last several ticks, so an input goes missing only if every packet carrying it drops.
That guarantee wasn't actually being kept. Players crowded each other out of the shared buffer, and the buffer discarded exactly the oldest ticks — which are precisely the ones somebody who has fallen behind is waiting for.
So: one lost packet early in a match, and that tick was gone for good. The machine that missed it could never confirm it, so it stopped sending; so the host's clock froze; so the host stalled too. Two frozen screens, permanently, with nothing anywhere to say which side had stopped receiving.
It needs a lost packet and a machine that falls behind — which is to say it needs a real network. That's why it never appeared in testing.
Each player has their own buffer now, and nothing is discarded until every machine has confirmed it.
A frozen match now names itself
The above cost a replay-file autopsy to find. It should have cost a glance.
The 🌐 panel gained a progress readout — confirmed C of S simulated (N ahead) —
and, per player, what they've confirmed and how many of their inputs the host is
still holding. Whoever's progress has stopped while their held count climbs is
the one being starved, and the line turns orange when it happens.
A match stalled for more than a second says so in the Console too, on every machine, once a second. So a two-machine test no longer needs somebody watching a panel to learn which end broke.
Fighters that nothing was running
The other half of the field report. A rollback node's scripts could sit
completely un-ticked for an entire match — every cross-script call into them
answering nil, hundreds of times a second, with no error to explain it.
Fixed, and made loud in two ways:
- A match that binds nothing says so. "A rollback match was announced but this scene has no Rollback nodes here" — because the alternative is a machine that stands still while the others fight.
- A node nothing runs is an error, not silence. The engine now checks that every rollback node is being run by something, and reports it as an engine fault if nothing is.
A warning that was wrong at the worst moment
Starting a match printed "Player1 is a Rollback node but none of its scripts define snapshot()/restore()" about a script that defines both.
It was asking before the scene's scripts existed, so every answer was "no". A warning that is wrong at exactly the moment somebody is debugging is worse than no warning. It now waits until the answer is knowable.
The referee was running a frame nobody else ran
The referee — the impartial simulation that judges desyncs — ran one extra gameplay frame at startup that no player's machine ran. Any fighter script that kept state therefore made the referee disagree with everybody by construction.
A referee that is wrong on purpose is worse than no referee; its entire job is to be the one simulation nobody can argue with.
Read the notes
Everything here comes from building a real matchmaking flow and finding the places where the engine went quiet.
Wire protocol unchanged (11) — interoperates with v0.10.0 and v0.10.1.
A refused join says why
Typing the lobby code in wrong is the most common thing that will ever go wrong in an online session, and it was the one thing a game could not tell its player about.
The relay always sent a reason — "no lobby QK7RM" — and it was thrown away
before it reached the game. A wrong code arrived as an event indistinguishable
from your opponent closing their laptop.
The reason now reaches net.on("disconnected", fn).
net.joinState()
The worse half. net.join does not block, so net.role() reads "client"
from the frame you called it — whether or not that lobby exists. A game that
trusts it congratulates a player on joining a lobby that was never there.
local state, why = net.joinState()
-- "offline" | "connecting" | "joined" | "refused"
if state == "refused" then
find("Error").text = why -- the relay's own words
end
This lets a lobby screen end its wait on an answer rather than on a stopwatch. The game that reported this was standing three guesses and a ten-second timer in place of one string the relay had already sent — so a player on a slow link waited out the full timer to be told something that had arrived immediately.
Two failures stay deliberately distinguishable: a relay that says no is
"refused" with a reason; a relay that is switched off never answers at all
and stays "connecting". Only the first is knowable from inside the engine, so
keep a timeout of your own for the second.
A stale lobby code can't sit on screen looking live
net.lobbyCode() was only cleared when a session stopped. A second relay host
that failed left the previous session's five letters on screen as though they
were current — a player reads them out and their friend gets "no such lobby" from
a game insisting it is hosting. It's dropped before the attempt now.
It's also documented in scripting.md §16 beside net.host, where it should
have been in the first place.
Also
scripts/setup-relay.sh— one command to stand up a relay on a fresh cloud box. Detectsaptvsdnf, opens the firewall the right way for either, and adds swap when it lands on a 1 GB instance (where the build otherwise gets killed with nothing about memory anywhere in the output).cargo run -p floptle-net --example relay_probe -- host:portcompletes a real handshake and registers a lobby, so a pass means DNS, the cloud firewall, the host firewall and the service are all working. A port scan can't tell you that.- A correction to v0.10.1's notes. They said the gamepad half of the joiner bug was narrower than reported. That was wrong — a joiner's controller drove nothing, exactly as first reported. It's asserted by a test now rather than reasoned about, which is what got it wrong.
Read the notes
Two fixes standing between v0.10.0's rollback netcode and two people actually playing over it.
Wire protocol is unchanged (still 11), so this interoperates with v0.10.0. Update both machines anyway — the first fix is local to whoever joined.
A joiner can play
Whoever joined a session got the couch player-two control layout, and their own keyboard drove nothing at all.
Two different things were both called a "slot": which fighter you drive, and whose keyboard and gamepad your input is read from. On a couch those are the same number and everything works. Over a network they diverge — a joiner is fighter two, but they're sitting alone at their own machine as its player one.
This could only ever break for the person who joined, never for the host. Which is why it survived: the host is the machine the developer is sitting at.
A practical consequence worth knowing: a project needs no separate bindings for joiners. The same controls work whether you're player one or player two.
The gamepad half is pinned by tests now, because these spellings look interchangeable in the Input settings and aren't:
| Binding | Behaviour |
|---|---|
Pad(id: Any), one pad connected |
Falls back to the connected pad — always worked |
Pad(id: Any), two pads connected |
Read the wrong one; fixed |
Pad(id: Slot(n)) |
Names a pad outright — unaffected |
net.lobbyCode()
A game that asked for a relay session had no way to tell its own players the code. The 🌐 debug panel had it and Lua didn't, so every game shipping its own lobby screen had to send players into an engine panel to find out how their friends were supposed to join.
function update(node, dt)
local code = net.lobbyCode()
find("CodeLabel").text = code or "getting a code…"
end
Poll it rather than reading it once — it's nil until the relay answers, a
round trip after net.host{relay=…}. It's nil for good on a client, and on a
direct or LAN host where there is no code and joiners use the address.
See docs/multiplayer.md §5.
Read the notes
Floptle has had multiplayer since v0.5: one machine simulates, the others watch a slightly delayed copy and predict their own character. That's the right shape for almost every game, and the wrong shape for exactly one — a fighting game, where the decision you make is made against your opponent's exact state this frame, and a correction that nudges your character 40 ms later is the difference between a punish and a whiff.
This release adds the third mode. Rollback: every machine simulates every fighter, every tick, from the same inputs. Nothing about a hit ever crosses the wire — only button presses do — so both machines agree about hitstop, meter and trades because the simulation agrees, not because they negotiated.
Around it is the rest of what a netcode needs before you can ship with it: a dedicated server, interest management for player counts, replays, a referee, and real per-player ping.
⚠ The wire protocol went from 9 to 11. Builds refuse mismatched versions at connect, so every machine in a session needs updating together.
Rollback netcode
Set a node's 🌐 Networked ▸ mode to Rollback (all peers) and every machine runs it. The engine handles input delay, predicts a missing input by repeating the last one, and — when the guess turns out wrong — restores the last agreed tick and re-simulates everything since, with no rendering in between.
Your side of the contract is two functions:
function snapshot() return { state = state, frame = frame, health = health } end
function restore(s) state, frame, health = s.state, s.frame, s.health end
That's the whole opt-in. Transforms and physics bodies are saved for you.
Anything you leave out of snapshot() survives the rewind unchanged, and
that is precisely what a desync is made of. Nothing warns you at the moment you
forget — the match keeps playing and the two machines quietly stop agreeing about
one counter. Which is why:
Checksums are mandatory and always on. Every 30 confirmed ticks each machine
hashes its state and they're compared. A mismatch is loud: a Console error naming
the tick, red in the 🌐 panel, and net.on("desync") so a match can end honestly
instead of playing out as two different fights.
Past a depth of 8 ticks the simulation stalls rather than guessing further —
the game runs slightly slow instead of teleporting the opponent, and recovers on
its own. net.stalled() lets you put your own banner on it, which matters
because a stall is otherwise indistinguishable from a bad frame rate.
New Lua: net.random (drawn from the match seed, so every machine rolls the same
number and a re-simulated tick rolls it again), net.replaying, net.stalled,
net.inputDelay, net.rollbackDepth / Max / Average, net.mispredictRate,
and node.tickPos / node.tickYaw for reading and writing the simulation pose
rather than the interpolated one the camera renders.
Full guide: docs/multiplayer.md.
The referee, and replays
Both are the same idea: a second, headless simulation of the match on the host.
The referee only ever advances to ticks every machine's input has actually arrived for. It never guesses and never rolls back, so it is never wrong — only behind. Checksums are judged against it rather than against a majority vote, and that's the difference between knowing "someone is out of sync" and knowing "that machine is". A room full of players running the same modified build agrees with itself perfectly.
Replays are the same second world with a different stopping rule. A rollback
match is a pure function of its inputs, so the input log is the match: a full
set is kilobytes, and playing it back re-simulates rather than re-enacts. Every
match writes one to replays/ in your project, and the 🌐 panel lists them. A
desync that reproduces in a replay is one you can debug at your leisure instead
of chasing live.
A dedicated server
floptle-runtime --server <project-dir> [--scene …] [--port N | --relay host:port]
[--tick 60] [--interest 150] [--budget 16384]
The same world, physics, scripts and session the editor hosts — minus the window, the GPU, the audio and the input, because nobody is sitting at it.
Until now every session was hosted by a player's game, which is fine for friends-and-a-lobby-code and wrong for anything that has to stay up: the world ends when the host closes their laptop, and the host is also a player with an unfair zero-latency view of it.
It refuses a rollback scene, which is a design position rather than a gap. A rollback match has every machine simulating every tick, so its "host" is a referee and a relay rather than a simulation — and for a fighting game that role is one of the players.
Interest management
The player-count feature, off by default:
net.host{ interest = 150, interestBudget = 16384 } -- metres, bytes/sec
Each player is told about their own neighbourhood instead of the whole world, inside a per-player bandwidth budget.
Nothing is dropped — only deferred. Anything that loses a turn gets priority and wins a later one, which is what makes this safe to switch on without auditing a game for things that must never be missed. For the few that genuinely must arrive from anywhere — a match clock, an objective, a boss — there's an always relevant checkbox on the Networked component.
Per-player ping that's true through a relay
The 🌐 panel used to report the connection's own round trip as the player's ping. Through a relay that only measures one leg of the journey: off by a whole hop, and always in the flattering direction. Ping is now measured end to end, per player.
Also
- A dev-only link impairment knob.
FLOPTLE_NET_IMPAIR=50ms,2%adds latency and loss to this build's real connections, so a rollback match can be rehearsed at match conditions between two instances on one desk. The panel section doesn't exist without the environment variable, so a real session can never be silently degraded from the UI. - The join seam, where two blocking bugs lived: a tick could be sampled twice while stalled, and a joining player's fighters stood still all match.
- The whole rollback Lua surface exists in the engine's own script editor, not only in the VSCode stub. It had shipped in one and not the other, so somebody who set a node to Rollback in the Inspector couldn't discover a single call the mode requires.
- Interest management is visible while it runs. It's the one feature whose job is to not send things, so with no readout it was indistinguishable from a bug. The panel shows, per player, how much of the world they're being told about and how much is waiting for a turn.
- docs/multiplayer.md — the missing half of the documentation. The design docs said why it works; nothing said how to build with it. It goes from a single-player scene to two machines and ends at shipping.
Known limits
- Cross-platform determinism (x64 ↔ Apple Silicon) is expected, not proven.
The basic arithmetic is bit-exact everywhere; the risk is
sin/cos/pow, which can differ in the last bit between platforms. A fighter's simulation typically avoids them. - Bodies resting on each other are out of scope for rollback — that's what the Rigidbody's pushbox only switch is for.
- The two-machine acceptance run over a real relay at 60–120 ms is still outstanding.
Read the notes
Three bugs here had the same shape: the engine gave a confident wrong answer and
never said so. A node handle that reported the spawn position forever. A texture
filter that showed as Pixelated in the Inspector and reached nothing. A script
mistake that took the whole editor down with no error at all.
Plus the tools that would have found them sooner: frame-stepping, hitboxes visible while you actually play, and animation timings read from the asset instead of guessed.
me = node no longer freezes at the spawn point
The most natural thing a script author writes:
function start(node)
me = node -- keep a handle so other functions can use it
end
me was a permanent snapshot of the spawn pose. No error, no warning, nothing in
the Console.
It failed in the most expensive possible way: partially. Everything using the
passed node — movement, ground probes, facing, impact effects — stayed
correct, so a character moved and animated perfectly. Only values read through
the stashed handle were wrong: hitboxes, cast points, a HUD's idea of where
something was. What you saw was attacks passing through each other and spell
effects anchored to a spot on the floor. It reads as a physics bug, and it got
chased as one for days.
It was asymmetric too. A handle to another node (find("Name"), a noderef
param) was always live — so params.opponent.x was correct while me.x was
stale, in the same function, on the same tick.
A stashed handle is now the same live table the engine keeps updated. A related edge goes with it: one script writing another script's node handle — telling an opponent to reset and setting its position — used to be silently dropped, and now lands.
A script mistake can no longer kill the editor
Passing a wrong table shape to setMaterial didn't produce a Console error. It
killed the process, taking unsaved work with it.
It was much broader than that one call: every script error raised from inside
an engine call killed a release build, and pcall couldn't catch them either. A
wrong table shape was simply the easiest way to get there.
Separately, a colour now accepts every spelling the docs promised: {r,g,b}
(which was documented, named in the error message, and the one shape it refused),
{x,y,z}, {1, 0.5, 0.2} and vec3(…).
And setMaterial is documented for what it is — a setup-time call. Use
setShaderParam for values that change every frame.
Texture filters actually reach the texture
A pixel-art HUD came out bilinear-blurred with Pixelated plainly selected in
the Inspector.
The Inspector saved the setting under one name and the renderer looked it up under another: a permanent miss and a silent fallback to the default. The setting persisted, the Inspector kept showing it, and nothing ever read it.
This was never UI-specific — meshes, materials and the terrain palette were all getting the default for any texture whose filter you set. Existing projects heal on next open, and changing a filter now re-uploads immediately instead of on reload. If you pre-scaled pixel art to dodge this, you can go back to the authored resolution.
Frame data you can actually tune
Frame-step. A fighter is authored in single frames: "is this jab 4 frames of startup or 5" is the difference between a good move and a broken one, and it cannot be answered by watching at full speed. Pause (⏸ / F2), then ⏭ Step (F3) advances exactly one gameplay tick — scripts, physics and animation each moving one frame, so the pose belongs to the frame you stopped on. A tick counter sits beside the button, so an observed event has a number you can write down.
Unpausing can never release a burst of caught-up ticks.
physics.step([n]) is the scriptable half, for a training mode's own stepper.
Gizmos in the Game view. gizmo.* shapes were Scene-view only, which meant
hit and hurtbox visualisation — the single most useful tool a fighting game has —
was invisible while you were actually playing with a controller in your hands.
There's an "…also in Game view" toggle under Script gizmos in the ⏷ menu, off by
default and remembered between sessions.
Animation events as data
Clip events fired one way: the engine called a Lua function when playback crossed one. That's right at runtime and useless as a data source, which is what an animator wants them for.
The workflow is the correct one — scrub the attack, drop an event on the frame where the strike visually connects, line the hitbox up with it. What a competitive fighter couldn't do is let that event drive the hitbox, because events fire off floating-point playback time and a rollback replay deliberately doesn't re-fire them.
So the authored list is readable now, and a game bakes it into integer frames once at load:
local dur = anim:duration("Punch")
for _, e in ipairs(anim:events("Punch") or {}) do
if e.func == "onHitboxStart" then
move.startup = math.floor(e.t / dur * move.frames + 0.5)
end
end
Both read the asset rather than playback, so they answer in start() before
anything has played. Every machine gets identical numbers — deterministic and
rollback-safe, while the animator still authors by eye. Runtime dispatch is
unchanged.
An unknown clip reads nil; a clip with no events reads an empty list, which is
deliberately a different answer.
Two players, one keyboard
v0.8.0's action map let one script run on a keyboard or a pad. It did not let two
people share a keyboard, which is the default way an arcade fighter gets played —
an unscoped Punch on J punched for both fighters.
A binding can name a player now:
Action(name: "Light", bindings: [
Binding(source: Key(KeyJ), player: Some(0)), // P1
Binding(source: Key(Digit1), player: Some(1)), // P2
Binding(source: Pad(id: Any, ctrl: Button(West))), // either player's pad
]),
Axis2(name: "Move", socd: Neutral, bindings: [
Keys(up: KeyW, down: KeyS, left: KeyA, right: KeyD, player: Some(0)),
Keys(up: ArrowUp, down: ArrowDown, left: ArrowLeft, right: ArrowRight, player: Some(1)),
Stick(id: Any, x: LeftStickX, y: LeftStickY, deadzone: 0.15),
]),
Leaving it off — the overwhelming default — means every player, so every existing
input.ron is unchanged and single-player is untouched. Right-click a binding
chip in Project Settings ▸ Input to scope it; scoped chips read ⌨ J P1. An
unscoped keyboard binding in a two-player project is flagged ▲, because "both
characters jump off one press" is otherwise found by watching it happen.
This also repairs motions for player 2: each player gets their own quarter-circle
off one shared Move axis. Two separate axes never could — the recogniser read
player one's for everyone, silently.
Known gaps
- Still no gamepad has been tested against a Floptle build. There is no controller on the development machine.
- Rollback netcode is not here. A fighter needs both players simulated locally every tick with snapshot and restore across a mispredict; today's prediction plus interpolated remotes cannot carry a 5-frame parry window. It's the next large piece of work.
- Stepping backwards one tick isn't implemented — it falls out of rollback, so it's sequenced after it.
Read the notes
Controllers work.
Floptle had no gamepad support at all — scripts polled raw keyboard events and nothing else. This release adds a named action map, gamepads with hot-plug, per-player device slots for same-couch versus, and a fighting-game input layer with buffering and motion inputs.
One controller script now runs on a keyboard, on a pad, or on both at once, and the player can rebind any of it. Every shipped script is converted, so a fresh project plays with either device from the first frame.
What an "action" is
Your script asks for a name. Project Settings decides what triggers it.
if input.justPressed("Jump") then node.vy = 9 end
local x, y = input.axis2("Move") -- WASD or the left stick, identical
Jump might be Space and the pad's South button — any binding fires it. The
script never asks which device you're on, and never has to change when someone
rebinds.
| Call | What it gives you |
|---|---|
input.action(n) |
held right now |
input.justPressed(n) / input.justReleased(n) |
the edges |
input.heldSecs(n) |
seconds held — hold-to-charge with no timer of your own |
input.axis1(n) |
a trigger, the wheel, or a key pair, in −1..1 |
input.axis2(n) |
a stick or WASD as x, y, deadzoned and clamped |
input.player(n) |
the same API bound to another local player |
input.pushContext(n, opts) / popContext(n) |
layers that swallow input |
input.actions() / bindingsOf(n) / startRebind(n) |
build an in-game controls screen |
One axis, two devices, honestly
A mouse and a stick are not the same kind of signal, and pretending otherwise is why "gamepad support" usually means writing everything twice.
- Gating. The
Lookaxis's mouse half requires the right button held — a free cursor must never spin the view — while its stick half is ungated, because a stick recentres itself. - Rate. A stick reports a position you integrate into a turn; a mouse
reports a movement that already is the turn. The mouse binding reports
pixels-per-second now, so
yaw = yaw - lookX * dtis correct on both — and frame-rate independent, so a fast mouse flick turns the same amount at 30 fps and 240 fps.
The fighting-game layer
Buffering and motion recognition need a per-tick history that a script can't rebuild correctly, so the engine keeps 180 ticks per player:
-- a special: motion first, so qcf+P never comes out as a bare punch
if input.motion("qcf") and input.buffered("Punch", 4) then
input.consume("Punch", 4) -- spend it, so it fires ONCE
fireball()
end
- Input buffer —
buffered(name, ticks)answers "pressed within the last N ticks and not yet used". A punch pressed two frames before recovery ends still lands. Withoutconsume, a 4-tick buffer would fire the attack four times. - Motions —
qcfqcbdprdphcfhcbddffbbchargeFchargeUship ready. Matching requires every listed direction in order, so a sloppy input isn't a quarter circle. Windows are counted in ticks, so a player on 144 Hz and one on 60 Hz get identical leniency. setFacing(-1)mirrors directions after a cross-up, soqcfkeeps meaning "toward the opponent".- SOCD — what happens when ← and → are held at once, which a leverless
controller does trivially.
Neutralcancels (the tournament standard, and the default);Last winslets a player pivot with no neutral frame.
All of it reads the tick clock — call it from fixedUpdate.
assets/scripts/fighter.lua is a worked example: walk, jump, normals, a
quarter-circle special, and local versus off a single script.
Two players, one script
defaults = { player = 1 }
local me = input.player(params.player) -- 1-based
if me.justPressed("Punch") then ... end
Pad slots are claimed by device, so a player whose battery dies mid-match returns to their own character on replug instead of everyone being renumbered. An unplugged pad reads neutral rather than freezing on its last pose.
The Settings tab
Project Settings was a fixed-size modal with four topics stacked in a column. It's now a dock tab — drag it anywhere, split it beside the viewport, close it. Edit ▸ Project Settings opens it.
- Nav on the left, one section on the right (Game, Rendering, Layers, Input), so each topic is short enough to read.
- Search spans every section. Type "gamepad", "deadzone", "raycast" or "retro" and it lands on the right one, instead of you having to know which topic owns a setting.
The Input section explains itself
- The action list is read out of your scripts. Every action, axis and motion
your Lua references, with the first
file:lineon hover. Addinput.action("Grapple")to a script and it appears here, flagged ▲ because nothing is bound to it — which is exactly what a control that silently does nothing looks like. - Each row states the Lua call that reads it, on hover.
- Bind two ways.
+arms press-to-bind — press the key, mouse button or pad control you want, Escape cancels.▾opens a picker listing every key, mouse button, pad button and pad axis, which needs no hardware connected at all — laying out controller bindings on a laptop is entirely normal. - A row bound only on the keyboard says so. That's the failure that otherwise ships: a control that works for you and not for someone on a pad.
- A live strip lights up as you mash, without entering Play.
- "Fill in the standard ones" adds Move / Look / Jump / Fire / Interact / Sprint / Crouch / Pause and friends, bound on both device families. It only fills gaps — your own actions and bindings are left exactly as they are.
The shipped scripts are converted
freelook, first_person, third_person, third_person_camera, character
and sword all run on named actions now. A new project seeds the matching
input.ron alongside them.
| Was | Now |
|---|---|
input.key("w") ×4, hand-normalised |
input.axis2("Move") |
input.button(1) + input.mouse_delta() |
input.axis2("Look") |
input.pressed("space") |
input.justPressed("Jump") |
input.key("shift") |
input.action("Sprint") |
input.scroll() |
input.axis1("Zoom") |
Third-person's double-tap-to-run now triggers off the Move axis leaving rest, so a stick flick arms it exactly like tapping W — it was key-only before and simply didn't exist on a pad.
Multiplayer: actions ride the wire
Network input used to carry key names. It now carries actions, so a pad player and a keyboard player who press "Jump" produce identical commands — and the packet gets much smaller.
The cost, taken deliberately: raw input.key/pressed/released/button/clicked
have nothing to replay from, so on a Predicted node they read neutral. An
unmigrated controller visibly does nothing rather than quietly desyncing.
Single-player scripts are untouched, and the Input section lists every raw-key
call site so the migration has a worklist.
Personal rebinds deliberately never lock you out of a session. Wire protocol 8 → 9.
Docking and undocking
assembly.merge(node, other) — the exact inverse of assembly.split. Two
machines become one body carrying their combined momentum, the absorbed parts
keep their world pose, and the emptied root retires. A docking latch, a crane
taking its load, an in-space weld.
The join is perfectly inelastic: an off-centre catch spins the pair up exactly as much as it should, and whatever relative motion is left at the moment of the latch is felt as a jolt. Latching onto something anchored pins the pair.
assembly.split takes a prefab now, so the half that comes away is a live,
scripted craft — an undocked lander that can fly home — instead of inert debris.
Fixes
- Icons rendered as squares. 🎮 ⚠ ⚪ ✓ ⬢ ✏ ✨ 🎧 🖌 were all tofu. Tabs are now ▦ Map, ⏱ Animating, ✱ Particles, ≣ Mixer, ◨ Paint.
- Binding menus snapped shut when clicked.
- The action map never loaded at boot — it was wired into File ▸ Open only, which a launched build never goes through, so every exported game would have started with nothing bound.
floptle --newdidn't seedinput.ron, so Hub-made projects shipped the converted scripts with no map.--migratetops up existing projects.- The Linux build was missing a system dependency the gamepad backend needs.
Known gaps
- No gamepad has been tested against this build. There was no controller on the development machine. The mapping is covered by tests and the backend starts and pumps cleanly with zero pads, but nobody has pressed a physical button.
- Input contexts are implemented but nothing in the shipped scripts uses them yet.
- Rebinding from a shipped game's settings menu has the full Lua API, but no example UI ships.
Read the notes
Build the level in the engine. This release adds a full map-building suite: draw a shape by dragging out its footprint and then its height, push its faces around with normal-aligned gizmos, extrude and inset it into a room, and give individual faces their own materials — without leaving the editor and without a Blender round trip.
A blockout is the first draft of a level. Now you can write it where you'll play it.
Draw shapes, don't spawn them
- Drag out the base, then the height. Press 8 for the ⬢ Map tool, pick a
shape, drag its footprint on the ground — or on any map surface you aim at, so
you can build straight onto the wall you just made — release, move to set the
height, click. A live wireframe and a
4.00 x 3.00 x 6.00 · 8 stepsreadout follow the cursor the whole way, and the shape stays armed so the next drag draws another one. - Seven shapes: box, plane, wedge, cylinder, sphere, stairs, arch.
- Drawn at any proportion. A cylinder or sphere on a non-square footprint comes out as an honest ellipse or ellipsoid — real geometry, not a scaled node, so its textures keep their real-world scale.
- Stairs and ramps climb the way you dragged them, with a green arrow labelled "up" while you draw and whenever such a node is selected.
- Turn it as you go.
,and.turn the shape 90°,Zturns it around, all inside the footprint you dragged.[and]retune the resolution — stair steps, cylinder sides, arch segments — with the preview updating live.Esccancels. - Everything grid-snaps when snap is on.
Edit the geometry
- Vertex / edge / face modes. Click to select, Shift or Ctrl to add, drag empty space to box-select. Tab cycles the mode and converts your selection rather than dropping it — pick a face, press Tab, and you're holding its four corners.
- Selection reaches only what you can see, so you can't grab the vertex on the far side of a wall. A toggle turns that off when you want it.
- Move, rotate and scale the selection, with normal / local / global
handle orientation.
normalis the default and it's the difference between a modelling tool and a toy: a diagonal face's handles point straight out of that face, so it extrudes in one drag instead of two axis drags that only approximate it. - Modelling ops: extrude, inset (inset then extrude carves a recess), subdivide, bridge two faces with a tube of walls, weld, delete faces, split the selection into its own node, flip faces, flip the mesh, snap to grid.
- Selection ops: all, none, grow, connected, coplanar, by material slot, and quad edge loops.
- Undo is one step per gesture, and your sub-object selection survives it — extrude, undo, adjust, extrude again without reselecting.
Shapes stay parametric
A drawn shape remembers what it is, so you can change its step count, sides,
rings or arch opening after the fact — from the Map tab's SHAPE panel, or with
[ / ] on the selected node. Re-generating keeps your material slot names and
your per-face assignments.
Resizing keeps it parametric. The first op that moves geometry retires the parameters, because silently re-generating would throw that edit away.
Size, pivot and materials
- Type exact extents. The SIZE panel resizes the geometry, not the node scale, so textures keep their real-world scale instead of stretching.
- Pivot controls — centre it in the mesh, or drop it onto the current selection, which is then what rotation and scale work around.
- Per-face materials in one click. ◑ New material for selected faces makes a slot from your selection and gives it its own material, with the full material editor — colour, texture, tiling, shader — available on it.
- Solid and textured by default. Every shape spawns collidable and carries a dev-grid texture whose UVs are 1 unit = 1 tile, so the grid measures the room for you while you block it out. Untick either in the Inspector.
Every control has a hotkey, and every hotkey is yours
41 commands — each shape, resolution ±, the turns, the sub-object modes, every selection op, the gizmo modes, every modify op, the pivots — all bound, all rebindable from the Map tab's KEYS section, all saved per user.
Conflicts are prevented rather than discouraged. Map keys are consulted only while the Map tool is active, you're not typing, Ctrl is up and Play is stopped, so they can't reach an application shortcut. Rebinding onto a key the editor owns is refused with the reason ("W is the fly camera"), and so is a key another map command already holds.
It survives everything you'll throw at it
- Prefabs and the clipboard carry the geometry. Copy a wall into another scene, another project, or save it as a prefab — it arrives as the real shape.
- Play is safe. Map geometry is snapshotted on Play and restored on Stop, and editing during Play is refused with a message saying why.
- A damaged sidecar is never overwritten. If
maps/<scene>.map.ronfails to load, map nodes stay empty and saving map geometry is disabled until it's fixed — rather than filling the level with placeholder cubes and making that permanent on the next Ctrl+S. - No T-junctions. A vertex sitting in the middle of another face's edge looks watertight in a render and tears the mesh apart the moment you drag that face. Subdividing part of a mesh stitches the same corner into its neighbours for the same reason.
- Absurd proportions are just proportions. Every shape builds correctly at every size from a hair's breadth to five kilometres.
The editor around it
- A new default layout, grouped by what each dock is for: Hierarchy + ⬢ Map on the left, viewports and full-canvas editors in the centre, Inspector + Terrain + Paint on the right, Assets + Console + the timelines below. Window ▸ ⟲ Reset layout returns to it at any time.
- The Map tab reads in the order you work — DRAW, SELECT, TRANSFORM, MODIFY, SHAPE, SIZE, FACE MATERIALS, KEYS — with rarely-touched knobs behind disclosures.
- A one-line status strip in the viewport states the current mode, gizmo and handle orientation plus what the next click and drag will do. Each word is clickable to cycle.
- Ops explain themselves. Anything that declines says why: "select one or more FACES first", "those two faces have different corner counts", "nothing merged — no two selected verts are within 0.050 of each other".
Fixes
- The script editor no longer crashes on Ctrl+X.
- Copy and cut reach the text field again — the editor was swallowing both before the text widget saw them.
- The play-mode cursor stays where you can use it. A mouse-locking script hid the cursor everywhere including over the Inspector; it now hides only over the game view.
- The animator says what you meant. A mistyped method (
anim:IsPlaying) now raises a did-you-mean instead of a bare nil-call error.
Known gaps
Bevel, edge-loop cuts (loop select ships), booleans, smoothing groups, per-face
UV offsets, vertex snapping to other objects' vertices, vertex and texture
painting on map meshes, and bake-to-.glb.
Read the notes
Duplicate a character and both stay themselves. This release fixes the two animation bugs that could block a project — skinned parts bleeding between copies of a model, and flow-rigged hair ignoring the head it's parented under — then keeps going: real multi-select, per-object materials, beams and trails, volumetric fog, and a starter project that's actually pleasant to open.
The blocking fixes
- Two characters, one model, independent hair. Duplicated characters shared one buffer for their skinned parts, so a copy's hair followed whichever one animated last, and deleting the copy froze it. Every character gets its own now: duplicate your fighter, animate both, each keeps their own hair. (Buffers are pooled, so spawning characters stays cheap.)
- Hair follows the head now. Parenting a flow-rigged hair object under the Head silently did nothing, because skinned vertices follow joints rather than the object. The engine now makes the chain follow wherever you parent its object. Existing scenes pick this up on load with no asset edits — poses and keyframes replay identically, the hair just finally comes along. The Bones list gained the same "under ▸" dropdown objects have.
Multi-select, for real
- Every selected object shows its outline — not just the last one clicked.
- The gizmo moves the whole selection. Move slides everything together, Rotate orbits the group around the primary, Scale scales the spacing too. Parent and child both selected? The child rides its parent once, no double move.
- Hierarchy drags carry the whole selection — drop N nodes onto a new parent as one undo step.
- Viewport clicks accept Ctrl to add to the selection as well as Shift, matching the Hierarchy.
Materials, per object
- Override one object's material inside a model. Select a sub-object and the new ◑ Material block gives just that part its own colour, texture or shader — the rest of the model keeps its imported look. Saved per node, so two instances of a model can dress differently.
- See a model's embedded materials. Selecting a model asset lists every material inside it — tint swatch, texture flag, and which objects use it.
- Filter embedded textures. A per-model crisp / smooth / smooth+mipmaps choice finally lets you soften a model's baked-in textures without a Blender round trip.
- Models with no Material component now draw their imported base colours (they used to render white in-editor unless textured).
Beams and trails
- Beam tracks draw a ribbon from an effect to an endpoint — width and colour
from the track's curves, an endpoint-pinned ripple, and UV scroll for energy
crackle. Aim it from Lua:
node:particles():setBeamEnd(x, y, z)takes a world point, so a raycast laser tracks its target every tick. - Particle trails — any billboard track can leave fading ribbons behind its particles, for sword slashes, wind streaks and tracer fire. Per-track history time, width, taper and texture; effects without trails pay nothing.
Both ride the existing particle pass, so every blend mode, fog and retro come along for free.
Volumetric fog
The Lighting node's fog gained a mode: keep the cheap depth ramp, or switch to volumetric — a height-bounded layer of drifting, noise-broken mist marched per pixel. Hills poke out of ground fog; patches roll past. Density, layer top, softness and noise are all in the Lighting panel. Existing scenes are untouched.
A better first five minutes
- New projects open pretty: a grassy sculpt-ready terrain, a Down gravity volume, three physics shapes waiting to tumble on Play, a warm sun with soft dithered fog — and retro pixelation off by default (it's one toolbar click away).
- The default camera's
freelook.luaflycam actually ships with new projects now (it was referenced but never seeded).
Editor and input fixes
- The play-mode cursor stays put. Camera scripts locking the mouse every frame were re-issuing the OS pointer lock at frame rate, which on Wayland gave you a flickering, uncontrollable cursor. Escape now always frees the mouse, including from a script's lock.
- The rotation gizmo spins the way you drag it. It was backwards whenever the rotation axis faced the camera.
- Negative scale works. The scale gizmo no longer snaps a mirrored node back to positive, and a mirrored character keeps its attachments on the correct side instead of popping.
- The script editor highlights other occurrences of whatever you select, like every other editor.
Upgrading
Scene files gained per-object materials and volumetric-fog settings, both fully backwards-compatible. Nothing to do.
No notes were written for this release.
No notes were written for this release.
Read the notes
An animator stability patch: timeline editing stays inside the animation you have open, and equipment can follow a character rig.
Reliable timeline editing
- Undo and redo stay in the animation.
Ctrl+Z,Ctrl+YandCtrl+Shift+Zin the Animating tab act only on the open clip. They can no longer restore a scene snapshot or undo something unrelated you did elsewhere in the editor. - Copy, cut and paste follow the selection. In particular, an empty Cut no longer acts on whatever was selected last time.
Attach equipment to an animated rig
Any scene mesh under a rigged model can be attached to one of that model's objects or bones from Inspector ▸ Bone attachment.
Picking a bone keeps the equipment exactly where it is, so a sword, shield, emitter or pickup doesn't jump when you attach it. From then on it follows the hand through animation, physics, rendering, particles and gizmo edits.
Read the notes
Animate any model — even one you never rigged. A model imported as a pile of separate meshes (the N64 way — a body split into a head, a torso, arms and legs) is now fully pose-able: every piece shows up, you can parent pieces to each other, keyframe them, and hang soft bone-chains off the ones that should flow.
Plus a Mirror-apply tool that finishes a Blender model whose Mirror modifier never got applied, so you can skip that last round trip entirely.
Every model is an object tree
- The pieces are addressable. A glTF with more than one mesh object used to be flattened into an anonymous blob on import — the individual pieces were simply gone. The engine keeps the tree now: each object stays named and separate, exactly like a rig's bones. A single-mesh prop still takes the cheap path.
- Objects and Bones, side by side. Select a model and the Inspector's ◈ Objects & Rig section lists its Objects (◈) and its Bones (🔗) in two groups, and the Hierarchy shows the same split. Click any entry to select it, move it with the gizmo, keyframe it in the Animating tab — an unrigged model animates cleanly, no skinning required.
Parent objects to each other
A "under ▸" dropdown on each object re-parents it within the model — put a forearm under a shoulder and posing the shoulder carries the forearm.
It's non-destructive: the parenting lives in a <model>.rig.ron file beside the
model, is re-applied on import, and keeps the object visually in place. Cycles
are refused.
Mirror-apply — finish a model without going back to Blender
⇋ Apply Mirror → new .glb completes a model whose Mirror modifier was left unapplied at export, so half the geometry is missing. Per object it:
- welds a centreline half (a head, a torso) into one whole object;
- splits a lateral limb (an arm, a leg) into a mirrored
.L/.Rpair you can animate independently; - leaves alone anything already symmetric.
The result is written beside the source as <name>.mirrored.glb — nothing is
overwritten — with a Console report of exactly what it did. Assign the new model
and you have a normal, both-sided character. Re-running it never stacks
duplicate limbs.
Flow-rig — make hair (or cloth, or a tail) bend
🦴 Rig selected object to flow drops a soft vertical bone-chain down the
selected object and weights it automatically, baked into a new
<name>.rigged.glb. Pose or keyframe the chain to make hair sweep, cloth drape
or an antenna wobble — that one object gets true vertex skinning while the rest
of the model rides along unchanged.
Editable pivots — set where each object rotates
Every object gets a rotation pivot, defaulting to its geometry centre — so a limb baked at the model origin no longer swings around the feet. Type the exact joint into the Inspector's ⌖ Pivot fields, or turn on ✥ drag in view and drag the gizmo straight to the elbow.
Pivots are per-model and non-destructive. They change only how a pose composes, never what's keyframed.
Animating polish
- Author a clip on any rigged model, no controller required. The ✚ New… button appears for any model in the Animating tab and creates a standalone clip bound to it, so you can start keying an imported character immediately.
- The scene and the animator stop fighting. While an object or bone is selected, Undo/Redo act on the clip, and Delete / copy / paste / duplicate leave scene nodes alone — so you can't accidentally undo the world or delete the thing you're animating.
- Deleting an in-use asset is safe. Removing a model or clip that's being animated drops it cleanly everywhere instead of freezing the editor.
The engine writes glTF now
Floptle can export .glb, not just read it — geometry, UVs, vertex colour,
embedded PNG textures, node hierarchy, and skins. That's what Mirror-apply and
Flow-rig produce, and it's the foundation for future bake-it-back-out tools.
Read the notes
A quality-of-life release: UI that adapts to the window, shader effects on any UI element with nine built in, drop shadows, and asset import that works on every OS.
(Supersedes the mis-tagged v0.4.0, which shipped without notes.)
UI that fits any screen
- A canvas scaler. A UI layer picks how it scales — Match Height, Match Width, a blend of the two, Expand (letterbox), Shrink (crop), or constant pixels — against a real 2D reference resolution instead of a single height. This is what keeps a HUD consistent from a laptop to an ultrawide.
- Stretch placement. Anchor an element to a region of its parent with per-side margins: "16 units in from all four edges, at any window size". Quick presets fill all / top / bottom / left / right.
- Min and max size on any element, so percentage-sized things can't collapse on a small window or balloon on a wide one.
Shader effects on any UI element
- An ✨ Effect section in the inspector: a dropdown of built-ins, a custom
.flslpicker, and live parameter rows generated from the shader itself, with compile errors shown inline. - Nine built-in effects, each also a copyable worked example — outline pulse, gradient fill, gloss sweep, glow, wobble shimmer, CRT scanlines, holographic, film grain and frosted glass.
- A UI shader can read what's behind it.
baseTexture(uv)samples the element's own image andbackdrop(uv)samples the composited scene — so a panel can genuinely frost or refract whatever it's sitting over, rather than faking it with an overlay. - Effects clip to rounded corners instead of spilling past them.
- Drop shadow on any shape — colour, offset, blur and spread.
Importing assets works everywhere
⤓ Import… in the Assets header and ⤓ Import here… in a folder's right-click menu open your OS's own file picker. That matters on Wayland, where drag-and-drop from a file manager doesn't exist at all. Multi-select, folders copy recursively, names auto-suffix so nothing is overwritten, and it runs in the background so the editor never freezes.
Drag-and-drop still works where the OS supports it, and lands in a sensible folder more reliably.
Particle lifetime, two ways
Editing an effect's lifetime now offers a choice: Absolute (automation graphs keep their real-time positions — the default, unchanged) or Scale with lifetime (the whole timeline stretches, preserving the shape over the new length).
Solar demo
The flight interface went from text scattered on screen to a laid-out cockpit:
framed instrument panels with dark glass and scanlines, staging moved to the
top-right corner, telemetry top-left, navball and tapes bottom-centre. Every
status icon the HUD font couldn't draw became an avionics-style flag —
[SOLAR], [LINK], [HEAT], [CHUTES OPEN], >> BURN NOW. Menus and buttons
gained shadows, frosted glass and hover feedback.
Animator
Dopesheet editing: box and shift multi-select, move, stretch, copy, cut, paste, duplicate, delete, undo/redo, key-all-bones, key-all-tracks, insert-key, prev/next-key navigation and a shortcuts cheat-sheet. Bone gizmos in the Scene view with live drag preview, a fixed selection outline on skinned meshes, and rig-only glTF characters now expose their bones.
No notes were written for this release.
Read the notes
Fly a craft from the map view, feel every scrape and crash, watch distant vessels coast their real orbits — and a sky that stops flickering.
The sky stops flashing black
At orbital distances the background could flicker black for a frame as the camera moved. It's gone: the sky is painted properly, and the maths that produced the bad pixel is rewritten to be stable that far out.
Scrapes and glancing hits register
A hull dragged along the ground — a topple, a belly-slide, a shallow graze — was invisible to gameplay. Only square-on slams reported anything, so a damage system could not react to a ship sliding down a hillside on its side.
Every real contact now reports, carrying its honest closing speed. And that speed is the true impact speed: a fast ram deep into terrain reports how fast it was actually going, rather than something near zero.
Fly a craft from the map
assembly.keepLive(node, on)keeps a machine in full physics however far the camera roams. Without it, pulling back to the map view froze the craft you were trying to fly.- A coasting craft reports its real orbital velocity. Anything reading
assembly.info().velfor a distant vessel — map trajectories, orbit read-outs — used to get zero and draw nothing. A far, coasting ship's orbit is drawable now.
Animations bind to the right skeleton
A character could start playing before its model finished loading, fall back to a static pose, and never recover — an animated model that simply stood there. The animator now picks up the real skeleton the moment it's available.
Also
objectPosin shaders — a coordinate that stays put on a surface, so procedural detail like panel grids and weathering stops swimming as the camera moves at large distances.- Spatial audio through the project mixer — reverb, compression, EQ and a master limiter on positioned voices.
Read the notes
Multi-part vehicles are a first-class thing now: build a machine from parts, fly it as one body, take it apart in flight, break it in a crash — on worlds that orbit, at any time-warp, by the hundreds.
One machine out of many parts
Flag a hierarchy of rigidbodies as an assembly and it becomes a single body: combined mass, a real centre of mass, and per-part collision shapes. A part bolted off-centre pushes the whole thing off-centre, the way it should.
- Split it mid-flight.
split()detaches any subset of parts into a new, live machine — staging, breakage, a crane letting go. Both halves keep flying. - Pin it down. A compound can be anchored where it stands — launch clamps, docking latches — riding its planet's motion, releasing from rest.
- It un-buries itself sanely. Something spawned half inside the ground rises out at a reasonable rate instead of being catapulted into orbit.
Assemblies from Lua
assembly.rebuild(root) -- gather the parts under a root into one body
assembly.forceAt(root, f, point) -- an off-centre engine honestly torques the stack
assembly.split(root, parts, fn) -- and run fn on the piece that came away
assembly.setAnchored(root, true)
assembly.teleport(root, pos) -- THE way to place a live assembly
local i = assembly.info(root) -- mass, com, origin, vel, angVel, grounded, parts
Assembly roots read like any other body — node.vel, node.up, node.grounded
— in every script pass. Vector arguments take vec3() values or plain
{x, y, z} tables everywhere.
Damage systems in ten lines
Every impact a machine takes is attributed to the part that took it:
for _, hit in ipairs(assembly.impacts(node)) do
-- hit.part, hit.impulse, hit.x/y/z
end
Compare against per-part strength and you have crashes, breakage and destruction. A soft landing reads as small loads on the legs; a nose-first crash as one enormous load on the nose.
Machines in orbit
- Compounds ride their planet. A machine sitting on an orbiting world moves with it, and hands off between worlds without a velocity glitch. A launchpad's collision rides along too, so surface structures stay as solid as the terrain.
- Time-warp coasts properly. Every craft in flight follows its own real orbit during warp — no drift at 10 000×, and dropping back to 1× resumes seamlessly. Warp cancels near a surface.
- Distant craft cost nothing. Machines far from the camera leave live physics on their own (landed ones freeze, flyers coast their orbit) and wake up on approach. Hundreds of satellites, stages and rovers are free.
Also
physics.pause(on)/physics.isPaused()— freeze the simulation while scripts and streaming keep running. For loading screens and pause menus. Held forces are dropped rather than banked up.- Filled debug shapes —
draw.tri,draw.cone,draw.discjoin the lines, rings, spheres and boxes. These draw in the game view, for markers and telegraphs a player is meant to see. node:uiRect()— a UI element's real on-screen rectangle, in the same pixelsinput.mouse()reports, so a script can hit-test the cursor against a panel instead of guessing where it ended up.- Materials can be procedural. Point a material at a shader that shades from world position and you get panel seams, rivets and weathering with no texture files. Triplanar sampling is scale-aware on scaled meshes.
The solar demo
The bundled reference project got a ship builder (select-then-tool editing, free part orientation, radial mounts, ×N symmetry, a staging tool with drag-to-reorder, undo, blueprints, CoM/CoT overlay, per-stage TWR), full vessel flight (clamped pad launches, staging, pooled fuel, SAS holds, navball, time-warp, a 3D map with trajectories and maneuver planning, EVA and boarding), and crash damage — fuel tanks that blast and crater the ground, reentry heating that cooks windward parts, landing gear, comms dishes and parachutes.
No notes were written for this release.
No notes were written for this release.
No notes were written for this release.