Hey there,
As I continue to move forward with the multiplayer poker game, much of the in-game logic on both client- and server-side is now complete. I’m in the phase of cleaning up code, smoothing out functionality, and squashing ππ
While learning to us the Socket.IO library for client/server communications, there’s once thing I’ve noticed about its event handlers:
They don’t provide a way to specify an context (this
) that the callback handlers should use.
Most of the time, it’s assumed the handler is used with an arrow function like this:
socket.on("event-name", (...args) => {
// ...
});
Now, If I’m using this inside a class, the scope (context, or this
) will reference the class, which is what I want.
Note: Socket.IO uses Node’s EventEmitter class for the server-side, while on the client-side, the component-emitter library is used. However, neither of these event libraries allow you to specify a context, like the way EventEmitter3 allows, which is also what Phaser 3 uses (and I’ve become spoiled by, heh π )
But there’s another problem with this approach. Say you want to later turn off the event handler. You won’t be able to because there is no reference to the callback function. You could instead write something like this:
const callback = (...args) => {
// ...
}
socket.on("event-type", callback);
// and then later...
socket.off("event-type", callback);
The issue here is that callback
is a local function, and it’s eventually lost when it goes out of scope.
Even if the callback is a method of a class, this
will reference the function, not the class. So not even this code will work as expected:
class SomeClass {
...
public callback(): void {
// ... 'this' will NOT reference this class instance
}
public setupSocket(socket): void {
socket.on("event-type", someClass.callback);
}
public disposeSocket(socket): void {
socket.off("event-type", someClass.callback);
}
...
}
This is where the bind
function comes in. This function allows you to specify what context the this
will reference. In this case, we want it to reference the class instance itself.
class SomeClass {
...
private boundCallback;
public callback(): void {
// ... now 'this' will reference this class instance
}
public setupSocket(socket): void {
this.boundCallback = this.callback.bind(this);
socket.on("event-type", someClass.callback);
}
public disposeSocket(socket): void {
socket.off("event-type", this.boundCallback);
}
...
}
Looking at the previous code, the first highlighted line shows how a bound callback was created, by wrapping the original callback
method, and making the this
reference the class as expected.
Buuuuuuut!
There’s an issue with this approach as well! π
What is we want to setup another socket with this class? Each call to setupSocket
to overwrite the previous boundCallback
reference. It would be nice for each bound callback to be mapped to the corresponding socket. That, plus it’d be nice to not have to litter the code with a bunch of bind
calls or get static
classes or methods involved…
EventsManager Class
Let’s introduce an EventsManager
class β
This will allow us to gracefully solve all the issues at once:
- Specify a context of our choice
- Keep references to bound callbacks
- Allows adding and removing of handlers
- Keeps code nice and tidy
type Callback = (...args: unknown[]) => void;
interface IEventData {
// the callback that will be bound to the original callback meth of the EventsManager class
boundCallback: Callback;
// reference to the user's custom callback
userCallback: Callback;
// the event type
id: string;
// specifies if the event should only fire once
isOnce: boolean;
// what `this` should point to
context: unknown;
}
class EventsManager {
private eventsDataById: Map<string, IEventData[]>;
private socket;
constructor(socket) {
this.eventsDataById = new Map();
this.socket = socket;
}
}
A few new things here. π
Note: The pseudocode is influenced by Typescript, such as type aliases and interfaces, but you can make the same thing in JavaScript.
The Callback
type is just a shortcut for specifying callback handler functions.
The IEventData
interface is a template that we’ll use to define plain objects that should have all the specified properties.
The eventsDataById
property is a Map
of key/value pairs. Each key is a string (the event id type), and the data is an array of IEventData
objects. This will make it possible to assign (and keep track of) multiple handlers to the same event type.
We’ll also be specifying event frequency (one-time events vs continual events).
But this code alone doesn’t allow us to do anything practical yet. Let’s add our own version of the on
method, the event emitter de facto standard if adding a handler.
The EventsManager.on
Method
on(eventId: string, callback: Callback, context: unknown, isOnce?: boolean): void {
// get the array of events handler data based on the specified event id. if it doesn't
// exist, create an empty array and add it
let eventsData = this.eventsDataById.get(eventId);
if (!eventsData) {
eventsData = [];
this.eventsDataById.set(eventId, eventsData);
}
// create event handler data for this new event being added
// also create the bound callback, wrapping around the original callback method
const eventData: IEventData = {
// when binding the callback function, pass the event id. this will allow us to
// reference the event data in the callback to access the user callback and
// context
boundCallback: this.callback.bind(this, eventId),
userCallback: callback,
id: eventId,
context,
isOnce,
};
// add the new event handler data to the array (the array is stored in the map)
eventsData.push(eventData);
// specify the BOUND callback in the socket
this.socket.on(eventId, eventData.boundCallback);
}
In the above code, we’re adding the event handler onto the socket as usual. However, its callback will point to the newly bound callback. But how do we call our own callback function that was specified in the on method? π€
And, how do we use the context that was also specified? π€
Well, the best part happens in the callback method itself…
To be continue in part 2…!
…
Nah, I’m just kidding, read on to find out!
The EventsHandler.callback Method
private callback(eventId: string, ...args: unknown[]): void {
const eventsData = this.eventsDataById.get(eventId);
let length = eventsData.length;
let index = 0;
while (index < length) {
const eventData = eventsData[index];
if (eventData.isOnce) {
// if this is a one-time event handler, remove it
this._eventTarget.off(eventId, eventData.boundCallback);
eventsData.splice(index, 1);
length -= 1;
} else {
index += 1;
}
eventData.userCallback.call(eventData.context, ...args, this);
}
if (length === 0) {
// it's possible that removing the one-time handler caused the eventsData array
// to become empty. if so, we can remove it from the map. it's not strictly
// necessary, but it helps to keep things clean ^_^
this.eventsDataById.delete(eventId);
}
}
All events that we’re listening for will call this callback
function. Since it’s bound to this class, this
will point to our class instance of EventsManager
.
When a socket event handler fires this function, it’ll loop through all the event handlers that we attached onto it, and call them in the order they were added. Any one-time handlers are removed after they’re called.
Let’s have a look at the first line, the function signature:
private callback(eventId: string, β¦args: unknown[]): void...
You’ll see the eventId
is the first argument. This is the argument we specified when we created the bound callback in line 16 of the on
method. Here, it’s followed by an ...args
parameter. This is for any parameters that are passed in by the socket handler.
Now, have a look at line 18. This is where the user’s callback is called, with context they wanted. The call
method is another method of the Function
object, like bind
. The call
method allows invokes a function while allowing us to specify a particular context on it – in our case, the one specified in the on
method, and later saved in the IEventData
object.
call
also allows us to specify any parameters. Here, we want pass along the arguments received from the socket’s handler.
The EventsHandler.off Method
Since we can listen for events with the on method, we need a way to un-listen to them. We’ll make our own off method for this.
off(eventId: string, callback: Callback, context?: unknown, isOnce?: boolean): void {
// get the specified events data array based on the event id. if not found,
// exit early
const eventsData = this.eventsDataById.get(eventId);
if (!eventsData) {
return;
}
for (let index = eventsData.length - 1; index >= 0; index -= 1) {
const eventData = eventsData[index];
// if removing handlers by callback, check that it matches. if not, skip
if (callback !== undefined && callback !== eventData.userCallback) {
continue;
}
// if removing handlers by context, check that it matches. if not, skip
if (context !== undefined && context !== eventData.context) {
continue;
}
// if removing handlers by frequency, check that it matches. if not, skip
if (isOnce !== undefined && isOnce !== eventData.isOnce) {
continue;
}
// turn the event handler off in the socket, referencing our bound callback
this.socket.off(eventId, eventData.boundCallback);
// remove this events data object from the array
eventsData.splice(index, 1);
}
if (eventsData.length === 0) {
this.eventsDataById.delete(eventId);
}
}
In this code, we first need to get access to the events data array, based on the event id. However, unlike the on
method, the array must exist before continuing.
When removing event listeners, it will optionally only remove listeners that have a specific callback, context, or frequency.
Finally, if this were to be used in your application, you could do something simple like this:
class YourClass {
private socketEvents: EventsManager;
constructor(socket) {
this.socketEvents = new EventsManager(socket);
}
public foo() {
socketEvents.on(eventId, this.onSocketEvent, this);
}
private onSocketEvent(args) {
// do stuff here
}
}
That’s pretty much it for a bare-bones version of this code.
You could do more to it, like add a removeAll
method, which completely removes all event handlers – excellent for cleaning up the EventsManager
class when you’re done. You could also build on this to handler server-side as well as client-side events, as they all use on
, off
, and once
methods. But this is a very short class that makes event handling using sockets a helluva lot easier, IMO. π
And I continue onward with the poker game! I’ll keep you updated as I make major milestones. Though I’ll admit, client work has got me super-busy these days, but continue to stay tuned in. I appreciate your patience! ππΎ
– C. out.
Are you planning to share your code for those who are learning Phaser?
I’d appreciate!
Hey Yuriy,
I do plan on doing so.
Currently, the poker game will eventually be a trio of GitHub repos: one for the server, one for the client, and one for common code shared among both.
Right now, it’s still a bit of a mess under the hood.
Also, thanks for the sub!
– C.