# Lightning

> A lightning strike over a city at night, painted in code: a bolt with branches, a glow in dithered bands, sparks, bloom and synthesized thunder.

Example `lightning`, running at https://nacatamalon.com/en/examples/lightning. The whole catalogue: https://nacatamalon.com/llms/examples.md.

A street at night under a storm. Click and a bolt comes down where you clicked. Left alone, the storm strikes on its own.

No image or sound files. The bolt is grown by midpoint displacement, with branches, and painted with `createPixels` into a white core and a glow cut into dithered bands, then shown with `updatePixelTexture` 30 times a second while it crackles. It flickers through return strokes and loses its branches as it fades. The sparks are particles written in code that bounce off the street through `useParticleCollider2d`, over `bloom`, an additive flash on the sky under the buildings, a camera shake and thunder from `useSound`'s synthesizer.

Click the preview first so it hears the keyboard. Space brings a bolt down at random, and S slows everything to a fifth.

## Code

`index.ts`:

```ts
import { createGame } from 'nacatamalon';
import { HEIGHT, WIDTH } from './settings';
import { Storm } from './scenes/storm';

// A lightning strike over a city at night, with nothing but the engine: no image files, no sound
// files. Every picture is painted with the pixel functions and the thunder is synthesized.
//
//   click               a bolt where you clicked
//   space               a bolt somewhere at random
//   s                   slow motion, to watch the strokes
const game = createGame('#app', {
    width: WIDTH,
    height: HEIGHT,
    background: '#15112a'
});

// Declare Scenes
game({
    Storm
});
```

`art/backdrop.ts`:

```ts
import { getColor, type TColor } from 'nacatamalon';
import { createPixels, fillCircle, fillGradient, fillRect, mapPixels, setPixel, type TPixels } from 'nacatamalon/pixels';
import { seeded } from '../simulation/bolt';

const hex = (value: string): TColor => getColor(value);

/**
 * The night sky with its stars and the band of storm clouds along the top, with the lilac edge
 * the city's light gives their undersides.
 */
export const paintSky = (width: number, height: number, cloudY: number): TPixels => {
    const rand = seeded(11);
    const sky = createPixels(width, height);

    fillGradient(sky, {
        colors: ['#15112a', '#221b45', '#2e275c', '#3a3270'].map(hex),
        bands: 9,
        dither: true,
    });

    for (let i = 0; i < 90; i++) {
        const x = Math.floor(rand() * width);
        const y = Math.floor(cloudY + rand() * (height * 0.6 - cloudY));
        const bright = rand();

        setPixel(sky, x, y, hex(bright > 0.85 ? '#e6e2ff' : bright > 0.5 ? '#8f88c9' : '#5a5296'));
    }

    // Two rows of cloud: the far one lighter and lower, the near one darker over it. Each is a row
    // of round puffs over a solid band, drawn once a pixel lower in the edge colour first.
    const cloudRow = (base: number, size: number, body: string, edge: string, seed: number) => {
        const puff = seeded(seed);
        const puffs: Array<[number, number, number]> = [];

        for (let x = -12; x < width + 12; x += 10 + Math.floor(puff() * 12)) {
            puffs.push([x, base + Math.floor(puff() * 6) - 3, size + Math.floor(puff() * 6)]);
        }

        for (const [colour, drop] of [[edge, 1], [body, 0]] as const) {
            fillRect(sky, 0, 0, width, base - size + drop, hex(colour));

            for (const [x, y, r] of puffs) {
                fillCircle(sky, x, y + drop, r, hex(colour));
            }
        }
    };

    cloudRow(cloudY - 6, 9, '#2b2452', '#5c4fa8', 3);
    cloudRow(cloudY - 16, 10, '#1b1636', '#433a82', 5);

    return sky;
};

/**
 * One row of buildings standing on the street: blocks of one colour, with windows in a grid and
 * a few of them lit.
 */
export const paintCity = (
    width: number, height: number, ground: number,
    look: { body: string; window: string; lit: string; tall: [number, number]; seed: number; },
): TPixels => {
    const rand = seeded(look.seed);
    const city = createPixels(width, height);
    let x = -4 - Math.floor(rand() * 10);

    while (x < width) {
        const w = 18 + Math.floor(rand() * 26);
        const h = look.tall[0] + Math.floor(rand() * (look.tall[1] - look.tall[0]));
        const top = ground - h;

        fillRect(city, x, top, w, h, hex(look.body));

        // A step or a mast on some roofs, so the skyline is not all flat boxes.
        if (rand() < 0.35) {
            fillRect(city, x + 3, top - 4, w - 6, 4, hex(look.body));
        }

        if (rand() < 0.2) {
            fillRect(city, x + Math.floor(w / 2), top - 10, 1, 10, hex(look.body));
        }

        for (let wy = top + 4; wy < ground - 6; wy += 6) {
            for (let wx = x + 3; wx < x + w - 4; wx += 5) {
                const roll = rand();

                if (roll < 0.55) {
                    fillRect(city, wx, wy, 2, 3, hex(roll < 0.07 ? look.lit : look.window));
                }
            }
        }

        x += w + Math.floor(rand() * 6);
    }

    return city;
};

/**
 * The street: a lit edge, paving stones with seams, darker towards the bottom.
 */
export const paintGround = (width: number, depth: number): TPixels => {
    const ground = createPixels(width, depth, hex('#3d3766'));

    fillGradient(ground, {
        from: hex('#4a4479'),
        to: hex('#24203f'),
        bands: 5,
        dither: true
    });
    fillRect(ground, 0, 0, width, 1, hex('#8f86c8'));
    fillRect(ground, 0, 1, width, 1, hex('#5d5596'));

    const seam = hex('#2a2548');

    return mapPixels(ground, (colour, x, y) => (y > 2 && (x + (y > 9 ? 12 : 0)) % 24 === 0 ? seam : colour));
};

/**
 * A burn on the street where the bolt landed: an ember line, hotter in the middle.
 */
export const paintScorch = (): TPixels => {
    const scorch = createPixels(28, 3);

    fillRect(scorch, 2, 1, 24, 2, hex('#2a1a24'));
    fillRect(scorch, 5, 0, 18, 2, hex('#a83a1e'));
    fillRect(scorch, 9, 0, 10, 1, hex('#ff8a2e'));
    fillRect(scorch, 12, 0, 4, 1, hex('#ffd36a'));

    return scorch;
};
```

