Signals for Ruby. Live pages for Rails.
This page's state lives in Ruby, on the server. When something changes, Hibiki re-renders the HTML that shows it, and the page updates.
Fire Cold ashes 0
The campfire is shared by every visitor. Add some fuel and everyone sees it: the fire grows, and the camera pulls back to fit the flame. It works because the fire lives in a Ruby process on the server. So the page has to change whenever the fire does, whether you or another visitor has added a log or a minute has passed and the fire has burnt down. Hibiki handles that with signals: values that remember who read them, so that when one changes, everything derived from it is worked out again and sent to the page.
The page keeps a WebSocket open to the server.
Pressing Add log calls the channel's add_log method over that socket,
much as a form submit calls a controller action, with no route, fetch or JSON API to write.
The channel keeps the fire in a state: its size, and the moment it was
measured. A second State is a clock, which the channel moves forward once a second. The rest
is worked out from those two, such as how much fire is left and which stage it has reached.
Each of those is a derived, a block that reads other signals and
is run again when one of them changes.
An effect is a block that runs once, and again whenever a signal it read
has changed. Each effect here keeps one part of the page fresh, such as the
message feed and the line over the fire. The views are the same ERB you would write for any Rails app.
Some parts of a page are better drawn in the browser. The flames are a 3D scene redrawn sixty times a second, far too often for the server to send HTML for each frame. Hibiki does not have to own that part. A JavaScript component can sit beside the server-rendered parts, listen to the same channel and follow the same state.
Here the component is written in Svelte and mounted with
turbo-mount. The view calls
turbo_mount "Campfire" and passes it the fire as props, measured when the page was rendered. After
that, one more effect in the channel sends each change to the fire as JSON.
campfire_controller.js subscribes the page to the channel, lets the HTML through to the panels, and hands that JSON to campfire.svelte.js, a small
module that holds the fire in a Svelte rune for the scene to read. The module also has its own
copy of the burn curve, so the flames shrink smoothly between messages.
The tabs below show the actual code this server is running: the channel and the burn curve on the server,
then the two JavaScript files that carry the fire into the scene. Start with the channel: its
build_graph method declares the States, Deriveds and Effects described above.
# frozen_string_literal: true
# The landing page's campfire. The fire is one database row shared by every
# visitor; each tab gets its own signal graph over it. The graph sends HTML
# for the HUD and one JSON message for the Svelte scene, which draws the
# flames from the same anchor.
class CampfireChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
STREAM = "campfire"
FUEL = { "stick" => 2, "log" => 10 }.freeze
TICK = 0.5 # seconds a tab waits between two pieces of fuel
Line = Data.define(:id, :text)
WELCOME = [
"Welcome to Hibiki.",
"This fire is shared. Everyone with this page open sees the same flames."
].freeze
def self.welcome = WELCOME.each_with_index.map { |text, id| Line.new(id:, text:) }
# The graph has no clock of its own, so the channel gives it one.
periodically every: 1.second do
graph_actor&.post { @now.value = Time.current }
end
def build_graph
@anchor = Hibiki::State.new(Campfire.current.anchor)
@now = Hibiki::State.new(Time.current)
@lines = Hibiki::State.new(self.class.welcome)
@overhead = Hibiki::State.new(nil)
fire = Hibiki::Derived.new { @anchor.value.fire_at(@now.value) }
stage = Hibiki::Derived.new { Campfire::Stage.for(fire.value) }
# The clock ticks every second, but these only send when their text
# changes: a small fire loses one point every two seconds.
transmit_value(:campfire_fire) { fire.value.ceil.to_fs(:delimited) }
transmit_value(:campfire_stage) { stage.value.name }
Hibiki::Effect.new { transmit({ html: render("campfire/feed", lines: @lines.value) }) }
Hibiki::Effect.new { transmit({ html: render("campfire/overhead", line: @overhead.value) }) }
# The scene animates the burn curve by itself, so it only needs to hear
# about the anchor moving.
Hibiki::Effect.new do
anchor = @anchor.value
transmit({ campfire: { fire: anchor.fire, at: anchor.at.to_f, now: Time.current.to_f } })
end
shown = stage.peek
Hibiki::Effect.new do
current = stage.value
say(current.index.zero? ? current.reached : "The fire dies down to #{current.noun}.") if current < shown
shown = current
end
end
def add_stick = add("stick")
def add_log = add("log")
private
def subscribed
super
return if subscription_rejected?
# Fuel from other visitors. The block runs on a cable thread, so it hops
# onto the graph thread before touching a signal.
stream_from STREAM, coder: ActiveSupport::JSON do |event|
next if event["by"] == cid
added = Campfire::Added.new(fire: event["fire"], at: Time.zone.at(event["at"]), was: event["was"])
graph_actor&.post { Hibiki.batch { absorb(added, "A traveller adds a #{event["fuel"]} to the fire.") } }
end
end
def add(fuel)
return if tired?
added = Campfire.current.add!(FUEL.fetch(fuel))
absorb(added, "You add a #{fuel} to the fire.")
ActionCable.server.broadcast(STREAM, { fuel:, by: cid, fire: added.fire, at: added.at.to_f, was: added.was })
end
# One piece of fuel per TICK from a tab, however fast it clicks.
def tired?
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
return true if @rested_at && now < @rested_at
@rested_at = now + TICK
false
end
def absorb(added, text)
@anchor.value = added.anchor
@now.value = [ added.at, @now.peek ].max
say(text)
before = Campfire::Stage.for(added.was)
after = Campfire::Stage.for(added.fire)
return unless after > before
say(after.reached)
@overhead.value = Line.new(id: next_id, text: after.reached) if after.reward?
end
def say(text)
@lines.value = (@lines.peek + [ Line.new(id: next_id, text:) ]).last(6)
end
def next_id = @next_id = (@next_id || WELCOME.size) + 1
def render(partial, **locals) = ApplicationController.render(partial:, locals:)
end
# frozen_string_literal: true
# How the campfire dwindles. A fire of size `fire` takes
#
# burn_time(fire) = MAX_BURN * fire / (fire + HALF)
#
# seconds to burn out: about 2 seconds per fire while it is small, bending
# towards MAX_BURN as it grows, so no fire outlasts three hours however much
# fuel went in. `after` is the same curve read the other way: what is left of
# a fire once some seconds have passed.
#
# The landing page's scene keeps its own copy of `after` in
# app/javascript/landing/campfire.svelte.js, fed these constants as props.
module Campfire::Burn
MAX_BURN = 3.hours.to_f
# The fire that burns for half of MAX_BURN. HALF / MAX_BURN is also the
# rate a dying fire loses: 0.5 fire per second.
HALF = 5_400.0
def self.burn_time(fire)
MAX_BURN * fire / (fire + HALF)
end
def self.after(fire, seconds)
return 0.0 unless fire.positive?
spread = [ seconds, 0.0 ].max * (fire + HALF)
[ HALF * (MAX_BURN * fire - spread) / (MAX_BURN * HALF + spread), 0.0 ].max
end
end
import { ChannelController } from "hibiki-rails"
import { anchor } from "./campfire.svelte.js"
// Subscribes the landing page to CampfireChannel. The base class swaps in
// the HTML and reactive values the channel sends; the one message it does
// not know, `campfire`, is the fire's anchor as JSON, which goes into the
// rune the Svelte scene reads.
export default class extends ChannelController {
static targets = ["fuel"]
// A little longer than CampfireChannel::TICK, so a click the page allows
// is never one the server drops.
static rest = 600
received(data) {
if (data.campfire) return anchor(data.campfire)
return super.received(data)
}
addStick() {
this.add("add_stick")
}
addLog() {
this.add("add_log")
}
add(action) {
if (this.resting || !this.perform(action)) return
const fuel = this.fuelTarget
this.resting = true
fuel.dataset.resting = ""
setTimeout(() => {
this.resting = false
delete fuel.dataset.resting
}, this.constructor.rest)
}
}
// The campfire as the browser knows it: the anchor the server last sent,
// held in a rune so Svelte components can react to it, plus the burn curve
// to work out the fire at any moment in between.
//
// The Hibiki channel is the only writer. CampfireController calls anchor()
// when the server transmits one; the Campfire component calls it once with
// the values the page was rendered with.
export const campfire = $state({ fire: 0, at: 0 })
// Burn curve constants, sent by the server (Campfire::Burn).
let half = 5400
let maxBurn = 10800
// Server clock minus this clock, in seconds.
let skew = 0
// Everything the scene's frame loop shares: written once per frame by
// Scene.svelte, read by the other parts. `night` runs from 0 (day) to 1. Plain object on purpose, since it
// changes sixty times a second and nothing needs to re-render for it.
export const live = { fire: 0, size: 0, zoom: 0, flare: 0, time: 0, night: 0 }
// Listeners for fuel landing on the fire: (amount) => void.
const fuelListeners = new Set()
export function onFuel(listener) {
fuelListeners.add(listener)
return () => fuelListeners.delete(listener)
}
export function configure(curve) {
half = curve.half
maxBurn = curve.maxBurn
}
export function anchor({ fire, at, now }) {
skew = now - Date.now() / 1000
const before = fireNow()
campfire.fire = fire
campfire.at = at
const gained = fireNow() - before
if (gained >= 1) for (const listener of fuelListeners) listener(gained)
}
// Campfire::Burn.after, in JavaScript.
export function after(fire, seconds) {
if (fire <= 0) return 0
const spread = Math.max(seconds, 0) * (fire + half)
return Math.max((half * (maxBurn * fire - spread)) / (maxBurn * half + spread), 0)
}
export function fireNow() {
return after(campfire.fire, Date.now() / 1000 + skew - campfire.at)
}
bundle add hibiki hibiki_rails
bin/rails g hibiki:rails:install