references/size-guide.md
# Image size guide
Most entities in a Maker workspace are based on small sprites. **Always specify an appropriate size** so the size ratio matches surrounding entities.
## Recommended size table
| Use | Recommended size | Examples |
|-----|------------------|----------|
| Icon, small object, button icon | `48×48` ~ `64×64` | Heart, coin, arrow, star |
| General character, item, NPC, monster | `96×96` ~ `128×128` | Slime, sword, shield, tree |
| Tile, floor, block | `64×64` ~ `128×128` | Grass tile, brick, platform |
| Background, large object | `256×256` or larger | **Only when the user explicitly requests a large size** |
## Rules
- **The default 512×512 is too large** — always specify `--width` / `--height`.
- Use **128×128** as the default when there is no special requirement.
- Transparent background (PNG alpha) is the default — if you do not draw a background in the SVG/Canvas/HTML, the output is automatically transparent.
## Aspect ratio guide
- Square (`width === height`) is the default. Characters / icons are almost always square.
- Horizontally elongated objects (vehicles, bridges) use `2:1` (e.g. 192×96).
- Vertically elongated objects (trees, flags) use `1:2` (e.g. 96×192).
- Avoid irregular ratios when possible — they can affect collider / hit-box alignment of the entity.
## Working grid per style
The standard pixel art workflow is to draw on a **small logical grid → scaled up to a larger output canvas**. The logical grid size depends on which style you picked in `SKILL.md` step 2.
### Chunky pixel working grid (see [style-chunky-pixel.md](style-chunky-pixel.md))
Larger pixels-per-dot → chunky retro feel.
| Output size | Recommended logical grid | Pixels per dot |
|-------------|--------------------------|----------------|
| 48×48 | 16×16 | 3 |
| 64×64 | 16×16 | 4 |
| 96×96 | 24×24 or 16×16 | 4 or 6 |
| 128×128 | 16×16 or 32×32 | 8 or 4 |
| 256×256 | 32×32 or 64×64 | 8 or 4 |
### Maple cartoon working grid (see [style-maple-cartoon.md](style-maple-cartoon.md))
Smaller pixels-per-dot → room for facial features, selout, and selective AA.
| Output size | Recommended logical grid | Pixels per dot |
|-------------|--------------------------|----------------|
| 48×48 | 24×24 | 2 |
| 64×64 | 32×32 | 2 |
| 96×96 | 48×48 | 2 |
| 128×128 | 64×64 | 2 |
| 256×256 | 128×128 | 2 |
> A logical grid that is too small (≤ 24×24) does not leave room for selout + AA + facial features, so it forces the result back into chunky territory. If the requested output is below 64×64 and you want maple cartoon feel, raise the output size first.
## Character proportions (Maple cartoon style only)
Maple-style characters are **2.5 to 3 heads tall** (super-deformed / chibi).
| Total height | Head | Torso | Legs |
|--------------|------|-------|------|
| 64 px | 26 px | 18 px | 20 px |
| 96 px | 32 px | 28 px | 36 px |
| 128 px | 42 px | 38 px | 48 px |
Full character drawing details (face features, hair, accents) are in [style-maple-cartoon.md](style-maple-cartoon.md).
references/style-chunky-pixel.md
# Style: Chunky Pixel (retro / 8-bit feel)
One of two style options for msw-painter. Choose this style for **icons, buttons, tiles, blocks, and small UI elements** where a clear, readable, NES/SNES-era look is desirable. For characters / NPCs / monsters, prefer the [Maple Cartoon style](style-maple-cartoon.md).
The chunky style emphasizes **large, clearly visible dots** with a minimal palette. Each pixel is a deliberate design element.
## Core principles
- **Disable antialiasing**: Keep sharp pixel edges instead of smooth lines. No intermediate-color "soft" pixels anywhere.
- **Restricted palette**: Keep colors to a minimum. Build depth with stepped solid shading (2–4 levels per surface) rather than gradients.
- **Grid alignment**: Snap every element to the pixel grid. Do not use fractional coordinates.
- **Small resolution → upscaled render**: Real chunky pixel art is drawn on a small canvas (e.g. 16×16, 32×32) and scaled up with `width`/`height`. See the "Chunky pixel working grid" table in [size-guide.md](size-guide.md).
- **Black or white outline** is acceptable and idiomatic.
## Pixel art implementation per medium
### SVG
Create a small logical coordinate system with `viewBox` and scale the output up with `width`/`height`. Use `image-rendering: pixelated` to prevent interpolation when upscaling. Place dots as 1px `<rect>` elements.
```xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="100%" height="100%"
style="image-rendering: pixelated; image-rendering: crisp-edges;">
<rect x="6" y="2" width="1" height="1" fill="#4A90D9"/>
<rect x="7" y="2" width="1" height="1" fill="#4A90D9"/>
<!-- Chain 1px rects together to fill in the picture with dots -->
</svg>
```
### HTML5 Canvas
Set `ctx.imageSmoothingEnabled = false` first, then call `fillRect` with positions/sizes obtained by multiplying logical grid coordinates by `scale`. Do not use curve APIs such as `arc()` or `bezierCurveTo()`.
```javascript
// `c` and `ctx` are auto-exposed by render.cjs (imageSmoothingEnabled = false already).
const GRID = 16;
const scale = c.width / GRID; // derive from c.width, not a hard-coded constant
ctx.fillStyle = '#4A90D9';
ctx.fillRect(6 * scale, 2 * scale, scale, scale); // One dot at (6,2)
ctx.fillRect(7 * scale, 2 * scale, scale, scale);
```
### HTML
Apply `image-rendering: pixelated` to the root element. Whether you embed an image with `<img>` or set it as a `background-image`, interpolation is turned off the same way.
```html
<!doctype html>
<style>
html, body { margin: 0; image-rendering: pixelated; }
.sprite { width: 128px; height: 128px; background: url('data:image/png;base64,...'); }
</style>
<div class="sprite"></div>
```
## Creating shading / depth
- Use **stepped shading** instead of gradients: base color + 1–2 darker steps + 1–2 lighter steps. **2–4 levels total per surface**.
- Make the darker color by lowering the saturation/brightness of the base color, and paint it at a consistent pixel width (usually 1–2px) within the same surface.
- Assume the light source is normally at the upper-left → shadows on the lower-right, highlights on the upper-left.
Example (a blue slime with base `#4A90D9`):
- Shadow: `#2E5C8A` (dark blue)
- Highlight: `#7FB5E8` (light blue)
- Outline: `#1A3A5C` or a white outline
## Forbidden
- **Anti-aliasing of any kind** — including manual intermediate-color pixels on edges. (If you want soft edges, use the Maple Cartoon style instead.)
- Curve APIs: `arc()`, `arcTo()`, `bezierCurveTo()`, `quadraticCurveTo()` — round shapes must be made by placing pixels directly.
- Soft effects: `box-shadow`, `filter: blur()`, `filter: drop-shadow()` (blur family).
- Gradients: `createLinearGradient()`, `createRadialGradient()`, CSS `linear-gradient()`/`radial-gradient()`.
- Fractional coordinates: `fillRect(10.5, 20.3, ...)` — breaks grid alignment.
- `stroke-width` less than 1 in SVG.
- Dithering (use Maple Cartoon style if you need soft gradients).
## Drawing round shapes manually
If you need a circle, place dots using the midpoint circle algorithm, or use a predefined small pixel circle pattern. Example: an 8×8 circle.
```
. . # # # # . .
. # . . . . # .
# . . . . . . #
# . . . . . . #
# . . . . . . #
# . . . . . . #
. # . . . . # .
. . # # # # . .
```
Place each cell with `fillRect` or `<rect>`.
references/style-maple-cartoon.md
# Style: Maple Cartoon (MapleStory-inspired cartoon pixel)
One of two style options for msw-painter. Choose this style for **characters, NPCs, monsters, and any sprite that should feel cute / illustrated / storybook-like**. For icons, tiles, and simple UI blocks where a clear retro look is desirable, prefer the [Chunky Pixel style](style-chunky-pixel.md).
The Maple Cartoon style is **higher-resolution pixel art** with **rich stepped shading**, **colored outlines (selout)**, and **selective anti-aliasing** on silhouette edges. The result reads as "painted / cartoon" rather than "retro 8-bit", while still being made of discrete pixels on a grid.
## Core principles
- **Higher logical grid** — typical working grid is 32×32 to 128×128 (vs 16×16 for chunky). This gives room for facial features, shading, and selout pixels. See the "Maple cartoon working grid" table in [size-guide.md](size-guide.md).
- **Rich stepped shading** — 4–6 color levels per surface (base + 2 darker + 2 lighter + optional rim light), still stepped (no gradient API), just with more steps than chunky.
- **Selout (colored outlines)** — outlines are NOT pure black. Use a desaturated, darker version of the adjacent fill color so the outline blends with each surface.
- **Selective anti-aliasing** — on silhouette edges and curved outlines, place a single intermediate-color pixel between two contrasting colors to soften the staircase. **Only on silhouettes**, never on internal shading.
- **Saturated pastel palette** — warm, slightly desaturated colors. Avoid pure primaries (`#FF0000`, `#00FF00`). Prefer `#E85A4F`, `#7BC96B` etc.
- **Grid alignment is still mandatory** — no fractional coordinates, no curve APIs, no gradient APIs.
## Pixel art implementation per medium
### SVG
```xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="100%" height="100%"
style="image-rendering: pixelated; image-rendering: crisp-edges;">
<!-- Base fill -->
<rect x="20" y="16" width="24" height="20" fill="#F4C8A8"/>
<!-- Selout outline (darker version of base) -->
<rect x="19" y="16" width="1" height="20" fill="#8B5A3C"/>
<!-- Soft AA pixel on a diagonal edge (intermediate color between outline and base) -->
<rect x="19" y="15" width="1" height="1" fill="#B98060"/>
</svg>
```
### HTML5 Canvas
```javascript
// `c` and `ctx` are auto-exposed by render.cjs (imageSmoothingEnabled = false already).
const GRID = 64;
const s = c.width / GRID;
const px = (x, y, color) => { ctx.fillStyle = color; ctx.fillRect(x * s, y * s, s, s); };
// Base
for (let x = 20; x < 44; x++) for (let y = 16; y < 36; y++) px(x, y, '#F4C8A8');
// Selout outline column
for (let y = 16; y < 36; y++) px(19, y, '#8B5A3C');
// Soft AA corner pixel
px(19, 15, '#B98060');
```
### HTML
HTML is rarely the right choice for cartoon pixel art. Prefer SVG or Canvas. If you must use HTML, render each pixel as a tiny absolutely-positioned `<div>`, but the code becomes verbose quickly.
## Color palette
### Recommended palette feel
- **Warm and slightly desaturated.** Think children's book illustration, not neon arcade.
- **Hues**: salmon (`#E85A4F`), peach (`#F4C8A8`), butter (`#FFE08A`), sage (`#A8D5A0`), sky (`#A8D8E8`), lavender (`#C8B4E8`), cocoa (`#8B5A3C`).
- **Avoid**: pure `#000000`, pure `#FFFFFF`, fully saturated primaries.
### Per-surface shading recipe (4–6 levels)
For each surface (e.g. the body of a slime), prepare a small ramp:
| Level | Role | Recipe from base |
|-------|------|------------------|
| 0 | Deep shadow | base − 40% lightness, +5% saturation |
| 1 | Mid shadow | base − 20% lightness |
| 2 | **Base** | the primary fill color |
| 3 | Mid highlight | base + 15% lightness |
| 4 | Top highlight | base + 30% lightness, slight hue shift toward yellow |
| 5 | Rim light (optional) | base + 40% lightness, used as 1px line on the dark side |
Example for a green slime with base `#7BC96B`:
- Deep shadow: `#3F7A3A`
- Mid shadow: `#5BA853`
- Base: `#7BC96B`
- Mid highlight: `#A5DC95`
- Top highlight: `#D4F0C2`
- Rim light: `#EAFADE`
Apply each level in shrinking bands, **2–4 px wide each**, following the form of the surface.
## Selout (colored outline) recipe
Pure black outlines (`#000000`) make the sprite feel harsh and "retro-comic". MapleStory-style sprites use a **darker, slightly desaturated version of the adjacent fill color** as the 1-pixel outline.
Rule of thumb: outline color = base color with **lightness − 40~50%**, **saturation similar or slightly lower**.
| Surface base | Selout outline |
|--------------|----------------|
| Skin `#F4C8A8` | `#8B5A3C` (warm dark brown) |
| Green leaf `#7BC96B` | `#2F5A2A` (forest green) |
| Red cloth `#E85A4F` | `#7A2A20` (dark wine) |
| Blue water `#5AA8E8` | `#1E4A7A` (deep navy) |
| Yellow metal `#F4D060` | `#8A6A20` (bronze) |
When two outlined surfaces meet (e.g. skin meets shirt), use the **darker of the two surfaces' outlines** at the boundary, OR omit the outline entirely and rely on the color contrast.
## Selective anti-aliasing (selout AA)
On a diagonal or curved silhouette, a hard outline reads as a staircase. Place a **single intermediate-color pixel** at the inside corner of each step to soften it visually.
```
. . . O O O . . . . O O O .
. . O X X X . . . a X X X . a = AA pixel
. O X X X X . → . a X X X X . (color between O and X)
O X X X X X . a X X X X X .
```
The AA color is mixed roughly halfway between the outline (`O`) and the inner fill (`X`). For `O = #8B5A3C` and `X = #F4C8A8`, a reasonable AA value is `#B98060`.
**Strict rules**:
- AA pixels ONLY on the silhouette (outer edge of the sprite, or the boundary between sprite and transparent background).
- NEVER use AA on internal shading boundaries. Internal shading stays stepped.
- Use 1 AA pixel per step at most. Stacking AA pixels turns the sprite mushy.
## Dithering (allowed sparingly)
For large soft surfaces (sky, water, a big shield) where stepped bands look too obvious, use a **2×2 checkerboard dither** to blend two adjacent levels.
```
Level A . Level A . (checker pattern between
. Level B . Level B level A and level B)
Level A . Level A .
. Level B . Level B
```
Constraints:
- Use only between two adjacent ramp levels (e.g. base ↔ mid highlight). Never across more than one step.
- Use only on large flat fields (≥ 8×8 px of dithered area). Tiny details should stay stepped.
- Never use dithering on a character's face or any detail-critical area.
## Character proportions (SD / chibi)
MapleStory-style characters are **2 to 3 heads tall** (super-deformed / chibi proportions).
| Total height | Head | Torso | Legs |
|--------------|------|-------|------|
| 64 px (2.5-head) | 26 px | 18 px | 20 px |
| 96 px (3-head) | 32 px | 28 px | 36 px |
| 128 px (3-head) | 42 px | 38 px | 48 px |
### Face features
- **Eyes**: large, round, **3–5 px wide**. Place them in the upper third of the face, spaced apart by roughly 1 eye-width. Add a 1-px white highlight inside each pupil.
- **Nose**: 1-px dot, or omit entirely on smaller sprites.
- **Mouth**: 2–3 px wide, 1 px tall, often a simple horizontal line or a tiny "v" / "u".
- **Cheek blush**: 1–2 px of soft pink (`#F4A8B8`) just below the eyes. Optional but very on-tone.
- **Outline of the head**: full selout in warm dark brown (`#8B5A3C`) — never black.
### Hair
- Solid block of base color + 1 highlight band on top + 1 shadow band underneath.
- A few **1-px flyaway strands** silhouetted against the background sell the cartoon look.
## Forbidden (still applies)
- **Curve APIs**: `arc()`, `arcTo()`, `bezierCurveTo()`, `quadraticCurveTo()` — round shapes must be made by placing pixels directly. (Selective AA softens visual roundness without using these.)
- **Soft effect APIs**: `box-shadow`, `filter: blur()`, `filter: drop-shadow()` — depth must come from manual stepped shading.
- **Gradient APIs**: `createLinearGradient()`, `createRadialGradient()`, CSS `linear-gradient()`/`radial-gradient()` — gradients must come from stepped bands and optional 2×2 dithering.
- **Fractional coordinates**: `fillRect(10.5, 20.3, ...)` — breaks grid alignment.
- **Pure black outlines** (`#000000`) — use selout.
- **Heavy AA / interior AA** — AA only at the silhouette, max 1 pixel per step.
## Drawing round shapes manually
Use the chunky midpoint circle as a starting silhouette, then add **1 selout AA pixel at each corner step**.
Example: a 12×12 cartoon-style circle.
```
. . . O O O O O O . . .
. . O X X X X X X O . .
. O X X X X X X X X O .
O X X X X X X X X X X O
O X X X X X X X X X X O
O X X X X X X X X X X O
O X X X X X X X X X X O
O X X X X X X X X X X O
O X X X X X X X X X X O
. O X X X X X X X X O .
. . O X X X X X X O . .
. . . O O O O O O . . .
```
Then sprinkle 1 AA pixel (mixture color between `O` and `X`) at each `.` cell that touches both `O` and `X` diagonally. This single tweak transforms a chunky circle into a soft cartoon button.
## Common reusable accents
| Accent | Purpose | Recipe |
|--------|---------|--------|
| Cheek blush | Cuteness on faces | 1–2 px soft pink (`#F4A8B8`) under eyes |
| Eye highlight | Liveliness | 1 px white inside each pupil, upper-left |
| Rim light | Form definition | 1 px lightest-ramp color on the dark side of the silhouette |
| Specular highlight | Glossy materials | 2–3 px cluster of lightest-ramp on metal/gem |
| Drop shadow on ground | Grounded look (only when entity sits on a tile) | 4–6 px oval of dark gray with 50% alpha, centered under feet |
scripts/package.json
{
"name": "msw-painter-render",
"version": "1.0.0",
"private": true,
"description": "Helper that renders SVG/Canvas/HTML code to PNG. For use by the msw-painter skill.",
"main": "render.cjs",
"scripts": {
"smoke": "node render.cjs --type svg --in samples/red-circle.svg --out /tmp/painter-smoke.png --width 128 --height 128"
},
"dependencies": {
"puppeteer": "^23.0.0"
}
}
scripts/render.cjs
#!/usr/bin/env node
'use strict';
/**
* msw-painter render helper — converts SVG/Canvas/HTML code to PNG.
*
* Usage:
* node render.cjs --type <svg|canvas|html> --in <path> --out <path.png> --width <px> --height <px>
*
* Code can also be passed via stdin instead of --in (use `--in -` or omit --in).
* Transparent background by default. width/height default to 128×128 when omitted.
*
* Exit code: 0 = success, 1 = failure (message on stderr).
*
* Security posture (W012 mitigations):
* - All network requests from the page are blocked at the puppeteer level
* (request interception). Sprite rendering needs no external resources.
* - A strict Content-Security-Policy meta tag is injected so that even if
* interception is bypassed, the page cannot reach external origins.
* - SVG / HTML input is sanitized to remove <script>, <foreignObject>,
* event handlers (on*), and any non-data: href / xlink:href / src.
* - Chromium is launched without --no-sandbox unless explicitly opted in
* via PAINTER_DISABLE_SANDBOX=1 (e.g. CI containers that require it).
*
* Dependency: puppeteer (one-time `npm ci` required; see SKILL.md).
*/
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = { type: null, in: null, out: null, width: 128, height: 128 };
for (let i = 2; i < argv.length; i++) {
const k = argv[i];
const v = argv[i + 1];
if (k === '--type') { args.type = v; i++; }
else if (k === '--in') { args.in = v; i++; }
else if (k === '--out') { args.out = v; i++; }
else if (k === '--width') { args.width = parseInt(v, 10); i++; }
else if (k === '--height') { args.height = parseInt(v, 10); i++; }
else if (k === '-h' || k === '--help') { args.help = true; }
}
return args;
}
function usage() {
console.error('Usage: node render.cjs --type <svg|canvas|html> [--in <path>|-] --out <path.png> [--width N] [--height N]');
}
function readInput(inPath) {
if (!inPath || inPath === '-') {
return fs.readFileSync(0, 'utf8');
}
return fs.readFileSync(inPath, 'utf8');
}
// --- Input sanitization (W012) -------------------------------------------------
//
// Sprite rendering legitimately needs only static markup and inline scripts that
// draw to a canvas. It NEVER needs to load remote resources or attach DOM event
// handlers. We strip the classes of constructs that could exfiltrate data or
// pull in attacker-controlled code, even though the network is also blocked.
//
// This is intentionally conservative: SVG <script> is allowed inside a normal
// SVG, but is unnecessary for the chunky/maple styles documented in SKILL.md,
// so we remove it. The canvas type intentionally keeps its own controlled
// <script> wrapper (built below in buildHtml), which is injected by us, not by
// the user.
function sanitizeMarkup(src) {
if (typeof src !== 'string') return '';
let s = src;
// Remove <script>…</script> blocks (any case, any attributes).
s = s.replace(/<script\b[\s\S]*?<\/script\s*>/gi, '');
// Remove self-closing or unterminated <script ...> tags too.
s = s.replace(/<script\b[^>]*\/?>/gi, '');
// Remove <foreignObject> — can host arbitrary HTML inside SVG.
s = s.replace(/<foreignObject\b[\s\S]*?<\/foreignObject\s*>/gi, '');
s = s.replace(/<foreignObject\b[^>]*\/?>/gi, '');
// Remove <iframe>, <object>, <embed>, <link>, <meta http-equiv refresh>.
s = s.replace(/<(iframe|object|embed|link)\b[\s\S]*?<\/\1\s*>/gi, '');
s = s.replace(/<(iframe|object|embed|link|meta)\b[^>]*\/?>/gi, '');
// Strip inline event handlers: on*="..." or on*='...'.
s = s.replace(/\son[a-z]+\s*=\s*"(?:[^"\\]|\\.)*"/gi, '');
s = s.replace(/\son[a-z]+\s*=\s*'(?:[^'\\]|\\.)*'/gi, '');
s = s.replace(/\son[a-z]+\s*=\s*[^\s>]+/gi, '');
// Block non-data: URLs in href / xlink:href / src.
// We allow only: data: URIs, fragment refs (#foo), and empty values.
const urlAttr = /(\s(?:xlink:href|href|src)\s*=\s*)("([^"]*)"|'([^']*)')/gi;
s = s.replace(urlAttr, (full, prefix, _quoted, dq, sq) => {
const val = (dq !== undefined ? dq : sq) || '';
const safe = val === '' || val.startsWith('data:') || val.startsWith('#');
return safe ? full : `${prefix}""`;
});
// Block javascript:/vbscript:/etc. anywhere they might survive above passes.
s = s.replace(/\b(?:javascript|vbscript|data:text\/html)\s*:/gi, 'about:blank#blocked-');
return s;
}
// --- HTML scaffolding ----------------------------------------------------------
function buildHtml(type, code, width, height) {
// Strict CSP: no network at all, only inline styles/scripts that we ourselves
// inject below. `default-src 'none'` denies everything; we then re-allow only
// the inline pieces that the canvas wrapper genuinely needs.
const csp = [
"default-src 'none'",
"img-src data:",
"style-src 'unsafe-inline'",
"script-src 'unsafe-inline'",
"base-uri 'none'",
"form-action 'none'",
"frame-ancestors 'none'",
].join('; ');
const head = `<!doctype html>
<html><head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<style>
html, body { margin: 0; padding: 0; background: transparent; }
body { width: ${width}px; height: ${height}px; image-rendering: pixelated; image-rendering: crisp-edges; }
svg, canvas, img { display: block; image-rendering: pixelated; image-rendering: crisp-edges; }
</style>
</head><body>`;
const closer = `</body></html>`;
if (type === 'svg') {
return head + sanitizeMarkup(code) + closer;
}
if (type === 'canvas') {
// The user code runs inside our wrapper; the wrapper itself is trusted, but
// we still want the user's code to be unable to reach the network. The CSP
// and request interception cover that.
return head
+ `<canvas id="__c" width="${width}" height="${height}"></canvas>`
+ `<script>
(function(){
var c = document.getElementById('__c');
var ctx = c.getContext('2d');
ctx.imageSmoothingEnabled = false;
try {
${code}
window.__painterDone = true;
} catch (e) {
window.__painterError = String(e && e.stack || e);
}
})();
</script>`
+ closer;
}
if (type === 'html') {
// Full-document HTML mode: still sanitize, but we don't wrap in our head.
// We DO inject a CSP meta as the first child of <head> if one exists,
// otherwise we fall back to the wrapped form.
const sanitized = sanitizeMarkup(code);
if (/<head\b[^>]*>/i.test(sanitized)) {
return sanitized.replace(
/<head\b[^>]*>/i,
(m) => `${m}<meta http-equiv="Content-Security-Policy" content="${csp}">`
);
}
return head + sanitized + closer;
}
throw new Error(`unknown type: ${type}`);
}
// --- Puppeteer driver ----------------------------------------------------------
async function render(args) {
const puppeteer = require('puppeteer');
const code = readInput(args.in);
const html = buildHtml(args.type, code, args.width, args.height);
// Sandbox: keep Chromium's sandbox ON by default. Some constrained
// environments (CI containers, WSL without user namespaces) cannot start
// a sandboxed Chromium; allow opt-out via env var only.
const disableSandbox = process.env.PAINTER_DISABLE_SANDBOX === '1';
const launchArgs = ['--disable-dev-shm-usage'];
if (disableSandbox) {
launchArgs.push('--no-sandbox', '--disable-setuid-sandbox');
}
const browser = await puppeteer.launch({
headless: 'new',
args: launchArgs,
});
try {
const page = await browser.newPage();
// Block ALL network requests. Sprite rendering does not need network.
// Even with CSP in place, request interception is the belt-and-suspenders
// guarantee that no external origin is ever contacted.
await page.setRequestInterception(true);
page.on('request', (req) => {
const url = req.url();
// Allow the synthetic data: URL we navigate to, and nothing else.
if (url.startsWith('data:') || url === 'about:blank') {
req.continue();
} else {
req.abort();
}
});
await page.setViewport({ width: args.width, height: args.height, deviceScaleFactor: 1 });
// Navigate to a data: URL instead of using setContent + networkidle. With
// network fully blocked, networkidle would have nothing to wait on anyway,
// and data: URLs make the origin opaque so even relative URL tricks fail.
const dataUrl = 'data:text/html;charset=utf-8,' + encodeURIComponent(html);
await page.goto(dataUrl, { waitUntil: 'load' });
if (args.type === 'canvas') {
await page.waitForFunction(
() => window.__painterDone === true || typeof window.__painterError === 'string',
{ timeout: 10000 }
);
const err = await page.evaluate(() => window.__painterError);
if (err) throw new Error('canvas code threw:\n' + err);
}
const outDir = path.dirname(path.resolve(args.out));
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const clip = { x: 0, y: 0, width: args.width, height: args.height };
await page.screenshot({ path: args.out, type: 'png', omitBackground: true, clip });
} finally {
await browser.close();
}
}
(async () => {
const args = parseArgs(process.argv);
if (args.help) { usage(); process.exit(0); }
if (!args.type || !args.out) {
usage();
process.exit(1);
}
if (!['svg', 'canvas', 'html'].includes(args.type)) {
console.error(`--type must be svg|canvas|html (got: ${args.type})`);
process.exit(1);
}
if (!Number.isFinite(args.width) || !Number.isFinite(args.height) || args.width <= 0 || args.height <= 0) {
console.error(`--width / --height must be positive integers`);
process.exit(1);
}
try {
await render(args);
process.stdout.write(path.resolve(args.out) + '\n');
} catch (e) {
console.error('render failed:', e && e.stack || e);
process.exit(1);
}
})();
SKILL.md
---
name: msw-painter
description: "When msw-search cannot find a suitable sprite RUID, draw a pixel art sprite directly with SVG / HTML5 Canvas / HTML code, render it to PNG, and upload it via the msw-mcp asset upload tool to obtain a sprite RUID (if no upload tool is connected, guide the user to register it through Maker). Two style modes are supported: chunky pixel (retro / icon / tile feel) and maple cartoon (MapleStory-inspired character / NPC feel). Triggers: draw sprite directly, create sprite, image generation, custom graphic, pixel art, cartoon sprite, maple style, chibi character, painter, draw a sprite, make an icon, create NPC image directly, draw a slime, custom sprite."
---
# MSW Painter
A workflow for registering a hand-drawn pixel art sprite as a sprite resource. **Call `msw-search` first, and only invoke this skill when no suitable RUID is found.**
This skill is dedicated to the sprite category. It does not handle animation / audio / avatar / atlas.
The painter supports two pixel art **styles**: **chunky pixel** (retro, icon/tile feel) and **maple cartoon** (MapleStory-inspired, character/NPC feel). Pick one before writing code — see step 2 below.
---
## When to invoke
| Situation | Action |
|-----------|--------|
| User wants a specific sprite | First use `msw-search` (Resource search section, sprite category) |
| `msw-search` returns an RUID that matches the intent | Use that RUID directly. **Do not invoke painter.** |
| No search results, or all results are unsuitable | Invoke painter → create directly |
| User explicitly says "I need a hand-drawn looking character/icon" | Invoke painter directly |
---
## Workflow
1. **Choose the medium** — One of SVG / Canvas / HTML. See "Choosing the medium" below.
2. **Choose the style** — `chunky` or `maple`. See "Choosing the style" below.
3. **Decide the size** — See [references/size-guide.md](references/size-guide.md). Default is 128×128.
4. **Write the code** — Follow the rules for the chosen style:
- `chunky` → [references/style-chunky-pixel.md](references/style-chunky-pixel.md)
- `maple` → [references/style-maple-cartoon.md](references/style-maple-cartoon.md)
5. **Render to PNG** — Run `scripts/render.cjs`.
6. **Upload the resource** — the msw-mcp asset upload tool, two-step presigned pattern (§5). If the connected MCP has no upload tool, ask the user to register the PNG through Maker.
7. **Register sprite properties** — `asset_update_resource_storage_info` right after upload: `filter_mode` / `wrap_mode` / pivot, plus 9-slice borders for UI frame sprites. See "Step 4" below.
8. **Report the result** — RUID + a 1–2 sentence description (include which style was used). Entity placement / script application is outside the painter's scope.
---
## 1. Choosing the medium
| Medium | Recommended use | Strengths |
|--------|-----------------|-----------|
| **SVG** | Icons, logos, simple characters, shape-based pixel art | Intuitive code, easy to drop 1px `<rect>` dots |
| **Canvas** | Procedural patterns, iterative logic (loop-drawn textures / noise) | Generate complex patterns via JS programming logic |
| **HTML** | Composite layouts that can be styled quickly with CSS | Rarely used — SVG/Canvas is usually a better fit for pixel art |
### Minimal SVG template
```xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"
width="100%" height="100%" preserveAspectRatio="xMidYMid meet"
style="image-rendering: pixelated;">
<rect x="6" y="2" width="1" height="1" fill="#4A90D9"/>
<!-- Place dots one by one with 1px rects -->
</svg>
```
> ⚠️ Use `width="100%" height="100%"` (NOT a fixed pixel count). The SVG element draws at its **own** declared size inside the render.cjs viewport — if you hard-code 128 but render at `--width 1024`, the SVG fills only the top-left 128px and the rest of the PNG is transparent. `100%` makes the SVG fill whatever canvas `--width`/`--height` specifies.
### Minimal Canvas template
```javascript
// `c` (canvas element) and `ctx` (2D context) are auto-exposed by render.cjs.
// ctx.imageSmoothingEnabled = false is applied automatically as well.
// IMPORTANT: derive scale from c.width, not a hard-coded constant — otherwise
// a different --width leaves the bottom-right of the canvas blank.
const GRID = 16;
const scale = c.width / GRID; // 16×16 logical grid → canvas-sized output
ctx.fillStyle = '#4A90D9';
ctx.fillRect(6 * scale, 2 * scale, scale, scale);
```
### Minimal HTML template
```html
<!doctype html>
<html><body style="margin:0; image-rendering: pixelated;">
<!-- Anything you like -->
</body></html>
```
---
## 2. Choosing the style
| Style | Recommended use | Look & feel | Logical grid | Outline | Shading |
|-------|-----------------|-------------|--------------|---------|---------|
| **`chunky`** | Icons, buttons, tiles, blocks, simple props | Retro / 8-bit / NES-SNES | Small (16×16, 32×32) | Black or white, 1px | 2–4 stepped levels, NO AA |
| **`maple`** | Characters, NPCs, monsters, cute mascots | MapleStory / storybook / cartoon | Larger (32×32 ~ 128×128) | **Selout** (darker version of fill color) | 4–6 stepped levels + **selective AA** on silhouette + optional 2×2 dithering |
### Defaults when in doubt
- Icon / button / tile / block → **`chunky`**
- Character / NPC / monster / mascot / "cute" requests / "draw a slime" → **`maple`**
- User says "retro" / "8-bit" / "NES" / "minimal" → **`chunky`**
- User says "MapleStory" / "cute" / "cartoon" / "chibi" / "illustrated" → **`maple`**
Full per-style rules:
- [references/style-chunky-pixel.md](references/style-chunky-pixel.md)
- [references/style-maple-cartoon.md](references/style-maple-cartoon.md)
Both styles share the same forbidden APIs (no curve APIs, no gradient APIs, no fractional coordinates, no `filter: blur`/`drop-shadow`). They differ in palette richness, outline color, AA, and working grid.
---
## 3. Size guide (summary)
| Use | Recommended size |
|-----|------------------|
| Icon / button | 48×48 ~ 64×64 |
| Character / item / NPC / monster | 96×96 ~ 128×128 |
| Tile / floor / block | 64×64 ~ 128×128 |
| Background / large object | 256×256 or larger (only on explicit request) |
The default is **128×128**. For style-specific working-grid tables (chunky uses a small logical grid like 16×16; maple uses a larger one like 64×64) and SD character proportions, see [references/size-guide.md](references/size-guide.md).
> If the requested output is **below 64×64**, the `maple` style does not have enough pixels for selout + AA + facial features — either bump the output size to 64+ or fall back to `chunky`.
---
## 4. PNG render — `render.cjs`
### One-time dependency install
```bash
cd scripts && npm ci
```
This installs `puppeteer` (~200MB including headless Chromium) from the committed `package-lock.json`. It is separate from other base skill dependencies, so run this only the first time you use painter.
> 🔒 Use `npm ci`, **not** `npm install`. `npm ci` installs exactly the versions pinned in `package-lock.json` and fails if the lockfile and `package.json` disagree — this is the supply-chain integrity guarantee for W012. Never edit `package-lock.json` by hand; if you need to bump puppeteer, run `npm install puppeteer@<version>` locally and commit the regenerated lockfile.
### Sandboxing & network isolation
`render.cjs` runs the headless Chromium with the OS sandbox **enabled** by default and blocks **all** network requests from the rendered page. The page is also served via a `data:` URL with a strict `Content-Security-Policy` (`default-src 'none'`), and the SVG / HTML input is sanitized to strip `<script>`, `<foreignObject>`, inline `on*` handlers, and non-`data:` URLs. You do not need to do anything to opt in — these protections are always on.
If you are in a constrained environment where Chromium cannot start its sandbox (some CI containers, certain WSL setups), set `PAINTER_DISABLE_SANDBOX=1` before invoking `render.cjs`. **Do not set this on a developer workstation.**
### Invocation
```bash
node scripts/render.cjs --type <svg|canvas|html> --in <code-file> --out <out.png> --width <W> --height <H>
```
Or pass the code via stdin:
```bash
echo "<svg ...>" | node scripts/render.cjs --type svg --out out.png --width 128 --height 128
```
Options:
- `--type`: One of `svg` / `canvas` / `html`. **Required**.
- `--in`: Path to the code file. Omit or use `-` for stdin.
- `--out`: Output PNG path. **Required**.
- `--width` / `--height`: Output pixel size. Default 128.
On success, the absolute path of the output PNG is printed to stdout on a single line and exit code is 0. On failure, the error is printed to stderr and exit code is 1.
The PNG defaults to a transparent background. If you need a background color, draw it explicitly inside the SVG/Canvas/HTML.
---
## 5. Resource upload — two-step pattern
Upload through the **asset upload (creation) tool exposed by the connected `msw-mcp`** — check the server's tool list and use the sprite-capable creation tool it actually provides. **The tool's own schema is authoritative for the exact call shape**; do not guess tool names, and do not confuse creation with `asset_update_resource_storage_data` (that one replaces an existing asset's binary).
**No upload tool in the connected MCP?** Stop the upload step and ask the user to register the PNG through Maker instead, then continue with the RUID they provide (or locate it via `msw-search`).
Whatever the exact tool, the flow is the same two-step pattern — the same tool is called twice.
> 🔒 **Security — handling the presigned URL (W007).** The `presignedUrl` returned in step 1 is a short-lived signed credential (anyone holding it can PUT to that storage slot until it expires). Treat it as a secret:
>
> - **Never** echo, quote, paraphrase, or include the URL or any of its query parameters (`X-Amz-Signature`, `X-Amz-Credential`, etc.) in the assistant's user-facing response, in commit messages, in logs, or in any subsequent prompt — including when reporting "what you did".
> - When invoking the shell, pass the URL via the `PAINTER_PRESIGNED_URL` environment variable as shown below, **not** as a command-line argument. Command-line arguments are visible to other processes via `/proc/*/cmdline` (Linux/macOS) and `Get-Process` (Windows), and they are recorded in shell history.
> - When invoking step 3, pass the URL directly as the `fileUrl` tool argument — do **not** copy it into a code block or markdown for the user to see first.
> - If the PUT step fails (typically `401`/`403` → URL expired), discard the URL and restart from step 1. Do not reuse it elsewhere.
### Step 1 — request a presigned URL
Call the upload tool with `fileUrl` omitted. Fill the fields its schema requires — typically `category: "sprite"`, a `subcategory` matching existing assets (see below), `name`, a 1–2 sentence `description`, and file metadata such as `fileName` / `contentLength` when the schema asks for them.
The response contains a `presignedUrl`. Keep it inside the agent's reasoning context only — do **not** surface it in chat output.
### Step 2 — PUT the PNG binary (URL passed via env var)
> ⚡ **Use `curl.exe`, not `Invoke-WebRequest` (P001 — the "freezes after upload" bug).** On Windows PowerShell 5.1, `Invoke-WebRequest` parses the HTTP response through the **Internet Explorer engine** unless you pass `-UseBasicParsing`. IE is **removed/disabled on Windows 11**, so the call blocks on IE "first-launch configuration" and appears to freeze for a long time after the bytes are already uploaded (the MCP tool itself returns in ~45 ms — the stall is entirely in this step). `curl.exe` (shipped in `System32` on Windows 10 1803+ and all Windows 11) has no IE dependency and behaves identically in PowerShell and Git Bash, so prefer it in **both** shells.
PowerShell (preferred — `curl.exe`):
```powershell
$env:PAINTER_PRESIGNED_URL = "<presignedUrl from step 1>"
try {
# Feed url/request/upload-file to curl via a stdin config (-K -) so the URL
# never lands in argv (visible via Get-Process) or shell history.
"url = `"$env:PAINTER_PRESIGNED_URL`"`nrequest = `"PUT`"`nupload-file = `"out.png`"" | curl.exe -K -
} finally {
Remove-Item Env:\PAINTER_PRESIGNED_URL -ErrorAction SilentlyContinue
}
```
bash (Git for Windows / WSL — `curl`):
```bash
# 1) Assign on its OWN statement (export), NOT as an inline prefix.
# `VAR=… curl … "$VAR"` does NOT work: the shell expands "$VAR" on the
# same command line BEFORE the assignment takes effect, so curl receives
# an empty URL and fails with "curl: option : blank argument…".
export PAINTER_PRESIGNED_URL="<presignedUrl from step 1>"
# 2) Feed the URL to curl via a config file read from stdin (-K -). Passing it
# as a normal argument (curl … "$PAINTER_PRESIGNED_URL") would expand the URL
# straight into argv, where it is visible via `ps` / /proc/<pid>/cmdline —
# -K - keeps it out of the argument list entirely.
printf 'url = "%s"\nrequest = "PUT"\nupload-file = "out.png"\n' "$PAINTER_PRESIGNED_URL" | curl -K -
unset PAINTER_PRESIGNED_URL
```
The PUT itself is a plain binary upload — no auth headers are needed (the signature is embedded in the presigned URL). The `-K -` (stdin config) form keeps the URL out of `ps` / `Get-Process` argument lists and shell history in both shells.
**Fallback only — `Invoke-WebRequest`.** If `curl.exe` is genuinely unavailable, you MUST add `-UseBasicParsing` (skips the IE engine → no freeze) and silence the progress bar (a separate PS 5.1 bug that slows transfers by 10–50×):
```powershell
$env:PAINTER_PRESIGNED_URL = "<presignedUrl from step 1>"
$ProgressPreference = 'SilentlyContinue'
try {
Invoke-WebRequest -Method PUT -InFile out.png -Uri $env:PAINTER_PRESIGNED_URL `
-ContentType "image/png" -UseBasicParsing
} finally {
Remove-Item Env:\PAINTER_PRESIGNED_URL -ErrorAction SilentlyContinue
}
```
### Step 3 — report upload completion
Call the **same tool again with the same arguments**, adding `fileUrl` set to the presigned URL from step 1 (pass it directly as the tool argument — do not echo it into chat or code blocks).
The response contains the sprite **RUID**. That is the final deliverable. After this call returns, treat the URL as fully consumed — do not retain it.
### Step 4 — register sprite properties
The creation tool does not accept `properties` — after step 3 returns the RUID, immediately call `mcp__msw-mcp__asset_update_resource_storage_info` with the asset's `guid`. Property entries are lowercase `{ "key": "...", "value": "..." }` with **string** values (resource *responses* show `Properties: [{ "Key", "Value" }]` — do not mirror that casing in the input).
| Key | Value | Meaning |
|---|---|---|
| `pivot_x` / `pivot_y` | numeric string | Sprite pivot |
| `border_left` / `border_right` / `border_top` / `border_bottom` | numeric string | 9-slice border in px |
| `filter_mode` | `Point` / `Bilinear` / `Trilinear` | Texture filtering |
| `wrap_mode` | `Repeat` / `Clamp` / `Mirror` / `MirrorOnce` | Texture wrap |
Painter defaults: `filter_mode=Point` (Bilinear smears chunky/maple pixel edges), `wrap_mode=Clamp`, `pivot_x=0.5`; `pivot_y=0.5` for icons / UI panels, `pivot_y=0.0` for characters and props standing on the ground (adjust only if visual verification shows foot drift). Set nonzero `border_*` only when the sprite is a 9-slice UI frame (button / panel / gauge) — the `.ui` side additionally needs `SpriteGUIRendererComponent.Type = Sliced(1)` (see [component-api.md](../msw-ui-system/references/component-api.md) §"SpriteGUIRenderer — ImageType Selection"). Never invent property keys or enum values beyond this table. If the connected MCP's tool list has no `asset_update_resource_storage_info`, report the intended property values to the user instead of calling a different tool.
### Choosing a subcategory
First inspect the subcategory distribution of existing sprites with `asset_search_resources` or `asset_list_account_resources` and match it. When in doubt, fall back to a generic value such as `object` / `etc`.
---
## 6. Report format
When the painter task is done, hand the user only this:
```
RUID: <received RUID>
Style: <chunky | maple>
<1–2 sentence description: what you drew, at what size, and what sprite it was registered as>
```
Entity creation/movement/spawn, script authoring, and UI editing are outside the painter's scope. Handle those in another skill or a follow-up step.
---
## Common pitfalls
- **Not running `npm ci` before `render.cjs`** → `Cannot find module 'puppeteer'`. Only needed the first time. Use `npm ci` (not `npm install`) so the lockfile-pinned puppeteer version is installed.
- **Omitting `--width` / `--height`** → It falls back to 128×128, and if the user wanted a different size you have to redraw. Always specify it.
- **SVG/Canvas content drawn only in the top-left corner of the PNG** → The drawing code declared its own dimensions (e.g. SVG `width="128" height="128"` or Canvas `scale = 8`) but render.cjs was invoked with a larger `--width`/`--height`. The content fills only its declared size and the rest of the PNG stays transparent. Fix: SVG uses `width="100%" height="100%"`; Canvas derives scale from `c.width`. The Minimal templates above already follow this.
- **Always Read the output PNG before uploading** → A misconfigured SVG/Canvas can silently produce a blank or off-canvas PNG. One `Read` on the output catches the size-mismatch and blank-canvas bugs in seconds; uploading first means re-doing the 2-step upload.
- **Background comes out black** → You drew a background inside the SVG/Canvas/HTML. To keep it transparent, remove the background shape itself.
- **Curves look smooth** → If using `chunky`, this is a rule violation; remove `arc()`/`bezierCurveTo()`/gradients and redraw with dots. If using `maple`, smoothness should come from **selective AA pixels at the silhouette**, NOT from gradient/curve APIs — the API ban still applies.
- **Maple sprite looks like chunky with extra colors** → You probably forgot the **selout** (1-pixel darker-color outline around each surface) and/or the selective AA at silhouette edges. Re-check `style-maple-cartoon.md` Selout and Selective AA sections.
- **Chunky sprite looks mushy / blurry** → You added intermediate-color pixels on edges. Chunky forbids ALL anti-aliasing — remove transition pixels and keep edges sharp. If a softer look is desired, switch to `maple` instead.
- **Maple sprite at small size (32×32 output) looks bad** → Maple style needs ≥ 64×64 output to fit selout + AA + features. Either increase size or switch to `chunky`.
- **PUT step fails with 401/403** → The presigned URL expired or is wrong. Restart from step 1.
- **Changing other arguments in the completion call** → Pass exactly the same arguments as in step 1. Only add `fileUrl`.