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

  1. 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).
  2. When the opponent accepts and picks a hand, it is sent to everyone (play). If they decline, only the challenger is told (decline, sendTo).
  3. When the challenger's screen receives the opponent's value, it reveals the original hand and the secret string (reveal).
  4. 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.subtle is 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 before reveal, 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) return false and null respectively. For details, see Skin UI API.

Still not sure how it works? Ask your question on the contact board.

Business name UCHAT · CEO 김경욱 · Business registration no. 141-05-32832
Address 경기도 파주시 와석순환로 61 · Mail-order business registration no. 2013-경기파주-4485호
Phone 010-8677-8633 · Email support@uchat.io

ⓒ 2013–2026 UCHAT. All rights reserved.