Multiplayer
Multiplayer
Replication, remote calls, rollback, and voice.
Part of the scripting guide · every call, as a reference
Contents
- 16. Networking:
net.*,synced,onRpc - 16b. Rollback netcode:
snapshot,restore&net.random - 16c. Voice: proximity chat
16. Networking: net.*, synced, onRpc
Multiplayer in Floptle is server-authoritative: the host simulates the truth, clients receive smoothed snapshots, and clients send intents (RPCs), never state — so cheating means asking the server nicely. Making a node multiplayer takes two steps, no rewrite:
- Give it the Networked component (Inspector → ➕ Add Component → Networking), or from code: mark what syncs in its settings.
- Declare which script vars sync with a top-level
replicatedtable, and read/write them throughsynced:
-- door.lua — a fully networked, late-joiner-correct door in ten lines
replicated = { open = false }
onRpc = {}
function onRpc.use(args, sender) -- a client walked up and sent net.rpc("use")
if net.isServer() then synced.open = not synced.open end
end
function update(node, dt) -- cosmetic: everyone eases toward the truth
local target = synced.open and 1.6 or 0.0
node.y = node.y + (target - node.y) * math.min(1, dt * 6)
end
| Call | What it does |
|---|---|
net.host{ maxPlayers = 16, port = 7777, relay = "addr" } |
become the authoritative host — relay = get a LOBBY CODE through a rendezvous relay (nobody port-forwards): "cloud" is Floptle Cloud's managed relay (six-character codes; needs the project's game key), "host:port" is your own floptle-relay (five-letter codes); port = direct UDP (QUIC); neither = the in-editor harness. Refused while this peer is in a session — net.leave() first |
net.host{ interest = 150, interestBudget = 16384 } |
interest management — each client is told about its own neighbourhood (metres) within a per-client byte budget, instead of everything. Absent = broadcast to everyone, which is cheaper below a few dozen players. Tick ⬦ always relevant on a node's Networked component to exempt it (the match clock, the objective, the boss) |
net.lobbyCode() |
the code friends type in, on a relay host — six characters on Floptle Cloud (the first names the region), five on your own relay — so your own lobby screen can show it. Poll it: nil until the relay answers (a round trip after net.host), and nil for good on a client or a direct/LAN host, where there is no code and joiners use the address |
net.join(addr) |
join a session ("cloud://CODE" = a Floptle Cloud lobby code, routed to its region by the first letter with no call to fopull.com; "relay://relayaddr/CODE" = a code through your own relay; "quic://host:port" = a server directly; "local://" = the in-editor test harness). Does not block — see net.joinState() |
net.joinState() |
"offline" / "connecting" / "joined" / "refused" / "starting", plus the reason as a second return when refused. "starting" means the lobby is real and its dedicated server is waking up — not a refusal, and unlike "connecting" it can last tens of seconds, so show the second return rather than a spinner. Wait on this, not on net.role() — joining doesn't block, so role reads "client" from the frame you called net.join, whether or not that code matched any lobby |
net.leave() |
end the session |
net.role() / net.isServer() / net.isClient() |
"offline" | "server" | "client" |
net.peers() / net.ping(peer) |
connected peer ids · round-trip ms |
net.rpc(name, args, {to=peer, withInput=true}) |
remote call — server→clients, or client→server; withInput stamps the tick you were seeing (for net.rewind) |
net.send(name, data, {to=peer}) |
a bulk message: any bytes (a Lua string) up to 16 MB — a player-built level, a drawing, a custom character. The engine cuts it up and puts it back together, and it arrives whole. Same directions as net.rpc; handled by onData.name(data, sender) |
net.receiving() |
bulk messages still arriving: { {from, name, got, total}, … } in bytes — a joiner's loading bar |
net.on(event, fn) |
"playerJoined"/"playerLeft" (peer id — playerLeft also gets the kick reason, when there was one), "connected", "disconnected", "kicked" (why the server removed you) |
net.identity(peer) |
{ id, name, tier, verified } — who that peer is. Check verified: it is false for everyone today (see below) |
net.kick(peer, reason) |
SERVER: remove a player, with words that reach them |
net.host{ requireIdentity = true, allow = {ids}, deny = {ids} } |
who this server will admit, consulted before a join is accepted |
net.spawn(what, {x,y,z,owner}) |
SERVER: spawn a replicated object everywhere — what is a prefab by name ("Knight" finds prefabs/Knight.prefab.ron), a prefab path, or a scene path ("scenes/player.ron"); its first root and everything under it spawn. x, y, z are three numbers, not a vec3; owner = peer makes a Predicted rig that player's avatar |
net.despawn(node) |
SERVER: remove it — and its subtree — everywhere |
net.setOwner(node, peer) |
SERVER: hand a replicated node to peer, or nil to release it. What gives a reconnecting player their slot back |
net.host{ interestOcclusion = "Level" } |
also require line of sight, tested against that collision layer. On top of interest, never instead of it |
net.setRelevant(node, peer, bool) |
SERVER: decide per client whether that client is told about that node at all (nil = let the radius and the sight test decide). The hidden-role hook |
net.rewind(peer, fn) |
SERVER: run fn against the world as peer perceived it (lag compensation) |
net.isMine(node) |
is this node under MY control here? (cameras/HUDs pick the local player; pair with findScripts) |
Who a peer is, and getting rid of one. A session peer used to be a
transport id plus whatever name the game's own handshake carried, and there was
no kick at all. Now a signed-in client presents its account claim when it
joins, the server records it, and net.identity(peer) reports it:
net.on("playerJoined", function(peer)
local who = net.identity(peer)
if who.verified and bans[who.id] then
net.kick(peer, "you are banned from this server")
end
end)
net.on("kicked", function(_, reason) -- on the removed player's machine
ui.show("You were removed: " .. reason)
end)
verifiedisfalsefor everyone right now, and that is not a bug. The engine carries what a client says about itself; turning that into an identity needs a credential the server can check with fopull.com, scoped so that presenting it does not also hand that server your account. No such credential exists yet (it is filed as engine task0184). So an allow/deny list andrequireIdentitywork, and they keep out the careless rather than the determined — andnet.identitysays so rather than reporting a confidence it does not have. Do not key a ban list or a statistic on an unverifiedidand call it settled.
Anonymous play is untouched: a LAN or friends game with nobody signed in joins
exactly as it always did, and verified = false with no id is a normal state.
net.host{ requireIdentity = true } refuses an anonymous join with a reason the
client can show.
A radius is not a security boundary. Interest management exists to save bandwidth, and for an open-world game that is the whole feature. In a hidden-role or competitive game, seeing another player through a wall is the game — and a client that has been told where everyone within the radius is standing knows where they are, whatever it chooses to draw. Nothing you do on the client side fixes that: hiding, attenuating or not rendering something the client already received is a setting a modified client turns back off.
Two server-side answers, and they compose:
-- Line of sight, on top of the radius. Nothing on the "Level" layer between
-- you and it, or you are not told about it. Off unless you ask.
net.host{ interest = 25, interestOcclusion = "Level" }
-- Your own rule, per (client, node) — whatever the geometry says.
net.setRelevant(killerNode, survivorPeer, false) -- withhold
net.setRelevant(objective, peer, true) -- pin, at any distance
net.setRelevant(killerNode, survivorPeer, nil) -- let the tests decide again
Losing sight is damped by a few snapshots so a body behind a door frame does not flicker; regaining it is immediate, because a player stepping out of cover has to be there on the frame they step out. A client is always told about its own avatar and about anything flagged always relevant — a filter cannot hide either, since the first is what prediction reconciles against. Losing relevance never despawns a scene-authored node; it stops being updated and is sent in full on re-entry. The 🌐 panel shows, per client, how many nodes are being withheld and by which rule.
For a fighting game — where the whole game is reading your opponent's exact state this frame — the mode above is the wrong shape. See §16b, rollback netcode.
synced rules. Values can be numbers, booleans, strings, and tables
(nested up to 4 levels, ≤ 1 KB encoded per var — an oversized write is dropped
whole with a Console warning, never truncated). Only the server's writes
replicate; writing on a client warns and gets overwritten. Late joiners receive
the current values automatically.
RPC handlers live in an onRpc table: function onRpc.use(args, sender).
sender is the verified peer id (0 = the server) — clients can't spoof it.
Args follow the same size/type rules as synced.
RPCs are reliable and ordered. Every RPC from one peer reaches the other,
in the order it was sent; none is dropped and none overtakes another. A bulk
message from net.send takes its place in that same order, so a level sent
before a stream of edits to it is handled before the first edit:
-- The host: a joiner gets the level as it stands, then every change after.
net.on("playerJoined", function(peer)
net.send("level", encodeLevel(), { to = peer })
end)
-- The joiner.
onData = {}
function onData.level(data, sender) -- data: the whole message, a Lua string
loadLevel(data)
end
Args to an RPC are capped at about 1 KB, so anything bigger goes by
net.send. It is cut into slices and reassembled for you; onData runs once,
with all of it. A message over 16 MB is refused with a warning.
Test it without a second machine: press Play, then the 🌐 toolbar button → Host + join a local client. A hidden ghost client joins over a simulated link — cyan ghost spheres show where it believes every networked node is. Drag the latency/loss sliders and watch the ghosts lag and stutter exactly as a real remote player would.
Play over a real network: both machines open THIS project and press Play. One hosts (🌐 → Host on LAN, or
net.host{ port = 7777 }), the others join (quic://<host's-LAN-ip>:7777). The link is QUIC — encrypted, zero-config (the trust model of a Minecraft server; verified identity comes with the relay). Player slots: scene-authored Predicted nodes, in node order — #1 is the HOST's, #2 the first joiner's, #3 the second's, and so on. Duplicate your character node to add a slot, and every camera/HUD picks its own player vianet.isMine(the stock camera already does).
Per-player avatars: spawn one on join
The scalable shape — no authored slot per player. The server spawns an avatar scene for each joiner; the engine registers its physics body live, the joiner's machine binds prediction to it (instant response at any latency), everyone else interpolates it, and it despawns automatically when its player disconnects:
-- player_spawner.lua — attach to any always-present node (the Map)
function start(node)
net.on("playerJoined", function(peer)
if net.isServer() then
-- a scene path; a prefab by name ("Player") or path works the same way
net.spawn("scenes/player.ron", { x = peer * 2, y = 2.5, z = 8, owner = peer })
end
end)
end
scenes/player.ron can be a whole rig: a capsule with a RigidBody, your
controller scripts, a Networked component set to Predicted, and whatever
hangs off it — a camera child, a first-person arms mesh, a point light, a
bone-attached item socket. The first root and its entire subtree spawn on the
server and on every client, parent links, bone attachments and scripts intact.
The scene's own Predicted node (if any) stays the host's avatar.
Only the root replicates. Its children are ordinary local nodes that follow it, and their scripts run on every peer — the same deal an authored child of a networked node has always had. Give a child its own Networked component and it replicates in its own right, under the same owner (a creature's model child syncing its animator, say). Spawns are dynamic bodies, not static geometry.
Ownership is not fixed at spawn. net.setOwner(node, peer) hands a
replicated node to a player after it exists, and net.setOwner(node, nil)
releases it — which is how a player who dropped gets their own slot back on
reconnect rather than a fresh one:
net.on("playerLeft", function(peer)
local slot = mySlots[peer]
if slot then net.setOwner(slot, nil) end -- free it, keep the body
end)
On a dedicated server, authored Predicted slots are handed out from #1 in
node order as players join, and freed when they leave. That differs from a
hosted session on purpose: there, slot #1 belongs to the host, because somebody
is sitting at that keyboard. A dedicated server has nobody, so reserving #1
would leave an avatar in the world that no client predicts and no input drives.
A slot your own script assigns is never reassigned behind your back.
Lobby codes: play without port-forwarding
Two relays to choose from, and the code says which one a friend is joining:
- Floptle Cloud —
net.host{ relay = "cloud" }(the project needs its game key from fopull.com/cloud, pasted in ⚙ Settings ⏵ Networked). The code is six characters, and its first letter names the region, sonet.join("cloud://CODE")finds the relay from the code alone — no call to fopull.com, and it works during an outage. The 🌐 panel does the same withcloudin its relay field, which is the default. - Your own relay — run
floptle-relayanywhere both machines can reach (one binary, default port 7788),net.host{ relay = "relay.host:7788" }, and you get a five-letter code; friends join withnet.join("relay://relay.host:7788/CODE"), or type thehost:portinto the 🌐 panel's relay field.
Show the code on your own lobby screen rather than sending players to the 🌐
panel — net.lobbyCode() returns it:
function update(node, dt)
find("CodeLabel").text = net.lobbyCode() or "getting a code…"
end
Poll it rather than reading it once: the relay has to answer first, so it is
nil for a round trip after net.host. It stays nil on a client and on a
direct/LAN host — there is no code in either case — and it clears the moment a
session ends or a host attempt fails, so a stale code can never sit on screen
looking live.
Joining does not block, and the code is usually wrong. net.join returns
immediately and net.role() reads "client" from that frame — before the relay
has said anything. A game that trusts role congratulates a player on joining a
lobby that was never there. Wait on net.joinState() instead:
function update(node, dt)
local state, why = net.joinState()
if state == "joined" then
scene.load("arena")
elseif state == "refused" then
find("Error").text = why -- "no lobby QK7RM", in the relay's words
end
end
Mistyping the code is the most common thing that will ever go wrong in an online
session. "refused" is the relay actively saying no; a relay that is switched
off entirely never answers at all, and stays "connecting" — so give that case a
timeout of your own.
The relay is dumb on purpose: lobbies, peer ids, forwarding — it never reads game state, and a session through it is byte-identical to a direct one. The lobby dies when its host leaves. Self-host it forever, no strings — the managed convenience (always-on relays near your players) is what Floptle Cloud sells.
Prediction (🌐 → Test as remote player): give your character's node a
Networked component with mode Predicted (owner) and it responds instantly
at any latency — the engine records your inputs, the server re-runs the same
script with them, and divergences rewind-replay invisibly. One thing to know:
in a session, a predicted node's update runs on the gameplay tick (60 Hz,
constant dt) instead of per frame, so the client and server integrate your
controller identically. Your script doesn't change — but movement code belongs
in fixedUpdate anyway, and cameras (per-frame update) belong on a separate,
non-networked node.
Which scripts run where. On a client, a node whose transform/physics
the server owns is fully snapshot-driven — its scripts don't run there (its
state arrives over the wire). A Networked node that only syncs script vars
runs its scripts everywhere: that's the door above — update eases toward
synced.open on every machine, and the authoritative flip guards with
net.isServer(). Rule of thumb: sync the transform for things physics moves;
sync only vars for things scripts animate.
Effects, sounds and drawing stay on the machine that made them.
spawnEffect, audio.play, draw.*, a hitstop in a local — none of it
crosses the wire. Code that runs only on the server (a hurt() a bot's script
calls, anything behind net.isServer()) spawns its sparks on the server alone:
a joiner sees the health bar drop and nothing else. Drive presentation from
state that travels — bump a synced counter with the facts needed to draw the
moment, and let a script that runs everywhere (a var-only Networked node, or a
child of the rig) spawn the effect when the counter moves:
-- hits.lua — on the fighter. The server records the hit; every machine draws it.
replicated = { hp = 100, hitSeq = 0, hitKind = "", hitX = 0, hitY = 0, hitZ = 0 }
local seen = 0
function hurt(amount, kind, at) -- server only, by contract
synced.hp = synced.hp - amount
synced.hitKind, synced.hitX, synced.hitY, synced.hitZ = kind, at.x, at.y, at.z
synced.hitSeq = synced.hitSeq + 1 -- the fact that travels
end
function update(node, dt) -- runs everywhere
if synced.hitSeq ~= seen then
seen = synced.hitSeq
spawnEffect("vfx/" .. synced.hitKind, synced.hitX, synced.hitY, synced.hitZ)
audio.play("audio/hit.ogg", synced.hitX, synced.hitY, synced.hitZ)
end
end
What a Predicted node's owner sees. On the owning client that script has
two copies of its state: its locals are the prediction (its own input,
re-run), and synced is the server's word. Anything only the server does
to the node — damage from a bot, a knockdown, a respawn — lands in the server's
copy and reaches the owner through synced alone; the body is corrected, the
locals are not. So a HUD drawn from a local hp never sees its player get hit,
and a controller gating movement on a local state keeps walking while the
server has it on the floor. The pattern: the authoritative side publishes its
state table into synced at the end of its tick, and every watcher — HUD,
animator, camera — reads one view():
local S = { hp = 100, state = "idle" } -- the simulation's own table
replicated = { hp = 100, state = "idle" }
local function authoritative() -- whose copy is the truth here
return net.role() == "offline" or net.isServer()
end
local function publish() -- server: S → synced, once a tick
synced.hp, synced.state = S.hp, S.state
end
function view() -- what to draw, on any machine
if authoritative() then return S end
return synced
end
function fixedUpdate(node, dt)
-- …simulate into S…
if authoritative() then publish() end
end
A HUD reads view().hp, an animator picks its clip from view().state, and a
camera shake keys off view(). Offline and on the server view() is the
locals; on the owner and on everyone else it is the server's word.
Levels in one session: additive layers
A hub with portals into separate levels doesn't have to be one giant scene. Load each level as a layer on top of the hub, on the server, and every client loads it too:
-- on the server, when a player steps into the cave portal
scene.load("levels/cave", { additive = true, offset = vec3(2500, 0, 0) })
-- and when the last player leaves it
scene.unload("levels/cave")
What that does:
- Every client follows. A player who joins later is told about every layer the session is running, in order, and loads them too. Nobody reconnects and nothing reloads.
- The layer's Networked nodes replicate. The server numbers them and each client numbers its copy the same way, so a lift in the cave moves for everyone.
offsetplaces the layer. Each level can live in its own file, worked on by its own person, and still sit 2.5 km from the hub in the running game.- Its map geometry comes with it. Each layer reads its own map file, so a level built with the map tools arrives whole.
- Unloading is cheap. Only that layer's sounds, effects, animators and colliders go, and the rest of the world keeps playing.
replicate = false keeps a layer on the server alone. A client can load its
own layers too (a cockpit interior only its player sees), and those stay its
own. Loading a new scene with scene.load(name) still replaces everything,
layers included.
To switch a whole level off without unloading it, set node.enabled = false
on its root. Its static colliders leave physics and rays with it, and come back
when it's switched on.
Lag-compensated combat: withInput + net.rewind
On your screen, every other player is rendered a beat in the past (the interpolation delay) — so by the time your "I swung" intent reaches the server, the defender has moved on. Judged at server time, hits you clearly landed whiff, and parries that were up on your screen don't count. The fix is the genre's standard contract: the server rewinds the world to what you saw and judges there.
Two pieces. The client stamps the intent with the tick it was seeing; the
server wraps its hit-check in net.rewind:
-- sword.lua — on the attacker (a Predicted node). A named action, bound in
-- Settings ▸ Input: raw keys and buttons read neutral on a networked node.
function update(node, dt)
if net.isClient() and input.justPressed("Attack") then
local yaw = input.aimYaw() or node.yaw
net.rpc("swing", { dx = math.sin(yaw), dz = math.cos(yaw) },
{ withInput = true }) -- ← stamp what I was seeing
end
end
onRpc = {}
function onRpc.swing(args, peer) -- runs on the SERVER
net.rewind(peer, function() -- ← the world as PEER saw it
local hit = raycast(node.x, node.y, node.z, args.dx, 0, args.dz, 3.0)
if hit and hit.node then
local combat = hit.node:getScript("combat") -- getscript works too
if combat and combat.synced.parrying then -- their flag AT THAT TICK
net.rpc("parried", { by = hit.node.id }, { to = peer })
elseif combat then
combat.hurt(25, peer)
end
end
end)
end
Inside the net.rewind closure, raycasts and shape queries see every
networked body where that player saw it, and other scripts' synced vars read the values from
that same tick — so a parry window that was open on the attacker's screen
counts, even if it just closed at server time. Everything snaps back to the
present when the closure returns (it also passes through return values, so
local hit = net.rewind(peer, function() return raycast(...) end) works).
The fine print, so you can reason about fairness:
raycasthits physics bodies (players, crates) as well as static geometry, and tells you who:hit.nodeis the node's handle either way. Your own body is always excluded from your rays, and an optional trailing arg skips one more node:raycast(…, max, someNode)— what the orbit camera does so the character it follows never reads as a wall.- Rewind depth is clamped to ~250 ms — a very-high-ping attacker can't shoot everyone else in the distant past. Beyond the clamp, their disadvantage is real (that's the honest tradeoff every game in the genre makes).
net.rewindoutside a server-sideonRpchandler for awithInputrpc (or with the wrong peer) warns and runs the closure at server time — your logic still works, it's just not compensated.
16b. Rollback netcode: snapshot, restore & net.random
Everything in §16 is server-authoritative: one machine simulates, the others watch a slightly delayed copy and predict their own avatar. That is the right shape for almost every game — and the wrong shape for a fighting game, where the decision you make is made against your opponent's exact state this frame.
Rollback is the third mode. Set a node's Networked component to
Rollback (every peer) and every machine simulates that node, every tick, from
the session's per-tick inputs. Nothing about a hit ever crosses the wire — only
inputs do — so hit resolution, hitstop and meter agree because the simulation
agrees. Why it works this way:
the rollback decision record (ADR-0025).
The contract: two hooks
function snapshot() return { hp = hp, meter = meter, frame = frame } end
function restore(s) hp, meter, frame = s.hp, s.meter, s.frame end
That is the whole opt-in. When a remote input arrives that contradicts what was predicted, the engine restores the last agreed tick and re-simulates every tick since, with no rendering in between — and it restores your script's state through these two hooks.
A script that defines neither hook is not rolled back — right for cosmetics, wrong for gameplay.
Read that twice, because nothing warns you at runtime: a rolled-back match with
one un-snapshotted counter keeps playing, and the two machines simply stop
agreeing about it. If a value affects what happens, it belongs in snapshot().
The engine owns the copy in both directions, so a replay can't corrupt the snapshot it restored from. Rollback state holds numbers, strings, booleans and nested tables; a node handle or a function is refused with a Console error, because those cannot be meaningfully restored and silently dropping them would produce a state that looks restored and isn't.
Writing a fighter that stays in sync
| Rule | Why |
|---|---|
| Count frames, not seconds | heldSecs reads 0 on rollback-driven slots (the wire carries actions, not durations). Integer frame counts are exact and re-simulate identically. |
Build hurtboxes from node.tickPos, never node.x |
Between ticks the transform holds the interpolated render pose. Reading it in fixedUpdate is a frame-rate-dependent read that no replay can reproduce. |
Move the body with node.tickX/tickY/tickZ or velocity, never node.x = node.x + d |
Same reason, from the other side: that teleports the body onto its visual position — the model slides and the hurtbox doesn't. |
Use net.random(), never an unseeded rng() |
An unseeded roll comes from the clock. Two peers draw different numbers and the match quietly forks in two. |
Put projectiles in rollback state, not in spawn() |
A spawned prefab isn't part of the rollback state and one-shot spawns are suppressed during a replay. A fireball that must exist on both machines is data in your controller's snapshot, rendered by the controller. |
Turn on script owns contacts (Rigidbody ▸ Netcode (rollback); pushboxOnly in Lua) |
It turns off gravity, floors and walls for the body. The contact solver is the part least likely to agree bit-for-bit between two machines. With it on, the body integrates its velocity and nothing else — your script owns gravity, the floor and pushout, which is how the genre works anyway. |
The API
| Call | What it does |
|---|---|
net.random() / net.random(n) / net.random(a, b) |
deterministic RNG: [0,1), 1..n, a..b. Drawn from (match seed, tick, draw index), so every peer rolls the same numbers and a re-simulated tick rolls them again |
net.replaying() |
true while the engine is re-simulating. For cosmetics it can't see (a material poke, a UI label) — never branch simulation on it, that IS a desync |
net.rollbackDepth() / net.rollbackMax() / net.rollbackAverage() |
ticks re-simulated by the last correction · the worst so far · the mean per correction |
net.mispredictRate() |
0..1 — the fraction of ticks that had to guess |
net.inputDelay() |
the session's fixed input delay, in ticks |
net.stalled() |
the sim is waiting for input rather than guessing further — show your own "connection trouble" banner off this |
net.on("desync", fn) |
the peers' checksums disagreed. From here the two machines are playing different matches; end the set honestly rather than play it out |
The engine suppresses one-shot side effects during a re-simulation —
spawnEffect, audio.play, prefab spawn()/destroy(), net.rpc, console
output. The honest consequence: a correction can eat a cosmetic (the spark
that only exists on the corrected timeline never fires) or orphan one (the
spark fired for a hit that turned out not to happen). Every rollback game lives
with this; at the depth cap it reads as network crackle, not wrongness.
What the engine does not promise
- Same build, same platform: determinism is guaranteed for the profile above.
- Across platforms (x64 ↔ Apple Silicon): expected, not proven. IEEE
add/mul/div/sqrt are bit-exact everywhere; the risk is
sin/cos/pow/atan2, which may differ in the last bit between platform math libraries. A fighter's simulation path typically avoids them. - Bodies in solver-resolved contact: out of scope — that is what
pushboxOnlyis for.
Which is exactly why checksums are mandatory and always on: every 30
confirmed ticks each peer hashes its state and the host compares. A mismatch is
loud — Console error naming the tick, red in the 🌐 panel, and
net.on("desync"). A rollback implementation without them doesn't fail
loudly; it plays a subtly different match on each screen until someone notices
the health bars disagree.
Frame-stepping backwards
While paused, ⏮ Back (Shift+F3) puts the simulation back one gameplay tick. A simulation isn't invertible, so this reads the rollback state ring rather than re-deriving anything: it needs a rollback session running and reaches back about a fifth of a second. Stepping back and forward again lands on exactly the state that was there — it's a scrubber, not an undo.
16c. Voice: proximity chat
A remote player's voice is an ordinary spatial sound. It is attenuated by distance, panned by direction, and routed through a mixer track like every other sound in the game — which is what makes the rest of this short.
| Call | What it does |
|---|---|
voice.devices() / voice.device() |
input device names · the one that's open. Empty list = no microphone, which is normal |
voice.setDevice(name) |
open it (nil = the system default) |
voice.setTransmit(on) |
open/close the mic — push-to-talk is your call, this is where it lands |
voice.level() |
mic level 0..1 for a settings meter. Live whether or not you're transmitting |
voice.sidetone(on) |
hear yourself. Off by default |
voice.attach(peer, node, opts) |
that player's voice comes out of that node and follows it |
voice.source(peer) |
a handle like audio.play returns — :setTrack, :setVolume, :setPosition(node)… |
voice.speaking(peer) / voice.mute(peer, on) |
HUD indicator · a local mute |
voice.setForward(peer, {peers}) |
SERVER: who may hear that speaker (nil = everyone) |
The whole thing, in a game
function start(node)
voice.setDevice(nil) -- the system default mic
net.on("playerJoined", function(peer)
voice.attach(peer, avatarFor(peer), {
mode = "Spatial", falloff = "Inverse",
minDistance = 2, maxDistance = 22,
track = "Voice",
})
end)
end
function update(node)
voice.setTransmit(input.action("Talk")) -- push-to-talk
end
Making a monster out of a voice
There is no per-effect voice API, and there does not need to be one. Author the
tracks in project.ron — Voice (clean), Voice Monster (PitchShift −6 st,
Distortion, Chorus, a low shelf, Reverb), Voice Dead — and move a speaker
between them:
voice.source(peer):setTrack(isMonster and "Voice Monster" or "Voice")
Putting a voice in the room
A voice can be partly in a reverb and partly behind a wall at once. sends
taps a speaker into other mixer tracks beside its own track, each at its own
level. It's one stream heard dry and wet together, so a voice can be 40% in the
room's reverb and 60% in the hall's, and moving between rooms is a glide
rather than a cut. lowpass muffles it through a wall:
voice.attach(peer, avatar, { track = "Voice", sends = { ["Verb Small"] = 0.4 } })
-- each frame, from where the listener is and what's between them
local src = voice.source(peer)
src:setSend("Verb Small", roomAmount)
src:setSend("Verb Large", hallAmount)
src:setLowpass(wallBetween and 850 or 0)
Sends are taken after the voice's own volume and distance, so a far speaker
sends less. The same sends and lowpass work on audio.play, with
:setSend and :setLowpass on the handle it returns.
Loudness. Each machine levels its own microphone before sending it, so a
quiet mic is raised toward a steady speaking level (up to +24 dB) and a loud
one is lowered. Silence between sentences is never raised, so room hiss stays
hiss. voice.setAutoGain(false) turns it off, and voice.setInputGain(db) is
a fixed gain for a settings slider. A microphone plugged into one input of a
stereo interface is taken on its own, not averaged with the silent input.
A mode or falloff the engine doesn't know now raises on voice.attach and
voice.source(peer), naming what it takes, as it does on audio.play.
Who can hear you is the server's decision
This is the part worth reading twice.
-- on the server, every tick or so
for _, speaker in ipairs(net.peers()) do
voice.setForward(speaker, whoIsWithinEarshotOf(speaker))
end
voice.setForward(deadPeer, deadPeers) -- the dead talk to the dead
A peer that is not on that list is never sent the audio at all. That matters
more than it sounds: turning a stream down on the receiving client is a volume
slider a modified client turns back up, and in a game where hearing someone is
knowing where they are, that is the difference between a mode that can ship
competitively and one that cannot. Range gating belongs on the server, and
voice.setForward is where it goes.
What it does when things go wrong
- No microphone.
voice.devices()is empty, every other call is a no-op, and the game runs. Most machines are this machine. - A lost packet is a gap Opus conceals — never a stall, and never the end of the stream. A voice that ended could not resume, and the player would go silent mid-sentence for the rest of the match.
- A late packet widens the jitter buffer, up to 60 ms; a link that settles earns the latency back. The 🌐 panel shows the cushion per speaker.
scene.loaddoes not cut anyone off. The capture and the streams live with the session, not the scene — re-attach in the new scene and the stream never restarted.- A dedicated server captures nothing and plays nothing. It forwards, which is all it should do with no audio device and no listener.
Trying it without a second machine
Voice normally needs two machines, two people and a microphone to test at all, which is what makes it the feature most likely to ship broken. The 🌐 panel has 🎤 test voice from a WAV…: it plays an audio file in as though a remote player were speaking it, through the real forwarding rules, the real jitter buffer and the real spatial voice. If it comes out of the right avatar at the right volume, the routing is right.