`art/paint_bolt.ts`:

```ts
import type { TPixels } from 'nacatamalon/pixels';
import type { TBoltSegment } from '../simulation/bolt';

/**
 * How each level of the bolt is drawn: how far its glow reaches, how bright it is, and whether it
 * has a white core. The main channel is the widest and brightest; a branch's branch is a thread.
 */
const LEVELS = [
    {
        glow: 12,
        strength: 1,
        core: true
    },
    {
        glow: 6,
        strength: 0.7,
        core: true
    },
    {
        glow: 3.5,
        strength: 0.5,
        core: false
    },
];

/**
 * The glow cut into flat bands, from the faintest out to the brightest in, as a palette of the
 * era would: each is a colour and how much of it is added over what is behind.
 */
const BANDS: Array<[number, number, number, number]> = [[64, 52, 200, 0.3], [86, 90, 240, 0.5], [120, 140, 255, 0.72], [170, 196, 255, 0.92],];

const RIM: [number, number, number, number] = [214, 228, 255, 1];
const CORE: [number, number, number, number] = [255, 255, 255, 1];

// An ordered 4x4 pattern: where two bands meet, it decides pixel by pixel which one shows.
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16);

export type TPaintBoltOptions = {
    /**
     * Branches above this level are left out: a bolt that is fading loses its branches first.
     */
    maxLevel?: number;
    /**
     * Where it hits the street, for the splash of light on the ground.
     */
    impact?: { x: number; y: number; };
    /**
     * How bright the splash on the ground is, 0 to 1.
     */
    impactStrength?: number;
};

type TBuffers = { core: Float32Array; glow: Float32Array; };

const buffers = new WeakMap<TPixels, TBuffers>();

/**
 * Paints the bolt over the whole picture, which is cleared first. The light is gathered in two
 * layers of numbers, the core and the glow, and only then turned into colours, so where two
 * branches cross the light does not pile up past white, and every band edge is dithered.
 */
export const paintBolt = (pixels: TPixels, segments: readonly TBoltSegment[], options: TPaintBoltOptions = {
}): void => {
    const { width, height, data } = pixels;
    const maxLevel = options.maxLevel ?? 2;
    let buffer = buffers.get(pixels);

    if (!buffer) {
        buffer = {
            core: new Float32Array(width * height),
            glow: new Float32Array(width * height)
        };
        buffers.set(pixels, buffer);
    }

    const { core, glow } = buffer;

    core.fill(0);
    glow.fill(0);

    const lift = (into: Float32Array, x: number, y: number, value: number) => {
        if (x < 0 || y < 0 || x >= width || y >= height) {
            return;
        }

        const i = y * width + x;

        if (value > into[i]) {
            into[i] = value;
        }
    };

    // A soft dot of glow, brightest in the middle and falling off faster towards its edge.
    const stampGlow = (cx: number, cy: number, radius: number, strength: number, squashY = 1) => {
        const r = Math.ceil(radius);

        for (let oy = -r; oy <= r; oy++) {
            for (let ox = -r; ox <= r; ox++) {
                const d = Math.hypot(ox, oy * squashY) / radius;

                if (d < 1) {
                    lift(glow, cx + ox, cy + oy, strength * (1 - d) ** 1.6);
                }
            }
        }
    };

    for (const piece of segments) {
        if (piece.level > maxLevel) {
            continue;
        }

        const look = LEVELS[Math.min(piece.level, LEVELS.length - 1)];
        const steps = Math.max(1, Math.ceil(Math.hypot(piece.x1 - piece.x0, piece.y1 - piece.y0)));

        for (let s = 0; s <= steps; s++) {
            const t = s / steps;
            const x = Math.round(piece.x0 + (piece.x1 - piece.x0) * t);
            const y = Math.round(piece.y0 + (piece.y1 - piece.y0) * t);

            stampGlow(x, y, look.glow, look.strength);

            if (look.core) {
                lift(core, x, y, look.strength);

                // The main channel is two pixels wide, so it reads at a glance.
                if (piece.level === 0) {
                    lift(core, x + 1, y, 0.7);
                }
            }
        }
    }

    if (options.impact) {
        const strength = options.impactStrength ?? 1;
        const ix = Math.round(options.impact.x);
        const iy = Math.round(options.impact.y);

        // Flattened, as light spilled on a floor is seen from the side.
        stampGlow(ix, iy, 30, strength, 2.6);
        stampGlow(ix, iy, 12, strength);

        // A white-hot splash where it touches, wider than tall, as bright as the channel.
        const rx = Math.round(3 + 5 * strength);

        for (let oy = -2; oy <= 1; oy++) {
            for (let ox = -rx; ox <= rx; ox++) {
                if ((ox / rx) ** 2 + (oy / 2.2) ** 2 <= 1) {
                    lift(core, ix + ox, iy + oy, strength);
                }
            }
        }
    }

    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            const i = y * width + x;
            const c = core[i];
            let colour: [number, number, number, number] | null = null;

            if (c >= 0.9) {
                colour = CORE;
            } else if (c > 0.5) {
                colour = RIM;
            } else {
                // Which band, with the pattern nudging the ones near an edge up or down a step.
                const band = Math.floor(glow[i] * BANDS.length + BAYER[(y & 3) * 4 + (x & 3)] - 0.5);

                if (band >= 0) {
                    colour = BANDS[Math.min(band, BANDS.length - 1)];
                }
            }

            const o = i * 4;

            if (colour) {
                data[o] = colour[0];
                data[o + 1] = colour[1];
                data[o + 2] = colour[2];
                data[o + 3] = Math.round(colour[3] * 255);
            } else {
                data[o + 3] = 0;
            }
        }
    }
};
```

