Skip to content

Advanced Tutorial

rech edited this page Jun 15, 2026 · 1 revision

Advanced Tutorial

This tutorial covers advanced techniques derived from real-world scenecontrol scripts. It assumes you have read the basic tutorial and are comfortable with channels, controllers, and scenecontrol types. (maybe even Unity editor)

When you need to debug scene, it's encouraged to just download Unity editor to debug scene.

Table of Contents

  1. Utility Libraries
  2. Replacing the Default Track
  3. Sprite Sheet Animation
  4. Advanced Triggers
  5. Dynamic Text
  6. Object Pooling
  7. Tagging Note Groups
  8. Screen Adaptation
  9. Extra

1. Utility Libraries

Real scripts quickly become unwieldy without helpers. A utility module is almost always the first thing you write.

Caching constant channels

Creating Channel.constant(1) repeatedly with the autocast wastes memory. Cache them:

local constants = {}
local function val(v)
    if type(v) == "number" then
        if constants[v] == nil then
            constants[v] = Channel.constant(v)
        end
        return constants[v]
    end
    return v
end

Now val(5) always returns the same ConstantChannel instance for 5.

Active / inactive window helpers

local function activeThrough(channel, t1, t2)
    if channel == nil then
        channel = Channel.keyframe()
            .setDefaultEasing("inconst")
            .addKey(-61616, 0)
    end
    return channel.addKey(t1, 1).addKey(t2, 0)
end

local function inactiveThrough(channel, t1, t2)
    if channel == nil then
        channel = Channel.keyframe()
            .setDefaultEasing("inconst")
            .addKey(-61616, 1)
    end
    return channel.addKey(t1, 0).addKey(t2, 1)
end

Usage:

local mySprite = Scene.createSprite("test.png")
mySprite.active = activeThrough(nil, 10000, 20000)

Bulk property setting

This can be done with other controller properties as well, using color as exmple here.

local function setColor(controller, r, g, b, a)
    controller.colorR = r
    controller.colorG = g
    controller.colorB = b
    if a ~= nil then controller.colorA = a end
end

2. Replacing the Default Track

The default track can be hidden and replaced with custom sprites.

Example

-- Hide the default track
Scene.track.active = Channel.constant(0)

-- Create a single-lane custom track body
local track = Scene.createSprite(
    "NewTrack.png",
    "default",
    "background",
    xy(0.5, 0),
    "repeat"
)
track.layer = "Track"
track.sort = 0
track.rotationX = -90
track.scaleX = -1.7896 * 950/4/1024
track.scaleY = 153.5/2.55

-- Make the texture repeat along the track
track.textureScaleX = 950/4/1024
track.textureScaleY = track.scaleY

-- Scroll the texture like the default track
local floorPosition = Context.floorPosition(0) / 15000 / 100
track.textureOffsetY = floorPosition

Copying internal controllers

Instead of creating divide lines and critical lines from scratch, copy the internal ones:

local crit = Scene.track.criticalLine1.copy()
crit.active = Channel.constant(1)
crit.setParent(track)
crit.translationX = 0
crit.translationY = 0.88275
crit.scaleY = -1/16
crit.scaleX = 1024

Per-lane parent canvases

For independent lane movement, parent each lane to its own canvas:

local trackParents = {}
for i = 0, 5 do
    trackParents[i] = Scene.createCanvas(true)
    trackParents[i].setParent(Scene.worldCanvas)
    -- Now animate each lane independently
    trackParents[i].translationX = Channel.keyframe().addKey(0, originalX[i])
end

3. Sprite Sheet Animation

Use textureOffsetX/textureScaleX to display different frames of a sprite sheet.

Frame-by-frame animation

local loading = Scene.createSprite("catexplosion.png", "default", "overlay")
loading.setParent(Scene.cameraCanvas)

-- The sprite sheet has 12 frames horizontally
loading.textureScaleX = Channel.constant(1/12)
loading.textureOffsetX = Channel.keyframe().setDefaultEasing("inconst")

local frameCount = 0
local totalFrames = 12
for t = -1999, -120, 18 do
    loading.textureOffsetX.addKey(t, frameCount)
    frameCount = frameCount + 1
    if frameCount >= totalFrames then frameCount = 0 end
