Proves Sperner’s Lemma with a discrete degree argument that counts oriented RGB triangles.
Author
Apurva Nakade
Published
May 25, 2025
// VM is the shared utility library, loaded globally via _includes/head-scripts.html (js/**). This page uses VM.discreteMath.barycentricTriples, VM.discreteMath.subTriangleTriples, VM.discreteMath.spernerColor, VM.discreteMath.randomColor, VM.discreteMath.triangulationEdges, VM.discreteMath.pairColor, VM.discreteMath.triangleFillColor, VM.discreteMath.vertexColor.VM =window.VM
This is my new favorite proof of Sperner’s lemma. In the following app the vertices are slowly morphed to the “cardinal” R, G, B vertices with time while preserving the adjacency relations. Only the rainbow (RGB) triangles are shaded — everything else is left unfilled. Both orientations use the same purple, distinguished by shading: light, ⟋-hatched triangles are oriented counter-clockwise and dark, ⟍-hatched triangles are oriented clockwise. Can you come up with the proof yourself without peeking below? You can try reducing the number of subdivisions to get a sense of what’s going on.
// Colors every vertex red/green/blue at t=0, moves each vertex linearly// toward its own color's "cardinal" vertex as t goes from 0 to 1, and// re-derives the (possibly degenerate) sub-triangles at each t.functioncreateTriangleMorpher(N, vertices, assignColor) {const baryCoords = VM.discreteMath.barycentricTriples(N)const buildColorMap = () => {const map =newMap()for (const [a, b, c] of baryCoords) { map.set(`${a},${b},${c}`,assignColor(a, b, c)) }return map }const interpolatePoints = (t, colorMap) => {const [A, B, C] = verticesconst vertexMap = { red: C,green: A,blue: B }const morphedPoints = []for (const [a, b, c] of baryCoords) {const sum = a + b + cconst x0 = (a * A.x+ b * B.x+ c * C.x) / sumconst y0 = (a * A.y+ b * B.y+ c * C.y) / sumconst color = colorMap.get(`${a},${b},${c}`)const target = vertexMap[color] morphedPoints.push({x: (1- t) * x0 + t * target.x,y: (1- t) * y0 + t * target.y, color,barycentricTriple: [a, b, c] }) }const morphedMap =newMap()for (const p of morphedPoints) morphedMap.set(p.barycentricTriple.join(','), p)return { morphedPoints, morphedMap } }const generateSubTriangles = morphedMap => {const triangles = []for (const coordinates of VM.discreteMath.subTriangleTriples(N)) {const vertex_colors = []for (const c of coordinates) vertex_colors.push(morphedMap.get(c.join(',')).color) triangles.push({ coordinates, vertex_colors }) }return triangles }return { baryCoords, buildColorMap, interpolatePoints, generateSubTriangles }}
// simplerAssignColor deliberately violates Sperner's condition: only the// three outer vertices get a fixed color, every other point (including// boundary points) is fully random -- used as a "what goes wrong" foil for// the correctly-colored triangulation (VM.discreteMath.spernerColor) below.simplerAssignColor = (a, b, c) => {if (a ===0&& b ===0) return'red'if (b ===0&& c ===0) return'green'if (c ===0&& a ===0) return'blue'return VM.discreteMath.randomColor()}
fillColorGroups = {const group1 =newSet(['RGB','GBR','BRG'])const group2 =newSet(['RBG','BGR','GRB'])return { group1, group2 }}// Whether an RGB triangle's vertices, read in their own (counterclockwise)// order, visit the colors in the same cyclic order as the outer triangle// ('ccw') or the reverse ('cw'). Returns null for a non-rainbow triangle.triangleOrientation = vertexColors => {const map = { red:'R',green:'G',blue:'B' }let key =''for (const c of vertexColors) key += map[c.toLowerCase()] ||''if (fillColorGroups.group1.has(key)) return'ccw'if (fillColorGroups.group2.has(key)) return'cw'returnnull}
// One color (the same palette purple, accent2, used for rainbow triangles// on every Sperner's lemma page -- passed in by the caller, which holds the// current theme's snapshot) for both orientations -- distinguished by// shading, not hue: counter-clockwise is a light diagonal hatch sloped one// way, clockwise is a darker, denser hatch sloped the other way. IDs are// unique per call since this page renders two comparison panels in the// same document, and SVG pattern references are resolved by document-wide// id.functionmakeOrientationDefs(ccwId, cwId, color) {const svgNS ="http://www.w3.org/2000/svg";const defs =document.createElementNS(svgNS,"defs");functionhatch(id, angleDegrees, shadeOpacity) {const pattern =document.createElementNS(svgNS,"pattern"); pattern.setAttribute("id", id); pattern.setAttribute("width","8"); pattern.setAttribute("height","8"); pattern.setAttribute("patternUnits","userSpaceOnUse"); pattern.setAttribute("patternTransform",`rotate(${angleDegrees})`);const background =document.createElementNS(svgNS,"rect"); background.setAttribute("width","8"); background.setAttribute("height","8"); background.setAttribute("fill", color); background.setAttribute("fill-opacity",String(shadeOpacity)); pattern.appendChild(background);const stripe =document.createElementNS(svgNS,"line"); stripe.setAttribute("x1","0"); stripe.setAttribute("y1","0"); stripe.setAttribute("x2","0"); stripe.setAttribute("y2","8"); stripe.setAttribute("stroke", color); stripe.setAttribute("stroke-width","3"); stripe.setAttribute("stroke-opacity",String(Math.min(1, shadeOpacity +0.35))); pattern.appendChild(stripe); defs.appendChild(pattern); }hatch(ccwId,45,0.25);// light shade, ⟋hatch(cwId,-45,0.6);// dark shade, ⟍return defs;}
// `colors` is the caller's VM.plotting.colors() snapshot (chartColors), so// a plot cell that passes it re-runs, and repaints, on a theme toggle.functionplotTriangles(triangles, morphedPoints, N, colors) {// Edges between differently-colored (morphed) vertices, in the shared// amber/teal/purple pair palette used elsewhere for mixed edges.const mixedEdges = [];for (const { a, b } of VM.discreteMath.triangulationEdges(N)) {const pa = morphedPoints.morphedMap.get(a.join(','));const pb = morphedPoints.morphedMap.get(b.join(','));const color = VM.discreteMath.pairColor(pa.color, pb.color);if (!color) continue; mixedEdges.push({ x1: pa.x,y1: pa.y,x2: pb.x,y2: pb.y, color }); }const uid =Math.random().toString(36).slice(2);const ccwId =`hatch-ccw-${uid}`;const cwId =`hatch-cw-${uid}`;const fillFor = tri => {const orientation =triangleOrientation(tri.vertex_colors);if (orientation ==='ccw') return`url(#${ccwId})`;if (orientation ==='cw') return`url(#${cwId})`;// Not a rainbow triangle: no fill, so the rainbow triangles stay the focus.return VM.discreteMath.triangleFillColor(tri.vertex_colors); };const features = [];for (const tri of triangles) {const ring = [];for (const c of tri.coordinates) {const p = morphedPoints.morphedMap.get(c.join(',')); ring.push([p.x, p.y]); } ring.push(ring[0]); features.push({type:"Feature",geometry: { type:"Polygon",coordinates: [ring] },properties: { fillColor:fillFor(tri) } }); }// A diagram, not a graph: no axes, and a height chosen so the unit-wide// equilateral triangle is drawn to scale (Plot stretches y to fill// whatever height it is given otherwise).const width =800;const margin =20;const height = (width -2* margin) *Math.sqrt(3) /2+2* margin;const plot = Plot.plot(VM.plotting.plotOptions({ width, height, margin,grid:false,x: {domain: [0,1],axis:null},y: {domain: [0,Math.sqrt(3) /2],axis:null},marks: [ Plot.geo({ type:"FeatureCollection", features }, {fill: d => d.properties.fillColor,stroke: colors.ink,strokeWidth:1 }), Plot.link(mixedEdges, {x1:"x1",y1:"y1",x2:"x2",y2:"y2",stroke:"color",strokeWidth:3 }), Plot.dot(morphedPoints.morphedPoints, {x:"x",y:"y",fill: d => VM.discreteMath.vertexColor(d.color),r:4 }) ] }));let svgRoot = plot;if (plot.tagName!=='svg') svgRoot = plot.querySelector('svg');if (svgRoot) svgRoot.prepend(makeOrientationDefs(ccwId, cwId, colors.accent2));return plot;}
viewof N = {const slider = Inputs.range([2,15], {step:1,value:10,label:"Number of subdivisions"}) slider.classList.add("ojs-fill")return slider}
viewof t = {const slider = Inputs.range([0,1], {step:0.01,value:0.15,label:"Time"}) slider.classList.add("ojs-fill")return slider}
// Referenced (but otherwise unused) inside colorMapValid/colorMapInvalid// below so that clicking the button re-runs both panels' random color// assignment together, without moving either slider.viewof comparisonRegenerate = {const button = Inputs.button("Regenerate colors", {value:0,reduce: v => v +1}) button.classList.add("ojs-auto")return button}
// Side by side, with each plot captioned above (stacking into one column// on a phone-width viewport, where two half-width panels would be too// small to read). .sperner-compare is what keeps the pair a row in// fullscreen, where styles.css's letterbox chain otherwise turns every// wrapper around a figure into a column (see the page-local <style> block).html`<div class="sperner-compare" style="display: flex; flex-wrap: wrap; justify-content: center; gap: 1rem;"> <div style="flex: 1 1 280px; min-width: 0;"><p><b>Sperner's condition satisfied</b></p>${plotValid}</div> <div style="flex: 1 1 280px; min-width: 0;"><p><b>Sperner's condition violated</b></p>${plotInvalid}</div></div>`
Let \(v^R\), \(v^G\), and \(v^B\) be the vertices of the original triangle, colored red, green, and blue, respectively.
Consider a triangulation \(\Delta\) of the triangle \((v^R, v^G, v^B)\). We’ll use the variable \(\delta\) to refer to the smaller triangles within this triangulation \(\Delta\). Assume each \(\delta\) is oriented counterclockwise, consistent with the orientation of \((v^R, v^G, v^B)\). This orientation will be important when we define signed areas.
Time
We’ll now describe how this triangulation evolves over time.
Let \(t\) be a real number in \([0, 1]\). Let \(v\) be a vertex in the subdivision, and let \(c(v)\) denote the color of \(v\). Define: \[\begin{align*}
v_t = (1 - t) \cdot v + t \cdot v^{c(v)}
\end{align*}\] so that \(v_0 = v\) and \(v_1 = v^{c(v)}\). For each triangle \(\delta = (v^1, v^2, v^3) \in \Delta\), define: \[\begin{align*}
\delta_t = (v^1_t, v^2_t, v^3_t)
\end{align*}\] and let: \[\begin{align*}
\Delta_t = \{ \delta_t : \delta \in \Delta \}.
\end{align*}\] Note that \(\Delta_0 = \Delta\). However, \(\Delta_t\) is simply a collection of triangles — it is not necessarily a triangulation of any shape. In fact, the triangles may overlap, and the union of \(\Delta_t\) may not form a proper region. (The “Time” slider above is exactly this \(t\) — moving it slowly from \(0\) shows you \(\Delta_t\) for small \(t\), which is the regime the theorem below is about.)
Our goal is to understand how \(\Delta_t\) behaves as \(t\) changes.
(Signed) Areas
A key observation: if a triangle \(\delta\) is not an RGB triangle, then \(\delta_1\) becomes degenerate — it has zero area. Assume, for simplicity, that the area of the original triangle \((v^R, v^G, v^B)\) is 1. This allows us to use area as a tool to identify RGB triangles.
NoteTheorem: RGB triangles via area
A triangle \(\delta\) is RGB if and only if \(\text{area}(\delta_1) = 1\).
We can rephrase Sperner’s Lemma in terms of area:
NoteConjecture: Sperner’s lemma, restated in terms of area
In fact, we will prove the following stronger statement:
NoteConjecture: Signed area at \(t = 1\)
\[\begin{align*}
|\Delta_1| = 1,
\end{align*}\] where \[\begin{align*}
|\Delta_t| = \sum_{\delta \in \Delta} |\delta_t|,
\end{align*}\] and \(|\delta_t|\) denotes the signed area of the triangle \(\delta_t\).
One way to define the signed area of a counterclockwise-oriented triangle \(\delta = (v^1, v^2, v^3)\) is as: \[\begin{align*}
|\delta| = \det \begin{bmatrix} v^2 - v^1 & v^3 - v^1 \end{bmatrix}
\end{align*}\] This is the determinant of a \(2 \times 2\) matrix, and therefore the signed area of \(\delta_t\) is a quadratic function of \(t\).
NoteTheorem: Signed area is quadratic in \(t\)
The signed area \(|\Delta_t|\) is a quadratic function of \(t\).
Sperner’s Condition
At time \(t = 0\), \(\Delta_0\) is a triangulation of \((v^R, v^G, v^B)\), so: \[\begin{align*}
|\Delta_0| = 1.
\end{align*}\]
If the triangulation satisfies Sperner’s condition, then for small values of \(t > 0\), \(\Delta_t\) continues to form a triangulation of the original triangle. This is the only step in the proof where we use Sperner’s condition!
NoteTheorem: The triangulation persists for small \(t\)
If the coloring satisfies Sperner’s condition, then there exists \(\varepsilon > 0\) such that \[\begin{align*}
|\Delta_t| = 1 \quad \text{for all } t \in [0, \varepsilon].
\end{align*}\]
But since \(|\Delta_t|\) is a quadratic function and is constant on an interval, it must be constant everywhere. This gives us the full strength of Sperner’s Lemma:
NoteTheorem: Signed area is constant in \(t\)
If the coloring satisfies Sperner’s condition, then \(|\Delta_t|\) is constant in \(t\). In particular, \[\begin{align*}
|\Delta_1| = |\Delta_0| = 1.
\end{align*}\]
By the theorem above, every non-RGB triangle contributes \(0\) to \(|\Delta_1| = \sum_\delta |\delta_1|\), so only RGB triangles matter. For an RGB triangle \(\delta\), the vertices of \(\delta_1\) are exactly \(v^R, v^G, v^B\) in some order, so \(|\delta_1| = \pm 1\): the sign is \(+1\) if \(\delta\)’s vertices, read in its own (counterclockwise) order, visit the colors in the cyclic order \(R \to G \to B\) — the same cyclic order as \((v^R, v^G, v^B)\) itself — and \(-1\) if they visit them in the reverse cyclic order \(R \to B \to G\). (This is exactly the light-vs-dark hatching in the app above: light ⟋-hatched triangles are the \(+1\)’s, dark ⟍-hatched triangles are the \(-1\)’s.) Summing over all triangles, \[\begin{align*}
|\Delta_1| = |\{\text{counterclockwise `RGB`}\}| - |\{\text{clockwise `RGB`}\}|.
\end{align*}\] Combined with \(|\Delta_1| = 1\) from the theorem above, this gives us the following strengthening:
NoteCorollary: Sperner’s lemma, strong form
If the coloring satisfies Sperner’s condition, then \[\begin{align*}
|\{ \text{counterclockwise oriented RGB triangles} \}|
=
|\{ \text{clockwise oriented RGB triangles} \}| + 1.
\end{align*}\]
Sperner’s condition is crucial in asserting that \(|\Delta_t|\) is constant. If the condition is violated, \(\Delta_t\) is no longer a triangulation of the original triangle — even for small \(t > 0\) — and thus we cannot conclude that \(|\Delta_t|\) remains constant.
Questions
The combinatorial proof of Sperner’s lemma does not require the full Sperner’s condition. All it needs is that the number of boundary RG edges is odd. This condition is guaranteed by Sperner’s condition but is weaker than it. Is there a way to extend the above proofs for this alternate hypothesis?
References
McLennan, Andrew, and Rabee Tourky. “Using Volume to Prove Sperner’s Lemma.” Economic Theory 35, no. 3 (2008): 593–97. http://www.jstor.org/stable/40282878.
Visual Math Lab would like to count page visits. It tells us which
topics are being read, so that effort goes where it is most useful.
No advertising, no personal information, and nothing shared with third
parties. The mathematics you enter never leaves your browser.
What is collected.
No cookies are stored by this site, at any point, for any purpose. This
notice is shown because GDPR and the ePrivacy Directive require opt-in
consent before any tracking technology runs, cookies or not.
Built for a bigger screen
This site is meant for larger screens. If a plot isn't fully visible, tap its fullscreen button.