2D Games with Dynamic Platforms
Create a new 2D Unity project called Snowboarder
New Project Dialog
Set the Game display to 16:9 in the Display section of the Player Settings.
Player Settings
Create a new folder in the Project window under Assets called Snow and import the images from the ZIP located in Canvas.
Copy Assets
Select and configure both the Floor Tile Fill
and Floor Tile
assets that you added to a Full Rect mesh type and a Repeat wrap mode and click the Apply button.
Configure Assets
Level Sprite Shape
Level Sprite Shape
Splines
Splines
buttonLet’s create a new shape profile and use it for our level sprite shape.
Level Sprite Shape Profile
Level Sprite Shape
in the hiearchy panelLevel Sprite Shape Profile
Does the ball roll down the slope?
Add the package to your project if you don’t have it already.
Let’s add a character to the game to replace the ball. You can use one of the characters from the assets folder or find your own.
If you were not able to finish Day 1 code, you can clone the repository from https://github.com/LucasCordova/Snowboarder
I will take tags for each day we add functionality.
Input Action Asset.asset
.Create Asset
button.InputSystem_Actions
asset, the Control Type is a Vector2.InputSystem_Actions
, let’s remove all the actions from the Input Action Asset except for Move
.Move
action in a script.
PlayerController
.MoveAction
.Move
action using the Input System’s FindAction method in Start()
.Update()
, create a Vector2
called moveAction
and set it to the value of moveAction.ReadValue<Vector2>()
.moveAction
is working.moveAction
when you move the player?rigidbody2D
and get the Rigidbody2D.GetComponent<Rigidbody2D>()
method in Start().torqueAmount
instance variable of type float
and set it to 1f.AddTorque()
method to add the torqueAmount to the Rigidbody2D.
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerController : MonoBehaviour
{
[SerializeField] float torqueAmount = 1f;
InputAction moveAction;
Rigidbody2D rigidbody2D;
void Start()
{
moveAction = InputSystem.actions.FindAction("Move");
rigidbody2D = GetComponent<Rigidbody2D>();
}
void Update()
{
Vector2 moveVector = moveAction.ReadValue<Vector2>();
if (moveVector.x > 0f)
{
rigidbody2D.AddTorque(-torqueAmount);
}
else if (moveVector.x < 0f)
{
rigidbody2D.AddTorque(torqueAmount);
}
}
}
Finish Line
.Is Trigger
checkbox.FinishLine
and add it to the Finish Line Game Object.FinishLine
script, add a OnTriggerEnter2D()
method to handle the collision with the finish line with a debug log statement to say “You finished the level!”.Snow Level
and add the Snow Level Game Object to it.Player
and add the Player Game Object to it.For Win:
FinishLine
script to use the layers to detect when the player has finished the level by detecting if the it was the Player Level
layer that we collided with.layerIndex
and set it to LayerMask.NameToLayer(“Player”).OnTriggerEnter2D()
method, check if the layerIndex is equal to the Player Level
layer and then print out the message.For Lose:
CrashDetector
and add a OnTriggerEnter2D()
method to handle the collision with the crash detector with a debug log statement to say “You crashed into something!”.SceneManager.LoadScene()
method.FinishLine
use the SceneManager to reload the current scene after the player has finished the level.Invoke("NameOfMethod", delayInSeconds)
FinishLine
use the Invoke() method to reload the current scene after the player has finished the level..Play()
method to play the particle system..Stop()
method to stop the particle system..Pause()
method to pause the particle system..Clear()
method to clear the particle system..Emit(int count)
method to emit a number of particles..SetBurst()
method to set a burst of particles..SetEmissionRate()
method to set the emission rate of the particle system.OnTriggerEnter2D()
and OnTriggerExit2D()
methods.collision
parameter to get the other game object that entered the trigger.GetComponent<ParticleSystem>()
method.
public class SnowTrail : MonoBehaviour
{
[SerializeField] ParticleSystem snowParticles;
void OnCollisionEnter2D(Collision2D collision)
{
int layerIndex = LayerMask.NameToLayer("SnowLayer");
if (collision.gameObject.layer == layerIndex)
{
snowParticles.Play();
}
}
void OnCollisionExit2D(Collision2D collision)
{
int layerIndex = LayerMask.NameToLayer("SnowLayer");
if (collision.gameObject.layer == layerIndex)
{
snowParticles.Stop();
}
}
}
baseSpeed
boostSpeed
GetComponent<SurfaceEffector2D>()
method.[SerializeField] float baseSpeed = 20f;
[SerializeField] float boostSpeed = 30f;
void Update()
{ // Refactored
Vector2 moveVector = moveAction.ReadValue<Vector2>();
RotatePlayer(moveVector);
BoostPlayer(moveVector);
}
void BoostPlayer(Vector2 moveVector)
{
if (moveVector.y > 0f) surfaceEffector2D.speed = boostSpeed;
else surfaceEffector2D.speed = baseSpeed;
}
void RotatePlayer(Vector2 moveVector)
{
if (moveVector.x > 0f) rigidbody2D.AddTorque(-torqueAmount);
else if (moveVector.x < 0f) rigidbody2D.AddTorque(torqueAmount);
}
canMove
.DisableControls()
to disable the controls.Where would we call DisableControls() from?
canMove
to the PlayerController script.DisableControls()
to the PlayerController script.DisableControls()
method, set the canMove
property to false.Update()
method, check if the canMove
property is false and if so, return.OnCollisionEnter2D()
method in the CrashDetection, get the instance of the PlayerController and call the DisableControls()
method.// PlayerController
[SerializeField] bool canMove = true;
public void DisableControls() => canMove = false;
public void Update()
{
if (!canMove) return;
RotatePlayer();
BoostPlayer();
CalculateFlips();
}
// CrashDetection
void OnCollisionEnter2D(Collision2D collision)
{
PlayerController playerController = FindFirstObjectOfType<PlayerController>();
playerController.DisableControls();
}
We can use this algorithm:
We will need 3 more variables to count flips:
previousRotation
: the rotation from the previous frame.totalRotation
: the total rotation we get from deltaAngle().flipCount
: the number of flips the player has done!
// PlayerController
float previousRotation = 0f;
float totalRotation = 0f;
int flipCount = 0;
public void CalculateFlips()
{
float currentRotation = transform.rotation.z;
totalRotation += Mathf.DeltaAngle(previousRotation, currentRotation);
previousRotation = currentRotation;
if (totalRotation >= 360f)
{
flipCount++;
totalRotation = 0f;
}
Debug.Log($"Flips: {flipCount}");
}
Scale with Screen Size
and set the Reference Resolution to 1920x1080
.AddScore(int additionalScore)
to the ScoreManager script.AddScore()
method, add the additionalScore to the score and update the scoreText.AddScore()
method.CalculateFlips()
method, call the AddScore()
method with the flipCount.ScriptableObject is a Unity class that allows you to store large amounts of shared data independent from script instances.
Key Benefits:
Common Uses:
Issues:
[CreateAssetMenu(fileName = "EnemyData",
menuName = "Game/Enemy Data")]
public class EnemyData : ScriptableObject {
public float health;
public float damage;
public float speed;
public Sprite sprite;
}
public class Enemy : MonoBehaviour {
public EnemyData data;
void Start() {
// Use data.health, data.damage, etc.
}
}
Benefits: One asset, many references!
The same Item asset can be referenced by multiple GameObjects!
[CreateAssetMenu(menuName = "Weapons/Weapon Config")]
public class WeaponConfig : ScriptableObject {
[Header("Visual")]
public GameObject prefab;
public Sprite uiIcon;
[Header("Combat Stats")]
public float damage;
public float fireRate;
public int magazineSize;
public float reloadTime;
[Header("Audio")]
public AudioClip fireSound;
public AudioClip reloadSound;
}
public class WeaponController : MonoBehaviour {
public WeaponConfig config;
private float lastFireTime;
private int currentAmmo;
void Start() {
currentAmmo = config.magazineSize;
}
public void Fire() {
if (Time.time - lastFireTime < 1f / config.fireRate)
return;
if (currentAmmo > 0) {
// Deal damage, play sound, etc.
AudioSource.PlayClipAtPoint(config.fireSound,
transform.position);
currentAmmo--;
lastFireTime = Time.time;
}
}
}
[CreateAssetMenu(menuName = "Events/Game Event")]
public class GameEvent : ScriptableObject {
private List<GameEventListener> listeners =
new List<GameEventListener>();
public void Raise() {
for (int i = listeners.Count - 1; i >= 0; i--) {
listeners[i].OnEventRaised();
}
}
public void RegisterListener(GameEventListener listener) {
listeners.Add(listener);
}
public void UnregisterListener(GameEventListener listener) {
listeners.Remove(listener);
}
}
[CreateAssetMenu(menuName = "Variables/Float Variable")]
public class FloatVariable : ScriptableObject {
[SerializeField] private float value;
public float Value {
get { return value; }
set { this.value = value; }
}
public void Add(float amount) {
value += amount;
}
public void Set(float amount) {
value = amount;
}
}
Multiple systems can read/write to the same value!
[CreateAssetMenu(menuName = "Database/Item Database")]
public class ItemDatabase : ScriptableObject {
public List<Item> allItems;
public Item GetItemByName(string name) {
return allItems.Find(item =>
item.itemName == name);
}
public List<Item> GetItemsByType(ItemType type) {
return allItems.FindAll(item =>
item.type == type);
}
public Item GetRandomItem() {
return allItems[Random.Range(0, allItems.Count)];
}
}
[CreateAssetMenu]
for easy creationSolution: Copy values to instance variables
public abstract class Ability : ScriptableObject {
public string abilityName;
public float cooldown;
public abstract void Activate(GameObject target);
}
[CreateAssetMenu(menuName = "Abilities/Damage")]
public class DamageAbility : Ability {
public float damage;
public override void Activate(GameObject target) {
target.GetComponent<Health>()?.TakeDamage(damage);
}
}
Different ability types, same interface!
~86% memory reduction!
// Strategy interface
public abstract class AIStrategy : ScriptableObject {
public abstract Vector3 CalculateMove(GameObject enemy);
}
// Concrete strategies
[CreateAssetMenu(menuName = "AI/Aggressive")]
public class AggressiveStrategy : AIStrategy {
public override Vector3 CalculateMove(GameObject enemy) {
// Move toward player
}
}
[CreateAssetMenu(menuName = "AI/Defensive")]
public class DefensiveStrategy : AIStrategy {
public override Vector3 CalculateMove(GameObject enemy) {
// Keep distance
}
}
[CreateAssetMenu(menuName = "Audio/Audio Collection")]
public class AudioCollection : ScriptableObject {
[System.Serializable]
public class AudioEntry {
public string name;
public AudioClip clip;
[Range(0f, 1f)] public float volume = 1f;
public bool loop;
}
public List<AudioEntry> sounds;
public AudioEntry GetSound(string soundName) {
return sounds.Find(s => s.name == soundName);
}
}
[CreateAssetMenu(menuName = "Debug/Test Config")]
public class TestConfig : ScriptableObject {
[Header("Debug Options")]
public bool godMode;
public bool showDebugInfo;
public float timeScale = 1f;
[Header("Test Values")]
public int startingLevel = 1;
public float testDamageMultiplier = 1f;
#if UNITY_EDITOR
[Button] // Requires custom attribute
public void ResetToDefaults() {
godMode = false;
timeScale = 1f;
// etc.
}
#endif
}
Connect ScriptableObject events to UnityEvents in Inspector!
||-|–| | ScriptableObjects | Shared static data | Not for runtime state | | Singletons | Runtime managers | Tight coupling | | Static Classes | Utilities | Hard to test | | PlayerPrefs | Simple save data | Limited types | | JSON Files | Complex save data | More overhead |
Values changed during play mode persist in the editor!
Solution: Reset values in OnDisable()
or use instance copies
ScriptableObject A references B, B references A
Solution: Use events or dependency injection
Not everything needs to be a ScriptableObject!
Solution: Use for data that’s truly shared/reusable
CharacterStats
ScriptableObject
✅ ScriptableObjects are data containers separate from GameObjects
✅ Perfect for shared configuration and asset-based design
✅ Enable data-driven development and designer-friendly workflows
✅ Great for memory efficiency and modularity
✅ Use for static data, not runtime state
✅ Powerful when combined with inheritance and events