Create 2D pixel art game using Yahaha HGK | Shattered Heart Development Breakdown

Hello! ( ᵔ ᵕ ᵔ )
Recently, I released a game using Yahaha HGK called Shattered Heart: Echoes of The Past. It’s a 2D pixel art–style game, which I thought would be interesting to create in Yahaha. The process took a bit of time and involved some customization and workaround techniques to achieve the style. I’ll try to get straight to the point and keep the explanation short. Even if you’re not into 2D-style games, you might still find something useful here.
Screenshot 2025-06-22 055321

:video_game: The Core Concept — Faking 2D in a 3D Scene
Since Yahaha gives us a 3D environment to work in, the first challenge is making the game feel 2D. This can be done by adjusting the camera angle and restricting gameplay to two axes instead of three.

  • For top-down games, you’ll typically work with the X and Z axes

  • If your game uses gravity, like a platformer, you’ll likely want X and Y axes

It might get a bit tricky from here, so I made a video to help make things clearer.

:framed_picture: Adding Sprites to the Scene (Two Methods)
Since Yahaha doesn’t support native sprite imports into 3D scenes, I found two workarounds. Both ways have their pros and cons, and it doesn’t really matter which one you choose—it just depends on your workflow and what you’re comfortable with.
:wrench: Method 1: Convert Sprite to 3D Object (Used in Shattered Heart)

  1. Export your sprite as a PNG.
  2. Import the PNG into Blender using a plugin (Link in video description).
  3. Export the model as an FBX file.
  4. Import the FBX into Yahaha.

Pros:

  • Easy to set up

Cons:

  • Each pixel is a face and each color becomes a material
  • Large or detailed sprites can generate a lot of vertices and materials

:warning: Be cautious about sprite size and color count to avoid performance issues.

:art: Method 2: Use Sprite as Texture

  1. Resize or scale up your pixel art to avoid blur (especially for low-res sprites like 16x16).
  1. Ensure all sprites share the same canvas size (e.g., 4K max supported by Yahaha).
  2. Apply the sprite as a base color in the material settings.
  3. Set Surface Type to Transparent for PNGs.
  4. Use a plane object (not a cube!) so the sprite only appears on one face.

Pros:

  • Better for detailed art
  • Doesn’t overload the scene with vertices/materials

Cons:

  • Requires more prep work to ensure scale and alignment
  • Using 4K texture canvas even for small sprites can be inefficient

You can also combine both methods but be careful with scaling

:person_standing: Setting Up the Custom Player

  • Create an empty object to serve as the player’s body.
  • Add a camera as a child of that object.
  • (Optional) Add a capsule mesh with collision turned off to help with debugging.
  • Use a virtual camera inside a looping cutscene. This makes it easy to switch or stop the camera without extra logic.
  • Rotate the camera to mimic a 2D view (e.g., for top-down style, set X rotation to 90°).
  • Add a script to control the player’s movement and use a Player Controller component.
  • Customize movement logic based on your game’s needs.

Example top-down player controller script

  • for .Editor
local fieldDefs = {
    {
        name = "speed",
        type = "float",
    },
    {
        name = "camera",
        type = "GameObject",
    },
}
script.DefineFields(fieldDefs)
  • for .Lua
local speed = script.fields.speed
local camera = script.fields.camera

local player = script.gameObject
local Input = UnityEngine.Input
local KeyCode = UnityEngine.KeyCode
local Vector3 = UnityEngine.Vector3
local Time = UnityEngine.Time
local Mathf = UnityEngine.Mathf
local CharacterController = UnityEngine.CharacterController

local gravity = -9.81
local verticalVelocity = 0

local controller

script.OnStart(function ()
    controller = player:AddComponent(typeof(CharacterController))
    controller.stepOffset = 0.3
    controller.radius = 1.0
    controller.slopeLimit = 0
end)

script.OnUpdate(function ()
    local moveDirection = Vector3.zero

    -- Movement input
    if Input.GetKey(KeyCode.W) then
        moveDirection = moveDirection + Vector3.forward
    elseif Input.GetKey(KeyCode.S) then
        moveDirection = moveDirection + Vector3.back
    elseif Input.GetKey(KeyCode.A) then
        moveDirection = moveDirection + Vector3.left
    elseif Input.GetKey(KeyCode.D) then
        moveDirection = moveDirection + Vector3.right
    end

    moveDirection = moveDirection.normalized * speed
    if controller.isGrounded then
        verticalVelocity = -0.5
    else
        verticalVelocity = verticalVelocity + gravity * Time.deltaTime
    end

    moveDirection.y = verticalVelocity

    -- Apply movement
    controller:Move(moveDirection * Time.deltaTime)
end)

:gear: Custom Interaction Logic

Using a custom player means losing some of Yahaha’s built-in features like interactions. But with a bit of scripting, it’s easy to replicate.

Example:

  • Get a reference to the custom player
  • Check distance between player and interactable object
  • If close enough, enable interaction (enter area)
  • If too far, disable it (exit area)

This opens the door to deeper interaction systems and more control.
Example interaction script

  • .Editor
local fieldDefs = {
    {
        name = "EnterTrigger",
        type = "GameObject",
    },
    {
        name = "ExitTrigger",
        type = "GameObject",
    },
    {
        name = "TriggeredObject",
        type = "GameObject",
    },
    {
        name = "PlayerName",
        type = "string",
        default = "Player",
    },
    {
        name = "InteractionDistance",
        type = "float",
        default = 2.5,
    }
}
script.DefineFields(fieldDefs)
  • .Lua
local EnterTrigger = script.fields.EnterTrigger
local ExitTrigger = script.fields.ExitTrigger
local TriggeredObject = script.fields.TriggeredObject
local PlayerName = script.fields.PlayerName
local InteractionDistance = script.fields.InteractionDistance

local Vector3 = UnityEngine.Vector3
local isEnter = false
local Input = UnityEngine.Input
local KeyCode = UnityEngine.KeyCode

script.OnUpdate(function ()
    local player = UnityEngine.GameObject.Find(PlayerName)
    local distance = (script.gameObject.transform.position - player.transform.position).magnitude

    if distance < InteractionDistance and isEnter == false then
        EnterTrigger:SetActive(true)
        ExitTrigger:SetActive(false)
        isEnter = true
    elseif distance > InteractionDistance and isEnter == true then
        EnterTrigger:SetActive(false)
        ExitTrigger:SetActive(true)
        isEnter = false
    end
    if isEnter and Input.GetKeyDown(KeyCode.J) then
        if TriggeredObject then
            TriggeredObject:SetActive(true)
            EnterTrigger:SetActive(false)
            script.gameObject:SetActive(false)

        end
    end
end)

:bulb: Lighting Setup for 2D Feel

Lighting is the final touch for that 2D look:

  1. Disable Sunlight in the environment settings.
  2. Use a Directional Light and point it directly from above (90°).
  3. Turn off shadows to preserve the flat 2D illusion.