Workaround: Create Any Custom Player Controller in Yahaha HGK

In this forum post, I’ll share a workaround for creating custom player controllers in Yahaha HGK. This approach gives you direct access to your player game object and the flexibility to tweak behavior to better match your project’s needs. In my opinion, this opens up many new possibilities for the types of games you can build in Yahaha HGK. If you’re comfortable managing game resources and systems, you can push this even further to create more experimental or genre-diverse experiences using the Horror Kit.

--------------------------------------------- :warning: Disclaimer :warning: ---------------------------------------------

This method is a workaround and may not function as expected in future updates. It is also primarily intended for single-player projects on PC.


This forum is divided into four core sections. Each section includes a video tutorial, written guide, and the scripts used in that part.

:video_game: 1. Player Controllers

First Person • Third Person • Top-Down • Side Scroller

:zap: 2. Trigger Handling

Custom trigger logic and detection systems

:radio_button: 3. Interaction Handling

Flexible interaction systems beyond built-in components

:person_standing: 4. Model & Animation

Character models, animation setup, and state control

In this section, I demonstrate four player controller samples: first person, third person, top-down, and side scroller. All of them follow the same core principles. First, let’s take a look at the player game object structure.

~~ PLAYER GAME OBJECT ~~

This is how every player is built. An empty object is used as the parent, and a cutscene is used as the camera, with the cutscene set to loop. The reason for using a cutscene is to easily adjust the camera position and rotation directly in the editor, and to quickly enable or disable the camera using cutscene events when needed.

:scroll: THIRD-PERSON CONTROLLER

local camera = script.fields.camera

local CharacterControllerComp = UnityEngine.CharacterController
local self = script.gameObject
local Input = UnityEngine.Input
local Mathf = UnityEngine.Mathf
local Vector3 = UnityEngine.Vector3
local Quaternion = UnityEngine.Quaternion
local Time = UnityEngine.Time
local Cursor = UnityEngine.Cursor
local CursorLockMode = UnityEngine.CursorLockMode

-- Config
local moveSpeed = 5.0
local mouseSensitivity = 3.0
local rotationSmoothSpeed = 10.0
local cameraSmoothSpeed = 10.0
local minPitch = -40.0
local maxPitch = 80.0
local cameraDistance = 5.0
local cameraHeight = 1.5
local gravity = -30.0

local controller
local transform = self.transform
local cameraTransform
local yaw = 0.0
local pitch = 10.0
local verticalVelocity = 0.0

script.OnStart(function()
    controller = self:GetComponent(typeof(CharacterControllerComp))
    if controller == nil then
        controller = self:AddComponent(typeof(CharacterControllerComp))
        controller.height = 1.8
    end
    cameraTransform = camera.transform
    local ce = cameraTransform.eulerAngles
    yaw = ce.y
    pitch = ce.x

    Cursor.lockState = CursorLockMode.Locked
    Cursor.visible = false
end)

script.OnUpdate(function()
    HandleCamera()
    HandleMovement()
end)

function HandleMovement()
    local h = Input.GetAxis("Horizontal") -- A/D input
    local v = Input.GetAxis("Vertical")   -- W/S input
    local inputVec = Vector3(h, 0, v)
    local moveDir = Vector3(0, 0, 0)

    if inputVec.magnitude > 0.01 then
        -- Move direction relative to camera
        local camYawRot = Quaternion.Euler(0, yaw, 0)
        moveDir = (camYawRot * inputVec).normalized

        -- Rotate player to face movement direction
        local targetYaw = Mathf.Atan2(moveDir.x, moveDir.z) * Mathf.Rad2Deg
        local currentYaw = transform.eulerAngles.y
        local smoothYaw = Mathf.LerpAngle(currentYaw, targetYaw, Time.deltaTime * rotationSmoothSpeed)
        transform.rotation = Quaternion.Euler(0, smoothYaw, 0)
    end

    -- Gravity
    if controller.isGrounded then
        verticalVelocity = -2
    else
        verticalVelocity = verticalVelocity + gravity * Time.deltaTime
    end
    
    local velocity = moveDir * moveSpeed + Vector3(0, verticalVelocity, 0)
    controller:Move(velocity * Time.deltaTime)