`art/paint_flash.ts`:

```ts
import type { TPixels } from 'nacatamalon/pixels';

const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16);

/**
 * The light a strike throws on the sky and the undersides of the clouds: brightest around where
 * the bolt leaves the cloud, wider than tall, in four dithered steps of a pale lilac. Drawn once
 * per strike; how strong it shows is the sprite's alpha.
 */
export const paintSkyFlash = (pixels: TPixels, x: number, cloudY: number): void => {
    const { width, height, data } = pixels;
    const reach = width * 0.55;

    for (let py = 0; py < height; py++) {
        for (let px = 0; px < width; px++) {
            const d = Math.hypot((px - x) / 1.7, py - cloudY) / reach;
            // The clouds catch more of it than the open sky below them.
            const near = Math.max(0, 1 - d) ** 1.4 * (py < cloudY + 6 ? 1.4 : 1);
            const value = 0.06 + near * 0.94;
            const step = Math.min(4, Math.floor(value * 4 + BAYER[(py & 3) * 4 + (px & 3)] - 0.5));
            const o = (py * width + px) * 4;

            data[o] = 168;
            data[o + 1] = 150;
            data[o + 2] = 255;
            data[o + 3] = Math.max(0, step) * 50;
        }
    }
};
```

`components/effects.ts`:

