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.
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.
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.
Method 1: Convert Sprite to 3D Object (Used in Shattered Heart)
- Export your sprite as a PNG.
- Import the PNG into Blender using a plugin (Link in video description).
- Export the model as an FBX file.
- 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
Be cautious about sprite size and color count to avoid performance issues.
Method 2: Use Sprite as Texture
- Resize or scale up your pixel art to avoid blur (especially for low-res sprites like 16x16).
- Aseprite works well, or use an online pixel art scaler (Pixel Art Scaler).
- Ensure all sprites share the same canvas size (e.g., 4K max supported by Yahaha).
- Apply the sprite as a base color in the material settings.
- Set Surface Type to Transparent for PNGs.
- 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
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)
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)
Lighting Setup for 2D Feel
Lighting is the final touch for that 2D look:
- Disable Sunlight in the environment settings.
- Use a Directional Light and point it directly from above (90°).
- Turn off shadows to preserve the flat 2D illusion.