← Back to Home

Teaching an AI to Ski

Using Riddle's headless browser API to explore, iterate, and accidentally invent a pathfinding algorithm

AI skiing gameplay - Score 3282+, 328m distance, still running when job timed out

Score: 3282+ • Distance: 328m • Still running when the job timed out

The Question

Can you automate actual gameplay through a browser API? Not clicking "Accept Cookies"—but dodging obstacles, reading game state, making real-time decisions?

I had access to Riddle, a headless browser API, and a skiing game where you dodge trees. Seemed like a good way to find out.

The Riddle Loop

Here's what makes Riddle interesting for this kind of exploration: every job gives you back everything.

curl -X POST https://api.riddledc.com/v1/run \
  -d '{"script": "await page.goto(...); ...", "sync": false}'

# Returns job_id, then you get:
# - screenshots (PNGs of whatever you captured)
# - console.json (every console.log from your script)
# - network.har (all HTTP traffic)

This meant I could write a script, fire it off, and immediately see what happened. No local browser windows. No "it works on my machine." Just data back in seconds.

That feedback loop turned out to be everything.

Attempt 1: Be Smart

My first script tried to be clever. Read the DOM, find trees, dodge them.

const state = await page.evaluate(() => {
    // Find all trees, find the skier, return positions
});
if (nearestTree.x > skierX) await page.keyboard.press('ArrowLeft');

I submitted it. Pulled the console output:

GAME OVER! Score: 570

Yikes. I grabbed the screenshots Riddle captured. The skier was jerking around like a malfunctioning robot, eventually slamming into a tree.

What the Data Told Me

I added more logging:

console.log(`Keyboard: ${keyPresses} in ${elapsed}ms`);
console.log(`DOM read took: ${evalTime}ms`);

Submitted again. Console came back:

Keyboard: 500 presses in 682ms = 733 keys/sec
DOM read: 87ms per evaluate() call

There it was. Keyboard input was blazing fast. DOM reading was the bottleneck. Every time I asked "where are the trees?", I lost ~90ms of reaction time. The skier was flying blind most of the time.

Attempt 2: Stop Thinking

What if the skier just... moved? No reading, no thinking, just zigzag?

for (let i = 0; i < 500; i++) {
    await page.keyboard.press('ArrowLeft');
    await page.keyboard.press('ArrowRight');
}

Console output:

GAME OVER! Score: 842

Wait. Random flailing beat my "smart" approach by 47%?

The screenshots showed why: constant movement made the skier a harder target. Trees spawn semi-randomly—if you're always moving, you're less likely to be in the wrong place.

Attempt 3: The Light Cone

Now I had a hypothesis: sample state occasionally, but commit to decisions between samples.

I borrowed a concept from physics. A "light cone" is the region of spacetime you can possibly reach. For the skier: given where you are, which direction has the most open space ahead?

// Divide view into 3 lanes, find longest clear distance in each
const lanes = {
    left:   { x: skierX - 100, clear: 800 },
    center: { x: skierX,       clear: 800 },
    right:  { x: skierX + 100, clear: 800 }
};

for (const tree of trees) {
    for (const [name, lane] of Object.entries(lanes)) {
        if (Math.abs(tree.x - lane.x) < 55) {
            lane.clear = Math.min(lane.clear, tree.distanceAhead);
        }
    }
}

// Pick lane with longest clear path, then COMMIT
const best = /* lane with highest clear value */;
for (let i = 0; i < 12; i++) {
    await page.keyboard.press(best === 'left' ? 'ArrowLeft' : 'ArrowRight');
}

Submitted. Console:

GAME OVER! Score: 1178

+40% over zigzag. The screenshots showed smooth, confident movement—the skier picking gaps and threading through.

Attempt 4: Continuous Angles + Hitboxes

The 3-lane model was crude. The console logs showed the skier sometimes clipping trees at lane boundaries. Two problems:

  1. Lanes were thinner than the skier. Collision detection ignored actual hitbox sizes.
  2. Only 3 choices. What about "go slightly left"?

New approach: sample 11 angles across the reachable cone, account for both skier and tree radii:

const SKIER_RADIUS = 25;
const TREE_RADIUS = 20;
const COMBINED_RADIUS = SKIER_RADIUS + TREE_RADIUS;  // Collision threshold
const MAX_TURN = 96;  // Max pixels we can move per round

// Sample 11 angles from hard-left to hard-right
for (let angleIdx = -5; angleIdx <= 5; angleIdx++) {
    const targetX = skierX + (angleIdx / 5) * MAX_TURN;

    for (const tree of trees) {
        // Where will skier be when reaching this tree's Y?
        const progress = tree.distAhead / 400;
        const skierXAtTree = skierX + (targetX - skierX) * Math.min(progress, 1);

        // Collision check with actual hitbox sizes
        if (Math.abs(skierXAtTree - tree.x) < COMBINED_RADIUS) {
            clearDist = Math.min(clearDist, tree.distAhead);
        }
    }
}

The key insight: we're not picking "left" or "right"—we're finding the angle through the forest. How hard to turn.

Submitted. Pulled console:

Frames captured: 41

No game over? Checked the final screenshot:

Distance: 328.3m    Score: 3282

The skier was still running when the job timed out at 120 seconds.

+290% over the 3-lane approach. Proper hitboxes + continuous steering = dramatically better survival.

The Breakthrough I Almost Missed

Earlier, scores had plateaued around 1100-1200. I added a debug job just to see the initial game state:

await page.goto("https://lilarcade.com/games/skiing-game");
await page.waitForTimeout(2000);
await page.screenshot({ path: "debug.png" });
console.log("Game Over: " + document.body.textContent.includes("Game Over"));

The screenshot came back showing Score: 600 already on screen. The game had been running for 2 seconds while I waited!

One-line fix:

await page.waitForTimeout(1000);  // Was 2000

That fix, combined with the continuous angle algorithm, is what got us to 3282+.

The Exploration Stack

What made this work wasn't just the algorithm—it was the iteration speed:

  1. Write script (2 minutes)
  2. Submit to Riddle (instant)
  3. Wait for execution (30 seconds)
  4. Pull console.json - see exactly what happened
  5. Pull screenshots - see it visually
  6. Adjust and repeat

No Docker containers. No Playwright installation. No "why is Chrome crashing." Just a curl command and JSON back.

Each failed attempt taught me something:

  • Score 570 → "DOM reads are slow"
  • Score 842 → "Movement beats thinking"
  • Score 1178 → "Light cone works"
  • Score 1815 → "Timing matters"
  • Score 3282+ → "Hitboxes and continuous angles matter"

The data was always there. I just had to look at it.

Final Results

ApproachScoreWhat I Learned
Reactive (read every frame)570DOM is the bottleneck
Pure zigzag842Movement > planning
Light cone (3 lanes)1178Commit to decisions
+ fast start1815Timing is everything
+ continuous angles + hitboxes3282+Physics matters

The final algorithm was still running when the job timed out. 328 meters traveled, no game over in sight.

Try It

The game: lilarcade.com/games/skiing-game

The API: riddledc.com

Can you beat 3282? The console logs will tell you what's going wrong. The screenshots will show you why. That's the fun part.

Build Your Own Browser Experiments

Headless browser API. Scripts run on our servers, you get back screenshots, logs, and artifacts. $0.50/hr.