end

BPM-synced animation

local bpm = Context.bpm(0).valueAt(0)
local beatDuration = 60000 / bpm

local player = Scene.createSprite("taiko/playerchar.png")
player.textureScaleY = 1/3   -- 3 rows
player.textureScaleX = 1/4   -- 4 columns

-- Cycle through columns on the beat
local frame = Channel.saw("l", beatDuration * 2, 0, 1, 0)
local bpm = Channel.saw("l", Context.beatLength(0)*2/Channel.condition(playerchar.textureOffsetY, 0, playerchar.textureOffsetY, 2, playerchar.textureOffsetY), 0, 1, 0)
local frameOffset = Channel.condition(
            0.5,
            bpm,
            Channel.condition(
                0.1, bpm, 1, 0, 0
            ),
            1,
            Channel.condition(
                0.6, bpm, 2, 3, 3
            )
        )
playerchar.textureOffsetX = frameOffset

4. Advanced Triggers

Triggers react to gameplay events. Combine them with TriggerChannel to drive visual effects.

Custom judgement text

--yanked from c0pyf0x freeform lane part but unused
local perfectTrigger = TriggerChannel.loop(
    Trigger.judgement().onPerfect().dispatch(1, 500, "l")
)
local goodTrigger = TriggerChannel.loop(
    Trigger.judgement().onGood().dispatch(1, 500, "l")
)
local missTrigger = TriggerChannel.loop(
    Trigger.judgement().onMiss().dispatch(1, 500, "l")
)

local perfect = Scene.createSprite("judgetext/perfect.png", "default", "overlay")
perfect.setParent(Scene.combo)
perfect.scaleX = 65
perfect.scaleY = 65
perfect.translationY = 65 + perfectTrigger * 20
perfect.colorA = TriggerChannel.stack(
    Trigger.judgement().onPerfect().dispatch(-255, 200, "l"),
    Trigger.observe(init).goAbove(0.5).dispatch(-255, 1, "l")
).setBaseValue(255)

Fail state toggle

--yanked from c0pyf0x taiko part
local onMiss = Trigger.judgement().onMiss().dispatch(1, 0, "l") -- dispatch 1
local onHit = Trigger.judgement().onPerfect().onGood().dispatch(0, 0, "l") -- dispatch 0

-- 1 when missed, 0 when hit
local failImmediate = TriggerChannel.setTo(onMiss, onHit).setBaseValue(1)

-- Swap sprites based on fail state
local normalScroller = Scene.createSprite("scroller.png")
normalScroller.colorA = alpha * (1 - failImmediate)

local failScroller = Scene.createSprite("scroller_fail.png")
failScroller.colorA = alpha * failImmediate

TriggerChannel types reference

Method Behavior
TriggerChannel.loop(trigger, fallback) Jumps to dispatched value on trigger, returns to fallback
TriggerChannel.setTo(trigger, trigger2, ...) Keeps the last triggered value
TriggerChannel.accumulate(trigger, fallback) Adds dispatched value on each trigger
TriggerChannel.stack(triggers...) Combines multiple triggers

Note: TriggerChannel.setBaseValue(value) sets the initial value before any trigger fires.


5. Dynamic Text

Combo and score display

local comboText = Scene.createText("Saira Regular", 42, 0, "uppercenter", "overlay")

local empty = TextChannel.create().addKey(0, "")
local comboNumber = TextChannel.fromValue(Context.currentCombo, 10, 0)
local comboSuffix = TextChannel.constant("<size=20>\nCOMBO</size>")

-- Build the full text
comboText.text = empty.concat(empty, comboNumber)
comboText.text = comboText.text.concat(comboText.text, comboSuffix)

TextChannel.concat(a, b) concatenates two text channels.


6. Object Pooling

Creating sprites during gameplay scenecontrol events can be slow. Pool objects and reuse them:

---@class rech.Pooling
---@field create_function fun(): Controller
---@field controllers Controller[]
local Pooling = {}
Pooling.__index = Pooling


---@param fn fun(): Controller
local function create(fn)
    local controller = fn()
    controller.active = Channel.keyframe().setDefaultEasing("cnsti").addKey(-999999, 0)
    return controller
