Introduction
Pub-sub lets one part of the game announce that something happened without directly calling the user interface. Other parts listen for that named event and react. “Pub-sub” is short for publish-subscribe: one part publishes a message, and one or more subscribers receive it.
In the Battleship project, an attack changes game state and the page must reflect the result. The direct approach would make the game-flow module call a rendering function. That works, but it gives the rules layer knowledge of the UI layer.
This repository introduces a small pub-sub object between them. The game-flow module publishes a named event, and the display controller subscribes to that name. The publisher knows the event name, but it does not import or call the display handler directly. This reduces one direct dependency between the game rules and the page rendering code.
The event registry
src/pubsub.js creates one exported pubsub instance. Internally, its private #events field is a Map:
#events = new Map();
Each map key is an event name. Each value is a Set of handler functions. A handler is a function that runs when its event is published. The three operations are small but meaningful:
subscribe(eventName, handler)creates a set when the event is first seen, then adds the handler.unsubscribe(eventName, handler)removes that exact function reference when the event exists.publish(eventName, data)invokes each registered handler withdata.
Optional chaining makes publishing or unsubscribing an unknown event a no-op. Because handlers are stored in a Set, subscribing the same function reference twice does not make it run twice. Different function objects with identical bodies are still different handlers.
Tracing the real updateBoard event
The clearest path begins with a click on the enemy board. The following sequence shows what happens:
UIController.jsreads the grid coordinates and callsgameFlow.attackEnemy([x, y]).attackEnemyasks the receiving player to process the attack.- If the result is valid and the fleet is not sunk,
gameFlow.jspublishes"updateBoard". displayerController.jshas already subscribedupdateGameBoardsto that event.- The handler asks
gameFlow.getBoardData()for the current public and private board views. - It replaces both rendered grid containers, then
attackEnemycontinues and switches the turn.
The published payload is currently an empty string, and updateGameBoards ignores its argument. The handler reads the needed state through getBoardData() instead. That is documented by the current source, not an inferred data contract.
The project has a second event, "updateDisplay", published when a fleet is sunk. Its subscriber selects the game-over rendering path. This article follows updateBoard so one event can be traced without turning the explanation into a catalogue.
Publication is synchronous here
Nothing in this pub-sub implementation introduces a Promise, timer, or event-loop phase. publish() calls Set.forEach(), and each handler runs before publish() returns.
A reproducible isolated check against the current source produced this order:
A { x: 2, y: 4 }
B { x: 2, y: 4 }
B again
In that demonstration, handlers A and B subscribed in that order. Adding A twice still called it once because the registry uses a Set. After unsubscribing A, only B received the second publication. Publishing an unknown event produced no output.
That demonstrated behavior matches the source’s control flow. It is not a claim that every pub-sub library dispatches synchronously; other implementations may queue or await delivery.
What separation the project gains
For this event path, gameFlow.js no longer names updateGameBoards or manipulates the DOM. Its responsibility is to decide that a valid attack changed displayable state and publish the contract. The display controller decides how to obtain board data and update the page.
This gives the project several practical benefits:
- the attack rules do not call a concrete rendering function;
- the event bus can be tested with small handlers independently of the DOM;
- the display handler can be reasoned about as a reaction to state change; and
- another subscriber could be added without editing
attackEnemy.
The separation is not absolute. displayerController.js imports gameFlow.js to read state, and UIController.js directly imports both game-flow and display modules for other actions. The repository demonstrates reduced coupling around selected transitions, not a completely event-driven architecture.
The costs are part of the design
The string "updateBoard" is an implicit contract. This means that both modules must agree on the event name and expected data, even though that agreement is not enforced by a shared function declaration. A spelling change in either module would silently disconnect the path because an unknown event is a no-op. Central constants or typed event definitions could make a larger system easier to refactor, although the current repository does not implement them.
Execution order also matters. Subscribers run synchronously in Set iteration order, and an exception from one handler is not caught by publish(). Such an exception would escape and prevent normal continuation. The bus also ignores handler return values.
unsubscribe() exists, but subscribe() does not return a cleanup function. The current subscriptions are created once at module load and live for the page lifetime. In a longer-lived application that repeatedly creates screens or components, forgetting to remove obsolete handlers could retain references and cause duplicate reactions or memory growth. That is a general risk, not a demonstrated leak in this repository.
Finally, debugging can require searching for an event name across modules rather than following a direct function call. A breakpoint in attackEnemy shows publish("updateBoard"), but the next function is determined by registry state.
Pub-sub and observer are related, not identical
The terms overlap in everyday JavaScript discussions, but their usual structures differ. In both patterns, one part reacts to a change. The difference is where the relationship is stored:
| Pattern | Typical relationship |
|---|---|
| Observer | a subject directly maintains and notifies its observers |
| Pub-sub | publishers and subscribers communicate through a separate broker or event channel |
This project fits the pub-sub description because the shared pubsub singleton owns the registry. gameFlow does not hold a reference to updateGameBoards; both modules know the broker and the event name.
Node’s EventEmitter and the Web platform’s EventTarget are useful comparisons for named event registration and synchronous dispatch, but Battleship uses neither. Its behavior comes from the project’s own Map and Set implementation.
Reflection
The useful lesson was not that pub-sub removes every dependency. It moves a particular dependency—from a direct function call to a named event contract. In this game, that makes the boundary between “the attack changed state” and “redraw the boards” easier to see. It also introduces a new responsibility: keep event names, handler lifetime, and synchronous ordering understandable.
Key takeaways
- Battleship stores event names in a private
Mapand handlers in per-eventSetobjects. publish()invokes subscribers synchronously and passes one data argument.- A valid non-winning attack publishes
updateBoard;updateGameBoardsreads state and redraws both boards before the turn switches. - The bus reduces direct game-logic-to-renderer coupling without making every module independent.
- Event names, execution order, errors, and subscription cleanup become architectural concerns.
- Pub-sub usually introduces a broker; observer usually describes a subject directly tracking observers.