-- Roulette Machine — circular wheel, top-down view -- Tom's Peripherals GPU + screen wall (any size). -- -- Authentic European pocket order (37 pockets, 0–36). -- -- Physics: -- Rotor : spins CW, decelerates under friction (heavy wheel, slow). -- Ball : orbits outer track CCW at higher speed, decelerates faster. -- When angular speed drops below DROP_SPEED the ball loses -- centripetal support, gains inward radial velocity, and a -- small random deflector-pin kick is applied. -- Ball decelerates in the pocket ring until stopped. -- Result : nearest pocket by angle (ball vs rotor) at rest. -- -- Rendering strategy: -- The full wheel (37 wedges) is expensive to rasterise, so it is only -- redrawn when the rotor has rotated more than ROTOR_REDRAW_THRESH rad -- since the last draw. Between redraws only the ball is erased/repainted -- over the static background — keeping the frame rate smooth. ---------------------------------------------------------------------- -- GPU discovery ---------------------------------------------------------------------- local function findGPU() print("[roulette] Scanning peripherals...") for _, name in ipairs(peripheral.getNames()) do local t = peripheral.getType(name) print(" " .. name .. " = " .. tostring(t)) if t and t:find("gpu") then print("[roulette] Using GPU: " .. name) return peripheral.wrap(name) end end return nil end ---------------------------------------------------------------------- -- Constants ---------------------------------------------------------------------- local FRAME_DELAY = 0.05 -- ~20 fps (keeps CC happy) local TWO_PI = math.pi * 2 -- Rotor is only redrawn when it has moved this many radians since last draw. -- At R_OUTER ~200px, 0.02 rad ≈ 4px of arc — imperceptible until it accumulates. local ROTOR_REDRAW_THRESH = 0.025 -- rad -- European wheel order local WHEEL_ORDER = { 0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5, 24, 16, 33, 1, 20, 14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26 } local NUM_POCKETS = #WHEEL_ORDER -- 37 local RED_SET = {} for _, n in ipairs({1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36}) do RED_SET[n] = true end -- Geometry (computed in start()) local CX, CY local R_OUTER, R_POCKET_OUT, R_POCKET_IN, R_HUB -- Colours local COL_BG = 0x050505 local COL_RIM = 0x8B6914 local COL_TRACK = 0x1A1A1A local COL_RED = 0xC62828 local COL_BLACK = 0x1C1C1C local COL_GREEN = 0x1B5E20 local COL_SEP = 0xB8860B local COL_HUB = 0x2C2C2C local COL_HUB_RING = 0x8B6914 local COL_WHITE = 0xFFFFFF local COL_BALL = 0xF0F0F0 local COL_BALL_SHD = 0x444444 -- Physics tunables local ROTOR_SPEED_MIN = 1.2 -- rad/s local ROTOR_SPEED_MAX = 2.0 local ROTOR_FRICTION = 0.06 -- rad/s² local BALL_SPEED_MIN = 7.0 -- rad/s (CCW → negative) local BALL_SPEED_MAX = 11.0 local TRACK_FRICTION = 0.38 -- rad/s² -- Radial bounce: ball oscillates between the outer wall and an inner -- wall (the pocket-ring outer edge) while on the track. local BALL_VR_INIT = 55.0 -- px/s initial inward radial speed local WALL_RESTITUTION = 0.55 -- fraction of radial speed kept on bounce -- The "pyramid tip" deflector sits at this fraction of the track width -- inward from the outer wall. Ball can bounce off it before dropping. local DEFLECTOR_FRAC = 0.62 -- 0 = outer wall, 1 = pocket-ring edge local DROP_SPEED = 1.4 -- rad/s — ball angular speed at which it finally -- drops into the pocket ring local DEFLECT_MAX = 0.22 -- rad — max random angular kick on final drop local POCKET_FRICTION = 1.4 -- rad/s² — higher friction in pocket ring local BALL_RADIUS = 8 -- px ---------------------------------------------------------------------- -- GPU / pixel primitives ---------------------------------------------------------------------- local gpu local PW, PH local function px_rect(x, y, w, h, col) x = math.floor(x); y = math.floor(y) w = math.floor(w); h = math.floor(h) if x < 1 then w = w + x - 1; x = 1 end if y < 1 then h = h + y - 1; y = 1 end if x + w - 1 > PW then w = PW - x + 1 end if y + h - 1 > PH then h = PH - y + 1 end if w < 1 or h < 1 then return end gpu.filledRectangle(x, y, w, h, col) end local function px_circle(cx, cy, r, col) cx = math.floor(cx); cy = math.floor(cy); r = math.floor(r) for dy = -r, r do local half = math.floor(math.sqrt(r*r - dy*dy) + 0.5) px_rect(cx - half, cy + dy, half*2 + 1, 1, col) end end local function px_annulus(cx, cy, r1, r2, col) cx = math.floor(cx); cy = math.floor(cy) r1 = math.floor(r1); r2 = math.floor(r2) for dy = -r2, r2 do local ho = math.floor(math.sqrt(math.max(0, r2*r2 - dy*dy)) + 0.5) local hi = math.floor(math.sqrt(math.max(0, r1*r1 - dy*dy)) + 0.5) if ho > hi then px_rect(cx - ho, cy + dy, ho - hi, 1, col) px_rect(cx + hi, cy + dy, ho - hi + 1, 1, col) elseif hi == 0 then px_rect(cx - ho, cy + dy, ho*2 + 1, 1, col) end end end local function px_spoke(cx, cy, r1, r2, angle, col) local ca, sa = math.cos(angle), math.sin(angle) for i = 0, r2 - r1 do local r = r1 + i px_rect(math.floor(cx + ca*r + 0.5), math.floor(cy + sa*r + 0.5), 1, 1, col) end end local function px_text(str, x, y, fg, bg, size) pcall(gpu.drawText, math.floor(x), math.floor(y), str, fg, bg, size or 1, 0) end ---------------------------------------------------------------------- -- Wedge rasteriser (used at startup and for glow flashes only) ---------------------------------------------------------------------- local function pocketColor(num) if num == 0 then return COL_GREEN end if RED_SET[num] then return COL_RED end return COL_BLACK end local function drawWedge(slotIdx, rotorAngle, glowing) local halfArc = math.pi / NUM_POCKETS local midAngle = rotorAngle + (slotIdx - 1) * TWO_PI / NUM_POCKETS local a0 = midAngle - halfArc local a1 = midAngle + halfArc local num = WHEEL_ORDER[slotIdx] local col = pocketColor(num) if glowing then local r = math.min(255, math.floor(col / 0x10000) + 60) local g = math.min(255, math.floor((col % 0x10000) / 0x100) + 60) local b = math.min(255, col % 0x100 + 60) col = r * 0x10000 + g * 0x100 + b end local ri, ro = R_POCKET_IN, R_POCKET_OUT local bx0 = math.floor(CX - ro) - 1 local bx1 = math.ceil (CX + ro) + 1 local by0 = math.floor(CY - ro) - 1 local by1 = math.ceil (CY + ro) + 1 local arc = (a1 - a0) % TWO_PI for sy = by0, by1 do local runStart = nil for sx = bx0, bx1 do local dx = sx - CX local dy = sy - CY local dist = math.sqrt(dx*dx + dy*dy) local inRing = dist >= ri and dist <= ro local rel = (math.atan2(dy, dx) - a0) % TWO_PI local inWedge = rel <= arc if inRing and inWedge then if not runStart then runStart = sx end else if runStart then px_rect(runStart, sy, sx - runStart, 1, col) runStart = nil end end end if runStart then px_rect(runStart, sy, bx1 - runStart + 1, 1, col) end end px_spoke(CX, CY, ri, ro, a0, COL_SEP) local labelR = (ri + ro) / 2 local lx = CX + math.cos(midAngle) * labelR local ly = CY + math.sin(midAngle) * labelR local label = tostring(num) px_text(label, lx - (#label * 4), ly - 4, COL_WHITE, col, 1) end -- Draw ALL wedges then overlay static chrome. Yields between wedges -- so CC doesn't timeout; only called when the wheel needs a full repaint. local function drawAllWedges(rotorAngle, glowSlot) for i = 1, NUM_POCKETS do drawWedge(i, rotorAngle, i == glowSlot) sleep(0) -- yield once per wedge (37 yields, not thousands) end end local function drawChrome() -- outer gold rim px_annulus(CX, CY, R_OUTER - 6, R_OUTER, COL_RIM) -- ball track channel px_annulus(CX, CY, R_POCKET_OUT + 2, R_OUTER - 6, COL_TRACK) -- inner/outer pocket borders px_annulus(CX, CY, R_POCKET_OUT, R_POCKET_OUT + 2, COL_RIM) px_annulus(CX, CY, R_POCKET_IN - 2, R_POCKET_IN, COL_RIM) -- hub px_circle(CX, CY, R_HUB, COL_HUB) px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING) px_circle(CX, CY, 6, COL_HUB_RING) px_circle(CX, CY, 3, COL_HUB) end local function drawWheelFull(rotorAngle, glowSlot) px_circle(CX, CY, R_OUTER, COL_BG) drawAllWedges(rotorAngle, glowSlot) drawChrome() end ---------------------------------------------------------------------- -- Ball helpers ---------------------------------------------------------------------- local ballX, ballY = 0, 0 local function bgColorAt(r) -- What colour is behind the ball at radius r? if r > R_POCKET_OUT + 2 then return COL_TRACK end if r > R_POCKET_IN - 2 then return COL_BLACK end -- approximate — wedge redraws handle exact colour return COL_HUB end local function eraseBall(bx, by, r) -- Repaint the annulus region the ball touched. -- Use COL_TRACK for track zone, COL_BLACK for pocket zone (close enough between full redraws). local dist = math.sqrt((bx - CX)^2 + (by - CY)^2) px_circle(math.floor(bx), math.floor(by), r + 2, bgColorAt(dist)) end local function drawBall(bx, by) ballX = math.floor(bx) ballY = math.floor(by) px_circle(ballX + 2, ballY + 2, BALL_RADIUS, COL_BALL_SHD) px_circle(ballX, ballY, BALL_RADIUS, COL_BALL) px_circle(ballX - 2, ballY - 2, 2, COL_WHITE) end local function ballPosAt(radius, angle) return CX + math.cos(angle) * radius, CY + math.sin(angle) * radius end ---------------------------------------------------------------------- -- Center text overlay ---------------------------------------------------------------------- local function drawCenterText(lines, textSize) textSize = textSize or 2 local r = R_HUB - 8 px_circle(CX, CY, r, COL_HUB) local lineH = 13 * textSize local totalH = #lines * lineH local startY = CY - math.floor(totalH / 2) for i, line in ipairs(lines) do local lx = CX - math.floor(#line * 6 * textSize / 2) px_text(line, lx, startY + (i-1) * lineH, COL_WHITE, COL_HUB, textSize) end px_annulus(CX, CY, R_HUB - 4, R_HUB, COL_HUB_RING) gpu.sync() end ---------------------------------------------------------------------- -- Physics spin -- -- Phases: -- TRACK : ball on outer track, both rotor+ball decelerating. -- DROP : ball's centripetal support gone; gains inward radial velocity -- + small random deflector-pin kick. -- POCKET : ball in pocket ring, decelerates to rest. -- -- The wheel is only fully redrawn when the rotor has moved -- ROTOR_REDRAW_THRESH radians. Between redraws only the ball moves. ---------------------------------------------------------------------- local rotorAngle = 0 local rotorSpeed = 0 local lastDrawnRotor = 0 -- rotorAngle at last full wheel redraw local function spin() local dt = FRAME_DELAY rotorSpeed = ROTOR_SPEED_MIN + math.random() * (ROTOR_SPEED_MAX - ROTOR_SPEED_MIN) local ballSpeed = -(BALL_SPEED_MIN + math.random() * (BALL_SPEED_MAX - BALL_SPEED_MIN)) local ballAngle = math.random() * TWO_PI -- Track radii local R_WALL_OUT = R_OUTER - 6 -- inner face of outer gold rim local R_WALL_IN = R_POCKET_OUT + 2 -- outer face of pocket ring (inner track wall) local R_DEFLECTOR = R_WALL_OUT - (R_WALL_OUT - R_WALL_IN) * DEFLECTOR_FRAC local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2 -- Ball starts pressed against the outer wall with a small inward nudge. local ballR = R_WALL_OUT - BALL_RADIUS local ballVr = BALL_VR_INIT -- positive = moving inward local phase = "TRACK" -- "TRACK" | "POCKET" -- Initial full draw drawWheelFull(rotorAngle, nil) lastDrawnRotor = rotorAngle local bx0, by0 = ballPosAt(ballR, ballAngle) drawBall(bx0, by0) gpu.sync() while true do -- ── Rotor ────────────────────────────────────────────────── if rotorSpeed > 0 then rotorSpeed = math.max(0, rotorSpeed - ROTOR_FRICTION * dt) end rotorAngle = (rotorAngle + rotorSpeed * dt) % TWO_PI -- ── Ball angular motion ───────────────────────────────────── local angFriction = (phase == "POCKET") and POCKET_FRICTION or TRACK_FRICTION if ballSpeed < 0 then ballSpeed = math.min(0, ballSpeed + angFriction * dt) else ballSpeed = math.max(0, ballSpeed - angFriction * dt) end ballAngle = (ballAngle + ballSpeed * dt) % TWO_PI -- ── Radial motion (bounce in track channel) ───────────────── if phase == "TRACK" then ballR = ballR + ballVr * dt -- Bounce off outer wall if ballR <= R_WALL_OUT - BALL_RADIUS then ballR = R_WALL_OUT - BALL_RADIUS ballVr = math.abs(ballVr) * WALL_RESTITUTION end -- Bounce off deflector tip (inner pyramid tip) — only while fast enough local angSpd = math.abs(ballSpeed) if ballR >= R_DEFLECTOR and angSpd > DROP_SPEED then ballR = R_DEFLECTOR ballVr = -math.abs(ballVr) * WALL_RESTITUTION -- Small random angular kick from the deflector tip local kick = (math.random() * 2 - 1) * (DEFLECT_MAX * 0.4) ballAngle = (ballAngle + kick) % TWO_PI end -- Once angular speed is slow enough the ball can no longer -- hold centripetal orbit — it falls past the deflector tip -- into the pocket ring. if angSpd <= DROP_SPEED and ballR >= R_DEFLECTOR then phase = "POCKET" ballVr = math.abs(ballVr) + 30 -- extra inward push -- Final random deflector kick local kick = (math.random() * 2 - 1) * DEFLECT_MAX ballAngle = (ballAngle + kick) % TWO_PI ballSpeed = ballSpeed * 0.55 end else -- POCKET phase: slide inward to R_SETTLE, then stop. ballR = ballR + ballVr * dt ballVr = ballVr * (1 - 5 * dt) if ballR >= R_SETTLE then ballR = R_SETTLE ballVr = 0 end end -- ── Redraw wheel if rotor has moved enough ────────────────── local rotorDelta = math.abs(rotorAngle - lastDrawnRotor) if rotorDelta > math.pi then rotorDelta = TWO_PI - rotorDelta end if rotorDelta >= ROTOR_REDRAW_THRESH then eraseBall(ballX, ballY, BALL_RADIUS) drawAllWedges(rotorAngle, nil) drawChrome() lastDrawnRotor = rotorAngle end -- ── Ball render ───────────────────────────────────────────── eraseBall(ballX, ballY, BALL_RADIUS) local bx, by = ballPosAt(ballR, ballAngle) drawBall(bx, by) gpu.sync() sleep(dt) -- ── Stop condition ────────────────────────────────────────── if phase == "POCKET" and ballSpeed == 0 and ballVr == 0 then break end end -- Determine winning pocket local relAngle = (ballAngle - rotorAngle) % TWO_PI local bestSlot, bestDist = 1, math.huge for i = 1, NUM_POCKETS do local sa = ((i - 1) * TWO_PI / NUM_POCKETS) % TWO_PI local diff = math.abs(sa - relAngle) if diff > math.pi then diff = TWO_PI - diff end if diff < bestDist then bestDist = diff; bestSlot = i end end -- Snap ball to pocket centre local snapAngle = rotorAngle + (bestSlot - 1) * TWO_PI / NUM_POCKETS local sx, sy = ballPosAt(R_SETTLE, snapAngle) eraseBall(ballX, ballY, BALL_RADIUS) drawBall(sx, sy) gpu.sync() return WHEEL_ORDER[bestSlot], bestSlot end ---------------------------------------------------------------------- -- Glow animation ---------------------------------------------------------------------- local function glowAnimation(slotIdx) local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2 local sa = rotorAngle + (slotIdx - 1) * TWO_PI / NUM_POCKETS local bx, by = ballPosAt(R_SETTLE, sa) for flash = 1, 6 do drawWedge(slotIdx, rotorAngle, flash % 2 == 1) drawBall(bx, by) gpu.sync() sleep(0.18) end drawWedge(slotIdx, rotorAngle, true) drawBall(bx, by) gpu.sync() end ---------------------------------------------------------------------- -- Redstone helper ---------------------------------------------------------------------- local function waitForRedstonePulse() while true do os.pullEvent("redstone") for _, side in ipairs(redstone.getSides()) do if redstone.getInput(side) then return end end end end ---------------------------------------------------------------------- -- Lifecycle ---------------------------------------------------------------------- local function start() math.randomseed(os.epoch("utc")) gpu = findGPU() if not gpu then error("No GPU peripheral found.") end gpu.refreshSize() sleep(0) gpu.setSize(64) PW, PH = gpu.getSize() print(("[roulette] GPU: %dx%d px"):format(PW, PH)) if not PW or PW < 128 or PH < 128 then error(("GPU pixel size %dx%d too small."):format(PW or 0, PH or 0)) end CX = math.floor(PW / 2) CY = math.floor(PH / 2) local R_MAX = math.floor(math.min(PW, PH) / 2) - 4 R_OUTER = R_MAX R_POCKET_OUT = math.floor(R_MAX * 0.82) R_POCKET_IN = math.floor(R_MAX * 0.58) R_HUB = math.floor(R_MAX * 0.38) gpu.fill(COL_BG) drawWheelFull(rotorAngle, nil) drawCenterText({ "ROULETTE", "Pull lever" }) end local function stop() if gpu then gpu.fill(COL_BG); gpu.sync() end end local function main() while true do waitForRedstonePulse() drawCenterText({ "SPINNING..." }) sleep(0.2) local num, slotIdx = spin() glowAnimation(slotIdx) local name = "GREEN" if num ~= 0 then name = RED_SET[num] and "RED" or "BLACK" end drawCenterText({ "WINNER!", name, tostring(num) }) sleep(5) drawWheelFull(rotorAngle, nil) lastDrawnRotor = rotorAngle drawCenterText({ "ROULETTE", "Pull lever" }) end end return { start = start, stop = stop, main = main }