<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bryan Hamilton</title>
    <description>The latest articles on DEV Community by Bryan Hamilton (@bryan_hamilton).</description>
    <link>https://dev.to/bryan_hamilton</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4169539%2Fc1db97a8-392c-4e4f-a32c-0ec9e5b54c46.jpg</url>
      <title>DEV Community: Bryan Hamilton</title>
      <link>https://dev.to/bryan_hamilton</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bryan_hamilton"/>
    <language>en</language>
    <item>
      <title>I Predicted When DJs Will Tour Next With MySQL and a Stats Trick</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Sat, 24 Oct 2026 16:55:43 +0000</pubDate>
      <link>https://dev.to/bryan_hamilton/i-predicted-when-djs-will-tour-next-with-mysql-and-a-stats-trick-2p45</link>
      <guid>https://dev.to/bryan_hamilton/i-predicted-when-djs-will-tour-next-with-mysql-and-a-stats-trick-2p45</guid>
      <description>&lt;p&gt;Every fan asks the same question: "when is [my favorite DJ] coming back to my city?" For a long time, my answer as the guy running The DJ Calendar was basically "check the page and hope." Ticket alerts help, but they only fire after an announcement exists.&lt;/p&gt;

&lt;p&gt;Then I realized I was already sitting on the answer. My site tracks upcoming shows for 99 DJs, and every show that passes becomes history. I had 629 past shows across 86 artists sitting in a JSON archive - venue, city, country, date. Touring is one of the most rhythmic things artists do. Festival season is festival season. Album cycles create bursts. The pattern is right there in the data.&lt;/p&gt;

&lt;p&gt;So I built a tour predictor. No machine learning, no Python, no third-party API. Just PHP, one SQL table, and a 45-day clustering trick. It now publishes predictions like "our model estimates John Summit's next tour window opens May 2027 - 55% confidence" on his artist page. Here's exactly how it works, including the bug that taught me what "confidence" actually means.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data I already had
&lt;/h2&gt;

&lt;p&gt;The archive exists because of a different feature: a Tour History section on every artist page, built from past events. The join is the boring-but-critical part. Each event's slug starts with the artist's ID, so grouping shows by artist is a prefix match against the roster - not a foreign key, just string surgery that works.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 629 past shows, 86 artists, one pass&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shows&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$show&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$show&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$roster&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$prefix&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$prefix&lt;/span&gt;&lt;span class="p"&gt;][]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'ymd'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$show&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ymd'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'city'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$show&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'country'&lt;/span&gt;&lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$show&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'country'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One rule up front: an artist needs at least 3 shows before the predictor will even look at them. Two data points can't make a rhythm, and I'd rather show nothing than show nonsense. On my 99-artist roster, 38 get skipped for exactly this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stats trick: shows are clusters, not points
&lt;/h2&gt;

&lt;p&gt;Here's the core insight. A DJ's year doesn't look like a steady stream of dates. It looks like bursts: a two-week European club run, a festival season, an Ibiza residency, a break. Between bursts there are gaps, and those gaps are the signal.&lt;/p&gt;

&lt;p&gt;So the first thing the predictor does is compress 47 shows into "tours" - clusters of shows where each show starts within 45 days of the previous one. One pass, no k-means, no elbow charts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$clusters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$current&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$prevEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strtotime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="s1"&gt;'ymd'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nv"&gt;$thisStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strtotime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'ymd'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;$thisStart&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$prevEnd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$shows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;          &lt;span class="c1"&gt;// same tour&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$clusters&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// gap - new tour&lt;/span&gt;
        &lt;span class="nv"&gt;$current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$shows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$clusters&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why 45 days? Tuned by eyeballing the data. Long enough that a festival weekend plus its club afterparties count as one tour. Short enough that a summer tour and a winter tour don't smear into one blob. If I were serving jazz residencies instead of EDM tours, I'd retune this number.&lt;/p&gt;

&lt;h2&gt;
  
  
  From clusters to an actual date
&lt;/h2&gt;

&lt;p&gt;With clusters in hand, the prediction is almost embarrassingly simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A "gap" is the distance from the end of one cluster to the start of the next.&lt;/li&gt;
&lt;li&gt;The predicted next window = the end of the last cluster + the average gap.&lt;/li&gt;
&lt;li&gt;Clamp it so it can never land in the past - a prediction of "last month" is worse than no prediction.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;artist_tour_patterns&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prediction_next_month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prediction_confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prediction_reasoning&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_calculated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;DUPLICATE&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;
    &lt;span class="n"&gt;prediction_next_month&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prediction_next_month&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;prediction_confidence&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prediction_confidence&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;prediction_reasoning&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prediction_reasoning&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;last_calculated&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_calculated&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That upsert matters more than it looks. The predictions live in one table keyed by artist slug, and a backstage button runs the whole 99-artist refresh in one pass. Artist pages never run the math - they read the stored row. The heavy work happens when I say so, not on every page view.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring it honestly: the confidence stack
&lt;/h2&gt;

&lt;p&gt;A date without a confidence number is just a guess with good posture. The score is a stack of small, explainable bonuses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$conf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                   &lt;span class="c1"&gt;// it made a prediction at all&lt;/span&gt;
&lt;span class="nv"&gt;$conf&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$gaps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// observed gaps = rhythm evidence&lt;/span&gt;
&lt;span class="nv"&gt;$conf&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$showCount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// more shows, more signal&lt;/span&gt;
&lt;span class="nv"&gt;$conf&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;$avgClusterSize&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// dense clusters = real tours&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$clusters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$conf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$conf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;55&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                   &lt;span class="c1"&gt;// one cluster = no rhythm observed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$conf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$conf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                       &lt;span class="c1"&gt;// never claim certainty&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run John Summit through it: 47 shows, all in one giant cluster, zero gaps. That's 20 + 0 + 20 + 15 = 55, capped at 55. His page says "May 2027 - 55% confidence," and the reasoning line reads "Based on 47 recent shows across 1 tour cluster primarily in Europe/Global and North America."&lt;/p&gt;

&lt;p&gt;Notice what the stack rewards: not raw show count - &lt;em&gt;repetition of the rhythm&lt;/em&gt;. Each observed gap is the pattern happening again. That's the evidence, and evidence is the only thing a confidence number should be made of.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that taught me the difference
&lt;/h2&gt;

