Ducklings vs Cyber-Dinos / agent mode

Let an agent play the horse

Agent mode is a toy. Add ?agent=1 (or #agent) to the game’s URL and the page exposes window.duckAgent, a small JavaScript API for playing the game without a keyboard: a language model driving a browser (Claude in Chrome, a ChatGPT agent, Claude Code with a browser tool) or a plain script. I made it to see how well an LLM can play a real-time game when time is turned into turns.

Without the flag nothing is exposed and the game is exactly the one humans play. With it, a yellow “agent mode” badge sits in the corner so a person watching knows who is driving.

How it works

The simulation is paused between calls. The game runs at a fixed 60 ticks per second, and act() advances it by the number of ticks you ask for. By default that is instant, followed by one drawn frame (so screenshot-based agents see the game too). Add &watch=1 to the URL and every act plays out smoothly in real time instead, which is what you want when a person is watching. Slow thinking costs nothing in game time. Everything runs in your browser tab; the site’s server is not involved beyond the two calls a human run makes anyway.

All methods return plain JSON (as a Promise, except observe(), which is synchronous). Each method returns the new observation, so one call per decision is enough. Open the game, wait for window.duckAgent to exist, and start with observe().

Tips

Every method except observe() returns a Promise: await it (in a console or a REPL tool, a Promise you do not await prints as empty). After you win a level, advance() plays the closing scene, then the Mallard’s gift, then the next level; the observation’s scene and next fields say where you are (the level name stays the same until the next level starts). One careful move per call means a hundred or two calls per level; a script that decides for you takes one or two seconds.

Fair comparisons: seeds

Add &seed=123 to the URL and every run with that seed deals the same dinos, drops and cards for the same sequence of moves, so you can compare different models, prompts or policies on identical games. Use summary() at the end for a scorecard. (Humans can use ?seed= too.)

The world

  • You are a horse. Ducklings orbit you and shoot on their own; you only steer. Survive the timer (2 minutes of game time for most of the 6 levels; the boss levels end when the boss dies instead).
  • Coordinates are game pixels, x to the right, y down. Observations give positions relative to the horse. The horse walks 62 px per second at the start (so 15 ticks is about 15 px), faster with the hooves passive.
  • Dinos spawn just outside the view and walk straight at you. Contact hurts, then you are invulnerable for 0.7 s. Stronger dinos wind up before they charge: look at st.
  • Bolts fly straight. Boss hazards are telegraphed: beams, circles and rings show a warning phase before they hurt.
  • Dinos drop gems. Walk near them to collect. Enough xp triggers a level-up screen with three cards.

Screens and what to call

screenwhat to do
titleCall start().
dialogueA story scene. advance() shows the next line, advance({skip:true}) ends the scene.
playCall act().
pauseCall resume().
levelupPick a card with pick(n).
boonBetween levels the Great Mallard offers three boons: pick(n).
completeLevel cleared. advance() continues (closing scene, then boon, then the next level).
deadretry() restarts the level with what you had when it began.
indexThe bestiary. Only reachable by a human clicking; start() or reload to leave.
endingThe run is over. Methods return the observation with ending set to loading, revealed, or an error code.

Methods

callwhat it does
observe()Synchronous. The current observation. Does not change anything.
start()From the title screen: begins a run (like pressing play). Returns the first dialogue scene.
advance({skip?})dialogue: next line (first finishes the typing), or with skip:true the whole scene. complete: continue to the next scene. title: same as start().
summary()A compact scorecard for comparing runs: seed, screen, level, levelsCleared, deaths, kills, simSeconds (game time), wallSeconds (real time since start), finished.
act({move, ticks})play only. move: a string of w a s d, or "" to stand still (opposite letters cancel). ticks: 1 to 120, default 15 (60 ticks = 1 simulated second). Holds that direction and steps the game, then returns the new observation. Without watch mode it runs instantly and draws one frame; with ?watch=1 (or animate: true) it plays out in real time, one frame at a time, so a person can watch. It stops early when the screen changes (level-up, death, boss arrival, level end), or when you set stopOnHit: true and the horse takes damage; ticksRun in the result is then smaller than ticks. Other options: animate (true/false, overrides watch mode for this call), speed (0.25 to 8, only for animated calls, default 1).
pick(n)levelup or boon: take card n (1-based, see choices).
retry()dead: restart the level.
pause() / resume()Pause screen on and off. The simulation is already paused between calls, so you rarely need these.

If a call is not valid on the current screen it changes nothing and the observation comes back with an error string. There is also window.duckAgent.version and .docs.

The observation

Compact on purpose: numbers are integers and lists are capped, so one observation stays under about 2 KB.

fieldmeaning
screenOne of the screens above.
level, levelNameLevel number (1 to 6) and its name.
time, durationSimulated seconds in this level, and how many you must survive. Boss levels end when the boss dies, and duration is then only nominal.
hp, maxHpHorse health. Contact and bolts hurt; there is a short invulnerability after each hit.
xp, xpNeeded, playerLevelGem progress toward the next level-up, and the duckling level.
killsDinos defeated this level.
weapons, passivesWhat you own: { id: level }.
viewVisible world in game pixels, { w, h }, roughly 480 x 270 up to 640 x 360. The horse is at the centre; dinos spawn just outside the view.
player{ x, y } in world pixels (the world is unbounded; only differences matter).
enemiesThe 10 nearest: { kind, dx, dy, dist, hp, st?, flags? }. dx, dy are relative to the horse in world pixels, y down. st is a behaviour state when it is not plain chasing (windup, charge, dash, fire...): a charge or dash is about to or already happening.
boltsThe 6 nearest enemy projectiles: { dx, dy, vx, vy }, velocity in px per second. They fly straight.
hazardsThe 3 nearest boss hazards: { type: beam | circle | emp, phase: warn | live, dx, dy, r | len, width, angle }. A warn phase is the telegraph: it does nothing yet. Beams may sweep. An emp ring takes your weapons offline for a few seconds instead of damaging you.
portalsRift portals { dx, dy, progress }. They keep spawning dinos; stand next to one until progress reaches 1 to close it (and get a gem bonus).
boss{ name, hp, maxHp, dx, dy } or null.
pickupsThe 4 nearest { kind: gem | heart, dx, dy, xp? }. Hearts heal. Gems within a short radius are pulled in automatically.
choicesOn levelup and boon: [{ n, id, name, desc }].
dialogueOn dialogue: { speaker, text } of the current line.
emptrue while your weapons are offline.
hintA one-line suggestion for the next call.
errorPresent when the call was not valid on this screen (the observation is still returned). ticksRun is present on act().
{"screen":"play","level":1,"levelName":"Meadow Breach","time":42.3,"duration":120,
 "hp":48,"maxHp":60,"xp":3,"xpNeeded":7,"playerLevel":2,"kills":31,
 "weapons":{"orbit":2},"passives":{},"view":{"w":480,"h":270},"player":{"x":132,"y":-40},
 "emp":false,"boss":null,
 "enemies":[{"kind":"raptor","dx":-18,"dy":9,"dist":20,"hp":8},{"kind":"raptor","dx":60,"dy":-30,"dist":67,"hp":8}],
 "bolts":[],"hazards":[],"portals":[],"pickups":[{"kind":"gem","xp":1,"dx":12,"dy":-6}],
 "hint":"Enemy raptor 20px away at (-18,9). Move away."}

A minimal loop

A naive policy that runs away from the nearest dinos and always takes the first card. It clears level 1 in a few hundred act() calls and a second or two of real time, because the game only moves when you tell it to.

// Paste into the devtools console of /games/ducklings-vs-cyber-dinos?agent=1
// (or run it with page.evaluate in Playwright). Plays level 1 with a naive policy.
async function playLevel1() {
  const A = window.duckAgent;
  let o = await A.start();                       // title -> opening scene
  o = await A.advance({ skip: true });           // skip the scene -> play
  let acts = 0;
  while (o.screen !== "complete") {
    if (o.screen === "levelup") { o = await A.pick(1); continue; }   // first card
    if (o.screen === "dead")    { o = await A.retry(); continue; }
    if (o.screen !== "play")    { o = await A.advance({ skip: true }); continue; }
    // Run away from the dinos that are close, nearest ones count most.
    let fx = 0, fy = 0;
    for (const e of o.enemies) {
      if (e.dist > 110) continue;
      const w = 1 / (e.dist * e.dist + 1);
      fx -= (e.dx / (e.dist || 1)) * w * 1000;
      fy -= (e.dy / (e.dist || 1)) * w * 1000;
    }
    let move = "";
    if (Math.abs(fx) > 0.05) move += fx > 0 ? "d" : "a";
    if (Math.abs(fy) > 0.05) move += fy > 0 ? "s" : "w";
    o = await A.act({ move, ticks: 15 });
    acts++;
  }
  console.log("level 1 done in", acts, "act() calls;", o.kills, "dinos defeated, hp", o.hp);
}
playLevel1();

A prompt for a browser agent

Two ways to play. To have the model play it itself, one decision per call (about a hundred or two calls per level), paste this into Claude in Chrome, a ChatGPT agent, or similar, and let it open the page. The &watch=1 in the URL makes every move play out in real time so you can watch:

You are playing a small browser game through a JavaScript API, window.duckAgent.
Open /games/ducklings-vs-cyber-dinos?agent=1&watch=1 (docs: /games/ducklings-vs-cyber-dinos/agents).
Run JavaScript in the page to call the API. Every method except observe() returns a Promise: always
await it and print the result (a returned Promise that is not awaited looks empty).

Rules of the game: you are a horse. Ducklings orbit you and shoot automatically; you only steer.
Cyber-dinosaurs walk toward you and hurt you on contact. Stay alive until the level timer
(observation.time reaches observation.duration), collect the blue gems that dinos drop (walk over
them) to level up, and pick a card (pick(n)) each time the screen becomes "levelup".

PLAY IT YOURSELF, TURN BY TURN: read each observation, decide each move yourself, and call
act({move, ticks: 30, stopOnHit: true}). Do not write an automatic loop or script that plays for
you. (A person is watching the page: with &watch=1 every act() plays out in real time, smoothly.)
Use ticks 45-60 when nothing is close and 10-20 when enemies are near; stopOnHit hands control
back to you as soon as you take damage. move is any of w a s d (y is down), 60 ticks = 1 second,
the horse moves about 62 px per second. dx/dy are relative to the horse in game pixels. Hazards
are telegraphed (phase "warn") before they go live: leave their area. Bolts fly along (vx, vy).

When screen is "dialogue" call advance({skip: true}); on "complete" call advance() (a closing
scene and a gift follow, then the NEXT level starts; you are not replaying the level); on "boon"
call pick(n); on "pause" call resume(); on "dead" call retry(). Read hint in each observation.
Do not use the keyboard; use only window.duckAgent. Play fair: do not edit the page or game state.
Report how far you got and what killed you.

To have the model write a bot that plays for it (fast, but you are judging its code more than its play), use this instead:

You are playing a small browser game through a JavaScript API, window.duckAgent,
by WRITING A BOT: a JavaScript policy that runs in the page and calls the API in a loop.
Open /games/ducklings-vs-cyber-dinos?agent=1. Always await every call. Use the example loop on the docs page
(/games/ducklings-vs-cyber-dinos/agents) as a starting point and improve it: commit to a direction for
a few ticks instead of flipping keys every call (smooth steering), flee from the nearest enemies and
bolts, walk to gems when nothing is near, pick cards sensibly, handle every screen
(dialogue, complete, boon, pause, dead). Keep each tool call short, and yield between batches
(await new Promise(requestAnimationFrame)) so the page can paint. Report your policy, how far
it got, and what killed it.

The ending is still on the clock

Finishing the last level shows the ending, which includes a way to contact me. As for human players, the server issues a run token when a run starts and only reveals the ending if at least 8 minutes of real wall-clock time have passed since then. Agent mode does not change that and does not try to: an agent that simulates the game far faster than real time will reach the end screen and be told the run was too fast. If you want the ending you have to actually take the time (six levels of thinking between calls may or may not get you there). This is deliberate.

Cost: none for the site. There is no server-side agent API, no extra requests, and the simulation runs in the visitor’s own browser. Any LLM cost is whatever your agent’s own model usage is.

Fair play

It is a toy, so be nice. Use only window.duckAgent: do not poke at the game’s internals, replay the server endpoints, or forge run tokens. If you build something fun on it, or an agent finds a nasty bug, I would like to hear about it.