```ts
import type { TParticlesEffect2d, TTexture } from 'nacatamalon';

/**
 * The three things a strike throws up where it lands, written in code with the fields of a
 * `.particles` file. None of them emits on its own: the scene fires each with `emitParticles`.
 */
export const strikeEffects = (puff: TTexture) => {
    // Hot metal: white, then yellow, then orange as it cools, bouncing on the street.
    const sparks: TParticlesEffect2d = {
        kind: 'particles-2d',
        max: 260,
        blend: 'additive',
        emission: {
            rate: 0,
            burst: 0
        },
        shape: {
            kind: 'circle',
            radius: 3,
            edge: false
        },
        direction: -Math.PI / 2,
        spread: 2.5,
        life: [0.35, 0.95],
        speed: [70, 200],
        size: 1,
        gravity: {
            y: 420
        },
        damping: 0.35,
        colorOverLife: [
            {
                t: 0,
                color: '#fff6c8'
            },
            {
                t: 0.18,
                color: '#ffe07a'
            },
            {
                t: 0.6,
                color: '#ff9a3c'
            },
            {
                t: 1,
                color: '#ff3c1a',
                alpha: 0
            },
        ],
        trail: {
            length: 3,
            width: 1,
            fade: true
        },
        collision: {
            mode: 'bounce',
            bounce: 0.35,
            friction: 0.4
        },
    };

    // The charge running out along the ground: short blue crackles, the colour of the bolt.
    const charge: TParticlesEffect2d = {
        kind: 'particles-2d',
        max: 140,
        blend: 'additive',
        emission: {
            rate: 0,
            burst: 0
        },
        shape: {
            kind: 'line',
            length: 14,
            angle: 0
        },
        direction: -Math.PI / 2,
        spread: Math.PI,
        life: [0.12, 0.4],
        speed: [20, 90],
        size: 1,
        gravity: {
            y: 80
        },
        colorOverLife: [
            {
                t: 0,
                color: '#d6e4ff'
            },
            {
                t: 0.3,
                color: '#8fb0ff'
            },
            {
                t: 1,
                color: '#5a4cff',
                alpha: 0
            },
        ],
        trail: {
            length: 2,
            width: 1,
            fade: true
        },
        collision: {
            mode: 'bounce',
            bounce: 0.2,
            friction: 0.6
        },
    };

    // What is left: a slow puff of smoke rising from the burn.
    const smoke: TParticlesEffect2d = {
        kind: 'particles-2d',
        texture: puff,
        max: 40,
        blend: 'alpha',
        emission: {
            rate: 0,
            burst: 0
        },
        shape: {
            kind: 'circle',
            radius: 5,
            edge: false
        },
        direction: -Math.PI / 2,
        spread: 0.9,
        life: [0.9, 1.8],
        speed: [6, 24],
        size: [4, 7],
        damping: 0.5,
        sizeOverLife: [
            {
                t: 0,
                scale: 1
            },
            {
                t: 1,
                scale: 2.6
            }
        ],
        colorOverLife: [
            {
                t: 0,
                color: '#8a80b8',
                alpha: 0
            },
            {
                t: 0.15,
                color: '#6c6499',
                alpha: 0.55
            },
            {
                t: 1,
                color: '#3a3466',
                alpha: 0
            },
        ],
    };

    return {
        sparks,
        charge,
        smoke
    };
};
```

`components/hero.ts`:

```ts
import { getColor } from 'nacatamalon';
import { createPixels, drawLine, fillCircle, fillPolygon, fillRect, setPixel, type TPixels } from 'nacatamalon/pixels';

export const HERO_WIDTH = 16;
export const HERO_HEIGHT = 26;

/**
 * Where the tip of the rod is in each pose, from the picture's top-left corner: the glow sits there.
 */
export const ROD_TIP = {
    idle: {
        x: 13,
        y: 5
    },
    raised: {
        x: 13,
        y: 0
    },
};

/**
 * The storm caller: a small figure in a hooded raincoat holding a copper rod, which goes up when
 * a bolt comes down.
 */
export const paintHero = (raised: boolean): TPixels => {
    const hero = createPixels(HERO_WIDTH, HERO_HEIGHT);
    const coat = getColor('#d9a441');
    const shade = getColor('#9c6a2a');
    const dark = getColor('#1a1530');
    const boots = getColor('#2c2547');
    const rod = getColor('#c9773a');
    const tip = ROD_TIP[raised ? 'raised' : 'idle'];

    fillRect(hero, 4, 23, 3, 3, boots);
    fillRect(hero, 9, 23, 3, 3, boots);
    fillPolygon(hero, [
        {
            x: 5,
            y: 9
        },
        {
            x: 10,
            y: 9
        },
        {
            x: 13,
            y: 23
        },
        {
            x: 2,
            y: 23
        }
    ], coat);
    fillPolygon(hero, [
        {
            x: 9,
            y: 9
        },
        {
            x: 10,
            y: 9
        },
        {
            x: 13,
            y: 23
        },
        {
            x: 10,
            y: 23
        }
    ], shade);
    fillRect(hero, 2, 22, 12, 1, shade);

    fillCircle(hero, 7, 6, 4, coat);
    fillCircle(hero, 7, 7, 2, dark);
    setPixel(hero, 6, 7, '#ffe9a8');
    setPixel(hero, 8, 7, '#ffe9a8');

    drawLine(hero, tip.x, tip.y, tip.x, raised ? 18 : 24, rod);

    const hand = raised ? 10 : 14;

    fillRect(hero, 10, hand, 3, 2, coat);
    fillRect(hero, tip.x, hand, 1, 2, shade);
    setPixel(hero, tip.x, tip.y, '#e6f3ff');

    return hero;
};
```

