-- Roulette Machine — static wheel, physics ball -- Tom's Peripherals GPU + screen wall (any size). -- -- The wheel is completely static — it never rotates. -- The ball is simulated in 2-D Cartesian coordinates: -- * Orbits inside a circular track (bounces off outer rim and inner wall) -- * Has tangential + radial velocity components -- * Loses energy each bounce (restitution < 1) -- * When slow enough, crosses the inner wall and bounces around the -- pocket ring until it comes to rest in a pocket -- -- Result: whichever pocket the ball is closest to when it stops. -- The wheel is drawn once at startup; only the ball moves each frame. ---------------------------------------------------------------------- -- 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.03 -- ~33 fps local TWO_PI = math.pi * 2 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 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 -- Ball physics — 3-D simulation, top-down projected to 2-D screen -- World units: 1 unit = 1 pixel at the wheel centre plane (z = 0). -- z is the vertical axis (positive = up). Gravity points -z. -- The bowl geometry is a truncated cone: -- Outer track : r = R_WORLD_OUT, z = Z_TRACK (rim, highest) -- Inner wall : r = R_WORLD_IN, z = Z_DEFLECT (slightly lower) -- Pocket ring : r in [R_WORLD_PKT_IN, R_WORLD_OUT], z = Z_POCKET (lowest) -- All radii are set at runtime from R_OUTER / R_POCKET_* in world pixels. local BALL_RADIUS = 8 -- px (screen drawing radius) local BALL_WORLD_R = 5 -- physics sphere radius in world units -- Initial tangential speed (world units / s) — very fast launch local BALL_SPEED_MIN = 1800 local BALL_SPEED_MAX = 2800 -- Gravity (world units / s²) local GRAVITY = 1800 -- Bowl cone half-angle from horizontal (radians) local BOWL_SLOPE = math.pi / 9 -- 20 degrees local POCKET_SLOPE = math.pi / 5 -- 36 degrees -- Restitution on wall bounce local RESTITUTION_WALL = 0.82 local RESTITUTION_POCKET = 0.68 -- Rolling friction: multiplier per frame — strong enough to stop cleanly local FRICTION_ROLL = 0.975 -- per frame (~0.43/s at 33fps — decays in ~5s) local FRICTION_POCKET = 0.955 -- pocket damps faster -- Stop when horizontal speed drops below this — no wiggle local STOP_SPEED = 18 -- px/s local BOUNCE_KICK_MAX = 0.08 -- rad ---------------------------------------------------------------------- -- 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 ---------------------------------------------------------------------- -- Wheel drawing (static — drawn once, never redrawn during spin) ---------------------------------------------------------------------- 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 FIXED_ROTOR = 0 -- wheel never rotates local function drawWedge(slotIdx, glowing) local halfArc = math.pi / NUM_POCKETS local midAngle = FIXED_ROTOR + (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) + 70) local g = math.min(255, math.floor((col % 0x10000) / 0x100) + 70) local b = math.min(255, col % 0x100 + 70) 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 local function drawAllWedges(glowSlot) for i = 1, NUM_POCKETS do drawWedge(i, i == glowSlot) sleep(0) end end local function drawChrome() px_annulus(CX, CY, R_OUTER - 6, R_OUTER, COL_RIM) px_annulus(CX, CY, R_POCKET_OUT + 2, R_OUTER - 6, COL_TRACK) 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) 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(glowSlot) px_circle(CX, CY, R_OUTER, COL_BG) drawAllWedges(glowSlot) drawChrome() end ---------------------------------------------------------------------- -- Ball helpers ---------------------------------------------------------------------- local ballX, ballY = 0, 0 -- Repair the wheel pixels inside a circle of radius r centred on (cx,cy). -- Single pass: for every pixel in the bounding box, compute the correct -- wheel colour and paint it. No flat-colour approximation. local function repairWheelPatch(cx, cy, r) cx = math.floor(cx); cy = math.floor(cy) local px0 = cx - r; local px1 = cx + r local py0 = cy - r; local py1 = cy + r -- Pre-compute per-wedge geometry (angle extents) once local halfArc = math.pi / NUM_POCKETS local wedges = {} for slotIdx = 1, NUM_POCKETS do local mid = FIXED_ROTOR + (slotIdx - 1) * TWO_PI / NUM_POCKETS wedges[slotIdx] = { a0 = mid - halfArc, arc = TWO_PI / NUM_POCKETS, col = pocketColor(WHEEL_ORDER[slotIdx]), } end for sy = py0, py1 do for sx = px0, px1 do local dx = sx - CX local dy = sy - CY local d = math.sqrt(dx*dx + dy*dy) local col -- Determine correct colour for this pixel, inside-out priority if d <= R_HUB - 4 then col = COL_HUB elseif d <= R_HUB then col = COL_HUB_RING elseif d <= R_POCKET_IN - 2 then col = COL_HUB -- gap between hub ring and inner rim elseif d <= R_POCKET_IN then col = COL_RIM elseif d <= R_POCKET_OUT then -- Pocket ring: find which wedge this pixel belongs to local angle = math.atan2(dy, dx) for _, w in ipairs(wedges) do local rel = (angle - w.a0) % TWO_PI if rel <= w.arc then col = w.col break end end if not col then col = COL_HUB end -- fallback elseif d <= R_POCKET_OUT + 2 then col = COL_RIM elseif d <= R_OUTER - 6 then col = COL_TRACK elseif d <= R_OUTER then col = COL_RIM end -- d > R_OUTER: outside wheel, leave as-is (ball never goes there) if col then px_rect(sx, sy, 1, 1, col) end end end end local function eraseBall() local r = BALL_RADIUS + 5 repairWheelPatch(ballX, ballY, r) 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 ---------------------------------------------------------------------- -- Center text ---------------------------------------------------------------------- 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 — full 3-D ball simulation, top-down projected to 2-D -- -- Ball position: (bx, by, bz) in world space -- Ball velocity: (vx, vy, vz) in world units/s -- -- z = vertical axis (up positive), gravity = -z -- x, y map 1:1 to screen pixels relative to (CX, CY) -- -- Bowl surface: cone frustum. -- TRACK phase : outer sloped ring, ball spirals inward as energy drops -- POCKET phase : steeper inner bowl, ball bounces until settled -- -- Each frame: -- 1. Apply gravity to vz -- 2. Project velocity onto bowl surface (normal-force constraint) -- 3. Apply rolling friction -- 4. Integrate position -- 5. Snap bz to bowl surface z -- 6. Handle radial wall collisions ---------------------------------------------------------------------- local PHASE_TRACK = 1 local PHASE_POCKET = 2 local function spin() local dt = FRAME_DELAY -- ── World-space geometry (radii in world px, heights in world px) ── local RW_OUT = R_OUTER - 6 - BALL_WORLD_R -- outer rim local RW_IN = R_POCKET_OUT + 2 + BALL_WORLD_R -- inner deflector local RW_PKT_OUT = R_POCKET_OUT - BALL_WORLD_R -- pocket outer wall local RW_PKT_IN = R_POCKET_IN + BALL_WORLD_R -- pocket inner wall local R_SETTLE = (R_POCKET_IN + R_POCKET_OUT) / 2 -- Height of the bowl surface at a given radius: -- Track: z = (r - RW_IN) * tan(BOWL_SLOPE) (zero at inner wall, rises outward) -- Pocket: z = -(r - RW_IN) * tan(POCKET_SLOPE) (drops inward past deflector) local tanTrack = math.tan(BOWL_SLOPE) local tanPocket = math.tan(POCKET_SLOPE) local function bowlZ(r, phase) if phase == PHASE_POCKET then return -(r - RW_PKT_OUT) * tanPocket else return (r - RW_IN) * tanTrack end end -- Surface outward normal in (radial, z) 2-D cross-section: -- Track cone slopes up outward → normal = (sin θ, cos θ) rotated into 3D -- radially. The 3-D normal = (nr * x/r, nr * y/r, nz) local function bowlNormal(x, y, phase) local r = math.sqrt(x*x + y*y) if r < 0.001 then return 0, 0, 1 end -- In the (r,z) plane the slope angle gives: -- track: normal points inward-upward = (-sin θ, cos θ) -- pocket: normal points outward-upward = ( sin θ, cos θ) local nr, nz if phase == PHASE_POCKET then nr = math.sin(POCKET_SLOPE) nz = math.cos(POCKET_SLOPE) else nr = -math.sin(BOWL_SLOPE) nz = math.cos(BOWL_SLOPE) end -- Expand into 3D radially local rx = x / r local ry = y / r return nr * rx, nr * ry, nz end -- ── Initial conditions ───────────────────────────────────────────── local startAngle = math.random() * TWO_PI local startSpeed = BALL_SPEED_MIN + math.random() * (BALL_SPEED_MAX - BALL_SPEED_MIN) local r0 = RW_OUT - 2 local bx = math.cos(startAngle) * r0 local by = math.sin(startAngle) * r0 local bz = bowlZ(r0, PHASE_TRACK) -- Start tangentially local vx = -math.sin(startAngle) * startSpeed local vy = math.cos(startAngle) * startSpeed local vz = 0.0 local phase = PHASE_TRACK local elapsed = 0 local MAX_TIME = 25.0 -- Project 3D (bx,by) → screen (sx,sy) (z is depth only, not projected) local function toScreen(x, y) return CX + x, CY + y end -- Draw ball at current world position local function drawBall3() local sx, sy = toScreen(bx, by) drawBall(sx, sy) end local function eraseBall3() local sx, sy = toScreen(bx, by) -- record for eraseBall's globals ballX = math.floor(sx) ballY = math.floor(sy) eraseBall() end drawBall3() gpu.sync() while elapsed < MAX_TIME do -- ── 1. Gravity ───────────────────────────────────────────────── vz = vz - GRAVITY * dt -- ── 2. Surface constraint ────────────────────────────────────── -- Project velocity onto the bowl surface (remove normal component). -- This simulates the ball being pressed against the bowl by the -- normal force, keeping it on the surface. local r = math.sqrt(bx*bx + by*by) local nx3, ny3, nz3 = bowlNormal(bx, by, phase) local vdotn = vx*nx3 + vy*ny3 + vz*nz3 -- Only cancel the component pushing INTO the surface (vdotn < 0) if vdotn < 0 then vx = vx - vdotn * nx3 vy = vy - vdotn * ny3 vz = vz - vdotn * nz3 end -- ── 3. Rolling friction ──────────────────────────────────────── local fric = (phase == PHASE_POCKET) and FRICTION_POCKET or FRICTION_ROLL vx = vx * fric vy = vy * fric vz = vz * fric -- ── 4. Integrate ─────────────────────────────────────────────── bx = bx + vx * dt by = by + vy * dt bz = bz + vz * dt -- ── 5. Constrain z to bowl surface (snap) ───────────────────── r = math.sqrt(bx*bx + by*by) local targetZ = bowlZ(r, phase) bz = targetZ -- hard constraint keeps ball on surface -- ── 6. Wall collisions ───────────────────────────────────────── if phase == PHASE_TRACK then -- Outer rim if r > RW_OUT then local scale = RW_OUT / r bx = bx * scale; by = by * scale -- Radial inward normal for bounce local rnx, rny = -bx/RW_OUT, -by/RW_OUT local vn = vx*rnx + vy*rny if vn < 0 then vx = vx - 2*vn*rnx*(RESTITUTION_WALL) vy = vy - 2*vn*rny*(RESTITUTION_WALL) local kick = (math.random() - 0.5) * BOUNCE_KICK_MAX * 2 local c, s = math.cos(kick), math.sin(kick) vx, vy = vx*c - vy*s, vx*s + vy*c end end -- Inner deflector — cross into pocket phase if r < RW_IN then phase = PHASE_POCKET end elseif phase == PHASE_POCKET then -- Outer pocket wall if r > RW_PKT_OUT then local scale = RW_PKT_OUT / r bx = bx * scale; by = by * scale local rnx, rny = -bx/RW_PKT_OUT, -by/RW_PKT_OUT local vn = vx*rnx + vy*rny if vn < 0 then vx = vx - 2*vn*rnx*RESTITUTION_POCKET vy = vy - 2*vn*rny*RESTITUTION_POCKET local kick = (math.random() - 0.5) * BOUNCE_KICK_MAX * 2 local c, s = math.cos(kick), math.sin(kick) vx, vy = vx*c - vy*s, vx*s + vy*c end end -- Inner pocket wall if r < RW_PKT_IN then local scale = RW_PKT_IN / r bx = bx * scale; by = by * scale local rnx, rny = bx/RW_PKT_IN, by/RW_PKT_IN local vn = vx*rnx + vy*rny if vn < 0 then vx = vx - 2*vn*rnx*RESTITUTION_POCKET vy = vy - 2*vn*rny*RESTITUTION_POCKET local kick = (math.random() - 0.5) * BOUNCE_KICK_MAX * 2 local c, s = math.cos(kick), math.sin(kick) vx, vy = vx*c - vy*s, vx*s + vy*c end end -- Settled when horizontal speed is very low local hspd = math.sqrt(vx*vx + vy*vy) if hspd < STOP_SPEED then break end end eraseBall3() drawBall3() gpu.sync() sleep(dt) elapsed = elapsed + dt end -- Final draw eraseBall3() drawBall3() gpu.sync() -- Nearest pocket by angle of final (bx, by) local finalAngle = math.atan2(by, bx) local bestSlot, bestDist = 1, math.huge for i = 1, NUM_POCKETS do local sa = ((i - 1) * TWO_PI / NUM_POCKETS) % TWO_PI local fa = finalAngle % TWO_PI local diff = math.abs(sa - fa) 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 on screen local snapAngle = FIXED_ROTOR + (bestSlot - 1) * TWO_PI / NUM_POCKETS local sx = CX + math.cos(snapAngle) * R_SETTLE local sy = CY + math.sin(snapAngle) * R_SETTLE eraseBall3() 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 = FIXED_ROTOR + (slotIdx - 1) * TWO_PI / NUM_POCKETS local bx = CX + math.cos(sa) * R_SETTLE local by = CY + math.sin(sa) * R_SETTLE for flash = 1, 6 do drawWedge(slotIdx, flash % 2 == 1) drawBall(bx, by) gpu.sync() sleep(0.15) end drawWedge(slotIdx, 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) -- Clear full screen before drawing anything gpu.fill(COL_BG) gpu.sync() drawWheelFull(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.1) 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) -- Erase ball, redraw wheel clean eraseBall() drawChrome() drawCenterText({ "ROULETTE", "Pull lever" }) end end return { start = start, stop = stop, main = main }