SnapDOM's afterRender hook saves the clone it just rendered; afterClone swaps that saved clone back in on the next capture and appends a fresh bullet-hole marker to it. The live target element in the DOM is never touched — the accumulation lives entirely in the plugin's closure.
What you're looking at
Ten shots, a swaying crosshair, a ring-scoring target — it looks like a canvas-based arcade game. It isn't. The board is a single <img> that gets replaced after every shot with a brand-new snapdom.toPng() result. Nothing in the page's own code remembers where the previous holes landed — that memory lives entirely inside a plugin.
Try it
Aim, click to fire. The reticle drifts and kicks after every shot, and the drift grows as you run out of ammo. Each shot triggers one real SnapDOM capture — the marker you see appear is the actual exported PNG, not an overlay.
Ready?
10 shots. The reticle sways and kicks with every shot — compensate for the drift.
The download button uses the <img> that the last snapdom.toPng() call returned, directly — no second capture needed.
How it works
A hidden source element, never the visible board
A 340×340
<div>off-screen holds one SVG target built once from a ring definition. This — not the board you see — is what SnapDOM actually captures.The shot is scored locally, against where the reticle really was
On click, the reticle's actual position (after sway and recoil, not the raw mouse position) is converted to a percentage and scored against the ring radii. That
{ xPct, yPct, color }is handed toplugin.addHit().addHit()appends a marker to the plugin's saved cloneIf this is the very first shot, the plugin clones the live source once to start from. Every call after that appends a jagged, randomized torn-paper hole — positioned in percentage coordinates so it survives the clone → render round trip — to the clone it already has.
snapdom.toPng()runs again, plugin includedThe demo doesn't touch the target's own DOM at all. It just calls
snapdom.toPng(target, { plugins: [plugin] })again, same as the first shot.afterCloneswaps in the saved cloneInstead of letting SnapDOM clone the pristine (hole-free) source, the plugin replaces
ctx.clonewith the clone it's been building — which already carries every hole from every previous shot.afterRendercheckpoints the resultAfter SnapDOM finishes rendering, the plugin clones what it just produced and holds onto it as the new baseline for the next shot.
The output
<img>replaces the board's pictureThe resulting PNG is inserted directly into the board. The download link just points at that same
<img>.src— it becomes the download source without a second capture.
Why this needed a plugin
SnapDOM's default behavior is stateless by design: every call re-clones the live element and renders exactly what it looks like right now. That's correct for a screenshot tool and wrong for a game that needs the opposite — remember every hole that's already there and add one more.
There's no snapdom() option for "keep what was captured last time," because accumulation isn't a rendering concern, it's application state — and the plugin system is exactly the seam SnapDOM exposes for that. The afterClone and afterRender hooks fire on every capture with access to the same closure, so a plugin can hold a private savedClone variable across calls that plain application code has no way to inject into SnapDOM's clone → render pipeline. Two hooks and one closure variable turn a stateless capture function into a stateful one.
Copy-paste example
The plugin factory — the persistent-clone technique works for any "keep building on the last capture" use case, not just bullet holes:
function createHitboardPlugin (sourceEl) {
let savedClone = null
return {
name: 'hitboard',
afterClone (ctx) {
if (savedClone) ctx.clone = savedClone.cloneNode(true)
},
afterRender (ctx) {
savedClone = ctx.clone.cloneNode(true)
},
addHit (xPct, yPct, color) {
if (!savedClone) savedClone = sourceEl.cloneNode(true)
if (getComputedStyle(savedClone).position === 'static') {
savedClone.style.position = 'relative'
}
const hit = document.createElement('div')
hit.style.cssText = `
position: absolute; left: ${xPct}%; top: ${yPct}%;
width: 24px; height: 24px; margin: -12px 0 0 -12px;
pointer-events: none;
`
hit.innerHTML = bulletHoleMarkup(color)
savedClone.appendChild(hit)
},
reset () { savedClone = null }
}
}
And the usage — score the shot, tell the plugin, capture again:
const plugin = createHitboardPlugin(target)
function shoot (xPct, yPct) {
const { color } = scoreAt(xPct, yPct)
plugin.addHit(xPct, yPct, color)
return snapdom.toPng(target, { plugins: [plugin] })
}
// on restart: plugin = createHitboardPlugin(target) — a fresh closure,
// not plugin.reset(), so a stale reference from the finished game can't
// leak a hole into the new one.
Limits and things to watch
addHit() lazily initializes savedClone from the live source the first time it's called, so the plugin never needs to be primed before the first shot. reset() clears it back to null, which lets the very next capture re-clone the pristine source from scratch — a fresh game, fresh holes. That's a simpler shape than Paint Canvas's skipNext flag: this demo never needs to "skip one capture" mid-stream, because a restart is a hard state reset rather than a stroke boundary the plugin has to detect and route around.
Build your own plugin
Every hook this demo uses — afterClone, afterRender, plus defineExports for adding new export formats — is documented in the plugin spec.