Skip to content

Ryu0118/SlotKit

Repository files navigation

🎰 SlotKit

Kill the boring Checking… βœ“. A Swift library that turns your CLI's checks into a clattering ASCII slot machine β€” arcade lights and all.

One check = one reel. While the work runs the reel spins, and the instant it resolves it slams to a stop on 7 (pass) or X (fail). Line up all 7s and…

╔══════════╗╔══════════╗╔══════════╗╔══════════╗
β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β•‘β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β•‘β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β•‘β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β•‘
β•‘ β–€β–€β–€β–€β–ˆβ–ˆ   β•‘β•‘ β–€β–€β–€β–€β–ˆβ–ˆ   β•‘β•‘ β–€β–€β–€β–€β–ˆβ–ˆ   β•‘β•‘ β–€β–€β–€β–€β–ˆβ–ˆ   β•‘
β•‘    β–ˆβ–ˆ    β•‘β•‘    β–ˆβ–ˆ    β•‘β•‘    β–ˆβ–ˆ    β•‘β•‘    β–ˆβ–ˆ    β•‘
β•‘   β–ˆβ–ˆ     β•‘β•‘   β–ˆβ–ˆ     β•‘β•‘   β–ˆβ–ˆ     β•‘β•‘   β–ˆβ–ˆ     β•‘
β•‘   β–ˆβ–ˆ     β•‘β•‘   β–ˆβ–ˆ     β•‘β•‘   β–ˆβ–ˆ     β•‘β•‘   β–ˆβ–ˆ     β•‘
β•šβ•β•β•β•β•β•β•β•β•β•β•β•šβ•β•β•β•β•β•β•β•β•β•β•β•šβ•β•β•β•β•β•β•β•β•β•β•β•šβ•β•β•β•β•β•β•β•β•β•β•
   BUILD        TEST        LINT       DEPLOY   

…all four 7s line up and the whole grid flashes β€” jackpot.

🌈 A rainbow gradient scrolls Β· ✨ the winning grid flashes on a jackpot Β· πŸ”₯ every reel spins in parallel. Dopamine, delivered.


Why it feels good

  • 🎑 Spinning = visible progress. Far more "alive" than a single spinner.
  • ⚑️ Every check is genuinely parallel. Heavy network validation keeps the reels turning while you wait.
  • πŸŽ‰ All-pass flashes the winning grid. Make the success land.
  • πŸ€– Pipes and CI stay quiet. Zero decoration, byte-stable output β€” it never misbehaves in production.
  • πŸͺΆ Zero dependencies. No logging or color library needed. SlotKit stands alone.

Install

// Package.swift
.package(url: "https://github.com/Ryu0118/SlotKit", from: "0.2.0"),
.target(name: "YourApp", dependencies: ["SlotKit"]),

Spin it in 30 seconds

import SlotKit

// A check = a reel. Each closure runs concurrently in the background.
// 🎰 Spin! Everything runs in parallel and stops as each check resolves.
let result = await SlotMachine.spin {
    SlotReel(label: "BUILD")  { compile() }                 // sync check
    SlotReel(label: "TEST")   { try await runTests() }      // long-running
    SlotReel(label: "LINT")   { try await lint() }
    if isMainBranch {
        SlotReel(label: "DEPLOY") { try await deploy() }    // only on main
    }
}

for outcome in result.outcomes {
    print("\(outcome.label ?? "reel"): \(outcome.passed ? "βœ…" : "❌")")
}
if result.allPassed { print("πŸŽ‰ All reels passed!") }

