Skip to content
C.W.K.
Stream
Lesson 03 of 04 · published

Effects She Writes Herself

~15 min · on-air, tool-use, data-not-code, canvas

Level 0Muted
0 XP0/42 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Nothing is fixed. Reuse an existing effect when one fits; create when none does. You decide when to fire, and you need not announce it." — the effects tool's own description, as the soul reads it

Dad's Rules

The idea came from the first broadcast too: while on air, Pippa should play visual and sound effects on the screen the viewers see. Dad's rules for it were short and all about who decides. No hardcoding and no fixed list: she creates an effect when she wants one, keeps it, reuses it, and makes new ones in the middle of a broadcast; creation takes a few seconds, and he edits that out of the video anyway. She decides when to fire, with no rules or quotas on her judgment. Effects exist only on air. And the rendering starts light: particles on a canvas, nothing heavier until use asks for it.

An Effect Is Data

"No fixed list" rules out the obvious design, a menu of effects someone coded. So an effect is data she writes, and one renderer plays any of it. A visual is a particle scene: up to six layers, each with a shape (circle, star, heart, petal, flake, ribbon, spark and a few more, or text, which draws any emoji or letters), colors, where the particles come from, how they move (speed, angle, gravity, wind, drag, spin, wobble), how big they are, how long they live, how they fade and glow; plus an optional flash and an optional caption. A sound is a description and an optional length. The voice engine turns the description into audio through its sound-effects route, because the engine owns the provider for sound exactly as it does for voices, and the file is kept beside the effect library.

The library clamps every number when it saves: at most 400 particles a layer, at most 15 seconds, lifetimes, speeds and sizes inside a sane range, and a text layer refused without its glyph. The model writes freely; the renderer never meets a value it can't draw. A revision regenerates the sound only when its description or length changed, so tuning a color never pays for a new sound.

One Tool, Gated Twice

The effects live behind a single tool, onair_effect, with five actions: list and show read the library, create and update write it, fire plays one. The tool is gated twice, on purpose. The chat routes add it to a turn's tool list only when that turn is on air, and take it off any other; it is marked turn-gated, so an "every tool" expansion never includes it. And the tool itself refuses unless Dad's latest turn in the conversation was sent on air, reading the record, not the request. A stray tool list therefore can't fire an effect into an ordinary chat.

