DeckBound
The idle game you play with friends: a formation auto-battler on Roblox with a shared world boss, real co-op enemy syncing, and a full original art pipeline run off one RTX 3060.

The idle game you play with friends: a formation auto-battler on Roblox with a shared world boss, real co-op enemy syncing, and a full original art pipeline run off one RTX 3060.

DeckBound is an idle formation auto-battler: your champions hold one side of a lane, monsters march in from the other, and clearing enough waves pushes you one area deeper. Gold buys hero levels; prestige resets the run for a permanent power multiplier; 33 champions each have original art and a distinct attack. The game ran on Roblox as a public universe (archived).
The hook I cared about: most idle games are something you play next to other people. DeckBound is one you play with them. Other players on the same server share a world boss whose health bar everyone chips at with their real idle DPS, computed server-side. Two players can start a co-op run where the server authors one stream of monsters and both clients render the same enemies in the same spots. There's also a walkable hub, a daily leaderboard, and a party buff that scales with headcount.









The client renders and plays audio. All game logic lives in pure Luau modules (IdleSim, IdleData, Battle, Cards) that have no Roblox service dependencies, so the full economy (damage curves, gold rates, prestige multipliers, pity-protected loot rolls) is covered by a 300+ test Lune spec suite that runs on every publish.
The server owns everything exploitable: Renown balance, shop upgrades, daily state, cosmetics, pass premium, world boss health. Client-tracked gold is safe because there's nothing to arbitrage (it's per-player and untradeable), but the server stores it and applies spends and grants as deltas rather than overwrites, so a reward push can never clobber what the player has already earned. That invariant has regression tests that prevent it from silently breaking.
ProfileStore handles persistence with load-with-retry, migrate-never-reset, and save-on-leave. The hardest production bug was a stale-low gold clamp that reconciled the client down to it on any reward push, fixed by relaxing the clamp and switching to delta reconciliation.
-- totalDps: formation DPS with prestige, faction, codex, shop, patron, and gamepass multipliers.
function IdleSim.totalDps(s: State): number
local active = IdleSim.activeLevels(s)
local dps = 0
for id, lvl in active do
dps += IdleData.heroDps(id, lvl)
* IdleData.formationMult(id, active, s.seatAssign)
* IdleData.specialtyMult(id, s, active)
end
return dps
* IdleData.prestigeMult(lifetimeRenown(s))
* (1 + IdleData.factionBonus(active) + IdleData.codexBonus(s.heroLevels))
* IdleData.shopMult(s.shop, "warChest")
* IdleData.patronMult(s.patron, "dps")
* IdleSim.purchaseMult(s, "dps")
end
-- processKill: award gold and advance area every killsPerArea threshold.
function IdleSim.processKill(s: State)
local wasBoss = IdleData.isBossArea(s.area)
local gold = math.floor(
IdleData.goldPerKill(s.area, wasBoss)
* IdleData.prestigeMult(lifetimeRenown(s))
* IdleData.shopMult(s.shop, "goldFind")
* IdleData.patronMult(s.patron, "gold")
* IdleSim.purchaseMult(s, "gold")
)
s.gold += gold
s.killsInArea += 1
local advanced = false
if s.killsInArea >= IdleData.TUNE.killsPerArea then
s.killsInArea = 0
s.area += 1
if s.area > s.bestArea then s.bestArea = s.area end
advanced = true
end
return { gold = gold, advanced = advanced, area = s.area, wasBoss = wasBoss }
end
-- goldPerSec: offline rate tied to real clear-speed at bestArea, capped to prevent runaway.
function IdleSim.goldPerSec(s: State): number
local dps = IdleSim.totalDps(s)
if dps <= 0 then return 0 end
local hp = IdleData.monsterHp(s.bestArea, false)
local killsPerSec = math.min(2.0, dps / math.max(1, hp))
return IdleData.TUNE.idleGoldRate
* killsPerSec
* IdleData.goldPerKill(s.bestArea, false)
* IdleData.prestigeMult(lifetimeRenown(s))
* IdleData.shopMult(s.shop, "goldFind")
* IdleData.patronMult(s.patron, "gold")
* IdleSim.purchaseMult(s, "gold")
end
The world boss is the centerpiece of the shared-server design. A single boss lives in the hub; every player's idle DPS is derived server-side from their saved formation. The client never reports a number that the server uses. Once per tick the server sums all online players' DPS, deducts it from the shared health bar, and broadcasts the updated state to every client. When the boss dies, gold is paid out weighted by each player's cumulative contribution (contrib[uid] / total), then the boss respawns one tier tougher with HP scaled to the current collective power so a kill reliably takes about 40 seconds regardless of server population.
The billboard (a floating HP bar visible anywhere in the hub) is built procedurally in WorldBoss.start() and driven by the same tick that does the damage. No Studio UI assets are needed; the whole thing is live code.
local function spawnBoss(tier: number)
boss.tier = tier
boss.name = BOSSES[((tier - 1) % #BOSSES) + 1]
-- size HP to current collective power so a kill takes ~40s regardless of who's online
local collective = 0
for _, pl in Players:GetPlayers() do collective += serverDps(ProfileStore.get(pl)) end
boss.maxHp = math.max(40000, math.floor(collective * 40 * (1 + 0.5 * (tier - 1))))
boss.hp = boss.maxHp
boss.contrib = {}
boss.alive = true
broadcast()
end
local function onKill()
boss.alive = false
local total = 0; for _, d in boss.contrib do total += d end
if total <= 0 then total = 1 end
-- pay every contributor: gold by contribution share + a flat renown for the kill
for uid, d in boss.contrib do
local pl = Players:GetPlayerByUserId(uid)
local prof = pl and ProfileStore.get(pl)
if prof and type(prof.idle) == "table" then
local share = d / total
local gold = math.floor(boss.maxHp * 0.6 * share)
prof.idle.gold = (prof.idle.gold or 0) + gold
prof.idle.renown = (prof.idle.renown or 0) + 1
pcall(function() ProfileStore.save(pl) end)
-- push a DELTA, not the full idle (full-gold pushes clobber the client)
pcall(function()
Net.event("StateUpdate"):FireClient(pl, {
phase = "idle",
goldReward = gold, renownReward = 1,
worldBossKill = { name = boss.name, tier = boss.tier, gold = gold, renown = 1 },
})
end)
end
end
task.wait(6)
spawnBoss(boss.tier + 1)
end
Co-op is opt-in: one player invites another in the same server; both screens show the same enemies only once the invite is accepted. The server authors a single wave for the shared area (zone-appropriate monsters with real area-based HP) and advances them at IdleData.TUNE.mobSpeed every 200 ms tick. Enemy positions (nx) are marched on the server, and health is deducted from collective DPS. Both clients receive the same payload each tick, so they see the same dragon at the same position.
If either player leaves, the session collapses: the remaining player gets a coopEnded event and their solo lane resumes. Non-members never receive co-op payloads at all, so their solo runs are completely unaffected by an active co-op session on the same server.
Every sprite, backdrop, and icon is original, generated locally on an RTX 3060 using ComfyUI driving SDXL with a pixel-art LoRA. The prompt recipe: one clean centered character, limited palette, bold dark outline, 16-bit RPG look, plain background. A hard negative prompt kills the sprite-sheet and collage failure modes. Backgrounds are removed with rembg to produce transparent standees. The three ambient music tracks (menu, combat, boss) were generated with MusicGen, trimmed, faded, and normalized.
All assets ship to Roblox via Open Cloud with an idempotent upload ledger so a re-run never double-uploads. The full pipeline (compose, generate, remove background, upload, update asset IDs in source) runs headless.
No Roblox Studio in the critical path. Rojo maps the source tree into the place format; a compile pass catches syntax; a runtime check boots the real scene under stubbed Roblox APIs to catch nil-index crashes the compiler misses; the full Lune spec suite runs; then rbxcloud publishes straight to the live place and prints the version number. The game shipped to v181 this way: small, verified increments rather than big risky drops.
The live-ops layer (daily quests, world boss tiers, limited events, season pass) was designed to change without a wipe. ProfileStore's migrate-never-reset guarantee means any new field added to the schema gets its default on first load, and regression tests lock the invariant that a server-side reward push can never silently decrease a player's balance.