Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How should a machine interact with websocket? #549

Closed
glenndixon opened this issue Jul 16, 2019 · 7 comments
Closed

How should a machine interact with websocket? #549

glenndixon opened this issue Jul 16, 2019 · 7 comments

Comments

@glenndixon
Copy link

glenndixon commented Jul 16, 2019

Bug or feature request?

Question

Description:

Apologies for the open-endedness of this question. Hopefully if there are other people wondering about this they can find this issue and gain some insight.

I'm working on a real-time multiplayer game that interacts with the server via a websocket connection (socket.io). My problem is I'm not really sure how best to make xstate and websockets work together. I've tried several approaches but none seem right.

  • How does the machine maintain the handle to the socket? How does the machine emit to the socket?

  • How should the machine subscribe to receive messages from the socket?

Approaches I've considered:

  1. Create the socket instance outside the machine. Set up a handler in my application code that sends events to the interpreter when it receives an event from the socket.

Problem: machine won't be able to access socket in actions if a side effect needs to emit to the socket.

  1. Process actions completely outside the machine i.e. execute: false on my interpreter and iterate over the actions in an onTransition handler. Also have socket's event handler outside the machine which can .send to the interpreter when the socket receives an event.

Problem: too much is handled outside of machine.

  1. Use callback service at top level of machine to manage websocket connection. Invoke callback to send events to the machine and onEvent to receive messages that the socket should emit. Example:
import io from 'socket.io-client';

Machine({
  initial: 'initializing',
  context: {
    playerName: null,
    playerId: null
  },
  invoke: {
    id: 'socket',
    src: (context, event) => (callback, onEvent) => {
      const socket = io();

      socket.on('connected', function() {
        callback('SOCKET_CONNECTED');
      });

      socket.on('join_successful', function(msg) {
        callback({ type: 'PLAYER_JOIN_SUCCESSFUL', playerId: msg.playerId })
      });

      ....

      onEvent(e => {
        switch(e.type) {
          case 'EMIT_JOIN_GAME':
            socket.emit('JOIN_GAME', { playerName: context.playerName });
            break;
          case 'EMIT_QUIT_GAME':
            socket.emit('QUIT_GAME', { playerId: context.playerId });
            break;
          ...
        }
      });
    }
  },
  states: {
    initializing: {
      on: {
        SOCKET_CONNECTED: 'join_screen'
      }
    },
    join_screen: {
      on: {
        PLAYER_CLICKED_JOIN: {
          target: 'joining',
          actions: assign({playerName: (c, e) => e.playerName })
        }
      }
    },
    joining: {
      onEntry: send('EMIT_JOIN_GAME', { to: 'socket' }),
      on: {
        PLAYER_JOIN_SUCCESSFUL: {
          target: 'game_screen',
          actions: assign({playerId: (c, e) => e.playerId })
        }
      }
    },
    game_screen: {
      on: {
        PLAYER_CLICKED_QUIT: {
          actions: send('EMIT_QUIT_GAME', { to: 'socket' }),
          target: 'join_screen'
        }
      }
    }
  }
});

Problems: Seems weird to have a service at the top level of the app? Not sure how this will scale in terms of complexity in a real-world application that might have to send/receive many different types of messages.

Seems like #3 is the best approach but can't find any literature confirming/rejecting this. Any help/examples on this would be greatly appreciated.

@Andarist
Copy link
Member

IMHO 3rd option is the best here. If the code for handling a socket gets complicated you could split things further and extract this to a machine which would be able to handle different types of messages based on socket's connection states etc.

@glenndixon
Copy link
Author

glenndixon commented Jul 16, 2019

Thanks for replying @Andarist. I've tried implementing a machine similar to my example in option 3 and I ran into an issue. It appears xstate doesn't simply update the context object when you modify the context with assign({playerName: (c, e) => e.playerName }). Rather, the context object is replaced by a new object so my service is no longer able to access the current context since the context passed to the service at invocation is out-of-date.

Demo:

import { Machine, interpret, send, assign } from 'xstate';

let serviceContext = null;

