Building a 2D Game in Unity from Scratch: Adding a Character, Writing Code, Running and Testing

Unity is one of the most widely used game engines, for beginners and professional teams alike. A 2D game is a good place to learn the core ideas: scenes, GameObjects, components, physics and reading player input, without a large amount of complexity. This article takes you through creating a new project, adding a character to the scene, writing scripts that make the character walk and jump, making the camera follow it, and then testing that the code behaves correctly.

Note: the code in this article keeps the Thai comments from the original version. The code itself works the same in any language, and you can translate the comments if you wish.

Schematic of the Unity window (not a real screenshot): 1 the Play button at the top, 2 the + button in the Hierarchy to create objects, 3 Add Component in the Inspector, 4 the Project panel where you create scripts
Schematic of the Unity window (not a real screenshot): 1 the Play button at the top, 2 the + button in the Hierarchy to create objects, 3 Add Component in the Inspector, 4 the Project panel where you create scripts

What you need

Step 1: create a 2D project

1. Open Unity Hub, click **New project** and choose the **Universal 2D** (URP) or **2D** (Built-in) template, using the names shown in Unity Hub.

2. Name the project, for example `MyPlatformer`, and click **Create project**.

3. Wait for Unity to open. You will see an empty scene called `SampleScene`.

A scene is the play area of the game. Everything in it, such as the character, the ground and the camera, is a **GameObject**, and components attached to each GameObject give it its behaviour.

Schematic of Edit → Project Settings → Player → Active Input Handling (not a real screenshot): 1 the Edit menu, 2 Project Settings, 3 the Player tab, 4 the Active Input Handling dropdown
Schematic of Edit → Project Settings → Player → Active Input Handling (not a real screenshot): 1 the Edit menu, 2 Project Settings, 3 the Player tab, 4 the Active Input Handling dropdown

Step 2: set up input

The script in this article uses the classic input system (Input Manager). To enable it:

1. Go to **Edit → Project Settings → Player**.

2. Find **Active Input Handling** and select **Both** or **Input Manager (Old)**.

3. Accept Unity's prompt to restart the editor.

The **Horizontal** axis (left and right) and the **Jump** button (Space) are already included in the default setup. Left and right use A/D or the arrow keys.

Step 3: create the ground

1. Right-click in the Hierarchy and choose **2D Object → Sprites → Square**. Name it `Ground`.

2. In the Inspector set Scale to `X = 20`, `Y = 1` to make a long floor.

3. Set Position to `X = 0`, `Y = -2`.

4. Click **Add Component** and add a **Box Collider 2D**, so the character can stand on it.

5. Create a layer called `Ground`: click the **Layer** dropdown at the top of the Inspector, choose **Add Layer**, then return to `Ground` and assign it the `Ground` layer.

The layer lets the script recognise what the character's feet are touching, so it can tell floor from walls or enemies.

Step 4: add the character

1. Right-click in the Hierarchy and choose **2D Object → Sprites → Square**. Name it `Player`.

2. Set Position to `X = 0`, `Y = 1` so the character starts above the ground.

3. If you have a character image, drag the PNG into the **Sprite** field of the Sprite Renderer.

4. Add a **Rigidbody 2D**, set **Body Type** to Dynamic and tick **Freeze Rotation Z** so the character does not topple.

5. Add a **Box Collider 2D**.

The Rigidbody 2D makes the object fall under gravity and collide with others. Without it the character would float in place.

Step 5: create a ground check point

1. Right-click `Player` in the Hierarchy and choose **Create Empty**. Name it `GroundCheck`.

2. Drag it to the character's feet, for example Position `X = 0`, `Y = -0.5`.

The script uses this point as the centre of a small circle that checks whether there is ground underneath.

Step 6: write the character controller

Right-click in the Project window and choose **Create → C# Script**. Name it `PlayerController2D`, open it, and paste in this code.

using UnityEngine;

