• English
  • Faces

    The face belongs to the character, not to a style. All three faces are drawn in the vector, pixel and kaomoji series alike, on the same states, poses and blinks. The default is cute.

    <CharacterMark shape="mochi" />
    <CharacterMark shape="mochi" face="eyes" />
    <CharacterMark shape="mochi" face="line" nose="hook" />
    cuteCute
    eyesEyes
    lineLine
    faceDrawn as
    cute (default)White dot eyes, a small mouth and pink blush painted on the body; some states also throw off a mood effect. See Expressions.
    eyesTwo cut-out eyes and nothing else — the silhouette does the rest.
    lineA nose and a mouth on top of the eyes, all strokes at one line weight.

    cute is the default; pass face="eyes" or face="line" for the others. eyes and line have their own geometry and eye tables. Switching face on a mounted character does not jump: the cute face and the others cross over on the cuteBlend spring.

    cute: the default face

    The cute face is painted on the body in --aora-face (white on every hue; see Palette). Every state has its own eyes, mouth and blush, and the eyes cycle on an interval. The tables and demos are on Expressions; the mood and activity effects of each state are on States.

    On the cute face the belt states do not shrink the body to a bead: the whole character, face included, stays, and what the belt says on the other faces is said by an effect beside it.

    The line face

    line adds a nose and a mouth to eyes. The sections below are about it: how it keeps one ink and one weight, what the mouth and nose are, and how they follow the state.

    One ink, one weight

    The eyes face works because it is a single visual idea: a filled body and two cut-outs. Adding a nose and a mouth risks turning that into a drawing with several kinds of line on it. The line face avoids that by keeping the rule:

    • Every feature is a stroke in the eye colour — the background on solid, the ink on outline and glass.
    • Every stroke has the same weight, LINE_WEIGHT times the eye radius, so it scales with the body exactly as the eyes do.
    • The eyes shrink towards dot proportions (LINE_FACE_TUNE) so three features share the plate instead of crowding it, and the thin expressions — closed, happy, sleepy — are floored to the line weight so they read as the same stroke as the mouth.
    • There are no brows, no blush and no second colour.
    neutralNeutral
    neutralSoftSoft neutral
    closedClosed
    halfHalf-lidded
    wideWide
    happyHappy
    joyfulJoyful
    angryAngry
    squintSquint
    lookLeftLooking left
    lookRightLooking right
    lookUpLooking up
    shyShy
    suspiciousSuspicious
    sleepySleepy
    excitedExcited
    dizzyDizzy
    winkWink

    Every eye topology of the eyes face is available on the line face; it only changes how they are sized and floored. wink is the one topology whose two eyes differ — a happy arc on one side, a neutral eye on the other.

    The mouth is six numbers

    interface MouthSpec {
      width: number; // half-width, in eye radii
      bend: number; // bow of the lip line; positive smiles, negative frowns
      open: number; // height of the opening; 0 is a closed line
      wave: number; // one sine ripple along the lip, for unease
      skew: number; // pushes the apex towards one corner, −1..1
      dy: number; // vertical offset from the resting position
    }

    The lip line is a parabola through the two corners, bowed by bend with its apex pushed sideways by skew, plus one ripple of amplitude wave. The opening is a lens split unevenly — the lower lip drops further than the upper lip rises, the way a real mouth opens. When open is 0 the two lips coincide and only the stroke is visible, so a closed mouth opening into an "o" is one continuous shape rather than a swap.

    flatFlat
    softSoft
    smileSmile
    grinGrin
    smirkSmirk
    oRound
    openOpen
    dotDot
    wavyWavy
    frownFrown
    poutPout
    humHum
    tightTight
    idwidthbendopenwaveskewdy
    flat0.7500000
    soft0.750.140000
    smile0.90.330000
    grin1.050.390.21000
    smirk0.750.22000.70
    o0.3600.72000
    open0.750.210.93000
    dot0.1500.3000
    wavy0.83000.1700
    frown0.75-0.270000
    pout0.45-0.150000.09
    hum0.510.2100.0800
    tight0.4800000.06

    Like the eyes, the mouth is parametric for a reason: morphing. Every field interpolates independently and every intermediate value is a valid mouth, so a state change eases from frown to grin on a spring instead of cutting between two outlines.

    morphMouth(MOUTHS.frown, MOUTHS.grin, t); // always a mouth

    The nose is chosen once

    A nose is not an expression. It is a trait, like a shape, so it is chosen once per character and stays put while the eyes and mouth change.

    slantSlant
    angularAngular
    hookHook
    longLong
    curveCurve
    bowBow

    Six polylines. Each is a single open stroke down the bridge with a small turn for the tip, stored as sixteen points in eye-radius units. Passing nose={null} draws none — a plain line face with eyes and mouth only.

    When no nose is given, one is derived from the character's key the same way its shape and ink are: autoNoseForKey('agent-42') is stable and spreads keys evenly across the six. AgentAvatar uses this, so every agent gets a consistent nose with nothing stored.

    Every state has a mouth

    STATE_MOUTH names one mouth per state, in the same way STATE_EYE_TOPOLOGIES names the eyes. The eyes carry most of the expression already, so the mouth's job is to agree with them — a frown under sad, an "o" under surprised, a wavy line under confused — rather than say something on its own.

    Resting

    idleIdlesoft
    sleepingSleepingflat
    drowsyDrowsyflat
    boredBoredflat
    wakingWakingo
    powering-downPowering downflat

    Attending

    listeningListeningsoft
    thinkingThinkingflat
    searchingSearchingdot
    readingReadingflat
    writingWritingsoft
    workingWorkingflat
    loadingLoadingdot
    progressProgressflat
    radarScanningdot
    orbitOrbitingsoft
    dictatingDictatingo
    uploadingUploadingsoft
    sendingSendingsmile
    receivingReceivingo
    messagingMessagingsoft
    waitingWaitingsoft
    spawningSpawningo
    composingComposingsoft
    draggingDraggingo

    Feeling

    happyHappysmile
    excitedExcitedgrin
    laughingLaughingopen
    playfulPlayfulsmirk
    proudProudgrin
    celebrateCelebratinggrin
    bouncingBouncingsmile
    shyShypout
    sadSadfrown
    angryAngrywavy
    confusedConfusedwavy
    suspiciousSuspicioustight
    surprisedSurprisedopen
    scaredScaredwavy
    curiousCuriouso
    hummingHumminghum

    Signalling

    alertingAlertingo
    notifyingNotifyingdot
    refusingRefusingfrown

    Two mouths exist for states the other eleven could not say honestly. hum is closed and content with a ripple running through it: a smile would say "pleased", but the point of humming is that the mouth is shut and something is coming out anyway. It is kept narrow so it does not read as a grin at sixteen pixels. tight is pressed shut — short, flat and a shade lower than flat — and suspicious wears it rather than smirk: suspicion is a mouth held shut while the eyes do the doubting, and a smirk reads as amusement next to a narrowed eye. refusing keeps its frown; that one is a refusal, not merely a reservation.

    Six states talk: composing, dictating, messaging, sending, laughing and humming. Their mouth pulses open and shut on two incommensurate sine waves, so it reads as speech rather than as a metronome. The pulse is switched off under prefers-reduced-motion.

    One plate

    The nose and mouth are projected with the same pose matrix as the eyes and take a share of the same gaze offset — 60% for the nose, 50% for the mouth. Turn the head and the whole face compresses as a unit; glance sideways and the features lean together. Without this the eyes would swing and the mouth would stay put, which reads as a mask sliding on a ball.

    mochiMochi
    circleCircle
    blobBlob
    pebblePebble
    eggEgg
    bunBun
    tofuTofu
    pillPill
    hexHexagon
    onigiriOnigiri
    gemGem
    cloudCloud
    dropDrop
    leafLeaf
    flowerFlower
    cloverClover
    sproutSprout
    mushroomMushroom
    acornAcorn
    cactusCactus
    pearPear
    appleApple
    strawberryStrawberry
    cherryCherry
    starStar
    heartHeart
    flameFlame
    bellBell
    ghostGhost
    botBot
    crescentCrescent
    bobaBoba tea
    fishFish
    octopusOctopus
    catCat
    bunnyBunny
    bearBear
    chickChick
    penguinPenguin
    pigPig
    foxFox
    bobBob
    topknotTop knot
    spikySpiky
    curlyCurly
    twinbunsTwin buns

    Both features are clamped inside the silhouette at their own height, and held above the chin on squat bodies, so a tofu or an onigiri gets a face that fits rather than one that hangs off the bottom edge. The face and pose internals cover the projection.

    Switching live

    face can change on a mounted character. The eye size, the stroke floor and the feature opacity all ease on one lineBlend spring, so the mouth fades in as the eyes shrink rather than everything snapping. Changing nose crossfades the old nose out and the new one in.

    import { useState } from 'react';
    import { CharacterMark, FACE_STYLES, NOSE_IDS } from '@aora-bot/react';
    
    export default function Demo() {
      const [face, setFace] = useState(0);
      const [nose, setNose] = useState(0);
      return (
        <div className="site-demo site-demo--center">
          <CharacterMark
            shape="pear"
            color="green"
            state="happy"
            face={FACE_STYLES[face]}
            nose={NOSE_IDS[nose]}
            sizePx={130}
          />
          <div className="site-controls">
            <button className="site-btn" onClick={() => setFace((f) => (f + 1) % FACE_STYLES.length)}>
              face: {FACE_STYLES[face]}
            </button>
            <button className="site-btn" onClick={() => setNose((n) => (n + 1) % NOSE_IDS.length)}>
              nose: {NOSE_IDS[nose]}
            </button>
          </div>
        </div>
      );
    }

    Where it applies

    Every component that draws a character accepts face and nose: CharacterMark, StagedMark, AgentAvatar (also through the agent object and through a cast preset), AvatarStack and the static renderers. A CharacterPicker with onSelectFace shows a face toggle, and a nose row when the line face is active.

    On a shared stage, face style and nose are part of the appearance key, so a blob with the line face and the hook nose and the same blob with the bow nose are two sources, while two cute blobs with different noses are one — only the line face draws a nose, so a nose on any other face is moot and does not split the stage.

    The line face across surfaces

    Every component that puts a character on a surface — HudPlate and AgentHud, the editor and its trigger, the template dialog and share card, OccupancyList, the restricted scene and boot loop, CastStage and its members, the walkthrough parts and both pickers — takes face, nose and lineWeight, and passes them down unchanged. A character that is a line-face bun with a hook nose in the roster is the same line-face bun in the editor's preview, on the share card and in the boot sequence.

    lineWeight is the third trait, and like the nose it is chosen once per character. It is a multiplier on LINE_WEIGHT, clamped to LINE_WEIGHT_RANGE (0.8–1.25), and one number reaches the nose, the mouth and the HUD dials: the ring outside the body is stroked at the same weight as the features inside it, so the face and its dial are one drawing rather than a face with a chart beside it. The range is deliberately narrow. The weight is the one number that decides whether a character reads as drawn with a pen or with a marker; letting a host set it freely would let one character in a list arrive at a different weight from its neighbours and break the set.

    lineWeight0.8
    lineWeight1
    lineWeight1.25
    import { CharacterMark, HudPlate } from '@aora-bot/react';
    
    export default function Demo() {
      return (
        <div className="site-demo">
          {[0.8, 1, 1.25].map((weight) => (
            <HudPlate key={weight} title="lineWeight" value={String(weight)}>
              <CharacterMark
                shape="bun"
                color="orange"
                state="working"
                face="line"
                nose="hook"
                lineWeight={weight}
                hud={{ kind: 'progress', value: 0.65 }}
                sizePx={88}
              />
            </HudPlate>
          ))}
        </div>
      );
    }

    A CharacterPicker takes lineWeight too, so the previews in the grid are drawn with the same pen as the character being edited.

    Next