A multiplayer relay
The client from the last chapter is shouting its position into the void. Let's write the server that catches every player's updates and fans them back out to everyone else. This is the heart of almost every MultiNostalgia mod.
What the server is
The server half of your script runs once, on a shared host, with every player connected to it. It has no screen and no emulator — it's pure coordination logic. Its job is usually simple: remember what each player sent, and relay it to the others.
The server's heartbeat is event tick (the server's answer to the client's event frame):
server {
event tick(tick_num: i32) {
// periodic work — fan out state to clients
}
}Slots: stable per-player identity
When a player connects, the runtime assigns them a slot — the lowest free index in [0, max_clients()). Slots are how you key per-player data. The standard setup is a set of parallel arrays plus a client handle per slot:
server {
var clients: client[64] // handle for each slot, used to send to them
var occupied: bool[64] // is this slot in use?
var playerX: u8[64]
var playerY: u8[64]
}client is an opaque handle to a connected player. You can't inspect or forge one — the runtime hands them out, you store them, and you pass them back to send when you want to message that player.
Connect and disconnect
Two events bracket a player's session. Maintain the arrays in both:
server {
event connect(c: client, handoff: u8[]) {
var s = slot(c) // their assigned slot
clients[s] = c
occupied[s] = true
playerX[s] = 0
playerY[s] = 0
}
event disconnect(c: client) {
occupied[slot(c)] = false // free the slot
}
}slot(c) returns a client's slot index. On connect you record the handle and mark the slot occupied; on disconnect you clear it so the next player can reuse the slot.
Slots get reused
When a player leaves, their slot is freed and the next player to join may get it. Always clear stale data on disconnect (or overwrite it on the next connect) so player B never sees player A's leftovers.
Receiving updates
When a client sends, the server's event receive fires — and unlike the client's version, it tells you who sent it:
server {
event receive(from: client, data: u8[]) {
var s = slot(from)
var vals = unpack(data, [8, 8])
playerX[s] = u8(vals[0])
playerY[s] = u8(vals[1])
// Just store it here. Don't fan out yet — see below.
}
}Notice we only store the update. We don't relay it to everyone from inside receive.
Fan out from tick, not receive
Why not relay immediately? Because receive fires once per incoming message. If 30 players each send 60 times a second and you broadcast to all 30 from inside receive, that's 30 × 60 × 30 ≈ 54 000 sends a second. Doing the fan-out from event tick instead decouples send rate from receive rate — you push the latest known state at a fixed cadence:
server {
event tick(tick_num: i32) {
for (var i = 0; i < max_clients(); i = i + 1) {
if !occupied[i] { continue }
for (var j = 0; j < max_clients(); j = j + 1) {
if !occupied[j] { continue }
if i == j { continue } // don't echo to self
var msg = pack([j, i32(playerX[j]), i32(playerY[j])], [8, 8, 8])
send(clients[i], msg, reliable = false)
}
}
}
}For each player i, we send them every other player j's slot, x, and y. The receiving client unpacks [8, 8, 8] — exactly what we built in the last chapter.
send on the server takes a recipient
The client's send(data) had an implicit destination (its server). The server's send is explicit — first argument is which client:
send(to: client, data: u8[], reliable = true)There is deliberately no broadcast(). "Send to everyone" is just a loop over your occupied slots, exactly as above — which keeps you in control of who gets what (proximity filtering, interest management, teams) the moment you need it.
max_clients() over a hard-coded bound
The arrays are sized to a fixed maximum (64 here), but the loops use max_clients() — the shard's actual configured capacity. Sizing arrays to a generous constant while looping to max_clients() keeps the same script working across servers configured for different player counts.
The whole server
server {
var clients: client[64]
var occupied: bool[64]
var playerX: u8[64]
var playerY: u8[64]
event connect(c: client, handoff: u8[]) {
var s = slot(c)
clients[s] = c
occupied[s] = true
}
event disconnect(c: client) {
occupied[slot(c)] = false
}
event receive(from: client, data: u8[]) {
var s = slot(from)
var vals = unpack(data, [8, 8])
playerX[s] = u8(vals[0])
playerY[s] = u8(vals[1])
}
event tick(tick_num: i32) {
for (var i = 0; i < max_clients(); i = i + 1) {
if !occupied[i] { continue }
for (var j = 0; j < max_clients(); j = j + 1) {
if !occupied[j] || i == j { continue }
send(clients[i],
pack([j, i32(playerX[j]), i32(playerY[j])], [8, 8, 8]),
reliable = false)
}
}
}
}Pair this with the client from the previous chapter and you have a complete, working multiplayer position-sharing mod — every player sees every other player move in real time.
What you learned
serveris screenless coordination logic; its heartbeat isevent tick.- The runtime assigns each player a slot; key parallel arrays by it.
- Maintain
clients[]/occupied[]inevent connect/event disconnect. event receive(from, data)tells you the sender; just store there.- Fan out from
event tick; serversend(to, data)names a recipient; there's nobroadcast— loop the occupied slots.
Next
Multiplayer movement works. Let's give the player some direct control — a menu you open with a button, layered over a paused game.