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:
| binding | shape | |
|---|---|---|
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) => Frame | pure 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) => Sub | optional (requires update); declarative timers |
physics | (model) => Physics.scene(…) | optional; declarative bodies, reconciled each frame |
ui | (model) => Ui.view | optional; 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 →
update → tick
→ 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:
| layout | use it for |
|---|---|
| TILED | scan every client and the authority in one row |
| GRID | give clients a two-column play area and the SERVER its own full-width authority strip |
| NETWORK | put the SERVER at the hub and inspect the links and packet flow around it |
| TABS | give 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 lowercase — float, 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_all → wire_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).