How to Build a 2.5D Motion Studio With Opus 5.5

One prompt, one HTML file, one 25-line exporter: build a browser 2.5D motion studio with Opus 5.5 driving Claude Code, render it to MP4, and test the Opus 5.5 shot-list API call with Apidog CLI.

Medy Evrard

9 November 2026

How to Build a 2.5D Motion Studio With Opus 5.5

Apidog for Enterprise

On-Premises Deploy

SSO & RBAC

SOC 2 Compliant

Explore Apidog Enterprise

You can build a 2.5D motion studio with Opus 5.5 in one Claude Code session: a single HTML file that stacks flat layers at different depths, moves a keyframed camera through them with easing, and a 25-line Playwright script that captures every frame and pipes it into ffmpeg. Our build rendered a 6-second 720p MP4 (180 frames) in about 20 seconds on a laptop.

This guide shows the prompts, the code the agent wrote, the bugs it hit, and how to check the Opus 5.5 API calls behind a “describe a shot” feature with Apidog CLI.

What is a 2.5D motion studio?

A 2.5D motion studio is an animation tool that places flat 2D layers (sky, hills, a city, a title card, foreground foliage) at different depths in a 3D space, then moves a virtual camera through them. Layers closer to the camera slide faster than distant ones, which produces parallax and a sense of depth without any 3D models.

Three parts make it a studio rather than a one-off animation:

  1. A scene graph: layers with a z depth, size, position and HTML content.
  2. A timeline: keyframes for the camera (dolly, truck, orbit) and for each layer (opacity, scale, position), each with an easing curve.
  3. A deterministic renderer: a renderAt(t) function that draws the exact frame for any time t, so export never depends on how fast the machine plays it back.

That third point is the one most tutorials skip, and it decides whether your MP4 stutters.

Set up Claude Code on Opus 5.5

Opus 5.5 is the model to start with for this kind of job. Anthropic’s models overview describes it as “for long-running agentic coding and knowledge work”, with a 1M-token context window, 128K max output, and $4 / $20 per million input / output tokens.

Pin the model so an alias change doesn’t swap it mid-project:

claude --model claude-opus-5-5 --effort high

Opus 5.5 defaults to medium effort, according to the effort docs. Raise it to high for the first build, when the agent has to get the projection math right. Drop back to medium for small tweaks to keyframes and colors. If you want the model background first, read what Claude Opus 5.5 is and how prompt caching changes its cost.

The prompt that builds the studio

One specific prompt beats five vague ones. Here is our build spec, written as a single prompt:

Build a 2.5D motion studio as one file, studio.html, with no dependencies.
- Scene: 6 layers (sky, moon, hills, city skyline, a title card "Ship it.",
  foreground foliage), each at its own z depth, using CSS perspective and
  preserve-3d.
- Camera: keyframes for x, y, z (dolly) and yaw (orbit around the card).
- Per-layer tracks: opacity, scale, y. Easings: linear, easeInOutCubic,
  easeInOutSine, easeOutBack.
- A timeline bar with keyframe markers, a playhead, click to scrub,
  space to play or pause.
- renderAt(t) must be pure in t. Expose window.__seek(t) that resolves after
  the frame paints, and a ?capture mode that hides the UI.
Then write export.mjs: Playwright opens ?capture, seeks every frame at 30 fps,
screenshots it and pipes PNGs to ffmpeg as H.264.

The last two lines matter most: they make export part of the design, not an add-on.

The code the agent wrote

The studio is about 150 lines. Three pieces matter.

Keyframes and easing

Each track is a sorted list of keyframes. sample() finds the two keys around t, applies the outgoing key’s easing, and interpolates every numeric field:

function sample(keys, t) {
  if (t <= keys[0].t) return keys[0];
  for (let i = 0; i < keys.length - 1; i++) {
    const a = keys[i], b = keys[i + 1];
    if (t <= b.t) {
      const p = (easings[a.ease] || easings.linear)((t - a.t) / (b.t - a.t));
      const out = {};
      for (const k in b) if (typeof b[k] === 'number' && k !== 't') out[k] = a[k] + (b[k] - a[k]) * p;
      return out;
    }
  }
  return keys[keys.length - 1];
}

