Releases / v0.104.0

v0.104.0 Ready For Steam

2026-09-30 Engine

An exported game can talk to Steam. Friends can invite each other and join from the Steam friend list, a game can see which DLC the player owns, and it can mark its highlights on Steam's game recording. Vessels collide with each other and with the people walking on them. A player-made level can be sent to someone who has just joined in one call, a script can tidy up when its node goes, and a static piece a script moves takes its collider with it. New projects, and the editor's own sample project, start blank.

Steam in a shipped game

Until now an exported game had no Steam in it at all: every steam.* call answered as if Steam were absent, and only a build from source could reach it. Give the project a Steam App ID in Edit ▸ Project Settings ▸ Game ▸ Steam App ID and File ▸ Export Game… ships a player with Steam built in, with Valve's runtime library beside it. A project with no App ID ships exactly as before, with no Valve files in it. Use 480, Valve's test app, before you have a partner account.

A shipped build hands itself to Steam when it is started some other way, as Steam games do. To run an export straight from its folder while testing, put a steam_appid.txt holding the App ID beside it, and leave that file out of what you upload.

Invites and joining a friend

steam.onJoinRequested(fn) runs when the player accepts an invite or clicks Join Game on a friend, and for the invite the game was started from. It hands the game { lobby = id } or { connect = "..." }, and the game joins the way it already does:

lua
steam.onJoinRequested(function(req)
  local code = req.connect and req.connect:match("^%+join (%w+)$")
  if code then net.join("cloud://" .. code) end
end)

-- The host, once it has a code: Join Game in the friend list now works.
local code = net.lobbyCode()
if code then steam.setRichPresence("connect", "+join " .. code) end

steam.inviteFriend(friendId, connect) sends an invite of your own. Start a connect string with +. A game started from an invite used to stop before it opened, because the player took what Steam added to its command line for a folder to open.

DLC

steam.ownsDlc(appId) and steam.dlcInstalled(appId) answer from the Steam client on the player's machine, so ask them whenever you need to. steam.onDlcInstalled(fn) hears a DLC bought while the game is running, so it can unlock at once. steam.dlc() lists every DLC the game has, with whether each is on sale, owned and installed. steam.installDlc and steam.uninstallDlc manage a DLC's download, and refuse one the player does not own.

Game recording and screenshots

steam.timelineEvent{ title = "Boss down", icon = "steam_combat", clip = "featured" } marks a moment on Steam's game recording so the player can find it and clip it. ago and duration say when it started and how long it lasted. steam.timelineMode colours the timeline by what the player is doing and steam.timelineState says where they are. steam.screenshot() takes a screenshot into the player's Steam library, for a photo mode's shutter button.

Vessels collide

Two assemblies used to pass straight through each other, and so did a character and an assembly: a lander could fly through the ship it had just left. Now they push each other apart part by part, and a glancing hit spins both. A character stands on a deck, and friction carries it along when the deck moves. Each hit shows in assembly.impacts on both sides and raises onCollisionEnter between the two. The collision layers decide who is pushed, anchored assemblies are never moved, and a rollback fighter is never pushed by one. Two plain rigidbodies still pass through each other as before.

Sending a whole level

net.send(name, data, { to = peer }) sends any bytes, up to 16 MB, such as a level built in the game, a drawing or a custom character. The engine cuts it up and puts it back together, and onData.<name>(data, sender) runs once with all of it. It keeps its place among RPCs, so a level sent before a stream of edits to it is handled before the first edit. net.receiving() lists what is still arriving, for a joiner's loading bar. RPCs are reliable and ordered, which the networking guide now says outright.

A script can tidy up after itself

function onDestroy(node) runs once when a script's node goes: destroy() on it or anything above it, an unloaded layer, or a scene switch it does not survive. The node is still there, so a script can remove helper nodes it made elsewhere in the world. It does not run when a script is switched off or when you press Stop.

Moving static pieces

A collidable model placed while the game runs no longer re-reads its whole file, textures included, for its collider. A large building hitched the frame by 60 to 90 ms each time; now the second one of a model costs a fraction of a millisecond. And a static piece a script moves, turns or scales takes its collider with it, so a level editor in a game no longer has to destroy and respawn a wall to move it.

Blank sample project

The editor's own project used to be full of test scenes, models and sounds. It now holds only what an empty new project does. The scripts every new project starts with are unchanged, and a new blockout shape gets its grid texture in an installed editor too.

Fixed

  • margin on a pinned UI element insets it from the corner it is pinned to. It was ignored, so { pin = "topRight", margin = 28 } sat flush in the corner.
  • A character the game's font lacks, like the minus sign "−", is drawn from the built-in font instead of as nothing, and the Console says once which font lacked which character.
  • An exported game run with --shot out.png no longer takes out.png for a project folder to open.

Floptle Hub

Linux x86_64 9.0 MB Download → sha256 95360f4190a34ff04c20ca264ec18c09d277df8dab9d5f01b21bb7c32afa8938
macOS Apple Silicon 5.9 MB Download → sha256 c1385496b120f3189a4b6bb328df7b368c0c06351d3fa35810724301992048ac
macOS Intel 6.4 MB Download → sha256 eeb99188c39d896eb066518a032abdfd5ec26044fce297b5ab25645c6e820fbb
Windows x86_64 7.3 MB Download → sha256 a117772aa481ce30ee849984a3263041181c44555baab4b531591f76ee9386c4

Engine

Linux x86_64 51.2 MB Download → sha256 342e59e742f64500cf422930a856b4155712c80213cc6846529807ba23918707
macOS Apple Silicon 39.3 MB Download → sha256 a67b633afcb8e78540dc3825710eddd78a42dee5f760560af8c873a0221945ea
macOS Intel 42.4 MB Download → sha256 7608f5f56a677ee942cb9efc791e308a28bcd0207aa84026b812fabc5730d7e3
web 4.6 MB Download → sha256 756a08dd81dc1a1b8d7fd85f43989b6e8843e3aa9f1b99518fb6cc81935edd31
Windows x86_64 47.9 MB Download → sha256 3f856d7f56c2dcc329bc1833c357d33be7d2c580a6061dc5c8ec230c2753a479

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