How to Code Ultra-Fast 2-Player HTML5 Game State Sync

Building Zero-Lag Two Player HTML5 Strategy Games on the Web


I’ve spent more than ten years of my life writing code, building gaming websites, and fixing broken client projects. One of the hardest problems in web development is getting multiplayer web games to run smoothly without lag.

Most web developers build their first 2-player game using standard WebSockets. When a player moves an item or triggers an action, the game package stringifies a big chunk of text into a JSON object and shoots it over the network.

This works fine when you test it locally with one player. But when you host a busy strategy game with hundreds of players competing in real-time, those heavy text packets will choke your server, spike your bandwidth costs, and make your game feel incredibly slow on mobile devices.

Today, I’m going to show you how to ditch JSON and use raw binary protocols to sync your game states. We will write a fast, custom byte-packer, build a lightweight WebSocket server, and set up a zero-lag syncing system.


1. Why JSON Is Terrible for Multiplayer Web Games

Let's look at a typical message a turn-based strategy game might send when a player places an explosive or moves a unit:

{
  "type": "place_item",
  "player": 2,
  "item_id": 45,
  "x": 12,
  "y": 8,
  "damage": 150
}

That looks clean and easy to read, right? But let's count the characters. That string is about 100 characters long. In computer memory, each character takes up 1 or 2 bytes depending on the encoding. So, this single tiny action requires sending around 100 to 200 bytes of data through the network.

If you have 1,000 players playing 500 active matches, and each match sends just five actions per second, your server has to process:

500 matches * 5 actions * 200 bytes = 500,000 bytes (500 KB) per second

That might not sound like a lot, but your server has to read, parse, and rebuild those JSON strings 2,500 times every single second. Parsing strings is slow because it uses a lot of CPU power. When the CPU gets busy, it delays the game messages, causing players to experience lag.

The Binary Alternative: Say Hello to Bytes

Instead of sending a long sentence like "type": "place_item", we can assign a single number to represent that action. For example, the number 1 means "place item."

By planning our data carefully, we can pack all that information into a tiny set of numbers:

  • Byte 0: Action Type (1)
  • Byte 1: Player ID (2)
  • Byte 2: Item ID (45)
  • Byte 3: X Coordinate (12)
  • Byte 4: Y Coordinate (8)
  • Bytes 5-6: Damage Value (150, which fits inside a standard 16-bit integer)

We just packed the exact same information into 7 bytes instead of 200 bytes. That is a 96.5% reduction in size. Your server will barely feel the traffic, and your players will get their moves updated instantly.


2. Setting Up Binary ArrayBuffers in JavaScript

To work with raw bytes in the browser, JavaScript uses special objects called ArrayBuffers and Typed Arrays. An ArrayBuffer is simply a chunk of raw memory. To read or write to it, we use a "view" like Uint8Array (unsigned 8-bit integers) or Uint16Array (unsigned 16-bit integers).

Before we write our code, you can read the MDN guide on ArrayBuffer to understand how these memory structures store binary data.

Let's write a custom helper class in JavaScript to handle our binary game packets.

