Original BY Luke Wood, translated BY New Frontend, licensed BY CC BY-NC 4.0.
I am the sole author of the web game Bulletz. IO. Recently I refactored the front-end code to better fit the back-end code. The back-end code uses the functional programming language Elixir and the front end uses native JavaScript. The front and back end programming languages are very different, based on very different programming paradigms.
The front-end was originally written based on a generic object-oriented model, resulting in a lot of technical debt, complex interface interactions, and confusing code.
I spent an evening rewriting the front-end code using an event-driven model, and the results were great. The library tiny-PubSub was used for the refactoring.
Over time, god objects appeared in my front-end code, and antipatterns were everywhere. This article will tell you how this giant class came to be and how I finally solved the problem with Tiny-PubSub.
Original model
The original state management model adopts object-oriented model. There is a central StateManager class that manages the state of the entire game, assigning entities to sub-management classes.
These submanagement classes attempt to infer the current state of the entity based on the most recent update pushed by the server via websocket. This allows the game to show the real-time status of each entity using very little traffic.
Over time, this has led to a host of problems, mostly focused on readability and maintainability. The states of various entities become entangled over time, making it difficult to investigate state-related bugs.
The biggest anti-pattern of this old model is to have a God object — a top-level state management class. Eventually this state management class takes care of things and is passed as a parameter to a bunch of user interface functions.
Here’s the code that handled displaying the number of active players on the old system: player_counter.js
const player_count = document.getElementById("score-div");
function update_player_counter(state_handler) {
const score = state_handler.player_registry.get_players().length;
player_count.innerText = `${players}/ 20 `; }export {update_player_counter}
Copy the code
This update_player_counter function needs to be called every time a player is born and dies. This function appears three times in all the code. Twice in player_registery.js:
import {update_player_counter} from '.. /.. /ui/update_player_counter'
class PlayerRegistry {
constructor(state_handler) {
this.state_handler = state_handler
}
...
add_player(player) {
...
update_player_counter(this.state_handler)
}
...
remove_player(player) {
...
update_player_counter(this.state_handler)
}
}
Copy the code
Once at state_handle.js:
import {update_player_counter} from '.. /.. /ui/update_player_counter'
class StateHandler {
...
listen_for_polls() {
update_socket.on("poll", (game_state) => { ... update_player_counter(this); })}... }Copy the code
This example alone isn’t that bad, but because you need to call these functions in all of your user interface interaction logic, it ends up making your code hard to understand and maintain. User interface interaction and state management are highly coupled, and other classes are ultimately responsible for triggering user interface updates.
The state_handler is ultimately responsible for triggering user interface updates, almost everything. Almost every method, user interface interaction, and so on needs to store a copy of state_handler. This means that every time an event listener is registered, a state_handler is stored nearby. State_handler appears in 70% of the files in the entire front-end code.
Use Tiny Pubsub to decouple the code
I shamelessly advertise here that I have written a javascript library to solve this problem: Tiny-PubSub. It is nothing special, but maintains the relationship between events and the functions that need to be called in response to them. Functions respond to data sent elsewhere rather than being explicitly called.
It does, however, take advantage of the event-driven programming paradigm to encourage decoupled code.
Here is a complete example:
import {subscribe, publish, unsubscribe} from 'tiny-pubsub'
import {CHATROOM_JOIN} from './event_definitions'
let logJoin = (name) => console.log(`${name}Into the room! `); subscribe(CHATROOM_JOIN,logJoin)
publish(CHATROOM_JOIN, "Luke") // > Luke enters the room! unsubscribe(CHATROOM_JOIN,logJoin)
publish(CHATROOM_JOIN, "Luke") // Nothing will print // You can also use a string as the event identifier subscribe("chatroom-join".logJoin)
publish("chatroom-join"."Luke") // > Luke enters the room!Copy the code
The reconstructed model
In terms of code organization, the refactored code is significantly more distributed. In the new model, each entity is represented by a series of callbacks defined in a single file. Callbacks respond to published events and update the state of the entity. These events are published by other self-contained modules that are only responsible for publishing events.
For example, the tick.js file looks like this:
import {TICK} from '.. /events'
import {game_time} from '.. /util/game_time'
function game_loop() {
publish(TICK, game_time());
requestAnimationFrame(game_loop)
}
document.addEventListener("load", game_loop);
Copy the code
Each event file is only responsible for one event. Some events are triggered by other events, and the data is modified slightly for use by other modules.
User interface interactions are also handled by self-contained modules. Here’s a new version of score.js:
import {subscribe} from 'tiny-pubsub'
import {PLAYER, POLL, PLAYER_DEATH} from '.. /events'
import {get_players} from '.. /entities/players'
const player_count = document.getElementById("score-div");
const update_player_count = ({players: players}) => player_count.innerText = `${get_players().length}/ 20 `; subscribe(PLAYER, update_player_count); subscribe(PLAYER_DEATH, update_player_count); subscribe(POLL, update_player_count);Copy the code
State management is also implemented by small self-contained modules. Here is an example of bullet state management:
import {subscribe} from "tiny-pubsub"
import {BULLET, POLL, REMOVE_BULLET, TICK} from '.. /events'
import {update_bullet} from './update_bullet'
import {array_to_map_on_key} from '.. /util/array_to_map_on_key'/ / stateletbullets = {}; // Subscribe (BULLET, BULLET => Bullets [BULLET. Id] = BULLET) subscribe(TICK, (current_time, world) => { bullets = bullets .map(bullet => update_bullet(bullet, current_time, world)) .filter(bullet => bullet ! = null); }) subscribe(POLL, ({ bullets: bullets_poll }) => { bullets = array_to_map_on_key(bullets_poll,"id"}) SUBSCRIBE (REMOVE_BULLET, (id) => Delete Bullets [id]) // the exposed functionfunction get_bullets() {
return Object.keys(bullets).map((uuid) => bullets[uuid])
}
export {get_bullets}
Copy the code
Everything is self-contained and simple. The following organization diagram shows the event-based front-end architecture.
Application effects of event-driven programming
Applying the event-driven model to rewrite Bulletz. IO results in highly decoupled logic. The refactored code is significantly simpler and easier to understand, and also fixes some user interface bugs. User interface updates and status updates are written as self-contained modules in response to data emitted elsewhere.
If you’re planning on writing web pages out of frameworks these days, I recommend learning about event-driven programming! The library I wrote to solve this problem is called Tiny-PubSub, and the GitHub link is LukeWood/ Tiny-PubSub.
Also, don’t forget to try bulletz.io.