Toy traceroute using ping on Linux (Debian, in my case):

$ for ttl in {1..30}; do ping -4 -c 1 -t $ttl example.com; done | grep -i from | nl -s ' ' -w 2
1 From router2-lon.linode.com (212.111.33.230) icmp_seq=1 Time to live exceeded
2 From if-11-0-0-1-0.gw2.lon1.gb.linode.com (109.74.207.48) icmp_seq=1 Time to live exceeded
3 From ldn-b2-link.ip.twelve99.net (62.115.41.64) icmp_seq=1 Time to live exceeded
4 From ldn-bb1-link.ip.twelve99.net (62.115.122.188) icmp_seq=1 Time to live exceeded
5 From nyk-bb2-link.ip.twelve99.net (62.115.113.20) icmp_seq=1 Time to live exceeded
6 From nyk-b1-link.ip.twelve99.net (62.115.135.133) icmp_seq=1 Time to live exceeded
7 From edgecast-ic317659-nyk-b6.ip.twelve99-cust.net (62.115.147.199) icmp_seq=1 Time to live exceeded
8 From ae-65.core1.nyb.edgecastcdn.net (152.195.68.131) icmp_seq=1 Time to live exceeded
9 64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=1 ttl=56 time=73.7 ms
10 64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=1 ttl=56 time=73.6 ms
11 64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=1 ttl=56 time=73.7 ms
12 64 bytes from 93.184.216.34 (93.184.216.34): icmp_seq=1 ttl=56 time=73.6 ms
...

This loop sends out multiple ICMP echo requests with different TTL values (1, 2, 3, etc.). Each router on the path decrements the TTL by one. Everytime a router finds that the TTL is 0, we get an ICMP TTL exceeded message from that router. The traceroute is complete when we see the ping responses appearing. For comparison, here is the output of the actual traceroute command:

$ traceroute -4 example.com
traceroute to example.com (93.184.216.34), 30 hops max, 60 byte packets
1 router2-lon.linode.com (212.111.33.230) 0.550 ms router1-lon.linode.com (212.111.33.229) 0.525 ms router2-lon.linode.com (212.111.33.230) 0.694 ms
2 if-0-1-0-1-0.gw2.lon1.gb.linode.com (109.74.207.6) 0.503 ms 0.491 ms if-11-0-0-1-0.gw1.lon1.gb.linode.com (109.74.207.24) 0.417 ms
3 be5787.rcr51.lon10.atlas.cogentco.com (204.68.252.58) 0.969 ms 1.029 ms ldn-b2-link.ip.twelve99.net (62.115.41.64) 1.281 ms
4 be2589.ccr41.lon13.atlas.cogentco.com (154.54.59.37) 1.351 ms 1.404 ms 1.474 ms
5 nyk-bb1-link.ip.twelve99.net (62.115.112.244) 70.847 ms 70.802 ms be2099.ccr31.bos01.atlas.cogentco.com (154.54.82.34) 63.580 ms
6 verizondms.bos01.atlas.cogentco.com (154.54.11.54) 63.639 ms 64.945 ms nyk-b1-link.ip.twelve99.net (62.115.135.133) 76.193 ms
7 edgecast-ic317660-nyk-b6.ip.twelve99-cust.net (62.115.147.201) 73.460 ms ae-65.core1.bsa.edgecastcdn.net (152.195.232.129) 63.659 ms edgecast-ic317660-nyk-b6.ip.twelve99-cust.net (62.115.147.201) 71.224 ms
8 ae-70.core1.nyb.edgecastcdn.net (152.195.68.141) 72.759 ms 93.184.216.34 (93.184.216.34) 62.964 ms ae-71.core1.nyb.edgecastcdn.net (152.195.69.139) 74.359 ms
9 93.184.216.34 (93.184.216.34) 63.023 ms 70.599 ms 73.223 ms

On macOS, the ping loop would look like this due to differences in what the ping option names mean:

for ttl in {1..30}; do ping -c 1 -t 1 -m $ttl example.com; done | grep -i from | nl -s ' ' -w 2

On macOS, the -t option of ping specifies a timeout (not ICMP TTL) which we need to specify to prevent ping from waiting for too long. The -m option specifies the ICMP TTL on macOS.