Skip to main content
A guided tour of the open-source LingBot World 2 reference app, which demonstrates every important pattern in the LingBot World 2 SDK. By the end you’ll know how to start a scene from an image plus a layered prompt, drive it with WASD, look around with the mouse and arrow keys, trigger world events on hold-keys, jump and crouch with per-latent camera motion, and surface model errors.

Installation and setup

Get the example running before reading further. Every section below points back at code in the example repo. You will need:
  • Node.js 18+.
  • pnpm (the example pins lockfiles to pnpm; npm or yarn will work but you’ll regenerate the lockfile).
  • A Reactor API key (starts with rk_).
  • Familiarity with the Next.js App Router.
1

Clone the example

The example lives alongside our other reference apps in reactor-team/js-sdk under examples/.
2

Add your API key

Your rk_… key must never reach the browser; the example reads it server-side and mints a short-lived JWT for the client. We’ll cover that route below; for now, drop the key into .env.local:
See a “Could not get a session token” notice? Your REACTOR_API_KEY isn’t loaded. The check lives in app/api/token/route.ts, and the notice in app/page.tsx.
3

Install dependencies and start the dev server

Open http://localhost:3000, click Connect, then click one of the Quick Start examples. The app uploads that scene’s starting image, sends its composed prompt, starts generating, and from there the controls are live: WASD to move, arrows or mouse-look to turn, number keys for world events, Space and C for jump and crouch.

How LingBot World 2 works

Building with LingBot World 2 is different from calling a typical generative API. There’s no image-in / video-out request. You open a long-lived connection, send a seed image plus a prompt, and the model produces a continuous stream of chunks that you steer in real time. The image anchors the scene and is locked at start; the prompt and the input channels drive everything after. Opening the connection isn’t instant. Reactor provisions a GPU for your session, so the client moves through the same four states as every other Reactor model before media flows (disconnected → connecting → waiting → ready). See Sessions for the full breakdown. Three properties of the LingBot World 2 API are worth internalizing before you read on, since the rest of this tutorial assumes them:
  • Commands are asynchronous; events are the source of truth. Calling setImage doesn’t mean the next chunk uses it; the model confirms with image_accepted once the upload has been decoded and is ready. The example keeps its UI state (hasImage, hasPrompt, isGenerating) from the event stream, never from the fact that it sent a command.
  • Errors arrive out-of-band. A broken precondition like start before setImage surfaces later as a command_error event, not as a thrown exception.
  • Input is persistent state, not pulses. The two movement axes hold their last value until you send a new one, and a set_camera_pose payload stays active until you send an empty one. Every press needs a matching release.
One more idea shapes the whole app: the prompt is not a string you write once. The example authors each scene as layers (base / camera / movement / events / vertical) and recomposes the prose whenever the input state changes, so the text the model sees always matches the motion it is asked to render. That harness is the subject of the prompt guide; this page shows the wiring.

Authentication

LingBot World 2 uses the same broker pattern as every browser-side Reactor app: your rk_… key stays on the server and the client receives a short-lived, session-scoped JWT minted from it. The authorization_details body locks the token to LingBot World 2 sessions, so a leaked JWT can’t touch anything else on your account. The route is a few lines:
app/api/token/route.ts
The client half lives in app/page.tsx: fetch the JWT once on mount, then hand it to <LingbotWorld2Provider>, which owns the connection lifecycle from there (including auto-disconnect on unmount).
app/page.tsx
See Authentication for the full concept page, including the Express equivalent and the Python path that has no need for the broker.

The layered prompt harness

The model only ever sees a single prose string via set_prompt, but the app never hand-writes that string. Each scene is a StructuredScene (lib/lingbot-world-prompts.ts) with one prose fragment per concern:
lib/lingbot-world-prompts.ts
composePrompt flattens the active selection to prose: base, then the camera and movement variants for the current isMoving state, then the detail clause of every held event key, then the jump/crouch sentence if one is engaged. The structure is authoring-time only; the wire format is plain text. The controller funnels every prompt-affecting change through one function, which composes the new string and re-sends it only when it differs from the last one sent:
components/lingbot-world-2/LingbotWorldController.tsx
Every input handler that changes what the model should render (pressing W, holding event key 2, releasing C) ends with a call to recomputePromptAndSend(). The model picks the new prompt up at the next chunk boundary. This is the single most transferable pattern in the example: derive the prompt from input state, don’t mutate it. Three UI surfaces sit on top of the same scene model, and none of them add new SDK concepts:
  • The ✎ scene editor (LayeredSceneEditor.tsx) edits any example’s layers, events, and vertical prompts. Edits persist to localStorage as per-example overrides and survive reloads; ↺ reverts to the built-in scene. Editing the running scene re-sends the prompt live.
  • The Show prompt inspector (LivePromptInspector.tsx) shows the exact composed string the model sees at this moment, colored per layer, with the selection logic mirrored in prompt-segments.ts so you can audit why each fragment is present.
  • The Custom scene card runs the same flow from your own image and a from-scratch layered prompt.

