Skip to content

Talking to the server

So far your mod has run entirely in one browser. To make a multiplayer mod, each player's client sends what it's doing to a shared server, and the server relays it to everyone else. This chapter covers the client half: sending.

The two halves, one file

Remember that a .munos file holds both sides. The client gathers data and sends it; the server receives and re-broadcasts it. They communicate through two primitives that are built into the language:

  • send(data) — a builtin that ships bytes to the other side.
  • event receive(data) — an event that fires when bytes arrive.

No sockets, no URLs, no connection code. The channel is implicit.

Sending bytes

From the client, send takes a byte array (u8[]):

munos
client {
    event frame(frame_num: i32) {
        var x = read_u8(0x0086)
        var y = read_u8(0x00CE)
        send([x, y])          // ship two bytes to the server
    }
}

That already works — but sending raw [x, y] is wasteful and inflexible. The idiomatic way to build a message is to pack it.

Packing a message

Real updates have several fields of different sizes: a position, a small pose ID, a one-bit "facing" flag. pack lets you lay them out to the bit:

munos
pack(values, widths)   // -> u8[]

You give it a list of integer values and a matching list of bit widths. Each value is written using exactly that many bits:

munos
// player_x and player_y as 8 bits each, a 4-bit sprite id,
// and a 2-bit palette id — 22 bits, packed into 3 bytes.
var msg: u8[] = pack(
    [player_x, player_y, sprite_id, palette_id],
    [8,        8,        4,         2]
)
send(msg)

A one-bit flag costs exactly one bit on the wire. This matters when you're sending 60 updates a second.

Reliable vs. unreliable

send has an optional second argument:

munos
send(msg, reliable = true)    // default: guaranteed, in order
send(msg, reliable = false)   // fire-and-forget: may drop, may reorder
  • Reliable (the default) is for things that must not be lost: a chat message, a "player joined" announcement, one-time setup.
  • Unreliable is for high-frequency streams where the next update is coming right behind this one. Position updates at 60 Hz are the textbook case — if one drops, the next frame fixes it, and you don't want the network stalling to redeliver stale coordinates.

So a position broadcaster wants send(msg, reliable = false).

Receiving on the client

The server will send updates back — the positions of every other player. Those arrive in event receive:

munos
client {
    event receive(data: u8[]) {
        var vals = unpack(data, [8, 8, 8])   // same widths the sender used
        var slot = vals[0]
        var x    = vals[1]
        var y    = vals[2]
        // ...store and draw the other player...
    }
}

unpack(bytes, widths) is the mirror of pack: same widths, in the same order, and you get your values back. The two width-lists — one on each side of the wire — are your protocol.

Widths must match

pack and unpack share no schema beyond the widths array you pass each. If the sender packs [8, 8, 8] and the receiver unpacks [8, 8, 16], you'll get garbage. Keep the widths together — many scripts define them once as a shared const array and use it on both sides.

A complete client

Here's a client that broadcasts its position every frame and remembers where everyone else is:

munos
const PLAYER_X: u32 = 0x0086
const PLAYER_Y: u32 = 0x00CE

client {
    var otherX: u8[64]
    var otherY: u8[64]
    var occupied: bool[64]

    event frame(frame_num: i32) {
        // 1. tell the server where we are
        var x = read_u8(PLAYER_X)
        var y = read_u8(PLAYER_Y)
        send(pack([i32(x), i32(y)], [8, 8]), reliable = false)

        // 2. draw everyone we've heard about
        for (var i = 0; i < 64; i = i + 1) {
            if occupied[i] {
                draw_image(ghostSprite, ghostPal, i32(otherX[i]), i32(otherY[i]))
            }
        }
    }

    event receive(data: u8[]) {
        var vals = unpack(data, [8, 8, 8])
        var slot = vals[0]
        otherX[slot] = u8(vals[1])
        otherY[slot] = u8(vals[2])
        occupied[slot] = true
    }
}

Each other player has a slot — a small index the server assigns them. The client keeps parallel arrays (otherX, otherY, occupied) keyed by that slot. We'll see where slots come from next.

What you learned

  • send(data) ships bytes; event receive(data) receives them.
  • pack(values, widths) / unpack(bytes, widths) build and parse messages to the bit; the widths arrays are your wire format.
  • reliable = false suits high-frequency position streams.
  • The client keeps other players in parallel arrays indexed by slot.

Next

The client is sending into the void — there's no server yet to relay anything. Let's write the other half.

A multiplayer relay →

Part of the MultiNostalgia project.