Project Mirabelle - Blog

Multiplayer prototype (semi-technical article)

Written by ♫Dex, published 2024-05-26

Note: This article was written on 2025-01-04, as a summary of the events happening on Discord back during this development log

After a bit of a hiatus, I'm back with a weekend prototype: a simple multiplayer mode for Project Mirabelle! This is a very, very hacky prototype still, and it's not going to be in production any time soon. It was still a good small project, and a cool way to show capabilities for multiplayer content!

First, on May 22nd, I showed off some work I've been doing that previous day. It was still a bit janky, and very hacky, but it was a good demo!

Image of two browsers both showing an empty menu to 'connect to an instance' Image of two browsers, the first showing a character inside of a small outdoors map, and the second showing a menu to 'connect to an instance' with one instance available Image of two browsers, both with characters inside of a small outdoors map, looking at each other from different points of views

The multiplayer system works as a peer-to-server-to-peer system, where a host peer can "register" an instance on the server, after which other players can "connect" to that instance. The connected players will send commands to the server, who will relay them to the host. This way, the server itself doesn't have to expand (much) resources to maintain a game session! The inconvenient is that if the host disconnects, all of the other players will be disconnected as well.

The connection also uses discord OAuth for player login, which was the easiest implementation I could think of (as I already implemented Discord oauth in the community DiD games list project). I didn't want to import a library just for oauth, and thankfully the implementation in pure JS is very, very simple. The basic idea is to implement the following two endpoints:

/project_mirabelle_server/

> login => oauth/callback

First, the login endpoint should just redirect the player to the Discord's oAuth login https://discord.com/api/oauth2/authorize URL, using the proper required query parameters (response_type=code, scope=identify, prompt=consent, state=123456, client_id as a configuration parameter that you get when creating your Discord app, and redirect_uri to the https://sleepy.place/project_mirabelle_server/oauth/callback URL). Note that you need to configure your Discord app to accept the redirect URI as-is too.

// Redirect to the Discord application login pag
const REDIRECT_URI =
  `https://sleepy.place` +
  `/project_mirabelle_server` +
  `/oauth/callback`;
const discordOAuthLoginURL =
  `https://discord.com/oauth2/authorize?` +
  `response_type=code&` +
  `client_id=${CLIENT_ID}&` +
  `scope=identify&` +
  `state=123456&` +
  `redirect_uri=${REDIRECT_URI}&` +
  `prompt=consent`;
response.writeHead(302, "Found", {
  Location: discordOAuthLoginURL
});
response.end();

Once that's done, you will get a request to /project_mirabelle_server/oauth/callback once the player logs in. The oauth/callback URL is by far doing the bulk of the work, but you only have to do it once.

First, when you start, you'll have to "confirm" your login with Discord. To do this, you'll have just received a query parameter called code. You need your server to make a request to https://discord.com/api/oauth2/token with a body in the application/x-www-form-urlencoded 'format' (which is just a query parameters in the body), and the parameters being client_id, client_secret, grant_type, and the code you just received.

// Get an access to the Discord AP
fetch("https://discord.com/api/oauth2/token", {
  method: "POST",
  headers: {
    "Content-Type":
      "application/x-www-form-urlencoded",
  },
  body: new URLSearchParams({
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    grant_type: "authorization_code",
    code: code,
    redirect_uri: REDIRECT_URI,
  }).toString(),
});

This is the most... Finnicky aspect of oauth integration, usually, as websites have quirks about the exact format of what's needed and what's not needed. For example, in the code above, I did add the redirect_uri query parameter again, but now I can't remember whether that's because it was required by Discord to work or if that's because another website required it and I just copy-pasted that in. Still, I'm leaving it there because it's a bit hard to debug oauth issues in case it becomes one down the line.

That API call will return you some JSON data, with a single thing you need in it: access_token, which is your access key the Discord API. Using this, you can ask things like the user's name (to show it in the game as a 'default' name):

// Get the user's login information from Discor
fetch("https://discord.com/api/users/@me", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${d.access_token}`,
  },
});

This will give you what you need to show the user they're logged in! It's basically just the internal Discord ID d.id, the username d.username, display name d.global_name, and avatar URL d.avatar ; you can retrieve & save them in your own database like this:

// Store the user's login informatio
storage.users[d.id] = {
  username: d.username,
  global_name: d.global_name,
  chosen_name: d.global_name || d.username,
  avatar: d.avatar,
};

Once that's done, all you need to do is to set a cookie so you don't have to ask the user to relogin every time:

// Create a secure cookie for the user logi
const newCode = crypto.randomUUID();
storage.codes[newCode] = d.id;
response.writeHead(200, "OK", {
  "Set-Cookie":
    `${COOKIE_NAME}=${newCode}; ` +
    `SameSite=Strict; ` +
    `Secure; ` +
    `HttpOnly; ` +
    `Path=${WEBSITE_PATH}; ` +
    `Max-Age=2592000`
});

And you're done with oauth handling!

Then, next time you receive any request, you can just check whether the cookie is a known value in the table:

// Use the cookie to know who the user i
// (Simplified code with no null checks
const cookie = request.headers.cookie;
const cookiePair = cookie.split("; ")
  .find(c => c.startsWith(`${COOKIE_NAME}=`));
const cookieId = cookiePair
  .substring(`${COOKIE_NAME}=`.length);
const userId = storage.codes[cookieId];
const user = storage.users[userId];

And then you can know who the logged in user is!


I ended up properly adding the "multiplayer" option and configuration to the main menu on May 26th.

Image of the main menu of the game showing the multiplayer instance connection list

Finally, as a small break from engine code, I added some new 'small wooden cages' props as an alternative to the metal cages.

Image of three characters kneeling while tied in small cages made of wooden branches