Networked Multiplayer¶
Build a networked game twice: first two consoles on a single link cable, then many consoles through a server. The engine API is identical; only the setup differs.
By the end you will have players who can see each other move, and a scoring rule that every console agrees on.
Nothing here is specific to 2D or 3D. The engine replicates an actor transform, so the same six calls work for a first-person shooter, a top-down tilemap game, or a racing game - the only thing that changes is how your game already moves the actor.
What you need
For the cable version: two consoles (or two PCSX-Redux instances) and a link cable. Nothing else.
For the server version: a checkout of psnl-server, Python 3.12, and a USB-to-serial adaptor if you are using real hardware.
Part 1 - Two consoles, one cable¶
1.1 Connect¶
The whole handshake is one call. Put it in onSceneCreationStart, so the link is
coming up while the rest of the scene loads:
Call it with no arguments. The baud comes from the engine's own constant, and the receive strategy resolves itself: interrupt-driven on real hardware, polled under an emulator. Passing a literal baud here is how the two ends end up at different rates, and a baud mismatch looks exactly like an unplugged cable.
1.2 Show the player what is happening¶
A serial link that does not come up is otherwise completely silent, so spend five lines on this now rather than debugging blind later:
local STATE = {
[0] = "DISCONNECTED",
[1] = "CONNECTING...",
[2] = "CONNECTED",
[3] = "VERSION MISMATCH - REBUILD THE DISC",
[4] = "SCENE MISMATCH - RE-EXPORT THE SCENE",
}
function onUpdate(self, dt)
UI.SetText(statusLabel, STATE[Net.State()] or "?")
end
States 3 and 4 are terminal: the console latches them and stops talking entirely. A screen that only knows how to say "connecting" will say it forever.
1.3 Agree on the scene¶
Two consoles will only share a scene if they agree on its identity. On the Scene Exporter, set Scene Network Id to a stable string:
Leave it blank and the engine derives a hash from the scene's contents. That hash changes whenever you add an object, so two discs built a day apart refuse to talk and report state 4. Pick a name and never change it once players have the disc.
1.4 Give each console an avatar¶
An avatar is any actor: a character in a 3D scene, a sprite on a tilemap, a ship, a cursor. The engine replicates its transform, so nothing here assumes a dimension or a movement style.
Author one actor per player in the scene, named player_1 and player_2. Then
tell the engine which is yours:
local MAX_PLAYERS = 2
local avatars = {}
local bound = false
function onSceneCreationEnd()
for slot = 1, MAX_PLAYERS do
avatars[slot] = Actor.Find("player_" .. slot)
end
end
local function bindAvatars()
local mySlot = Net.LocalSlot()
for slot = 1, MAX_PLAYERS do
if slot == mySlot then
Net.SetLocalAvatar(avatars[slot]) -- we move this one
else
Net.SetRemoteAvatar(slot, avatars[slot]) -- the engine moves this one
end
end
bound = true
end
function onUpdate(self, dt)
if not bound and Net.IsConnected() then bindAvatars() end
end
That is the whole of avatar replication. Move your own avatar however you like and the engine sends it; incoming transforms are applied to the actors you mapped, and interpolated between snapshots so remote players glide rather than teleport.
Do not read LocalSlot before you are connected
The slot is handed out by the handshake, and Net.Connect() returns long
before that finishes. Called too early, Net.LocalSlot() returns 255, no
branch of the loop matches, and every avatar including your own is registered
as remote. Nothing errors; your character simply never moves. Bind on the frame
Net.IsConnected() first turns true, as above.
Slots start at 1, not 0
Slot 0 is the authority. On a cable that is one of the two consoles; with a server it is the server, which has no avatar. Either way, player avatars are slots 1 upward.
1.5 Move¶
There is no networked movement API, and that is the point. Whatever already moves an actor in your game keeps moving it; the engine reads the local avatar's transform each tick and sends it. Pick whichever of these your game already does - none of it is networking code:
The shortest path in a 3D scene. Hand the pad to the avatar and the engine's own movement, collision, nav mesh and camera all apply:
local function bindAvatars()
-- ... the loop from 1.4 ...
local me = avatars[Net.LocalSlot()]
Input.BindToActor(1, me) -- pad 1 now drives this actor
Camera.SetFollowTarget(me) -- and the camera follows it
bound = true
end
No movement code at all. The player walks the scene exactly as in a single-player game - collision, nav mesh, camera and all - and the far console sees it.
When you drive the transform yourself, keep doing that. The engine does not care where the numbers came from:
local STEP = FixedPoint.new(1) / 8 -- world units per frame
function onUpdate(self, dt)
if not Net.IsConnected() then return end
local me = avatars[Net.LocalSlot()]
if not me then return end
local move = Vec3.new(0, 0, 0)
if Input.IsHeldPlayer1(Input.UP) then move.z = STEP end
if Input.IsHeldPlayer1(Input.DOWN) then move.z = -STEP end
Actor.SetPosition(me, Vec3.add(Actor.GetPosition(me), move))
end
Rotation replicates too, so Actor.SetRotation(me, Vec3.new(0, heading, 0))
turns the remote copy as well.
You own collision on this path
Writing a position directly bypasses the nav mesh. The engine's collision
pass runs before onUpdate, so a position you write here is not checked
until the next frame. The engine locomotion tab avoids this entirely.
A tilemap scene moves actors against the grid:
local SPEED = 2
function onUpdate(self, dt)
if not Net.IsConnected() then return end
local me = avatars[Net.LocalSlot()]
if not me then return end
local dx, dz = 0, 0
if Input.IsHeldPlayer1(Input.LEFT) then dx = -SPEED end
if Input.IsHeldPlayer1(Input.RIGHT) then dx = SPEED end
if Input.IsHeldPlayer1(Input.UP) then dz = -SPEED end
if Input.IsHeldPlayer1(Input.DOWN) then dz = SPEED end
Tile.MoveActor(me, dx, dz)
end
Build any of these onto two discs, connect the cable, and the two players can see each other move. That is a complete networked game in about thirty lines, and the only part that mentions the network is the six calls in 1.1 and 1.4.
Only move the avatar that is yours
The remote avatars are driven by the engine every time a snapshot lands. Move
one from Lua and the two fight: the actor stutters between where you put it and
where the far console says it is. Guard on Net.LocalSlot(), as every example
above does.
1.6 Send a message¶
Positions are continuous state and the engine handles them. Discrete events - a door opening, a point scored - are yours, and they go on the reliable channel:
local MSG_SCORED = 1
-- sending
Net.SendData(string.char(MSG_SCORED, Net.LocalSlot()))
-- receiving, on every other console
function onNetData(payload)
local op = string.byte(payload, 1)
if op == MSG_SCORED then
local slot = string.byte(payload, 2)
scores[slot] = (scores[slot] or 0) + 1
end
end
The payload is bytes, not a string of text: build it with string.char and
read it with string.byte. The engine never looks inside it, so the format is
entirely yours. A leading opcode byte costs nothing and gives you something to
dispatch on.
Always check the return value
Net.SendData returns false when the reliable queue is full. Ignore it and
the message is gone with no error anywhere - and the symptom appears much later
as the two consoles disagreeing about what happened.
local outbox = {}
local function send(payload)
if #outbox == 0 and Net.SendData(payload) then return true end
outbox[#outbox + 1] = payload -- keep order: a vote must not
return false -- overtake the meeting it belongs to
end
local function flush()
while #outbox > 0 do
if not Net.SendData(outbox[1]) then return end
table.remove(outbox, 1)
end
end
Call flush() once per frame.
1.7 Who decides?¶
With two consoles there is no referee, so one of them has to be. The engine elects slot 0 during the handshake:
This works, and it is the right answer for a co-operative game. It is the wrong answer for a competitive one, because the host's console is deciding the rules and a modified host decides them differently. That is what Part 2 is for.
Part 2 - Many consoles, one server¶
Everything from Part 1 still applies. What changes: there can be up to ten players, and the rules live somewhere neither player controls.
2.1 Run the server¶
git clone https://github.com/psxsplash/psnl_server
cd psnl_server
pip install -e ".[dev]"
psnl-server
With no game plug-in it is a pure relay: it fans out positions and passes your messages between consoles. That alone is enough for Part 1's game with ten players instead of two.
Or in a container:
2.2 Connect a console¶
PCSX-Redux needs no extra software. Open the SIO1 settings, choose Client mode and Protobuf, and point it at your server's IP on port 6700.
Redux's client cannot use Raw
It throws on Raw mode, so the protobuf port is the only one that works from the emulator. Its host field also accepts digits and dots only, so type a bare IP address rather than a hostname.
Real hardware goes through the bridge, which pumps bytes between the serial cable and the server:
Pick the COM port and enter the server address. It connects to port 6699, the raw port, because a serial cable carries raw bytes with no protobuf involved.
PlayStation --serial--> psnl-bridge --raw------> :6699 -+
+-> psnl-server
PCSX-Redux -------------protobuf---------------> :6700 -+
Both kinds of player share the same rooms. The wire below the adapter is identical.
2.3 Ten players instead of two¶
Author ten actors, player_1 through player_10, and change one constant:
bindAvatars from 1.4 is unchanged, and so is your movement code. Slot 0 is now
the server, which has no avatar, which is why players are slots 1..10 and the
engine's slot space is eleven.
2.4 Move the rules onto the server¶
This is the part that is worth the server. Write a plug-in:
# games/mygame/game.py
from psnl_server.game import Game
SCORED = 1 # console -> server
TOTALS = 0x80 # server -> console
class MyGame(Game):
name = "mygame"
def __init__(self) -> None:
self._scores: dict[int, int] = {}
def on_app_data(self, session, payload: bytes) -> bool:
if not payload or payload[0] != SCORED:
return False # not ours; the relay passes it on untouched
self._scores[session.slot] = self._scores.get(session.slot, 0) + 1
self._broadcast(session.room)
return True # handled
def _broadcast(self, room) -> None:
body = bytes([TOTALS, len(self._scores)])
for slot, score in sorted(self._scores.items()):
body += bytes([slot, min(score, 255)])
for member in room.members.values():
member.session.send_app_data(body)
Now the console asks and draws; it never decides. Pressing the score button sends a request and waits. A modified client can send whatever it likes and the server still counts what it counts.
Serialise once, send many
_broadcast builds the payload once and hands the same bytes to every member.
At ten players on a 5.76 KB/s link that is not a micro-optimisation.
2.5 Test it without hardware¶
FakeConsole speaks the byte-exact wire the engine emits:
from psnl_server.testing import FakeConsole
console = FakeConsole()
await console.connect("127.0.0.1", port)
await console.handshake()
console.send_app_data(bytes([SCORED]))
reply = await console.wait_for_app_data(TOTALS)
assert reply[2] == console.slot
A ten-player room can be proven with nothing plugged in. Do this before you burn a disc.
2.6 Handing over between scenes¶
A lobby that starts a match needs the session to survive the scene load:
Without it, Scene.Load tears the session down and every console rejoins as a
brand new player with a new slot - which, in a room that is already full of the
very players trying to move, fails.
Designing for the wire¶
Everything below is invisible on an emulator, which models bytes rather than bit timing and has no cable at all. It matters on hardware.
The link carries 5.76 KB/s at 57600, and the console can send about 1.4 KB/s. That asymmetry is deliberate: the transmit path is bounded so it cannot busy-wait away the frame.
The receive FIFO is 8 bytes and overruns about 1.4ms into a burst, which is why receive is interrupt-driven on real hardware.
So:
- Send on a change, not on a timer. The engine already sends positions on a clock; almost nothing else needs to be.
- Send bytes, not text. A four-byte message and a forty-byte one differ by seven milliseconds of wire.
- Do not send what the receiver can derive. Send the event, not the consequences.
- Let the server pace itself. It measures the link and throttles position updates before it throttles anything that matters.
When it does not work¶
Draw Net.Stats() somewhere reachable. The connecting screen is the natural home,
since that is exactly where a link that never comes up leaves you.
| Reading | Means |
|---|---|
bytesRx 0, bytesTx rising |
nothing is coming back: wrong port, wrong address, or the far end is not running |
bytesRx rising, rxIrqs 0 |
the receive interrupt never fired |
frames 0 with bytesRx rising |
bytes arrive but no frame completes: almost always a baud mismatch |
rxFramingErrors rising |
the two ends disagree on bit timing: cable, adapter, grounding |
rxOverrunErrors rising |
the console cannot drain the FIFO fast enough |
crcErrors and resyncs rising |
corruption on the wire; check the same things as framing |
rttSamples 0 after a while |
nothing has ever replied |
baud |
compare it with the bridge's setting; they must match exactly |