Reference
Pathlace docs
One language, three views: blocks on a canvas, the text underneath, and a set of edit operations an AI can apply safely.
Programs
A program is a text file ending in .plc. It starts with a version line, may declare types, and has one flow named main.
pathlace 0.1
type Todo = { userId: Int, id: Int, title: Text, completed: Bool }
flow main() -> List<Todo> {
#1 @n_k3f9 fetch = http.getJson<List<Todo>>(url: "https://jsonplaceholder.typicode.com/todos")
@n_p2a1 open = list.filter(items: fetch.out, where: item => not item.completed)
@n_q8z0 first5 = list.take(items: open.out, n: 5)
#2 @n_m4c7 show = ui.table(rows: first5.out, title: "Open todos")
return first5.out
}
#1, #2 mark actions. They run in that order and are the only blocks allowed to have effects (fetch, show, save). An action may only read earlier actions.
- A line without a number is pure. It runs when something downstream needs it and may be re-evaluated freely.
@n_k3f9 is the block's permanent id. Wires reference ids, so labels are display only and renaming never breaks anything.
label = kind(arg: value, ...) calls a block. Arguments are always named. Read a block's result with label.out.
return is the program's value: what the CLI prints and what a webhook responds with.
Types
Built-in: Int, Float, Text, Bool, Json, List<T>. Declare records with type Name = { field: Type, ... }. Generic blocks such as http.getJson<T> check external data against T at the boundary and report the path of any mismatch, so nothing downstream sees a bad shape.
Expressions
Arguments take expressions: literals (5, 1.5, "text", true), references (name.out), field access (item.completed), arithmetic and comparison (+ - * / == != < > <= >=), and, or, not, list literals ([a.out, b.out]) and one-parameter lambdas for blocks that take them (item => not item.completed).
Two deliberate limits keep the canvas honest: blocks are only called at the top level of a flow, never nested inside an expression, and there is no if-expression. Use the pick block.
Files and layout
Box positions live in name.plc.layout.json, keyed by block id. Custom blocks live in blocks.js next to the program. Neither is part of the program text, so moving a box or adding a block definition never shows up as a program change.
Blocks
277 built-in blocks, generated from spec/stdlib.json. A action badge means the block has an effect and must be numbered.
GUI
Widgets that draw in the preview and return their current value. Each is an action because it shows something.
ui.table(rows: List<A>, title?: Text) → List<A>
Show rows as a table in the Output panel. Passes the rows through.
action
ui.text(value: Any, title?: Text) → Text
Show a value as text in the Output panel.
action
ui.button(label: Text) → Int
A button. Returns how many times it has been pressed.
action
ui.input(label: Text, default?: Text) → Text
A text box. Returns what the user typed.
action
ui.slider(label: Text, min: Float, max: Float, default?: Float, step?: Float) → Float
A slider between min and max. Returns the chosen number.
action
ui.toggle(label: Text, default?: Bool) → Bool
An on/off switch. Returns true when on.
action
ui.image(url: Text, title?: Text) → Text
Show an image from a URL.
action
ui.heading(text: Text, level?: Int) → Text
A heading line in the output.
action
ui.markdown(text: Text) → Text
Formatted text: **bold**, *italic*, # headings, - lists and [links](url).
action
ui.number(label: Text, default?: Float, min?: Float, max?: Float, step?: Float) → Float
A number box. Returns the number typed.
action
ui.select(label: Text, options: List<Text>, default?: Text) → Text
A dropdown. Returns the chosen option (text).
action
ui.color(label: Text, default?: Text) → Text
A colour picker. Returns a hex colour.
action
ui.textarea(label: Text, default?: Text, rows?: Int) → Text
A multi-line text box.
action
ui.date(label: Text, default?: Text) → Text
A date picker. Returns "YYYY-MM-DD".
action
ui.radio(label: Text, options: List<Text>, default?: Text) → Text
Pick one of a few options shown side by side.
action
ui.stat(label: Text, value: Any, unit?: Text) → Any
A big number with a label, like a dashboard tile.
action
ui.progress(value: Float, max?: Float, label?: Text) → Float
A progress bar from 0 to max (default 1).
action
ui.list(items: List<Any>, title?: Text) → List<Any>
Show items as a bulleted list.
action
ui.json(value: Any, title?: Text) → Any
Show any value as pretty JSON.
action
ui.link(url: Text, text?: Text) → Text
A clickable link.
action
ui.chart(values: List<Float>, labels?: List<Text>, type?: Text, title?: Text, color?: Text) → List<Float>
A bar or line chart of numbers. labels are optional x-axis names.
action
ui.badge(text: Text, color?: Text) → Text
A small coloured label, e.g. a status.
action
ui.divider() → Bool
A thin line between sections.
action
ui.keyValue(value: Json, title?: Text) → Json
Show a record as a two-column key / value table.
action
ui.alert(text: Text, tone?: Text) → Text
A highlighted notice. tone is "info", "ok", "warn" or "error".
action
2D
Shapes are plain values; canvas2d.show paints a list of them. In the Game section this runs every frame.
draw.rect(x: Float, y: Float, w: Float, h: Float, fill?: Text) → Json
A rectangle shape.
draw.circle(x: Float, y: Float, r: Float, fill?: Text) → Json
A circle shape.
draw.line(x1: Float, y1: Float, x2: Float, y2: Float, stroke?: Text, width?: Float) → Json
A line from (x1, y1) to (x2, y2).
draw.text(x: Float, y: Float, text: Text, fill?: Text, size?: Float) → Json
Text drawn on the canvas.
canvas2d.show(shapes: List<Json>, width?: Int, height?: Int, background?: Text, title?: Text) → List<Json>
Draw a list of shapes on a 2D canvas. Passes the shapes through.
action
draw.ellipse(x: Float, y: Float, rx: Float, ry: Float, fill?: Text, rotation?: Float) → Json
An ellipse with radii rx and ry.
draw.roundRect(x: Float, y: Float, w: Float, h: Float, radius?: Float, fill?: Text) → Json
A rectangle with rounded corners.
draw.triangle(x1: Float, y1: Float, x2: Float, y2: Float, x3: Float, y3: Float, fill?: Text) → Json
A triangle through three points.
draw.polygon(points: List<Json>, fill?: Text, stroke?: Text) → Json
A filled shape through a list of [x, y] points.
draw.star(x: Float, y: Float, r: Float, points?: Int, fill?: Text) → Json
A star with points tips.
draw.arc(x: Float, y: Float, r: Float, start: Float, end: Float, stroke?: Text, width?: Float) → Json
Part of a circle, from start to end angle (radians).
draw.ring(x: Float, y: Float, r: Float, stroke?: Text, width?: Float) → Json
A circle outline.
draw.outline(x: Float, y: Float, w: Float, h: Float, stroke?: Text, width?: Float) → Json
A rectangle outline.
draw.image(url: Text, x: Float, y: Float, w?: Float, h?: Float) → Json
An image from a URL drawn at (x, y).
draw.sprite(glyph: Text, x: Float, y: Float, size?: Float) → Json
An emoji or character drawn big, e.g. "🚀" as a game sprite.
draw.grid(w: Float, h: Float, step: Float, stroke?: Text) → List<Json>
Grid lines every step pixels, for a board or a chart background.
draw.move(shape: Json, dx: Float, dy: Float) → Json
A shape (or list of shapes) shifted by dx, dy.
draw.recolor(shape: Json, fill: Text) → Json
A copy of a shape with a new fill colour.
3D
Meshes are plain values; scene3d.show renders them with three.js and a slowly orbiting camera.
mesh.box(x: Float, y: Float, z: Float, size?: Float, color?: Text, spin?: Float) → Json
A cube at (x, y, z).
mesh.sphere(x: Float, y: Float, z: Float, r?: Float, color?: Text) → Json
A sphere at (x, y, z).
mesh.ground(size?: Float, color?: Text) → Json
A flat floor at y = 0.
scene3d.show(meshes: List<Json>, camera?: Float, background?: Text, title?: Text) → List<Json>
Render a list of meshes in a 3D viewport. Passes the meshes through.
action
mesh.cylinder(x: Float, y: Float, z: Float, r?: Float, height?: Float, color?: Text) → Json
A cylinder standing on (x, y, z).
mesh.cone(x: Float, y: Float, z: Float, r?: Float, height?: Float, color?: Text) → Json
A cone pointing up at (x, y, z).
mesh.torus(x: Float, y: Float, z: Float, r?: Float, tube?: Float, color?: Text, spin?: Float) → Json
A ring (doughnut) at (x, y, z).
mesh.wall(x: Float, y: Float, z: Float, w: Float, h: Float, color?: Text, spin?: Float) → Json
A thin upright slab, w wide and h tall, facing the camera.
mesh.scaled(x: Float, y: Float, z: Float, w: Float, h: Float, d: Float, color?: Text, spin?: Float) → Json
A box with separate width, height and depth.
mesh.light(x: Float, y: Float, z: Float, color?: Text, intensity?: Float) → Json
A point light at (x, y, z).
mesh.move(mesh: Json, dx: Float, dy: Float, dz: Float) → Json
A mesh (or list of meshes) shifted by dx, dy, dz.
mesh.orbit(angle: Float, radius: Float) → Json
A point going round a circle: { x, z } at angle radians and radius.
Game loop
Time, input and state that carries over between frames.
game.tick() → Json
Time for this frame: { t, dt, frame }. Zero outside the game loop.
input.keys() → List<Text>
Keys held down right now, e.g. ["ArrowLeft", "Space"].
input.keyDown(key: Text) → Bool
True while the named key is held.
input.axis(negative: Text, positive: Text) → Int
-1 while negative is held, 1 while positive is held, else 0.
input.pointer() → Json
Mouse or touch position over the canvas: { x, y, down }.
game.state(key: Text, initial: Json) → Json
Read a value saved by game.setState in an earlier frame, or initial on the first frame.
game.setState(key: Text, value: Json) → Json
Save a value for the next frame. Passes the value through.
action
game.vec(x: Float, y: Float) → Json
A 2D vector { x, y }: a position, a speed or a size.
game.vecAdd(a: Json, b: Json) → Json
a + b for vectors.
game.vecScale(v: Json, by: Float) → Json
A vector multiplied by a number (e.g. speed × dt).
game.vecLength(v: Json) → Float
The length of a vector.
game.vecNormalize(v: Json) → Json
The same direction with length 1 (zero stays zero).
game.move(position: Json, velocity: Json, dt: Float) → Json
Position after moving at velocity for dt seconds.
game.hitRect(a: Json, b: Json) → Bool
True when two rectangles { x, y, w, h } overlap.
game.hitCircle(a: Json, b: Json) → Bool
True when two circles { x, y, r } touch.
game.hitPoint(point: Json, rect: Json) → Bool
True when a point { x, y } is inside a rectangle { x, y, w, h }.
game.bounce(position: Float, velocity: Float, min: Float, max: Float) → Json
Flip a velocity component when a position leaves [min, max]: returns { position, velocity }.
game.wrap(x: Float, min: Float, max: Float) → Float
Wrap a number round so it stays within [min, max) (e.g. a ship leaving one side appears on the other).
game.every(t: Float, dt: Float, seconds: Float) → Bool
True once every seconds, using the frame time t.
game.pulse(t: Float, period: Float, min?: Float, max?: Float) → Float
A number that swings between min and max over period seconds.
game.counter(key: Text, start?: Float, step?: Float) → Float
A number that goes up by step each frame, starting at start. Shorthand for state + add.
action
game.toggleState(key: Text, when: Bool) → Bool
Flip a saved true/false each time when is true. Returns the current value.
action
game.score(key: Text, when: Bool, points?: Int) → Int
Add points to a running score saved under key, when when is true.
action
input.mouseDown() → Bool
True while the mouse button or finger is down on the canvas.
input.mouse() → Json
Mouse position as a vector { x, y }.
input.wasd() → Json
A direction vector from WASD or the arrow keys: { x, y } in -1..1.
input.anyKey() → Bool
True while any key is held.
Physics
A body is { x, y, w, h, vx, vy }. Apply gravity, walk, jump and step it against walls each frame; the results are plain values you draw with physics.toRect or toSprite.
physics.body(x: Float, y: Float, w: Float, h: Float, vx?: Float, vy?: Float) → Json
A moving box { x, y, w, h, vx, vy } for the physics blocks.
physics.gravity(body: Json, dt: Float, g?: Float) → Json
Pull a body down: vy grows by g pixels per second squared.
physics.step(body: Json, dt: Float, walls?: List<Json>) → Json
Move a body by its velocity for dt seconds, stopping at the walls (rects { x, y, w, h }). Sets grounded when it lands on one.
physics.jump(body: Json, when: Bool, power?: Float) → Json
When when is true and the body is on the ground, launch it up with power.
physics.walk(body: Json, axis: Float, speed?: Float) → Json
Set the sideways speed from an axis (-1..1) times speed.
physics.friction(body: Json, dt: Float, amount?: Float) → Json
Slow a body down: velocity shrinks by amount (0..1) per second.
physics.push(body: Json, dvx: Float, dvy: Float) → Json
Add to a body's velocity.
physics.keepInside(body: Json, w: Float, h: Float, bounce?: Float) → Json
Keep a body inside a box of size w × h, bouncing off the edges with bounce (0 = stop, 1 = full bounce).
physics.touching(body: Json, rects: List<Json>) → Bool
True when a body overlaps any of the rects.
physics.onGround(body: Json) → Bool
True when the body landed on something in the last physics.step.
physics.toRect(body: Json, fill?: Text) → Json
Draw a body as a rectangle shape.
physics.toSprite(body: Json, glyph: Text) → Json
Draw a body as an emoji sprite centred on it.
Tilemaps
Levels as rows of characters. tilemap.walls feeds physics.step, tilemap.draw and tilemap.sprites make shapes, tilemap.find places things.
tilemap.make(rows: List<Text>, size?: Float) → Json
A tile map from rows of characters, e.g. ["#######", "#.....#"], with size pixels per tile.
tilemap.at(map: Json, col: Int, row: Int) → Text
The character at column col, row row ("" outside the map).
tilemap.atPoint(map: Json, point: Json) → Text
The character under a pixel position { x, y }.
tilemap.walls(map: Json, solid?: Text) → List<Json>
Rects { x, y, w, h } for every tile whose character is in solid (default "#"). Feed them to physics.step.
tilemap.draw(map: Json, colors: Json) → List<Json>
Shapes for a map: colors maps characters to fills, e.g. { "#": "#444", ".": "" }. Empty fill skips the tile.
tilemap.sprites(map: Json, glyphs: Json) → List<Json>
Emoji sprites for a map: glyphs maps characters to emoji, e.g. { "T": "🌳", "C": "🪙" }.
tilemap.find(map: Json, ch: Text) → List<Json>
Pixel positions { x, y } (tile centres) of every tile with character ch. Use it to place the player or coins.
tilemap.set(map: Json, col: Int, row: Int, ch: Text) → Json
A copy of the map with one tile replaced (e.g. a collected coin becomes ".").
tilemap.size(map: Json) → Json
Pixel size of the whole map: { w, h }.
Sound
Tones from note names, short beeps on events, melodies, and sound files from your uploaded assets. Silent in the CLI and in webhooks.
sound.tone(note: Text, duration?: Float, wave?: Text, volume?: Float) → Bool
Play a tone: note like "C4" or a frequency in Hz, for duration seconds. Returns true.
action
sound.beep(when: Bool, pitch?: Float) → Bool
A short beep when when is true (one per frame it is true). Good for hits and pickups.
action
sound.play(url: Text, volume?: Float, loop?: Bool, id?: Text) → Text
Play a sound file from a URL (an uploaded asset works). Returns the URL.
action
sound.playWhen(when: Bool, url: Text, volume?: Float) → Bool
Play a sound file once each time when is true.
action
sound.stop(id?: Text) → Bool
Stop a looping sound started with an id (or every sound when no id).
action
sound.melody(notes: List<Text>, beat?: Float, wave?: Text) → Int
Play notes in turn, e.g. ["C4", "E4", "G4"], each beat seconds long. Returns the note count.
action
sound.noteFreq(note: Text) → Float
The frequency in Hz of a note name like "A4" (440).
Web
Fetch JSON or text from any URL, or post data to one. These work in every edition and in the cloud.
http.getJson<T>(url: Text) → T
GET a URL and parse JSON. The body is checked against T before anything downstream sees it.
action
http.getText(url: Text) → Text
GET a URL and return the body as text.
action
http.postJson(url: Text, body: Json, headers?: Json) → Json
POST a JSON body to a URL and parse the JSON reply.
action
http.request(method: Text, url: Text, body?: Text, headers?: Json) → Json
Any HTTP request. Returns { status, body }.
action
Data and JSON
Read, build and reshape records. Everything here is a pure value block.
json.get(value: Json, path: Text) → Json
Read a field path like "a.b.0" from JSON.
log(value: Any) → Any
Write a value to the run log.
action
json.set(value: Json, path: Text, to: Any) → Json
A copy of the record with the field at path set to value.
json.has(value: Json, path: Text) → Bool
True when the field at path exists and is not null.
json.keys(value: Json) → List<Text>
The field names of a record.
json.values(value: Json) → List<Json>
The field values of a record.
json.entries(value: Json) → List<Json>
A record as a list of { key, value }.
json.fromEntries(entries: List<Json>) → Json
A list of { key, value } back into one record.
json.merge(a: Json, b: Json) → Json
Fields of b written over a copy of a.
json.pair(key: Text, value: Any) → Json
A one-field record { key: value }. Merge several to build a record.
json.record(key1: Text, value1: Any, key2?: Text, value2?: Any, key3?: Text, value3?: Any, key4?: Text, value4?: Any) → Json
Build a record from up to four named fields.
json.without(value: Json, key: Text) → Json
A copy of the record without the field key.
json.parse(text: Text) → Json
Text → JSON value. Errors on invalid JSON.
json.stringify(value: Any, pretty?: Bool) → Text
JSON value → text (pretty: true for indentation).
json.size(value: Json) → Int
Number of fields in a record, or items in a list.
Text
Join, split, search, format and convert text.
text.concat(a: Text, b: Text, sep?: Text) → Text
Join text values.
text.upper(text: Text) → Text
UPPERCASE.
text.lower(text: Text) → Text
lowercase.
text.capitalize(text: Text) → Text
Capital first letter.
text.trim(text: Text) → Text
Remove spaces at both ends.
text.length(text: Text) → Int
Number of characters.
text.contains(text: Text, search: Text) → Bool
True when text contains search.
text.startsWith(text: Text, prefix: Text) → Bool
True when text begins with prefix.
text.endsWith(text: Text, suffix: Text) → Bool
True when text ends with suffix.
text.replace(text: Text, search: Text, with: Text) → Text
Replace every search with with.
text.slice(text: Text, start: Int, end?: Int) → Text
Characters from start up to (not including) end. Negative counts from the end.
text.split(text: Text, sep: Text) → List<Text>
Cut text into a list at every sep.
text.lines(text: Text) → List<Text>
Cut text into lines.
text.words(text: Text) → List<Text>
Cut text into words.
text.join(items: List<Any>, sep?: Text) → Text
Glue a list of values into one text, with sep between them.
text.repeat(text: Text, times: Int) → Text
Repeat text n times.
text.format(template: Text, values: Json) → Text
Fill a template: "Hi {name}, you are {age}" with fields from values.
text.padStart(text: Text, length: Int, pad?: Text) → Text
Pad on the left to length with pad (default space).
text.padEnd(text: Text, length: Int, pad?: Text) → Text
Pad on the right to length with pad.
text.reverse(text: Text) → Text
Reverse the characters.
text.indexOf(text: Text, search: Text) → Int
Where search first appears, or -1.
text.toNumber(text: Text, fallback?: Float) → Float
Read a number out of text. fallback when it is not one.
text.fromNumber(number: Float, decimals?: Int) → Text
A number as text, optionally with fixed decimals.
text.toText(value: Any) → Text
Anything as text (records and lists become JSON).
text.count(text: Text, search: Text) → Int
How many times search appears in text.
text.isEmpty(text: Text) → Bool
True when the text is empty or only spaces.
text.slug(text: Text) → Text
Make a URL-friendly slug: "Hello World" → "hello-world".
text.encodeUrl(text: Text) → Text
Escape text for use inside a URL.
Math and random
Arithmetic, trigonometry, interpolation and random numbers, picks and ids.
math.add(a: Float, b: Float) → Float
a + b
math.clamp(x: Float, min: Float, max: Float) → Float
Keep x between min and max.
math.mod(a: Float, b: Float) → Float
Remainder of a / b (always 0 or positive).
math.abs(x: Float) → Float
Absolute value.
math.sin(x: Float) → Float
Sine of x (radians).
math.cos(x: Float) → Float
Cosine of x (radians).
math.floor(x: Float) → Int
Round down to a whole number.
math.random(min?: Float, max?: Float) → Float
A random number between min and max. Deterministic in Fixture mode.
math.sub(a: Float, b: Float) → Float
a - b
math.mul(a: Float, b: Float) → Float
a × b
math.div(a: Float, b: Float) → Float
a ÷ b. Dividing by zero is an error.
math.pow(a: Float, b: Float) → Float
a to the power b.
math.sqrt(x: Float) → Float
Square root.
math.neg(x: Float) → Float
Flip the sign: -x.
math.min(a: Float, b: Float) → Float
The smaller of a and b.
math.max(a: Float, b: Float) → Float
The larger of a and b.
math.round(x: Float, decimals?: Int) → Float
Round to the nearest whole number, or to decimals places.
math.ceil(x: Float) → Int
Round up to a whole number.
math.sign(x: Float) → Int
-1, 0 or 1 depending on the sign of x.
math.lerp(a: Float, b: Float, t: Float) → Float
Blend from a to b: t = 0 gives a, t = 1 gives b.
math.map(x: Float, inMin: Float, inMax: Float, outMin: Float, outMax: Float) → Float
Re-scale x from one range to another.
math.tan(x: Float) → Float
Tangent of x (radians).
math.atan2(y: Float, x: Float) → Float
The angle (radians) of the vector (x, y).
math.toDegrees(radians: Float) → Float
Radians to degrees.
math.toRadians(degrees: Float) → Float
Degrees to radians.
math.distance(x1: Float, y1: Float, x2: Float, y2: Float) → Float
Distance between two points.
math.pi(times?: Float) → Float
π, optionally multiplied by times.
math.isEven(n: Int) → Bool
True when n is even.
math.between(x: Float, min: Float, max: Float) → Bool
True when min ≤ x ≤ max.
random.int(min: Int, max: Int) → Int
A whole number from min to max, both included. Deterministic in Fixture mode.
random.pick(items: List<A>) → A
One random item from the list.
random.chance(probability: Float) → Bool
True with the given probability (0 to 1).
random.id(length?: Int) → Text
A random id like "k7f3q9". Handy for new records.
Lists
Filter, map, sort, group and summarise lists. The where, sortBy and pluck blocks need no lambda.
list.filter(items: List<A>, where: A => Bool) → List<A>
Keep the items where where is true.
list.map(items: List<A>, to: A => B) → List<B>
Transform every item.
list.take(items: List<A>, n: Int) → List<A>
The first n items.
list.sort(items: List<A>, by: A => Key, desc?: Bool) → List<A>
Sort by a key. Set desc: true for descending.
list.count(items: List<A>) → Int
Number of items.
list.range(n: Int) → List<Int>
The numbers 0, 1, … n-1.
list.first(items: List<A>) → A
The first item (null when empty).
list.last(items: List<A>) → A
The last item (null when empty).
list.get(items: List<A>, index: Int) → A
The item at index (0 is first, -1 is last).
list.skip(items: List<A>, n: Int) → List<A>
Everything after the first n items.
list.slice(items: List<A>, start: Int, end?: Int) → List<A>
Items from start up to (not including) end.
list.reverse(items: List<A>) → List<A>
Last to first.
list.unique(items: List<A>) → List<A>
Remove repeated items.
list.flatten(items: List<Any>) → List<Json>
Lists inside the list are opened up one level.
list.contains(items: List<A>, item: Any) → Bool
True when item is in the list.
list.indexOf(items: List<A>, item: Any) → Int
Where item first appears, or -1.
list.isEmpty(items: List<A>) → Bool
True when there are no items.
list.sum(items: List<Float>) → Float
Add up the numbers.
list.average(items: List<Float>) → Float
The mean of the numbers (0 when empty).
list.min(items: List<Float>) → Float
The smallest number.
list.max(items: List<Float>) → Float
The largest number.
list.append(items: List<A>, item: Any) → List<A>
The list with item added at the end.
list.prepend(items: List<A>, item: Any) → List<A>
The list with item added at the start.
list.concat(a: List<A>, b: List<A>) → List<A>
Two lists joined into one.
list.remove(items: List<A>, index: Int) → List<A>
The list without the item at index.
list.set(items: List<A>, index: Int, item: Any) → List<A>
The list with the item at index replaced.
list.zip(a: List<A>, b: List<B>) → List<List<Json>>
Pair items up: [[a0, b0], [a1, b1], …].
list.chunk(items: List<A>, size: Int) → List<List<Json>>
Split into lists of size.
list.enumerate(items: List<A>) → List<Json>
Each item with its position: { index, item }.
list.sortNumbers(items: List<A>, desc?: Bool) → List<A>
Sort plain numbers or texts, smallest first (desc: true for largest first).
list.shuffle(items: List<A>) → List<A>
Random order. Deterministic in Fixture mode.
list.repeat(item: Any, n: Int) → List<Json>
A list with item n times.
list.pluck(items: List<A>, key: Text) → List<Json>
The field key from every record.
list.where(items: List<A>, key: Text, value: Any) → List<A>
Records whose field key equals value. A filter without a lambda.
list.sortBy(items: List<A>, key: Text, desc?: Bool) → List<A>
Sort records by the field key. No lambda needed.
list.groupBy(items: List<A>, key: Text) → List<Json>
Group records by the field key: [{ key, items }].
list.countBy(items: List<A>, key: Text) → List<Json>
How many records share each value of key: [{ key, count }].
list.find(items: List<A>, where: A => Bool) → A
The first item where where is true, or null.
list.some(items: List<A>, where: A => Bool) → Bool
True when where is true for at least one item.
list.every(items: List<A>, where: A => Bool) → Bool
True when where is true for every item.
list.countWhere(items: List<A>, where: A => Bool) → Int
How many items make where true.
list.sumBy(items: List<A>, of: A => Float) → Float
Add up a number taken from each item.
list.rangeBetween(from: Float, to: Float, step?: Float) → List<Float>
The numbers from from to to (inclusive), stepping by step.
Logic and compare
Booleans, comparisons, conditional picks and conversions between value types.
pick(when: Bool, then: Json, else: Json) → Json
Choose between two values: then when when is true, otherwise else. A block-shaped if.
compare.equals(a: Any, b: Any) → Bool
True when a and b are the same value.
compare.notEquals(a: Any, b: Any) → Bool
True when a and b differ.
compare.less(a: Float, b: Float) → Bool
a < b
compare.lessOrEqual(a: Float, b: Float) → Bool
a ≤ b
compare.greater(a: Float, b: Float) → Bool
a > b
compare.greaterOrEqual(a: Float, b: Float) → Bool
a ≥ b
logic.and(a: Bool, b: Bool) → Bool
True when both are true.
logic.or(a: Bool, b: Bool) → Bool
True when either is true.
logic.not(a: Bool) → Bool
Flip true and false.
logic.xor(a: Bool, b: Bool) → Bool
True when exactly one is true.
value.isNull(value: Any) → Bool
True when the value is null or missing.
value.default(value: Any, fallback: Any) → Json
The value, or fallback when it is null.
value.match(value: Any, cases: Json, otherwise?: Json) → Json
Look value up in cases (a record of answers) and return the match, or otherwise.
value.typeOf(value: Any) → Text
The kind of a value: "text", "number", "bool", "list", "record" or "null".
value.text(text: Text) → Text
A fixed piece of text.
value.number(number: Float) → Float
A fixed number.
value.bool(value: Bool) → Bool
A fixed true or false.
value.list(items: List<Json>) → List<Json>
A fixed list, typed as JSON, e.g. [1, 2, 3] or ["a", "b"].
value.json(value: Json) → Json
Any fixed JSON value, e.g. { name: "Ada", age: 36 }.
Time
Now, parts, formatting and arithmetic on dates.
time.now() → Int
The current time in milliseconds since 1970 (fixed in Fixture mode).
time.parts(ms: Int) → Json
A time in ms split into { year, month, day, hour, minute, second, weekday }.
time.format(ms: Int, style?: Text) → Text
A time in ms as text. style is "date", "time", "datetime" or "iso".
time.parse(text: Text) → Int
Text like "2026-11-07" or "Nov 7 2026 3pm" → ms. Null when unreadable.
time.add(ms: Int, seconds?: Float, minutes?: Float, hours?: Float, days?: Float) → Int
A time plus some seconds, minutes, hours or days.
time.since(ms: Int) → Float
Seconds between ms and now.
time.ago(ms: Int) → Text
Friendly text like "3 minutes ago" for a time in ms.
Color
Build colours from RGB or HSL, mix them, or pick at random. Results are CSS colour strings for 2D and 3D blocks.
color.rgb(r: Int, g: Int, b: Int) → Text
A colour from red, green and blue (0 to 255).
color.hsl(h: Float, s: Float, l: Float) → Text
A colour from hue (0 to 360), saturation and lightness (0 to 100).
color.rgba(color: Text, alpha: Float) → Text
A colour with transparency (alpha 0 to 1).
color.mix(a: Text, b: Text, t: Float) → Text
Blend two hex colours: t = 0 gives a, t = 1 gives b.
color.random() → Text
A random bright colour. Deterministic in Fixture mode.
Cloud
Pathlace Cloud, the free key-value store and webhook runner. Spaces are public by name.
cloud.get(space: Text, key: Text) → Json
Read a value from your free Pathlace Cloud space. Null if missing.
action
cloud.put(space: Text, key: Text, value: Json) → Json
Save a JSON value in your Pathlace Cloud space. Passes the value through.
action
cloud.list(space: Text, prefix?: Text) → List<Json>
All keys and values in a space, newest first.
action
webhook.payload() → Json
The JSON body sent to this program when it runs as a webhook. Empty object in Studio.
cloud.delete(space: Text, key: Text) → Bool
Remove a key from your Pathlace Cloud space. Returns true.
action
cloud.count(space: Text) → Int
How many keys a space holds.
action
cloud.increment(space: Text, key: Text, by?: Float) → Float
Add by (default 1) to a number stored under key, saving and returning the new value.
action
cloud.push(space: Text, key: Text, item: Any, max?: Int) → List<Json>
Append an item to a list stored under key (creating it) and return the list.
action
webhook.field(path: Text, fallback?: Any) → Json
One field from the webhook payload, or fallback when missing.
AI
Ask a model from inside a program. The fixture host returns a mock reply.
ai.prompt(prompt: Text, input?: Json) → Text
Ask a language model. v0.1 runs a deterministic mock in fixture mode; live providers arrive in v0.2.
action
ai.summarize(input: Any, style?: Text) → Text
Ask the AI for a short summary of some text or data.
action
ai.classify(input: Any, labels: List<Text>) → Text
Ask the AI to pick one of the given labels for the input.
action
Cloud HTTP API
Base URL: https://olejisysfdhfjybefvbm.supabase.co/functions/v1/pathlace-api. Everything is JSON. Routes marked account take the session token from sign-in as Authorization: Bearer; the rest are open. Spaces and keys are names matching [a-zA-Z0-9][a-zA-Z0-9_.:-]{0,79}.
Who may do what
- Reading a space needs no account while the space is public (the default). The owner can make a space private, after which only the owner's token reads it.
- Writing always needs an account. The first write to a new space name claims it for that account; from then on only the owner can write or delete there, and anyone else gets a 403 with a hint to pick another name.
- Webhooks belong to the account that published them and run as that account, so a hook can write to its owner's spaces even when the caller is anonymous. That is how an exported game keeps a shared high score: the game posts to the hook, the hook does the write.
- Deleting an account removes its programs, spaces (with every key) and webhooks.
| Route | What it does |
| GET /health | Service name and version. |
| GET /kv/:space | List entries as [{ key, value, updated_at }], newest first. Optional ?prefix=. Open unless the space is private. |
| GET /kv/:space/:key | One value, or 404. Open unless the space is private. |
| PUT /kv/:space/:key | account. Store the JSON body as the value. Up to 64 KB per value and 500 keys per space. Claims the space on first use. |
| DELETE /kv/:space/:key | account, owner only. Remove a key. |
| POST /programs | account. Body { text, name? }. Stores a program and returns { id, hook }. |
| GET /programs/:id | The stored program text. |
| POST /hook/:id | Runs the program as its owner with the request body as hook.payload. Returns { ok, value, views, logs, error, runId }. If the hook has a secret, send it as the x-pathlace-secret header (or ?secret=). |
| GET /p/:shareId | A shared program: { shareId, name, mode, text, layout, blocks, description, views, author, url }. 404 once sharing is switched off. Counts a view. |
| GET /gallery | Recently shared programs, newest first. Optional ?limit= (max 100). |
| GET /auth/config | Which features are on: { enabled, password, google, email, lacey, webClientId, desktopClientId }. |
| POST /auth/forgot | Body { email }. Emails a reset link when email is switched on; always answers the same way whether or not the address exists. |
| POST /auth/reset | Body { token, password }. Sets a new password from a reset link and returns { token, account }. |
| POST /auth/verify | Body { token }. Confirms an email address from a verification link. |
| POST /auth/signup | Body { email, password, name? }. Password 8 characters or more. Returns { token, account }; the token lasts 30 days. |
| POST /auth/login | Body { email, password }. Returns { token, account }. |
| POST /auth/google | Body { idToken } (browser) or { code, codeVerifier, redirectUri } (desktop). Only when Google sign-in is configured. |
| GET /me | account. Your account with program, space and webhook counts. |
| DELETE /me | account. Delete the account and everything it owns. |
| POST /me/password | account. Body { current?, password }. Change (or set) the password. |
| POST /me/verify | account. Send the verification email again. |
| GET /me/projects | account. Your saved programs, newest first: [{ id, name, mode, size, updated_at }]. |
| POST /me/projects | account. Body { name, text, layout?, blocks?, mode? }. Saves a program (up to 256 KB, 100 per account). |
| GET, PUT, DELETE /me/projects/:id | account. Read, replace or remove one saved program. |
| POST /me/projects/:id/share | account. Body { shared, description? }. Switch the public link on or off; returns { shared, shareId, url }. The id stays the same if you share again later. |
| GET /me/spaces | account. Your data spaces: [{ name, public_read, keys, created_at }]. |
| PATCH /me/spaces/:name | account. Body { public_read }. Make a space public or private. |
| DELETE /me/spaces/:name | account. Delete a space and all its keys. |
| GET /me/hooks | account. Your webhooks: [{ id, name, runs, created_at, hook }]. |
| DELETE /me/hooks/:id | account. Remove a webhook. |
| POST /me/hooks/:id/secret | account. Body { clear? }. Make a new secret for the hook (or remove it). Returns { secret }. |
| GET /me/hooks/:id/runs | account. The last 50 runs: [{ at, ok, ms, error }]. |
| GET, POST /me/assets | account. List uploaded files, or upload one as { name, contentType, data } with base64 data. Images and audio only, 2 MB each, 25 MB per account. Returns the public url. |
| DELETE /me/assets/:id | account. Remove a file. |
| POST /lacey | account. Body { text, message, history? }. Lacey answers with { reply, text, diagnostics, ok }; text is the proposed new program, already parsed and checked, or null when she only explains. |
Daily limits
Per account and day: 2000 webhook runs, 5000 data writes, 50 Lacey messages, 100 file uploads, 10 emails. Over the limit the API answers 429 with a plain message; counters reset at midnight UTC. Sign-in attempts are also throttled per address.
Public spaces are readable by anyone who knows the name, so keep secrets out of them or switch the space to private on your account page.
Sharing programs
Any program saved to the cloud can get a public link. In Studio: Account → Share link, add a one-line description, press Create share link. The link looks like https://pathlace.com/p/p_1a2b3c4d5e and opens a page with three tabs: App runs the program once and reruns it when a widget changes, Game runs it every frame with the keyboard and pointer live, and Code shows the program text with a copy button. Open in Studio loads a fresh copy into the visitor's own Studio (/app/?open=p_…), so remixing never touches your original. Shared programs also appear in the gallery. Turn sharing off from the same dialog or on the account page and the link stops working at once.
Shared programs run in the visitor's browser on sample data, and cloud blocks read public spaces only. Anything secret should stay out of a shared program: the text is public.
Images and sounds
Account → Images and sounds (or the account page) uploads PNG, JPEG, GIF, WebP, SVG, MP3, WAV and OGG files to your account. Each file gets a public URL. Paste it into canvas2d.image to draw a picture, or into sound.play to play a clip. Tones need no files: sound.tone and sound.beep play notes by name (C4, F#5) or frequency, sound.melody plays a list of notes, and sound.playWhen fires once each time a condition becomes true, which is how a paddle game goes blip. Sound blocks are silent on the command line and in webhooks.
Webhooks
In the desktop app, sign in, then choose File, then Publish as webhook. Studio posts the program text with your token and shows the hook URL. Any HTTP client can then run it:
curl -X POST https://olejisysfdhfjybefvbm.supabase.co/functions/v1/pathlace-api/hook/h_xxxxxxxx \
-H 'content-type: application/json' \
-d '{ "name": "Bob" }'
Inside the program, webhook.payload returns the posted JSON and webhook.field picks one value out of it. The response includes the return value, every view the program showed and every log line. The hook runs as you: its cloud.put blocks write to your spaces. Manage and delete hooks from Account → Data and webhooks in Studio or on the account page.
Two things keep a hook safe once it is public. A secret (Add secret, in the same list) means callers must send the x-pathlace-secret header or the hook refuses to run; rotate or remove it any time. The runs log shows the last 50 calls with timing and any error, so you can see who is hitting it and why it failed.
Accounts
Studio runs programs without an account, and public data reads work for everyone. Storing anything (saving programs to the cloud, writing data spaces, publishing webhooks) needs a free account: press Sign in at the right of the toolbar and create one with an email address and a password, or do the same on the account page of this site. Google sign-in appears as a second option when it is configured.
Once signed in, the account menu gains Save to cloud (Ctrl Shift S), Save a copy, Open from cloud, Share link, Images and sounds and Data and webhooks, and a cloud mark appears next to the program name. Forgot your password? Type your email in the sign-in form and press Forgot password: a reset link arrives by mail when email is switched on; otherwise write to help@pathlace.com. New accounts get a verification mail too; nothing is locked behind it, it just marks the address as yours. Saved programs are private to your account. Pathlace stores your email, name and a salted password hash plus the programs, spaces and webhooks you create, nothing else; Sign out forgets the session on that computer and Delete account removes everything.
Exported apps and games run as whoever opens them, which is usually nobody signed in. Reads of public spaces work; for writes, publish a webhook that does the writing and call it from the exported program with http.postJson.
Adding blocks
Adding works the way it does in Unreal Blueprint. Right-click (or double-click, or long-press on a phone) any empty spot on the canvas and a searchable menu opens there; pick a block and it lands under the cursor. Drag a wire off a port and drop it on empty canvas and the same menu opens context sensitive: from an output it lists only blocks with an input that takes that type and wires the new block in; from an input it lists blocks whose output fits. Type a few letters to filter (gjs finds http.getJson), use the arrow keys, press Enter to add, Esc to close. The Add block button in the toolbar opens the same menu in the middle of the canvas.
Export and compile
A finished program can leave Studio in three shapes. Web page: File → Export → Save .html writes one file with the Pathlace Player (the runtime) and your program inside; open it in any browser, host it on any site, or send it to a friend. Runnable app (desktop Studio only): pick Windows, Linux or macOS and Studio downloads the prebuilt Player for that platform once, glues your program to it and saves a real executable (.exe, a Linux binary, or a zipped .app) that opens straight into your app or game. Source files: the .plc text, its layout and blocks.js.
The export dialog asks how the program runs: App runs once and again whenever a widget changes; Game runs every frame with the keyboard and pointer available; Auto picks Game when the program draws or reads input. Programs built on sample data ship the recorded responses they use, so they still work offline.
The command line does the same without Studio:
plc build game.plc -o game.html
plc pack game.plc --target windows-x64 -o Game.exe
plc pack game.plc --target macos-aarch64 -o Game.app.zip # also linux-x64, macos-x64
Exported apps are not code-signed. Windows SmartScreen and macOS Gatekeeper will ask before the first launch (macOS: right-click → Open). Custom blocks from blocks.js are included in the export.
Editions
The desktop app (macOS, Windows, Linux) is the full product. The browser edition is deliberately smaller: it runs programs on recorded sample data only, cannot open or save files, shows the block catalog instead of the custom block editor, exports web pages but not runnable apps, and leaves out webhook publishing. Accounts, cloud saves, sharing, data spaces and Lacey work in both. Everything else, including the game loop and 3D, is the same code.
Your own blocks
In Code, the Outside tab is where you extend the language. Write JavaScript, press Load blocks, and the new kinds join the palette under Your blocks.
defineBlock({
kind: 'my.double',
doc: 'Twice the number.',
args: [{ name: 'x', type: 'Float' }],
out: 'Float',
run: ({ x }) => x * 2,
});
Types are the same as the language's. Set effect: true for a block that does something, and it becomes an action like any built-in.
Lacey
Lacey is the helper built into the Code section. Sign in, open the Lacey tab, and ask in plain words: add a counter that goes up when I press a button, make the ball bounce, explain this program. She answers in a sentence or two and, when she wants to change something, proposes new program text. The Studio parses and checks the proposal before you see it, shows the difference line by line, and changes nothing until you press Apply this change. Your program text and message are sent to Pathlace Cloud only when you press Send. 50 messages a day per account.
Under the chat, Developer tools exposes the structured edit operations (add_node, set_arg, connect, rename, remove_node) against a known program version. A batch is checked as a whole; if any step fails, nothing changes. Dry run shows the diff first. The schema is in spec/lacey-tools.json.
Signing and trust
Pathlace Studio installers and exported apps are not code-signed yet. On Windows, SmartScreen shows More info → Run anyway the first time; on macOS, right-click the app and choose Open once. The release workflow is ready to sign as soon as certificates are added (docs/signing.md in the source lists exactly what), and every download comes with a SHA256SUMS.txt you can check.