Functor Lang — the functor game language

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. Every full program on this page has a ▶ try it button that opens it live in the sandbox.

Get started

The fastest path is the sandbox — nothing to install. For local development you need Rust (stable), Node 22, and wasm-pack; then:

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

An Functor Lang project is a directory with two files:

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

And the game itself — here is the smallest interesting one:

let init = {}
let tick = (m, dt, tts) => m
let draw = (m, tts: Float) =>
  Frame.create(
    Camera.lookAt(0.0, 2.0, -6.0, 0.0, 0.0, 0.0),
    Scene.cube()
      |> Scene.emissive(1.0, 0.2, 0.8)
      |> Scene.rotateY(Angle.radians(tts)))

Run it:

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.

A complete game

A game is Model–View–Update: init is the starting model, tick steps it every frame, draw renders it, and messages (from timers, effects) fold through update. This one pulses a sphere and counts beats once a second:

// a pulsing sphere with a beat counter
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 draw = (model, tts: Float) =>
  Frame.create(
    Camera.lookAt(0.0, 2.0, -6.0, 0.0, 0.0, 0.0),
    Scene.sphere()
      |> Scene.emissive(1.0, 0.3, 0.8)
      |> Scene.scale(1.0 + 0.2 * Math.sin(model.spin * 4.0)))

Input is another pure function — slide the cube with A and D (click the preview first so it has keyboard focus):

let init = { x: 0.0 }

let input = (model, key, isDown) =>
  match isDown with
  | false => model
  | true =>
    (match key with
     | "A" => { model with x: model.x - 0.5 }
     | "D" => { model with x: model.x + 0.5 }
     | _ => model)

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

let draw = (model, tts) =>
  Frame.create(
    Camera.lookAt(0.0, 2.0, -8.0, 0.0, 0.0, 0.0),
    Scene.cube()
      |> Scene.emissive(0.2, 0.9, 1.0)
      |> Scene.translate(model.x, 0.0, 0.0))

And physics is declarative — describe the bodies each frame; the runtime reconciles and steps the world:

let init = {}
let tick = (m, dt, tts) => m

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

let draw = (m, tts) =>
  Frame.createLit(
    Camera.lookAt(0.0, 3.0, -8.0, 0.0, 1.0, 0.0),
    Scene.group([
      Scene.plane() |> Scene.scale(20.0) |> Scene.lit(0.4, 0.45, 0.55),
      Scene.sphere()
        |> Scene.scale(0.5)
        |> Scene.lit(1.0, 0.4, 0.6)
        |> Physics.transformed("ball"),
    ]),
    [
      Light.ambient(0.15, 0.15, 0.2),
      Light.directional(0.4, -1.0, 0.3, 1.0, 0.95, 0.9, 0.9) |> Light.castShadows,
    ])

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 "W", "Up", "Space", … — key repeats arrive as isDown = true, so latch if you need edges
mouseMove(model, x, y) => model'optional; window pixels
mouseWheel(model, delta) => model'optional
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

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

Frame order: 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 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 is gradual: unannotated values check against everything; annotations buy diagnostics (functor build reports them all). Recursion depth is capped (~200) — deep iteration belongs in List.*.

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 file. 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 conditional

There is no if/else. The conditional is a bool-literal match:

let sizeOf = (n: Float): String =>
  match n > 10.0 with
  | true => "big"
  | false => "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.

Operators and equality

+ - * /, < > ==, unary -; conventional precedence, pipelines bind loosest. == 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.

The prelude

Available in runner-hosted games (the CLI, the sandbox) — not in the bare functor-lang interpreter.

Scene

Scene.cube() / sphere() / cylinder() / quad() / plane()primitives (zero args, enforced); plane lies in XZ (ground), quad in XY — a quad's front is +Z
Scene.group([scene, …])compose
scene |> Scene.color(r, g, b)flat unlit color
scene |> Scene.lit(r, g, b)diffuse + specular (needs lights)
scene |> Scene.emissive(r, g, b)unlit glow
scene |> Scene.translate(x, y, z)transforms wrap outward: the outer call applies last in world space — s |> rotateY(r) |> translate(x, 0.0, 0.0) rotates in place, then moves
scene |> Scene.rotateX/Y/Z(angle)
scene |> Scene.scale(k)

