Create: Computing AgesBrass Docs
Cookbook: real projects

Smart intersection

Two Traffic Lights and two Inductive Loop Detectors run by a state machine that serves the waiting road, with a minimum green, amber and red-amber phases, and flashing amber at night.

A crossroads in your town: the main road carries the trains of carts and the villagers' traffic, a side road comes from the farms. A fixed cycle stops the main road every thirty seconds even when nobody is on the side road. This project does what real controllers do: the main road keeps the green, and an Inductive Loop Detector in the side road calls for the green only when something stands over it. Each change goes through amber, a moment of red on both sides to clear the junction, and red and amber together before the green, like British and German lights. At night both lights flash amber.

The program is a state machine: a variable holds the current phase, and a function called four times a second decides whether it is time to move to the next one. It is the clearest way to write anything that goes through steps: lights, a train station, a lift, a sequence of machines.

What you need

  • A Microcontroller (or any computer from the Minicomputer on): it is small, and its Data Cable port reaches every block of the junction. Its screen is 40 x 12; the program draws the two lights on it. It is driven from below.
  • Two Traffic Lights, one facing each road (or more: each road takes a list of lights). A light set by a computer stays under its control and ignores redstone until traffic_light.release() (Traffic Light).
  • Two Inductive Loop Detectors, set into the road a few blocks before the junction. A loop sees mobs, players, minecarts, boats and Create Aeronautics vehicles up to two blocks above it (Inductive Loop Detector).
  • Data Cable under the road, touching the Microcontroller, both lights and both loops. Each block on the cable is named after its type and position: traffic_light@10,65,4, inductive_loop@10,63,9.

Seen from above:

               |  main  |
               |  road  |
               |   L1   |        L1, L2: Inductive Loop Detectors in the road,
               |        |                a few blocks before the junction
    -----------+        +-----------
     side   L2             T1        T1: Traffic Light for the main road
     road                  T2        T2: Traffic Light for the side road
    -----------+        +-----------
               |        | [M]        M: the Microcontroller at the corner
               |        |
         a Data Cable under the road joins M, T1, T2, L1 and L2

The program, step by step

1. The roads and the timings

Each road has a name, a list of lights and a list of loops. The first road is the main road: it gets the green back when nobody waits anywhere.

Brass
local ROADS = {
  {name = "Main road", lights = {"traffic_light@10,65,4"}, loops = {"inductive_loop@10,63,9"}},
  {name = "Side road", lights = {"traffic_light@14,65,0"}, loops = {"inductive_loop@19,63,0"}},
}
local MIN_GREEN = 8        -- seconds of green before the light may change
local MAX_GREEN = 30       -- seconds of green at most while the other road waits
local AMBER = 3            -- seconds of amber
local ALL_RED = 1          -- seconds with both roads red, to clear the junction
local RED_AMBER = 1.5      -- seconds of red and amber before the green
local NIGHT_START = 13000  -- os.day_time() at nightfall (7 pm)
local NIGHT_END = 23000    -- os.day_time() at dawn (5 am)
local TICK = 0.25          -- seconds between two looks at the loops

MIN_GREEN keeps a light from changing again right after it turned green; MAX_GREEN makes sure a busy main road cannot keep a waiting side road on red forever.

To find the names of your blocks, run the program with list after its name: it prints them and stops. The devices example (devices) also shows them, on a map.

Brass
if arg ~= nil and arg[1] == "list" then
  for _, kind in ipairs({"traffic_light", "inductive_loop"}) do
    for _, name in ipairs(peripheral.list(kind)) do print(name) end
  end
  return
end

2. Talking to the blocks

The program never wraps the blocks once and for all. It calls them by name with peripheral.call, through pcall, so that a light broken by a creeper or a loop in an unloaded chunk does not stop the whole junction: the name goes into broken, and the screen reports it.