A work closure returning true locks the reel on 7 (win); false or a thrown error lands on X (lose). spin returns a SlotResult β€” just read outcomes (each reel's result) and allPassed (did every reel win).

Labels are optional. Drop them β€” SlotReel { spin() } β€” and when every reel is unlabeled the caption row disappears, leaving a plain slot machine (handy for a spin-for-its-own-sake CLI rather than a named check runner).


Going quiet (wiring up --silent)

spin auto-detects the terminal: it animates on a TTY, and falls back to a plain result line on pipes, CI, or NO_COLOR. You can also force it β€” this is how you wire it to a host --silent flag πŸ‘‡

// `spin` also takes a plain `[SlotReel]` array if you'd rather build it yourself.
let reels: [SlotReel] = [
    SlotReel(label: "BUILD") { compile() },
    SlotReel(label: "TEST")  { try await runTests() },
]

await SlotMachine.spin(reels, plain: isSilent)  // true β†’ no animation, result only
await SlotMachine.spin(reels, plain: nil)       // nil (default) β†’ auto-detect the terminal

When plain is in effect nothing is drawn. The checks still run and a result is still returned, so the caller can print its own plain lines. Output stays byte-stable β€” safe for tests and CI.


Make it yours 🎨

Look and timing all live in SlotTheme. Building one through make validates the symbol dimensions up front β€” if any symbol is off by even one cell from cellWidth Γ— cellHeight, it throws SlotThemeError instead of silently clipping at render time. No surprises.

let theme = try SlotTheme.make { draft in
    draft.cellWidth  = 7
    draft.cellHeight = 3
    draft.win  = SlotSymbol(rows: ["  β•±    ", " β•±     ", "β•²β•±     "])  // the win face
    draft.lose = SlotSymbol(rows: ["       ", "═══════", "       "])  // the lose face
    draft.spinning = [                                                // the spinning faces
        SlotSymbol(rows: ["  β–„β–„β–„  ", " β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ ", "  β–€β–€β–€  "]),
        SlotSymbol(rows: ["  β—‡β—‡β—‡  ", " β—‡β—‡β—‡β—‡β—‡ ", "  β—‡β—‡β—‡  "]),
    ]
    draft.colorize      = SlotColorizers.rainbow   // .rainbow / .plain / your own
    draft.frameInterval = 0.09                     // seconds per frame (speed)
    draft.minSpin       = 1.0                      // minimum spin time (avoid finishing too fast)
    draft.finale        = SlotTheme.SlotFinale(frames: 8, interval: 0.12)  // all-win grid flash
    draft.bust          = SlotTheme.SlotFinale(frames: 6, interval: 0.18)  // orange↔red loss flash
    draft.scrollSpin    = true                     // grid: reels scroll vertically (real reel feel)
}

await SlotMachine.spin(theme: theme) {
    SlotReel(label: "BUILD") { compile() }
    SlotReel(label: "TEST")  { try await runTests() }
}

The knobs:

Knob What it changes
cellWidth / cellHeight Reel window size (all symbols must share these dimensions)
spinning The faces that flicker by while a reel spins
win / lose The faces a reel locks on when it stops (pass/fail spin)
symbols / jackpotIndex The faces reels land on, and which is the jackpot (symbol spinSymbols)
colorize Coloring (line, phase) -> String. .rainbow / .plain / your own
frameInterval Spin speed (interval between frames)
minSpin Minimum spin time (so a reel doesn't finish in a dull instant)
finale The all-win flash: blink the winning grid (flash count, interval); nil = no flash
bust The loss flash: the final grid pulses orange ↔ red; nil = no loss animation
scrollSpin Grid path only: in-flight reels scroll their faces vertically (a real reel sliding past the window) instead of swapping the whole face each frame. Default false
spinningStrips Grid path only: one spinning strip per column, so each reel scrolls a different sequence β€” how a real machine weights a symbol differently on each reel. Column i uses spinningStrips[i % count]. Empty (default) shares spinning across every column

A custom colorize receives one laid-out line plus the animation phase. Don't change the display width β€” doing so misaligns the reel grid. Color only.

With nothing specified you get SlotTheme.default (the 10Γ—5 arcade faces, rainbow gradient, 90 ms cadence, 1 s spin, and the all-win grid flash) β€” exactly what you saw above.

Want the arcade look but a tweak or two? Derive from any theme with with β€” it inherits every field you don't touch and re-validates the result, so a change that breaks the symbol dimensions throws SlotThemeError instead of misaligning.

// Same default look, just faster.
let snappy = try SlotTheme.default.with { draft in
    draft.minSpin       = 0
    draft.frameInterval = 0.02
}

Real slot machine: matching symbols πŸ’

The spin above is pass/fail β€” every reel lands on win or lose. For an actual slot machine, where each reel stops on one of several faces and you win by lining them up, reach for spinSymbols.

Give the theme a set of symbols to land on (and which index is the jackpot), then return a landed index from each reel:

let theme = try SlotTheme.default.with { draft in
    draft.symbols      = [seven, cherry, bar, bell]   // the faces reels can stop on
    draft.jackpotIndex = 0                             // index 0 (seven) is the top line
}

let result = await SlotMachine.spinSymbols(theme: theme) {
    SymbolReel(label: "1") { draw() }   // each returns an index into `theme.symbols`
    SymbolReel(label: "2") { draw() }
    SymbolReel(label: "3") { draw() }
}

if result.isJackpot {       // every reel on `jackpotIndex` β†’ 777
    print("🎰 JACKPOT!")
} else if result.allSame {   // every reel on the same (non-jackpot) symbol β†’ a win line
    print("πŸ’ Winner!")
} else {
    print("…spin again.")    // mixed line β†’ no win
}

A SymbolReel's closure returns an Int β€” the index into theme.symbols the reel lands on. SlotKit only animates the reveal; you supply the draw, so the odds (how rare a 777 is) live entirely in your code. spinSymbols returns a SymbolSpinResult: read outcomes (each reel's landedIndex), allSame (every reel matched), and isJackpot (every reel on jackpotIndex).

Like spin, spinSymbols takes a plain [SymbolReel] array and a plain: flag, and stays byte-stable on pipes / CI. The pass/fail spin is untouched β€” both live side by side.


A whole grid: rows, columns, and paylines 🎰

spinSymbols is one row. For a real machine β€” an R Γ— C grid that pays along rows and diagonals β€” reach for spinGrid. A GridReel is a column: it stops as a unit, drawing one symbol per row (top to bottom). You declare the paylines a win is judged against; .allLines(forSquare:) gives you every row plus both diagonals of a square grid.

let result = await SlotMachine.spinGrid(
    rows: 3,
    paylines: .allLines(forSquare: 3),   // 3 rows + 2 diagonals = 5 lines
    theme: sevenTheme
) {
    GridReel(label: "β‘ ") { await draw3() }   // each draw β†’ 3 indices, top to bottom
    GridReel(label: "β‘‘") { await draw3() }
    GridReel(label: "β‘’") { await draw3() }
}

if result.isJackpot   { print("🎰 JACKPOT on \(result.winningLines.map(\.id))") }
else if result.didWin { print("πŸŽ‰ \(result.winningLines.count) line(s)!") }

spinGrid returns a GridSpinResult: landed (the [row][col] symbols), winningLines (the paylines that paid), didWin, and isJackpot. A win flashes the grid; a winning row is highlighted on its own. Columns stop left to right, so the caller can gate them on keypresses for a real "stop the reel" feel.

A single row is just the rows: 1 case β€” same API:

await SlotMachine.spinGrid(rows: 1, paylines: [.row(0)]) {
    for face in drawn { GridReel { [face] } }
}

Skill stop: land on what's showing 🎯

spinGrid lands each column on a symbol you decide up front. For a skill stop β€” a real machine where you stop the reel and it lands on whatever's spinning by at that instant β€” use spinGridSkill. A SkillReel carries only a stop signal: its stop returns when the player (or a timer) chooses, and the column lands on the face showing at that frame.

let result = await SlotMachine.spinGridSkill(
    rows: 3,
    paylines: .allLines(forSquare: 3),
    theme: theme
) {
    SkillReel(label: "β‘ ") { await gate.awaitTurn(0) }   // a keypress releases the gate
    SkillReel(label: "β‘‘") { await gate.awaitTurn(1) }
    SkillReel(label: "β‘’") { await gate.awaitTurn(2) }
}

The odds of catching a face live in the theme's spinning pool β€” make a face rare in the pool and it's genuinely hard to stop on, the way a real machine does. spinGridSkill returns the same GridSpinResult as spinGrid, so wins, jackpots, and highlighting all work the same. (The predetermined-draw spinGrid is untouched β€” use it for an auto spin where a fixed interval shouldn't decide the outcome.)

Pass a finaleHold closure to keep a win flashing until it returns (e.g. await a keypress) β€” the blink holds instead of running a fixed length; a loss holds its settled board. With no finaleHold the finale is the theme's fixed length.


See it move first πŸ‘€

swift run slotkit-demo            # 🎰 all win β†’ the winning grid flashes
swift run slotkit-demo --fail     # πŸ’₯ one reel loses β†’ orange↔red bust flash, no jackpot
swift run slotkit-demo --custom   # πŸ’° a money-slot theme: gold neon + all-win flash
swift run slotkit-demo --bare     # 🎰 unlabeled reels β€” no caption row, just the slot
swift run slotkit-demo | cat      # 🀐 piped = plain result only

A picture's worth a thousand words. Run swift run slotkit-demo in your terminal and watch the reels spin.


License

SlotKit is released under the MIT License. See LICENSE for details.

About

🎰 A customizable terminal slot machine for parallel async checks

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors