The Functor manual

Functor Lang is a deliberately small, F#-inspired language for game logic. It is interpreted — the source ships as text and runs natively, in the browser, and in the sandbox — and it hot-reloads with your game's state preserved. Most full programs on this page carry a ▶ try it button that opens them live in the sandbox.

Need an exact signature? This manual teaches the shape of a game; the generated API reference answers "what exactly does this take?" — see Looking things up.

Get started

Nothing to install: the sandbox runs Functor Lang in the browser. Start there; install only when you want a desktop window, real assets, and your own files on disk.

Set up

Local development needs Rust (stable), Node 22, and wasm-pack:

git clone https://github.com/tommy-xr/functor && cd functor
npm run build:cli                    # builds the functor CLI + runtimes

A Functor Lang project is a directory with two files — a manifest and the game:

# functor.json
{ "language": "functor-lang", "entry": "game.fun" }

Your first run

Take a game.fun from the tutorial below — or scaffold one with functor -d . init. With a manifest and a game in the directory, these are the four commands you will use all day:

functor -d . run native      # desktop window
functor -d . run wasm        # serves it in the browser
functor -d . develop         # hot-reload loop: save the file, see it in ~1 frame
functor -d . build           # typecheck (diagnostics are errors)

Hot reload preserves the model. Under develop (and in the sandbox), saving an edit swaps the program under the running state: a bouncing ball keeps bouncing, mid-arc, with your new gravity. A broken edit keeps the old program running and shows the error. An edited init takes effect on restart, not on reload.

Tutorial

Six steps, each a complete program — nothing is elided, and every one runs as written. Press ▶ try it to open a step in the sandbox, or paste it into a game.fun and run it. Each step introduces one new idea; each program is self-contained.

1 · Hello world

The smallest program that puts something on the screen. Three bindings are all a game ever requires: init (the starting model — a value), tick (step it), and draw (describe a frame). Frame.create2D plus a Camera2D gives you a flat, center-origin world measured in your own units:

let init = {}

let tick = (model, dt: float, tts: float) => model

let draw = (model, tts: float) =>
  Frame.create2D(
    Camera2D.create(20.0, 12.0),
    "HELLO, FUNCTOR" |> Sprite.text(Color.rgb(0.25, 0.85, 0.9), 1.0))

Nothing moves yet, because tick hands the model straight back and draw ignores the clock.

2 · A rotating cube

Now use the clock. draw's second argument is tts — total time in seconds — so a frame that rotates by tts animates without storing anything. This is a 3D frame: Frame.create with a Camera3D placed in the Y-up world.

let init = {}

let tick = (model, dt: float, tts: float) => model

let draw = (model, tts: float) =>
  Frame.create(
    Camera3D.lookAt(Vec3.make(0.0, 2.0, -6.0), Vec3.make(0.0, 0.0, 0.0)),
    Scene.cube()
      |> Scene.emissive(Color.rgb(1.0, 0.2, 0.8))
      |> Scene.rotateY(Angle.radians(tts)))

Angles are branded values — Scene.rotateY(1.57) is a teaching error, not a rotation. Say Angle.radians(1.57) or Angle.degrees(90.0) — or their literal suffixes, 1.57rad and 90deg.

3 · Handle input

Motion that depends on the player has to live in the model. sampledInput runs once immediately before every simulation step and hands you a snapshot of what is currently held — so continuous movement needs no key-state bookkeeping of your own. Store a direction, then integrate it in tick. Hold A or D (click the preview first so it has keyboard focus):

let init = { x: 0.0, dx: 0.0 }

let sampledInput = (model, snapshot: Input.snapshot) =>
  let held = (k: Key.t) => snapshot.heldKeys |> List.any((key) => key == k) in
  { model with
      dx: (if held(Key.D) then 1.0 else 0.0) - (if held(Key.A) then 1.0 else 0.0) }

let tick = (model, dt: float, tts: float) =>
  { model with x: Math.clamp(-4.0, 4.0, model.x + model.dx * 6.0 * dt) }

let draw = (model, tts: float) =>
  Frame.create(
    Camera3D.lookAt(Vec3.make(0.0, 2.0, -8.0), Vec3.make(0.0, 0.0, 0.0)),
    Scene.cube()
      |> Scene.emissive(Color.rgb(0.2, 0.9, 1.0))
      |> Scene.translate(Vec3.make(model.x, 0.0, 0.0)))

Multiplying by dt is what makes the speed a rate (6 units per second) rather than a per-frame nudge. The snapshot also carries pressedKeys / releasedKeys for one-shot actions — see the game contract, which also covers the event-shaped input hook for when you want raw key events.

4 · Messages and subscriptions

