Reveal hidden Codenames card colors using game WebSocket data.
// ==UserScript==
// @name Codenames X-Ray
// @namespace https://github.com/im-yellow
// @version 1.0.1
// @description Reveal hidden Codenames card colors using game WebSocket data.
// @author Y-ellow (Discord: @y.lw)
// @match https://codenames.game/*
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const OriginalWebSocket = window.WebSocket;
const PREFIX = 'codenames_colors_';
const COLORS = ['blue', 'red', 'neutral', 'black'];
const TEAMS = ['red', 'blue'];
const ROLES = ['operatives', 'spymasters'];
const state = {
roomId: null,
playerId: null,
credentials: null,
stateId: null,
team: null,
role: null,
rememberedTeam: null,
rememberedRole: null,
gameCards: [],
socket: null,
showColors: false,
lockSent: new Set()
};
window.codenames = state;
function storageKey(roomId) {
return PREFIX + roomId;
}
function validColor(color) {
return COLORS.includes(color);
}
function savedGame(roomId = state.roomId) {
if (!roomId) return null;
try {
const data = JSON.parse(
localStorage.getItem(storageKey(roomId))
);
if (
!data ||
!Array.isArray(data.cards) ||
data.cards.length !== 25
) {
return null;
}
if (data.cards.some(card =>
!card ||
typeof card.word !== 'string' ||
!validColor(card.color)
)) {
return null;
}
return data;
} catch {
return null;
}
}
function saveGame() {
if (!state.roomId || state.gameCards.length < 25) {
return false;
}
const cards = [];
for (let i = 0; i < 25; i++) {
const card = state.gameCards[i];
if (
!card ||
typeof card.word !== 'string' ||
!validColor(card.color)
) {
return false;
}
cards.push({
word: card.word,
color: card.color
});
}
localStorage.setItem(
storageKey(state.roomId),
JSON.stringify({
roomId: state.roomId,
cards
})
);
return true;
}
function loadGame(roomId) {
const saved = savedGame(roomId);
if (!saved) return false;
state.gameCards = saved.cards.map((card, id) => ({
id,
word: card.word,
color: card.color,
revealed: false
}));
return true;
}
function deleteGame(roomId) {
if (roomId) {
localStorage.removeItem(storageKey(roomId));
}
}
function setRoom(roomId) {
if (!roomId) return;
roomId = String(roomId);
if (state.roomId === roomId) return;
state.roomId = roomId;
state.stateId = null;
state.gameCards = [];
state.showColors = false;
state.rememberedTeam = null;
state.rememberedRole = null;
loadGame(roomId);
updateButton();
}
function resetGame() {
deleteGame(state.roomId);
state.roomId = null;
state.stateId = null;
state.team = null;
state.role = null;
state.rememberedTeam = null;
state.rememberedRole = null;
state.gameCards = [];
state.showColors = false;
updateButton();
}
function card(id) {
if (id < 0 || id > 24) return null;
if (!state.gameCards[id]) {
state.gameCards[id] = {
id,
word: null,
color: null,
revealed: false
};
}
return state.gameCards[id];
}
function updateCard(id, data) {
const target = card(id);
if (!target || !data) return;
if (data.word !== undefined) {
target.word = data.word;
}
if (data.color !== undefined) {
target.color = data.color;
}
if (data.revealed !== undefined) {
target.revealed = data.revealed;
}
}
function updateGrid(grid) {
if (!Array.isArray(grid)) return;
for (const item of grid) {
if (
!item ||
item.index === undefined ||
item.index < 0 ||
item.index > 24
) {
continue;
}
updateCard(item.index, {
word: item.word,
color: item.color,
revealed: item.revealed
});
}
}
function updateTeam(teams) {
if (
state.playerId === null ||
state.playerId === undefined ||
!teams
) {
state.team = null;
state.role = null;
return;
}
const player = `p#${state.playerId}`;
for (const team of TEAMS) {
for (const role of ROLES) {
if (teams[team]?.[role]?.[player]) {
state.team = team;
state.role = role;
return;
}
}
}
state.team = null;
state.role = null;
}
function rememberTeam() {
state.rememberedTeam = state.team;
state.rememberedRole = state.role;
}
function hasAllWords() {
if (state.gameCards.length < 25) return false;
return state.gameCards.every(card =>
card &&
typeof card.word === 'string'
);
}
function captureColors(operations) {
let changed = false;
for (const op of operations) {
const match = op?.path?.match(
/^\/G\/grid\/(\d+)\/color$/
);
if (!match) continue;
const id = Number(match[1]);
if (
id >= 0 &&
id <= 24 &&
validColor(op.value)
) {
updateCard(id, {
color: op.value
});
changed = true;
}
}
if (!changed) return false;
if (!hasAllWords()) return false;
for (let i = 0; i < 25; i++) {
if (!validColor(state.gameCards[i]?.color)) {
return false;
}
}
return saveGame();
}
function send(packet) {
if (!state.socket) return false;
try {
state.socket.send(
'42/cno2,' + JSON.stringify(packet)
);
return true;
} catch {
return false;
}
}
function move(type, args) {
const playerID = String(state.playerId);
return [
'update',
{
type: 'MAKE_MOVE',
payload: {
type,
args,
playerID,
credentials: state.credentials
}
},
state.stateId,
state.roomId,
playerID
];
}
function sendUnlockSequence() {
if (
!state.socket ||
!state.roomId ||
state.playerId === null ||
state.playerId === undefined ||
!state.credentials ||
state.stateId === null ||
state.stateId === undefined ||
savedGame() ||
state.lockSent.has(state.roomId) ||
!hasAllWords()
) {
return;
}
rememberTeam();
const team = state.rememberedTeam;
const role = state.rememberedRole;
send(
move('setLockTeams', [false])
);
state.lockSent.add(state.roomId);
send(
move('joinTeam', [
'red',
'spymasters'
])
);
if (
(team === 'red' || team === 'blue') &&
(role === 'operatives' || role === 'spymasters')
) {
send(
move('joinTeam', [
team,
role
])
);
} else {
send(
move('leaveTeam', [
String(state.playerId)
])
);
}
}
function processSync(message) {
const roomId = message[1];
const data = message[2];
if (roomId) {
setRoom(roomId);
}
if (!data?.state) return;
const game = data.state;
if (game._stateID !== undefined) {
state.stateId = game._stateID;
}
if (game.G) {
updateTeam(game.G.teams);
updateGrid(game.G.grid);
}
sendUnlockSequence();
}
function processPatch(message) {
const roomId = message[1];
const stateId = message[3];
const operations = message[4];
if (roomId) {
setRoom(roomId);
}
if (
state.roomId &&
String(roomId) !== String(state.roomId)
) {
return;
}
if (stateId !== undefined) {
state.stateId = stateId;
}
if (!Array.isArray(operations)) return;
for (const op of operations) {
if (!op?.path) continue;
if (op.path === '/_stateID') {
state.stateId = op.value;
continue;
}
if (op.path === '/G/teams') {
if (op.op === 'replace') {
updateTeam(op.value);
}
continue;
}
const teamMatch = op.path.match(
/^\/G\/teams\/(red|blue)\/(operatives|spymasters)\/(.+)$/
);
if (teamMatch) {
continue;
}
let match = op.path.match(
/^\/G\/grid\/(\d+)$/
);
if (match && op.value) {
updateCard(Number(match[1]), {
word: op.value.word,
color: op.value.color,
revealed: op.value.revealed
});
continue;
}
match = op.path.match(
/^\/G\/grid\/(\d+)\/color$/
);
if (match) {
updateCard(Number(match[1]), {
color: op.value
});
continue;
}
match = op.path.match(
/^\/G\/grid\/(\d+)\/revealed$/
);
if (match) {
updateCard(Number(match[1]), {
revealed: op.value
});
}
}
const saved = captureColors(operations);
if (
saved ||
(!savedGame() && hasAllWords())
) {
sendUnlockSequence();
}
if (state.showColors) {
applySavedColors();
}
}
function processMatchData(message) {
const roomId = message[1];
if (roomId) {
setRoom(roomId);
}
sendUnlockSequence();
}
function processPlayNextMatch(message) {
resetGame();
if (message[1]) {
setRoom(message[1]);
}
sendUnlockSequence();
}
function processIncoming(data) {
if (
typeof data !== 'string' ||
!data.startsWith('42/cno2,')
) {
return;
}
try {
const message = JSON.parse(data.slice(8));
if (!Array.isArray(message)) return;
switch (message[0]) {
case 'sync':
processSync(message);
break;
case 'patch':
processPatch(message);
break;
case 'matchData':
processMatchData(message);
break;
case 'playNextMatch':
processPlayNextMatch(message);
break;
}
} catch {}
}
function processOutgoing(data) {
if (
typeof data !== 'string' ||
!data.startsWith('42/cno2,')
) {
return;
}
try {
const message = JSON.parse(data.slice(8));
if (!Array.isArray(message)) return;
if (message[0] === 'sync') {
if (message[1]) {
setRoom(message[1]);
}
if (
message[2] !== undefined &&
message[2] !== null
) {
state.playerId = String(message[2]);
}
if (message[3]) {
state.credentials = message[3];
}
return;
}
if (message[0] !== 'update') return;
const payload = message[1]?.payload;
if (!payload) return;
if (
payload.playerID !== undefined &&
payload.playerID !== null
) {
state.playerId = String(
payload.playerID
);
}
if (payload.type === 'joinTeam') {
const [team, role] = payload.args || [];
if (
TEAMS.includes(team) &&
ROLES.includes(role)
) {
state.team = team;
state.role = role;
}
return;
}
if (payload.type === 'leaveTeam') {
state.team = null;
state.role = null;
}
} catch {}
}
function getCards() {
return [
...document.querySelectorAll(
'article[style*="--CardColor"]'
)
].slice(0, 25);
}
function setCardColor(id, color) {
if (
id < 0 ||
id > 24 ||
!validColor(color)
) {
return;
}
const card = getCards()[id];
if (!card) return;
card.style.setProperty(
'--CardColor',
`var(--${color}-cardBg)`
);
card.style.setProperty(
'--CardBorder',
`var(--${color}-cardBorder)`
);
card.style.setProperty(
'--InnerBorder',
`var(--${color}-innerBorder)`
);
const inner = card.querySelector(
'section.mx-auto'
);
if (inner) {
inner.style.background =
`var(--${color}-innerBg)`;
}
const word = card.querySelector(
'article.font-barlowCondensed'
);
if (word) {
word.style.backgroundColor =
`var(--${color}-darkWordBg)`;
}
}
function applySavedColors() {
const saved = savedGame();
if (!saved) return false;
const cards = getCards();
if (cards.length < 25) return false;
for (let i = 0; i < 25; i++) {
setCardColor(
i,
saved.cards[i].color
);
}
return true;
}
function hideColors() {
const cards = getCards();
if (cards.length < 25) return;
for (let i = 0; i < 25; i++) {
if (
!state.gameCards[i] ||
!state.gameCards[i].revealed
) {
setCardColor(i, 'neutral');
}
}
}
function toggleColors() {
if (!savedGame()) {
alert(
'No saved colors for this game. Try refreshing the page.'
);
return;
}
state.showColors = !state.showColors;
if (state.showColors) {
applySavedColors();
} else {
hideColors();
}
updateButton();
}
function updateButton() {
const button = document.getElementById(
'codenames-show-colors'
);
if (button) {
button.textContent =
state.showColors
? 'Hide Colors'
: 'Show Colors';
}
}
function createButton() {
if (
document.getElementById(
'codenames-show-colors'
)
) {
return;
}
const news = [
...document.querySelectorAll('button')
].find(
button =>
button.textContent.trim() === 'News'
);
if (!news?.parentElement) return;
const button = document.createElement('button');
button.id = 'codenames-show-colors';
button.type = 'button';
button.className = news.className;
button.textContent = 'Show Colors';
const stop = event => {
event.preventDefault();
event.stopPropagation();
};
button.addEventListener('click', event => {
stop(event);
toggleColors();
});
button.addEventListener('mousedown', stop);
button.addEventListener('pointerdown', stop);
news.parentElement.insertBefore(
button,
news
);
updateButton();
}
function startUI() {
createButton();
const observer = new MutationObserver(() => {
createButton();
if (state.showColors) {
applySavedColors();
}
});
observer.observe(
document.documentElement,
{
childList: true,
subtree: true
}
);
}
function hookWebSocket(ws) {
state.socket = ws;
ws.addEventListener(
'message',
event => processIncoming(event.data)
);
const originalSend = ws.send.bind(ws);
ws.send = function (data) {
processOutgoing(data);
return originalSend(data);
};
}
class CodenamesWebSocket
extends OriginalWebSocket {
constructor(...args) {
super(...args);
hookWebSocket(this);
}
}
window.WebSocket = CodenamesWebSocket;
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
startUI,
{ once: true }
);
} else {
startUI();
}
})();