How the two films in this repository are made, and what carried over from the first one to the second. The code is plain JavaScript on Canvas 2D; the only external tools are a headless browser for rendering frames and ffmpeg for encoding.
Nothing is drawn by hand and nothing is loaded. Shapes come from geometry, tone from fields, motion from simulation, sound from synthesis, and quality is checked by measurement. The unit of work is not a canvas primitive but a placed mark: a dot of a halftone screen, a hatch line, a brush stroke.
A shape is always an array of points, never ctx.arc(). A circle is 120
points, so it can be bent, clipped, stroked, filled, projected into 3D or
fed to a tone rule in exactly the same way as anything else.
Before any drawing, FLIP THE COIN fixed a handful of quantities that never change: the frame centre, the hero’s radius, its spin rate, the fall speed, the near-miss gap, and a cut grid of 12 frames (half a second at 24 fps, one beat of the score). Eleven unrelated scenes read as one film because the object in the middle is always the same size, at the same place, spinning at the same rate.
The invariants also do real work:
A scene never draws tone. It declares a field, dens(x, y) -> 0..1: how
much ink falls on each point of the plane, derived from lighting, curvature,
depth or material. A separate rule decides how that field becomes marks.
| rule | tone is carried by | reads as | needs from the scene |
|---|---|---|---|
dot |
dot radius on a fixed grid (by area, r = .564·cell·√v) | risograph, comics | nothing |
hatch |
line width | woodcut, engraving | a scalar coordinate for form-following lines |
dither |
ordered or blue-noise threshold | one-bit graphics | an honest field, there is nothing to hide behind |
stipple |
dot frequency | scientific plates | nothing |
flow |
line length | pen drawing | a direction or gradient |
band |
quantised steps | cel animation | real normals |
TONE.rule = 'hatch' restyles a whole film without touching a scene
(test/toneproof.html prints one sphere through all of them). The
practical consequence: effort goes into fields. No rule rescues an empty
field; a flat fill stays flat in any style.
Findings that shaped the rules:
u = const at a fixed step can never
cross or merge, because u has one value at every point. So the rule
asks the scene for a coordinate, not an angle. Straight woodcut hatching
is the special case u = x·cos + y·sin; a sphere uses distance from its
highlight and gets engraved in rings. Only the scene knows the form; the
rule should never try to recover geometry from tone.?v=2). A
fixed ink set cannot reproduce colours outside its gamut. Instead the
scenes draw into a proxy context that routes each fill into a coverage
layer for its ink; each layer is screened once, at its own angle, with a
1–3 px misregistration, and the inks add like light on dark paper.Measured on real drawings, the thing that reads as a hand is not tremor
(0.03–0.58 px, invisible) but decision noise: where a mark lands, whether it
is laid at all, how long it is. A quarter of human marks miss by more than a
millimetre. Visible waviness is 2–5 cycles per stroke, pressure follows a
minimum-jerk profile (p(τ) = 10τ³ − 15τ⁴ + 6τ⁵: thin slow ends, a
confident middle), and strokes close in time share one oscillator.
Thousands of strokes are collected into one Path2D and filled once.
makeR(key), a seeded generator per purpose, never
Math.random(). Texture stays on the object from frame to frame, and the
same frame renders to the same bytes.continue. Skipping one mark
otherwise shifts the sequence for every mark after it, and the whole field
is redrawn every frame. It looks like crawling texture and is miserable to
find, because the code looks right.renderFrame(f) only
reads the record. FLIP THE COIN replays its only physics (the tail)
deterministically.That last property pays off everywhere: any frame can be rendered alone, a film can be split across processes with no synchronisation, a changed range can be re-rendered without touching the rest, and a refactor can be checked by comparing frames byte for byte, which is how the comment clean-up of this repository was verified.
Tweens give “moved from A to B”; simulation gives weight. The chain is springs plus one root-to-tip pass that clamps each joint at exact link length (no iterations, no stretch). Fur is individual hair around a hidden core. Branches grow by space colonisation with pipe-model thickness.
The drops of HYDRO MASSACRE are a centre of mass plus one deformation mode,
the Rayleigh l = 2 mode, with exaggerated surface tension. Deformation is a
nematic vector Q = q·(cos 2φ, sin 2φ) (a stretch axis φ is the same as
φ + π), so squash along a support normal and stretch along velocity add as
plain vectors, and the whole deformation is one damped 2D oscillator.
Choreography is solved rather than tuned: path first, then the world. Contacts are fixed in time and space and starts are solved backwards. The two drops launched from different launchers ended 3.6 m apart in height at the meeting; since gravity is equal, the difference grows linearly in time, so one correction to one launch speed closes it, found by re-running the flight with a secant update (four runs to a centimetre). Sets are then placed where the drops will be on their reference frames, measured in frame pixels, so a change to the air model moves the sets with the path. Near misses are guaranteed by geometry: a bat pivots outside the frame at exactly bat length + ball radius + gap from the ball, so its tip cannot get closer at any angle.
Slow motion is a time map: the film runs evenly while world time slows, and every simulation, including rain and sign jitter, steps by world time.
The camera is one function, project(x, y, z). Everything else keeps working
because the output is again an array of flat points. The number of
vanishing points is not a setting: a family of parallel lines converges only
if it is not parallel to the image plane, so yaw alone gives two points and
yaw with pitch gives three (test/spaceproof.html computes and marks them).
Depth comes from marks rather than scale: a fixed screen pitch, contrast
lost to air, fewer marks rather than smaller ones, and parallax.
“Water” is three different problems with three models (lib/water.js):
| problem | model |
|---|---|
| open surface | Gerstner waves: analytic height anywhere, the Jacobian doubles as a foam field |
| response to a disturbance | the wave equation on a grid, nine-point Laplacian (five points turn rings into squares) |
| liquid changing shape | position-based fluids, with constants calibrated on the first step |
A single drop is solved analytically: ray-ellipsoid intersection, Snell, Fresnel (Schlick, with the cosine taken on the less dense side), total internal reflection inside air bubbles, Beer–Lambert absorption, and floor caustics by pushing sun rays forward through the drop. The inverted world inside the drop, both highlights and the silver ring of a bubble are not drawn; they come out of the optics.
HYDRO MASSACRE builds its frames as a stage of depth cards
(lib/stage.js): each sample is a ray that accumulates ink through cards
with coverage and refracts through drops. Two ideas matter most:
z' = 2·z_glass − z_drop; the ray to it crosses the glass at
K = z_glass / (2·z_glass − z_drop) of the way, so the image is K times
smaller and can be placed exactly. The reflected world has its own
contents (spikes that exist only there), which is how a drop’s double can
be killed in the glass while the real drop flies on. Shards reflect about
their own normals; which shard shows the drop on which frame is decided
by solving for the normal that bisects the view and the target direction.A contact sheet does not show holes in a composition, broken invariants or an object that popped in between two frames. The checks here measure finished frames:
flip-the-coin/tools/verify.py: film length, cut grid, edge-on cuts,
disc and sphere spinning the same way, the sakuga schedule, byte-identical
holds, and every frame rendering without an exception. Each frame is
wrapped in its own try, because an exception inside page.evaluate
never reaches the pageerror listener and the frame is silently blank.hydro-massacre/tools/act.py: every scene registers its objects each
frame; the tool walks every frame of a range and flags anything that
appears or vanishes mid-frame without entering past an edge, growing from
zero or fading in; jumps more than three times an object’s own typical
step; spikes whose root is not on their own pane; living things stuck
inside a nearer opaque object; heroes changing course or drifting apart.hydro-massacre/tools/pop.py: the same question asked of pixels, for
everything that is not registered (backgrounds, walls, reflections). Each
frame is reduced to a grid of mean tones; a compact region that changes
many times more in one frame than in the frames around it is a pop-in.
Lightning is told apart by sign (the whole frame brightens), objects
entering past an edge by their position, and declared cuts are skipped.window.GATE in hydro.html) finds flat, compact patches
of the printed field sitting on a step threshold, which print as speckle.test/measure.py checks the library numerically: ring isotropy of the
wave equation, PBF calibration and settling.Numbers do not replace looking; they catch what looking misses, mostly in motion.
No samples in FLIP THE COIN. A note is a waveguide string (delay line
SR/f, one-pole loss filter, fractional-delay allpass, DC blocker) followed
by a body of three band-pass biquads; bells are modal synthesis at
inharmonic ratios; the room is eight combs and four allpasses with early
reflections. Instruments at equal gain differed up to 9x in loudness, so
each is measured on a test note and scaled by its RMS. Layer balance is
measured with stems: a layer’s share is sqrt(full² − without²), which is
how the harp turned out to take 38% of the mix.
Harmony follows the same rule as the colour chain: neighbouring chords share exactly two tones, and at every cut two voices hold through it. The beat is 12 frames, so every pluck lands on an edge-on frame. In HYDRO MASSACRE the heart beats on the frames where the divider’s ECG crosses the middle of the screen (the same formula), and the rain’s density follows the time map.
FLIP THE COIN was rendered at four times the size (4320×4320) and reduced
with Lanczos. Beyond a cleaner screen, the encoder stops spending bits on
shimmering dots, so the file got both more accurate and smaller. Measured
against the source PNGs, the master went from 436 MB to 149 MB while SSIM
rose from 0.9843 to 0.9891. tune=grain was not worth it: it treats the
screen as noise to preserve, but the screen is deterministic geometry, and
it doubled the bitrate. HYDRO MASSACRE renders natively at 3840×2160
(?scale=2), since everything is vector marks under a base transform.
A lesson from uploading: FLIP THE COIN played fine locally but jumped back to the start on YouTube after 14 seconds, because of rare open-GOP keyframes. HYDRO MASSACRE’s masters use a closed GOP of exactly 48 frames with no scene-cut keyframes, and the build counts keyframes and fails otherwise.
Each of these looked correct in code and was only visible in a frame or a waveform:
Batch.stroke with closed: true outlines but does not fill; spikes
read as barbed wire.difference blend over an empty canvas gives white, and white strokes
drawn over it vanish.continue (section 5).x = u·cos θ only squashes); the eye flips it every half turn. A
slightly raised camera puts points on ellipses and fixes it.fbm summed raw sits around ±0.31, so thresholds like
clamp(fbm() * 1.8 - .78, 0, 1) were always zero and whole texture
layers silently disappeared. It is normalised to 0..1.page.evaluate does not reach pageerror.