Holiday Hack 2024: Elf Minder 9000
Introduction
At the left end of Frosty’s Beach I’ll find Poinsettia McMittens next to the Elf Minder 9000:
Poinsettia is struffing with this challenge:
Poinsettia McMittens
enter your mind, and become one with the island!
Relax…
This isn’t working! I’m trying to play this game but the whole “moving back to the North Pole” thing completely threw me off.
Say, how about you give it a try. It’s really simple. All you need to do is help the elf get to the exit.
The faster you get there, the better your score!
I’ve run into some weirdness with the springs though. If I had created this game it would’ve been a lot more stable, but I won’t comment on that any further.
Elf Minder 9000
Overview
The game starts with a menu showing 12 levels:
Clicking on a level gives an area of sand with some crates and a flag, as well as a menu on the right side:
I need to draw out a path from the start flag (green) to the finish flag (black and white checkered) collecting all the crates along the way, and complete the journey before the clock runs out.
Help
The Help popup shows the different rules:
I can rotate the path objects while the elf walks by clicking on them. The tunnels allow the elf to jump from one spot to the next:
Springs jump the elf in the current direction of travel:
The elf walks faster on the sizzling sand:
And slowly by the sleepy crabs:
Silver Solution
I can solve each level just by playing them to get silver.
Sandy Start
The first level is straight forward:
I’ve got some jumps and tunnels in there to speed things up, but all that’s necessary is a path to collect the crates and get to the flag.
Waves and Crates
The second level is straight forward as well:
I’ll use as much of the hot sand to speed things up as possible.
Tidal Treasures
Level three, I’ll use the tunnels to get from the top crate to the bottom, and then the jump to get past the crabs:
Dune Dash
This is the first time I’ll use the pivot mechanic:
I’ll collect the second crate, and then reverse back up toward the start. Then I rotate the path at [1] to turn down to the finish.
Coral Cove
The first time I solved this, I used the following path, rotating at [1] to get to the finish after grabbing the third crate:
A faster way is to use a spring:
The first time it hits the spring coming down, there’s no where to jump down, so it just continues with the turn to the left. On the way back, it can jump to the finish, so it does.
Shell Seekers
This is level is my lowest score, taking 749 of the available 999 ticks:
I wait for the elf to pass [1], and rotate it to horizontal:
Once the elf is coming back, I rotate at [2] so that they can get to the tunnel:
Palm Grove Shuffle
Palm Grove Shuffle is all about the springs. I’ll need two rotations at [1] and [2], each just after the elf passes for the first time:
Tropical Tangle
This one required a bit more spatial thought:
I’ll rotate [1] the first time the elf passes, creating a deadend to turn the elf around. The elf collects the third crate, comes back, turning around at [1]. While on [1], I’ll rotate [2] so they go to left into [3]. They get the forth and fifth crate:
Crate Caper
Crate Caper was where I learned that tunnel paths could rotate as well:
When the elf goes into the tunnel, I’ll rotate at [1] so that path deadends into the rock. The elf grabs the first crate and goes back into the tunnel. While they are back at [1], I’ll rotate [2] to the next crate. They hit the deadend and come back. I’ll repeat this for each crate, and then rotate [1] again to allow the elf to get to the finish.
Shoreline Shuffle
This one required a bit of trickieness as well. I’ll use the tunnels to grab the first two crates by turning [1] 180 degrees as soon as the elf enters:
The elf will grab the crate above [2], and then go back in coming out at [1] going up to get the next crate. I’ll turn [2] 180 degrees so the elf comes back out at [2] going down. The next spring is just for speed, landing at [3]. The elff hits the bottom of the square at [3] and turns up, hitting the spring up to get the last create. Then I turn [3] so that coming down the spring jumps the elf to the bottom line to the finish.
Beachy Bounty
This one looks very straight forward:
The only trick is that for some reason when the elf hits the spring, doesn’t jump, and turns left, it turns around and come back. I’ll do some rotations at the spring to get them to the last crate and then the finish.
Driftwood Dunes
There’s nothing complicated about this one, and the springs are only there for some extra speed:
Gold Solution
Challenge
On solving the 12 levels, it provides the Silver solve, and unlocks a 13th level on the main menu:
The board includes something that looks impossible:
There’s no way to jump or tunnel to the flag as all the spaces on the bottom and right sides have rocks.
Source Review
Overview
There are four files with JavaScript in them loaded for each level:
change.min.js
is minified and large, a good sign that’s not where I’m to be looking on a one snowflake challenge.
levels.js
has a JSON blob with the information for the various levels.
game2.js
game2.js
handles rendering the game. It’s 1334 lines long, but the part that catches my eye is renderHero
function starting at line 1246:
const renderHero = () => {
if (game.hero.journey && mode === 1) {
const [start, end] = game.hero.journey;
const dx = (end[0] - start[0]) * cellSize;
const dy = (end[1] - start[1]) * cellSize;
let x, y;
if (!game.hero.airtime) {
x = start[0] * cellSize + dx * game.hero.progress;
y = start[1] * cellSize + dy * game.hero.progress;
} else {
const jumpProgress = 1 - (game.hero.airtime / game.hero.airtimeStatic);
const jumpHeight = 500 * (game.hero.airtimeStatic / (7 * 10));
// interpolate between this.hero.cell and this.hero.journey[0]
const dx = game.hero.journey[0][0] - game.hero.cell[0];
const dy = game.hero.journey[0][1] - game.hero.cell[1];
x = game.hero.cell[0] * cellSize + (dx * cellSize * jumpProgress);
// make hero jump
y = game.hero.cell[1] * cellSize + (dy * cellSize * jumpProgress) - (Math.sin(jumpProgress * Math.PI) * jumpHeight);
}
game.hero.x = x;
game.hero.y = y;
console.groupEnd();
drawImageWithTransform(ImageAssets[`elf${game.hero.face || ''}`], x - ImageAssets.elf.width / 2, y - ImageAssets.elf.height / 2 - 14);
}
}
I’m interested in the jump because I know I need to get either the jump or the tunnel to move diagonally. If game.hero.airtime
is true, then it moves the hero along a path using game.hero.journey
as the guide. I need to know where that’s calculated.
guide.js
guide.js
is responsible for calculating all the movements within the game. At line 316, it loops over entities for the current position:
cellEntities.forEach(entity => {
switch(EntityTypesRef[entity[2]]) {
case 'crate':
if (typeof playSfx !== 'undefined') playSfx('pickup.mp3');
this.path.push(`${this.EMMEHGERDTICKSTHERESTICKSEVERYWHEREARHGHAHGREHUHGHH}: REMOVED CRATE`);
this.removeEntity(this.hero.journey[1], EntityTypes.CRATE);
break;
case 'spring':
const target = this.getSpringTarget(position);
if (target) {
const startSegments = this.getSegmentsThatTouchPoint(target);
if (!startSegments.length) {
return;
}
if (typeof playSfx !== 'undefined') playSfx('spring.mp3');
let newJourney = startSegments[0];
newJourney = newJourney[0][0] === target[0] && newJourney[0][1] === target[1] ? newJourney : [ newJourney[1], newJourney[0] ];
this.hero.progress = 0;
this.hero.newJourney = newJourney;
this.hero.airtime = Math.floor(this.pythag(position, newJourney[0]) * 5);
this.hero.airtimeStatic = this.hero.airtime;
}
break;
If it’s on a spring, it calls getSpringTarget
(line 266):
getSpringTarget(springCell) {
const journey = this.hero.journey;
const dy = journey[1][1] - journey[0][1];
const dx = journey[1][0] - journey[0][0];
let nextPoint = [ springCell[0], springCell[1] ];
let entityHere;
let searchLimit = 15;
let searchIndex = 0;
let validTarget;
do {
searchIndex += 1;
nextPoint = [ nextPoint[0] + dx, nextPoint[1] + dy ];
entityHere = this.entities.find(entity =>
~[
EntityTypes.PORTAL,
EntityTypes.SPRING,
].indexOf(entity[2]) &&
searchIndex &&
entity[0] === nextPoint[0] &&
entity[1] === nextPoint[1]);
if (searchIndex >= searchLimit) {
break;
}
validTarget = this.isPointInAnySegment(nextPoint) || entityHere;
} while (!validTarget);
if (this.isPointInAnySegment(nextPoint) || entityHere) {
if (entityHere) return this.segments[0][0]; // fix this
return nextPoint;
} else {
return;
}
}
dx
and dy
are set to show the direction the elf is moving. Then it walks in that direction looking for a space that has either a segment in it (this.isPointInAnySegment(nextPoint)
) or a spring or portal entity. If it searches too far or finds a target, it breaks the loop.
Then it does something weird - if there’s an entity there, it returns this.segments[0][0]
. That’s going to be the first segment I drew, regardless of where it is.
That means if I can get a jump to end in a spot with a portal or a spring, it will actually jump wherever I want on the map.
Solution
That leads to this solution:
It’s important to make sure that the segment from the final flag is the first thing drawn.
Additional Hacking
Above is the cleanest way to get the gold archivement, but there are other things I can mess with to help along the way.
Local Storage
Source Analysis
In game2.js
, I’ll notice that in the code for setting up the map, when I add a item, it calls saveSegmentsAndEntitiesToLocalStorage()
. For example, after an erase:
} else if (isMouseDown && tool === 'eraser') {
if (!(pointX % 2 === 0 && pointY % 2 === 0) &&
pointX !== 0 &&
pointY !== 0 &&
pointX < cols &&
pointY < rows) {
// if (isMouseDown) {
deleteSegmentsWithPoint([pointX, pointY]);
// }
const entitiesAtCell = getEntitiesAtCell([pointX, pointY])
.filter(entity => ~erasableElements.indexOf(entity[2]));
entitiesAtCell.forEach(entity => {
game.entities = game.entities.filter(e => e.join(',') !== entity.join(','));
});
saveSegmentsAndEntitiesToLocalStorage();
}
}
Local Storage
Looking in the “Storage” tab in the browser dev tools under Local Storage, I’ll see there’s a key for each level:
Each one has a segments
object and a entities
object:
Some exploring shows that the three values for an entity are x-position, y-position, object type.
A segment looks like [[x1, y1], [x2, y2]]
.
Edit
The checks that are put in place to limit springs and tunnels to two each are only checked when the items are put in place, not when they are loaded from local storage. This means I can create lots of springs or put segments in places that couldn’t have them before. For example, this run for Coral Cove:
I’ve got three extra springs, and springs in places typically blocked (such as on the left edge of the rock). I’m also making a diagonal jump from the 4th spring to the last crate.
Another example here is to beat A Real Pickle by editing localstorage to put a tunnel over the end flag:
On “Restart”, it looks like:
The elf come out of the tunnel at the bottom right corner, goes up the path, bounces back down and the level is complete!
If I add more than the allowed two springs to a level and load it, there’s an ASCII art dump to the console when I start:
WHY CAN'T I HOLD ALL THESE SPRINGS??
.--======-
-- --.
-= =:
.= -
-. -
:: .:
: :.
+ -
.- :
:: .-
.. =
: :. =:
: =: -
= * -
.. : .:. .. -
. :.
=. .++: .. . . : =
+ *=. = - . *@@ .+ #
-+- .:-**--:::: = :+#%*-. .:
:- -+. - = .@@# * =
.# .* :- .: #+: :
+ += -#. - = : -
# .= = . + -. .=++- :-
= -. .++=-=+- . = - ..:---- ... ::
+ .- :=. : ..-+%+ + :- :: .. -***+++++= =
- .+ ..-:: *%:--. + - .-*: .++*++++----::. ..
.: # =%:.-:. :*#:=+%= # .***+--=::--:.. .-: :++*-::----:::....: =
# .+. -.:: ..=#=: .*#. .= :+**:.+**==+:...:::::::::-. +: :.-++. .::-... +
+ .:.: .--*... .#+-:=**- -. -+*+#+-. - .-=#. :.*:+*=.. .:- - -
= ++=-.: ..-*-.. ++::---*%- :=-. -++*: .. =%#. :.-*-:=*+-..... .-.+ -.
= :*==.-. ..-*:.. =:.-...:=%=....:: .:++++. :-......-===*#%+. .=:--+..::-+*+=---.. =#*. :
+ +*=*=-. ..-*::. -+... =#:.=+*%-+ . --:-++*********=:.-::**.:=..:=+: :... :+**###%@*. #
+ **=#=# -*+:.-=+= .. -*.-. ##. :..*+--:.......:-: .*#::: .:. =+. :-:....:. -
+ -***+=-+*+:-: ==..:. =-.: *#:.=#+::--===: .-+%#::.+=+-...:..-==. .+#= :
+ ++++++-:===+=..:.- .+:.: .=*-:..:##-..:.....:::::::-=-:..:-.=. ..:....:+###%#- *
= .-.:--:.:-:.:=: ==... .-+.: .+%:+#+.-..::::...:---:=+:.:-=. :-::... :
.. + -...-=. .+- .+-.:: .=- . .:=**-==*%== -+#- .:.:=: .--= -
: * - . -****-.-::-.: .+... :: -=.-:.=%=**. +%#=+-..::.-*=:. .+#: #
- * - ..:::::. .=-. :=..: ... .:..+-.=%=+%========+#%*::= .:.. :*#**#%- .
= : = ..=+-:=====..:. ... -....=- +.-=*-........--.-=-=. + ..:::::. :
* ..: ......-::.--. .-:=:.. . =+.:-=.: . =-=#=.:=-..-- :-* -
:. -. .==:.-=. -+:.. -= ::::. ::-+*%-:...=+=... :+#. =
. =. . ....=-:. -:.:==:.-.:.... .-=+%#: ... .=**+*#%#: .:
: - . .:..+=-+. -=..:+.=.:-..........:::-=--:.. .:::::. .-
: .= = .: .= .=. .....--....=:.--: .:.+=..=*+- ..::. +==. +
: - = .- .: = ...-+=-:--::=. ....:.:.:. =%=.:-. .=.. :*#. +
- : == +. := :- ...::...::--==-......=:-==#%- ....:=**#%+. +
- . .=: + .:.: * .-=--:...-=========:-*: .:---+-. +
- -+ - :.* =: : +- ...:---. -:-*+ :-** - +
- :=.. .+ * + .:.:+ .:=- .*%-.-+==--=*%+. = +
- .- := :=++==. .- ..+:-==-. .-=##+=+.....-===*- :==: +
- .- = -. . = ..............=*#-:-===+##*=--. +
- =-%= -: .:.-. .-+#+. .:.:=-: -
- - :*. - ..:..::--:.. .-. :==* :
= : - .+ .- .*+. =
= = * .= -+====: :.
= = -. -- :
: = : .---::: =
= = - .==.. +
- : .+*+. :=
-. :. ::---=+**+-. -#:
.. * :
:: # -
: +: :.
:. :-. =
: .
: -
- .-
= -
- ..
- +
Proxy
I can run the site through a proxy like Burp and modify the game as it’s downloaded to the browser. There are a bunch of safeguards put into the JS to protect against this. For example, on solving a level, it submits not only the data necessary to calculate the score, but also all the segments so that the server can make sure they were legit.
As another example, there is a function in the code named disappointHackers
that does integrity checking on the segments:
disappointHackers() {
this.segments = this.segments.filter(segment => {
const midpoint = this.getSegmentMidpoint(segment);
const cell = this.getCellByPoint(midpoint.map(d => d * cellSize));
return !this.entities.some(entity => entity[2] === EntityTypes.BLOCKER && entity[0] === cell[0] && entity[1] === cell[1]);
});
};
I didn’t go too far down this vector, but there’s a lot of possibility, even if there are safeguards to bypass.
Edit Mode
There’s a block in game2.js
that defines “edit mode”:
const isEditor = !!urlParams.edit;
if (isEditor) {
adminControls.classList.remove('hidden');
console.log('⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡');
console.log('⚡ Hey, I noticed you are in edit mode! Awesome!');
console.log('⚡ Use the tools to create your own level.');
console.log('⚡ Level data is saved to a variable called `game.entities`.');
console.log('⚡ I\'d love to check out your level--');
console.log('⚡ Email `JSON.stringify(game.entities)` to evan@counterhack.com');
console.log('⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡');
}
It’s enabled by adding &edit=1
to the end of the URL for the level, and loads an editor at the bottom of the screen:
I can edit and build levels, but they don’t submit for scoring.