Attacking Pixels - Adam Robinson

Hi, I’m Adam Robinson, a software engineer and maker based in London

Take a look at my projects and experiments.

Search 20 Posts

Building a Multiplayer Game with R3F, Socket.io & Vite Pt.2

Posted 3 months ago - 8 min read

Tags : How To GuideReact Three FiberThree.js

Part 2 - Vite & Node.js Express Server

The following code sets up the game server using the Express.js framework and the Socket.IO library. The server serves both the static react three fiber build files and also provides real-time communication between clients via WebSocket connections. The server code file can be found here: server.js. I’ll be stepping through it in this post and outlying some of my deisgn cosiderations. The guide to run the code locally on your machine can be found here: Project Introduction

Code Breakdown

import fs from "fs"
import express from "express"
import Router from "express-promise-router"
import { createServer } from "vite"
import viteConfig from "./vite.config.js"
import { Server } from "socket.io"
import parser from "socket.io-msgpack-parser"

The code imports several Node.js modules and libraries required for the application. The modules are:

  • fs: used for reading files from the file system. The react projects index.html respectively.
  • express: a Node.js web application framework used for creating web servers
  • express-promise-router: an extension of the Express.js router that allows for asynchronous handling of HTTP requests
  • vite: a build tool that enables fast development and optimized production builds. Vite has been making a lot of noise recently for its speed in bootstrapping projects and its hot module replacement. I’ve managed some tom-foolery to get it working in dev and serving the files from the node.js server…
  • socket.io: a library for building real-time, bidirectional and event-based communication between the browser and the server
  • socket.io-msgpack-parser: a plugin for the Socket.IO library that uses the MessagePack binary format for more efficient data serialization. This keeps the messages sent over websockets small at the cost of making them a harder to debug when things are going wrong… unless you’re fluent in binary! The parser also needs to be implimented client side, for more information Github Repo
const router = Router()
const app = express()

if (process.env.ENVIRONMENT === "local") {
  const vite = await createServer({
    configFile: false,
    server: {
      middlewareMode: true,
    },
    ...viteConfig,
  })
  router.use(vite.middlewares)
} else {
  app.use(express.static("dist"))
}

router.get("/", async (req, res) => {
  let html = fs.readFileSync("index.html", "utf-8")
  if (process.env.ENVIRONMENT === "local") {
    html = await vite.transformIndexHtml(req.url, html)
  }
  res.send(html)
})

router.use("*", (req, res) => {
  res.status(404).send({ message: "Not Found" })
})

app.use(router)

const server = app.listen(process.env.PORT || 8080, () => {
  console.log(`Listening on port http://localhost:8080...`)
})

The above code is all pretty standard express stuff with the exception of the vite block.

  • Here we create an instance of the Express.js router and the Express.js app.

  • If the ENVIRONMENT environment variable is set to ‘local’, the code creates a Vite server using the createServer function and sets the middleware mode to true. The Vite server is used to serve the client-side code during development. Otherwise, the server serves the client-side code from the dist directory. The ENVIRONMENT value is passed from the .env file in the project root (mentioned in the previous project set up post) this is then passed through to the node server when running the dev script in the project package "dev": "nodemon -r dotenv/config server.js", via npm run dev

  • Foer roouting the code defines a route for the root URL (/) using the router’s get method. When a client requests this URL, the server reads the contents of the index.html file, if on local dev it transforms it using Vite or in a deployed setting it gets it from the dist build output folder.

  • The code defines a catch-all route using the router’s use method. This route handles all other requests that do not match any of the defined routes and responds with a 404 Not Found status code and a message in JSON format.

  • The app listens on a specified port, which is either the value of the PORT environment variable (which can also be set by you in the .env file) or port 8080.

const ioServer = new Server(server, { parser })
  • This code creates a new instance of the Socket.IO Server class, with the express HTTP server instance returned by the listen method, and the binary message parser from the socket.io-msgpack-parser module. The Server instance is used to handle WebSocket connections and events.
let clients = {}
// ex. { id1 : { p: [0, 0, 0], r: 0, s: "3" }, id2 : { p: [0, 0, 0], r: 0, s: "1" } }
let largeScenery = []
let smallScenery = []

This is where we declare the state of the server. This server has knowledge of the environments scenery which all of the players are going to be served. It would be no good having an inconsistent level for the clients so we generate this on the server. The server will also contain the most recent state for all of the players in the client object. This will outline the players current position, rotation and state (which animation to play on the character model)