Starting a scene

Both the Quick Start examples and the custom scene funnel into one function, applyScene, which runs the canonical LingBot World 2 launch sequence: reset if something is already running, upload the starting image, send the composed prompt, then start.
components/lingbot-world-2/LingbotWorldController.tsx
Two details are worth stealing. First, the input state is cleared before switching scenes: a key held through the switch would otherwise leave a movement axis stuck on in the new world. Second, start is delayed until the image and prompt have had time to land; start requires both a registered prompt and image and fails with command_error otherwise. The UI still treats the event stream as truth: image_accepted, prompt_accepted, and conditions_ready set the hasImage / hasPrompt flags that gate the manual Start button.

Reading the event stream

The example handles every backend message in one typed hook with a switch:
components/lingbot-world-2/LingbotWorldController.tsx
Note the workers_ready cast: when the backend emits a message the published schema doesn’t declare yet (workers_ready remains undeclared as of SDK 0.3.0), widen the union in place rather than abandoning the typed hook. Every declared message still narrows to its typed shape inside the switch. chunk_complete is more than progress reporting here. It is the clock for everything chunk-granular: the per-latent jump and crouch motion below advances one chunk per tick, and the camera-pose layer re-sends its current payload so held inputs keep applying. See the Messages table for the full list.

Driving with WASD

Movement uses the model’s two persistent axes: set_move_longitudinal (W/S) and set_move_lateral (A/D). Both can be non-idle at once, so W+A drives a diagonal. The crucial invariant: axes hold their last value until you send a new one. Every keydown needs a matching keyup, or the world keeps moving after the user lets go. The example tracks each axis as a small stack of held keys, so opposing keys resolve the way game players expect (press W, add S, release S: you’re moving forward again, because W is still held):
components/lingbot-world-2/LingbotWorldController.tsx
That last line is the layered harness earning its keep: pressing W doesn’t just send a movement command, it flips the composed prompt from the scene’s static variants (an idle subject, an orbiting camera) to its dynamic variants (a traveling subject, a rear-view tracking camera). Two channels, one story. Also worth carrying into your own code: the example clears all held input on window blur, on reset, and before applying a new scene. Any path where a keyup can be lost needs a sweep back to idle.

The camera-pose layer

Everything look-related routes through set_camera_pose, the model’s native motion-delta channel: mouse-look (pointer lock), arrow-key look, Q/E roll, the on-screen joystick, orbit mode, and the vertical motion of jump and crouch. The example leaves the discrete set_look_horizontal / set_look_vertical axes unused; routing arrows through the same channel as the mouse means they stack, share one code path, and drive orbit the same way. Four backend facts shape the implementation (CONTROLS.md in the repo derives all of this):
  1. Payloads are per-latent deltas. Each latent is [rx, ry, rz, tx, ty, tz] (Euler-radian rotation plus camera-local translation). The backend chunk is 3 latents (≈12 pixel frames), so the app sends 18 floats per chunk to steer each latent on its own.
  2. Rotation overrides the look axes; translation adds to WASD. A crouch dip doesn’t need to re-send forward motion; the backend sums them.
  3. Translation is max-norm normalized per chunk. Absolute magnitude is erased; only the sign and the within-chunk shape survive. A motion’s size is its latent count, not its numbers.
  4. The convention is y-down: up is negative ty.
One function builds and sends the payload. It runs on every chunk_complete (the clock) and once when any control engages or releases, so a new input applies without waiting a full chunk:
components/lingbot-world-2/LingbotWorldController.tsx
Patterns to keep:
  • Accumulate between sends. Mouse deltas add up in a ref and convert to one per-frame rotation at send time, clamped so a fast fling can’t over-rotate.
  • Release with an empty payload, once. When the last control disengages, send camera_pose: [] a single time to hand rotation back to the look axes and translation back to WASD, then stop sending.
  • Orbit is a coupling, not a mode. Orbit (the O key) pairs the yaw you’re already sending with a proportional strafe, so the point R ahead stays centered while the camera circles it. R is the only control; R = 0 is rotate-in-place.
