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:
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
marginon 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.pngno longer takesout.pngfor a project folder to open.
Floptle Hub
Engine
The binaries are not signed yet. On macOS, right-click the app and pick Open the first time. On Windows, if SmartScreen appears, pick More info then Run anyway.