end

function HandleCamera()
    local mx = Input.GetAxis("Mouse X")
    local my = Input.GetAxis("Mouse Y")

    yaw = yaw + mx * mouseSensitivity
    pitch = Mathf.Clamp(pitch - my * mouseSensitivity, minPitch, maxPitch)

    local camRot = Quaternion.Euler(pitch, yaw, 0)
    local desiredPos = transform.position + Vector3(0, cameraHeight, 0) + (camRot * Vector3(0, 0, -cameraDistance))

    cameraTransform.position = Vector3.Lerp(cameraTransform.position, desiredPos, Time.deltaTime * cameraSmoothSpeed)
    cameraTransform:LookAt(transform.position + Vector3(0, cameraHeight, 0))
end

function AnimationFinished(earlyTime)
    local Controller = script.GetLuaComponentByGameObject(PlayerModel, "com.yahaha.sdk.animator.components.YaPlayableController")
    local state
    if Controller then
        state = Controller.Layers[1].CurrentState
    end
    if not state then
        return false
    end
    if state.Length and state.Length > 0 and earlyTime > 0 then
        local earlyThreshold = 1 - (earlyTime / state.Length)
        return state.NormalizedTime >= earlyThreshold
    end
    return state.NormalizedTime >= 1
end
local fieldDefs = {
    {
        name = "camera", type = "GameObject"
    },
}
script.DefineFields(fieldDefs)

:scroll: FIRST-PERSON CONTROLLER

local camera = script.fields.camera
local CharacterControllerComp = UnityEngine.CharacterController
local self = script.gameObject
local Input = UnityEngine.Input
local Mathf = UnityEngine.Mathf
local Vector3 = UnityEngine.Vector3
local Quaternion = UnityEngine.Quaternion
local Time = UnityEngine.Time
local Cursor = UnityEngine.Cursor
local CursorLockMode = UnityEngine.CursorLockMode

local moveSpeed = 5.0
local mouseSensitivity = 3.0
local minPitch = -80.0
local maxPitch = 80.0
local gravity = -30.0

local controller
local transform = self.transform
local cameraTransform
local yaw = 0.0
local pitch = 0.0
local verticalVelocity = 0.0

script.OnStart(function()
    controller = self:GetComponent(typeof(CharacterControllerComp))
    if controller == nil then
        controller = self:AddComponent(typeof(CharacterControllerComp))
        controller.height = 1.8
    end

    cameraTransform = camera.transform
    local ce = cameraTransform.eulerAngles
    yaw = ce.y
    pitch = 0

    Cursor.lockState = CursorLockMode.Locked
    Cursor.visible = false
end)

script.OnUpdate(function()
    HandleMouseLook()
    HandleMovement()
end)

function HandleMouseLook()
    local mx = Input.GetAxis("Mouse X")
    local my = Input.GetAxis("Mouse Y")

    yaw = yaw + mx * mouseSensitivity
    pitch = Mathf.Clamp(pitch - my * mouseSensitivity, minPitch, maxPitch)
    transform.rotation = Quaternion.Euler(0, yaw, 0)
    cameraTransform.localRotation = Quaternion.Euler(pitch, 0, 0)
end

function HandleMovement()
    local h = Input.GetAxis("Horizontal")
    local v = Input.GetAxis("Vertical")

    local moveDir =
        transform.forward * v +
        transform.right * h

    if controller.isGrounded then
        verticalVelocity = -2
    else
        verticalVelocity = verticalVelocity + gravity * Time.deltaTime
    end

    local velocity = moveDir * moveSpeed + Vector3(0, verticalVelocity, 0)
    controller:Move(velocity * Time.deltaTime)
end
local fieldDefs = {
    {
        name = "camera", type = "GameObject"
    }
}
script.DefineFields(fieldDefs)

:scroll: TOP-DOWN CONTROLLER

local CharacterControllerComp = UnityEngine.CharacterController
local PlayerModel = script.fields.PlayerModel
local self = script.gameObject
local Input = UnityEngine.Input
local Vector3 = UnityEngine.Vector3
local Quaternion = UnityEngine.Quaternion
local Time = UnityEngine.Time
local Mathf = UnityEngine.Mathf