Brass
local aspects = {"off", "off"}   -- what each road shows
local counts = {0, 0}            -- what stands over the loops of each road
local waiting = {false, false}   -- true when something waits on a road
local broken = {}                -- names of blocks that did not answer

local function set_road(i, aspect)
  aspects[i] = aspect
  for _, name in ipairs(ROADS[i].lights) do
    local r = pcall(peripheral.call, name, "set", aspect)
    if r.ok then broken[name] = nil else broken[name] = true end
  end
end

local function read_loops(i)
  local total, all_ok = 0, true
  for _, name in ipairs(ROADS[i].loops) do
    local r = pcall(peripheral.call, name, "count")
    if r.ok then
      total = total + r.value
      broken[name] = nil
    else
      broken[name] = true
      all_ok = false
    end
  end
  counts[i] = total
  waiting[i] = total > 0 or not all_ok
end

A loop that does not answer counts as a car waiting. That is the safe choice: the road keeps getting its turn on a regular cycle, instead of never getting the green again because its detector is broken.

The aspects a Traffic Light understands are "off", "red", "red_amber", "amber", "green" and "flashing" (flashing amber). Any other word stops the call with bad aspect 'yellow' (off, red, red_amber, amber, green, flashing).

3. The state machine

The state is three variables: the phase, the road that has (or is about to get) the green, and the moment the phase began, read from os.clock() (seconds since the computer started). enter moves to a phase and notes the time:

Brass
local phase = "green"   -- "green", "amber", "all_red", "red_amber" or "night"
local go = 1            -- the road that has, or is about to get, the green
local since = 0         -- os.clock() when the phase began
local forced = false    -- flashing forced by hand (the f key)

local function enter(new_phase)
  phase = new_phase
  since = os.clock()
end

local function is_night()
  local t = os.day_time()
  return forced or (t >= NIGHT_START and t < NIGHT_END)
end

step runs four times a second. It reads the loops, then looks at the current phase:

phaselights of road gomoves on whento
greengreenthe other road waits and go is empty (or MAX_GREEN passed), after MIN_GREENamber
amberamberAMBER seconds passedall_red
all_redredALL_RED seconds passed (the other road becomes go)red_amber
red_amberred and amberRED_AMBER seconds passedgreen
nightflashingthe night endsall_red
Brass
local function step()
  read_loops(1)
  read_loops(2)
  local other = 3 - go
  local elapsed = os.clock() - since
  if is_night() then
    if phase ~= "night" then
      set_road(1, "flashing")
      set_road(2, "flashing")
      enter("night")
    end
  elseif phase == "night" then
    set_road(1, "red")
    set_road(2, "red")
    go = 2                        -- so that the main road gets the next green
    enter("all_red")
  elseif phase == "green" then
    local change = false
    if elapsed >= MIN_GREEN then
      if waiting[other] and (not waiting[go] or elapsed >= MAX_GREEN) then change = true end
      if go ~= 1 and not waiting[go] then change = true end   -- back to the main road
    end
    if change then
      set_road(go, "amber")
      enter("amber")
    end
  elseif phase == "amber" and elapsed >= AMBER then
    set_road(go, "red")
    enter("all_red")
  elseif phase == "all_red" and elapsed >= ALL_RED then
    go = other
    set_road(go, "red_amber")
    enter("red_amber")
  elseif phase == "red_amber" and elapsed >= RED_AMBER then
    set_road(go, "green")
    enter("green")
  end
end

3 - go is the other road: 2 when go is 1, 1 when go is 2. The lights only change when the phase changes, so step costs almost nothing the rest of the time.

4. The screen

The left half of the screen lists the roads with their aspect and their loop; the right half draws the two light heads with gfx: a grey body and three round lamps, lit or black. In flashing mode the amber lamp follows os.clock(), on for half a second, off for half a second.

Brass
local ASPECT_TEXT = {off = "OFF", red = "RED", red_amber = "RED+AMBER", amber = "AMBER",
  green = "GREEN", flashing = "FLASHING"}