// ควบคุมตัวละคร 2D: เดินซ้ายขวา กระโดด และหันหน้าตามทิศทาง
[RequireComponent(typeof(Rigidbody2D))]
[RequireComponent(typeof(SpriteRenderer))]
public class PlayerController2D : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;      // ความเร็วเดิน
    [SerializeField] private float jumpForce = 12f;     // แรงกระโดด
    [SerializeField] private Transform groundCheck;     // จุดตรวจพื้น
    [SerializeField] private float groundRadius = 0.15f;
    [SerializeField] private LayerMask groundLayer;     // เลเยอร์ที่นับว่าเป็นพื้น

    private Rigidbody2D rb;
    private SpriteRenderer sprite;
    private float moveInput;
    private bool isGrounded;
    private bool jumpRequested;

    public bool IsGrounded => isGrounded;

    private void Awake()
    {
        rb = GetComponent<Rigidbody2D>();
        sprite = GetComponent<SpriteRenderer>();
    }

    // Update ทำงานทุกเฟรม ใช้อ่านปุ่มกด แต่ยังไม่กระโดดทันที
    // เพราะค่า isGrounded ถูกอัปเดตใน FixedUpdate ถ้ากระโดดใน Update อาจใช้สถานะพื้นเก่า
    private void Update()
    {
        moveInput = Input.GetAxisRaw("Horizontal");   // -1 ซ้าย, 0 ไม่กด, 1 ขวา

        if (Input.GetButtonDown("Jump"))
        {
            jumpRequested = true;                      // จำไว้ว่าผู้เล่นกดกระโดด
        }

        if (moveInput != 0)
        {
            sprite.flipX = moveInput < 0;             // หันหน้าไปทางที่เดิน
        }
    }

    // FixedUpdate ทำงานตามจังหวะฟิสิกส์ ตรวจพื้นก่อน แล้วค่อยตัดสินใจกระโดด
    private void FixedUpdate()
    {
        // ถ้ายังไม่ได้ผูก groundCheck ใน Inspector ให้ใช้ตำแหน่งของตัวละครแทน
        Vector2 checkPosition = groundCheck != null ? groundCheck.position : transform.position;
        isGrounded = Physics2D.OverlapCircle(checkPosition, groundRadius, groundLayer);

        float verticalSpeed = rb.velocity.y;
        if (jumpRequested && isGrounded)
        {
            verticalSpeed = jumpForce;
        }
        jumpRequested = false;                         // ใช้คำขอกระโดดแล้ว

        rb.velocity = new Vector2(moveInput * moveSpeed, verticalSpeed);
    }
}

Key points:

Schematic of the Player Inspector (not a real screenshot): 1 the PlayerController2D script, 2 the Rigidbody 2D panel with Freeze Rotation Z ticked, 3 the Ground Check field, 4 the Ground Layer field
Schematic of the Player Inspector (not a real screenshot): 1 the PlayerController2D script, 2 the Rigidbody 2D panel with Freeze Rotation Z ticked, 3 the Ground Check field, 4 the Ground Layer field

Step 7: connect the script

1. Drag the `PlayerController2D` script onto `Player`.

2. In the Inspector, drag `GroundCheck` into the **Ground Check** field.

3. Set **Ground Layer** to `Ground`.

4. Set **Move Speed** to 5 and **Jump Force** to 12, or choose values you like.

Step 8: make the camera follow the character

Create a script named `CameraFollow2D` and paste in this code.

using UnityEngine;

// กล้องติดตามตัวละครแบบนุ่มนวล และไม่ให้ตกเกินขอบล่างของฉาก
public class CameraFollow2D : MonoBehaviour
{
    [SerializeField] private Transform target;          // ตัวละครที่จะติดตาม
    [SerializeField] private float smoothTime = 0.2f;   // ยิ่งน้อยยิ่งตามทันเร็ว
    [SerializeField] private float minY = -2f;          // ขอบล่างของกล้อง

    private Vector3 velocity;

    private void LateUpdate()
    {
        if (target == null) return;

        Vector3 desired = new Vector3(target.position.x, Mathf.Max(target.position.y, minY), transform.position.z);
        transform.position = Vector3.SmoothDamp(transform.position, desired, ref velocity, smoothTime);
    }
}

`LateUpdate` runs after all Update calls, which suits a camera that must follow a character that has just moved this frame. `Vector3.SmoothDamp` makes the movement smooth rather than jerky.

Select **Main Camera**, add `CameraFollow2D`, drag `Player` into the **Target** field, and set the camera's Z position to `-10` so everything in the scene is in front of it.

Step 9: run the game

Press **Play** at the top of the editor and try:

Press Play again to stop. Values changed in the Inspector during play are reset when you stop, so make any tuning changes while the game is stopped and then save the project.

Step 10: write automated tests

Trying things by hand works for a small game, but automated tests catch mistakes faster as the code grows. Unity has its own Unity Test Framework, which is worth learning. This article uses a simpler approach: it simulates Unity's frame loop with plain C#, which lets you test the script's logic without opening the editor.

The test below calls Update and FixedUpdate in order, sets up input and ground state, moves the object by its velocity, and checks the results.

using System;

public static class PlayerTests
{
    private const float Dt = 1f / 50f;   // 1 FixedUpdate equals 1/50 second
    private static int passed = 0, failed = 0;

    private static void Check(string name, bool condition)
    {
        if (condition) { passed++; Console.WriteLine("PASS  " + name); }
        else { failed++; Console.WriteLine("FAIL  " + name); }
    }

    private static PlayerController2D CreatePlayer(out Rigidbody2D rb, out SpriteRenderer sprite)
    {
        var player = new PlayerController2D();
        rb = new Rigidbody2D();
        sprite = new SpriteRenderer();
        player.Components[typeof(Rigidbody2D)] = rb;
        player.Components[typeof(SpriteRenderer)] = sprite;
        player.Call("Awake");
        return player;
    }

    private static void Step(PlayerController2D player, Rigidbody2D rb)
    {
        player.Call("Update");
        player.Call("FixedUpdate");
        player.transform.position = new Vector3(
            player.transform.position.x + rb.velocity.x * Dt,
            player.transform.position.y + rb.velocity.y * Dt, 0);
    }

