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;
npmoryarnwill 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:3
Install dependencies and start the dev server
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
setImagedoesn’t mean the next chunk uses it; the model confirms withimage_acceptedonce 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
startbeforesetImagesurfaces later as acommand_errorevent, 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_posepayload stays active until you send an empty one. Every press needs a matching release.
Authentication
LingBot World 2 uses the same broker pattern as every browser-side Reactor app: yourrk_… 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
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
The layered prompt harness
The model only ever sees a single prose string viaset_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
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 tolocalStorageas 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 inprompt-segments.tsso 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
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 aswitch:
components/lingbot-world-2/LingbotWorldController.tsx
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
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 throughset_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):
- 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. - Rotation overrides the look axes; translation adds to WASD. A crouch dip doesn’t need to re-send forward motion; the backend sums them.
- 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.
- The convention is y-down: up is negative
ty.
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
- 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 = 0is rotate-in-place.
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
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
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:setSeedfor reproducible runs (applies to the nextstart).setRotationSpeedDegfor the discrete look axes.setAttnWindow("auto" | "small" | "large") to override the DiT self-attention window.
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
Surfacing command_error
Every LingBot World 2 command can fail a precondition check (most commonlystart 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
startbefore conditions are set. The model rejectsstartunless both a prompt AND a reference image have been registered.setImageduring generation is a silent no-op. The seed image is locked once a session starts; the new image is dropped without acommand_error.setPromptduring generation is fine. The new prompt takes effect at the next chunk boundary. The whole layered harness depends on this.trigger_kv_cache_resetwhile the reset mode is"off"is rejected withcommand_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
requestClipand 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 intolib/lingbot-cases/and list it inlib/lingbot-cases-examples.ts. The prompt guide covers how to write one.
CONTROLS.md
in the example repo.