&lt;p&gt;The first version of that stack had a bug, and it was the instructive kind: the gap bonus paid out per &lt;em&gt;cluster&lt;/em&gt;, not per &lt;em&gt;gap&lt;/em&gt;. An artist with 3 clusters has only 2 observable gaps between them - the third cluster's "gap" is in the future, unobserved. Counting clusters gave unearned points for a rhythm that had never repeated yet.&lt;/p&gt;

&lt;p&gt;The reasoning text lied too. It said "1 recent shows" for an artist who had actually played 15 recent shows across 3 clusters, because it counted the last cluster's size instead of the totals. After the fix, the same artist's confidence dropped, the text became true, and the high-confidence tier on my predictions page shrank from 51 artists to 27. That drop was the fix working.&lt;/p&gt;

&lt;p&gt;The lesson generalizes past this project: if you can't walk through the arithmetic that produced your confidence number, it's a lie with a percent sign attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  The edge case that keeps it honest
&lt;/h2&gt;

&lt;p&gt;Constant tourers are the best test. Some DJs simply never stop - 47 dates, no 45-day break anywhere. They collapse into a single cluster, produce zero gaps, and get capped at 55.&lt;/p&gt;

&lt;p&gt;The model refusing to fake certainty about the hardest case is my favorite property of the whole system. If an artist never pauses, there is no rhythm to learn. Saying "55% and here's why" beats inventing "87%" from vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving it without slowing the site down
&lt;/h2&gt;

&lt;p&gt;My artist pages are deliberately fast - fully JSON-driven, no database on the hot path, LCP around 1.2 seconds. I didn't want to give that up for a paragraph of text.&lt;/p&gt;

&lt;p&gt;The compromise: the prediction lives in one MySQL row keyed by artist slug. The artist page does a single primary-key lookup inside a try/catch - if the database hiccups, the page renders exactly as before, the blurb just stays quiet. There's also a public hub that ranks every prediction by confidence, which doubles as the place all those artist-page links point to.&lt;/p&gt;

&lt;p&gt;Two rules for presenting it: always say "our model estimates," never "he will tour," and always show the reasoning line next to the number. A prediction with visible reasoning is a conversation; one without is a fortune cookie.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this approach falls apart
&lt;/h2&gt;

&lt;p&gt;Being honest about the limits, because they're real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fewer than 3 shows&lt;/strong&gt; - nothing to learn, don't predict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dense residencies&lt;/strong&gt; - a weekly Ibiza residency is one fat cluster with no internal structure. The model shrugs, correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Life events&lt;/strong&gt; - album drops, visa problems, a residency renewal. No amount of history predicts a surprise. The confidence cap of 95 is a permanent reminder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 45-day constant&lt;/strong&gt; - it's tuned to how electronic artists tour. Different genre, different constant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a heuristic wearing a lab coat, and I'm fine with that. It turns 629 rows of history into 61 useful, honest predictions - with 38 artists correctly left alone - and it recalibrates itself every time new shows land in the archive.&lt;/p&gt;

&lt;p&gt;The whole thing is live: every artist page on The DJ Calendar shows a "Tour Radar" box when a prediction exists, and the full ranked list is at thedjcalendar.com/tour-predictions/.&lt;/p&gt;




&lt;p&gt;If you enjoyed this, I've written about the rest of the site's guts too: &lt;a href="https://dev.to/bryan_hamilton/a-login-form-beat-my-twenty-years-of-php-1dc9"&gt;a login form that beat twenty years of PHP&lt;/a&gt;, and &lt;a href="https://dev.to/bryanhamiltondev/the-search-engine-with-zero-server-calls-34oa"&gt;a search engine with zero server calls&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>mysql</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A Login Form Beat My Twenty Years of PHP</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Fri, 23 Oct 2026 16:22:00 +0000</pubDate>
      <link>https://dev.to/bryan_hamilton/a-login-form-beat-my-twenty-years-of-php-1dc9</link>
      <guid>https://dev.to/bryan_hamilton/a-login-form-beat-my-twenty-years-of-php-1dc9</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh9vl1u4mms435t7dymtw.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh9vl1u4mms435t7dymtw.jpg" alt="A Login Form Beat My Twenty Years of PHP" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I have written PHP for over twenty years. I survived the register_globals era, magic quotes, and every framework that promised to save me from myself. And this week, a login form - the software equivalent of a screen door - beat me for the better part of a day.&lt;/p&gt;

&lt;p&gt;This is the coroner's report. There were three monsters, and none of them were where I looked first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I run a live DJ event calendar. Plain PHP, vanilla JavaScript, zero dependencies - if it needs a build step, I don't want it. I decided to do the auth stack properly: one signup page, one signin page, CSRF protection, real sessions. Classic work. An afternoon, tops.&lt;/p&gt;

&lt;p&gt;It worked flawlessly. On my machine. With my warm, already-authenticated cookies.&lt;/p&gt;

&lt;p&gt;Then I opened a private window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monster one: the doppelganger
&lt;/h2&gt;

&lt;p&gt;Every page on my site boots with a small compatibility shim. Or so I believed - it turned out there were TWO files with the same name in two different directories. The one at the root: harmless, does what it says.&lt;/p&gt;

&lt;p&gt;The one buried in the includes directory contained a "fix" - and I want you to appreciate the irony - for a real edge case: when a PHP session is active but the client sent no cookie, things get weird. Except this fix handled the weirdness by ABORTING THE SESSION and stripping the Set-Cookie header. Silently.&lt;/p&gt;

&lt;p&gt;Read that again: the exact moment a brand-new session needed to be born, this file executed it. My auth code was fine. It was being assassinated by a file with the same name as its bodyguard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; grep for duplicate filenames before you grep for bugs. Two files with the same name in different directories is not a refactor waiting to happen - it is a refactor that already happened, badly, to someone who did not notice. That someone was me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monster two: the cookie eater
&lt;/h2&gt;

&lt;p&gt;Like most small sites, I sit behind a CDN that caches pages at the edge. Here is a thing I knew in the abstract and got to learn viscerally: for cacheable GET responses, the edge strips the Set-Cookie header.&lt;/p&gt;

&lt;p&gt;This is CORRECT behavior. You absolutely do not want the edge serving visitor A's session cookie to visitor B - that is not a bug, that is an incident report. The CDN is protecting my users from my misconfiguration.&lt;/p&gt;

&lt;p&gt;But follow the consequences: my login page is a GET. The edge cached it and ate every Set-Cookie it ever tried to send. POST requests are never cached, so the only thing that could ever hand a browser a session cookie was the form submission itself.&lt;/p&gt;

