XR & Spatial Computing / VR Bartending
VR Bartending Simulator
A full VR bartending training sim in Unity 6, built around real liquid physics and a live quality-evaluation loop.
A commercial-grade VR training simulation where the core challenge is making liquid behave believably — pouring, mixing, overflowing, and blending color across multiple simultaneous sources — then grading the result against real service metrics.
Engineering highlights
Hover to preview · click to pin
PourController.cs
// ---------- Pouring (angle-driven) ----------
void PourUpdate(float dt)
{
if (!CanPour)
return;
pouredThisFrame = 0f;
if (dt <= 0f)
{
return;
}
// --- 1. Tilt & threshold shared by all containers ---
float fillRatio = Mathf.Clamp01(currentLiquid / Mathf.Max(1e-6f, maxLiquid));
Transform t = bottle ? bottle : transform;
currentTiltAngle = Vector3.Angle(t.up, Vector3.up); // 0 upright .. 180 upside down
currentPourAngleThreshold = Mathf.Lerp(minPourAngle, basePourAngle, fillRatio);
float overTilt01 = Mathf.InverseLerp(currentPourAngleThreshold, 180f, currentTiltAngle);
overTilt01 = Mathf.Clamp01(overTilt01);
float targetFlow01;
float flowPerSec = 0f;
// --- 2. Different behavior for bottles vs open containers ---
if (containerType == ContainerType.Bottle)
{
// Bottles: near-constant flow once you're clearly past the threshold.
// We use a gentle power curve so small tilts don't instantly pour full speed.
targetFlow01 = Mathf.Pow(overTilt01, bottleAngleSharpness);
if (targetFlow01 <= 0.001f)
{
// Snappy shut: once we’re back below the pour angle, kill the flow immediately
currentFlow01 = 0f;
_flowVel = 0f;
}
else
{
// Still in the pouring region: use smoothing so it ramps nicely
currentFlow01 = Mathf.SmoothDamp(currentFlow01, targetFlow01, ref _flowVel, flowSmoothing);
}
flowPerSec = bottleFlowPerSecond * currentFlow01;
}
else // OpenContainer (cups, wine glasses, shaker inner cup)
{
// Open containers: tilt strongly controls how fast you dump.
targetFlow01 = Mathf.Pow(overTilt01, openAngleSharpness);
currentFlow01 = Mathf.SmoothDamp(currentFlow01, targetFlow01, ref _flowVel, flowSmoothing);
if (currentFlow01 > 0.001f && currentLiquid > 0f)
{
// When FULL and FULLY tilted, we want to empty roughly in fullDrainTimeAtMaxTilt seconds.
// Rate ~ (maxLiquid / time) scaled by:
// - angleFactor (how much past threshold)
// - volumeFactor (more full = slightly faster)
float baseDrainRate = maxLiquid / Mathf.Max(0.01f, fullDrainTimeAtMaxTilt);
float angleFactor = Mathf.Lerp(minOpenFlowFactor, 1f, currentFlow01);
float volumeFactor = Mathf.Lerp(0.5f, 1f, fillRatio); // optional; makes very low fills a bit slower
flowPerSec = baseDrainRate * angleFactor * volumeFactor;
}
else
{
flowPerSec = 0f;
}
}
// --- 3. Apply viscosity (shared) ---
flowPerSec /= Mathf.Max(0.01f, viscosity);
// --- 4. Actually remove liquid this frame ---
float request = Mathf.Max(0f, flowPerSec * dt);
if (currentLiquid > 0f && request > 0f)
{
float outUnits = Mathf.Min(request, currentLiquid);
if (outUnits > 0f)
{
RemoveAmount(outUnits);
pouredThisFrame = outUnits;
}
}
isPouring = pouredThisFrame > 0f;
// --- 5. Particles / FX / self-replenish logic stays as-is ---
if (pourParticleSystem)
{
var emission = pourParticleSystem.emission;
emission.enabled = isPouring;
}
if (currentLiquid <= 1e-6f)
{
if (selfReplenishing && _originalMix != null && _originalMix.Count > 0)
{
ResetToOriginalMix();
}
else
{
if (!empty)
{
empty = true;
OnEmptied?.Invoke();
}
currentLiquid = 0f;
}
}
else empty = false;
}Demo
LiquidSurface.cs
public float ReceivePour(LiquidType type, float units)
{
if (units <= 0f) return 0f;
float space = Mathf.Max(0f, maxLiquid - currentLiquid);
accepted = Mathf.Min(units, space);
// Overflow logic
if (accepted <= 0f)
{
overflowing = true;
_overflowUntilTime = Time.time + overflowHoldSeconds;
return 0f;
}
if (!mixLookup.TryGetValue(type, out int idx))
{
idx = liquidCount;
mixLookup[type] = idx;
mix.Add(0f);
liquidCount++;
}
mix[idx] += accepted;
currentLiquid += accepted;
OnContentsChanged?.Invoke();
OnContentsAdded?.Invoke();
return accepted;
}
// Pour from another Drink's mix into this one, distributing proportionally
public float ReceivePourFromSourceMix(Drink source, float totalUnits)
{
if (!source || totalUnits <= 0f) return 0f;
if (source.mix == null || source.mix.Count == 0) return 0f;
// Snapshot fractions
float sum = 0f;
for (int i = 0; i < source.mix.Count; i++)
{
sum += Mathf.Max(0f, source.mix[i]);
}
if (sum <= 1e-6f)
{
return 0f;
}
// Respect this container's capacity
float space = Mathf.Max(0f, maxLiquid - currentLiquid);
float acceptedTotal = Mathf.Min(totalUnits, space);
if (acceptedTotal <= 0f)
{
overflowing = true;
_overflowUntilTime = Time.time + overflowHoldSeconds;
return 0f;
}
// Build index->type map once
var typeByIndex = new System.Collections.Generic.List<LiquidType>();
foreach (var kv in source.mixLookup)
{
while (typeByIndex.Count <= kv.Value) typeByIndex.Add(default);
typeByIndex[kv.Value] = kv.Key;
}
// Distribute proportionally
float actuallyAccepted = 0f;
for (int i = 0; i < source.mix.Count; i++)
{
float frac = source.mix[i] / sum;
float u = acceptedTotal * frac;
if (u <= 0f) continue;
actuallyAccepted += ReceivePour(typeByIndex[i], u);
}
UpdateDrinkColor(source.color, actuallyAccepted);
IsShaken = source.IsShaken;
return actuallyAccepted;
}Demo
HandContentsUI.cs
public class HandContentsUI : MonoBehaviour
{
[Header("Source")]
[Tooltip("Hand that drives this HUD. If left null, it searches in parents.")]
public Hand hand;
[Tooltip("Maps LiquidType -> ItemData (icon/name).")]
public LiquidItemCatalog catalog;
public Drink _activeDrink;
public bool Active => _activeDrink != null;
public Transform listParent;
public IngredientUI ingredientPrefab;
void OnEnable()
{
// Find the Hand automatically if not set
if (!hand) hand = GetComponentInParent<Hand>();
Subscribe(hand);
}
void OnDisable()
{
Unsubscribe(hand);
_activeDrink = null;
}
void Subscribe(Hand h)
{
if (!h) return;
h.OnHeldDrinkChanged += OnHandHeldChanged;
}
void Unsubscribe(Hand h)
{
if (!h) return;
h.OnHeldDrinkChanged -= OnHandHeldChanged;
}
void OnHandHeldChanged(Drink newDrink)
{
SetActiveDrink(newDrink);
}
void SetActiveDrink(Drink d)
{
if (_activeDrink == d) return;
// Unsubscribe from previous drink's events if needed
if (Application.isPlaying && Active)
_activeDrink.OnContentsChanged -= OnDrinkContentsChanged;
_activeDrink = d;
// Subscribe to new drink’s change event if available
if (Application.isPlaying && Active)
_activeDrink.OnContentsChanged += OnDrinkContentsChanged;
}
void OnDrinkContentsChanged()
{
// Optional: immediate refresh when drink composition changes
// (You can call your record function here too if desired)
}
void Update()
{
// Only run when the player is holding a drink
if (!Active) return;
// This will execute every frame while holding a drink
RecordCurrentIngredients();
}
void RecordCurrentIngredients()
{
// Mirror UpgradeUIController.RefreshUpgradeList() guard clauses
if (_activeDrink == null || listParent == null || ingredientPrefab == null || catalog == null)
return;
// 1) Snapshot existing children (like UpgradeUIController collects UpgradeButtonUI children)
var children = new List<IngredientUI>(listParent.GetComponentsInChildren<IngredientUI>(true));
// 2) Build the current "active set" from the drink (analogous to allUpgrades -> activeSet)
// - Translate mix index -> LiquidType using mixLookup
// - Filter out ~0 amounts
// - (Optional) sort by amount descending (similar to price sort)
var typeByIndex = new List<LiquidType>();
foreach (var kvp in _activeDrink.mixLookup)
{
while (typeByIndex.Count <= kvp.Value) typeByIndex.Add(default);
typeByIndex[kvp.Value] = kvp.Key;
}
var activeSet = new List<(ItemData item, float amount)>();
for (int i = 0; i < _activeDrink.mix.Count; i++)
{
float amt = _activeDrink.mix[i];
if (amt <= 0f) continue;
var t = (i < typeByIndex.Count) ? typeByIndex[i] : default;
var data = catalog.Get(t);
if (data != null)
activeSet.Add((data, amt));
}
// Sort like the upgrade list does (price asc/desc) — here by amount desc
// activeSet = activeSet
// .OrderByDescending(e => e.amount)
// .ToList();
// 3) Ensure a UI element exists for every active ingredient
// This mirrors the "create if missing" loop in RefreshUpgradeList()
for (int i = 0; i < activeSet.Count; i++)
{
var (itemData, amount) = activeSet[i];
// Try to find an existing child that corresponds to this ItemData.
// If IngredientUI exposes a Data or ItemData property, prefer matching by reference like:
// var existing = children.FirstOrDefault(c => c.ItemData == itemData);
// If not, we can fallback to position-based reuse:
IngredientUI existing = (i < children.Count) ? children[i] : null;
if (existing == null)
{
// Instantiate like UpgradeUIController (PrefabUtility in editor, Instantiate in play)
GameObject go = Instantiate(ingredientPrefab.gameObject, listParent, false);
if (go != null)
{
existing = go.GetComponent<IngredientUI>();
if (existing != null)
children.Add(existing);
}
}
if (existing != null)
{
// Name the object like the upgrades do: "<Name> Ingredient"
if (itemData != null && !string.IsNullOrEmpty(itemData.itemName))
existing.gameObject.name = itemData.itemName + " Ingredient";
// --- Populate the UI (YOU implement these on IngredientUI) ---
existing.Set(itemData.itemIcon, itemData.itemName, amount.ToString("F2"));
//
// If you need percentage:
// float percent = (_activeDrink.currentLiquid > 0f) ? amount / _activeDrink.currentLiquid : 0f;
// existing.SetPercent(percent);
// Make sure it's visible and ordered properly
existing.gameObject.SetActive(true);
existing.transform.SetSiblingIndex(i);
}
}
// 4) Deactivate any surplus children not in the current active set (mirrors the trailing loop in RefreshUpgradeList)
for (int i = activeSet.Count; i < children.Count; i++)
{
if (children[i] != null)
children[i].gameObject.SetActive(false);
}
}
}Demo
DrinkScorer.cs
public static class TipHelper
{
public static int CalculateTip(Customer c, float quality, float acceptDelay, float serveDelay)
{
// Safety clamp
quality = Mathf.Clamp01(quality);
switch (c.tipMode)
{
case TipCalcMode.LinearAdditive:
return CalcLinearAdditiveTip(c, quality, acceptDelay, serveDelay);
case TipCalcMode.TieredSteps:
return CalcTieredStepTip(c, quality, acceptDelay, serveDelay);
case TipCalcMode.Multiplicative:
return CalcMultiplicativeTip(c, quality, acceptDelay, serveDelay);
default:
return CalcLinearAdditiveTip(c, quality, acceptDelay, serveDelay);
}
}
// --- Method A: LinearAdditive ---
static int CalcLinearAdditiveTip(Customer c, float quality, float acceptDelay, float serveDelay)
{
float min = c.minTip;
float max = c.maxTip;
// 1) Quality-based tip
float qualityTip = Mathf.Lerp(min, max, quality);
// 2) Normalize delays into [0..1] “slowness”
float acceptSlow = 0f;
float serveSlow = 0f;
if (c.maxAcceptDelay > 0.01f)
acceptSlow = Mathf.Clamp01(acceptDelay / c.maxAcceptDelay);
if (c.maxServeDelay > 0.01f)
serveSlow = Mathf.Clamp01(serveDelay / c.maxServeDelay);
float timePenaltyFraction = (acceptSlow + serveSlow) * 0.5f; // average
timePenaltyFraction *= c.speedWeight;
// 3) Normalize weights so they sum to 1
float qW = c.qualityWeight;
float sW = c.speedWeight;
float sum = qW + sW;
if (sum < 0.0001f)
{
qW = 1f;
sW = 0f;
}
else
{
qW /= sum;
sW /= sum;
}
// 4) Base tip biased toward quality
float baseTipFromQuality = Mathf.Lerp(min, qualityTip, qW);
// Max penalty scaled by speed weight
float maxPenalty = (max - min) * sW;
float penalty = maxPenalty * timePenaltyFraction;
float final = Mathf.Clamp(baseTipFromQuality - penalty, min, max);
return Mathf.RoundToInt(final);
}
// --- Method B: TieredSteps ---
static int CalcTieredStepTip(Customer c, float quality, float acceptDelay, float serveDelay)
{
float tip = c.minTip;
// Quality bands
if (quality >= c.perfectQualityThreshold)
tip += c.perfectQualityBonus;
else if (quality >= c.goodQualityThreshold)
tip += c.goodQualityBonus;
tip = Mathf.Clamp(tip, c.minTip, c.maxTip);
// Long-wait penalty
float acceptFrac = 0f;
float serveFrac = 0f;
if (c.maxAcceptDelay > 0.01f)
acceptFrac = Mathf.Clamp01(acceptDelay / c.maxAcceptDelay);
if (c.maxServeDelay > 0.01f)
serveFrac = Mathf.Clamp01(serveDelay / c.maxServeDelay);
float worstFrac = Mathf.Max(acceptFrac, serveFrac);
if (worstFrac >= c.longWaitFractionForPenalty)
{
tip *= c.longWaitPenaltyMultiplier;
}
// Small bonus for very fast + good quality
bool veryFastAccept = (c.maxAcceptDelay > 0.0f) &&
(acceptDelay <= c.maxAcceptDelay * 0.25f);
bool veryFastServe = (c.maxServeDelay > 0.0f) &&
(serveDelay <= c.maxServeDelay * 0.25f);
if (veryFastAccept && veryFastServe && quality >= c.goodQualityThreshold)
{
tip += 1f;
}
tip = Mathf.Clamp(tip, c.minTip, c.maxTip);
return Mathf.RoundToInt(tip);
}
// --- Method C: Multiplicative ---
static int CalcMultiplicativeTip(Customer c, float quality, float acceptDelay, float serveDelay)
{
float min = c.minTip;
float max = c.maxTip;
// 1) Quality factor with exponent shaping
float qFactor = Mathf.Pow(Mathf.Clamp01(quality), c.qualityExponent); // 0..1
// 2) Timing factors
float acceptFactor = 1f;
float serveFactor = 1f;
if (c.acceptDelayTolerance > 0.01f)
acceptFactor = 1f - Mathf.Clamp01(acceptDelay / c.acceptDelayTolerance);
if (c.serveDelayTolerance > 0.01f)
serveFactor = 1f - Mathf.Clamp01(serveDelay / c.serveDelayTolerance);
float timingFactor = Mathf.Lerp(1f, acceptFactor * serveFactor, c.speedWeight);
float combinedFactor = qFactor * timingFactor;
float tip = Mathf.Lerp(min, max, combinedFactor);
tip = Mathf.Clamp(tip, min, max);
return Mathf.RoundToInt(tip);
}
}Demo
Code
Demo
Why liquid is the hard part
Most VR training sims can fake their props. A bartending sim cannot fake liquid — it is the thing the user is manipulating, judging, and being judged on. If the pour doesn’t read as believable, the whole simulation stops teaching.
So the liquid system carries the project. It runs as a multi-layer pipeline: a Shader Graph material driven by fill level, screen-space effects for the surface, and a color-blending model that composites hue, saturation, value, and opacity from every source currently contributing to the glass. Overflow is handled explicitly rather than clamped, because overflowing is a mistake the trainee needs to see and feel.
Grading the pour, not just simulating it
Underneath the visuals is a quality model. Every drink resolves to a score built from accuracy, timing, and service quality, and that score drives the tipping system — so the feedback loop is the same one a real bar runs on.
Built to be re-skinned
The recipe data lives in scriptable objects that can be edited and serialized at runtime. A venue can load its own menu into the simulation without touching the project, which is what makes this deployable as a training product rather than a tech demo.