Skip to content

Facing works, and your sprite never rotates

You set a pickup to require the player to face it. In the editor it looks sensible. In play, the player can only pick things up while standing below them — walk round to the other side and the prompt refuses. Nothing is misconfigured. The check is doing exactly what it was told, with the only information it could find.

Recipe

Systems required: Pickups. It is not referenced at compile time — IFacingProvider lives in Core.Abstractions, and this class touches nothing else — but Pickups is the only thing in the framework that ever asks for one, so without it installed this component is answered by nobody. Package: Inventory, Pickups & Crafting, or Complete. Shape: one class you drop into a project that already exists. No prefab, no bespoke scene. Public API only. It assumes: your actor moves by changing its transform position, and you have pickups with a facing threshold set. Put this on the actor. Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.

First, check the facing test is switched on at all

facingDotThreshold defaults to -1, and PassFacingCheck returns true immediately at anything <= -0.99f. Out of the box the check is disabled, so every pickup works from every direction and this component will appear to do nothing.

Raise the threshold on the pickups that should care — around 0.5 for a rough ninety-degree cone in front — or nothing below is reachable.

The part that is not obvious

InteractablePickupBase.PassFacingCheck asks the actor for its facing, and falls back if nobody answers:

if (actor.TryGetComponent<IFacingProvider>(out var provider))
    facing2 = provider.Facing2D;
else
{
    var fwd = actor.transform.forward;
    facing2 = new(fwd.x, Mathf.Abs(fwd.y) > Mathf.Abs(fwd.z) ? fwd.y : fwd.z);
}

That fallback is fine for a character that turns to face things. A 2D sprite does not turn — it flips, or it swaps to a different animation.

So in 2D, the fallback is not approximate. It is constant.

An unrotated sprite has transform.forward == (0, 0, 1) for its entire life. Feed that through the flatten above and the facing is (0, 1)up — forever, no matter which way the character is walking or which animation is playing.

"Face the item to pick it up" therefore means "stand below it", permanently. This is why the seam exists, and it is why in a 2D game implementing it is not optional: there is no configuration that makes the fallback correct.

Never return a zero vector

This is the part the interface does not warn about, and it is the easiest mistake to make. Facing is usually derived from movement — and a player who has stopped has no movement.

The consumer does this with whatever you return:

float dot = Vector2.Dot(facing2.normalized, toItem2.normalized);
return dot >= facingDotThreshold;

Vector2.zero.normalized is still zero, and a zero dot is not 'unknown'

It is 0. So the check collapses to 0 >= facingDotThreshold, which at any sensible threshold is false.

A provider that returns zero while the player stands still refuses every pickup at precisely the moment someone stops walking in order to pick something up. It will read as "the pickup is broken", and the facing code will look innocent, because the value it was handed was a perfectly ordinary Vector2.

Holding the last non-zero direction is the entire job of this recipe. SetFacing ignores a zero rather than storing one, and OnValidate refuses a zero typed into the inspector — the one other place it could get in.

Choose the plane; do not let the heuristic choose twice

The consumer flattens the direction to the item with a guess:

Vector2 toItem2 = new(toItem3.x, Mathf.Abs(toItem3.y) > Mathf.Abs(toItem3.z) ? toItem3.y : toItem3.z);

That is a per-call guess at whether this is a 2D game (use Y) or a top-down 3D one (use Z), made from the geometry of that one comparison.

Your facing has to land in whichever plane that guess picks

If the item direction resolves to the XZ plane and your facing is expressed in XY, the dot product is comparing two different things and the answer is noise.

So this component asks you which game you are making — XY or XZ — rather than guessing a second time and hoping the two guesses agree. It is the mapping the interface's own remarks describe: (x, y) for 2D, (x, z) for top-down 3D.

And the guess is per item, so height can flip it under you

Configuring the plane correctly is not the end of it, because that expression runs fresh for every pickup. A pickup raised well above the actor makes |y| the larger component, and the heuristic switches to the Y axis for that one item — while your provider is still answering in XZ.

Worked through, in a top-down XZ game with the actor facing +X and a pickup one unit ahead and two units up:

direction to item, normalized (0.447, 0.894, 0)
heuristic compares \|y\| vs \|z\| 0.894 > 0 → picks Y
flattened direction (0.447, 0.894)
dot against a facing of (1, 0) 0.447

