I spend most of my time in Ruby, Elixir, and Emacs. Web apps, small libraries, editor tooling. Comfortable, familiar ground.

So one weekend I decided to do something well outside that world. I had never written a game, never touched Roblox, and had never seen a line of Luau. That felt like reason enough to try. And there is something I find hard to resist about building a thing you can actually play, not just run.

The result is Neon Shatter, an endless neon brick-breaker. You can play it on Roblox.

A Neon Shatter arena: an electric-blue frame with three cracked-neon
pillars behind it, several balls trailing comet streaks, power-up capsules,
and neon bricks breaking apart.

What I did not expect was how much it would feel like a normal software project, down to the tooling and a couple of lessons I thought I already knew.

It is a real codebase

My mental image of Roblox was a kid-friendly editor where you drag blocks around and glue a bit of script on top. That image is wrong, or at least it is only half the story.

You can work entirely from your own editor. Rojo watches a src/ tree on disk and syncs it live into Roblox Studio. Studio becomes the runtime and the viewport, while the actual code lives in files, under Git, like anything else.

The mapping between the folder tree and the Roblox data model is a small JSON file:

{
  "name": "neon-shatter",
  "tree": {
    "$className": "DataModel",
    "ReplicatedStorage": {
      "$className": "ReplicatedStorage",
      "Shared": { "$path": "src/shared" }
    },
    "ServerScriptService": {
      "$className": "ServerScriptService",
      "Server": { "$path": "src/server" }
    },
    "StarterPlayer": {
      "$className": "StarterPlayer",
      "StarterPlayerScripts": {
        "$className": "StarterPlayerScripts",
        "Client": { "$path": "src/client" }
      }
    }
  }
}

Three folders: shared modules both sides can require, server scripts, and client scripts. That split turns out to matter a lot, and I will come back to it.

Then there is the language. Luau is a typed dialect of Lua that Roblox maintains. Every file in the project starts with --!strict, and the type checker earns its keep: it caught most of my silly mistakes before Studio ever ran the code, the same way a decent type checker does anywhere else.

Around the language sits a toolchain that would look at home in any repository: StyLua for formatting, selene for linting, Wally for packages, Rokit to pin the exact versions of all of them, and Lune to run tests. None of it is bundled with Roblox. It is the community rebuilding the conveniences we take for granted elsewhere.

The server owns everything

The single most useful thing I learned is that a Roblox place is a client-server system, and you are much better off treating it like one from the first line.

Anything the client can compute, the client can lie about. A player running a modified client could tell the server the ball is wherever they want it to be. So in Neon Shatter the server owns all the state: it builds each arena, runs a fixed-step physics loop, and resolves every collision. The client sends intent and renders what the server reports back. Nothing more.

Steering the paddle with the keyboard is a good small example. The client never moves the paddle. It only tells the server which direction is held, and only when that changes:

local function setKeyboardDir(dir: number)
	if dir == keyboardDir then
		return
	end
	keyboardDir = dir
	if dir ~= 0 then
		controlMode = "keyboard"
	end
	setPaddleDir:FireServer(dir)
end

The server integrates that direction each step and clamps the result to the play area, so the paddle can never leave the arena whatever a client claims:

function Paddle.clampX(
	requestedX: number,
	arenaCenterX: number,
	halfPlayWidth: number,
	halfPaddleWidth: number
): number
	local minX = arenaCenterX - halfPlayWidth + halfPaddleWidth
	local maxX = arenaCenterX + halfPlayWidth - halfPaddleWidth
	return math.clamp(requestedX, minX, maxX)
end

The two sides talk only through named RemoteEvents, gathered in one shared module so both ends agree on the vocabulary: SetPaddleDir, LaunchBall, BrickBroken, FireLaser, and so on. The server creates them on startup; the client waits for them and fires intent across.

None of this is specific to Roblox. It is the same discipline you want in any networked application: never trust the client, validate on the server. A game just makes it vivid: get that boundary wrong and a client can slide its paddle straight out of the arena.

Making the ball behave

The ball gave me the most trouble, and its failure mode was genuinely funny.

The obvious approach is to move the ball a little each frame, then check whether it now overlaps a brick. At low speed that is fine. Crank the speed up and it falls apart: between two frames the ball jumps clean over a thin brick, touches nothing on any single frame, and sails out the back of the arena. The first time I watched a ball teleport straight through a wall I laughed out loud, then went looking for the fix.

