Skip to main content
Markdown

Polls, quizzes and questions

A live session where the audience takes part: the host opens a poll and everyone votes, the counts move as votes come in, the host closes it and everyone sees the result. A quiz is a poll with a right answer, and each player gets their own score. Questions come in from the audience, the others upvote the ones they want answered, and the host marks them answered.

The pages send their votes, questions and upvotes, and your server counts them: it keeps one vote for each person, decides what counts, and sends the results to everyone. It sits beside anything live, a live stream, a class or a meeting in a room.

The rooms​

Two kinds of room, both named after the session: session:5, which everyone in session 5 hears, and a room of one for each person, session:5:from:42, where the page of user 42 sends to your server alone.

Your server decides who may join. A page that joins asks your server first, and a rule answers: a function your server registers once for every room whose name fits a pattern, such as session:{id}. WordPlus Cloud then holds the page to the answer (permissions). These rules say:

  1. The session lets in those who attend it, and counts them.
  2. Nobody sends into the session: your server alone speaks there, with the polls, the counts and the questions.
  3. The room of one lets in the person in its name alone, while they attend, and takes their votes, questions and upvotes to your server.
// 1. The session, such as session:5: those who attend it, counted.
// 2. It lists no `emit`: no page sends into it, your server alone does.
realtime.room( 'session:{id}', { count: true, history: 50 }, ( user, { id } ) => attends( id, user ) );

// 3. The room of one, such as session:5:from:42: that person alone, to your server.
const box = { emit: [ 'vote', 'ask', 'upvote' ], webhook: [ 'vote', 'ask', 'upvote' ] };
realtime.room( 'session:{id}:from:{user}', box, async ( user, p ) => {
if ( String( user ) !== p.user ) return false;
return attends( p.id, user );
} );
  • Why a room of one: a vote reaches your server and no other page. Nobody sees how the others voted before the results, nobody reads the answers of the others in a quiz, and a thousand votes are not sent to a thousand pages.
  • webhook brings each vote, question and upvote to your server, with the person who sent it as your server vouched for them, so one person is one vote.
  • count gives the session the number of people in it, live.
  • history carries the last 50 for 5 minutes, for a page that reconnects: a poll opened meanwhile, the counts since.
  • session:{id} never matches session:5:from:42, since a part of a pattern stops at :, so each room keeps its own rule. The ids of your users go in the name as they are: digits, or text without : or ..

Votes, questions and upvotes​

Each comes to your server by webhook, and your server decides what counts:

  1. A vote counts while its poll is open, one for each person: a new vote takes the place of their last.
  2. The counts go to everyone, at most once a second. Closing the poll sends the last ones.
  3. A question is kept, then shown to everyone, with the name of who asked.
  4. An upvote counts once for each person and question, and its count goes to everyone.
const sessionOf = ( room ) => room.split( ':' )[ 1 ];   // session:5:from:42

// 1. A vote, while its poll is open, one for each person.
realtime.on( 'vote', async ( { room, from, data } ) => {
const poll = await findPoll( data.poll );
if ( !poll?.open || poll.session !== sessionOf( room ) || !isChoice( poll, data.choice ) ) return;
await saveVote( poll.id, from, data.choice );

// 2. The counts to everyone, at most once a second.
if ( await resultsDue( poll.id ) ) {
await realtime.to( 'session:' + poll.session ).emit( 'results', { poll: poll.id, counts: await countVotes( poll.id ) } );
}
} );

// 3. A question, kept, then shown to everyone.
realtime.on( 'ask', async ( { room, from, data } ) => {
const question = await saveQuestion( sessionOf( room ), from, String( data.text ).slice( 0, 280 ) );
await realtime.to( 'session:' + question.session ).emit( 'question', { id: question.id, text: question.text, by: await nameOf( from ), votes: 0 } );
} );

// 4. An upvote, once for each person and question.
realtime.on( 'upvote', async ( { room, from, data } ) => {
const count = await upvoteOnce( sessionOf( room ), data.question, from ); // null for a question not in this session
if ( count !== null ) await realtime.to( 'session:' + sessionOf( room ) ).emit( 'votes', { question: data.question, count } );
} );
  • resultsDue() is yours: true at most once a second for a poll, from the time your cache keeps of its last results. Counts every second read as live, and a thousand votes in a second are one event to everyone, not a thousand.
  • The webhook comes at least once: keeping a vote, an upvote and a question by person and poll, or by the event_id, makes a repeat count nothing.
  • On localhost no webhook reaches your server, as a webhook needs an address on the internet: a tunnel gives your computer one while you build (while you develop).