Coordinates are Y-up, right-handed: +Y up, +X right, ground in XZ. Angles are branded values: Angle.degrees(60.0) / Angle.radians(1.57) — never bare numbers.

Camera, lights, frames

Camera.lookAt(ex, ey, ez, tx, ty, tz)up = +Y, fov 45°
Camera.firstPerson(ex, ey, ez, yaw, pitch, fov)all three are Angles; yaw 0 / pitch 0 looks down +Z
Light.ambient(r, g, b)
Light.directional(dx, dy, dz, r, g, b, intensity)|> Light.castShadows for a shadowed key light
Light.point(px, py, pz, r, g, b, intensity, range)
Frame.create(camera, scene)what draw returns
Frame.createLit(camera, scene, [light, …])lit + shadowed

Render targets

let feed = RenderTarget.named("security") |> RenderTarget.sized(256.0, 256.0)
frame |> Frame.withRenderTarget(target, targetFrame)writer: renders a full frame (own camera + lights) into the target before the main pass
scene |> Scene.screen(target)reader: an emissive screen showing the target

Declare a target once and use the value at both sites (never a bare string). A scene sampling its own target sees last frame's image. The Render targets example in the sandbox is the reference.

Time, subscriptions, effects

Time.seconds(1.0) / Time.millis(500.0)Durations are branded, like Angles
Sub.every(duration, Msg)stateless global time grid: a long frame fires once; timers tick through hot reload
Sub.none() / Sub.batch([sub, …])
Effect.random(Tagger) / Effect.now(Tagger)one-shots; the tagger is a function (Float) => Msgrandom gives [0,1), now epoch seconds
Effect.none() / Effect.batch([fx, …])return (model', effect) from any entry point

Physics

Physics.box(w, h, d) / sphere(r) / capsule(halfH, r)shapes; box takes full extents
Physics.dynamic("tag", shape)simulated; also kinematic / fixed
body |> Physics.at(x, y, z)spawn pose; also velocity, mass, friction, restitution, sensor
Physics.scene(gx, gy, gz, [body, …])what the physics hook returns
Physics.position("tag"){x, y, z} of the live body
scene |> Physics.transformed("tag")draw a scene at the body's live pose

The tag is cross-frame identity: same tag = same body; drop a body by not declaring it. Re-declaring an unchanged body leaves the simulation alone; changing its declared position teleports it. Reading a tag your physics hook doesn't declare is a runtime error. The physics world survives hot reload, like the model.

Builtins

List.map(fn, list) · List.filter(fn, list)data comes last — pipeline-friendly (F#/Elm-style)
List.fold(fn, init, list) — callback is (acc, x) => …
List.range(n)[0 … n-1]
List.maximum(list)
Text.concat(a, b) · Text.fromFloat(n) · Text.toBullets(list)
Math.sin(n) · Math.cos(n) · Math.clamp01(n)

Sharp edges

  • x |> f(a) is f(a, x) — pipelines append (thread-last).
  • Assignment is := (not <-) and must be followed by ; and a continuation.
  • No if/else — use a bool-literal match.
  • Angles, Durations, and render targets are branded valuesSub.every(0.5, …) and Scene.rotateY(1.57) are errors.
  • A nested match inside an arm eats the following arms — parenthesize it.
  • Constructor names are global to the file's value namespace; nullary constructors never take parens.
  • Key repeats arrive as isDown = true — latch for rising edges.
  • Physics reads belong in draw; in tick you see last frame's world.
  • Engine values (<Scene>, <Frame>, …) are opaque: pass them around, don't compare or inspect them.

This page tracks the language exactly — if a construct isn't here, it doesn't parse. The roadmap lives in docs/functor-lang.md.