Learn Creative Coding (#29) - Building for the Blockchain: Output as SVG and PNG

Learn Creative Coding (#29) - Building for the Blockchain: Output as SVG and PNG

cc-banner

We've spent this whole phase building a generative art toolkit. Seeded randomness in episode 24, composition algorithms in 25, typography in 26, texture in 27, color systems last episode. All of those pieces fit together into something you can actually ship. But ship where? And in what format?

This episode is about getting your art out of the browser and into the world. Specifically, we're looking at what it takes to make generative art mint-ready for platforms like fxhash, Art Blocks, or even your own smart contract. But honestly -- even if you never mint an NFT, the discipline of building for the blockchain makes your code better. Deterministic, self-contained, resolution-independent, portable. These are good engineering habits regardless of where the output ends up.

I got into this topic because I kept seeing beautiful generative pieces on fxhash and wondering how they handled things like determinism across browsers, responsive rendering, and trait metadata. Turns out the constraints are pretty strict, and working within them teaches you a lot about how your code actually behaves when you can't control the enviroment.

What the blockchain demands

Blockchain generative art has strict technical requirements that go beyond "it looks good on my laptop":

  1. Deterministic: same input produces the same output, every time, on every machine, in every browser
  2. Self-contained: no external dependencies, no CDN links, no API calls, no fetch requests
  3. Responsive: renders correctly at any resolution the collector's screen supports
  4. Fast: should render in seconds, not minutes
  5. Single HTML file: everything bundled together -- HTML, CSS, JavaScript, all in one file

Each of these constraints pushes your code in a specific direction. And they stack -- being deterministic AND responsive AND self-contained at the same time is harder than any one of those alone. Let's work through them one by one.

Deterministic rendering

We covered seeded PRNGs in episode 24. That's the foundation. But determinism goes deeper than just replacing Math.random():

// BAD - non-deterministic
let x = Math.random() * width;
let color = `hsl(${Math.random() * 360}, 70%, 50%)`;

// GOOD - seeded, reproducible
let rng = createRandom(seed);
let x = rng() * width;
let color = `hsl(${rng() * 360}, 70%, 50%)`;

That part you already know. But here are the subtle traps that will bite you even with a seeded PRNG:

Object iteration order. for...in on plain objects doesn't guarantee order in all JavaScript engines. If your algorithm iterates an object's keys and the order affects output, you'll get different results in Chrome vs Firefox. Use arrays or Map instead. Arrays have guaranteed order. Maps iterate in insertion order.

Floating point accumulation. 0.1 + 0.2 !== 0.3 in JavaScript. Everyone knows this fact but people forget it matters for art. If you're comparing float values for equality anywhere in your algorithm (like "does this noise value cross a threshold"), tiny floating point differences between browsers can cascade into completly different outputs. Use epsilon comparisons or round to integers where precision matters.

Canvas anti-aliasing. Different browsers anti-alias Canvas drawing slightly differently. A circle at a sub-pixel position will have different edge pixels in Chrome vs Firefox. For most art this is invisible, but if your algorithm reads back canvas pixels as input (like we did in the typography episode for text-to-particle conversion), those tiny differences can matter. SVG doesn't have this problem -- it's math all the way down.

Font rendering. System fonts look different on every OS. A paragraph of text in Arial on Windows has different line breaks than the same text on Mac. If your art uses text as a visual element, either bundle a web font as base64 or avoid text entirely. Or use the pixel-sampling approach from episode 26 where you render text once to an offscreen canvas and only use the positions -- the exact rendering of the font doesn't matter because you're only reading where pixels are, not displaying the font itself.

// paranoia check - verify your PRNG is deterministic
function verifyDeterminism(seed) {
  let rng1 = createRandom(seed);
  let rng2 = createRandom(seed);

  let match = true;
  for (let i = 0; i < 1000; i++) {
    if (rng1() !== rng2()) {
      match = false;
      break;
    }
  }

  console.log('Deterministic:', match);
  return match;
}

Run this before anything else. If your PRNG doesn't pass this test, nothing downstream will be deterministic either.

The fxhash template

fxhash is probably the most accessible platform for generative NFTs right now (Tezos blockchain, much lower fees than Ethereum). Their template gives you two things: a unique hash string per token, and a seeded PRNG based on that hash. Here's what the template looks like:

<!DOCTYPE html>
<html>
<head>
  <style>
    html, body { margin: 0; padding: 0; overflow: hidden; }
    canvas { display: block; }
  </style>
  <script>
    // fxhash injects these at mint time:
    // fxhash - a unique string hash per token
    // fxrand() - seeded PRNG based on fxhash

    // for local testing, simulate them:
    if (typeof fxhash === 'undefined') {
      var fxhash = 'oo' + Array.from({length: 49}, () =>
        'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
        [Math.floor(Math.random() * 62)]
      ).join('');
    }
    if (typeof fxrand === 'undefined') {
      var fxrand = (function(hash) {
        var seed = Array.from(hash).reduce((a, c) =>
          ((a << 5) - a + c.charCodeAt(0)) | 0, 0
        );
        var a = seed, b = seed, c = seed, d = seed;
        return function() {
          a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0;
          var t = (a + b) | 0;
          a = b ^ b >>> 9;
          b = c + (c << 3) | 0;
          c = (c << 21 | c >>> 11);
          d = d + 1 | 0;
          t = t + d | 0;
          c = c + t | 0;
          return (t >>> 0) / 4294967296;
        };
      })(fxhash);
    }
  </script>
</head>
<body>
  <canvas id="canvas"></canvas>
  <script>
    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');

    function resize() {
      let size = Math.min(window.innerWidth, window.innerHeight);
      canvas.width = size;
      canvas.height = size;
    }

    resize();
    window.addEventListener('resize', () => {
      resize();
      render();
    });

    function render() {
      let W = canvas.width;
      let H = canvas.height;

      // your generative art code goes here
      // use fxrand() instead of Math.random()

      // signal to fxhash that rendering is complete
      if (typeof fxpreview === 'function') fxpreview();
    }

    render();
  </script>
</body>
</html>

Key points: fxrand() replaces ALL randomness. fxpreview() tells the platform to capture the preview image (this is what shows up in the marketplace thumbnail). Everything goes in one HTML file. And the resize listener means it adapts to any screen size.

Notice the local simulation code at the top -- when you're testing on your own machine, fxhash and fxrand don't exist yet (they get injected by the platform at mint time). The simulation creates a random hash and a simple sfc32 PRNG so you can develop locally. In production, the platform replaces those with the real values.

Resolution independence

Your art should look equally good at 400x400 on a phone and 4000x4000 on a 4K monitor. The trick: never use fixed pixel values. Scale everything relative to canvas dimensions:

function render() {
  let W = canvas.width;
  let H = canvas.height;
  let S = Math.min(W, H);  // shortest side as reference unit

  // BAD - fixed pixels, looks tiny on 4K, huge on mobile
  ctx.lineWidth = 3;
  ctx.arc(300, 300, 100, 0, Math.PI * 2);

  // GOOD - relative to canvas size
  ctx.lineWidth = S * 0.004;
  ctx.arc(W * 0.5, H * 0.5, S * 0.15, 0, Math.PI * 2);
}

Everything multiplied by S (or W, H). A circle that's 15% of the canvas is 15% at any resolution. A line that's 0.4% of the canvas width stays proportional whether the canvas is 300px or 3000px. This is the same proportional thinking we use in responsive web design, just applied to art.

The Math.min(W, H) as reference unit is important. If you use W for everything and the canvas is wider than it is tall, vertical elements will be stretched. Using the shortest side means your art fits within a square regardless of aspect ratio. Most generative art platforms render in a square viewport anyway.

Re-rendering on resize

When the window resizes, you need the EXACT same art at the new size. That means re-seeding your PRNG to the original state. If you just call fxrand() again after resize, the PRNG has already advanced past its initial state and you'll get different random values -- which means different art:

// store the initial state
let initialSeed;

function init() {
  initialSeed = fxhash;
}

function render() {
  // CRITICAL: reset PRNG to initial state before every render
  let rng = createRandom(hashToSeed(initialSeed));

  let W = canvas.width;
  let H = canvas.height;

  // now all random calls produce the same sequence
  let numShapes = Math.floor(rng() * 50) + 20;

  ctx.fillStyle = '#0d1b2a';
  ctx.fillRect(0, 0, W, H);

  for (let i = 0; i < numShapes; i++) {
    let x = rng() * W;   // scales with canvas
    let y = rng() * H;
    let r = rng() * W * 0.05;
    let hue = rng() * 360;

    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fillStyle = `hsl(${hue}, 70%, 60%)`;
    ctx.fill();
  }
}

Same seed, same random sequence, same relative positions. The circles are at 73% across, 42% down (or wherever the RNG puts them) regardless of whether the canvas is 400px or 4000px. Resolution independence + deterministic re-seeding = the art looks identical at any size.

The hashToSeed function converts the string hash to a numeric seed. A simple version:

function hashToSeed(hash) {
  let seed = 0;
  for (let i = 0; i < hash.length; i++) {
    seed = ((seed << 5) - seed + hash.charCodeAt(i)) | 0;
  }
  return seed;
}

This is the same kind of hash-to-integer conversion that fxhash uses internally. It's not cryptographically secure but it doesn't need to be -- we just need a deterministic mapping from string to number.

SVG output

Canvas gives you pixels. SVG gives you vectors. For pen plotters, high-quality prints, and collectors who want infinite zoom, SVG output is valuable. And it's resolution-independent by nature -- no need to worry about scaling because SVG coordinates are abstract units, not pixels.

Build a simple SVG builder class:

class SVGBuilder {
  constructor(width, height) {
    this.width = width;
    this.height = height;
    this.elements = [];
  }

  circle(cx, cy, r, fill, stroke, strokeWidth) {
    this.elements.push(
      `<circle cx="${cx}" cy="${cy}" r="${r}" ` +
      `fill="${fill || 'none'}" stroke="${stroke || 'none'}" ` +
      `stroke-width="${strokeWidth || 1}"/>`
    );
  }

  line(x1, y1, x2, y2, stroke, strokeWidth) {
    this.elements.push(
      `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" ` +
      `stroke="${stroke || '#000'}" stroke-width="${strokeWidth || 1}"/>`
    );
  }

  rect(x, y, w, h, fill) {
    this.elements.push(
      `<rect x="${x}" y="${y}" width="${w}" height="${h}" ` +
      `fill="${fill || 'none'}"/>`
    );
  }

  path(d, fill, stroke, strokeWidth) {
    this.elements.push(
      `<path d="${d}" fill="${fill || 'none'}" ` +
      `stroke="${stroke || '#000'}" stroke-width="${strokeWidth || 1}"/>`
    );
  }

  toString() {
    return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 ${this.width} ${this.height}">
${this.elements.join('\n')}
</svg>`;
  }

  download(filename) {
    let blob = new Blob([this.toString()], { type: 'image/svg+xml' });
    let url = URL.createObjectURL(blob);
    let link = document.createElement('a');
    link.download = filename || 'artwork.svg';
    link.href = url;
    link.click();
    URL.revokeObjectURL(url);
  }
}

Nothing fancy here -- just string concatenation building valid SVG markup. The viewBox attribute makes it resolution-independent. The download() method creates a temporary blob URL and triggers a file download. Press a key, get a vector file. Clean.

Dual output: Canvas and SVG from the same algorithm

The real trick is writing your algorithm once and outputting to BOTH Canvas and SVG simultaneously. Same random calls, same positions, same colors -- but one version is pixels and the other is vectors:

function render(ctx, svg, W, H, rng) {
  let bg = '#0d1b2a';

  // canvas background
  ctx.fillStyle = bg;
  ctx.fillRect(0, 0, W, H);

  // svg background
  svg.rect(0, 0, W, H, bg);

  let count = Math.floor(rng() * 30) + 20;

  for (let i = 0; i < count; i++) {
    let x = rng() * W;
    let y = rng() * H;
    let r = rng() * W * 0.03;
    let hue = rng() * 360;
    let color = `hsl(${hue}, 70%, 60%)`;

    // canvas version
    ctx.beginPath();
    ctx.arc(x, y, r, 0, Math.PI * 2);
    ctx.fillStyle = color;
    ctx.fill();

    // svg version
    svg.circle(x, y, r, color);
  }
}

// usage
let svgDoc = new SVGBuilder(800, 800);
let rng = createRandom(hashToSeed(fxhash));
render(ctx, svgDoc, 800, 800, rng);

// press 's' to save SVG
document.addEventListener('keydown', (e) => {
  if (e.key === 's') {
    e.preventDefault();
    svgDoc.download(`art-${fxhash.substring(0, 8)}.svg`);
  }
});

Both outputs use the same RNG in the same order, so the same seed produces the same composition in both formats. The canvas version is what the collector sees in their browser. The SVG version is what they download for printing or plotting. Two outputs, one algorithm, zero divergence. Makes sense, right?

The only awkward part is that every drawing operation needs to be written twice -- once for Canvas API, once for the SVG builder. For complex algorithms this gets verbose. You could write an abstraction layer that wraps both targets behind a common interface, but honestly for most generative pieces the explicit dual-write is fine. You see exactly what's happening.

PNG export for high-res prints

Sometimes collectors want a high-resolution PNG instead of (or in addition to) SVG. Canvas can do this:

function exportPNG(canvas, scale, filename) {
  // create a high-res offscreen canvas
  let hiRes = document.createElement('canvas');
  hiRes.width = canvas.width * scale;
  hiRes.height = canvas.height * scale;
  let hiCtx = hiRes.getContext('2d');

  // scale the context so all drawing operations are enlarged
  hiCtx.scale(scale, scale);

  // re-render at high resolution
  let rng = createRandom(hashToSeed(fxhash));
  renderToContext(hiCtx, canvas.width, canvas.height, rng);

  // download
  let link = document.createElement('a');
  link.download = filename || 'artwork.png';
  link.href = hiRes.toDataURL('image/png');
  link.click();
}

// press 'p' for 4x resolution PNG
document.addEventListener('keydown', (e) => {
  if (e.key === 'p') {
    exportPNG(canvas, 4, `art-${fxhash.substring(0, 8)}.png`);
  }
});

A 4x scale on an 800x800 canvas gives you a 3200x3200 PNG -- good enough for a decent print. The key is hiCtx.scale(scale, scale) which makes all Canvas drawing commands automatically larger without changing the coordinates in your code. Your algorithm still draws at 800x800 coordinates, but the context scales everything up. Same art, more pixels.

Watch out for memory though. An 8x scale on a 1000x1000 canvas creates an 8000x8000 canvas, which is 256 million pixels. That's about 1GB of RAM for the pixel buffer alone. Most browsers handle 4x fine but 8x can crash on mobile devices.

Traits and metadata

NFT platforms display traits -- properties that describe the piece and determine rarity. You define them, the platform displays them, and collectors filter by them. From a code perspective, traits are just deterministic function outputs derived from the seed:

function weightedChoice(rng, options) {
  let total = options.reduce((sum, opt) => sum + opt.weight, 0);
  let roll = rng() * total;
  let cumulative = 0;

  for (let opt of options) {
    cumulative += opt.weight;
    if (roll < cumulative) return opt.value;
  }

  return options[options.length - 1].value;
}

function generateTraits(rng) {
  let traits = {};

  traits.palette = weightedChoice(rng, [
    { value: 'Sunset', weight: 3 },
    { value: 'Ocean', weight: 3 },
    { value: 'Forest', weight: 2 },
    { value: 'Neon', weight: 1 },
    { value: 'Monochrome', weight: 1 },
  ]);

  traits.density = weightedChoice(rng, [
    { value: 'Sparse', weight: 2 },
    { value: 'Medium', weight: 5 },
    { value: 'Dense', weight: 3 },
  ]);

  traits.composition = weightedChoice(rng, [
    { value: 'Grid', weight: 3 },
    { value: 'Spiral', weight: 2 },
    { value: 'Flow Field', weight: 3 },
    { value: 'Subdivision', weight: 2 },
  ]);

  traits.hasGrain = rng() > 0.4 ? 'Yes' : 'No';

  return traits;
}

// report traits to fxhash
let traits = generateTraits(rng);
if (typeof window.$fxhashFeatures !== 'undefined') {
  window.$fxhashFeatures = traits;
}

Trait weights determine rarity. "Neon" palette appears roughly 10% of the time (weight 1 out of total 10) -- making it rarer and potentially more desirable to collectors. "Medium" density appears 50% of the time (weight 5 out of 10) -- common, expected, the baseline. Designing these distributions is a creative decision. How rare should rare be? Too rare and nobody gets the cool ones. Too common and there's nothing special to chase.

The weightedChoice function is essentially the same pattern as R.weighted from the palette system in episode 28. We keep coming back to weighted random selection because it's the core mechanism for controlling variety in generative systems.

Notice that traits are generated BEFORE the art. This is important -- the trait values drive the rendering. If "Neon" palette is selected, the rendering code uses neon colors. If "Flow Field" composition is selected, the layout uses a flow field. The traits aren't a description of the output; they're instructions for the algorithm.

Bundling everything into one file

For submission to any generative art platform, everything goes in one HTML file. No imports, no fetch calls, no external fonts (unless base64-encoded), no CDN links. The file must work offline. If the CDN goes down in 2035, your art must still render.

<!DOCTYPE html>
<html>
<head>
  <style>
    html, body { margin: 0; overflow: hidden; background: #000; }
    canvas { display: block; }
  </style>
</head>
<body>
  <canvas id="c"></canvas>
  <script>
    // fxhash boilerplate (local simulation)...
    // your seeded PRNG...
    // your noise function (from episode 12)...
    // your palette system (from episode 28)...
    // your texture functions (from episode 27)...
    // your render function...
    // everything in one script block
  </script>
</body>
</html>

If you built your noise function from scratch in episode 12 instead of using a library, you can just paste it in. Same for the seeded PRNG from episode 24, the color system from last episode, the texture overlay from episode 27. All those pieces we built without external dependencies are paying off now. A generative art piece made from our toolkit has zero external imports because we wrote everything from scratch.

That's one of the hidden benefits of this whole series approach. Every tool we built by hand -- Perlin noise, PRNGs, easing functions, color converters -- is now a self-contained block of code you can inline into a single file. No npm, no bundler, no build step. Just code that works.

Testing checklist

Before submitting to any platform, run through this systematically:

// automated test: render 50 different seeds, check for errors
function stressTest() {
  let errors = [];

  for (let seed = 0; seed < 50; seed++) {
    try {
      let rng = createRandom(seed);
      let traits = generateTraits(rng);

      // verify all traits have valid values
      if (!traits.palette || !traits.density) {
        errors.push(`Seed ${seed}: missing traits`);
      }

      // render to offscreen canvas
      let test = document.createElement('canvas');
      test.width = 400;
      test.height = 400;
      let testCtx = test.getContext('2d');
      renderToContext(testCtx, 400, 400, createRandom(seed));

      // check it's not blank
      let data = testCtx.getImageData(0, 0, 400, 400).data;
      let nonBlack = 0;
      for (let i = 0; i < data.length; i += 16) {
        if (data[i] > 0 || data[i+1] > 0 || data[i+2] > 0) nonBlack++;
      }
      if (nonBlack < 10) {
        errors.push(`Seed ${seed}: nearly blank output`);
      }

    } catch (e) {
      errors.push(`Seed ${seed}: ${e.message}`);
    }
  }

  console.log(errors.length === 0 ? 'All 50 seeds passed' : errors);
}

The manual checklist on top of that:

  • Renders identically on Chrome, Firefox, Safari
  • Renders identically at 400x400 and 2000x2000
  • No Math.random() calls anywhere -- search your code, grep for it
  • No external resources -- everything inline, no fetch, no import
  • No console.log left in production code
  • fxpreview() called after rendering completes
  • Traits are correctly reported via $fxhashFeatures
  • File size under platform limits (usually 15-30MB, but smaller is better)
  • Renders in under 10 seconds on a midrange laptop

That stress test with 50 seeds catches the edge cases that manual testing misses. Maybe seed 37 produces zero shapes because the RNG happened to generate all small values. Maybe seed 12 creates a division by zero in your layout algorithm. You won't find these by testing three seeds. Test fifty. Test a hundred if you're paranoid (I usually am).

Art Blocks vs fxhash vs rolling your own

Quick comparison of the major platforms since people always ask:

fxhash (Tezos): lowest barrier to entry. Any artist can publish. Mint fees are minimal. Community is active and supportive. Template is straightforward. Downside: Tezos has less collector attention than Ethereum.

Art Blocks (Ethereum): curated. You apply, they review your work, and if accepted your collection goes live. Higher mint fees but much larger collector base. The prestige factor is real -- Art Blocks is where Fidenza, Ringers, and Archetype live. Getting in is hard.

Your own contract: maximum control, maximum effort. You deploy your own smart contract, build your own minting page, handle your own marketing. Makes sense if you have an existing audience or want specific on-chain mechanics. For most artists starting out, this is overkill.

The code techniques in this episode work for all three. The template differs slightly (Art Blocks has its own hash injection system, and custom contracts need their own token-to-hash mapping) but the core principles -- determinism, self-containment, responsive rendering -- are universal.

't Komt erop neer...

  • Blockchain art must be deterministic, self-contained, and responsive -- no exceptions
  • Replace ALL Math.random() with a seeded PRNG, and watch for subtle non-determinism traps (object iteration, float comparison, font rendering)
  • The fxhash template gives you fxrand() and fxhash -- simulate them locally for development
  • Scale everything relative to canvas dimensions for resolution independence -- never use fixed pixel values
  • Re-seed the PRNG on resize to get identical output at any resolution
  • Build an SVG builder for vector output alongside Canvas rendering
  • Write your algorithm once, output to both Canvas and SVG simultaneously
  • Export high-res PNGs by rendering to a scaled offscreen canvas
  • Define traits with weighted probabilities for rarity distribution
  • Bundle everything in a single HTML file -- no external dependencies whatsoever
  • Stress test with 50+ seeds before submitting -- edge cases hide in the long tail

Everything we built across this phase -- seeds, composition, typography, texture, color systems -- can be inlined into a single self-contained file. That's the payoff of building from scratch instead of relying on libraries. Next episode we're putting it all together: a complete generative collection from start to finish, with traits, variations, and output formats all wired up. The grand finale of Phase 4 :-)

Sallukes! Thanks for reading.

X

@femdev



0
0
0.000
0 comments