
Msw Painter
- 4.2k installs
- 33 repo stars
- Updated July 29, 2026
- msw-git/msw-ai-coding-plugins-official
msw-painter is an agent skill that draws pixel art sprites, renders PNGs, and uploads them to MapleStory Worlds resource storage when catalog search fails.
About
MSW Painter is an agent skill for creating hand-drawn pixel art sprites and registering them as MapleStory Worlds sprite resources when msw-search cannot find a suitable RUID. It supports chunky pixel style for icons and tiles and maple cartoon style for characters, NPCs, and mascots with selout outlines and selective anti-aliasing rules. The workflow chooses SVG, Canvas, or HTML medium, picks size from the guide defaulting to 128 by 128, writes code following style references, renders PNG via render.cjs with puppeteer, and uploads through a two-step presigned URL pattern on asset_create_resource_storage_item. Security rules keep presigned URLs out of user-facing output and pass them via environment variables during PUT. The skill forbids curve and gradient APIs, requires reading the output PNG before upload, and reports only the final RUID with style and description. Developers invoke it when users need custom sprites, pixel art icons, maple-style characters, or NPC images that are not available in the MSW asset catalog.
- Two styles: chunky pixel for icons and maple cartoon for characters and NPCs.
- Renders SVG, Canvas, or HTML to PNG via sandboxed render.cjs with puppeteer.
- Two-step presigned upload registers sprites without exposing signed URLs in chat.
- Calls msw-search first and only paints when no suitable RUID exists.
- Size guide defaults to 128x128 with style-specific logical grid requirements.
Msw Painter by the numbers
- 4,191 all-time installs (skills.sh)
- +540 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #118 of 1,337 Generative Media skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
msw-painter capabilities & compatibility
- Capabilities
- pixel art drawing · style selection · png rendering · asset upload · catalog fallback · presigned upload · sprite registration
- Use cases
- image generation · ui design
What msw-painter says it does
Call `msw-search` first, and only invoke this skill when no suitable RUID is found.
npx skills add https://github.com/msw-git/msw-ai-coding-plugins-official --skill msw-painterAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4.2k |
|---|---|
| repo stars | ★ 33 |
| Security audit | 1 / 3 scanners passed |
| Last updated | July 29, 2026 |
| Repository | msw-git/msw-ai-coding-plugins-official ↗ |
How do I get a custom sprite RUID when msw-search has no matching asset for this icon or character?
Draw pixel art sprites in chunky or maple cartoon style, render PNGs, and upload them to MSW resource storage when search finds no RUID.
Who is it for?
MSW developers needing custom icons, tiles, or maple-style characters after catalog search returns no match.
Skip if: Animation, audio, avatar, or atlas assets, or cases where msw-search already returns a suitable RUID.
When should I use this skill?
Invoke after msw-search fails or when the user explicitly wants hand-drawn pixel art, custom graphics, maple style characters, or direct sprite creation.
What you get
A registered sprite RUID with chunky or maple style art at the requested size ready for entity placement elsewhere.
- PNG sprite file
- MSW storage resource with sprite RUID
By the numbers
- Supports 2 sprite style modes: chunky pixel and maple cartoon
- Outputs PNG sprites uploaded via msw-mcp asset_create_resource_storage_item
Files
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. Default is 128×128. 4. Write the code — Follow the rules for the chosen style:
chunky→ references/style-chunky-pixel.mdmaple→ references/style-maple-cartoon.md
5. Render to PNG — Run <SKILL_PATH>/scripts/render.cjs. 6. Upload the resource — mcp__msw-mcp__asset_create_resource_storage_item two-step pattern. 7. 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
<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>⚠️ Usewidth="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/--heightspecifies.
Minimal Canvas template
// `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
<!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-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.
If the requested output is below 64×64, themaplestyle does not have enough pixels for selout + AA + facial features — either bump the output size to 64+ or fall back tochunky.
---
4. PNG render — render.cjs
One-time dependency install
cd scripts && npm ciThis 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.
🔒 Usenpm ci, notnpm install.npm ciinstalls exactly the versions pinned inpackage-lock.jsonand fails if the lockfile andpackage.jsondisagree — this is the supply-chain integrity guarantee for W012. Never editpackage-lock.jsonby hand; if you need to bump puppeteer, runnpm 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
node <SKILL_PATH>/scripts/render.cjs --type <svg|canvas|html> --in <code-file> --out <out.png> --width <W> --height <H>Or pass the code via stdin:
echo "<svg ...>" | node <SKILL_PATH>/scripts/render.cjs --type svg --out out.png --width 128 --height 128Options:
--type: One ofsvg/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
mcp__msw-mcp__asset_create_resource_storage_item 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 thePAINTER_PRESIGNED_URLenvironment variable as shown below, not as a command-line argument. Command-line arguments are visible to other processes via/proc/*/cmdline(Linux/macOS) andGet-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 (typically401/403→ URL expired), discard the URL and restart from step 1. Do not reuse it elsewhere.
Step 1 — request a presigned URL
mcp__msw-mcp__asset_create_resource_storage_item({
category: "sprite",
subcategory: "<appropriate subcategory>", // e.g. "monster", "npc", "object", "icon"
name: "<resource name>",
description: "<1–2 sentence description>",
makerOwnerType: 0, // 0 = Account
makerOwnerId: "<account id>", // look up in advance with mcp__msw-mcp__account_get_my_user_id
// omit fileUrl in this step
})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)
PowerShell:
$env:PAINTER_PRESIGNED_URL = "<presignedUrl from step 1>"
try {
Invoke-WebRequest -Method PUT -InFile out.png -Uri $env:PAINTER_PRESIGNED_URL -ContentType "image/png"
} finally {
Remove-Item Env:\PAINTER_PRESIGNED_URL -ErrorAction SilentlyContinue
}bash (Git for Windows / WSL):
PAINTER_PRESIGNED_URL="<presignedUrl from step 1>" \
curl -X PUT -T out.png "$PAINTER_PRESIGNED_URL" && \
unset PAINTER_PRESIGNED_URLThe PUT itself is a plain binary upload — no auth headers are needed (the signature is embedded in the presigned URL). The env-var pattern keeps the URL out of Get-Process / ps-visible argument lists and out of shell history.
Step 3 — report upload completion
mcp__msw-mcp__asset_create_resource_storage_item({
...same arguments,
fileUrl: "<presignedUrl from step 1>" // pass directly as tool arg, do not echo
})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.
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. Usenpm ci(notnpm 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 Canvasscale = 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 useswidth="100%" height="100%"; Canvas derives scale fromc.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
Readon 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; removearc()/bezierCurveTo()/gradients and redraw with dots. If usingmaple, 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.mdSelout 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
mapleinstead. - 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 metadata in the step-2 completion call → Pass the exact same
category/subcategory/name/description/makerOwnerType/makerOwnerIdas in step 1. Only addfileUrl.
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)
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)
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: 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.
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. - 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.
<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().
// `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.
<!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:
#1A3A5Cor 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(), CSSlinear-gradient()/radial-gradient(). - Fractional coordinates:
fillRect(10.5, 20.3, ...)— breaks grid alignment. stroke-widthless 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>.
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.
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.
- 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,#7BC96Betc. - Grid alignment is still mandatory — no fractional coordinates, no curve APIs, no gradient APIs.
Pixel art implementation per medium
SVG
<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
// `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 BConstraints:
- 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(), CSSlinear-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 |
{
"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"
}
}
#!/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);
}
})();
Related skills
How it compares
Pick msw-painter over generic image generators when you need MSW-compatible pixel sprites with a registered RUID after catalog search misses.
FAQ
When should painter run instead of msw-search?
Only when msw-search returns no suitable RUID or the user explicitly requests hand-drawn art.
What are the two supported art styles?
Chunky pixel for icons and tiles, and maple cartoon for characters, NPCs, and cute mascots.
How are presigned upload URLs handled securely?
Pass URLs via PAINTER_PRESIGNED_URL env vars during PUT and never echo them in user-facing responses.
Is Msw Painter safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.