The host​

The buttons of the host call routes of yours, which check it is the host:

  1. Opening a poll keeps it and sends it to everyone: its question and its choices.
  2. Closing it sends the last counts, and for a quiz the right answer, then each player their own score.
  3. Marking a question answered tells everyone, and moves it down the list.
// 5. The host opens a poll: { question, choices, correct? }, correct for a quiz.
app.post( '/sessions/:id/polls', async ( req, res ) => {
if ( !( await isHostOf( req.params.id, req.user.id ) ) ) return res.sendStatus( 403 );
const poll = await createPoll( req.params.id, req.body );
await realtime.to( 'session:' + req.params.id ).emit( 'poll', { id: poll.id, question: poll.question, choices: poll.choices } );
res.status( 201 ).json( { id: poll.id } );
} );

// 6. The host closes it: the last counts, the right answer, and each score.
app.post( '/sessions/:id/polls/:poll/close', async ( req, res ) => {
if ( !( await isHostOf( req.params.id, req.user.id ) ) ) return res.sendStatus( 403 );
const poll = await closePoll( req.params.poll );
await realtime.to( 'session:' + req.params.id ).emit( 'closed', { poll: poll.id, counts: await countVotes( poll.id ), correct: poll.correct ?? null } );
if ( poll.correct !== undefined ) {
const scores = await scoresOf( req.params.id ); // [ { user, score, rank } ]
await realtime.batch( scores.map( ( s ) => ( { users: s.user, event: 'score', data: { score: s.score, rank: s.rank } } ) ) );
}
res.sendStatus( 204 );
} );

// 7. The host marks a question answered.
app.post( '/sessions/:id/questions/:question/answered', async ( req, res ) => {
if ( !( await isHostOf( req.params.id, req.user.id ) ) ) return res.sendStatus( 403 );
await markAnswered( req.params.question );
await realtime.to( 'session:' + req.params.id ).emit( 'answered', { question: req.params.question } );
res.sendStatus( 204 );
} );
  • A score goes to each player alone, on every page and app they have open, sealed with their own key. batch() takes them in one call and sends each as its own emit, one after another: for a quiz of many players, send them from a background job, so the route answers the host at once (many at once).
  • On WordPress, the page calls these REST routes with the REST nonce, which wp.apiFetch sends, so the route knows the host.
  • A poll with a time limit: a job of your server closes it as its time ends, with the same route code. Send its end with the poll, ends_at, for the pages to count down.

The page​

The page of each person joins the session, to hear it, and their room of one, to send:

const session = rt.join( 'session:5' );
const mine = rt.join( 'session:5:from:' + me ); // to your server alone

session.on( 'count', ( n ) => ( here.textContent = n + ' here' ) );
session.on( 'poll', ( poll ) => showPoll( poll, ( choice ) => mine.emitWithAck( 'vote', { poll: poll.id, choice } ) ) );
session.on( 'results', ( { poll, counts } ) => showCounts( poll, counts ) );
session.on( 'closed', ( { poll, counts, correct } ) => showResult( poll, counts, correct ) );
session.on( 'question', ( q ) => addQuestion( q, () => mine.emitWithAck( 'upvote', { question: q.id } ) ) );
session.on( 'votes', ( { question, count } ) => setVotes( question, count ) );
session.on( 'answered', ( { question } ) => markAnswered( question ) );

askForm.onsubmit = () => mine.emitWithAck( 'ask', { text: askInput.value } );
rt.on( 'score', ( { score, rank } ) => showScore( score, rank ) ); // a quiz: this player alone
  • emitWithAck resolves once WordPlus Cloud has taken the vote, and rejects with its code where it could not (errors): a page can say Voted at once. The counts follow from your server.
  • A page that opens during the session reads the open poll and the questions so far from a route of yours, then follows the room.
  • The host page shows the same, and its buttons call the routes above.

Variations​

  • Beside a live stream: the polls in a column next to <live-stream> (live streaming), the host opening them from the studio page.
  • In a class or a meeting: the same rooms beside the call (classes, team meetings), the question asked on a call brought on to speak (classes).
  • Questions the host lets through: your server keeps a question, sends it to the host alone with toUser(), and to everyone once the host approves it.
  • Anonymous votes: your server keeps that a person voted, for one vote each, and only the counts of each choice.
  • In iOS and Android apps, the same rooms (iOS events, Android events).
  • On another stack, the guide of each sets up the routes and the webhook: Next.js, Express, Laravel, Django, FastAPI, WordPress.