Skip to content

Engine side

npm run gen:engine writes a constants file in your language:

engine/generated/OscAddresses.cs C# (Unity)
engine/generated/osc_addresses.h C++ (Unreal, JUCE, openFrameworks)
engine/generated/osc_addresses.py Python (tooling, tests)

Drop it into your project and never type an address literal:

using ClimbingWall.Osc;
osc.Send(Osc.WallHoldTouch, holdId, velocity, timestamp);

A typo in "/wall/hold/tuoch" is invisible until the sound does not happen, and then it is invisible for another twenty minutes because the string looks fine. A typo in Osc.WallHoldTuoch does not compile.

The file also carries the ports, geometry helpers that take the live wall size, zone helpers, and a constant for every closed set of string values:

Osc.HoldIndex(column, row, wall.Columns); // never a constant width
Osc.ZoneOfColumn(column, zoneCount, wall.AreaFirstColumn, wall.AreaColumns);
osc.Send(Osc.ModeArcadeEnvironment, Osc.Environment.Helmet); // not "helmet"

The reference installation is in Osc.ReferenceWall, for tests. The real wall is whatever you send in /wall/config.

// Fire and forget. Do not wait for anything, do not retry — except for
// /mode/load, which is the one message with an acknowledgement.
var sc = new UdpClient();
sc.Connect(Osc.ScHost, Osc.ScLangPort); // 127.0.0.1 : 57120

Any OSC library will do. What matters is how it handles types, which is the next section.

This is the one that will cost you an afternoon, so it gets its own heading.

osc.Send(Osc.AudioMasterVolume, 1); // ✗ sends an int32. Silently wrong.
osc.Send(Osc.AudioMasterVolume, 1.0); // ✗ a double in C#. Some libraries
// encode 'd', which SC will not
// unpack the way you expect.
osc.Send(Osc.AudioMasterVolume, 1.0f); // ✓ float32, type tag 'f'.

Check what your OSC library does with a bare numeric literal once, at the start, with the monitor running. Some libraries coerce ints to floats for you; several do not; a few will encode a C# double as d and quietly break every message. Ten minutes of checking now, or an afternoon of “why is nothing working” later.

The declared type tag string is on every message on this site, above the argument it applies to.

  1. Absolute values, never deltas. Send the whole synth pattern, every effect’s level, the objective’s progress — never “add” or “more”. A dropped UDP packet must heal itself on the next one.
  2. Idempotent state. Sending /mode/start twice is safe and must stay safe.
  3. IDs are arguments. /wall/hold/touch 12, never /wall/hold/12/touch.
  4. Geometry and samples before the mode. /wall/config and every /sample/load a mode will name go out before /mode/load.
// On startup, and every 2s until /sys/ready arrives.
osc.Send(Osc.SysHello, "wall-game 2026.3.1-dev", Osc.ProtocolVersion);
// Listening on Osc.EnginePort (9000):
case Osc.SysReady:
if (args[0] != Osc.ProtocolVersion)
Log.Warn($"OSC protocol mismatch: engine {Osc.ProtocolVersion}, SC {args[0]}");
scAlive = true;
break;
case Osc.SysSync:
// SuperCollider restarted and has no state. Re-send everything
// marked `rate: state`, in startup order: assets, wall config,
// mix, every sample load, the mode and its state, then /mode/start.
ResendAllState();
break;
case Osc.SampleEnded:
// The narration line finished: light the next hold, play the next line.
story.Advance((string)args[0]);
break;
case Osc.SysPong:
lastPong = Time.now; // two missed pongs = SC is gone; show it in the UI
break;

ResendAllState() is worth writing properly on day one rather than growing it message by message. Every time you add a rate: state message to the protocol, it goes in there too — and the one you forget will be the one that is wrong after the next restart.

osc.Send(Osc.SysAssets, assetRoot);
osc.SendBundle(
(Osc.WallConfig, new object[] { wall.Columns, wall.Rows, wall.PanelColumns, wall.PanelRows,
wall.AreaFirstColumn, wall.AreaColumns,
wall.WidthMeters, wall.HeightMeters }), // floats!
(Osc.WallBlanks, wall.Blanks.Cast<object>().ToArray()));
foreach (var (key, path) in story.Samples) osc.Send(Osc.SampleLoad, key, path);
// ...wait for a /sample/loaded per key...

/mode/load takes the mode id followed by that mode’s init parameters, in order, so the type tag string differs per mode. Each mode’s page shows its own signature, and the lifecycle page lists them all.

osc.Send(Osc.ModeLoad, Osc.Modes.Synth,
Osc.Scale.Pentatonic, "", 60.0f, 110.0f, 4, Osc.Instrument.Marimba);
// scale customScale rootNote tempo steps instrument
// ^ f! ^ f!

This is the one mode message with an acknowledgement, because loading may allocate buffers. Wait for /mode/loaded before sending /mode/start — starting early produces silence that looks exactly like a broken patch. Retry /mode/load if the ack does not arrive within a second or so.

It is the mode where the two sides share the most, so it is worth spelling out:

  • You own: which key holds are pressed and in what order, the freeze timer, the Reset Hold, and how each effect hold behaves (temporary, cumulative, persistent). You send the results as absolute state — the whole pattern, each effect’s level.
  • SuperCollider owns: the clock. It sends /mode/synth/step on every step; pulse the lit key holds from that, not from your own timer, or the lights will drift away from the sound within a minute.