Your brush has an invisible camera
InkField records actions, not pixels. On replay, the app re-runs that script—like a robot redrawing your moves.
Why not just record video?
Because video files are huge and you cannot change resolution or effects. Recording actions gives you:
- Tiny files: A recording can be tens of KB (video would be tens of MB)
- Perfect replay: The replayed drawing matches the original
- Change settings: Replay at different canvas size or background
- Like a performance: Viewers see the whole creation process, not just the result
What gets recorded? Event list
Each "action" in the program is called an event. The recording system records these event types:
Event type overview
| Code | Full name | Meaning | Payload |
|---|---|---|---|
mp | mousePressed | Mouse down (pen down) | Position x, y + stroke settings |
md | mouseDragged | Mouse drag (stroke) | Position x, y |
mr | mouseReleased | Mouse up (pen up) | Position x, y |
kp | keyPressed | Keyboard key | Which key |
ec | effectControl | Effect setting change | Setting and new value |
Each event has a timestamp in milliseconds. On replay, the program knows when each action should happen.
Payload: brush size=3, mode=1, color=black
mousePressed payload: strokeData
On each pen down (mousePressed), the event records not only position but a full stroke snapshot:
- Brush size (initialSize)
- Brush mode (brushMode, brushColorMode)
- Brush color (brushColorMode)
- Ink effect (useSharpen)
- White brush mode (whiteBrushMode)
- Flow (flow, flowSpeed)
- Distort (distort, distortMode)
- Blend (keyBlendMode, blend)
- …and more
So even if settings change mid-session, each stroke start has a full snapshot for exact replay.
JSON: the "tape" format
A minimal recording looks like this:
"version": "1.0",
"randomSeed": 42857, // seed (critical for replay!)
"canvasSize": { "width": 1920, "height": 1080 },
"canvasBackgroundColor": [255, 255, 255],
"initialBrushColorMode": 0, // start: black
"initialEffectControl": {
"shapeType": 0,
"metallicStrength": 85,
"metallicTintType": "copper"
},
"events": [
{ "m": "mp", "t": 0, "x": 234, "y": 567,
"strokeData": { "initialSize": 3, "brushMode": 1, ... } },
{ "m": "md", "t": 16, "x": 240, "y": 560 },
{ "m": "md", "t": 33, "x": 255, "y": 548 },
{ "m": "mr", "t": 200, "x": 412, "y": 489 }
]
}
Field reference
| Field | Meaning |
|---|---|
version | Format version for future upgrades |
randomSeed | Random seed—key to replay consistency (Ch5) |
canvasSize | Canvas dimensions, restored on replay |
canvasBackgroundColor | Background RGB |
events[].m | Event type (mp/md/mr/kp/ec) |
events[].t | Timestamp (ms) |
events[].x, .y | Mouse position |
events[].strokeData | Full stroke snapshot on pen down |
Event types use abbreviations: mp for mousePressed, md for mouseDragged, etc. A single drawing can have tens of thousands of mouseDragged events; shortening names saves a lot of file size.
Why replay is hard
Sounds simple—just replay the event list. But the devil is in the details:
Challenge 1: Random numbers
The brush uses random() everywhere—splash positions, texture, split hairs… Each call returns a different number.
Like two people rolling dice with the same rules but different outcomes. Record might get 3, 6, 1, 4…; replay might get 5, 2, 6, 3…—visually different.
Challenge 2: Frame rate
Your machine doesn't run at a constant speed. Recording might be 60fps; replay might be 45fps under load. Different events per frame change stroke detail.
Challenge 3: Conditional branches
Code has many "if… then…" branches. E.g. "if speed > threshold, call random() again for texture." Tiny float differences can send record down one branch and replay down another—random sequence drift.
Crandom: taming randomness
That's what js/crandom.js does. The Crandom class wraps p5's random() and adds counting:
Same seed = same sequence; record and replay match.
Crandom debug superpower
Besides wrapping random(), Crandom counts calls—you can see how many random() calls so far. Essential for debugging:
If the counts differ, some branch made record and replay take different paths. CrandomDebugger checkpoints help find where the drift starts.
Event sync: one thing per frame
Replay is the same. A key rule:
maxMouseDraggedPerFrame = 1
Each draw() updates brush physics (spring, damping) once. If one frame processes three events, physics updates once; if record had one event per frame, physics updated three times—stroke elasticity and thickness diverge.
Before vs after
| Metric | Before | After |
|---|---|---|
| Draw loop match | 93% | 100% |
| Random consistency | 93% | 99.9% |
| Visual consistency | Noticeable difference | Indistinguishable |
Delayed pen-up: don't drop the last draw
Another subtle fix concerns pen-up.
Problem: mouseReleased too early
If a frame gets both mouseDragged and mouseReleased, processing drag then release seems fine—but release triggers pen-up before the last drag's draw() has run.
This ensures the last draw runs, so the final pen-up stroke isn't dropped.
99.9% consistency: how?
Putting it all together, replay consistency works like this:
What is step 6?
Some code looked like this:
Fix: remove conditional random, or ensure random() is always called (result may be ignored).
Final scorecard
| Brush size | random() diff | Consistency |
|---|---|---|
| Size 3.0 (10 strokes) | ±25 | 99.9% |
| Size 5.0 (4 strokes) | ±150 | 99.8% |
±25 calls sounds a lot, but a full drawing uses tens of thousands; 25 is visually negligible.
Demo mode: automatic playback
Demo flow:
startPlayback() init list
On replay start the app does a long init (like pre-show setup):
- Clear canvas
- Restore background
- Set random seed
- Restore brush color mode
- Restore effects (metallic, shape, etc.)
- Regenerate forceMap
- Re-init shader
- Reset state (paths, flags, blur, …)
- Set camera tracking
Skip any step and replay can diverge. That's why you see long if (typeof xxx !== 'undefined') chains—every state must be reset.
Design philosophy
Don't record the result; record the process.
Don't rely on luck; rely on the seed.
Don't do many things at once; one step per frame.
Don't skip details; reset everything.
These four principles get a highly random art program to 99.9% replay consistency.
URL parameters: the address-bar remote
Basic syntax
Parameters go after ?, separated by _, in key:value format:
https://your-site.com/?_pix:2.0_wd:0.5_gr:0.3_console:1
?_wd:0.5 means "only White Dot on, everything else off."
Toggle parameters (1 = on / 0 = off)
| Param | Function | Example |
|---|---|---|
path | Future path preview | _path:1 |
grid | Grid overlay | _grid:1 |
console | Screen text (Art System Log) | _console:1 |
paper | Paper texture | _paper:1 |
camera | Camera movement (doMoving) | _camera:1 |
loop | Loop playback | _loop:1 |
distort | Distort shader | _distort:1 |
rs | RS effect | _rs:1 |
cl | Cellular effect | _cl:1 |
Numeric parameters
These accept numbers; any value > 0 automatically enables the corresponding effect:
| Param | Function | Slider range | Actual value | Example |
|---|---|---|---|---|
pix |
Pixel Density | — | Used directly (0.5–5.0) | _pix:2.0 |
wd |
White Dot Density | 0–1.0 | display × 0.1 | _wd:1.0 → actual 0.1 |
gr |
Grain Amount | 0–1.0 | display × 0.1 | _gr:0.5 → actual 0.05 |
Common combinations
?_pix:2.0_wd:1.0_gr:0.5_loop:1_console:1
// Mobile power-saving: 1x quality + Grain only
?_pix:1.0_gr:0.3
// High-quality replay: record at low pix, play at high pix
// (path density = recording FPS)
// Recording: ?_pix:1.0
// Playback: ?_pix:3.0_wd:0.8_gr:0.4_loop:1
pix and playback smoothness
Recording events (mouseDragged) are generated inside the draw() loop, so recording FPS = path point density.
Recording at high _pix (e.g. 4.0) means low FPS → sparse path → jerky playback. Solution: record at low pix, play back at any pix.
Mobile auto-override
Mobile devices (iPhone/iPad/Android) automatically cap pixel density at 1.0 for performance. The URL _pix parameter has the highest priority and overrides this default.
Priority: URL _pix > mobile override > desktop default