    public static int Main()
    {
        // 1. Hold right and walk right
        {
            Rigidbody2D rb; SpriteRenderer sprite;
            var player = CreatePlayer(out rb, out sprite);
            Physics2D.GroundBelowPlayer = true;
            Input.HorizontalAxis = 1f;
            for (int i = 0; i < 50; i++) Step(player, rb);
            Check("walk right: x velocity equals moveSpeed (5)", Math.Abs(rb.velocity.x - 5f) < 0.001f);
            Check("walk right: x position moves about 5 units per second", Math.Abs(player.transform.position.x - 5f) < 0.2f);
            Check("walk right: faces right (flipX = false)", sprite.flipX == false);
        }

        // 2. Hold left and face left
        {
            Rigidbody2D rb; SpriteRenderer sprite;
            var player = CreatePlayer(out rb, out sprite);
            Physics2D.GroundBelowPlayer = true;
            Input.HorizontalAxis = -1f;
            Step(player, rb);
            Check("walk left: x velocity is negative", rb.velocity.x < 0);
            Check("walk left: faces left (flipX = true)", sprite.flipX == true);
        }

        // 3. No input means the character stays still
        {
            Rigidbody2D rb; SpriteRenderer sprite;
            var player = CreatePlayer(out rb, out sprite);
            Physics2D.GroundBelowPlayer = true;
            Input.HorizontalAxis = 0f;
            Step(player, rb);
            Check("no input: x velocity is 0", rb.velocity.x == 0f);
        }

        // 4. Jump from the ground
        {
            Rigidbody2D rb; SpriteRenderer sprite;
            var player = CreatePlayer(out rb, out sprite);
            Physics2D.GroundBelowPlayer = true;
            Input.HorizontalAxis = 0f;
            Step(player, rb);                    // let FixedUpdate check the ground first
            Input.JumpPressedThisFrame = true;
            Step(player, rb);
            Input.JumpPressedThisFrame = false;
            Check("jump on ground: y velocity equals jumpForce (12)", Math.Abs(rb.velocity.y - 12f) < 0.001f);
        }

        // 5. Pressing jump in mid-air must not jump again
        {
            Rigidbody2D rb; SpriteRenderer sprite;
            var player = CreatePlayer(out rb, out sprite);
            Physics2D.GroundBelowPlayer = true;
            Input.HorizontalAxis = 0f;
            Step(player, rb);
            Physics2D.GroundBelowPlayer = false; // the character has left the ground
            rb.velocity = new Vector2(0, -3f);   // falling
            Input.JumpPressedThisFrame = true;
            Step(player, rb);
            Input.JumpPressedThisFrame = false;
            Check("jump in air: y velocity unchanged (still falling)", Math.Abs(rb.velocity.y + 3f) < 0.001f);
        }

        Console.WriteLine();
        Console.WriteLine("Passed " + passed + " / Failed " + failed);
        return failed == 0 ? 0 : 1;
    }
}

The stub file shown in the Thai version of this article (`UnityStubs.cs`) provides the `Vector2`, `Rigidbody2D`, `Input`, `Physics2D` and related types, so the test runs outside Unity. The stubs do not simulate real physics or rendering. They check the calculations and the order in which the script runs, which is what this test is for. Compile the four files together with a recent C# compiler, then run the executable. In the scripts used with Unity, keep `using UnityEngine;`. Remove that line only in the copies used for the stub test.

Result of running the tests

Compiled with the Roslyn C# compiler and run, the output was:

PASS  walk right: x velocity equals moveSpeed (5)
PASS  walk right: x position moves about 5 units per second
PASS  walk right: faces right (flipX = false)
PASS  walk left: x velocity is negative
PASS  walk left: faces left (flipX = true)
PASS  no input: x velocity is 0
PASS  jump on ground: y velocity equals jumpForce (12)
PASS  jump in air: y velocity unchanged (still falling)

Passed 8 / Failed 0

A bug the test found

The first version of the controller jumped directly inside Update. The test "jump in air" failed: Update read `isGrounded` from the previous frame, which was still true, so the character jumped again while it was already airborne. In a real game this happens when the character walks off a platform edge. The fix was to store the jump request in `jumpRequested` and let FixedUpdate check the ground and decide to jump in the same step. That is the version in step 6.

This is why automated tests matter: the mistake is easy to miss by hand, but a clear test that covers the edge case finds it straight away.

Step 11: build the game

1. Open **File → Build Profiles** (called **Build Settings** in older versions).

2. Choose your platform, such as Windows or WebGL, and click **Build**.

3. Choose a folder. Unity creates an `.exe` file or a web build that others can play.

Common problems and fixes

Next steps

Once the character walks and jumps, you can add coins with a Trigger Collider 2D and `OnTriggerEnter2D`, enemies that patrol, health and death, and a background made with Tilemap. All of these use the same ideas as this article.

Summary

Making a 2D game in Unity starts with understanding that everything is a GameObject made of components. The controller reads input in Update and handles physics in FixedUpdate, and automated tests catch mistakes that are hard to see by eye. Work through the steps one at a time, adjust the values in the Inspector, and make the game your own.