<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[DVT Software Engineering - Medium]]></title>
        <description><![CDATA[Making an impact in Software Engineering - Medium]]></description>
        <link>https://medium.com/dvt-engineering?source=rss----eb2b463a00e2---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>DVT Software Engineering - Medium</title>
            <link>https://medium.com/dvt-engineering?source=rss----eb2b463a00e2---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 12 Nov 2026 16:59:57 GMT</lastBuildDate>
        <atom:link href="https://medium.com/feed/dvt-engineering" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[How to stream structured AI on Android without sacrificing UX]]></title>
            <link>https://medium.com/dvt-engineering/how-to-stream-structured-ai-on-android-without-sacrificing-ux-667ac8b83e7c?source=rss----eb2b463a00e2---4</link>
            <guid isPermaLink="false">https://medium.com/p/667ac8b83e7c</guid>
            <category><![CDATA[parser]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[ux]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[token]]></category>
            <dc:creator><![CDATA[George Mujuru]]></dc:creator>
            <pubDate>Fri, 05 Jun 2026 10:47:21 GMT</pubDate>
            <atom:updated>2026-06-05T12:26:26.196Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Why parser-first streaming creates cleaner interfaces, better feedback and more meaningful progress than raw token output</em></p><p><em>By </em><a href="https://medium.com/u/67fd67b3aa39"><em>George Mujuru</em></a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HE4kyeNFyx3lX6lymOJoTA.png" /></figure><p><strong>1. The problem: why streamed AI still feels slow</strong></p><p>Latency is usually the first thing Android teams notice when they add AI features. Five seconds for a chat reply feels slow. Fifteen seconds feels unbearable. That is why streaming, through Server-Sent Events (SSE) and token-by-token updates, has become the default pattern, popularised by ChatGPT.</p><p>But streamed text does not necessarily show meaningful progress. Users are not waiting for words to appear; they are waiting for a useful result, whether that is a four-day trip itinerary, a contract summary or a workout draft.</p><p>Raw token streaming shows the model “typing”, but not the artefact taking shape. Instead of seeing a plan assemble itself, users watch JSON fragments and formatting noise scroll past — and then suddenly get a complete result. The transition is jarring and breaks immersion. The system still feels slow, even when it is not.</p><p>The question, then, is not just how to stream faster. It is how to make users feel that something useful is being built in front of them.</p><p><strong>2. The solution: parser-first streaming with WayPoint</strong></p><p>Parser-first streaming reverses the usual approach. Instead of accumulating all incoming data and parsing after the stream ends, the parser processes each incoming chunk incrementally and emits structural events as soon as a recognisable unit is ready. The UI updates progressively, titles appear, days fill in, activities slot into place, as the model generates them.</p><p>This approach is demonstrated in WayPoint, an open-source Android travel planner built to explore exactly this problem. The project is available at:</p><p><a href="https://github.com/tungamiraimujuru/Waypoint">github.com/tungamiraimujuru/Waypoint</a></p><p>WayPoint integrates with Anthropic’s Claude API to generate structured travel itineraries as JSON, streams the response via SSE, and renders each piece of the itinerary as it arrives — without ever showing the user a raw JSON fragment.</p><figure><img alt="Waypoint demo, of functionality" src="https://cdn-images-1.medium.com/max/800/1*MYuXUS6B_NHSw5wvTNn_0g.gif" /></figure><p><strong>3. Architecture overview</strong></p><p>WayPoint’s architecture started with a simple question: where should the work live? The easiest answer is to do everything inside a single Composable, with networking, streaming state, parsing and rendering all handled in one place. That works for a demo, but it breaks down quickly when you need to test, swap AI providers or reuse logic across screens.</p><p>The work is instead split into five focused components:</p><p><strong>Orchestrator</strong></p><p>Exposes a simple interface: take a request, return a stream of AI events. This shields the rest of the app from which model or provider is in use — whether Anthropic, Gemini, an on-device model or a test fixture.</p><p><strong>SSE client</strong></p><p>Handles HTTP streaming and is unaware of prompts or domain logic. It streams text deltas and cleans up on cancellation. This keeps business logic out of networking code.</p><p><strong>Parser</strong></p><p>The heart of the system. A pure, dependency-free component that processes incoming bytes incrementally and emits structural events. It has no Android dependencies and no coroutines, which keeps it reusable and easy to test in isolation.</p><p><strong>Reducer</strong></p><p>A pure function that takes the current UI state and an event, and returns a new UI state. It lives outside the ViewModel, making logic straightforward to test without any Android frameworks.</p><p><strong>ViewModel</strong></p><p>Glue code that collects events from the orchestrator, runs them through the reducer and exposes a StateFlow to the UI. It stays small because the heavy lifting is elsewhere.</p><p>A few design choices here diverge from typical Android practice. There is no separate use-cases layer — it was an unnecessary ceremony for this scope. A single sealed event stream preserves event ordering. And the parser is kept separate from the network client so that parsing logic stays reusable and isolated.</p><p><strong>4. How the parser works</strong></p><p>Most streaming implementations buffer all incoming data and parse it once the stream ends. This is simple to implement, but the experience it produces, raw JSON appearing character by character, followed by an instant render, is exactly the problem we are trying to solve.</p><p>The WayPoint parser takes a different approach. It is a compact state machine, around 200 lines of Kotlin, that:</p><ul><li>Tracks opening and closing braces to identify when a structural unit (an activity, a day, a title) is complete</li><li>Ignores braces inside strings, to avoid false positives from quoted content</li><li>Strips Markdown code fences that Claude occasionally inserts despite explicit instructions not to — this happened roughly 5% of the time in testing</li><li>Handles truncated buffers mid-string and stays re-entrant by consuming the buffer as it processes</li></ul><p>The parser runs in microseconds, has no Android dependencies and is the most defensively coded piece in the project. Here is a simplified version of the core parsing loop:</p><pre>fun processChunk(chunk: String) { <br>    buffer += chunk <br>    while (buffer.isNotEmpty()) { <br>        when (state) { <br>            OUTSIDE_OBJECT -&gt; { <br>                val start = buffer.indexOf(&#39;{&#39;) <br>                if (start == -1) {<br>                 buffer = &quot;&quot;;<br>                 return <br>                } <br>                buffer = buffer.substring(start) <br>                state = INSIDE_OBJECT <br>                depth = 0 <br>                inString = false <br>                escaped = false <br>            } <br>            INSIDE_OBJECT -&gt; { <br>                loop@ for (i in buffer.indices) { <br>                    val c = buffer[i] <br>                    when { <br>                        escaped -&gt; escaped = false <br>                        c == &#39;\\&#39; &amp;&amp; inString -&gt; escaped = true <br>                        c == &#39;&quot;&#39; -&gt; inString = !inString <br>                        !inString &amp;&amp; c == &#39;{&#39; -&gt; depth++ <br>                        !inString &amp;&amp; c == &#39;}&#39; -&gt; { <br>                            depth-- <br>                            if (depth == 0) { <br>                                emit(buffer.substring(0, i + 1)) <br>                                buffer = buffer.substring(i + 1) <br>                                state = OUTSIDE_OBJECT <br>                                break@loop <br>                            } <br>                        } <br>                    } <br>                } <br>            } <br>        } <br>    } <br>} </pre><p>Each time a complete JSON object is identified, it is emitted as a structural event. The UI reacts to that event immediately, rendering the new element without waiting for the rest of the stream.</p><p><strong>5. Compose UI rendering</strong></p><p>The parser emits structural events, and the UI has to render them efficiently, sometimes dozens of times per second under heavy streaming load.</p><p>The standard Compose pattern of mutating state directly inside the ViewModel became cumbersome at this frequency. The fix was to move state transformation into the pure reducer described above. The ViewModel collects events, applies the reducer and updates a StateFlow. That keeps the ViewModel thin and makes the state transformation logic easy to test independently.</p><p>Two Compose-specific optimisations made a significant difference:</p><ul><li>Stable keys on list items prevent unnecessary recomposition when the list grows incrementally</li><li>Separating the itinerary preview from the streaming message panel means only the component that changes gets recomposed</li></ul><p>Here is a simplified example of how the ViewModel wires events to state:</p><pre>viewModelScope.launch { <br>    orchestrator.stream(request).collect { event -&gt; <br>        _uiState.update { currentState -&gt; <br>            reducer(currentState, event) <br>        } <br>    } <br>} </pre><p>This pattern kept UI performance smooth under streaming loads, with no dropped frames observed during testing on a mid-range Android device.</p><p><strong>6. Claude integration: what worked and what did not</strong></p><p>Using Anthropic’s Claude was a straightforward choice given its stable API and reliable structured JSON output. But integration revealed a few practical challenges worth documenting.</p><p><strong>Default serialisation omits default values</strong></p><p>Kotlin’s default serialisation behaviour omits fields that match their default values. This caused 400 errors because the Claude API expected explicit field names — including the model name — on every request. The fix was to enable encoding of defaults explicitly in the serialisation configuration, ensuring all required fields are always sent.</p><p><strong>Retry logic for streaming failures</strong></p><p>Streaming can fail mid-response: network drops, 5xx errors and rate limits all occur in practice. WayPoint implements bounded retries on these transient errors. Parse errors and client-side 4xx errors do not trigger retries, to avoid wasting API credits on requests that will not succeed.</p><p><strong>Markdown fencing</strong></p><p>Despite explicit instructions in the system prompt to return only raw JSON, Claude wrapped its output in Markdown code fences (```json … ```) approximately 5% of the time during testing. The parser strips these defensively. This is a good example of why production LLM integration requires forgiving parsers — even well-instructed models are not perfectly consistent.</p><p><strong>7. Try it yourself</strong></p><p><strong>Get the code</strong></p><p>WayPoint is open-source and available on GitHub:</p><p><a href="https://github.com/tungamiraimujuru/Waypoint">github.com/tungamiraimujuru/Waypoint</a></p><p><strong>Prerequisites</strong></p><p>Before you begin, you will need:</p><ul><li>Android Studio Hedgehog or later</li><li>Kotlin 1.9+</li><li>An Anthropic API key (available at console.anthropic.com)</li><li>Gradle 8.0+</li></ul><p><strong>Key dependencies</strong></p><p>Add the following to your build.gradle.kts:</p><pre>// HTTP streaming <br>implementation(&quot;com.squareup.okhttp3:okhttp:4.12.0&quot;) <br>// JSON serialisation <br>implementation(&quot;org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3&quot;) <br>// Coroutines <br>implementation(&quot;org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3&quot;) </pre><p><strong>Getting started</strong></p><p>Clone the repository and open it in Android Studio:</p><pre>git clone https://github.com/tungamiraimujuru/Waypoint.git <br>cd Waypoint </pre><p>Add your Anthropic API key to local.properties:</p><pre>ANTHROPIC_API_KEY=your_key_here </pre><p>Build and run on a device or emulator running Android API 26 or above. The main entry point for the streaming logic is in StreamingOrchestrator.kt. The parser lives in IncrementalJsonParser.kt — this is the best place to start if you want to adapt the pattern for a different structured output format.</p><p>The architecture is intentionally modular. Swapping Claude for a different provider means replacing the SSE client and adjusting the prompt — the parser, reducer and UI layer remain unchanged.</p><p><strong>8. Conclusion</strong></p><p>The core lesson is straightforward: streaming structured AI output is a user experience problem before it is a networking problem. If users cannot see the result taking shape, the system still feels slow, regardless of actual response times.</p><p>A parser-first approach solves this by turning incoming tokens into visible structure. Users do not watch syntax arrive; they watch a plan, a summary or a draft assemble itself step by step.</p><p>WayPoint is a prototype, not a production system. It is missing observability tooling, remote configuration for prompts and model settings, provider fallback chains and performance instrumentation. But the architecture already makes room for these without major rework, and the pattern itself is solid enough to build on.</p><p>Android still lacks much of the ready-made streaming tooling that exists in the JavaScript ecosystem. That leaves room to experiment, and hopefully WayPoint is a useful starting point for teams trying to make AI on Android feel less clunky and more useful.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=667ac8b83e7c" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dvt-engineering/how-to-stream-structured-ai-on-android-without-sacrificing-ux-667ac8b83e7c">How to stream structured AI on Android without sacrificing UX</a> was originally published in <a href="https://medium.com/dvt-engineering">DVT Software Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Computer Vision AI Bot for Subway Surfers Using ML.NET, and OpenCVSharp (in C#)]]></title>
            <link>https://medium.com/dvt-engineering/building-a-computer-vision-ai-bot-for-subway-surfers-using-ml-net-and-opencvsharp-in-c-e202d64e4531?source=rss----eb2b463a00e2---4</link>
            <guid isPermaLink="false">https://medium.com/p/e202d64e4531</guid>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[csharp]]></category>
            <category><![CDATA[computer-vision]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[opencv]]></category>
            <dc:creator><![CDATA[Grant Jansen]]></dc:creator>
            <pubDate>Fri, 20 Mar 2026 07:29:19 GMT</pubDate>
            <atom:updated>2026-03-20T07:29:18.463Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bAge5YjBlmMabQ4MLIghqA.png" /></figure><h3>Building a Computer Vision AI Bot for Subway Surfers Using ML.NET and OpenCVSharp (in C#)</h3><h3>This walkthrough shows how to build a real-time computer vision pipeline in C# using:</h3><ul><li>A .NET 10 console app</li><li>ML.NET object detection model trained with Visual Studio Model Builder</li><li>OpenCvSharp for debug visualization (drawing boxes, saving frames)</li><li>Win32 SendInput for swipe-like mouse gestures</li></ul><p>In this article we will create a real-time computer vision pipeline that captures live screen frames, runs them through an ML.NET object detection model, and reacts to predictions in milliseconds — all without leaving the .NET ecosystem</p><p>The goal is to show how little code you actually need to go from raw screen pixels to real-time object detection once you have a trained ML.NET model. (excluding the debugging code to help us debug what the model sees and outputs)</p><h4>Disclaimer</h4><p>This example is shared for educational purposes to demonstrate real-time computer vision, ML.NET object detection, and input automation techniques in c#. Automating gameplay may violate a game’s terms of service, so please ensure you understand and comply with the relevant policies before experimenting with similar approaches.</p><h3>What We’re Building</h3><p>By the end we’ll have:</p><ul><li>A program that captures a region of the screen (your emulator window)</li><li>A trained object detection model that detects obstacles like trains, hurdles, barriers, etc.</li><li>Logic that decides an action (jump/roll/left/right)</li><li>Input automation that performs the action</li><li>Debug images saved to disk showing what the model saw and what the bot decided</li></ul><h3>Prerequisites</h3><ul><li>Windows</li><li>Visual Studio 2022+ with the ML.NET Model Builder extension installed</li><li>An Android emulator running Subway Surfers</li><li>Basic C# knowledge</li></ul><h3>Step 1: Create a .NET 10 console project and install packages</h3><p>Run the following in your favourite terminal.</p><p>Create the project:</p><pre>dotnet new console -n SubwaySurferPlayer -f net10.0<br>cd SubwaySurferPlayer</pre><p>Add the packages:</p><pre>dotnet add package Microsoft.ML<br>dotnet add package Microsoft.ML.ImageAnalytics<br>dotnet add package OpenCvSharp4<br>dotnet add package OpenCvSharp4.runtime.win<br>dotnet add package OpenCvSharp4.Extensions<br>dotnet add package System.Drawing.Common</pre><h3>Step 2: Set up your emulator</h3><p>For the emulation, I’ll be using <a href="https://www.memuplay.com/">Memu Play 9</a> but feel free to use whatever emulator you are comfortable with. After setting up Google Play services, install Subway Surfers.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/621/1*nYQR_Zv4Jp2m8nDhes9edQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/622/1*qZTHrcGVVYnO1NM0JAuhYQ.png" /></figure><p>Now we need our program to see the screen.</p><h3>Step 3: Capture the emulator window</h3><p>Create ScreenCapture.cs. It provides two capture methods: CaptureScreen returns an OpenCV Mat (handy for direct image processing), and CaptureScreenToStream returns a MemoryStream (what ML.NET&#39;s MLImage expects). SaveImage is a thin wrapper around OpenCV&#39;s ImWrite.</p><pre>using OpenCvSharp;<br>using OpenCvSharp.Extensions;<br>using System.Drawing;<br>using System.Drawing.Imaging;<br><br>namespace SubwaySurferPlayer<br>{<br>    public static class ScreenCapture<br>    {<br>        public static Mat CaptureScreen(int x, int y, int width, int height)<br>        {<br>            using var bitmap = new Bitmap(width, height, PixelFormat.Format24bppRgb);<br>            using var graphics = Graphics.FromImage(bitmap);<br>            graphics.CopyFromScreen(x, y, 0, 0, new System.Drawing.Size(width, height), CopyPixelOperation.SourceCopy);<br>            return BitmapConverter.ToMat(bitmap);<br>        }<br>        public static MemoryStream CaptureScreenToStream(int x, int y, int width, int height)<br>        {<br>            using var bitmap = new Bitmap(width, height, PixelFormat.Format24bppRgb);<br>            using var graphics = Graphics.FromImage(bitmap);<br>            graphics.CopyFromScreen(x, y, 0, 0, new System.Drawing.Size(width, height), CopyPixelOperation.SourceCopy);<br>            var stream = new MemoryStream();<br>            bitmap.Save(stream, ImageFormat.Png);<br>            stream.Position = 0;<br>            return stream;<br>        }<br>        public static bool SaveImage(Mat image, string filename)<br>        {<br>            return Cv2.ImWrite(filename, image);<br>        }<br>    }<br>}</pre><p>Think of x and y as the top-left corner of your capture rectangle, and width/height as the distance to travel from that point. The top-left corner of your screen is x: 0, y: 0; the bottom-right is your screen resolution, e.g. 1920 x 1080.</p><h3>Finding your capture coordinates</h3><p>Figuring out the right capture rectangle can be tricky. Create CaptureHelper.cs as a one-shot diagnostic tool. Run it once and it saves test screenshots of several candidate regions to disk so you can see which one frames your emulator correctly.</p><pre>using OpenCvSharp;<br><br>namespace SubwaySurferPlayer<br>{<br>    internal static class CaptureHelper<br>    {<br>        public static void RunDiagnostic()<br>        {<br>            Console.WriteLine(&quot;=== Screen Capture Helper ===&quot;);<br>            Console.WriteLine(&quot;This tool helps you find the correct coordinates for your game window.\n&quot;);<br>            var testAreas = new[]<br>            {<br>                new { Name = &quot;Default&quot;,        X = 900, Y =   0, W = 800, H = 1368 },<br>                new { Name = &quot;Top-Left&quot;,       X =   0, Y =   0, W = 800, H = 1368 },<br>                new { Name = &quot;Center&quot;,         X = 560, Y =   0, W = 800, H = 1368 },<br>                new { Name = &quot;Full-HD-Center&quot;, X = 560, Y = 140, W = 800, H = 1000 },<br>            };<br>            Directory.CreateDirectory(&quot;capture_tests&quot;);<br>            Console.WriteLine(&quot;Capturing test images in 3 seconds...\n&quot;);<br>            Thread.Sleep(3000);<br>            foreach (var area in testAreas)<br>            {<br>                try<br>                {<br>                    Console.WriteLine($&quot;Capturing: {area.Name} (X={area.X}, Y={area.Y}, W={area.W}, H={area.H})&quot;);<br>                    using var image = ScreenCapture.CaptureScreen(area.X, area.Y, area.W, area.H);<br>                    Cv2.PutText(image,<br>                        $&quot;{area.Name}: X={area.X}, Y={area.Y}, {area.W}x{area.H}&quot;,<br>                        new Point(10, 30),<br>                        HersheyFonts.HersheySimplex, 1.0, new Scalar(0, 255, 0), 2);<br>                    string filename = $&quot;capture_tests/{area.Name}.png&quot;;<br>                    ScreenCapture.SaveImage(image, filename);<br>                    Console.WriteLine($&quot;  -&gt; Saved: {filename}\n&quot;);<br>                }<br>                catch (Exception ex)<br>                {<br>                    Console.WriteLine($&quot;  -&gt; ERROR: {ex.Message}\n&quot;);<br>                }<br>            }<br>            Console.WriteLine(&quot;Check the images in &#39;capture_tests/&#39; and update GAME_X/GAME_Y/GAME_WIDTH/GAME_HEIGHT in Program.cs.&quot;);<br>            Console.WriteLine(&quot;\nPress any key to continue...&quot;);<br>            Console.ReadKey();<br>        }<br>    }<br>}</pre><p>To invoke the diagnostic, add this check at the top of Main (shown in full in Step 7):</p><pre>Log(&quot;INFO&quot;, &quot;Press &#39;D&#39; to run screen capture diagnostic, or any other key to start bot...&quot;);<br>var key = Console.ReadKey(true);<br>if (key.Key == ConsoleKey.D)<br>{<br>    CaptureHelper.RunDiagnostic();<br>    return;<br>}</pre><p>Alternatively, take a screenshot with Win + Shift + S, open it in Paint, hover over the top-left corner of the emulator window, and note the X, Y values. Then measure the window&#39;s width and height.</p><h3>Step 4: Train an object detection model using ML.NET Model Builder</h3><p>This is the “magic sauce” that makes ML.NET feel easy. If you haven’t set up Model Builder yet, follow the Getting Started docs to install the Visual Studio extension.</p><p>In Visual Studio:</p><ol><li>Right-click your project → Add → Machine Learning Model…</li><li>Choose Object Detection</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/537/1*t0CYufQpk8INnw3gHoiA3Q.png" /><figcaption>Scenario</figcaption></figure><p>3. Point it at your dataset (images + labels). I used VoTT for labelling — screenshots of the game with each obstacle type annotated.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/612/1*zbeLg4tSyi1evNotDgVzvw.png" /><figcaption>Data</figcaption></figure><p>4. Choose your label format (I used VoTT)</p><p>5. Train. My training took about 5 minutes on a GTX 1060 6 GB. Time will vary based on dataset size and hardware.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/500/1*16L8RWpXHWHhnFO5MclDZg.png" /><figcaption>Train</figcaption></figure><p>6. Evaluate. Upload a test image to verify the model looks reasonable. If you’re happy, move on.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/826/1*4GrL4qamQ6KPUWXXcaxFGg.png" /><figcaption>Evaluate</figcaption></figure><p>7. Generate code. A code snippet is generated for you, or you can add a console app or web API to the solution with full boilerplate.</p><p>After training you’ll have:</p><ul><li>An ObjectDetection.mbconfig file</li><li>A generated ObjectDetection class with:</li><li>ModelInput containing an MLImage Image property</li><li>A Predict(ModelInput) method</li><li>ModelOutput containing PredictedBoundingBoxes, Score, and PredictedLabel</li></ul><p>That generated ObjectDetection class is what Program.cs calls.</p><h3>Step 5: Draw detections and save debug frames</h3><p>This step is optional but strongly recommended. Being able to see exactly what the model detected and what action the bot decided makes tuning much faster.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*-hu-hLCDMiyBbJNRXqVnGA.png" /><figcaption>Debug image</figcaption></figure><p>Calling Predict on our ObjectDection class calls ML.NET’s PredictionEngine Predict method. This will return a ModelOuput object with the following properies:</p><ul><li><strong>PredictedLabel</strong>: A list of names for each thing the model spotted, e.g. “train red” or “hurdle high”.</li><li><strong>Score</strong>: A confidence level between 0 and 1 for each of the detections. 0.9 means very sure while 0.3 would be a guess.</li><li><strong>PredictedBoundingBoxes</strong>: The location of each detected thing on screen, stored as a flat list of numbers. Every four numbers describes the box like so: [x_topleft, y_topleft, x_bottomright, y_bottomright]</li><li><strong>Labels</strong>: (Not useful at runtime, perhaps for a fallback) The index of a label from the list of potential labels, e.g. Labels[i] = 2, PredictedLabel[i] = the string mapped from that index</li><li><strong>Box</strong>: (Not useful at runtime) Used in training for holding the VoTT labels we define when creating training data.</li><li><strong>Image</strong>: (Not useful at runtime, used in retraining) The input image after it’s been resized to the training dimensions in ObjectDetection.BuildPipeline</li></ul><p>You could iterate the model output like below.</p><p><em>0 ≥ i &lt; PredictedLabelLength.</em></p><p>PredictedLabel[i] → the label<br>Score[i] → the confidence<br>PredictedBoundingBoxes[i * 4] → x top-left<br>PredictedBoundingBoxes[i * 4 + 1] → y top-left<br>PredictedBoundingBoxes[i * 4 + 2] → x bottom-right<br>PredictedBoundingBoxes[i * 4 + 3] → y bottom-right</p><h3>Debugging</h3><p>SaveDetectionsDebugImage decodes the captured frame bytes into an OpenCV Mat, maps bounding boxes into pixel space via NormalizeRect, draws labelled rectangles with a deterministic colour per label, then writes the annotated frame to disk. It lives in Program.cs alongside the rest of the bot logic.</p><p>The bounding-box normalisation (NormalizeRect) handles two coordinate systems the model might produce: 0..1 normalised values, or larger model-space coordinates that need to be scaled into capture-space. You&#39;ll see its full implementation in Step 7.</p><p>SummarizeDetections builds a compact filename-safe string of the top detections so each saved frame self-describes what the model saw. SanitizeFileName keeps the resulting path legal on Windows.</p><p>To keep debug saves from stalling the prediction loop, all three methods are called from a background BlockingCollection writer that is set up in Main:</p><pre>using var debugCts = new CancellationTokenSource();<br>var debugQueue = new BlockingCollection&lt;Action&gt;(boundedCapacity: maxQueuedDebugFrames);<br>var debugWriter = Task.Run(() =&gt;<br>{<br>    try<br>    {<br>        foreach (var work in debugQueue.GetConsumingEnumerable(debugCts.Token))<br>        {<br>            try { work(); }<br>            catch { }<br>        }<br>    }<br>    catch (OperationCanceledException) { }<br>});</pre><p>Then instead of calling SaveDetectionsDebugImage directly on the hot path, the frame is captured into local variables and enqueued:</p><pre>var bytesCopy  = frameBytes;<br>var boxesCopy  = predictionResult.PredictedBoundingBoxes;<br>var scoresCopy = predictionResult.Score;<br>var labelsCopy = predictionResult.PredictedLabel;<br>var tagCopy    = frameDecisionTag;<br>var reasonCopy = frameDecisionReason;<br><br>if (!debugQueue.IsAddingCompleted)<br>{<br>    debugQueue.TryAdd(() =&gt;<br>    {<br>        SaveDetectionsDebugImage(<br>            bytesCopy, detectionsFolder, frameCount,<br>            boxesCopy, scoresCopy, labelsCopy,<br>            GAME_WIDTH, GAME_HEIGHT, tagCopy, reasonCopy);<br>    });<br>}</pre><p>The locals are captured into the lambda so the main loop can continue to the next frame immediately. The full implementations of these methods are included in the complete Program.cs in Step 7.</p><h3>Step 6: Input automation</h3><p>Because Subway Surfers runs inside an emulator window, the simplest reliable approach is to simulate what your finger would do on a touchscreen: press, drag, release. On Windows we can generate low-level mouse input using the Win32 API SendInput(uint, Input[], int).</p><p>SendInput lives in user32.dll, which is unmanaged (native) Windows code. C# runs in managed .NET, so we use P/Invoke (Platform Invocation Services) to call it.</p><h3>P/Invoke declarations</h3><p>These go inside the Program class:</p><pre>[DllImport(&quot;user32.dll&quot;, EntryPoint = &quot;SetCursorPos&quot;)]<br>[return: MarshalAs(UnmanagedType.Bool)]<br>public static extern bool SetCursorPos(int x, int y);<br><br>[DllImport(&quot;user32.dll&quot;)]<br>public static extern bool GetCursorPos(out POINT lpPoint);<br><br>[DllImport(&quot;user32.dll&quot;, SetLastError = true)]<br>private static extern uint SendInput(uint nInputs, Input[] pInputs, int cbSize);<br><br>[DllImport(&quot;user32.dll&quot;)]<br>private static extern IntPtr GetMessageExtraInfo();<br><br>[DllImport(&quot;User32.dll&quot;, ExactSpelling = true, CharSet = CharSet.Auto)]<br>public static extern int GetSystemMetrics(int nIndex);</pre><h3>Interop structs and enums</h3><p>These are defined as top-level types outside the namespace so they are available everywhere in the file. The [StructLayout] attributes tell .NET how to lay out the structs in memory so they match what Win32 expects.</p><pre>[StructLayout(LayoutKind.Sequential)]<br>public struct POINT<br>{<br>    public int X;<br>    public int Y;<br>}<br><br>[StructLayout(LayoutKind.Sequential)]<br>public struct KeyboardInput<br>{<br>    public ushort wVk;<br>    public ushort wScan;<br>    public uint dwFlags;<br>    public uint time;<br>    public IntPtr dwExtraInfo;<br>}<br>[StructLayout(LayoutKind.Sequential)]<br>public struct MouseInput<br>{<br>    public int dx;           // delta x<br>    public int dy;           // delta y<br>    public uint mouseData;   // wheel info when MOUSEEVENTF_WHEEL is set<br>    public uint dwFlags;     // bit flags for mouse buttons and movement<br>    public uint time;<br>    public IntPtr dwExtraInfo;<br>}<br>[StructLayout(LayoutKind.Sequential)]<br>public struct HardwareInput<br>{<br>    public uint uMsg;<br>    public ushort wParamL;<br>    public ushort wParamH;<br>}<br>[StructLayout(LayoutKind.Explicit)]<br>public struct InputUnion<br>{<br>    [FieldOffset(0)] public MouseInput mi;<br>    [FieldOffset(0)] public KeyboardInput ki;<br>    [FieldOffset(0)] public HardwareInput hi;<br>}<br>public struct Input<br>{<br>    public int type;<br>    public InputUnion u;<br>}<br>[Flags]<br>public enum InputType<br>{<br>    Mouse    = 0,<br>    Keyboard = 1,<br>    Hardware = 2<br>}<br>[Flags]<br>public enum KeyEventF<br>{<br>    KeyDown    = 0x0000,<br>    ExtendedKey = 0x0001,<br>    KeyUp      = 0x0002,<br>    Unicode    = 0x0004,<br>    Scancode   = 0x0008<br>}<br>[Flags]<br>public enum MouseEventF<br>{<br>    Absolute       = 0x8000,<br>    HWheel         = 0x01000,<br>    Move           = 0x0001,<br>    MoveNoCoalesce = 0x2000,<br>    LeftDown       = 0x0002,<br>    LeftUp         = 0x0004,<br>    RightDown      = 0x0008,<br>    RightUp        = 0x0010,<br>    MiddleDown     = 0x0020,<br>    MiddleUp       = 0x0040,<br>    VirtualDesk    = 0x4000,<br>    Wheel          = 0x0800,<br>    XDown          = 0x0080,<br>    XUp            = 0x0100<br>}</pre><h3>SendMouseEvent helper</h3><p>A convenience wrapper that builds a single-element Input array and calls SendInput. It logs a warning if Windows reports that fewer inputs were sent than expected (which can happen if another application is blocking input).</p><pre>private static void SendMouseEvent(MouseEventF flags, int dx = 0, int dy = 0)<br>{<br>    Input[] inputs = new Input[]<br>    {<br>        new Input<br>        {<br>            type = (int)InputType.Mouse,<br>            u = new InputUnion<br>            {<br>                mi = new MouseInput<br>                {<br>                    dx = dx,<br>                    dy = dy,<br>                    dwFlags = (uint)flags,<br>                    dwExtraInfo = GetMessageExtraInfo()<br>                }<br>            }<br>        }<br>    };<br>    var sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input)));<br>    if (sent != inputs.Length)<br>    {<br>        var err = Marshal.GetLastWin32Error();<br>        Log(&quot;WARN&quot;, $&quot;SendInput(mouse) sent={sent}/{inputs.Length} err={err} flags={flags} dx={dx} dy={dy}&quot;);<br>    }<br>}</pre><h3>SendKeyboardInput helper</h3><p>Used at startup to send Alt+Tab to bring the emulator window to the foreground before the bot begins. It accepts an optional second key so the two keys are pressed together as a chord and then released in the correct order.</p><pre>private static void SendKeyboardInput(ushort key1, ushort? key2 = null)<br>{<br>    List&lt;Input&gt; inputs =<br>    [<br>        new Input<br>        {<br>            type = (int)InputType.Keyboard,<br>            u = new InputUnion<br>            {<br>                ki = new KeyboardInput<br>                {<br>                    wVk = key1,<br>                    dwFlags = (uint)KeyEventF.KeyDown,<br>                    dwExtraInfo = GetMessageExtraInfo()<br>                }<br>            }<br>        },<br>    ];<br><br>    if (key2.HasValue)<br>    {<br>        inputs.Add(new Input<br>        {<br>            type = (int)InputType.Keyboard,<br>            u = new InputUnion<br>            {<br>                ki = new KeyboardInput<br>                {<br>                    wVk = key2.Value,<br>                    dwFlags = (uint)KeyEventF.KeyDown,<br>                    dwExtraInfo = GetMessageExtraInfo()<br>                }<br>            }<br>        });<br>    }<br>    if (key2.HasValue)<br>    {<br>        inputs.Add(new Input<br>        {<br>            type = (int)InputType.Keyboard,<br>            u = new InputUnion<br>            {<br>                ki = new KeyboardInput<br>                {<br>                    wVk = key2.Value,<br>                    dwFlags = (uint)KeyEventF.KeyUp,<br>                    dwExtraInfo = GetMessageExtraInfo()<br>                }<br>            }<br>        });<br>    }<br>    inputs.Add(new Input<br>    {<br>        type = (int)InputType.Keyboard,<br>        u = new InputUnion<br>        {<br>            ki = new KeyboardInput<br>            {<br>                wVk = key1,<br>                dwFlags = (uint)KeyEventF.KeyUp,<br>                dwExtraInfo = GetMessageExtraInfo()<br>            }<br>        }<br>    });<br>    SendInput((uint)inputs.Count, inputs.ToArray(), Marshal.SizeOf(typeof(Input)));<br>}</pre><h3>GameAction and PerformAction</h3><p>GameAction names the four possible swipe directions. PerformAction resets the cursor to screen centre (so every swipe starts from a consistent position), then performs a press-move-release sequence. The preMoveHoldMs delay between mouse down and the drag is important - without it the emulator can interpret the gesture as a tap rather than a swipe.</p><pre>public enum GameAction<br>{<br>    SwipeUp,<br>    SwipeDown,<br>    SwipeLeft,<br>    SwipeRight<br>}<br><br>private static bool PerformAction(GameAction action, int swipeDistance, int swipeDurationMs, out string? error)<br>{<br>    error = null;<br>    var screenWidth  = GetSystemMetrics(0);<br>    var screenHeight = GetSystemMetrics(1);<br>    int centerX = screenWidth  / 2;<br>    int centerY = screenHeight / 2;<br>    SetCursorPos(centerX, centerY);<br>    Thread.Sleep(5);<br>    int preMoveHoldMs = 8;<br>    int postReleaseMs = 10;<br>    try<br>    {<br>        switch (action)<br>        {<br>            case GameAction.SwipeUp:<br>                SendMouseEvent(MouseEventF.LeftDown);<br>                Thread.Sleep(preMoveHoldMs);<br>                SendMouseEvent(MouseEventF.Move, 0, -swipeDistance);<br>                Thread.Sleep(Math.Max(1, swipeDurationMs));<br>                SendMouseEvent(MouseEventF.LeftUp);<br>                Thread.Sleep(postReleaseMs);<br>                break;<br>            case GameAction.SwipeDown:<br>                SendMouseEvent(MouseEventF.LeftDown);<br>                Thread.Sleep(preMoveHoldMs);<br>                SendMouseEvent(MouseEventF.Move, 0, swipeDistance);<br>                Thread.Sleep(Math.Max(1, swipeDurationMs));<br>                SendMouseEvent(MouseEventF.LeftUp);<br>                Thread.Sleep(postReleaseMs);<br>                break;<br>            case GameAction.SwipeLeft:<br>                SendMouseEvent(MouseEventF.LeftDown);<br>                Thread.Sleep(preMoveHoldMs);<br>                SendMouseEvent(MouseEventF.Move, -swipeDistance, 0);<br>                Thread.Sleep(Math.Max(1, swipeDurationMs));<br>                SendMouseEvent(MouseEventF.LeftUp);<br>                Thread.Sleep(postReleaseMs);<br>                break;<br>            case GameAction.SwipeRight:<br>                SendMouseEvent(MouseEventF.LeftDown);<br>                Thread.Sleep(preMoveHoldMs);<br>                SendMouseEvent(MouseEventF.Move, swipeDistance, 0);<br>                Thread.Sleep(Math.Max(1, swipeDurationMs));<br>                SendMouseEvent(MouseEventF.LeftUp);<br>                Thread.Sleep(postReleaseMs);<br>                break;<br>            default:<br>                error = &quot;No action&quot;;<br>                return false;<br>        }<br>    }<br>    catch (Exception ex)<br>    {<br>        error = ex.Message;<br>        return false;<br>    }<br>    Thread.Sleep(5);<br>    SetCursorPos(centerX, centerY);<br>    return true;<br>}</pre><h3>Tuning tips</h3><ul><li>If swipes don’t register: increase swipeDistance or swipeDurationMs</li><li>If movement is too slow: reduce swipeDurationMs</li><li>If it sometimes taps instead of swiping: increase preMoveHoldMs slightly</li></ul><h3>Step 7: The complete Program.cs</h3><h4>The main loop performs the following:</h4><ol><li>Capture screenshot of the screen</li><li>Create instance of ModelInput using the byte array of the screenshot</li><li>Call predict on ObjectDetection class and passes the output to TryDecideAndPerformAction</li></ol><h4>TryDecideAndPerformAction</h4><ol><li>Normalize the predicted bounding boxes</li><li>Determine which lane the player is in by looking at the ‘boy’ detection’s bounding box if present, otherwise assume center.</li><li>Get player’s Y position, filter detections that overlapp with player and determine the closest obect to the player.</li><li>Perform the appropriate action based on the closest detection, e.g. if the label is “hurdle high” then perform GameAction.SwipeDown action to roll under the hurdle.</li></ol><p>After performing the action, we take care of any needed debugging and logging steps.</p><p>With ScreenCapture.cs, CaptureHelper.cs, and the ML.NET generated ObjectDetection class in place, Program.cs is the only remaining file. It ties everything together: setup, the capture/predict/decide loop, the decision logic, the debug image helpers, and all the input automation from Step 6.</p><pre>using Microsoft.ML.Data;<br>using OpenCvSharp;<br>using SubwaySurferPlayer;<br>using System.Diagnostics;<br>using System.Runtime.InteropServices;<br>using System.Collections.Concurrent;<br><br>namespace SubwaySurferPlayer<br>{<br>    internal static class Program<br>    {<br>        private const float scoreThreshold = 0.43f;<br>        private const float obstacleTriggerFraction = 0.35f;<br>        private const bool enableDebugImages = true;<br>        private const bool enableDebugLogs = false;<br>        private const int maxQueuedDebugFrames = 3;<br>        private static readonly object _logLock = new();<br>        private readonly record struct BotConfig(<br>            Rect CaptureRegion,<br>            int SwipeDistancePx,<br>            int SwipeDurationMs,<br>            TimeSpan ActionCooldown,<br>            TimeSpan NoActionDebugImageCooldown,<br>            string DetectionsFolder);<br>        private static void Log(string level, string message)<br>        {<br>            lock (_logLock)<br>            {<br>                Console.WriteLine($&quot;[{DateTime.Now:HH:mm:ss.fff}] [{level}] {message}&quot;);<br>            }<br>        }<br>        [DllImport(&quot;user32.dll&quot;, EntryPoint = &quot;SetCursorPos&quot;)]<br>        [return: MarshalAs(UnmanagedType.Bool)]<br>        public static extern bool SetCursorPos(int x, int y);<br>        [DllImport(&quot;user32.dll&quot;)]<br>        public static extern bool GetCursorPos(out POINT lpPoint);<br>        [DllImport(&quot;user32.dll&quot;, SetLastError = true)]<br>        private static extern uint SendInput(uint nInputs, Input[] pInputs, int cbSize);<br>        [DllImport(&quot;user32.dll&quot;)]<br>        private static extern IntPtr GetMessageExtraInfo();<br>        [DllImport(&quot;User32.dll&quot;, ExactSpelling = true, CharSet = CharSet.Auto)]<br>        public static extern int GetSystemMetrics(int nIndex);<br><br>        static void Main(string[] args)<br>        {<br>            Log(&quot;INFO&quot;, &quot;=== Subway Surfers AI Bot with ML.Net ===&quot;);<br>            Log(&quot;INFO&quot;, &quot;======================================================&quot;);<br>            // Option to run capture diagnostic<br>            Log(&quot;INFO&quot;, &quot;Press &#39;D&#39; to run screen capture diagnostic, or any other key to start bot...&quot;);<br>            var key = Console.ReadKey(true);<br>            if (key.Key == ConsoleKey.D)<br>            {<br>                CaptureHelper.RunDiagnostic();<br>                return;<br>            }<br>            // Game window configuration<br>            const int GAME_X = 900;<br>            const int GAME_Y = 32;<br>            const int GAME_WIDTH = 800;<br>            const int GAME_HEIGHT = 1368;<br>            // Swipe configuration<br>            const int swipeDistance = 170;   // in pixels<br>            const int swipeDurationMs = 100; // duration of the swipe<br>            Log(&quot;INFO&quot;, &quot;=== Loading AI Model ===&quot;);<br>            // The class labels your model was trained on<br>            var labels = new[]<br>            {<br>                &quot;barrier&quot;,<br>                &quot;boy&quot;,<br>                &quot;crate&quot;,<br>                &quot;crate left&quot;,<br>                &quot;crate right&quot;,<br>                &quot;crate red left&quot;,<br>                &quot;crate red right&quot;,<br>                &quot;hurdle high&quot;,<br>                &quot;hurdle low&quot;,<br>                &quot;pillar&quot;,<br>                &quot;platform&quot;,<br>                &quot;platform left&quot;,<br>                &quot;platform right&quot;,<br>                &quot;ramp&quot;,<br>                &quot;train green&quot;,<br>                &quot;train green left&quot;,<br>                &quot;train green right&quot;,<br>                &quot;train red&quot;,<br>                &quot;train red left&quot;,<br>                &quot;train red right&quot;,<br>                &quot;wall&quot;<br>            };<br><br>            string detectionsFolder = Path.Combine(AppContext.BaseDirectory, &quot;detections&quot;);<br>            Directory.CreateDirectory(detectionsFolder);<br>            Log(&quot;INFO&quot;, &quot;✓ AI Bot initialized successfully!&quot;);<br>            Log(&quot;INFO&quot;, &quot;Instructions:&quot;);<br>            Log(&quot;INFO&quot;, &quot;1. Start Subway Surfers&quot;);<br>            Log(&quot;INFO&quot;, &quot;2. Position game window where the bot will capture&quot;);<br>            Log(&quot;INFO&quot;, &quot;3. Press ENTER to start the bot&quot;);<br>            Log(&quot;INFO&quot;, &quot;4. Press Ctrl+C to stop&quot;);<br>            Log(&quot;INFO&quot;, &quot;Press &#39;R&#39; at any time to restart&quot;);<br>            Console.ReadLine();<br><br>            // Background worker to write debug images without stalling prediction/action<br>            using var debugCts = new CancellationTokenSource();<br>            var debugQueue = new BlockingCollection&lt;Action&gt;(boundedCapacity: maxQueuedDebugFrames);<br>            var debugWriter = Task.Run(() =&gt;<br>            {<br>                try<br>                {<br>                    foreach (var work in debugQueue.GetConsumingEnumerable(debugCts.Token))<br>                    {<br>                        try { work(); }<br>                        catch { }<br>                    }<br>                }<br>                catch (OperationCanceledException) { }<br>            });<br><br>            // Warm up ML.NET prediction engine to avoid first-predict latency spike<br>            try<br>            {<br>                Log(&quot;INFO&quot;, &quot;Warming up model...&quot;);<br>                using var warmupStream = ScreenCapture.CaptureScreenToStream(GAME_X, GAME_Y, GAME_WIDTH, GAME_HEIGHT);<br>                var warmupImage = MLImage.CreateFromStream(warmupStream);<br>                _ = ObjectDetection.Predict(new ObjectDetection.ModelInput { Image = warmupImage });<br>                Log(&quot;INFO&quot;, &quot;Model warm-up complete.&quot;);<br>            }<br>            catch (Exception ex)<br>            {<br>                Log(&quot;WARN&quot;, $&quot;Model warm-up failed (continuing): {ex.Message}&quot;);<br>            }<br><br>            // Focus the emulator window<br>            var screenWidth = GetSystemMetrics(0);<br>            var screenHeight = GetSystemMetrics(1);<br>            const ushort ALT_KEY = 0x12;<br>            const ushort TAB_KEY = 0x09;<br>            SendKeyboardInput(ALT_KEY, TAB_KEY);<br>            Thread.Sleep(500);<br>            SetCursorPos(screenWidth / 2, screenHeight / 2);<br>            Thread.Sleep(500);<br><br>            // Tap to start the game<br>            SendMouseEvent(MouseEventF.LeftDown);<br>            SendMouseEvent(MouseEventF.LeftUp);<br>            Thread.Sleep(1000);<br>            Log(&quot;INFO&quot;, &quot;Bot is now running!&quot;);<br>            Log(&quot;INFO&quot;, $&quot;Capturing area: X={GAME_X}, Y={GAME_Y}, Width={GAME_WIDTH}, Height={GAME_HEIGHT}&quot;);<br>            int frameCount = 0;<br>            var lastActionTime = DateTime.UtcNow;<br>            var actionCooldown = TimeSpan.FromMilliseconds(120);<br>            var lastDebugImageTime = DateTime.UtcNow;<br>            var debugImageCooldown = TimeSpan.FromMilliseconds(250);<br><br>            // Clear old debug images before starting<br>            foreach (var img in Directory.GetFiles(detectionsFolder, &quot;frame_*.png&quot;))<br>            {<br>                try { File.Delete(img); }<br>                catch { }<br>            }<br>            try<br>            {<br>                while (true)<br>                {<br>                    frameCount++;<br>                    Stopwatch? stopWatch = null;<br>                    if (enableDebugLogs)<br>                    {<br>                        stopWatch = Stopwatch.StartNew();<br>                    }<br>                    using var gameScreen = ScreenCapture.CaptureScreenToStream(GAME_X, GAME_Y, GAME_WIDTH, GAME_HEIGHT);<br>                    using var capturedBytes = new MemoryStream();<br>                    gameScreen.CopyTo(capturedBytes);<br>                    var frameBytes = capturedBytes.ToArray();<br>                    var image = MLImage.CreateFromStream(new MemoryStream(frameBytes));<br>                    ObjectDetection.ModelInput sampleData = new ObjectDetection.ModelInput()<br>                    {<br>                        Image = image,<br>                    };<br>                    if (stopWatch is not null)<br>                    {<br>                        stopWatch.Stop();<br>                        Console.WriteLine($&quot;capture {stopWatch.ElapsedMilliseconds}&quot;);<br>                        stopWatch.Restart();<br>                    }<br>                    var predictionResult = ObjectDetection.Predict(sampleData);<br>                    if (stopWatch is not null)<br>                    {<br>                        stopWatch.Stop();<br>                        Console.WriteLine($&quot;predict {stopWatch.ElapsedMilliseconds}&quot;);<br>                    }<br>                    if (enableDebugLogs)<br>                    {<br>                        Console.WriteLine(&quot;\n\nPredicted Boxes:\n&quot;);<br>                    }<br>                    if (predictionResult.PredictedBoundingBoxes == null)<br>                    {<br>                        if (enableDebugLogs)<br>                        {<br>                            Console.WriteLine(&quot;No Predicted Bounding Boxes&quot;);<br>                        }<br>                        // Some models can intermittently return null outputs; treat as no detections and continue.<br>                        continue;<br>                    }<br>                    if (enableDebugLogs)<br>                    {<br>                        var boxes =<br>                            predictionResult.PredictedBoundingBoxes.Chunk(4)<br>                                .Select(x =&gt; new { XTop = x[0], YTop = x[1], XBottom = x[2], YBottom = x[3] })<br>                                .Zip(predictionResult.Score, (a, b) =&gt; new { Box = a, Score = b });<br>                        foreach (var item in boxes)<br>                        {<br>                            Console.WriteLine($&quot;XTop: {item.Box.XTop},YTop: {item.Box.YTop},XBottom: {item.Box.XBottom},YBottom: {item.Box.YBottom}, Score: {item.Score}&quot;);<br>                        }<br>                    }<br>                    string frameDecisionTag = &quot;&quot;;<br>                    string frameDecisionReason = &quot;&quot;;<br>                    try<br>                    {<br>                        if (DateTime.UtcNow - lastActionTime &gt;= actionCooldown)<br>                        {<br>                            if (TryDecideAndPerformAction(<br>                                predictionResult.PredictedBoundingBoxes,<br>                                predictionResult.Score,<br>                                predictionResult.PredictedLabel,<br>                                GAME_WIDTH,<br>                                GAME_HEIGHT,<br>                                swipeDistance,<br>                                swipeDurationMs,<br>                                out var decidedAction,<br>                                out var decisionReason,<br>                                out var error))<br>                            {<br>                                lastActionTime = DateTime.UtcNow;<br>                                Log(&quot;INFO&quot;, $&quot;Action={decidedAction} reason={decisionReason}&quot;);<br>                                frameDecisionTag = decidedAction.ToString();<br>                                frameDecisionReason = decisionReason;<br>                                if (enableDebugImages)<br>                                {<br>                                    var bytesCopy  = frameBytes;<br>                                    var boxesCopy  = predictionResult.PredictedBoundingBoxes;<br>                                    var scoresCopy = predictionResult.Score;<br>                                    var labelsCopy = predictionResult.PredictedLabel;<br>                                    var tagCopy    = frameDecisionTag;<br>                                    var reasonCopy = frameDecisionReason;<br>                                    if (!debugQueue.IsAddingCompleted)<br>                                    {<br>                                        debugQueue.TryAdd(() =&gt;<br>                                        {<br>                                            SaveDetectionsDebugImage(<br>                                                bytesCopy, detectionsFolder, frameCount,<br>                                                boxesCopy, scoresCopy, labelsCopy,<br>                                                GAME_WIDTH, GAME_HEIGHT, tagCopy, reasonCopy);<br>                                        });<br>                                    }<br>                                }<br>                            }<br>                            else<br>                            {<br>                                if (enableDebugLogs &amp;&amp; !string.IsNullOrWhiteSpace(decisionReason))<br>                                {<br>                                    Log(&quot;DEBUG&quot;, $&quot;NoAction reason={decisionReason}&quot;);<br>                                }<br>                                frameDecisionTag = &quot;NoAction&quot;;<br>                                frameDecisionReason = decisionReason;<br>                                // Save a throttled debug image even when no action is taken<br>                                if (enableDebugImages &amp;&amp; DateTime.UtcNow - lastDebugImageTime &gt;= debugImageCooldown)<br>                                {<br>                                    lastDebugImageTime = DateTime.UtcNow;<br>                                    var bytesCopy  = frameBytes;<br>                                    var boxesCopy  = predictionResult.PredictedBoundingBoxes;<br>                                    var scoresCopy = predictionResult.Score;<br>                                    var labelsCopy = predictionResult.PredictedLabel;<br>                                    var tagCopy    = frameDecisionTag;<br>                                    var reasonCopy = frameDecisionReason;<br>                                    if (!debugQueue.IsAddingCompleted)<br>                                    {<br>                                        debugQueue.TryAdd(() =&gt;<br>                                        {<br>                                            SaveDetectionsDebugImage(<br>                                                bytesCopy, detectionsFolder, frameCount,<br>                                                boxesCopy, scoresCopy, labelsCopy,<br>                                                GAME_WIDTH, GAME_HEIGHT, tagCopy, reasonCopy);<br>                                        });<br>                                    }<br>                                }<br>                                if (!string.IsNullOrWhiteSpace(error))<br>                                {<br>                                    Log(&quot;WARN&quot;, $&quot;Action failed: {error}&quot;);<br>                                    if (enableDebugImages)<br>                                    {<br>                                        var bytesCopy  = frameBytes;<br>                                        var boxesCopy  = predictionResult.PredictedBoundingBoxes;<br>                                        var scoresCopy = predictionResult.Score;<br>                                        var labelsCopy = predictionResult.PredictedLabel;<br>                                        var tagCopy    = frameDecisionTag;<br>                                        var reasonCopy = $&quot;{frameDecisionReason}__err_{error}&quot;;<br>                                        if (!debugQueue.IsAddingCompleted)<br>                                        {<br>                                            debugQueue.TryAdd(() =&gt;<br>                                            {<br>                                                SaveDetectionsDebugImage(<br>                                                    bytesCopy, detectionsFolder, frameCount,<br>                                                    boxesCopy, scoresCopy, labelsCopy,<br>                                                    GAME_WIDTH, GAME_HEIGHT, tagCopy, reasonCopy);<br>                                            });<br>                                        }<br>                                    }<br>                                }<br>                            }<br>                        }<br>                        else<br>                        {<br>                            frameDecisionTag = &quot;Cooldown&quot;;<br>                            frameDecisionReason = $&quot;cooldown {(actionCooldown - (DateTime.UtcNow - lastActionTime)).TotalMilliseconds:0}ms&quot;;<br>                        }<br>                    }<br>                    catch (Exception ex)<br>                    {<br>                        Log(&quot;WARN&quot;, $&quot;Decision loop error: {ex.Message}&quot;);<br>                        frameDecisionTag = &quot;DecisionError&quot;;<br>                        frameDecisionReason = ex.Message;<br>                    }<br>                    if (enableDebugLogs)<br>                    {<br>                        try<br>                        {<br>                            var summary = SummarizeDetections(predictionResult.PredictedLabel, predictionResult.Score);<br>                            Log(&quot;DEBUG&quot;, $&quot;Frame={frameCount} det=[{summary}] decision={frameDecisionTag} reason={frameDecisionReason}&quot;);<br>                        }<br>                        catch { }<br>                    }<br>                }<br>            }<br>            finally<br>            {<br>                // Graceful shutdown of the debug writer<br>                try<br>                {<br>                    debugQueue.CompleteAdding();<br>                    debugCts.Cancel();<br>                    debugWriter.Wait(TimeSpan.FromSeconds(2));<br>                }<br>                catch { }<br>            }<br>        }<br>        // -----------------------------------------------------------------------<br>        // Debug image helpers<br>        // -----------------------------------------------------------------------<br>        private static void SaveDetectionsDebugImage(<br>            byte[] frameBytes,<br>            string detectionsFolder,<br>            int frameCount,<br>            float[]? predictedBoundingBoxes,<br>            float[]? scores,<br>            string[]? predictedLabels,<br>            int imageWidth,<br>            int imageHeight,<br>            string decisionTag,<br>            string decisionReason)<br>        {<br>            if (predictedBoundingBoxes is null || scores is null)<br>            {<br>                return;<br>            }<br>            using var mat = Cv2.ImDecode(frameBytes, ImreadModes.Color);<br>            if (mat.Empty())<br>            {<br>                Log(&quot;WARN&quot;, &quot;OpenCV failed to decode captured frame; no debug image saved.&quot;);<br>                return;<br>            }<br>            var boxCount = predictedBoundingBoxes.Length / 4;<br>            for (int i = 0; i &lt; boxCount &amp;&amp; i &lt; scores.Length; i++)<br>            {<br>                var score = scores[i];<br>                if (score &lt;= 0)<br>                {<br>                    continue;<br>                }<br>                var rect = NormalizeRect(predictedBoundingBoxes, i, imageWidth, imageHeight, boxCount);<br>                if (rect.Width &lt;= 0 || rect.Height &lt;= 0)<br>                {<br>                    continue;<br>                }<br>                string label = (predictedLabels is not null &amp;&amp; i &lt; predictedLabels.Length)<br>                    ? predictedLabels[i]<br>                    : $&quot;#{i}&quot;;<br>                string caption = $&quot;{label} {score:0.00}&quot;;<br>                // Deterministic colour per label<br>                var color = new Scalar(<br>                    (Math.Abs(label.GetHashCode()) &amp; 0xFF),<br>                    ((Math.Abs(label.GetHashCode()) &gt;&gt; 8) &amp; 0xFF),<br>                    ((Math.Abs(label.GetHashCode()) &gt;&gt; 16) &amp; 0xFF));<br>                Cv2.Rectangle(mat, rect, color, thickness: 2);<br>                var textOrg = new OpenCvSharp.Point(rect.X, Math.Max(0, rect.Y - 5));<br>                Cv2.PutText(mat, caption, textOrg, HersheyFonts.HersheySimplex, 0.6, Scalar.Black, thickness: 3);<br>                Cv2.PutText(mat, caption, textOrg, HersheyFonts.HersheySimplex, 0.6, color, thickness: 1);<br>            }<br>            var detSummary = SummarizeDetections(predictedLabels, scores);<br>            var filename = $&quot;frame_{frameCount:000000}__det_{detSummary}__dec_{decisionTag}_{decisionReason}.png&quot;;<br>            filename = SanitizeFileName(filename, maxLength: 180);<br>            string outputPath = Path.Combine(detectionsFolder, filename);<br>            if (!Cv2.ImWrite(outputPath, mat))<br>            {<br>                Log(&quot;WARN&quot;, $&quot;OpenCV failed to write debug image: {outputPath}&quot;);<br>            }<br>        }<br>        private static string SummarizeDetections(string[]? labels, float[]? scores)<br>        {<br>            if (labels is null || scores is null)<br>            {<br>                return &quot;none&quot;;<br>            }<br>            var n = Math.Min(labels.Length, scores.Length);<br>            if (n == 0)<br>            {<br>                return &quot;none&quot;;<br>            }<br>            bool IsRelevant(string l) =&gt; !string.Equals(l, &quot;boy&quot;, StringComparison.OrdinalIgnoreCase);<br>            var top = Enumerable.Range(0, n)<br>                .Where(i =&gt; scores[i] &gt; 0)<br>                .Select(i =&gt; new { Label = labels[i], Score = scores[i] })<br>                .Where(x =&gt; !string.IsNullOrWhiteSpace(x.Label) &amp;&amp; IsRelevant(x.Label))<br>                .OrderByDescending(x =&gt; x.Score)<br>                .Take(3)<br>                .Select(x =&gt; $&quot;{x.Label.Replace(&#39; &#39;, &#39;-&#39;)}-{x.Score:0.00}&quot;);<br>            var s = string.Join(&quot;_&quot;, top);<br>            return string.IsNullOrWhiteSpace(s) ? &quot;none&quot; : s;<br>        }<br>        private static string SanitizeFileName(string name, int maxLength)<br>        {<br>            var invalid = Path.GetInvalidFileNameChars();<br>            var cleaned = new string(name.Select(ch =&gt; invalid.Contains(ch) ? &#39;_&#39; : ch).ToArray());<br>            cleaned = cleaned.Replace(&quot;..&quot;, &quot;.&quot;);<br>            if (cleaned.Length &gt; maxLength)<br>            {<br>                cleaned = cleaned[..maxLength];<br>            }<br>            return cleaned;<br>        }<br>        // -----------------------------------------------------------------------<br>        // Detection helpers<br>        // -----------------------------------------------------------------------<br>        private readonly record struct Det(int Index, string Label, float Score, Rect Rect);<br>        private static float IoU(Rect a, Rect b)<br>        {<br>            int x1 = Math.Max(a.Left, b.Left);<br>            int y1 = Math.Max(a.Top, b.Top);<br>            int x2 = Math.Min(a.Right, b.Right);<br>            int y2 = Math.Min(a.Bottom, b.Bottom);<br>            int iw = x2 - x1;<br>            int ih = y2 - y1;<br>            if (iw &lt;= 0 || ih &lt;= 0)<br>            {<br>                return 0f;<br>            }<br>            float inter = iw * ih;<br>            float union = (a.Width * a.Height) + (b.Width * b.Height) - inter;<br>            return union &lt;= 0 ? 0f : (inter / union);<br>        }<br>        private static Rect NormalizeRect(float[] boxes, int index, int imageWidth, int imageHeight, int boxCount = -1)<br>        {<br>            if (boxCount &lt;= 0)<br>            {<br>                boxCount = boxes.Length / 4;<br>            }<br>            int baseIdx = index * 4;<br>            float xTop    = boxes[baseIdx + 0];<br>            float yTop    = boxes[baseIdx + 1];<br>            float xBottom = boxes[baseIdx + 2];<br>            float yBottom = boxes[baseIdx + 3];<br>            // Case 1: normalised 0..1 coordinates<br>            bool isNormalized01 = xTop &gt;= 0 &amp;&amp; xTop &lt;= 1.5f &amp;&amp; yTop &gt;= 0 &amp;&amp; yTop &lt;= 1.5f<br>                                &amp;&amp; xBottom &gt;= 0 &amp;&amp; xBottom &lt;= 1.5f &amp;&amp; yBottom &gt;= 0 &amp;&amp; yBottom &lt;= 1.5f;<br>            if (isNormalized01)<br>            {<br>                xTop    *= imageWidth;<br>                xBottom *= imageWidth;<br>                yTop    *= imageHeight;<br>                yBottom *= imageHeight;<br>            }<br>            else<br>            {<br>                // Case 2: model-space coordinates (e.g. up to ~1024). Scale into capture size<br>                // using the max extents across the whole frame for consistent lane detection.<br>                float maxX = 0;<br>                float maxY = 0;<br>                for (int i = 0; i &lt; boxCount; i++)<br>                {<br>                    int bi = i * 4;<br>                    maxX = MathF.Max(maxX, MathF.Abs(boxes[bi + 0]));<br>                    maxX = MathF.Max(maxX, MathF.Abs(boxes[bi + 2]));<br>                    maxY = MathF.Max(maxY, MathF.Abs(boxes[bi + 1]));<br>                    maxY = MathF.Max(maxY, MathF.Abs(boxes[bi + 3]));<br>                }<br>                if (maxX &gt; 0 &amp;&amp; maxX &gt; imageWidth * 1.1f)<br>                {<br>                    float sx = imageWidth / maxX;<br>                    xTop    *= sx;<br>                    xBottom *= sx;<br>                }<br>                if (maxY &gt; 0 &amp;&amp; maxY &gt; imageHeight * 1.1f)<br>                {<br>                    float sy = imageHeight / maxY;<br>                    yTop    *= sy;<br>                    yBottom *= sy;<br>                }<br>            }<br>            int left   = (int)MathF.Round(MathF.Min(xTop, xBottom));<br>            int top    = (int)MathF.Round(MathF.Min(yTop, yBottom));<br>            int right  = (int)MathF.Round(MathF.Max(xTop, xBottom));<br>            int bottom = (int)MathF.Round(MathF.Max(yTop, yBottom));<br>            left   = Math.Clamp(left,   0, imageWidth  - 1);<br>            top    = Math.Clamp(top,    0, imageHeight - 1);<br>            right  = Math.Clamp(right,  0, imageWidth  - 1);<br>            bottom = Math.Clamp(bottom, 0, imageHeight - 1);<br>            return new Rect(left, top, Math.Max(1, right - left), Math.Max(1, bottom - top));<br>        }<br>        private static int LaneFromX(float x, int imageWidth)<br>        {<br>            var third = imageWidth / 3f;<br>            if (x &lt; third) return 0;<br>            if (x &lt; third * 2) return 1;<br>            return 2;<br>        }<br>        private static int LaneFromX(float x, float laneOriginX, float laneWidth)<br>        {<br>            if (laneWidth &lt;= 1)<br>            {<br>                return 1;<br>            }<br>            var local = x - laneOriginX;<br>            var idx = (int)MathF.Floor(local / laneWidth);<br>            return Math.Clamp(idx, 0, 2);<br>        }<br>        // -----------------------------------------------------------------------<br>        // Decision logic<br>        // -----------------------------------------------------------------------<br>        private static bool TryDecideAndPerformAction(<br>            float[]? predictedBoundingBoxes,<br>            float[]? scores,<br>            string[]? predictedLabels,<br>            int imageWidth,<br>            int imageHeight,<br>            int swipeDistance,<br>            int swipeDurationMs,<br>            out GameAction decidedAction,<br>            out string decisionReason,<br>            out string? error)<br>        {<br>            decidedAction = default;<br>            decisionReason = string.Empty;<br>            error = null;<br>            if (predictedBoundingBoxes is null || scores is null || predictedLabels is null)<br>            {<br>                return false;<br>            }<br>            var count = Math.Min(scores.Length, predictedLabels.Length);<br>            count = Math.Min(count, predictedBoundingBoxes.Length / 4);<br>            if (count &lt;= 0)<br>            {<br>                return false;<br>            }<br>            var dets = Enumerable.Range(0, count)<br>                .Select(i =&gt; new Det(i, predictedLabels[i], scores[i], default))<br>                .Where(d =&gt; d.Score &gt;= scoreThreshold)<br>                .ToArray();<br>            // Normalise all rectangles in one pass using a consistent scale for the frame<br>            dets = dets<br>                .Select(d =&gt; d with { Rect = NormalizeRect(predictedBoundingBoxes, d.Index, imageWidth, imageHeight, count) })<br>                .Where(d =&gt; d.Rect.Width &gt; 0 &amp;&amp; d.Rect.Height &gt; 0)<br>                .ToArray();<br>            // If a ramp overlaps a crate, relabel it as a crate (model sometimes double-detects).<br>            // Also treat a stand-alone ramp with crate-like proportions as a crate.<br>            const float rampCrateOverlapIou           = 0.25f;<br>            const float rampLooksLikeCrateAspectMin   = 0.8f;<br>            const float rampLooksLikeCrateAspectMax   = 1.6f;<br>            const float rampLooksLikeCrateAreaFracMin = 0.02f;<br>            {<br>                var crates = dets<br>                    .Where(d =&gt; d.Label.StartsWith(&quot;crate&quot;, StringComparison.OrdinalIgnoreCase))<br>                    .ToArray();<br>                dets = dets<br>                    .Select(d =&gt;<br>                    {<br>                        if (!string.Equals(d.Label, &quot;ramp&quot;, StringComparison.OrdinalIgnoreCase))<br>                        {<br>                            return d;<br>                        }<br>                        if (crates.Length &gt; 0)<br>                        {<br>                            var best = crates.Max(c =&gt; IoU(d.Rect, c.Rect));<br>                            if (best &gt;= rampCrateOverlapIou)<br>                            {<br>                                return d with { Label = &quot;crate&quot; };<br>                            }<br>                        }<br>                        float aspect   = d.Rect.Height &lt;= 0 ? 0 : (d.Rect.Width / (float)d.Rect.Height);<br>                        float areaFrac = (d.Rect.Width * d.Rect.Height) / (float)(imageWidth * imageHeight);<br>                        if (areaFrac &gt;= rampLooksLikeCrateAreaFracMin<br>                            &amp;&amp; aspect &gt;= rampLooksLikeCrateAspectMin<br>                            &amp;&amp; aspect &lt;= rampLooksLikeCrateAspectMax)<br>                        {<br>                            return d with { Label = &quot;crate&quot; };<br>                        }<br>                        return d;<br>                    })<br>                    .ToArray();<br>            }<br>            if (dets.Length == 0)<br>            {<br>                decisionReason = &quot;no detections&quot;;<br>                return false;<br>            }<br>            // Determine player lane from the &quot;boy&quot; detection; fall back to centre<br>            var boy = dets<br>                .Where(d =&gt; string.Equals(d.Label, &quot;boy&quot;, StringComparison.OrdinalIgnoreCase))<br>                .OrderByDescending(d =&gt; d.Score)<br>                .FirstOrDefault();<br>            // Infer the road boundary from obstacle x extents to reduce UI/border influence<br>            float roadLeft  = 0;<br>            float roadRight = imageWidth;<br>            {<br>                var xs = dets<br>                    .Where(d =&gt; !string.IsNullOrWhiteSpace(d.Label)<br>                             &amp;&amp; !string.Equals(d.Label, &quot;boy&quot;, StringComparison.OrdinalIgnoreCase))<br>                    .Select(d =&gt; (Left: d.Rect.X, Right: d.Rect.X + d.Rect.Width))<br>                    .ToArray();<br>                if (xs.Length &gt; 0)<br>                {<br>                    roadLeft  = Math.Clamp(xs.Min(v =&gt; (float)v.Left), 0, imageWidth - 1);<br>                    roadRight = Math.Clamp(xs.Max(v =&gt; (float)v.Right), roadLeft + 1, imageWidth);<br>                }<br>            }<br>            var laneWidth = (roadRight - roadLeft) / 3f;<br>            int playerLane = !string.IsNullOrWhiteSpace(boy.Label)<br>                ? LaneFromX(boy.Rect.X + (boy.Rect.Width / 2f), roadLeft, laneWidth)<br>                : 1;<br>            float playerYBottom = string.IsNullOrWhiteSpace(boy.Label)<br>                ? imageHeight * 0.85f<br>                : (boy.Rect.Y + boy.Rect.Height);<br>            // Discard detections that overlap or are behind the player<br>            if (!string.IsNullOrWhiteSpace(boy.Label))<br>            {<br>                const float playerOverlapIouThreshold = 0.15f;<br>                const float behindPlayerSlackPx       = 10f;<br>                dets = dets<br>                    .Where(d =&gt;<br>                    {<br>                        if (string.Equals(d.Label, &quot;boy&quot;, StringComparison.OrdinalIgnoreCase))<br>                        {<br>                            return true;<br>                        }<br>                        var dBottom = d.Rect.Y + d.Rect.Height;<br>                        if (dBottom &gt;= playerYBottom - behindPlayerSlackPx)<br>                        {<br>                            return false;<br>                        }<br>                        if (IoU(d.Rect, boy.Rect) &gt;= playerOverlapIouThreshold)<br>                        {<br>                            return false;<br>                        }<br>                        return true;<br>                    })<br>                    .ToArray();<br>            }<br>            bool IsObstacle(string label) =&gt;<br>                label.StartsWith(&quot;hurdle&quot;, StringComparison.OrdinalIgnoreCase)<br>                || label.StartsWith(&quot;train&quot;,  StringComparison.OrdinalIgnoreCase)<br>                || string.Equals(label, &quot;barrier&quot;, StringComparison.OrdinalIgnoreCase)<br>                || string.Equals(label, &quot;wall&quot;,    StringComparison.OrdinalIgnoreCase)<br>                || string.Equals(label, &quot;pillar&quot;,  StringComparison.OrdinalIgnoreCase)<br>                || label.StartsWith(&quot;crate&quot;, StringComparison.OrdinalIgnoreCase);<br>            var obstacles = dets<br>                .Where(d =&gt; IsObstacle(d.Label))<br>                .Select(d =&gt; (Det: d, Lane: LaneFromX(d.Rect.X + (d.Rect.Width / 2f), roadLeft, laneWidth)))<br>                .ToArray();<br>            if (obstacles.Length == 0)<br>            {<br>                decisionReason = &quot;no obstacles&quot;;<br>                return false;<br>            }<br>            // Nearest obstacle per lane by yBottom (larger = lower on screen = closer)<br>            var nearestPerLane = obstacles<br>                .GroupBy(o =&gt; o.Lane)<br>                .Select(g =&gt; g.OrderByDescending(x =&gt; x.Det.Rect.Y + x.Det.Rect.Height).First())<br>                .ToDictionary(x =&gt; x.Lane, x =&gt; x.Det);<br>            if (!nearestPerLane.TryGetValue(playerLane, out var nearest))<br>            {<br>                var lanes = string.Join(&quot;,&quot;, nearestPerLane.OrderBy(k =&gt; k.Key).Select(k =&gt; $&quot;{k.Key}:{k.Value.Label}&quot;));<br>                decisionReason = $&quot;no obstacle in player lane {playerLane} (roadLeft={roadLeft:0} roadRight={roadRight:0} lanes=[{lanes}])&quot;;<br>                return false;<br>            }<br>            var nearestYBottom = nearest.Rect.Y + nearest.Rect.Height;<br>            // Only react once the obstacle is in the lower portion of the screen<br>            if (nearestYBottom &lt; imageHeight * obstacleTriggerFraction)<br>            {<br>                decisionReason = $&quot;nearest {nearest.Label} too far yBottom={nearestYBottom:0} lane={playerLane} trigger={obstacleTriggerFraction:0.00}&quot;;<br>                return false;<br>            }<br>            // Policy: specific obstacles get specific moves<br>            if (nearest.Label.Equals(&quot;hurdle high&quot;, StringComparison.OrdinalIgnoreCase))<br>            {<br>                decidedAction = GameAction.SwipeDown;<br>                decisionReason = $&quot;hurdle high in lane {playerLane} yBottom={nearestYBottom}&quot;;<br>                return PerformAction(decidedAction, swipeDistance, swipeDurationMs, out error);<br>            }<br>            if (nearest.Label.Equals(&quot;hurdle low&quot;, StringComparison.OrdinalIgnoreCase)<br>                || nearest.Label.Equals(&quot;barrier&quot;, StringComparison.OrdinalIgnoreCase))<br>            {<br>                decidedAction = GameAction.SwipeUp;<br>                decisionReason = $&quot;{nearest.Label} in lane {playerLane} yBottom={nearestYBottom}&quot;;<br>                return PerformAction(decidedAction, swipeDistance, swipeDurationMs, out error);<br>            }<br>            // Default: solid obstacle in our lane - try to switch to a free lane<br>            var close = nearestPerLane<br>                .Where(kvp =&gt; (kvp.Value.Rect.Y + kvp.Value.Rect.Height) &gt;= imageHeight * 0.35f)<br>                .ToDictionary(kvp =&gt; kvp.Key, kvp =&gt; kvp.Value.Score);<br>            int? targetLane = null;<br>            if (!close.ContainsKey(1)) targetLane = 1;<br>            if (!close.ContainsKey(0)) targetLane ??= 0;<br>            if (!close.ContainsKey(2)) targetLane ??= 2;<br>            if (targetLane.HasValue &amp;&amp; targetLane.Value != playerLane)<br>            {<br>                decidedAction = targetLane.Value &lt; playerLane ? GameAction.SwipeLeft : GameAction.SwipeRight;<br>                decisionReason = $&quot;lane change {playerLane}-&gt;{targetLane.Value} due to {nearest.Label}&quot;;<br>                return PerformAction(decidedAction, swipeDistance, swipeDurationMs, out error);<br>            }<br>            decisionReason = $&quot;blocked; no free lane (playerLane={playerLane}, nearest={nearest.Label} yBottom={nearestYBottom:0})&quot;;<br>            return false;<br>        }<br>        // -----------------------------------------------------------------------<br>        // Input<br>        // -----------------------------------------------------------------------<br>        public enum GameAction<br>        {<br>            SwipeUp,<br>            SwipeDown,<br>            SwipeLeft,<br>            SwipeRight<br>        }<br>        private static bool PerformAction(GameAction action, int swipeDistance, int swipeDurationMs, out string? error)<br>        {<br>            error = null;<br>            var screenWidth  = GetSystemMetrics(0);<br>            var screenHeight = GetSystemMetrics(1);<br>            int centerX = screenWidth  / 2;<br>            int centerY = screenHeight / 2;<br>            SetCursorPos(centerX, centerY);<br>            Thread.Sleep(5);<br>            int preMoveHoldMs = 8;<br>            int postReleaseMs = 10;<br>            try<br>            {<br>                switch (action)<br>                {<br>                    case GameAction.SwipeUp:<br>                        SendMouseEvent(MouseEventF.LeftDown);<br>                        Thread.Sleep(preMoveHoldMs);<br>                        SendMouseEvent(MouseEventF.Move, 0, -swipeDistance);<br>                        Thread.Sleep(Math.Max(1, swipeDurationMs));<br>                        SendMouseEvent(MouseEventF.LeftUp);<br>                        Thread.Sleep(postReleaseMs);<br>                        break;<br>                    case GameAction.SwipeDown:<br>                        SendMouseEvent(MouseEventF.LeftDown);<br>                        Thread.Sleep(preMoveHoldMs);<br>                        SendMouseEvent(MouseEventF.Move, 0, swipeDistance);<br>                        Thread.Sleep(Math.Max(1, swipeDurationMs));<br>                        SendMouseEvent(MouseEventF.LeftUp);<br>                        Thread.Sleep(postReleaseMs);<br>                        break;<br>                    case GameAction.SwipeLeft:<br>                        SendMouseEvent(MouseEventF.LeftDown);<br>                        Thread.Sleep(preMoveHoldMs);<br>                        SendMouseEvent(MouseEventF.Move, -swipeDistance, 0);<br>                        Thread.Sleep(Math.Max(1, swipeDurationMs));<br>                        SendMouseEvent(MouseEventF.LeftUp);<br>                        Thread.Sleep(postReleaseMs);<br>                        break;<br>                    case GameAction.SwipeRight:<br>                        SendMouseEvent(MouseEventF.LeftDown);<br>                        Thread.Sleep(preMoveHoldMs);<br>                        SendMouseEvent(MouseEventF.Move, swipeDistance, 0);<br>                        Thread.Sleep(Math.Max(1, swipeDurationMs));<br>                        SendMouseEvent(MouseEventF.LeftUp);<br>                        Thread.Sleep(postReleaseMs);<br>                        break;<br>                    default:<br>                        error = &quot;No action&quot;;<br>                        return false;<br>                }<br>            }<br>            catch (Exception ex)<br>            {<br>                error = ex.Message;<br>                return false;<br>            }<br>            Thread.Sleep(5);<br>            SetCursorPos(centerX, centerY);<br>            return true;<br>        }<br>        private static void SendMouseEvent(MouseEventF flags, int dx = 0, int dy = 0)<br>        {<br>            Input[] inputs = new Input[]<br>            {<br>                new Input<br>                {<br>                    type = (int)InputType.Mouse,<br>                    u = new InputUnion<br>                    {<br>                        mi = new MouseInput<br>                        {<br>                            dx = dx,<br>                            dy = dy,<br>                            dwFlags = (uint)flags,<br>                            dwExtraInfo = GetMessageExtraInfo()<br>                        }<br>                    }<br>                }<br>            };<br>            var sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(Input)));<br>            if (sent != inputs.Length)<br>            {<br>                var err = Marshal.GetLastWin32Error();<br>                Log(&quot;WARN&quot;, $&quot;SendInput(mouse) sent={sent}/{inputs.Length} err={err} flags={flags} dx={dx} dy={dy}&quot;);<br>            }<br>        }<br>        private static void SendKeyboardInput(ushort key1, ushort? key2 = null)<br>        {<br>            List&lt;Input&gt; inputs =<br>            [<br>                new Input<br>                {<br>                    type = (int)InputType.Keyboard,<br>                    u = new InputUnion<br>                    {<br>                        ki = new KeyboardInput<br>                        {<br>                            wVk = key1,<br>                            dwFlags = (uint)KeyEventF.KeyDown,<br>                            dwExtraInfo = GetMessageExtraInfo()<br>                        }<br>                    }<br>                },<br>            ];<br>            if (key2.HasValue)<br>            {<br>                inputs.Add(new Input<br>                {<br>                    type = (int)InputType.Keyboard,<br>                    u = new InputUnion<br>                    {<br>                        ki = new KeyboardInput<br>                        {<br>                            wVk = key2.Value,<br>                            dwFlags = (uint)KeyEventF.KeyDown,<br>                            dwExtraInfo = GetMessageExtraInfo()<br>                        }<br>                    }<br>                });<br>            }<br>            if (key2.HasValue)<br>            {<br>                inputs.Add(new Input<br>                {<br>                    type = (int)InputType.Keyboard,<br>                    u = new InputUnion<br>                    {<br>                        ki = new KeyboardInput<br>                        {<br>                            wVk = key2.Value,<br>                            dwFlags = (uint)KeyEventF.KeyUp,<br>                            dwExtraInfo = GetMessageExtraInfo()<br>                        }<br>                    }<br>                });<br>            }<br>            inputs.Add(new Input<br>            {<br>                type = (int)InputType.Keyboard,<br>                u = new InputUnion<br>                {<br>                    ki = new KeyboardInput<br>                    {<br>                        wVk = key1,<br>                        dwFlags = (uint)KeyEventF.KeyUp,<br>                        dwExtraInfo = GetMessageExtraInfo()<br>                    }<br>                }<br>            });<br>            SendInput((uint)inputs.Count, inputs.ToArray(), Marshal.SizeOf(typeof(Input)));<br>        }<br>    }<br>}<br>// -----------------------------------------------------------------------<br>// Top-level interop types (outside namespace so they are globally visible)<br>// -----------------------------------------------------------------------<br>[StructLayout(LayoutKind.Sequential)]<br>public struct POINT<br>{<br>    public int X;<br>    public int Y;<br>}<br>[StructLayout(LayoutKind.Sequential)]<br>public struct KeyboardInput<br>{<br>    public ushort wVk;<br>    public ushort wScan;<br>    public uint dwFlags;<br>    public uint time;<br>    public IntPtr dwExtraInfo;<br>}<br>[StructLayout(LayoutKind.Sequential)]<br>public struct MouseInput<br>{<br>    public int dx;           // delta x<br>    public int dy;           // delta y<br>    public uint mouseData;   // wheel info when MOUSEEVENTF_WHEEL is set<br>    public uint dwFlags;     // bit flags for mouse buttons and movement<br>    public uint time;<br>    public IntPtr dwExtraInfo;<br>}<br>[StructLayout(LayoutKind.Sequential)]<br>public struct HardwareInput<br>{<br>    public uint uMsg;<br>    public ushort wParamL;<br>    public ushort wParamH;<br>}<br>[StructLayout(LayoutKind.Explicit)]<br>public struct InputUnion<br>{<br>    [FieldOffset(0)] public MouseInput mi;<br>    [FieldOffset(0)] public KeyboardInput ki;<br>    [FieldOffset(0)] public HardwareInput hi;<br>}<br>public struct Input<br>{<br>    public int type;<br>    public InputUnion u;<br>}<br>[Flags]<br>public enum InputType<br>{<br>    Mouse    = 0,<br>    Keyboard = 1,<br>    Hardware = 2<br>}<br>[Flags]<br>public enum KeyEventF<br>{<br>    KeyDown     = 0x0000,<br>    ExtendedKey = 0x0001,<br>    KeyUp       = 0x0002,<br>    Unicode     = 0x0004,<br>    Scancode    = 0x0008<br>}<br>[Flags]<br>public enum MouseEventF<br>{<br>    Absolute       = 0x8000,<br>    HWheel         = 0x01000,<br>    Move           = 0x0001,<br>    MoveNoCoalesce = 0x2000,<br>    LeftDown       = 0x0002,<br>    LeftUp         = 0x0004,<br>    RightDown      = 0x0008,<br>    RightUp        = 0x0010,<br>    MiddleDown     = 0x0020,<br>    MiddleUp       = 0x0040,<br>    VirtualDesk    = 0x4000,<br>    Wheel          = 0x0800,<br>    XDown          = 0x0080,<br>    XUp            = 0x0100<br>}</pre><p>At this point your solution should contain three files (ScreenCapture.cs, CaptureHelper.cs, Program.cs) and the ML.NET generated ObjectDetection class. That&#39;s all you need to run the bot.</p><h3>How it all fits together</h3><p>At runtime the loop does the following on every frame:</p><ol><li>Capture a PNG of the emulator window region into a byte array</li><li>Wrap it in an MLImage and call ObjectDetection.Predict(...)</li><li>Read PredictedBoundingBoxes, Score, and PredictedLabel from the result</li><li>Pass the predictions to TryDecideAndPerformAction, which:</li></ol><ul><li>Normalises bounding boxes into pixel space</li><li>Infers lane boundaries from obstacle extents</li><li>Determines which lane the player is in from the boy detection</li><li>Filters out detections that overlap or sit below the player</li><li>Picks the nearest obstacle in the player’s lane</li><li>Chooses a swipe direction based on obstacle type and free lanes</li></ul><p>5. If an action was chosen, PerformAction executes a press-drag-release via SendInput</p><p>6. The annotated debug frame is enqueued for the background writer</p><p>The performance of your bot will depend on the size and accuracy of your training data.</p><p>The below gif shows the best run my bot could do on the small data set I gave it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/854/1*jQ0QA8tK85CgUf2-JEQzgg.gif" /><figcaption>Bot run example</figcaption></figure><h3>Conclusion</h3><p>Now we have a working computer vision bot in a few hundred lines of C# — no Python, no cloud API, no game mods. The whole thing runs locally, trained and deployed entirely inside Visual Studio.</p><p>The performance of your bot will depend on the size and accuracy of your training data. If the bot is making bad decisions, open the <strong>detections</strong> folder first. The annotated frames will tell you whether the problem is in the model or the logic.</p><p>A few things worth trying if you want to push further:</p><ul><li>More training data for the obstacles the model struggles with most — red crates and partially occluded trains were the worst offenders in my runs</li><li>Lower obstacleTriggerFraction if the bot is reacting too late at higher game speeds</li><li>Detect the game-over screen so the bot can tap to restart automatically and run unattended</li></ul><p>Thanks for reading and happy coding.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e202d64e4531" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dvt-engineering/building-a-computer-vision-ai-bot-for-subway-surfers-using-ml-net-and-opencvsharp-in-c-e202d64e4531">Building a Computer Vision AI Bot for Subway Surfers Using ML.NET, and OpenCVSharp (in C#)</a> was originally published in <a href="https://medium.com/dvt-engineering">DVT Software Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Homelab: Elevate your Home Setup and Tech Skills]]></title>
            <link>https://medium.com/dvt-engineering/building-a-homelab-elevate-your-home-setup-and-tech-skills-ff8dd8703dee?source=rss----eb2b463a00e2---4</link>
            <guid isPermaLink="false">https://medium.com/p/ff8dd8703dee</guid>
            <category><![CDATA[portainer]]></category>
            <category><![CDATA[frigates]]></category>
            <category><![CDATA[homelab]]></category>
            <category><![CDATA[tailscale]]></category>
            <category><![CDATA[homarr]]></category>
            <dc:creator><![CDATA[Dewald Oosthuizen]]></dc:creator>
            <pubDate>Thu, 26 Feb 2026 09:11:28 GMT</pubDate>
            <atom:updated>2026-02-26T09:37:50.844Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IJne-OYQucR6mQo4uNeUfg.png" /><figcaption>Image generated with ChatGPT</figcaption></figure><h3>What is a homelab?</h3><p>A homelab is a small-scale, personal IT environment you operate at home for learning, testing ideas, and experimenting with different technologies. It can be as simple as a single low-power device or as complex as a miniature datacenter with multiple servers, switches, and services. This setup allows you to explore virtualisation, networking, automation, and self-hosting in a hands-on way without risking production systems or relying solely on theory.</p><p>The setup can be as simple or as complex as you would like.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/617/1*r0Oh01wiqMddInCzXPmatA.png" /><figcaption>This is a sample diagram of a basic homelab server</figcaption></figure><p>In this example, the homelab runs as a node within a Tailscale tailnet. Remote devices connect securely through the mesh VPN without port forwarding. Subnet routing can also be enabled to expose the broader home network, allowing access to printers, NAS devices and IoT systems without installing Tailscale on each device.As you expand with new features, hardware, and security layers, your requirements will naturally evolve.</p><p>For example, if we wanted to illustrate the Tailscale side of this design, we’d show how remote devices connect through the VPN mesh and interact with the homelab.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/816/1*Z1jgLyUt0jsezPPVvfCroQ.png" /><figcaption>This is a sample diagram of a basic homelab server including a tailnet mesh</figcaption></figure><p>Devices outside the home network connect via the Tailscale VPN mesh. Because the homelab is itself a node within the tailnet, remote clients can access it directly. Tailscale can also be configured to advertise the entire home network using subnet routing, effectively exposing all local devices to the mesh and expanding the architecture shown above. This enables Tailscale clients to reach any device within the advertised subnet through the subnet router, without requiring each home device to run Tailscale or become an individual mesh node. This is useful for devices such as printers, NAS, or even IoT devices.</p><h3>Why you should set up your own homelab</h3><p>Technology changes quickly, and the best way to keep up is by working directly with the tools you want to master. A homelab gives you a safe place to explore, build, and break things without consequences (mostly).</p><p>Whether you’re just getting into IT or already work in the field, a home environment can dramatically accelerate your learning. A homelab is also a good way to make use of old hardware and breathe new life into systems and components that have been lying around, especially with the increasing prices of new hardware. If you don’t have anything lying around, not to worry. You can get up and running with a Raspberry Pi, or you can purchase a refurbished computer for similar pricing to the Raspberry Pi 5.</p><h4>1. Learn through real experience</h4><p>Instead of reading explanations or watching demos, you can interact with real systems. A homelab lets you deploy VMs and containers, configure networks, automate tasks, and troubleshoot issues, mirroring what you would encounter in professional environments.</p><h4>2. Strengthen your career prospects</h4><p>Practical skills stand out. Having your own lab gives you a place to practice for certifications, experiment with new technologies, and demonstrate your understanding of modern platforms. It also shows initiative and curiosity, qualities employers value.</p><h4>3. Gain control through self-hosting</h4><p>If you’d rather avoid depending on third‑party cloud services, a homelab gives you the freedom to host your own. With even a modest setup, you can run a <strong>media server</strong> like Plex or Jellyfin to stream movies and music, deploy <strong>DNS filtering</strong> with Pi‑hole to block ads and trackers, manage <strong>home automation</strong> through Home Assistant, or spin up <strong>development environments</strong> for coding projects. You can also add <strong>monitoring tools</strong> such as Grafana and Prometheus to keep an eye on your systems.</p><p>By self‑hosting, you reduce monthly subscription costs, gain more control over your data, and tailor your digital ecosystem exactly to your needs. For example, instead of paying for Spotify, Netflix, or cloud backup services, you could cancel those subscriptions and replace them with self‑hosted alternatives like Jellyfin, Nextcloud, or Syncthing to access media and files that you already own.</p><h4>4. Experiment freely</h4><p>Curious about a new operating system? Want to try clustering, build a new service, or test backup strategies? Your homelab gives you a safe playground to explore unfamiliar territory and simulate real-world setups, all without risking important data.</p><h4>5. Start with what you have</h4><p>You don’t need enterprise gear to begin. Many people start with a Raspberry Pi, an old laptop, or a budget-friendly mini PC and expand gradually. As your interests and knowledge grow, your homelab can grow with you.</p><h4>6. Stay technically sharp</h4><p>Running your own systems keeps you in tune with trends in virtualization, automation, networking, containerization, and more. This continuous exposure helps you stay relevant in a rapidly evolving tech landscape.</p><h3>What can you self-host?</h3><p>There is a vast ecosystem of self-hosted software available, often as Docker containers or installable directly on Linux. Tools like Portainer simplify managing containers and templates, making it easy to get up and running, especially if you’re new to self-hosting or working with small devices such as Raspberry Pi boards.</p><p>I recommend following the pi-hosted series if you are doing this for the first time: <a href="https://github.com/novaspirit/pi-hosted">https://github.com/novaspirit/pi-hosted</a>. There is also a community that contributes to these Portainer templates to allow beginners to get up to speed quickly.</p><p>If you want something more advanced, try setting up Proxmox instead of Portainer.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/583/1*hlp-shtn8rSPJsEBkE-axQ.png" /><figcaption>⚖️ Why Choose Proxmox Over Portainer?</figcaption></figure><p>Below are some of my favourite services to use in a self-host setup:</p><h4>🎬 Media and entertainment</h4><ul><li><strong>Jellyfin</strong> — Open‑source media server for movies, shows, photos, and music. <br><em>Highlights: multi‑user streaming, metadata fetching, plugin ecosystem, cross‑device compatibility.</em></li><li><strong>mStream</strong> — Personal music server for streaming your audio collection anywhere. <br><em>Highlights: fast interface, playlists, browser/mobile playback, and optional transcoding.</em></li><li><strong>Calibre‑Web</strong> — Web portal for managing and reading your Calibre eBook library. <br><em>Highlights: browser‑based reading, metadata tools, uploads/downloads, and access control.</em></li></ul><h4>🏠 Home automation and networking</h4><ul><li><strong>Home Assistant</strong> — Central hub for smart home automation. <br><em>Highlights: large integration library, local‑first design, customizable dashboards, automation engine.</em></li><li><strong>Tailscale</strong> — Secure VPN mesh built on WireGuard. <br><em>Highlights: seamless connectivity, encrypted peer‑to‑peer links, identity‑based access controls.</em></li><li><strong>Pi‑hole</strong> — DNS‑level ad and tracker blocker. <br><em>Highlights: detailed analytics, custom lists, optional DHCP server, lightweight resource usage.</em></li></ul><h4>🔒 Monitoring and security</h4><ul><li><strong>Uptime Kuma</strong> — Service monitoring and uptime tracking. <br><em>Highlights: multiple check types, clean dashboards, notifications, and incident tracking.</em></li><li><strong>Frigate</strong> — Lightweight NVR with real‑time object detection. <br><em>Highlights: motion/continuous recording, snapshots, alerts, Home Assistant integration, Docker‑friendly deployment.</em></li></ul><h4>📚 Productivity and knowledge management</h4><ul><li><strong>Wiki.js</strong> — Modern documentation platform for notes or knowledge bases. <br><em>Highlights: Markdown editing, revision tracking, Git integration, theming.</em></li><li><strong>Paperless‑NGX</strong> — Document archiving and OCR search. <br><em>Highlights: tagging, metadata editing, powerful search, automation API.</em></li><li><strong>Mealie</strong> — Recipe organizer and meal‑planning tool. <br><em>Highlights: import recipes, weekly planning, grocery lists, and nutrition data.</em></li><li><strong>n8n</strong> — Workflow automation engine. <br><em>Highlights: visual editor, integrations, event triggers, and custom scripting.</em></li><li><strong>Whoogle</strong> — Privacy‑friendly search interface. <br><em>Highlights: no tracking, clean interface, customizable filters.</em></li></ul><h4>💻 System and file management</h4><ul><li><strong>Filebrowser</strong> — Web‑based file manager. <br><em>Highlights: uploads, edits, permissions, sharing links, API integration.</em></li><li><strong>Webmin</strong> — Browser‑based Linux administration. <br><em>Highlights: manage services, edit configs, monitor metrics.</em></li><li><strong>Snippetbox</strong> — Save and share text/code snippets. <br><em>Highlights: syntax highlighting, expiration options, and simple organization.</em></li><li><strong>Homarr</strong> — Dashboard for organizing homelab services. <br><em>Highlights: drag‑and‑drop tiles, real‑time widgets, and theme support.</em></li></ul><h4>⚡ Practical considerations</h4><ul><li><strong>Power &amp; UPS: </strong>As your lab grows, consider a UPS to protect against outages and calculate electricity costs to balance efficiency with uptime.</li><li><strong>Backup strategies: </strong>Follow the 3‑2‑1 rule: three copies of your data, two different media, one offsite. Tools like Syncthing or external drives can help. But also remember that if you can not cover your backup needs fully, any backup is better than no backup.</li><li><strong>Security: </strong>Exposing services online requires caution. Use VPNs, reverse proxies with SSL, strong authentication, and regular updates.</li></ul><h3><strong>Security and remote access</strong></h3><p>Exposing services directly to the internet carries inherent risks. Rather than opening ports on your router, use a secure access layer such as Tailscale or a properly configured reverse proxy with SSL and strong authentication.</p><p>The goal is simple: minimise your attack surface. Keep systems patched, restrict access to authenticated devices, and monitor what you expose.</p><p>If you’d like a deeper comparison between traditional VPN setups and Tailscale, you can read our breakdown here: <a href="https://medium.com/dvt-engineering/traditional-vpn-vs-tailscale-fd077d943e1f">https://medium.com/dvt-engineering/traditional-vpn-vs-tailscale-fd077d943e1f</a></p><h3>Pitfalls to avoid</h3><p>When setting up a homelab, these are the common pitfalls users usually fall into:</p><ul><li><strong>Expanding too quickly without planning </strong><br>Avoid rushing to build your entire vision at once. Take it step by step, learn from each stage, and make sure you understand your setup before tackling the next challenge.</li><li><strong>Ignoring backups </strong><br>Imagine losing all your data and progress to a sudden hardware or software failure; that’s why backups are essential. Always implement a backup strategy to protect what you’ve built. I recommend a service such as Zerobyte. Do not store your backups of your homelab on your homelab (Thank me later!)</li><li><strong>Poor documentation of ports and configurations<br></strong> Keep clear notes on what you’ve configured and how. Good documentation saves time when troubleshooting or rebuilding after a failure.</li><li><strong>Exposing services insecurely</strong><br>There are many ways to make your services accessible outside your home network, but not all are safe. Stick to secure options like Tailscale or similar VPN solutions rather than opening raw ports.</li><li><strong>Overlooking electricity and hardware costs<br></strong>While power usage may not be a major concern for a small homelab, it’s important to match your hardware to the tasks you expect it to handle. For example, running Pi‑hole on a high‑end gaming PC with a 1000W power supply left on all day is overkill and could become an unnecessarily expensive exercise.</li></ul><h3>My Homelab Setup</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MCU97tVWZISkdmUxt2V9jw.png" /><figcaption>My current homelab services (Homarr Dashboard)</figcaption></figure><p>I’ve been building and expanding my homelab for several years, running a variety of services ranging from home automation and backups to camera management and service monitoring.</p><p>My primary motivation for running a homelab is to <strong>regain control and improve privacy</strong>. In fact, I run <strong>two homelabs</strong> to provide a level of redundancy.</p><p>My hardware.</p><ul><li><strong>Main Server:</strong><br> Refurbished HP ProDesk 600 G3 SFF<br> Intel i5–6500 (6th Gen)<br> 8GB DDR4 RAM<br> 256GB NVMe SSD</li><li><strong>Secondary Server:</strong><br>Raspberry Pi 5 (8GB)</li></ul><h4>What were my key requirements and solutions?</h4><p><strong>Remote access to my servers and services</strong></p><blockquote>I need secure access to my servers when I’m away from home. To achieve this, both systems are connected to my Tailscale tailnet, allowing encrypted remote access without port forwarding.</blockquote><blockquote>For full desktop access, I pair this with RustDesk. This gives me remote control capabilities without having to expose additional services publicly. Combined with the Homarr dashboard, I can manage and monitor my environment from anywhere.</blockquote><p><strong>Privacy and ad blocking (Network-wide &amp; remote)</strong></p><blockquote>Privacy and ad-free browsing are a priority for me, so I run <strong>Pi-hole</strong> on both my main and secondary servers. This not only enhances my security and privacy, but also improves overall network performance, as browsers and apps no longer need to load advertisements or allow tracking services to “phone home.”</blockquote><blockquote>- My home router is configured to use both servers as DNS gateways, ensuring Pi-hole is always active across my entire home network.</blockquote><blockquote>- To extend ad blocking beyond my home network, I use <strong>Tailscale’s DNS configuration</strong> so that connected devices resolve traffic through my Pi-hole instances.</blockquote><blockquote>As a result, any device connected to Tailscale automatically benefits from network-level ad blocking, helping me maintain control and protect my privacy wherever I am.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RRlgVK933ru9VZMnPBAA2g.png" /><figcaption>My main server</figcaption></figure><p><strong>Centralised camera management</strong></p><blockquote>I wanted a single dashboard to manage all my network cameras. I use <strong>Frigate</strong> to handle RTSP streams for all cameras and enabled object detection for people and cars, along with motion detection.</blockquote><blockquote>My setup is optimised so that:</blockquote><blockquote>- Recording only occurs when a person or a car is detected, and there is motion.</blockquote><blockquote>- This prevents unnecessary recordings, such as continuous footage of my own car in the garage.</blockquote><blockquote>I have a version of the config that I updated in my GitHub gist section, which you can find <a href="https://gist.github.com/DewaldOosthuizen/79b245b8f6dca4b74396228ba8dce331"><strong>here</strong></a>.</blockquote><p><strong>Personal media access</strong></p><blockquote>Like many people, I wanted easy access to my own movies, music, photos, and books without relying on external drives or physical media.</blockquote><blockquote><strong>- Photos:</strong> PhotoShow (basic) and PhotoPrism (advanced with face/object recognition and smart albums)</blockquote><blockquote><strong>- Movies &amp; series:</strong> Jellyfin, streaming from external storage connected to my server</blockquote><blockquote><strong>- Music:</strong> mStream for streaming my personal music collection</blockquote><blockquote><strong>- Books:</strong> calibre-web for organizing and reading books online (a requirement from my wife)</blockquote><blockquote>In my case, I ripped my own CDs and DVDs so all media could be stored and accessed digitally from my server.</blockquote><p><strong>Server configuration backups</strong></p><blockquote>To protect my server configurations, I use <strong>Zerobyte</strong> to back up configs to:</blockquote><blockquote>- An external drive</blockquote><blockquote>- A Samba network share</blockquote><blockquote>This ensures I can quickly restore or recover in case of failure and can easily be extended to more storage solutions if needed.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-T0prD0iAI62NoBLuYPepw.png" /><figcaption>Zerobyte backups</figcaption></figure><p><strong>Smart home &amp; automation</strong></p><blockquote>For managing smart devices and automation, I use <strong>Home Assistant</strong>. It allows me to control and automate various parts of my home, including:</blockquote><blockquote>- Swimming pool filter timer</blockquote><blockquote>- See my solar system stats and have automation set up around it. I use Solar Assistant running on a Raspberry Pi connected to the inverter. The stats are then fed into my Home Assistant through integration options.</blockquote><blockquote>- Geyser timer</blockquote><blockquote>- Various other smart devices and automations</blockquote><blockquote>Home Assistant acts as the central hub for my smart home and ties everything together.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DAXgsXJjC8xvZJdkU_6_9g.png" /><figcaption>Solar System Stats</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/517/1*TDeKFm95v4yEhrumCQCRnA.png" /><figcaption>Energy Consumption Monitoring</figcaption></figure><p>There are many more services that I self-host to meet other needs, although these were my main requirements up to this point.</p><h3>Conclusion</h3><p>Setting up a homelab is one of the most effective ways to expand your technical skills while gaining more control over your digital environment. With countless services to self‑host, it gives you a private space to experiment, replicate real‑world setups, and grow at your own pace.</p><p>But success isn’t just about what you build, it’s also about how you manage it. Protect your investment with reliable backups, safeguard your systems by exposing services securely, and plan for practical considerations like electricity usage, hardware costs, and power protection with a UPS. Avoid common pitfalls such as expanding too quickly or neglecting documentation, and you’ll create a lab that’s not only powerful but sustainable.</p><p>Your homelab becomes what you make it. Start small, learn through each step, plan carefully, and protect what you’ve built. Over time, you’ll gain both confidence and resilience, turning your homelab into a lasting foundation for curiosity, growth, and technical mastery.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ff8dd8703dee" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dvt-engineering/building-a-homelab-elevate-your-home-setup-and-tech-skills-ff8dd8703dee">Building a Homelab: Elevate your Home Setup and Tech Skills</a> was originally published in <a href="https://medium.com/dvt-engineering">DVT Software Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Data migration from a business analyst’s perspective]]></title>
            <link>https://medium.com/dvt-engineering/data-migration-from-a-business-analysts-perspective-73ca065235fd?source=rss----eb2b463a00e2---4</link>
            <guid isPermaLink="false">https://medium.com/p/73ca065235fd</guid>
            <category><![CDATA[ba-tips]]></category>
            <category><![CDATA[on-prem-to-cloud]]></category>
            <category><![CDATA[data-migration]]></category>
            <category><![CDATA[business-analysis]]></category>
            <dc:creator><![CDATA[Nobulali Marubungwana]]></dc:creator>
            <pubDate>Thu, 05 Feb 2026 12:54:04 GMT</pubDate>
            <atom:updated>2026-02-05T11:26:02.203Z</atom:updated>
            <content:encoded><![CDATA[<h3>A step-by-step guide to migrating data from on-premises to the cloud<br> By Nobulali Marubungwana, Senior BA, DVT</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6HG3xRuhsEfpUVG00zQaig.jpeg" /></figure><p>Joining a new organisation as a business analyst, particularly as a consultant, often comes with the expectation that you are “here to make things happen”. You may find yourself starting a new project or taking over one that has stalled. Either way, the pressure to deliver quickly is familiar to many consultants.</p><p>To support business analysts who are beginning their journey with cloud data migration, I have outlined several practical guidelines and considerations that can make the work clearer, more structured, and more collaborative.</p><p>This guide covers the key steps and best practices to follow when preparing for and executing a cloud data migration project.</p><h3>Understand your data sources</h3><p>A successful migration begins with knowing your data in detail:</p><p>· Where the data currently resides (on-prem environment, system, or platform)</p><p>· What format it is in (structured, semi-structured, or unstructured)</p><p>· How it will be accessed (manually, via APIs or services, or through automated pipelines)</p><h3>Common types of data sources:</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/765/1*e2LtVXqVc7MyoNKYT1OwiQ.png" /></figure><p>Understanding these fundamentals early helps you identify potential risks, constraints, and transformation needs.</p><p><strong>Define the ingestion and transformation flow</strong></p><p>Once the data sources are known, the next step is to map out exactly how the data will move into the cloud and what needs to happen along the way.</p><p>Consider the following:<br>• How will the data be received? Pushed, pulled, or manually uploaded?<br>• What transformation logic is required? i.e. Fields to map, normalisation rules, enrichment steps, or data cleansing?<br>• What should the final state look like? i.e. The expected structure, schema, or model in the cloud environment?</p><h3>A complete end-to-end flow typically includes:</h3><h3>· Raw ingestion layer</h3><h3>· Transformation or staging layer</h3><h3>· Final curated layer for business consumption</h3><p>Documenting this flow early reduces confusion and supports smoother hand-offs between teams.</p><h3>Visualise the data flow</h3><p>A visual representation makes the ingestion process easier to understand, maintain, and scale as new data sources are added.</p><p>Useful tools include:</p><p>· Lucidchart</p><p>· Microsoft Visio</p><p>A well-designed diagram becomes a reliable reference point for developers, analysts, new team members, and business stakeholders</p><h3>Prioritise strong documentation</h3><p>Clear documentation is vital in any data migration. Each data source should include:</p><p>· A description of the processes involved</p><p>· A mapping document showing every attribute from source to destination</p><p>· Notes on business rules, exceptions, and dependencies</p><p>Good documentation becomes invaluable during testing, sign-off, handover, and future changes.</p><h3>Where the Business Analyst adds real value</h3><p>Data migration can be uncomfortable for teams, particularly when long-established processes begin to change. People may feel uncertain or reluctant as they move from known routines to new ways of working. As a business analyst, your role is to help make this transition smoother by documenting both the <strong>current state (AS-IS)</strong> and the <strong>future state (TO-BE)</strong> clearly and communicating these effectively.</p><p>In some organisations, AS-IS documentation may be outdated or missing entirely. This presents an opportunity to document existing processes accurately before defining the future state.</p><p>You will often work closely with external delivery partners responsible for the technical execution of the migration. Your role is to engage confidently, ask the right questions, build strong working relationships, and ensure that internal teams are fully equipped to operate the new processes end-to-end once the migration is complete.</p><p>Always refer back to the existing processes and use cases to ensure that all roles, touchpoints, and hand-offs have been considered. In some instances, tasks previously performed by multiple individuals may be consolidated into a single role in the new environment. This must be identified and clearly documented.</p><h3>Example use cases</h3><p>· <em>As a developer, I want to ingest data from a database extract so that I can stage it for transformation and loading into the target system.</em></p><p>· <em>As a development manager, I want to manage access permissions and monitor storage requirements so that I can approve resource usage and associated costs.</em></p><h3>What went wrong?</h3><p>In a previous migration project, a key lookup table transformation was overlooked during the ingestion process. The reason? The transformation requirement was not documented clearly, a crucial measure was missing from a final business report.</p><p>This reinforces the importance of thorough, validated documentation across every stage of the process: source, transformation, landing, and consumption.</p><h3>What could have been done better?</h3><p>· Identify and document all touchpoints and dependencies</p><p>· Define exception and validation rules for each stage</p><p>· Allow space to identify issues early and correct them quickly</p><p>· Introduce sign-off gates at each stage of the migration lifecycle</p><h3>Learnings for the BA role</h3><p>· Be willing to learn and deepen your technical understanding. Interpreting data is essential.</p><p>· Ensure that every stage of the migration has clear reviewers and approvers.</p><p>· Document all data attributes at source, transformation, and destination layers.</p><p>· Remember that these guidelines represent only some of the key considerations in a data migration project.</p><p>Data migration is complex, but with structured processes, clear communication, and strong documentation, the business analyst plays a crucial role in ensuring a successful transition.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=73ca065235fd" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dvt-engineering/data-migration-from-a-business-analysts-perspective-73ca065235fd">Data migration from a business analyst’s perspective</a> was originally published in <a href="https://medium.com/dvt-engineering">DVT Software Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The State of Jetpack Compose in 2025]]></title>
            <link>https://medium.com/dvt-engineering/the-state-of-jetpack-compose-in-2025-987145a773fb?source=rss----eb2b463a00e2---4</link>
            <guid isPermaLink="false">https://medium.com/p/987145a773fb</guid>
            <category><![CDATA[jetpack-compose]]></category>
            <category><![CDATA[android]]></category>
            <category><![CDATA[androiddev]]></category>
            <dc:creator><![CDATA[George Mujuru]]></dc:creator>
            <pubDate>Fri, 15 Aug 2025 11:18:55 GMT</pubDate>
            <atom:updated>2025-08-15T11:18:55.098Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ox7hAen78SiyY0FHvTrBYA.png" /></figure><p><strong>Introduction</strong></p><p>It has been a few years since Google’s Jetpack Compose made its stable debut, and in that time, it has completely changed how Android apps are built. What began as a modern alternative to the traditional View system has now become the go-to toolkit for Android developers around the world.</p><p>In this blog, we will take a closer look at how Compose has grown and where it stands today. We will look at how widely it has been adopted across the industry, with examples from major apps and development teams that have embraced it fully.</p><p>So, whether you are already using Compose or thinking about giving it a try, this post will give you a holistic view of where things stand.</p><p><strong>Goodbye XML, Hello Compose</strong></p><p>Jetpack Compose was designed to simplify and accelerate UI development on Android. The old view system used XML layouts combined with imperative logic in activities and fragments. This resulted in a lot of boilerplate code, difficult state management and UI inconsistencies as the applications grew more complex.</p><p>In response to these limitations, Compose was developed. The key reasons were: <br>• Declarative UI: Build UI with functions that react to state changes. <br>• Less Boilerplate: No more XML, findViewById, the chaos of adapters. <br>• Unified Toolkit: Everything from layouts to animations is handled in Kotlin. <br>• Faster Iteration: Live previews, hot reload, and simpler testing.</p><p>In 2025, Jetpack Compose has improved in several core areas: <br>• Live Edit 2.0 brings real-time updates to stateful composables and animations, significantly improving developer productivity. <br>• Compose Multiplatform is now stable and production-ready for Android and desktop, with web support rapidly evolving. <br>• Material 3 feels complete, offering robust theming and accessibility out of the box. <br>• Animation APIs enable smoother transitions and improved control, helping developers detect and resolve jank earlier in the development process. <br>• UI Testing Tools have matured with visual diffing, semantic matchers, and flaky test detection, making test coverage more reliable.</p><p>Today, compose has matured into a powerful and stable solution used to build many applications. What started as a newcomer in the Android development world has now become the norm for modern UI development.</p><p><strong>A Good Fit for Modern Android </strong><br> <br>While improvements are still being made, Jetpack Compose has kept up with the needs of modern Android development. It works well for building modern interfaces, including smart features powered by AI, apps for smartwatches using Wear Compose, and flexible layouts for foldable phones and large screens. It’s also used in embedded and IoT environments, like car displays and smart dashboards, and performs surprisingly well.</p><p>Compose Multiplatform goes beyond mobile. Support for desktop, iOS and Android is stable, while web support is still in alpha. However, it’s already useful for trying out internal tools. At the time of writing, version 1.8.0 is the latest stable release for the Android libraries.</p><p>Global multinational companies such as Airbnb, Disney+, Meta (notably Threads), Spotify, Chat GPT and even Google Play have adopted Compose for real-world, user-facing applications. The benefits reported include faster delivery cycles, better performance, and reduced defect rates. In the local and African context, uptake has been slow, with only Standard Bank and Discovery being the most notable users of Jetpack Compose, indicating growth potential.</p><p><strong>Developer Experience over the Past Two Years: A Mixed Bag</strong></p><p>Developers praise the framework for its rapid iteration, composable architecture, and reusability. However, some challenges remain. Understanding recomposition, managing state effectively, and integrating with legacy View-based systems can still be tricky. For teams working with legacy codebases, ComposeView offers a practical way to bring Compose into existing XML-based projects. It lets developers embed composables directly into traditional views, making the migration process less daunting and more incremental. Tooling has improved significantly, though debugging and UI inspection tools still have room to mature.</p><p>Performance has also improved significantly. Smarter recomposition, more efficient lazy layouts, a smoother Skia renderer, and Kotlin 2.x have led to faster builds and improved runtime performance. Development tools such as modifier tracing, recomposition highlights, and memory leak detection further enhance reliability. <br> <br>Over the past few years, I have been using Jetpack Compose as my main tool for Android development, and I have learned a lot through hands-on experience: <br> <br>• Building UIs from designs is quicker and easier. It is much faster to go from a design to a working screen. I can create reusable components which help speed things up. The live preview in Android Studio also saves time since I don’t have to rebuild the whole app just to see small changes. <br>• I used to struggle with recomposition, but I’ve improved over time and now understand how to manage it better.</p><p>•State hoisting has helped me write cleaner code, especially when working with shared or lifted state across components. <br>• The Android developer community has been helpful; for example, tools like custom lint checks (such as Compose Lints by Slack) help catch potential issues early, making it easier to write better Compose code.</p><p><strong>The Ecosystem: Mature and Expanding</strong></p><p>Google continues to invest in its Jetpack Compose through key libraries like Navigation, Paging, Glance, and Material, covering everything from app architecture to home screen widgets. On top of this, community-driven tools such as Accompanist, Showkase, Voyager, and Lottie make it easier to build polished, responsive UIs.</p><p>The tooling is just as strong. Android Studio offers features like Live Edit, Compose Metrics, and helpful lint checks that support quick iteration and code quality.</p><p>Together, this growing ecosystem helps developers build high-quality apps faster, whether for Android alone or across platforms with Compose Multiplatform. Despite the progress, there are still a few areas where Google could improve Jetpack Compose: <br>• Working with XML and older code, in larger apps, switching from XML to Compose can still be tricky. Having better migration tools built in would make it easier to deal with older codebases. <br>• Simpler architectural guidance, better official guidance could assist in removing confusion for newcomers. <br>• Some error messages, especially when a composable can’t be shown, could be clearer and more helpful.</p><p>Compose is also expected to expand further into system-level apps and embedded platforms.</p><p><strong>Conclusion</strong></p><p>In 2025, Jetpack Compose is no longer the future; it is the present. It offers faster development, cleaner architectures, and cross-platform potential. While challenges around learning and tooling remain, the benefits far outweigh the drawbacks. For any developer building Android apps today, sticking with the legacy View system now feels like an avoidable limitation. <br> <br>If you haven’t tried Compose yet, now’s the time to explore it. The ecosystem is mature, the community is active, and the future is clearly composable.</p><p><strong>References</strong></p><p>1. https://developer.android.com/jetpack/androidx/releases/compose <br>2. <a href="https://slackhq.github.io/compose-lints/">https://slackhq.github.io/compose-lints/</a></p><p>3. <a href="https://betulnecanli.medium.com/why-you-should-learn-jetpack-compose-in-">https://betulnecanli.medium.com/why-you-should-learn-jetpack-compose-in-</a>2025-and-how-to-do-it-for-free-a9e1b1db48e8 <br>4. <a href="https://android-developers.googleblog.com/2022/05/airbnb-uses-jetpack-">https://android-developers.googleblog.com/2022/05/airbnb-uses-jetpack-</a><br>compose.html <br>5. <a href="https://github.com/airbnb/Showkase">https://github.com/airbnb/Showkase</a> <br>6. <a href="https://www.linkedin.com/posts/jorgecastillopr_guiding-the-jetpack-compose-migration-at-activity-7308205675501789185-LLdj?utm_source=share&amp;utm_medium=member_desktop&amp;rcm=ACoAAAznNGgBl_8Tm0rhhda-rOFjm37wD5qe5V8">https://www.linkedin.com/posts/jorgecastillopr_guiding-the-jetpack-compose-migration-at-activity-7308205675501789185-LLdj?utm_source=share&amp;utm_medium=member_desktop&amp;rcm=ACoAAAznNGgBl_8Tm0rhhda-rOFjm37wD5qe5V8</a> <a href="https://www.linkedin.com/posts/jorgecastillopr_guiding-the-jetpack-compose-migration-at-activity-7308205675501789185-LLdj?utm_source=share&amp;utm_medium=member_desktop&amp;rcm=ACoAAAznNGgBl_8Tm0rhhda-rOFjm37wD5qe5V8"><br></a>7. <a href="https://www.jetbrains.com/lp/devecosystem-2024/">https://www.jetbrains.com/lp/devecosystem-2024/</a> <br>8. <a href="https://x.com/JorgeCastilloPr/status/1902439932129390808">https://x.com/JorgeCastilloPr/status/1902439932129390808</a> <br>9. <a href="https://www.youtube.com/watch?v=23sNq-N06xU">https://www.youtube.com/watch?v=23sNq-N06xU</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=987145a773fb" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dvt-engineering/the-state-of-jetpack-compose-in-2025-987145a773fb">The State of Jetpack Compose in 2025</a> was originally published in <a href="https://medium.com/dvt-engineering">DVT Software Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From Tennis Court to Table: McDonald’s Design Thinking Legacy]]></title>
            <link>https://medium.com/dvt-engineering/from-tennis-court-to-table-mcdonalds-design-thinking-legacy-ec3194520b44?source=rss----eb2b463a00e2---4</link>
            <guid isPermaLink="false">https://medium.com/p/ec3194520b44</guid>
            <dc:creator><![CDATA[James Chisholm]]></dc:creator>
            <pubDate>Tue, 24 Jun 2025 15:15:39 GMT</pubDate>
            <atom:updated>2025-06-24T15:15:39.378Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vcWP5-GhcMisCzk8_kNDnQ.png" /></figure><p>One of the things that bothers me is when young designers define ‘<strong>Graphic Design</strong>’ as “<em>design for print</em>” while ‘<strong>Digital Design</strong>’ is <em>“</em><strong><em>real,</em></strong><em> modern design on computers”</em>.</p><p>While the latter isn’t <em>necessarily</em> wrong, something they are missing is that the “Graphic” in graphic design merely emphasises that the design is visual; ie, <em>you can see it</em>.</p><p>What seems to get lost on the next generation of designers is that t<strong>he core principles of design work across mediums </strong>and<strong> across generations</strong>, which is why we have had graphic designers in movies, music, video games… You name it, decades before computers and software became a thing.</p><p>A core principle in the design world is <strong>“Design Thinking”,</strong> which is a problem-solving approach that emphasises empathy, creativity, and iterative testing to develop user-centered solutions. It involves five stages: empathise, define, ideate, prototype, and test, where teams understand user needs, brainstorm ideas, create prototypes, and refine solutions based on feedback. This method fosters innovation by prioritising human experiences and collaboration.</p><p>Now that the boring stuff is out of the way, let me explain what this has to do with ‘McDonald&#39;s’, a fast food chain that has been running for nearly a century.</p><p>Back in the 1940’s, Richard and Maurice (Dick and Mac) McDonald wanted to reinvent their restaurant, allowing them to lower costs while maintaining a premium product. Among many of the decisions they made was the choice by Dick to go out with their staff to a tennis court and draw out the layout of the kitchen in chalk, illustrated here in the movie “The Founder” (2016)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fT0IM6ZHppgnOvcQklXTqQ.png" /><figcaption>Image courtesy of Filmnation (2016)</figcaption></figure><p>They then asked their staff to walk around the “kitchen”, manning their posts and pretending to go about their day-to-day business. Here we see the McDonald’s brothers incorporating many of the Design Thinking methodologies at once. They ideated, created a ‘low fidelity’ <em>prototype</em> and tested.</p><p>Viewing from the sidelines, they witnessed staff bumping into one another and being generally inefficient.</p><p>So they erased the chalk and started again:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*W7cn9356yT2biATbvm-kzg.png" /><figcaption>Image courtesy of Filmnation (2016)</figcaption></figure><p>And again they stood on the sidelines discussing, analysing, refining as the kitchen staff moved back and forth through the new layout until finally they iterated on their design for a 3rd time:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lH26oq3J2nUsppnzch5adg.png" /><figcaption>Image courtesy of Filmnation (2016)</figcaption></figure><p>6 hours. <br>3 Design iterations.<br>Zero cost.</p><p>While modern designers are often eager to dive into software, spending hours, days, or even weeks on high-fidelity designs at the client’s expense, there’s value in stepping back to create quick, low-cost iterations. When software crashes or tools are unavailable, the instinct to wait can stall progress. But <strong>Design Thinking teaches adaptability</strong> — like grabbing a pen and paper to sketch ideas during a power outage, ensuring the creative process continues uninterrupted.</p><p>As we can see, there are definite lessons to be learned from the McDonald’s brothers.</p><p>Even if their staff <em>did</em> burn my last burger.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ec3194520b44" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dvt-engineering/from-tennis-court-to-table-mcdonalds-design-thinking-legacy-ec3194520b44">From Tennis Court to Table: McDonald’s Design Thinking Legacy</a> was originally published in <a href="https://medium.com/dvt-engineering">DVT Software Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Fast But Soulless: Why AI Design Tools Miss the Mark]]></title>
            <link>https://medium.com/dvt-engineering/fast-but-soulless-why-ai-design-tools-miss-the-mark-c19f71dd3bc1?source=rss----eb2b463a00e2---4</link>
            <guid isPermaLink="false">https://medium.com/p/c19f71dd3bc1</guid>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[ux]]></category>
            <category><![CDATA[design]]></category>
            <category><![CDATA[ui]]></category>
            <dc:creator><![CDATA[James Chisholm]]></dc:creator>
            <pubDate>Wed, 04 Jun 2025 08:52:00 GMT</pubDate>
            <atom:updated>2025-06-04T08:52:00.161Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*o5YY_iXeABm_Cvq4EmQsYg.png" /><figcaption>An image comparing an actual photo (left) with the AI equivalent (right)</figcaption></figure><p>I recently took one of those hyped-up AI design tools for a spin, the kind that promise to churn out polished UI screens faster than you can say “deliverable.” It delivered, alright — crisp layouts, neatly aligned components, colours that didn’t make my eyes bleed. But after testing it on a real project, I was left with one gut-punch realisation: it’s all polish, no soul. Like a stock photo of a sunset, it’s technically fine but feels like it’s trying to sell you something instead of telling a story.</p><p><strong>The Generic Trap</strong><br>The output was, frankly, generic as hell. It looked like every other trendy UI kit you’ve seen on Dribbble — sleek buttons, safe typography, and a colour palette that screams “I read a blog post about 2025 design trends.” There’s no depth, no narrative. It’s decoration, not design.</p><p>As a designer, I could feel the absence of intent. This wasn’t a solution crafted for a specific user or problem; it was a one-size-fits-all template dressed up as innovation. And that’s a problem when your job is to create something that connects, not just something that looks “nice.”</p><p><strong>Skipping the Soul of Design</strong><br>Here’s where it gets worse: tools like this don’t just produce bland results; they encourage you to skip the actual design process. No wireframing, no sketching, no wrestling with ideas in that messy, beautiful way that leads to breakthroughs. Those early stages aren’t just prep work — they’re where the magic happens. It’s where you ask, “<em>Who</em> is this for? <em>What</em> are we solving? <em>How</em> do we make it <strong>feel</strong> human?”</p><p>When you jump straight to a pre-baked layout, you’re not designing; you’re decorating someone else’s idea. My test run with the tool proved it. The AI spat out a screen in seconds, but it was like being handed a puzzle with half the pieces already placed — except they didn’t quite fit. I spent more time tweaking redundant components than I would have sketching from scratch. And because I didn’t build the logic myself, I couldn’t even spot what was missing, or even unnecessary, until I was knee-deep in revisions. It felt like un-designing a completed design, with blinders on, following a script I didn’t write.</p><p><strong>Redundancy Without Insight</strong><br>Speaking of redundant components, let’s talk about the mess these tools create. The layouts generated tend to be bloated with repetitive elements — think three variations of the same button style, none of which served a clear purpose. It was as if the AI was throwing in extras to look busy, like a kid padding an essay to hit the word count. Worse, it obscured what was actually needed. Because I hadn’t mapped out the user flow or problem space myself, I was stuck playing catch-up, trying to reverse-engineer the AI’s choices. That’s not <em>efficiency</em>; it’s <strong>chaos masquerading as a shortcut.</strong></p><p>This is why we wireframe and sketch first. Those steps force you to think through the problem, to question every element’s purpose. Without them, you risk ending up with a design that’s busy but empty, like a restaurant menu with 50 items but nothing you want to eat.</p><p><strong>The “Pixel-Pusher” Stereotype</strong><br>Maybe the most frustrating part is how these tools feed into a narrative that designers have been fighting forever: the idea that we’re just here to “make things pretty.” You know the stereotype — “Oh, you’re a designer? So you push pixels around and pick nice colours?” It’s infuriating, and tools like this make it worse. By reducing design to a button press, they strip away the strategy, the problem-solving, the human insight that’s at the core of what we do.While chatting my colleague Roberto and I hashed it out: these tools might dazzle stakeholders in a sprint review, but they risk wrapping the wrong solution in a shiny bow. Dead right. Clients love quick turnarounds — time is money, after all — but speed without context is a disaster waiting to happen. A beautiful UI that doesn’t serve the user is just lipstick on a pig. We’re not decorators; we’re translators, turning needs into experiences. That takes thought, empathy, and iteration — not an algorithm spitting out a template.</p><p><strong>What AI Should Be</strong><br>Don’t get me wrong — I’m not anti-AI. There’s huge potential for tools that support designers, especially for the grunt work. Automate repetitive tasks like resizing assets, generating style guides, or cleaning up file exports? Hell yes, sign me up. That’s the kind of AI I’d welcome with open arms, like one that could tackle my laundry pile while I’m at it. But when AI tries to replace the creative process entirely, it’s not just unhelpful — <em>it’s irresponsible</em>.</p><p>Design<strong> isn’t just about what something looks like</strong>; it’s about <em>why</em> it exists, <em>who</em> it serves, and <em>how</em> it <strong>feels</strong>. AI can’t replicate the messy, human spark of ideation or the gut-check moments when you realise a solution isn’t quite right. It can’t sit in a room with a client and sense their unspoken needs. And it sure as hell can’t replace the satisfaction of crafting something that feels alive.</p><p><strong>A Call for Better Tools</strong><br>If AI is going to be part of our workflow, it needs to elevate our thinking, not bypass it. Imagine a tool that acts like a collaborator — suggesting ideas, flagging inconsistencies, or speeding up iterations while leaving the big decisions to us. That’s the kind of AI that respects design as a craft, not a commodity. Until then, I’ll stick to my sketches, my wireframes, and my <em>tea</em>-fuelled brainstorming sessions. Because that’s where the real work happens — and no algorithm can touch that.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c19f71dd3bc1" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dvt-engineering/fast-but-soulless-why-ai-design-tools-miss-the-mark-c19f71dd3bc1">Fast But Soulless: Why AI Design Tools Miss the Mark</a> was originally published in <a href="https://medium.com/dvt-engineering">DVT Software Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Vibe Coding vs. Reality: A Love Letter (and Warning) to Developers]]></title>
            <link>https://medium.com/dvt-engineering/vibe-coding-vs-reality-a-love-letter-and-warning-to-developers-e742808011ec?source=rss----eb2b463a00e2---4</link>
            <guid isPermaLink="false">https://medium.com/p/e742808011ec</guid>
            <dc:creator><![CDATA[Saurabh Agrawal]]></dc:creator>
            <pubDate>Tue, 03 Jun 2025 10:38:53 GMT</pubDate>
            <atom:updated>2025-06-03T10:38:53.516Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UphdnwdQMNG07eHf2HLmIw.jpeg" /><figcaption>Vibe Coding</figcaption></figure><p><em>Lo-fi beats, late nights, and no JIRA tickets in sight ; vibe coding feels like freedom. But when passion projects turn into production code, things can go sideways fast. This love letter celebrates the joy of coding in flow and gently reminds you when to put the vibes away.</em></p><p>Let’s face it; sometimes, you’re not writing code. You’re <strong>vibing code</strong>. Isn’t it?</p><p>You know the feeling. It’s 2 AM, and you’ve got lo-fi beats in your ears, caffeine (or Red Bull and Monster) in your veins, and an idea that might change the world or at least get a few GitHub stars. No stakeholders. No JIRA tickets. Just raw creative energy and a slightly unstable prototype named <em>my_final_version_really_this_one_last_time_v7.</em></p><p>Welcome to vibe coding. It’s fun. It’s fast. It’s how pet projects are born, how AI tools get test-driven, and how developers accidentally invent the future (or break their laptops in the process).</p><h3>When is vibe coding a vibe? 😏</h3><ol><li><strong>Pet projects, hackathons, and rapid prototypes: </strong>These are playgrounds. There’s no legacy code, no stakeholders breathing down your neck, and no tight deadlines to meet. It’s just you and your imagination. Use that fancy new AI framework. Try a weird new layout strategy. Let the vibes flow.</li><li><strong>Learning and experimentation: </strong>Want to wrap your head around Claude Desktop, Cursor, Devin AI, Spring AI, GitHub’s MCP tools or Cohere APIs? Vibe coding is a great way to get your hands dirty without getting tied down. It’s spontaneous. It’s messy and that’s the point. You learn by building, however weird and wonderful it turns out.</li><li><strong>Starting something from scratch: </strong>No tech debt. No rigid architecture. Just ideas and momentum. It’s easier to fix mistakes early, without dealing with legacy decisions, especially when it’s not yet a client’s production environment.</li></ol><h3>When is vibe coding a recipe for chaos? 😦</h3><ol><li><strong>Enterprise Applications: </strong>You’re not just coding; you’re contributing to a carefully orchestrated system. Teams depend on structure, patterns, consistency, scalability, security and documentation. Vibe coding here is like trying to improv jazz in a symphony orchestra. Attempting it in a production environment is likely to result in someone being fired.</li><li><strong>Shared Codebases: </strong>When multiple devs touch the same repositories, standards matter more than vibes. Vibe coding can lead to inconsistent patterns, mysterious frameworks, and everyone’s favourite game: “<em>Who wrote this and what were they thinking?</em>” followed closely by “<em>What is this for?”</em> moments.</li><li><strong>High-Stakes Projects: </strong>Deadlines. Compliance. Security audits. Your code doesn’t need to <em>feel</em> right, it needs to <strong><em>be</em> right</strong>. Vibes don’t pass a PEN test. Run one on your freestyle masterpiece and watch it light up like a Christmas tree.</li></ol><h3>AI Assistants and Tools: A humble request to use them wisely 🙏</h3><p>Sure, AI can autocomplete your thoughts and generate code like a caffeinated programmer. But should you always trust it?</p><ol><li>AI can enhance your efficiency, but don’t hand over your judgment.</li><li>Be selective. Use tools that solve real problems, not just ones trending on X or paid news platforms, even if you’re not 100% sure how to test them (yet).</li><li>Evaluate tools based on your team’s maturity, the project’s lifecycle, and your tech stack not just the hype cycle.</li></ol><h3>Bottom line: Use vibes for flow, not for foundations 💡</h3><p>Vibe coding is brilliant for sparking creativity. But enterprise apps need architecture, not astrology. The key is balance. Experiment with enthusiasm, but adopt with intention and discipline.</p><p>Think of it like seasoning: a sprinkle of vibe coding can make your project flavourful, but pouring in the whole bottle? Now, that’s just a mess.</p><p><strong>Now, go forth, code hard, and vibe responsibly. </strong><em>May the </em><strong><em>Force</em></strong><em> be with you!</em></p><p><em>By Saurabh Agrawal, Practice and Engineering Lead: Java, Backend Open-Source Technologies and AWS Cloud Solutions at DVT.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e742808011ec" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dvt-engineering/vibe-coding-vs-reality-a-love-letter-and-warning-to-developers-e742808011ec">Vibe Coding vs. Reality: A Love Letter (and Warning) to Developers</a> was originally published in <a href="https://medium.com/dvt-engineering">DVT Software Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The evolution of software development]]></title>
            <link>https://medium.com/dvt-engineering/the-evolution-of-software-development-7c3d70de8775?source=rss----eb2b463a00e2---4</link>
            <guid isPermaLink="false">https://medium.com/p/7c3d70de8775</guid>
            <category><![CDATA[cloud]]></category>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[development]]></category>
            <category><![CDATA[cloud-services]]></category>
            <category><![CDATA[deployment]]></category>
            <dc:creator><![CDATA[Dewald Oosthuizen]]></dc:creator>
            <pubDate>Thu, 15 May 2025 08:29:14 GMT</pubDate>
            <atom:updated>2025-05-15T08:29:13.818Z</atom:updated>
            <content:encoded><![CDATA[<h3>The evolution of software development and deployment</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*kZSFZ04rlVCDF_LL" /></figure><p>Many developers likely share my perspective on the evolution — perhaps over-complication — of the tech stack over the years. Not too long ago, we would configure and deploy an application server. While setting up the server required some configuration, it was not an overly complex process. However, this approach had its limitations, particularly in terms of scalability and fail over recovery.</p><p>Modern tools like Docker, cloud platforms and Kubernetes have since addressed these challenges. Yet, these solutions, often embraced by corporate environments, introduce their own drawbacks — primarily the complexity of orchestration and the necessity of upskilling in numerous additional tools.</p><p>Let’s break this down and explore whether the resulting hype and complexity were truly justified.</p><h3>The Evolution: Simplicity to Complexity</h3><h4>Traditional Application Servers:</h4><ul><li>Early application servers like Tomcat, WebLogic, and JBoss provided a centralised runtime environment for deploying applications.</li><li>Setup and Deployment: Configuring these servers for a single-node environment was relatively simple and well-documented.</li></ul><p>Challenges:</p><ul><li>Scalability: Scaling typically involved manual efforts, such as setting up multiple server nodes and load balancers.</li><li>Fail over and Recovery: Required proprietary solutions or additional tools, which were often costly or platform-specific.</li><li>Vendor Lock-In: Organisations were often tied to a specific vendor’s ecosystem, limiting flexibility and innovation.</li></ul><h4>The arrival of Docker and Cloud Services:</h4><ul><li>Docker: Revolutionised development by introducing lightweight containers that package applications and their dependencies into portable units.</li><li>Impact: This “write once, run anywhere” model ensured consistent behaviour across environments.</li><li>Development Agility: Enabled micro services architecture by allowing applications to be split into modular, independently deployable units.</li><li>Cloud Services: Platforms like AWS Elastic Beanstalk, Azure App Services, and Google App Engine abstracted the complexities of infrastructure management.</li><li>Impact: Simplified deployment and automated scaling, making enterprise-grade capabilities accessible to smaller teams.</li><li>Integration: Tools like CI/CD pipelines integrated seamlessly, reducing the time to market.</li></ul><p>Challenges:</p><ul><li>Dependency on Cloud Vendors: Created a new form of vendor lock-in.</li><li>Cost Management: Scaling services dynamically often led to unpredictable expenses.</li></ul><h4>Kubernetes and the Rise of Orchestration Tools:</h4><ul><li>Kubernetes: Evolved to address the complexities of managing containerized applications at scale.</li><li>Capabilities: Automated deployment, scaling, and management of containerized workloads in distributed systems.</li><li>Ecosystem: Grew to include supporting tools like Helm (for package management), Kustomize (for configuration management), and Istio (for service mesh).</li></ul><p>Challenges:</p><ul><li>Complexity: Kubernetes itself has a steep learning curve, requiring significant expertise to manage.</li><li>Operational Overhead: Monitoring, debugging, and securing Kubernetes clusters require additional layers of tooling, such as Prometheus, Grafana, and Kubescape.</li><li>Over-Engineering Risks: Small to mid-sized teams often find Kubernetes overkill for simpler workloads, leading to inefficiencies.</li></ul><h4>Modern Landscape and Emerging Trends:</h4><ul><li>Serverless Architectures: Solutions like AWS Lambda, Azure Functions, and Google Cloud Functions aim to further abstract infrastructure management by allowing developers to focus solely on code.</li></ul><p>Benefits: Scalability and fail over are handled automatically by the provider.</p><p>Challenges: Observability and cold-start latency can be significant drawbacks for some use cases.</p><ul><li>Edge Computing: Platforms like Cloudflare Workers and AWS Lambda@Edge enable applications to run closer to users for low-latency performance.</li><li>Low-Code/No-Code Solutions: Tools like OutSystems and Bubble offer simplified environments for building applications without extensive coding, catering to non-technical teams.</li></ul><p>By tracing the evolution from traditional servers to modern orchestration and serverless solutions, we can see how the development world has shifted from simplicity to increasingly powerful but complex ecosystems. This journey highlights the balance between solving legacy challenges and introducing new ones, ultimately shaping the way we build and deploy software today.</p><h3>The Trade-Offs: Why the Complexity?</h3><h4>Scalability and Resilience at Scale</h4><p>Global Scale: Modern applications often serve millions of users across multiple regions, necessitating robust infrastructure capable of:</p><ul><li>Load Balancing: Distributing traffic efficiently across nodes to prevent overload.</li><li>Data Replication: Ensuring consistent and fast access to data worldwide.</li></ul><p>Advanced Resilience: Beyond self-healing, modern systems demand:</p><ul><li>Disaster Recovery: Automating fail over to backup systems in case of catastrophic failures.</li><li>Zero Downtime Deployments: Strategies like blue-green and canary deployments to update services without disrupting users.</li></ul><p>Cost of Complexity: Achieving these capabilities with Kubernetes and related tools requires:</p><ul><li>Significant expertise in configuration and tuning.</li><li>Investments in monitoring and logging systems to maintain visibility.</li></ul><h4>DevOps Culture and Automation</h4><p>Shift in Responsibilities: Developers are now expected to understand and manage operational aspects traditionally handled by sysadmins.</p><ul><li>Infrastructure as Code (IaC): Tools like Terraform and Ansible are now essential for managing infrastructure declaratively.</li><li>Build Pipelines: Automated testing, building, and deployment are table stakes for modern development.</li></ul><p>Explosion of Tools: The demand for automation has created a crowded ecosystem, including:</p><ul><li>Monitoring and Observability: Prometheus, Grafana, and New Relic to ensure systems remain performant.</li><li>Secret Management: Tools like Vault to securely manage sensitive configuration.</li><li>Security Scanning: Solutions like Aqua Security or Trivy to secure containers and their dependencies.</li></ul><h4>Cloud-Native Complexity</h4><p><strong>Portability vs. Complexity:</strong></p><ul><li>Portability: Kubernetes provides a consistent API and deployment model across cloud providers and on-premises systems, reducing vendor lock-in.</li><li>Complexity: Managing cloud-native workloads introduces abstractions like:</li></ul><p>Operators: Custom controllers for automating application-specific tasks.</p><p>Helm Charts: Simplifying Kubernetes configurations but adding another layer of templating complexity.</p><p><strong>Security and Compliance:</strong></p><ul><li>Multi-Tenant Systems: Ensuring isolation and security in shared environments adds overhead.</li><li>Compliance Requirements: Adhering to standards like GDPR or HIPAA often requires custom implementations for data encryption, audit logging, and access control.</li></ul><h4>Developer Experience vs. Operational Burden</h4><ul><li>Learning Curve: The number of tools and the depth of knowledge required to operate modern systems can be overwhelming for developers.</li><li>Trade-Offs in Speed: While DevOps practices and cloud-native tools promise faster delivery, the initial setup and ongoing maintenance can slow teams down if not well-managed.</li></ul><h4>Emerging Efforts to Reduce Complexity</h4><ul><li>Platform Engineering: Organisations are increasingly building internal developer platforms (IDPs) to abstract away complexity, enabling developers to focus on application code instead of infrastructure.</li><li>Managed Kubernetes Services: Offerings like Amazon EKS, Google GKE, and Azure AKS offload operational burdens while retaining Kubernetes’ benefits.</li><li>Serverless as an Alternative: For simpler use cases, serverless computing eliminates the need to manage infrastructure altogether, shifting focus entirely to the application logic.</li></ul><h3>Was It Worth It?</h3><h4>The Good:</h4><p>Unprecedented Agility and Innovation: The shift to containerization, orchestration, and cloud-native tools has empowered organisations to:</p><ul><li>Release features faster with CI/CD pipelines.</li><li>Rapidly adapt to changing business needs and customer demands.</li></ul><p>Resilience at Scale: For companies with global reach, like Google, Netflix, and AWS, these technologies ensure:</p><ul><li>High availability with near-zero downtime.</li><li>Robust disaster recovery strategies.</li></ul><p>Developer Empowerment: DevOps practices and tools enable teams to own the entire lifecycle of their applications, fostering collaboration and innovation.</p><p>Standardisation: Kubernetes and its ecosystem have created a common language for infrastructure, reducing fragmentation across platforms and providers.</p><h4>The Bad:</h4><p>Over engineering Risks:</p><ul><li>For smaller applications or simpler workloads, Kubernetes often introduces more complexity than value.</li><li>Organisations may find themselves spending significant time and resources on managing tools rather than delivering features.</li></ul><p>Steep Learning Curve:</p><ul><li>Teams face a daunting task in learning and adopting a sprawling ecosystem of tools (Kubernetes, Helm, Istio, Prometheus, etc.). These days many job adverts for developers are not even considering the candidate for a developer role unless they have knowledge of DevOps or some of the DevOps tools.</li><li>The learning curve can lead to productivity dips and delayed project timelines.</li><li>Operational Overhead: Even with managed services, operational expertise is required to troubleshoot issues, optimise performance, and secure systems effectively.</li></ul><p>Missed Opportunities with Simpler Solutions:</p><ul><li>Many organisations overlook managed services (e.g., Heroku, AWS App Runner, Elastic Beanstalk) or serverless architectures that could achieve similar results with far less effort.</li></ul><h4>A Balanced Perspective:</h4><p><strong>When Complexity Makes Sense:</strong></p><ul><li>If your organisation operates in a multi-cloud or hybrid cloud environment, Kubernetes’ portability can offer significant advantages.</li><li>When running highly distributed systems or microservices architectures with demanding scalability needs, the investment pays off.</li></ul><p><strong>When It Doesn’t:</strong></p><ul><li>Monolithic applications or workloads with predictable traffic often don’t benefit from Kubernetes’ features.</li><li>Startups and smaller teams may be better served by simpler solutions that allow them to focus on product development rather than infrastructure management.</li></ul><h4>Emerging Trends and Alternatives:</h4><ul><li>Platform Engineering: Many organisations are moving towards creating Internal Developer Platforms (IDPs) to abstract away the complexity of Kubernetes and provide developers with self-service tools.</li><li>Serverless and Edge Computing: Serverless platforms like AWS Lambda or edge services like Cloudflare Workers provide scalable solutions without requiring teams to manage infrastructure.</li><li>Kubernetes Alternatives: Lightweight orchestration tools like Nomad or frameworks like Docker Swarm might be a better fit for less complex use cases.</li></ul><h3>Cloud App Services</h3><p>Managed cloud app services like AWS Elastic Beanstalk, Azure App Services, or Google Cloud Run solutions abstract away much of the complexity:</p><ul><li>Automatic scaling.</li><li>Built-in failover.</li><li>Simplified deployments.</li></ul><p>For many use cases, these services hit the sweet spot of simplicity and functionality without needing to manage Kubernetes clusters. These could serve as the ideal solution for smaller teams, or teams that are lacking the skillset to manage and orchestrate entire Kubernetes clusters.</p><h3>What Can We Learn?</h3><h4>Pick the Right Tool for the Job:</h4><ul><li>Understand Your Needs: Before jumping into complex solutions, assess your application’s requirements in terms of traffic, scalability, and deployment frequency.</li><li>Ask Questions: Does your application truly need multi-region resilience? Can managed services or simpler orchestration tools meet your needs?</li><li>Avoid Hype-Driven Decisions: Evaluate tools based on your team’s skillset and the problem you’re solving, not on industry trends.</li></ul><h4>Embrace Managed Kubernetes:</h4><ul><li>Leverage Ecosystem Tools: Pair managed Kubernetes with tools like Helm, ArgoCD, or Crossplane to simplify management.</li><li>Cost Optimisation: Managed services often include features like auto-scaling and resource monitoring, which can help optimize cloud spend.</li><li>Start Small: Begin with a single managed cluster for non-critical workloads to build confidence before scaling up.</li></ul><h4>Push for Developer Experience:</h4><ul><li>Adopt Internal Platforms: Many organisations are building Internal Developer Platforms (IDPs) to abstract complexity, allowing developers to focus on code rather than infrastructure.</li><li>Explore Low-Code and No-Code Tools: Depending on the use case, platforms like OutSystems, Bubble, or Retool can dramatically simplify development and deployment.</li><li>Prioritise Usability: Invest in tools and practices that enhance usability, such as simplified deployment pipelines, integrated monitoring, and clear documentation.</li></ul><h4>Balance Infrastructure with Innovation:</h4><ul><li>Shift Complexity Away from Developers: Consider adopting platform engineering practices to centralise operational complexity within a dedicated team, freeing developers to focus on features and functionality.</li><li>Encourage Experimentation: Create environments where teams can test and deploy using simpler solutions without penalty. Not every project needs the full complexity of Kubernetes or cloud-native stacks.</li></ul><h4>Future-Proof Your Choices:</h4><ul><li>Stay Agile: The tech landscape evolves rapidly. Adopt tools and practices that allow flexibility and adaptability, avoiding rigid vendor lock-in or overly customised implementations.</li><li>Invest in Training: Equip teams with the knowledge to make informed decisions about when and how to adopt new tools. Training reduces the risk of failed implementations and promotes better long-term adoption.</li></ul><h3>Final Thoughts</h3><p>The evolution of development and infrastructure tools has undeniably brought immense value to organisations that require scale, resilience, and innovation. Kubernetes and its ecosystem have enabled industry giants to operate globally with high availability and fault tolerance. However, for many smaller organisations or less complex use cases, the trade-offs in complexity, cost, and operational burden make simpler solutions more practical and equally effective. Whether it was worth it largely depends on the organisation’s scale, use case, and its ability to manage the accompanying complexity.</p><p>The developer world has historically oscillated between simplicity and complexity, and the Kubernetes era represents a period of heavy investment in complexity to meet the demands of large-scale, distributed systems. However, the rise of managed services, serverless offerings, and even the evolving field of platform engineering signals a shift towards more developer-friendly solutions. These tools promise to strike a balance between scalability and ease of use, potentially providing a future where we can achieve the benefits of cloud-native systems without drowning in a sea of orchestration tooling.</p><p>As we look forward, the key lesson is that there is no one-size-fits-all solution. Each organisation must evaluate its needs, its capabilities, and its long-term goals to determine the right mix of tools. Simpler solutions may often be overlooked in the rush to adopt “cutting-edge” technology, but they can provide just as much value — if not more — when aligned with the appropriate context.</p><p>While developers face significant pressure to master the multitude of tools necessary for managing and orchestrating these new deployment and setup strategies, the shift towards complexity is not inherently negative. It mirrors the increasing demands placed on contemporary systems. The true challenge lies in adeptly navigating these evolving landscapes, striking a balance between the need for flexibility and scalability, and maintaining the simplicity and productivity that are the cornerstones of exceptional software development.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7c3d70de8775" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dvt-engineering/the-evolution-of-software-development-7c3d70de8775">The evolution of software development</a> was originally published in <a href="https://medium.com/dvt-engineering">DVT Software Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Traditional VPN vs Tailscale]]></title>
            <link>https://medium.com/dvt-engineering/traditional-vpn-vs-tailscale-fd077d943e1f?source=rss----eb2b463a00e2---4</link>
            <guid isPermaLink="false">https://medium.com/p/fd077d943e1f</guid>
            <category><![CDATA[software-defined-networks]]></category>
            <category><![CDATA[networking]]></category>
            <category><![CDATA[homelab]]></category>
            <category><![CDATA[security]]></category>
            <category><![CDATA[vpn]]></category>
            <dc:creator><![CDATA[Dewald Oosthuizen]]></dc:creator>
            <pubDate>Fri, 22 Nov 2024 07:24:11 GMT</pubDate>
            <atom:updated>2024-11-22T07:24:11.128Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xBik1WBfGuzcnOfQxrR67w.png" /></figure><p>In today’s tech landscape, tech enthusiasts and small businesses alike increasingly value the ability to connect to their networks remotely. Traditionally, this has been achieved using tools like <a href="https://www.wireguard.com/">WireGuard </a>and <a href="https://openvpn.net/">OpenVPN</a>, which — while effective — require a degree of networking knowledge for configuration and ongoing maintenance, as keys or user accounts need to be managed individually. However, software-defined networking has simplified this process considerably, reducing the need for extensive configuration and maintenance.</p><p>In this overview, we’ll examine the benefits of <a href="https://tailscale.com/">Tailscale </a>as a networking solution well-suited for home and enterprise environments. Configuring a home VPN typically requires exposing a port on the router, which can raise security concerns. Tailscale, on the other hand, eliminates this need by establishing secure peer-to-peer connections between devices without requiring open ports. It leverages WireGuard as its encryption protocol, a familiar technology for those experienced with traditional VPNs. Tailscale is also available as a free VPN solution for basic home use.</p><p>Tailscale is a mesh VPN solution that uses the modern WireGuard protocol to secure traffic within a peer-to-peer network. Its design prioritizes ease of use, scalability, and secure device-to-device communication without requiring centralized infrastructure. Unlike traditional VPNs, Tailscale forms directly encrypted connections between devices, avoiding the need for a central VPN server to route traffic. This direct communication reduces latency, enhances speed, and benefits from WireGuard’s lightweight, high-performance encryption.</p><p>Tailscale streamlines network management by integrating single sign-on (SSO) services and identity providers like Google and GitHub for authentication. It automates IP allocation, firewall traversal, and device registration, creating a straightforward set-up experience. With automatic firewall and NAT bypassing, Tailscale enables devices to connect from nearly any network without manual intervention, making it ideal for remote access across distributed teams or dynamic environments. Its fine-grained access control using ACLs enables tailored access to specific devices or users.</p><p>By removing the need for centralized infrastructure, Tailscale also reduces operational costs for small-to-medium businesses, eliminating the expenses associated with dedicated VPN servers and additional resources.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/728/1*UMhjzPzPKyIobtdnofD-7g.png" /><figcaption>Key Comparisons</figcaption></figure><h3><strong>How to get started</strong></h3><p>Getting started with Tailscale is relatively simple. First, you will have to create a <a href="https://login.tailscale.com/">Tailscale account</a>. There are multiple methods for logging in<a href="https://login.tailscale.com/">.</a></p><p>Choose the option that you are most comfortable with.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/689/1*TfINxoWdX3uXvxWeHoBB1Q.png" /><figcaption>Select your login preference</figcaption></figure><p>Once you have an account and are logged in, you will see the admin console. This is where you can manage your Tailscale account, devices, users, etc.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*v0cHHYO4cwD_251yIW-MsA.png" /><figcaption><em>Sensitive info has been blanked out in the above image</em></figcaption></figure><p>Note: Your admin console will look slightly different from the above as the devices linked in the images displayed are already linked. Continue to link your first device.</p><h3>How to link a device to your Tailscale accounts</h3><p>Now that you have a Tailscale account, you can link the devices you want to connect to through your Tailscale VPN. To start, visit the <a href="https://tailscale.com/download">official download page</a> to view all supported devices and platforms. You can also click on the “Add device” button located on your admin console and follow the steps for your type of device.</p><p>To link your device to Tailscale, first identify the type of device you wish to connect and follow the installation instructions specific to that platform. After successfully installing the application, launch it and sign in with the Tailscale account you created. Once logged in, the device will be added to your Tailscale account, and you can connect to your VPN, making it accessible to other devices on your Tailscale network (also referred to as a tailnet).</p><p>In the admin console, you will notice an IP address associated with your device, representing its address within the Tailscale network. If MagicDNS is enabled (typically the default setting), you can access your device by its hostname rather than its IP address. This feature simplifies device management, eliminating the need to remember multiple IP addresses.</p><p>For example, if you have a home server hosting Pi-hole with an internal IP address of 192.168.0.10, you would typically use this address to access it within your local network. However, when you connect this device to Tailscale, it will receive an additional IP address that can be viewed in the admin console. If you attempt to access your Pi-hole using the internal IP address (192.168.0.10) while away from home, the connection will fail, as Tailscale is unaware of this internal address.</p><p>Instead, if you utilise the IP address provided in the admin console or the domain name generated by Tailscale (typically ends with .ts.net), the connection will succeed. To facilitate seamless transitions between being connected to the VPN and being disconnected, you can take advantage of MagicDNS. For instance, if your Pi-hole has a host name of “pi-hole-server,” you can access it using this host name instead of its internal IP address. Thus, when connected to the Tailscale network while away from home, you can still reach the server using the same host name you would at home, eliminating the need to remember its IP address.</p><p>To confirm that MagicDNS is enabled, navigate to the DNS tab within your admin console.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/792/1*ZK1iR0XlDHwup4MEyAcjGA.png" /></figure><p>Once you have clicked on the DNS tab you can scroll down until you see the MagicDNS config.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/735/1*T6yzVobxI6KCXVaqEkUWWg.png" /><figcaption>MagicDNS</figcaption></figure><p>Ensure that this is enabled, and you should be able to access your devices easily via your host name.</p><p>Once the application is installed on the device that you want to link and you have logged in with your account details, you should be able to manage your Tailscale connection from your device:</p><ul><li><strong>On Windows and macOS:</strong> Tailscale is running in the system tray. Click the tray icon to view your connection status and connect or disconnect as needed.</li><li><strong>On Android and iOS:</strong> Open the Tailscale app and use the toggle at the top to control the connection.</li><li><strong>On Linux:</strong> Since there’s no graphical interface, connect by running these commands:</li></ul><p>To connect to the Tailscale network:</p><pre>sudo tailscale up</pre><p>To disconnect, use:</p><pre>sudo tailscale down</pre><p>And to check the current status:</p><pre>tailscale status</pre><p>The device will now be connected to your Tailscale network if connected correctly. You should be able to see your device and its info on your admin console. Each device added to your Tailscale account is managed by a unique key with an expiration date. When a key expires, you must log back into Tailscale on that device to generate a new one. In traditional VPNs, these keys would have been managed by someone. Tailscale manages these keys for you. For critical devices, like servers or home labs, that require uninterrupted access, you may want to disable key expiry.</p><h3>Disabling Key Expiry for Specific Devices</h3><p>To disable key expiry for select devices, follow these steps:</p><ol><li>Open the <em>Machines</em> page in the admin console.</li><li>Locate the row for the device you want to modify.</li><li>Click the ellipsis menu (three dots) on the far right and select <em>Disable Key Expiry</em>.</li></ol><p>Once this setting is applied, the keys for that device will no longer expire, which is particularly useful for remote or critical devices that need continuous access.</p><h3>Expanding Your Secure Network</h3><p>With your secure VPN setup, you can now access all connected devices from anywhere. You may want to explore further features to enhance your network:</p><ul><li><strong>Ad-blocking with Pi-hole:</strong> Connect a <a href="https://pi-hole.net/">Pi-hole</a> device to your Tailscale network and set its IP as your Tailscale DNS. This allows all connected devices to benefit from ad-blocking, even when away from home. You can follow the steps <a href="https://tailscale.com/kb/1114/pi-hole">here</a>.</li><li><strong>Other Advanced Tailscale Features</strong>: Tailscale provides various advanced features, including the ability to designate a device as an exit node, integrate with cloud providers and containers, expose entire subnets, and share devices between other Tailscale accounts that you trust. Please refer to the <a href="https://tailscale.com/kb/1017/install">official Tailscale documentation for further details on these features</a>.</li><li><strong>Home Assistant</strong>:<strong> </strong><a href="https://www.home-assistant.io/">Home Assistant</a> offers a <a href="https://www.home-assistant.io/integrations/tailscale/">Tailscale integration</a>, allowing you to create a dashboard that displays device statuses and network statistics. These insights can be leveraged for additional home automation tasks, enabling seamless management of your smart home devices.</li></ul><h3>Conclusion</h3><p>Setting up a home VPN with Tailscale creates a secure, software-defined network that provides flexibility, scalability, and simplified management in less than a few minutes. This simplicity makes it attractive for personal use, such as a home lab. This setup can be a compelling alternative to traditional VPNs and offers substantial advantages for managing your devices from anywhere. I hope you found this guide informative and helpful in understanding the benefits of software-defined networking with Tailscale.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fd077d943e1f" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dvt-engineering/traditional-vpn-vs-tailscale-fd077d943e1f">Traditional VPN vs Tailscale</a> was originally published in <a href="https://medium.com/dvt-engineering">DVT Software Engineering</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>