The fix is continuous collision. Instead of testing positions, you test the movement: treat the step from the old position to the new one as a segment, and ask when along that segment the ball first enters a box. That is a swept axis-aligned bounding box test, solved with the slab method: for each axis you compute the interval of time the segment spends inside the box’s extent, intersect the intervals, and the earliest entry that survives is your hit, along with the face it hit.

Once you have the hit and the surface normal, the bounce is a three-line reflection:

function Physics.reflect(vx: number, vz: number, nx: number, nz: number): (number, number)
	local dot = vx * nx + vz * nz
	return vx - 2 * dot * nx, vz - 2 * dot * nz
end

The whole step is one function that moves the ball, finds the nearest thing it hits (brick or paddle), reflects off it, and returns a short status string: "brick", "paddle", "wall", "lost", or "alive". The caller reacts to the tag rather than re-deriving what happened. It kept the loop easy to reason about, even once piercing balls and multi-ball were in the mix.

Testing a game without the engine

This is the piece I enjoyed most, and the reason the rest stayed manageable.

Games are awkward to test because so much of them lives inside the engine: rendering, input, sound, data stores. You cannot easily run any of that in CI.

But a large slice of a game is not engine-bound at all. The collision maths, the difficulty curve, the brick layout, the power-up rules are all plain functions: numbers in, numbers out. I kept them in dependency-free modules with no require and no Roblox globals, so they run with no engine whatsoever.

The difficulty curve is a clean example. Every value the game escalates is just arithmetic on the current level:

--!strict

local Difficulty = {}

function Difficulty.ballSpeed(
	level: number,
	baseSpeed: number,
	perLevelFactor: number,
	maxSpeed: number
): number
	return math.min(baseSpeed * (1 + (level - 1) * perLevelFactor), maxSpeed)
end

function Difficulty.rows(level: number, baseRows: number, maxRows: number): number
	return math.min(baseRows + (level - 1), maxRows)
end

function Difficulty.maxHp(level: number, cap: number): number
	return math.min(1 + math.floor((level - 1) / 2), cap)
end

return Difficulty

Because it is pure, I can pin its behaviour exactly. Lune runs the suite headless, straight from a terminal:

local Difficulty = require("../src/shared/Difficulty")

return function(suite)
	-- ballSpeed: level 1 is the base; later levels scale; capped
	suite:approx(Difficulty.ballSpeed(1, 45, 0.08, 90), 45, 1e-6, "level 1 speed is base")
	suite:approx(Difficulty.ballSpeed(3, 45, 0.08, 90), 52.2, 1e-6, "level 3 speed scales")
	suite:approx(Difficulty.ballSpeed(100, 45, 0.08, 90), 90, 1e-6, "speed is capped")
	-- rows and maxHp are checked the same way
end

The collision code gets the same treatment, and here it really pays off. A swept test is exactly the kind of geometry that is easy to get subtly wrong and impossible to eyeball. So I nail down concrete cases: a ball crossing from x = 0 toward a box centred at x = 5 with half-width 1 should first touch its left face at 40% of the way, with a normal pointing back along -x:

local t, nx, nz = Physics.sweptAABB(0, 0, 10, 0, 5, 0, 1, 1)
suite:approx(t :: number, 0.4, 1e-6, "swept +x entry fraction")
suite:eq(nx, -1, "swept +x normal points -x")
suite:eq(nz, 0, "swept +x normal has no z")

Even the input rules are pure where they can be. Deciding whether a touch is a tap (launch the ball) or a drag (move the paddle) is not a matter of engine plumbing; it is a rule about pixels and time:

function TouchMath.isTap(
	movementPixels: number,
	elapsedSeconds: number,
	maxPixels: number,
	maxSeconds: number
): boolean
	return movementPixels <= maxPixels and elapsedSeconds <= maxSeconds
end

One command, lune run tests/run.luau, verifies all of it in milliseconds. Everything that genuinely needs the engine, I check by playtesting in Studio. Drawing that line, with pure logic on one side and engine glue on the other, is the thing I would keep on any future project of this kind.

Fair by construction

Power-ups were where design started to matter more than code. Drop a rare laser every few seconds and the game is trivial. Never drop one and it is joyless. I wanted variety without chaos, and I wanted it to be a rule rather than a vibe.

