Examples/downfall

downfall

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. Cursor did not edit workspace/.

2026-10-16 · task VJ0oVQHM4LCc · opencode · openrouter/deepseek/deepseek-v4-pro-0813

The prompt

Build a 3D obstacle course in the Fall Guys style. I am a bean running across raised platforms. The first stretch has spinning hammers and spiked rollers. The next stretch is a floor of tiles that fall after I stand on them. Then more rollers. The last stretch is slopes with bombs dropping onto them. A yellow gate at the end of each stretch sends me to the next one.

I run, strafe, look around, and jump, and I can only jump while I am standing on something. Getting closer to the gate can be worth a little. Falling off, or touching a hammer, roller, or bomb, ends the round and I have lost. Reaching a gate should feel like progress, not like the whole game is over. Keep a long round so a person can cross more than one stretch. Show the course in 3D from behind me, with the score. Different starts can begin on a later stretch. The same start should repeat.

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 : Bean Run — a 3D Fall Guys style obstacle course.
# A bean runs raised platforms through four stretches (hammers/rollers, falling
# tiles, rollers, bomb slopes), each closed by a yellow gate. Touch a hazard or
# fall off -> lost. Reach a gate -> progress. Reach the last gate -> won.
from __future__ import annotations

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

from helpers import base_observation, key_input, pick_task, rng_for, seed_from_task, task_ids, clamp01

GAME_CLASS = "BeanRun"

# ---- tuning ----------------------------------------------------------------
DT = 1.0 / 60.0
RUN_SPEED = 7.0
JUMP_VY = 4.8
GRAVITY = 9.5
TURN_STEP = 0.22            # radians per look press
DEATH_Y = -3.0
BEAN_R = 0.6
BEAN_H = 1.2
STRETCH_LEN = 26.0
GAP = 4.0
HALF_W = 5.5               # platform half width (bean falls off past this)
TILE_LEN = 3.2
TILE_FALL_TICKS = 14       # ~0.23s of standing before a tile falls
HAMMER_H = 0.75
HAMMER_ARM = 1.5
HAMMER_SPIN = 1.1
ROLLER_R = 1.0
ROLLER_TOP = 0.85
BOMB_DROP = 8.0
BOMB_BOOM_R = 2.2
BOMB_BOOM_TICKS = 9

TASK_IDS = task_ids(8, "s")

MOVE_TOKENS = {
    "up", "down", "left", "right",
    "jump", "turn_left", "turn_right", "stay",
    "forward", "back",
}
ALL_MOVES = [
    "up", "down", "left", "right", "stay",
    "up left", "up right", "down left", "down right",
    "jump", "turn_left", "turn_right",
]