const m = Machine({
  initial: 'initializing',
  context: {
    playerName: null,
    playerId: null
  },
  invoke: {
    id: 'socket',
    src: (context, event) => (callback, onEvent) => {
      serviceContext = context;

      setTimeout(function() {
        callback({ type: 'SOCKET_CONNECTED' })
      }, 1000);

      onEvent(e => {
        console.log(e);

        switch(e.type) {
          case 'EMIT_JOIN_GAME':
            console.log("emitting send_join with context.playerName = ", context.playerName);

            setTimeout(function() {
              callback({ type: 'PLAYER_JOIN_SUCCESSFUL', playerId: 17 })
            }, 3000);
            break;
          case 'EMIT_QUIT_GAME':
            console.log("context = ", context);
            console.log("emitting quit_game with context.playerId = ", context.playerId);

            setTimeout(function() {
              callback('QUIT_SUCCESSFUL')
            });
            break;
        }
      });
    }
  },
  states: {
    initializing: {
      on: {
        SOCKET_CONNECTED: 'join_screen'
      }
    },
    join_screen: {
      on: {
        PLAYER_CLICKED_JOIN: {
          target: 'joining',
          actions: assign({playerName: (c, e) => e.playerName })
        }
      }
    },
    joining: {
      onEntry: send('EMIT_JOIN_GAME', { to: 'socket' }),
      on: {
        PLAYER_JOIN_SUCCESSFUL: {
          target: 'game_screen',
          actions: assign({playerId: (c, e) => e.playerId })
        }
      }
    },
    game_screen: {
      on: {
        PLAYER_CLICKED_QUIT: {
          actions: send('EMIT_QUIT_GAME', { to: 'socket' }),
          target: 'join_screen'
        }
      }
    }
  }
});
let service;
service = interpret(m).onTransition(function(nextState) {
  if (service) {
    // this outputs "true" the first transition but "false" on subsequent transitions, demonstrating
    // the context object passed the service diverges from the the one on the service
    console.log("serviceContext === service.state.context?", serviceContext === service.state.context);
  }
}).start();


setTimeout(function() {
  service.send('PLAYER_CLICKED_JOIN', { playerName: 'glenn' });
}, 1500);

setTimeout(function() {
  console.log("done")
}, 5000);

I expected emitting send_join with context.playerName = glenn but got emitting send_join with context.playerName = null. (I checked and confirmed the current context of the interpreter (edited) is successfully updated with the proper playerName value)

Is there some way the callback service can access the current context when it receives an event via onEvent. OR is there some way a side effect can send the values it needs from the context where it's doing send('EMIT_JOIN_GAME', { to: 'socket' }).

@Andarist
Copy link
Member

Andarist commented Jul 17, 2019

I expected emitting send_join with context.playerName = glenn but got emitting send_join with context.playerName = null. (I checked and confirmed the current context of the interpreter (edited) is successfully updated with the proper playerName value)

Think of a context as of redux state - it's immutable value, so whenever you update it with assign it creates a "copy" of the previous one, with changes applied ofc. This isn't enforced for nested values, you can mutate smth and it might stay unnoticed but I would encourage you to never mutate it. Immutable data is easier to reason about.

So actually what is given to your invoked callback service is a context snapshot - so you only have data from that point in time (invocation time).

To handle this here you can use expression as your event like this (link):

send(
  ctx => ({
    type: "EMIT_JOIN_GAME",
    playerName: ctx.playerName
  }),
  { to: "socket" }
)

Or you can use similarly event's payload because it carries that name - so maybe you don't even need to store playerName in the context in this case? it depends on your other needs though (link):

send(
  (ctx, ev) => ({
    type: "EMIT_JOIN_GAME",
    playerName: ev.playerName
  }),
  { to: "socket" }
)

@glenndixon
Copy link
Author

Ah, I didn't realize you could pass a function to the send action creator to access the context. That's great. Thank you so much.

@Andarist
Copy link
Member

I couldn't find it in the docs either - had to find it in the source code. This is something you could mention in #552

@glenndixon
Copy link
Author

Looks like it is mentioned in the docs and an example is given: https://xstate.js.org/docs/guides/actions.html#send-action

@Andarist
Copy link
Member

Oh, I had to miss it while scanning the docs quickly. All good then 👌

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants