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:
| 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 "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) => Sub | optional (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 → 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 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) => Msg — random 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) |