The full loop is Model–View–Update. Beyond tick, a game can receive messages — your own variant values — which fold through update. subscriptions declares where they come from; Sub.every is a timer on a global time grid. This one pulses a sphere from tick and counts beats once a second from a subscription, showing the count in a HUD:

type Msg = | Beat

let init = { spin: 0.0, beat: 0.0 }

let tick = (model, dt: float, tts: float) =>
  { model with spin: model.spin + dt }

let update = (model, msg) =>
  match msg with
  | Beat => { model with beat: model.beat + 1.0 }

let subscriptions = (model) => Sub.every(Time.seconds(1.0), Beat)

let ui = (model) =>
  Ui.text(Text.concat("BEATS  ", Text.fixed(model.beat, 0.0)))
    |> Ui.panel(Ui.topLeft())

let draw = (model, tts: float) =>
  Frame.create(
    Camera3D.lookAt(Vec3.make(0.0, 2.0, -6.0), Vec3.make(0.0, 0.0, 0.0)),
    Scene.sphere()
      |> Scene.emissive(Color.rgb(1.0, 0.3, 0.8))
      |> Scene.scale(1.0 + 0.2 * Math.sin(model.spin * 4.0)))

subscriptions requires update. Durations are branded like Angles, so Sub.every(1.0, Beat) is an error — say Time.seconds(1.0) or 1s. The ui hook is the screen-space HUD, another pure function of the model.

5 · Physics

Physics is declarative: the optional physics hook describes the bodies that should exist this frame, and the runtime reconciles them against the live world and steps it. Bodies are matched across frames by their Physics.tag, which is also how draw asks for a body's live pose:

let init = {}
let tick = (model, dt: float, tts: float) => model

let ballTag = Physics.tag("ball")

let physics = (model) =>
  Physics.scene(Vec3.make(0.0, -9.81, 0.0), [
    Physics.fixed(Physics.tag("ground"), Physics.box(20.0, 0.4, 20.0))
      |> Physics.at(Vec3.make(0.0, -0.2, 0.0)),
    Physics.dynamic(ballTag, Physics.sphere(0.5))
      |> Physics.at(Vec3.make(0.3, 4.0, 0.0))
      |> Physics.restitution(0.8),
  ])

let draw = (model, tts: float) =>
  Frame.createLit(
    Camera3D.lookAt(Vec3.make(0.0, 3.0, -8.0), Vec3.make(0.0, 1.0, 0.0)),
    Scene.group([
      Scene.plane() |> Scene.scale(20.0) |> Scene.lit(Color.rgb(0.4, 0.45, 0.55)),
      Scene.sphere()
        |> Scene.scale(0.5)
        |> Scene.lit(Color.rgb(1.0, 0.4, 0.6))
        |> Physics.transformed(ballTag),
    ]),
    [
      Light.ambient(Color.rgb(0.15, 0.15, 0.2)),
      Light.directional(Vec3.make(0.4, -1.0, 0.3), Color.rgb(1.0, 0.95, 0.9), 0.9) |> Light.castShadows,
    ])

Keep physics reads in draw: it runs after the step and sees this frame's world, while tick sees the previous frame's.

6 · Multiplayer: two roles in one file

A multiplayer game is two roles over one world. Functor lets both roles live in a single file: functor.json declares entries instead of one entry, and each role names an inline module block in that file. The block's members are that role's contract — the client role reads Client.init / Client.tick / Client.draw, the server role reads Server.init / Server.tick / Server.draw.

# functor.json
{
  "language": "functor-lang",
  "entries": {
    "client": { "file": "game.fun", "module": "Client" },
    "server": { "file": "game.fun", "module": "Server" }
  }
}

Everything above the two blocks is shared: the protocol type both roles agree on, and the authoritative rules written as pure functions. That sharing is the point — one declaration of the protocol, so a typo on either side is a check-time error. And because it is one buffer, the protocol and both role contracts change together; build validates every declared role in one pass. Each local runtime watches that same file and reloads its own live model when it changes; delete a block a role names and the reload fails loudly, keeping the old program running.

This step is the role structure, not the wire. To keep it runnable in one process, the client below folds both seats' messages in locally — so what you get is the shape a networked game has, with the transport left out. The real transport is Sub.connect / Sub.listen and Effect.sendMsg, whose events arrive as the built-in Net module's variants. Sub.connect is a desired connection: it reports each failed attempt as Net.Error and retries forever with bounded exponential backoff (250 ms initially, at most 8 s), resetting after Net.Connected; examples/orbs is a client and an authoritative server actually talking over it.

// ===== PROTOCOL — what both roles agree on =====
type Ship = { pid: float, x: float, dx: float }
type Wire = | Steer(dx: float)

// ===== THE WORLD — the authoritative rules, as pure functions =====
let world0: List<Ship> = [
  { pid: 0.0, x: -4.0, dx: 0.0 },
  { pid: 1.0, x: 4.0, dx: 0.0 }]

