Examples/dodgeball

dodgeball

Created from a prompt

Re-run on 2026-10-16 with OpenCode (DeepSeek V4 Pro via OpenRouter) against the updated starter (scoped picture bundle, answer scrub, moveButton, key_input clocks, check_env.py). No hardcoded demo code. After the re-run, view.js was changed so the court is mounted into the picture. The generated frame built the SVG and never attached it.

2026-10-16 · task YSBKDx-8kEZu · opencode · openrouter/deepseek/deepseek-v4-pro-0813

The prompt

Build a dodgeball scene I can watch and play. One character, Albert, stands on a court while other characters throw balls at him. I want three ways to look at it. A reel that shows him getting better: at the start he wanders and the throws miss on their own, and later he actually dodges and the throws aim. A mode where I am Albert and I dodge with the keyboard. A mode where I watch a one-on-one and a one-against-many.

Staying alive should be worth a little every moment. Making a ball miss me when it was going to hit should be worth more. Getting hit should hurt and end the run. Draw the court, the people, and the balls in the browser from positions the game reports. Do not pretend a neural net is loaded if the improvement is just a scripted scale from clumsy to sharp. The same mode should play the same way when I restart it.

Live environment

Running the prompt-created workspace on this machine — not a mock. Save an episode, then open Replay below to scrub the timeline.

opening · 0 ms

Generated workspace

Exact files the agent wrote — not rewritten by hand.

# AI : Dodgeball. Albert stands on a court while throwers hurl balls at him.
# Three ways to watch/play: reel (scripted clumsy->sharp), play (you dodge),
# watch (one-on-one and one-against-many spectator).
from __future__ import annotations

import math
from typing import Any, Dict, List, Optional, Tuple

from helpers import (
    base_observation,
    key_input,
    pick_task,
    rng_for,
)

GAME_CLASS = "Dodgeball"

COURT_W = 100.0
COURT_H = 60.0
BALL_R = 1.8
BODY_R = 2.6
PLAYER_SPEED = 16.0
BALL_SPEED = 30.0
ALIVE_PER_STEP = 0.004
DODGE_REWARD = 0.3

MODES = ("reel", "play", "watch")


