CHALLENGE
The core mechanic — scaling the player and world objects via scroll wheel or right-mouse drag — breaks a surprising number of things simultaneously. Scaling the player upward into adjacent geometry causes collider overlap. Scaling while airborne or moving destabilizes physics. Scaling a world object into a wall crushes it against the geometry. Applying a scale delta to a left-facing character or object flips orientation because Unity encodes facing direction in the sign of localScale.x. And world objects need two distinct scaling behaviors depending on type: some should resize uniformly (platforms, movable blocks), while others should only grow or shrink vertically (columns, barriers). Every one of these is a distinct failure mode that needs its own explicit solution.
THOUGHT PROCESS
I structured the scaling logic into three layers: a precondition gate, a collision-free check, and a mode-aware, direction-preserving scale application.
The precondition gate enforces that the player must be grounded and stationary before any scaling occurs. This prevents mid-air physics explosions and exploit cases where a player could scale up while moving to pass through geometry.
The collision-free check uses three OverlapBox probes placed at the player's left edge, right edge, and top edge, sized dynamically from the current localScale. The rule is deliberately asymmetric: scale-up requires the top and at least one lateral side to be clear; scale-down is always allowed, since shrinking never causes new overlaps. The same three-probe system is mirrored in Scalable.cs for world objects, with the same asymmetric rule.
Scale application has to be sign-preserving because localScale.x is negative for left-facing objects. Applying a raw positive delta to a left-facing player inverts their orientation. VecToPlayerFacingDir and VecToActiveObjFacingDir re-sign the x component of the scale delta to match the object's current facing before adding it. For world objects, the originalScale is normalized before computing the per-frame delta, so non-uniform starting scales don't get progressively distorted each frame.
Object selection uses a line-of-sight raycast from the player's eyes rather than proximity or a screen-space check. Two separate layer masks control what the raycast passes through: one for geometry the player can always see through, and one for geometry that becomes transparent only after the object is already tagged. This means the player can't tag objects behind walls, but a tagged object that drifts behind cover stays selected until line of sight is actually broken.
SOLUTION
A) Precondition gate and asymmetric collision check in UpdateScaling():
if (!playerMovement.IsGrounded() || Mathf.Abs(playerMovement.horizontal) > 0) return;
if (Mathf.Abs(transform.localScale.x) > calculatedPlayerMaxScale.x
|| Mathf.Abs(transform.localScale.x) < calculatedPlayerMinScale.x) return;
if (GetScalingAxis() != 0f && (IsCollisionFree() || GetScalingAxis() < 0f))
{
Vector3 normalizedPlayerScale = new Vector3(
Math.Abs(originalPlayerScale.x), originalPlayerScale.y, originalPlayerScale.z
).normalized;
normalizedPlayerScale.x *= Mathf.Sign(transform.localScale.x);
transform.localScale += normalizedPlayerScale * GetScalingAxis();
if (Math.Abs(transform.localScale.x) > calculatedPlayerMaxScale.x)
transform.localScale = VecToPlayerFacingDir(calculatedPlayerMaxScale);
else if (Math.Abs(transform.localScale.x) < calculatedPlayerMinScale.x)
transform.localScale = VecToPlayerFacingDir(calculatedPlayerMinScale);
UpdateCamera
B) Three-probe collision detection, shared between player and world objects:
public bool IsCollisionFree()
{
bool leftFree = !Physics2D.OverlapBox(GetObjEdgePos(-1, 0), GetVerticalBoxSize(), 0, ~LayerMask.GetMask("Player", "UI"));
bool rightFree = !Physics2D.OverlapBox(GetObjEdgePos( 1, 0), GetVerticalBoxSize(), 0, ~LayerMask.GetMask("Player", "UI"));
bool topFree = !Physics2D.OverlapBox(GetObjEdgePos( 0, 1), GetHorizontalBoxSize(), 0, ~LayerMask.GetMask("Player", "UI"));
return (leftFree || rightFree) && topFree
The probe boxes are sized from the current localScale each call, so they stay accurate as the player resizes. The asymmetric (leftFree || rightFree) && topFree rule allows scale-up against a single wall, which is a deliberate design choice: players can grow while flush against one wall as long as the opposite side and top are clear.
C) Two-mode scale application for world objects:
switch (scalableObject.scaleOption)
{
case ScaleOption.PROPORTIONAL:
Vector3 normalizedOriginalScale = new Vector3(
Math.Abs(objectOriginalScale.x), objectOriginalScale.y, objectOriginalScale.z
).normalized;
activeTaggedObject.transform.localScale +=
VecToActiveObjFacingDir(normalizedOriginalScale) * GetScalingAxis();
break;
case ScaleOption.VERTICAL:
activeTaggedObject.transform.localScale += Vector3.up * GetScalingAxis();
break
PROPORTIONAL normalizes the original scale before computing the delta so objects with non-uniform starting dimensions maintain their aspect ratio. VERTICAL applies the delta only to the Y axis, leaving X and Z untouched regardless of input magnitude.
D) Line-of-sight selection with dual layer masks:
public bool IsObjectAvailable(GameObject clickedObject, bool checkForDeselect = false)
{
if (!clickedObject.GetComponent<Scalable>()) return false;
LayerMask combineMask = checkForDeselect
? canSeeThroughLayer | canSeeThroughWhenTagged
: canSeeThroughLayer;
RaycastHit2D hit = Physics2D.Raycast(
playerEyes.transform.position,
clickedObject.transform.position - playerEyes.transform.position,
Mathf.Infinity,
~combineMask
);
return hit.collider != null && hit.collider.gameObject == clickedObject
When checking whether to auto-deselect an already-tagged object (checkForDeselect = true), the raycast ignores an additional set of layers, giving the tagged object a slightly wider window before it loses selection. This prevents flickering deselection when the player briefly passes in front of a tagged object.
Physics-Aware Movement and Jump Compensation
Physics-Aware Movement and Jump Compensation
Physics-Aware Movement and Jump Compensation
CHALLENGE
When the player changes size, their physics silently breaks. A larger player has a bigger collider and more visual presence, but unless movement speed and jump height scale with size, the game becomes inconsistent: a giant player that moves at the same speed as a tiny player feels weightless, and a jump arc tuned for normal size will send a shrunken player into the ceiling or barely lift a grown player off the ground. The grounded check also breaks — a fixed-size ground overlap box that works at default scale will miss the floor entirely at small size or over-detect at large size.
THOUGHT PROCESS
I derived every size-dependent physics value from the current scale ratio relative to the original, rather than hardcoding any values. The scale multiplier Mathf.Abs(transform.localScale.x / playerScale.originalPlayerScale.x) is computed fresh each FixedUpdate and applied to move speed. This means a player twice the original size moves at twice the speed automatically, without any additional logic.
Jump height required a physics-derived formula rather than a simple multiplier. A jump that "feels the same height" regardless of player size is actually a jump that reaches the same number of body-heights — which means the world-space apex must increase proportionally with scale. The correct initial velocity for a given apex height under constant gravity is sqrt(2 * height * |gravity|). I used the player's world-space height (derived from localScale.x, since the player is uniformly scaled) as the target apex, giving a jump that always clears approximately one player-height regardless of current size.
The grounded check uses an OverlapBox whose width is 0.75 * |localScale.x|, so it stays proportional to the player's actual footprint at any size.
SOLUTION
A) Scale-proportional move speed in FixedUpdate():
void FixedUpdate()
{
float scaleMultiplier = Mathf.Abs(transform.localScale.x / playerScale.originalPlayerScale.x);
if (IsGrounded())
{
rb.velocity = new Vector2(horizontal * moveSpeed * scaleMultiplier, rb.velocity.y);
}
else
{
rb.velocity = new Vector2(horizontal * moveSpeed * scaleMultiplier * 0.7f, rb.velocity.y
The 0.7 air multiplier is applied after the scale multiplier, so air control stays at a consistent fraction of ground speed at all sizes.
B) Physics-derived jump velocity from current scale:
float HeightToVelocity()
{
return Mathf.Sqrt(2 * (1f * Mathf.Abs(transform.localScale.x)) / Mathf.Abs(Physics2D.gravity.y))
* Mathf.Abs(Physics2D.gravity.y
This simplifies to sqrt(2 * h * g) where h = |localScale.x| and g = |gravity|. Using localScale.x as the jump height target means a player scaled to 2x jumps twice as high in world space — which feels like the same jump relative to their body size.
C) Scale-proportional grounded detection:
public bool IsGrounded()
{
return Physics2D.OverlapBox(
groundCheck.position,
new Vector2(0.75f * Mathf.Abs(transform.localScale.x), 0.05f),
0f,
groundLayer
The box width tracks |localScale.x| so the grounded check remains accurate regardless of current size. A fixed-width box would over-detect at small scale (the player appears floating but registers as grounded) or under-detect at large scale (the player is standing but the check misses the ground).
CHALLENGE
A button that responds physically to weight — activated when pressed down far enough, released when weight lifts — sounds simple, but several edge cases compound. The button top is a separate Rigidbody2D that needs to be constrained to move only along its local Y axis, not drift or rotate under physics. The threshold for "pressed" needs to be defined as a fraction of the button's travel range, but that travel range is expressed in local space and breaks if the button is rotated in the world. The button needs to fire UnityEvent callbacks on the exact frame the pressed state changes, not every frame it's held, and the events need to be suppressible during a reset so the button doesn't trigger side effects when being repositioned. Colliders on the button base need to not collide with the button top or with specified objects (like the player), or the button physically fights itself.
THOUGHT PROCESS
I computed the button's travel range (upperLowerDiff) in a rotation-zeroed context: before storing the value, I temporarily set the transform's euler angles to zero, measured the Y distance between the upper and lower limit transforms, then restored the original rotation. This gives a stable local-space measurement regardless of how the button is oriented in the world.
Pressed state is detected by checking whether the button top's distance to the lower limit is below a percentage of upperLowerDiff, set via a threshHold inspector field. This is a continuous spatial check rather than a collision event, which means it correctly handles the button being held down by a resting object, not just an initial impact.
Edge-detection for the callback uses a prevPressedState bool that lags one frame behind isPressed. The callback fires only when the two disagree — Pressed() when isPressed is true but prevPressedState was false, Released() when the reverse. This is the same pattern as Input.GetKeyDown vs Input.GetKey, applied manually to a physics state.
Collision exclusion is set up at Start: the button base ignores the button top via Physics2D.IgnoreCollision, and all colliders in CollidersToIgnore are excluded from both the base and the button top's attached colliders.
SOLUTION
A) Rotation-aware travel range calculation:
if (transform.eulerAngles != Vector3.zero)
{
Vector3 savedAngle = transform.eulerAngles;
transform.eulerAngles = Vector3.zero;
upperLowerDiff = buttonUpperLimit.position.y - buttonLowerLimit.position.y;
transform.eulerAngles = savedAngle;
}
else
{
upperLowerDiff = buttonUpperLimit.position.y - buttonLowerLimit.position.y
Measuring upperLowerDiff while the rotation is zeroed ensures the Y difference is a pure local-space measurement. Without this, a rotated button would compute a travel range that mixes X and Y contributions from world space.
B) Axis-constrained button top and spring-back force:
void FixedUpdate()
{
buttonTop.transform.localPosition = new Vector3(0, buttonTop.transform.localPosition.y, 0);
buttonTop.transform.localEulerAngles = Vector3.zero;
if (buttonTop.localPosition.y >= 0)
buttonTop.transform.position = new Vector3(buttonUpperLimit.position.x, buttonUpperLimit.position.y, 0);
else if (!isPressed)
buttonTopRigid.AddForce(buttonTop.transform.up * force * Time.deltaTime);
if (buttonTop.localPosition.y <= buttonLowerLimit.localPosition.y)
{
buttonTop.transform.position = new Vector3(buttonLowerLimit.position.x, buttonLowerLimit.position.y, 0);
buttonTopRigid.bodyType = RigidbodyType2D.Kinematic
The button top is a Rigidbody2D that can physically be pushed down by objects landing on it. Local position and rotation are zeroed every FixedUpdate to strip any drift introduced by the physics solver. The spring-back AddForce uses buttonTop.transform.up so it works correctly for rotated buttons.
C) Threshold-based pressed detection with edge-triggered callbacks:
if (Vector2.Distance(buttonTop.position, buttonLowerLimit.position) < upperLowerDiff * threshHold)
isPressed = true;
else
isPressed = false;
if (isPressed && prevPressedState != isPressed)
Pressed();
if (!isPressed && prevPressedState != isPressed)
Released
threshHold is a 0–1 fraction of the total travel range. Setting it to 0.1 means the button fires when the top is within 10% of the lower limit. The prevPressedState != isPressed check fires exactly once per state transition, matching Unity's GetKeyDown pattern and preventing repeated UnityEvent invocations while the button is held.