At a threshold of 0.5 that refuses a pickup the actor is looking straight at, and no facing a provider could return would fix it. Keep interactable pickups near the actor's own height in a top-down game, or lower the threshold enough to absorb the flip.

Two limits, stated rather than hidden

It has to be on the actor itself, not on a child

The lookup is actor.TryGetComponent<IFacingProvider> — the root object only, no children.

Worth stating because the same class is inconsistent about it: twenty-eight lines earlier, ResolveInput looks for IInputService with GetComponent and then GetComponentInChildren. So if your input service sits on a child rig and works fine there, putting this beside it will silently find nothing — and a facing provider that is never resolved looks exactly like one that is working, because the fallback answers in its place.

Facing is inferred from movement, so turning on the spot does not register

An actor that rotates without moving keeps its last travelled direction. If your game already knows which way the character is looking — from input, from an animator, from a controller — call SetFacing and this stops guessing.

The deadzone is a real setting, not a formality

A physics character standing on a slope jitters by tiny amounts every frame. Too small a deadzone and the facing flickers between directions while the player stands still; too large and a slow walk never registers at all. The default suits a character moving at ordinary speeds in a scene built at a scale of roughly one unit per metre.

Drop it in

LastFacing.cs
using UnityEngine;

using RevGaming.RevFramework.Core.Abstractions.Actors;

namespace RevGaming.RevFramework.Cookbook.LastFacing
{
    /// <summary>
    /// Reports the direction the actor was last moving, so a "face it to pick it up" check works for
    /// a sprite that never rotates — and keeps working while the player stands still.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Pickups</b> — not referenced at compile time, because <see cref="IFacingProvider"/> lives in
    /// <c>Core.Abstractions</c> and this class touches nothing else, but Pickups is the only thing in
    /// the framework that ever asks for one. Public API only.</para>
    ///
    /// <para><b>In a 2D game this seam is not optional, it is required.</b> With no
    /// <see cref="IFacingProvider"/> on the actor, <c>InteractablePickupBase.PassFacingCheck</c> falls
    /// back to <c>transform.forward</c>. A sprite that faces left by flipping rather than rotating has
    /// a forward of <c>(0, 0, 1)</c> for its entire life, which the fallback collapses to a planar
    /// facing of <c>(0, 1)</c>. So "face the item to pick it up" quietly means <b>"stand below it"</b>,
    /// permanently, and no amount of turning the character round changes it.</para>
    ///
    /// <para><b>Never return a zero vector, and this is the trap the interface does not warn about.</b>
    /// The consumer takes <c>Facing2D.normalized</c>, and <c>Vector2.zero.normalized</c> is still
    /// zero, so the dot product against the direction-to-item becomes <c>0</c> — and the check reduces
    /// to <c>0 &gt;= facingDotThreshold</c>. At any sensible threshold that is <i>false</i>. A provider
    /// that returns zero when the player is not moving therefore refuses every pickup while they stand
    /// still, which is exactly when someone stops to pick something up. Holding the last non-zero
    /// direction is the whole job.</para>
    ///
    /// <para><b>Pick the plane explicitly rather than letting the heuristic pick it.</b> The consumer
    /// flattens the direction-to-item with <c>Mathf.Abs(y) &gt; Mathf.Abs(z) ? y : z</c> — a per-call
    /// guess at whether this is a 2D or a top-down 3D game. Your facing has to be expressed in
    /// whichever plane that guess lands on, so this component asks you which game you are making
    /// instead of guessing a second time and hoping the two agree. It is the mapping the interface's
    /// own remarks describe: <c>(x, y)</c> for 2D, <c>(x, z)</c> for top-down 3D.</para>
    ///
    /// <para>Two limits, stated rather than hidden. Facing is inferred from movement, so an actor that
    /// turns on the spot without moving does not update — call <see cref="SetFacing"/> from whatever
    /// already knows, if your game has that. And the deadzone exists because a physics character
    /// jitters by tiny amounts while standing on a slope; too small a value and the facing flickers,
    /// too large and a slow walk never registers.</para>
    /// </remarks>
    [AddComponentMenu("RevFramework/Cookbook/Last Facing")]
    public sealed class LastFacing : MonoBehaviour, IFacingProvider
    {
        /// <summary>Which pair of world axes the planar facing is expressed in.</summary>
        public enum Plane
        {
            /// <summary>Side-on and top-down 2D: world X and Y.</summary>
            XY,

            /// <summary>Top-down 3D on the ground plane: world X and Z.</summary>
            XZ,
        }

