SolRengine

Building Solana Bytes: A Hex Visualizer and an 8-Bit Game in Rails, Without a SPA

Building Solana Bytes — a hex visualizer and 8-bit game — with Rails 8, Turbo Frames, and signed challenge tokens. No SPA, no router, no JSON API.

· moviendo.me

Solana Bytes is a hex visualizer for any Solana account, plus a game where you race to find specific fields in a gray hex dump before three wrong clicks end your streak. It is the SolRengine team’s Colosseum Frontier submission. It runs on devnet, testnet, and mainnet, and it works without a wallet (login just saves your scores).

Solana Bytes — colored hex grid of a Solana account, with the Byte Challenge game above

It is also the most un-React thing we have shipped. There is no client-side router, no useState, no fetch call dispatching to a JSON API. The hex dump is server-rendered HTML inside a Turbo Frame. The game state is a signed token that round-trips through hidden form fields. The 8-bit sound effects are 30 lines of Web Audio inside a Stimulus controller.

This post walks through three patterns that fall out naturally when you build this kind of app in Rails 8: a presenter that turns 165 raw bytes into colored hex cells, a MessageVerifier-signed challenge token that prevents streak-spoofing, and a Turbo Frame “SPA” that gives you URL pushing and back-button support without any of the SPA cost.

The shape of the problem

A Solana account is mostly bytes. An SPL token mint is 82 bytes; a token account is 165 bytes; a Token-2022 mint with extensions is variable. Most explorers show you a JSON dump of decoded fields. That is fine for casual inspection but useless when you are trying to understand the byte layout — which fields live where, why an Option<Pubkey> takes 33 bytes, what a Token-2022 extension TLV header looks like.

Solana Bytes inverts that. It shows you the hex first, with each field colored, and decodes only when you hover. Then it turns the same data into a game: hide all the colors, ask “find the mint_authority,” and let players click cells until they nail the right region.

Both modes share one piece of data: a list of (offset, length, field_name, decoded_value, color) tuples for the account. That list is a presenter.

A presenter that does the actual work

The hex view is rendered server-side. The controller fetches the account, hands it to a presenter, and the presenter produces rows of cells.

# app/controllers/accounts_controller.rb
class AccountsController < ApplicationController
  def show
    @account = AccountFetcher.new(params[:address], network: current_network)
                             .call(retries: 3, timeout: 8)
    @presenter = AccountPresenter.new(@account)
  rescue AccountFetcher::NotFound
    head :not_found
  end
end

The presenter splits the raw bytes into 16-byte rows, builds an O(1) offset → region map, and exposes two iterators: each_row and region_at(offset).

# app/presenters/account_presenter.rb
class AccountPresenter
  HexRow  = Struct.new(:offset, :cells, keyword_init: true)
  HexCell = Struct.new(:offset, :byte, :region, keyword_init: true)

  def initialize(account)
    @account  = account
    @regions  = RegionDecoder.decode(account)
    @by_offset = build_offset_map(@regions)
  end

  def each_row
    @account.data.each_slice(16).with_index do |bytes, row|
      cells = bytes.each_with_index.map do |byte, col|
        offset = row * 16 + col
        HexCell.new(offset:, byte:, region: @by_offset[offset])
      end
      yield HexRow.new(offset: row * 16, cells:)
    end
  end

  def region_at(offset) = @by_offset[offset]

  private

  def build_offset_map(regions)
    map = {}
    regions.each do |region|
      (region.offset...region.offset + region.length).each { |i| map[i] = region }
    end
    map
  end
end

RegionDecoder is a dispatcher with one decoder class per known program:

# app/presenters/region_decoder.rb
module RegionDecoder
  DECODERS = {
    "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" => Decoders::SplTokenMint,
    "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" => Decoders::Token2022Mint,
    # ...
  }

  def self.decode(account)
    decoder = DECODERS[account.owner] || Decoders::Bpf
    decoder.new(account).regions
  end
end

Each decoder returns a list of Region value objects. Decoders::SplTokenMint, for example, returns five regions: mint_authority (33 bytes, optional pubkey), supply (8 bytes, u64), decimals (1 byte), is_initialized (1 byte), freeze_authority (33 bytes, optional pubkey). Each region carries its own color from a small palette.

The view is plain ERB:

<%# app/views/accounts/show.html.erb %>
<%= turbo_frame_tag "account_result", src: nil do %>
  <table class="hex-grid">
    <% @presenter.each_row do |row| %>
      <tr>
        <td class="offset"><%= "%04x" % row.offset %></td>
        <% row.cells.each do |cell| %>
          <td class="hex-cell"
              style="background-color: <%= cell.region&.color || "transparent" %>"
              data-controller="hex-viewer"
              data-action="mouseenter->hex-viewer#showTooltip"
              data-region="<%= cell.region&.field_name %>"
              data-decoded="<%= cell.region&.decoded_value %>">
            <%= "%02x" % cell.byte %>
          </td>
        <% end %>
      </tr>
    <% end %>
  </table>
<% end %>

That is the entire client-server contract. No JSON API, no GraphQL, no swr. The Stimulus hex-viewer controller reads data-region and data-decoded straight from the DOM and positions a tooltip — fewer than 40 lines of JavaScript.

Turbo Frame as the SPA you didn’t write

The landing page has a search box. You paste an account address, hit submit, and the hex grid appears in place — with the URL updating to /accounts/:address so back-button and copy-link work. In React, that is a router, a client-side fetch, a loading state, and a useEffect. In Rails 8 with Turbo, it is one attribute:

<%# app/views/home/index.html.erb %>
<%= form_with url: lookup_path, method: :post,
              data: { turbo_frame: "account_result" } do |f| %>
  <%= f.text_field :address, placeholder: "Paste a Solana account address" %>
  <%= f.submit "Inspect" %>
<% end %>

<%= turbo_frame_tag "account_result", src: nil, data: { turbo_action: "advance" } %>

POST /lookup redirects to GET /accounts/:address, the matching frame from that page replaces the empty one, and turbo_action: "advance" pushes the new URL into history. Loading state, error state, and partial updates are all the same Rails request-response cycle. We caught two production bugs that would have been invisible in a SPA — a malformed base58 address that caused a 500, and a Helius rate-limit response — because they showed up as ordinary controller exceptions in Sentry, not as silent fetch rejections.

A signed challenge token that survives a hostile client

The Byte Challenge game is the part that taught us Rails’ MessageVerifier is underrated. The game flow:

  1. Server picks a random mainnet SPL Mint or Token Account, picks a target field, gray-rasters the hex grid.
  2. Player clicks cells. Each click is a guess.
  3. Three wrong clicks ends the streak. Zero wrong = 3 stars, 1 = 2, 2 = 1.
  4. On success, the client POSTs the result to /challenge/result and we save it to the leaderboard.

The naive version trusts the client. The client tells you “I won, my streak is now 47, save it.” That is unworkable the moment a leaderboard exists. The standard fix in a SPA is JWTs and a backend that re-validates everything. In Rails the equivalent is one line:

# app/controllers/challenges_controller.rb
class ChallengesController < ApplicationController
  def play
    account = sample_random_account
    target_field = pick_target(account)
    streak = signed_in? ? current_user.current_streak : 0

    @token = verifier.generate(
      { account: account.address,
        target: target_field,
        streak:,
        nonce: SecureRandom.hex(16) },
      expires_in: 5.minutes
    )

    @presenter = AccountPresenter.new(account).gray_mask
  end

  def result
    payload = verifier.verify(params[:token])
    raise ActionController::BadRequest unless payload[:target] == params[:field_clicked]

    ChallengeResult.create!(
      user: current_user,
      account_address: payload[:account],
      target_field: payload[:target],
      time_seconds: params[:time].to_i,
      attempts: params[:attempts].to_i,
      stars: stars_for(params[:attempts].to_i),
      streak: payload[:streak] + 1
    )
    head :ok
  end

  private

  def verifier = Rails.application.message_verifier(:bytes_challenge)
end

The token contains the account, the target field, and the player’s pre-game streak. The client gets the token in a hidden field on the game page and sends it back when reporting results. The server re-verifies the signature and re-derives stars from attempts. The client cannot lie about which field they were asked to find, and they cannot inflate their pre-existing streak. The whole thing is ActiveSupport::MessageVerifier, which Rails uses internally for signed cookies. No new dependency, no JWT library, no key rotation drama.

The Stimulus controller carries the token through:

// app/javascript/controllers/challenge_controller.js
export default class extends Controller {
  static values = { token: String, target: String }
  static outlets = ["hex-viewer"]

  cellClicked(event) {
    const field = event.currentTarget.dataset.region
    if (field === this.targetValue) {
      this.playSound("correct")
      this.submitResult({ field_clicked: field })
    } else {
      this.attempts++
      this.playSound(this.attempts >= 3 ? "gameOver" : "wrong")
      if (this.attempts >= 3) this.endGame()
    }
  }

  submitResult(extra) {
    fetch("/challenge/result", {
      method: "POST",
      headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrf },
      body: JSON.stringify({ token: this.tokenValue, attempts: this.attempts, time: this.elapsed, ...extra })
    })
  }
}

That fetch is the only non-Turbo network call in the entire app. Even then, it returns 204 No Content and the page just navigates to the leaderboard via Turbo on success.

The 8-bit aesthetic, on the cheap

The pixel-art look — Press Start 2P font, scanline backgrounds, 14 inline pixel-icon SVGs, arpeggio sound effects — is a helpers/pixel_icon_helper.rb module and a Stimulus controller that does its sounds with the Web Audio API. No sprite sheets, no asset compilation hacks, no game framework.

# app/helpers/pixel_icon_helper.rb
module PixelIconHelper
  ICONS = {
    coin: %(<svg viewBox="0 0 8 8"><rect ... /></svg>),
    skull: %(<svg viewBox="0 0 8 8"><rect ... /></svg>),
    # ... 12 more
  }

  def pixel_icon(name, class: "w-6 h-6")
    content_tag(:span, ICONS.fetch(name).html_safe, class:)
  end
end

Inline SVG keeps everything in the same HTTP response; CSP nonce-based script-src works because we never inject runtime JS; Rack::Attack (60 req/min general, 20/min RPC, 10/min save) blocks the obvious leaderboard-scripting attempts. Total game JS, including sound: under 200 lines.

Where the framework helped most

Reading back the code, three SolRengine-flavored choices made this app cheap to ship:

  • Multi-database SQLite by default. Solana Bytes uses all four — primary for users and challenge_results, cache for RPC responses (Helius rate-limits hard, the cache earns its keep), queue for occasional sync jobs, cable for the live leaderboard ticker. Setting that up in Next.js means picking and integrating four services. In Rails 8 it is the template default.
  • solrengine-auth mounted as an engine. Login, logout, SIWS challenge/verify — three views and zero controllers in the host app. Worth noting, after our security review in April: integration tests that hit auth routes and then assert against a host-app path need absolute path strings, not main_app.foo_path, because ActionDispatch::Integration::Session swaps _routes to the engine’s after any request into it. We learned that one the slow way.
  • Server-side RPC via solrengine-rpc. All account fetches happen in Ruby, with a 3-attempt-with-jitter retry built in. The browser never touches an RPC URL, so we never paid the cost of CORS, key rotation, or “different RPC for different networks” wiring on the JS side. The network selector is a PATCH /network that flips a session value; the next page load picks up the new endpoint server-side.

What this is not

This isn’t an argument that Rails is better than Next.js for every Solana app. If your dApp is a tightly-coupled real-time trading interface where every interaction needs to feel like local state mutation, you probably want the full SPA.

But Solana Bytes — a server-rendered hex viewer plus a game with a leaderboard — is a much more common shape than people think. Most “dApps” are content sites with a wallet. For those, Rails 8 + Turbo gives you everything a SPA gives you (URL state, partial updates, no full reloads) without any of the SPA tax (state management, hydration, JSON APIs, separate auth strategies on client and server).

The whole thing is open source: github.com/solrengine/solana-bytes. 29 tests, three processes, four SQLite files. Try it at bytes.solrengine.org — bring your wallet if you want a high score, or play guest if you don’t.

← All posts