Map Editor Documentation

Custom Units & Models

Giving a custom unit or building a 3D model: modelUrl and mod: references, the limits a model must fit, animation clip naming, effect sockets, and what happens when a model fails.

Presentation · Page 19 of 46

A custom unit starts as a data override: a clone of an existing unit with different numbers. This chapter is about the next step, giving it a different look: a 3D model of your own, hosted anywhere on the web, or embedded in a mod.

Three things to know first:

  • Models are presentation only. Whether a player sees your model, the unit's shipped sprite, or nothing because their browser could not download the file, the match plays identically for everyone. A broken model can never desync a game.
  • Sprite art cannot be custom. spriteKey, iconKey and portraitKey must name art the game already ships, and a clone that names none uses its base's (see sprite art for a clone). Only 3D models (and sounds) can come from outside.
  • The Data module has no model URL field yet. Its Art section edits spriteKey, modelId, renderScale and a bake yaw offset. modelUrl goes in the JSON: import it with File → Import from AI / JSON…, or ship it in a mod.

Pointing a unit at a model

{
  "units": [
    {
      "id": "frost_drake",
      "baseId": "dwarf_thunderhawk",
      "patch": {
        "name": "Frost Drake",
        "hp": 750,
        "modelUrl": "https://example.com/models/frost_drake/character.glb",
        "animationsUrl": "https://example.com/models/frost_drake/animations.glb",
        "spriteKey": "dwarf_thunderhawk",
        "renderScale": 1.25
      }
    }
  ]
}
FieldMeaning
modelUrlThe model: a .glb or .gltf
animationsUrlOptional separate file of animation clips for the same skeleton. Absent: clips are read from modelUrl itself
spriteKeyThe shipped sprite used in previews and whenever the model cannot be shown. Leave it out and a clone uses its base's, which is usually what you want
renderScaleVisual size multiplier. Does not change the unit's footprint or collision

A modelUrl or animationsUrl must start with https://, http:// (accepted with a warning, and blocked by browsers on the HTTPS site, so do not use it), or /assets/, and end in .glb or .gltf; or it can be a data: URI or a mod: reference. A .sosmod package is stricter about its top-level models: each is an https:// URL or a data: URI, and http:// there is an error. Use https:// everywhere.

Sprite art for a clone

A clone has no custom sprite art of its own. Every piece of it comes from its base unless the clone names a shipped key itself, for units and buildings alike:

FieldDrawsLeft out
spriteKeyThe unit or building's sprite for placement previews, build-menu buttons, construction stages, rubble, and when a model is turned off, blocked, failed or still loadingThe base's sprite
portraitKeyThe selection portrait, the selection tray, control groups, and garrison and cargo slotsThe base's portrait
iconKeyTrain buttons, the production queue and building-upgrade buttonsportraitKey, then the base's portrait

Units in 3D have one step before the sprite. While a unit's model is loading, or after it fails, the unit is drawn as an animated billboard rendered from that model, where the game ships one. A unit clone without modelUrl uses its base's billboard, even if the clone changes race or spriteKey. A clone with its own modelUrl shows its sprite instead, because no billboard was ever rendered from your file.

A key you name is looked up in the definition's own race folder, which is where the validator checks it. A key you leave out is found wherever the base keeps it, so a clone that changes race still shows its base's art.

Setting spriteKey on a shipped building (a patch with no baseId) changes that building's sprite. A clone of that building keeps the original art, because clones are built from the shipped definition, not from your patch.

The host must send CORS headers that allow the game's origin (Access-Control-Allow-Origin: * is simplest). Without them the browser refuses the download, and the unit shows its sprite.

Reusing a shipped model on a clone

A clone does not automatically get a different shipped unit's model by setting modelId: that field is a loading key, not a pointer to another unit's files. To give a clone a model the game ships, set modelUrl to that model's file:

{
  "units": [
    {
      "id": "runesmith_elite",
      "baseId": "dwarf_runesmith",
      "patch": {
        "modelUrl": "https://assets.shardsofstone.com/assets/models/dwarf/units/dwarf_runesmith/character.glb",
        "animationsUrl": "https://assets.shardsofstone.com/assets/models/dwarf/units/dwarf_runesmith/animations.glb"
      }
    }
  ]
}

The bundled Frost Warden sample mod points its clone at the same two runesmith files, through its models map (see below).

modelId values are listed in the Asset Catalog.

mod: references