&lt;p&gt;So the session worked. The browser just never found out about it until it was too late.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; any page that participates in authentication should not be edge-cacheable, or should bypass the cache when a session cookie is present. "Cacheable" is not a page property - it is a per-request decision, and auth pages make that decision for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monster three: the second-try bug
&lt;/h2&gt;

&lt;p&gt;Now combine the two monsters.&lt;/p&gt;

&lt;p&gt;First visit ever: GET the signup page, edge eats the cookie, submit the form, server-side state says there is no session, "Session expired."&lt;/p&gt;

&lt;p&gt;Retry: the first POST already set a cookie, so now everything works.&lt;/p&gt;

&lt;p&gt;Works-on-retry is the worst possible bug class in existence. It is invisible in every test suite. It survives every staging environment. It makes your users feel like THEY did something wrong, because the second try works and now they think the internet is haunted and so do you.&lt;/p&gt;

&lt;p&gt;And here is the part that humbled me: I knew WHY it happened. Every developer on my side of the glass tests with warm cookies. Every user arrives cold. The bug only existed for people who had never been there before - which is to say, every person I was trying to convert into a regular.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; test auth flows in a fresh private window, every single time, from cold. Warm cookies are a lie you tell yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: stop keeping state at all
&lt;/h2&gt;

&lt;p&gt;I could have played whack-a-mole with cache rules and shim files forever. Instead I asked a better question: why does my CSRF protection need server-side state at all?&lt;/p&gt;

&lt;p&gt;The answer, it turns out, is that it doesn't. Enter the stateless HMAC token - the CSRF token that keeps no secrets the cache can eat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Issue: payload + signature, no server storage&lt;/span&gt;
&lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'exp'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;7200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'n'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bin2hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;random_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CSRF_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Verify: recompute, compare, check expiry&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$sig&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CSRF_KEY&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;base64_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="s1"&gt;'exp'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payload is just an expiry and a random nonce. The signature proves I issued it. The server holds NOTHING - there is no session row to lose, no state to expire, nothing for the CDN to eat. The token rides inside the form, the form rides inside the POST, and POSTs are never cached.&lt;/p&gt;

&lt;p&gt;The whole class of bugs I had been fighting - session swamps, cookie theft, first-visit death spirals - evaporated, because there was no longer anything TO lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monster four: debugging against yesterday
&lt;/h2&gt;

&lt;p&gt;I would be lying if I said the monsters fought fair. There was a fourth: my own server page-caches HTML for five minutes. Which means every time I shipped a fix and checked the live page, I was frequently debugging a page that no longer existed - inspecting the corpse of yesterday's code and wondering why my fix "didn't work."&lt;/p&gt;

&lt;p&gt;I verified the same fix three separate times because the page kept lying to me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; cache-bust your own debugging. If the page can be cached, your verification URL should look like nothing the cache has ever seen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-offs
&lt;/h2&gt;

&lt;p&gt;Because nothing is free: a stateless token cannot be revoked individually. There is no session row to delete when someone's token needs to die - your options are waiting for the TTL or rotating the key and nuking every token at once. Keep the expiry short. And the token is bearer-style until it expires, so treat the key like a password and the token like cash.&lt;/p&gt;

&lt;p&gt;That is the deal, and for a form that exists to stop cross-site forgeries, it is a deal I will take every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoreboard
&lt;/h2&gt;

&lt;p&gt;Twenty years of PHP: lost the first round to a screen door.&lt;/p&gt;

&lt;p&gt;Final score: three monsters, two doppelganger files, one cookie-eating CDN, one caching server, and a login form that now works on the very first try - for the people who have never been there before. You know. The ones who matter.&lt;/p&gt;

&lt;p&gt;If you have a login horror story, I want it in the comments. Misery loves company, and company loves misery.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Read next: &lt;a href="https://dev.to/bryanhamiltondev/the-search-engine-with-zero-server-calls-34oa"&gt;The Search Engine With Zero Server Calls&lt;/a&gt; - how I took every search bar off my server. Or if you want something that hums: &lt;a href="https://dev.to/bryanhamiltondev/the-equalizer-that-actually-listens-a-web-audio-analyser-deep-dive-b3"&gt;The Equalizer That Actually Listens&lt;/a&gt;, a Web Audio analyser deep-dive.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>security</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Replay the Feed, Never a Duplicate: Idempotent Upserts in MySQL</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Mon, 12 Oct 2026 00:33:07 +0000</pubDate>
      <link>https://dev.to/bryan_hamilton/replay-the-feed-never-a-duplicate-idempotent-upserts-in-mysql-3mm</link>
      <guid>https://dev.to/bryan_hamilton/replay-the-feed-never-a-duplicate-idempotent-upserts-in-mysql-3mm</guid>
      <description>&lt;p&gt;I run &lt;a href="https://thedjcalendar.com" rel="noopener noreferrer"&gt;The DJ Calendar&lt;/a&gt;, a site that tracks tour dates for the world's biggest DJs. Every show on it arrives through feeds from Bandsintown and Ticketmaster, and those feeds arrive again and again: on a schedule, after a retry, sometimes two runs at once.&lt;/p&gt;

&lt;p&gt;That gives the database one job that's easy to say and easy to get wrong: &lt;strong&gt;the same show must always land as the same row&lt;/strong&gt;, no matter how many times it's ingested or which process gets there first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious approach, and the race inside it
&lt;/h2&gt;

&lt;p&gt;The first version most of us write looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$exists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SELECT id FROM events WHERE artist_id = ? AND venue_id = ? AND event_date = ?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$exists&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$artistId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$venueId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$eventDate&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$exists&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INSERT INTO events (artist_id, venue_id, event_date) VALUES (?, ?, ?)'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$artistId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$venueId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$eventDate&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works perfectly in testing, because in testing only one process runs at a time. In production, two ingestion runs overlap. Worker A runs its &lt;code&gt;SELECT&lt;/code&gt; and sees nothing. Worker B runs its &lt;code&gt;SELECT&lt;/code&gt; a moment later and also sees nothing, because A hasn't inserted yet. Both insert. Every fan following that artist now sees the show twice.&lt;/p&gt;

&lt;p&gt;The bug lives in the gap between the read and the write. You can't close that gap from application code without locks, and locks bring their own problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the database decide
&lt;/h2&gt;

