| name | scratch-costume-insert |
| description | Add custom SVG or PNG costumes to sprites programmatically via the Scratch VM storage API. Enables creating original characters, shapes, or artwork without using the paint editor UI. |
| license | MIT |
Scratch Custom Costume Injection
This skill provides a pattern for programmatically adding custom costumes (SVG or PNG) to Scratch sprites. Instead of manually drawing in the paint editor, you create image data and inject it through the VM's storage API.
The Problem
When building Scratch projects programmatically, the default sprite (cat) costume is often unsuitable. The paint editor requires complex UI interactions (clicks, drags, color picks) that are fragile and slow to automate. There is no built-in block or simple API to add a custom-drawn costume from code.
The Solution
Use the Scratch VM's storage API to create an image asset (SVG or PNG), wrap it in a costume object, and add it to the target sprite. This bypasses the paint editor entirely and works reliably in a single page.evaluate() call.
1. Create image data (SVG string or PNG binary)
2. Encode it as an asset via vm.runtime.storage.createAsset()
3. Build a costume descriptor object
4. Call vm.addCostume() to register it
5. Switch to the new costume with target.setCostume()
Prerequisites
This skill builds on the scratch-project-edit skill. Ensure:
- The
playwright-cli skill is installed and a browser session is open
- The Scratch editor is open and
window.vm is available
Implementation — SVG Costume
Use playwright-cli run-code to inject an SVG costume:
playwright-cli run-code "$(cat <<'EOF'
async page => await page.evaluate(async () => {
const vm = window.vm;
const target = vm.editingTarget;
const storage = vm.runtime.storage;
// Step 1: Define SVG markup
const svgString = `
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="-50 -50 100 100">
<circle cx="0" cy="0" r="40" fill="#4CAF50" stroke="#2E7D32" stroke-width="3"/>
<circle cx="-12" cy="-10" r="5" fill="white"/>
<circle cx="12" cy="-10" r="5" fill="white"/>
<circle cx="-12" cy="-10" r="2.5" fill="#333"/>
<circle cx="12" cy="-10" r="2.5" fill="#333"/>
<path d="M -10,10 Q 0,20 10,10" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"/>
</svg>`;
// Step 2: Create a storage asset
const asset = storage.createAsset(
storage.AssetType.ImageVector, // SVG type
storage.DataFormat.SVG, // SVG format
new TextEncoder().encode(svgString), // SVG as UTF-8 bytes
null, // auto-generate asset ID (md5)
true // generate md5 from content
);
// Step 3: Build costume descriptor
const costume = {
name: 'my-costume',
dataFormat: 'svg',
asset: asset,
md5: asset.assetId + '.svg',
assetId: asset.assetId,
bitmapResolution: 1,
rotationCenterX: 50, // center X in pixels (width / 2)
rotationCenterY: 50 // center Y in pixels (height / 2)
};
// Step 4: Add costume and switch to it
await vm.addCostume(costume.md5, costume);
const costumeIndex = target.getCostumes().length - 1;
target.setCostume(costumeIndex);
return 'Costume added: ' + costume.name;
})
EOF
)"
Implementation — PNG Costume
From a Local File
Use playwright-cli run-code to read a local PNG file and inject it. The file is read via Node's fs (available in the run-code outer scope), converted to base64, and passed into the page.
playwright-cli run-code "$(cat <<'EOF'
async (page) => {
const fs = require('fs');
const pngPath = '/absolute/path/to/image.png';
const pngBase64 = fs.readFileSync(pngPath).toString('base64');
await page.evaluate(async (b64) => {
const vm = window.vm;
const target = vm.editingTarget;
const storage = vm.runtime.storage;
// Decode base64 to Uint8Array
const binaryString = atob(b64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const asset = storage.createAsset(
storage.AssetType.ImageBitmap,
storage.DataFormat.PNG,
bytes,
null,
true
);
const costume = {
name: 'png-costume',
dataFormat: 'png',
asset: asset,
md5: asset.assetId + '.png',
assetId: asset.assetId,
bitmapResolution: 2, // 2 for high-res PNGs (see Key Details)
rotationCenterX: 0, // set after measuring image
rotationCenterY: 0
};
// Measure actual image dimensions to set rotation center
const blob = new Blob([bytes], { type: 'image/png' });
const url = URL.createObjectURL(blob);
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = url;
});
URL.revokeObjectURL(url);
// For bitmapResolution=2, rotation center is in image pixels (not halved)
costume.rotationCenterX = Math.floor(img.width / 2);
costume.rotationCenterY = Math.floor(img.height / 2);
await vm.addCostume(costume.md5, costume);
const costumeIndex = target.getCostumes().length - 1;
target.setCostume(costumeIndex);
return `PNG costume added: ${img.width}x${img.height}`;
}, pngBase64);
}
EOF
)"
From a Canvas (Programmatic Drawing)
Generate a PNG entirely in the browser using an off-screen canvas:
playwright-cli run-code "$(cat <<'EOF'
async page => await page.evaluate(async () => {
const vm = window.vm;
const target = vm.editingTarget;
const storage = vm.runtime.storage;
// Draw on an off-screen canvas
const width = 200;
const height = 200;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
// Example: draw a star
ctx.fillStyle = '#FFD700';
ctx.beginPath();
for (let i = 0; i < 5; i++) {
const angle = (i * 4 * Math.PI) / 5 - Math.PI / 2;
const x = 100 + 80 * Math.cos(angle);
const y = 100 + 80 * Math.sin(angle);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#B8860B';
ctx.lineWidth = 3;
ctx.stroke();
// Convert canvas to PNG bytes
const blob = await new Promise(r => canvas.toBlob(r, 'image/png'));
const arrayBuffer = await blob.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
const asset = storage.createAsset(
storage.AssetType.ImageBitmap,
storage.DataFormat.PNG,
bytes,
null,
true
);
const costume = {
name: 'star',
dataFormat: 'png',
asset: asset,
md5: asset.assetId + '.png',
assetId: asset.assetId,
bitmapResolution: 2,
rotationCenterX: width / 2, // 100
rotationCenterY: height / 2 // 100
};
await vm.addCostume(costume.md5, costume);
const costumeIndex = target.getCostumes().length - 1;
target.setCostume(costumeIndex);
return 'Canvas PNG costume added';
})
EOF
)"
Common Operations
Adding a Costume to a Specific Sprite
To add a costume to a sprite other than the currently selected one, find the target first:
const target = vm.runtime.getSpriteTargetByName('Sprite2');
const stage = vm.runtime.getTargetForStage();
await vm.addCostume(costume.md5, costume, target.id);
Removing Default Costumes
After adding the custom costume, you can remove the default cat costumes:
target.deleteCostume(1);
target.deleteCostume(0);
⚠️ deleteCostume cannot delete the last remaining costume. If only one costume is left in the list, deleteCostume(0) silently fails. Always ensure at least 2 costumes exist before deleting. The safest approach is to add new costumes first, then delete the old ones (see "Correct Pattern" below).
Multiple Costumes for Animation
Add multiple costumes to create frame-based animation:
const frames = [
{ name: 'walk1', data: svgOrPngData1 },
{ name: 'walk2', data: svgOrPngData2 },
{ name: 'walk3', data: svgOrPngData3 }
];
for (const frame of frames) {
const isSvg = typeof frame.data === 'string';
const asset = storage.createAsset(
isSvg ? storage.AssetType.ImageVector : storage.AssetType.ImageBitmap,
isSvg ? storage.DataFormat.SVG : storage.DataFormat.PNG,
isSvg ? new TextEncoder().encode(frame.data) : frame.data,
null, true
);
const ext = isSvg ? 'svg' : 'png';
await vm.addCostume(asset.assetId + '.' + ext, {
name: frame.name,
dataFormat: ext,
asset: asset,
md5: asset.assetId + '.' + ext,
assetId: asset.assetId,
bitmapResolution: isSvg ? 1 : 2,
rotationCenterX: 50,
rotationCenterY: 50
});
}
Then use looks_nextcostume or looks_switchcostumeto blocks to animate.
Costume List Management for Animation
The Core Rule
looks_nextcostume cycles through every costume in the sprite's list in order. If any unwanted costume remains in the list — the default cat, a "?" placeholder, a dummy entry, or a leftover frame — it will appear during animation.
The fix is always the same: add the new frames first, then delete all the old costumes.
Correct Pattern
Perform loadProject and costume injection inside a single page.evaluate call so the costume list is clean before any rendering occurs.
Key rule: add new costumes first, then delete old ones. deleteCostume silently fails when only one costume remains in the list. Adding new costumes first guarantees the list always has more than one entry during deletion.
playwright-cli run-code "$(cat <<'EOF'
async (page) => {
const svg1 = `<svg ...>frame 1</svg>`;
const svg2 = `<svg ...>frame 2</svg>`;
await page.evaluate(async (frames) => {
const vm = window.vm;
// Step 1: inject blocks via loadProject
const projectJSON = JSON.parse(vm.toJSON());
const sprite = projectJSON.targets.find(t => !t.isStage);
sprite.blocks = { /* looks_nextcostume loop ... */ };
await vm.loadProject(JSON.stringify(projectJSON));
// Step 2: immediately replace the costume list
const target = vm.editingTarget;
const storage = vm.runtime.storage;
// Record how many OLD costumes exist before adding new ones
const oldCount = target.getCostumes().length;
// Add new animation frames FIRST (appended to the end of the list)
for (const { svg, name } of frames) {
const asset = storage.createAsset(
storage.AssetType.ImageVector, storage.DataFormat.SVG,
new TextEncoder().encode(svg), null, true
);
await vm.addCostume(asset.assetId + '.svg', {
name, dataFormat: 'svg', asset,
md5: asset.assetId + '.svg', assetId: asset.assetId,
bitmapResolution: 1, rotationCenterX: 50, rotationCenterY: 50
});
}
// THEN delete the old costumes from the front (new ones are safe at the end)
for (let i = oldCount - 1; i >= 0; i--) target.deleteCostume(i);
target.setCostume(0);
}, [{ svg: svg1, name: 'frame1' }, { svg: svg2, name: 'frame2' }]);
await page.evaluate(() => window.vm.greenFlag());
}
EOF
)"
Anti-Patterns
❌ Putting a dummy/placeholder costume in the project JSON before loadProject:
sprite.costumes = [{ name: 'blank', assetId: '00000000000000000000000000000000', ... }];
❌ Two separate page.evaluate calls (loadProject then costume injection):
await page.evaluate(async () => { await vm.loadProject(...); });
await page.evaluate(async () => { });
❌ Forgetting to delete default costumes:
await vm.addCostume(...);
❌ Checking the count before deciding whether to delete:
After loadProject the costume list may contain original cat costumes, previously injected custom costumes, or broken "?" entries from failed CDN fetches. Always delete all costumes unconditionally before adding your frames.
❌ Deleting all costumes before adding new ones (delete loop leaves last costume):
const count = target.getCostumes().length;
for (let i = count - 1; i >= 0; i--) target.deleteCostume(i);
await vm.addCostume(...frame1...);
await vm.addCostume(...frame2...);
The fix: add new costumes first, then delete old ones (see Correct Pattern above).
Using Custom Costumes with vm.loadProject()
The Problem
vm.loadProject() (used by the scratch-project-edit skill's updateSprite helper) destroys all locally-injected costumes. When the project is reloaded, Scratch tries to fetch every asset by its md5 hash from the remote CDN (api.scratch.mit.edu). Locally created assets have never been uploaded there, so the fetch fails and the costume appears as a "?" placeholder.
This affects both SVG and PNG costumes equally.
The Solution: Re-inject After loadProject (in One page.evaluate)
Save your costume data before calling loadProject, then re-inject the costumes in the same page.evaluate call immediately after. Splitting into two separate page.evaluate calls risks a render gap where the old (cat/"?") costume briefly appears.
For SVG — data is a plain string, passed as an argument to page.evaluate:
playwright-cli run-code "$(cat <<'EOF'
async (page) => {
const svg1 = `<svg ...>...</svg>`; // frame 1
const svg2 = `<svg ...>...</svg>`; // frame 2
// Inject blocks AND re-inject costumes in one evaluate (no render gap)
await page.evaluate(async (svgs) => {
const vm = window.vm;
const projectJSON = JSON.parse(vm.toJSON());
const sprite = projectJSON.targets.find(t => !t.isStage);
sprite.currentCostume = 0;
sprite.blocks = { /* ... your blocks ... */ };
await vm.loadProject(JSON.stringify(projectJSON));
// NOTE: custom costumes are now broken/missing — fix immediately below
const target = vm.editingTarget;
const storage = vm.runtime.storage;
// Record old costume count BEFORE adding new ones
const oldCount = target.getCostumes().length;
// Add new costumes FIRST (so the list always has >1 entry during deletion)
for (const { svg, name } of svgs) {
const asset = storage.createAsset(
storage.AssetType.ImageVector,
storage.DataFormat.SVG,
new TextEncoder().encode(svg),
null, true
);
await vm.addCostume(asset.assetId + '.svg', {
name, dataFormat: 'svg', asset,
md5: asset.assetId + '.svg', assetId: asset.assetId,
bitmapResolution: 1, rotationCenterX: 50, rotationCenterY: 50
});
}
// THEN delete old costumes (cat, "?", or any leftovers) from the front
for (let i = oldCount - 1; i >= 0; i--) target.deleteCostume(i);
target.setCostume(0);
}, [{ svg: svg1, name: 'frame1' }, { svg: svg2, name: 'frame2' }]);
}
EOF
)"
For PNG — Uint8Array is not JSON-serializable, so convert to base64 before passing to page.evaluate, then decode inside:
playwright-cli run-code "$(cat <<'EOF'
async (page) => {
const fs = require('fs');
// Read PNG files and encode as base64 (JSON-serializable)
const png1b64 = fs.readFileSync('/path/to/frame1.png').toString('base64');
const png2b64 = fs.readFileSync('/path/to/frame2.png').toString('base64');
// Step 1: Inject blocks via loadProject
await page.evaluate(async () => {
const vm = window.vm;
const projectJSON = JSON.parse(vm.toJSON());
const sprite = projectJSON.targets.find(t => !t.isStage);
sprite.currentCostume = 0;
sprite.blocks = { /* ... your blocks ... */ };
await vm.loadProject(JSON.stringify(projectJSON));
});
// Step 2: Re-inject PNG costumes after loadProject
await page.evaluate(async (pngs) => {
const vm = window.vm;
const target = vm.editingTarget;
const storage = vm.runtime.storage;
// Helper: decode base64 string to Uint8Array
const b64ToBytes = (b64) => {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
};
// Record old costume count
const oldCount = target.getCostumes().length;
for (const { b64, name } of pngs) {
const bytes = b64ToBytes(b64);
// Measure image dimensions for accurate rotation center
const blob = new Blob([bytes], { type: 'image/png' });
const url = URL.createObjectURL(blob);
const img = new Image();
await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = url; });
URL.revokeObjectURL(url);
const asset = storage.createAsset(
storage.AssetType.ImageBitmap,
storage.DataFormat.PNG,
bytes,
null, true
);
await vm.addCostume(asset.assetId + '.png', {
name, dataFormat: 'png', asset,
md5: asset.assetId + '.png', assetId: asset.assetId,
bitmapResolution: 2,
rotationCenterX: Math.floor(img.width / 2),
rotationCenterY: Math.floor(img.height / 2)
});
}
// Remove old broken placeholder costumes
for (let i = oldCount - 1; i >= 0; i--) target.deleteCostume(i);
target.setCostume(0);
}, [{ b64: png1b64, name: 'frame1' }, { b64: png2b64, name: 'frame2' }]);
}
EOF
)"
Why This Works
storage.createAsset() stores the asset data in the VM's in-memory storage. This survives as long as the page is alive. After loadProject completes, calling addCostume again re-registers the asset in the new runtime's storage, making it available for rendering. This works identically for SVG and PNG.
Key Difference When Passing PNG Data to page.evaluate
Uint8Array is not JSON-serializable and cannot be passed directly as an argument to page.evaluate. Always convert PNG bytes to a base64 string first (Buffer.toString('base64') in Node.js, or btoa() in the browser), then decode back to Uint8Array inside the evaluate callback using atob().
Alternative: Inject Blocks Without loadProject
If you only need to add a few blocks and the sprite already has the right costumes, consider manipulating target.blocks directly or using the Blockly workspace API instead of loadProject. This avoids the asset loss entirely — though it is more complex for large scripts.
Key Details
SVG vs PNG: When to Use Which
| SVG | PNG |
|---|
| Best for | Simple shapes, icons, characters with flat colors | Photos, complex textures, pixel art, gradients |
| Scaling | Scales without quality loss | Fixed resolution, may blur when enlarged |
| Creation | Write markup as a string | Canvas drawing, file read, or fetch from URL |
| AssetType | ImageVector | ImageBitmap |
| DataFormat | SVG | PNG |
| bitmapResolution | 1 | 2 (standard for Scratch HD) |
| Data encoding | new TextEncoder().encode(str) | Uint8Array of raw PNG bytes |
bitmapResolution
Scratch uses bitmapResolution to scale bitmap costumes on the stage:
1 — Used for SVG. 1 image pixel = 1 stage unit.
2 — Standard for PNG in Scratch. The image is displayed at half its pixel dimensions on stage (2x retina). A 200x200px PNG appears as 100x100 on stage.
Always use bitmapResolution: 2 for PNG costumes to match Scratch's default behavior.
Rotation Center
The rotationCenterX and rotationCenterY values define the point the sprite rotates around and where the sprite's position (0,0) maps to in the image.
For SVG:
{
rotationCenterX: 50,
rotationCenterY: 50
}
For PNG:
{
rotationCenterX: Math.floor(img.width / 2),
rotationCenterY: Math.floor(img.height / 2)
}
SVG Coordinate System
The SVG viewBox defines the coordinate space. For Scratch, center-origin viewBoxes work well:
<svg xmlns="http://www.w3.org/2000/svg"
width="100" height="100"
viewBox="-50 -50 100 100">
</svg>
width / height: the pixel dimensions of the costume
viewBox: "minX minY width height" — use negative min values to center the origin
- Keep dimensions reasonable (50–200px). Scratch scales costumes to fit the sprite size setting.
Storage API Parameters
storage.createAsset(
assetType,
dataFormat,
dataBuffer,
null,
true
);
When to Use This Pattern
- Custom characters: Creating original sprites that don't exist in the Scratch library
- Programmatic generation: Generating costumes from data (charts, patterns, procedural art)
- Multi-costume animation: Adding multiple frames for smooth animation
- Replacing default sprite: Changing the cat to a project-specific character
- External images: Loading photos or downloaded images as costumes from local files
When NOT to Use This Pattern
- Library sprites: If a suitable costume exists in the Scratch sprite library, use the scratch-sprite-library skill to add it from the UI instead
- Interactive drawing: If the user needs to draw or edit the costume themselves through the paint editor