0x77b35947…cc7dsent to0xc3e1f0dd…9515·#25,613,194·view on Etherscan
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SATARI</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- SATARI ENGINE — zero-dependency p5 shim -->
<script>
/* ================================================================
MICRO-P5: minimal p5.js-compatible engine for SATARI
Provides: canvas, draw loop, graphics buffers, HSB colors,
shapes, blend modes, seeded RNG, noise, WebGL shader support
================================================================ */
// ─── CONSTANTS ───
var PI = Math.PI, TWO_PI = PI * 2, HALF_PI = PI / 2, QUARTER_PI = PI / 4
var TAU = TWO_PI
var CLOSE = 'close'
var CENTER = 1, CORNER = 0
var HSB = 'hsb', RGB = 'rgb'
var WEBGL = 'webgl'
var ROUND = 'round', SQUARE = 'butt', PROJECT = 'square'
var BLEND = 'source-over', ADD = 'lighter', MULTIPLY = 'multiply'
var SCREEN = 'screen', DIFFERENCE = 'difference', EXCLUSION = 'exclusion'
var OVERLAY = 'overlay', REPLACE = 'copy'
// ─── MATH GLOBALS ───
var { floor, ceil, round, abs, min, max, sin, cos, tan, atan2, sqrt, pow, log, exp, sign } = Math
function lerp(a, b, t) { return a + (b - a) * t }
function map(v, a1, b1, a2, b2) { return a2 + (v - a1) / (b1 - a1) * (b2 - a2) }
function constrain(v, lo, hi) { return max(lo, min(hi, v)) }
// ─── SEEDED RNG ───
var _rngState = 12345
function _rngNext() {
let t = (_rngState += 0x6D2B79F5)
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
function randomSeed(s) { _rngState = s >>> 0 }
function random(a, b) {
const r = _rngNext()
if (a === undefined) return r
if (b === undefined) {
if (Array.isArray(a)) return a[floor(r * a.length)]
return r * a
}
return a + r * (b - a)
}
function randomGaussian(m, sd) {
const u1 = _rngNext(), u2 = _rngNext()
return (m || 0) + (sd || 1) * sqrt(-2 * log(u1 || 1e-10)) * cos(TWO_PI * u2)
}
// ─── PERLIN NOISE ───
var _noiseState = 0
var _noisePerm = new Uint8Array(512)
function noiseSeed(s) {
_noiseState = s
const prev = _rngState; _rngState = s >>> 0
for (let i = 0; i < 256; i++) _noisePerm[i] = i
for (let i = 255; i > 0; i--) {
const j = floor(_rngNext() * (i + 1))
const tmp = _noisePerm[i]; _noisePerm[i] = _noisePerm[j]; _noisePerm[j] = tmp
}
for (let i = 0; i < 256; i++) _noisePerm[i + 256] = _noisePerm[i]
_rngState = prev
}
function _fade(t) { return t * t * t * (t * (t * 6 - 15) + 10) }
function _ngrad(hash, x) { return (hash & 1) ? -x : x }
function noise(x, y, z) {
x = x || 0; y = y || 0; z = z || 0
const X = floor(x) & 255, Y = floor(y) & 255, Z = floor(z) & 255
x -= floor(x); y -= floor(y); z -= floor(z)
const u = _fade(x), v = _fade(y), w = _fade(z)
const A = _noisePerm[X] + Y, B = _noisePerm[X + 1] + Y
const AA = _noisePerm[A] + Z, AB = _noisePerm[A + 1] + Z
const BA = _noisePerm[B] + Z, BB = _noisePerm[B + 1] + Z
return (lerp(
lerp(lerp(_ngrad(_noisePerm[AA], x), _ngrad(_noisePerm[BA], x - 1), u),
lerp(_ngrad(_noisePerm[AB], x), _ngrad(_noisePerm[BB], x - 1), u), v),
lerp(lerp(_ngrad(_noisePerm[AA + 1], x), _ngrad(_noisePerm[BA + 1], x - 1), u),
lerp(_ngrad(_noisePerm[AB + 1], x), _ngrad(_noisePerm[BB + 1], x - 1), u), v),
w
) + 1) / 2 // map to 0-1
}
noiseSeed(0) // init permutation table
// ─── COLOR SYSTEM ───
function _hsbToCSS(h, s, b, a) {
h = ((h % 360) + 360) % 360
s = constrain(s, 0, 100) / 100
b = constrain(b, 0, 100) / 100
a = constrain(a !== undefined ? a : 100, 0, 100) / 100
const k = n => (n + h / 60) % 6
const f = n => b * (1 - s * max(0, min(k(n), 4 - k(n), 1)))
return `rgba(${round(f(5) * 255)},${round(f(3) * 255)},${round(f(1) * 255)},${a})`
}
function _parseHex(hex) {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return [r, g, b, 255]
}
function _rgbToHSB(r, g, b) {
r /= 255; g /= 255; b /= 255
const mx = max(r, g, b), mn = min(r, g, b), d = mx - mn
let h = 0, s = mx === 0 ? 0 : d / mx
if (d !== 0) {
if (mx === r) h = ((g - b) / d + 6) % 6
else if (mx === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h *= 60
}
return [h, s * 100, mx * 100]
}
// Color object — stores RGBA internally
class SColor {
constructor(r, g, b, a) { this._r = r; this._g = g; this._b = b; this._a = a }
toString() { return `rgba(${this._r},${this._g},${this._b},${this._a / 255})` }
setAlpha(a) { this._a = constrain(round(a / 100 * 255), 0, 255) }
}
window.SColor = SColor
function color(a, b, c, d) {
if (a instanceof SColor) return a
if (typeof a === 'string') {
const [r, g, bl, al] = _parseHex(a)
return new SColor(r, g, bl, al)
}
// HSB mode: (h, s, b [, a])
if (c !== undefined) {
const css = _hsbToCSS(a, b, c, d !== undefined ? d : 100)
const m = css.match(/[\d.]+/g)
return new SColor(+m[0], +m[1], +m[2], round(+m[3] * 255))
}
// single value → grayscale brightness
const v = constrain(a, 0, 100)
const bri = round(v / 100 * 255)
if (b !== undefined) {
// (gray, alpha)
return new SColor(bri, bri, bri, round(constrain(b, 0, 100) / 100 * 255))
}
return new SColor(bri, bri, bri, 255)
}
function red(c) { return c._r }
function green(c) { return c._g }
function blue(c) { return c._b }
function hue(c) { return _rgbToHSB(c._r, c._g, c._b)[0] }
function lerpColor(c1, c2, t) {
return new SColor(
round(lerp(c1._r, c2._r, t)),
round(lerp(c1._g, c2._g, t)),
round(lerp(c1._b, c2._b, t)),
round(lerp(c1._a, c2._a, t))
)
}
// ─── RESOLVE COLOR ARGS → CSS STRING ───
function _resolveColor(args) {
if (args.length === 0) return null
const a0 = args[0]
if (a0 instanceof SColor) return a0.toString()
if (typeof a0 === 'string') return a0
if (args.length >= 3) return _hsbToCSS(args[0], args[1], args[2], args[3])
// single value = grayscale brightness (0-100 in HSB), or >100 clamps to white
const bri = constrain(a0, 0, 255)
const v = round(bri / 100 * 255)
const av = args[1] !== undefined ? constrain(args[1], 0, 100) / 100 : 1
return `rgba(${min(v, 255)},${min(v, 255)},${min(v, 255)},${av})`
}
// ─── GRAPHICS BUFFER ───
class PGraphics {
constructor(w, h, mode) {
this.width = w
this.height = h
this._isWebGL = (mode === WEBGL)
this.canvas = document.createElement('canvas')
this.canvas.width = w
this.canvas.height = h
if (this._isWebGL) {
this._gl = this.canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: true })
this.drawingContext = this._gl
this._setupGL()
} else {
this.drawingContext = this.canvas.getContext('2d')
this.drawingContext.imageSmoothingEnabled = false
}
this._rectMode = CORNER
this._fillCSS = '#000000'
this._strokeCSS = null
this._strokeW = 1
this._doFill = true
this._doStroke = true
this._tintAlpha = 1
this._tintColor = null
this._erasing = false
this._shapeVerts = null
this._shapeBeziers = null
this._activeShader = null
}
// ── GL setup ──
_setupGL() {
const gl = this._gl
// p5 vertex shader expects aPosition in 0-1 range (maps to clip space via *2-1)
const verts = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0])
const texCoords = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1])
this._quadPos = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, this._quadPos)
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW)
this._quadTex = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, this._quadTex)
gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW)
this._inputTex = gl.createTexture()
}
createShader(vertSrc, fragSrc) {
const gl = this._gl
function compile(type, src) {
const s = gl.createShader(type)
gl.shaderSource(s, src)
gl.compileShader(s)
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(s))
}
return s
}
const vs = compile(gl.VERTEX_SHADER, vertSrc)
const fs = compile(gl.FRAGMENT_SHADER, fragSrc)
const prog = gl.createProgram()
gl.attachShader(prog, vs)
gl.attachShader(prog, fs)
gl.linkProgram(prog)
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error('Shader link error:', gl.getProgramInfoLog(prog))
}
return {
_prog: prog, _gl: gl, _uniforms: {},
setUniform(name, val) {
gl.useProgram(prog)
let loc = this._uniforms[name]
if (!loc) { loc = gl.getUniformLocation(prog, name); this._uniforms[name] = loc }
if (loc === null) return
if (val instanceof PGraphics || (val && val.canvas)) {
// Texture
gl.activeTexture(gl.TEXTURE0)
const pgTex = this._gl._boundTex || this._gl.createTexture()
this._gl._boundTex = pgTex
gl.bindTexture(gl.TEXTURE_2D, pgTex)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE,
val.canvas || val)
gl.uniform1i(loc, 0)
} else if (Array.isArray(val)) {
if (val.length === 2) gl.uniform2f(loc, val[0], val[1])
else if (val.length === 3) gl.uniform3f(loc, val[0], val[1], val[2])
else if (val.length === 4) gl.uniform4f(loc, val[0], val[1], val[2], val[3])
} else if (typeof val === 'boolean') {
gl.uniform1i(loc, val ? 1 : 0)
} else {
gl.uniform1f(loc, val)
}
}
}
}
shader(s) { this._activeShader = s }
// Draw fullscreen quad with current shader
rect(x, y, w, h) {
if (this._isWebGL && this._activeShader) {
const gl = this._gl
const prog = this._activeShader._prog
gl.useProgram(prog)
gl.viewport(0, 0, this.width, this.height)
const posLoc = gl.getAttribLocation(prog, 'aPosition')
if (posLoc >= 0) {
gl.bindBuffer(gl.ARRAY_BUFFER, this._quadPos)
gl.enableVertexAttribArray(posLoc)
gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 0, 0)
}
const texLoc = gl.getAttribLocation(prog, 'aTexCoord')
if (texLoc >= 0) {
gl.bindBuffer(gl.ARRAY_BUFFER, this._quadTex)
gl.enableVertexAttribArray(texLoc)
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0)
}
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
return
}
// 2D rect — use path-based fill to match p5's behavior
const ctx = this.drawingContext
let rx = x, ry = y
if (this._rectMode === CENTER) { rx = x - w / 2; ry = y - h / 2 }
// Optimization: REPLACE + transparent fill = clearRect (safer across browsers)
if (ctx.globalCompositeOperation === 'copy' && this._fillCSS === 'rgba(0,0,0,0)') {
ctx.clearRect(rx, ry, w, h)
} else {
ctx.beginPath()
ctx.rect(rx, ry, w, h)
if (this._doFill) { ctx.fillStyle = this._fillCSS; ctx.fill() }
if (this._doStroke && this._strokeCSS) { ctx.strokeStyle = this._strokeCSS; ctx.lineWidth = this._strokeW; ctx.stroke() }
}
}
// ── 2D Drawing ──
pixelDensity() { return 1 }
colorMode() { } // noop — always HSB internally
noStroke() { this._doStroke = false; this._strokeCSS = null }
noFill() { this._doFill = false }
fill(...args) {
this._doFill = true
this._fillCSS = _resolveColor(args)
}
stroke(...args) {
this._doStroke = true
this._strokeCSS = _resolveColor(args)
}
strokeWeight(w) { this._strokeW = w; this.drawingContext.lineWidth = w }
blendMode(m) { if (!this._isWebGL) this.drawingContext.globalCompositeOperation = m }
rectMode(m) { this._rectMode = m }
background(...args) {
if (this._isWebGL) return
const ctx = this.drawingContext
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
if (args.length >= 4 && args[0] === 0 && args[1] === 0 && args[2] === 0 && args[3] === 0) {
ctx.clearRect(0, 0, this.width, this.height)
} else if (args[0] instanceof SColor) {
ctx.fillStyle = args[0].toString()
ctx.fillRect(0, 0, this.width, this.height)
} else {
ctx.fillStyle = _resolveColor(args)
ctx.fillRect(0, 0, this.width, this.height)
}
ctx.restore()
}
clear() {
if (this._isWebGL) { const gl = this._gl; gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT) }
else { const ctx = this.drawingContext; ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, this.width, this.height); ctx.restore() }
}
push() {
if (!this._isWebGL) {
this.drawingContext.save()
this._stateStack = this._stateStack || []
this._stateStack.push({ fill: this._fillCSS, stroke: this._strokeCSS, doFill: this._doFill, doStroke: this._doStroke, strokeW: this._strokeW, rectMode: this._rectMode, erasing: this._erasing, tintAlpha: this._tintAlpha, tintColor: this._tintColor })
}
}
pop() {
if (!this._isWebGL) {
this.drawingContext.restore()
if (this._stateStack && this._stateStack.length) {
const s = this._stateStack.pop()
this._fillCSS = s.fill; this._strokeCSS = s.stroke; this._doFill = s.doFill; this._doStroke = s.doStroke; this._strokeW = s.strokeW; this._rectMode = s.rectMode; this._erasing = s.erasing; this._tintAlpha = s.tintAlpha; this._tintColor = s.tintColor
}
}
}
translate(x, y) { if (!this._isWebGL) this.drawingContext.translate(x, y) }
rotate(a) { if (!this._isWebGL) this.drawingContext.rotate(a) }
scale(x, y) { if (!this._isWebGL) this.drawingContext.scale(x, y !== undefined ? y : x) }
// Erase mode
erase() {
this._erasing = true
this._prevComposite = this.drawingContext.globalCompositeOperation
this.drawingContext.globalCompositeOperation = 'destination-out'
}
noErase() {
this._erasing = false
this.drawingContext.globalCompositeOperation = this._prevComposite || 'source-over'
}
// ── Shapes ──
beginShape() { this._shapeVerts = []; this._firstVert = true }
vertex(x, y) {
if (!this._shapeVerts) return
this._shapeVerts.push({ type: 'v', x, y })
}
bezierVertex(x2, y2, x3, y3, x4, y4) {
if (!this._shapeVerts) return
this._shapeVerts.push({ type: 'b', x2, y2, x3, y3, x4, y4 })
}
curveVertex(x, y) {
if (!this._shapeVerts) return
this._shapeVerts.push({ type: 'c', x, y })
}
endShape(mode) {
if (!this._shapeVerts || this._shapeVerts.length === 0) return
const ctx = this.drawingContext
ctx.beginPath()
// Separate handling for curve vertices
const allCurve = this._shapeVerts.every(v => v.type === 'c')
if (allCurve && this._shapeVerts.length >= 4) {
this._drawCatmullRom(ctx, this._shapeVerts.map(v => ({ x: v.x, y: v.y })))
} else {
let first = true
for (const v of this._shapeVerts) {
if (v.type === 'v') {
if (first) { ctx.moveTo(v.x, v.y); first = false }
else ctx.lineTo(v.x, v.y)
} else if (v.type === 'b') {
ctx.bezierCurveTo(v.x2, v.y2, v.x3, v.y3, v.x4, v.y4)
}
}
}
if (mode === CLOSE || mode === 'close') ctx.closePath()
if (this._doFill) { ctx.fillStyle = this._fillCSS; ctx.fill() }
if (this._doStroke && this._strokeCSS) { ctx.strokeStyle = this._strokeCSS; ctx.lineWidth = this._strokeW; ctx.stroke() }
this._shapeVerts = null
}
_drawCatmullRom(ctx, pts) {
// Catmull-Rom → cubic bezier conversion
// p5 curveVertex expects: first and last points are control-only
if (pts.length < 4) return
ctx.moveTo(pts[1].x, pts[1].y)
for (let i = 0; i < pts.length - 3; i++) {
const p0 = pts[i], p1 = pts[i + 1], p2 = pts[i + 2], p3 = pts[i + 3]
ctx.bezierCurveTo(
p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6,
p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6,
p2.x, p2.y
)
}
}
ellipse(x, y, w, h) {
if (h === undefined) h = w
const ctx = this.drawingContext
ctx.beginPath()
ctx.ellipse(x, y, w / 2, h / 2, 0, 0, TWO_PI)
if (this._doFill) { ctx.fillStyle = this._fillCSS; ctx.fill() }
if (this._doStroke && this._strokeCSS) { ctx.strokeStyle = this._strokeCSS; ctx.lineWidth = this._strokeW; ctx.stroke() }
}
circle(x, y, d) { this.ellipse(x, y, d, d) }
line(x1, y1, x2, y2) {
const ctx = this.drawingContext
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
if (this._doStroke && this._strokeCSS) { ctx.strokeStyle = this._strokeCSS; ctx.lineWidth = this._strokeW; ctx.stroke() }
}
triangle(x1, y1, x2, y2, x3, y3) {
const ctx = this.drawingContext
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); ctx.closePath()
if (this._doFill) { ctx.fillStyle = this._fillCSS; ctx.fill() }
if (this._doStroke && this._strokeCSS) { ctx.strokeStyle = this._strokeCSS; ctx.lineWidth = this._strokeW; ctx.stroke() }
}
quad(x1, y1, x2, y2, x3, y3, x4, y4) {
const ctx = this.drawingContext
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineTo(x3, y3); ctx.lineTo(x4, y4); ctx.closePath()
if (this._doFill) { ctx.fillStyle = this._fillCSS; ctx.fill() }
if (this._doStroke && this._strokeCSS) { ctx.strokeStyle = this._strokeCSS; ctx.lineWidth = this._strokeW; ctx.stroke() }
}
// ── Image / tint ──
image(src, x, y, w, h) {
if (!src) return
const ctx = this.drawingContext
const prevAlpha = ctx.globalAlpha
// ALWAYS set globalAlpha — prevent stale values from leaking across operations
ctx.globalAlpha = this._tintAlpha
// Handle slice references from get() — avoids creating temp canvases
if (src._isSlice) {
ctx.drawImage(src.canvas, src._sx, src._sy, src._sw, src._sh,
x || 0, y || 0, w || src._sw, h || src._sh)
ctx.globalAlpha = prevAlpha
return
}
const srcCanvas = (src instanceof PGraphics) ? src.canvas : src
if (this._tintColor && this._tintAlpha > 0) {
// Color tint: draw to temp, multiply with color
if (!this._tintCanvas || this._tintCanvas.width !== this.width || this._tintCanvas.height !== this.height) {
this._tintCanvas = document.createElement('canvas')
this._tintCanvas.width = this.width; this._tintCanvas.height = this.height
this._tintCtx = this._tintCanvas.getContext('2d')
}
const tc = this._tintCtx
tc.clearRect(0, 0, this.width, this.height)
tc.globalCompositeOperation = 'source-over'
tc.globalAlpha = 1.0
tc.drawImage(srcCanvas, x || 0, y || 0, w || srcCanvas.width || this.width, h || srcCanvas.height || this.height)
tc.globalCompositeOperation = 'source-atop'
tc.fillStyle = this._tintColor
tc.fillRect(0, 0, this.width, this.height)
ctx.drawImage(this._tintCanvas, 0, 0)
} else if (w !== undefined && h !== undefined) {
ctx.drawImage(srcCanvas, x || 0, y || 0, w, h)
} else {
ctx.drawImage(srcCanvas, x || 0, y || 0)
}
ctx.globalAlpha = prevAlpha
}
tint(...args) {
if (args.length === 2 && args[0] === 255) {
// tint(255, alpha) — just alpha
this._tintAlpha = args[1] / 255
this._tintColor = null
} else if (args.length === 4) {
// tint(r, g, b, a)
this._tintColor = `rgb(${args[0]},${args[1]},${args[2]})`
this._tintAlpha = args[3] / 255
} else if (args.length === 1) {
this._tintAlpha = args[0] / 255
this._tintColor = null
}
}
noTint() { this._tintAlpha = 1; this._tintColor = null }
// p5's get(x,y,w,h) — return lightweight slice reference
// Avoids creating a new canvas for every call (critical for corrupt effect performance)
get(x, y, w, h) {
const srcCanvas = this.canvas
return {
canvas: srcCanvas,
_isSlice: true,
_sx: x, _sy: y, _sw: w, _sh: h,
width: w, height: h
}
}
// p5 color() on buffer instance
color(...args) { return color(...args) }
lerpColor(c1, c2, t) { return lerpColor(c1, c2, t) }
// Cleanup — p5 removes from DOM, we just release the canvas
remove() {
if (this._isWebGL && this._gl) {
const ext = this._gl.getExtension('WEBGL_lose_context')
if (ext) ext.loseContext()
}
this.canvas.width = 0
this.canvas.height = 0
}
}
window.PGraphics = PGraphics
// ─── MAIN CANVAS + LIFECYCLE ───
var _mainPG = null
var _mainCanvas = null
var _displayCanvas = null
var _displayCtx = null
var width = 0, height = 0
var frameCount = 0
var _targetFPS = 60
var _frameDuration = 1000 / 60
var _lastFrame = 0
var _running = true
var key = ''
// When null, millis() reads real wall-clock time (live views). The capture
// fast-forward (see the DOMContentLoaded handler) sets this to a virtual
// accumulator so millis() and frameCount advance in lockstep.
var _virtualClock = null
function createCanvas(w, h) {
_mainPG = new PGraphics(w, h)
_mainCanvas = _mainPG.canvas
_mainCanvas.id = 'defaultCanvas0'
// The 5:7 render canvas stays off-DOM. The DOM shows a square display
// canvas holding the top 1:1 crop (blitted in _presentFrame), so
// right-click copy / drag-out yields the same square the viewer sees.
_displayCanvas = document.createElement('canvas')
_displayCanvas.width = w
_displayCanvas.height = w
_displayCtx = _displayCanvas.getContext('2d')
_displayCtx.imageSmoothingEnabled = false
document.body.appendChild(_displayCanvas)
width = w; height = h
_mainPG.drawingContext.imageSmoothingEnabled = false
window.drawingContext = _mainPG.drawingContext
return { elt: _mainCanvas, canvas: _mainCanvas }
}
// Present the finished frame: copy the top square of the offscreen render
// canvas onto the DOM display canvas.
function _presentFrame() {
if (!_displayCtx || !_mainCanvas) return
_displayCtx.drawImage(_mainCanvas, 0, 0, width, width, 0, 0, width, width)
}
function createGraphics(w, h, mode) { return new PGraphics(w, h, mode) }
function pixelDensity() { return 1 }
function frameRate(fps) { _targetFPS = fps; _frameDuration = 1000 / fps }
function colorMode() { } // HSB always
function millis() { return _virtualClock === null ? performance.now() : _virtualClock }
// Global drawing proxies — delegate to main canvas
function background(...a) { _mainPG.background(...a) }
function image(...a) { _mainPG.image(...a) }
function fill(...a) { _mainPG.fill(...a) }
function noFill() { _mainPG.noFill() }
function stroke(...a) { _mainPG.stroke(...a) }
function noStroke() { _mainPG.noStroke() }
function strokeWeight(w) { _mainPG.strokeWeight(w) }
function push() { _mainPG.push() }
function pop() { _mainPG.pop() }
function translate(x, y) { _mainPG.translate(x, y) }
function rotate(a) { _mainPG.rotate(a) }
function blendMode(m) { _mainPG.blendMode(m) }
function rectMode(m) { _mainPG.rectMode(m) }
function beginShape() { _mainPG.beginShape() }
function vertex(x, y) { _mainPG.vertex(x, y) }
function endShape(m) { _mainPG.endShape(m) }
function rect(x, y, w, h) { _mainPG.rect(x, y, w, h) }
function ellipse(x, y, w, h) { _mainPG.ellipse(x, y, w, h) }
function circle(x, y, d) { _mainPG.circle(x, y, d) }
function line(x1, y1, x2, y2) { _mainPG.line(x1, y1, x2, y2) }
// ─── DRAW LOOP ───
function _loop(ts) {
if (!_running) return
if (ts - _lastFrame >= _frameDuration) {
_lastFrame = ts
try {
if (typeof draw === 'function') { frameCount++; draw(); _presentFrame() }
} catch (e) {
console.error('Draw error:', e)
}
}
requestAnimationFrame(_loop)
}
window.addEventListener('DOMContentLoaded', () => {
try {
if (typeof setup === 'function') setup()
} catch (e) {
console.error('Setup error:', e)
}
// Capture fast-forward: under the capture UA, advance straight to the
// snapshot frame synchronously instead of waiting ~4s of real-time RAF.
// The particle systems accumulate per frame, so we still run every step —
// just back-to-back with no throttle. The virtual clock ticks +one frame
// per step so millis()-driven motion (breathing, crack cycle) lands at the
// same phase a live viewer sees at the snapshot frame, deterministically
// and with no RAF jitter. The draw loop's own freeze/ready logic fires on
// the final step. Live/collector views skip this branch and animate normally.
if (typeof window.$art !== 'undefined' && window.$art.captureMode) {
const target = (typeof SNAPSHOT_FRAME === 'number') ? SNAPSHOT_FRAME : 96
_virtualClock = 0
while (frameCount < target && typeof draw === 'function') {
_virtualClock += _frameDuration
frameCount++
try { draw() } catch (e) { console.error('Draw error:', e) }
}
_presentFrame()
if (typeof DEBUG !== 'undefined' && DEBUG) {
console.log(`%c⏩ CAPTURE — fast-forwarded to frame ${frameCount}, frozen`, 'color:#00FFFF; font-weight:bold;')
}
// The canvas now holds the fully-rendered hero frame. Stop here and do
// NOT restart RAF: freezeMode gates the update/breath code but not the
// per-frame frameCount++, so any uncached display-path read of
// frameCount/millis()/random() (e.g. eye flicker, aura shimmer) would
// keep animating on subsequent ticks. Leaving the loop off makes the
// snapshot a genuinely static single frame.
_running = false
return
}
requestAnimationFrame(_loop)
})
window.addEventListener('keydown', (e) => {
key = e.key
if (e.key === ' ' || e.key === 'Escape') e.preventDefault()
if (typeof keyPressed === 'function') keyPressed()
})
</script>
<style>
html,
body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
background: #0b0b0b;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
canvas {
display: block;
width: min(100vw, 100vh) !important;
height: min(100vw, 100vh) !important;
image-rendering: pixelated;
image-rendering: crisp-edges;
-webkit-image-rendering: pixelated;
}
</style>
</head>
<body>
<!-- TL Gen Art — rendering pipeline only. Satari owns its own seeding/RNG; this
provides just the snapshot + traits DOM hooks that survive in a captured HTML
snapshot (Cloudflare Browser Rendering). No seeding/randomness here. -->
<script>
const $art = (function () {
function writeHiddenJSON(id, data) {
let el = document.getElementById(id);
if (!el) {
el = document.createElement("script");
el.type = "application/json"; // not executed, not rendered, survives in HTML
el.id = id;
(document.body || document.documentElement).appendChild(el);
}
el.textContent = JSON.stringify(data);
return el;
}
let currentTraits = null;
// { Palette: "Sunset", Layers: 5 } -> OpenSea [{trait_type, value}, ...]
function setTraits(traits) {
if (!traits || typeof traits !== "object" || Array.isArray(traits))
throw new Error("$art.setTraits: expects a plain object of { name: value }");
currentTraits = Object.keys(traits).map((k) => ({ trait_type: k, value: traits[k] }));
writeHiddenJSON("art-traits", currentTraits);
return currentTraits;
}
// Capture environment detection. Cloudflare Browser Rendering is configured to send
// this sentinel user agent; live collector views never match. Keep in sync with infra.
const CAPTURE_UA = "tl-gen-art";
const captureMode =
typeof navigator !== "undefined" &&
new RegExp(CAPTURE_UA).test(navigator.userAgent || "");
// Append a hidden #art-snapshot-ready marker so Cloudflare can waitForSelector on it.
// Optional freeze callback runs ONLY under the capture UA (live views keep animating).
function snapshot(onCapture) {
if (captureMode && typeof onCapture === "function") onCapture();
let el = document.getElementById("art-snapshot-ready");
if (!el) {
el = document.createElement("div");
el.id = "art-snapshot-ready";
el.style.display = "none";
(document.body || document.documentElement).appendChild(el);
}
return el;
}
return { setTraits, getTraits: () => currentTraits, snapshot, captureMode };
})();
window.$art = $art;
</script>
<script>
let CURRENT_SEED = null
const DEBUG = true // Set false for production — disables console logging
let freezeMode = false
// Snapshot: under the capture UA, settle to this frame, freeze, then mark ready.
// 96 frames = the same 4s of formation (at the live 24fps) a viewer perceives.
// Live views reach it in real time; the capture path fast-forwards through the
// 96 accumulating frames synchronously on a virtual clock (see the
// DOMContentLoaded handler), so both frame-count structure and millis()-driven
// motion land at exactly the 4-second phase — instantly and deterministically.
let _snapshotMarked = false
const SNAPSHOT_FRAME = 96
let bwMode = false // Driven by Etched palette trait; set in setup/regenerate.
const ASPECT_RATIO = 1.4 // 5:7 internal render — displayed as 1:1 top crop
let FORM_BREATH_HZ = 0.4
let FORM_BREATH_AMP = 10
let BREATH_DELAY_PART1 = 0.30
let BREATH_DELAY_MASK = 0.20
let FILL_ANIMATION_SPEED_MULT = 2.2
const QUALITY_TIERS = [
{ minWidth: 0, canvasW: 200, fps: 6, maxAnims: 5, gridCols: 40, noise: false },
{ minWidth: 200, canvasW: 300, fps: 12, maxAnims: 10, gridCols: 60, noise: false },
{ minWidth: 400, canvasW: 400, fps: 18, maxAnims: 15, gridCols: 75, noise: true },
{ minWidth: 600, canvasW: 500, fps: 24, maxAnims: 20, gridCols: 86, noise: true },
]
let _currentTier = null
let _tierMaxAnims = 20
let _tierGridDensity = 86
let _tierNoiseOverride = null // null = use trait value, false = force off
let _pendingTier = null // Deferred tier change — applied at start of next frame
function headCenterY() { return width / 2 } // center of head zone
let _designScale = 1 // width / 750 — used to scale pixel values to current canvas size
let _cachedPart2Offset = 0
let _cachedPart1Offset = 0
let _cachedMaskOffset = 0
let _cachedEyeOpacity = 0
let _cachedOmniaCol = null
let _cachedShaderTime = 0
let _breathStartMillis = null
function seedFromString(str) {
let h = 2166136261 >>> 0
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i)
h = Math.imul(h, 16777619)
}
h += h << 13
h ^= h >>> 7
h += h << 3
h ^= h >>> 17
h += h << 5
return h >>> 0
}
function randomU32() {
if (window.crypto && crypto.getRandomValues) {
const a = new Uint32Array(1)
crypto.getRandomValues(a)
return a[0] >>> 0
}
return (Math.random() * 0xffffffff) >>> 0
}
// ─── SEED SOURCE ───
// Priority chain:
// 1. ?tokenId=<n>&blockhash=<hex> (production render — both required)
// 2. ?seed=<uint32> (manual preview / debugging)
// 3. randomU32() (local fallback)
function readSeed() {
const params = new URLSearchParams(window.location.search)
// 1. Production render: seed deterministically from the token id + block hash
// injected via query params. Both must be present. We hash the combined
// string into the existing uint32 RNG pipeline so the same
// (tokenId, blockhash) pair always yields the same seed.
if (params.has("tokenId") && params.has("blockhash")) {
const tokenId = params.get("tokenId")
const blockhash = params.get("blockhash")
CURRENT_SEED = seedFromString(tokenId + "_" + blockhash)
return
}
// 2. Manual override via URL query.
if (params.has("seed")) {
CURRENT_SEED = Number(params.get("seed")) >>> 0
return
}
// 3. Random fallback for local preview.
CURRENT_SEED = randomU32()
}
function applySeed() {
randomSeed(CURRENT_SEED)
noiseSeed(CURRENT_SEED)
}
function mulberry32(a) {
return () => {
let t = (a += 0x6d2b79f5) | 0
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return (t ^ (t >>> 14)) >>> 0 // raw uint32, not float
}
}
function makeTraitRNG(tag) {
return mulberry32(seedFromString(tag + "_" + CURRENT_SEED))
}
function pickWeightedWith(rng, table) {
const raw = rng()
let sum = 0
for (const [, w] of table) sum += w
let r = Math.floor(raw * sum / 4294967296), acc = 0
for (const [v, w] of table) {
acc += w
if (r < acc) return v
}
return table.at(-1)[0]
}
// ─── POST-MINT MUTATIONS ───
// Pieces accumulate state after mint via the kill mechanic. Mutations are
// fetched from external storage (Transient Labs custom tooling at production;
// returns zeros when unavailable for local preview / fresh-mint state).
// Shape:
// {
// killCount: <int>, // total kills → Slain (every kill ticks +1)
// rememberingCount: <int>, // trait-inheritance kills → Remembering
// sameHouseKillCount: <int>, // in-house kills → derives Doctrine (with killCount)
// palette?, energy?, sight?, iris?, scent?, voice?, ornament?
// }
// Mutation keys accepted as individual, readable URL params (dev / sacrifice
// preview), e.g. ?killCount=1&rememberingCount=1&sameHouseKillCount=2&palette=Toxin
// Graftable traits: Palette, Aura, Energy, Sight, Iris, Scent, Voice, Ornament.
// killCount / rememberingCount / sameHouseKillCount are automatic kill outcomes,
// not stealable traits. Doctrine is derived from killCount + sameHouseKillCount (see below).
const MUT_PARAM_KEYS = ["killCount", "rememberingCount", "sameHouseKillCount",
"palette", "aura", "energy",
"sight", "iris", "scent", "voice", "ornament"]
function getMutations() {
// Priority order:
// 1. tokenData.mutations (production: on-chain values via Transient Labs)
// 2. individual ?killCount=&scent=&palette=… params (readable dev/sacrifice overrides)
// 3. fresh-mint defaults (zeros)
if (typeof tokenData !== "undefined" && tokenData && tokenData.mutations) {
return _normalizeMutations(tokenData.mutations)
}
// URL-param fallbacks (dev/tester only; production never uses these paths).
if (typeof window !== "undefined" && window.location && window.location.search) {
const usp = new URLSearchParams(window.location.search)
// 2. Flat per-trait params. _normalizeMutations coerces the string values
// ("1" -> 1 via | 0; trait strings pass through unchanged).
if (MUT_PARAM_KEYS.some((k) => usp.has(k))) {
const flat = {}
for (const k of MUT_PARAM_KEYS) {
if (usp.has(k)) flat[k] = usp.get(k)
}
return _normalizeMutations(flat)
}
}
return _normalizeMutations({})
}
// Canonical values for graftable traits, so override values are accepted
// case-insensitively (e.g. sight=mono → "Mono") — the renderer matches these
// strings exactly. Keep in sync with the trait tables in buildTraits(). Energy
// and Aura carry a few internal names the renderer keys on; MUT_PUBLISHED_TO_INTERNAL
// (below) also accepts their collector-facing names. Unknown values pass through.
const MUT_CANONICAL = {
palette: ["Flux", "Acid", "Surge", "Infra", "Toxin", "Auric", "Jazz", "Corrupt",
"Vapor", "Tropic", "Wraith", "Riptide", "Petal", "Sage", "Lilac", "Crimson",
"Abyss", "Canopy", "Amber", "Haze", "Current", "Flare", "Coast", "Flora",
"Astra", "Dune", "Polar", "Clash", "Royal", "Axis", "Blood", "Signal", "Dual",
"Stark", "Etched"],
aura: ["Calm", "Radiant", "Skyfall", "Melt", "CorruptMask", "CorruptRealm",
"Seeds", "Motes", "Prisms", "Vent", "Kintsugi"],
energy: ["Supernova", "ExpandingCircles", "ExpandingSquares", "VerticalStripes",
"Gradient", "HorizontalStripes", "WavyLines", "Sparkles", "VoronoiCells", "Shockwave"],
sight: ["Core", "Echo", "Verge", "Slate", "Vert", "Signal", "Pierce", "Cross",
"Cut", "Lune", "Mono", "Teardrop"],
iris: ["Null", "Abyss", "Fury", "Plasma", "Omnia"],
scent: ["Sealed", "Aero", "Sego", "Duos", "Jewel", "Dot", "Aexo", "Tri"],
voice: ["Still", "Prowl", "Forge", "Sunder", "Hollow", "Devour", "Viper", "Bind",
"Downfall", "Stun", "Grate", "Ease"],
ornament: ["Bare", "Links", "Amp", "Crest", "Bestia", "Root", "Gauge", "Thorns",
"Tandem", "Spike"],
// Doctrine is not an input trait — it is derived from killCount + sameHouseKillCount.
}
// Energy and Aura are published to collectors under names that differ from
// the renderer's internal values. Production passes the collector-facing
// (on-chain) names, so accept those here and translate to the internal
// names the renderer keys on. Internal names still resolve via MUT_CANONICAL.
const MUT_PUBLISHED_TO_INTERNAL = {
energy: {
Pulse: "ExpandingCircles", Lattice: "ExpandingSquares", Beam: "VerticalStripes",
Bleed: "Gradient", Strata: "HorizontalStripes", Frequency: "WavyLines",
Spark: "Sparkles", Plasma: "VoronoiCells",
},
aura: { Broken: "CorruptMask", Fragments: "CorruptRealm" },
}
function _canonMut(trait, value) {
if (typeof value !== "string") return value
const aliases = MUT_PUBLISHED_TO_INTERNAL[trait]
if (aliases) {
const hit = Object.keys(aliases).find((k) => k.toLowerCase() === value.toLowerCase())
if (hit) return aliases[hit]
}
const list = MUT_CANONICAL[trait]
if (!list) return value
const lc = value.toLowerCase()
return list.find((v) => v.toLowerCase() === lc) || value
}
function _normalizeMutations(m) {
return {
// killCount / rememberingCount / sameHouseKillCount are the only accepted
// counter params (production sends exactly these). String values from
// URLSearchParams ("2") coerce to int via `| 0`; absent → 0.
slain: (m.killCount | 0) >>> 0,
remembering: (m.rememberingCount | 0) >>> 0,
sameHouseKillCount: (m.sameHouseKillCount | 0) >>> 0,
palette: _canonMut("palette", m.palette) || null,
aura: _canonMut("aura", m.aura) || null,
energy: _canonMut("energy", m.energy) || null,
sight: _canonMut("sight", m.sight) || null,
iris: _canonMut("iris", m.iris) || null,
scent: _canonMut("scent", m.scent) || null,
voice: _canonMut("voice", m.voice) || null,
ornament: _canonMut("ornament", m.ornament) || null,
}
}
// Doctrine is derived from kill counts (not a graftable trait). With no kills it
// is Idle. Otherwise it reflects the balance between out-of-house and in-house
// kills: diff = differentHouseKillCount - sameHouseKillCount.
// diff >= +5 → Crusader, >= +2 → Devoted, |diff| < 2 → Sovereign,
// <= -2 → Dissenting, <= -5 → Heretic.
function doctrineFromKills(killCount, sameHouseKillCount) {
if (killCount <= 0) return "Idle"
const differentHouseKillCount = killCount - sameHouseKillCount
const diff = differentHouseKillCount - sameHouseKillCount
if (diff >= 5) return "Crusader"
if (diff >= 2) return "Devoted"
if (diff <= -5) return "Heretic"
if (diff <= -2) return "Dissenting"
return "Sovereign"
}
// Apply mutation overlay to a freshly-built traits object. Respects compatibility
// rules: incompatible inheritances are marked "(inert)" in the trait field so
// they show in metadata but don't break render assumptions.
function applyMutations(result, mut) {
result.slain = mut.slain
result.remembering = mut.remembering
// Palette: re-graft as-is (including Etched).
// Palette graft: when adopting Etched, the underlying _basePalette stays as
// the killer's own original base (preserves their original ash beneath the
// new Etched skin). When grafting a non-Etched palette, the killer's
// _basePalette is cleared (since they're no longer Etched).
if (mut.palette) {
if (mut.palette === "Etched") {
// Adopt Etched override. _basePalette = whatever killer already had as
// their underlying palette: either their existing _basePalette (if they
// were already Etched), or their current paletteName (if they weren't).
if (!result._basePalette) {
result._basePalette = result.paletteName
}
result.paletteName = "Etched"
} else {
// Adopt a non-Etched palette: clear any Etched state, set new palette.
result._basePalette = null
result.paletteName = mut.palette
}
}
// Energy → fillVariant
if (mut.energy) result.fillVariant = mut.energy
// Sight, Iris, Scent, Voice: free swaps
if (mut.sight) result.sight = mut.sight
if (mut.iris) result.iris = mut.iris
if (mut.scent) result.scent = mut.scent
// Aura: free swap. Note: the Helios+Radiant override is a renderer invariant —
// grafting Radiant onto a Helios killer converts to Seeds or Calm here too,
// matching mint-time behavior.
if (mut.aura) {
let newAura = mut.aura
if (result.displayedArchetype === "Helios" && newAura === "Radiant") {
// Deterministic flip — use the seed's existing entropy via a fresh tag.
const _r = makeTraitRNG("auraGraftHeliosRadiant")
newAura = _r() < 2147483648 ? "Seeds" : "Calm"
}
result.aura = newAura
}
if (mut.voice) result.voice = mut.voice
// Doctrine: derived from kill counts (Idle when no kills; otherwise the balance
// of out-of-house vs in-house kills — see doctrineFromKills).
result.doctrine = doctrineFromKills(mut.slain, mut.sameHouseKillCount)
// Ornament: respect bestiaCompatible / Vanta-Crest rules
if (mut.ornament) {
const bestiaCompatible = ["Havoc", "Sentinel", "Apex", "Rogue", "Riven", "Guard", "Nomad", "Blade", "Prime"]
if (mut.ornament === "Bestia" && !bestiaCompatible.includes(result.displayedArchetype)) {
result.ornament = "Bestia (inert)"
} else if (mut.ornament === "Crest" && result.displayedArchetype === "Vanta") {
result.ornament = "Crest (inert)"
} else {
result.ornament = mut.ornament
}
}
return result
}
function buildTraits() {
const rArch = makeTraitRNG("archetype")
const rPal = makeTraitRNG("palette")
const rEye = makeTraitRNG("sight")
const rMou = makeTraitRNG("voice")
const rNose = makeTraitRNG("nose")
const rOrn = makeTraitRNG("ornament")
const rCut = makeTraitRNG("cutouts")
const rRealm = makeTraitRNG("realm")
const rOr = makeTraitRNG("orient")
const rEdge = makeTraitRNG("edgeStyle")
const rEyeColor = makeTraitRNG("eyeColor")
const rOrigin = makeTraitRNG("originTopology")
const rGrowth = makeTraitRNG("growthProfile")
const rBlend = makeTraitRNG("blendSchema")
const rSize = makeTraitRNG("sizeEnvelope")
const rSpawn = makeTraitRNG("spawnRhythm")
const rFillVariant = makeTraitRNG("fillVariant")
const rOrbit = makeTraitRNG("orbitingElements")
const rOrbitSpeed = makeTraitRNG("orbitalSpeed")
const rWavyDir = makeTraitRNG("wavyDirection")
const rExpandRotation = makeTraitRNG("expandingRotation")
const rExpandPersist = makeTraitRNG("expandingPersist")
const rApplyNoise = makeTraitRNG("applyNoise")
const rInkMode = makeTraitRNG("inkMode")
const rSymbolSize = makeTraitRNG("symbolSize")
const rSpacecraft = makeTraitRNG("spacecraft")
let paletteName = pickWeightedWith(rPal, [
["Flux", 10],
["Acid", 10],
["Surge", 10],
["Infra", 10],
["Toxin", 10],
["Auric", 10],
["Jazz", 10],
["Corrupt", 10],
["Vapor", 10],
["Tropic", 10],
["Wraith", 10],
["Riptide", 10],
["Petal", 10],
["Sage", 10],
["Lilac", 10],
["Crimson", 10],
["Abyss", 10],
["Canopy", 10],
["Amber", 10],
["Haze", 10],
["Current", 10],
["Flare", 10],
["Coast", 10],
["Flora", 10],
["Astra", 10],
["Dune", 10],
["Polar", 10],
["Clash", 10],
["Royal", 10],
["Axis", 10],
["Bl