def _inputs() -> List[Dict[str, Any]]:
    return [
        key_input(
            "WASD",
            "run and strafe",
            {
                "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("Q E", "look around", {"q": "turn_left", "e": "turn_right"}, hold=False),
        key_input("Space", "jump", {" ": "jump"}, hold=False),
    ]


class BeanRun:
    def __init__(self) -> None:
        self.reset("")

    # ---- world construction -------------------------------------------------
    def _build(self, task_id: str) -> None:
        seed = seed_from_task(task_id)
        rng = rng_for(task_id)
        canonical = pick_task(task_id, TASK_IDS, "")
        start = seed % 4
        self.task_id = canonical or ""
        self.start_stretch = start
        self.total_gates = 4

        # stretch kinds, in order
        kinds = ["hammers", "tiles", "rollers", "bombs"]
        self.stretches: List[Dict[str, Any]] = []
        z = 0.0
        for i, kind in enumerate(kinds):
            self.stretches.append({
                "index": i,
                "kind": kind,
                "z0": z,
                "z1": z + STRETCH_LEN,
                "gate": z + STRETCH_LEN,
                "height": 0.0,
            })
            z += STRETCH_LEN + GAP

        # floor segments (tiles are per-tile alive, others solid)
        self.segments: List[Dict[str, Any]] = []
        self.hammers: List[Dict[str, Any]] = []
        self.rollers: List[Dict[str, Any]] = []
        self.tile_segments: List[Dict[str, Any]] = []
        self.bombs: List[Dict[str, Any]] = []
        self._bomb_schedule: List[float] = []   # upcoming spawn times (absolute t)

        for st in self.stretches:
            z0, z1 = st["z0"], st["z1"]
            kind = st["kind"]
            if kind == "tiles":
                ntiles = int((STRETCH_LEN - 1.0) // TILE_LEN)
                for t in range(ntiles):
                    tz0 = z0 + t * TILE_LEN
                    tz1 = min(tz0 + TILE_LEN, z1)
                    seg = {
                        "x0": -HALF_W, "x1": HALF_W, "z0": tz0, "z1": tz1,
                        "height": 0.0, "kind": "tile", "state": "alive",
                        "ticks": 0,
                    }
                    self.segments.append(seg)
                    self.tile_segments.append(seg)
            else:
                self.segments.append({
                    "x0": -HALF_W, "x1": HALF_W, "z0": z0, "z1": z1,
                    "height": 0.0, "kind": kind, "state": "solid", "ticks": -1,
                })

            if kind == "hammers":
                zs = [z0 + 6.0, z0 + 13.0, z0 + 20.0]
                for j, cz in enumerate(zs):
                    self.hammers.append({
                        "x": 0.0, "z": cz, "angle": rng.random() * 6.28,
                        "spin": HAMMER_SPIN * (1 if j % 2 == 0 else -1),
                        "arm": HAMMER_ARM, "height": HAMMER_H,
                    })
            elif kind == "rollers":
                centers = [z0 + 8.0, z0 + 17.0]
                for j, cz in enumerate(centers):
                    amp = rng.uniform(3.0, 4.5)
                    self.rollers.append({
                        "x": 0.0, "z": cz, "base_z": cz,
                        "amp": amp, "phase": rng.random() * 6.28,
                        "r": ROLLER_R, "top": ROLLER_TOP,
                    })
            elif kind == "bombs":
                # slopes are drawn tilted; physics floor stays level.
                n = 6
                for b in range(n):
                    when = self._start_time_for_self() + 3.0 + b * 3.4
                    self._bomb_schedule.append(when)

        # bomb spawn positions derived from schedule at spawn time
        self._bomb_seed = rng
        self._bomb_times: List[float] = list(self._bomb_schedule)

    def _start_time_for_self(self) -> float:
        # helper only during build; base time is 0
        return 0.0

    # ---- reset / step -------------------------------------------------------
    def reset(self, task_id: str = "") -> Dict[str, Any]:
        self._build(task_id)
        seed = seed_from_task(task_id)
        self.t = 0.0
        self.alive = True
        self.done = False
        self.won = False
        start = self.start_stretch
        self.stretch = start
        st = self.stretches[start]
        # bean spawns a little behind the start of its stretch, center, on the floor
        spawn_z = max(0.0, st["z0"] + 1.5)
        self.x = 0.0
        self.y = 0.0
        self.z = spawn_z
        self.vy = 0.0
        self.yaw = 0.0
        self.grounded = True
        self.gates = 0
        self.score = 0.0
        self.message = ""
        self.prev_z = self.z
        self._age_ticks = 0
        self._bomb_schedule = list(self._bomb_times)
        self.bombs = []
        self._last_move = None
        return self._observe(0.0)

    def _observe(self, reward: float) -> Dict[str, Any]:
        return base_observation(
            reward=reward,
            done=self.done,
            legal_moves=ALL_MOVES,
            last_move=self._last_move,
            bean={
                "x": round(self.x, 3),
                "y": round(self.y, 3),
                "z": round(self.z, 3),
                "yaw": round(self.yaw, 4),
                "grounded": self.grounded,
            },
            alive=self.alive,
            won=self.won,
            score=round(self.score, 3),
            gates=self.gates,
            total_gates=self.total_gates,
            t=round(self.t, 3),
            stretch=self.stretch,
            stretch_label=["Hammers", "Falling tiles", "Rollers", "Bomb slopes"][min(self.stretch, 3)],
            message=self.message,
            inputs=_inputs(),
            segments=[
                {
                    "x0": s["x0"], "x1": s["x1"], "z0": s["z0"], "z1": s["z1"],
                    "height": s["height"], "kind": s["kind"], "state": s["state"],
                }
                for s in self.segments
            ],
            hammers=[
                {"x": h["x"], "z": h["z"], "angle": h["angle"], "arm": h["arm"], "height": h["height"]}
                for h in self.hammers
            ],
            rollers=[
                {"x": r["x"], "z": r["z"], "r": r["r"], "top": r["top"]}
                for r in self.rollers
            ],
            bombs=[
                {"x": b["x"], "z": b["z"], "y": b["y"], "state": b["state"]}
                for b in self.bombs
            ],
            gate_list=[
                {"z": st["gate"], "passed": i < self.gates or (i < self.stretch)}
                for i, st in enumerate(self.stretches)
            ],
        )

    def _ground_at(self, x: float, z: float) -> Optional[float]:
        for s in self.segments:
            if s["state"] == "solid" or s["state"] == "alive" or s["state"] == "falling":
                if s["z0"] <= z <= s["z1"] and s["x0"] <= x <= s["x1"]:
                    return s["height"]
        return None

    def step(self, action):
        if self.done:
            return self._observe(0.0), True, None
        move = getattr(action, "move", "") or ""
        return self._tick(move), True, None

    def _tick(self, move: str) -> Dict[str, Any]:
        self._last_move = move or None
        reward = 0.0
        if self.done:
            return self._observe(0.0)

        self.t += DT
        self._age_ticks += 1
        tokens = [t for t in (move or "").split() if t]
        jump = False
        fwd = back = left = right = False

        for tok in tokens:
            if tok == "jump":
                jump = True
            elif tok in ("turn_left",):
                self.yaw -= TURN_STEP
            elif tok in ("turn_right",):
                self.yaw += TURN_STEP
            elif tok in ("up", "forward"):
                fwd = True
            elif tok == "down":
                back = True
            elif tok == "left":
                left = True
            elif tok == "right":
                right = True
            elif tok == "back":
                back = True

        # facing / right vectors
        fx, fz = math.sin(self.yaw), math.cos(self.yaw)
        rx, rz = math.cos(self.yaw), -math.sin(self.yaw)
        dvx = fx * (fwd - back) + rx * (right - left)
        dvz = fz * (fwd - back) + rz * (right - left)
        if abs(dvx) > 1e-9 or abs(dvz) > 1e-9:
            n = math.hypot(dvx, dvz)
            dvx /= n
            dvz /= n

        if jump and self.grounded:
            self.vy = JUMP_VY
            self.grounded = False

        # integrate
        self.x += dvx * RUN_SPEED * DT
        self.z += dvz * RUN_SPEED * DT
        self.vy -= GRAVITY * DT
        self.y += self.vy * DT

        ground = self._ground_at(self.x, self.z)
        if ground is not None and self.vy <= 0.0 and self.y <= ground:
            self.y = ground
            self.vy = 0.0
            self.grounded = True
        elif ground is None:
            self.grounded = False

        # progress reward toward the gate is worth a little
        reward += clamp01((self.z - self.prev_z) * (RUN_SPEED * DT) / STRETCH_LEN) * 0.04
        self.prev_z = self.z

        self._update_hazards()
        self._update_bombs()
        self._update_tiles()
        self._collide()

        if self.done:
            return self._observe(reward)

        # gate crossing
        st = self.stretches[self.stretch]
        if self.z >= st["gate"]:
            self.gates += 1
            reward += 0.30
            if self.stretch + 1 >= self.total_gates:
                self.won = True
                self.done = True
                self.score += 1.0
                self.message = "You reached the finish gate. You won!"
                return self._observe(reward + 1.0)
            else:
                self.stretch += 1
                self.score += 0.3
                nxt = self.stretches[self.stretch]
                self.z = nxt["z0"] + 1.0
                self.y = nxt["height"]
                self.vy = 0.0
                self.grounded = True
                self.prev_z = self.z
                self.message = "Gate cleared! On to the next stretch."
        else:
            self.message = ""

        self.score += reward
        return self._observe(reward)

    # ---- dynamics -----------------------------------------------------------
    def _update_hazards(self) -> None:
        for h in self.hammers:
            h["angle"] += h["spin"] * DT
        for r in self.rollers:
            r["x"] = r.get("amp", 3.0) * math.sin(r.get("phase", 0.0) + self.t * 1.1)

    def _update_bombs(self) -> None:
        # spawn due bombs
        due = [i for i, when in enumerate(self._bomb_schedule) if self.t >= when]
        for i in due:
            bx = self._bomb_seed.uniform(-HALF_W + 0.8, HALF_W - 0.8)
            bz = self._bomb_seed.choice(
                [self.stretches[-1]["z0"] + 4.0, self.stretches[-1]["z0"] + 12.0,
                 self.stretches[-1]["z0"] + 20.0]
            )
            self.bombs.append({"x": bx, "z": bz, "y": BOMB_DROP, "vy": 0.0,
                               "state": "fall", "ticks": 0})
        for i in sorted(due, reverse=True):
            self._bomb_schedule.pop(i)

        for b in self.bombs:
            if b["state"] == "fall":
                b["vy"] -= GRAVITY * 0.55 * DT
                b["y"] += b["vy"] * DT
                if b["y"] <= 0.4:
                    b["y"] = 0.4
                    b["state"] = "boom"
                    b["ticks"] = BOMB_BOOM_TICKS
            elif b["state"] == "boom":
                b["ticks"] -= 1
                if b["ticks"] <= 0:
                    b["state"] = "gone"
        self.bombs = [b for b in self.bombs if b["state"] != "gone"]

    def _update_tiles(self) -> None:
        for seg in self.tile_segments:
            if seg["state"] == "falling":
                seg["ticks"] -= 1
                if seg["ticks"] <= 0:
                    seg["state"] = "gone"
            if seg["state"] == "alive" and self.grounded and not self.done:
                if seg["z0"] <= self.z <= seg["z1"] and seg["x0"] <= self.x <= seg["x1"]:
                    seg["state"] = "falling"
                    seg["ticks"] = TILE_FALL_TICKS

    # ---- collision ----------------------------------------------------------
    def _collide(self) -> None:
        if self.y < DEATH_Y:
            self._lose("You fell off the course.")
            return
        for h in self.hammers:
            ca = math.cos(h["angle"])
            sa = math.sin(h["angle"])
            dx = self.x - h["x"]
            dz = self.z - h["z"]
            radial = abs(dx * ca + dz * sa)
            dperp = abs(dx * sa - dz * ca)
            if radial < h["arm"] and dperp < 0.22 + BEAN_R and self.y < h["height"] + 0.2:
                self._lose("A spinning hammer knocked you off.")
                return
        for r in self.rollers:
            d = math.hypot(self.x - r["x"], self.z - r["z"])
            if d < r["r"] + BEAN_R * 0.6 and self.y < r["top"] + 0.15:
                self._lose("A spiked roller flattened you.")
                return
        for b in self.bombs:
            if b["state"] == "fall":
                d = math.hypot(self.x - b["x"], self.z - b["z"])
                if d < 0.9 and abs(b["y"] - (self.y + BEAN_H * 0.5)) < 1.0:
                    self._lose("A bomb landed right on you.")
                    return
            elif b["state"] == "boom":
                d = math.hypot(self.x - b["x"], self.z - b["z"])
                if d < BOMB_BOOM_R:
                    self._lose("A bomb exploded too close.")
                    return

    def _lose(self, msg: str) -> None:
        self.alive = False
        self.done = True
        self.message = msg
        self.score -= 1.0