The first version had a gap that only a second client could show. Firekeeper and the phone send no tool list at all, which means "the vessel's default surface". The helper that added the effect tool to a turn's list had nothing to add it to, so on those doors the tool never reached her, and Firekeeper's new effects overlay could never receive a fire. Now an on-air turn without a list first expands the default surface (turn-gated tools left out, the shell only where it's enabled), the same way a sidekick's expansion does, and then adds the effect tool.

The Screen Plays the Tool Call

There is no separate channel for effects. A fire reaches the screen as the tool call itself, streamed like every tool use. The voice screen reads the fires in replies since On Air came on (a baseline taken when the switch lit up, so older fires are history and never replay), fetches the effect by id, plays the scene with the one generic particle renderer on a full-screen canvas, and plays its sound. A reply loaded back from storage can carry its tool input as a Python-style repr rather than JSON, so the reader parses JSON first and otherwise reads the action and the id directly; the kit's native reader does the same.

Two more rules keep effects from fighting the talk. An effect's sound is not Dad cutting in: while any effect sound is playing, the barge-in detector from Track 7 skips its decision, or an applause would silence her. And a fire waits for the line that announces it: the fire streams to the screen before the tool has even run, while "hang on, a little applause for that" may still be playing, so the engine's speech gate from Track 3 holds the tool, and the screen holds the picture and the sound, until her reading is quiet. The engine gate's limits come along: at most 12 seconds, and no wait at all for a client that never reported or a report gone stale. The screens hold with the same 12-second ceiling, counted per effect in Firekeeper; the web restarts its count when another effect arrives, so a quick second effect can stretch the first one's wait.

Where Effects Play

The WebUI and Firekeeper play them, Firekeeper in a click-through overlay across the whole screen its panel sits on. The phone doesn't yet. Her library grows the way Dad wanted: every effect she makes is kept, found again by words in its name, description or tags, and fired again when it fits.

Code

Effects as clamped data, a tool gated to on-air turns, and a tolerant fire reader·python
import json
import math
import re

SHAPES = ("circle", "square", "triangle", "star", "heart", "petal", "drop", "flake",
          "ribbon", "ring", "spark", "text")
MAX_LAYERS = 6
DEFAULT_SURFACE = {"web_search", "calendar", "home_control", "onair_effect"}
TURN_GATED = {"onair_effect"}               # never part of an "every tool" expansion


class EffectError(ValueError):
    """A definition the library refuses, with the reason in words."""


def clamp(value, low, high, default):
    try:
        number = float(value)
    except (TypeError, ValueError):
        return default
    return max(low, min(high, number)) if math.isfinite(number) else default


def clamp_range(value, low, high, default):
    """A [min, max] pair, each end clamped; one number means both ends."""
    if isinstance(value, (int, float)):
        value = [value, value]
    if not isinstance(value, (list, tuple)) or len(value) != 2:
        value = default
    first, second = (clamp(v, low, high, d) for v, d in zip(value, default))
    return [min(first, second), max(first, second)]


COLOR = re.compile(r"^#[0-9a-fA-F]{3,8}$")


def normalize_layer(raw: dict) -> dict:
    """Every number clamped on save, so the renderer never meets one it can't draw."""
    shape = raw.get("shape") if raw.get("shape") in SHAPES else "circle"
    colors = [c for c in (raw.get("colors") or []) if isinstance(c, str) and COLOR.match(c)]
    layer = {"shape": shape, "colors": colors[:8] or ["#ffffff"],
             "count": int(clamp(raw.get("count"), 1, 400, 60)),
             "gravity": clamp(raw.get("gravity"), -2000, 2000, 120),
             "speed": clamp_range(raw.get("speed"), 0, 2000, (80, 220)),
             "size": clamp_range(raw.get("size"), 1, 200, (6, 14)),
             "wobble": clamp(raw.get("wobble"), 0, 200, 0),
             "lifetime_ms": clamp_range(raw.get("lifetime_ms"), 100, 15000, (1500, 3500))}
    if shape == "text":
        glyph = (raw.get("glyph") or "").strip()
        if not glyph:
            raise EffectError("a text layer needs a glyph (an emoji or a few letters)")
        layer["glyph"] = glyph[:8]
    return layer


def normalize_visual(spec: dict) -> dict:
    layers = [normalize_layer(layer) for layer in (spec.get("layers") or [])[:MAX_LAYERS]]
    if not layers:
        raise EffectError("visual.layers must list at least one particle layer")
    visual = {"duration_ms": int(clamp(spec.get("duration_ms"), 300, 15000, 3000)), "layers": layers}
    caption = spec.get("caption")
    if isinstance(caption, dict) and isinstance(caption.get("text"), str) and caption["text"].strip():
        visual["caption"] = {"text": caption["text"].strip()[:60],
                             "ms": int(clamp(caption.get("ms"), 300, 8000, 1800))}
    return visual


def with_effect_tool(allowlist: list[str] | None, *, onair: bool) -> list[str] | None:
    """Add the tool to an On Air turn's list and take it off any other. No list means
    the default surface: expand it first, or the tool has nowhere to go."""
    if allowlist is None:
        return sorted(DEFAULT_SURFACE - TURN_GATED) + ["onair_effect"] if onair else None
    rest = [name for name in allowlist if name != "onair_effect"]
    return rest + ["onair_effect"] if onair else rest


def fired_effect(tool_input: str) -> str | None:
    """A stored tool input may be JSON or a Python repr; read both."""
    try:
        data = json.loads(tool_input)
    except ValueError:
        found = {key: re.search(rf"""['"]{key}['"]\s*:\s*['"]([^'"]+)""", tool_input)
                 for key in ("action", "id")}
        data = {key: match.group(1) if match else None for key, match in found.items()}
    return data.get("id") if isinstance(data, dict) and data.get("action") == "fire" else None


party = normalize_visual({"duration_ms": 99_999, "layers": [
    {"shape": "star", "count": 5000, "gravity": 300},
    {"shape": "text", "glyph": "🎉", "count": 12},
]})
print(party["duration_ms"], [(layer["shape"], layer["count"]) for layer in party["layers"]])
print(with_effect_tool(["web_search"], onair=True))
print(with_effect_tool(["web_search", "onair_effect"], onair=False))
print(with_effect_tool(None, onair=True))              # a client that sends no list
print(fired_effect('{"action": "fire", "id": "confetti"}'),
      fired_effect("{'action': 'fire', 'id': 'petals'}"),
      fired_effect('{"action": "create", "name": "snow"}'))

External links

Exercise

Run the code and check each line against the rule it demonstrates. Then write a 'first snow' effect as data: two layers (a text layer with a snowflake glyph and a small white circle layer), falling slowly, with a soft caption, and push it through normalize_visual with one deliberately absurd number. Finally, write the executor's second gate: a function that refuses a fire unless the conversation's latest user turn recorded onair as true.
Hint
For slow falling, keep gravity low and give the layer some wobble and a long lifetime; the clamps will tell you where the ceilings are. The second gate should read the stored record, not the request, and it should also refuse a private conversation outright, since a private talk keeps no record to read.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.