The depth trick

The camera never moves. The world moves the opposite way. Every layer then gets a translate in z plus a compensation scale:

const PERSPECTIVE = 1200; // must match #viewport perspective
world.style.transform =
  `translate3d(${-cam.x}px, ${-cam.y}px, ${cam.z}px) rotateY(${-cam.yaw}deg)`;
const comp = 1 - L.z / PERSPECTIVE;
el.style.transform = `translate(-50%,-50%) ` +
  `translate3d(${x * comp}px, ${y * comp}px, ${L.z}px) scale(${comp * s})`;

With CSS perspective set to 1200px, a layer at z = -900 renders at 1200 / 2100, or 57% of its size. Multiplying its scale and its x/y offset by 1 + 900/1200 = 1.75 cancels that out. Every layer appears at the size and position you authored when the camera rests, and still moves at its own speed when the camera moves. That is the whole illusion.

The 2.5D motion studio in the browser: a title card in front of a moon, hills and a city skyline, with a timeline showing camera keyframes in blue, layer keyframes in yellow and a red playhead at 2.10s

The bug worth knowing about

The first version scaled each layer but not its offset, so the hills rose to cover two thirds of the sky. The fix: multiply x and y by comp too. We caught it from one rendered frame, not from reading the code, so ask your agent to render stills at the start, middle and end before you trust any camera move.

Export the timeline to MP4

Real-time playback uses requestAnimationFrame, which drops frames when the machine is busy. Export can’t. So the exporter drives time from the frame index and waits for each frame to paint before it takes a Playwright screenshot:

import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { pathToFileURL } from 'node:url';

const out = process.argv[2] || 'out.mp4';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
await page.goto(pathToFileURL('studio.html').href + '?capture');

const { duration, fps } = await page.evaluate(() => window.__scene);
const ffmpeg = spawn('ffmpeg', ['-y', '-f', 'image2pipe', '-framerate', String(fps),
  '-i', '-', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18', out],
  { stdio: ['pipe', 'ignore', 'inherit'] });

const frames = Math.round(duration * fps);
for (let i = 0; i < frames; i++) {
  await page.evaluate(t => window.__seek(t), i / fps);
  ffmpeg.stdin.write(await page.screenshot({ type: 'png' }));
}
ffmpeg.stdin.end();
await new Promise(r => ffmpeg.on('close', r));
await browser.close();

window.__seek(t) resolves after two animation frames, so each screenshot sees the new transforms. image2pipe (see the ffmpeg formats docs) reads PNGs from stdin, and yuv420p keeps the file playable in browsers.

Our run: 180 frames, 1280x720, 30 fps, 6.0 seconds, a 420 KB MP4, in 20 seconds of wall time. Run npx playwright install chromium once if Playwright has no browser yet.

Three frames from the exported MP4 at 0.3s, 2.5s and 5.9s: the camera pushes in on the title card while the city skyline and foreground hill shift at different speeds, then orbits so the card tilts

The city towers shift less than the dark foreground hill, which leaves the frame entirely. That is parallax.

Add a “describe a shot” feature with the Opus 5.5 API

Next, let a writer type a shot description and get keyframes back. The studio calls the Messages API directly:

curl https://api.anthropic.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2026-10-21" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-opus-5-5",
    "max_tokens": 4096,
    "output_config": { "effort": "low" },
    "system": "Return only JSON: {\"camera\": [{\"t\",\"x\",\"y\",\"z\",\"yaw\",\"ease\"}], \"tracks\": {}}. Times in seconds, 0 to 6.",
    "messages": [{ "role": "user", "content": "Slow push-in, slight orbit right, title fades up in the first second." }]
  }'

Two details from the effort docs matter here. output_config.effort is where the effort level goes in a Messages API request. And Opus 5.5 always uses adaptive thinking (a request with thinking: {"type": "disabled"} returns a 400), so thinking tokens count toward max_tokens. Set it too low and the JSON gets cut off mid-array with stop_reason: "max_tokens". A shot list is a small, well-specified task, so low effort is a sensible first setting; run your own comparison before you settle on it.

