The other day i was doom scrolling (against my will) through instagram like the millennial i am when i came across this reel:

Instagram

Santi FiorinoMay 28, 2026

Santi Fiorino (@santifiorino.py) • Instagram reel

87K likes, 238 comments - santifiorino.py on May 29, 2026: ”🗣️ Diagramas de Voronoi”.


It’s a quick video about a Voronoi diagrams and one of it’s real world applications. You’ve probably have heard about voronoi diagrams, but in case you need help remembering, here’s the wikipedia definition:

A Voronoi diagram with black sites surrounded by irregular, brightly colored cells.

Each black site owns the colored region closest to it.

WikipediaVoronoi diagramSimplest case

[...] given a finite set of points {p₁, … pₙ} in the Euclidean plane. In this case, each point pₖ has a corresponding cell Rₖ consisting of the points in the Euclidean plane for which pₖ is the nearest site: the distance to pₖ is less than or equal to the minimum distance to any other site pⱼ.

Wikipedia contributorsSource

Which instantly made me thing about dynamic split screens. There are many tutorials online, and different ways of achieving this, but i just wanted to go with the basic approach.

First things first, in games “split screen” refers to the technique of dividing the screen into multiple viewports so each local player can have an independent camera, there are many ways of doing this but the most basic ones are: Vertical Split and Horizontal Split.

Vertical Split / vertical divider / left-right views

+---------------+---------------+
|               |               |
|   player 1    |    player 2   |
|               |               |
|               |               |
+---------------+---------------+
Horizontal Split / horizontal divider / top-bottom views

+-------------------------------+
|           player 1            |
|                               |
+-------------------------------+
|           player 2            |
+-------------------------------+

These type of splits, on their simplest implementation can be defined as:

  1. select player 1’s rectangle;
  2. draw the entire world through player 1’s camera;
  3. select player 2’s rectangle;
  4. draw the same world through player 2’s camera;
  5. clear the scissor and draw the dividing line.
local function drawViewport(player, x, y, width, height, backgroundColor)
	-- Anything outside this rectangular viewport is discarded.
	love.graphics.setScissor(x, y, width, height)

	if backgroundColor then
		love.graphics.setColor(
			backgroundColor[1],
			backgroundColor[2],
			backgroundColor[3],
			backgroundColor[4] or 1
		)
		love.graphics.rectangle("fill", x, y, width, height)
		love.graphics.setColor(1, 1, 1, 1)
	end

	love.graphics.push()

	-- Move the viewport center to the origin, then move the world in the
	-- opposite direction of the camera. The tracked player becomes centered.
	love.graphics.translate(x + width / 2, y + height / 2)
	love.graphics.translate(-player.x, -player.y)

	drawWorld()

	love.graphics.pop()
	love.graphics.setScissor()
end
Drawing the split screenlua
1 function love.draw()
2 local width, height = love.graphics.getDimensions()
3 local leftWidth = math.floor(width / 2)
4
5 drawViewport(players[1], 0, 0, leftWidth, height, { 0.12, 0.04, 0.04 })
6 drawViewport(players[2], leftWidth, 0, width - leftWidth, height, { 0.04, 0.06, 0.12 })
7
8 love.graphics.setColor(1, 1, 1)
9 love.graphics.setLineWidth(3)
10 love.graphics.line(leftWidth, 0, leftWidth, height)
11 love.graphics.setLineWidth(1)
12 end
Step 1 of 2: Vertical
Example of love2d Vertical Split screen.

This is already useful and shippable! But it has some limitations that a niche set of games might want to splore.

  1. current definition can only be strictly vertical or horizontal
  2. the screen is still divided even when both players are in the same place.
  3. What would happen if there are more than 2 players?

Let’s explore a possible solution for the first:

Perpendicular bisector

WikipediaBisectionDefinition

The perpendicular bisector of a line segment is a line which meets the segment at its midpoint perpendicularly.

Wikipedia contributorsSource
Perpendicular bisector of a line segment

By Ag2gaeh - Own work, CC BY-SA 4.0, Link


Basically if you have a point A and a point B, the line that divides these two points is the perpendicular bisector, which fives the impression that this concept can be used for a dynamic camera. Giving player A and player B, let’s divide the screen in their perpendicular bisection.

