Build a Solari Split-Flap Display

What You'll Build

In this tutorial, you'll create a NeuroSim plugin that simulates a classic Solari split-flap display—those iconic mechanical displays found in train stations and airports that flip through characters to show departure information. Your plugin will animate individual characters flipping through the alphabet to spell out custom messages, complete with realistic timing and visual effects.

The split-flap display responds to messages sent from NeuroSim, making it perfect for learning how plugins interact with the simulation environment through message passing and configuration.

What You'll Learn

By the end of this tutorial, you'll understand:

  • Plugin Structure: How to organize a NeuroSim plugin with backend logic and frontend UI
  • Configuration Schema: Defining and validating plugin settings using JSON Schema
  • Message Handling: Processing messages from NeuroSim to trigger plugin behavior
  • HMI Development: Building interactive web interfaces that connect to your simulation

Prerequisites

This tutorial assumes you have NeuroSim installed and running locally. If you haven't set up NeuroSim yet, follow the installation guide first.

You'll also need:

  • Go 1.21 or later
  • Node.js 18+ and npm
  • Basic familiarity with Go and React
  • A code editor (VS Code, GoLand, etc.)

Project Structure

Create a new directory for your plugin with the following structure:

solari-plugin/
├── plugin.yaml              # Plugin metadata
├── config-schema.json       # Configuration validation
├── main.go                  # Backend message handler
├── hmi/
│   ├── package.json
│   ├── tsconfig.json
│   ├── vite.config.ts
│   └── src/
│       ├── index.tsx        # React entry point
│       ├── App.tsx          # Main component
│       └── SplitFlap.tsx    # Character animation
└── go.mod

Step 1: Define Configuration Schema

Create config-schema.json to define what settings your plugin accepts:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "characters": {
      "type": "integer",
      "minimum": 1,
      "maximum": 50,
      "default": 12,
      "description": "Number of characters in the display"
    },
    "flipSpeed": {
      "type": "number",
      "minimum": 50,
      "maximum": 500,
      "default": 150,
      "description": "Milliseconds per character flip"
    },
    "theme": {
      "type": "string",
      "enum": ["classic", "modern", "retro"],
      "default": "classic",
      "description": "Visual theme for the display"
    }
  },
  "required": ["characters"]
}

This schema validates that users provide a valid number of characters, optional flip speed, and theme selection.

Step 2: Handle Messages

Create main.go to process incoming messages and update the display:

package main

import (
    "context"
    "encoding/json"
    "log"
    "github.com/neurosim/plugin-sdk/go"
)

type Config struct {
    Characters int    `json:"characters"`
    FlipSpeed  int    `json:"flipSpeed"`
    Theme      string `json:"theme"`
}

type MessagePayload struct {
    Text string `json:"text"`
}

func main() {
    plugin := neurosim.NewPlugin("solari-display")

    plugin.OnMessage("display.update", func(ctx context.Context, msg neurosim.Message) error {
        var payload MessagePayload
        if err := json.Unmarshal(msg.Payload, &payload); err != nil {
            return err
        }

        log.Printf("Updating display with text: %s", payload.Text)

        // Broadcast to HMI clients
        plugin.BroadcastToHMI("text.update", payload)

        return nil
    })

    plugin.OnConfigChange(func(ctx context.Context, config json.RawMessage) error {
        var cfg Config
        if err := json.Unmarshal(config, &cfg); err != nil {
            return err
        }

        log.Printf("Config updated: %d characters, %dms flip speed, %s theme",
            cfg.Characters, cfg.FlipSpeed, cfg.Theme)

        return nil
    })

    if err := plugin.Start(); err != nil {
        log.Fatal(err)
    }
}

Step 3: Build the HMI Client

Create hmi/src/SplitFlap.tsx for the animated character component:

import React, { useEffect, useState } from 'react';

const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ';

interface SplitFlapProps {
  target: string;
  flipSpeed: number;
  position: number;
}

export const SplitFlap: React.FC<SplitFlapProps> = ({
  target,
  flipSpeed,
  position
}) => {
  const [current, setCurrent] = useState(' ');
  const [isFlipping, setIsFlipping] = useState(false);

  useEffect(() => {
    const targetIndex = CHARSET.indexOf(target.toUpperCase());
    const currentIndex = CHARSET.indexOf(current);

    if (targetIndex === currentIndex) return;

    setIsFlipping(true);

    const timeout = setTimeout(() => {
      const nextIndex = (currentIndex + 1) % CHARSET.length;
      setCurrent(CHARSET[nextIndex]);

      if (nextIndex === targetIndex) {
        setIsFlipping(false);
      }
    }, flipSpeed);

    return () => clearTimeout(timeout);
  }, [current, target, flipSpeed]);

  return (
    <div
      className={`split-flap ${isFlipping ? 'flipping' : ''}`}
      style={{ animationDelay: `${position * 50}ms` }}
    >
      <div className="flap-top">{current}</div>
      <div className="flap-bottom">{current}</div>
    </div>
  );
};

Now create hmi/src/App.tsx to compose the full display:

import React, { useEffect, useState } from 'react';
import { useNeuroSim } from '@neurosim/hmi-react';
import { SplitFlap } from './SplitFlap';
import './App.css';

export const App: React.FC = () => {
  const { subscribe, config } = useNeuroSim();
  const [message, setMessage] = useState('HELLO WORLD');

  useEffect(() => {
    const unsubscribe = subscribe('text.update', (payload) => {
      setMessage(payload.text.toUpperCase());
    });

    return unsubscribe;
  }, [subscribe]);

  const characters = config.characters || 12;
  const flipSpeed = config.flipSpeed || 150;
  const displayText = message.padEnd(characters, ' ').slice(0, characters);

  return (
    <div className="solari-display" data-theme={config.theme}>
      <div className="display-container">
        {displayText.split('').map((char, idx) => (
          <SplitFlap
            key={idx}
            target={char}
            flipSpeed={flipSpeed}
            position={idx}
          />
        ))}
      </div>
    </div>
  );
};

Step 4: Run Your Plugin

Build and start your plugin:

# Build the Go backend
go build -o solari-plugin

# Install HMI dependencies
cd hmi && npm install

# Start the plugin (in development mode)
./solari-plugin --dev

In NeuroSim, send a message to update the display:

neurosim message send solari-display display.update '{"text": "DEPARTURE 10:45"}'

You should see the split-flap characters animate to spell out your message.

Complete Source Code

The full source code for this tutorial is available on GitHub:

https://github.com/NeurosimIO/solari-plugin

Clone it to see the complete implementation including CSS animations, error handling, and additional features.

Next Steps

Now that you've built your first plugin, try extending it:

  1. Add Sound Effects: Play a clicking sound for each character flip using the Web Audio API
  2. Message Queue: Implement a queue system to display multiple messages in sequence
  3. Status Indicators: Add colored LEDs to show plugin health and connection status
  4. Remote Control: Build a control panel HMI that lets operators type and send messages

For more advanced plugin development, check out: