Scene.ttype
type t = host
An opaque scene node.
PRELUDE + STANDARD LIBRARY · GENERATED SOURCE OF TRUTH
Every type and function a .fun game can call: the engine
modules the runner provides, and the standard library that ships with
Functor Lang itself. This page is generated from the exact sources
embedded in native, wasm, and editor tooling.
Provided by the game runner — available to a hosted .fun game.
Declarative 3D scene nodes, materials, models, animation, and transforms.
Constructors produce immutable scene values. Most modifiers take the scene last so a node can be built as a readable pipeline.
Coordinates are Y-up and right-handed: +Y is up, +X is right, and the ground is the XZ plane.
Transforms wrap outward, so the OUTER call applies last in world space — s |> Scene.rotateY(r) |> Scene.translate(v) rotates in place and then moves, which is the order the source reads. The primitives take no size arguments, so a box is Scene.cube() |> Scene.scaleXYZ(w, h, d).
type t = host
An opaque scene node.
let cube : () => t
Create a unit cube centered at the origin.
let sphere : () => t
Create a unit sphere centered at the origin.
let cylinder : () => t
Create a unit cylinder aligned to the Y axis.
let quad : () => t
Create a unit quad in the XY plane, facing +Z.
let plane : () => t
Create a unit plane in the XZ ground plane.
let model : (Asset.Model) => t
Create a scene node from a model asset.
A locator whose file is missing logs an error and renders the empty fallback asset, so a mistyped or unfetched model shows as nothing rather than failing the frame.
let heightmap : (List<List<float>>) => t
Create terrain from a rectangular grid of height values.
let terrain : (Terrain.t) => t
Create a scene node from an asset-backed terrain descriptor.
let group : (List<t>) => t
Group scene nodes under one transform.
let color : (Color.t, t) => t
Apply an unlit solid color; the scene is last for piping.
let lit : (Color.t, t) => t
Apply a lit solid-color material; the scene is last for piping. It needs lights to be visible — under a plain Frame.create it renders black.
let emissive : (Color.t, t) => t
Apply an emissive solid-color material; the scene is last for piping.
let litTexture : ('texture, t) => t
Apply a lit texture from Texture.t or Asset.Texture.
let emissiveTexture : ('texture, t) => t
Apply an emissive texture from Texture.t or Asset.Texture.
let litNormalMapped : (Color.t, 'texture, t) => t
Apply a lit color and a TANGENT-SPACE normal-map texture.
The map perturbs the surface normal used for lighting, so its bumps catch the scene's diffuse and specular response without changing the geometry.
let screen : (RenderTarget.t, t) => t
Display a render target on this surface.
The surface is emissive, so the feed is shown unlit. A target no frame writes renders magenta with one warning. A quad's front face is +Z, so a monitor built from Scene.quad has to be rotated to face the viewer or the feed reads mirrored.
let opacity : (float, t) => t
Make a subtree TRANSLUCENT — alpha from 0 (invisible) to 1 (unchanged); the scene is last for piping.
The alpha applies uniformly to everything below it, whatever material each node uses — solid colors, textures, lit models, terrain — so a ghost copy of a craft is craftScene(...) |> Scene.opacity(0.35). Nested opacities multiply. An alpha outside 0..1 is an error, not a clamp.
HOW IT RENDERS, so the caveats are predictable:
- Translucent subtrees draw in a pass AFTER all opaque geometry, with depth testing on and depth WRITING off — opaque things in front hide them; they never hide each other. - They are sorted back-to-front by the VIEW-SPACE depth of the average world position of the leaves under each Scene.opacity node. That is the whole sorting granularity: there is no per-triangle sort, so within ONE translucent subtree overlapping surfaces (including the back faces of a closed mesh) read denser where they overlap, and two INTERPENETRATING translucent objects sort by their averages, which is wrong for the overlapping sliver. - A translucent subtree casts no shadow. - Scene.opacity(1.0, scene) is exactly scene — the identity. Nothing that never calls this changes in cost or appearance. The flip side is a STEP at that boundary: the instant alpha drops below 1 the subtree leaves the opaque pass, stops writing depth AND stops casting a shadow, so a fade beginning at exactly 1.0 pops on its first frame rather than easing. - An alpha of 0 draws nothing at all — the subtree is skipped rather than rasterized — so fading fully out costs nothing.
let instanced : (List<Instance.t>, t) => t
Stamp a scene once per instance — one node for thousands of copies.
scene |> Scene.instanced(instances) is semantically a group holding one transformed copy of the template per Instance.t, with each copy's Instance.tint multiplied into the template's material colors. Materials come FROM the template: Scene.cube() |> Scene.lit(color) instanced is lit and shadowed exactly like its copies would be.
The renderer draws recognized templates with hardware instancing — a single cube/sphere/cylinder/quad/plane leaf under any transforms and at most one solid Scene.color / Scene.lit / Scene.emissive material (one draw call), or a Scene.model leaf under transforms (one draw call per mesh primitive, textured exactly like the ordinary model draw). A SKINNED model template — with an attached Scene.animate pose or the zero-config first-clip autoplay — instances at the SHARED pose: the pose is sampled and uploaded once, and every copy skins from it (a crowd in step; per-instance playheads are planned, not yet available). Any other template still renders correctly, expanded copy-by-copy on the CPU with a once-per-topology [functor] perf note — comparable to writing the group by hand, but not faster.
An empty list draws nothing. Scene.opacity inside the template is a teaching error — wrap the whole instanced node instead (… |> Scene.instanced(xs) |> Scene.opacity(a)).
let animate : (Anim.t, t) => t
Attach an animation pose to model nodes; the scene is last for piping.
Without an attached pose, a skinned model plays its FIRST clip on the game clock — attaching one is what puts the playhead under the game's control.
let translate : (Vec3.t, t) => t
Translate a scene node; the scene is last for piping.
let scale : (float, t) => t
Scale a scene node uniformly; the scene is last for piping.
let scaleXYZ : (float, float, float, t) => t
Scale a scene node independently on each axis.
let rotateX : (Angle.t, t) => t
Rotate a scene node around X.
let rotateY : (Angle.t, t) => t
Rotate a scene node around Y.
let rotateZ : (Angle.t, t) => t
Rotate a scene node around Z.
let equals : (t, t) => bool
Compare two scene nodes structurally — the escape hatch for Scene.t, which is opaque and therefore supports no ==.
Intended for inline expect tests over draw output, NOT for per-frame logic: the walk is O(scene size), which is why it is an explicit call rather than an operator.
Three things it is literal about:
- Floats compare EXACTLY — transforms, colors, and playheads. There is no epsilon, so build both sides of a test from the same arithmetic. (The comparison happens after the engine boundary's narrowing to 32-bit, so two numbers that narrow to the same float are equal.) - Assets compare by LOCATOR, not by loaded content: the same path or URL (and the same Asset.whilePending chain) is equal, whether or not either has finished loading. - Animation compares as DECLARED — the clip name and playhead seconds in the attached pose, never a sampled skeleton.
Children are ordered, so two groups holding the same nodes in a different order are not equal.
The answer is a bare bool, so a failing expect reports only that the two scenes differ — scene values are opaque and cannot be printed. Write the assertion over the smallest node that makes the point, or bisect by comparing sub-scenes, rather than one expect over a whole frame.
Per-copy placement for Scene.instanced.
Build one Instance.t per copy — where it sits, how it is scaled and rotated, and an optional color tint — then hand the list to Scene.instanced, which stamps its template scene once per instance.
Instances are CHANNELS, not free-form transform chains: the copy's transform always applies scale, then rotation, then translation, whatever order the combinators were piped in. Combinators compose within their own channel — rotations multiply (the outer pipe applies last, like Scene.rotate*), scales multiply componentwise, and tints multiply componentwise.
type t = host
One copy's placement: position, rotation, per-axis scale, and tint.
let at : (Vec3.t) => t
Start an instance at position, with no rotation, unit scale, and no tint.
let scale : (float, t) => t
Multiply the instance's scale uniformly.
let scaleXYZ : (float, float, float, t) => t
Multiply the instance's per-axis scale componentwise.
let rotateX : (Angle.t, t) => t
Rotate the instance about the X axis; piped later means applied later.
let rotateY : (Angle.t, t) => t
Rotate the instance about the Y axis; piped later means applied later.
let rotateZ : (Angle.t, t) => t
Rotate the instance about the Z axis; piped later means applied later.
let trs : (Vec3.t, Angle.t, float, float, float) => t
The flat fast path: position, yaw, and per-axis scale in ONE call.
Exactly Instance.at(position) |> Instance.scaleXYZ(sx, sy, sz) |> Instance.rotateY(rotationY), minus the per-copy builder-call overhead — reach for it when a large per-frame field makes construction cost visible. Compose further channels (another rotation axis, Instance.tint) on top.
let tint : (Color.t, t) => t
Multiply the copy's material colors by color — a per-instance tint over the template's material, not a second material system. Tints compose by multiplying; white is the identity. The tint is RGB only — a copy's alpha always comes from the template's material. A bare Scene.model template has no material colors to multiply, so tint has no effect there (exactly as in the stamped group).
Complete frame descriptions returned by a game's draw function.
type t = host
An opaque frame description.
let create : (Camera3D.t, Scene.t) => t
Create an unlit frame from a camera and scene.
let createLit : (Camera3D.t, Scene.t, List<Light.t>) => t
Create a lit frame from a camera, scene, and lights.
let create2D : (Camera2D.t, Sprite.t) => t
Create a standalone 2D frame from a camera and sprite tree.
let withRenderTarget : (RenderTarget.t, t, t) => t
Render another frame into a target before rendering the main frame.
The target frame is a complete Frame.create / Frame.createLit with its OWN lights, so a lit or shadowed feed needs createLit plus Light.castShadows there. A scene that samples the very target it is drawn into sees the previous frame's image. The main frame is last for piping.
let withUiTarget : (RenderTarget.t, Ui.view, t) => t
Paint a UI view into a target before rendering the main frame.
The view is a Ui.* tree painted at the target's declared size and read back like any render target (Scene.quad() |> Scene.screen(target)) — a monitor mesh, a cockpit panel. Views on targets are display-only for now: interactive widgets (buttons, sliders, text inputs) render, but their handlers are ignored. The screen clears to the engine's default background. Like withRenderTarget, the FIRST declaration of a target id wins; one id must not be declared by both writers. Runs on every shell — native, web, and VR. The main frame is last for piping.
let withFog : (Fog.t, t) => t
Attach fog to a frame; the frame is last for piping.
let withSkybox : (Skybox.t, t) => t
Attach a cubemap skybox to a frame; the frame is last for piping.
let withClearColor : (Color.t, t) => t
Override a frame's background clear color.
This overrides the default of clearing to the fog color, and paints the background only — it does not change how fog blends over geometry.
let with2D : (Camera2D.t, Sprite.t, t) => t
Add an ordered sprite pass above the frame's 3D scene.
Layers render in call order, so a second with2D draws above the first. The main frame is last for piping.
let equals : (t, t) => bool
Compare two frames structurally — the escape hatch for Frame.t, which is opaque and therefore supports no ==.
Compares every part of the frame: camera, scene, lights, render-target passes, fog, skybox, clear color, and 2D layers (all ordered). It also distinguishes HOW the frame was built — a Frame.create2D frame is never equal to a 3D frame carrying the same layer through with2D. Intended for inline expect tests over draw output, NOT for per-frame logic — the walk is O(frame size).
It inherits Scene.equals's rules: floats compare exactly, assets compare by locator rather than by loaded content, animation compares as declared, and a failing expect can only report THAT the two frames differ — assert over the smallest piece that makes the point.
Cameras for viewing a Y-up, right-handed 3D scene.
type t = host
An opaque camera description.
let lookAt : (Vec3.t, Vec3.t) => t
Create a camera from an eye position and target with a fixed 45° field of view.
let firstPerson : (Vec3.t, Angle.t, Angle.t, Angle.t) => t
Create a first-person camera from position, yaw, pitch, and field of view.
Zero yaw and pitch look down +Z.
On XR this camera is the authored reference center-eye rig: live head and eye deltas compose in its local basis. Position, orientation, and the near/ far planes stay game-owned, while OpenXR owns IPD and per-eye optical FOV.
type ray = { origin: Vec3.t, direction: Vec3.t }
A world-space ray from the camera eye through a logical surface point.
let toWorldRay : (Input.mouse, t) => Option.t<ray>
Map a sampled mouse position through the authored perspective camera.
Mouse position and extent share one top-left-origin logical coordinate space, so the result stays stable across resize and Retina/device-pixel ratio changes. The direction is normalized and both fields feed directly into Physics.cast / Physics.raycast. Returns Option.None while the pointer is outside the surface or the camera is degenerate.
type mappedPose = { position: Input.point3, forward: Input.point3, up: Input.point3 }
A tracked pose mapped into world space through an authored camera.
let mapTrackedPose : (t, Input.pose) => mappedPose
Map a rig-local tracked pose through the authored camera.
The returned position, forward, and up vectors are suitable for placing a controller representation or aiming a ray in the authored world.
let clip : (float, float, t) => t
Set near and far clipping distances; the camera is last for piping.
Both distances must be finite with 0 < near < far; anything else is a teaching error rather than a degenerate projection. Large outdoor worlds should set the far plane explicitly. Keep the near plane as large as gameplay permits to preserve depth-buffer precision.
Center-origin, Y-up cameras for Sprite scenes.
type t = host
An opaque 2D camera description.
let create : (float, float) => t
Create a camera with the visible world width and height at zoom 1.
The renderer preserves this aspect ratio and letterboxes rather than stretching. Width and height must be positive.
let at : (float, float, t) => t
Center a camera at the given world position; the camera is last for piping.
let zoom : (float, t) => t
Set a positive camera zoom; the camera is last for piping.
LARGER is closer: the zoom divides the visible world extent, so 2.0 shows half as much world at twice the size.
let toWorld : (Input.mouse, t) => Option.t<Input.point2>
Map a sampled mouse position through the camera's fitted viewport.
Returns Option.None while the pointer is in a letterbox/pillarbox bar. The mouse carries its logical surface extent, so this remains correct across window resizes and Retina/device-pixel-ratio changes.
Pure, inspectable 2D picture values.
Unlike Scene.t and Frame.t, Sprite.t is represented at runtime as ordinary Functor Lang data. It can be compared, inspected, serialized, stored in a model, and carried through time travel while its internal rendering schema stays private.
type t
An abstract plain-data 2D picture.
type region
A rectangular section of an image in whole source pixels. Coordinates use the conventional image origin: x grows right and y grows down from the top-left corner.
type metrics = {
width: float,
height: float
}
The size of a laid-out run of text, in the same world units as the sprite itself.
let blank : () => t
Create an empty picture.
let rectangle : (Color.t, float, float) => t
Create a centered rectangle with positive width and height.
let square : (Color.t, float) => t
Create a centered square with a positive side length.
let text : (Color.t, float, string) => t
Draw text in the built-in font, centered on its own box like every other primitive, with size the height of one line in world units. Needs no asset: the font is compiled into the runtime. The text comes last, so a formatted value pipes straight in:
Text.fixed(model.score, 0.0) |> Sprite.text(Color.rgb(0.0, 1.0, 1.0), 1.2)
The font is a monospace 8x8 bitmap, so each character advances by exactly size — ask measure for a run's size rather than assuming it. \n starts a new line, stacked at exactly one size of line height, and each line is centered within the block. That stride is the glyph cell, so lines never overlap but sit tight — a descender nearly meets the next line's capitals, as in a terminal. For airier text, draw the lines yourself and space them by more than size with group and moveY. Characters outside printable ASCII occupy their cell but draw nothing, so unsupported text leaves gaps instead of shifting the rest of the line.
Text is centered, so align it by shifting half its measured width — Sprite.moveX(Sprite.measure(size, s).width * 0.5, …) puts its LEFT edge at the origin, and negating that puts its right edge there. That aligns the BLOCK: with several lines only the widest reaches the edge, since each line stays centered until a left-aligning text block exists.
Glyphs are sampled like any other sprite image, so nearest gives crisp pixel edges and the default linear gives smoother ones at large sizes.
let measure : (float, string) => metrics
Measure what text at size would occupy, without rendering it, so labels and columns can be laid out in game logic. The width is the widest line's; the height is size per line, counting a trailing newline as a line, so stacking blocks by their measured height never overlaps them.
let circle : (Color.t, float) => t
Create a filled circle of the given radius, centered on the origin like square — so it spans 2 * radius across. Approximated by a 32-sided polygon, which is under a pixel from true at any size that reads as a circle.
let polygon : (Color.t, List<Input.point2>) => t
Fill a CONVEX polygon through the given points, in order.
Unlike every other primitive, a polygon is NOT re-centered: the points are the geometry, in the sprite's own coordinate space, so an outline computed in game logic lands where it was computed. Either winding works (clockwise or counter-clockwise).
The fill is a triangle fan, which is only correct for a convex outline, so anything else is REJECTED with an error rather than filled wrongly — draw it as a group of convex pieces instead. That covers a CONCAVE outline, a self-intersecting STAR (which turns consistently but winds around more than once), fewer than 3 points, and points that are all on one line.
let line : (Color.t, float, Input.point2, Input.point2) => t
Draw a straight line of the given thickness between two points, in the sprite's own coordinate space (not re-centered, like polygon).
Thickness is measured across the line and is exact at every angle. There are no caps and no joins: the line stops flat at each endpoint, so two lines meeting at an angle leave a notch at the corner — a jointed polyline is not part of this surface yet. A zero-length line draws nothing.
Thickness is geometry, not a screen-space stroke: scale multiplies it along with the length, and scaleXY with unequal factors distorts it for any line that is not axis-aligned.
let image : (float, float, Asset.Texture) => t
Create a centered, textured rectangle with positive width and height.
let imageRegion : (float, float, region, Asset.Texture) => t
Select a source rectangle without requiring the image's full dimensions.
let region : (float, float, float, float) => region
Construct a top-left-origin source rectangle as x, y, width, height.
let group : (List<t>) => t
Group pictures in painter's order, with earlier items behind later items.
let move : (float, float, t) => t
Move a picture by the given X and Y offsets; the picture is last for piping.
let moveX : (float, t) => t
Move a picture along X; the picture is last for piping.
let moveY : (float, t) => t
Move a picture along Y; the picture is last for piping.
let rotate : (Angle.t, t) => t
Rotate a picture around its center; the picture is last for piping.
A positive angle rotates COUNTER-CLOCKWISE, matching the Y-up 2D camera.
let scale : (float, t) => t
Scale a picture uniformly; the picture is last for piping.
let scaleXY : (float, float, t) => t
Scale a picture independently along X and Y; the picture is last for piping.
let fade : (float, t) => t
Multiply a picture's opacity by an alpha from 0 to 1.
let tint : (Color.t, t) => t
Multiply a picture's color by a tint; the picture is last for piping.
let nearest : (t) => t
Use crisp nearest-neighbor sampling for every image in the subtree.
let linear : (t) => t
Use smooth linear sampling for every image in the subtree (the default).
Lights used by Frame.createLit.
type t = host
An opaque light description.
let ambient : (Color.t) => t
Create uniform ambient light.
let directional : (Vec3.t, Color.t, float) => t
Create directional light from direction, color, and intensity.
let point : (Vec3.t, Color.t, float, float) => t
Create point light from position, color, intensity, and range.
let spot : (Vec3.t, Vec3.t, Color.t, float, float, Angle.t) => t
Create a spot light from position, direction, color, intensity, range, and cone angle.
let castShadows : (t) => t
Enable shadow casting; the light is first for piping.
Cubemap skyboxes attached to frames with Frame.withSkybox.
type t = host
An opaque cubemap skybox.
let files : (string, string, string, string, string, string) => t
Load six cubemap faces in +X, -X, +Y, -Y, +Z, -Z order.
Faces are ordinary fetched image files resolved from the game directory. While they load the frame's clear color shows through, and a face that fails to load warns once and leaves the frame with no sky.
Texture values accepted by scene material functions.
type t = host
An opaque texture value.
let file : (string) => t
Load an image texture from a path relative to the game directory.
Distance fog attached to a frame with Frame.withFog.
Fog applies to every forward material, emissive included, and its color also becomes the frame's clear color unless Frame.withClearColor says otherwise. A skybox is never fogged.
type t = host
An opaque fog description.
let linear : (float, float, Color.t) => t
Create linear fog between near and far distances.
near must be at least 0 and far must exceed near; anything else is a teaching error rather than a silently degenerate ramp.
let exp : (float, Color.t) => t
Create exponential fog with the given density.
The density must be positive.
Named off-screen render targets for render-to-texture effects.
Declare a target ONCE and use that value at both sites — the Frame.withRenderTarget writer and the Scene.screen reader — rather than repeating a bare string, exactly as Angle and Physics.tag brand their own identities. A scene that samples the very target it is being rendered into shows the PREVIOUS frame's image.
type t = host
An opaque render target identity and size.
let named : (string) => t
Create a stable, named 512×512 render target.
let sized : (float, float, t) => t
Set target width and height; the target is last for piping.
Branded 3D vectors used for positions, directions, velocities, and gravity.
Vectors are **opaque** — like Angle and Color, a Vec3 is built once (Vec3.make) and passed as a value, so three interleaved bare floats can never be mistaken for a position. Read components back with Vec3.x / Vec3.y / Vec3.z, and combine vectors with the arithmetic below rather than unpacking to a record and rebuilding.
**Argument order is thread-last**, matching the rest of the prelude (Scene.translate(v, scene)): the *subject* is the LAST parameter, so a pipeline reads left-to-right as the subject being acted on.
`` // v - origin, scaled by 2, then normalized let dir = v |> Vec3.sub(origin) |> Vec3.scale(2.0) |> Vec3.normalize() ``
For the non-commutative operations this means Vec3.sub(b, a) computes a - b, so v |> Vec3.sub(origin) reads as "v minus origin"; likewise a |> Vec3.cross(b) is a × b, and from |> Vec3.lerp(target, t) moves from toward target.
Components are 32-bit floats and every vector is **finite**: an operation whose result would overflow that range is an error naming the operation, rather than an infinity that becomes a NaN and silently blanks the scene.
type t = host
An opaque 3D vector.
let make : (float, float, float) => t
Construct a vector from X, Y, and Z components.
let x : (t) => float
The X component of a vector.
Components are stored as 32-bit floats, so a value that is not exactly representable comes back rounded (Vec3.x(Vec3.make(0.1, 0.0, 0.0)) is 0.10000000149011612). Compare components with a tolerance, not ==.
let y : (t) => float
The Y component of a vector. Rounded to 32-bit like Vec3.x.
let z : (t) => float
The Z component of a vector. Rounded to 32-bit like Vec3.x.
let add : (t, t) => t
Componentwise sum. Vec3.add(b, a) is a + b, so a |> Vec3.add(b) reads as "a plus b" (addition is commutative, so the order is free).
let sub : (t, t) => t
Componentwise difference. Vec3.sub(b, a) is a - b, so v |> Vec3.sub(origin) reads as "v minus origin".
let scale : (float, t) => t
Multiply every component by a scalar: v |> Vec3.scale(2.0) doubles v. Scaling by a negative number negates: v |> Vec3.scale(0.0 - 1.0).
let dot : (t, t) => float
The dot product a · b — commutative, so argument order does not matter. Zero when the vectors are perpendicular.
let cross : (t, t) => t
The cross product. Vec3.cross(b, a) is a × b, so a |> Vec3.cross(b) reads as "a cross b" — the result is perpendicular to both, right-handed: X × Y = Z.
Mind the order for a strafe axis. In Functor's Y-up right-handed frame the world-space "right" of a gaze is up × forward, which in this argument order is up |> Vec3.cross(forward) — with forward = +Z and up = +Y that is +X. The other order gives -X.
let length : (t) => float
The Euclidean length (magnitude) of a vector.
let normalize : (t) => t
A unit vector pointing the same way.
**A zero-length vector normalizes to zero**, not an error and not NaN — Vec3.normalize(Vec3.make(0.0, 0.0, 0.0)) is the zero vector. Per-frame code normalizes a velocity or an input direction that is legitimately zero all the time, so this must not fault the frame; test the length first when you need a fallback direction. Zero is the ONLY input that yields a non-unit result — the length is computed with enough range that even the largest representable vector normalizes correctly.
let distance : (t, t) => float
The distance between two points — symmetric, so argument order is free. a |> Vec3.distance(b) is the length of a - b.
let lerp : (t, float, t) => t
Linear interpolation. Vec3.lerp(target, t, from) moves from toward target by fraction t, so from |> Vec3.lerp(target, 0.5) is the midpoint. t is NOT clamped: 0.0 yields from, 1.0 yields target, and values outside 0..1 extrapolate.
Branded angles used by cameras, rotations, and spot lights.
Angle-taking APIs require an Angle.t, preventing radians and degrees from being mixed accidentally. Construct one with Angle.degrees or Angle.radians.
type t = host
An opaque angle value.
let degrees : (float) => t
Construct an angle from degrees.
let radians : (float) => t
Construct an angle from radians.
let add : (t, t) => t
Add two angles. This is what 90deg + 45deg calls.
let sub : (t, t) => t
Subtract one angle from another. This is what 90deg - 45deg calls.
let scale : (t, float) => t
Scale an angle by a plain number. This is what 45deg * 2.0 calls. It takes the angle FIRST — the shape every declared * has — so unlike most of the prelude it is not written to be piped into.
let equals : (t, t) => bool
Are two angles the same? This is what 90deg == 90deg calls.
It is float equality on the underlying radians, with every consequence that implies: 90deg == 90deg is true because both sides build the same number, but 90deg == 1.5708rad is false, and an angle accumulated through arithmetic may miss an exact literal by a rounding step. An angle is one-way (there is no way back to a number), so where a tolerance matters, keep the plain float you built it from and compare THAT.
let less : (t, t) => bool
Is the first angle smaller than the second? This is what 45deg < 90deg calls, and — swapped or negated — >, <=, and >= too.
Angles are ordered by their raw radians, so this is signed magnitude, NOT a direction on the circle: -270deg < 90deg is true.
unit deg = Angle.degrees
Degrees as a literal suffix: 90deg is exactly Angle.degrees(90.0).
unit rad = Angle.radians
Radians as a literal suffix: 0.5rad is exactly Angle.radians(0.5).
unit deg (+) = Angle.add
+ on angles: 90deg + 45deg is Angle.add(90deg, 45deg). An operator belongs to the BRAND, so it covers every angle suffix — 90deg + 1.5rad adds too.
unit deg (-) = Angle.sub
- on angles: 90deg - 45deg is Angle.sub(90deg, 45deg).
unit deg (*) = Angle.scale
* scales an angle by a number, on either side: 45deg * 2.0 and 2.0 * 45deg are both Angle.scale(45deg, 2.0). (Multiplying two angles would be a different kind of thing — Functor Lang does not model that.)
unit deg (==) = Angle.equals
== on angles: 90deg == 90deg is Angle.equals(90deg, 90deg), and != is its negation. Underneath it is FLOAT equality on radians — see Angle.equals.
unit deg (<) = Angle.less
< on angles: 45deg < 90deg is Angle.less(45deg, 90deg). >, <=, and >= derive from it (swapped and/or negated), so all four orderings come from this one declaration.
RGB colors shared by rendering, lighting, fog, and UI APIs.
Declarative rigid-body physics for the optional physics hook.
Shapes and bodies are values, body attributes pipe naturally, and stable branded tags connect declarations, reads, commands, and collision events.
The hook DECLARES the world each frame and the runtime reconciles that declaration against the live one. A tag is cross-frame identity: the same tag is the same body, and a body is dropped by no longer declaring it. Re-declaring an unchanged body leaves the simulation alone, while changing its declared position or rotation drives that field — a dynamic or fixed body teleports immediately, and a kinematic body takes the new pose as its next-step target, so it carries velocity into contacts. Reading a tag that is not in the live world is a runtime error rather than an empty answer, so read only bodies your hook has declared. Like the model, the world survives hot reload for as long as the hook does; deleting the hook drops it. An error raised inside the hook does NOT stop the world: the previous frame's declaration is kept and stepped, and the error is reported once.
Reads are SYNCHRONOUS and writes are QUEUED. Physics.position, Physics.linearVelocity, and Physics.cast answer in place from the LAST STEPPED world — in any entry point, the physics hook included. Everything that runs before the step (tick, input, a pre-step update, the hook) sees the previous step; only draw and the post-step updates see the world this frame just stepped. The world is primed from init before the first frame, so frame 1's reads answer with the initial declared poses. Every mutation instead returns an Effect.t that applies at the next physics step after it queues — after reconcile, on that step's first substep. A command issued from tick therefore normally lands in time for the same frame's draw; on a frame that takes no substep at all (normal above 60fps) it waits for the next simulated frame.
type shape = host
An opaque collision shape.
type body = host
An opaque rigid body description.
type world = host
An opaque physics world description.
type tag
A stable, branded body identity used throughout the physics API.
type position = { x: float, y: float, z: float }
The live world-space position of a body.
type velocity = { x: float, y: float, z: float }
The live linear velocity of a body, in world units per second.
type rayHit = {
hit: bool,
x: float, y: float, z: float,
nx: float, ny: float, nz: float,
distance: float,
tag: tag
}
A raycast result with hit position, normal, distance, and body tag.
For a miss, hit is false and the remaining fields are zeroed.
type collisionEvent = { started: bool, a: tag, b: tag, sensor: bool }
A contact-begin or contact-end event between two bodies.
let tag : (string) => tag
Construct a stable body tag from a string.
The empty tag is reserved as the no-body sentinel in a raycast miss.
The brand is check-time only: declare a tag once and use that VALUE at every site — a bare string where a tag is expected is a check error. At runtime a tag simply IS its string, so comparing one against a collision event's a/b with == works.
let box : (float, float, float) => shape
Create a local-axis box shape from its full width, height, and depth.
let sphere : (float) => shape
Create a sphere shape from its radius.
let capsule : (float, float) => shape
Create a capsule shape from half-height and radius.
let heightfield : (tag, Terrain.t) => body
Create a fixed heightfield body from a shared terrain descriptor.
The renderer and collider share dimensions, elevation range, asset resolution, and pending-asset chain. Collision uses at most 1025 samples per axis to bound frame-thread work; larger render sources are decimated. The body supports translation via Physics.at; pair it only with an unrotated, unscaled Scene.terrain, using the same translation.
let dynamic : (tag, shape) => body
Create a dynamic body affected by forces and collisions.
let kinematic : (tag, shape) => body
Create a kinematic body driven explicitly by the game.
let fixed : (tag, shape) => body
Create a fixed body that does not move.
let at : (Vec3.t, body) => body
Set a body's initial world position; the body is last for piping.
let rotateX : (Angle.t, body) => body
Rotate a body about world X around its center; the body is last for piping.
An outer rotation applies last in world space, matching Scene.rotateX. Heightfield bodies reject rotation because terrain rendering is translation-only.
let rotateY : (Angle.t, body) => body
Rotate a body about world Y around its center; the body is last for piping.
An outer rotation applies last in world space, matching Scene.rotateY. Heightfield bodies reject rotation because terrain rendering is translation-only.
let rotateZ : (Angle.t, body) => body
Rotate a body about world Z around its center; the body is last for piping.
An outer rotation applies last in world space, matching Scene.rotateZ. Heightfield bodies reject rotation because terrain rendering is translation-only.
let velocity : (Vec3.t, body) => body
Set a body's initial linear velocity; the body is last for piping.
let mass : (float, body) => body
Set a body's mass; the body is last for piping.
let friction : (float, body) => body
Set a body's friction coefficient; the body is last for piping.
let restitution : (float, body) => body
Set a body's restitution; the body is last for piping.
let linearDamping : (float, body) => body
Damp a body's LINEAR velocity — drag, per second; the body is last for piping.
The default is 0.0 (no drag), so a rolling sphere on a box coasts almost forever: contact friction resists sliding, not rolling. A small value (0.3–0.8) is what makes a marble, a puck, or a thrown prop actually settle, and it belongs here rather than in a per-frame velocity command: damping is a property of the body, so declaring it keeps tick pure. Must not be negative. Only dynamic bodies integrate, so damping is inert on a kinematic or fixed one. Changing the value writes it onto the live body (the friction/restitution rule) — the new drag applies from the next step, with the body's current pose and velocity untouched.
let angularDamping : (float, body) => body
Damp a body's ANGULAR velocity — spin resistance, per second; the body is last for piping.
The default is 0.0. Pair it with linearDamping for a ball that stops rolling instead of creeping, or use it alone to bleed off spin while linear motion is preserved. Must not be negative, dynamic-only, and reconciles onto a live body exactly like linearDamping.
let sensor : (body) => body
Make a body a non-solid sensor; the body is last for piping.
let upright : (body) => body
Lock a body's rotation so it translates but never tips.
The character-controller attribute: an upright capsule that lands, scuffs a ledge, or leans on a wall would otherwise pick up angular velocity and topple, which also invalidates any fixed standing-height assumption a grounding probe makes. The body is last for piping.
let scene : (Vec3.t, List<body>) => world
Declare a physics world from gravity and bodies.
let position : (tag) => position
Read a body's live, stepped world position.
Answers with the LAST stepped world, so a pre-step caller (tick, the physics hook) sees the previous step and draw sees this frame's.
let linearVelocity : (tag) => velocity
Read a body's live, stepped linear velocity.
The read counterpart of Physics.setVelocity. (Physics.velocity is the body-builder attribute that sets an *initial* velocity.)
let cast : (Vec3.t, Vec3.t, float) => rayHit
Cast a ray against the world and get the nearest hit immediately.
Unlike Physics.raycast — an effect whose answer arrives through update after the step — this answers in place, so tick can branch on it while deciding. It reads the world as of the last step, like Physics.position: in tick that is the previous step, in draw this frame's. A miss is hit: false with zeroed fields, not an error. dir need not be normalized, so maxDist is in world units.
let castExcluding : (tag, Vec3.t, Vec3.t, float) => rayHit
Physics.cast, ignoring one body — the grounding probe.
A ray cast from inside a character's own capsule would otherwise hit that capsule at distance 0 and report the character standing on itself. Excluding a tag that isn't in the world excludes nothing.
let transformed : (tag, Scene.t) => Scene.t
Apply a body's live transform to a scene node.
The scene is last for piping.
let applyImpulse : (tag, Vec3.t) => Effect.t
Apply an instantaneous impulse to a body.
let applyForce : (tag, Vec3.t) => Effect.t
Apply a continuous force to a body for the next step.
let setVelocity : (tag, Vec3.t) => Effect.t
Replace a body's linear velocity — all three axes.
For a character controller prefer Physics.setVelocityXZ: writing the vertical axis every frame fights the solver's own ground contact.
let setVelocityXZ : (tag, float, float) => Effect.t
Replace a body's HORIZONTAL velocity, leaving the vertical axis alone.
The character-controller command. Steering owns x and z while the solver keeps the y it is using to resolve the ground contact, so the game never has to author a vertical velocity it has no opinion about — with Physics.setVelocity it must write all three every frame, and the only values available to it are a one-step-stale read or a guess.
The preserved axis is read from the live world when the command applies — after reconcile, and after any command queued earlier the same frame. So velocity commands in one frame compose as last-write-wins per axis, and an axis nobody wrote is left exactly as the solver left it.
let setVelocityY : (tag, float) => Effect.t
Replace a body's VERTICAL velocity, leaving the horizontal plane alone.
A jump that keeps the run: unlike Physics.applyImpulse the result does not depend on the body's mass, and unlike Physics.setVelocity it does not discard the horizontal momentum the character arrived with.
let teleport : (tag, Vec3.t) => Effect.t
Move a body immediately to a world position.
let raycast : (Vec3.t, Vec3.t, float, (rayHit) => 'msg) => Effect.t
Cast a ray and tag its Physics.rayHit result as a message.
let events : ((collisionEvent) => 'msg) => Sub.t
Subscribe to contact begin/end events and tag them as messages.
Declarative animation poses attached to scene models with Scene.animate.
Playheads and blend weights are values derived by the game, so the engine owns no hidden animation clock and poses rewind and replay deterministically.
type t = host
An opaque animation pose expression.
let clip : (string, float) => t
Sample a named glTF clip at a playhead in seconds.
The clip loops by its duration; negative playheads wrap backwards from the end. A name the model does not define warns once and renders the bind pose — functor import's generated clip constants (Assets.xbotClips.walk.name) turn that into a check-time error instead.
let blend : (List<(t, float)>) => t
Blend a list of (animation, weight) pairs.
Weights are normalized and entries with non-positive weights are ignored. Translation and scale are interpolated linearly and rotation as a normalized quaternion mix. An entry may itself be a blend, so blends nest.
let rest : () => t
Return the bind pose as a base for programmatic posing.
let add : (t, float, t) => t
Apply an additive animation layer to a base pose.
The base is last for piping. Weight is clamped to 0..1, and the delta applies only where the base has influence.
let mask : (List<string>, t) => t
Restrict a pose to the subtrees rooted at the named joints.
Joints the mask does not cover fall out of this pose entirely — they take the bind pose, or the other inputs of an enclosing blend. A joint name the model does not define warns once.
let rotate : (string, Angle.t, Angle.t, Angle.t, t) => t
Add a local XYZ Euler rotation to one joint.
The joint counts as FULLY DRIVEN by this node, so a mask BENEATH it cannot drop the joint; an enclosing mask — one applied to this node's result — still can.
let lookAt : (string, Vec3.t, Angle.t, float, t) => t
Aim one joint's local +Z axis at a model-space target after evaluating the pose below it.
The target is baked into the animation value at draw time, and Scene transforms sit deliberately outside the solver — so a world-space aim point has to be inverted through the node's own transforms before it is passed here. maxDeflection limits the shortest correction from the evaluated pose and must be between 0 and 180 degrees. weight is clamped to 0..1. The joint is fully driven by this node, exactly as with Anim.rotate; an enclosing mask can still exclude it.
let reach : (string, string, string, Vec3.t, float, t) => t
Reach a model-space target with a direct two-bone joint chain.
root, middle, and end must name direct parent/child joints, such as an upper arm, forearm, and hand. Unreachable targets clamp to the chain's nearest extension. The evaluated pose below supplies the elbow bend side, root must have uniform scale, and weight is clamped to 0..1.
Finite, asset-backed heightfield terrain shared by rendering and physics.
Heightmaps should be 16-bit grayscale PNGs. Black maps to the declared minimum height and white maps to the maximum height.
type t = host
An immutable terrain descriptor.
let heightmap : (Asset.Texture, float, float, float, float) => t
Create a terrain centered on the origin and spanning width by depth in XZ.
let maxPixelError : (float, t) => t
Set the maximum projected vertex spacing in pixels; lower is more detailed.
The default is 2 pixels. The terrain is last for piping.
let color : (Color.t, t) => t
Set the basic lit terrain color; the terrain is last for piping.
This is the ALTERNATIVE to Terrain.layered, not a stage before it: it clears any layers, so whichever of the two comes last in a pipeline wins and the earlier one is simply dead. Pick one.
let layered : (Color.t, Color.t, Color.t, Color.t, float, t) => t
Blend lowland, highland, rock, and snow colors by height and slope.
snowHeight is in terrain-local world units. The terrain is last for piping.
let textured : (Asset.Texture, Asset.Texture, Asset.Texture, Asset.Texture, float, t) => t
Dress the layered bands with detail maps.
Each map supplies STRUCTURE, not color: it is divided by its own average, so it adds surface detail to the band's layered color without repainting it. (A photographic ground albedo averages brown; used directly it would turn a green hillside to dirt.) The maps blend by the same height and slope weights as the colors, so texturing changes what a band looks like, not where it falls. tileSize is the world-unit span of one tile; each map is sampled at two scales to hide the repeat, and detail fades out with distance. Requires layered. The terrain is last for piping.
let grass : (float, float, float, Color.t, t) => t
Add camera-local GPU-instanced grass clusters.
spacing, distance, and bladeHeight are terrain-local world units. Grass is suppressed on steep, low-lying, and snowy samples. The terrain is last for piping.
Branded durations used by timing APIs such as Sub.every.
Constructing a Time.t explicitly prevents milliseconds and seconds from being mixed accidentally.
type t = host
An opaque duration.
let seconds : (float) => t
Construct a duration from seconds.
let millis : (float) => t
Construct a duration from milliseconds.
let micros : (float) => t
Construct a duration from microseconds.
let minutes : (float) => t
Construct a duration from minutes.
let hours : (float) => t
Construct a duration from hours.
let add : (t, t) => t
Add two durations. This is what 1.5s + 200ms calls.
let sub : (t, t) => t
Subtract one duration from another. This is what 1.5s - 200ms calls.
let scale : (t, float) => t
Scale a duration by a plain number. This is what 0.5s * 2.0 calls. It takes the duration FIRST — the shape every declared * has — so unlike most of the prelude it is not written to be piped into.
let equals : (t, t) => bool
Are two durations the same? This is what 1s == 1000ms calls.
Durations are stored in seconds, so this is FLOAT equality on that number: 1s == 1000ms is true, but a duration accumulated through arithmetic may miss an exact literal by a rounding step. A duration is one-way (there is no way back to a number), so where a tolerance matters, keep the plain float you built it from and compare THAT.
let less : (t, t) => bool
Is the first duration shorter than the second? This is what 200ms < 1.5s calls, and — swapped or negated — >, <=, and >= too.
unit s = Time.seconds
Seconds as a literal suffix: 0.5s is exactly Time.seconds(0.5).
unit ms = Time.millis
Milliseconds as a literal suffix: 500ms is exactly Time.millis(500.0).
unit us = Time.micros
Microseconds as a literal suffix: 250us is exactly Time.micros(250.0).
unit min = Time.minutes
Minutes as a literal suffix: 2min is exactly Time.minutes(2.0).
unit hr = Time.hours
Hours as a literal suffix: 1hr is exactly Time.hours(1.0).
unit s (+) = Time.add
+ on durations: 1.5s + 200ms is Time.add(1.5s, 200ms). An operator belongs to the BRAND, so every duration suffix shares it — seconds and milliseconds add directly.
unit s (-) = Time.sub
- on durations: 1.5s - 200ms is Time.sub(1.5s, 200ms).
unit s (*) = Time.scale
* scales a duration by a number, on either side: 0.5s * 2.0 and 2.0 * 0.5s are both Time.scale(0.5s, 2.0).
unit s (==) = Time.equals
== on durations: 1s == 1000ms is Time.equals(1s, 1000ms), and != is its negation. Underneath it is FLOAT equality on seconds — see Time.equals.
unit s (<) = Time.less
< on durations: 200ms < 1.5s is Time.less(200ms, 1.5s). >, <=, and >= derive from it (swapped and/or negated), so all four orderings come from this one declaration.
Target-neutral continuously sampled input.
The optional sampledInput(model, snapshot) game hook receives one Input.snapshot immediately before every fixed simulation step. XR, gamepad, and touch are typed device domains; further devices belong as siblings beside them on the snapshot.
type point2 = { x: float, y: float }
A plain two-dimensional point or axis pair.
type point3 = { x: float, y: float, z: float }
A plain three-dimensional point or direction.
type quaternion = { x: float, y: float, z: float, w: float }
A quaternion in [x, y, z, w] component order, matching OpenXR and glTF.
type pose = { position: point3, orientation: quaternion }
A rig-local pose where +X is right, +Y is up, and -Z is forward.
type controller = {
active: bool,
grip: Option.t<pose>,
aim: Option.t<pose>,
trigger: float,
squeeze: float,
thumbstick: point2,
primaryPressed: bool,
secondaryPressed: bool,
thumbstickPressed: bool,
menuPressed: bool
}
One XR controller's availability, poses, analog controls, and buttons.
type xr = {
head: Option.t<pose>,
left: controller,
right: controller
}
Head and left/right controller state sampled from an XR runtime.
type gamepad = {
leftStick: point2,
rightStick: point2,
leftTrigger: float,
rightTrigger: float,
south: bool,
east: bool,
west: bool,
north: bool,
leftBumper: bool,
rightBumper: bool,
leftStickPressed: bool,
rightStickPressed: bool,
dpadUp: bool,
dpadDown: bool,
dpadLeft: bool,
dpadRight: bool,
start: bool,
select: bool
}
The primary connected gamepad's held state, aligned to the standard mapping desktop and web pads share. Face buttons are POSITIONAL — south is the bottom face button (A on Xbox, Cross on PlayStation, B on Nintendo) — because letter names swap between vendors. Sticks are -1..1 with up-positive y (the XR thumbstick convention); triggers are 0..1. Values are raw — apply your own deadzone. Levels only: detect edges against your model, as XR games do. Native's windowed runtime and the web runtime both poll the first connected standard-mapping pad each frame (on native, debug injection wins over the poll); while the window/document is unfocused or the clock is pinned a connected pad reads rest-level controls rather than Option.None. Browsers hide pads until a button is first pressed, and headless polls nothing — there this is Option.None unless injected.
type touchPoint = { id: float, x: float, y: float }
One touch contact in the same top-left-origin logical coordinate space as mouse (window points natively, CSS pixels on web). id is a small ordinal stable for the contact's lifetime.
type touch = {
touches: List<touchPoint>,
pressed: List<touchPoint>,
released: List<touchPoint>
}
Active touch contacts plus this step's transitions — the keyboard contract for fingers: touches are held levels at current positions, pressed/released are de-duplicated one-step edges (a quick tap can appear in both while touches no longer carries it). A contact the platform steals (gesture navigation) reports through released, never a silently vanished touch. On the snapshot, Option.Some signals a touch surface EXISTS (empty lists while idle — the cue to show touch UI); Option.None means no touch input at all.
type mouseButtons = { left: bool, right: bool, middle: bool }
A fixed set of mouse buttons. mouse.buttons uses it for held levels; mouse.pressed / mouse.released use it for one-step transitions.
type mouse = {
x: float,
y: float,
surfaceWidth: float,
surfaceHeight: float,
buttons: mouseButtons,
pressed: mouseButtons,
released: mouseButtons
}
Mouse position in top-left-origin logical surface coordinates, the matching logical extent, plus held levels and transitions since the previous fixed simulation step. Desktop uses window points; web uses CSS pixels, so this stays stable across Retina/device-pixel-ratio changes. A quick click may be both pressed.left and released.left in one sample.
type snapshot = {
heldKeys: List<Key.t>,
pressedKeys: List<Key.t>,
releasedKeys: List<Key.t>,
mouse: mouse,
xr: Option.t<xr>,
gamepad: Option.t<gamepad>,
touch: Option.t<touch>
}
Keyboard/mouse levels and transitions for one fixed simulation step.
pressedKeys and releasedKeys are de-duplicated transition sets. They survive render frames with no simulation step, are consumed by the first catch-up step, and are empty on later steps. Native OS-repeat events still reach the legacy input hook, but do not repeat pressedKeys.
Commands returned beside a model and performed by the runtime.
Results are converted into game messages and folded back through update. Effects remain outside the game's pure functional core.
type t = host
An opaque effect command.
let none : () => t
Produce no command.
let now : ((float) => 'msg) => t
Read the current Unix time in seconds and tag the result as a message.
let random : ((float) => 'msg) => t
Generate a random float in 0..1 and tag it as a message.
let batch : (List<t>) => t
Combine effects to be performed together.
let send : (float, string) => t
Send text over a live connection by connection ID.
let sendMsg : (float, 'a) => t
Send a plain-data value over a live connection.
The peer receives Net.Data(id, value). Functions and opaque host values cannot be sent.
let httpGet : (string, (Net.HttpResponse) => 'msg) => t
Perform an HTTP GET and tag its Net.HttpResponse as a message.
let httpPost : (string, string, (Net.HttpResponse) => 'msg) => t
Perform an HTTP POST with a text body and tag its response as a message.
let play : (Asset.Sound) => t
Play a non-spatial sound once.
let playAt : (Asset.Sound, Vec3.t) => t
Play a spatial sound once at a world position.
let playThen : (Asset.Sound, 'msg) => t
Play a sound once and deliver a message when playback finishes.
The completion message is native-only; on wasm the sound plays but the message is not delivered, so do not gate game progress on it.
let preload : ('asset) => t
Begin loading a model or texture before it is referenced by draw.
The imperative prefetch; the declarative default is simply that draw references the asset. The parameter is generic, but only an Asset.Model or Asset.Texture is accepted: an Asset.Sound is a teaching error (a sound decodes at play time and has no pending state), and a bare path string is rejected toward Asset.model / Asset.texture like any other asset consumer. preloadThen takes the same values.
let preloadThen : ('asset, 'msg) => t
Preload an asset and deliver a message when the load settles.
Settlement includes success and failure; Sub.assets reports which assets failed. Preloads count toward Sub.assets totals, and unlike playThen's completion message this one is delivered on wasm too.
Declarative event sources returned by the optional subscriptions hook.
Events become game messages and are folded through update before tick.
type t = host
An opaque subscription description.
let none : () => t
Subscribe to no events.
let every : (Time.t, 'msg) => t
Deliver a message at the requested interval.
The timer is STATELESS: it fires when an integer multiple of its period lies in the interval this frame covers, measured on the global time grid. So a long frame that spans several boundaries fires ONCE (missed boundaries collapse rather than queueing up), and timers keep their phase across a hot reload.
let batch : (List<t>) => t
Combine subscriptions.
let connect : (string, (Net.NetEvent) => 'msg) => t
Maintain a client connection and tag its ordered Net.NetEvent values as messages.
A failed attempt delivers Net.Error, then retries forever on the game-time clock after 250ms, doubling through 500ms, 1s, 2s, 4s, and at most 8s. Every failed attempt remains observable as an Error; Connected resets the next retry to 250ms. Dropping the subscription cancels the connection and its pending retry.
let listen : (string, (Net.NetEvent) => 'msg) => t
Maintain a server listener and tag its Net.NetEvent values as messages.
type AssetFailure = { path: string, error: string }
One asset byte-load failure.
Only a failed BYTE load lands here — an asset whose bytes arrived but did not decode counts as loaded (it renders its fallback).
type AssetProgress = { loaded: float, total: float, failed: List<AssetFailure> }
A snapshot of loaded, total, and failed assets.
All assets are settled when total > 0 and loaded + List.length(failed) == total. Failures never join loaded, and frame one can legitimately deliver 0 / 0, so a loading screen must gate on the whole expression rather than on loaded == total.
let assets : ((AssetProgress) => 'msg) => t
Subscribe to asset-loading snapshots, including the initial state.
Delivery is driven by CHANGE, not by the time grid: the tagger fires whenever the shell's snapshot differs from the last one it delivered (including the first one on frame one), so this is not a per-frame poll. Like every subscription it requires an update hook.
Durable local state — the save slot that outlives the process.
The model is the game's live state and hot-reload preserves it, but quitting does not: a slot is where progress goes to survive. Both functions produce an ordinary Effect.t, performed by the same broker as every other effect, so a save is deterministic under replay like anything else.
This is the simple path for the common case — one autosave, saved whole and read back at boot. It deliberately says nothing about where the bytes live; a general file API over a virtual user namespace is planned, and these two functions are meant to keep reading as the two-line convenience layer over it.
Slots belong to the running project: natively <project>/.functor/saves/, in the browser a page- and entry-scoped localStorage key. One process runs one game, so the store root is process-global. The Quest/embedded producer does not name a writable project root yet — there slots follow the process working directory.
let save : (string, 'a) => Effect.t
Persist a plain-data value in a local save slot.
The durable dual of the model: a slot survives quitting the game, where hot-reload's preserved model does not. The value is carried by the same codec as Effect.sendMsg — numbers, strings, bools, lists, maps, tuples, records and variants of those — so functions and opaque host values are a teaching error at the call site. Saving the whole model is the common case; save what init can rebuild from.
The slot is a NAME, not a path: letters, digits, _ and - (1–64 characters). Natively it lands in <project>/.functor/saves/<slot>.json, written atomically; in the browser it is one project-scoped localStorage key. Writing the same slot again replaces it.
let load : (string, (Option.t<'a>) => 'msg) => Effect.t
Read a local save slot back and tag it as a message.
The tagger receives Option.Some(value) — the value exactly as saved — or Option.None when the slot has never been written or what is there cannot be read at all (a corrupt or hand-mangled file): an unreadable save degrades to "no save", it never stops the game.
A save that reads back fine but was written by an OLDER MODEL SHAPE is still Option.Some — nothing checks the shape for you. Handle that the way you would any external data: change the slot name when the model changes incompatibly, or save a record carrying your own version field and match on it.
Usually issued once at boot from the first tick.
Declarative audio scenes returned by the optional soundScape hook.
type t = host
An opaque audio scene.
let create : (List<AudioSource.t>) => t
Create an audio scene from continuous sources.
let empty : () => t
Create an audio scene with no sources.
Continuous, declarative voices used in an AudioScene.
Sources are keyed for cross-frame identity so the runtime can reconcile a live voice instead of restarting it every frame.
type t = host
An opaque continuous audio source.
let ambient : (string, Asset.Sound) => t
Create a non-spatial source identified by a stable key.
let at : (string, Asset.Sound, Vec3.t) => t
Create a spatial source at a world position, identified by a stable key.
let gain : (float, t) => t
Set LINEAR source gain, where 1.0 is full volume; the source is last for piping.
Lightweight game UI views returned by the optional ui hook.
Compose text and widgets into rows or columns, then pin them to a screen anchor with Ui.panel. Text is 14pt monospace.
Like draw, the hook is a pure function of the model, and the widgets are CONTROLLED: a view shows what the model says, and interacting with it sends a message — the model stays the only state. Those messages (a button's, and a slider's or text input's tagger result) are folded through update, so a program with interactive UI must define that hook.
type view = host
An opaque UI view.
type anchor = host
An opaque screen anchor.
let text : (string) => view
Create a text view.
let textColor : (Color.t, string) => view
Create a colored text view.
let column : (List<view>) => view
Stack views vertically.
let row : (List<view>) => view
Arrange views horizontally.
let panel : (anchor, view) => view
Pin a view to an anchor; the view is last for piping.
let topLeft : () => anchor
Anchor a panel to the top-left corner.
let topRight : () => anchor
Anchor a panel to the top-right corner.
let bottomLeft : () => anchor
Anchor a panel to the bottom-left corner.
let bottomRight : () => anchor
Anchor a panel to the bottom-right corner.
let center : () => anchor
Anchor a panel to the center of the screen.
let button : (string, 'msg) => view
Create a button that delivers a message through update when clicked.
The message is delivered VERBATIM, like Sub.every's — so the link between its type and update's is a runtime check, not a static one.
let slider : (float, float, float, 'tagger) => view
Create a controlled slider from minimum, maximum, value, and tagger.
A drag applies the tagger to the new value and folds the resulting message through update. The maximum must exceed the minimum, and the tagger must be a function or constructor.
let textInput : (string, 'tagger) => view
Create a controlled text input whose tagger receives edited text.
While a field is FOCUSED the game's input hook is suppressed, so keys type into the field instead of driving the game; Escape defocuses the field first and releases the cursor second. An update that transforms the text resets the caret to the end.
Elm-style HTML trees returned by the optional webview hook.
Native renders the tree over the 3D frame; wasm renders it as a DOM overlay above the canvas.
type node = host
An opaque HTML node.
let text : (string) => node
Create an escaped text node.
let element : (string, List<Attr.t>, List<node>) => node
Create an element from a safe tag name, attributes, and children.
Names must start with a letter and then contain only letters, digits, or dashes. Script-capable tags such as script and iframe are refused.
let div : (List<Attr.t>, List<node>) => node
Create a div element.
let span : (List<Attr.t>, List<node>) => node
Create a span element.
let button : (List<Attr.t>, List<node>) => node
Create a button element.
let h1 : (List<Attr.t>, List<node>) => node
Create an h1 heading element.
let h2 : (List<Attr.t>, List<node>) => node
Create an h2 heading element.
let p : (List<Attr.t>, List<node>) => node
Create a paragraph element.
let input : (List<Attr.t>) => node
Create a controlled, single-line text input.
Pair it with Attr.value and Attr.onInput.
let style : (string) => node
Create a style element containing a raw CSS stylesheet.
Attributes and event handlers for Html elements.
Event attributes deliver messages through the game's update function.
type t = host
An opaque HTML attribute.
let class : (string) => t
Set the element's CSS class string.
let style : (string) => t
Set the element's raw inline style string.
let styles : (List<Style.t>) => t
Combine typed Style values into one inline style attribute.
let id : (string) => t
Set the element's id.
let attr : (string, string) => t
Set a safe attribute by name and value.
Names must start with a letter and then contain only letters, digits, or dashes. Executable, navigating, document-embedding, and runtime-reserved names (on*, href, action, formaction, srcdoc, and data-fn-*) are refused.
let value : (string) => t
Set the controlled value of an Html.input.
let placeholder : (string) => t
Set an input's placeholder text.
let onClick : ('msg) => t
Deliver a message through update when the element is clicked.
A click on a descendant counts too, through ordinary DOM bubbling. The message is delivered verbatim, like Ui.button's.
let onInput : ('tagger) => t
Apply a tagger to edited text and deliver its message through update.
Works on both shells. Clicking the input focuses it, after which keys type into the field rather than reaching the game's input hook (Escape defocuses first, releases the cursor second), and focus survives the per-edit re-render. As with Ui.textInput, an update that transforms the text resets the caret to the end. IME composition (CJK, dead keys) is deferred on native.
Typed inline CSS declarations combined with Attr.styles.
Constructors format their values immediately and share Color.t with the 3D APIs. Use Html.style for selectors, pseudo-classes, and keyframes.
type t = host
An opaque inline CSS declaration.
let flexRow : () => t
Use horizontal flexbox layout.
let flexColumn : () => t
Use vertical flexbox layout.
let gapPx : (float) => t
Set the flex/grid gap in pixels.
let justifyStart : () => t
Align content to the start of the main axis.
let justifyCenter : () => t
Center content on the main axis.
let justifyEnd : () => t
Align content to the end of the main axis.
let justifyBetween : () => t
Distribute content with space between items.
let alignStart : () => t
Align items to the start of the cross axis.
let alignCenter : () => t
Center items on the cross axis.
let alignEnd : () => t
Align items to the end of the cross axis.
let widthPx : (float) => t
Set width in pixels.
let widthPct : (float) => t
Set width as a percentage of the parent.
let heightPx : (float) => t
Set height in pixels.
let heightPct : (float) => t
Set height as a percentage of the parent.
let paddingPx : (float) => t
Set padding on all sides in pixels.
let marginPx : (float) => t
Set margin on all sides in pixels.
let color : (Color.t) => t
Set the text color.
let background : (Color.t) => t
Set the background color.
let fontSizePx : (float) => t
Set font size in pixels.
let bold : () => t
Use a bold font weight.
let textCenter : () => t
Center-align text.
let borderPx : (float, Color.t) => t
Add a solid border with pixel width and color.
let roundedPx : (float) => t
Set border radius in pixels.
let opacity : (float) => t
Set opacity in 0..1. A value outside that range is an error, not a clamp.
let raw : (string, string) => t
Create an inline declaration for an arbitrary CSS property and value.
Property names must start with a letter and then contain only letters, digits, or dashes.
Typed locators for models, textures, and sounds.
Each asset kind has its own branded value, making wrong-kind uses a type error. Prefer constants generated by functor import; constructors remain available where a locator enters the program dynamically.
Asset consumers — Scene.model, Sprite.image/imageRegion, Terrain.heightmap/textured, Effect.play/playAt/playThen/ preload/preloadThen, AudioSource.ambient/at — take these branded values ONLY: a bare path string there is a check error and, at runtime, a teaching error pointing at the generated manifest, and an asset of the wrong kind names the constructor that was wanted. (Texture.file paths, Skybox.files faces, Anim.clip names, and AudioSource keys are not asset locators and stay plain strings.)
type Model = host
A model asset locator.
type Texture = host
A texture asset locator.
type Sound = host
A sound asset locator.
let model : (string) => Model
Construct a model locator from a relative path or URL.
let texture : (string) => Texture
Construct a texture locator from a relative path or URL.
let sound : (string) => Sound
Construct a sound locator from a relative path or URL.
let whilePending : ('placeholder, 'asset) => 'asset
Use another asset of the same kind while a model or texture is loading.
Models and textures only — a sound decodes at play time and has no pending state, so asking for one is a teaching error. The placeholder is just another asset of the same kind, so placeholders chain. The requested asset is last for piping. Failed loads use the normal fallback because failure is no longer pending, and Sub.assets still reports the failure.
Ships with the language — available in every Functor Lang program, runner or not.
Immutable list operations.
Functor Lang has no loops: iteration is List.map, List.filter, and List.fold, which run iteratively in the interpreter and so consume no evaluation depth (unlike a hand-rolled recursive walk, which trips the recursion cap).
Every function takes its list LAST, so it threads through the thread-last pipeline operator: xs |> List.map(f) is exactly List.map(f, xs).
The partial accessors — nth, head, last, find — answer with Option.t, never a sentinel, so the absent case has to be handled.
let map : (('a) => 'b, List<'a>) => List<'b>
Apply fn to every element, preserving order and length.
let indexedMap : ((float, 'a) => 'b, List<'a>) => List<'b>
Apply fn to every element with its 0-based index, as fn(index, element) — index FIRST, unlike the subject-last list argument.
let filter : (('a) => bool, List<'a>) => List<'a>
Keep the elements the predicate accepts, in order.
let fold : (('b, 'a) => 'b, 'b, List<'a>) => 'b
Reduce left-to-right from an initial accumulator, calling fn(acc, element). This is the iteration primitive to reach for when map and filter do not fit — a recursive walk hits the interpreter's recursion cap around 40–60 elements, while fold has no such limit.
let concatMap : (('a) => List<'b>, List<'a>) => List<'b>
Map each element to a list and concatenate the results (one level).
let range : (float) => List<float>
[0, 1, … n - 1]. A non-positive n gives the empty list; a count that is not finite, or above one million, is an error. A fractional count truncates toward zero, so List.range(3.7) is [0, 1, 2].
let grid : ((float, float) => 'a, float, float) => List<List<'a>>
Build a rows x cols grid by calling fn(row, col) for every cell, both 0-based — the procedural-heightmap shape, e.g. Scene.heightmap(List.grid(height, rows, cols)). Both counts must be whole and non-negative, and the cells must total at most one million.
let maximum : (List<float>) => Option.t<float>
The largest number in the list as Option.Some, or Option.None for an empty list — partial like nth/head/last/find. NaN elements are ignored unless every element is NaN.
let minimum : (List<float>) => Option.t<float>
The smallest number in the list as Option.Some, or Option.None for an empty list — maximum's mirror, with the same NaN rule.
let sum : (List<float>) => float
The sum of the numbers. The empty list sums to 0.0.
let length : (List<'a>) => float
How many elements the list has, as a number.
let isEmpty : (List<'a>) => bool
Whether the list has no elements.
let reverse : (List<'a>) => List<'a>
The list in reverse order.
let append : (List<'a>, List<'a>) => List<'a>
The piped list followed by other: xs |> List.append(ys) is xs then ys.
let flatten : (List<List<'a>>) => List<'a>
Concatenate a list of lists, one level deep.
let zip : (List<'b>, List<'a>) => List<('a, 'b)>
Pair each element of the piped list with the element of other at the same index — the PIPED list fills the first slot of every tuple. The result truncates to the shorter of the two.
let sortBy : (('a) => float, List<'a>) => List<'a>
Sort ascending by the number fn returns. The sort is STABLE and calls fn exactly once per element. NaN keys sort last, tied with each other regardless of sign, so the order is identical on every platform; -0.0 and 0.0 tie.
let take : (float, List<'a>) => List<'a>
The first count elements, saturating: past the end gives the whole list, and a negative count behaves as 0. A fractional count truncates toward zero rather than raising — unlike nth, where a fractional index is a caller bug.
let drop : (float, List<'a>) => List<'a>
Everything after the first count elements, saturating: past the end gives the empty list, and a negative count behaves as 0. A fractional count truncates, like take.
let any : (('a) => bool, List<'a>) => bool
Whether ANY element satisfies the predicate, short-circuiting at the first one that does. The empty list is false.
let all : (('a) => bool, List<'a>) => bool
Whether EVERY element satisfies the predicate, short-circuiting at the first one that does not. The empty list is true.
let nth : (float, List<'a>) => Option.t<'a>
The element at a 0-based index, or Option.None when the index is out of range. An index that is not a whole, finite number is an ERROR rather than an absence — that is a caller bug, not a missing element.
let head : (List<'a>) => Option.t<'a>
The first element, or Option.None for an empty list.
let last : (List<'a>) => Option.t<'a>
The last element, or Option.None for an empty list.
let find : (('a) => bool, List<'a>) => Option.t<'a>
The first element satisfying the predicate, or Option.None when none does. It stops at that first match rather than scanning the whole list.
Immutable keyed collections.
A Map is plain data: it compares structurally, displays, snapshots, and survives hot reload. Every operation returns a NEW map rather than mutating the old one, and every function takes the map LAST so it threads through a pipeline.
Keys are bounded to bool, FINITE float, and string. Inference keeps a map homogeneous in ordinary code, and a generic or unknown seam is checked again at runtime; NaN and the infinities are refused, while -0.0 and 0.0 are the same key.
Every map is stored in one canonical key order — bool before float before string, then false before true, ascending numerically, and strings by Unicode scalar value (not locale-aware). So values, toList, structural equality, and display all agree byte-for-byte between native and wasm.
get and member are logarithmic; the immutable insert and remove copy the ordered storage and are linear, as are values and toList; fromList is O(n log n).
let empty : () => Map<'a, 'b>
The empty map.
let get : ('a, Map<'a, 'b>) => Option.t<'b>
The value stored under key, or Option.None when the map has no such key.
let insert : ('a, 'b, Map<'a, 'b>) => Map<'a, 'b>
The map with key bound to value, replacing any existing binding.
let remove : ('a, Map<'a, 'b>) => Map<'a, 'b>
The map without key. Removing an absent key is not an error.
let member : ('a, Map<'a, 'b>) => bool
Whether the map holds a binding for key.
let values : (Map<'a, 'b>) => List<'b>
Every value, in canonical key order.
let toList : (Map<'a, 'b>) => List<('a, 'b)>
Every (key, value) pair, in canonical key order.
let fromList : (List<('a, 'b)>) => Map<'a, 'b>
Build a map from (key, value) pairs. For a repeated key the LAST pair wins.
String building, formatting, and inspection.
There is no string-concatenation operator and no character type: interpolation ($"score: {n}") covers most formatting, Text.concat joins two strings, and single characters are one-character STRINGS.
Functions with a clear subject take the string LAST so they thread through a pipeline: s |> Text.contains("ab") is Text.contains("ab", s).
Lengths and character splits count **Unicode scalar values** — not bytes, and not grapheme clusters. Text.length(s) always equals List.length(Text.chars(s)).
let concat : (string, string) => string
a followed by b.
The subject is LAST here too, which means piping PREPENDS: the piped string lands in b, so "SUF" |> Text.concat("PRE") is "PRESUF".
let fromFloat : (float) => string
A number rendered in Functor Lang's canonical display form — the same text string interpolation produces. That is the shortest round-tripping form, so 1.0 renders as "1"; use Text.fixed when a HUD needs a stable width.
let fixed : (float, float) => string
A number rendered with exactly decimals digits after the point. Text.fixed(42.0, 0.0) is "42", the integer-formatting shape — the one to reach for in a HUD. The digit count must be a whole number from 0 to 12; anything else is an error. Unlike the string functions, the NUMBER comes first, so this cannot be piped on: write Text.fixed(hp, 0.0), not hp |> Text.fixed(0.0).
let toBullets : (List<string>) => string
The strings as a bulleted block, one item per line.
let split : (string, string) => List<string>
Split the subject on every occurrence of sep. Splitting the empty string gives [""]; an empty separator is an error.
let join : (string, List<string>) => string
Join the strings with sep between them. An empty separator is fine here — unlike Text.split — and simply concatenates.
let parseFloat : (string) => float
Parse a number, ignoring surrounding whitespace. Unparseable text answers 0.0 rather than raising — validate the input yourself when the difference matters. Text that parses to a non-finite number ("inf", "NaN", an overflowing literal) degrades to 0.0 as well, so the result is always finite.
let length : (string) => float
How many Unicode scalar values the string contains.
let chars : (string) => List<string>
The string's Unicode scalar values, each as a one-character string.
let toUpper : (string) => string
The string uppercased, Unicode-aware — so the LENGTH may change ("ß" uppercases to "SS").
let toLower : (string) => string
The string lowercased, Unicode-aware — so the length may change.
let trim : (string) => string
The string without leading or trailing whitespace.
let contains : (string, string) => bool
Whether the subject contains needle. The empty needle is contained in every string.
let replace : (string, string, string) => string
Replace EVERY occurrence of from with to, never re-scanning what was just written. An empty from is an error.
Numeric functions and constants.
Functor Lang has one number type (float, an f64) and a deliberately small operator set: Math.mod stands in for % and Math.pow for ^. Arithmetic is IEEE throughout, so 1.0 / 0.0 is infinity and NaN compares false against everything, itself included.
Two behaviors differ from the usual defaults and are worth knowing: mod is EUCLIDEAN (its result is never negative) and round goes half AWAY FROM ZERO (not banker's rounding).
Unlike the collections, most of Math reads as ordinary notation rather than as a pipeline: pow, atan2, mod, min, and max take their NUMBER FIRST, so for the order-sensitive ones (pow, atan2, mod) piping feeds the wrong slot. clamp, clamp01, lerp, and smoothstep are the deliberate subject-last exceptions — their bounds and parameters are configuration and their number is the subject, so they pipe: n |> Math.clamp(0.0, 10.0).
let pi : float
The ratio of a circle's circumference to its diameter — a constant VALUE, not a function, so it is written Math.pi with no parentheses.
let sin : (float) => float
The sine of an angle in radians.
let cos : (float) => float
The cosine of an angle in radians.
let tan : (float) => float
The tangent of an angle in radians.
let asin : (float) => float
The arcsine, in radians. NaN outside [-1, 1]: it does NOT clamp, so a dot product nudged past 1.0 by float error needs an explicit |> Math.clamp(-1.0, 1.0) first.
let acos : (float) => float
The arccosine, in radians. NaN outside [-1, 1], exactly like asin.
let atan : (float) => float
The arctangent, in radians.
let atan2 : (float, float) => float
The angle in radians from the positive x-axis to the point (x, y), using the standard mathematical argument order with y FIRST.
let sqrt : (float) => float
The non-negative square root.
let pow : (float, float) => float
base raised to exp — the language has no ^ operator.
let log : (float) => float
The natural logarithm, base e — the inverse of Math.exp.
let exp : (float) => float
e raised to the given power.
let abs : (float) => float
The magnitude, without sign.
let sign : (float) => float
-1, 0, or 1 — and exactly 0 AT zero, so it is not a two-way branch. NaN answers NaN.
let floor : (float) => float
The largest whole number that is not greater than n.
let ceil : (float) => float
The smallest whole number that is not less than n.
let round : (float) => float
The nearest whole number, rounding halves AWAY FROM ZERO rather than to even: 0.5 rounds to 1, 2.5 to 3, and -2.5 to -3.
let mod : (float, float) => float
The EUCLIDEAN remainder: the result always lands in [0, abs(b)), so negatives wrap positively — Math.mod(-1.0, 8.0) is 7.0, the wraparound games want. A zero divisor answers NaN.
let min : (float, float) => float
The smaller of two numbers.
let max : (float, float) => float
The larger of two numbers.
let clamp : (float, float, float) => float
n confined to [low, high], subject-last so it pipes: n |> Math.clamp(0.0, 10.0). A low greater than high is an error, not a silent swap.
let clamp01 : (float) => float
n confined to [0, 1] — the same as Math.clamp(0.0, 1.0, n).
let lerp : (float, float, float) => float
Linear interpolation from from toward target by t (unclamped): from + (target - from) * t, and t = 1.0 answers target exactly. Mirrors Vec3.lerp(target, t, from) — the pipe subject is the start value, so x |> Math.lerp(target, t) works like its vector sibling. (Deliberately NOT GLSL's mix(a, b, t) order.)
let smoothstep : (float, float, float) => float
Hermite smoothstep of x across [edge0, edge1], clamped to [0, 1]. The edges must be a finite ascending range (edge0 < edge1, both finite and not overflow-wide) — anything else is an error, never a silent NaN.
Pure, seeded pseudo-random numbers.
There is no hidden global generator: a draw takes a seed and hands back the NEXT seed alongside its value, so randomness is an ordinary part of the model. Thread the next seed through and a run is exactly reproducible — which is what makes rewind, replay, and hot reload work.
Seed a stream once, at init: a fixed Random.seed(42.0) for a reproducible run, or Effect.random / Effect.now for a different stream each session. Distinct seeds produce DECORRELATED streams that share no prefix, so per-entity streams do not visibly rhyme with each other.
type Seed
An opaque PRNG seed.
The brand keeps seeds out of arithmetic: a bare number where a Seed is expected, or seed + 1.0 to derive a sibling stream, is a check-time error — use Random.fork instead. At runtime a seed is still plain data, so it snapshots, hot-reloads, and time-travels like any other model field.
let seed : (float) => Seed
Make a seed from any finite number.
The number's BITS are hashed, so fractional seeds are as usable as whole ones — Random.seed(0.42) from an Effect.random result names a distinct starting point just as Random.seed(42.0) does.
let step : (Seed) => (float, Seed)
Draw the next value, as (value, nextSeed) with value in [0, 1).
The same seed always yields the same pair. Bind both halves and carry the next seed forward — let (v, next) = Random.step(model.seed) in … — otherwise the stream never advances.
let range : (float, float, Seed) => (float, Seed)
Draw one value rescaled into [lo, hi), as (value, nextSeed) — one step draw, interpolated between the bounds.
Only finiteness is checked, so the bounds are yours to get right: reversed ones simply interpolate the other way (landing in (hi, lo]), and equal ones always answer that value.
let fork : (float, Seed) => Seed
The seed of decorrelated child stream i — the typed replacement for deriving sibling streams by arithmetic. Subject-last, so per-entity streams read as model.seed |> Random.fork(i), and any number may name a stream.
A value that may be absent.
Option is an ordinary generic variant bundled with the language, so it is available in every project and under the plain functor-lang CLI. It is what the partial accessors (List.head, Map.get, …) answer with instead of a sentinel, which is what forces the absent case to be handled.
ALWAYS qualify the constructors: bare Some / None do not resolve — they belong to this module, so write Option.Some(x) and Option.None in both expressions and patterns (or open Option first).
Helpers take the option LAST, so they thread through a pipeline: Option.Some(41.0) |> Option.map((n) => n + 1.0) |> Option.defaultValue(0.0).
type t<'value> =
| Some(value: 'value)
| None
A present value (Option.Some) or its absence (Option.None).
let map : (('value) => 'mapped, t<'value>) => t<'mapped>
Transform a present value; leave None unchanged.
let bind : (('value) => t<'mapped>, t<'value>) => t<'mapped>
Continue with an optional computation when a value is present.
let defaultValue : ('value, t<'value>) => 'value
Return the contained value, or an eager fallback for None.
let defaultWith : (() => 'value, t<'value>) => 'value
Return the contained value, computing the fallback only for None.
let isSome : (t<'value>) => bool
Whether the option contains a value.
let isNone : (t<'value>) => bool
Whether the option is None.
let filter : (('value) => bool, t<'value>) => t<'value>
Keep a present value only when it satisfies the predicate.
let toList : (t<'value>) => List<'value>
Convert Some(value) to [value] and None to [].
A computation that either succeeded or carries an error.
Result is an ordinary generic variant bundled with the language, so it is available in every project and under the plain functor-lang CLI. Use it where a failure needs to explain itself; use Option where absence needs no explanation.
ALWAYS qualify the constructors: bare Ok / Error do not resolve — write Result.Ok(x) and Result.Error(e) in both expressions and patterns (or open Result first).
Helpers take the result LAST, so they thread through a pipeline.
type t<'value, 'error> =
| Ok(value: 'value)
| Error(error: 'error)
A success carrying a value (Result.Ok) or a failure carrying an error (Result.Error).
let map : (('value) => 'mapped, t<'value, 'error>) => t<'mapped, 'error>
Transform a successful value; leave an error unchanged.
let mapError : (('error) => 'mapped, t<'value, 'error>) => t<'value, 'mapped>
Transform an error; leave a successful value unchanged.
let bind : (('value) => t<'mapped, 'error>, t<'value, 'error>) => t<'mapped, 'error>
Continue with a result-producing computation after success.
let defaultValue : ('value, t<'value, 'error>) => 'value
Return the successful value, or an eager fallback for an error.
let defaultWith : (('error) => 'value, t<'value, 'error>) => 'value
Return the successful value, or compute a fallback from the error.
let isOk : (t<'value, 'error>) => bool
Whether the result is successful.
let isError : (t<'value, 'error>) => bool
Whether the result contains an error.
let toOption : (t<'value, 'error>) => Option.t<'value>
Convert Ok(value) to Option.Some(value) and an error to Option.None.
Keyboard keys, as a variant rather than strings.
Key is built in — no declaration and no import. The input hook's key parameter and the Input.snapshot key sets all carry these constructors, so a misspelling (Key.Enterr) is a load-time error instead of an arm that silently never matches. Match them (| Key.W =>) or compare them (key == Key.Enter).
type t =
| A | B | C | D | E | F | G | H | I | J | K | L | M
| N | O | P | Q | R | S | T | U | V | W | X | Y | Z
| Up | Down | Left | Right
| Space | Enter | Escape
| Num0 | Num1 | Num2 | Num3 | Num4 | Num5 | Num6 | Num7 | Num8 | Num9
A keyboard key.
The digit row is Num0 … Num9 — constructor names have to be identifiers, so a bare digit is not one of them. Keys the platform reports that Functor does not name are never delivered to game logic.
Mouse buttons, as a variant rather than strings.
The mouse twin of Key, and built in the same way: the mouseButton hook's button parameter carries these constructors, so a typo is caught at load time. Match them (| Mouse.Left =>) or compare them (button == Mouse.Right).
type t =
| Left | Right | Middle
A mouse button. Buttons beyond these three are never delivered to game logic.
The observability escape hatch.
Debug is the one impure corner of the standard library, and it is impure only in the direction of the terminal: it cannot influence the model or the simulation, so a game with and without it evolves identically.
let log : (string, 'a) => 'a
Log label: value and return value UNCHANGED — an Elm-style trace.
The value is rendered exactly as functor-lang run displays it, whatever its type. Because the label comes first and the subject last, it reads standalone (Debug.log("x", model.x)) and threads through a pipeline (model.x |> Debug.log("x") |> Math.clamp01) without changing what flows through.
Under the plain functor-lang CLI the line goes to stdout; under the game runner it goes to the CLI's log stream, or the browser console on wasm. It is NOT rate-limited, so a call in tick or draw fires every frame — prefer an event path such as input or update, or remove the call when you are done with it.