- The preview is now a turntable: drag it to rotate the avatar and see all
sides; it slowly auto-spins after ~2.5s of no input. Starts facing the
camera so you see the face. "drag to rotate" hint under the viewport.
- The big grey void to the right of the controls is gone -- the preview
panel now expands to fill the leftover width; the controls dock to the
right edge at a fixed width.
- Camera reframed to fit the whole ~2 m body; softer preview lights.
- Placeholder avatar: torso/arms/legs now share one 0.46 m depth so a limb
always covers the torso slice behind it -- kills the bright "cyan seam"
(it was a lit sliver of the torso side peeking past the thinner arm).
Verified headless (boot, 2p multiplayer, avatar smoke source+pack,
fresh-clone) + eyeballed the customization screen and the avatar seams in
rendered screenshots.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
ESC: player.gd changed scene directly while game.gd._exit_tree closed the
socket, which re-fired server_disconnected -> _on_server_lost ran mid-
teardown (add_child on an exiting node, await on a null get_tree(), a
second change_scene_to_file). Moved ESC to game.gd as one ordered path:
_leave_world() sets a _leaving guard, disconnects the _on_server_lost
handlers before closing the connection, then changes scene. _on_server_lost
and _exit_tree now respect the guard. Verified headless: ESC -> world_list,
no errors; 2p multiplayer still connects.
Avatar faced +Z but a Godot node's forward is -Z, so walking forward
showed the character its own face. Rebuilt the placeholder facing -Z (face
plates, eyes, jaw, toes, and front-mounted attachments all moved to
negative Z; backpack/ponytail to +Z). The procedural walk was already
written for -Z forward, so it's unchanged.
Build stamp was there but tiny/low-contrast bottom-left -- bumped to 20px
opaque yellow with a heavy outline, moved to the top-left strip.
limb_animator: guard _process with is_instance_valid(_skel) so a frame
landing during avatar teardown can't touch a freed skeleton.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
Now that cloudbison can render (Radeon GPU via Xvfb, or llvmpipe software),
added scripts/tools/screenshot_rig.gd + scenes/dev/ so a scene can be booted,
run a few frames, and its rendered frame saved to PNG -- caught three things
the headless smoke never could:
- limbs floated detached from the torso: widened the torso and overlapped the
arm/leg boxes into it, and pulled limb depth in behind the torso front so
they stop z-fighting.
- proportions were scrawny: reworked to Roblox R6 ratios (2-stud torso, 2-stud
arms/legs, 1-stud head) with the head clearly pegged on top.
- the walk swing was a 40-deg lunge: STRIDE 0.7->0.42 rad, separate 0.32 arm
swing, KNEE 1.0->0.5. Reads as a walk now, not a crouch.
test_level lighting was near-black on the shaded side (ambient was disabled) --
colour ambient at 0.8 + a stronger key light, so the avatar reads in-world.
avatar_preview.tscn (AGP_PREVIEW_WALK=1) renders the assembled avatar walking
in place for quick visual checks.
Verified: avatar smoke 14/14 source + exported pack; fresh-clone boot PASS;
eyeballed in rendered screenshots (game scene + walk sequence).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
The blocky body rendered fully black from every angle: _append_box wound
its faces clockwise-from-outside, so every computed normal pointed inward
-- an inside-out shell, lit from behind on all sides, and the level had
ambient light disabled so nothing lifted it. Fixed the winding (normals
now verify outward 234:36) and switched test_level's environment to a
soft colour ambient so shaded sides read grey, not pure black.
Limbs were static because the rig had no animation at all. Added
scripts/avatar/limb_animator.gd: a clip-free walk cycle that swings the
upper arm/leg bones with ground speed, folds the knees on the rear
stride, and eases back to rest when idle. Wired into the local player
(speed from velocity) and remote players in game.gd (speed from the
per-snapshot authoritative position delta).
Verified: avatar smoke 14/14 from source and exported pack; fresh-clone
boot PASS; game + menu boot headless clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
Playtest log had the smoking gun:
WARNING: player.gd: no avatar to show; keeping the placeholder capsule
ERROR: Parameter "data.tree" is null. at: get_tree
avatar_loader.gd read raw .glb bytes and ran GLTFDocument.append_from_buffer
+ generate_scene() at runtime. In a release export that path produced NO
Skeleton3D (works fine in the editor / headless-from-source), so
load_base_body() returned null and the player fell back to the capsule.
The queue_free() calls in that path also errored (loader runs detached from
any tree).
Fix: load(path).instantiate() the Godot-imported PackedScene (the .scn that
actually ships in the pack, with the skeleton + skin set up by the import
pipeline). The raw-bytes GLTFDocument path stays available for future
user-uploaded models but isn't used for the bundled catalog.
- run_checkpoint_tests.py: also runs the avatar smoke against the EXPORTED
pack, not just source -- that's what catches release-only resource
differences. Verified: 14/14 against export/game.pck.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
- SpringArm3D was colliding with the player's own capsule (same layer) ->
camera yanked up against the head. add_excluded_object(self) in _ready +
gentler default pitch, shorter arm, lower pivot.
- AvatarSystem.build_current_avatar() is now self-sufficient: loads the
catalog itself and falls back to a bare default loadout if login didn't
prime one. So the in-world player always shows an avatar, never the
capsule, as long as the assets are packed (they are now).
- New AvatarSystem.assemble_avatar(): builds a standalone avatar with a
throwaway loader, touching no shared state -- so it's safe to call once
for the local player AND once per remote peer. (load_avatar()'s shared
loader reset()s and freed the previous avatar on every call.)
- game.gd: remote players render as the assembled avatar too (shared default
look, tinted per-peer, name tag) instead of a plain capsule.
Client-side -- rebuild the exe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
You couldn't see your own character because the camera was first-person
(inside the avatar's head), and the customize screen's 3D preview rendered
nothing.
- player.tscn: camera now hangs off a SpringArm3D on a CameraPivot, 4 m
behind + above the player, pulled in on collision. player.gd: yaw still
turns the body, pitch orbits the arm (starts tilted down to frame the
avatar).
- avatar_customization_ui.gd: the preview SubViewport had own_world_3d=false,
so it shared the Control-only main world -- no scenario, nothing rendered.
Set own_world_3d=true, add a WorldEnvironment with ambient fill + a
key/fill light pair, make the camera current, tweak framing.
- smoke_test_player: assert the third-person SpringArm rig (18/18).
- smoke_test_enter_world: wait longer for the scene change -- a real
POST /join spawns a dedicated server, which is slow (9/9).
Client-side change -- rebuild the exe to get it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
- api_client.gd: get_avatar_loadout() / save_avatar_loadout()
- main_menu.gd: on successful auth (fresh or session-restore), load the
catalog + fetch the saved loadout into AvatarSystem so the in-world
player renders as the player's avatar. Best-effort: a slow/failed prime
never gates getting in (UI goes authenticated first, prime runs after).
New "Customize Avatar" button (enabled only when signed in).
- avatar_system.gd: local-player convenience API -- ensure_catalog(),
set_loadout_from_dict() (from the backend's GET shape), has_loadout(),
build_current_avatar().
- player.gd: swaps the placeholder capsule for the assembled avatar
(feet-aligned to the capsule bottom) when a loadout is primed; silent
fallback to the capsule otherwise. Camera stays first-person for now
(third-person is a noted follow-up).
- avatar_visualizer.gd: rewrote it -- the old version referenced
AvatarLoader.SlotResult / load_slot() which never existed, so it didn't
compile and took avatar_customization_ui.gd down with it. Now a thin
wrapper over AvatarLoader for the preview.
- avatar_customization_ui.gd: seeds from the live loadout, saves through to
AvatarSystem + PUT /api/avatar/loadout (local file kept as fallback),
fixed a Camera3D.look_at() before-in-tree crash.
- scenes/avatar_customization.tscn rebuilt (old one used the Godot-3
`ViewportContainer` node type) + avatar_customization_screen.gd wrapper
that returns to the menu on close.
- smoke_test_avatar_in_world.gd: primed loadout -> game.tscn -> player
shows the assembled avatar, capsule hidden, rig + bone mounts present.
Verified: cold-cache smokes green (player 15/15, avatar_system 14/14,
avatar_in_world 10/10, main_menu_ui 13/13); against a live backend
main_menu_full_flow 12/12, main_menu_back 20/20, world_list 4/4,
enter_world 9/9, e2e 4/4; plus a manual register->default-loadout->
customize->save->re-fetch-persisted round trip.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
assets/avatars/catalog.json referenced 14 GLB files that were never in the
repo, so the avatar system could not load a single avatar. And the loader's
assembly path had never run against a real skeleton -- it called
Skeleton3D.has_bone() (no such method in Godot 4) and hand-copied bones in a
way that dropped the skin binding.
- scripts/tools/gen_placeholder_avatars.gd: generates the pack headlessly via
GLTFDocument -- one rigged/skinned base body (the full 24-bone canonical
rig from a T-pose) + 13 primitive attachments. Crude on purpose; real art
swaps in as a pure content change (catalog paths unchanged).
- assets/avatars/*.glb: the generated pack (checked in).
- avatar_loader.gd: rewrote _parse_glb_as_avatar to lift the skin-bound
Skeleton3D that generate_scene() already produces, instead of rebuilding it.
Attachments now mount via BoneAttachment3D to the catalog's mount_bone and
are actually added to the avatar tree (they were parsed then dropped).
Fixed the has_bone() crash, the null ARRAY_BONES cast in _retarget_skin,
and the Array[String] type mismatch on the accessory list.
- smoke_test_avatar_system.gd: replaced the "asserts it fails gracefully
because the files don't exist" section with a real assembly check --
Skeleton3D present, full 24-bone rig, canonical bones resolve by name,
visible body mesh, equipped items mounted to bones. 14/14 from a cold cache.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
Root cause: a stale .godot/global_script_class_cache.cfg was force-committed
to the repo (last regenerated before the avatar system merged), and Godot
does not rebuild the global class registry on a plain headless scene run --
only an editor scan / `--import` does. So every cross-file `class_name`
reference in the avatar module failed to resolve from a fresh clone, and the
AvatarSystem autoload failed to instantiate entirely.
- Untrack .godot/ and sandbox_probe/.godot/ (already in .gitignore; were
force-added during old WIP checkpoints)
- Reference sibling avatar scripts via preload() constants instead of their
global class_name, so the module parses independent of scan order. Keeps
the class_name declarations for editor/tooling use.
- avatar_loadout.gd's static from_json() factory self-references via a
preload const too (a script can't resolve its own class_name mid-compile
on a cold cache).
- smoke_test_avatar_system.gd: same preload treatment so it runs from a bare
`--script` invocation.
Verified from a wiped .godot/: main_menu.tscn and game.tscn boot with zero
script errors; smoke_test_avatar_system 10/10, smoke_test_player 15/15,
smoke_test_main_menu_ui 13/13, smoke_test_test_level PASS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
- avatar_slot_registry.gd: _slot_data/_slot_definitions made static (every
caller uses AvatarSlotRegistry.method() on the class, never an instance,
so the old instance _init() populating them was never invoked); moved
registry build to static func _static_init()
- avatar_catalog.gd: compute_loadout_hash() return type uint32 -> int
(GDScript has no fixed-width uint type)
- avatar_loader.gd: GLTFDocument.parse() (doesn't exist) -> the real
append_from_buffer()+generate_scene() API; Error.parse_error() (doesn't
exist) -> error_string(); Mesh.ARRAY_JOINTS (doesn't exist) ->
Mesh.ARRAY_BONES; removed a dead 'mesh is MultiMesh' check that can
never be true (MeshInstance3D.mesh is always Mesh, never MultiMesh);
fixed _merge_node_into_avatar to recurse into a Skeleton3D's children
(skinned meshes are commonly children of the skeleton in a GLB export,
the old code returned before ever reaching them)
- avatar_customization_ui.gd: added the missing _on_slot_button_pressed
handler (referenced by a button connect, never defined -- just calls
the existing _select_slot())
Verified: godot --headless --import + --quit is fully clean, and the
avatar_customization.tscn scene now instantiates successfully.
Remaining, scoped separately: avatar_visualizer.gd calls a
AvatarLoader.load_slot() static async API and SlotItem/SlotResult types
that don't exist -- AvatarLoader's real API (matching the project's own
avatar.md spec: one canonical skeleton, attachments retargeted onto it)
is instance-based load_base_body()/load_attachment(). AvatarVisualizer
needs rewriting to use that real API; it's the one out of step with spec,
not AvatarLoader.
- scenes/avatar_customization.tscn: script_extense -> script (typo meant
the customization UI script was never actually attached to the scene)
- scripts/avatar/avatar_loadout.gd: add missing class_name AvatarLoadout
(avatar_system.gd references the type everywhere; only this file among
the avatar scripts lacked its class_name)
- scripts/main_menu.gd: _ready() referenced _customize_button/_settings_button/
_quit_button but the @onready vars are named without the leading
underscore