Have more questions? Join our

Images, fonts, and assets

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.

Quick answers

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.

Images

The contract

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').

Using an image

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.

Replacing and deleting

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.
  • The studio's upload button posts only a filename. The server derives a key from it and, on collision, appends the first free integer ≥ 2: XPieceXPiece2XPiece3. 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.

Uploading in bulk

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:

  • Tokens expire after 5 minutes. A bulk script must be resumable: log each success and skip completed keys on re-run, rather than starting over.
  • Responses are MCP-wrapped. The tool's JSON is a string inside content[0].text, not the top-level body.

Naming as an interface

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.

Vector art

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.

Fonts

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:

  • Self-hosted or base64 @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.

Marketing images

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.

What the frame permits

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.

Bundle accounting

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.

Next

  • 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.