The lab

Break my work

Eight systems from my real projects, rebuilt to run in the browser. One of them is playable with a keyboard, one is a room you light yourself, and the rest have a control that breaks them on purpose, so you can see exactly where the naive approach falls over.

Hand-written logic ported over from Luau. No game engine, no libraries, nothing off a shelf.

01 / Gameplay

Movement & momentumPlayable

The controller I built for Anomalous, cut down to two dimensions. Sprint into a low block and you'll vault it without touching another key. Slide under the tall ones, jump the gaps. Speed you earn is speed you keep, so holding a clean line is faster.

State: idle  ·  0m
Click to play
Speed
0
Coyote time and jump buffering are both in there. You will not notice them, which is the point: they are what stops a jump feeling like it got eaten.
Slide
function Controller:UpdateSlide(dt, moveDir)
    local vel, speed = self.Velocity, self.Velocity.Magnitude
    if speed < MIN_SLIDE_SPEED then return self:ExitSlide() end

    -- steering bleeds speed, so a straight line is the fast line
    local turn = 1 - vel.Unit:Dot(moveDir.Unit)
    speed *= (1 - turn * STEER_COST * dt * 60)

    self.Velocity = vel.Unit * (speed * SLIDE_FRICTION ^ (dt * 60))
end
02 / Grimworks

Dynamic light & shadowInteractive

The trick behind Grimworks' look, flattened to 2D. Every light in this room computes true line-of-sight shadows against the geometry, every frame. Your cursor is the flashlight. Click to set lamps down, then cut the power and watch the blackout play out.

Shadow quads / frame: 0  ·  Solve: 0ms
Power: 100%
Move · flashlight  |  Click · place lamp
In Grimworks this runs in 3D with the engine's lighting on top. And the blackout isn't a screen filter: the power grid actually dies, generators and all.
Shadow casting
function Light:CastShadows(walls)
    for _, wall in walls do
        for _, edge in wall:Edges() do
            local a, b = edge.A, edge.B

            -- push both ends of the edge away from the light,
            -- far past the edge of the room
            local a2 = a + (a - self.Position).Unit * FAR
            local b2 = b + (b - self.Position).Unit * FAR

            -- everything inside that quad never sees this light
            self:EraseQuad(a, a2, b2, b)
        end
    end
end
03 / Netcode

Lag compensation

The dashed ring is where the server thinks the target is. The solid orb is what a player on your connection sees. Drag the ping up and try to land a hit, then switch to rewind and try the same thing.

Dashed · server truth  |  Solid · your screen
Click the orb
0ms
Used in Anomalous. In 1v5 horror, a hit that doesn't register is the difference between escaping and dying.
Naive
function VerifyHit(player, target)
    local pos = target.Position
    if (pos - player.Aim).Magnitude < 5 then
        return true
    end
end
Rewind
function Rewind:Verify(player, ping)
    local t = workspace.Time - ping
    local old = self:GetHistory(t)
    return (old - player.Aim).Magnitude < 5
end
04 / Replication

Interpolation

Three ways to draw the same player from exactly the same packets. Drop the tick rate and add packet loss: the raw lane teleports, the naive lane rubber-bands, and the buffered lane keeps gliding because it renders a beat in the past on purpose.

8Hz 0%
The buffered lane is always behind the truth and still looks the best. Smoothness beats freshness for anything you are not directly controlling.
Snapshot buffer
function Buffer:Sample(now)
    local target = now - INTERP_DELAY   -- deliberately behind

    for i = #self._frames - 1, 1, -1 do
        local a, b = self._frames[i], self._frames[i + 1]
        if a.t <= target and b.t >= target then
            local alpha = (target - a.t) / (b.t - a.t)
            return a.state:Lerp(b.state, alpha)
        end
    end
end
05 / Broadphase

Spatial hashing

Same 250 objects, same movement, same collisions coming out the other end. The only thing that changes is how many pairs get tested to find them.

Pair checks / frame: 0
Cost: baseline
Roughly the difference between a server holding 40 players and one holding 400.
Brute force
for i, a in pairs(parts) do
    for j, b in pairs(parts) do
        -- every pair, every frame
        if (a.Pos - b.Pos).Mag < 5 then
            collide(a, b)
        end
    end
end
Spatial hash
function SpatialHash:Query(part)
    local cell = self:GetCell(part.Pos)
    for _, n in pairs(cell) do
        -- only the neighbours
        collide(part, n)
    end
end
06 / Crowd AI

Flow fields

Five hundred agents, one path calculation. Drag to draw walls and they reroute straight away, because the field gets solved once for the whole map instead of once per agent.

Field solve: 0ms
Drag · wall  |  Right-click · move goal
Pathfinding each agent separately would cost 500 searches a frame. This costs one.
Flow field
function FlowField:Update(target)
    -- 1. flood fill outward from the goal
    self:CalculateDistances(target)
    -- 2. every cell points at its cheapest neighbour
    for x, y in grid do
        grid[x][y].Vector = self:GetLowest(x, y)
    end
end
07 / Anti-exploit

Heuristic detection

Basic anti-cheat bans anyone moving too fast, which also bans everyone on bad wifi. Watching how much the speed jumps around, not just the average, tells them apart. Toggle either one on and give it a second to gather data.

Clean

Average and variance both nominal. Player is moving normally.

Inject
Call it wrong one way and you ban a paying player. Call it wrong the other way and you lose the server.
Heuristics
function AntiCheat:Analyze(history)
    local variance = Math.Variance(history)
    local avg = Math.Average(history)
    -- high average + low variance = not a human
    if avg > 100 and variance < 50 then
        return "FLIGHT_DETECTED"
    end
end
08 / Data integrity

Atomic saves

Start a transfer, then kill the connection while the money is still moving. In the unsafe version it just disappears. In the atomic version the write never committed, so it rolls back on its own.

Sender💰
$1000
$
Receiver🏦
$0
Ready.
This is what stands between a player's inventory and a rollback thread on Twitter.
Unsafe
function Trade:Process(p1, p2, amt)
    p1.Cash.Value -= amt
    wait(2)          -- anything can happen here
    p2.Cash.Value += amt
end
Atomic
function Atomic:Tx(key, fn)
    DataStore:UpdateAsync(key, function(d)
        -- commits or it never happened
        return fn(d)
    end)
end

Seen enough?

All of that is hand written. It runs a lot better inside an actual game than it does in a browser tab.

Hire me Back to the work