// Identity is keyed by SENDER, never read out of the wire value — with a real
// transport that is the connection the message arrived on.
let recv = (senderPid: float, wire: Wire, ships: List<Ship>): List<Ship> =>
  match wire with
  | Steer(dx) =>
    ships |> List.map((s) => if s.pid == senderPid then { s with dx: dx } else s)

let step = (dt: float, ships: List<Ship>): List<Ship> =>
  ships |> List.map((s) => { s with x: Math.clamp(-9.0, 9.0, s.x + s.dx * 7.0 * dt) })

// A stand-in peer: one steering value per pid, so the seats never march in step.
let botDx = (pid: float, tts: float): float => Math.sign(Math.sin(tts + pid * 2.0))

// ===== RENDERING — shared by both roles =====
let mine = () => Color.rgb(0.25, 0.85, 0.9)
let theirs = () => Color.rgb(0.91, 0.35, 0.72)

let render = (myPid: float, ships: List<Ship>) =>
  Frame.create2D(
    Camera2D.create(24.0, 14.0),
    Sprite.group(ships |> List.map((s) =>
      Sprite.circle(if s.pid == myPid then mine() else theirs(), 1.0)
        |> Sprite.move(s.x, 0.0))))

// ===== CLIENT ROLE — Client.init / Client.tick / Client.draw =====
module Client {
  type Model = { myPid: float, botPid: float, ships: List<Ship>, dx: float }

  let init: Model = { myPid: 0.0, botPid: 1.0, ships: world0, dx: 0.0 }

  let sampledInput = (m: Model, snapshot: Input.snapshot) =>
    let held = (k: Key.t) => snapshot.heldKeys |> List.any((key) => key == k) in
    { m with dx: (if held(Key.D) then 1.0 else 0.0) - (if held(Key.A) then 1.0 else 0.0) }

  let tick = (m: Model, dt: float, tts: float) =>
    { m with ships:
        m.ships
          |> recv(m.myPid, Steer(m.dx))
          |> recv(m.botPid, Steer(botDx(m.botPid, tts)))
          |> step(dt) }

  let draw = (m: Model, tts: float) => render(m.myPid, m.ships)
}

// ===== SERVER ROLE — Server.init / Server.tick / Server.draw =====
module Server {
  type Model = { ships: List<Ship> }

  let init: Model = { ships: world0 }

  let tick = (m: Model, dt: float, tts: float) =>
    { m with ships:
        m.ships
          |> recv(0.0, Steer(botDx(0.0, tts)))
          |> recv(1.0, Steer(botDx(1.0, tts)))
          |> step(dt) }

  // The authority holds no seat of its own, so nothing renders as "mine".
  let draw = (m: Model, tts: float) => render(-1.0, m.ships)
}

Two rules to know when a role moves into a block. A block's own names shadow the file's, so module Client { let init = init } is self-referential rather than an alias — give shared values names the contract never uses (world0, render) and let each block call those. And a module name occupies its file's type namespace too, so module Client may not sit beside a top-level type Client; the role's model type goes inside the block, as Client.Model.

--entry picks the role, and build validates every declared role's contract under its own block:

functor -d . build                      # checks both roles
functor -d . run native --entry client  # you steer, with A and D
functor -d . run native --entry server  # the same world, seen from the authority

A role is not native-only. run wasm and build wasm bake it into the served or exported page's boot config, this site's player takes ?module=Server on its URL, and run vr declares the role in every push to the headset.

This is the one step with no ▶ try it button — that path pastes a program in and boots the file's bare init / tick / draw, which a role living in a block by definition doesn't answer. The sandbox can play a declared role when it knows it up front: open Orbs in the sandbox to see the same two-block shape at full size — over a real wire, with an authoritative claim-resolution rule.

The game contract

A runner-hosted game defines these top-level bindings:

bindingshape
init{ … }the initial model — a value, not a function
tick(model, dt, tts) => model'per-frame step; dt seconds since last frame, tts total time
draw(model, tts) => Framepure frame description
input(model, key, isDown) => model'optional; key is a Key.t variant such as Key.W, Key.Up, or Key.Space — key repeats arrive as isDown = true, so latch if you need edges
sampledInput(model, snapshot) => model'optional; held levels plus deterministic pressed/released edges — one Input.snapshot immediately before every fixed step
mouseMove(model, x, y) => model'optional; window pixels
mouseWheel(model, delta) => model'optional
mouseButton(model, button, isDown) => model'optional; button is a Mouse.t variant — Mouse.Left, Mouse.Right, or Mouse.Middle. Captured delivery supports free-look shooting; visible delivery supports absolute 2D picking
update(model, msg) => model'optional; messages are your variant values
subscriptions(model) => Suboptional (requires update); declarative timers
physics(model) => Physics.scene(…)optional; declarative bodies, reconciled each frame
ui(model) => Ui.viewoptional; a screen-space HUD drawn over the frame — see Ui
soundScape(model) => AudioScene.create(…)optional; declarative positional audio, reconciled like physics