Let Claude Code test the API call with Apidog CLI

A truncated or chatty reply breaks sample() at render time, so this call needs a contract test. The same Claude Code session can write and run it with Apidog CLI.

Install and log in once:

npm install -g apidog-cli@latest
apidog auth login --with-token <YOUR_TOKEN>
apidog skill install

Then give the agent one prompt:

In Apidog project <projectId>, create an endpoint for POST /v1/messages and a
test case that sends the "describe a shot" request above with model
claude-opus-5-5. Assert: status 200, body.model is "claude-opus-5-5",
stop_reason is not "max_tokens", and content[0].text parses as JSON with a
non-empty camera array whose t values rise from 0 to 6. Use an environment
with base URL https://api.anthropic.com and the key in a variable. Run it and
summarize failures.

The agent reads the payload schemas instead of guessing them, validates its files, then creates and runs everything:

apidog cli-schema get endpoint-create
apidog cli-schema get test-case-create
apidog cli-schema validate test-case-create --file case.json
apidog environment create anthropic --project <projectId> --base-url https://api.anthropic.com
apidog endpoint create --project <projectId> --file endpoint.json
apidog test-case create --project <projectId> --file case.json
apidog run --test-case <caseId> --project <projectId> -e <envId> -r cli,json

Pass the key at run time with --env-var "api_key=$ANTHROPIC_API_KEY" so it never sits in the project. Run the case again when you change the system prompt or the effort level, since both change what comes back. The same apidog run with -r junit drops into CI; see the Apidog CLI CI/CD guide and Apidog CLI in Claude Code. Apidog’s Free plan covers up to 4 users.

Where to take the studio next

FAQ

What does 2.5D mean in motion graphics? It means flat 2D artwork placed at different depths and filmed by a moving camera. You get parallax and depth cues without 3D models, lighting or a 3D engine.

Why use Opus 5.5 instead of a smaller model for this build? The first build mixes projection math, a keyframe engine and a capture pipeline in one pass. Anthropic positions Opus 5.5 for long-running agentic coding, and our session got a working studio from one prompt plus one round of fixes. For small edits afterwards, a lower effort level is enough.

Why not record the screen instead of capturing frames? Screen recording follows real time, so any slow frame becomes a dropped or doubled frame. Seeking to i / fps and capturing each frame makes the output identical on every machine.

Can I use the “describe a shot” feature without an API key? No. The feature calls the Messages API, which needs your own Anthropic API key. The rest of the studio and the MP4 export run locally with no key.

Which effort level should the shot-list call use? Start at low, since the task is short and the output schema is fixed. Raise it only if your Apidog test shows malformed or off-brief keyframes.

Explore more

How to Make 3D Animations and VFX With an AI Agent for Free

How to Make 3D Animations and VFX With an AI Agent for Free

vork is a free, source-available 3D and VFX engine your coding agent drives from the terminal. Here is the full loop from vork init to a final MP4, what the license allows, and the hardware you need.

9 November 2026

Claude Code Phone Control for Free: Use Your iPhone or Android with Pinky

Claude Code Phone Control for Free: Use Your iPhone or Android with Pinky

Pinky and Poki let Claude Code operate a real phone over MCP for $0. Here is the Pinky setup for iPhone and Android, the safety rules, a Poki comparison, and how to check the APIs behind each screen.

9 November 2026

Run Qwen 3.8 27B Locally for Free: Ollama, LM Studio, llama.cpp, MLX

Run Qwen 3.8 27B Locally for Free: Ollama, LM Studio, llama.cpp, MLX

Run Qwen 3.8 27B locally for free with Ollama, LM Studio, llama.cpp or MLX: RAM per quant, measured tok/s on RTX and Mac, and how to test the local endpoint.

9 November 2026

Practice API Design-first in Apidog

Discover an easier way to build and use APIs

How to Build a 2.5D Motion Studio With Opus 5.5