local moveSpeed = 5.0
local gravity = -30.0

local controller
local verticalVelocity = 0.0
local State = {
    Idle = 1,
    Walk = 2
}
local currentState = State.Idle
local lastState = nil

script.OnStart(function()
    controller = self:GetComponent(typeof(CharacterControllerComp))
    if controller == nil then
        controller = self:AddComponent(typeof(CharacterControllerComp))
        controller.height = 1.8
    end
end)

script.OnUpdate(function()
    HandleMovement()
end)

function HandleMovement()
    local h = Input.GetAxis("Horizontal")
    local v = Input.GetAxis("Vertical")

    local moveDir = Vector3(h, 0, v)

    if moveDir.magnitude > 0.01 then
        moveDir = moveDir.normalized

        -- Rotate model to face movement direction
        local targetYaw = Mathf.Atan2(moveDir.x, moveDir.z) * Mathf.Rad2Deg
        local smoothYaw = Mathf.LerpAngle(PlayerModel.transform.eulerAngles.y, targetYaw, Time.deltaTime * 10)
        PlayerModel.transform.rotation = Quaternion.Euler(0, smoothYaw, 0)
    end

    if controller.isGrounded then
        verticalVelocity = -2
    else
        verticalVelocity = verticalVelocity + gravity * Time.deltaTime
    end

    local velocity = moveDir * moveSpeed + Vector3(0, verticalVelocity, 0)
    controller:Move(velocity * Time.deltaTime)
end
local fieldDefs = {
    {
        name = "camera", type = "GameObject"
    },
    {
        name = "PlayerModel", type = "GameObject"
    }
}
script.DefineFields(fieldDefs)

:scroll: SIDE-SCROLLER CONTROLLER

local CharacterControllerComp = UnityEngine.CharacterController
local PlayerModel = script.fields.PlayerModel
local self = script.gameObject
local Input = UnityEngine.Input
local KeyCode = UnityEngine.KeyCode
local Vector3 = UnityEngine.Vector3
local Time = UnityEngine.Time

local moveSpeed = 6.0
local jumpForce = 10.0
local gravity = -30.0

local controller
local verticalVelocity = 0.0
local facingRight = true

script.OnStart(function()
    controller = self:GetComponent(typeof(CharacterControllerComp))
    if controller == nil then
        controller = self:AddComponent(typeof(CharacterControllerComp))
        controller.height = 1.8
    end
end)

script.OnUpdate(function()
    HandleMovement()
end)

function HandleMovement()
    local h = Input.GetAxis("Horizontal") -- horizontal movement
    local moveDir = Vector3(h, 0, 0)

    -- Flip player when changing direction
    if h > 0 and not facingRight then
        facingRight = true
        PlayerModel.transform.rotation = Quaternion.Euler(0, 0, 0)
    elseif h < 0 and facingRight then
        facingRight = false
        PlayerModel.transform.rotation = Quaternion.Euler(0, 180, 0)
    end

    -- Jump
    if controller.isGrounded then
        if verticalVelocity < 0 then
            verticalVelocity = -2
        end
        if Input.GetKeyDown(KeyCode.Space) then
            verticalVelocity = jumpForce
        end
    else
        verticalVelocity = verticalVelocity + gravity * Time.deltaTime
    end

    local velocity = moveDir * moveSpeed + Vector3(0, verticalVelocity, 0)
    controller:Move(velocity * Time.deltaTime)
end

local fieldDefs = {
    {
        name = "camera", type = "GameObject"
    },
    {
        name = "PlayerModel", type = "GameObject"
    }
}
script.DefineFields(fieldDefs)

In this section, I explain how to create a custom system for handling trigger enter and exit events. This can be used in many situations, but in this tutorial, I focus specifically on detecting when the player enters and exits a trigger area.

Start by creating a group with two empty objects. Each empty object contains an event that will be triggered when the player enters or exits the trigger area. Attach the script and trigger box to the parent group object, then set the keyword for the object name you want the trigger to detect.

:scroll: TRIGGER HANDLER

local EnterTrigger = script.fields.EnterTrigger
local ExitTrigger = script.fields.ExitTrigger
local KeyWord = script.fields.KeyWord

local function OnTriggerEnter(collision)
    if collision.gameObject.name:find(KeyWord) then
        EnterTrigger:SetActive(true)
        ExitTrigger:SetActive(false)

    end
end

local function OnTriggerExit(collision)
    if collision.gameObject.name:find(KeyWord) then
        EnterTrigger:SetActive(false)
        ExitTrigger:SetActive(true)
    end
end

local cancelEnterFunc = YaPhysicsAPI.OnTriggerEnter(script.gameObject,OnTriggerEnter)
local cancelExitFunc = YaPhysicsAPI.OnTriggerExit(script.gameObject,OnTriggerExit)

script.OnDispose(function ()
    cancelEnterFunc()
    cancelExitFunc()
end)
local fieldDefs = {
    { name = "EnterTrigger", type = "GameObject"},
    { name = "ExitTrigger", type = "GameObject"},
    { name = "KeyWord", type = "string"}
}
script.DefineFields(fieldDefs)

There are many ways to handle interaction, depending on how you design your game and characters. If you’re using a first-person or over-the-shoulder third-person controller, the built-in interactable component usually works well since the player can rotate camera to look around.

For other controller types, the built-in interaction system is often not suitable. In those cases, you can create custom interactions using raycasts or interaction areas. In this tutorial, I focus on interaction areas because they’re easier to implement and work well for most projects.

Just like the trigger handler, this interaction system uses a trigger box and an additional object that fires an event when the player presses the interact key while inside the trigger area.

:scroll:INTERACTION HANDLER

local interactTrigger = script.fields.InteractTrigger
local KeyWord = script.fields.KeyWord
local canInteract = false

local Input = UnityEngine.Input
local KeyCode = UnityEngine.KeyCode
local self = script.gameObject

script.OnUpdate(function ()
    if canInteract and Input.GetKeyDown(KeyCode.E) then
        interactTrigger:SetActive(true)
        canInteract = false
        self:Destroy()
    end
end)

local function OnTriggerEnter(collision)
    if collision.gameObject.name:find(KeyWord) then
        canInteract = true
    end
end

local function OnTriggerExit(collision)
    if collision.gameObject.name:find(KeyWord) then
        canInteract = false
    end
end

local cancelEnterFunc = YaPhysicsAPI.OnTriggerEnter(script.gameObject,OnTriggerEnter)
local cancelExitFunc = YaPhysicsAPI.OnTriggerExit(script.gameObject,OnTriggerExit)

script.OnDispose(function ()
    cancelEnterFunc()
    cancelExitFunc()
end)
local fieldDefs = {
    { name = "InteractTrigger", type = "GameObject"},
    { name = "KeyWord", type = "string"}
}
script.DefineFields(fieldDefs)

For this part, I use models and animations from the Yahaha asset library and Horror package, but you can use any model you want or import your own.


Add an Animation Player component and an Animator Controller component to the model. This time, we’ll only update the script — I’ll use the updated third person controller as the example.

:scroll:THIRD-PERSON CONTROLLER WITH ANIMATION HANDLER

local camera = script.fields.camera
local playerModel = script.fields.PlayerModel
local AnimationPlayer -- from the player model

local CharacterControllerComp = UnityEngine.CharacterController
local self = script.gameObject
local Input = UnityEngine.Input
local Mathf = UnityEngine.Mathf
local Vector3 = UnityEngine.Vector3
local Quaternion = UnityEngine.Quaternion
local Time = UnityEngine.Time
local Cursor = UnityEngine.Cursor
local CursorLockMode = UnityEngine.CursorLockMode

-- Config
local moveSpeed = 5.0
local mouseSensitivity = 3.0
local rotationSmoothSpeed = 10.0
local cameraSmoothSpeed = 10.0
local minPitch = -40.0
local maxPitch = 80.0
local cameraDistance = 5.0
local cameraHeight = 1.5
local gravity = -30.0

local controller
local transform = self.transform
local cameraTransform
local yaw = 0.0
local pitch = 10.0
local verticalVelocity = 0.0

