You build a character for months. Then you build the feature that lets you fire him.
The Feature Nobody Asked For (Until They Saw It)
SpiceX has one avatar. His name is Cleetus, he’s a pink escaped SpiceX employee with a tragic backstory and 121 animation clips, and he is, at this point, load-bearing. The whole animation stack — the clip library, the mixer, the FSM — is bolted to his skeleton by name and by cached bone index.
So naturally, the next feature was: drag any .vrm onto the game window and watch it become the player.
Not an editor-time import. Not a “restart the game with a new model” flow. Live swap, mid-play, hair-and-skirt physics included. And the twist that makes it interesting: zero lines of the animation or controller code change. The FSM keeps running. Cogito stays untouched. The new avatar just… starts dancing the existing choreography.
This post is the swap path — what has to happen between the OS files_dropped event and a stranger’s VRM doing Cleetus’s run cycle.
Problem 1: Runtime Import Isn’t Free
Here’s the first trap, and it’s a good one. Godot’s VRM support lives in addons/vrm, and the plugin’s own plugin.gd contains this confession at line 203:
“Be sure to also register at runtime if you want runtime import. This editor plugin script won’t run outside of the editor.”
Translation: in the editor, .vrm files import beautifully. In a running game, they import as dumb glTF — correct mesh, wrong everything else. No humanoid retarget. No spring bones. No MToon. If you drop a VRM into a play-mode build without registering anything, you get a stiff mannequin with no hair physics and clip tracks that bind to nothing.
The fix is registering the five VRM glTF document extensions yourself, at runtime:
# The editor plugin does this for you. The running game does not.
VRMC_vrm.register()
VRMC_node_constraint.register()
VRMC_springBone.register()
VRMC_materials_hdr_emissiveMultiplier.register()
VRMC_materials_mtoon.register()
Spring bones are on that list for free — VRMC_springBone runs entirely at runtime once registered, so dropped avatars get hair and skirt physics without you writing a physics line. That alone sells the feature.
The Load: PackedScene Round-Trip
The second trap is in the load path itself. The obvious append_from_file → generate_scene has known runtime-import glitches, so the working path (a V-Sekai workaround from godot-vrm#126) is:
GLTFDocument.append_from_file()— parse the.vrmgenerate_scene()— build the node treeps.pack(node)— collapse it into aPackedSceneinstantiate()— pop it back out
That round-trip through PackedScene smooths out whatever the direct path chokes on. One more flag and you’re safe:
state.handle_binary_image = GLTFState.HANDLE_BINARY_EMBED_AS_UNCOMPRESSED
Skip that and some VRMs crash the engine outright — BasisU texture compression on import is a known killer (godot-vrm#72). “My game hard-crashed on a cosplay model” is not a bug report you want to file.

Problem 2: The Animation Library Belongs to Cleetus
With the avatar loaded, the real problem appears. The dropped VRM’s AnimationPlayer contains… blink and lookAt clips. That’s it. The VRM spec ships facial-animation clips on the avatar; the entire body animation library is Cleetus’s, and it lives on his AnimationPlayer.
The naive fix is pointing the FSM at the new AnimationPlayer and hoping. The hope dies fast: humanoid clips recorded on one skeleton don’t play on another just because both skeletons have a LeftHand.
retarget_library_from()
So the swap copies the whole library across:
# spice_anim_controller.gd
func retarget_library_from(old_player: AnimationPlayer) -> void:
for lib_name in old_player.get_animation_library_list():
var lib := old_player.get_animation_library(lib_name)
var new_lib := lib.duplicate(true)
new_player.add_animation_library(lib_name, new_lib)
This works because of groundwork laid months ago: the clips are humanoid-renamed. The retargeting post covered how the library got built (Blender bone mapping, glTF imports, the “Make Local” trick); the VRM import extension runs its own perform_retarget() with SkeletonProfileHumanoid at import, so tracks named for humanoid bones bind onto the new skeleton automatically. Same naming convention, two different pipelines, one shared vocabulary.
Not everything survives the trip. Tracks targeting Cleetus-specific helper bones (his little quirks — tail physics, accessory bones) get silently dropped per-VRM, with a console warning. That’s the honest summary of the whole feature: “works, with per-avatar tuning,” not pixel-perfect. A dropped VRM runs the full clip library; a few of Cleetus’s personal flourishes just don’t apply.
Problem 3: Cached Bone Indices Go Stale
The sneakiest bug in the whole path. spice_fighter.gd caches bone indices for head-look — the integer index of Neck and Head on Cleetus’s skeleton. But bone indices are skeleton-specific. The new avatar’s skeleton has a completely different index layout, so after a swap, head-look would either freeze or wrench the neck toward some random bone.
_bind_skeleton() re-resolves indices by name after every swap:
func _bind_skeleton() -> void:
skeleton = visual.find_child("GeneralSkeleton", true, false)
neck_idx = skeleton.find_bone("Neck")
head_idx = skeleton.find_bone("Head")
Find-by-name every time, never trust a cached index across a skeleton swap. That’s the rule this bug taught us, and it’s cheap insurance: one function, called once per swap, kills an entire class of “why is his head doing that” bugs.
The Rename That Keeps Everything Else Working
The last piece is the laziest and best trick in the path: the loaded VRM node gets renamed to CleetusVisual.
Not a refactor. Not an interface. A rename. Every downstream path — animation controller bindings, look-chain, visibility toggles, the FSM’s idea of where the body is — resolves through that node name. Swap the model, keep the name, and nothing downstream knows or cares that the skeleton underneath changed. (The one thing that does care — the cached bone indices — is exactly what _bind_skeleton() exists for.)
It’s the “when in doubt, rename the node” school of software engineering, and it works because the rest of the system was already built on name-based lookups rather than hard references.
The Whole Swap Path in One Look
Drag .vrm → OS files_dropped → register 5 VRM extensions (once) → GLTF parse with uncompressed-embedded textures → PackedScene round-trip → instantiate → rename to CleetusVisual → _bind_skeleton() re-resolve Neck/Head indices → retarget_library_from() copy Cleetus’s 121-clip libraries onto the new AnimationPlayer → FSM replays its current state on the new avatar. Spring bones come free. Cogito untouched.
Seven steps, three files touched, no changes to the animation stack, the FSM, or Cogito. The feature is the swap path; the animation system never learns the avatar changed.

What’s Next: Procedural Loco, One Teaser Only
The 121 clips are hand-authored (well — library-authored). The next spike is generated locomotion: kimodo.cpp offline animation.glb first — a skeleton-only throwaway scene to see if the bones actually move — then a ~20-line SOMA-30→humanoid BoneMap dict, then ARDY streaming poses as a sidecar. Nothing is in the game yet, and that’s all this post is saying about it.
Summary
- Register the 5 VRM glTF extensions at runtime — the editor plugin never runs in-game, so an unregistered
.vrmimports as plain glTF with no retarget, no spring bones, no MToon - Load via the PackedScene round-trip (
append_from_file→generate_scene→pack→instantiate) withHANDLE_BINARY_EMBED_AS_UNCOMPRESSEDto dodge the BasisU crash (godot-vrm#72, workaround godot-vrm#126) - Copy Cleetus’s animation libraries onto the new AnimationPlayer (
retarget_library_from()) — the VRM’s own player ships only blink/lookAt, and humanoid-renamed tracks bind on the new skeleton automatically - Re-bind cached bone indices by name (
_bind_skeleton()) — bone indices are skeleton-specific and go stale on every swap - Rename the new node to
CleetusVisual— name-based downstream lookups mean nothing else changes - Spring bone physics come free once
VRMC_springBoneis registered - Honest limit: Cleetus-specific helper-bone tracks drop with warnings — “works, with per-avatar tuning,” not pixel-perfect