Perpendicular bisectorlua
1 local function perpendicularBisector(a, b)
2 -- Midpoint
3 local mx = (a.x + b.x) / 2
4 local my = (a.y + b.y) / 2
5
6 -- Direction from A to B
7 local dx = b.x - a.x
8 local dy = b.y - a.y
9
10 -- Rotate 90 degrees
11 local px = -dy
12 local py = dx
13
14 -- normalize dx, dy if you want length to represent an actual pixel distance:
15
16 local len = math.sqrt(px * px + py * py)
17
18 if len < 0.000001 then
19 return nil
20 end
21
22 px = px / len
23 py = py / len
24
25 return {
26 x = mx,
27 y = my,
28 dx = px,
29 dy = py,
30 }
31 end
Step 1 of 2: perpendicularBisector
Example output of a Perpendicular bisector line between two random points.

There we go! We are halfway… welp… kind of. This is still just a line and while it divides the screen, it does not create polygonal viewports. love.graphics.setScissor is not enough here because it only clips rectangles.

We first need to calculate the two polygons created by the perpendicular bisector. Once calculated, those polygons can be used as stencil masks. And this is where the concept of a Voronoi diagram comes in.

The math

Determine which side a point belongs to

We need the part of the screen closer to aa than to bb:

local function dist2(p, q)
	local dx = p.x - q.x
	local dy = p.y - q.y

	return dx * dx + dy * dy
end
local function inside(p)
	return dist2(p, a) <= dist2(p, b)
end

Segment-bisector intersection

HabH_{ab} is a half-plane containing all the points closer to a. Clipping the screen rectangle RR by it gives aa‘s region:

Va=RHab.V_a=R\cap H_{ab}.

Reversing the sites gives bb‘s region:

Vb=RHba.V_b=R\cap H_{ba}.

But that’s way too fancy, in simple terms, we need a way to find the intersection of our Perpendicular line and our viewports (planes).

There is a formula for this:

t=cnp1n(p2p1).t=\frac{c-n\cdot p_1}{n\cdot(p_2-p_1)}.

and it’s code implementation:

local function intersection(p1, p2, a, b)
	local nx = b.x - a.x
	local ny = b.y - a.y

	local c =
		(b.x * b.x + b.y * b.y - a.x * a.x - a.y * a.y)
		/ 2

	local dx = p2.x - p1.x
	local dy = p2.y - p1.y

	local denominator = nx * dx + ny * dy

	if math.abs(denominator) < 0.000001 then
		return nil
	end

	local t = (c - nx * p1.x - ny * p1.y) / denominator

	return {
		x = p1.x + dx * t,
		y = p1.y + dy * t,
	}
end

The denominator is zero when the segment and bisector are parallel. The implementation returns nil when its absolute value is below an epsilon.

Convex polygon clipping

With the segment-bisector intersection in place, we can clip the entire viewport polygon. We’ll walk around its edges and keep only the portions inside HabH_{ab}. Whenever an edge crosses the bisector, we insert the intersection point so the remaining vertices form a new closed polygon.