`scenes/storm.ts`:

```ts
import { createParticles, createScene, createSprite, createText, emitParticles, useCamera2d, useGame, useKeyboard, useParticleCollider2d, usePointer, useScreenSpace, useSound, useSpawn, useTransform, useTween, useUpdate } from 'nacatamalon';
import { clamp, useRandom } from 'nacatamalon/math';
import { createPixels, createPixelTexture, fillCircle, updatePixelTexture } from 'nacatamalon/pixels';
import { bloom, usePostProcess } from 'nacatamalon/effects';
import { HERO_HEIGHT, HERO_WIDTH, paintHero, ROD_TIP } from '../components/hero';
import { paintCity, paintGround, paintScorch, paintSky } from '../art/backdrop';
import { buildBolt, seeded, type TBoltSegment } from '../simulation/bolt';
import { strikeEffects } from '../components/effects';
import { paintBolt } from '../art/paint_bolt';
import { paintSkyFlash } from '../art/paint_flash';
import { BLEED, CLOUD_Y, GROUND_Y, HEIGHT, LAYER, WIDTH } from '../settings';

/**
 * How bright a strike is over its life, as [seconds, brightness] to go between: a first stroke,
 * a dark gap, the return strokes that make lightning flicker, and the fade.
 */
const STROKES: Array<[number, number]> = [[0, 1], [0.04, 1], [0.05, 0.15], [0.08, 0.15], [0.085, 1], [0.12, 0.9], [0.13, 0.3], [0.17, 0.3], [0.175, 0.8], [0.22, 0.6], [0.45, 0],];

const STRIKE_LENGTH = STROKES[STROKES.length - 1][0];

export const brightnessAt = (t: number): number => {
    for (let i = 1; i < STROKES.length; i++) {
        const [t1, v1] = STROKES[i];

        if (t <= t1) {
            const [t0, v0] = STROKES[i - 1];

            return v0 + (v1 - v0) * ((t - t0) / (t1 - t0));
        }
    }

    return 0;
};

const HERO_X = 34;

/**
 * The street the bolt lands on, as far as the sparks are concerned: solid below its line.
 */
const Street = () => {
    useTransform({
        x: 0,
        y: GROUND_Y
    });
    useParticleCollider2d({
        shape: {
            kind: 'plane'
        }
    });
};

/**
 * What to press, pinned to the screen so the camera's shake leaves it still.
 */
const Hud = () => {
    useScreenSpace();
    createText({
        text: 'CLICK/SPACE: STRIKE   S: SLOW',
        tint: '#8f86c8',
        zIndex: LAYER.hud,
        transform: {
            x: 6,
            y: HEIGHT - 10
        },
    });
};

/**
 * A street at night under a storm. A click brings a bolt down where it was made, Space somewhere at
 * random, and with nobody touching anything the storm strikes on its own.
 */
export const Storm = () => {
    const random = useRandom();
    const game = useGame();
    const keys = useKeyboard();
    const pointer = usePointer();
    const camera = useCamera2d();
    const tween = useTween();

    usePostProcess(bloom({
        threshold: 0.68,
        intensity: 0.9,
        radius: 10
    }));

    // The backdrop is painted BLEED pixels past every edge and laid from there.
    const topLeft = {
        x: 0,
        y: 0
    };

    const bled = {
        x: -BLEED,
        y: -BLEED
    };

    const wide = WIDTH + BLEED * 2;
    const tall = HEIGHT + BLEED * 2;

    createSprite({
        texture: createPixelTexture(paintSky(wide, tall, CLOUD_Y + BLEED)),
        anchor: topLeft,
        transform: bled,
        zIndex: LAYER.sky,
    });

    const skyFlashPixels = createPixels(wide, tall);
    const skyFlashTexture = createPixelTexture(skyFlashPixels);

    const skyFlash = createSprite({
        texture: skyFlashTexture,
        anchor: topLeft,
        transform: bled,
        blend: 'additive',
        tint: '#ffffff00',
        zIndex: LAYER.skyFlash,
    });

    createSprite({
        texture: createPixelTexture(paintCity(wide, tall, GROUND_Y + BLEED, {
            body: '#2a2452',
            window: '#3a3472',
            lit: '#8d7fd0',
            tall: [60, 118],
            seed: 21,
        })),
        anchor: topLeft,
        transform: bled,
        zIndex: LAYER.cityFar,
    });
    createSprite({
        texture: createPixelTexture(paintCity(wide, tall, GROUND_Y + BLEED, {
            body: '#1c1838',
            window: '#2f2a5c',
            lit: '#c9b26a',
            tall: [24, 76],
            seed: 8,
        })),
        anchor: topLeft,
        transform: bled,
        zIndex: LAYER.cityNear,
    });

    // A faint wash over everything, buildings too: the whole street lights up for an instant.
    const flash = createSprite({
        width: wide,
        height: tall,
        anchor: topLeft,
        transform: bled,
        blend: 'additive',
        tint: '#b8b0ff00',
        zIndex: LAYER.flash,
    });

    createSprite({
        texture: createPixelTexture(paintGround(wide, HEIGHT - GROUND_Y + BLEED)),
        anchor: topLeft,
        transform: {
            x: -BLEED,
            y: GROUND_Y
        },
        zIndex: LAYER.ground,
    });
    useSpawn(Street, 'Street')();

    const scorch = createSprite({
        texture: createPixelTexture(paintScorch()),
        anchor: {
            x: 0.5,
            y: 0
        },
        tint: '#ffffff00',
        transform: {
            x: 0,
            y: GROUND_Y
        },
        zIndex: LAYER.scorch,
    });

    const boltPixels = createPixels(WIDTH, HEIGHT);
    const boltTexture = createPixelTexture(boltPixels);

    const bolt = createSprite({
        texture: boltTexture,
        anchor: topLeft,
        blend: 'additive',
        zIndex: LAYER.bolt
    });

    const idle = createPixelTexture(paintHero(false));
    const raised = createPixelTexture(paintHero(true));

    const hero = createSprite({
        texture: idle,
        anchor: {
            x: 0.5,
            y: 1
        },
        transform: {
            x: HERO_X,
            y: GROUND_Y
        },
        zIndex: LAYER.hero,
    });

    tween({
        target: hero,
        to: {
            transform: {
                y: GROUND_Y - 1
            }
        },
        duration: 0.9,
        loop: true,
        yoyo: true
    }).play();

    const glowPixels = createPixels(7, 7);

    fillCircle(glowPixels, 3, 3, 3, '#5a6cff');
    fillCircle(glowPixels, 3, 3, 1, '#d6e4ff');

    const rodGlow = createSprite({
        texture: createPixelTexture(glowPixels),
        blend: 'additive',
        tint: '#ffffff',
        zIndex: LAYER.hero,
    });

    const puffPixels = createPixels(8, 8);

    fillCircle(puffPixels, 4, 4, 3, '#ffffff');

    const effects = strikeEffects(createPixelTexture(puffPixels));

    const sparks = createParticles({
        effect: effects.sparks,
        zIndex: LAYER.sparks
    });

    const charge = createParticles({
        effect: effects.charge,
        zIndex: LAYER.sparks
    });

    const smoke = createParticles({
        effect: effects.smoke,
        zIndex: LAYER.smoke
    });

    const thunder = [3, 4, 7].map((seed) => useSound({
        sfx: {
            preset: 'explosion',
            pitch: 0.42,
            length: 2.2,
            seed,
            volume: 0.75
        }
    }));

    const crack = useSound({
        sfx: {
            preset: 'hit',
            pitch: 2.4,
            length: 0.12,
            volume: 0.5
        }
    });

    useSpawn(Hud, 'Hud')();

    type TStrike = { age: number; seed: number; top: { x: number; y: number; }; impact: { x: number; y: number; }; };

    let strike: TStrike | null = null;
    let segments: TBoltSegment[] = [];
    let crackle = 0;
    let painted = {
        level: -1,
        frame: -1
    };
    let scorchAge = Infinity;
    let quiet = 0;
    let nextAuto = 1.2;

    const strikeAt = (x: number) => {
        const impact = {
            x: Math.round(clamp(x, 60, WIDTH - 8)),
            y: GROUND_Y
        };

        strike = {
            age: 0,
            seed: random.randInt(1, 1_000_000),
            top: {
                x: clamp(impact.x + random.rand(-50, 50), 10, WIDTH - 10),
                y: CLOUD_Y - 6
            },
            impact,
        };
        segments = buildBolt({
            from: strike.top,
            to: impact,
            rand: seeded(strike.seed),
            floor: GROUND_Y
        });
        painted = {
            level: -1,
            frame: -1
        };
        crackle = 0;

        paintSkyFlash(skyFlashPixels, strike.top.x + BLEED, CLOUD_Y + BLEED);
        updatePixelTexture(skyFlashTexture);

        for (const emitter of [sparks, charge, smoke]) {
            emitter.transform.x = impact.x;
            emitter.transform.y = impact.y - 2;
        }

        emitParticles(sparks, 44);
        emitParticles(charge, 34);
        emitParticles(smoke, 9);
        scorch.transform.x = impact.x;
        scorchAge = 0;

        crack.play();
        random.choose(thunder).play();
    };

    pointer.onDown((info) => {
        strikeAt(info.worldX);
        quiet = 0;
    });

    useUpdate((delta) => {
        if (keys.justPressed('Space')) {
            strikeAt(random.rand(70, WIDTH - 20));
            quiet = 0;
        }

        if (keys.justPressed('s')) {
            game.setTimeScale(game.getTimeScale() === 1 ? 0.2 : 1);
        }

        // Left alone, the storm keeps going.
        quiet += delta;

        if (quiet > 3) {
            nextAuto -= delta;

            if (nextAuto <= 0) {
                strikeAt(random.rand(80, WIDTH - 20));
                nextAuto = random.rand(1.4, 3);
            }
        }

        let light = 0;

        if (strike) {
            strike.age += delta;
            light = brightnessAt(strike.age);

            // Branches go out before the channel does; while it is bright, its fine detail crackles.
            const level = light > 0.55 ? 2 : light > 0.3 ? 1 : 0;

            crackle += delta;

            const frame = strike.age < 0.3 ? Math.floor(crackle * 30) : painted.frame;

            if (level !== painted.level || frame !== painted.frame) {
                if (frame !== painted.frame) {
                    segments = buildBolt({
                        from: strike.top,
                        to: strike.impact,
                        rand: seeded(strike.seed),
                        detail: seeded(strike.seed + frame * 7919),
                        floor: GROUND_Y,
                    });
                }

                paintBolt(boltPixels, segments, {
                    maxLevel: level,
                    impact: strike.impact,
                    impactStrength: Math.min(1, light + 0.2)
                });
                updatePixelTexture(boltTexture);
                painted = {
                    level,
                    frame
                };
            }

            if (strike.age >= STRIKE_LENGTH) {
                strike = null;
            }
        }

        bolt.tint.a = light;
        skyFlash.tint.a = light * 0.95;
        flash.tint.a = light * 0.06;

        // The rod goes up with the bolt and glows with it.
        hero.texture = light > 0.05 ? raised : idle;

        const tip = ROD_TIP[light > 0.05 ? 'raised' : 'idle'];

        rodGlow.transform.x = hero.transform.x - HERO_WIDTH / 2 + tip.x + 0.5;
        rodGlow.transform.y = hero.transform.y - HERO_HEIGHT + tip.y + 0.5;
        rodGlow.tint.a = 0.25 + light * 0.75;

        scorchAge += delta;
        scorch.tint.a = scorchAge < 2.5 ? 1 - scorchAge / 2.5 : 0;

        // The street shakes for the first moments, less and less.
        const shake = strike && strike.age < 0.35 ? 3 * (1 - strike.age / 0.35) : 0;

        camera.transform.x = shake ? random.rand(-shake, shake) : 0;
        camera.transform.y = shake ? random.rand(-shake, shake) : 0;
    });

    return createScene();
};
```