While a pose is active, its rotation overrides the arrow-look axes even when the pose carries zero rotation. Say so in your UI copy for held vertical modes; otherwise “my arrow keys stopped working” reads as a bug.

World events on hold-keys

Each scene binds up to nine detail clauses to the number keys. Holding key 2 weaves that event’s sentence into the composed prompt; releasing it recomposes without it, reverting the world to the scene’s idle. Events stack: any two held together are appended in press order.
components/lingbot-world-2/LingbotWorldController.tsx
There is no bespoke “event” wire feature behind this: it’s set_prompt again, driven by the same recompose function. All the craft is in the prose. The three shipped scenes (lib/lingbot-cases/*.json) are production-authored examples: the noir alley’s key 1 fires a pistol, its key 3 fires a rocket launcher with a disambiguation guard against the model reading “RPG” as a handheld gun. The prompt guide covers the authoring rules; an event can even swap the whole base layer (a portal world) via layer versions.

Jump and crouch

Space and C are hold-controls like WASD, but each is a (camera motion, prompt sentence) pair delivered together, so the prose matches the physics. The sentences are per-scene fields (jumpPrompt, crouchPrompt, standPrompt), editable in the scene editor, and flow through composePrompt as the vertical segment. The motion half rides the per-latent ty channel you saw above. The default jump mode, charge, holds Space to step a discrete meter (1 to 3 chunks), then fires that level’s per-latent arc on release. Arcs are hand-editable grids of +1 up / 0 still / -1 down per latent, and the defaults are symmetric: because the backend normalizes magnitude away, equal counts of up-latents and down-latents is what makes the character land back at launch height. The arc advances on the chunk clock:
components/lingbot-world-2/LingbotWorldController.tsx
Crouch mirrors jump with press and release as independent triggers: C-down fires a one-chunk downward dip plus the crouchPrompt for the whole hold, C-up fires the reverse dip plus the standPrompt for one chunk. Jump is locked while an arc is in flight (no double-jump), and crouch dips fire only on the idle-to-held transition, so key auto-repeat can’t spam motion. Both controls also have simpler modes (prompt sends the sentence only; hold sustains a straight up or down translation), selectable next to the pad; CONTROLS.md in the repo is the full design writeup.

Backend knobs

The Advanced panel wires up the remaining typed setters, each a plain push-on-change:
  • setSeed for reproducible runs (applies to the next start).
  • setRotationSpeedDeg for the discrete look axes.
  • setAttnWindow ("auto" | "small" | "large") to override the DiT self-attention window.
The KV-cache reset controls, set_kv_cache_reset and trigger_kv_cache_reset, predate their typed methods: the example was written against SDK 0.2.5, which did not declare them, so they go through the raw escape hatch. SDK 0.3.0 declares both as setKvCacheReset and triggerKvCacheReset, so on current SDKs call the typed methods and reserve the escape hatch for commands the schema does not declare yet. The typed surface wraps the base store’s sendCommand, which stays available for this case:
components/lingbot-world-2/LingbotWorldController.tsx
The example also re-asserts the selected values once on every connect, so a fresh session reflects the UI state rather than the backend defaults. Sliders and toggles that look set but were never sent to this session are a classic reconnect bug.

Surfacing command_error

Every LingBot World 2 command can fail a precondition check (most commonly start before both setImage and setPrompt have landed). The example never lets one pass without a visible trace: the command_error branch of the message handler feeds a toast that self-dismisses after a few seconds.
components/lingbot-world-2/LingbotWorldController.tsx
A few LingBot-specific failure modes worth knowing about:
  • start before conditions are set. The model rejects start unless both a prompt AND a reference image have been registered.
  • setImage during generation is a silent no-op. The seed image is locked once a session starts; the new image is dropped without a command_error.
  • setPrompt during generation is fine. The new prompt takes effect at the next chunk boundary. The whole layered harness depends on this.
  • trigger_kv_cache_reset while the reset mode is "off" is rejected with command_error; the example disables the button in that mode instead of letting users find out.

What’s intentionally left out

The demo covers the launch + drive + restyle loop. A few things stay out of scope:
  • Recording: the base SDK ships requestClip and clip playback components that work unchanged with LingBot World 2; see Recordings.
  • Gamepad input: same shape as the keyboard handler; press = value, release = idle.
  • Authoring new scenes in code: scenes are plain JSON conforming to StructuredExample; drop a file into lib/lingbot-cases/ and list it in lib/lingbot-cases-examples.ts. The prompt guide covers how to write one.
For the deeper design rationale behind the motion system (the camera-pose contract, symmetry, trigger semantics), read CONTROLS.md in the example repo.