ioServer.on("connection", socket => {
  console.log(
    `User ${socket.id} connected - ${ioServer.engine.clientsCount} active users`
  )

  clients[socket.id] = {
    p: [0, 0, 0],
    r: 0,
    s: "3",
  }

  if (largeScenery.length === 0) {
    let newLargeObjects = new Array(125)

    for (let i = 0; i < newLargeObjects.length; i++) {
      newLargeObjects[i] = [
        Math.floor(Math.random() * 12),
        Math.ceil(Math.random() * 475) * (Math.round(Math.random()) ? 1 : -1),
        Math.ceil(Math.random() * 475) * (Math.round(Math.random()) ? 1 : -1),
      ]
    }

    largeScenery = newLargeObjects
    socket.emit("largeScenery", newLargeObjects)
  } else {
    socket.emit("largeScenery", largeScenery)
  }

  if (smallScenery.length === 0) {
    let newSmallScenery = new Array(400)

    for (let i = 0; i < newSmallScenery.length; i++) {
      newSmallScenery[i] = [
        Math.floor(Math.random() * 22),
        Math.ceil(Math.random() * 500) * (Math.round(Math.random()) ? 1 : -1),
        Math.ceil(Math.random() * 500) * (Math.round(Math.random()) ? 1 : -1),
      ]
    }

    smallScenery = newSmallScenery
    socket.emit("smallScenery", newSmallScenery)
  } else {
    socket.emit("smallScenery", smallScenery)
  }

  ioServer.sockets.emit("clientUpdates", clients)

  socket.on("move", ({ r, p, s }) => {
    if (clients[socket.id]) {
      clients[socket.id].p = p
      clients[socket.id].r = r
      clients[socket.id].s = s
    }
  })

  setInterval(() => {
    ioServer.sockets.emit("clientUpdates", clients)
  }, 60)

  socket.on("disconnect", () => {
    console.log(
      `User ${socket.id} disconnected - ${ioServer.engine.clientsCount} active users`
    )

    if (Object.keys(clients).length === 1) {
      largeScenery = []
      smallScenery = []
    }

    delete clients[socket.id]
    ioServer.sockets.emit("clientUpdate", clients)
  })
})

This code sets up a connection event listener on the ioServer instance. When a client connects, the event listener logs a message to the console indicating that a new user has connected, initializes a new entry in the clients server state object (keyed to the socket id established from the client code) with some default values, and emits the largeScenery and smallScenery events to the client. The default p (position) ensure that a new player spawns at the same place in the level x0, y0, z0 with a r (rotation) of 0. The s (state) of 3 relates to animation number 3 which is the idle state of the players gtlf model.

If the player is the first to connect to the server and the largeScenery array is empty, the code generates 125 new objects with random positions within a predefined range and stores them in the largeScenery array. Similarly, if the smallScenery array is empty, the code generates 400 new objects with random positions within a predefined range and stores them in the smallScenery array. Once the level is generated on the server the arrays are sent to the client using socket.emit().

The clientUpdates event is emitted to all connected clients upon connection with the clients object as the payload. This sends the servers active players state of all avatars to all clients.

The code sets up an event listener on the move event emitted by the client. When the event is triggered, the code updates the client’s position, rotation, and state values in the clients object.

A setInterval() function is called to emit the clientUpdates event every 60 milliseconds to all clients.

When a client disconnects, the client is removed from the clients object, the clientUpdate event is emitted to all clients, and the largeScenery and smallScenery arrays are reset if the number of clients is zero allowing for a new level to be generated the next time someone connects.

Design Considerations / Trade offs

  • In order to keep the websocket messages short I have sacrificed a bit of readability in the server state objects
  • socket.io-msgpack-parser has yielded the greatest perfomance gains combined with the above trade off in getting the message sizes down
  • socket.io is easy to set up but it is by no means the most perfomant way of handling webSockets out there. I’ve tried implimenting a different websockets engine (eiows) as outlined here Socket.io Perfomance Tuning into socket.io but it resulted in a new socket connection being made upon connection and the old one dropping. The native ws bufferutil & utf-8-validate packages also seemed to add very little.
  • Stripping out socket.io and going for a more lightweight websockets or a uWebSockets.js implimentation would be prefereable… there are claims out there that this is x10 more performant. If there’s anyone reading this who’s down for the challange feel free to make a PR on the project repo

Part 3

Coming Soon


Adam G Robinson
Crafter. Explorer. Coder. 🇬🇧