Light of Ko
2D challenging platformer for Global Game Jam 2026
The Context
Light of Ko was created during the 2026 Global Game Jam on the theme of “Mask.” The player takes on the role of Ko, a Kodama who must save the forest after an earthquake, but he is frail and must wear a mask to be able to move around.
In a small group, I worked on the mask system with shaders, but I also programmed part of the gameplay and did bug fixes until the last minute.
My Contributions
- 01 Shader Implemented fully functional shader system for the mask
- 02 Gameplay & Bug Fixes Implemented gameplay and bug fixes
- 03 Sequencer Implemented a fully functional sequencer system for introduction and endings
Learning & Implementation
In Light of Ko, darkness isn't just aesthetic—it's the main antagonist. I needed a way to restrict the player's vision dynamically without relying on heavy transparent sprites.
My solution was to create a custom Fullscreen Shader (Post-Process). By manipulating the Blit Source directly on the GPU, I achieved a smooth, performant transition that acts as a vignetting effect driven by gameplay logic.
01. The Result
The player can activate and deactivate the mask as desired. The transition is interpolated.
02. The Logic
Fullscreen Shader effect for atmospheric overlay.
It captures the URP Blit Source and blends a dark tint based on a sampled texture mask,
with controllable intensity.
Create Sequencer System
When finalizing the game, and in order to honor the magnificent work done by the artists, I coded a Sequencer so that I could stage the narration and the mini comic strips that had been created.
For my subsequent projects, I adapted my manager into a logic that could be reused for any input.
using UnityEngine;
using TMPro;
using UnityEngine.SceneManagement;
using System.Collections;
using System.Collections.Generic;
public class Sequencer : MonoBehaviour
{
[System.Serializable]
public struct IntroSlide
{
public string name;
public CanvasGroup imageGroup;
public CanvasGroup textGroup;
public TMP_Text textComponent;
[TextArea(3, 10)] public string storyText;
}
[Header("Settings")]
[SerializeField] private float imageFadeInTime = 1f;
[SerializeField] private float textFadeInTime = 1f;
[SerializeField] private float displayTime = 6f;
[SerializeField] private float textFadeOutTime = 1f;
[SerializeField] private string nextSceneName = "GameScene";
[SerializeField] private MusicTrack[] introTrack;
[Header("Sequence Data")]
[SerializeField] private List slides;
private string ActiveScene;
private void Start()
{
ActiveScene = SceneManager.GetActiveScene().name;
if (ActiveScene == "Introduction")
{
AudioManager.Instance.ChangeAmbianceMusic(introTrack);
}
StartCoroutine(PlayIntroSequence());
}
// This duplication below is not ideal, but it was necessary to meet the tight deadline of the jam.
// Instead of creating a more generic system, I had to quickly implement the specific logic for the introduction and ending sequences, which led to some code repetition.
private IEnumerator PlayIntroSequence()
{
foreach (var slide in slides)
{
if (ActiveScene == "Introduction")
{
if (slide.name == "2")
{
displayTime = 4f;
Debug.Log(slide.name);
if (slide.textComponent != null)
{
slide.textComponent.text = slide.storyText;
}
slide.imageGroup.alpha = 0f;
slide.textGroup.alpha = 0f;
yield return StartCoroutine(FadeCanvasGroup(slide.imageGroup, 0f, 1f, imageFadeInTime));
yield return StartCoroutine(FadeCanvasGroup(slide.textGroup, 0f, 1f, textFadeInTime));
yield return new WaitForSeconds(displayTime);
yield return StartCoroutine(FadeCanvasGroup(slide.textGroup, 1f, 0f, textFadeOutTime));
}
else
{
Debug.Log(slide.name);
if (slide.textComponent != null)
{
slide.textComponent.text = slide.storyText;
}
slide.imageGroup.alpha = 0f;
slide.textGroup.alpha = 0f;
yield return StartCoroutine(FadeCanvasGroup(slide.imageGroup, 0f, 1f, imageFadeInTime));
yield return StartCoroutine(FadeCanvasGroup(slide.textGroup, 0f, 1f, textFadeInTime));
yield return new WaitForSeconds(displayTime);
yield return StartCoroutine(FadeCanvasGroup(slide.textGroup, 1f, 0f, textFadeOutTime));
}
} else
{
Debug.Log(slide.name);
if (slide.textComponent != null)
{
slide.textComponent.text = slide.storyText;
}
slide.imageGroup.alpha = 0f;
slide.textGroup.alpha = 0f;
yield return StartCoroutine(FadeCanvasGroup(slide.imageGroup, 0f, 1f, imageFadeInTime));
yield return StartCoroutine(FadeCanvasGroup(slide.textGroup, 0f, 1f, textFadeInTime));
yield return new WaitForSeconds(displayTime);
yield return StartCoroutine(FadeCanvasGroup(slide.textGroup, 1f, 0f, textFadeOutTime));
}
}
if (ActiveScene == "Introduction")
{
LoadNextScene();
}
}
private IEnumerator FadeCanvasGroup(CanvasGroup group, float start, float end, float duration)
{
float counter = 0f;
while (counter < duration)
{
counter += Time.deltaTime;
group.alpha = Mathf.Lerp(start, end, counter / duration);
yield return null;
}
group.alpha = end;
}
private void LoadNextScene()
{
SceneManager.LoadScene(nextSceneName);
}
}