The Avatar Paint Studio
- Status: v1 built 2026-08-04 (owner direction, same day); persistence built 2026-08-06 — a save is login-only and writes
avatar_paintings(ADR-0007), with the relay cache still in front as the fast read path. - Related:
docs/SPACE-MUSEUM-DIRECTION.md§10 (presence),docs/PERFORMANCE.md(avatar import; texture sizes), ADR-0005 (where data lives).
What it is
Pick an avatar on the landing page, press Paint, and color the actual 3D body full screen: brush, spray, dot and eraser, a kid-sized palette plus a free color, size slider, undo and clear. There is deliberately no live painting here — strokes are plain pigment on the avatar's own texture, nothing animates. One finger (or the left button) paints; two fingers, the right button, or the Move tool turn and zoom the model. The studio opens in front of the avatar — the camera starts along the body's own forward axis, including the mount trim, so every avatar greets the painter face-on (owner 2026-08-06; it used to open behind the model).
The eraser (owner 2026-08-06) is the brush's stroke with a different stamp: instead of laying pigment it redraws the factory coat inside each disc — one small Clear, walked along the drag. Erasing to transparent would punch holes in an opaque body, so on a model that already wears a skin, rubbing the paint off means the skin comes back. Erasing a part nobody painted does nothing and, deliberately, does not add that part to the save payload.
There is deliberately no line tool: dragging the brush straight already draws a line, and a press-then-release tool needed its own path rules — a second way for a stroke to go wrong, for no new ability (owner 2026-08-05, after the line's first version arrived broken).
Who may paint, and who is who (owner 2026-08-05)
The sign-in form itself is one implementation, src/inception/auth/signInPanel.ts, mounted by both the museum's Esc menu and this studio — including the dev shortcut, which is only offered on a development origin (auth/localOrigin.js: loopback, private LAN addresses and .local names, since npm run dev serves on 0.0.0.0 for iPads).
Painting requires signing in. Not as a paywall — because a painting has to belong to somebody. Without an account, every tab mints its own painting id and one child ends up with several avatars competing to be the latest, which is exactly the mess this rule ends. Guests may still open the studio, turn the model and look; the tools, Save and Enter are disabled behind a sign-in card, and a guest's drag turns the body instead of painting it.
Every explorer has an identity, guest or not (src/inception/net/identity.js):
| Who | Identity | Why |
|---|---|---|
| Signed in | sha256("account:" + email), first 16 hex | The same person on any device or tab is ONE explorer |
| Guest | a random 16-hex id kept in this browser profile | Multiple tabs still resolve to one avatar |
Where to look when it seems not to work (owner 2026-08-05):
- The browser prints
presence-identity: <kind> <id>at boot. Two tabs of one person must print the SAME id. The guest id is stored atlocalStorage["inception.guestId"]; an account's id is derived from the address every time and never stored. - The API prints
presence: retired <socket> — identity <prefix> opened a newer tabwhen the rule fires. No such line means the rule never ran: the API is a plain Node process, sonpm run apimust be RESTARTED after a server change, and the deployed API needs a deploy. - Plain http on a LAN address is not a secure context, so the browser has no
crypto.subtlethere. An account then falls back to this profile's guest id (the boot line says so). Multi-tab still collapses to one explorer; only "the same account on two devices is one person" waits for https.
The identity travels only on join, only as that opaque hash — never an email — and is never relayed to other clients. The server keeps the latest connection per identity: a new tab makes the older one retire (it is told replaced, stops publishing, and everyone else sees it leave), so three tabs of one child are no longer three avatars in the corridor.
A private window is a different browser profile and therefore a different guest. That is not something a web app can or should try to prevent.
A painting belongs to one avatar. The record names its avatarId, and a painting is only ever worn by that body: material keys are positions in THAT avatar's material list, so putting an angel's painting on a baby astronaut smears unrelated textures over unrelated parts. Presence therefore also carries av, the explorer's chosen avatar slug, and a mismatch is refused on both sides (the puller skips it, and the renderer refuses it anyway).
Data relations (for the persistent layer, ADR-0007)
users (email) ← identity comes from Lucas Account
├─ avatar_paintings 1..n one per email today; up to 3 per avatar later
│ └─ avatar_id WHICH body it was painted for (never mixed)
└─ avatars (ownership) 0..n the avatars this account may use
· public avatars everyone sees them in the catalog
· private avatars only their owners see them ("自己用自己 own 的")
and assets still arrive through the repo's
provenance review — there is NO upload path
Read as sentences: an account owns avatars and owns paintings; a painting is for exactly one avatar; a world owns rooms (ADR-0007) and none of that is per-world — your painted body follows you into every world. Since 2026-08-06 the wiring exists: POST /api/paintings verifies the account token, writes the users row, and stores the painting, while GET /api/paintings/:id reads the cache first and the database second. The landing page asks GET /api/avatars for the ownership registry and offers only the avatars it names (public plus your own private ones).
Painting happens in texture space: the pointer ray hits the mesh, the hit's UV names a pixel on that material's albedo canvas, and the tool stamps there (src/inception/studio/paintTools.js is the pure geometry; avatarStudio.ts is the app).
A paintable surface is one MESH, not one material — measured the hard way (owner 2026-08-05: "one line came out as many"). The baby astronaut's seven suit parts share a single fabric texture AND the same full 0..1 UV square, so a stroke in that texture appeared on both arms, both legs, the torso and both boots at once. Each mesh is therefore its own surface, and at its first stroke it takes a copy of the material it was sharing (isolateSurface, copy-on-write). Consequences worth knowing:
- Nothing is duplicated for parts nobody paints, so painting one arm costs one texture — and a saved painting carries only the parts that were painted, which keeps the payload small.
- Clear puts the factory textures back and drops the paint canvases, so it now clears every part rather than only the ones that happened to have a canvas. It cannot be undone, but nothing was saved either: reloading the studio brings back the last SAVED painting.
Strokes are walked across the SCREEN, not across the texture. A scanned body's UVs are many small islands, so a straight path between two texture positions leaves the surface and the stroke arrives in pieces. The brush therefore samples its path every few screen pixels and traces each sample back onto the model; consecutive samples are joined in texture space only when they land on the same material and close together, and at a UV seam the stroke stamps instead of smearing across the atlas. The result reads as one continuous mark on the body, which is the only place continuity matters.
How a painting travels (built)
The design goal (owner): the WebSocket must never carry image bytes — only an id — and peers fetch the actual painting once.
- Save flattens each painted material to a small lossy JPEG (512², quality 0.82 — visual loss accepted by design) and stores the painting locally per avatar (
localStorage,paintStore.js). - The painting's id is its content hash (SHA-256 prefix). The same painting has the same id on every machine, so uploads are idempotent and caches can never go stale.
- Save (and every museum boot) uploads the painting —
POST /api/paintingswith the account token → id. The upload lands in two places: the in-memory LRU relay cache (server/paintings.js, validated by shape and capped in size) and theavatar_paintingsrow keyed by the verified email. A guest, or a laptop dev session, has no token to send and skips the upload; the paint still applies locally. The response says{ id, persisted }—persisted:falsemeans the cache took it but the database refused, which is a warning, never a failed Save. - Presence carries only
pt, the id, as a strictly format-checked field (presenceProtocol.js— a hex hash, never free text). - A peer seeing an unknown
ptchecks its local painting cache first, then pullsGET /api/paintings/:idonce (the response is immutable and cacheable), stores it locally (last 10 kept), and dresses that explorer's rig. Remote rigs wear cloned materials, so one player's paint can never bleed onto another's body. A cold cache — a restarted server — is served from the database instead: the row is re-serialized by the studio's own canonical rule (v, avatarId, sorted materials), so the bytes still hash to the id the peer asked for.
Failure is soft everywhere: no API → the paint still applies locally and the museum is simply a solo museum; a 404 id → the peer keeps the factory coat and the pull is retried with backoff (see below).
Two rules this cost us a bug to learn (2026-08-05)
- A painting names materials by position, so that rule may exist only once.
studio/paintTargets.jsis it: walk the scene, every textured material not yet seen takes the next number, and a material shared by several meshes counts ONCE. The baby astronaut's suit is worn by seven meshes; a remote clone that cloned materials per-mesh turned one paintable surface into seven, shifted every key after it, and showed a peer's paint on one patch of suit with the face and hair left bare. Remote rigs now clone one material per SOURCE material, preserving the sharing the studio saw. - A 404 from the relay is a "not yet", not an answer. A peer announces its painting id over presence the instant it joins, while its own upload is still in flight (and Render's free API may be waking up). Treating that first 404 as permanent left peers' paint invisible for the whole session.
net/paintPull.jsretries with exponential backoff (2s → 20s, six tries) and only then stops asking. A pulled painting is applied immediately and held in memory, so correctness never depends on localStorage having room — the device cache is a pure optimization and its quota errors are swallowed.
The persistence plan (owner 2026-08-04) — BUILT 2026-08-06
Recorded verbatim as product intent; the checked items now exist in code.
- ✅ Signing in is required to paint and save. The studio itself stays open for trying, but persisting a painting requires a Lucas Account session, and the painting is saved to the database, not just the relay cache.
- ✅ One painting per email for now (a save replaces, it never piles up). The schema already allows several: later, an account may keep up to 3 paintings per avatar and choose which one to wear.
- Sketch (per ADR-0005: documents in Postgres, blobs out of the database):
CREATE TABLE avatar_paintings (
id TEXT PRIMARY KEY, -- content hash, same as the relay id
email TEXT NOT NULL, -- verified JWT subject
avatar_id TEXT NOT NULL,
painting JSONB NOT NULL, -- v1 record; move to R2 if it grows
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- "one per email now": UNIQUE (email); relax later to
-- UNIQUE (email, avatar_id, slot) with slot < 3 per avatar.
- ✅ The write endpoint re-checks the session server-side (the same verifyBearer the rooms API uses) and rejects dev sessions, exactly like the room-save rule. The relay cache stays in front as the fast path; the database is the durable copy that survives restarts.
- Still to come: your paint on a NEW device. The row is keyed by email and
store.paintingFor(email)already answers the question, but there is no endpoint that hands you your own painting back yet — today the studio reads it from this device'slocalStorage. Signing in on a fresh iPad therefore still shows the factory coat. - Moderation lever (school reality): a painting is only ever visible to others through presence, so disabling an account's
ptpublishing is a complete kill switch.
Watching it work
Painting bytes never travel over the WebSocket — only the id — so the two halves are inspected in different places:
- Console, filtered to
presence-paint: one line per event — which peer now wears which painting and how many surfaces it covers, or that an id is not in the relay yet and will be retried. - Network, filtered to
/api/paintings: the POST that publishes your painting (200 +{id}) and the GET each peer makes once (200, or 404 while an upload is still in flight). - WS frames: select the
/presencesocket → Messages, filter"pt":"[0-9a-f]{16}"to see exactly which peers are announcing a painting id.
Performance notes
- A painting is a few tens of KB (lossy JPEG per material), pulled at most once per peer per session and cached locally.
- In the museum, applying a painting swaps a material's map texture once — no per-frame cost, no paint scheduler involvement.
- The studio paints on canvases capped at 1024² and exports at 512²; the avatar's own oversized scan textures are already capped at load (
docs/PERFORMANCE.md, "Importing an avatar").