I've been doing a bunch of work lately with the Playwright browser automation library, and when all you have is a hammer... I'm not saying this is a good way to cheat—it is not—but I was curious if I could do it without messing with the game itself at all.
My original idea was to use Playwright to load the page and start the game and then have it repeatedly capture screenshots for analysis. This would mean I wouldn't have to touch anything within the browser's environment besides pressing space, for maximum stealth. This worked, but it turned out that asking Chromium to take a screenshot (which caused a resize of the whole page), loading it into a buffer, and then processing it took on the order of 150ms, which was too slow to achieve even a respectable score using the very simple logic I had in mind. I was cheating, just badly.
So I switched to asking the client browser to invoke toDataURL()
on the game's <canvas>
element and getting the image data back that way. This could achieve latencies on the order of 10-15ms. I repeatedly capture an image of the game and look at a small detection rectangle for obstacles (non-white pixels; computer vision, this is not). If I find any, I press the space bar. This isn't particularly clever (I make no attempt to use the down arrow, and at high speeds it can fail because it tries to jump while we're still in the air), but the game is automated, so we can just keep playing until we get a lucky enough run.
This process is controlled by two constants. There's XCROP
, which determines the detection region: how far in advance to look ahead when detecting obstacles. Setting it wrong means we jump too early or late. And there's SPEED_FACTOR
, which linearly increases xcrop as the game progresses to adjust for the faster speed (does the speed increase linearly? I didn't check). These constants are highly sensitive to the environment on which the script is being run—no attempt is made to synchronize the automation with the game, and SCALE_FACTOR
is entirely dependent on how long the image capture/processing loop takes to execute; even adding/removing a console.log()
statement in testing was enough to throw them off. So these values will probably not work on your system. But if tuned decently (which can be automated by playing many games with different values), it can easily achieve scores good enough for the daily leaderboard.
This doesn't particularly try to avoid detection—it spams the space bar repeatedly at superhuman speed when an object is detected and has superhuman endurance to play game after game—and the detection logic of looking at a 33px-wide rectangle for non-white pixels is hardly clever, but it always seems to print "Is Game Suspect = false."
const Jimp = require('jimp');
const {chromium} = require('playwright');
(async () => {
const XCROP = 122
const SPEED_FACTOR = 0.008
const browser = await chromium.launch({
headless: false
})
const context = await browser.newContext()
const page = await context.newPage()
// pipe the browser console to our console so we can see the "Is Game Suspect" message
page.on('console', msg => console.log(msg.text()))
const playGame = async (xcrop, speedFactor) => {
await page.goto('https://winterbash2020.stackexchange.com/run-with-the-hats')
const container = await page.waitForSelector('.runner-container')
// start the game
await container.click()
await container.press(' ')
await page.waitForTimeout(500)
const canvas = await page.$('canvas')
let weLost = false
for (let count=0; !weLost; count++) {
// check if we lost and return our score if we did
if (await page.$('.js-personal-stats > div')) {
weLost = true
const scoreElems = await page.$$('.js-personal-stats strong')
const score = parseInt(await scoreElems[2].innerText())
return score
}
// ask the game's <canvas> for its image data as a data url and stuff it in a buffer
const dataURL = await page.evaluate((elem) => {
return elem.toDataURL()
}, canvas)
const buffer = Buffer.from(dataURL.substr(22), 'base64')
// parse the image data and crop out a small rectangle from it with 1-bit color depth for analysis
const img = await Jimp.read(buffer)
let foundPixel = false
img
.crop(xcrop + (speedFactor * count), 100, 33, 20)
.posterize(2)
// look though the image data for any non-white pixels, which indicates we found an object
// the image is in RGBA format, so we can skip bits
for (let i = img.bitmap.data.length - 1; i >= 0; i -= 4) {
if (img.bitmap.data[i] != 0) {
foundPixel = true
break
}
}
// if we detected an object, press space to jump
if (foundPixel) {
await container.press(' ')
console.log(xcrop + (speedFactor * count), speedFactor, speedFactor * count)
}
}
}
const playMultipleGames = async (tries, ...args) => {
const results = []
for (let i=0; i<tries; i++) {
const score = await playGame.apply(null, args)
results.push(score)
}
console.log(results, args)
}
console.log(await playMultipleGames(20, XCROP, SPEED_FACTOR))
await page.close()
await context.close()
await browser.close()
})().catch((ex) => {
console.error(ex);
process.exit(1)
});
I did most of my testing logged out, but added a login step to the script for some final tests (after a while, I did start triggering the captcha on the login form, so that works at least). You can ban me now.