The three mouse hooks receive captured shell input by default. Set "mouseCapture":false in functor.json to disable that path, or "cursor":"visible" for absolute pointer input (which also disables capture). A program with no captured mouse hooks shows no capture control at all. Native capture starts on a non-UI click and Escape releases it, and a held button is swept released when the window loses focus. Manifest-less site IDE and inline-sandbox sessions use ?mouseCapture=false or ?cursor=visible on the page URL.

The model-returning entry points (tick, input, sampledInput, mouseMove, mouseWheel, mouseButton, update) may instead return a 2-tuple (model', effect) to issue one-shot effects.

Sampled levels and edges: sampledInput

input is event-oriented (including native key repeats). sampledInput gives you both what is currently held and de-duplicated transitions since the previous fixed step. Continuous movement and one-shot actions therefore need no model-resident input latch.

let sampledInput = (model, snapshot: Input.snapshot) =>
  let held = (k) => snapshot.heldKeys |> List.any((key) => key == k) in
  let pressed = (k) => snapshot.pressedKeys |> List.any((key) => key == k) in
  { model with
      dx: (if held(Key.D) then 1.0 else 0.0) - (if held(Key.A) then 1.0 else 0.0),
      jumps: model.jumps + (if pressed(Key.Space) then 1.0 else 0.0) }

The snapshot is a plain record. Keyboard has heldKeys, pressedKeys, and releasedKeys; mouse has buttons, pressed, and released, each with left / right / middle. A quick tap may appear in both edge sets while the held level reports the final state. Edges survive render frames with no simulation step, reach the first catch-up step, then clear. Native key repeat still reaches input, but does not create another sampled press. The snapshot also carries xr : Option.t<…> for headset head and controller poses (see Input in the API reference). Being plain data is what lets sampled input be recorded and replayed deterministically.

Frame order: sampledInput → subscriptions → updatetick → physics (fixed-step 60 Hz) → draw. Physics reads in draw see this frame's stepped world; reads in tick see the previous frame's — so on the very first frame declared bodies don't exist yet. Keep physics reads in draw.

The debug camera

Every project gets a shell-owned Debug camera in the timeline — no manifest setting, available while playing or paused. Fly it with mouse look, WASD, and Q/E for down/up (the wheel adjusts FOV); a 2D view pans and zooms instead. Its drawer switches between FPS and orbit views, makes authored materials transparent or visualizes normals/tangents, overlays live physics and the authored camera frustum, and can hide game UI; a pure 2D view shows only the pan and zoom controls.

It never touches game state, replay, or the authored camera used for culling and portal views — it is a way to look, not to play. Escape releases the pointer, clicking the viewport recaptures it, and Exit debug view returns to the authored camera.

Building and debugging multiplayer

Functor does not make one model magically shared. A multiplayer project declares roles, and each role is an ordinary game runtime with its own model. The usual compact shape is one game.fun: shared protocol and world rules at file scope, then module Client { … } and module Server { … }. The manifest maps role names to those inline modules:

{
  "language": "functor-lang",
  "entries": {
    "client": { "file": "game.fun", "module": "Client" },
    "server": { "file": "game.fun", "module": "Server" }
  }
}

examples/orbs is the reference. Its one buffer declares the wire exactly once, above both roles:

type Ship = { pid: float, x: float, y: float, rot: float }
type Orb = { id: float, x: float, y: float, owner: float }
type Intent = { turn: float, thrust: bool, claim: bool }

type Wire =
  | Join
  | Welcome(pid: float)
  | Steer(intent: Intent)
  | Claim(orbId: float)
  | Snapshot(ships: List<Ship>, orbs: List<Orb>)

Join opens the handshake; Welcome(pid) gives a client the identity the authority assigned it; Snapshot carries the authoritative ships and orbs back. Two details are the design lesson. Steer(intent) carries no pid: identity is the connection the packet arrived on, so a client cannot choose whom it steers. And Claim(orbId) is a request, not a fact. The server looks up the sender, checks that ship is really in range, resolves ties, and announces the result in a later snapshot.

Keeping both contracts and the protocol in one file means one edit cannot update a client-side copy while forgetting a server-side copy. functor build checks every role, and each local role runtime watches and reloads the same file with its own model preserved. The browser sandbox reloads the same way: an editor push reaches every pane, the authority included, and each one re-resolves its own role from the edited buffer — one edit, one session-wide reload, every model preserved.

The transport

Networking follows the same subscription/effect split as the rest of the game contract. A client returns Sub.connect(url, tagger) from subscriptions; an authority returns Sub.listen(address, tagger). The tagger maps ordered Net.NetEvent values — Net.Connected, Net.Data, Net.Disconnected, and Net.Error (plus Net.Message for text) — into your game's messages. Effect.sendMsg(connectionId, value) sends plain data and the peer receives that value already decoded in Net.Data; there is no string codec to maintain for the shared Wire type.

In the sandbox every pane boots on the embedder transport. Its Sub.connect, Sub.listen, and Effect.sendMsg commands go to the host page, which matches the listener, assigns connection ids, and routes events back into the panes. No browser socket or separate server process is involved; the page is the network.

The sandbox is the instrument

Open Orbs in the sandbox. The CLIENTS control chooses one, two, or three player seats, and the + seat adds the next client without restarting the existing panes. The SERVER pane is separate from that count. Its slate chrome marks it as the authority: it has no player number, no keyboard owner, and is never another player. Press f to cycle the available layouts, or choose one in the chrono bar:

layoutuse it for
TILEDscan every client and the authority in one row
GRIDgive clients a two-column play area and the SERVER its own full-width authority strip
NETWORKput the SERVER at the hub and inspect the links and packet flow around it
TABSgive one selected pane the full preview while every other runtime keeps running

NETWORK draws one wire from each client to the hub, with one packet circle per link, direction, and frame that carried traffic. Circles moving toward the server are intents in that client's color; packets moving back are authority traffic in slate. Size reflects bytes carried. On a delayed link, a dot's flight is the packet's real scheduled sent → delivered latency. The default 8 ms LAN preset rounds to the same 60 Hz frame, so its chip says <1f and the UI draws a short 40 ms visibility floor; that one streak is not a latency measurement. Pause or scrub and the moving feed becomes a replay of the packet log at the playhead.

Each client header has a link chip. Pick LAN, Wi-Fi, mobile, or awful, or enter custom latency and jitter. Those two numbers are live: the host schedules the next packet with that one-way delay plus a deterministic jitter draw, while packets already in flight keep their schedule.

Sub.connect is reliable and ordered. The coordinator never drops a packet and never lets a later packet overtake an earlier one. The link chip's loss field is displayed but inert, and no reorder control is shipped; loss and reordering belong to a future datagram channel. There is no datagram API today. This is transport semantics, not a sandbox convenience.

The bottom panel's WIRE tab is available in every layout whenever a SERVER is present. Its badge counts routed packets even while the panel is closed. Open it to tail all traffic, filter to one client↔server link, then choose both, intent (client → server), or authority (server → client). Each row reads #f sent→delivered, direction, payload, and byte count; a same-frame delivery omits the redundant arrow. Effect.sendMsg payloads appear as decoded Functor values such as Steer(…) and Snapshot(…), not as a byte dump. Plain Effect.send text is the explicit exception and is shown exactly as sent.

Click a structured payload to open its value tree. Records, lists, tuples, maps, and variants expand a level at a time; the same tree opens when you click a structured return value in the neighbouring EXECUTIONS tab. The chrono bar above the panes controls the whole session: pause every role, step every role once, or seek every pane to the same session instant. While it is parked, NETWORK and WIRE read the recorded traffic at that playhead instead of continuing to show the live tail.

The language

Values and bindings

let threshold = 10          // every number is a float (f64)
let name = "neon\n"         // strings: \" \\ \n \t
let on = true
let origin = { x: 0.0, y: 0.0 }
let scores = [1.0, 2.0, 3.0]

Line comments only (//). Top-level definitions are mutually visible inside function bodies (and late-bound — that's the hot-reload seam), but a top-level initializer may only use names defined above it.

Functions

let area = (w: float, h: float): float => w * h   // annotations optional
let describe = (score) => Text.concat("score: ", Text.fromFloat(score))

Typing uses Hindley–Milner inference with let-polymorphism: unannotated code still gets full types, and bad calls or mixed-element lists are errors. Annotations make intent explicit; functor build reports all diagnostics. Evaluation depth is capped at 128 — deep iteration belongs in List.*.

Type names are lowercasefloat, string, bool. Writing Float is an error with a did-you-mean, as is Int or Number (there is one number type, float). The one gradual annotation is unknown, which is compatible with everything — write it where a value is genuinely dynamic (a host payload only the two ends can type), and nowhere else.

Records

type Position = { x: float, y: float }        // nominal in annotations

let p = { x: 0.0, y: 1.0 }
let nudged = { p with x: p.x + 1.0 }          // update: fields must exist

Variants and match

type Shape =
  | Circle(radius: float)     // leading | required — first alternative too
  | Rect(w: float, h: float)
  | Point                     // nullary: no parens, ever

let c = Circle(2.0)           // constructors are CALLED positionally
let shapes = [c, Rect(3.0, 4.0), Point]

let area = (s: Shape): float =>
  match s with
  | Circle(r) => 3.14 * r * r // ctor patterns bind positionally
  | Rect(w, _) => w * w       // sub-patterns: names or _ only (no nesting)
  | Point => 0.0

Constructors resolve bare and live in the value namespace (Shape.Circle does not work), so constructor names must be unique across all variant types in the module — a file, or an inline module block, each of which is its own namespace. Variants from a module are the other way around — Option.Some, never bare Some (see Option and Result). Unapplied constructors are first-class: xs |> List.map(Circle). When the scrutinee's type is known, exhaustiveness is checked. Patterns are minimal: Ctor(x, _), Ctor, tuple (x, _) (matched by exact arity), bare names, _, and literals — number/string literal arms need a catch-all (true + false together are exhaustive).

Arms are greedy: a nested match inside an arm consumes the following | arms as its own — parenthesize the inner match. The checker diagnoses it for you: a swallowed arm reports as "not a constructor of" the inner scrutinee, with a hint pointing back at the outer match.

The conditional

if is an expression, so both branches are required. Chain with else if; there is no elif keyword. A bool-literal match is equally valid.

let sizeOf = (n: float): string =>
  if n > 100.0 then "huge"
  else if n > 10.0 then "big"
  else "small"

Pipelines

|> appends the piped value: x |> f(a) is f(a, x). Every prelude function therefore takes its subject (list, scene, body) last (thread-last, F#/Elm-style).

let isHigh = (score) => score > 10.0
let describe = (score) => Text.concat("score: ", Text.fromFloat(score))

let report = (scores) =>
  scores
    |> List.filter(isHigh)
    |> List.map(describe)
    |> Text.toBullets

Tuples

let minMax = (a: float, b: float): float * float =>
  match a < b with
  | true => (a, b)            // (e) is grouping, not a 1-tuple
  | false => (b, a)

let span = (a, b) =>
  let (lo, hi) = minMax(a, b) in   // destructuring let
  hi - lo

Tuples are structural (2+ elements) and are for multiple returns; prefer named records for anything that outlives an expression.

Local mutation

let sum3 = (a, b, c) =>
  let mut acc = a in          // a rebindable slot; expression let-in
  acc := acc + b;             // assignment is := and carries a continuation
  acc := acc + c;
  acc

mut is local-only: no top-level let mut, and a lambda may not capture an enclosing mut slot. Params, globals, and plain lets are immutable. Assignment is := — F#'s <- is not an alias for it.

Operators and equality

+ - * /, < > <= >= == !=, unary -, and the short-circuiting booleans && / || with prefix not. Precedence, tightest to loosest: comparisons → not&&|| → pipelines. So not a == b means not (a == b).

The comparison set is exactly <, >, <=, >=, ==, != — all six at the same precedence. Inequality is !=; F#’s <> is not an alias, and prefix negation stays not (bare ! is not an operator).

let inRange = (n: float): bool => n >= 0.0 && n <= 1.0
let changed = (a: float, b: float): bool => a != b

<= and >= are valid wherever < and > are; != is the exact negation of == — the same operands, the same errors. Comparisons follow IEEE, so 0.0 / 0.0 (NaN) is false against everything including itself, which makes nan != nan true.

== is structural (comparing functions is a runtime error). Division is IEEE (1.0/0.0 is inf); the engine boundary rejects non-finite numbers. There are no loops — iteration is List.map/filter/fold.

Driving games with agents

A Functor game runs and answers questions with no window and no human: the model is plain data, draw is pure data, and the clock can be pinned. The functor mcp command serves exactly that as an MCP server over stdio, so any MCP-speaking coding agent can build, run, inspect, and rewind your game — with no bespoke script and no screen.

Register it with Claude Code:

claude mcp add functor -- functor mcp

Any other MCP client takes the ordinary stdio-server shape:

{
  "mcpServers": {
    "functor": { "command": "functor", "args": ["mcp"] }
  }
}

The server speaks JSON-RPC on stdout, so it takes no -d — every game names its own directory as it is launched, and the server can hold several sessions at once.

The tools cover sessions (launch_game / launch_session_group / connect_game / list_sessions / stop_game), observation (get_state / get_scene / get_trace / capture_frame / wire_log), driving (pause / step / step_all / resume / send_input / rewind / reload_source), authoring (init_game / save_project), and two knowledge tools that need no session at all (language_guide / api_reference). launch_game's mode is hidden (the default — it renders, so frames can be captured) or headless (no GL context at all — CI-friendly, but nothing to read back). The full tool-by-tool reference — every argument, the protocol versions each tool needs, and the errors they surface — is docs/mcp.md.

Driving a multiplayer group

launch_session_group reads the roles from the project's functor.json and starts one runtime per role. With no explicit role list, it launches one of each and puts a role named server first so the authority exists before clients dial. Repeat a role when the test needs more seats. Every runtime starts with --net-transport embedder: the MCP host process drains and routes the traffic itself, and no runtime opens a socket.

launch_session_group { "dir": "examples/orbs",
                       "roles": ["server", "client", "client"],
                       "mode": "headless" }
// → group g1: server s1, client1 s2, client2 s3

pause     { "session": "s1" }
pause     { "session": "s2" }
pause     { "session": "s3" }
step_all  { "sessions": ["s2", "s1", "s3"] }
wire_log  { "group": "g1", "since": 0 }
get_state { "session": "s1" }
get_state { "session": "s3" }

The loop is step_allwire_log → assertions against each get_state response's structured model. The wire log returns routed packets as rows — sequence, endpoints, connection, size, and the exact payload_text the receiver saw — so an agent can prove what crossed as well as what each role eventually believed. Use its since cursor to read only the traffic from the round just stepped. The complete filters and row shape live in docs/mcp.md.

Ordering is semantics: step producer → authority → observer. In the example above s2 produces intent, s1 applies it and broadcasts authority, then s3 observes. step_all steps sessions strictly sequentially in the caller's order; concurrent stepping makes packet arrival a race and is not reproducible.

Pause freezes the clock, not the network. Inbound messages still fold through update, so a paused model can change while frame and tts do not. Do not use frame as a networked model's version label: compare model_revision. Before taking a baseline, wait for pending_net to reach zero on every role; it reports inbound events the shell has accepted but not yet folded. It cannot see a packet still in transit, so pair it with a game-level convergence assertion. The exact state fields and transport endpoints are documented in docs/debug-runtime.md.

One trusted automation function

After launch_game or connect_game, prefer one run_game_code_unsafe call for a trusted multi-step workflow. Its code must be a bare JavaScript function expression — Node invokes it as await program(game):

run_game_code_unsafe {
  "session": "s1",
  "code": "async (game) => { await game.pause(); await game.pressKey(\"3\"); return await game.stepUntil((state) => state.model.enemies.length > 0, { maxFrames: 120, dts: 1 / 60, description: \"the first enemy\" }); }"
}

This is ordinary JavaScript, not TypeScript. It requires Node.js 20 or newer; FUNCTOR_NODE can name a non-default executable. The unsafe suffix is literal: submitted code can import modules, access files, environment variables, and the network, and start processes with the same OS authority as functor mcp. It is RCE-equivalent and the child process is not a security or process-tree sandbox. Use only code and MCP clients you trust with equivalent developer-machine access.

The injected methods cover observation (state / scene / trace / capture), the clock (pause / resume / step / stepFrames), input (input / pressKey), waits (waitForState / stepUntil), reload (reloadSource / reloadProject / reloadAsset / reloadAssets), and rewind. Prefer pressKey for a press-and-step: it attempts the release even if the action fails, and failed runs restore SDK-touched key and mouse button levels to their pre-run state.

stepUntil is the deterministic wait: it checks current state, then advances and observes one fixed frame at a time. Its maxFrames defaults to 600 and is capped at 10,000; dts defaults to 1/60 second; exhaustion throws. waitForState instead polls a running game without advancing it.

State and scene values keep Functor's JSON encoding in Node. Pattern-match ADT variants such as {"$ctor":"Playing","args":[7.0]}, and maps such as {"$map":[["a",1.0],["b",2.0]]}. The tool's first MCP content block includes the function's JSON return value, concrete SDK-call trace, logs, capture metadata, and fresh final state. Every game.capture() also arrives as a following PNG MCP image block.

Lower-level one-offs

Use the individual tools for one-off operations, protocol debugging, or clients without Node.js. Pin the clock, act, step, read. Nothing advances on its own in between, and step does not return until the steps have actually landed — so what comes back is the effect of the input, not a sample of a still-moving game:

launch_game { "dir": "examples/counter", "mode": "headless" }
// → {"session":"s1","url":"http://127.0.0.1:53127","owned":true,…}

pause      { "session": "s1" }
send_input { "session": "s1", "command": {"type":"ui_event","slot":0,"kind":"Clicked"} }
step       { "session": "s1" }
// → {"frame":9,"tts":0.13,"pending_steps":0,…,"model":{"count":1}}

Input is level state. A key, a held mouse button, and an injected XR sample stay in force across steps until released or replaced — that is how you script a paused session: press, step a few frames, release. And a batch (frames > 1) runs up to 8 ticks per rendered frame, so step one at a time when the game must see input or I/O between steps.

Authoring without a filesystem

An agent with no disk of its own can still go from nothing to a durable project. launch_game accepts files[path, source] pairs, the entry first — instead of dir; the server writes them to a scratch directory it owns and runs them normally, so hot reload behaves exactly as it does for a project on disk. That scratch directory is removed when the session stops, so the game has no durable home until save_project gives it one:

launch_game  { "mode": "headless",
               "files": [["game.fun", "let init = { n: 0.0 }\n…"]] }
// → {"session":"s1","dir":"/tmp/functor-mcp-…",…}
get_state    { "session": "s1" }                          // → {"model":{"n":0},…}
reload_source{ "session": "s1", "source": "…edited…" }     // model preserved
save_project { "session": "s1", "dir": "./my-game" }

save_project asks the runtime what it is running rather than copying the launch directory, so a session edited only over the wire saves the edited source. It refuses a directory that already holds a project unless you pass overwrite — which also deletes any module the session does not have, so the directory ends up being exactly the program that ran. The other direction is init_game, which scaffolds the ordinary starter on disk for launch_game to open.

Attaching to a Quest

The device runtime serves the same protocol on loopback port 8123. Forward it over USB and attach — the session is attached, not owned, so stop_game forgets it and leaves the headset running:

adb forward tcp:8123 tcp:8123
connect_game { "url": "http://127.0.0.1:8123" }

Everything else is identical. capture_frame returns the two raw eye buffers side by side, and injected xr samples are rejected on device — the headset resamples live tracking every frame.

Looking things up

Every module, type, and signature Functor exposes lives in the generated API reference — both halves of the API, in one searchable page: the engine prelude that only resolves under the game runner (Scene, Sprite, Camera3D, Frame, Light, Physics, Input, Sub, Effect, Ui, Html, Anim, Asset, AudioScene, …) and the language standard library that resolves everywhere Functor Lang runs, including the bare functor-lang interpreter (List, Map, Text, Math, Random, Debug, Option, Result, Key, Mouse).

It is generated from the exact Functor Lang sources embedded in the engine — the prelude's .funi interfaces and the standard library's own — so a signature there is the signature your build checks against, never a copy that drifted. Each module's list is also exhaustive: if a function is not there it does not exist, and calling it is a check-time error (there is no Math.sinh or Math.hypot, however idiomatic they are elsewhere). The same text is available offline from the CLI:

functor docs                       # Markdown to stdout
functor docs --format json         # the machine-readable shape

Two conventions carry across most of the surface and are worth holding in mind while you read it. The collection, container, and scene functions take their subject last so they thread through |> (see Pipelines) — the numeric and formatting helpers instead read as ordinary notation and take their number first, Text.fixed(hp, 0.0). And identities that matter are branded values rather than bare numbers or strings — Angle.degrees(60.0), Time.seconds(0.5), Physics.tag("ball"), RenderTarget.named("feed") — so a mixed-up unit is a check-time error instead of a puzzling frame. Angles and durations also read as unit-suffixed literals (90deg, 0.5s), which build exactly the same branded values — and they do arithmetic and comparison without leaving the brand (90deg + 45deg, 1.5s < 2000ms).

Sharp edges

The few things that bite hard enough to be worth knowing before you meet them. Every other surprise is explained where it comes up — inline above, or in the API reference.

  • Physics reads are one step behind everywhere except draw. A read (Physics.position, linearVelocity, transformed) answers the last stepped world in every entry point — including inside the physics hook — while draw sees the freshly stepped one. The world is primed from init, so first-frame reads answer the declared poses rather than erroring, and a hook that throws keeps the previous declaration (reported once) instead of stopping the simulation. The one genuine gap: Physics.cast misses against a primed-but-never-stepped world, so a ray query needs one step to have run.
  • Identities are branded values, not bare numbers or strings. Scene.rotateY(1.57) and Sub.every(0.5, …) are check-time errors, not rotations and timers: say Angle.radians(1.57) and Time.seconds(0.5) — or use the literal suffixes, 90deg, 1.57rad, 0.5s, 500ms (the suffix must touch the digits, and unit px = Px declares one for your own brand). The same goes for render targets and physics tags — declare the value once and use it at every site.
  • Engine values are opaque. <Scene>, <Frame>, <Camera3D> and friends can be passed around and composed, but not taken apart, and == on one is a check-time error — compare the numbers you derived instead, or compare structurally with Scene.equals / Frame.equals (meant for tests over draw). Branded values do compare: 90deg == 90deg, 1.5s < 2000ms, and physics tags, all fine. (Sprite.t is the other deliberate exception: it is plain data underneath.)

This manual covers the supported language surface and the engine's core workflows. The detailed language design and roadmap live in docs/functor-lang.md.