Parameters
A @Param is a tunable parameter. Declare one on a sketch and read it like a normal property. The live host finds it and shows a control for it. So a value you would otherwise edit and recompile becomes one you adjust while the sketch runs. The control follows the property's type: a Double gets a slider, a Bool gets a toggle, and a Color gets a color well. You can also drive the same parameter from hardware, because an OSC address or a MIDI controller binds directly to it.
final class Pulse: Sketch {
@Param(0...200) var radius = 120.0 // slider "Radius", from the name
@Param("Speed", 0.1...4) var rate = 1.0 // explicit label
@Param var filled = true // toggle
override func draw() {
background(.white)
if filled { fill(.black) } else { noFill() }
drawCircle(width / 2, height / 2, radius + sin(time * rate) * 40)
}
}
A read of a number, an integer, or a switch costs what a stored property costs: under a nanosecond, so a loop may read one per vertex. A color, a vector, a palette, or a text value reads through a lock, about 40 nanoseconds on an M2, which a few reads a frame never show but a loop reading one for every vertex or every sample does. Read those into locals before such a loop starts:
final class Bands: Sketch {
@Param(1...12) var turns = 3.0
@Param var highlight = Color.white
var colors = [Color](repeating: .black, count: 160_000)
override func draw() {
let highlight = self.highlight // one locked read, not one per vertex
for i in colors.indices {
let t = Double(i) / Double(colors.count) * turns
colors[i] = t - t.rounded(.down) < 0.1 ? highlight : .black
}
}
}
Contents
- The typed family
- Groups and icons
- Show-rules: parameters that come and go
- Where the controls appear
- Saving what you turned
- Putting a parameter back
- Scrubbing values
- Smoothing
- Driving a parameter from outside
- The parameter object
The typed family
You declare every supported type the same way, and each one gets the matching inspector control:
| Property type | Control | Declaration |
|---|---|---|
Double | slider + value field | @Param(0...200) var radius = 120.0 |
Int | stepper (− value +) | @Param(1...12) var rings = 5 |
Bool | toggle | @Param var filled = true |
enum (ParamOption) | pop-up menu | @Param var style: Style = .dots |
Color | color well | @Param var ink: Color = .black |
Vector2 | paired x/y fields | @Param(x: 0...1080, y: 0...1080) var anchor = Vector2(540, 540) |
Vector3 | x/y/z fields | @Param(x: -1...1, y: -1...1, z: 0...10) var eye = Vector3(0, 0, 5) |
Rectangle | x/y and w/h fields | @Param(x: 0...1080, y: 0...1080, width: 10...1080, height: 10...1080) var region = … |
Insets | t/r/b/l fields | @Param(0...200) var margins = Insets.all(40) |
ClosedRange<Double> | two-thumb slider + min/max fields | @Param(in: 1...60) var sizes = 6.0...24.0 |
String | text field | @Param var caption = "hello" |
Palette | strip of swatches | @Param var inks = Palette(.red, .white, .black) |
Ramp | gradient band with a handle per stop | @Param var fade = Ramp([.black, .white]) |
ResponseCurve | a graph whose points drag | @Param var falloff = ResponseCurve([(0, 1), (0.5, 0.8), (1, 0)]) |
[Double], [Int] | a row of bars, one per value | @Param(0...1) var accents = [1, 0.2, 0.6, 0.2] |
Tempo | slider in beats per minute | @Param(60...160) var tempo: Tempo = 104 |
NoteLength | pop-up menu of the named lengths | @Param var length: NoteLength = .eighth |
Easing | pop-up menu of the named curves | @Param var curve: Easing = .easeInOut |
Environment | pop-up menu of the twenty built-in environments | @Param var surroundings = Environment.studio |
ParamChoices type | pop-up menu | @Param var mood: LightingPreset = .standard |
A numeric value is always clamped to its range, and the property's default is the starting value. The label comes from the property name, so noiseScale becomes "Noise Scale". When that label reads poorly, pass one explicitly as the first argument.
A Double can also take a step:. Every write then snaps to that increment, so the sketch reads exactly the values the slider offers:
@Param(0...1, step: 0.25) var mix = 0.5
An enum becomes a menu when it conforms to ParamOption. Declare it CaseIterable as well:
enum Style: String, CaseIterable, ParamOption { case dots, rings, meshLines }
@Param var style: Style = .dots
The menu shows the case names in humanized form, so meshLines reads as "Mesh Lines", and optionLabel overrides that wording. The saved selection is keyed on the case name. This means renaming a case loses a tuned choice, while reordering the cases is safe. Ollin's own mode enums already conform, so a mode parameter needs no declaration at all. @Param var blend: BlendMode = .normal gets its menu directly, and StrokeCap, StrokeJoin, Colormap, and RenderQuality do the same. A quality tier becomes a live control in the same way. @Param var quality = RenderQuality.default feeds globalIlluminationQuality(quality), or the matching shadow, raymarch, and volumetric calls.
A type that is not an enum but has a fixed set of named built-in values gets a menu through ParamChoices instead. Provide paramChoices, a list of (name, value) pairs, and the inspector shows the humanized names. LightingPreset, Material, Easing, Fog, and Environment already conform, so @Param var finish: Material = .glossy puts the whole curated library on a menu and @Param var surroundings = Environment.studio the twenty built-in environments (an environment built another way, a .sky(...) or a built-in with lightingOnly() applied, is off that menu; keep the parameter on the plain built-in and apply the modifier where the sketch uses it). The menu finds the current selection through the type's Equatable conformance. That is why the parameterized Material helpers (.glass(...), .metal(...), .skin(radius:)) stay off the menu, because a menu needs fixed values. Pick the nearest built-in and adjust its parameters from there.
The color well opens the system color panel, with its eyedropper, so you can tune a sketch's ink while it runs. The vector, rectangle, and insets forms take a range per field and clamp each field on its own. The min/max pair of a range parameter stays ordered inside its in: bounds. Drag the minimum past the maximum, and the maximum moves along with it.
Curves
A shaping curve is a value like any other, so a parameter can hold one. @Param var curve: Easing = .easeInOut gets a menu of every built-in curve, and the sketch calls the curve as usual:
@Param var spacing: Easing = .linear
// …
let r = radius * spacing(Double(i) / Double(rings - 1))
Each built-in curve carries its own name. The menu selects on that name, and a tuned choice is saved under it. The three friendly aliases are the cubic curves themselves, so .easeInOut reads on the menu as "Ease In Out Cubic". A curve you build from a closure (Easing { t in t * t }) is not on the menu. It equals itself and nothing else, so the row shows the first entry instead, and a reload restores that entry rather than your closure. Write a closure curve directly into the code that uses it, and keep the parameter for the named curves.
A curve you shape
A ResponseCurve parameter is a small graph of the curve with a dot on each of its points. It suits a falloff or a response you would rather shape by eye than by formula. The sketch reads it as a function from 0 to 1, the way it reads an Easing:
@Param(count: 2...8) var falloff = ResponseCurve([(0, 1), (0.3, 0.85), (0.7, 0.2), (1, 0)])
override func draw() {
background(.white)
noStroke()
for ring in 0..<20 {
let t = Double(ring) / 19
fill(Color(white: 1 - falloff(t)))
drawCircle(width / 2, height / 2, 400 * (1 - t))
}
}
Drag a dot to move its point. A dot stops short of its neighbors, so the order the graph shows is the order the curve holds. A press away from every dot adds a point there, on the curve, and goes on dragging it. The + button adds one in the middle of the widest gap, again on the curve, so the shape does not move. The − button takes the selected point away. in and out beside the name are the selected point's place and height, and they scrub or take a typed number like any value box. count: sets how many points the curve may hold, which is where the two buttons stop. A curve given more points than that keeps both its ends and drops inner points from the end.
The line in the graph is drawn by asking the curve for its height across the box, so the row shows exactly what falloff(t) returns. Saving writes the points back as the pairs you would type (ResponseCurve([(0, 1), (0.25, 0.9), (1, 0)])), and --param falloff=1@0,0.7@0.4,0@1 sets the curve for a run, each height at its place.
A list of numbers as bars
A list of numbers is a row of bars, one per value: a step pattern, a weight for each ring, a level for each band. Every value is held to the range and snapped to step: when one is given. A list of whole numbers lands on whole steps.
@Param(0...1) var accents = [1, 0.2, 0.6, 0.2, 0.9, 0.2, 0.5, 0.1]
@Param(0...12) var notes = [0, 3, 7, 10]
override func draw() {
let step = frameCount / 8 % accents.count
background(.white)
fill(.black)
drawCircle(width / 2, height / 2, 100 + 300 * accents[step])
}
Drag across the bars to paint them. Each bar takes the height under the pointer, and a quick stroke that skips bars between two frames still paints the ones it skipped. A range that crosses zero, -1...1, grows its bars up and down from the zero line. The selected bar's number and value sit beside the name. A pattern of 8, 12, 16 bars or more has a small mark under every fourth bar, so it reads in beats.
The number of bars is the list's own, fixed by the declaration. A restored value with more numbers keeps its first ones. One with fewer keeps the list's values past its end, so a list lengthened in the source keeps the bars it was tuned to. Saving writes the list back as typed. Whole numbers stay whole while every value is whole, and a list written with a point keeps one on every value. --param accents=1,0.2,0.6,0.2 sets it for a run.
Both kinds move smoothly between two values of the same length. A cue fade or a timeline key moves each bar of a list and each point of a curve. Two lists or two curves of different lengths hold until the next key. Over OSC a list is one address per bar and a curve one address per point. The phone's remote surface paints the bars and drags the points by touch.
Palettes and gradients
A Palette parameter is a strip of blocks, one per color. A Ramp parameter is one blended band with a handle per stop. You edit both the same way. Click a block or a handle to select it, and the color well beside the label then edits that color. The + and − buttons add and remove a color, and count: sets the limits they stop at:
@Param(count: 2...8) var inks = Palette(.red, .white, .black)
@Param(count: 2...6) var fade = Ramp([.black, .white])
A palette's colors are spread evenly, so only their order matters. A ramp's handles drag along the band but never past their neighbors, so the order the strip shows is the order the ramp holds. The band is drawn by asking the ramp for the color at each step, not by fading between the stops. So what you see is the blend the sketch draws, in whatever color space the ramp mixes in. Editing a ramp's stops never changes that space.
A palette given more colors than count: allows drops the extra ones from the end. A palette shorter than the lower bound is left alone, because there is no color to invent. That lower bound only stops the remove button. The Parameters example has one of each.
Control styles
A few kinds take a style: when the default control is not the right fit:
@Param(1...100_000, style: .field) var iterations = 2000.0 // no track, just the value box
@Param(x: 0...1080, y: 0...1080, style: .pad) var anchor = … // adds a draggable XY pad
@Param(in: 1...60, style: .field) var sizes = 6.0...24.0 // fields only, no two-thumb track
@Param(style: .segmented) var mode: Style = .dots // every case visible at once
.field removes a numeric control's track. Use it for a precise quantity, or for a range too wide for a slider to resolve. The XY pad maps its square to the two ranges, with the top-left corner at both lower bounds. That matches the canvas origin, so dragging the dot feels like dragging on the canvas. A segmented control suits two to four short names. When the segments do not fit beside the label, the row wraps them into a full-width control underneath. Keep longer case lists on the default menu.
Your own types
ParamValue is public, so you can conform your own type. The conformance provides the clamp, the ParamStored round-trip, and the ParamControl the type edits with. The control must be one of the existing kinds, because the inspector takes no custom rows. So a custom type appears as a slider, a menu, fields, and so on. The conformance is a mapping from your type onto the closest built-in control. ParamOption covers the common case, which is any CaseIterable enum, and ParamChoices covers the named-catalog case. Both take almost no work. Write a full ParamValue conformance only when a wrapped scalar or compound type needs to be a parameter.
A full conformance names its constraints type, and the wrapper takes that payload under constraints:. An opacity held as a fraction maps onto the slider through the numeric constraints the framework already has, so its clamp is Double's:
struct Opacity: ParamValue {
var fraction: Double
init(_ fraction: Double) { self.fraction = fraction }
typealias Constraints = ParamNumericConstraints<Double>
static func clamped(_ value: Opacity, by constraints: Constraints) -> Opacity {
Opacity(Double.clamped(value.fraction, by: constraints))
}
static func stored(_ value: Opacity) -> ParamStored { .number(value.fraction) }
static func restored(_ stored: ParamStored) -> Opacity? {
guard case .number(let fraction) = stored else { return nil }
return Opacity(fraction)
}
static func control(for param: Param<Opacity>) -> ParamControl {
.slider(.init(range: param.constraints.range, step: param.constraints.step,
read: { param.wrappedValue.fraction },
write: { param.wrappedValue = Opacity($0) }))
}
}
@Param(constraints: .init(range: 0...1)) var veil = Opacity(0.5)
@Param("Fade", constraints: .init(range: 0...1, step: 0.25)) var fade = Opacity(1)
A type that fits in 64 bits can also answer bits(of:) and value(fromBits:), the pair Double, Int, and Bool answer, and its reads then skip the lock the way theirs do; the default for both is nil, which reads through the lock, and a type that answers one must answer the other.
The built-in constraint types are there to borrow: ParamNumericConstraints for anything on a slider or a stepper, ParamRangeConstraints for a two-thumb range (whose outer is the span the two thumbs move inside), ParamVectorConstraints and ParamVector3Constraints for a point, ParamRectangleConstraints for a rectangle or a set of insets, ParamSwatchConstraints for a palette or a ramp, and ParamCurveConstraints(count:) for a curve, whose count bounds its points. A list of numbers takes its element's own constraints: Array.Constraints is ParamNumericConstraints of the element, so the range and step apply to every value. Each carries the bounds its control needs and a style where the control has more than one shape (ParamVectorStyle.fields against .pad, for instance). A type whose Constraints is Void needs no payload, so @Param var caption = Caption("hello") is the whole declaration. The constraints: form is the primitive. To read @Param(0...1) for your type too, put a short initializer over it in an extension of Param, the way the built-in kinds do:
extension Param where Value == Opacity {
convenience init(wrappedValue: Opacity, _ range: ClosedRange<Double>,
icon: String? = nil, group: ParamGroup? = nil) {
self.init(wrappedValue: wrappedValue, constraints: .init(range: range), icon: icon, group: group)
}
}
From there your type is one more kind. The inspector shows the control the conformance chose, the value persists across reloads through its ParamStored payload, and --param reads text against that payload. Smoothing stays with Double. The save button writes the payload's literal into the file. It keeps a wrapper around that literal, so Opacity(0.5) comes back as Opacity(0.75). A default that wraps more than one plain value has no single place for the tuned one. That parameter is refused by name, the way a computed default is.
Groups and icons
Every form takes an optional icon: and group::
@Param(10...375, icon: "circle.dashed", group: "Rings") var radius = 175.0
@Param(1...12, icon: "circle.grid.2x2", group: "Rings") var rings = 5
@Param(0...4, icon: "speedometer", group: "Motion") var speed = 1.0
group: names an inspector section. Each group renders as its own titled card, in the order the groups first appear in the sketch. Parameters without a group come first, under the default "Parameters" header. icon: is an SF Symbol name shown at the start of the row. Rows without an icon stay aligned with the others when a card mixes both.
Folded groups
A sketch with many parameters usually has a few that are useful but not needed at first glance. Declare their group .folded, and it becomes a disclosure section that starts closed. The everyday parameters then all stay visible:
@Param(0...30, group: .folded("Advanced")) var jitter = 0.0
@Param(-2...2, group: .folded("Advanced")) var orbit = 0.0
One .folded member folds the whole group, so the other members can keep the plain spelling (a ParamGroup carries the name and whether it isFolded). Click the header to open or close the group. The hosts remember that state per sketch, so a group you opened is open the next time that sketch runs. The default (unnamed) group cannot fold. Folding is display only. A parameter behind a closed header still holds, persists, and restores its value. OSC or MIDI keep driving it, exactly as they drive a hidden row.
Show-rules: parameters that come and go
A sketch with many parameters often has some that only matter while another parameter turns them on. A toon band count means nothing outside toon shading, and a glass thickness does nothing at transmission zero. A show-rule hides such a row until it matters. Set the rule in setup(), and reach both parameters through $:
override func setup() {
$toonBands.show(when: $shading) { $0 == .toon }
$thickness.show(when: $transmission) { $0 > 0 }
}
The rule reads the other parameter's current value, and the inspector re-checks it while the sketch runs. Turn transmission up and the thickness row appears. Turn it back to zero and the row goes away. A group whose rows are all hidden drops its whole card, so a mode switch can swap entire sections in and out.
Hiding is display only. A hidden parameter still holds its value, persists it across reloads, restores it, and keeps following an OSC or MIDI binding. Calling show(when:_:) again replaces the rule, and a parameter without a rule always shows. The Materials Explorer example uses show-rules across its whole panel, with one rule for each dependent finish scalar.
Where the controls appear
Under the live-reload host (swift run OllinLive path/to/Sketch.swift), every @Param is a control in the inspector sidebar. Tuned values survive a reload. When you save the file and the sketch hot-swaps, the host re-applies the values you set. So a parameter does not snap back to its default mid-session. If an edit changes a property's type, the stale tuned value is dropped and the newly written default takes effect.
A standalone run of an example shows the same controls in the inspector panel, under View ▸ Show Inspector (⌘/). The examples gallery shows them in its right sidebar.
Headless export never opens an inspector, so a render uses the defaults written in code, or the values --param gives on the command line. Once a tuned value is right, save it into the declaration.
Saving what you turned
A tuned value lives in the running process, so it is gone when you quit the host. The Save parameters to Sketch.swift button under the rows writes it into the file instead. Each value you changed goes into the @Param line that declared it (a look you want to come back to during a run is a cue, saved from the card under the rows and called back over a fade):
@Param(0...200) var radius = 120.0 // before, and after the save:
@Param(0...200) var radius = 86.5 // your file, one number newer
Only the parameters you moved are written. A parameter nobody touched keeps whatever the file says, so editing a default by hand still takes effect. Everything around the value stays as you wrote it. The attribute, the range, the label, the spacing, and a comment at the end of the line all survive. The value is the only text that changes, because the file is scanned rather than written out again.
Some parameters cannot be written, and the line under the button names the first one. The line stays until you turn a value or put one back, since after that it describes values that are no longer the ones showing. A default the sketch computes has no literal value to replace:
@Param(0...900) var radius = side / 3 // "radius is set to side / 3, so there is no value to replace."
The same answer covers a default that names another value (houseRadius), one that is arithmetic (600.0 / 2), and one declared in another file. Write the default as a literal value if you want the save to reach that parameter.
Two details matter here. A number keeps the form you gave it. A whole default stays whole as long as the value is whole, and a fraction always keeps its decimal point. A color is written to four decimals, which is finer than one step of an 8-bit channel.
In the live host the button writes the file, so the watcher reloads the sketch, exactly as your own save does. In the performance host it writes into the code on the stage instead, and ⌘S still decides what reaches the disk. This is the same mechanism as dragging a shape, applied to a property's default rather than to a draw call's arguments.
Putting a parameter back
A parameter you turned shows a small dot after its name. Click the dot, or right-click the row and choose Reset, and the value goes back to what its @Param line declares. Reset all beside the save button puts every parameter back, and a group's card offers its own Reset once one of its rows is turned. The sketch keeps running: nothing is reloaded and setup() does not run again, so the clock, the seed, and an accumulating canvas carry on, and a value that only setup() read keeps what setup() built from it. A smoothed parameter jumps rather than glides, as it does on a reload. A parameter on a timeline track goes back for one frame and then follows its track again.
A reset also forgets the value as tuned. The dot leaves, the save button skips the parameter, and the next reload takes the file's value, so a default you then edit by hand is read.
From code, $radius.reset() puts one parameter back, and resetParameters() puts every one back (resetParameters(named: ["radius", "speed"]) a chosen few); a live host is told, so its tuned record follows. $radius.declaredValue reads what the line gave it, clamped to the range like any other value. The remote surface offers the same Reset all from the phone.
Scrubbing values
Every numeric value box scrubs. Drag horizontally across it to change the value, as professional inspectors do. A drag about a sidebar's width covers the whole range, even a count from 0 to 10,000. A whole number with a short range moves a step every eight points. Hold Option while dragging for a fine adjustment at a tenth of the speed. Hold Shift to step on round marks at the same speed. In a box from 10 to 375 the marks fall every 10, and from 0 to 1 every 0.1. Shift leaves four to forty marks across the range, so it steps through the range rather than running to an end. A plain click starts typing instead.
While you type in a box, the up and down arrows step it to the next mark. A mark is the declared step, or a tenth of Shift's mark in a box with no step. Shift takes Shift's marks, and Option a tenth again where the box has no step to keep. An arrow starts from the number you have typed so far, so typing 12 and pressing up gives 13. A whole number's − and + buttons repeat while you hold them. What you type stays as you typed it until you press Return or Tab, or click away. Only then is it clamped to the range. Meanwhile, a number already inside the range shows on the canvas as you type. In a box that runs from 10 to 30, typing 20 shows 20, never 10 after the first key. Escape puts back the value you started from. A click on the inspector's empty surface ends the typing. The slider, the box, and the scrub all drive the same parameter.
A box shows a value without trailing zeros or a thousands separator: 175, 0.25, 25000. A drag lands on round values, so a slider leaves 2.4 rather than a long fraction. The save button writes the same digits the box shows.
Smoothing
@Param(20...400, smoothing: .eased(0.3)) var radius = 120.0 // 0.3s glide
@Param(0...1, smoothing: .smoothed) var mix = 0.5 // adaptive 1€ filter
Pass a smoothing: and the parameter glides to each new value instead of jumping. .eased(duration, curve:) glides over a fixed time along an Easing curve, so the glide always takes the same time. .smoothed(minCutoff:beta:) runs the value through the 1€ filter. The filter stays steady while the parameter rests, and it responds faster as the parameter moves. That usually feels better when you move a control on live hardware.
The smoothing lives on the parameter, so every source gets it. A MIDI fader, an OSC message, and a drag of the inspector slider all glide the same way. The sketch advances the glide each frame on its own, as @Eased and @Smoothed do. Smoothing applies only to Double parameters, and the other kinds switch instantly.
Driving a parameter from outside
An OSCQuery server goes one step further than a binding. It publishes every parameter as a tree a control app browses, so the app builds a control per parameter with the right range, and nobody types an address.
The projected value ($radius) is the parameter object itself, and it is what the integration libraries bind to:
osc.bind("/radius", to: $radius) // an OSC address (OllinOSC)
midi.bind(controlChange: 7, to: $radius) // a MIDI CC knob (OllinMIDI)
midi.bindPitchBend(to: $lean) // the pitch wheel, its rest at the midpoint
Each incoming value is mapped into the parameter's range and assigned, so a bound parameter updates on its own as messages arrive. The inspector control, the binding, and plain assignment in code all drive the same value, and the most recent write wins. Bindings target Double parameters. The from: input ranges and the other details are on the OSC and MIDI pages.
A parameter is safe to read and write from any thread. The inspector drives it from the main thread, while an OSC or MIDI callback writes from its own queue.
The parameter object
$radius has two more members:
$radius.jump(to: 200) // jump straight there, skipping any smoothing glide
$radius.range // the declared bounds (Double and Int parameters)
Assignment sets a new target, and the parameter glides to it when it is smoothed. jump(to:) lands immediately. The live host puts your tuned values back the same way across a reload, because gliding in from the default would look wrong there.
You can also sweep a parameter offline. --export-sweep renders a proof sheet along one parameter's range, with one tile per value. Every tile uses the same seed, so the parameter is the only thing that changes across the sheet. A seed keeps the meaning it has on the Variations page, where it is the sketch's identity. A sweep is a tuning tool. It shows on one sheet the values you would otherwise reach by dragging the inspector control:
swift run --package-path Examples Example-Live-Parameters --export-sweep sweep.png --sweep-param radius --from 40 --to 360
The full flag list is on the Export page, and OllinApp.contactSheet(of:sweeping:values:seed:) is the code form. In code, you can name the parameter by its handle, sweeping: \.$radius, which the compiler checks.
You can also set a parameter from the command line, on any export path and on a standalone window:
swift run --package-path Examples Example-Live-Parameters --export keeper.png --param radius=40 --param paper=#101018
--param name=value can repeat. The value is read against the parameter's own kind. You can pass a color, a vector, a menu choice, or a swatch strip as easily as a number. The value is on the parameter before the canvas is sized and before setup() runs. So a canvasSize that reads a parameter takes the run's size, and anything built in setup() from it reads the run's value. It is applied again after setup(), so a setup() that sets the parameter itself does not undo it. Both passes go through the same restore path described below. Every export writes its parameter values into the file's recipe, so this flag is how a frame re-renders from the recipe it carries. The kinds and their spellings are on the Export page.
--list-params beside any sketch says what there is to set. Every parameter with its kind, what it accepts, and what it holds, each value in the spelling the flag reads back. See Checking the machine.
To build your own control surface, call parameters(). It returns the sketch's parameters as [ParamHandle]. Each handle carries a stable name key, a display label, the icon and group metadata, and the type-erased param. Its control describes the matching UI: the kind, the ranges, the options, and live get/set closures. stored and restore(_:) round-trip the value through the small ParamStored payload the hosts persist. The live host builds its inspector from exactly this, and most sketches never call it.
A parameter can also follow a curve instead of your hand on a control. An Automation writes a parameter's values down over time, and the sketch sets the parameter each frame from that track of keys. It is the same parameter, directed rather than tuned, and any export renders it exactly as the track sets it.
The Parameters example is the worked demo. It shows a spread of the typed family in three groups driving a ring pattern, and it is made for swift run OllinLive Examples/Live/Parameters/Sketch.swift.