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,iconKeyandportraitKeymust 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,renderScaleand a bake yaw offset.modelUrlgoes 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
}
}
]
}| Field | Meaning |
|---|---|
modelUrl | The model: a .glb or .gltf |
animationsUrl | Optional separate file of animation clips for the same skeleton. Absent: clips are read from modelUrl itself |
spriteKey | The 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 |
renderScale | Visual 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:
| Field | Draws | Left out |
|---|---|---|
spriteKey | The 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 loading | The base's sprite |
portraitKey | The selection portrait, the selection tray, control groups, and garrison and cargo slots | The base's portrait |
iconKey | Train buttons, the production queue and building-upgrade buttons | portraitKey, 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:
| Limit | Value | Over the limit |
|---|---|---|
| Triangles | 40,000 | Model rejected, sprite shown |
| Texture size | 2048 px on the longest side | Model 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:
| State | Clip name contains | Plays |
|---|---|---|
idle | idle, confusedscratch | Standing still. Several idle clips are cycled |
walk | walk, walking, walkcycle, move, moving, baselayer, unrealtake, unreal | Moving |
attack | attack, attacking, hit, swing, strike | Each attack; several clips are varied between |
death | death, die, dying, dead | Once, holding the last frame |
gather | gather, harvest, spellcast, magespellcast, mage (and the misspellings magesoellcast, soellcast) | Workers harvesting |
hop | jump, leap | Units that can hop |
special_1 | special1, ability1, spell1, cast1 | Spell or ability slot 1 |
special_2 | special2, ability2, spell2, cast2 | Slot 2 |
special_3 | special3, ability3, spell3, cast3 | Slot 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):
| Socket | Names | Used for |
|---|---|---|
muzzle | muzzle, muzzleflash, barreltip, guntip, weapontip | Where projectiles and muzzle flashes start |
weapon_tip | weapontip, swordtip, bladetip, stafftip, tipweapon | Melee trails, staff glows |
hand_r | handr, righthand, mixamorigrighthand, mixamorighthand, handright, rhand | Casting 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.glbandanimations.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
- Export a GLB under 40,000 triangles with textures of 2048 px or less.
- Name clips so they match the states you need; check them in the model merger.
- 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). - Clone a unit whose role and size match, set
modelUrl(andanimationsUrl) and keep a sensiblespriteKey. - 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.