Multiplayer¶
Preview and test networked scenes.
In plain words: the editor is the drawing board. When you hit Play Multiplayer, it starts a referee (the server) and a few players (the clients) as separate windows, all looking at the same drawing. You close them all with Stop.
For what happens inside those processes, read the Engine → Networking page. This page is only about using the feature.
1. Where it lives¶
Two places in the editor:
| Control | Location | What it does |
|---|---|---|
| Play Multiplayer (people icon) / Stop | main toolbar, right of the single-player Play button | launches / kills the whole playtest |
| Client Count | Settings panel → Multiplayer Playtest | how many client windows to launch (1–5) |
| Headless Server | Settings panel → Multiplayer Playtest | run the server with no window |
The toolbar buttons, left to right:
- Play launches a single, single-player process.
- Play Multiplayer launches
1 server + N clients. - Stop (replaces the play buttons while games run) kills every launched process.
2. The settings¶
Settings panel → Multiplayer Playtest:
| Setting | Range | Meaning |
|---|---|---|
| Client Count | 1–5 |
how many client processes to launch; clamped to PlayerSlotState::kMaxPlayers |
| Headless Server | on/off | adds --headless to the server, so it runs without a window |
The count is capped at 5 because the engine assigns one distinct color slot per connected
player (0–4). The transport also enforces it, so a sixth client is simply refused. Player N
gets color slot N-1; in the shipped R-Type project that maps to sprite block 5N-4 .. 5N.
The server address and port are not exposed here yet - a launched client always connects to
127.0.0.1:3000 (see §8).
This will be modified to allow flexibility
3. The Network component in the Inspector¶
Select an entity (or select a .prefab in the Assets panel) and the Inspector shows a Network
section when the entity has a NetworkComponent. Add it from the component menu like any other.
| Field | Control | Meaning |
|---|---|---|
| Authority | dropdown: Server / Client / Shared |
who simulates the entity (see below) |
| Sync Transform | checkbox | replicate Position + Rotation every tick |
| Sync Sprite | checkbox | replicate the sprite (path, offset, scale, flips) |
| Prefab Path | text field + drag-and-drop target | prefab to instantiate on the other machine when spawning |
| Network ID | read-only label | engine-assigned; shows (assigned on connect) until then |
You can type a prefab path or drag a .prefab from the Assets panel straight onto the field.
Choosing an authority¶
| Authority | Means | Use it for |
|---|---|---|
| Server | the server simulates it; clients follow | enemies, pickups, moving platforms, hazards |
| Client | one client sends input; the server simulates it | each connected player |
| Shared | not synced at all; built identically from the scene | walls, floors, background |
A
Cliententity in the scene is a template: the server destroys it on startup and spawns one copy per connected player from Prefab Path. So you author the player once, set the prefab path, and every connection gets its own instance - with its own color slot.
4. What the button actually launches¶
When you click Play Multiplayer with a scene open and a client count of 3, the editor spawns:
Game --server <scene> [--headless] # the server
Game --client <scene> # client 1
Game --client <scene> # client 2
Game --client <scene> # client 3
Each process is started in its own process group so Stop can kill the whole tree without
touching the editor. On Windows the same happens through CreateProcessA / TerminateProcess.
The processes themselves parse these flags in MultiplayerPlugin::Init (see the
engine page); --headless additionally
disables the window and rendering.
5. Quick start: a multiplayer scene in five minutes¶
This is the minimal recipe. It assumes one player prefab and one enemy.
1. Author the player as a prefab.
- Build the entity in the scene:
Position,SpriteComponent, a collider, and whatever script drives it (player.lua). - Drag it into the Assets panel to write
Player.prefab.
2. Make it networked.
- Select
Player.prefab(or the entity) and add a Network component. - Set Authority → Client, tick Sync Transform and Sync Sprite.
- Set Prefab Path →
Prefabs/Player.prefab(drag the prefab in).
3. Place the template in the scene.
- Keep one instance of the player in the scene. It is only a template; the server will replace it with one live player per connection. Don't script it to depend on being "The Player".
4. Author the enemy.
- Add a Network component to the enemy (or its prefab) with Authority → Server, Sync Transform on. Because it exists in the scene on every machine, it needs no prefab path.
5. Build, then play.
- Click 🔨 Build (Play Multiplayer does not build for you - see §8).
- Set Client Count to
2in Settings. - Click 👥 Play Multiplayer. Two client windows appear and connect; the tail of the server log shows the clients connecting.
- Click ⏹ Stop when done.
Your game code stays ordinary: player.lua reads Net.input(entity), moves, shoots; the engine
handles the rest. In single-player the same script falls back to the local controller
(Net.isNetworked() is false).
6. How flexible is it? Examples¶
The mechanism is genre-agnostic: you are only choosing, per entity, who owns it and what gets synchronised. Below are sketches for very different kinds of games, all using the same editor controls.
a. Side-scrolling shooter (the shipped R-Type example)¶
| Entity | Authority | Sync | Notes |
|---|---|---|---|
| Player | Client |
transform + sprite | one per connection, distinct color slot |
| Enemy | Server |
transform (+ sprite if it animates) | exists in the scene on both sides |
| Bullet / explosion | Server |
transform + sprite | created at runtime, marked with Net.replicate |
| Walls / background | Shared |
- | identical everywhere, zero bandwidth |
-- playerGun.lua: fire and let the engine replicate the bullet
local bullet = Entity.create()
Transform.setPosition(bullet, x, y)
Net.replicate(bullet, "Server", true, true)
b. Top-down arena brawler / party game¶
Same shape as (a) but with free 2D movement and pickups:
| Entity | Authority | Sync | Notes |
|---|---|---|---|
| Player | Client |
transform + sprite | each client controls its own |
| Pickup | Server |
transform + sprite | server decides who grabs it |
| Arena tiles | Shared |
- | level geometry |
Because the server owns the pickup, two players reaching it on the same frame resolves deterministically - no client gets to "win" by touching first locally.
c. Co-op action / physics playground¶
| Entity | Authority | Sync | Notes |
|---|---|---|---|
| Player | Client |
transform | |
| Crate / ball / lever | Server |
transform | shared physics the server resolves |
| Door | Server |
sprite | opened by an RPC when the lever is pulled |
Use Net.rpc for discrete interactions ("open the door"), and Server authority for anything both
players must agree on.
d. Turn-based board / card game¶
Turn-based games barely need state streaming at all - they need events:
- Add a
Networkcomponent with Authority → Client to each player's hand/avatar, or skip replication entirely and use RPCs. - Turn actions are
Net.rpc(entity, ...)calls (playCard,endTurn), and the server validates them and broadcasts the resulting state. - Because a snapshot is sent every tick regardless, you can also just mark the board
Serverand let the state replicate; no interpolation issues when nothing moves.
e. Racing / sports with several local players¶
| Entity | Authority | Sync |
|---|---|---|
| Car / athlete | Client |
transform |
| Track decorations | Shared |
- |
| Checkpoints / finish line | Server |
- (triggers, resolved server-side) |
The server decides finishing order and lap counts; clients only ever report steering and throttle.
f. Single-player still works¶
A scene with Network components still runs if launched without any network flag (the ordinary
▶ Play button, or Game with no arguments). No host is created and Net.isNetworked() is
false, so scripts take their single-player branch. You do not maintain two versions of a game.
Summary table¶
| Genre | Who is Client-authority |
Who is Server-authority |
Discrete events |
|---|---|---|---|
| Shooter | players | enemies, bullets | shoot |
| Arena brawler | players | pickups, hazards | grab |
| Co-op action | players | physics objects | interact |
| Turn-based | (optional) hands | board, deck | play/end turn |
| Racing | vehicles | checkpoints | lap |
| Solitaire-like | - | - | all RPC |
7. Recipes for different genres¶
Deeper than the sketches above: for each genre, what to tag in the Inspector and the Lua pattern that makes it work. Every recipe is editor + Lua only - no C++.
Two facts shape all of them. Only
Position(+Rotation.angle) and the sprite replicate declaratively; anything else (health, money, turn, cards) travels as an RPC, or is encoded in a sprite path. And a Lua script cannot instantiate a.prefab- it builds bare entities withEntity.create()+Component.add*. The workaround: build on the server, then hand aprefabPathtoNet.replicate(...)so clients still instantiate the authored prefab (see the engine TODO).
Two blocks reused everywhere:
-- true in single-player and on the server; false on a client
local function shouldSimulate()
if not Net.isNetworked() then return true end
return Net.isServer()
end
-- Server: create a runtime object; clients instantiate the authored prefab
local e = Entity.create()
Transform.setPosition(e, x, y)
Component.addSprite(e, "assets/thing.png", 0, 0, 2, 2)
Component.addBoxCollider(e, 24, 24)
Net.replicate(e, "Server", true, true, "Prefabs/Thing.prefab")
7.1 Pong / air-hockey - real-time, almost no state¶
| Entity | Authority | Sync | Notes |
|---|---|---|---|
Ball (scene) |
Server | Transform | the server moves it in Lua |
Paddle prefab |
Client | Transform | one per connection (slots 0, 1) |
| Goal / wall | Shared | - | identical everywhere |
Paddle (server side):
function Paddle:onUpdate(e, dt)
if not shouldSimulate(e) then return end
local i = Net.input(e) or { UP = Controller.isActionActive("UP"),
DOWN = Controller.isActionActive("DOWN") }
local dy = (i.DOWN and 1 or 0) - (i.UP and 1 or 0)
Transform.translate(e, 0, dy * SPEED * dt)
end
The ball needs no networking code at all - it is Server authority, so syncTransform carries
it to both clients. Score is the only extra state: Net.broadcast("score", { l = .., r = .. }) on the
server, Net.on("score", ...) on clients.
Why it fits: the simplest proof that a moving object "just appears" on every screen.
7.2 Top-down arena shooter - bullets, health, respawn¶
| Entity | Authority | Sync | Notes |
|---|---|---|---|
Player prefab |
Client | Transform + Sprite | color from slot |
Bullet (runtime) |
Server | Transform + Sprite | server builds it; clients get the prefab |
| Wall | Shared | - |
Firing uses the Net.replicate(..., prefabPath) pattern. Health is not synced, so the server
announces it:
-- server, on hit
Net.rpc(victim, "hurt", { hp = newHp })
-- client handler
Net.onEntityRpc("hurt", function(e, data) self.hp = data.hp; self.flash = 0.2 end)
player_input carries the whole set of active action names marked networked, so extra actions
(dash / reload) work on the server too - no extra RPC needed as long as the keybind is networked:
Respawn should reuse the player entity rather than destroy and re-create it: the server binds a
player's identity and color slot when it spawns them, and a plain Entity.destroy from a script does
not release that binding or the slot (only a disconnect does, or an explicit Net.untrack). So
reset in place:
Transform.setPosition(e, spawnX, spawnY)
Component.setColliderEnabled(e, false)
Component.setOpacity(e, 0)
Why it fits: a twitch shooter with health, respawn and score, from just Net.replicate +
Net.rpc.
7.3 Co-op platformer - a server-driven world¶
| Entity | Authority | Sync | Notes |
|---|---|---|---|
Player prefab |
Client | Transform + Sprite | |
Platform prefab |
Server | Transform | the server animates it in Lua |
Lever / Door |
Server | Sprite | sprite swaps on/off; the door slides via a broadcast |
| Terrain | Shared | - |
Continuous state - the server animates, clients just watch:
function Platform:onUpdate(e, dt)
if not shouldSimulate(e) then return end
self.t = (self.t or 0) + dt
Transform.setPosition(e, self.x0, self.y0 + math.sin(self.t) * 60)
end
Discrete event - a lever is an RPC, not a stream:
-- lever, server
Component.setSpritePath(e, self.on and ON_IMG or OFF_IMG)
Net.broadcast("door", { open = self.on })
Why it fits: the two flavours side by side - replicate a transform (continuous) vs. announce an event (discrete).
7.4 Turn-based card / board game - RPCs and sprites¶
| Entity | Authority | Sync | Notes |
|---|---|---|---|
Seat prefab (invisible) |
Client | none | gives each client an identity + slot |
CardSlot × N |
Server | Sprite | the sprite path is the card |
| HUD | none | - | local text per client |
Encode categorical state in the sprite path and let Sync Sprite do the work:
-- server, when a card is revealed
Component.setSpritePath(cardSlot, "cards/ace_spades.png") -- clients update automatically
Moves are RPCs, validated on the server (which owns the rules in a script's self):
Net.rpc(seat, "playCard", { slot = 3 }) -- client intent
Net.onEntityRpc("playCard", function(seat, data) -- server authority
if self.turnSlot ~= Net.playerSlot(seat) then return end
-- ... validate the card ...
self.turnSlot = (self.turnSlot + 1) % Net.maxPlayers()
Net.broadcast("turn", { slot = self.turnSlot })
end)
Why it fits: nothing moves, so there is no tick, no interpolation and no cheating surface; the server is simply the only rule-keeper.
7.5 Co-op horde / tower-defense - many server units + an economy¶
| Entity | Authority | Sync | Notes |
|---|---|---|---|
Player prefab |
Client | Transform + Sprite | |
Director (empty + script) |
none | - | the server spawns waves |
Zombie (runtime) |
Server | Transform + Sprite | Net.replicate(..., "Prefabs/Zombie.prefab") |
Tower (runtime) |
Server | Transform | placed via RPC |
| Path / terrain | Shared | - |
The server director spawns a wave:
for i = 1, waveSize do
local z = Entity.create()
Transform.setPosition(z, spawnX, spawnY + i * 40)
Component.addSprite(z, "assets/zombie.png", 0, 0, 3, 3)
Component.addBoxCollider(z, 40, 40)
Component.addScript(z, "scripts/zombie.lua")
Net.replicate(z, "Server", true, true, "Prefabs/Zombie.prefab")
end
Building goes client → server → everyone:
Net.rpc(seat, "build", { x = mx, y = my, kind = "gun" }) -- client
Net.onEntityRpc("build", function(seat, d) -- server validates + spends
if self.money < COST then return end
self.money = self.money - COST
local t = Entity.create()
-- ... place + dress the tower ...
Net.replicate(t, "Server", true, false, "Prefabs/Tower.prefab")
Net.broadcast("money", { v = self.money })
end)
Why it fits: 5 humans, dozens of replicated AI and a shared economy, from two verbs
(Net.replicate, Net.broadcast).
7.6 The palette, at a glance¶
| You need… | Editor / Lua |
|---|---|
| A thing everyone sees move | NetworkComponent Server + Sync Transform |
| One entity per player | NetworkComponent Client + Prefab Path (scene template) |
| Something that never changes | Shared |
| A bullet / effect made at runtime | Entity.create + Net.replicate(..., "Prefabs/…") |
| Health / score / money / turn | Net.broadcast + Net.on |
| An action beyond WASD + fire | Net.rpc(localPlayer, "action", data) → Net.onEntityRpc |
| Categorical state without a packet | encode it in the sprite path + Sync Sprite |
| A per-player identity / color | Net.playerSlot / Net.localEntity |
7.7 Several scenes - lobby → level → results¶
The server decides which scene everyone plays. Call Scene.load on the server only; every client
follows automatically.
-- lobby.lua (on any entity of Lobby.scene)
function Script:onUpdate(entity, dt)
if Net.isServer() and everyoneReady() then
Scene.load("levels/Level1.scene")
end
end
What happens:
- At the end of that frame the server switches scenes and sends a reliable
scene_loadmessage to every client. - Each client loads the same scene. Packets that arrive after
scene_loadwait until the client has switched, so nothing from the new scene lands in the old one. - The server respawns every connected player in the new scene with the same network id and
color slot (
Net.playerSlot,Net.localEntitykeep working). - A client that joins later, or that was started on another scene, is moved to the server's current scene when it connects.
Rules of thumb:
Scene.loadon a client is ignored (scripts often run on every machine).- Every machine needs the scene files: they ship in the same project folder.
- Data that must survive a switch (scores, who is ready) lives in Lua globals on the server,
then is sent again with
Net.broadcastonce the new scene starts. Handlers registered withNet.onstay registered; register them again in the new scene'sonCreateif they useself. - Reloading the current scene (
Scene.load(Scene.current())) works too: everyone reloads it.
8. Missing steps (not available yet)¶
The multiplayer path is complete end to end for local playtests, but several conveniences are still missing. This list is the honest "what you cannot do yet".
Playtest workflow¶
- Play Multiplayer does not build. If the game executable is missing or stale, the button does nothing (the processes fail to launch silently). Click 🔨 Build first.
- Port and host are not in the UI. Clients always connect to
127.0.0.1:3000; changing that means editingmain.cppor launching from a terminal with--host/--port. - No per-client configuration. Every client gets identical arguments.
Netcode & gameplay feel¶
- No client-side prediction. Input has at least one tick of latency, even on localhost. This is the biggest "feel" gap for fast games.
- No interpolation. Remote entities move at the snapshot rate and can look steppy.
- No lobby, matchmaking, server browser, password or authentication. You connect to a host and port; the server trusts the input it receives.
- No reconnect / session resume. A dropped client is simply gone (its color slot is freed).
- Hard cap of 5 players, baked into both the slot resource and the transport (a TODO to make it configurable).
Tooling¶
- No in-editor play mode and no script execution in the editor (by design - scripts only run in the launched game).
- No network inspector. There is no panel for peers, bandwidth, packet log or per-entity sync rates.
- No simulated latency / jitter / packet loss for testing poor connections.
- No hot reload. Code changes require a rebuild before the playtest reflects them.
Design constraints (things to design around)¶
These are not bugs - they are the current shape of the system, and a game has to fit them.
- One player prefab per scene. The engine keeps a single player prefab path (the first
Client-authority template's Prefab Path in the current scene) and destroys everyClienttemplate when the scene loads, so every connection spawns that same prefab. A scene without a template keeps the previous scene's prefab. Two different player classes in one scene are not expressible today - give everyone the same prefab and vary look/behaviour per color slot (Net.playerSlot). - No private messages from Lua.
Net.broadcastreaches everyone, and Lua cannot turn a player entity into its peer toNet.sendto a single client. Anything a player must not see (a hidden hand, fog of war) cannot be withheld through the API - only by not sending it. - The input packet is a fixed 5-bit set (
up/down/left/right/shoot). Any other button must go through an RPC (see §7.2); a sixth input bit is an engine change. - RPC direction is fixed. A client's RPC goes to the server only; the server's RPC goes to all clients.
- Two client windows on one machine share one keyboard. Only the focused window receives key
events, so you cannot drive two local clients at once by hand - use two machines, or the automated
input hook (
RTYPE_TEST_INPUT, see Engine → Diagnostics).
Engine capabilities (TODOs)¶
- No prefab instantiation from Lua. Lua can only
Entity.create()a bare entity; it cannot instantiate a.prefab. Players are spawned by the engine, not by Lua (see Engine → §14). Workaround: build the object on the server, then pass aprefabPathtoNet.replicateso clients instantiate the authored prefab. - The 5-player cap is a compile-time constant.
PlayerSlotState::kMaxPlayersis5and the transport refuses a 6th connection. Supporting a 6th player needs code changes (raise the constant and provide enough color/sprite blocks); making it a per-project setting is a TODO.
9. Troubleshooting¶
| Symptom | Likely cause / fix |
|---|---|
| Clicking Play Multiplayer does nothing | The game executable is missing or stale - click 🔨 Build first. |
| Client windows open but show a frozen scene | The server failed to bind :3000 (already in use) or the client could not connect. Check the console logs. |
| Only one player appears | The scene entity is not Client authority, or its Prefab Path is empty (the server has nothing to spawn). |
| All players look identical | The color slot is not being read in your player script - use Net.playerSlot(entity) (see player.lua). |
| Changes to scripts don't show up | Rebuild the game, then relaunch; there is no hot reload. |
| Need to see traffic | Launch a process with RTYPE_NET_DEBUG=1 and watch its log for per-second input/position lines. |
| A process lingers after Stop | Stop kills each process group; if a child was launched outside it, kill it manually. |
For the full protocol, packet reference and the Net API, see
Engine → Networking & Multiplayer.