local ASPECT_COLORS = {off = "gray", red = "red", red_amber = "orange", amber = "orange",
  green = "lime", flashing = "orange"}
local PHASE_TEXT = {green = "green", amber = "amber", all_red = "all red",
  red_amber = "red and amber", night = "night, flashing"}

local function clock_text()
  local t = (os.day_time() + 6000) % 24000  -- 0 is 6 am
  return string.format("%02d:%02d", t // 1000, t % 1000 * 60 // 1000)
end

local function lamp(x, y, lit, color)
  if lit then gfx.circle(x, y, 8, color, true) else gfx.circle(x, y, 8, "black", true) end
end

local function draw_head(x, aspect, label)
  local blink = math.floor(os.clock() * 2) % 2 == 0
  gfx.rect(x, 14, 26, 62, "gray", true)
  lamp(x + 13, 25, aspect == "red" or aspect == "red_amber", "red")
  lamp(x + 13, 45, aspect == "amber" or aspect == "red_amber" or (aspect == "flashing" and blink), "orange")
  lamp(x + 13, 65, aspect == "green", "lime")
  gfx.text(x + 11, 81, label, "light_gray")
end

local function draw()
  local w = term.get_size().w
  term.set_bg(term.colors.black)
  term.clear()
  gfx.clear()
  term.set_cursor(1, 1)
  term.set_bg(term.colors.blue)
  term.set_fg(term.colors.white)
  term.write(string.format(" %-" .. (w - 8) .. "s%6s ", "INTERSECTION", clock_text()))
  term.set_bg(term.colors.black)
  for i, road in ipairs(ROADS) do
    term.set_cursor(2, i * 3)
    term.set_fg(term.colors.white)
    term.write(i .. " " .. road.name)
    term.set_cursor(4, i * 3 + 1)
    term.set_fg(term.colors[ASPECT_COLORS[aspects[i]]])
    term.write(string.format("%-10s", ASPECT_TEXT[aspects[i]]))
    term.set_fg(term.colors.light_gray)
    term.write("loop: " .. counts[i])
    draw_head(w * 6 - 84 + (i - 1) * 40, aspects[i], tostring(i))
  end
  term.set_cursor(2, 9)
  term.set_fg(term.colors.light_gray)
  term.write(string.format("%s, %d s", PHASE_TEXT[phase], math.floor(os.clock() - since)))
  local missing = 0
  for _, _ in pairs(broken) do missing = missing + 1 end
  if missing > 0 then
    term.set_cursor(2, 10)
    term.set_fg(term.colors.red)
    term.write(missing .. " block(s) not answering")
  end
  term.set_cursor(2, 11)
  term.set_fg(term.colors.gray)
  term.write("f: flashing on/off")
end

5. Starting and the main loop

At start the main road gets the green and the side road the red. The loop waits for its timer, and for the f key, which forces the flashing mode by hand: handy during roadworks.

Brass
set_road(1, "green")
set_road(2, "red")
enter("green")
local timer = os.start_timer(TICK)
while true do
  local e = os.pull_event()
  if e.name == "timer" and e.id == timer then
    step()
    draw()
    timer = os.start_timer(TICK)
  elseif e.name == "char" and e.char == "f" then
    forced = not forced
  end
end

The whole program

startup
-- Smart intersection: two roads, each with its Traffic Lights and its
-- Inductive Loop Detectors. The main road keeps the green until something
-- waits on the side road; at night, every light flashes amber.
-- "startup list" prints the names of the lights and loops on the cable.

local ROADS = {
  {name = "Main road", lights = {"traffic_light@10,65,4"}, loops = {"inductive_loop@10,63,9"}},
  {name = "Side road", lights = {"traffic_light@14,65,0"}, loops = {"inductive_loop@19,63,0"}},
}
local MIN_GREEN = 8        -- seconds of green before the light may change
local MAX_GREEN = 30       -- seconds of green at most while the other road waits
local AMBER = 3            -- seconds of amber
local ALL_RED = 1          -- seconds with both roads red, to clear the junction
local RED_AMBER = 1.5      -- seconds of red and amber before the green
local NIGHT_START = 13000  -- os.day_time() at nightfall (7 pm)
local NIGHT_END = 23000    -- os.day_time() at dawn (5 am)
local TICK = 0.25          -- seconds between two looks at the loops

if arg ~= nil and arg[1] == "list" then
  for _, kind in ipairs({"traffic_light", "inductive_loop"}) do
    for _, name in ipairs(peripheral.list(kind)) do print(name) end
  end
  return
end

local ASPECT_TEXT = {off = "OFF", red = "RED", red_amber = "RED+AMBER", amber = "AMBER",
  green = "GREEN", flashing = "FLASHING"}
local ASPECT_COLORS = {off = "gray", red = "red", red_amber = "orange", amber = "orange",
  green = "lime", flashing = "orange"}
local PHASE_TEXT = {green = "green", amber = "amber", all_red = "all red",
  red_amber = "red and amber", night = "night, flashing"}

local aspects = {"off", "off"}   -- what each road shows
local counts = {0, 0}            -- what stands over the loops of each road
local waiting = {false, false}   -- true when something waits on a road
local broken = {}                -- names of blocks that did not answer
local phase = "green"            -- "green", "amber", "all_red", "red_amber" or "night"
local go = 1                     -- the road that has, or is about to get, the green
local since = 0                  -- os.clock() when the phase began
local forced = false             -- flashing forced by hand (the f key)

-- Blocks -------------------------------------------------------------------------------

local function set_road(i, aspect)
  aspects[i] = aspect
  for _, name in ipairs(ROADS[i].lights) do
    local r = pcall(peripheral.call, name, "set", aspect)
    if r.ok then broken[name] = nil else broken[name] = true end
  end
end

local function read_loops(i)
  local total, all_ok = 0, true
  for _, name in ipairs(ROADS[i].loops) do
    local r = pcall(peripheral.call, name, "count")
    if r.ok then
      total = total + r.value
      broken[name] = nil
    else
      broken[name] = true
      all_ok = false
    end
  end
  counts[i] = total
  waiting[i] = total > 0 or not all_ok
end

-- State machine ------------------------------------------------------------------------

local function enter(new_phase)
  phase = new_phase
  since = os.clock()
end

local function is_night()
  local t = os.day_time()
  return forced or (t >= NIGHT_START and t < NIGHT_END)
end

local function step()
  read_loops(1)
  read_loops(2)
  local other = 3 - go
  local elapsed = os.clock() - since
  if is_night() then
    if phase ~= "night" then
      set_road(1, "flashing")
      set_road(2, "flashing")
      enter("night")
    end
  elseif phase == "night" then
    set_road(1, "red")
    set_road(2, "red")
    go = 2                        -- so that the main road gets the next green
    enter("all_red")
  elseif phase == "green" then
    local change = false
    if elapsed >= MIN_GREEN then
      if waiting[other] and (not waiting[go] or elapsed >= MAX_GREEN) then change = true end
      if go ~= 1 and not waiting[go] then change = true end   -- back to the main road
    end
    if change then
      set_road(go, "amber")
      enter("amber")
    end
  elseif phase == "amber" and elapsed >= AMBER then
    set_road(go, "red")
    enter("all_red")
  elseif phase == "all_red" and elapsed >= ALL_RED then
    go = other
    set_road(go, "red_amber")
    enter("red_amber")
  elseif phase == "red_amber" and elapsed >= RED_AMBER then
    set_road(go, "green")
    enter("green")
  end
end

-- Screen ---------------------------------------------------------------------------------

local function clock_text()
  local t = (os.day_time() + 6000) % 24000  -- 0 is 6 am
  return string.format("%02d:%02d", t // 1000, t % 1000 * 60 // 1000)
end

local function lamp(x, y, lit, color)
  if lit then gfx.circle(x, y, 8, color, true) else gfx.circle(x, y, 8, "black", true) end
end

local function draw_head(x, aspect, label)
  local blink = math.floor(os.clock() * 2) % 2 == 0
  gfx.rect(x, 14, 26, 62, "gray", true)
  lamp(x + 13, 25, aspect == "red" or aspect == "red_amber", "red")
  lamp(x + 13, 45, aspect == "amber" or aspect == "red_amber" or (aspect == "flashing" and blink), "orange")
  lamp(x + 13, 65, aspect == "green", "lime")
  gfx.text(x + 11, 81, label, "light_gray")
end

local function draw()
  local w = term.get_size().w
  term.set_bg(term.colors.black)
  term.clear()
  gfx.clear()
  term.set_cursor(1, 1)
  term.set_bg(term.colors.blue)
  term.set_fg(term.colors.white)
  term.write(string.format(" %-" .. (w - 8) .. "s%6s ", "INTERSECTION", clock_text()))
  term.set_bg(term.colors.black)
  for i, road in ipairs(ROADS) do
    term.set_cursor(2, i * 3)
    term.set_fg(term.colors.white)
    term.write(i .. " " .. road.name)
    term.set_cursor(4, i * 3 + 1)
    term.set_fg(term.colors[ASPECT_COLORS[aspects[i]]])
    term.write(string.format("%-10s", ASPECT_TEXT[aspects[i]]))
    term.set_fg(term.colors.light_gray)
    term.write("loop: " .. counts[i])
    draw_head(w * 6 - 84 + (i - 1) * 40, aspects[i], tostring(i))
  end
  term.set_cursor(2, 9)
  term.set_fg(term.colors.light_gray)
  term.write(string.format("%s, %d s", PHASE_TEXT[phase], math.floor(os.clock() - since)))
  local missing = 0
  for _, _ in pairs(broken) do missing = missing + 1 end
  if missing > 0 then
    term.set_cursor(2, 10)
    term.set_fg(term.colors.red)
    term.write(missing .. " block(s) not answering")
  end
  term.set_cursor(2, 11)
  term.set_fg(term.colors.gray)
  term.write("f: flashing on/off")
end

-- Main loop ------------------------------------------------------------------------------

set_road(1, "green")
set_road(2, "red")
enter("green")
local timer = os.start_timer(TICK)
while true do
  local e = os.pull_event()
  if e.name == "timer" and e.id == timer then
    step()
    draw()
    timer = os.start_timer(TICK)
  elseif e.name == "char" and e.char == "f" then
    forced = not forced
  end
end

What the screen looks like

The drawing code of the program on the Microcontroller, at the moment the main road turns amber because a cart waits over the loop of the side road:

Brass
local ROADS = {{name = "Main road"}, {name = "Side road"}}
local ASPECT_TEXT = {off = "OFF", red = "RED", red_amber = "RED+AMBER", amber = "AMBER",
  green = "GREEN", flashing = "FLASHING"}
local ASPECT_COLORS = {off = "gray", red = "red", red_amber = "orange", amber = "orange",
  green = "lime", flashing = "orange"}
local PHASE_TEXT = {green = "green", amber = "amber", all_red = "all red",
  red_amber = "red and amber", night = "night, flashing"}
local aspects = {"amber", "red"}
local counts = {0, 1}
local broken = {}
local phase = "amber"
local since = os.clock() - 1

local function clock_text()
  local t = (os.day_time() + 6000) % 24000
  return string.format("%02d:%02d", t // 1000, t % 1000 * 60 // 1000)
end

local function lamp(x, y, lit, color)
  if lit then gfx.circle(x, y, 8, color, true) else gfx.circle(x, y, 8, "black", true) end
end

local function draw_head(x, aspect, label)
  local blink = math.floor(os.clock() * 2) % 2 == 0
  gfx.rect(x, 14, 26, 62, "gray", true)
  lamp(x + 13, 25, aspect == "red" or aspect == "red_amber", "red")
  lamp(x + 13, 45, aspect == "amber" or aspect == "red_amber" or (aspect == "flashing" and blink), "orange")
  lamp(x + 13, 65, aspect == "green", "lime")
  gfx.text(x + 11, 81, label, "light_gray")
end

local function draw()
  local w = term.get_size().w
  term.set_bg(term.colors.black)
  term.clear()
  gfx.clear()
  term.set_cursor(1, 1)
  term.set_bg(term.colors.blue)
  term.set_fg(term.colors.white)
  term.write(string.format(" %-" .. (w - 8) .. "s%6s ", "INTERSECTION", clock_text()))
  term.set_bg(term.colors.black)
  for i, road in ipairs(ROADS) do
    term.set_cursor(2, i * 3)
    term.set_fg(term.colors.white)
    term.write(i .. " " .. road.name)
    term.set_cursor(4, i * 3 + 1)
    term.set_fg(term.colors[ASPECT_COLORS[aspects[i]]])
    term.write(string.format("%-10s", ASPECT_TEXT[aspects[i]]))
    term.set_fg(term.colors.light_gray)
    term.write("loop: " .. counts[i])
    draw_head(w * 6 - 84 + (i - 1) * 40, aspects[i], tostring(i))
  end
  term.set_cursor(2, 9)
  term.set_fg(term.colors.light_gray)
  term.write(string.format("%s, %d s", PHASE_TEXT[phase], math.floor(os.clock() - since)))
  local missing = 0
  for _, _ in pairs(broken) do missing = missing + 1 end
  if missing > 0 then
    term.set_cursor(2, 10)
    term.set_fg(term.colors.red)
    term.write(missing .. " block(s) not answering")
  end
  term.set_cursor(2, 11)
  term.set_fg(term.colors.gray)
  term.write("f: flashing on/off")
end

draw()
Screen
Screen

Testing it

  1. Build the junction, run startup list and copy the names into ROADS: the light and the loop of the main road in the first line, those of the side road in the second.
  2. Run startup. The main road is green, the side road red, and both loops read 0.
  3. Walk onto the loop of the side road and wait. After at most MIN_GREEN seconds, the main road goes amber, then red; a second later, the side road shows red and amber, then green.
  4. Step off the loop: after MIN_GREEN seconds of green, the side road goes back through amber, and the main road gets the green again.
  5. Stand on both loops (bring a friend, or leave a cow on one): the green alternates, each road keeping it at most MAX_GREEN seconds.
  6. Type f in the terminal: both lights flash amber. Type f again, or wait for the night (/time set night) and the morning: the junction goes back to red on both roads, then green on the main road.
  7. Break the loop of the side road: the screen reports 1 block(s) not answering, and the side road still gets its turn on a regular cycle.

Variations

  • Pedestrian button. A button by the road, wired to the computer: in step, treat rs.get("left") > 0 as a car waiting on the side road.
  • Count the traffic. Count the rising edges of each loop (counts[i] going from 0 to more) and append them to a file every hour: see Data logger and graph.
  • Four roads. With four approaches, keep the two pairs that never cross (north-south, east-west) and give each pair a list of lights and loops: the program does not change, ROADS just gets longer lists.
  • Give the lights back to redstone. Before leaving the program, call traffic_light.release() on each light (peripheral.call(name, "release")): the lights follow redstone again, like a Traffic Light without a computer. Without it, a light keeps the last aspect the program set, even after a reboot or once the computer is broken.
  • A level crossing. Replace the side road by a minecart track: an Inductive Loop Detector under the rails sees each cart, and the road gets the red while one is coming.