logo icon
Huu Thang's blog
wanna or
Connect with me on:

GMS2 Multiplayer: A Simple WebSocket Relay Architecture Guide


Nov 30, 2025

1. Introduction: The "Postman" Philosophy

When we think about multiplayer, our minds often jump to massive, authoritative servers that calculate physics, prevent cheating, and simulate a whole world. That’s a mountain to climb. But for many turn-based or grid-based games, you don't need a God; you just need a Postman.

The architecture I’m outlining here is a Relay Server model. The server’s job isn't to decide if a move is legal; its job is to manage the "mail." It handles:

  • Connections: Who is currently online?
  • Room Management: Creating private spaces for two players to talk.
  • Broadcasting: Taking a message from Player A and delivering it to Player B.

By offloading the game logic to the clients and using the server strictly as a switchboard, we can get a Node.js backend up and running in a few dozen lines of code, and GameMaker Studio 2 (GMS2) can treat a remote opponent almost exactly like a local one.

2. Background: The L-Game Case Study

To make this concrete, let's look at my implementation of L-Game. If you haven't played it, it's a deceptively simple strategy game played on a 4 ×\times 4 grid. Players move an L-shaped piece (attack) and then optionally move a neutral coin (defense) to block their opponent. 3

Check it out: You can try the game here or read more about the Game Juice and polish here

The "Abstract Player" Pattern

The secret to making multiplayer easy in G MS2 is how you structure your objects. In L-Game, I don't hardcode "Player 1" and "Player 2." Instead, I use a Game Manager that controls the state machine (waiting, attacking, defending) and communicates with two Abstract Player objects.

The Game Manager doesn't care what is controlling those players. This allows for a beautiful abstraction:

2

  1. Local Player: Acts as a bridge for Hardware Input. It listens for mouse or keyboard events during the "Attack" or "Defense" phases and translates those signals into game coordinates.
  2. Bot: Acts as a bridge for Calculated Logic. Instead of waiting for a click, it runs a search algorithm to select a move and submits it to the Manager in the exact same format as a human player.
  3. Remote Player: Bridges Network Data. It remains idle until the GMS2 Networking Async event receives a WebSocket packet from our server. It then unpacks the data and execute that player's move.

3. The Handshake & Room Logic

With the player objects ready, the Node.js server (our Postman) needs to pair them up. THis is handled via a simple Room System:

1

  1. The Dispatch: The process begins when the Host sends their game settings - for example, host:red_first. This tells the server, "I'm opening a room, and in this match, the Red player goes first."
  2. The Token Exchange: The server generates a unique Token (like 881) and sends it back to the Host. The server now stores that room 881 belongs to this Host (via a socket connection) and uses the red_first rule.
  3. The Join Request: The Guest enters the code 881. The server looks through its active rooms and finds the match.
  4. The Pairing: This is the final confirmation that syncs both players:
    • To the Guest: The server sends paired:red_first. Now the Guest's game knows exactly which player is starting without the user having to select it manually.
    • To the Host: The server sends a simple paired:. This acts as a "Green Light" to move from the lobby to the game board.

This Postman approach is simple as:

  • Zero Game Knowledge: Notice that the server doesn't care what red_first means. It just stores the string and hands it to the Guest. You can add logics and the server code stays exactly the same.
  • Synchronized State: By passing the config during the handshake, you ensure that both players are looking at the same game rules before the game starts.

4. The Active Game Loop: Command & Relay

Once the handshake is complete, the server’s role becomes beautifully simple. It stops being a "Matchmaker" and starts being a Relay. I call this the "Postman" phase: the server doesn't care what is inside your messages; it only cares who they are addressed to.

The Postman's Simple Job

In our Node.js code, this is handled by a simple logic gate. If a message starts with command:, the server looks at its pairs map, finds the connected opponent, and forwards the data.

  • No Logic on Server: The server doesn't know the rules of the L-Game. It doesn't know what an "Attack" is or if a move is legal.
  • Transparent Tunnel: It simply strips the command: prefix and pushes the raw instructions to the other side.

    Trade-offs: Because the server is "blind" and doesn't validate moves, it relies entirely on the honesty of the clients. This model is best for friendly matches or low-stakes games, as a malicious user could theoretically send a fake command: string that the server would faithfully deliver or mishandle. Also, if a single packet containing a move is lost, the two games will fall out of sync. Therefore, a periodic State Sync should also be implemented to perform an anti-desync.

The Sequence of a Move

To visualize how a turn works, let's look at the sequence of a single move (an Attack followed by a Defense):

0

  1. Dispatch: Player 1 decides on a move. Their game sends command:attack:0_1_2.
  2. The Relay: The Server sees the command: tag, finds Player 2, and sends them attack:0_1_2.
  3. Execution: Player 2’s game receives the string, parses the coordinates, and moves the L-piece on their screen.
  4. Acknowledgment: Player 2 sends back command:received:. The Server relays this as received: to Player 1, confirming the "letter" was delivered.
  5. The Second Half: Player 1 then sends the defense move (e.g., command:defend:1_1_3_2), which follows the exact same relay path.