So the selection is a weighted roll with two kinds of cap: a per-type limit and a global limit on how many power-ups can be live in a wave. If the wave is already saturated, nothing drops; otherwise only the types still under their own cap are eligible, and the roll is weighted among those:

function PowerUps.pickType(
	roll: number,
	weights: { [string]: number },
	caps: { [string]: number },
	counts: { [string]: number },
	totalCap: number
): string?
	local total = 0
	for _, kind in PowerUps.Types do
		total += counts[kind] or 0
	end
	if total >= totalCap then
		return nil
	end

	local eligible = {}
	local weightSum = 0
	for _, kind in PowerUps.Types do
		local weight = weights[kind] or 0
		if weight > 0 and (counts[kind] or 0) < (caps[kind] or 0) then
			table.insert(eligible, kind)
			weightSum += weight
		end
	end
	if weightSum <= 0 then
		return nil
	end

	local target = roll * weightSum
	local accumulated = 0
	for _, kind in eligible do
		accumulated += weights[kind]
		if target < accumulated then
			return kind
		end
	end
	return eligible[#eligible]
end

roll is a number in [0, 1) the caller passes in. That one detail keeps the function pure and, again, trivially testable: feed it a fixed roll and assert exactly which type comes out. Randomness lives at the very edge, where the server calls it; the rule itself is deterministic.

Persistence that is allowed to fail

The last surprise was persistence, which had me writing more defensive code than I expected from a game.

Best scores are saved to an OrderedDataStore, Roblox’s sorted key-value store, which is what makes the top-scores board possible. The catch is that data stores only work when the place belongs to a published experience with API access enabled. In a plain Studio session they are simply not there, and every call throws.

The answer is to treat persistence as best-effort. Every store access is wrapped, resolved once, and cached, and the whole thing degrades to zero instead of crashing the game:

local stores: { [string]: OrderedDataStore } = {}
local resolved: { [string]: boolean } = {}
local function getStore(name: string): OrderedDataStore?
	if not resolved[name] then
		resolved[name] = true
		local ok, store = pcall(function()
			return DataStoreService:GetOrderedDataStore(name)
		end)
		if ok then
			stores[name] = store
		end
	end
	return stores[name]
end

Reads and writes follow the same shape: if the store is missing, a read returns 0 and a write silently does nothing. The leaderboard panel just stays empty, and nothing else breaks. It let me develop the whole game offline in Studio and only turn on real persistence in the published build.

There is one design wrinkle I am quietly proud of. Some players buy an extra starting life, or revive after a loss. Those runs are not comparable to a clean run, so scores set with a boost go to a separate Boosted board, and the Pure board stays honest. Two ordered stores, one rule about which one you land on, and the ranking that matters keeps its meaning.

It got out of hand

“Just to try” was meant to be a paddle and a ball.

Then the bricks had to break with a satisfying burst. A burst made me want a score to go with it, a score is dull without a combo multiplier, and a multiplier is pointless unless there are power-ups to chase. Power-ups needed rules to stay fair. A finished run wanted a leaderboard, and the leaderboard dragged persistence in behind it.

By the time I stopped, it had multi-hit bricks, a handful of power-ups (including a laser you fire and a reverse-controls trap you want to dodge), a combo multiplier, two persistent score boards, a cosmetics shop, comfort passes, a revive, and mobile controls. It reached a 1.0 I am happy to call released.

I planned none of that up front. Each feature led to the next, and because the codebase stayed small, typed, and tested, adding one more was never scary. That came straight from the pure-module split and the type checker doing their jobs.

Was it worth it

Completely.

I set out to poke at an unfamiliar platform for a weekend and came away with real respect for it. Roblox is a genuine engine with a proper language, a sane client-server model, and community tooling good enough to work the way I already like to work: files under Git, types on, tests green, small functions I can hold in my head.

The part I did not plan for is that I still open it to play. The shatter is stupidly satisfying, and every so often I forget I am meant to be testing and just chase the combo counter for a while. For a weekend experiment, that feels like a win.

If you have been curious about it, the barrier is lower than it looks. Install Rojo, point it at a src/ folder, and write typed Luau in the editor you already use. Studio handles the rest.

And if you would rather just play, Neon Shatter is right here.

Have comments or want to discuss this topic?

Send an email to ~bounga/bounga.org-discuss@lists.sr.ht