Chapter 7 - Time
Every program you have written waits. Mover’s dot sits where the last keypress left it; Meter’s bar holds its level until plus or minus goes down. Between presses, frame after frame passes with every change flag clear, and every block at rest.
A game keeps moving. Take your hands off any game for ten seconds and something still happens: a drop falls, a ghost patrols, a fuse burns down. The moments behind that motion come from time, and this chapter is about declaring them.
You already own a clock. The frame you toured in chapter 6 scans the
matrix, polls the keypad, runs what changed, and comes around again,
for as long as the power holds. Until now, every moment in your
programs was raised by a key. This chapter adds triggers raised by the
turning of the loop itself: a built-in frame counter, timers, and
ramps. The chapter’s program is Drip: a drop that falls on its own
schedule, blinks as it falls, and falls faster the longer the program
runs. It has no bind line. Nothing in it answers to the keypad, and
it plays anyway.
Every frame
The smallest schedule first: a block that runs on every frame.
program Ticks
platform tec1g-mon3
display matrix8x8
render ShowFrames
on FrameCount
begin
ld a,(FrameCount)
ld l,a
ld h,0
call HudWriteU16
end
FrameCount is built in: a byte cell every Glimmer program may name
without declaring it. Each frame, before the phases run, the runtime
increments it and marks it changed, so a block with on FrameCount
runs every frame, reading a value that climbs 0, 1, 2, and wraps past
255 back to 0.
Build and run this, and the seven-segment display counts the frames as they happen. Watch the pace of the digits: each count is one full turn of the loop - one matrix scan, one poll, one pass through your blocks. That pace is the fastest schedule a Glimmer program has.
Flag bits are a budget - a program holds up to 32 flag-carrying cells -
and FrameCount takes a bit only in a program that names it. Ticks
pays for one cell; Drip, which never mentions FrameCount, pays
nothing for it.
For motion, the every-frame schedule runs too hot. A drop stepping one row per frame falls off an eight-row board in eight frames. Chapter 1 called one step every eight frames a playable pace; eight steps in eight frames is a flash. Game tempo lives at every N frames, with N yours to choose - and, since games change difficulty, yours to change while the program runs.
A drop on a schedule
Here is Drip’s first cut: a drop that falls one row at a time, and starts over from the top after it leaves the bottom.
program Drip
platform tec1g-mon3
display matrix8x8
state DropY : byte = 0 changed
pulse FallTick
timer Fall : byte = 24 -> FallTick
effect Descend
on FallTick
updates DropY
begin
ld a,(DropY)
inc a
cp 8
jr c,_store ; still on the board
xor a ; past the bottom: back to the top
_store:
ld (DropY),a
end
render DrawDrop
on DropY
begin
call FbClear
ld a,(DropY)
ld c,a ; C = y
ld b,3 ; B = x, the middle column
ld a,COLOR_BLUE
call FbPlot
end
One declaration is new:
timer Fall : byte = 24 -> FallTick
Read it aloud: Fall is a byte timer with period 24, firing FallTick.
A timer is an oscillator. Behind the name sits a hidden countdown
that loses one on every frame; the frame it reaches zero, the timer
fires its pulse and the countdown reloads from Fall to begin the
next cycle. FallTick fires on frame 24, frame 48, frame 72 - every
24 frames, for as long as the program runs.
FallTick itself is a pulse like the ones keys fire, declared with
the same word and consumed the same way. Descend reads as every rule
you have written: on a moment, change a fact. Point a bind line at
FallTick instead and the same block would run per keypress - a rule
never knows where its moment comes from, which is what lets you retune
a game’s schedule without touching its rules.
The ticking happens right after the keypad poll, before any phase runs - the generated loop below shows the call. A pulse fired by a timer is seen by every block in the same frame, and clears at the end of the frame like every pulse.
The cell named Fall is the period, and it is ordinary writable
state: a block that lists updates Fall and stores a new value has
changed the tempo from the next reload on. One distinction to keep:
a timer’s cell carries no change flag - the pulse is its announcement.
So Fall may stand in updates lines, and on lines take
FallTick. Drip’s last section spends the writable period.
A blink
One steady pixel reads as furniture. A blink makes the drop read as alive, and it costs one more timer and one more fact:
state Visible : byte = 1
timer Fall : byte = 24 -> FallTick
timer Blink : byte = 5 -> BlinkTick
effect Twinkle
on BlinkTick
updates Visible
begin
ld a,(Visible)
xor 1
ld (Visible),a
end
Every fifth frame, Twinkle flips Visible between 1 and 0.
DrawDrop’s trigger grows to on DropY, Visible, so the drop redraws
when it moves and when it blinks, and its body tests Visible before
plotting: the dark half of the blink is a cleared framebuffer. The
full listing follows in the next section.
Each timer owns its own hidden countdown; the two share nothing except the frame. Periods 24 and 5 drift in and out of step with each other, and neither cares.
One shot
An oscillator fires forever. Some moments should arrive once, after a
delay: a grace period before a hazard arms, a pause before a restart.
Add once to the declaration:
timer Grace : word = 384 -> GraceOver once
With once, the cell is the countdown itself. It loses one each
frame, fires its pulse the frame it reaches zero, and then sits at
zero, idle, until a block writes it. Each write arms exactly one
firing:
ld hl,384
ld (Grace),hl
word earns its keep here: a byte cell tops out at a 255-frame delay,
and a word countdown runs to 65535. Drip has no use for a one-shot;
the delayed restart in chapter 13’s card game is the shape of moment
they are for.
The climb
Drip’s last problem: it plays its hundredth descent at the pace of its
first. A game grows harder, and concretely that means the fall period
should shrink as time passes - 24, then 20, then 16, down to a floor.
Two needs: a long, patient schedule to space the changes out, and the
change itself when the schedule comes due. The schedule is a ramp:
ramp Heat : byte steps 250 -> HeatUp
Heat is a ramp over 250 steps, firing HeatUp. Each frame, a ramp
steps its cell one closer to steps - 1, marking it changed at every
step - a fact in motion, and a block with on Heat could watch the
whole journey. On the step that reaches 249 it fires its pulse, and
there it idles. Writing the cell sets it moving again: write 0 and the
full climb runs from the start. Drip spends only the arrival; chapter
8 spends the journey.
A freshly started program’s ramp sits at its terminal value, idle. So the first climb needs a push, and a familiar word supplies the moment to push from:
state Boot : byte = 0 changed
effect Ignite
on Boot
updates Heat
begin
xor a
ld (Heat),a ; start the first climb
end
Boot begins changed, and no block ever updates it, so Ignite runs
exactly once, on the first frame. The word changed has drawn
first-frame pictures since chapter 1; here it fires a first-frame
rule.
When the climb arrives, Quicken collects it. Here is the whole
program:
program Drip
platform tec1g-mon3
display matrix8x8
state DropY : byte = 0 changed
state Visible : byte = 1
state Boot : byte = 0 changed
pulse FallTick
pulse BlinkTick
pulse HeatUp
timer Fall : byte = 24 -> FallTick
timer Blink : byte = 5 -> BlinkTick
ramp Heat : byte steps 250 -> HeatUp
effect Ignite
on Boot
updates Heat
begin
xor a
ld (Heat),a ; start the first climb
end
effect Descend
on FallTick
updates DropY
begin
ld a,(DropY)
inc a
cp 8
jr c,_store ; still on the board
xor a ; past the bottom: back to the top
_store:
ld (DropY),a
end
effect Twinkle
on BlinkTick
updates Visible
begin
ld a,(Visible)
xor 1
ld (Visible),a
end
effect Quicken
on HeatUp
updates Fall, Heat
begin
ld a,(Fall)
cp 8
jr c,_floor ; fast enough: hold the pace
sub 4
ld (Fall),a ; the next reload counts from here
_floor:
xor a
ld (Heat),a ; rewind the climb
end
render DrawDrop
on DropY, Visible
begin
call FbClear
ld a,(Visible)
or a
jr z,_done ; the blink's dark half: leave the matrix clear
ld a,(DropY)
ld c,a ; C = y
ld b,3 ; B = x, the middle column
ld a,COLOR_BLUE
call FbPlot
_done:
end
Quicken is where difficulty turns out to be ordinary: sub 4 and a
store into Fall, the same write any effect makes to any state, and
the timer’s next reload counts from the new period. The cp 8 holds a
floor - periods run 24, 20, 16, 12, 8, then settle at 4 - and the
final store rewinds Heat to begin the next 250-frame climb.
Run it. The drop crawls down the middle column, blinking as it goes, and wraps back to the top. Around its second descent the pace picks up, then again at the top of every climb, until it settles into a quick steady drip. Speed, blink, and difficulty each came from one declaration and one small rule.
The program, as a report
glimmer --deps drip.glim
program Drip
DropY : state byte
raised by: Descend
triggers: DrawDrop (render)
Visible : state byte
raised by: Twinkle
triggers: DrawDrop (render)
Boot : state byte
raised by: (nothing)
triggers: Ignite (logic)
FallTick : pulse
raised by: timer Fall
triggers: Descend (logic)
BlinkTick : pulse
raised by: timer Blink
triggers: Twinkle (logic)
HeatUp : pulse
raised by: ramp Heat
triggers: Quicken (logic)
Fall : timer
raised by: Quicken
triggers: (nothing)
Blink : timer
raised by: (nothing)
triggers: (nothing)
Heat : ramp
raised by: Ignite, Quicken
triggers: (nothing)
The schedules sit in the graph beside everything else: raised by:
timer Fall and raised by: ramp Heat read exactly like the key
lines in Meter’s report. Fall shows raised by: Quicken and
triggers: (nothing) - its writes matter to the hidden countdown, and
no block watches the cell. And Boot, raised by nothing, is the
report’s way of showing a moment that exists purely because a
declaration marked it changed.
Inside GlimTickTimers
The generated file gives every piece of this chapter an address. From
drip.main.asm, the storage:
; --- state storage ---
DropY: .db 0
Visible: .db 1
Boot: .db 0
FallTick: .db 0
BlinkTick: .db 0
HeatUp: .db 0
Fall: .db 24 ; period (writable)
Glim_Fall_cnt: .db 24
Blink: .db 5 ; period (writable)
Glim_Blink_cnt: .db 5
Heat: .db 249 ; ramp progress, idle at terminal
Changed0: .db %00000101 ; flags dispatch tests
The hidden countdown has a name after all: Glim_Fall_cnt, one byte
beside the period it reloads from, and Blink gets its own. Heat
begins at 249, its terminal, idle until Ignite writes it. And
Changed0 starts at %00000101: bits 0 and 2, the two cells declared
changed - DropY for the first picture, Boot for the first climb.
The loop places the tick:
MainLoop:
call ScanFrame ; show one full frame, then blank
call GlimPollBindings ; game work runs in the blank window
call GlimTickTimers
call GlimRunLogicEffects
call GlimMergeRaised
call GlimRunRenderEffects
call GlimEndFrame
jp MainLoop
GlimTickTimers runs after the poll and before every phase - which is
why a timer’s pulse reaches its consumers in the frame it fires. The
routine itself opens with Fall:
; --- timers, ramps, frame counter ---
.routine
GlimTickTimers:
ld a,(Glim_Fall_cnt)
dec a
ld (Glim_Fall_cnt),a
jr nz,_next_Fall
ld a,(Fall) ; reload from period cell
ld (Glim_Fall_cnt),a
ld a,1
ld (FallTick),a
ld a,(Changed0)
or CHG_FALLTICK
ld (Changed0),a
_next_Fall:
Decrement, store, and on the zero frame: reload from Fall, set the
pulse byte, and OR the pulse’s flag straight into Changed0. Blocks
you write raise through Raised0 or Next0 because some consumers
may already have run; the tick runs before all of them, so it delivers
directly, and the exactly-once rule from chapter 5 holds untouched.
Further down, the ramp:
ld a,(Heat)
cp 249
jr nc,_next_Heat ; idle at terminal
inc a
ld (Heat),a
ld a,(Changed0)
or CHG_HEAT
ld (Changed0),a
ld a,(Heat)
cp 249
jr nz,_next_Heat
ld a,1 ; arrived: fire completion
ld (HeatUp),a
ld a,(Changed0)
or CHG_HEATUP
ld (Changed0),a
_next_Heat:
ret
The first compare is the idle test: at 249 the whole section falls
through. Below it, each moving frame steps the cell and marks
CHG_HEAT - the per-step change flag a ramp cell carries and a
timer cell lacks - and the step that lands on 249 also fires
HeatUp. When Quicken stores 0, the idle test fails on the next
tick and the climb resumes: restart is a plain write to a plain byte.
One line of Quicken’s wrapper closes the circle. Its header says
updates Fall, Heat, and the generated raise after its body covers
Heat alone:
ld a,(Raised0) ; deliver to later phases this frame
or CHG_HEAT
ld (Raised0),a
ret
With no flag behind Fall, updates Fall compiles to nothing here;
the store inside the body is the entire event, and the header line
documents it for the dependency report and for you.
GlimTickTimers is generated only when a program declares a timer or
a ramp or names FrameCount - look back at Meter’s loop in chapter 5
and you will find no such call.
Summary
FrameCountis a built-in byte cell, incremented and marked changed every frame;on FrameCountruns a block every frame, and the cell takes a change-flag bit only in programs that name it.timer Name : byte = N -> Pulsedeclares an oscillator: a hidden countdown fires the pulse and reloads from the cell every N frames. The cell is the writable period - store a new value and the tempo changes from the next reload.timer ... oncemakes the cell the countdown itself: one firing when it reaches zero, idle at zero until a block writes it again.ramp Name : byte steps N -> Pulsesteps its cell towardN - 1once per frame, marks it changed at every step, fires the pulse on arrival, and idles at the terminal; writing 0 starts the climb over.- Timer cells carry no change flag - trigger on the pulse. Ramp cells
do, so blocks can follow the journey with
on. - Ticking runs after the poll and before the phases: timer and ramp pulses are seen the same frame they fire and clear at frame end.
- A state declared
changedthat nothing updates is a first-frame moment: one rule, run once, at startup.
Drip’s drop moves in equal steps; the next chapter shapes steps into motion - curves, and the ramp-driven pattern that plays them back.