npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@metaloot/sdk

v0.3.2

Published

Unified Metaloot SDK for games: hosted 2D, 3D, audio and animation assets, player auth, and multiplayer rooms.

Readme

Metaloot SDK

The unified game-developer SDK for Metaloot: hosted 2D, 3D, audio, animation, and generated assets from Metaloot Studio, player auth, multiplayer rooms, and in-game AI generation — as typed, engine-agnostic building blocks with zero dependencies.

Games deployed with metaloot deploy already get auth and multiplayer with no install (an auth widget and /__metaloot/multiplayer.js are served on the game's own origin). This package adds what npm-based games want on top: TypeScript types, bundler-friendly imports, and — the star — hosted assets, so your game streams studio-generated GLB models from a URL instead of bundling them.

Install

npm install @metaloot/sdk

Quickstart

Generate a public asset once (metaloot assets generate --prompt "low-poly treasure chest" --name "Treasure Chest" --visibility public --wait), then load it by id or slug — no download, no file in your repo:

import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { createMetaloot } from "@metaloot/sdk";

const ml = createMetaloot();

// Who is playing? (works out of the box on <your-game>.metaloot.app)
const session = await ml.auth.getSession();
if (session.signedIn) console.log(`Hello ${session.user.name}!`);

// Stream a hosted GLB straight into three.js.
const url = await ml.assets.loadAssetObjectUrl("treasure-chest-1a2b3c4d");
new GLTFLoader().load(url, (gltf) => scene.add(gltf.scene));

// Join a multiplayer room (requires sign-in).
const room = await ml.multiplayer.joinRoom("lobby");
room.on("message", ({ from, data }) => console.log(from.name, data));
room.send({ kind: "wave" });

Everything is also available as plain functions:

import { assetUrl, loadAsset, getSession, joinRoom } from "@metaloot/sdk";

or per module: @metaloot/sdk/assets, @metaloot/sdk/auth, @metaloot/sdk/multiplayer, @metaloot/sdk/ai.

Assets

Metaloot Studio (studio.metaloot.app) turns text prompts or images into game-ready GLB models (drive it with metaloot assets generate — see the CLI). Public assets are hosted at stable Metaloot URLs with CORS enabled, so games can hot-link them instead of shipping the file. Curated packs expose a hashed manifest and every listed file through the same Metaloot API; game code never depends on a creator site.

import {
  assetUrl,        // (idOrSlug, opts?) => stable hosted URL of the GLB
  assetFileUrl,    // universal URL for any asset or downloadable pack
  assetManifestUrl, // hosted JSON inventory for a pack
  animationUrl,    // (idOrSlug, preset, opts?) => URL of an animated variant
  loadAnimation,   // (idOrSlug, preset, opts?) => Promise<ArrayBuffer>
  loadAnimationObjectUrl, // (idOrSlug, preset, opts?) => loader-ready blob: URL
  loadAsset,       // (idOrSlug, opts?) => Promise<ArrayBuffer>
  loadAssetFile,   // any primary file: image, audio, animation, ZIP, or GLB
  loadAssetFileObjectUrl, // browser-ready blob URL for any primary file
  loadAssetObjectUrl, // (idOrSlug, opts?) => Promise<blob: URL> for GLTFLoader.load
  getAsset,        // (idOrSlug, opts?) => Promise<MetalootAsset> metadata
  getAssetManifest, // paths, MIME types, byte sizes, SHA-256 hashes
  listAssets,      // ({ query?, scope?, ... }?) => Promise<MetalootAsset[]>
} from "@metaloot/sdk/assets";

Use the generic file helpers for non-3D catalog entries:

const packs = await listAssets({ kind: "audio" });
const manifest = await getAssetManifest(packs[0].id);
const sound = manifest.files.find((file) => file.contentType === "audio/ogg");
const bytes = await loadAssetFile(packs[0].id, { path: sound.path });
console.log(sound.path, bytes.byteLength);

Omit path to download the Metaloot-hosted pack ZIP. assetUrl/loadAsset remain optimized for individual GLBs and can use Metaloot Hosting's same-origin model proxy. Pack manifests and files always use Studio so they work consistently across every media type.

assetUrl picks the best URL automatically:

  • On a <slug>.metaloot.app game origin it returns the same-origin proxy /__metaloot/assets/<idOrSlug>.glb — edge-cached by Metaloot hosting, zero CORS concerns.
  • Everywhere else it returns https://studio.metaloot.app/api/assets/<idOrSlug>/file, which serves public assets with Access-Control-Allow-Origin: *.
  • Override with { origin } (e.g. a local studio) or { preferProxy: false }.

Variants

Every 3D asset can have two files: the full-resolution source model and a game-ready LOD (~15k faces) that the studio builds from it. By default (variant: "auto") games get the LOD automatically once it's ready, falling back to the source until then — you don't have to do anything. Ask for a specific file with variant:

assetUrl("treasure-chest-1a2b3c4d");                        // auto (default)
assetUrl("treasure-chest-1a2b3c4d", { variant: "source" }); // full-res original
await loadAsset("treasure-chest-1a2b3c4d", { variant: "lod" }); // LOD only (404 until ready)

loadAsset and loadAssetObjectUrl take the same option. Responses from the studio carry an X-Metaloot-Variant: lod|source header telling you which file auto resolved to, and getAsset metadata includes sourceModelUrl, lodModelUrl, and lodStatus (null | "queued" | "running" | "success" | "failed"). "source" and "lod" always use the studio URL — the hosting proxy only serves auto.

Animations

Assets rigged with metaloot assets rig additionally expose animated GLB variants, one per preset (idle, walk, run, …). getAsset metadata carries rigStatus and animations: { [preset]: { status, url } }; each ready url (also available via animationUrl(idOrSlug, preset)) streams a GLB containing the rigged model plus that preset's AnimationClip. All presets are retargeted onto the same skeleton, so a game can use the idle GLB's scene as the character and feed the other clips into one AnimationMixer, crossfading by movement speed:

const asset = await getAsset("frost-witch-c78f6ed7");
if (asset.animations?.idle?.status === "success") {
  const [idleUrl, walkUrl] = await Promise.all([
    loadAnimationObjectUrl(asset.id, "idle"),
    loadAnimationObjectUrl(asset.id, "walk"),
  ]);
  try {
    const idle = await gltfLoader.loadAsync(idleUrl);
    const walk = await gltfLoader.loadAsync(walkUrl);
    const mixer = new THREE.AnimationMixer(idle.scene);
    mixer.clipAction(idle.animations[0]).play();    // swap to walk.animations[0] when moving
  } finally {
    URL.revokeObjectURL(idleUrl);
    URL.revokeObjectURL(walkUrl);
  }
}

No three.js dependency — loadAsset and loadAnimation return ArrayBuffers you can feed to any engine (GLTFLoader.parse, Babylon SceneLoader, …), and they work in the browser, Node 20+, and Workers alike.

Private assets are only served to their owner. Create a scoped token with assets:read at metaloot.app/settings/api-tokens, then pass { token: "mtl_api_…" }. This works from local browser games with CORS, Node, and Workers. Never commit the token or include it in a production bundle; make the asset public or download it into the game for deployment.

Metadata matches the studio API:

const asset = await getAsset("treasure-chest-1a2b3c4d");
// { id, name, slug, status: "success", progress: 100, visibility, category,
//   tags, modelFormat: "glb", modelUrl, previewUrl, createdAt, ... }

const swords = await listAssets({ query: "sword" });        // public gallery
const characters = await listAssets({
  category: "Characters", // exact, case-insensitive
  kind: "model3d",
});
const mine   = await listAssets({ scope: "private", token }); // your assets

Three.js adapter

The optional @metaloot/sdk/three adapter turns a hosted asset into a game-ready Three.js instance. It handles GLTFLoader, game-ready LOD selection, rigged animation variants, AnimationMixer actions, crossfades, uniform height scaling, centering, grounding, shadows, bounds, and disposal:

npm install @metaloot/sdk three
import { loadThreeAsset } from "@metaloot/sdk/three";

const hero = await loadThreeAsset("ember-mage", {
  scene,
  animations: ["idle", "walk", "run"], // or "available"
  targetHeight: 1.8,
  shadows: true,
  autoPlay: "idle",
  token: import.meta.env.DEV ? import.meta.env.VITE_METALOOT_TOKEN : undefined,
});

// In the render loop:
hero.update(clock.getDelta());

// State transitions crossfade automatically:
hero.play(speed > 0.1 ? "run" : "idle");

console.log(hero.bounds); // ready for framing or simple collision
hero.dispose();

Pass a preconfigured loader when a game uses DRACO/KTX2. The adapter never creates a renderer, camera, lights, physics body, or gameplay collision shape; those remain deliberate game-level choices, while bounds provides the data needed to create one.

Game-ready materials

Hosted GLBs — studio-generated models and curated catalog packs alike — may ship materials tuned for a PBR viewer rather than a game: metallicFactor: 1 with no environment map renders near-black in a typical three.js scene, and some stylized packs carry off-palette base colors (aqua grass, pure-white stone) that wash out further under ACES tone mapping. Opt in to the game preset instead of hand-rolling per-material fixups:

const rock = await loadThreeAsset("rock-large", {
  scene,
  normalizeMaterials: true, // game preset: metalness ≤ 0.2, roughness ≥ 0.6
});

// Tune the thresholds, or recolor specific materials by glTF material name:
const tree = await loadThreeAsset("tree-pine", {
  scene,
  normalizeMaterials: {
    maxMetalness: 0,
    minRoughness: 0.8,
    materialOverrides: { grass: 0x4c9e45, leafsFall: "#c26a2d" },
  },
});

The option defaults to off, so existing scenes render exactly as before, but the preset is the recommended starting point for new games. Materials that bring their own envMap keep their metalness; if your scene supplies reflections through scene.environment and you want true metals, pass maxMetalness: 1 or leave normalization off for those assets.

The standalone helper works with any loaded GLTF, not just Metaloot loads:

import { normalizeMaterials } from "@metaloot/sdk/three";

const gltf = await loader.loadAsync(url);
normalizeMaterials(gltf.scene, { materialOverrides: { stone: 0x8a8f98 } });

Borrowing animations from the Universal Animation Library

Metaloot hosts the Quaternius Universal Animation Library as a curated pack (quaternius-universal-animation-library): a UE-Mannequin-style humanoid skeleton with 43 high-quality clips (Idle_Loop, Walk_Loop, Sprint_Loop, Sword_Attack, Roll, …). The three adapter can retarget those clips onto any humanoid model — Metaloot Studio's auto-rigged (Tripo) characters or your own GLBs — so a hero is not limited to the Metaloot animation presets:

import { loadThreeAsset } from "@metaloot/sdk/three";

const hero = await loadThreeAsset("path-knight-8082eb40", {
  scene,
  animations: ["idle"],           // a rigged base — retargeting needs a skeleton
  targetHeight: 1.8,
  animationLibrary: {
    source: "quaternius-universal-animation-library",
    clips: ["Idle_Loop", "Walk_Loop", "Sprint_Loop", "Sword_Attack"],
    rename: { Idle_Loop: "ual-idle", Walk_Loop: "walk", Sprint_Loop: "run", Sword_Attack: "attack" },
  },
  autoPlay: "idle",
});

hero.play("run");                  // retargeted clips are ordinary actions
console.log(hero.retarget);        // which bones mapped, which did not

Or do it by hand — load a hero any way you like, borrow clips explicitly:

import { loadAnimationLibrary, retargetClips, loadThreeAsset } from "@metaloot/sdk/three";
import { AnimationMixer } from "three";

const hero = await loadThreeAsset("path-knight-8082eb40", { scene, animations: ["idle"] });
const library = await loadAnimationLibrary("quaternius-universal-animation-library");
console.log(library.clipNames);    // all 43 clips

const { clips, boneMap, unmappedTargetBones } = retargetClips(hero.root, library, {
  clips: ["Idle_Loop", "Walk_Loop", "Jog_Fwd_Loop"],
});
library.dispose();                 // retargeted clips are self-contained

const mixer = new AnimationMixer(hero.root);
mixer.clipAction(clips.Walk_Loop).play();
// … mixer.update(delta) in the render loop

The default preset: "auto" maps bones by normalized names plus the actual bone hierarchy and bind pose. That matters for real Tripo rigs: their chain names are unreliable (production rigs have been observed with leg chains named 0_Left_Limb_*, an arm spelled Spine_3 → bone_8 → bone_9, and the Root bone at ground level), so the mapper classifies ambiguous chains by where they attach and which way they run, and anchors hip translation at the target's own bind-pose hip position with motion scaled to the skeletons' height ratio. A static preset: "tripo" map for the documented Tripo v2.5 naming scheme is also available (TRIPO_BONE_MAP).

Auto-mapping quality has limits. It transfers the core humanoid pose (hips, spine, neck/head, arms, legs) but drops what the target cannot express: UAL finger curls on a fingerless rig, leaf/end bones, and root motion for rigs without a dedicated root bone (use the pack's UAL1_Standard_RM.glb via path for root-motion variants). Always check the returned report — unmappedTargetBones stay in bind pose, unmappedLibraryBones lose their motion — and expect side-cases (weapon bones, capes, off-axis bind poses) to need help. When the heuristic guesses wrong, supply a custom map; it overrides individual auto entries and is matched with name normalization (write tripo::Root or tripoRoot, both work):

retargetClips(hero.root, library, {
  boneMap: {
    "tripo::Spine_3": "clavicle_l",  // force a mapping
    "tripo::Head_2": "",             // remove one (bone keeps its bind pose)
  },
});

The pure planning helpers (autoMapBones, resolveBoneMap, normalizeBoneName, findHipBone, TRIPO_BONE_MAP) are exported from the zero-dependency core too, so tooling can inspect mappings without three.js.

Babylon.js adapter

The optional Babylon adapter loads an AssetContainer, creates and places a root mesh, merges requested Metaloot animation variants by node name, exposes named AnimationGroups and bounds, configures shadows, and owns cleanup:

npm install @metaloot/sdk @babylonjs/core @babylonjs/loaders
import { loadBabylonAsset } from "@metaloot/sdk/babylon";

const hero = await loadBabylonAsset("ember-mage", {
  scene,
  animations: "available",
  targetHeight: 1.8,
  receiveShadows: true,
  shadowGenerator,
  autoPlay: "idle",
});

hero.play("run");
hero.dispose();

The Babylon adapter mirrors the three.js normalizeMaterials option — pass normalizeMaterials: true (or { maxMetalness, minRoughness, materialOverrides }) to make hosted PBR materials game-ready, and use the standalone normalizeBabylonMaterials(container.materials, opts) with any loaded container.

Water

The optional @metaloot/sdk/water module is a stylized-water kit for Three.js scenes: a shader material (animated waves, caustics, a shore foam ring, sparkles, sky tint) plus the geometry builders that feed it — a river ribbon along a centerline and a "pools" flood-fill that finds every lake in a heightfield. The look is driven by a single float vertex attribute, aDepth (0 at the shore → 1 at full depth), which both builders pack for you:

npm install @metaloot/sdk three
import {
  buildPoolsGeometry,
  buildRibbonGeometry,
  createWaterSurface,
} from "@metaloot/sdk/water";

// A winding river: z = f(x) (or pass a parametric (u) => ({ x, z })).
const river = createWaterSurface(
  buildRibbonGeometry({
    centerline: (x) => Math.sin(x * 0.02) * 30,
    bounds: [-120, 120],
    halfWidth: 12,
  }),
);
river.group.position.y = WATER_LEVEL;
scene.add(river.group);

// Every lake below the waterline, sharing the river's material and clock —
// exclude the river channel, its own ribbon covers it.
const lakes = createWaterSurface(
  buildPoolsGeometry({
    heightAt: (x, z) => terrainHeight(x, z),
    waterLevel: WATER_LEVEL,
    bounds: { minX: -120, minZ: -120, maxX: 120, maxZ: 120 },
    exclude: (x, z) => isRiverChannel(x, z),
  }),
  { material: river.material },
);
lakes.group.position.y = WATER_LEVEL;
scene.add(lakes.group);

// In the render loop — one clock per material:
river.update(clock.getDelta());

Each surface is a Group of two meshes: the shader-driven top and a darker translucent "under-tint" clone a little below it that sells depth (also available standalone as createUnderTint(geometry, { color, opacity, offsetY }), or skip it with underTint: false). Colors and opacity are overridable — createWaterSurface(geometry, { deep, shallow, foam, sky, alphaMin, alphaMax }) — and the returned handle exposes material, uniforms, update(delta), and dispose().

Builder details worth knowing:

  • buildRibbonGeometry offsets the ribbon perpendicular to the local centerline tangent and packs aDepth = 1 at the channel centre fading to 0 at the edges (depthCurve shapes the falloff). Front faces point +Y.
  • buildPoolsGeometry scans a grid over bounds (resolution cells per axis) and keeps every quad with any submerged corner, so the surface overlaps the bank by one cell and the shader's foam ring lands on the shore. aDepth comes from real submersion depth (depthScale world units → 1), vertices are deduplicated, and an empty geometry comes back when nothing is below waterLevel.
  • Both geometries are flat at y = 0 — position the mesh/group at the water level.

For custom pipelines, createWaterMaterial(opts) returns the bare ShaderMaterial (drive material.uniforms.uTime yourself), and the GLSL sources are exported as WATER_VERTEX_SHADER / WATER_FRAGMENT_SHADER. Any geometry works with the material as long as it supplies the aDepth attribute (WATER_DEPTH_ATTRIBUTE).

Auth

Thin, typed browser helpers for the Metaloot auth endpoints every deployed game has on its own origin (/auth/metaloot/*):

import { getSession, signIn, signOut } from "@metaloot/sdk/auth";

const session = await getSession(); // { signedIn: true, user, scope, expiresAt } | { signedIn: false }
if (!session.signedIn) signIn();    // full-page redirect, returns to the game

On Metaloot hosting these endpoints exist automatically (and a sign-in widget is injected — opt out with <meta name="metaloot-auth-widget" content="off" />).

Self-hosting? Mount the server adapters from @metaloot/auth (Express, Next.js, or any fetch-style server) to get the same endpoints, then use this module unchanged — pass { basePath: "/api/auth/metaloot" } if you mounted them under a custom prefix.

Multiplayer

A typed room client, protocol-compatible (v1) with the zero-install client Metaloot hosting serves at /__metaloot/multiplayer.js. Rooms run on your game's own origin at wss://<slug>.metaloot.app/mp/rooms/<roomId>; the player's Metaloot session cookie authenticates the connection, so this works in the browser on the deployed game.

import { joinRoom, MetalootAuthRequiredError } from "@metaloot/sdk/multiplayer";

try {
  const room = await joinRoom("lobby"); // ids: 1-64 chars of A-Za-z0-9 _ . ~ -
  room.self;                            // { connectionId, id, name, imageUrl }
  room.players;                         // other connections in the room
  room.on("join",    (player) => {});
  room.on("leave",   (player) => {});
  room.on("message", ({ from, data }) => {});
  room.send({ kind: "move", x: 3, y: 7 });   // relay to everyone else
  room.send(data, playerOrConnectionId);     // …or to one player
  room.setState("phase", "playing");         // shared key-value room state
  room.on("state",   ({ key, value, from }) => {});
  room.on("reconnect", ({ players, state }) => {}); // resync after auto-reconnect
  room.on("close",   ({ code, reason }) => {});
  room.leave();
} catch (error) {
  if (error instanceof MetalootAuthRequiredError) error.signIn();
}

Limits: 32 connections per room, 32 KB per JSON message, up to 256 state keys; room state clears when the last player leaves. The relay is not an authoritative server — send small semantic messages and use setState for the few values every client must agree on. Full docs: metaloot.app/docs/multiplayer.

AI generation

Live AI inside the game — structured JSON (quests, loot tables, dialogue trees), plain text, and images — through the Metaloot portal. Nothing new to install, and no model-provider key in your bundle: calls go to www.metaloot.app with a scoped Metaloot token and are billed to your AI credit balance.

Create a token with the ai:generate scope at metaloot.app/settings/api-tokens.

import { createMetaloot } from "@metaloot/sdk";

const ml = createMetaloot({ token: process.env.METALOOT_TOKEN });

or per function: import { generateText } from "@metaloot/sdk/ai" — every function also takes per-call { origin, token, fetch, signal }.

generateObject

Pass a JSON Schema and get back a typed object, ready to drop into game state:

type Quest = { title: string; objective: string; rewardGold: number };

const { result, creditsUsed, balance } = await ml.ai.generateObject<Quest>({
  prompt: "A fetch quest for a level-3 player in a frozen harbour town",
  system: "You write terse quests for a pixel-art RPG.",
  schema: {
    type: "object",
    properties: {
      title: { type: "string" },
      objective: { type: "string" },
      rewardGold: { type: "number" },
    },
    required: ["title", "objective", "rewardGold"],
  },
});

console.log(result.title, result.rewardGold); // typed as Quest
console.log(`${creditsUsed} credits used, ${balance} left`);

generateText

const { text } = await ml.ai.generateText({
  prompt: "The shopkeeper greets a player wearing cursed armor.",
  system: "One sentence, in character, no narration.",
  maxOutputTokens: 120,
});

generateImage

Bytes come back base64-encoded, so they go straight into an <img> or a texture without a round trip to your own server:

const { b64Json, contentType } = await ml.ai.generateImage({
  prompt: "Pixel-art health potion icon, transparent background",
  aspectRatio: "1:1",
  imageSize: "1K", // or "2K"
});

const src = `data:${contentType};base64,${b64Json}`;
document.querySelector("img#potion").src = src;

getAiCredits

const { balance, ledger } = await ml.ai.getAiCredits();
// ledger: [{ amount, kind, description, createdAt }, …]

Errors and credits

Every failure throws MetalootAiError with a status and an optional code. Check the flags rather than matching on message text — and note the SDK never retries, so backoff and caching are yours to decide:

import { MetalootAiError } from "@metaloot/sdk";

try {
  const { text } = await ml.ai.generateText({ prompt: "Taunt the player" });
  say(text);
} catch (error) {
  if (error instanceof MetalootAiError && error.isInsufficientCredits) {
    say(TAUNTS[Math.floor(Math.random() * TAUNTS.length)]); // written fallback
  } else if (error instanceof MetalootAiError && error.isUnauthorized) {
    console.error("Token missing, expired, or lacking the ai:generate scope.");
  } else {
    throw error;
  }
}

Always ship a non-AI fallback for anything on the critical path: credits run out, and a game that hard-fails on a taunt is worse than one with a canned line.

Every Metaloot account starts with 100 free credits on signup; paid credit packs are coming. Balances and the ledger live at metaloot.app/settings and in getAiCredits().

Keep the token server-side for anything public. A scoped ai:generate token in a deployed browser bundle is spendable by anyone who opens devtools. It's fine for local development and prototypes; for a shipped game, proxy generation through your own endpoint (or a game-specific gameId-scoped token) so the credential never reaches the client.

For AI agents

The whole pipeline is scriptable end to end — generate assets with the CLI, reference them by id in code, deploy:

export METALOOT_TOKEN="mtl_api_…" # scoped token from metaloot.app/settings/api-tokens

# 1. Generate a PUBLIC asset so the game can hot-link it (1-3 minutes).
metaloot assets generate --prompt "low-poly treasure chest, game-ready" \
  --name "Treasure Chest" --visibility public --wait --json \
  | sed -n '/^{/,$p' > asset.json
ASSET_ID=$(node -p "JSON.parse(require('fs').readFileSync('asset.json','utf8')).asset.id")
// 2. Reference it in game code — no file ships with the game.
import { loadThreeAsset } from "@metaloot/sdk/three";
await loadThreeAsset("<ASSET_ID>", { scene, targetHeight: 1.8, shadows: true });
# 3. Deploy. On <name>.metaloot.app the asset loads via the same-origin,
#    edge-cached proxy /__metaloot/assets/<ASSET_ID>.glb automatically.
metaloot deploy

Notes:

  • Public assets hot-link without a token. A local game can load an owned private asset with a scoped assets:read token; download and ship the file before production so the credential never enters a deployed browser bundle.
  • No SDK required for the simplest path: the hosted URL https://studio.metaloot.app/api/assets/<id>/file (or, on Metaloot hosting, /__metaloot/assets/<id>.glb) works directly with GLTFLoader.load(...).
  • Verify after deploy: fetch the asset URL from the deployed origin and confirm HTTP 200 with Content-Type: model/gltf-binary.

Assets are the build-time half. The ai module is the runtime half — the same mtl_api_… token (with the ai:generate scope) lets the running game call hosted models:

import { generateObject, generateText, generateImage, getAiCredits } from "@metaloot/sdk/ai";

// Structured game data, schema-checked, straight into game state.
const { result } = await generateObject(
  { prompt: "3 shop items for a desert outpost", schema: ITEMS_SCHEMA },
  { token: process.env.METALOOT_TOKEN },
);

Agent notes for the ai module:

  • generateObject over generateText + JSON.parse whenever the output feeds game logic — the schema is enforced server-side.
  • Errors are MetalootAiError: branch on isInsufficientCredits (402) and isUnauthorized (401), never on message text. There are no retries in the SDK; add your own backoff if you need one.
  • Generation is billed per call (100 free credits on signup). Prefer generating once at load or level start and caching, over per-frame calls.
  • Pass gameId to attribute usage to a specific game in the portal.
  • Same token-handling rule as private assets: keep an ai:generate token out of shipped browser bundles — proxy the call from your own server.

Publishing

npm login
npm publish --access public

If the @metaloot npm scope is not available on your account, change the package name in package.json before publishing.