-- Plichinko Machine (fully physics-based) -- Tom's Peripherals GPU + screen wall (any size). -- -- A ball is dropped from a random X position near the top. -- It falls under gravity and collides with circular pegs using -- proper elastic circle-circle collision response (reflect velocity -- along the contact normal). The ball also bounces off the side -- walls and the bucket dividers. The bucket it lands in is -- determined entirely by the physics — no predetermined outcome. -- -- Trigger: redstone pulse on any side. ---------------------------------------------------------------------- -- GPU discovery ---------------------------------------------------------------------- local function findGPU() print("[plinko] 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("[plinko] Using GPU: " .. name) return peripheral.wrap(name) end end return nil end ---------------------------------------------------------------------- -- Physics / display constants ---------------------------------------------------------------------- local FRAME_DELAY = 0.02 -- s (~50 fps) local DT = FRAME_DELAY local GRAVITY = 700 -- px/s² local RESTITUTION = 0.55 -- fraction of normal speed kept after peg bounce local FRICTION = 0.88 -- fraction of tangential speed kept after peg bounce local WALL_RESTITUTION = 0.45 -- side wall bounce local FLOOR_RESTITUTION = 0.38 -- bucket-floor bounce (ball settles after a few hops) local DIVIDER_RESTITUTION = 0.30 -- bucket divider bounce local MAX_SPEED = 1200 -- px/s (clamp to avoid tunnelling) local SETTLE_VY = 18 -- px/s below this vertical speed the ball is "at rest" local PEG_RADIUS = 7 -- px local BALL_RADIUS = 9 -- px local COMBINED_R = PEG_RADIUS + BALL_RADIUS local PEG_ROWS = 9 local PEG_COLS_TOP = 3 -- pegs in first (narrowest) row -- bottom row has PEG_COLS_TOP + PEG_ROWS - 1 pegs local BUCKET_H = 60 -- px local BOARD_MARGIN = 48 -- px left/right margin -- Colours local COL_BG = 0x080C14 local COL_BOARD = 0x0E1620 local COL_PEG = 0xCFD8DC local COL_PEG_HIT = 0xFFFFFF -- flash colour when struck local COL_BALL = 0xFFD600 local COL_BALL_SHADE = 0xBB8800 local COL_WALL = 0x1C2840 local COL_TEXT = 0xFFFFFF -- Buckets: defined centre-outward; mirrored. local BUCKET_DEFS = { -- { label, colour, multiplier } { "100x", 0xF9A825, 100 }, { "10x", 0x1565C0, 10 }, { "5x", 0x2E7D32, 5 }, { "2x", 0x4CAF50, 2 }, { "1x", 0x37474F, 1 }, } ---------------------------------------------------------------------- -- Module-level GPU state ---------------------------------------------------------------------- local gpu local PW, PH ---------------------------------------------------------------------- -- Drawing helpers ---------------------------------------------------------------------- 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_text(str, x, y, fg, bg, size) pcall(gpu.drawText, math.floor(x), math.floor(y), str, fg, bg, size or 1, 0) end local function px_text_centre(str, y, fg, bg, size) size = size or 2 local w = #str * 7 * size px_text(str, math.max(1, math.floor((PW - w) / 2)), y, fg, bg, size) end ---------------------------------------------------------------------- -- Board geometry ---------------------------------------------------------------------- local pegs = {} local buckets = {} local NUM_BUCKETS local colSpacing, rowSpacing local boardLeft, boardRight, boardTop, boardBottom local bucketTop -- y of the top of bucket row local function buildBoard() pegs = {} buckets = {} local lastRowPegs = PEG_COLS_TOP + PEG_ROWS - 1 NUM_BUCKETS = lastRowPegs + 1 boardLeft = BOARD_MARGIN boardRight = PW - BOARD_MARGIN colSpacing = math.floor((boardRight - boardLeft) / lastRowPegs) -- Reserve top 55px for title, bottom BUCKET_H + 10 for buckets. rowSpacing = math.floor((PH - BUCKET_H - 75) / (PEG_ROWS + 1)) boardTop = 65 boardBottom = boardTop + (PEG_ROWS - 1) * rowSpacing for row = 1, PEG_ROWS do local pegsInRow = PEG_COLS_TOP + row - 1 local rowW = (pegsInRow - 1) * colSpacing local startX = math.floor(PW / 2 - rowW / 2) local py = boardTop + (row - 1) * rowSpacing for col = 1, pegsInRow do -- Jitter each peg slightly so the layout is never identical local jx = math.floor((math.random() - 0.5) * colSpacing * 0.35) local jy = math.floor((math.random() - 0.5) * rowSpacing * 0.35) table.insert(pegs, { x = startX + (col - 1) * colSpacing + jx, y = py + jy, }) end end -- Bucket top is one rowSpacing below the last peg row. bucketTop = boardBottom + rowSpacing - math.floor(rowSpacing * 0.2) local bucketW = colSpacing local totalBW = NUM_BUCKETS * bucketW local startBX = math.floor(PW / 2 - totalBW / 2) local halfB = math.floor(NUM_BUCKETS / 2) for i = 1, NUM_BUCKETS do local dist if NUM_BUCKETS % 2 == 1 then dist = math.abs(i - (halfB + 1)) else dist = math.max(0, math.abs(i - halfB) - 1) end local def = BUCKET_DEFS[math.min(dist + 1, #BUCKET_DEFS)] table.insert(buckets, { x = startBX + (i - 1) * bucketW, y = bucketTop, w = bucketW, label = def[1], color = def[2], mult = def[3], }) end end ---------------------------------------------------------------------- -- Identify which bucket an X coordinate falls in (physics outcome) ---------------------------------------------------------------------- local function bucketForX(x) for i, b in ipairs(buckets) do if x >= b.x and x < b.x + b.w then return i end end -- Clamp to edges if x < buckets[1].x then return 1 end return NUM_BUCKETS end ---------------------------------------------------------------------- -- Static board draw ---------------------------------------------------------------------- local function drawBucket(i, glowing) local b = buckets[i] local bg = glowing and 0xFFFFFF or b.color local fg = glowing and b.color or COL_TEXT px_rect(b.x, b.y, b.w - 1, BUCKET_H, bg) -- Divider px_rect(b.x + b.w - 1, b.y, 1, BUCKET_H, COL_BOARD) -- Centred label local lw = #b.label * 7 local lx = b.x + math.floor((b.w - lw) / 2) local ly = b.y + math.floor((BUCKET_H - 9) / 2) px_text(b.label, lx, ly, fg, bg, 1) end local function drawAllBuckets(glowIdx) for i = 1, NUM_BUCKETS do drawBucket(i, i == glowIdx) end end local function drawPeg(p, col) px_circle(p.x, p.y, PEG_RADIUS, col or COL_PEG) end local function drawBoard() px_rect(1, 1, PW, PH, COL_BG) -- Board area background px_rect(boardLeft - 8, boardTop - 18, boardRight - boardLeft + 16, bucketTop - boardTop + 18 + BUCKET_H + 4, COL_BOARD) -- Left/right walls px_rect(boardLeft - 8, boardTop - 18, 8, bucketTop - boardTop + 18, COL_WALL) px_rect(boardRight, boardTop - 18, 8, bucketTop - boardTop + 18, COL_WALL) for _, p in ipairs(pegs) do drawPeg(p) end drawAllBuckets() px_text_centre("Plichinko", 8, 0xFFD600, COL_BG, 3) px_text_centre("Pull lever to drop", 46, 0x607080, COL_BG, 1) end ---------------------------------------------------------------------- -- Physics simulation — returns final bucket index ---------------------------------------------------------------------- -- Erase the ball at (lx,ly) and restore any pegs it was covering. local function eraseBall(lx, ly) local r = BALL_RADIUS + 3 px_circle(lx, ly, r, COL_BOARD) -- Restore wall stripes if needed if lx - r < boardLeft then px_rect(boardLeft - 8, ly - r, 8, r * 2 + 1, COL_WALL) end if lx + r > boardRight then px_rect(boardRight, ly - r, 8, r * 2 + 1, COL_WALL) end -- Restore any pegs within the erased disc for _, p in ipairs(pegs) do local dx = lx - p.x local dy = ly - p.y if dx*dx + dy*dy <= (r + PEG_RADIUS + 1)^2 then drawPeg(p) end end end local function drawBall(bx, by) local ix, iy = math.floor(bx), math.floor(by) px_circle(ix + 2, iy + 2, BALL_RADIUS, 0x1A1200) -- shadow px_circle(ix, iy, BALL_RADIUS, COL_BALL) -- simple highlight px_circle(ix - 3, iy - 3, math.max(2, math.floor(BALL_RADIUS * 0.35)), 0xFFF8C0) end local function physicsLoop() -- Re-randomise peg positions each drop buildBoard() drawBoard() gpu.sync() -- Random drop X anywhere across the board width local dropX = boardLeft + math.random() * (boardRight - boardLeft) local dropY = boardTop - rowSpacing * 0.6 -- Random launch angle: mostly downward but tilted ±35° local launchAngle = (math.pi / 2) + (math.random() - 0.5) * (math.pi / 180 * 70) local launchSpeed = 180 + math.random() * 220 -- px/s local bx = dropX local by = dropY local vx = math.cos(launchAngle) * launchSpeed local vy = math.sin(launchAngle) * launchSpeed -- positive = downward (screen coords) local lastBx, lastBy = bx, by local elapsed = 0 local MAX_TIME = 14.0 -- Per-peg cooldown to prevent vibrating stuck against one peg. -- Also used to track glow: peg stays lit while cooldown > 0. local pegCooldown = {} -- index -> time remaining local pegLit = {} -- index -> true while glowing -- Pre-build bucket divider X positions (walls between buckets). -- Each divider is a thin vertical wall from bucketTop downward. local dividers = {} for i = 1, NUM_BUCKETS - 1 do table.insert(dividers, buckets[i].x + buckets[i].w) end local bucketFloor = bucketTop + BUCKET_H local inBucket = false -- true once ball has entered bucket zone -- Draw initial ball drawBall(bx, by) gpu.sync() while elapsed < MAX_TIME do vy = vy + GRAVITY * DT bx = bx + vx * DT by = by + vy * DT -- Speed clamp local spd = math.sqrt(vx*vx + vy*vy) if spd > MAX_SPEED then local s = MAX_SPEED / spd vx = vx * s; vy = vy * s end -- Tick peg cooldowns; restore normal colour when glow expires for k, v in pairs(pegCooldown) do pegCooldown[k] = v - DT if pegCooldown[k] <= 0 then pegCooldown[k] = nil if pegLit[k] then drawPeg(pegs[k], COL_PEG) pegLit[k] = nil end end end -- ── Peg collisions (only while above bucket zone) ─────────── if not inBucket then for i, p in ipairs(pegs) do if not pegCooldown[i] then local dx = bx - p.x local dy = by - p.y local distSq = dx*dx + dy*dy if distSq < COMBINED_R * COMBINED_R and distSq > 0 then local dist = math.sqrt(distSq) local nx = dx / dist local ny = dy / dist -- Depenetrate local overlap = COMBINED_R - dist bx = bx + nx * overlap by = by + ny * overlap -- Velocity reflection local vn = vx * nx + vy * ny local vnx = vn * nx; local vny = vn * ny local vtx = vx - vnx; local vty = vy - vny vx = -vnx * RESTITUTION + vtx * FRICTION vy = -vny * RESTITUTION + vty * FRICTION -- Guarantee separation local newVn = vx * nx + vy * ny if newVn < 0 then vx = vx - newVn * nx vy = vy - newVn * ny end pegCooldown[i] = 0.18 -- glow duration (s) pegLit[i] = true drawPeg(p, COL_PEG_HIT) end end end end -- ── Side wall collisions ───────────────────────────────────── if bx - BALL_RADIUS < boardLeft then bx = boardLeft + BALL_RADIUS vx = math.abs(vx) * WALL_RESTITUTION end if bx + BALL_RADIUS > boardRight then bx = boardRight - BALL_RADIUS vx = -math.abs(vx) * WALL_RESTITUTION end -- ── Entered bucket zone? ───────────────────────────────────── if by + BALL_RADIUS >= bucketTop then inBucket = true end -- ── Bucket physics (dividers + floor) ──────────────────────── if inBucket then -- Bucket divider walls (thin vertical pillars) for _, dx in ipairs(dividers) do if bx + BALL_RADIUS > dx and bx - BALL_RADIUS < dx then if vx > 0 then bx = dx - BALL_RADIUS else bx = dx + BALL_RADIUS end vx = -vx * DIVIDER_RESTITUTION end end -- Bucket floor bounce if by + BALL_RADIUS >= bucketFloor then by = bucketFloor - BALL_RADIUS vy = -math.abs(vy) * FLOOR_RESTITUTION vx = vx * 0.80 -- extra horizontal damping on floor hit -- Settle: if vertical bounce is tiny, stop if math.abs(vy) < SETTLE_VY then vy = 0 vx = vx * 0.5 if math.abs(vx) < 4 then vx = 0 break end end end end -- Render eraseBall(math.floor(lastBx), math.floor(lastBy)) drawBall(bx, by) gpu.sync() sleep(DT) lastBx, lastBy = bx, by elapsed = elapsed + DT end -- Final render eraseBall(math.floor(lastBx), math.floor(lastBy)) drawBall(bx, by) gpu.sync() sleep(0.3) -- Erase ball eraseBall(math.floor(bx), math.floor(by)) gpu.sync() return bucketForX(bx) end ---------------------------------------------------------------------- -- Win flash ---------------------------------------------------------------------- local function flashBucket(idx) for i = 1, 8 do drawBucket(idx, i % 2 == 1) gpu.sync() sleep(0.12) end drawBucket(idx, false) gpu.sync() end ---------------------------------------------------------------------- -- Result banner ---------------------------------------------------------------------- local function showResult(mult) local msg = mult .. "x MULTIPLIER!" local bh = 40 local by_ = math.floor(PH / 2 - bh / 2) local marg = 40 px_rect(marg, by_, PW - marg * 2, bh, 0x000000) px_text_centre(msg, by_ + math.floor((bh - 18) / 2), 0xFFD600, 0x000000, 2) gpu.sync() sleep(3) -- Clear banner px_rect(marg, by_, PW - marg * 2, bh, COL_BOARD) -- Restore any pegs behind the banner for _, p in ipairs(pegs) do if p.y >= by_ - PEG_RADIUS and p.y <= by_ + bh + PEG_RADIUS then drawPeg(p) end end 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("[plichinko] No GPU peripheral found.") end gpu.refreshSize() sleep(0) gpu.setSize(64) PW, PH = gpu.getSize() print(("[plichinko] GPU %dx%d px"):format(PW, PH)) if not PW or PW < 128 or PH < 128 then error(("GPU size %dx%d too small."):format(PW or 0, PH or 0)) end buildBoard() print(("[plichinko] %d pegs, %d buckets"):format(#pegs, NUM_BUCKETS)) -- Clear full screen before drawing anything gpu.fill(COL_BG) gpu.sync() drawBoard() gpu.sync() end local function stop() if gpu then gpu.fill(COL_BG); gpu.sync() end end local function main() while true do waitForRedstonePulse() -- Run physics — redraws board with fresh peg positions, then drops local winIdx = physicsLoop() -- Celebrate flashBucket(winIdx) showResult(buckets[winIdx].mult) -- Redraw clean board (fresh pegs already in place from physicsLoop) drawBoard() gpu.sync() end end return { start = start, stop = stop, main = main }