Just Enough Multiplayer: Prototyping Real-Time Multi-User Spaces with Socket.io
In the past year, we’ve had a renewed interest in building collaborative applications and virtual spaces where people can come together. While full-featured multiplayer networked games can be complex, we’ve found a simple socket.io server on Glitch to be an effective tool for rapid multi-user prototyping.
Where multiplayer gets complicated
Multiplayer games get complicated because of latency.
If I send an input to move to the left, my local client immediately renders that movement so the game feels snappy. However, it may take 100 milliseconds for that input to get to the game server, run the game simulation to determine what happens with a given input, and then another 100 milliseconds to send the resulting game state to all players. Now, there’s a 200 millisecond difference between where I think I am and where the other players do.
Competitive multiplayer games get complicated because they must create the illusion that this 200 millisecond difference does not exist. If you’re interested in how this is done, check out this fantastic resource by Gabriel Gambetta.
(Oh yeah, also networked physics.)
What’s not so complicated
Luckily, we can do a lot without solving this latency issue. For many applications, whether it’s a simulated world or a collaborative tool, we can simply run our virtual environment on each client, respond to updates from the server, and not worry about compensating for the milliseconds between us.
A Demo Multiplayer App with Glitch and Socket.io
The following demo app will get you started with the concepts involved in adding simple multiplayer presence to an interface. The frontend is built with React. We will not be covering basic React concepts, so some level of familiarity is assumed. The server is built with Node.js/Socket.io and is hosted on Glitch. Glitch has been a fun development server environment for our multiplayer prototypes. It’s instantly deployed and allows for quick collaborative iteration.
Get the full code repos here:
Our demo app will render a user as a colored circle and move them on click. We’ll use react-spring to interpolate between user position updates. While not suitable for every use case, getting fewer state updates and handling more of the movement in a client-side interpolation is a nice simplification and can still communicate presence effectively.
The player component renders a dot with a position and color. React-spring’s useSpring hook will interpolate between position updates with some configured settings (which are quite fun to play with).
The Game Board
Our Game board renders all other players at the latest updated position from the server, renders the current player in their latest input position, and captures user input. We separately render the current player so the user does not feel a delay between their input and the application’s response to a server update.
Client-Side Network Class
The client-side network’s
init method is called on mount of the application. This establishes the socket connection, sets at listener for the
stateUpdate events the client will receive from the server, and sends an
initialize event to the server which will register the current player with its randomly chosen color.
When the user clicks to move, we both locally update the user’s position and send this position update to the server.
The Node Socket.io Server
Our server will accomplish a few tasks:
- Keep track of the current state of all players
- Handle new users joining
- Send updates to each client of the current player state
- Remove a user when they leave.
Our state data will be an object holding player information, keyed by their IDs. The
createPlayer function will be called when a user joins to build a new player object.
Handling Player Events
We set up a handler for when the client side application calls
io.connect. In this handler, we set up listeners for the three events the client can trigger.
When the client application sends the
initialize event, we create a new player with the corresponding id and color, and add that new player data to the game state. We also begin emitting updates to all players if this is the first player joining (discussed in the next section.)
When the client sends a
positionUpdate event, we update that player’s position in game state.
When a player leaves, the
disconnect event is sent, and we remove that player from state.
Broadcasting State Updates
Once users have joined and we are updating their position in the server-side game state, we must broadcast these updates to all players.
The core of this function is
io.emit('stateUpdate', players) call. This sends a snapshot of the current player data to all connected users. On the client side, this state object is used to interpolate each player to their updated position.
We have a
stateChanged flag which prevents an update from being sent if there has not been an event that changed the application state since its last update.
We then confirm that players are still connected, and schedule another
stateUpdate message at a given interval.
Extending the Pattern
This pattern can be summarized as:
- Client-side: Emit a user action as a message to the server
- Server-side: Update shared application state
- Server-side: In a game loop, broadcast that shared state to connected users
- Client-side: Interpolate between state updates
In our Holiday Oasis, we used this flexible pattern to model all user actions, including movement on different surfaces, eating holiday treats, and triggering dance floor fireworks. It should serve as a solid foundation for bringing multiplayer features to a variety of contexts, whether it’s a real-time collaborative application or a simulated 3D environment.