class Dodgeball:
    def __init__(self) -> None:
        self.mode = "play"
        self.rng = rng_for("play")
        self._reset_state()

    # ---------------------------------------------------------------- state
    def _reset_state(self) -> None:
        rng = self.rng
        self.time = 0.0
        self.done = False
        self.hit_reason = ""
        self.player = [COURT_W * 0.68, COURT_H * 0.5]
        self.throwers: List[Dict[str, Any]] = []
        self.balls: List[Dict[str, Any]] = []
        self._spawn_interval = 1.6 if self.mode != "reel" else 1.8
        if self.mode == "play":
            self._spawn_interval = 2.1
        if self.mode == "watch":
            self._spawn_interval = 1.7
        self._next_spawn = 0.9 if self.mode == "play" else 0.0
        self._last_dodge = 0.0
        self._wander_target = None
        # reel bookkeeping
        self.reel_progress = 0.0
        self.reel_episode = 0
        self._setup_throwers()

    def _setup_throwers(self) -> None:
        # one thrower to start in every mode; watch adds more over time.
        self._add_thrower(0)

    def _add_thrower(self, index: int) -> None:
        # throwers line up along the far (left) edge; Albert works the rest.
        ys = [18.0, 30.0, 42.0]
        y = ys[index % len(ys)]
        thrower = {
            "x": 6.0,
            "y": y,
            "id": index,
        }
        self.throwers.append(thrower)

    # ---------------------------------------------------------- skill curve
    def _skill(self) -> float:
        # reel: scripted clumsy -> sharp, no fake neural net. play/watch are sharp.
        if self.mode == "reel":
            p = self.reel_progress
            return 0.1 + 0.9 * p
        if self.mode == "play":
            # ramps from wild throws to aimed ones over the run, so dodging
            # stays possible early and earns dodge rewards once throws aim.
            return 0.35 + 0.45 * min(1.0, self.time / 40.0)
        return 0.62

    def _aim_error(self) -> float:
        skill = self._skill()
        return (1.0 - skill) * 9.0

    # -------------------------------------------------------------- reset
    def reset(self, task_id: str = "") -> Dict[str, Any]:
        mode = pick_task(task_id if task_id else "", MODES, default="play")
        if mode not in MODES:
            mode = "play"
        self.mode = mode
        self.rng = rng_for(mode)
        self._reset_state()
        if self.mode == "watch":
            self._setup_watch()
        return self._obs()

    def _setup_watch(self) -> None:
        # begins one-on-one, escalates to one-against-many over time.
        self.throwers = []
        self._add_thrower(0)
        self._spawn_interval = 1.7
        self._watch_adds: List[float] = [7.0, 11.0, 15.0]

    # --------------------------------------------------------------- obs
    def _obs(self) -> Dict[str, Any]:
        players = [
            {"x": self.player[0], "y": self.player[1], "name": "Albert", "me": True}
        ]
        throwers = [
            {"x": t["x"], "y": t["y"], "name": "Thrower", "id": t["id"]}
            for t in self.throwers
        ]
        balls = [{"x": b["x"], "y": b["y"], "vx": b["vx"], "vy": b["vy"]} for b in self.balls]

        extra: Dict[str, Any] = {
            "mode": self.mode,
            "court": {"w": COURT_W, "h": COURT_H},
            "players": players,
            "throwers": throwers,
            "balls": balls,
            "time": round(self.time, 2),
            "tosses": len(self.balls),
            "reel_progress": round(self.reel_progress, 3) if self.mode == "reel" else 0.0,
            "reel_episode": self.reel_episode if self.mode == "reel" else 0,
            "skill": round(self._skill(), 3) if self.mode == "reel" else 0.0,
            "status": self._status(),
        }
        if self.mode == "play":
            extra["inputs"] = self._inputs()
        else:
            # watch/reel run by themselves; every_ms keeps the scene stepping.
            extra["inputs"] = [
                key_input(
                    "Auto", "the action plays on its own", {" ": "stay"},
                    every_ms=60, idle="stay",
                )
            ]
        return base_observation(
            reward=self._current_reward(),
            done=self.done,
            legal_moves=self._legal(),
            last_move=None,
            **extra,
        )

    def _status(self) -> str:
        if self.done:
            if self.mode == "reel" and self.reel_episode >= 3:
                return "Reel complete — from clumsy to sharp"
            return "Hit! " + (self.hit_reason or "Run over.")
        if self.mode == "reel":
            return "Reel episode " + str(self.reel_episode + 1) + ("" if self.reel_progress < 1 else " ending")
        if self.mode == "watch":
            return "Watching"
        return "Dodge!"

    def _current_reward(self) -> float:
        # small life value + dodge bonus; handled in step, kept simple here.
        return 0.0

    def _legal(self) -> List[str]:
        if self.mode == "play" and not self.done:
            return ["up", "down", "left", "right", "stay"]
        return []

    def _inputs(self) -> List[Dict[str, Any]]:
        return [
            key_input(
                "WASD",
                "dodge",
                {
                    "w": "up", "ArrowUp": "up",
                    "s": "down", "ArrowDown": "down",
                    "a": "left", "ArrowLeft": "left",
                    "d": "right", "ArrowRight": "right",
                },
                hold=True,
                every_ms=60,
                idle="stay",
            ),
            key_input("Space", "hold still", {" ": "stay"}, hold=True),
        ]

    # ----------------------------------------------------------------- step
    def step(self, action) -> Tuple[Dict[str, Any], bool, Optional[str]]:
        if self.done:
            return self._obs(), False, "run is over; reset to play again"

        move = getattr(action, "move", None) or ""
        dt = 1.0 / 60.0  # 60 fps step

        # 1. move Albert
        reward = 0.0
        if self.mode == "play":
            reward += ALIVE_PER_STEP
            self._move_player(move, dt)
        elif self.mode in ("watch", "reel"):
            self._auto_player(dt)

        # 2. spawn balls
        self._spawn_balls(dt)

        # 3. advance balls, resolve dodge vs hit
        collision, dodged = self._step_balls(dt)
        if self.mode == "reel":
            # a highlight reel: a would-be hit reads as a narrow escape; the
            # arc always plays through to the end. No death in the reel.
            if collision or dodged:
                reward += DODGE_REWARD
        else:
            if collision:
                self.done = True
                self.hit_reason = "a ball struck you"
                reward -= 1.0
            if dodged:
                reward += DODGE_REWARD
                self._last_dodge = self.time

        self.time += dt

        # 4. reel progression
        if self.mode == "reel":
            reward += ALIVE_PER_STEP
            self._advance_reel(dt)
            if self.reel_episode >= 3:
                self.done = True
                self.hit_reason = "reel complete"
        elif self.mode == "watch":
            self._advance_watch()

        obs = self._obs()
        obs["reward"] = reward
        if not self.done:
            obs["last_move"] = move or None
        return obs, True, None

    def _move_player(self, move: str, dt: float) -> None:
        dx = dy = 0.0
        moves = set((move or "").split())
        if "up" in moves:
            dy -= 1
        if "down" in moves:
            dy += 1
        if "left" in moves:
            dx -= 1
        if "right" in moves:
            dx += 1
        if dx or dy:
            mag = math.hypot(dx, dy)
            speed = PLAYER_SPEED * dt
            self.player[0] += (dx / mag) * speed
            self.player[1] += (dy / mag) * speed
        self._clamp_player()

    def _clamp_player(self) -> None:
        self.player[0] = max(BODY_R, min(COURT_W - BODY_R, self.player[0]))
        self.player[1] = max(BODY_R, min(COURT_H - BODY_R, self.player[1]))

    def _auto_player(self, dt: float) -> None:
        # Albert's autonomous movement is a scripted wander, not a model.
        # Early in the reel he meanders slowly and survives because throws are
        # wild; as skill rises he moves faster so aimed throws miss a moving man.
        skill = self._skill()
        # pick a fresh wander target when we get close (or none yet).
        target = getattr(self, "_wander_target", None)
        if target is None:
            target = self._new_wander_target()
            self._wander_target = target
        dx = target[0] - self.player[0]
        dy = target[1] - self.player[1]
        d = math.hypot(dx, dy)
        if d < 3.0 or self._nearest_incoming() is not None and d > 26.0:
            target = self._new_wander_target()
            self._wander_target = target
            dx = target[0] - self.player[0]
            dy = target[1] - self.player[1]
            d = math.hypot(dx, dy) or 1.0
        # speed scales with skill; a sharp Albert is a hard target.
        speed = PLAYER_SPEED * (0.25 + 0.75 * skill)
        self.player[0] += (dx / d) * speed * dt
        self.player[1] += (dy / d) * speed * dt
        self._clamp_player()

    def _new_wander_target(self):
        # Albert works the right two-thirds, away from the throwing line.
        return (
            self.rng.uniform(COURT_W * 0.42, COURT_W - BODY_R - 4.0),
            self.rng.uniform(BODY_R + 4.0, COURT_H - BODY_R - 4.0),
        )

    def _nearest_incoming(self) -> Optional[Dict[str, Any]]:
        best = None
        bd = 1e9
        for b in self.balls:
            d = math.hypot(b["x"] - self.player[0], b["y"] - self.player[1])
            if d < bd:
                bd = d
                best = b
        return best

    def _spawn_balls(self, dt: float) -> None:
        self._next_spawn -= dt
        if self._next_spawn <= 0 and self.throwers:
            self._next_spawn = self._spawn_interval
            thrower = self.rng.choice(self.throwers)
            self.balls.append(self._launch(thrower))

    def _launch(self, thrower: Dict[str, Any]) -> Dict[str, Any]:
        tx = self.player[0]
        ty = self.player[1]
        err = self._aim_error()
        ax = tx + self.rng.uniform(-err, err)
        ay = ty + self.rng.uniform(-err, err)
        dx = ax - thrower["x"]
        dy = ay - thrower["y"]
        d = math.hypot(dx, dy) or 1.0
        vx = (dx / d) * BALL_SPEED
        vy = (dy / d) * BALL_SPEED
        return {
            "x": thrower["x"], "y": thrower["y"],
            "vx": vx, "vy": vy,
            "target": (ax, ay),
            "passed": False,
        }

    def _step_balls(self, dt: float) -> Tuple[bool, bool]:
        collision = False
        dodged = False
        alive: List[Dict[str, Any]] = []
        px, py = self.player
        hit_r = BALL_R + BODY_R
        for b in self.balls:
            nx = b["x"] + b["vx"] * dt
            ny = b["y"] + b["vy"] * dt
            nd = math.hypot(nx - px, ny - py)
            if nd < hit_r:
                collision = True
                b["dead"] = True
            # a ball counts as "dodged" once it has flown past its aim point
            # while the player has moved off that spot.
            tx, ty = b.get("target", (px, py))
            if not b.get("passed"):
                on_target = math.hypot(b["x"] - tx, b["y"] - ty)
                if on_target < BALL_R:
                    b["passed"] = True
                    player_off = math.hypot(px - tx, py - ty)
                    if player_off > hit_r * 0.9:
                        dodged = True
            b["x"] = nx
            b["y"] = ny
            if b.get("dead") or b["x"] < -5 or b["x"] > COURT_W + 5 or b["y"] < -5 or b["y"] > COURT_H + 5:
                continue
            alive.append(b)
        self.balls = alive
        return collision, dodged

    def _advance_reel(self, dt: float) -> None:
        # progresses skill scripted from clumsy to sharp over episodes
        self.reel_progress = min(1.0, self.reel_progress + dt / 20.0)
        if self.reel_progress >= 1.0:
            self.reel_episode += 1
            self.reel_progress = 0.0
            self.player = [COURT_W * 0.68, COURT_H * 0.5]
            self.balls = []
            self._next_spawn = 0.3

    def _advance_watch(self) -> None:
        # one-on-one grows into one-against-many on a fixed timetable.
        while self._watch_adds and self.time >= self._watch_adds[0]:
            self._watch_adds.pop(0)
            self._add_thrower(len(self.throwers))
            self._spawn_interval = max(0.5, self._spawn_interval - 0.15)