        [Tooltip("XY for a 2D game, XZ for top-down 3D. Must match the plane your gameplay moves in.")]
        [SerializeField] private Plane plane = Plane.XY;

        [Tooltip("Facing before the actor has moved at all. Must not be zero.")]
        [SerializeField] private Vector2 initialFacing = new(0f, -1f);

        [Tooltip("Movement below this per-frame distance is treated as standing still, so the facing " +
                 "holds instead of flickering on physics jitter.")]
        [Min(0f)]
        [SerializeField] private float deadzone = 0.001f;

        private Vector2 _facing;
        private Vector3 _lastPosition;

        /// <summary>The last direction the actor moved in. Never zero.</summary>
        /// <remarks>
        /// The fallback is not defensive padding. <c>_facing</c> is seeded in <c>Awake</c>, and a
        /// component added by editor tooling — or by an EditMode fixture — is a live provider that
        /// never awoke, so the backing field is still <c>default</c>. Returning that would hand the
        /// consumer a zero vector, which is the exact failure this whole component exists to prevent:
        /// see the remarks on the class. "Never zero" has to be true of the property, not merely of
        /// the paths that write to it.
        /// </remarks>
        public Vector2 Facing2D => _facing.sqrMagnitude > 0f ? _facing : Safe(initialFacing);

        private void Awake() => ResetTo(initialFacing);

        private void OnEnable()
        {
            // Position, not facing: re-enabling after a teleport must not read the jump as movement.
            _lastPosition = transform.position;
        }

        private void LateUpdate()
        {
            Vector3 delta = transform.position - _lastPosition;
            _lastPosition = transform.position;

            Vector2 planar = Flatten(delta);

            // sqrMagnitude against a squared deadzone: same test, no square root on every actor every
            // frame, and the comparison is exact for the zero case that matters.
            if (planar.sqrMagnitude > deadzone * deadzone)
                _facing = planar.normalized;
        }

        /// <summary>
        /// Sets the facing directly, for a game that already knows which way the actor is looking.
        /// </summary>
        /// <remarks>
        /// A zero vector is ignored rather than stored, because a stored zero is the failure this
        /// component exists to prevent — see the remarks on the class.
        /// </remarks>
        /// <param name="facing">Direction in the configured plane. Need not be normalized.</param>
        public void SetFacing(Vector2 facing)
        {
            if (facing.sqrMagnitude > 0f)
                _facing = facing.normalized;
        }

        /// <summary>Restores the facing to a known direction, ignoring a zero.</summary>
        public void ResetTo(Vector2 facing)
        {
            _facing = Safe(facing);
            _lastPosition = transform.position;
        }

        /// <summary>Normalizes a direction, substituting a fixed one for a zero.</summary>
        private static Vector2 Safe(Vector2 v) => v.sqrMagnitude > 0f ? v.normalized : Vector2.down;

        private Vector2 Flatten(Vector3 v) => plane == Plane.XY ? new Vector2(v.x, v.y) : new Vector2(v.x, v.z);

#if UNITY_EDITOR
        private void OnValidate()
        {
            // A zero here would ship the exact bug the class is about, and the inspector is the one
            // place it can be typed. Fixing it in place is louder than a runtime fallback nobody sees.
            if (initialFacing.sqrMagnitude <= 0f)
                initialFacing = new Vector2(0f, -1f);
        }
#endif
    }
}

Wiring it up

  1. Put the component on the actor GameObject itself — the one handed to the pickup as the actor, which is the object whose collider entered the trigger. PassFacingCheck calls actor.TryGetComponent<IFacingProvider>, with no walk into children or parents.
  2. Set plane to match the axes your gameplay moves in: XY for a 2D game, XZ for top-down 3D.
  3. Set initialFacing to whichever way the character starts out looking. OnValidate refuses a zero, because a zero here is the exact bug the component exists to prevent.
  4. Raise facingDotThreshold on the pickups that should care. It ships at -1, and PassFacingCheck returns true immediately at anything <= -0.99f — so out of the box the check is off and this component will look like it does nothing. Around 0.5 is a rough ninety-degree cone.
  5. If your game already knows which way the character is looking — from input, an animator, a controller — call SetFacing from it and stop inferring. A zero is ignored rather than stored.
  6. Leave deadzone alone unless your scene is built at an unusual scale. It exists because a physics character standing on a slope jitters every frame; too small and the facing flickers, too large and a slow walk never registers.