Holiday Hack 2025 Appendix A: Really Hacking HolidayHack
Introduction
Given that the Holiday Hack Challenge is a hacking challenge, I had a lot of fun this year hacking the game itself. There’s no rule against it, and the CounterHack team has always seemed to enjoy seeing how people approach it.
Of course this makes the game easier. Having the ability to teleport or walk through walls makes the game quicker to play. But there is an additional benefit, and that’s getting to understand the technology behind the website and how it works.
This year I created a Tampermonkey script, HolidayHackHack.js, that starts with my previous improvements and adds some killer new features. The full source is available on my GitLab.
Prior Work
TamperMonkey is a browser plugin that allows you to create JavaScript that is injected into specific webpages. The script metadata specifies what pages are targeted. This allows users to create scripts that change the experience of pages they don’t have control over.
My first TamperMonkey script was in 2019, where I found it hard to find items and even terminals in the world with all the other players visible. There was no option in the badge to hide other players at the time, so I wrote a script which I showcased in Holiday Hack 2019: Appendix A: Hide Others TamperMonkey. This one is no longer useful since that functionality has been added to the game settings.
In 2020 for the Santavator challenge I wrote a TamperMonkey that would highlight items in the game by adding red circles around them:
I built on this for the 2022 Holiday Hack where I showed how to inspect HTML elements, network traffic, and WebSocket messages to understand the game structure in the post Holiday Hack 2022 Appendix B: Hacking KringleCon. The script that year was pretty basic, just an overlay showing entity locations:
Last year, I shared a script that would add a button to launch terminals in their own window:
For anyone playing #holidayhack, new TamperMonkey script that will add a button at the bottom right corner to open the terminal in a new tab. Can be helpful to avoid context issues in the dev tools. Button goes away with when "Close" button clicked.https://t.co/vkiDQKF7pj pic.twitter.com/GyK3dLHE1r
— 0xdf (@0xdf_) November 11, 2024
This year, I spent a lot of time rewriting everything from scratch to add new features.
Grabbed a couple hours of #HolidayHack time when I should have been going to bed. Did I solve any challenges? Nope. Attempt any? Still nope.
— 0xdf (@0xdf_) November 6, 2025
But man my tampermonkey script is getting awesome.
Items, terminals, doors, NPCs list - ✅.
Teleport within scenes- ✅.
Understanding the Game Architecture
Before diving into the features, it’s worth understanding how the game works. KringleCon is built on React, which means all the game state lives in a centralized store that React components read from and write to.
Finding React State
React components attach internal data to DOM elements via special properties. The first step is to find an element I know exists in the game - like the player character - and look at what properties it has.
Using the browser’s Element Inspector, I can see that game entities have the class ent, and my player has both ent and player. In the console, I can grab this element and look at its properties:
__reactInternalInstance$ and __reactEventHandlers$ are React’s internal properties that it attaches to DOM elements to track which component rendered them. The random suffix (like $y2qe14j5fx) is a unique identifier that React generates when it initializes on the page. This namespacing prevents conflicts if multiple copies of React were running on the same page - each instance would have its own unique suffix. The suffix stays consistent for a page session but changes on each reload.
The __reactInternalInstance$ (or __reactFiber$ in newer React versions) property contains a “fiber node”, React’s internal representation of the component. I can access it by using the key I just found:
React’s fiber tree is a linked structure. Each fiber has a return property pointing to its parent component. By walking up this tree, I can find the root of the application, which holds the global state store:
I traverse up the tree until I find a component where stateNode.store exists. This store holds all game state. Calling getState() on it reveals a bunch of useful stuff:
The state object includes:
scene- current room data including the walkability grid, origin coordinates, and room namesession- user session info including my user ID- Entity positions for all players, NPCs, terminals, and items
With this understanding, the script uses the following code during initialization to find the store:
function findReactStore() {
const playerElt = document.querySelector('.ent.player');
if (!playerElt) return null;
const reactKey = Object.keys(playerElt).find(k =>
k.startsWith('__reactInternalInstance$') || k.startsWith('__reactFiber$')
);
if (!reactKey) return null;
let current = playerElt[reactKey];
while (current && current.return) {
current = current.return;
if (current.stateNode?.state && current.stateNode?.store) {
return current.stateNode.store;
}
}
return null;
}
Dispatching Actions
React stores use a dispatch pattern, sending WebSocket messages to manage the game’s state:
The Chrome dev tools Network tab offers a filter on “Socket”:
Clicking into it shows the messages being exchanged:
The two action types I’ll make use of are:
SET_LOCATIONS- moves an entity to new coordinatesAAANNNDD_SCENE- loads a new room’s data including the walkability grid
As I showed in the Holiday Hack 2022 post, the AAANNNDD_SCENE message includes a grid value that tells the game where I can and cannot walk:
This data can also be accessed at store.getState().scene.grid.
This string represents a grid the same size as the current map, where space is not walkable, and “1” is. For example in the Retro Emporium:
Entities
The main game HTML has a div with the class camera. Inside of that are divs for each item that isn’t on the background of the game, to include characters (NPCs), terminals, items, and doors (as well as scenery like tables or other things not built into the background).
Most entity locations are stored as data-location="x,y", where typically 0,0 is at the top right of the screen, and going right and down increases x and y. Doors are slightly different, as each door has four divs, and they don’t have data-location. Their location can be parsed from the style tag.
The origin offset is important, as the grid coordinates usually but don’t always start at 0,0. The scene data includes an origin point that I need to add to world coordinates to get grid coordinates.
Features
Improved Overlay
The new overlay at the top left of the screen shows all entities (NPCs, terminals, doors, items) with their coordinates, sorted by distance from the player:
These are parsed out of the HTML data above. The key improvements over last year include:
- Toggle visibility of entity types (👤 NPCs, 🖥️ terminals, 🚪 doors, 📦 items) with counts.
- Added a scroll bar for when the list gets long.
- The ↕ button shrinks the overlay when it’s getting in the way.
The overlay updates every 0.5 seconds, though typically only the player location updates.
These items also can be used to trigger teleport, and there are some buttons as well for the minimap and walking through walls which I’ll cover in future sections.
Minimap
The minimap renders the room’s walkability grid as a small canvas in the corner. It shows the current player as a red dot, NPCs as blue dots, terminals as purple, doors as yellow, items as orange, and unwalkable areas as green.
For something like the Retro Emporium it’s not super complicated:
For the larger map, it’s scaled way down:
Clicking anywhere on the minimap teleports me to the nearest walkable position to that click.
Teleport
The teleport feature lets me instantly move anywhere in the current room by dispatching a SET_LOCATIONS action:
store.dispatch({type: "SET_LOCATIONS", loc: { [105459]: [[70,44]] }});
This will teleport my user (with ID 105459) to the location 70, 44 on the map. If I teleport to a non-walkable tile, the game crashes and reloads. Before teleporting, I’ll always check the walkability grid. Within the script, the code looks like:
const Teleport = {
to(x, z, force = false) {
if (!State.store || !State.myUserId) {
console.log('[Teleport] Not initialized');
return;
}
if (!force && !Grid.isWalkable(x, z)) {
console.log(`[Teleport] Position [${x},${z}] is not walkable`);
return;
}
State.store.dispatch({
type: 'SET_LOCATIONS',
loc: { [State.myUserId]: [[x, z]] }
});
console.log(`[Teleport] Teleported to [${x},${z}]`);
},
near(x, z, maxRadius = 10) {
const pos = Grid.findNearestWalkable(x, z, maxRadius);
if (pos) {
console.log(`[Teleport] Found walkable [${pos[0]},${pos[1]}] near [${x},${z}]`);
this.to(pos[0], pos[1]);
} else {
console.log(`[Teleport] No walkable position within ${maxRadius} of [${x},${z}]`);
}
}
};
The Teleport.near function will get the closest walkable space near the target location.
I’ve added the teleport to both the overlay and the minimap. Clicking on an entity in the overlay will teleport to the closest walkable space. Clicking on a space in the minimap will do the same!
Walk Anywhere
At the very start of the game this year, while working on the overlay, I noticed a large block of unwalkable snow at the right side of the main map with characters in it. I’ll explore this in Appendix B, but it inspired a “Walk Anywhere” mode for the plugin, which lets me walk through walls and into normally inaccessible areas.
I wasn’t able to find where I could edit in the React state to make this change, but I was able to do it by replaying the AAANNNDD_SCENE message. When the game loads a room via WebSocket, it sends an AAANNNDD_SCENE message containing the grid. I intercept this by wrapping the WebSocket constructor:
init() {
const OrigWebSocket = window.WebSocket;
window.WebSocket = function(url, protocols) {
const ws = protocols ? new OrigWebSocket(url, protocols) : new OrigWebSocket(url);
State.gameWebSocket = ws;
ws.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'AAANNNDD_SCENE' && data.areaData?.grid) {
State.originalGridData = data.areaData.grid;
if (State.walkAnywhereEnabled) {
setTimeout(() => WalkAnywhere.applyModifiedGrid(), 100);
}
}
} catch (e) { /* ignore parse errors */ }
});
return ws;
};
// ... preserve WebSocket prototype and constants
}
When walk anywhere is enabled, I replace all spaces in the grid with 1s and send another message:
applyModifiedGrid() {
if (!State.store || !State.originalGridData) return;
const state = State.store.getState();
if (!state.scene) return;
const newGrid = State.originalGridData.replace(/ /g, '1');
State.store.dispatch({
type: 'AAANNNDD_SCENE',
areaData: { ...state.scene, grid: newGrid }
});
}
This does cause the screen to reload the scene again, but then I can walk anywhere.
When disabling Walk Anywhere mode, if I’m standing on a tile that’s non-walkable in the original grid, I first teleport to the nearest safe position before restoring the grid. Otherwise, the game would crash.
Other Features
The plugin does include a few other quality-of-life improvements:
- Avatar visibility toggle - Click player name to hide my avatar (useful for screenshots).
- Terminal new-tab link - Adds a button when a terminal overlay comes up to open terminal challenges in a new tab.
- Datacenter labels - Labels the maze doors in the datacenter to show which path leads back to the city