class GamePacketHelper {
  /*
   * Packs game action data into a 7-byte ArrayBuffer
   * @param {number} actionType - 1 byte (0 - 255)
   * @param {number} playerId - 1 byte (0 - 255)
   * @param {number} itemId - 1 byte (0 - 255)
   * @param {number} x - 1 byte (0 - 255)
   * @param {number} y - 1 byte (0 - 255)
   * @param {number} damage - 2 bytes (0 - 65535)
   * @returns {ArrayBuffer}
   /
  static packAction(actionType, playerId, itemId, x, y, damage) {
    // Create a buffer of exactly 7 bytes
    const buffer = new ArrayBuffer(7);

// Create a DataView so we can write different byte sizes into the same buffer
const view = new DataView(buffer);

// Write single-byte values (Offsets 0 to 4)
view.setUint8(0, actionType);
view.setUint8(1, playerId);
view.setUint8(2, itemId);
view.setUint8(3, x);
view.setUint8(4, y);

// Write a two-byte value (Offset 5)
// The "true" parameter tells the browser to use Little Endian byte ordering
view.setUint16(5, damage, true);

return buffer;

}

/* * Unpacks a 7-byte ArrayBuffer back into a readable JavaScript Object * @param {ArrayBuffer} buffer * @returns {Object} / static unpackAction(buffer) { const view = new DataView(buffer);

return {
  actionType: view.getUint8(0),
  playerId: view.getUint8(1),
  itemId: view.getUint8(2),
  x: view.getUint8(3),
  y: view.getUint8(4),
  damage: view.getUint16(5, true)
};

} }

Let’s Test the Packer:

To see how easy this is, we can log the packed and unpacked results in the browser console:

const packed = GamePacketHelper.packAction(1, 2, 45, 12, 8, 150);
console.log("Packed buffer size:", packed.byteLength, "bytes"); // Prints: 7 bytes

const unpacked = GamePacketHelper.unpackAction(packed);
console.log("Unpacked data:", unpacked);
/*
Prints:
{
  actionType: 1,
  playerId: 2,
  itemId: 45,
  x: 12,
  y: 8,
  damage: 150
}
*/


3. Building a Lightweight Node.js WebSocket Server

Now that we can pack and unpack binary data on the frontend, let's build an incredibly fast Node.js backend. This server will receive binary game packets from one player and broadcast them directly to the other player in the room.

Because we are dealing with raw binary data, our server doesn't even need to parse the messages. It just reads a couple of bytes to identify the room and forwards the raw buffer directly. This saves a massive amount of server memory and CPU cycles.

First, make sure you have the ws library installed in your Node.js project:

npm install ws

Here is our clean, performance-optimized server script (server.js):

const { WebSocketServer } = require('ws');

const wss = new WebSocketServer({ port: 8080 });
console.log('WebSocket game server is listening on port 8080');

// Keep track of active game sessions
// Key: Room ID, Value: Array of player sockets
const gameRooms = new Map();

wss.on('connection', (ws) => {
  console.log('A player connected.');

  // Tell the socket to receive binary data as ArrayBuffers
  ws.binaryType = 'arraybuffer';

  ws.on('message', (message, isBinary) => {
    if (!isBinary) {
      // Ignore text messages to protect our server from spam
      return;
    }

    const view = new DataView(message);

    // Let's read our system byte values:
    // Byte 0: Packet Type (e.g., 99 = Join Room, 1 = Game Action)
    const packetType = view.getUint8(0);

    if (packetType === 99) {
      // Join Room Packet
      // Byte 1: Room ID
      const roomId = view.getUint8(1);
      ws.roomId = roomId;

      if (!gameRooms.has(roomId)) {
        gameRooms.set(roomId, []);
      }

      const players = gameRooms.get(roomId);
      if (players.length < 2) {
        players.push(ws);
        console.log(`Player joined Room #${roomId}. Total players: ${players.length}`);
      } else {
        console.log(`Room #${roomId} is full. Connection rejected.`);
        ws.close();
      }
      return;
    }

    // If it's a standard gameplay action packet, forward it to the other player in the room
    const roomId = ws.roomId;
    if (roomId && gameRooms.has(roomId)) {
      const players = gameRooms.get(roomId);
      players.forEach((player) => {
        if (player !== ws && player.readyState === 1) { // 1 = OPEN
          player.send(message, { binary: true });
        }
      });
    }
  });

  ws.on('close', () => {
    console.log('A player disconnected.');
    // Clean up empty rooms
    if (ws.roomId && gameRooms.has(ws.roomId)) {
      const players = gameRooms.get(ws.roomId);
      const index = players.indexOf(ws);
      if (index !== -1) {
        players.splice(index, 1);
      }
      if (players.length === 0) {
        gameRooms.delete(ws.roomId);
        console.log(`Room #${ws.roomId} is empty and has been deleted.`);
      }
    }
  });
});


4. Connecting the Game Engine to Your Binary Server

Once your server is running, you can connect your game engine to it. If you use Construct 3 to build your games, you can easily use the standard WebSocket and AJAX features to communicate with this backend.

Let’s write the JavaScript logic to register a player in a room and sync moves.

const socket = new WebSocket('ws://localhost:8080');
socket.binaryType = 'arraybuffer';

socket.onopen = () => { console.log('Connected to game server.');

// Let's join Room 12 // Packet Type 99 = Join Room const joinBuffer = new ArrayBuffer(2); const view = new DataView(joinBuffer); view.setUint8(0, 99); // Join Action view.setUint8(1, 12); // Room ID 12

socket.send(joinBuffer); };

socket.onmessage = (event) => { const buffer = event.data; const actionData = GamePacketHelper.unpackAction(buffer);

console.log('Received action from opponent:', actionData); // Trigger game logic (e.g. place a bomb, update unit health) triggerOpponentAction(actionData); };

// Function to send a move to our opponent function sendPlayerMove(itemId, x, y, damage) { // Action Type 1 = Move/Action, Player ID 1 const actionBuffer = GamePacketHelper.packAction(1, 1, itemId, x, y, damage); socket.send(actionBuffer); }


5. Case Study: Sourcing and Testing 2-Player Strategy Mechanics

Building local or online 2-player grid games requires robust turn logic, grid management, and solid visual asset alignment. To speed up development and learn these complex mechanics, studying professional game templates is highly recommended.

Analyzing Turn-Based Grid Strategy

For a direct, real-world example of 2-player layout engineering, check out Boom Bites - Two Player HTML5 Strategy Game.

This game is built for local, fast-paced tactical matches where players place items and take turns attacking on a structured board. Because it has two players competing on the same screen, it provides the perfect template for testing network synchronization.

+---------------------------------------+
|  [P1 HEALTH: 100]   [P2 HEALTH: 100]  |
+---------------------------------------+
|   [   ]   [   ]   [   ]   [   ]   [   ] |
|   [   ]   [ P1]   [   ]   [ P2]   [   ] |
|   [   ]   [ B ]   [   ]   [   ]   [   ] |
+---------------------------------------+
|         * BOOM BITES STRATEGY *       |
+---------------------------------------+

If you want to move this from a local game to an online game, you can implement the binary WebSocket protocol we just built. When Player 1 drops an explosive (represented by item code B on grid coordinate x=2, y=3), you package that action into 7 bytes and send it over the WebSockets server. Player 2's game receives the byte packet, unpacks it instantly, and renders the action on their screen within milliseconds.

Single Player Logic vs. Multiplayer Logic

It is important to notice the structural difference between turn-based multiplayers and traditional web-based arcade games. For example, if you download a card game using a simple HTML Game download package, the network syncing requirements are very different.

Card games usually run on a single machine where the computer plays the dealer. In those scenarios, you don't need real-time binary synchronization. Instead, you only need secure database writes to log final scores or bankroll balances.

Understanding when to use high-performance binary syncing (like in 2-player strategy battle games) versus when to use basic database calls (like in card games) is what separates junior coders from experienced web game architects.

If you are looking for clean, pre-configured Construct 3 source projects, templates, and gaming assets to study and build upon, I highly recommend checking out GPLPAL. It is an incredibly helpful repository where I source high-quality materials for testing and building out client portals safely.


6. Performance Audit Checklist for Online Web Games

To wrap things up, here is a quick, practical checklist I go through before launching any multiplayer web game project:

  • Ditch the Strings: Ensure you are using binary formats (ArrayBuffers or ArrayBufferViews) for any data packet that gets sent more than once per second.
  • Enable binaryType: Always set socket.binaryType = 'arraybuffer'; on both your client and server sockets to avoid automatic string conversions.
  • Keep Portals Clean: Run regular database audits on your backend servers. Delete empty game room logs and temporary player sessions every hour to keep memory usage low.
  • Use Compression on Static Assets: Even if your network protocol is fast, verify that your client files are compressed. Keep game sprites small and clean.
  • Handle Packet Loss Gracefully: Since WebSockets run over TCP, packets are guaranteed to arrive, but they might get delayed. Implement a client-side prediction system so your game animations feel smooth even if a network message is a few milliseconds late.

Using these steps, you can significantly reduce your server costs and provide a seamless, lag-free experience for your web game community. Happy coding!

评论 0