Open a poll automatically when fans react
PubNub Illuminate watches a stream of messages, computes metrics from them, and runs an action when a metric crosses a condition you set. In this tutorial, fan reactions published to game.stream-reactions feed an Illuminate metric. When that metric crosses a threshold, an Illuminate Decision publishes a trigger to game.poll-triggers. Your own poll service then opens a poll on game.new-poll in response, with no one pressing a button.
Illuminate is a paid add-on. You can complete the reaction-publishing and poll-service code on this page without it. The Decision half, where Illuminate watches for the burst and fires the trigger, requires Illuminate to be active on your account.
What you'll build
Fans tap reactions in react.js, which publishes each one to game.stream-reactions. An Illuminate metric counts those reactions, and when the count crosses the threshold you set, an Illuminate Decision publishes a trigger to game.poll-triggers. Your poll-service.js subscribes to that channel and publishes a poll to game.new-poll, but only if it has not opened one in the last 60 seconds. Fans subscribed to game.new-poll then see the poll appear, with no one pressing a button.
Before you begin
You need:
- Node.js 22 or later.
- A PubNub account and your own keyset, with Message Persistence enabled, as set up in Run a live poll during a match. If you already have
game.new-pollpublishing working from that tutorial, you can skip ahead to Receive the trigger in your poll service and reuse it. - Illuminate available on your account. Illuminate runs on a two-week free trial. Continuing to use it after the trial requires a paid plan. See Availability and access.
Set up the project
Create a new directory and install the PubNub SDK:
mkdir automated-polling-tutorial
cd automated-polling-tutorial
npm init -y
npm install pubnub
Add "type": "module" to package.json, since this tutorial's code uses import.
Create three files:
react.js, which publishes a fan's reaction.poll-service.js, which receives Illuminate's trigger and opens a poll.send-trigger.js, a small script that publishes a trigger message by hand, standing in for Illuminate while you build and testpoll-service.js.
Add this to react.js:
import PubNub from 'pubnub';
const pubnub = new PubNub({
publishKey: 'YOUR_PUBLISH_KEY',
subscribeKey: 'YOUR_SUBSCRIBE_KEY',
userId: 'fan-42',
});
Add this to poll-service.js:
import PubNub from 'pubnub';
const pubnub = new PubNub({
publishKey: 'YOUR_PUBLISH_KEY',
subscribeKey: 'YOUR_SUBSCRIBE_KEY',
userId: 'poll-service',
});
Add this to send-trigger.js:
import PubNub from 'pubnub';
const pubnub = new PubNub({
publishKey: 'YOUR_PUBLISH_KEY',
subscribeKey: 'YOUR_SUBSCRIBE_KEY',
userId: 'match-service',
});
Replace YOUR_PUBLISH_KEY and YOUR_SUBSCRIBE_KEY in all three files with the keys from your keyset.
Publish what fans are reacting to
Add this to react.js:
await pubnub.publish({
channel: 'game.stream-reactions',
message: { reaction: '🔥' },
customMessageType: 'reaction',
});
Every tap publishes one small message. A reaction like this is also a good candidate for a signal instead of a message, since you never need to look one up later. This tutorial keeps it a message because Illuminate reads it the same way either source publishes it. For the difference and when to choose each, refer to Send different message types.
Configure the reaction-count metric in Illuminate
An Illuminate Business Object reads reactions published to game.stream-reactions. New Business Objects pre-map Channel and Message Type. Add a Reaction field with the String type before activation.
| Field | Source | Mapped automatically? |
|---|---|---|
| Channel | $.message.channel | Yes, pre-mapped |
| Message Type | $.message.body.type | Yes, pre-mapped |
| Reaction | $.message.body.reaction | No, add and map it |
Use a metric with Function set to Count, Dimension set to Reaction, and Period set to 1 minute. Filter for Channel Equals game.stream-reactions and Message Type Equals reaction. The dimension keeps each reaction's count separate.
Activate the Business Object after mapping Reaction. You cannot add data fields after activation. For the console steps, see Create a Business Object and Metrics.
Decide when a poll is worth opening
A Decision evaluates that metric and fires an action when it crosses a threshold. Create one that uses your reaction-count metric as its condition source, with a rule such as Count of Reactions greater than or equal to 15.
How many reactions in a minute justify opening a poll is your call, not a fixed product threshold. Fifteen is a starting point. Watch how your own audience reacts, and raise or lower it from there.
Add a Send Message action to the rule, targeting Channel ID game.poll-triggers with a Body of {"reaction": "${Reaction}"}. ${Reaction} injects whichever emoji's count crossed the threshold, so your poll service learns which reaction fired it. Follow Create a Decision for the console steps to build and activate a rule like this one.
You don't need this Decision active yet to continue. The rest of this tutorial builds and tests the code that receives its trigger, using a stand-in message that matches the same shape.
Open the poll a trigger asks for
Build the poll first, so the trigger handler has something to call. Add this to poll-service.js:
1
pollsByReaction maps each reaction your Decision can report to the poll you want that reaction to open. openPollForReaction looks up the template and publishes it to game.new-poll with storeInHistory: true, the same channel and pattern Run a live poll during a match covers. A reaction with no template logs and returns, so an unexpected emoji can't open an empty poll.
Stop one burst from opening ten polls
A burst of reactions crosses your Decision's threshold again and again while it lasts, and every crossing fires the action. Without a guard, one burst opens a poll, then another, then another, seconds apart.
Illuminate can limit this on its own side. Set the Send Message action's execution limit to Once per interval instead of the default Always. Doing this makes Illuminate fire it at most once per interval, regardless of how many times the rule matches. Refer to Action execution limit for the setting.
Add a guard in your own service too. It protects poll-service.js regardless of how the Decision is configured, including while you're still testing with a trigger you send by hand. Add this to poll-service.js:
1
shouldOpenPoll() returns true at most once every 60 seconds, and records when it last did so. When it blocks a trigger, it logs that it did, so a poll that never appeared is never a silent failure.
Receive the trigger in your poll service
Now wire the two together. Add this to poll-service.js:
1
pubnub.channel('game.poll-triggers').subscription() scopes a subscription to that one channel, and onMessage fires with the reaction field your Decision's Body sent. subscribe() activates it. The handler logs every trigger it receives, then opens a poll only if the cooldown allows one. So the log tells you both whether a trigger arrived and whether it did anything.
Run it
Add this to send-trigger.js, after its client setup:
const reaction = process.argv[2] || '🎉';
await pubnub.publish({
channel: 'game.poll-triggers',
message: { reaction },
customMessageType: 'poll-trigger',
});
console.log('sent a trigger for', reaction);
In one terminal, start the poll service:
node poll-service.js
It prints nothing and waits. The service has no work until a trigger arrives.
In a second terminal, send one by hand:
node send-trigger.js 🎉
In the poll-service.js terminal, you should see the trigger arrive and the poll go out:
open a poll because fans keep tapping 🎉
triggered poll published at timetoken: 17123456789011111
That publish also started the 60-second cooldown. Send a second trigger straight away, while the cooldown is still running:
node send-trigger.js 🎉
This time the trigger arrives but opens nothing:
open a poll because fans keep tapping 🎉
that trigger arrived inside the cooldown window, so no new poll opened
That is the guard doing its job. Wait a full 60 seconds from the first poll, send a third trigger, and it publishes again.
poll-service.js keeps running because its subscription holds the connection open. Press Ctrl+C to stop it.
What happened
send-trigger.jspublished a message togame.poll-triggers, standing in for the Send Message action your Illuminate Decision publishes for real once it's active.poll-service.js's subscription togame.poll-triggersreceived it and read thereactionfield.- The guard allowed the first trigger, because nothing had opened a poll yet, and
poll-service.jspublished a poll togame.new-poll. That is the same channel and shape Run a live poll during a match covers. - The guard blocked the second trigger, because it arrived inside the 60-second cooldown the first poll had begun, and said so in the log.
Once your Business Object, metric, and Decision are active, game.stream-reactions traffic from real fans replaces send-trigger.js, and the rest of this flow runs unchanged.
Next steps
- Run a live poll during a match. The poll-opening, voting, and results flow this tutorial's poll service builds on.
- Illuminate. How Business Objects, Decisions, and Dashboards fit together.
- Dashboards. Chart the reaction-count metric and the Decision's triggered actions together.
- Rate limiting. Throttle or shard
game.stream-reactionsitself if reaction volume grows large enough to need it.