script.OnStart(function()
    controller = self:GetComponent(typeof(CharacterControllerComp))
    if controller == nil then
        controller = self:AddComponent(typeof(CharacterControllerComp))
        controller.height = 1.8
    end
    AnimationPlayer = script.GetLuaComponentByGameObject(playerModel, "com.yahaha.sdk.animator.components.SimpleAnimationPlayComponent")
    cameraTransform = camera.transform
    local ce = cameraTransform.eulerAngles
    yaw = ce.y
    pitch = ce.x

    Cursor.lockState = CursorLockMode.Locked
    Cursor.visible = false
end)

script.OnUpdate(function()
    HandleCamera()
    HandleMovement()
end)

function HandleMovement()
    local h = Input.GetAxis("Horizontal") -- A/D input
    local v = Input.GetAxis("Vertical")   -- W/S input
    local inputVec = Vector3(h, 0, v)
    local moveDir = Vector3(0, 0, 0)

    if inputVec.magnitude > 0.01 then
        -- Move direction relative to camera
        local camYawRot = Quaternion.Euler(0, yaw, 0)
        moveDir = (camYawRot * inputVec).normalized

        -- Rotate player to face movement direction
        local targetYaw = Mathf.Atan2(moveDir.x, moveDir.z) * Mathf.Rad2Deg
        local currentYaw = transform.eulerAngles.y
        local smoothYaw = Mathf.LerpAngle(currentYaw, targetYaw, Time.deltaTime * rotationSmoothSpeed)
        transform.rotation = Quaternion.Euler(0, smoothYaw, 0)
        currentState = State.Walk
    else
        currentState = State.Idle
    end

    -- Gravity
    if controller.isGrounded then
        verticalVelocity = -2
    else
        verticalVelocity = verticalVelocity + gravity * Time.deltaTime
    end
    
    local velocity = moveDir * moveSpeed + Vector3(0, verticalVelocity, 0)
    controller:Move(velocity * Time.deltaTime)
    if currentState ~= lastState then
        if currentState == State.Idle then
            print("Idle")
            AnimationPlayer.Play(State.Idle, 1)
        elseif currentState == State.Walk then
            print("Walk")
            AnimationPlayer.Play(State.Walk, 1)
        end
        lastState = currentState
    end
    if AnimationFinished(0.1) then
        AnimationPlayer.Play(currentState, 1)
    end
end

function HandleCamera()
    local mx = Input.GetAxis("Mouse X")
    local my = Input.GetAxis("Mouse Y")

    yaw = yaw + mx * mouseSensitivity
    pitch = Mathf.Clamp(pitch - my * mouseSensitivity, minPitch, maxPitch)

    local camRot = Quaternion.Euler(pitch, yaw, 0)
    local desiredPos = transform.position + Vector3(0, cameraHeight, 0) + (camRot * Vector3(0, 0, -cameraDistance))

    cameraTransform.position = Vector3.Lerp(cameraTransform.position, desiredPos, Time.deltaTime * cameraSmoothSpeed)
    cameraTransform:LookAt(transform.position + Vector3(0, cameraHeight, 0))
end

function AnimationFinished(earlyTime)
    local Controller = script.GetLuaComponentByGameObject(playerModel, "com.yahaha.sdk.animator.components.YaPlayableController")
    local state
    if Controller then
        state = Controller.Layers[1].CurrentState
    end
    if not state then
        return false
    end
    if state.Length and state.Length > 0 and earlyTime > 0 then
        local earlyThreshold = 1 - (earlyTime / state.Length)
        return state.NormalizedTime >= earlyThreshold
    end
    return state.NormalizedTime >= 1
end

local fieldDefs = {
    {
        name = "camera", type = "GameObject"
    },
    { name = "PlayerModel", type = "GameObject"}
}
script.DefineFields(fieldDefs)

---------------------------------------------------- THE END ----------------------------------------------------

That wraps up this guide on building custom player controllers, triggers, interactions, and animations in Yahaha HGK. I hope this workflow helps you create more flexible systems and experiment with new game ideas. Feel free to adapt and expand these setups to match your own projects, and let me know if you find new use cases or improvements. Thanks for reading, and happy developing! :rocket: