A feature-rich spinner wheel desktop application built with .NET 9 and Avalonia UI 11, designed for streamers and content creators. Spin the wheel live on stream via OBS Window Capture with full chroma key support, multi-wheel management, weighted slices, confetti, sounds, and more.
- Features at a Glance
- Two-Window Design
- Getting Started
- Settings Reference
- Multi-Wheel Tabs
- OBS Setup
- Spin Physics
- Save & Load Formats
- Spin Log
- Publishing
- Project Structure
- Unlimited wheels — multiple wheel tabs, each fully independent
- Spin All — fire every wheel simultaneously with one button
- Drag-to-spin — click and drag the wheel in the capture window to send it spinning
- Chain triggers — configure any slice to automatically spin a second wheel when it wins
- Troll mode — probability-based surprise animations that play after the wheel stops
- Weighted slices — probabilistic spinning with per-slice weights and undo
- Per-slice customisation — label, colour, image (PNG/JPG), sound, winner message override
- Three image modes — Static, Rotating, Upright
- Confetti burst — particles in five shapes, rainbow or custom colour, or animated GIF
- Blackout mode — hide slice contents until a winner is revealed
- Four independent audio channels — spin-start, tick A, tick B, winner sounds never interrupt each other
- Master volume — app-wide volume slider in the header controls all channels simultaneously
- OBS-ready — chroma key background, hard-edge clip boundary, zero-focus-steal z-ordering
- Save / Load — JSON (portable settings) or self-contained ZIP (bundles all assets)
- Spin log — timestamped CSV-style log of every result
- 30 FPS cap — for lower-end machines
PerfectSpinner opens two windows side by side:
| Window | Title | Purpose |
|---|---|---|
| Settings | PerfectSpinner — Settings | Configure and control wheels |
| OBS Capture | PerfectSpinner — OBS Capture | Add as Window Capture source in OBS |
- Switching the active tab in Settings instantly changes what the capture window shows.
- Clicking either window brings both to the front without stealing keyboard focus.
- Closing either window closes both and exits the application.
| Tool | Minimum version |
|---|---|
| .NET SDK | 9.0 |
| Visual Studio 2022 (optional) | 17.8+ |
| Avalonia VS extension (optional) | For AXAML previewer |
# Clone and run
git clone https://github.com/murkantor/GoldenSpinner.git
cd GoldenSpinner
dotnet rundotnet build -o C:/Temp/gs-build-checkAll settings below are per-wheel and saved with the layout.
| Setting | Range | Default | Description |
|---|---|---|---|
| SPIN! | — | — | Starts the wheel (disabled while already spinning) |
| ↺ Reset | — | — | Stops the wheel, clears the winner, returns to 0° |
| Speed | 1.0 – 30.0 s | 4.0 s | Controls how long the overall spin cycle runs |
| Friction | 1 – 10 | 5 | How quickly the wheel slows down in free-spin (higher = stops faster) |
| Randomise Start | — | — | Jumps the wheel to a random angle before spinning |
| Randomise Order | — | — | Shuffles the slice list (Fisher-Yates) |
Spin All Wheels — the button above the editor panel fires every wheel tab's spin simultaneously.
The 🔊 volume slider in the top-right of the header controls the playback level for all four audio channels at once (0–100%). Adjusting it takes effect immediately, including on any tick sounds currently playing.
Click and drag anywhere inside the OBS capture window to rotate the wheel by hand. When you release, the wheel coasts to a stop using the same friction physics as a normal spin. The minimum release speed to trigger a spin is 30°/s.
| Setting | Options | Default | Description |
|---|---|---|---|
| Slice Image Mode | Static / Rotating / Upright | Static | How per-slice images are rendered |
| Show Labels | On / Off | On | Show text inside slices |
| Show Pointer Label | On / Off | Off | Show the current slice's name near the top pointer |
| Label Bold | On / Off | Off | Bold weight for label text |
| Font | Default, Arial, Courier New, Georgia, Impact, Tahoma, Times New Roman, Trebuchet MS, Verdana | Default | Label typeface |
| Font Size | 0 – 72 px | 0 | 0 = auto-scales with slice count; any other value is fixed |
| Label Colour | White text / Black text | White text | Applies to all labels; border is the inverse colour |
| Borders | White / Black | White | Outer ring and slice divider colour |
| Pointer Position | Top / Right | Top | Moves the pointer triangle and the win-detection point to the top or the right side of the wheel |
| Chroma Key Colour | Any hex colour | #00FF00 | SpinnerWindow background (set this in your OBS Chroma Key filter) |
| Blackout Wheel | See Blackout Wheel | Off | — |
Image Modes:
| Mode | Behaviour |
|---|---|
| Static | Image stays fixed at the wheel centre; the rotating wedge acts as a window over it |
| Rotating | Image rotates with its slice (anchored at the wheel centre) |
| Upright | Image orbits with its slice at 50% radius but never rotates — always right-side-up |
The right panel shows the slice list with a toolbar:
+— Add a new slice (auto-coloured from a 12-colour cycling palette)−— Remove the selected slice▲/▼— Move the selected slice up or down
Each slice in the list shows a checkbox (active/inactive toggle), a colour swatch, and its label.
| Property | Description |
|---|---|
| Label | Text displayed inside the slice on the wheel |
| Winner Label | If set, overrides the wheel's Winner Message Template when this slice wins; leave blank to use the template |
| Colour | Slice background colour (hex); click the swatch for a colour picker |
| Weight | Relative probability when weighted mode is on (0 = excluded; see Weights) |
| Image | PNG or JPG shown inside the slice; browse to assign, remove to clear |
| Sound | WAV or MP3 played when this slice wins; overrides the wheel-level default sound |
| Chain Trigger | Select another wheel from the dropdown; if this slice wins, that wheel automatically spins after a 1.5 s pause. Set to — None — to disable |
| Active | Untick to exclude this slice from the wheel entirely |
| Setting | Range | Default | Description |
|---|---|---|---|
| Use Weighted Slices | On / Off | Off | Enable probabilistic mode |
| Default Weight | 1 – 100 | 3 | Used when clicking Apply |
| Apply to All | — | — | Sets every slice to the default weight; snapshots old values |
| Undo Weight | — | — | Restores the snapshot (one level; cleared after a spin) |
When weighted mode is on:
- Each slice's arc =
(weight / totalWeight) × 360° - Slices with weight 0 are excluded for that spin (set inactive automatically at spin start)
- After each spin the winning slice's weight is decremented by 1 (min 0)
- Weights of exactly 0 still render at a minimum 1° arc so they remain visible
Troll Mode adds a chance for the wheel to perform a surprise animation after it stops — changing the result just before the winner is declared. Great for keeping live audiences on their toes.
| Setting | Range | Default | Description |
|---|---|---|---|
| Troll Mode | On / Off | Off | Enables the troll system for this wheel |
| Chance | 0 – 100 | 30 | Percentage probability (per spin) that a troll animation fires |
When a troll activates, one of nine effects is picked at random:
| Effect | What happens |
|---|---|
| Little Tick | One last hop — pointer moves back one slice |
| Second Thoughts | Wheel reverses slightly then creeps forward one slice |
| Second Wind | Sudden burst of energy — wheel accelerates for 2+ full extra rotations |
| Victory Lap | Dignified extra full rotation, lands on exactly the same result |
| The Shakes | Earthquake oscillation, then a dramatic snap to a different slice |
| Skip Ahead | Smooth skip forward two slices |
| Boomerang | Reverses partway then springs past the original result |
| Spin Doctors | Three rapid full rotations with a slow dramatic finish |
| Big Slice | A random slice inflates to half the wheel, holds, then Second Wind fires — repeats up to three times, stacking inflated slices, before all revert |
The final winner is always determined from wherever the wheel physically rests after all troll animations complete.
| Setting | Default | Description |
|---|---|---|
| Winner Message Template | 🎉 %t%! |
Shown in the banner; %t% is replaced with the winning slice's label |
| Default Sound | — | WAV/MP3 played on win; per-slice sounds take priority |
| Brighten Winner | Off | Adds a white overlay on the winning slice |
| Darken Losers | Off | Adds a dark overlay on all non-winning slices |
| Invert Loser Text | Off | Swaps label text/border colours on losing slices |
| Show Confetti | Off | Enables the confetti burst when a winner is decided |
| Setting | Options / Range | Default | Description |
|---|---|---|---|
| Custom Image | PNG / JPEG / GIF path | — | Use an image instead of coloured shapes; GIF will animate |
| Particle Count | 1 – 2000 | 120 | Number of particles in the burst |
| Shape | Mixed / Strips / Circles / Triangles / Stars | Mixed | Particle shape |
| Colour Mode | Rainbow / Custom | Rainbow | Rainbow cycles through 9 bright colours; Custom uses a single hex |
| Custom Colour | Hex colour | #FFD700 (gold) | Active only when Colour Mode = Custom |
How confetti works:
- Particles burst from the wheel centre on win, spreading to 65–98% of the wheel radius
- Size peaks at midpoint of lifetime (parabolic arc — simulates rising and falling)
- Particles range from 16–46 px base size and live 1.5–3.5 seconds
- GIF animation is supported on Windows; falls back to a static PNG on Linux/macOS
Four audio channels operate independently — none can interrupt another.
| Channel | Setting | Description |
|---|---|---|
| Spin Start | Spin Start Sound | Played once when a spin begins |
| Tick A | Tick Sound 1 | Played on odd slice-border crossings while spinning |
| Tick B | Tick Sound 2 | Played on even slice-border crossings while spinning |
| Winner | Default Sound / Per-slice Sound | Played when the wheel stops; per-slice sound takes priority |
- If only one tick sound is set, it plays on both channels.
- Tick sounds are pre-loaded into memory; each crossing just seeks to position 0 and replays (no file I/O per tick).
- Supports WAV and MP3 on all platforms.
Platform audio:
| Platform | Method |
|---|---|
| Windows | NAudio (WaveOutEvent + AudioFileReader) |
| macOS | afplay (built-in; supports WAV and MP3) |
| Linux | aplay (WAV) / mpg123 (MP3 — install separately) |
Hides slice content to build suspense during a live spin.
| Mode | Behaviour |
|---|---|
| Off | Normal rendering — all slices visible at all times |
| Reveal winner only | Entire wheel is blacked out during the spin. After stopping, only the winning slice is revealed; all others stay blacked out |
| Reveal all on win | Entire wheel is blacked out during the spin. After stopping, the blackout is removed entirely and all slices are visible |
- Blackout colour is always the inverse of the border colour (white borders → black fill; black borders → white fill).
- Slice labels on blacked-out slices are suppressed.
- The centre-pin dot inverts to match the blackout colour.
| Setting | Description |
|---|---|
| Save | Opens a save dialog; choose .json or .zip |
| Load | Opens a file dialog; supports .json and .zip |
| Log Spins | Appends every spin result to ./logs/spins.log |
| Cap frame rate to 30 FPS | Reduces the animation timer from ~60 fps to ~30 fps — useful for lower-end PCs |
See Save & Load Formats for full details.
You can create an unlimited number of independent wheels.
| Action | How |
|---|---|
| Add wheel | Click + in the tab bar |
| Switch wheel | Click a tab, or use ◀ / ▶ |
| Rename | Double-click the tab label, or right-click → Rename |
| Clone | Right-click → Clone (deep copy with (2), (3) suffix) |
| Delete | Right-click → Delete (requires confirmation; creates a blank wheel if last) |
| Spin All | Click Spin All Wheels above the editor — fires every wheel simultaneously |
The active tab controls what appears in the OBS capture window in real-time.
- Launch PerfectSpinner — both windows open side by side.
- In OBS, add a Window Capture source and select PerfectSpinner — OBS Capture.
- Add a Chroma Key filter to the source. Set the key colour to match Settings → Appearance → Chroma Key Colour (default:
#00FF00bright green). - Adjust Similarity and Smoothness as needed.
- Resize / crop the source in OBS to fit your scene layout.
- The red pointer triangle always sits at 12 o'clock. The wheel spins clockwise.
Tip: The outer wheel boundary is intentionally pixel-hard (aliased) for a clean chroma key edge. Interior elements are antialiased.
Each spin runs through five phases:
| Phase | Duration | Behaviour |
|---|---|---|
| Wind-up | 0–5% | Brief backwards motion (anticipation) |
| Acceleration | 5–10% | Ease-in forward, quadratic curve |
| Cruise | 10–80% + 1–5 s random | Constant peak velocity — adds unpredictability |
| Engine off | 80–100% | Linear drop to half speed |
| Free-spin | Until stopped | Exponential decay; Friction slider controls the rate |
- Peak velocity =
SpinDuration × 180 °/s - Friction rate =
0.20 + (Friction − 1) × 0.28— higher friction → faster stop - Stops when
|velocity| < 0.5 °/s - Winner is read from the physical rotation angle when the wheel stops — it is never pre-determined
Stores all settings as a JSON document. Asset paths (images, sounds) are stored as absolute paths on disk. The file is portable only if the referenced assets are also accessible from the loading machine.
A self-contained archive containing:
layout.json ← all wheel settings
img/ ← all image assets
snd/ ← all audio assets
On load the ZIP is extracted to %TEMP%\PerfectSpinner\<guid>\. Asset paths in layout.json are relative within the archive. This format is fully portable — everything travels in one file.
{
"name": "My Wheel",
"spinDurationSeconds": 4.0,
"friction": 5,
"useWeightedSlices": false,
"globalWeight": 3.0,
"showLabels": true,
"showPointerLabel": false,
"labelFontIndex": 0,
"labelFontSize": 0,
"labelColorStyle": 0,
"labelBold": false,
"sliceImageMode": 0,
"chromaKeyColor": "#00FF00",
"borderColorStyle": 0,
"blackoutWheelMode": 0,
"brightenWinner": false,
"darkenLosers": false,
"invertLoserText": false,
"winnerMessageTemplate": "🎉 %t%!",
"defaultSoundPath": null,
"spinStartSoundPath": null,
"tickSound1Path": null,
"tickSound2Path": null,
"showConfetti": false,
"confettiImagePath": null,
"confettiCount": 120,
"confettiShapeMode": 0,
"confettiColorMode": 0,
"confettiCustomColor": "#FFD700",
"logSpins": true,
"capTo30Fps": false,
"trollMode": false,
"trollChance": 30,
"slices": [
{
"id": "00000000-...",
"label": "Grand Prize",
"colorHex": "#E74C3C",
"weight": 1.0,
"isActive": true,
"imagePath": null,
"soundPath": null,
"winnerLabel": null,
"triggerWheelId": null
}
]
}When Log Spins is enabled, every result is appended to ./logs/spins.log:
=== Session 2026-03-14 15:42:00 ===
2026-03-14 | 15:42:07 | 4.0 | 5 | 3.7 | Grand Prize
2026-03-14 | 15:43:11 | 6.0 | 3 | 2.1 | Prize 2
Columns: Date | Time | Speed (s) | Friction | Cruise duration (s) | Winner label
dotnet publish -c Release -r win-x64 --self-contained true \
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true \
-o ./publish/win-x64Output: publish/win-x64/PerfectSpinner.exe
dotnet publish -c Release -r linux-x64 --self-contained true \
-p:PublishSingleFile=true -o ./publish/linux-x64
chmod +x ./publish/linux-x64/PerfectSpinnerLinux audio requirements:
- WAV:
aplay(sudo apt install alsa-utils) - MP3:
mpg123(sudo apt install mpg123)
# Apple Silicon
dotnet publish -c Release -r osx-arm64 --self-contained true \
-p:PublishSingleFile=true -o ./publish/osx-arm64
# Intel
dotnet publish -c Release -r osx-x64 --self-contained true \
-p:PublishSingleFile=true -o ./publish/osx-x64macOS uses afplay (built-in) for both WAV and MP3.
Note: GIF-animated confetti requires
System.Drawing.Commonwhich is Windows-only. On Linux/macOS the GIF silently falls back to a static image.
PerfectSpinner/
├── Models/
│ ├── WheelSlice.cs – Serialisable data model for one slice
│ └── WheelLayout.cs – Root save/load container
├── ViewModels/
│ ├── ViewModelBase.cs – ObservableObject base (CommunityToolkit)
│ ├── WheelSliceViewModel.cs – Observable wrapper + cached colour + loaded bitmap
│ ├── WheelViewModel.cs – Per-wheel state, commands, spin animation, physics
│ ├── WheelChoiceItem.cs – Display record for chain-trigger wheel selector
│ └── MainWindowViewModel.cs – Wheel collection, tabs, Spin All, chain routing
├── Views/
│ ├── MainWindow.axaml – Settings window (tab bar + editor panel)
│ ├── MainWindow.axaml.cs – Code-behind; service wiring, tab interactions
│ ├── SpinnerWindow.axaml – OBS capture window
│ ├── SpinnerWindow.axaml.cs – Drag-to-spin input handling
│ ├── WheelEditorPanel.axaml – Full per-wheel settings UI
│ └── ConfirmDialog.axaml – Modal yes/no confirmation dialog
├── Controls/
│ └── PerfectSpinnerControl.cs – Custom Control; full wheel renderer (DrawingContext)
├── Services/
│ ├── IFilePickerService.cs – File-picker abstraction
│ ├── WindowFilePickerService.cs – Avalonia StorageProvider implementation
│ ├── LayoutService.cs – JSON + ZIP save/load
│ ├── AudioService.cs – 4-channel NAudio playback (Windows) + shell fallback
│ └── LogService.cs – Spin result logging
├── Converters/
│ ├── HexColorToBrushConverter.cs – "#RRGGBB" → SolidColorBrush (one-way)
│ └── ColorToHexConverter.cs – Color ↔ "#RRGGBB" (two-way, for ColorView)
└── Assets/
└── avalonia-logo.ico
Stack: .NET 9 · Avalonia 11.3.11 · CommunityToolkit.Mvvm 8.2.1 · NAudio 2.2.1