Global Game Jam runs 48 hours. Coyote time, jump buffering, variable jump height, an invincibility window and a hit flash are already written and already commented here — 26 values in the Inspector and 6 character presets, so those hours go into the level instead of into re-deriving a jump arc.
Assets/Character2DFeelKit| File | What it is for |
|---|---|
Runtime/Character2DMotor.cs | The movement itself. Run, jump, coyote time, jump buffering, variable jump height, air jumps and a fall-speed cap, plus two warnings that catch the two setup mistakes everybody makes: a ground layer that includes the character, and a gravity scale of zero. |
Runtime/CharacterHealth.cs | Hit points with a real invincibility window. Contact damage from hazard layers ticks once per window instead of once per physics frame, death fires exactly once however many spikes are touching, and healing cannot go over the maximum. |
Runtime/HitFlash.cs | The damage feedback. Blinks every SpriteRenderer on the character when a hit lands, restarts cleanly if a second hit arrives mid-blink, and puts the original colours back in OnDisable so the character is never left stuck red. |
Runtime/CharacterAnimatorBridge.cs | Feeds the Animator from the motor and the health without ever asking for a parameter the controller does not have, so a half-built controller cannot flood the console. Also flips the sprite to face the way you are travelling. |
Runtime/HealthBarUI.cs | A health bar on a filled UI Image. Catches up smoothly, turns your low-health colour, can hide itself at full health, survives a max health of zero, and switches itself off with a readable message instead of throwing a null reference every frame. |
Presets/character-tuning-presets.json | Six characters, one formula. Every key matches a public field on Character2DMotor exactly, so you can either type the numbers into the Inspector or pass one preset object to ApplyTuningJson at runtime for an ice level or a pair of heavy boots. |
QuickStart.md | Import to a moving, hurting, flashing character in five minutes, plus the two version facts that stop refunds: which Unity versions this runs on, and the fact that there is no material or shader in the pack so no render pipeline can break it. |
| File | Field | What it does |
|---|---|---|
Runtime/Character2DMotor.cs | moveSpeed | The one number that decides whether your game feels like a stroll or a sprint. Set it before you touch anything else, because acceleration is measured against it. |
Runtime/Character2DMotor.cs | groundAcceleration | How quickly you reach top speed. Above 150 feels arcade and instant; below 40 feels like the character has weight and momentum. |
Runtime/Character2DMotor.cs | groundDeceleration | How quickly you stop. Set it below groundAcceleration and the character slides past ledges; set it above and stops are crisp. |
Runtime/Character2DMotor.cs | airAcceleration | How much you can steer mid-jump. Low values make a jump a commitment, which is what precision platformers want. |
Runtime/Character2DMotor.cs | jumpHeight | Set in world units, not force, so it stays the same when you change gravity scale. Measure your tallest platform and set this a little above it. |
Runtime/Character2DMotor.cs | extraAirJumps | 0 for a single jump, 1 for a double jump. Change it at runtime the moment the player picks up the wings. |
Runtime/Character2DMotor.cs | coyoteTime | Seconds after walking off a ledge during which a jump still works. Players never notice it is there; they only notice when it is missing. |
Runtime/Character2DMotor.cs | jumpBufferTime | Seconds before landing during which an early jump press is remembered. This is the difference between a game that feels responsive and one that feels like it ignores you. |
Runtime/Character2DMotor.cs | fallGravityMultiplier | Raise this before you touch jump height. A character that falls faster than it rose is the single cheapest fix for a floaty jump. |
Runtime/Character2DMotor.cs | lowJumpGravityMultiplier | Applied while rising with the button already released. This is what turns a quick tap into a small hop and a held press into a full jump. |
Runtime/Character2DMotor.cs | maxFallSpeed | Keeps long drops readable and stops a fast fall tunnelling through a thin platform collider. |
Runtime/CharacterHealth.cs | maxHealth | Hearts, not percent. Five means the player survives five hits, which is what you are really designing when you place hazards. |
Runtime/CharacterHealth.cs | invincibleSeconds | Seconds of immunity after a hit. Leave it at 0 and standing on a spike drains the whole health bar in a quarter of a second. |
Runtime/CharacterHealth.cs | contactDamage | Damage taken from anything on the hazard layers. Set it to 0 while you are prototyping and want to walk through your own spikes. |
Runtime/CharacterHealth.cs | hazardLayers | Which layers hurt. One field instead of a damage script on every spike, every enemy and every falling rock in the level. |
Runtime/CharacterHealth.cs | destroyOnDeath | Leave it off while you are still building the death animation, so the object stays in the scene for you to look at. |
Runtime/HitFlash.cs | flashColor | Red reads as damage; white reads as impact. Change it here rather than in a shader, because there is no shader in this pack to change. |
Runtime/HitFlash.cs | flashSeconds | Length of one on-off step. Around 0.06 to 0.10 reads as a hit; longer than 0.2 reads as a bug. |
Runtime/HitFlash.cs | flashCount | Match it to the invincibility window: when the blinking stops, the player is vulnerable again, and that is free information the player reads without being told. |
Runtime/HitFlash.cs | includeChildSprites | On, the whole character flashes including the sword and the cape. Off, only the body does, which suits characters made of many pieces. |
Runtime/CharacterAnimatorBridge.cs | speedParameter | Type the name your Animator already uses instead of renaming your controller to match somebody else's script. Leave it empty to skip it entirely. |
Runtime/CharacterAnimatorBridge.cs | groundedParameter | The bool your jump and fall states switch on. If your controller does not have it yet, the bridge stays silent rather than logging an error every frame. |
Runtime/CharacterAnimatorBridge.cs | flipSpriteWithMovement | On for a character that faces the way it walks. Off if your art has separate left and right clips, or if you flip a parent transform yourself. |
Runtime/HealthBarUI.cs | smoothSeconds | 0 snaps and reads as arcade. Around 0.25 makes the bar drain visibly, which is what makes a big hit feel big. |
Runtime/HealthBarUI.cs | hideWhenFull | On for an exploration game where a clean screen matters. Off for an action game where the player wants the number in view at all times. |
Runtime/HealthBarUI.cs | lowHealthColor | The colour the bar turns near death. It is the last warning the player gets before the run ends, so make it loud. |
Runtime/Character2DMotor.csCopy this file into your project as-is. It is one of the scripts in the pack.
using UnityEngine;
/// <summary>
/// 2D platformer movement with the feel details tutorials leave out:
/// coyote time, jump buffering, variable jump height, air jumps and a
/// separate fall gravity.
///
/// Drive it with the legacy Input Manager (Read Legacy Input, on by default)
/// or turn that off and call SetMoveInput, RequestJump and ReleaseJump from
/// your own input code. Both input systems are supported that way.
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
[DisallowMultipleComponent]
[AddComponentMenu("2D Character Feel Kit/Character 2D Motor")]
public class Character2DMotor : MonoBehaviour
{
[Header("Run")]
[Tooltip("Top horizontal speed, in world units per second.")]
[Min(0f)] public float moveSpeed = 8f;
[Tooltip("How hard the character is pushed towards top speed on the ground, in units per second squared. Higher feels snappier.")]
[Min(0f)] public float groundAcceleration = 70f;
[Tooltip("How hard the character is slowed on the ground when you let go. Set it below groundAcceleration for a slide.")]
[Min(0f)] public float groundDeceleration = 90f;
[Tooltip("Air control. Below groundAcceleration makes a jump feel committed instead of steerable.")]
[Min(0f)] public float airAcceleration = 35f;
[Header("Jump")]
[Tooltip("Peak jump height in world units, measured from take-off. The take-off velocity is solved from this and the current gravity, so changing gravity does not change your jump height.")]
[Min(0f)] public float jumpHeight = 3.2f;
[Tooltip("Jumps allowed after leaving the ground. 0 is a single jump, 1 is a double jump.")]
[Min(0)] public int extraAirJumps = 1;
[Tooltip("Seconds after walking off a ledge during which a jump press still works. 0.08 to 0.15 feels generous without feeling loose.")]
[Min(0f)] public float coyoteTime = 0.12f;
[Tooltip("Seconds before landing during which a jump press is remembered and fired the moment the feet touch down.")]
[Min(0f)] public float jumpBufferTime = 0.12f;
[Tooltip("Gravity multiplier while falling. Above 1 makes the character drop faster than it rose, which is what most good platformers do.")]
[Min(0f)] public float fallGravityMultiplier = 2.2f;
[Tooltip("Gravity multiplier while still rising after the jump button was released. This is what turns a short tap into a short hop.")]
[Min(0f)] public float lowJumpGravityMultiplier = 2.8f;
[Tooltip("Fastest the character may fall, in units per second. Also stops fast falls tunnelling through thin colliders.")]
[Min(0f)] public float maxFallSpeed = 22f;
[Header("Ground check")]
[Tooltip("Layers that count as ground. Do not include the layer this character is on.")]
public LayerMask groundLayers = 1;
[Tooltip("Thickness of the box used to look for ground under the feet, in world units.")]
[Min(0f)] public float groundProbeThickness = 0.08f;
[Header("Input")]
[Tooltip("Read the legacy Input Manager axes Horizontal and Jump. Turn this off and call SetMoveInput, RequestJump and ReleaseJump from your own input code.")]
public bool readLegacyInput = true;
public event System.Action Jumped;
public event System.Action Landed;
public bool IsGrounded { get { return _grounded; } }
public float MoveInput { get { return _moveInput; } }
public float HorizontalSpeed { get { return _body != null ? _body.velocity.x : 0f; } }
public float VerticalSpeed { get { return _body != null ? _body.velocity.y : 0f; } }
Rigidbody2D _body;
Collider2D _collider;
float _moveInput;
float _coyoteTimer;
float _bufferTimer;
int _airJumpsUsed;
bool _grounded;
bool _wasGrounded;
bool _jumpHeld;
bool _jumpFiredThisStep;
void Awake()
{
_body = GetComponent<Rigidbody2D>();
_collider = GetComponent<Collider2D>();
_body.freezeRotation = true;
if (_collider == null)
{
Debug.LogWarning("Character2DMotor: there is no Collider2D on this object, so the ground check has nothing to measure from. Add a CapsuleCollider2D or a BoxCollider2D.", this);
}
// The mistake that costs an hour: the character is on a layer that is
// also listed as ground, so the probe finds the character itself and
// it never falls. Say so out loud instead of letting them guess.
if (((1 << gameObject.layer) & groundLayers.value) != 0)
{
Debug.LogWarning("Character2DMotor: Ground Layers includes this object's own layer, so the ground check will keep finding the character itself and it will never fall. Put the character on its own layer, or clear that layer from Ground Layers.", this);
}
if (_body.gravityScale <= 0f)
{
Debug.LogWarning("Character2DMotor: Rigidbody2D Gravity Scale is 0, so there is no arc to solve and the character will not come back down. Set it to 1.", this);
}
}
void Update()
{
if (readLegacyInput) ReadLegacyInput();
if (_grounded) _coyoteTimer = coyoteTime;
else if (_coyoteTimer > 0f) _coyoteTimer -= Time.deltaTime;
if (_bufferTimer > 0f) _bufferTimer -= Time.deltaTime;
}
void ReadLegacyInput()
{
// If the project is set to Input System Package only, the legacy calls
// throw. Switch ourselves off once, say what to call instead, and never
// throw again.
try
{
_moveInput = Input.GetAxisRaw("Horizontal");
if (Input.GetButtonDown("Jump")) RequestJump();
if (Input.GetButtonUp("Jump")) ReleaseJump();
_jumpHeld = Input.GetButton("Jump");
}
catch (System.Exception error)
{
readLegacyInput = false;
Debug.LogWarning("Character2DMotor: the legacy Input Manager is not active in this project, so Read Legacy Input has been switched off. Call SetMoveInput, RequestJump and ReleaseJump from your own input code instead. Details: " + error.Message, this);
}
}
/// <summary>Horizontal input from -1 to 1. Call this from your own input code.</summary>
public void SetMoveInput(float horizontal)
{
_moveInput = Mathf.Clamp(horizontal, -1f, 1f);
}
/// <summary>Jump pressed. Buffered, so it still fires if you were slightly early.</summary>
public void RequestJump()
{
_bufferTimer = jumpBufferTime;
_jumpHeld = true;
}
/// <summary>Jump released. Cuts the rise short for a lower hop.</summary>
public void ReleaseJump()
{
_jumpHeld = false;
}
void FixedUpdate()
{
_jumpFiredThisStep = false;
UpdateGrounded();
ApplyHorizontal();
TryJump();
ApplyGravity();
}
void UpdateGrounded()
{
_wasGrounded = _grounded;
if (_collider == null) { _grounded = false; return; }
Bounds bounds = _collider.bounds;
Vector2 centre = new Vector2(bounds.center.x, bounds.min.y - groundProbeThickness * 0.5f);
Vector2 size = new Vector2(Mathf.Max(0.01f, bounds.size.x * 0.9f), Mathf.Max(0.01f, groundProbeThickness));
_grounded = Physics2D.OverlapBox(centre, size, 0f, groundLayers) != null;
// Only give the air jumps back once we are resting or falling, so the
// frame we leave the ground does not silently refund one.
if (_grounded && _body.velocity.y <= 0.01f) _airJumpsUsed = 0;
if (_grounded && !_wasGrounded && Landed != null) Landed();
}
void ApplyHorizontal()
{
float target = _moveInput * moveSpeed;
float rate;
if (!_grounded) rate = airAcceleration;
else if (Mathf.Abs(target) > 0.01f) rate = groundAcceleration;
else rate = groundDeceleration;
float x = Mathf.MoveTowards(_body.velocity.x, target, rate * Time.fixedDeltaTime);
_body.velocity = new Vector2(x, _body.velocity.y);
}
void TryJump()
{
if (_jumpFiredThisStep) return; // one jump per physics step, never two
if (_bufferTimer <= 0f) return;
bool groundJump = _grounded || _coyoteTimer > 0f;
bool airJump = !groundJump && _airJumpsUsed < extraAirJumps;
if (!groundJump && !airJump) return;
float gravity = Mathf.Abs(Physics2D.gravity.y) * _body.gravityScale;
if (gravity <= 0f) return; // no gravity, no arc to solve
if (airJump) _airJumpsUsed++;
float takeOff = Mathf.Sqrt(2f * gravity * jumpHeight);
_body.velocity = new Vector2(_body.velocity.x, takeOff);
_bufferTimer = 0f;
_coyoteTimer = 0f;
_grounded = false;
_jumpFiredThisStep = true;
if (Jumped != null) Jumped();
}
void ApplyGravity()
{
float extra = 1f;
if (_body.velocity.y < 0f) extra = fallGravityMultiplier;
else if (_body.velocity.y > 0f && !_jumpHeld) extra = lowJumpGravityMultiplier;
if (extra > 1f)
{
float gravity = Physics2D.gravity.y * _body.gravityScale;
_body.velocity += new Vector2(0f, gravity * (extra - 1f) * Time.fixedDeltaTime);
}
if (maxFallSpeed > 0f && _body.velocity.y < -maxFallSpeed)
{
_body.velocity = new Vector2(_body.velocity.x, -maxFallSpeed);
}
}
/// <summary>
/// Overwrites the tuning fields from one preset object out of
/// Presets/character-tuning-presets.json. Fields the JSON does not name
/// keep their current value, so a preset can change only the two numbers
/// an ice level needs.
/// </summary>
public void ApplyTuningJson(string json)
{
if (string.IsNullOrEmpty(json))
{
Debug.LogWarning("Character2DMotor: ApplyTuningJson was given an empty string, so nothing was changed.", this);
return;
}
try
{
JsonUtility.FromJsonOverwrite(json, this);
}
catch (System.Exception error)
{
Debug.LogWarning("Character2DMotor: that preset is not valid JSON, so nothing was changed. Details: " + error.Message, this);
return;
}
// JSON can carry values the Inspector would have refused. Clamp them.
moveSpeed = Mathf.Max(0f, moveSpeed);
jumpHeight = Mathf.Max(0f, jumpHeight);
maxFallSpeed = Mathf.Max(0f, maxFallSpeed);
extraAirJumps = Mathf.Max(0, extraAirJumps);
}
void OnDrawGizmosSelected()
{
Collider2D shape = GetComponent<Collider2D>();
if (shape == null) return;
Bounds bounds = shape.bounds;
Vector3 centre = new Vector3(bounds.center.x, bounds.min.y - groundProbeThickness * 0.5f, 0f);
Vector3 size = new Vector3(Mathf.Max(0.01f, bounds.size.x * 0.9f), Mathf.Max(0.01f, groundProbeThickness), 0f);
Gizmos.color = Color.green;
Gizmos.DrawWireCube(centre, size);
}
}
This page is the working piece. The full pack has everything below.
Global Game Jam runs 48 hours. Coyote time, jump buffering, variable jump height, an invincibility window and a hit flash are already written and already commented here — 26 values in the In
Where the 48 comes from: Global Game Jam is a 48-hour event, held once a year — check your own jam's rules on pre-made assets before you rely on any pack, including this one. What you can count in this file: 5 C# scripts, 26 field
Send it to me