local function clip(polygon, a, b)
	local result = {}

	local function inside(p)
		return dist2(p, a) <= dist2(p, b)
	end

	for i = 1, #polygon do
		local current = polygon[i]
		local nextPoint = polygon[(i % #polygon) + 1]

		local currentInside = inside(current)
		local nextInside = inside(nextPoint)

		if currentInside and nextInside then
			-- IN → IN
			table.insert(result, nextPoint)

		elseif currentInside and not nextInside then
			-- IN → OUT
			local hit = intersection(current, nextPoint, a, b)

			if hit then
				table.insert(result, hit)
			end

		elseif not currentInside and nextInside then
			-- OUT → IN
			local hit = intersection(current, nextPoint, a, b)

			if hit then
				table.insert(result, hit)
			end

			table.insert(result, nextPoint)

			-- OUT → OUT adds nothing.
		end
	end

	return result
end

It’s a implementation of Sutherland-Hodgman algorithm. It takes a polygon and cuts away the part outside a boundary. Or as Wikipedia says:

WikipediaSutherland-Hodgman algorithmDefinition

It works by extending each line of the convex clip polygon in turn and selecting only vertices from the subject polygon that are on the visible side.

Wikipedia contributorsSource

Imagine this is the screen

Before clipping:             After clipping:

+------------------+         +---------+
|        /         |         |        /
|       /          |   →     |       /
|      /           |         |      /
+------------------+         +-----/

It walks around the polygon one edge at a time and asks whether each endpoint is inside or outside the area we want to preserve:

Inside → Inside    Keep the edge
Inside → Outside   Keep up to the boundary
Outside → Inside   Keep from the boundary onward
Outside → Outside  Discard the edge

See “inside” as:

distanceToA <= distanceToB

tl;dr;

For viewport A, it stars with the entire screen, and then removes everything that is closer to B. Doing the opposite give us B’s polygon.

Generate both cells

local rectangle = {
	{ x = 0, y = 0 },
	{ x = width, y = 0 },
	{ x = width, y = height },
	{ x = 0, y = height },
}

-- A’s polygon keeps everything closer to A:
polygonA = clip(rectangle, a, b)

-- B’s polygon reverses the comparison:
polygonB = clip(rectangle, b, a)

If you need help drawing the polygon, i got you:

local function drawPolygon(polygon, mode)
	if #polygon < 3 then
		return
	end

	local vertices = {}

	for _, p in ipairs(polygon) do
		table.insert(vertices, p.x)
		table.insert(vertices, p.y)
	end

	love.graphics.polygon(mode or "line", vertices)
end
Convex polygon clipping.

This give us as result a dynamic split screen where the division moves with the player movements. This type of screen is useful for coop games where player are separated in space but they world position can add additional cues to solve puzzles, or let’s be honest, it looks cool enough to maybe use in a gamejam. But it still has the same quirk as our fixed splits: Even when players are near, there are still multiple viewports instead of a shared one.

Shared mode, split mode, and hysteresis

Our goal is simple, if the players are close, then no split screen should be presented, just one camera with both of them there, if they are not close, then the game would enter in split mode and show the two viewports, using the hysteresis? what the heck is that? Tbh this is just a fancy way of naming thresholds. Yes, plural, basically having one for entering a state and one for leaving a state.

distance

0                 220                   300
├──────────────────┼─────────────────────┼──────────>
       merge          keep current state      split
Animated two-player Voronoi split-screen demonstration

Imagine we have just one threshold,

split = distance > 300

Very small movements would cause the instant switch between split mode and shared mode.

distance 299 → shared
distance 301 → split
distance 298 → shared
distance 302 → split

Trust me, not pretty.

Simple example of camera splitting and shared viewport with one threshold.

With hysteresis:

distance 301 → split
distance 290 → remain split
distance 250 → remain split
distance 219 → merge
distance 250 → remain shared
distance 301 → split

And it’s really easy to implement:

local splitDistance = 300
local mergeDistance = 220
local split = false

local function updateSplitState(a, b)
	local distanceSquared = dist2(a, b)

	if split then
		-- We are already split.
		-- Only merge when the players are closer than 220.
		if distanceSquared < mergeDistance * mergeDistance then
			split = false
		end
	else
		-- We are currently sharing the screen.
		-- Only split when the players are farther than 300.
		if distanceSquared > splitDistance * splitDistance then
			split = true
		end
	end
end

Now, the shared / split behavior. Let’s go with the basic horizontal / vertical examples. So let’s start with the most basic step: Shared mode.

local function drawSharedCamera()
	local width, height = love.graphics.getDimensions()

	local camera = {
		x = (players[1].x + players[2].x) / 2,
		y = (players[1].y + players[2].y) / 2,
	}

	drawViewport(camera, 0, 0, width, height)
end

This is just a single camera where both players are present. Drawing the split screen (vertical/horizontal) is the exact code from above but extracted into it’s own function.

function love.update(dt)
  -- update players positions
  updateSplitState(players[1], players[2]) -- which updates split
end


function love.draw()
	if not split then
		drawSharedCamera()
	elseif splitDirection == "vertical" then
		drawVerticalSplit()
	else
		drawHorizontalSplit()
	end
end
Simple example of camera splitting and shared viewport.

In the case of the dynamic split screen, something similar can be done, where we can reuse the split logic:

polygonA = clip(rectangle, a, b)
polygonB = clip(rectangle, b, a)

And when shared, both players use the complete screen:

polygonA = rectangle
polygonB = rectangle

Resulting in:

Dynamic example of camera splitting and shared viewport.

Where to go from here

Who would have guessed that wasting time on instagram can sometimes be a productive thing? Not me fore sure. Voronois are fun, before this my main exposure to it was in shader, to be precised, on toon water shaders trying to replicate wind waker ocean. lol

So, everything above handles two players, I had some code for more than 2 players, but I’m not trying to write a book on this post. But basically, for (N) players, start each player’s cell as the full screen and clip it against every other player:

The rendering process stays the same, we just perform more clipping passes. I’ll leave that implementation for another day, but you can see the idea in action in this multi-site Voronoi example on Shadertoy.

More than 2 players.