end

---Creates new pool with supplied function that creates the controller
---@param fn fun(): Controller
function Pooling.new(fn)
    local self = setmetatable({}, Pooling)
    self.create_function = fn
    self.controllers = {}
    self.active_ranges = {}
    return self
end



---Get an instance that is active from t1 to t2
---@param t1 any
---@param t2 any
function Pooling:get_instance(t1, t2)
    for i,controller in pairs(self.controllers) do
        local busy = false
        for _,range in ipairs(self.active_ranges[i]) do
            if t1 <= range.end_timing and t2 >= range.start_timing then
                busy = true
                break
            end
        end
        if not busy then
          controller.active.addKey(t1, 1).addKey(t2, 0)
          table.insert(self.active_ranges[i], {start_timing = t1, end_timing = t2})
          return controller
        end
    end
    local controller = create(self.create_function)
    controller.active.addKey(t1, 1).addKey(t2, 0)
    self.controllers[#self.controllers+1] = controller
    self.active_ranges[#self.active_ranges+1] = {{start_timing = t1, end_timing = t2}}
    return controller
end

return Pooling

Usage for particle bursts:

local particlePool = Pooling.new(function()
    local p = Scene.createSprite("particle.png", "fastadd", "overlay")
    p.setParent(Scene.cameraCanvas)
    p.layer = StringChannel.constant("Effect")
    -- initialize the channel you will modify HERE!!
    p.translationX = Channel.keyframe()
    p.translationY = Channel.keyframe()
    p.colorA = Channel.keyframe()
    return p
end)

addScenecontrol("burst", 0, function(cmd)
    local t = cmd.timing
    local p = particlePool:get_instance(t)
    p.translationX.addKey(t, 0).addKey(t + 500, math.random(-100, 100))
    p.translationY.addKey(t, 0).addKey(t + 500, math.random(-100, 100))
    p.colorA.addKey(t, 255).addKey(t + 500, 0)
end)

7. Tagging Note Groups

Instead of hardcoding timing group numbers, use a tagging system:

local Tagging = {}
local mappings = {}

function Tagging.addTag(tagName, func)
    mappings[tagName] = func
end

addScenecontrol("tag", {"name"}, function(cmd)
    local name = cmd.args[1]
    if mappings[name] ~= nil then
        local tg = Scene.getNoteGroup(cmd.timingGroup)
        mappings[name](tg)
    end
end)

return Tagging

Usage:

local tagging = require("tagging")

tagging.addTag("spin", function(tg)
    tg.rotationIndividualZ = Channel.keyframe()
        .addKey(0, 0, "si")
        .addKey(500, -720, "cnsti")
        .addKey(1000, 0)
end)

Then in your .aff file, add this to one or more timing group you wish to take effect:

scenecontrol(0,tag,"spin");

8. Screen Adaptation

Aspect ratio correction

-- How far the screen deviates from 16:9
local p = (Context.screenHeight / Context.screenWidth * 16 - 9) / 3

-- Clamp to 0..1
local pClamped = Channel.clamp(p, 0, 1)

9. Extra

World-space background

By reparenting, you can compose a 3D background (maybe even skybox) with sprites.

-- yanked from copyfox
local worldParent = Scene.createCanvas(true)
worldParent.setParent(Scene.worldCanvas)
worldParent.scaleX = -1   -- Flip to face camera
worldParent.scaleY = 1
worldParent.scaleZ = -1
worldParent.translationZ = -30

-- Everything in the background is parented here
local path = Scene.createSprite("bg/path.jpg", "default", "background", xy(0.5, 0.5))
path.setParent(worldParent)
path.rotationX = 90
path.scaleX = 3
path.scaleY = 20000 / 1024
path.textureScaleY = 10
path.translationZ = -90

-- Texture scrolling
path.textureOffsetY = Channel.saw("l", 60000/168*8 - 300, 0, -1, 0)

Copying all channels from another controller

local newController = Scene.createSprite("black.png", "default", "overlay")
newController.copyAllChannelsFrom(refController)

This copies transform, color, texture, and active channels from refController to newController.

Clone this wiki locally