&lt;p&gt;The fix has two parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, tell the database what "the same show" means.&lt;/strong&gt; Feeds don't give you stable IDs you can trust across sources, but they do give you an artist, a venue and a start time. That combination is the show's natural key, so make it a constraint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;artist_id&lt;/span&gt;   &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;venue_id&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_date&lt;/span&gt;  &lt;span class="nb"&gt;DATETIME&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- always UTC&lt;/span&gt;
  &lt;span class="n"&gt;ticket_url&lt;/span&gt;  &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;is_sold_out&lt;/span&gt; &lt;span class="nb"&gt;TINYINT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;uq_events_natural&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;venue_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;CHARSET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;utf8mb4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now duplicates aren't just discouraged. They're impossible. And because the rule lives in the database rather than in application memory, it survives crashes, restarts and every future script that touches the table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, write in one atomic statement.&lt;/strong&gt; Instead of asking and then acting, do both at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;artist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;venue_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ticket_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_sold_out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;artist_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;venue_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;event_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;ticket_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;is_sold_out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;DUPLICATE&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;
  &lt;span class="n"&gt;ticket_url&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;is_sold_out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_sold_out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the show is new, it's inserted. If it already exists, the existing row is updated with the latest ticket link and sold-out status. There's no gap between the check and the write, so there's nothing for a second worker to slip through. Run the same feed once or a hundred times and you get the same table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing what actually happened
&lt;/h2&gt;

&lt;p&gt;MySQL reports an upsert's outcome through the affected-row count: 1 for an insert, 2 for an update, and 0 when the row already matched. That makes ingestion logs genuinely useful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$stmt&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;rowCount&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'inserted'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'updated'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'unchanged'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A healthy re-run of an unchanged feed reports almost everything as &lt;code&gt;unchanged&lt;/code&gt;. If a run suddenly reports hundreds of inserts, something upstream changed, and you find out from a log line instead of from users.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;When correctness depends on "this should only exist once," don't enforce it with a check in application code. Encode the rule as a constraint and write with a statement that can't race. Your ingestion becomes safe to retry, safe to run in parallel, and safe to replay after a crash.&lt;/p&gt;