`settings.ts`:

```ts
/**
 * The game's size, in its own pixels. 320x200 is 16:10, the shape of the clip this answers.
 */
export const WIDTH = 320;
export const HEIGHT = 200;

/**
 * Where the street's top edge is: the bolt ends here and the sparks bounce off it.
 */
export const GROUND_Y = 184;

/**
 * How far the backdrop reaches past each edge of the screen, so the camera's shake never shows
 * what is beyond it.
 */
export const BLEED = 6;

/**
 * Where the bottoms of the clouds hang, about: a bolt is born just under them.
 */
export const CLOUD_Y = 26;

/**
 * Drawing order, back to front. The flash on the sky sits under the city so the buildings stay
 * dark against it, the way a city looks when lightning goes off behind it.
 */
export const LAYER = {
    sky: 0,
    skyFlash: 1,
    cityFar: 2,
    cityNear: 3,
    flash: 4,
    smoke: 5,
    sparks: 6,
    ground: 7,
    scorch: 8,
    bolt: 9,
    hero: 10,
    hud: 20,
} as const;
```

`simulation/bolt.ts`:

```ts
/**
 * One straight piece of a bolt. `level` is 0 for the main channel, 1 for a branch off it, 2 for a
 * branch off a branch: the painter makes each level thinner and dimmer.
 */
export type TBoltSegment = {
    x0: number;
    y0: number;
    x1: number;
    y1: number;
    level: number;
};

export type TBoltOptions = {
    from: { x: number; y: number; };
    to: { x: number; y: number; };
    /**
     * Shapes the big bends and decides the branches: the same one, the same bolt.
     */
    rand: () => number;
    /**
     * Shapes only the last two splits, the crackle. A new one each frame over the same `rand` keeps
     * the bolt where it is and makes its edge flicker.
     */
    detail?: () => number;
    /**
     * How many times every piece is split in two. 6 gives pieces of about 3 pixels on a 160 pixel bolt.
     */
    generations?: number;
    /**
     * How likely a split of the main channel throws a branch.
     */
    branchChance?: number;
    /**
     * No branch reaches below this: the street.
     */
    floor: number;
};

/**
 * A bolt from `from` to `to` by midpoint displacement: each piece is split at its middle, and the
 * middle pushed sideways by a random amount that halves every generation, so the big bends come
 * first and the crackle last. Branches leave from some of those middles, turned away from the
 * channel, and are split in turn. The ends never move, so the bolt always lands where it was aimed.
 */
export const buildBolt = (options: TBoltOptions): TBoltSegment[] => {
    const { from, to, rand, floor } = options;
    const detail = options.detail ?? rand;
    const generations = options.generations ?? 6;
    const branchChance = options.branchChance ?? 0.5;
    let offset = Math.hypot(to.x - from.x, to.y - from.y) * 0.16;
    let segments: TBoltSegment[] = [
        {
            x0: from.x,
            y0: from.y,
            x1: to.x,
            y1: to.y,
            level: 0
        }
    ];

    for (let generation = 0; generation < generations; generation++) {
        const pick = generation >= generations - 2 ? detail : rand;
        const next: TBoltSegment[] = [];

        for (const piece of segments) {
            const dx = piece.x1 - piece.x0;
            const dy = piece.y1 - piece.y0;
            const length = Math.hypot(dx, dy) || 1;
            // Sideways is across the piece; a branch bends less than the channel it leaves.
            const shift = (pick() * 2 - 1) * offset * (piece.level === 0 ? 1 : 0.55);
            const mx = (piece.x0 + piece.x1) / 2 - (dy / length) * shift;
            const my = Math.min((piece.y0 + piece.y1) / 2 + (dx / length) * shift, floor);

            next.push({
                x0: piece.x0,
                y0: piece.y0,
                x1: mx,
                y1: my,
                level: piece.level
            });
            next.push({
                x0: mx,
                y0: my,
                x1: piece.x1,
                y1: piece.y1,
                level: piece.level
            });

            // Branches come from the splits that still have room to bend, never the crackle's.
            const chance = piece.level === 0 ? branchChance : branchChance * 0.45;

            if (piece.level < 2 && generation >= 1 && generation < generations - 2 && rand() < chance) {
                const side = rand() < 0.5 ? -1 : 1;
                const angle = Math.atan2(dy, dx) + side * (0.4 + rand() * 0.55);
                const reach = length * (0.6 + rand() * 0.7);

                next.push({
                    x0: mx,
                    y0: my,
                    x1: mx + Math.cos(angle) * reach,
                    y1: Math.min(my + Math.sin(angle) * reach, floor - 2),
                    level: piece.level + 1,
                });
            }
        }

        segments = next;
        offset /= 2;
    }

    return segments;
};

/**
 * A stream of numbers that is the same for the same seed (mulberry32): the bolt's shape is kept as
 * a seed, and drawn again from it each time it crackles.
 */
export const seeded = (seed: number): (() => number) => {
    let state = seed >>> 0;

    return () => {
        state = (state + 0x6d2b79f5) >>> 0;

        let t = state;

        t = Math.imul(t ^ (t >>> 15), t | 1);
        t ^= t + Math.imul(t ^ (t >>> 7), t | 61);

        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
};
```
