Question
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:
Problem: machine won't be able to access socket in actions if a side effect needs to emit to the socket.
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.
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.
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.
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' }).
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" }
)
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.
I couldn't find it in the docs either - had to find it in the source code. This is something you could mention in https://github.com/davidkpiano/xstate/issues/552
Looks like it is mentioned in the docs and an example is given: https://xstate.js.org/docs/guides/actions.html#send-action
Oh, I had to miss it while scanning the docs quickly. All good then 馃憣
Most helpful comment
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):
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):