Everything your game displays (piece art, card faces, textures, ornament, type) is either an uploaded asset addressed by a key, or something you draw in the client. This section covers which is which, and the exact limits of each.
| Question | Answer |
|---|---|
| What image formats can I upload? | JPEG and PNG only. Detected from the bytes; renaming a file does not help. |
| Can I upload an SVG? | No, but inline <svg> in your React tree is unrestricted. See Vector art. |
| Can I upload a WebP? | No. It is detected and rejected by name. |
| Max image size? | 5 MB per file. |
| Do images count against the 1024 KB bundle? | No. They are fetched by URL. Only base64 inlined in source does. |
| How do I replace an image? | upload_art with the same key. |
| How do I delete an image? | You cannot. Art keys are append-only; leave unused keys unreferenced. |
| Can I use a custom font? | Yes: Google Fonts, loaded by your own client code. Nothing else. |
| Can I self-host a font or inline one as base64? | No. font-src permits only fonts.gstatic.com. |
Do data: URIs work? |
Yes for <img src>, no for font-src, no for a stylesheet <link>. |
| Can I fetch anything at runtime? | No. connect-src 'none', so fetch/XHR/WebSocket are compile errors. |
| Formats | JPEG or PNG. The format is read from the decoded bytes, never from the filename or a declared MIME type. |
| Size | 5 MB per image. |
| Key | UpperCamelCase ASCII alphanumerics: ^[A-Z][A-Za-z0-9]*$. Validated before the bytes are even fetched. |
| State | An upload is a pending worktree change. It appears in the studio preview immediately and becomes permanent on commit. |
A rejected upload tells you what it detected, so the error is diagnostic:
UNSUPPORTED_IMAGE_FORMAT: Only JPEG and PNG are supported (detected 'svg').
Keys become members of the per-game GameImage enum exported by "boardweaver", regenerated as
you upload, and visible to both /src/game.ts and /src/frontend.tsx.
import { GameImage } from "boardweaver";
import { useImage } from "boardweaver/react";
function PieceArt() {
const src = useImage(GameImage.AbyssalPredator);
return <img src={src} alt="" />;
}
useImage is a hook: call it unconditionally at the top level. Resolving a variable-length list
of keys inside a .map() changes hook order between renders and will break. Resolve one key per
component, or resolve the whole set once at a stable call site.
The result is a plain URL, usable in <img src> or as a CSS background-image, including
layered, masked, filtered, or blended.
The two upload paths behave differently, and the difference bites.
upload_art with an explicit key uses that key verbatim. Re-uploading an existing key
replaces the image in place. This is how you swap art.XPiece → XPiece2 → XPiece3. It never
replaces. Uploading a corrected knight.png through the UI gives you Knight2, not a new
Knight.There is no delete for gameplay art. delete_file accepts only text paths, and the studio's
file tree excludes images. Treat art keys as append-only for the life of the game: supersede an
image by re-uploading to the same key with upload_art, and simply leave stray keys unreferenced.
They cost nothing at runtime.
Marketing art is the exception: see Marketing images.
Pass image bytes inline only for a handful of files; a few hundred card faces will exhaust an agent's context. Mint a token and POST the files directly instead:
curl -X POST "https://www.boardweaver.com/api/tool/art" \
-H "Authorization: Bearer $TOKEN" \
-F key=AbyssalPredator -F [email protected]
This runs the same tool and returns the same result; the bytes simply never pass through the model. Two practical notes:
content[0].text, not the
top-level body.A key is the contract between your art and your code, so name for the thing depicted and keep one
image per thing. A card's front and back are two images with two keys; a counter and its flipped
side are two images. Deriving keys mechanically from game data (`${cardId}` for a portrait
face and `${cardId}Wide` for a landscape one) means adding content never requires touching
the art plumbing.
SVG is not an uploadable asset, but the client can draw vectors freely. Three routes, in order of preference:
1. Inline <svg> in JSX, the primary answer. Writing SVG as React elements is DOM
construction, not a fetch, so the frame's CSP does not govern it at all. The whole language is
available: <defs>, gradients, <filter>, <mask>, <pattern>, <clipPath>. boardweaver/motion
exports motion.svg, so paths can animate (pathLength for a drawn-on stroke, for instance).
Inline SVG also composes with useGameTheme() and useColorMode(), so an ornament can recolor
itself per theme, something a raster asset cannot do.
2. data: URI in <img src>. Permitted (img-src includes data: and blob:). Note the
cost: the string is bundle bytes and counts against the 1024 KB cap.
3. Inline style or a rendered <style> element. style-src allows 'unsafe-inline', and a
<style> element is how you get pseudo-elements, @keyframes, and real cascade. You cannot touch
document, so render it as JSX. A <link> to a data-URI stylesheet will not load.
There is no server-side rasterizer. To serve vector art from the asset store (and keep it out of the bundle), export it to PNG yourself before uploading.
Google Fonts, loaded by your own client code, is the supported path. The frame's CSP permits
https://fonts.googleapis.com for stylesheets and https://fonts.gstatic.com for the font files,
and nothing else.
export default function App() {
return (
<>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700&display=swap"
/>
<div style={{ fontFamily: '"Cinzel", Georgia, serif' }}>…</div>
</>
);
}
Reference the face by its real family name, and always pair it with a system fallback. Load one or two faces, not six: each is a render-blocking request on every match load.
What does not work, so you don't spend time trying:
@font-face. font-src has no 'self', no data:, and no
media-bucket origin. A font file you upload or inline is CSP-blocked.GameFont and the /fonts/ prefix. These exist in the type surface but nothing populates
them; the enum is empty in practice./theme.json's fonts block. It stores font keys for the BWSS rules and marketing pages.
The v2 React client does not resolve them to families and injects no @font-face. Read
theme.fonts for authoring intent if you like, but map it to a real family yourself.If no web font suits, system stacks are the fallback, and differentiating a display face from a body face matters more than either being custom.
Cover and logo art are separate from gameplay art. They occupy fixed slots rather than a key
namespace, are uploaded with upload_marketing_image (slot is cover or logo), always
overwrite in place, and, unlike gameplay art, can be removed. They are not members of
GameImage and are not addressable from game code.
The client runs sandboxed on an opaque origin. Rather than infer what will load, this is the whole policy:
| Directive | Effect |
|---|---|
default-src 'none' |
Nothing loads unless listed below. |
script-src 'unsafe-inline' |
The frame runtime and your bundle, both inline. No external scripts load at all. |
style-src 'unsafe-inline' https://fonts.googleapis.com |
Inline styles and <style>; Google Fonts stylesheets. |
img-src <media bucket> data: blob: |
Uploaded art, data URIs, blob URLs. |
font-src https://fonts.gstatic.com |
Google Fonts files only. |
connect-src 'none' |
No fetch, XHR, WebSocket, or EventSource. These are compile errors. |
worker-src, object-src, base-uri, form-action |
All 'none'. |
The distinction that catches people: "no network access" refers to data. Subresource loads for images and fonts from the allowlisted origins are permitted and are the intended mechanism.
The client bundle is /src/frontend.tsx plus all of /src/game.ts, capped at 1024 KB after
minification. Uploaded images and Google Fonts are fetched by URL and cost nothing against it, so
high-resolution art is free: a few hundred 1024×1024 faces are fine.
What does count: executable code, and any asset inlined as a base64 string literal. Inlining is the fastest way to blow the limit; upload it and use a key.
validate_code reports only pass or fail: the measured byte size appears only in the failure
message, so there is no way to track headroom before exceeding it. Budget conservatively if your
rules engine is large, since it ships whether or not the UI touches it.
react-frontend: the client contract, the sandbox boundary, and bundle accounting.react-match-api: useImage alongside the other derived hooks.v2-game-themes: /theme.json, including background layers.v2-entities: PieceDef.orientations, which references image keys.