rameshvarun / netplayjs

Make P2P multiplayer browser games, no server hosting or synchronization code required. Powered by rollback netcode + WebRTC.
https://rameshvarun.github.io/netplayjs/
ISC License
496 stars 34 forks source link

The case of a BoardGame with NetplayJS #88

Open operatorequals opened 1 month ago

operatorequals commented 1 month ago

This Issue is about sharing some insights on making a Boardgame using NetplayJS. The no-server approach, as @rameshvarun is kind enough to host a public server here (https://netplayjs.varunramesh.net/), and the simplicity of the API, as well as the ease of Matchmaking (just a click and a QR), drew me to this library, and I really want to thank @rameshvarun for his work.

The Canvas

So, first things first, the game would be plain HTML, so I minimized and threw away the canvas element. Additionally, one can bring their own canvas (answering this one: https://github.com/rameshvarun/netplayjs/issues/80), as the constructor can also be like this:

class NetPlayJSGame extends netplayjs.Game {

  constructor(canvas, players) {
    super();

    this.players = players;
    this.G = {}; //GameState - explained below
[...]

I first moved and then totally threw my canvas away and the lib didn't fall apart:

// Create a ghost game (no Canvas), that is only used
// to sync the data and also for net discovery.
NetPlayJSGame.canvasSize = { width: 0, height: 0 };
[...]

// (Re)Move the NetPlayJS ghost canvas
$("canvas").each((i, c) => {
    console.log(c)
    c.id = `netplayjs-canvas-${i}`;
    $('#netplayjs').remove(c);
    // $('#netplayjs').append(c);
});

Game state

This lib works fundamentally different than a board game! It only syncs inputs (key-presses, touches) between the 2 browsers, so anything else is not synced (e.g variables).

Luckily there is a serialization engine that I only found to work through the RollbackWrapper of the game. My serialization implementation:

class NetPlayJSGame extends netplayjs.Game {
  [...]

  serialize() {
    return JSON.stringify(this.G)
  }

  deserialize(value) {
    let newG = JSON.parse(value);
    if (!this._isHost())
      this.G = newG
  }

The G object is a JSON serializable (data only) Object that carries stuff around in the same style as boardgame.io. Stuff like cards in player's hand, board positions, turns, player names, scores are all stored in this same object. Sharing such object between 2 browsers would need an authoritative source. A source of truth.

The Host and passing messages

Looks like every time a NetplayJS game starts, it chooses one browser to become the host. I found out that this host never accepts the serialized objects of the client. So it is the authoritative for the state and all GameState manipulation should happen ONLY in the Host's tick().

I found the host like this:

class NetPlayJSGame extends netplayjs.Game {
  [...]

  _getOwnPlayerID(){
    for(let i = 0; i < this.players.length; i++)
      if (this.players[i]['isLocal']) return i;
    return null;
  }

  _isHost(){
    //if I'm not a Client - I'm HOST
    return !this.players[this._getOwnPlayerID()].isClient()
  }

And I used _isHost() in my tick()

class NetPlayJSGame extends netplayjs.Game {
  [...]
  tick(playerInputs) {
    [...] // stuff that runs in both Host and Client
    /*
      The rest of the game loop
      is run only by the Host
      Client only gets the G updates
    */
    if (!this._isHost()) return;

    // Process the "Events" from all players and do "moves" accordingly
    for (const [player, input] of playerInputs.entries()) {

      Object.keys(input.keysPressed).forEach((event)=>{
        [...]

But, if it's a boardgame, played through clicking, what kinds of events is the Host getting?

The fake Keyboard Events

I used this code here, based on this answer of @hciam (https://github.com/rameshvarun/netplayjs/issues/85#issuecomment-2128473208).

function passToHost(key) {
  const body = document.body;
  body.dispatchEvent(new KeyboardEvent("keydown", { key }));
}

Funny thing is that calling this function with ANY string will make the Host think that such button is pressed:

passToHost("thiskeydoesntexist 123");

The above code will make the Host's tick consume the thiskeydoesntexist 123 string. So I created some basic event "commands" like:

passToHost("DRAW 2"); // will make the Host add 2 cards to my hand

and parse the "commands" when processing the playerInputs parameter in Host's tick(), along with the playerID who sent the "command". Finally, every consumed "command" should be deleted in the tick(), as the passToHost does not create any keyup event:

class NetPlayJSGame extends netplayjs.Game {
  [...]
  tick(playerInputs) {
    [...]
    for (const [player, input] of playerInputs.entries()) {

      Object.keys(input.keysPressed).forEach((event)=>{
        // Parse everything
        const eventTokens = event.split(" ")
        const eventAction = EVENTS[eventTokens[0]]
        const eventParameter = eventTokens[1]

        // use eventAction and eventParameter to change the Gamestate (this.G)
        [...]

        // delete the keypress
        delete input.keysPressed[event] // remove the event from the inputs
      });

So, finally, to attach an event to an HTML outside the NetplayJS objects, it's as simple as:

// using JQuery
$('#deck').click(() => {
  passToHost("DRAW 1"); // this is evaluated in Host's tick()
});

Drawing stuff (without a Canvas)

This is very simple as there is a built-in draw function with access to the Gamestate (as this.G):

class NetPlayJSGame extends netplayjs.Game {
  [...]
  draw(){
    // needed to find out who's view is drawn
    // E.g whether cards are drawn face up or down
    const playerID = this._getOwnPlayerID()

    // Create HTML elements to the DOM as one does...
    [...]
  }

Performance

As the game does not draw or does not need any synchronization magic we can run easy with:

NetPlayJSGame.timestep = 1000 / 10; // Our game runs slowly as it is a boardgame
NetPlayJSGame.deterministic = true;
NetPlayJSGame.highDPI = false;

let GameObjectWrapper = new netplayjs.RollbackWrapper(NetPlayJSGame)
GameObjectWrapper.start();

The LockstepWrapper does not seem to work with serialization for me, but I assume it would be better on performance.

Security...

This game can be tampered with only by the Host by altering the GameState (G), through the browser console. Some checks can be set in tick() for both Host and Client, so it can at least be detected if there is any bogus change to the GameState by any player. The impossible issue is that both players have full access to the GameState through the Browser Console, allowing them to see hidden parts, such as player hands in a card game. There is no way around this without a server component. Again, boardgame.io covers this: https://boardgame.io/documentation/#/secret-state

So here you have it. How I made NetplayJS to work for a Boardgame!

myagoo commented 1 month ago

This is the best issue I’ve ever encountered