A self-contained Unity module for expressive social communication in multiplayer VR experiences using network-synced animated speech bubbles.
This module was originally developed by me while building a production multiplayer VR experience at Evermorrow Labs. This repository is a cleaned-up, standalone version intended for reuse and documentation. No proprietary code or assets from the original project are included.
- Features
- Architecture Overview
- Installation
- Quick Start
- Core Systems
- Configuration
- API Reference
- Extending the System
- Joystick-Controlled Radial Menu - Intuitive thumbstick-based emote selection with 4-12 configurable options
- Networked Speech Bubbles - Real-time emote broadcasting via Unity Netcode RPCs, visible to all players
- Animated Pop Effects - Satisfying stretch/squash animations when bubbles appear
- Auto-Hide System - Configurable display duration with smooth transitions
- Cross-Platform XR - Supports Meta/Oculus SDK and generic Unity Input System devices
- Hand Agnostic - Works with left or right controller
- Audio Feedback - Optional sound effects per emote
- Event-Driven Architecture - Hook into selection and broadcast events for custom behavior
- Fully Configurable - ScriptableObject-based settings for easy tweaking
┌─────────────────────────────────────────────────────────────────┐
│ EmoteWheelManager │
│ (Singleton + NetworkObject) │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Speech Bubble │ │ RPC System │ │ Event System │ │
│ │ Registry │ │ Server ↔ Client │ │ OnEmoteSelected │ │
│ │ Dict<ulong,Bub> │ │ SendEmoteServer │ │ OnEmoteBroadcast│ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↑ ↑ ↓
│ │ │
┌────────┴────────┐ ┌─────────┴─────────┐ ┌────────┴─────────┐
│ NetworkedSpeech │ │ EmoteWheelInput │ │ RadialEmoteMenu │
│ Bubble │ │ │ │ │
│ (Per Avatar) │ │ IXRInputProvider │ │ EmoteMenuItem │
└─────────────────┘ └───────────────────┘ └──────────────────┘
| Component | Responsibility |
|---|---|
EmoteWheelManager |
Central hub for networking, speech bubble registry, and events |
EmoteWheelInput |
Processes VR controller input and maps joystick to selection |
RadialEmoteMenu |
Generates and animates the circular UI layout |
NetworkedSpeechBubble |
Displays emotes above avatars with pop animations |
IXRInputProvider |
Abstracts platform-specific input (Meta/Generic) |
- Open Window → Package Manager
- Click "+" → "Add package from git URL"
- Enter:
https://github.com/Alchemishty/ExpressVR.git
Download and extract into your Packages/ folder.
| Package | Version | Required |
|---|---|---|
com.unity.netcode.gameobjects |
1.5.0+ | ✅ Yes |
com.meta.xr.sdk.core |
Any | ⚪ Optional (Meta devices) |
com.unity.inputsystem |
Any | ⚪ Optional (Generic XR) |
Add the EmoteWheelManager prefab to your scene (or create one):
EmoteWheelManager (GameObject)
├── NetworkObject
├── EmoteWheelManager
├── EmoteWheelInput
├── RadialEmoteMenu
└── AudioSource (for emote sounds)
Add a speech bubble to your networked avatar prefab:
PlayerAvatar (NetworkObject)
└── SpeechBubble (child, positioned above head)
├── NetworkedSpeechBubble
├── BubbleQuad (background visual)
└── EmoteQuad (displays the emote)
Create your emote configuration:
- Right-click in Project → Create → VREmoteWheel → Configuration
- Add 4-12 emotes with sprites/materials and optional sounds
- Assign to
EmoteWheelManagerandEmoteWheelInput
using VREmoteWheel;
public class MyGameManager : MonoBehaviour
{
void Start()
{
// Subscribe to emote events
EmoteWheelManager.Instance.OnEmoteSelected += OnLocalEmoteSelected;
EmoteWheelManager.Instance.OnEmoteBroadcast += OnAnyEmoteBroadcast;
}
void OnLocalEmoteSelected(int emoteIndex)
{
Debug.Log($"Local player selected emote {emoteIndex}");
}
void OnAnyEmoteBroadcast(ulong clientId, int emoteIndex)
{
Debug.Log($"Player {clientId} expressed emote {emoteIndex}");
}
}The input system abstracts VR controller differences through IXRInputProvider:
public interface IXRInputProvider
{
bool IsInitialized { get; }
bool IsThumbstickTouched { get; }
Vector2 ThumbstickValue { get; }
void Initialize();
void UpdateInput();
}Supported Platforms:
- Meta/Oculus - Uses
OVRInputAPI (requiresOCULUS_XR_SDKdefine) - Generic XR - Uses Unity Input System with XR bindings
Selection is calculated from joystick angle:
float angle = Mathf.Atan2(joystick.y, joystick.x) * Mathf.Rad2Deg;
int selection = Mathf.FloorToInt((90f - angle + 360f) % 360f / (360f / emoteCount));Player A selects emote
↓
EmoteWheelManager.SelectEmote(index)
↓
SendEmoteServerRpc(index) ─────→ Server validates
↓ ↓
Local bubble shows BroadcastEmoteClientRpc(clientId, index)
↓ ↓
OnEmoteSelected fires All clients receive
↓
Each client updates the correct
speech bubble via registry lookup
↓
OnEmoteBroadcast fires everywhere
Bubbles use a two-phase pop animation for satisfying visual feedback:
// Phase 1: Stretch (overshoot)
scale = Vector3.Lerp(Vector3.zero, targetScale * stretchAmount, t);
// Phase 2: Squash (settle)
scale = Vector3.Lerp(stretchedScale, targetScale, t);| Property | Type | Description |
|---|---|---|
emotes |
List<EmoteData> |
Emote definitions (4-12 recommended) |
joystickThreshold |
float |
Dead zone for input (0.1-0.9) |
menuRadius |
float |
UI radius in units |
itemScale / selectedItemScale |
float |
Normal/selected item sizes |
unselectedAlpha / selectedAlpha |
float |
Transparency values |
animationDuration |
float |
Selection animation time |
requireTouchToOpen |
bool |
Require thumbstick touch |
| Property | Type | Description |
|---|---|---|
popDuration |
float |
Animation duration |
stretchAmount |
float |
Overshoot scale multiplier |
squashAmount |
float |
Settle scale multiplier |
displayDuration |
float |
Auto-hide delay |
bubbleScale |
Vector3 |
Base bubble size |
positionOffset |
Vector3 |
Offset from avatar |
useSpriteRenderer |
bool |
Sprite vs Material display |
[Serializable]
public class EmoteData
{
public string emoteName; // Display name
public Sprite emoteSprite; // 2D sprite for UI
public Material emoteMaterial; // 3D material for bubble
public AudioClip emoteSound; // Optional sound effect
}// Singleton access
EmoteWheelManager.Instance
// Trigger emote selection (call from local player)
void SelectEmote(int emoteIndex)
// Get speech bubble for a specific player
NetworkedSpeechBubble GetSpeechBubble(ulong clientId)
// Events
Action<int> OnEmoteSelected // Local selection (emoteIndex)
Action<ulong, int> OnEmoteBroadcast // Any player broadcast (clientId, emoteIndex)// Display an emote
void ShowEmote(int emoteIndex)
void ShowEmote(EmoteData emote)
// Manual control
void Hide()
// Events
Action OnBubbleShown
Action OnBubbleHiddenImplement IXRInputProvider for additional platforms:
public class MyCustomInputProvider : IXRInputProvider
{
public bool IsInitialized { get; private set; }
public bool IsThumbstickTouched => /* your implementation */;
public Vector2 ThumbstickValue => /* your implementation */;
public void Initialize() { /* setup */ IsInitialized = true; }
public void UpdateInput() { /* poll input */ }
}Subscribe to events for custom reactions:
EmoteWheelManager.Instance.OnEmoteBroadcast += (clientId, emoteIndex) =>
{
// Trigger particle effects, animations, achievements, etc.
var bubble = EmoteWheelManager.Instance.GetSpeechBubble(clientId);
if (bubble != null)
{
// Access bubble transform for positioning effects
SpawnParticles(bubble.transform.position);
}
};VREmoteWheel/
├── package.json
├── VREmoteWheel.asmdef
├── Runtime/
│ ├── Core/
│ │ ├── EmoteWheelManager.cs
│ │ ├── EmoteWheelInput.cs
│ │ └── NetworkedSpeechBubble.cs
│ ├── UI/
│ │ ├── RadialEmoteMenu.cs
│ │ └── EmoteMenuItem.cs
│ ├── Configuration/
│ │ ├── EmoteWheelConfig.cs
│ │ ├── EmoteData.cs
│ │ └── SpeechBubbleSettings.cs
│ └── XRAbstraction/
│ ├── IXRInputProvider.cs
│ ├── GenericXRInputProvider.cs
│ └── OVR/
│ ├── OVRInputProvider.cs
│ └── VREmoteWheel.OVR.asmdef
├── Settings/
│ ├── DefaultEmoteWheelConfig.asset
│ └── DefaultSpeechBubbleSettings.asset
└── Samples~/
└── BasicSetup/
MIT
Built with: