Add TCP/UDP post, explain dN/dh, voice edits

This commit is contained in:
Graham McIntire 2026-04-22 13:07:45 -05:00
parent c1f07190f3
commit 031d04ed3f
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 774 additions and 6 deletions

View file

@ -4,7 +4,7 @@ Working scratch list. Same format as the NLOS post: vanilla JS, 2D canvas, scene
## Electronics
- **How a transistor actually amplifies.** Draggable base current / gate voltage on a NMOS or NPN schematic, live I-V curve on the side, carrier flow animation in the channel or base region. Payoff: "small signal gain" stops being a formula and becomes something you can see.
- ~~**How a transistor actually amplifies.** Draggable base current / gate voltage on a NMOS or NPN schematic, live I-V curve on the side, carrier flow animation in the channel or base region. Payoff: "small signal gain" stops being a formula and becomes something you can see.~~ *Written 2026-04-23: [how-a-transistor-amplifies](/blog/how-a-transistor-amplifies/).*
- **LC resonance from scratch.** Tank circuit with sliders for L, C, and drive frequency. Show the phasors rotating, impedance magnitude/phase sweeping, and a scope trace of voltage vs current. Payoff scene: pumping the tank at resonance and watching it ring.
- **An oscillator starting up.** Colpitts or similar. Start from noise, show the limit cycle forming, let the reader nudge the loop gain and see it either die off or run away.
- **Feedback loop stability.** Drag the poles and zeros around on the s-plane, watch the step response and the bode plot change. The moment a pole crosses into the right half plane and everything goes sideways.
@ -14,7 +14,7 @@ Working scratch list. Same format as the NLOS post: vanilla JS, 2D canvas, scene
## Computers / networking
- **A TCP connection, from the packets' perspective.** Client on the left, server on the right, sequence numbers and window sizes visible, user can introduce loss or latency and watch congestion control react. Slow start, fast recovery, the window collapsing on a single dropped packet.
- ~~**A TCP connection, from the packets' perspective.** Client on the left, server on the right, sequence numbers and window sizes visible, user can introduce loss or latency and watch congestion control react. Slow start, fast recovery, the window collapsing on a single dropped packet.~~ *Written 2026-04-27 (scheduled): [how-tcp-and-udp-work](/blog/how-tcp-and-udp-work/). Also covers UDP.*
- **How DNS actually resolves a name.** Recursive resolver, root, TLD, authoritative, caching. User types a hostname, watches the queries fan out with their TTLs.
- **What a CPU does per cycle.** A tiny toy ISA executing step by step. Pipeline stages, hazards, stalls. Adds a register renamer in a second scene and you get a decent intuition for why modern CPUs are fast.
- **Cache coherence (MESI).** Two cores, a shared line of memory, user issues reads and writes, arrows show the invalidations flying around. The reason your "obviously correct" lock-free code isn't is usually on this graph somewhere.

View file

@ -20,7 +20,7 @@ A bipolar junction transistor is a three-terminal semiconductor device: a sandwi
"N-type" and "p-type" are just two versions of silicon that have been doped with impurities to give them either a surplus of free electrons (n-type) or a surplus of the absence of free electrons, which solid-state physicists call *holes* and treat like positive charge carriers that move around independently. The *bipolar* in the name is an admission that both kinds of carriers end up doing useful work inside the device. Most semiconductor designs try to avoid that. The BJT embraces it.
The practical consequence is the behavior in the rest of this post: a small current flowing into the base lets a much larger current flow from the collector to the emitter. That is the whole trick, and once you accept that it happens, the amplifier parts follow.
The practical consequence is simple: a small current flowing into the base lets a much larger current flow from the collector to the emitter. That is the whole trick, and once you accept that it happens, the amplifier parts follow.
## A transistor is a valve
@ -76,7 +76,7 @@ The voltage gain of this common-emitter stage is then:
A BJT biased at 2 mA with a 1 kΩ collector resistor has a transconductance of about 77 mA/V and a voltage gain of about 77. Run it at 5 mA and the gain climbs to around 190. Run it at 100 μA and you have a gain of about 4 and should probably consider a different amplifier topology. None of this depends on β, by the way, which is why a well-designed amplifier doesn't care very much that β varies from part to part.
Everything else, and there is a lot of *else*, is decoration on this core. Bypass capacitors, emitter degeneration, cascode stages, differential pairs, current mirrors, Darlingtons, bootstraps. They all exist to make the basic idea in this post more linear, more bandwidth-y, more stable with temperature, or less dependent on a specific transistor's specific quirks. But underneath the decoration there's still a valve controlling a current, and a resistor turning that current into a voltage, and the voltage is bigger than the one you started with. The rest is bookkeeping.
Everything else, and there is a lot of *else*, is decoration on this core. Bypass capacitors, emitter degeneration, cascode stages, differential pairs, current mirrors, Darlingtons, bootstraps. They all exist to make the basic idea above more linear, more bandwidth-y, more stable with temperature, or less dependent on a specific transistor's specific quirks. But underneath the decoration there's still a valve controlling a current, and a resistor turning that current into a voltage, and the voltage is bigger than the one you started with. The rest is bookkeeping.
</div>

View file

@ -0,0 +1,79 @@
+++
title = "How TCP actually talks (and why UDP doesn't bother)"
date = 2026-04-27
draft = false
+++
<link rel="stylesheet" href="/css/scene.css">
<div class="scene-host" data-scene-host>
TCP and UDP are the two transport protocols that carry essentially all internet traffic that isn't doing something exotic. They live at the same layer, speak to the same lower-level IP plumbing, and have almost completely opposite personalities.
TCP is a protocol that wants very badly to behave. It sets up connections, numbers every byte, acknowledges what it receives, retransmits what gets lost, slows down when the network complains, and eventually closes the connection neatly. If TCP were a person, it would arrive at meetings early and send a follow-up email.
UDP is a protocol that has politely declined to do any of that. It takes a blob of data, wraps it in a tiny header, and throws it at the network. Whether it gets there is someone else's problem. If UDP were a person, it would be the one who texts "sent" without checking whether you got the message.
Both are the right answer for different things, and the rest of what follows is an attempt to make it clear which is which, and why.
## TCP opens with a handshake
Before any data moves, TCP wants to establish that both ends exist, are talking to each other on purpose, and agree on where to start numbering. This takes three packets.
<div id="scene-handshake"></div>
The client sends a SYN with its chosen initial sequence number. The server replies with a SYN-ACK carrying its own sequence number and acknowledging the client's. The client sends one more ACK to confirm. Now both sides know each other is alive, and both sides know a starting byte number, and the connection is officially ESTABLISHED. If you have ever wondered why `curl` appears to hang for a beat before any bytes come back, this is part of what it's doing.
Notice that no data moves during the handshake. These three packets are pure ceremony. That ceremony is also why TCP has measurable setup latency, which is a big deal if you're connecting to a server on the other side of the planet and your RTT is 300 ms.
## Sequence numbers and ACKs keep things honest
Once the connection is open, every byte sent over it has a sequence number. The receiver's job is to keep track of what it's got and tell the sender what it's expecting next. Those are the ACKs. If a segment gets lost, the receiver keeps asking for the byte it never saw, and the sender eventually figures out something is wrong and sends the missing segment again.
<div id="scene-data"></div>
Drag the slider through the loss scenarios. In the no-loss case ACKs march back one after another, each asking for the next byte. When a segment goes missing, the receiver keeps asking for the same byte over and over (duplicate ACKs), and the sender retransmits it. Cumulative ACKs mean a single "ack=5" implicitly confirms everything up to byte 4, which is why TCP can survive one lost ACK without restarting the world.
The retransmission is how TCP keeps the "reliable" in "reliable byte stream." From the application's perspective, the bytes either show up in order or the connection dies. There is no "most of the bytes arrived."
## A sliding window turns one pipe into many
Sending one segment and then waiting for its ACK is absurdly slow over any network with real distance to cover. TCP instead allows multiple segments to be in flight at once, up to a window size, and only blocks when that window is full.
<div id="scene-window"></div>
Bigger window, more packets sitting on the wire at once, higher throughput. Halve the RTT and you halve the time to send a fixed amount of data even without changing the window. Double the window and you roughly double throughput, up to the point where some bottleneck in the network becomes the actual limit. The math here (throughput ≈ window / RTT) is the reason your 1 ms home LAN feels infinitely faster than your 150 ms satellite link, even though both technically support "gigabit."
## Congestion control is TCP worrying about bandwidth
TCP has no way of knowing, in advance, how much bandwidth is actually available on any given path. It finds out by experiment. It starts sending slowly, ramps up aggressively until something breaks, retreats, and then ramps up again. This cycle never stops for the life of the connection.
<div id="scene-congestion"></div>
The window grows exponentially during *slow start* (which despite the name is not slow, it's just starting small) until it reaches the *slow-start threshold*. After that it creeps up linearly, adding one segment per round trip. Eventually a packet gets dropped, which TCP interprets as the network saying "enough." The window is cut in half, the threshold is moved to the new value, and the cycle starts over. TCP is a protocol that lives its entire life anxious about whether it should be going faster.
This is the source of the famous sawtooth pattern in any graph of TCP throughput over time. It is also why networks with high but variable delay (cellular, satellite, transoceanic) can be fairly miserable for TCP: every burst of delay looks like congestion, the window collapses, and throughput never really gets to stretch out.
## UDP: the opposite approach
Everything above exists to provide the illusion of a reliable, ordered byte stream on top of a network that guarantees neither. UDP exists for people who don't want that illusion, because they have a better idea of what to do with the occasional lost packet than the kernel does.
<div id="scene-udp"></div>
Packets leave the sender. Some of them arrive. The ones that don't arrive are simply gone, and the sender has no idea anything went wrong. There are no ACKs, no retransmits, no sequence numbers that mean anything to the protocol itself, and no back-off. Applications that want any of those things build them on top.
This is wonderful for the applications that benefit from not paying for TCP's services. Real-time voice and video are the classic cases: the receiver would rather glitch for 20 ms than hear a one-second gap while a lost packet gets retransmitted. DNS is another: the query and the response fit in single packets, timeouts are sub-second, and re-querying is cheaper than keeping a connection open. Game state updates are a third: a position that's a quarter-second late is worse than no position at all, because by the time it arrives it's wrong.
## When you'd pick which
**Reach for TCP when** you want bytes to arrive intact and in order, you can tolerate whatever latency that costs, and the application doesn't want to think about packets at all. HTTP, SMTP, SSH, file transfer, databases, most RPC frameworks. The default.
**Reach for UDP when** late data is worse than lost data, when you care about the cost of a handshake, when you want to multicast to many recipients, or when you have a better reliability scheme than TCP's on top. Real-time media, DNS, NTP, SNMP, most online games, QUIC (which builds its own TCP-replacement on top of UDP specifically because UDP gets out of the way).
Most of the modern protocols people hear about lately (HTTP/3, QUIC, WebRTC) are on UDP. That isn't because TCP has stopped being useful. It's because these protocols want control over exactly the parts of the stack that TCP does not let you touch: connection setup, head-of-line blocking, multiplexing. UDP is the way you get a blank sheet of paper where TCP would otherwise fill in the details for you.
And if you find yourself staring at Wireshark wondering why a perfectly fine TCP connection is suddenly slow, there is a reasonable chance it is not slow at all, just anxiously deciding whether it should be going faster.
</div>
<script src="/js/tcpudp.js" defer></script>

View file

@ -20,7 +20,7 @@ Two antennas, flat ground, a hill in the middle. If the hill sticks above the li
<div id="scene-los"></div>
That model is how nearly every commercial point-to-point microwave link is designed. WISPs, utility SCADA, carrier backhaul, all of it. It hides two small lies that the rest of this post is mostly about. The Earth isn't flat, and the signal isn't a line.
That model is how nearly every commercial point-to-point microwave link is designed. WISPs, utility SCADA, carrier backhaul, all of it. It hides two small lies that the rest of what follows is mostly about. The Earth isn't flat, and the signal isn't a line.
## Earth gets in the way too
@ -72,9 +72,15 @@ The dashed line is the international standard atmosphere. Drag the slope and the
A horizontal ray in a medium whose refractive index falls with altitude bends downward. The tighter the gradient, the tighter the curve. Under the standard atmosphere the bend is gentle, about a quarter of Earth's own curvature, and engineers handle this by quietly pretending the Earth is 4/3 its real size and the rays are straight. Nobody points out that this is a slightly strange thing to do. That's the familiar k = 4/3 factor.
The slider below is labeled **dN/dh**, which reads as "the change in N per unit of altitude." It's just the slope of the refractivity curve from the previous scene, in N-units per kilometer. If N drops from 315 at the surface to 275 at 1 km up, dN/dh is 40 N/km. That's the international standard atmosphere, and it's also the zero point for everything that follows. More negative numbers mean refractivity is falling *faster* with altitude, which is what bends rays harder:
- **around 40 N/km**, standard. k ≈ 4/3, mildly bent rays, ordinary radio horizon.
- **below 79 N/km**, super-refraction. Rays bend meaningfully more than normal. A lot of VHF/UHF tropo propagation lives here.
- **below 157 N/km**, trapping. Rays curve tighter than the Earth itself curves away. The ray can't climb out anymore, and the atmosphere starts behaving less like an atmosphere and more like a waveguide.
<div id="scene-raytrace"></div>
Push the slope below 79 N/km and rays start bending faster than standard. A lot of VHF/UHF tropo propagation lives in that regime. Push below 157 N/km and rays curve tighter than the Earth curves away. The ray can't climb out anymore.
Drag dN/dh through those thresholds and watch the traced ray. Standard air, ray exits into space eventually. Super-refractive air, the ray stays close to the surface far longer. Trapping air, the ray never gets out at all, which is where the next scene picks up.
## The signal gets trapped

683
static/js/tcpudp.js Normal file
View file

@ -0,0 +1,683 @@
// TCP / UDP explainer. Vanilla JS + 2D canvas.
// Scoped to a single post via <link>/<script> in the markdown.
(() => {
'use strict';
const C = {
bg: '#1f232b',
panel: '#242932',
fg: '#d4d8df',
dim: '#5c6370',
grid: '#323844',
blue: '#61afef',
cyan: '#56b6c2',
green: '#98c379',
orange: '#d19a66',
red: '#e06c75',
yellow: '#e5c07b',
magenta: '#c678dd',
};
// ----- framework (same shape as transistor/nlos scenes) -----
function setupCanvas(canvas) {
const ctx = canvas.getContext('2d');
const dpr = Math.max(1, window.devicePixelRatio || 1);
const fit = () => {
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
fit();
new ResizeObserver(fit).observe(canvas);
return { ctx, getSize: () => ({ w: canvas.clientWidth, h: canvas.clientHeight }) };
}
function scene(id, { height = 360, controls = [], readout = [], caption = '', threeCol = false } = {}) {
const root = document.getElementById(id);
if (!root) return null;
root.classList.add('scene-box');
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = `${height}px`;
root.appendChild(canvas);
const readoutEl = document.createElement('div');
readoutEl.className = 'scene-readout';
const readoutSpans = {};
readout.forEach(r => {
const item = document.createElement('span');
item.className = 'scene-readout-item';
const label = document.createElement('span');
label.textContent = r.label + ':';
const val = document.createElement('span');
val.textContent = r.init ?? '';
item.appendChild(label); item.appendChild(val);
readoutEl.appendChild(item);
readoutSpans[r.key] = val;
});
if (readout.length) root.appendChild(readoutEl);
const controlsEl = document.createElement('div');
controlsEl.className = 'scene-controls' + (threeCol ? ' scene-controls--three' : '');
const values = {};
controls.forEach(c => {
const wrap = document.createElement('div');
wrap.className = 'scene-control';
const label = document.createElement('label');
const name = document.createElement('span'); name.textContent = c.label;
const valSpan = document.createElement('span'); valSpan.className = 'scene-value';
label.appendChild(name); label.appendChild(valSpan);
const input = document.createElement('input');
input.type = 'range';
input.min = c.min; input.max = c.max; input.step = c.step ?? 'any';
input.value = c.value;
values[c.key] = parseFloat(input.value);
const fmt = c.format || (v => v.toFixed(2));
valSpan.textContent = fmt(values[c.key]);
input.addEventListener('input', () => {
values[c.key] = parseFloat(input.value);
valSpan.textContent = fmt(values[c.key]);
});
wrap.appendChild(label); wrap.appendChild(input);
controlsEl.appendChild(wrap);
});
if (controls.length) root.appendChild(controlsEl);
if (caption) {
const cap = document.createElement('div');
cap.className = 'scene-caption';
cap.textContent = caption;
root.appendChild(cap);
}
const { ctx, getSize } = setupCanvas(canvas);
const setReadout = (key, text) => {
if (readoutSpans[key]) readoutSpans[key].textContent = text;
};
return { canvas, ctx, getSize, values, setReadout };
}
function clear(ctx, w, h) { ctx.clearRect(0, 0, w, h); }
function text(ctx, str, x, y, color = C.fg, align = 'left', baseline = 'alphabetic', size = 12) {
ctx.save();
ctx.fillStyle = color;
ctx.font = `${size}px system-ui, sans-serif`;
ctx.textAlign = align;
ctx.textBaseline = baseline;
ctx.fillText(str, x, y);
ctx.restore();
}
// ----- sequence-diagram primitives -----
// Draw a packet as a slanted arrow from (fromX, yStart) to (toX, yEnd).
// If lost === true, dash the line and draw an X near the midpoint where it gets dropped.
// dropFrac (0..1) controls how far along the loss happens.
function drawPacket(ctx, fromX, toX, yStart, yEnd, label, color, lost = false, dropFrac = 0.5) {
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1.8;
if (lost) {
ctx.setLineDash([4, 4]);
const mx = fromX + (toX - fromX) * dropFrac;
const my = yStart + (yEnd - yStart) * dropFrac;
ctx.beginPath(); ctx.moveTo(fromX, yStart); ctx.lineTo(mx, my); ctx.stroke();
ctx.setLineDash([]);
// X at drop point
ctx.strokeStyle = C.red;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(mx - 6, my - 6); ctx.lineTo(mx + 6, my + 6);
ctx.moveTo(mx - 6, my + 6); ctx.lineTo(mx + 6, my - 6);
ctx.stroke();
} else {
ctx.beginPath();
ctx.moveTo(fromX, yStart);
ctx.lineTo(toX, yEnd);
ctx.stroke();
// arrowhead
const dx = toX - fromX, dy = yEnd - yStart;
const L = Math.hypot(dx, dy);
const ux = dx / L, uy = dy / L;
const ax = toX - ux * 8;
const ay = yEnd - uy * 8;
const px = -uy, py = ux;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(toX, yEnd);
ctx.lineTo(ax + px * 4, ay + py * 4);
ctx.lineTo(ax - px * 4, ay - py * 4);
ctx.closePath();
ctx.fill();
}
// label, place near the midpoint, slightly above the line
const lx = (fromX + toX) / 2;
const ly = (yStart + yEnd) / 2 - 4;
text(ctx, label, lx, ly, color, 'center');
ctx.restore();
}
function drawTimelines(ctx, leftX, rightX, topY, botY, clientLabel = 'Client', serverLabel = 'Server') {
ctx.save();
ctx.strokeStyle = C.dim;
ctx.lineWidth = 1;
ctx.setLineDash([2, 4]);
ctx.beginPath();
ctx.moveTo(leftX, topY); ctx.lineTo(leftX, botY);
ctx.moveTo(rightX, topY); ctx.lineTo(rightX, botY);
ctx.stroke();
ctx.setLineDash([]);
// boxes at the top
ctx.fillStyle = C.panel;
ctx.strokeStyle = C.cyan;
ctx.fillRect(leftX - 40, topY - 30, 80, 24);
ctx.strokeRect(leftX - 40, topY - 30, 80, 24);
ctx.fillRect(rightX - 40, topY - 30, 80, 24);
ctx.strokeRect(rightX - 40, topY - 30, 80, 24);
text(ctx, clientLabel, leftX, topY - 14, C.fg, 'center');
text(ctx, serverLabel, rightX, topY - 14, C.fg, 'center');
// "time" arrow on the far left
ctx.strokeStyle = C.dim;
ctx.beginPath();
ctx.moveTo(20, topY); ctx.lineTo(20, botY - 8);
ctx.lineTo(16, botY - 14);
ctx.moveTo(20, botY - 8); ctx.lineTo(24, botY - 14);
ctx.stroke();
text(ctx, 'time', 26, botY - 4, C.dim);
ctx.restore();
}
// ---------- Scene 1: TCP three-way handshake ----------
function sceneHandshake() {
const s = scene('scene-handshake', {
height: 360,
readout: [
{ key: 'cseq', label: 'Client seq' },
{ key: 'sseq', label: 'Server seq' },
{ key: 'state', label: 'Connection state' },
],
caption: 'Every TCP connection opens with the same short ritual. The client announces a starting sequence number, the server acknowledges it and announces its own, and the client acknowledges that. After those three packets the connection is open. TCP wants you to have made eye contact before exchanging any data.',
});
if (!s) return;
const CLIENT_SEQ = 1000;
const SERVER_SEQ = 5000;
// Animated cycle schedule (seconds).
const CYCLE = 7.0;
const schedule = [
{ name: 'SYN', t0: 0.3, t1: 1.5, color: C.yellow, dir: 'right', label: `SYN seq=${CLIENT_SEQ}` },
{ name: 'SYN-ACK', t0: 2.0, t1: 3.2, color: C.orange, dir: 'left', label: `SYN-ACK seq=${SERVER_SEQ} ack=${CLIENT_SEQ + 1}` },
{ name: 'ACK', t0: 3.7, t1: 4.9, color: C.green, dir: 'right', label: `ACK seq=${CLIENT_SEQ + 1} ack=${SERVER_SEQ + 1}` },
];
const startWall = performance.now();
function drawPartial(ctx, fromX, toX, yStart, yEnd, frac, label, color) {
// Draw a forward-progress arrow whose head has advanced `frac` along the
// full line from (fromX, yStart) to (toX, yEnd). 1.0 = fully arrived.
frac = Math.max(0, Math.min(1, frac));
if (frac <= 0) return;
const endX = fromX + (toX - fromX) * frac;
const endY = yStart + (yEnd - yStart) * frac;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.moveTo(fromX, yStart);
ctx.lineTo(endX, endY);
ctx.stroke();
// Arrowhead
const dx = toX - fromX, dy = yEnd - yStart;
const L = Math.hypot(dx, dy);
const ux = dx / L, uy = dy / L;
const ax = endX - ux * 8;
const ay = endY - uy * 8;
const px = -uy, py = ux;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(endX, endY);
ctx.lineTo(ax + px * 4, ay + py * 4);
ctx.lineTo(ax - px * 4, ay - py * 4);
ctx.closePath();
ctx.fill();
// Label at midpoint of the traveled portion
const lx = (fromX + endX) / 2;
const ly = (yStart + endY) / 2 - 4;
text(ctx, label, lx, ly, color, 'center');
ctx.restore();
}
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const topY = 60, botY = h - 40;
const leftX = 150, rightX = w - 60;
drawTimelines(ctx, leftX, rightX, topY, botY);
const slant = 24;
const rowH = (botY - topY - slant) / 3.5;
const y1a = topY + rowH * 0.2, y1b = y1a + slant;
const y2a = y1b + rowH * 0.5, y2b = y2a + slant;
const y3a = y2b + rowH * 0.5, y3b = y3a + slant;
const rowsY = [
{ a: y1a, b: y1b },
{ a: y2a, b: y2b },
{ a: y3a, b: y3b },
];
const now = ((performance.now() - startWall) / 1000) % CYCLE;
schedule.forEach((pkt, i) => {
const { a, b } = rowsY[i];
const fromX = pkt.dir === 'right' ? leftX : rightX;
const toX = pkt.dir === 'right' ? rightX : leftX;
const frac = (now - pkt.t0) / (pkt.t1 - pkt.t0);
if (frac <= 0) return;
drawPartial(ctx, fromX, toX, a, b, frac, pkt.label, pkt.color);
});
// Progressive state labels on the client timeline. Place them in the
// vertical gaps between the three packet rows so they never collide with
// each other or with the packet arrows.
const gutterRight = leftX - 10;
const state =
now < schedule[0].t0 ? 'CLOSED' :
now < schedule[1].t1 ? 'SYN-SENT' :
'ESTABLISHED';
const yClosed = topY + 10;
const ySynSent = (y1b + y2a) / 2;
const yEstablished = (y2b + y3a) / 2;
const yOpen = y3b + 22;
text(ctx, 'CLOSED', gutterRight, yClosed, state === 'CLOSED' ? C.fg : C.dim, 'right', 'middle');
text(ctx, 'SYN-SENT', gutterRight, ySynSent, state === 'SYN-SENT' ? C.yellow : C.dim, 'right', 'middle');
text(ctx, 'ESTABLISHED', gutterRight, yEstablished, state === 'ESTABLISHED' ? C.green : C.dim, 'right', 'middle');
if (state === 'ESTABLISHED') {
text(ctx, 'connection open', gutterRight, yOpen, C.green, 'right', 'middle');
}
// Time progress bar along the very top, subtle.
const barY = 18, barX0 = 20, barX1 = w - 20;
ctx.strokeStyle = C.grid;
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(barX0, barY); ctx.lineTo(barX1, barY); ctx.stroke();
ctx.fillStyle = C.cyan;
ctx.beginPath();
ctx.arc(barX0 + (barX1 - barX0) * (now / CYCLE), barY, 3, 0, Math.PI * 2);
ctx.fill();
s.setReadout('cseq', `${CLIENT_SEQ + 1}`);
s.setReadout('sseq', `${SERVER_SEQ + 1}`);
s.setReadout('state', state);
}
function loop() { draw(); requestAnimationFrame(loop); }
loop();
}
// ---------- Scene 2: Data with sequence numbers, ACKs, and loss ----------
function sceneData() {
const s = scene('scene-data', {
height: 460,
controls: [
{ key: 'loss', label: 'Packet loss', min: 0, max: 4, value: 1, step: 1,
format: v => ['none', '1 packet', '2 packets', '3 packets', 'burst'][Math.floor(v)] },
],
readout: [
{ key: 'sent', label: 'Segments sent' },
{ key: 'retx', label: 'Retransmissions' },
],
caption: 'Once the connection is open, each byte has a sequence number. The receiver acknowledges the next byte it expects, not the one it just got. If a segment goes missing, the receiver keeps asking for the same byte (duplicate ACKs) and the sender, eventually noticing, hands it over again. This is TCP being stubborn about delivery.',
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const topY = 50, botY = h - 30;
const leftX = 90, rightX = w - 90;
drawTimelines(ctx, leftX, rightX, topY, botY);
// Scenario data: sequence of sends and the receiver response.
// Each packet occupies one "row". Lost packets don't arrive; ACKs keep
// asking for the lost byte.
const scenarios = [
// 0: none lost
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: false },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'data', seq: 4, lost: false },
],
// 1: one lost (seq 2)
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'data', seq: 4, lost: false },
{ kind: 'retx', seq: 2, lost: false },
],
// 2: two lost (seq 2 and 4)
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'data', seq: 4, lost: true },
{ kind: 'retx', seq: 2, lost: false },
{ kind: 'retx', seq: 4, lost: false },
],
// 3: three lost
[
{ kind: 'data', seq: 1, lost: true },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'retx', seq: 1, lost: false },
{ kind: 'retx', seq: 2, lost: false },
],
// 4: burst loss
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: true },
{ kind: 'data', seq: 4, lost: true },
{ kind: 'retx', seq: 2, lost: false },
{ kind: 'retx', seq: 3, lost: false },
{ kind: 'retx', seq: 4, lost: false },
],
];
const scenario = scenarios[Math.floor(s.values.loss)];
const slant = 22;
// Compute rows: each data/retx gets a row; if not lost, the ACK comes
// back just below on the next slant.
const rows = [];
let nextExpected = 1;
scenario.forEach(pkt => {
rows.push({ type: 'send', pkt });
if (!pkt.lost) {
if (pkt.seq === nextExpected) nextExpected++;
rows.push({ type: 'ack', expect: nextExpected });
} else {
// duplicate ACK still goes out for the last in-order byte
rows.push({ type: 'ack', expect: nextExpected, dup: true });
}
});
const rowH = Math.min(34, (botY - topY - slant - 10) / rows.length);
let sent = 0, retx = 0;
rows.forEach((r, i) => {
const y = topY + 10 + i * rowH;
if (r.type === 'send') {
const color = r.pkt.kind === 'retx' ? C.orange : C.cyan;
const label = r.pkt.kind === 'retx' ? `retx seq=${r.pkt.seq}` : `DATA seq=${r.pkt.seq}`;
drawPacket(ctx, leftX, rightX, y, y + slant, label, color, r.pkt.lost, 0.5);
sent++;
if (r.pkt.kind === 'retx') retx++;
} else {
const label = r.dup ? `ACK ack=${r.expect} (dup)` : `ACK ack=${r.expect}`;
const color = r.dup ? C.red : C.green;
drawPacket(ctx, rightX, leftX, y, y + slant, label, color, false);
}
});
s.setReadout('sent', sent);
s.setReadout('retx', retx);
requestAnimationFrame(() => {});
}
// Re-render whenever slider changes: attach input listeners via rAF loop.
function loop() {
draw();
requestAnimationFrame(loop);
}
loop();
}
// ---------- Scene 3: Sliding window ----------
function sceneWindow() {
const s = scene('scene-window', {
height: 520,
controls: [
{ key: 'win', label: 'Window size (packets)', min: 1, max: 8, value: 4, step: 1, format: v => `${v}` },
],
readout: [
{ key: 'rtts', label: 'RTTs to send 8 packets' },
{ key: 'throughput', label: 'Throughput vs stop-and-wait' },
],
caption: 'Sending one packet and then waiting for its ACK wastes nearly the entire round-trip time. TCP instead keeps a pile of packets in flight at once, up to the window size, and only waits when that pile is full. Doubling the window roughly doubles the throughput, up to the point where something else in the network becomes the bottleneck.',
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const topY = 50, botY = h - 30;
const leftX = 90, rightX = w - 90;
drawTimelines(ctx, leftX, rightX, topY, botY);
const win = Math.floor(s.values.win);
const totalPackets = 8;
const rtts = Math.ceil(totalPackets / win);
const slant = 20;
// Compute vertical layout adaptively so every seq/ack label gets
// enough space to be legible regardless of window size.
const avail = botY - topY - 20;
const rowsCount = totalPackets * 2; // send rows + ack rows
const gapCount = rtts - 1;
const unit = avail / (rowsCount + gapCount * 0.7);
const rowStep = Math.max(slant + 8, Math.min(32, unit));
const rttGap = Math.min(rowStep * 0.7, 24);
let y = topY + 12;
let packetN = 1;
for (let batch = 0; batch < rtts; batch++) {
const batchSize = Math.min(win, totalPackets - batch * win);
// Send batch
for (let i = 0; i < batchSize; i++) {
drawPacket(ctx, leftX, rightX, y, y + slant, `seq=${packetN}`, C.cyan);
packetN++;
y += rowStep;
}
// ACKs for the batch
for (let i = 0; i < batchSize; i++) {
const ackNum = batch * win + i + 2;
drawPacket(ctx, rightX, leftX, y, y + slant, `ack=${ackNum}`, C.green);
y += rowStep;
}
// Extra gap between RTTs so each round-trip is visually distinct.
if (batch < rtts - 1) y += rttGap;
}
s.setReadout('rtts', `${rtts}`);
const speedup = Math.ceil(totalPackets / rtts);
s.setReadout('throughput', `${speedup}× faster`);
}
function loop() { draw(); requestAnimationFrame(loop); }
loop();
}
// ---------- Scene 4: Congestion control ----------
function sceneCongestion() {
const s = scene('scene-congestion', {
height: 380,
controls: [
{ key: 'lossAt', label: 'First loss at time', min: 3, max: 20, value: 8, step: 0.5, format: v => `RTT ${v.toFixed(1)}` },
{ key: 'ssthresh0', label: 'Initial ssthresh', min: 8, max: 64, value: 32, step: 1, format: v => `${v}` },
],
readout: [
{ key: 'peak', label: 'Peak cwnd' },
{ key: 'final', label: 'Final cwnd' },
],
caption: 'TCP has no idea how much bandwidth is available. It finds out by sending more and more until something breaks. Slow start ramps the congestion window up exponentially until it hits the slow-start threshold, then tiptoes up by one packet per RTT. A packet loss halves the window and the whole thing starts over. This is a protocol that lives its entire life anxious about whether it should be going faster.',
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const margin = { l: 50, r: 20, t: 25, b: 40 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
const tMax = 40; // RTTs
const cwndMax = 80;
const xOf = t => margin.l + (t / tMax) * pW;
const yOf = c => margin.t + (1 - c / cwndMax) * pH;
ctx.strokeStyle = C.grid;
ctx.strokeRect(margin.l, margin.t, pW, pH);
for (let t = 0; t <= tMax; t += 5) {
ctx.beginPath(); ctx.moveTo(xOf(t), margin.t); ctx.lineTo(xOf(t), margin.t + pH); ctx.stroke();
text(ctx, `${t}`, xOf(t), margin.t + pH + 14, C.dim, 'center');
}
for (let c = 0; c <= cwndMax; c += 20) {
ctx.beginPath(); ctx.moveTo(margin.l, yOf(c)); ctx.lineTo(margin.l + pW, yOf(c)); ctx.stroke();
text(ctx, `${c}`, margin.l - 5, yOf(c), C.dim, 'right', 'middle');
}
text(ctx, 'time (RTTs)', margin.l + pW / 2, margin.t + pH + 28, C.dim, 'center');
ctx.save();
ctx.translate(margin.l - 40, margin.t + pH / 2);
ctx.rotate(-Math.PI / 2);
text(ctx, 'cwnd (segments)', 0, 0, C.dim, 'center');
ctx.restore();
// Run the classic Reno state machine
let cwnd = 1;
let ssthresh = s.values.ssthresh0;
const firstLoss = s.values.lossAt;
const lossInterval = 12; // RTTs between simulated losses
const points = [{ t: 0, cwnd, phase: 'slow' }];
let nextLoss = firstLoss;
let peak = cwnd;
for (let t = 1; t <= tMax; t += 1) {
if (t >= nextLoss) {
// Multiplicative decrease: ssthresh = cwnd / 2, cwnd resumes at ssthresh (fast recovery)
ssthresh = Math.max(2, Math.floor(cwnd / 2));
cwnd = ssthresh;
nextLoss += lossInterval;
points.push({ t, cwnd, phase: 'loss' });
continue;
}
if (cwnd < ssthresh) {
cwnd = cwnd * 2; // slow start: exponential
} else {
cwnd = cwnd + 1; // congestion avoidance: linear
}
if (cwnd > cwndMax) cwnd = cwndMax;
peak = Math.max(peak, cwnd);
points.push({ t, cwnd, phase: cwnd < ssthresh ? 'slow' : 'avoid' });
}
// Plot cwnd
ctx.strokeStyle = C.cyan;
ctx.lineWidth = 2;
ctx.beginPath();
points.forEach((p, i) => {
const x = xOf(p.t), y = yOf(p.cwnd);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
// Mark losses
points.filter(p => p.phase === 'loss').forEach(p => {
ctx.fillStyle = C.red;
ctx.beginPath();
ctx.arc(xOf(p.t), yOf(p.cwnd), 4, 0, Math.PI * 2);
ctx.fill();
text(ctx, 'loss', xOf(p.t) + 6, yOf(p.cwnd) - 4, C.red);
});
// Phase legend
text(ctx, 'slow start → congestion avoidance → loss → repeat', margin.l + 6, margin.t + 14, C.dim);
s.setReadout('peak', `${peak.toFixed(0)} segments`);
s.setReadout('final', `${points[points.length - 1].cwnd.toFixed(0)} segments`);
requestAnimationFrame(() => {});
}
function loop() { draw(); requestAnimationFrame(loop); }
loop();
}
// ---------- Scene 5: UDP: send and forget ----------
function sceneUDP() {
const s = scene('scene-udp', {
height: 400,
controls: [
{ key: 'loss', label: 'Network loss rate', min: 0, max: 60, value: 15, step: 1, format: v => v.toFixed(0) + '%' },
],
readout: [
{ key: 'sent', label: 'Sent' },
{ key: 'rcvd', label: 'Received' },
{ key: 'recovery', label: 'Recovery' },
],
caption: 'UDP lives in the same IP packets that TCP does but deliberately refuses almost all of TCP\'s help. There is no handshake, no sequence tracking, no retransmission, no flow control, no congestion control. Packets either arrive or they don\'t, and if they don\'t, UDP would like you to deal with it. This sounds bad until you need to send voice, video, DNS queries, or game state, at which point it sounds wonderful.',
});
if (!s) return;
// Deterministic pseudo-random so the scene is reproducible.
function hash(seed, n) {
let x = Math.sin(seed * 9301 + n * 49297) * 233280;
return x - Math.floor(x);
}
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const topY = 50, botY = h - 30;
const leftX = 90, rightX = w - 90;
drawTimelines(ctx, leftX, rightX, topY, botY, 'Client', 'Server');
const totalPackets = 10;
const lossPct = s.values.loss / 100;
const slant = 18;
const rowH = Math.min(28, (botY - topY - slant - 10) / totalPackets);
let received = 0;
for (let i = 0; i < totalPackets; i++) {
const y = topY + 10 + i * rowH;
const lost = hash(1, i) < lossPct;
drawPacket(ctx, leftX, rightX, y, y + slant, `UDP seq=${i + 1}`, C.magenta, lost, 0.5 + (hash(2, i) - 0.5) * 0.3);
if (!lost) received++;
}
s.setReadout('sent', `${totalPackets}`);
s.setReadout('rcvd', `${received}`);
s.setReadout('recovery', received === totalPackets ? 'none needed' : 'none (UDP does not care)');
requestAnimationFrame(() => {});
}
function loop() { draw(); requestAnimationFrame(loop); }
loop();
}
function boot() {
const host = document.querySelector('.scene-host, [data-scene-host]');
if (host) host.classList.add('scene-host');
sceneHandshake();
sceneData();
sceneWindow();
sceneCongestion();
sceneUDP();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();