Publish match statistics that update independently
PubNub's Pub/Sub model carries each statistic change to every viewer as it happens. Message Persistence holds the latest value of each one, so a viewer who arrives mid-match can fill a scoreboard instead of waiting for the next change. This tutorial teaches one design: give each statistic its own channel. That design only works because Message Persistence is enabled on your keyset, so the value of a statistic that hasn't changed in an hour is still there to read.
What you'll build
scoreboard.js subscribes once to the wildcard game.match-stats.* and fetches the latest stored value of each statistic from Message Persistence. statsFeed.js publishes each change to that statistic's own channel, such as game.match-stats.score, and stores it. PubNub delivers the change through the wildcard subscription, and the channel name tells the scoreboard which statistic changed. A scoreboard that starts later reads the current values from Message Persistence in one call.
Before you begin
You need:
- Node.js 22 or later.
- A PubNub account and a keyset. A keyset is the set of publish, subscribe, and secret keys that identifies your application to the PubNub network. If you don't have one yet, follow Set up your account first.
- Message Persistence enabled on that keyset in the Admin Portal, with a retention window chosen. Message Persistence retention is 1 or 7 days on the Free plan, 30 days, 3 months, or 6 months on Starter, and 1 year or Unlimited on Pro.
Set up the project
Create a project and install the PubNub JavaScript SDK:
mkdir match-stats-tutorial
cd match-stats-tutorial
npm init -y
npm install pubnub
Open package.json and add "type": "module". Then create two files: statsFeed.js, the script that publishes statistic updates, and scoreboard.js, the script that receives and displays them.
Add this to the top of both files, then replace YOUR_PUBLISH_KEY and YOUR_SUBSCRIBE_KEY with the keys from your keyset:
import PubNub from 'pubnub';
const pubnub = new PubNub({
publishKey: 'YOUR_PUBLISH_KEY',
subscribeKey: 'YOUR_SUBSCRIBE_KEY',
userId: 'match-service',
});
In scoreboard.js, change userId to 'fan-42'.
Give each statistic its own channel
You could publish the whole scoreboard as one message on one channel. Every goal would then republish the possession figure, the shot count, and the cards along with the score, because there's only one message to send. A viewer who renders only the score would still receive, and pay the bandwidth for, every other field in it too. Splitting the scoreboard into one channel per statistic fixes both problems. A change to one number costs one small message, and a client subscribes only to the statistics it actually renders.
| Statistic | Channel |
|---|---|
| Score | game.match-stats.score |
| Possession | game.match-stats.possession |
| Shots | game.match-stats.shots |
| Cards | game.match-stats.cards |
A layout like this only stays manageable because a client can subscribe to every one of these channels with a single wildcard subscription, game.match-stats.*, instead of naming each one. That's the next step. PubNub bounds how many dot-separated levels a wildcard pattern can match. Before you add another level to this hierarchy, such as splitting score further, check the current bound in API limits. The layout on this page, game.match-stats.<statistic> matched by game.match-stats.*, fits inside it today.
The stats feed publishes each change to its own channel, and one wildcard subscription delivers all of them to the viewer. Message Persistence keeps the latest value of each channel, so a viewer who joins later fills the scoreboard with a single call.
Publish one statistic
Add this to statsFeed.js:
1
storeInHistory is what makes this value survive in Message Persistence, so a viewer who arrives after this publish can still read it. customMessageType, set here to match-stat, labels the publish so a subscriber can branch on the label without inspecting the payload. Refer to Send different message types for the full set of publish options this call can take.
Receive every statistic with one subscription
Add this to scoreboard.js:
1
game.match-stats.* is a wildcard pattern, not a channel you could publish to, and this subscription is still a single Subscription object, built the same way any other one is. It receives events from every channel currently matching the pattern, including one that starts carrying traffic after you subscribed. Because one handler now covers four different channels, the channel name on each event, not the payload shape, is what tells you which statistic just changed. Refer to Subscriptions and subscription sets for how a subscription like this one relates to a SubscriptionSet covering several unrelated entities at once.
Fill the scoreboard when a viewer arrives
Add this to scoreboard.js:
1
Asking for one stored message per channel gives you the current value of every statistic in a single call, the payoff of splitting the scoreboard in the first place. A single-channel scoreboard would need only one such call too, but every field in it would be as stale, or as fresh, as the least recently changed one. That's because they all live in the same message. A single fetchMessages call accepts far more channels than this scoreboard uses, so room to grow isn't a concern here. Refer to Retrieve message history for the exact per-call channel ceiling and how to page through more.
Send only what changed
Publish a statistic when it changes, not on a fixed interval. A timer that republishes every statistic every few seconds sends messages for numbers that haven't moved, which costs bandwidth and processing on both ends for no new information. The only thing a fixed interval buys you is a message that arrives even when nothing happened. Some applications use that message as a liveness signal, confirming the feed is still connected. If you need that signal, send it deliberately and separately from the statistics themselves, rather than letting it dictate how often real changes get published.
Run it
Run the scoreboard first:
node scoreboard.js
It fetches the current value of every statistic, prints nothing if none have been published yet, and then waits.
In a second terminal, run the stats feed:
node statsFeed.js
The feed's terminal confirms the publish:
score published at timetoken: 17123456789012345
The scoreboard's terminal shows the change arriving through the wildcard subscription. The channel name supplied the statistic name, and the payload supplied the value:
score is now { value: '3-1' }
Now prove the catch-up path. Stop the scoreboard with Ctrl+C. In statsFeed.js, change the channel to game.match-stats.possession and the value to '54%', then run the feed again while nothing is listening. Restart the scoreboard, and it reads the current value of every statistic from Message Persistence, including the one that changed while it was stopped:
score is currently { value: '3-1' }
possession is currently { value: '54%' }
What happened
- The scoreboard subscribed once to
game.match-stats.*and started receiving events from every channel matching that pattern. - The stats feed published a change to one statistic, on that statistic's own channel, with
storeInHistoryset. - PubNub delivered the change to the scoreboard's wildcard subscription, and the handler used the channel name to update the right field.
- After you restarted the scoreboard, it called
fetchMessagesonce across every statistic channel and filled in the current value of each, including the one that changed while it wasn't running.
Next steps
- Live commentary. Publish a one-way broadcast feed and catch up a viewer who joins late.
- Send different message types. The full set of options
publish()takes, includingcustomMessageType. - API limits. Wildcard subscribe depth, message size, and other ceilings this design runs inside.