A mod (or a map's defOverrides) can name its models once in a models map, and refer to them by key:

{
  "models": { "drake_body": "https://example.com/drake.glb" },
  "units": [{ "id": "frost_drake", "baseId": "dwarf_thunderhawk", "patch": { "modelUrl": "mod:drake_body" } }]
}

mod:<key> is replaced with the URL when the match starts. An unknown key is a validation error. In a .sosmod, a top-level models value may also be an embedded data: URI of up to 256 KB, within the package's 4 MB total. Anything bigger has to be hosted.

Importing a GLB in the Forge

In Data, select or clone a unit or building and open its model fields. Browse picks from shipped models; Import GLB file embeds a self-contained file up to 2 MB in the map. The animation field accepts a separate GLB the same way. Embedded files survive drafts, exports and Test Map. The whole map has a 4 MB limit, so use a hosted HTTPS URL for larger files.

The import uses the shared asset validator and reports unsupported compression, external file dependencies and model-budget errors before saving. Use Browse & preview in the placement palette to inspect the selected model; the 3D editor loads the same file as the game.

Limits

When a model from outside the game loads, it is measured after download:

LimitValueOver the limit
Triangles40,000Model rejected, sprite shown
Texture size2048 px on the longest sideModel rejected, sprite shown

The same limits apply to units and buildings. A rejected URL is not fetched again for the rest of the session. More than 20,000 triangles logs a notice in the browser console but loads.

There is no download size limit or timeout in the game itself, so a large file simply takes a long time to appear. Keep files small: texture compression and 1024 px textures do far more for download size than trimming triangles. The model tool below flags files over 35 MB.

If the animation file fails but the model loads, the model is shown without those animations.

Players can turn external models off

Settings → Graphics → Load custom models from third-party hosts (on by default). With it off, the game only loads models from its own servers, so a custom map never contacts another server for a model; units fall back to their sprites. data: models embedded in a mod still load. Design so that the sprite is an acceptable fallback.

Animations

Clips are matched to the game's nine animation states by name. A clip name is lowercased with everything except letters and digits removed (Armature|Walk_Cycle.001 becomes armaturewalkcycle001), then checked for these substrings:

StateClip name containsPlays
idleidle, confusedscratchStanding still. Several idle clips are cycled
walkwalk, walking, walkcycle, move, moving, baselayer, unrealtake, unrealMoving
attackattack, attacking, hit, swing, strikeEach attack; several clips are varied between
deathdeath, die, dying, deadOnce, holding the last frame
gathergather, harvest, spellcast, magespellcast, mage (and the misspellings magesoellcast, soellcast)Workers harvesting
hopjump, leapUnits that can hop
special_1special1, ability1, spell1, cast1Spell or ability slot 1
special_2special2, ability2, spell2, cast2Slot 2
special_3special3, ability3, spell3, cast3Slot 3

baselayer and unreal are there because Meshy exports a single loop as Armature|Unreal Take|baselayer. Name your clips deliberately: a clip called hit_react lands in the attack pool.

Vehicles, ships and flyers that have no skeleton need no animation file; the renderer moves them procedurally.

Effect sockets

Projectiles, muzzle flashes and spell effects start from named points on the model, if it has them. Add a bone or an empty object with one of these names (compared after lowercasing and removing punctuation, as the whole name or its ending):

SocketNamesUsed for
muzzlemuzzle, muzzleflash, barreltip, guntip, weapontipWhere projectiles and muzzle flashes start
weapon_tipweapontip, swordtip, bladetip, stafftip, tipweaponMelee trails, staff glows
hand_rhandr, righthand, mixamorigrighthand, mixamorighthand, handright, rhandCasting effects

muzzle and weapon_tip fall back to the right hand, and without any of these the game uses an offset from the model's bounds.

Making a model

Any tool that exports glTF works. Some practical advice for an RTS camera, which sees units from high up and far away:

  • Silhouette over detail. Exaggerate weapons, heads and shoulders; fine surface detail disappears at game zoom.
  • Feet on the ground, centred. The model's origin is the unit's position on the ground.
  • Facing. If the model faces the wrong way in game, fix it in your 3D tool; there is no rotation field for external models.
  • One skeleton for both files. If you split character.glb and animations.glb, bone names and hierarchy must match exactly, or those limbs will not animate.

/tools/model-merger

AI generators such as Meshy often download each animation as its own GLB. The in-browser model merger combines them: drop in the character and the animation files, check the triangle count, texture sizes and how each clip was classified into the states above, adjust any that are wrong, and download a matched character.glb and animations.glb. Its Inspector tab checks an existing file against the limits.

Blender

Keep each animation as its own Action and push each one to an NLA track (or stash it), so the glTF exporter includes it. Name the actions with the words from the table above.

Checklist

  1. Export a GLB under 40,000 triangles with textures of 2048 px or less.
  2. Name clips so they match the states you need; check them in the model merger.
  3. Host the file somewhere that serves HTTPS with CORS, or embed it in a mod as a data: URI of at most 256 KB (base64 makes that a file of roughly 190 KB).
  4. Clone a unit whose role and size match, set modelUrl (and animationsUrl) and keep a sensible spriteKey.
  5. Validate, then Test Map in the 3D view. Check the browser console if the sprite shows instead of the model: a rejected model says why.

Next: Asset Catalog.