Docs Design

Contents

Your own art and sound: the Asset kind

Designed and built 2026-10-09, Wave 4 of the audit (design/audit-2026-10.md: "Import your own media"). Read with ADR-0002 (a game is documents plus allowed media), ADR-0005 (schemas are the contract) and ADR-0011 (assets are recipes first; files are frozen).

Why

Every sprite, model, sound and score was made from a recipe. That is the right default (it works offline, restyles in one document and costs nothing), but a person who drew their hero, bought a sound pack or hired an artist could not use any of it. The best tools let you bring your files; Gamewright has to, safely: a bundle must never carry a script or a Godot resource, a picture must never be big enough to take a phone's memory, and a store's AI disclosure has to be filled from what the person said, not guessed.

What

An Asset document (schemas/v1/asset.schema.json, assets/<slug>.asset.json) names one media file in the pack and pins it:

  • file: its path and its SHA-256. Import keeps the file at assets/<slug>-<first 12 hex of its hash>.<format>: a name a person recognises in their folder, which no other file can take, so it is written once and never replaced. A changed file is a new asset (asset.changed refuses the old one); undo finds the old file where it was.
  • bytes, and media: what its bytes are, read at import and read again at every check: a picture's format (png, jpg, webp), widthPx and heightPx, or a sound's format (ogg, wav, mp3), durationSec, channels and rateHz.
  • source: the ledger. origin (unstated, made_by_me, made_for_me, made_by_others), author, license (SPDX or LicenseRef-*), url, ai (unstated, no_ai, ai_assisted, ai_generated), aiTool, credit. Steam's content survey asks which content AI made and with what; itch.io tags AI-generated graphics and sound; a credits page needs author and credit line. Every one of them can be filled from these fields, per file, by media kind. Unstated stays unstated: a warning (asset.source_unstated) asks for it, and nothing guesses.

What the player takes, and why (core/src/game/media.ts, the engine's GwMediaSniff):

formats limits why
pictures PNG, JPEG (baseline, progressive), still WebP 4096 px a side, 8 megapixels the largest texture a phone's browser draws; 32 MB decoded
sounds Ogg Vorbis, WAV (PCM 8/16/24/32-bit, 32-bit float), MP3 60 s, mono or stereo, 8–96 kHz every sound is baked into a 16-bit buffer at load, on one thread in a browser
any file 6,000,000 bytes the web Studio sends a file in one 8 MB message; the web player fetches all media first

What a file is comes from its bytes, never its name: each format is walked far enough to know it is whole (a PNG's chunks and CRCs, a WAV's chunks, every Ogg page, every MP3 frame), and anything else is refused by name (a GIF, an SVG, a Godot scene or resource, a script, a zip, a program, an Opus or FLAC sound). Not now: glTF/GLB. Godot's GLTFDocument loads one without scripts, but it follows external URIs, decodes embedded textures of any size and runs registered extensions; the 3D look path is Model recipes, which take no texture yet. It waits until both are designed.

Files only from inside the pack. readMediaFile refuses a path with an empty, ., .. or hidden segment, a backslash, or a link whose real path leaves the pack, and reads a file only once its size is known to be within the limit. Bake reads through it too, so a link in a pack's folder can no longer carry a file from outside into a bundle (an Item's icon could, before). The player checks again (GwMedia): the bytes must hash to the Asset's pin, be what it says, and name a size within the limits before a decoder sees them; the bundle loader refuses a "png" that is not one (bundle.media_content).

Using an asset. Two builders, so a Sprite and a Sound stay what they were:

  • sprite.sheet (kit.sprite): an imported picture cut into frames of one size, each clip a run of frames along a row (clips: [{name, row, column, frames, fps, loop}]), the feet at the bottom middle unless pivotXPx/pivotYPx say, flip for art that faces left, pixelsPerTile for its size in a level. Every 2D view draws it as it draws a baked sheet (GwSpriteSheet, now with a first column and a texel scale): the platformer's bodies by what they do, kit.ui's sprite widget, the Studio's previews and room look. The pixels are the person's: no palette snap.
  • sound.file (kit.audio): an imported sound decoded by Godot's own decoders (data, never code) a chunk a step at the file's own rate, for as long as its Asset says and never past 60 s, then baked as every Sound is (high-pass, a one-shot's trim and fade, the mix sheet's gain), so it sits in the mix and plays as an AudioStreamWAV in a browser's sample mode too. vary makes variants differ in pitch and length.

So "bring your own art and sound" is real for every 2D game (the platformer, a visual novel's portraits, any Screen's sprite widget) and "your own sound" for every game, 3D included. Left for later, each needing a design of its own: a picture as a Style's backdrop or a 3D texture, glTF models, music longer than a minute (streaming), a picture as a grid game's tiles, replacing a file in place (drop on its Asset), and a file picker in the web Studio.

Importing. The person's own change, applied at once like an edit in the Inspector, so Ctrl+Z takes it back (assets.import over the session protocol; core/src/session/assets.ts):

  1. the bytes come as base64; their size is told from the text before decoding;
  2. they are sniffed and checked against the limits; a refusal writes nothing;
  3. the same bytes already in the game are not imported twice: the result names that Asset;
  4. the file is written at assets/<slug>-<hash12>.<format>, then one change set writes its Asset and, unless the person says not, a Sprite (sprite.sheet, when the game runs kit.sprite) or a Sound (sound.file) that uses it, ids made free with _2, _3 (a template's snd.jump is never taken over); if the change is refused, the new file goes again;
  5. a pack that is its own git repository commits the file with its Asset.

The Studio imports a file dropped on its window or chosen with File ▸ Import… (Ctrl+Shift+I, the palette, or the last row of the project's Look and sound, "Import your own files…"). The new file opens in the Inspector with its preview (a picture at whole-pixel zoom on a checkerboard, or a sound with its waveform and Play) and its Source fields to fill. In the browser there is no dialog for a game on the host: Import… says to drop the file, which goes over the same session through the host (assets.import counts as a write against the person's storage). gamewright import does the same from a terminal, with --origin, --ai, --license, --author and --url.

The Director sees the person's files in game_summary (assets: what each is, what uses it), the picture itself through get_document, and both builders in the vocabulary. The playbook says to prefer the person's files and never to name one the game lacks. Assets are the person's: no proposal may add, change or remove one (asset.person_only, at proposal and again at approval), and an Asset whose file isn't there fails L0, so none can be made up.

Looks, not play. An Asset is a view-only kind (stages.ts); the simulation never reads one or its files (tools/check-presentation-reads.sh), and media reach views through the stage (stage.service("media")), never through the simulation's content.

Proof

  • Core: core/test/assets.test.ts (sniffing every format and refusing others by name, limits, damaged files, paths and links out of the pack, the L0 checks, bake's media by hash, import and undo, already-imported, refusals that write nothing, the git commit, the Director never writing one and seeing the picture) and core/test/session/assets.test.ts (the protocol, every message checked against session.schema.json). Each guard was put back and its test seen red.
  • Engine: engine/tests/runtime/gw_media_test.gd, kits/sprite/gw_sprite_import_test.gd, kits/audio/gw_sound_import_test.gd, player/gw_player_assets_test.gd (the asset_mini bundle: the hero drawn from the imported sheet by what it does, the jump baked from the WAV), and studio/gw_studio_import_test.gd (a drop with the real core, the preview, a refusal, Ctrl+Z).
  • Watched: tools/studio/import-scenario.sh (frames of a drop on a copy of Burrow Run, the sheet cut and given to the hero), then that game baked and its look tour shot in the player.

From docs/design/assets.md in the Gamewright repository.