Hibiki

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.

  1. Welcome to Hibiki.
  2. This fire is shared. Everyone with this page open sees the same flames.

Fire Cold ashes 0

How this fire works

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.

  1. 1. A click is an action

    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.

  2. 2. Signals hold the state

    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.

  3. 3. Effects send the HTML

    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.

Things that are better left to JavaScript

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

Try it in your own app

bundle add hibiki hibiki_rails
bin/rails g hibiki:rails:install
Browse the recipes