Why This Matters

This "blind relay" approach is incredibly powerful for simple game development. Because the server is just a middleman:

  • You can update your game (change coordinates, add new pieces, or change the rules) without ever touching your Node.js code.
  • Latency is minimized because the server isn't wasting time processing game logic; it's just moving strings from Point A to Point B.

5.Connecting the Dots: GMS2 Integration

We have the server (the Postman) and the protocol (the Mail). Now we need to bridge the gap in GameMaker Studio 2. This happens in two stages: establishing the connection and handling the incoming data stream

Step 1: Opening the Mailbox

Before any messages can be sent, we must establish a persistent connection to our Node.js server. In GMS2, we use a raw WebSocket socket. This is usually triggered when the player clicks "Host" or "Join" in your menu.

// Initializing the Connection
global.ws = network_create_socket(network_socket_ws);
var result = network_connect_raw_async(global.ws, global.SOCKET_SERVER, global.SOCKET_PORT);

if (result < 0) {
   show_debug_message("Connection failed!");
}
  • network_socket_ws: This tells GMS2 to use the WebSocket protocol, which is exactly what our Node.js server is listening for.
  • network_connect_raw_async: This is crucial. It prevents the game from "freezing" while it waits for the server to respond.

Step 2: Processing the Delivery

To wrap up the technical implementation, let's look at how the GMS2 Networking Async Event actually processes the data. We can break this down into three layers: the Listener, the Distributor, and the Execution.

Layer 1: The listener

Everything starts by identifying the message. Since GMS2 can handle multiple network events at once, we first ensure we are only listening to our specific WebSocket.

var sid = async_load[? "id"]; 

if (sid == global.ws) {
   var stype = async_load[? "type"];
   // This is where the branching logic begins...
}

Layer 2: The Distributor (stype)

Now we look at the nature of the event. Is it a new connection, or is it actual data being delivered?

  • network_type_non_blocking_connect: This triggers once when you first hit the server. This is your "Introduction" phase where the Host tells the server what the game rules are (e.g., host:green_first), or the Client submits a token to join.
  • network_type_data: This is the core of the game loop. It triggers every time the Postman delivers a packet.
  • network_type_disconnect: The cleanup phase. If this triggers, we reset the UI and variables because the connection to the Postman has been severed.

Layer 3: The Deep Dive (Processing Data)

When stype is network_type_data, we dig into the buffer to read the data inside. This is where our protocol executes.

  1. Read the Buffer: We convert raw binary data back into readable string (e.g., attack:0_1_2).
    var r_buffer = async_load[? "buffer"];
    var msg = buffer_read(r_buffer, buffer_string);
    
  2. Extract the Command: Using a parser, we separate the string into command and its value.
    var data = {
       command: "",
       value: ""
    };
    scr_extract_message(msg, data); // Extract command and value from the message
    
  3. The Switch: Finally, we handle the logic based on the data.command extracted. This is where the game reacts to the server's instructions.
    switch(data.command) {
       // This is where the game logic begins...
    }
    

By nesting the logic this way, you keep your game organized. The "outer" layers handle the boring network stuff (connecting/disconnecting), while the "inner" layers focus purely on the L-Game's unique mechanics.

6. Conclusion

We've reached the end of our Simple WebSocket Relay journey. By treating the server as a logic-less middleman, we’ve bypassed the most intimidating part of multiplayer development: authoritative server logic.

However, while the "Postman" makes the networking simple, the burden of State Management now sits squarely on your GameMaker project. A robust multiplayer session is more than just sending coordinates; it’s about handling the "What Ifs."

The Critical Case Checklist

Even with a perfect relay, you must ensure your GMS2 client handles these tedious but vital scenarios:

  1. Disconnects: What happens if a player’s internet cuts out mid-move? Your unpaired logic must be bulletproof to prevent the remaining player from being stuck in a permanent "Waiting for Opponent" state.
  2. Memory Management: When a session ends, are you resetting your global variables and clearing your buffers? Failing to clean up leads to "Ghost Games" where data from the last match leaks into the next one.
  3. Mode Switching: This is the most critical part of the Abstract Player pattern. If a player switches from a Multiplayer match to a Single-player session, you must ensure the game correctly destroys the Remote Player and replaces it with a Bot or Local instance.
  4. State Resync (Anti-Desync): In a "blind relay," if a single packet containing a move is lost, the two game clients will fall out of sync. You should implement a periodic State Sync where the host sends the entire board state string. Upon receipt, the guest overwrites their local variables to match the host exactly.

Building a multiplayer game isn't just about the technology - it’s about consistency. The Postman ensures the mail gets delivered, but it’s up to your GameMaker code to ensure the right Brain is active to process it.

Happy coding!

( ´ ▽ ` )


Written by:

Join the discussion!


comment-posting-avatar