Rock paper scissors
An example where you challenge someone to rock paper scissors from the nickname menu, verify the values both sides sent, and announce the result.
Last updated October 17, 2026
On this page
Challenge someone to rock paper scissors from their nickname menu, and show the result on everyone's screen in the chat room. Each challenge has a unique ID and an expiry time, and each screen calculates the result from the values the two players sent themselves.
Flow
- The challenger clicks Rock paper scissors in the opponent's nickname menu and picks a hand. The hand is not revealed right away; only its hash (SHA-256) is sent to everyone (
start). - When the opponent accepts and picks a hand, it is sent to everyone (
play). If they decline, only the challenger is told (decline,sendTo). - When the challenger's screen receives the opponent's value, it reveals the original hand and the secret string (
reveal). - Every screen checks that the revealed value matches the original hash and shows the result.
Code
<script>U=window.U=window.U||{},U.events=U.events||[],U.chat=function(n){return{on:function(e,t){U.events.push([n,e,t])},off:function(e){for(var t=U.events.length;t>0;)U.events[--t][0]==n&&U.events[t][1]==e&&U.events.splice(t,1)}}};</script>
<script async src="https://client.uchat.io/uchat.js"></script>
<u-chat room="my-room" style="display:inline-block;width:100%;max-width:400px;height:500px"></u-chat>
<script>
U.chat('my-room').on('after.create#rps-demo', function (room) {
var ui = room.skin;
var channel = room.plugin.add('rps-demo');
var TO_ALL = room.plugin.TO_ALL;
var ONLY_ME = room.plugin.ONLY_ME;
var HANDS = { r: '✊ Rock', p: '✋ Paper', s: '✌️ Scissors' };
var LIMIT = 60000; // How long a challenge is valid (milliseconds)
var games = {}; // Challenge ID → progress (spectators keep their own record too)
var secrets = {}; // Challenge ID I started → { hand, salt }
function esc(value) {
return String(value).replace(/[&<>"']/g, function (c) {
return '&#' + c.charCodeAt(0) + ';';
});
}
function hex(bytes) {
return Array.from(bytes, function (b) {
return b.toString(16).padStart(2, '0');
}).join('');
}
function random(size) {
return hex(crypto.getRandomValues(new Uint8Array(size)));
}
function commitOf(id, hand, salt) {
var text = new TextEncoder().encode(id + ':' + hand + ':' + salt);
return crypto.subtle.digest('SHA-256', text).then(function (buffer) {
return hex(new Uint8Array(buffer));
});
}
function winner(a, b) {
if (a === b) return 0;
return (a === 'r' && b === 's') || (a === 's' && b === 'p') || (a === 'p' && b === 'r') ? 1 : 2;
}
function alive(game) {
return !!game && !game.done && room.now() <= game.expires;
}
function pick(title) {
return ui.dialog.select(title, 'Pick your hand.', HANDS, 'r');
}
// 1. Challenge: hide the hand and send only its hash to everyone
ui.userMenu.add({
id: 'rps-demo.challenge',
label: 'Rock paper scissors',
condition: function (ctx) {
return !!ctx.target && !!ctx.room.my.nick && ctx.target.nick !== ctx.room.my.nick;
},
onSelect: function (ctx) {
var target = ctx.target.nick;
pick('Challenge ' + target).then(function (hand) {
if (!HANDS[hand]) return;
var id = random(8);
var salt = random(16);
secrets[id] = { hand: hand, salt: salt };
return commitOf(id, hand, salt).then(function (commit) {
channel.send(['start', id, target, String(room.now() + LIMIT), commit]);
});
});
},
});
// 2. Opponent: on accept, send the hand to everyone; on decline, tell only the challenger
function answer(game) {
ui.dialog.confirm('Rock paper scissors', game.a + ' challenged you to rock paper scissors. Play?').then(function (ok) {
if (!ok) return decline(game);
return pick('Match against ' + game.a).then(function (hand) {
if (!HANDS[hand]) return decline(game);
if (!alive(game)) return room.print('Time ran out, so the game was canceled.');
channel.send(['play', game.id, hand]);
});
});
}
function decline(game) {
game.done = true;
if (room.now() <= game.expires) channel.sendTo(game.a, ['decline', game.id]);
}
function showResult(game, handA) {
var w = winner(handA, game.handB);
var text =
esc(game.a) + ' ' + HANDS[handA] + ' vs ' + HANDS[game.handB] + ' ' + esc(game.b) + ' → ';
room.print(text + (w === 0 ? 'Draw' : esc(w === 1 ? game.a : game.b) + ' wins!'));
}
channel.onReceived(function (room, data) {
// The server attaches the sender's nickname, so trust it instead of any name inside the data
var from = data.user && data.user.nick;
var msg = data.data;
if (!from || !Array.isArray(msg)) return;
var game = games[msg[1]];
if (msg[0] === 'start' && data.type === TO_ALL) {
var now = room.now();
var expires = Number(msg[3]);
if (game || !/^[0-9a-f]{16}$/.test(msg[1])) return; // Each ID only once
if (typeof msg[2] !== 'string' || msg[2] === from) return;
if (!(expires > now && expires <= now + LIMIT + 5000)) return; // Expired or too long
if (!/^[0-9a-f]{64}$/.test(msg[4])) return;
game = games[msg[1]] = { id: msg[1], a: from, b: msg[2], expires: expires, commit: msg[4] };
if (game.a === room.my.nick) {
room.print('You challenged ' + esc(game.b) + ' to rock paper scissors.');
setTimeout(function () {
if (game.done) return;
game.done = true;
room.print('No response, so the game was canceled.');
}, expires - now + 1000);
}
if (game.b === room.my.nick) answer(game);
return;
}
// Accept only the value the opponent sent themselves, and only once
if (msg[0] === 'play' && data.type === TO_ALL) {
if (!alive(game) || from !== game.b || game.handB || !HANDS[msg[2]]) return;
game.handB = msg[2];
var secret = secrets[game.id];
if (game.a === room.my.nick && secret) channel.send(['reveal', game.id, secret.hand, secret.salt]);
return;
}
// Show the result only when the challenger's own reveal matches the original hash
if (msg[0] === 'reveal' && data.type === TO_ALL) {
if (!alive(game) || from !== game.a || !game.handB || !HANDS[msg[2]]) return;
if (typeof msg[3] !== 'string') return;
commitOf(game.id, msg[2], msg[3]).then(function (commit) {
if (commit !== game.commit || game.done) return;
game.done = true;
showResult(game, msg[2]);
});
return;
}
if (msg[0] === 'decline' && data.type === ONLY_ME) {
if (!game || game.done || from !== game.b || game.a !== room.my.nick) return;
game.done = true;
room.print(esc(from) + ' declined.');
}
});
});
</script>
What gets checked
| Check | What it prevents |
|---|---|
| A random ID per challenge (16 hex digits); each ID is registered only once | Resending an old challenge or mixing it with another |
The expiry time is later than now and no further than LIMIT plus 5 seconds of slack |
Never-ending challenges and stale messages |
play only from the opponent (game.b), and only the first one |
A spectator playing for them, or the opponent changing their value |
reveal only from the challenger (game.a), and only when the hash matches |
The challenger changing their hand after seeing the opponent's |
decline only when it came to me alone (data.type === ONLY_ME) and from the opponent |
Someone else faking a decline |
The server fills data.user with the sender's info, so it can't be forged. Values inside data.data, on the other hand, can be made up by anyone, so don't read and trust values like nicknames from there.
Things to note
- Times are compared with
room.now()(server-based milliseconds), so everyone uses the same reference even if their computer clocks differ. crypto.subtleis available only on HTTPS pages.- If the challenger doesn't reveal because the result looks bad for them, the challenge ends by expiring. If your game must force a result, have your server judge it and send the result with the Server REST API.
- One round makes three sends to everyone (
start,play,reveal). In free chat rooms, each send to everyone counts against the limit once per connected user, so check Channel API and limits for busy rooms. - If the opponent changes their nickname before
play, or the challenger beforereveal, the challenge ends by expiring. If they change it after that, the result shows the old nickname. - When canceled, the dialogs (
dialog.confirm,dialog.select) returnfalseandnullrespectively. For details, see Skin UI API.
Still not sure how it works? Ask your question on the contact board.