&lt;p&gt;The full schema, with every design decision documented inline, plus a live demo where you can watch two workers race in both modes, is on the &lt;a href="https://bryanhamilton.info/projects/event-calendar-schema/" rel="noopener noreferrer"&gt;project page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>php</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Client-Side Search From a Prebuilt JSON Index: Zero Dependencies, Zero Server Calls</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Fri, 09 Oct 2026 21:38:48 +0000</pubDate>
      <link>https://dev.to/bryan_hamilton/client-side-search-from-a-prebuilt-json-index-zero-dependencies-zero-server-calls-4ed0</link>
      <guid>https://dev.to/bryan_hamilton/client-side-search-from-a-prebuilt-json-index-zero-dependencies-zero-server-calls-4ed0</guid>
      <description>&lt;p&gt;I run The DJ Calendar (&lt;code&gt;https://thedjcalendar.com/&lt;/code&gt;) — a site with hundreds of artist pages. Every page has a search bar in the top-right corner that searches across every artist on the site.&lt;/p&gt;

&lt;p&gt;It works instantly. No loading spinner. No debounce delay. No network request at all.&lt;/p&gt;

&lt;p&gt;Here's how I built it with a prebuilt JSON index and zero dependencies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Problem&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A site with hundreds of artists needs search. The obvious options are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Algolia / Meilisearch — great products, but adds a dependency, a monthly bill, and a server round-trip on every keystroke.&lt;/li&gt;
&lt;li&gt;Server-side search — hits your database on every keystroke. Fine for low traffic, but each search is a full HTTP request with latency.&lt;/li&gt;
&lt;li&gt;Lunr / Fuse.js — client-side libraries, but they're dependencies you don't control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted search that felt instant because it was instant. No network, no loading, no third-party script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Approach: Prebuilt JSON Index&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At deploy time, a PHP script crawls the artist database and writes a flat JSON file. Every artist gets one entry with their name, genre, location, and a URL. The file sits on the server as static JSON — no PHP involved at runtime.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[&lt;br&gt;
  {&lt;br&gt;
    "name": "David Guetta",&lt;br&gt;
    "slug": "david-guetta",&lt;br&gt;
    "genres": ["EDM", "House", "Pop"],&lt;br&gt;
    "locations": ["Paris", "Ibiza", "Las Vegas"],&lt;br&gt;
    "url": "https://thedjcalendar.com/artist/david-guetta"&lt;br&gt;
  },&lt;br&gt;
  ...&lt;br&gt;
]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;At page load, the search script fetches this JSON file once and stores it in memory. Every keystroke searches the local array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scored AND/OR Matching&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The search function does scored matching across weighted fields. Name scores highest, genre and location score lower. A query like "david house" matches artists whose name OR genre contains "david" AND "house".&lt;/p&gt;

&lt;p&gt;`function search(query, items) {&lt;br&gt;
  var terms = query.toLowerCase().split(' ');&lt;br&gt;
  var results = [];&lt;/p&gt;

&lt;p&gt;for (var i = 0; i &amp;lt; items.length; i++) {&lt;br&gt;
    var item = items[i];&lt;br&gt;
    var score = 0;&lt;br&gt;
    var matchCount = 0;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;for (var t = 0; t &amp;lt; terms.length; t++) {
  var term = terms[t];
  var fieldScore = 0;

  // Name match — highest weight
  if (item.name.toLowerCase().includes(term)) {
    fieldScore = Math.max(fieldScore, 10);
  }

  // Genre match — medium weight
  for (var g = 0; g &amp;lt; item.genres.length; g++) {
    if (item.genres[g].toLowerCase().includes(term)) {
      fieldScore = Math.max(fieldScore, 5);
    }
  }

  // Location match — lower weight
  for (var l = 0; l &amp;lt; item.locations.length; l++) {
    if (item.locations[l].toLowerCase().includes(term)) {
      fieldScore = Math.max(fieldScore, 3);
    }
  }

  if (fieldScore &amp;gt; 0) matchCount++;
  score += fieldScore;
}

// AND: all terms must match at least one field
if (matchCount === terms.length) {
  results.push({ item: item, score: score });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;results.sort(function(a, b) { return b.score - a.score; });&lt;br&gt;
  return results.slice(0, 20);&lt;br&gt;
}`&lt;/p&gt;

&lt;p&gt;The AND requirement prevents irrelevant results. If you type "las vegas pop", you get artists in Las Vegas whose genre is Pop — not every artist in Las Vegas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ARIA Combobox&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The search UI is a standard ARIA combobox pattern. The input has role="combobox", the results list has role="listbox", and each result is role="option".&lt;/p&gt;

&lt;p&gt;Keyboard navigation: down/up arrows cycle through results, Enter selects, Escape closes. Focus is trapped in the combobox while it's open. The active option is announced via aria-activedescendant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why This Matters&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This pattern works for any collection that fits in memory — artists, products, documentation pages, blog posts. The index is generated at build time, so there's zero runtime cost. The JSON file is cacheable by the browser and CDN.&lt;/p&gt;

&lt;p&gt;I extracted this into a standalone repo: instant-search-index (&lt;a href="https://github.com/bryanhamiltondev/instant-search-index" rel="noopener noreferrer"&gt;https://github.com/bryanhamiltondev/instant-search-index&lt;/a&gt;). It includes the full combobox implementation with keyboard navigation, plus a PHP index builder you can adapt to any data source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lessons Learned&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;JSON size matters. My index is ~150 KB gzipped. For larger datasets, chunk the index or lazy-load it.&lt;/li&gt;
&lt;li&gt;Scoring needs tuning. Start with simple weights and adjust based on real queries.&lt;/li&gt;
&lt;li&gt;AND matching is opinionated. Some search UIs work better with OR. Make it configurable.&lt;/li&gt;
&lt;li&gt;The ARIA pattern is non-negotiable. Search is one of the most-used features. It has to work for keyboard and screen reader users.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full code, demo, and PHP index builder are on GitHub (&lt;a href="https://github.com/bryanhamiltondev/instant-search-index" rel="noopener noreferrer"&gt;https://github.com/bryanhamiltondev/instant-search-index&lt;/a&gt;). The live version runs on The DJ Calendar (&lt;a href="https://dev.tourl"&gt;https://thedjcalendar.com/&lt;/a&gt;&lt;a href="https://thedjcalendar.com/" rel="noopener noreferrer"&gt;https://thedjcalendar.com/&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;All my production-extracted repos live at github.com/bryanhamiltondev (&lt;a href="https://github.com/bryanhamiltondev" rel="noopener noreferrer"&gt;https://github.com/bryanhamiltondev&lt;/a&gt;). My portfolio is at bryanhamilton.info (&lt;a href="https://www.bryanhamilton.info/" rel="noopener noreferrer"&gt;https://www.bryanhamilton.info/&lt;/a&gt;).&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>10 Production-Extracted Repos, Zero Dependencies</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Fri, 09 Oct 2026 18:06:09 +0000</pubDate>
      <link>https://dev.to/bryan_hamilton/10-production-extracted-repos-zero-dependencies-oi4</link>
      <guid>https://dev.to/bryan_hamilton/10-production-extracted-repos-zero-dependencies-oi4</guid>
      <description>&lt;p&gt;I run &lt;a href="https://thedjcalendar.com/" rel="noopener noreferrer"&gt;The DJ Calendar&lt;/a&gt; — a site that tracks electronic music events for about 214,000 people. It's built with PHP and vanilla JavaScript. No frameworks, no build step, no npm install.&lt;/p&gt;

&lt;p&gt;Over the years I've extracted the patterns I'm proudest of into standalone repos, each with a live demo, CI, and its own README. Here's the full catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Philosophy
&lt;/h2&gt;

&lt;p&gt;Every repo here follows the same rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies.&lt;/strong&gt; No npm, no Composer, no CDN script tags. Drop the file in your project and it works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extracted from production.&lt;/strong&gt; Every pattern was running on a live site first. Nothing was written for the sake of a demo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility built in.&lt;/strong&gt; Reduced-motion respect, keyboard navigation, ARIA attributes, consent-first geolocation — not bolted on later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI-tested.&lt;/strong&gt; Every repo has a GitHub Actions workflow that validates the code.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. dj-card-preview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/dj-card-preview" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/dj-card-preview/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hover over an artist card and hear a 30-second iTunes preview. The audio plays through a Web Audio API analyser (fftSize: 64, smoothing: 0.8) that drives an 8-bar canvas equalizer in real time.&lt;/p&gt;

&lt;p&gt;What makes it interesting: the autoplay gate. Browsers block audio until the user clicks once, so the first hover primes a one-click unlock. After that, every hover fires an iTunes Search API lookup (entity=musicTrack, limit=12), grabs the preview URL, and starts the analyser. A generation-counter race guard prevents stale responses from overwriting the current playback.&lt;/p&gt;

&lt;p&gt;Also includes: mediaSession integration (lock screen controls), document.title swap, reduced-motion early return, and a 75% black overlay with "You are now listening to:" label.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. instant-search-index
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/instant-search-index" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/instant-search-index/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A client-side search system that works from a prebuilt JSON index. No Algolia, no Elasticsearch, no server endpoint — just a JSON file and ~200 lines of JavaScript.&lt;/p&gt;

&lt;p&gt;The index is generated at build time (a PHP script crawls the artist database and writes a flat JSON file). On the client, the search function runs scored AND/OR matching across weighted fields (name scores highest, genre and location lower). Results render in an ARIA combobox with keyboard navigation, Escape-to-close, and focus trapping.&lt;/p&gt;

&lt;p&gt;This was the feature I was proudest of on &lt;a href="https://www.bryanhamilton.info/" rel="noopener noreferrer"&gt;bryanhamilton.info&lt;/a&gt; — a search that feels instant because it is. No network request, no loading spinner, no debounce dance.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. next-show-radar
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/next-show-radar" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/next-show-radar/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A geolocation-aware tour map. It asks for the user's location (consent-first — no nagging), calculates haversine distances to upcoming shows, and plots the nearest ones on a Leaflet map.&lt;/p&gt;

&lt;p&gt;The consent pattern is worth studying alone: the map loads with a "Find shows near me" button. Nothing happens until the user clicks it. If they deny geolocation, they get a text list instead. Race-safe Leaflet boot prevents the map from initializing twice, and the whole thing works without a single third-party script tag beyond Leaflet's own CSS.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. tour-feed-pipeline
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/tour-feed-pipeline" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://github.com/bryanhamiltondev/tour-feed-pipeline/actions" rel="noopener noreferrer"&gt;CI Status&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The backend counterpart to the radar. It ingests tour data from Bandsintown and Ticketmaster, normalizes both feeds into a single row shape, deduplicates overlapping events, caches aggressively, and prunes stale entries.&lt;/p&gt;

&lt;p&gt;Written in PHP 8 with zero Composer dependencies. CI-tested with PHPUnit against the actual production library code — not mocked abstractions. The test suite taught me a hard lesson about testing production-verbatim code: your tests need to match the library's actual behavior, not what you imagine the contract to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. eq-visualizer
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/eq-visualizer" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/eq-visualizer/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An 8-bar sensory equalizer that renders in pure CSS. Zero JavaScript required to display it — the bars are just divs with staggered animation delays. An optional 1 KB injector script adds Web Audio reactivity.&lt;/p&gt;

&lt;p&gt;This repo holds the line on an important principle: visual effects should degrade gracefully. If JavaScript fails, the EQ still animates. If the user prefers reduced motion, it freezes at full height. No broken UI states.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. sri-lazy-loader
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/sri-lazy-loader" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/sri-lazy-loader/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A race-safe lazy script loader with Subresource Integrity. If two components request the same script simultaneously, only one network request fires. Subsequent callers get the same promise. Timeout and cleanup are built in.&lt;/p&gt;

&lt;p&gt;The pattern I extracted from a production pain point: The DJ Calendar's artist pages load different widgets depending on the artist. Without a loader, widgets would trample each other's script tags. With this, each widget calls loadScript() independently and the deduplication handles the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. event-calendar-schema
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/event-calendar-schema" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/event-calendar-schema/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A PHP library that generates valid schema.org Event JSON-LD. It handles sameDay date detection (no more "through Feb 3" when the event is one day), geo-coordinate injection, and performer-by-type resolution.&lt;/p&gt;

&lt;p&gt;This is the library that powers The DJ Calendar's structured data — every event page on the site runs through it. I extracted it because I was tired of rewriting the same JSON-LD boilerplate for every new project.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. jsonld-schema-audit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/jsonld-schema-audit" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/jsonld-schema-audit/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A PHP tool that validates JSON-LD against schema.org shapes and audits structured data across your entire site. It crawls your pages, extracts every &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; block, and reports errors, warnings, and missing properties.&lt;/p&gt;

&lt;p&gt;Built because Google's Rich Results Test only checks one URL at a time. When you have hundreds of event pages, you need bulk auditing.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. wp-schema-audit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/wp-schema-audit" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/wp-schema-audit/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A WordPress plugin version of the same idea. It adds a Schema Audit page to the WordPress admin, scans every post and page for JSON-LD, and flags issues. Zero-config — activate and run.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. csrf-json-fetch
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/csrf-json-fetch" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/csrf-json-fetch/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A lightweight fetch wrapper that automatically injects CSRF tokens into every request. Tokens are read from a meta tag, rotated on each session, and validated server-side. Error handling is baked in — network failures, non-JSON responses, and 403s all produce typed error objects instead of silent failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned Extracting Production Code
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Your production code has hidden dependencies.&lt;/strong&gt; The first extraction always fails because you forgot the utility function living in a sibling file. That's fine — it forces you to write standalone utilities.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tests reveal the truth.&lt;/strong&gt; The tour-feed-pipeline taught me that my mental model of the code was wrong in three places. Only the test suite caught it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Zero dependencies is a constraint that improves code.&lt;/strong&gt; Without npm, you can't reach for a library. You have to write the 20-line function yourself. Nine times out of ten, that's the right call.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Live demos matter more than READMEs.&lt;/strong&gt; Every repo has a GitHub Pages demo. People trust what they can interact with.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I'm still extracting. The DJ Calendar has patterns I haven't touched yet — the city map system, the email newsletter pipeline, the caching layer. Every extraction makes the parent site better because the extracted code gets hardened, documented, and tested.&lt;/p&gt;

&lt;p&gt;All 10 repos live under &lt;a href="https://github.com/bryanhamiltondev" rel="noopener noreferrer"&gt;github.com/bryanhamiltondev&lt;/a&gt;. The full portfolio is at &lt;a href="https://www.bryanhamilton.info/" rel="noopener noreferrer"&gt;bryanhamilton.info&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If any of these patterns save you a weekend of work, that's exactly why I extracted them.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>php</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>10 Repos, Zero Dependencies: A Production PHP/JS Portfolio</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Fri, 09 Oct 2026 02:23:35 +0000</pubDate>
      <link>https://dev.to/bryan_hamilton/10-repos-zero-dependencies-a-production-phpjs-portfolio-2og5</link>
      <guid>https://dev.to/bryan_hamilton/10-repos-zero-dependencies-a-production-phpjs-portfolio-2og5</guid>
      <description>&lt;p&gt;I run &lt;a href="https://thedjcalendar.com" rel="noopener noreferrer"&gt;The DJ Calendar&lt;/a&gt; — a real-time EDM tour date platform that's been in production since 2015. It's a single PHP + vanilla JS codebase with zero frameworks, zero build tools, and zero package managers.&lt;/p&gt;

&lt;p&gt;Over the past few weeks, I went through the codebase and extracted every reusable pattern into its own open-source repo. Not tutorials. Not simplified examples. The actual production code, cleaned, documented, and pushed.&lt;/p&gt;

&lt;p&gt;Here's the full portfolio.&lt;/p&gt;




&lt;h2&gt;
  
  
  The philosophy
&lt;/h2&gt;

&lt;p&gt;Every repo shares the same rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies.&lt;/strong&gt; No npm install. No Composer. No build step. Clone it and open index.html, or include the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extracted from production.&lt;/strong&gt; Every pattern was already running on a live site before it became a repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live demo or CI test.&lt;/strong&gt; If it runs in the browser, there's a GitHub Pages demo. If it's backend, there's a CI pipeline proving it works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MIT licensed.&lt;/strong&gt; Use it, fork it, ship it.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Audio &amp;amp; Visual
&lt;/h2&gt;

&lt;h3&gt;
  
  
  dj-card-preview
&lt;/h3&gt;

&lt;p&gt;The hover preview system from the homepage cards. Hover any artist card and hear a 30-second iTunes preview with a real Web Audio API equalizer driving 8 canvas bars.&lt;/p&gt;

&lt;p&gt;Key patterns: iTunes Search API integration, Web Audio analyser with fftSize=64, generation-counter race condition guard, consent-first design (touch toggle, reduced-motion skip, autoplay gate).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/dj-card-preview" rel="noopener noreferrer"&gt;Repo&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/dj-card-preview/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  eq-visualizer
&lt;/h3&gt;

&lt;p&gt;The decorative 8-bar equalizer strip that runs on every homepage card. Pure CSS animation with a 1KB optional JS injector. Respects &lt;code&gt;prefers-reduced-motion&lt;/code&gt;. Zero JS required to render — the CSS alone is the full widget.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/eq-visualizer" rel="noopener noreferrer"&gt;Repo&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/eq-visualizer/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  next-show-radar
&lt;/h3&gt;

&lt;p&gt;A geolocation-aware tour map. Asks for location consent once, calculates haversine distance to upcoming shows, and renders them on a Leaflet map. If the user declines location, it shows a friendly fallback — it doesn't nag.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/next-show-radar" rel="noopener noreferrer"&gt;Repo&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/next-show-radar/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Data &amp;amp; Search
&lt;/h2&gt;

&lt;h3&gt;
  
  
  instant-search-index
&lt;/h3&gt;

&lt;p&gt;Client-side search across ~1,000 pages using a prebuilt JSON index. Scored AND matching across weighted fields (title, artist, venue, city), keyboard-first combobox with full ARIA wiring. Zero server round-trips after page load.&lt;/p&gt;

&lt;p&gt;This is the feature I'm proudest of on the site. A user types "martin garrix london" and gets results before their finger leaves the keyboard.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/instant-search-index" rel="noopener noreferrer"&gt;Repo&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/instant-search-index/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  tour-feed-pipeline
&lt;/h3&gt;

&lt;p&gt;PHP 8 pipeline that ingests tour dates from Bandsintown and Ticketmaster, normalizes them into one row shape, dedupes across sources, caches with versioned JSON files, and prunes past dates. CI-tested, zero Composer dependencies.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/tour-feed-pipeline" rel="noopener noreferrer"&gt;Repo&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  event-calendar-schema
&lt;/h3&gt;

&lt;p&gt;The MySQL schema and PDO repository behind the calendar. Idempotent event upserts, double opt-in subscriber management, query-shaped indexes. Every design decision documented inline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/event-calendar-schema" rel="noopener noreferrer"&gt;Repo&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Security &amp;amp; Quality
&lt;/h2&gt;

&lt;h3&gt;
  
  
  csrf-json-fetch
&lt;/h3&gt;

&lt;p&gt;A hardened wrapper around fetch() that handles CSRF token caching, dual injection (header + request body), single-retry on token expiry, and typed error responses. Every endpoint on the site uses this.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/csrf-json-fetch" rel="noopener noreferrer"&gt;Repo&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/csrf-json-fetch/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  sri-lazy-loader
&lt;/h3&gt;

&lt;p&gt;Race-safe lazy script loader with Subresource Integrity. Handles concurrent callers (only one script tag injected), detects pre-existing tags so it never duplicates, times out and cleans up on failure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/sri-lazy-loader" rel="noopener noreferrer"&gt;Repo&lt;/a&gt; | &lt;a href="https://bryanhamiltondev.github.io/sri-lazy-loader/" rel="noopener noreferrer"&gt;Live Demo&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  jsonld-schema-audit
&lt;/h3&gt;

&lt;p&gt;A zero-dependency PHP CLI that audits JSON-LD structured data. Detects missing required properties per schema.org type, resolves internal &lt;a class="mentioned-user" href="https://dev.to/id"&gt;@id&lt;/a&gt; references, and fails CI on regressions. Runs on any PHP 8+ system with no installation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/jsonld-schema-audit" rel="noopener noreferrer"&gt;Repo&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  wp-schema-audit
&lt;/h3&gt;

&lt;p&gt;The WordPress version of the same auditor. Ships as a WP-CLI command and an admin Tools page. Point it at any post type and it validates every schema block on your site.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/wp-schema-audit" rel="noopener noreferrer"&gt;Repo&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Tooling
&lt;/h2&gt;

&lt;h3&gt;
  
  
  resume-builder
&lt;/h3&gt;

&lt;p&gt;A self-hosted PHP resume builder. Pick a template (tech or retail), fill in the hiring manager's name, company, and role, and get a finished PDF. Zero dependencies — pure PHP with a clean HTML template system.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/resume-builder" rel="noopener noreferrer"&gt;Repo&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  resume-hub
&lt;/h3&gt;

&lt;p&gt;The landing page that powers &lt;a href="https://bryanhamilton.info" rel="noopener noreferrer"&gt;bryanhamilton.info&lt;/a&gt;. "Same guy. Two ways to hire him." — two resume packages, one page, stat counters, vCard QR code, and a work-ethic section. The page I send employers to.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/resume-hub" rel="noopener noreferrer"&gt;Repo&lt;/a&gt; | &lt;a href="https://bryanhamilton.info" rel="noopener noreferrer"&gt;Live&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Every one of these repos started as a real problem on a real site. The search index handles real queries from real users. The tour pipeline processes real API responses with real edge cases (missing fields, rate limits, encoding quirks). The CSRF wrapper protects real endpoints.&lt;/p&gt;

&lt;p&gt;That's the difference between a portfolio of tutorials and a portfolio of production code. These patterns were battle-tested before they were extracted.&lt;/p&gt;

&lt;p&gt;And they all share the same DNA: no frameworks, no build tools, no package managers. Just PHP and vanilla JavaScript, the way the web was built and the way it still runs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;All repos are MIT licensed on &lt;a href="https://github.com/bryanhamiltondev" rel="noopener noreferrer"&gt;github.com/bryanhamiltondev&lt;/a&gt;. Follow me on &lt;a href="https://dev.to/bryanhamiltondev"&gt;dev.to&lt;/a&gt; for the next extraction in this series.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>php</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Classic Snake and Tank Wars!</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Thu, 08 Oct 2026 03:52:18 +0000</pubDate>
      <link>https://dev.to/bryan_hamilton/classic-snake-and-tank-wars-1bp0</link>
      <guid>https://dev.to/bryan_hamilton/classic-snake-and-tank-wars-1bp0</guid>
      <description>&lt;p&gt;I wanted a couple of small games for my portfolio that anyone could play in ten seconds, so I rebuilt two classics: Snake and Tank Wars. Everything lives in a single index.html, written in plain JavaScript on a canvas, with no framework, no bundler and no dependencies.&lt;/p&gt;

&lt;p&gt;Play it: &lt;a href="https://bryanhamilton.info/arcade.html" rel="noopener noreferrer"&gt;bryanhamilton.info/arcade.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here are the parts that were more interesting to build than I expected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tank Wars&lt;/strong&gt;&lt;br&gt;
Terrain from three sine waves&lt;/p&gt;

&lt;p&gt;The ground is a height map: one y value per pixel column. I add three sine waves of different frequency, then raise the middle so there's always a hill between the two tanks and you can't fire straight across.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
for (let i = 0; i &amp;lt; W; i++) {&lt;br&gt;
  const mid = 1 - Math.abs(i - W / 2) / (W / 2);   // 1 in the center, 0 at the edges&lt;br&gt;
  h[i] = clamp(&lt;br&gt;
    300 - mid * 40&lt;br&gt;
    + 40 * Math.sin(i * .009 + p[0])&lt;br&gt;
    + 22 * Math.sin(i * .025 + p[1])&lt;br&gt;
    +  9 * Math.sin(i * .07  + p[2]),&lt;br&gt;
    150, 410&lt;br&gt;
  );&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;The three phases p are random, so every battle gets a different map.&lt;/p&gt;

&lt;p&gt;Shells, gravity and wind&lt;/p&gt;

&lt;p&gt;Each frame the shell gets wind added to its horizontal velocity and gravity added to its vertical velocity, then moves. That's the whole physics model:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
s.vx += wind; s.vy += G;&lt;br&gt;
s.x  += s.vx; s.y  += s.vy;&lt;/p&gt;

&lt;p&gt;Wind is re-rolled every turn, which is what makes a "perfect" shot from last turn a miss this turn. The animation loop runs three of these steps per frame so shells feel quick without changing the math.&lt;/p&gt;

&lt;p&gt;Craters are just a height-map edit&lt;/p&gt;

&lt;p&gt;Because the terrain is an array, destruction is cheap. When a shell lands, I carve a circle out of every column it touches:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
for (let i = Math.max(0, Math.floor(px - R)); i &amp;lt; Math.min(W, px + R); i++) {&lt;br&gt;
  const dx = i - px;&lt;br&gt;
  const s  = Math.sqrt(Math.max(0, R * R - dx * dx));&lt;br&gt;
  if (py - s &amp;lt; h[i]) h[i] = clamp(Math.max(h[i], py + s), 0, H);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Tanks don't store their own y. They look up h[x] whenever they're drawn, so when the ground disappears under one, it simply drops into the crater with no extra code. Damage falls off with distance from the blast center, so a near miss still hurts.&lt;/p&gt;

&lt;p&gt;A CPU opponent that checks its work&lt;/p&gt;

&lt;p&gt;I didn't want to hand-tune an AI. Instead the CPU guesses: it picks 260 random angle and power pairs, simulates each shot against the current terrain and wind, and keeps whichever lands closest to you. Then it adds a few degrees of random error so it isn't a perfect shot every time.&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
for (let i = 0; i &amp;lt; 260; i++) {&lt;br&gt;
  const a = 20 + Math.random() * 140, p = 20 + Math.random() * 80;&lt;br&gt;
  const r = sim(a, p, me.x, gy(me) - 12);            // where would this land?&lt;br&gt;
  if (!r) continue;&lt;br&gt;
  const d = Math.hypot(r.x - foe.x, r.y - (gy(foe) - 6));&lt;br&gt;
  if (d &amp;lt; bestDist) { bestDist = d; best = { a, p }; }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Because it uses the same sim function as the real physics, it accounts for wind and craters automatically, and difficulty is a single number: how much noise you add afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snake&lt;/strong&gt;&lt;br&gt;
The input queue&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The classic Snake bug:&lt;/em&gt; you press up then left quickly, both land between two game ticks, and the snake turns back into itself. The fix is to queue turns and apply one per tick, rejecting any turn that reverses or repeats the last queued direction:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
function turn(d) {&lt;br&gt;
  const v = { u: [0, -1], d: [0, 1], l: [-1, 0], r: [1, 0] }[d];&lt;br&gt;
  const last = queue.length ? queue[queue.length - 1] : dir;&lt;br&gt;
  if ((v[0] === -last.x &amp;amp;&amp;amp; v[1] === -last.y) || (v[0] === last.x &amp;amp;&amp;amp; v[1] === last.y)) return;&lt;br&gt;
  queue.push({ x: v[0], y: v[1] });&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Fast double-turns now work, and reversing into yourself isn't possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed, storage and touch&lt;/strong&gt;&lt;br&gt;
Speed curve: the delay starts at 125 ms and drops 3 ms per point, with a 55 ms floor so it stays playable. I used a setTimeout chain instead of setInterval so the delay can change on every step.&lt;/p&gt;

&lt;p&gt;Best score: saved in localStorage inside a try/catch. If a browser blocks storage (private modes, some embeds), the game still runs and just skips saving.&lt;/p&gt;

&lt;p&gt;Touch: swipes on the canvas steer, a tap restarts after game over, and an on-screen d-pad appears only on devices without a precise pointer.&lt;/p&gt;

&lt;p&gt;Tab switching: the game pauses when you switch to Tank Wars, so you don't come back to a dead snake.&lt;br&gt;
Small things that paid off&lt;/p&gt;

&lt;p&gt;One file, zero dependencies. It's easy to host, easy to embed in an iframe, and nothing can break because a package moved.&lt;/p&gt;

&lt;p&gt;A tiny CI check. A standard-library Python script verifies the meta tags, parses the JSON-LD, confirms the share image is a 1200×630 PNG, checks that every element ID the script looks up exists, and runs node --check on the inline script. It's about 100 lines and has already caught typos.&lt;/p&gt;

&lt;p&gt;Colors as CSS variables. The page matches my portfolio's palette because the tokens live in one place at the top of the stylesheet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd do next&lt;/strong&gt;&lt;br&gt;
Different weapons in Tank Wars, such as a shell that splits or one that digs.&lt;/p&gt;

&lt;p&gt;Online two-player, which would mean moving the game state off the client.&lt;/p&gt;

&lt;p&gt;A touch-friendlier aiming control, maybe drag-to-aim instead of sliders.&lt;/p&gt;

&lt;p&gt;The code is MIT-licensed, so take it, break it and make it yours. If you have ideas for the CPU or the physics, I'd like to hear them in the comments or as issues on the repo.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>html</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
