Wednesday, December 13, 2023

making a minimal multiplayer online game via Rune

I just found out about a cool game platform called Rune - it lets you play online multiplayer games with voice chat.

And in so many ways it's an indie game dev's dream come true: Rune handles the game state management and reconciliation and voice chat and social and provide a simple, framework agnostic js api.

Their Quick Start page has a nifty bootstrap:

npx rune-games-cli@latest create
npm run dev


Not only does that spit out a basic React template, but using VITE the terminal pops up a QR code that you can scan with your device (if it's on the network) and test on-device.

But, what if you're not coding in React? Their tic-tac-toe example uses barebones JS, but has a fair amount of tic-tac-toe-ness logic you'd have to remove to start from scratch. So here is my minimal quick start:

A Bare Bones Game

Lets make a simple game: the first player to click their button ten times wins! The UI will be a single button, and then your score and the other player's.

A minimal Rune game can consist of just 3 files: an index.html file to hold the structure of the page, a logic.js that syncs game logic across players, and a client.js that runs on each page to handle events and update the game display.

Here is the HTML for index.html

<!DOCTYPE html>
<html>
    <h1>first-to-ten</h1>
    <ul id="scoreboard"></ul>
    <button onclick="clickNow()">hit it</button>
    <script src="https://cdn.jsdelivr.net/npm/rune-games-sdk@4.16.2/multiplayer-dev.js"></script>
    <script src="logic.js"></script>
    <script src="client.js"></script>   
</html>

That is just an unordered list for our score and the opponent's, a button to press, and then we include the Rune js and then our custom code.

Next step: logic.js. This can be as simple as a single call to Rune.initLogic(args)

Barebones arguments are minPlayers, maxPlayers, a setup() function that initializes game state, and then a plain object of actions the clients can call.

Rune.initLogic({
    minPlayers: 2,
    maxPlayers: 2,
    setup: (allPlayerIds) => {
      const game = { scores: {} }
      for (let playerId of allPlayerIds) {
        game.scores[playerId] = 0
      }
      return game;
    },
    actions: {
      incrementScore(payload, { game, playerId }) {        
        game.scores[playerId]++;       
      },
    }
  })

Setup returns the game object. In our case, we just need a plain object for mapping playerid=>score. Our only action is "incrementScore()". We will actually ignore any orguments passed in (to that payload placeholder), and just increment the score for the playerId.

Finally we can write our client.js. The critical line here will be a call to Rune.initClient(args) - at minimum, args should have a value for "onChange", alerting the client that the game state has updated. We only need three things:

function clickNow (){
    Rune.actions.incrementScore();
  }

function onChange({ game, yourPlayerId, players }) {
  const myScore = game.scores[yourPlayerId];
  const otherPlayerId = Object.keys(game.scores).find(key => key !== yourPlayerId);
  const otherPlayerScore = game.scores[otherPlayerId];
  
document.getElementById("scoreboard").innerHTML = `
      <li>My score: ${myScore}</li>
      <li>Their score: ${otherPlayerScore}
  `;
}

Rune.initClient({ onChange })

So clickNow is our click event handler that calls the incrementScore function we set up in logic.js. We then create our onChange handler that has some useful information: the game state, this player's id, the list of players, etc. 

We can immediately look up our own score via the game.scores object, since we know our player ID. But for a two player game we have to do a little "find()" logic to get the score value that is NOT ours. Once we have that, we will fill in the guts of our scorebard with two list items.

Finally, we call initClient with our onChange handler.

We're ready to give it a whirl!  If you don't have a server handy, from the command line you can just naviagate to the folder with 3 files and run
npx serve@latest

And boom! Rune has a nice side-by-side mode that pretends to be two (or more) phones at once:

Congrats, you made a multiplayer game! You can click either player's "hit it" button and see the display advance for both players.

But wait... we want first to TEN, this just lets you play forever...

Winner Winner Chicken Dinner

So how do we tell Rune the game is over? We just call Rune.gameOver(); That takes a single object that can be a map of playerIDs to player score... conveniently we have that handy, since it's the same structure of game.scores - so we can just add this line to incrementScore , right after we ++ the relevant score:
      if(game.scores[playerId] >= 10) {
        Rune.gameOver({players:game.scores});
      }

Now Rune automatically constructs a little results screen for us:

So there's a game over, but that's not much of an accolade, it's not instantly clear who won. But, if instead of numeric values, we can set each player ID key to "WON" or "LOST". and Rune will record those results. Lets just write some easy to follow code to make a new onject mapping players to the end state...here is the entire incrementScore function :

    incrementScore(payload, { game, playerId, allPlayerIds }) {        
        game.scores[playerId]++;      
        if(game.scores[playerId] >= 10) {
          const results = {};          
          for (let playerId of allPlayerIds) {
            results[playerId] = game.scores[playerId] >= 10 ? "WON" : "LOST";
          }              
          Rune.gameOver({players:results});
        }
      }

Now we get a result like 

That's much more clear. (In the real Rune UI it will be fancier.)

Two's Company, Three is More Fun

But, a two player game might be a little meh. Lets kick it up to 4 players!

We'll start by updating our scoreboard logic in client.js to show the player names, since pretty soon it won't be just "us vs them":

Lets just replace the line updating the innerHTML:
  document.getElementById("scoreboard").innerHTML =
    Object.keys(players)
      .sort((id1,id2)=> game.scores[id2] - game.scores[id1])
      .map((id)=>{
        return `<li>${players[id].displayName}: ${game.scores[id]}</li>`})
      .join("");


We'll use some of those nifty js array functions - first we get the keys of the players object, then we sort by looking up on each playerId current score. Then we build the list item for each player, and join it all together to make the guts of the scoreboard.

One final improvement: it might be nice to highlight the players name within the list, so they can see where they currently are in the standings. Lets just do a simple conditional color style inline. Here is our new onChange, removing some of the old unneeded Us vs Them logic:

function onChange({ game, yourPlayerId, players }) {
  document.getElementById("scoreboard").innerHTML =
    Object.keys(players)
      .sort((id1,id2)=> game.scores[id2] - game.scores[id1])
      .map((id)=>{
        const itsAMe = id===yourPlayerId;
        return `<li style='color:${itsAMe?'red':'black'}'>${players[id].displayName}: ${game.scores[id]}</li>`})
      .join("");
}

The only difference from before is setting itsAMe if it's the current player in this item, and using red to highlight it if so.


Ok, one FINAL final improvement to the client - lets add a little CSS to the index.html to get a sans-serif typeface - we're not barbarians:

<style>
    body {
        font-family: sans-serif;
    }
    button {
        margin:auto;
        display: block;
    }
</style>

 

So now all we need to do is tell our logic.js that maxPlayers is 4, and handle what happens when a player joins or leaves.

    maxPlayers: 4,
Is a simple enough change...and just like we passed an "actions" object to Rune.initLogic() we can pass an events object with handlers for players joining or leaving.. all we need to do is add and initalized their score as they join, and remove it if they go.

  events: {
    playerJoined: (playerId, { game }) => {
      game.scores[playerId] = 0
    },
    playerLeft:(playerId, { game }) =>{
      delete game.scores[playerId];
    }
  }

Here's the result... I admit I enjoy the character names Rune uses:

So, tadah! You should now have a solid base to know how to wire up your game to Rune's great player management system no matter what JS framework you are using.

No comments:

Post a Comment