<?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[Stories by Ming on Medium]]></title>
        <description><![CDATA[Stories by Ming on Medium]]></description>
        <link>https://medium.com/@lmy?source=rss-7e8adfcc60fb------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*qqpupOjKCjNmP5hcq93liQ.png</url>
            <title>Stories by Ming on Medium</title>
            <link>https://medium.com/@lmy?source=rss-7e8adfcc60fb------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Wed, 14 Oct 2026 19:33:02 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@lmy/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Rolling Dice on a $60 handheld]]></title>
            <link>https://lmy.medium.com/rolling-dice-on-a-60-handheld-08be2780ad64?source=rss-7e8adfcc60fb------2</link>
            <guid isPermaLink="false">https://medium.com/p/08be2780ad64</guid>
            <category><![CDATA[coding]]></category>
            <category><![CDATA[computer-graphics]]></category>
            <category><![CDATA[game-development]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[hacking]]></category>
            <dc:creator><![CDATA[Ming]]></dc:creator>
            <pubDate>Mon, 25 May 2026 00:15:13 GMT</pubDate>
            <atom:updated>2026-05-25T00:15:13.483Z</atom:updated>
            <content:encoded><![CDATA[<p>In the last post, “<a href="https://medium.com/p/6a22c268ff15">Porting Raylib to a $60 GBA clone</a>”, I described how I convinced <a href="https://www.raylib.com/">Raylib</a> to <strong>draw 3D scenes</strong> on a budget handheld — the <a href="https://lomiyoo.com/products/miyoo-mini-flip">Miyoo Mini Flip</a> (MMF) — that has <strong>no GPU</strong> whatsoever. The trick was pairing Raylib’s bare-framebuffer platform (PLATFORM_MEMORY) with <a href="https://github.com/tslmy/tinygl/tree/mmf">TinyGL</a>, a software OpenGL 1.1 renderer that skips the expensive glReadPixels copy step. The result: <strong>25 FPS</strong> on a dual-core ARM Cortex-A7.</p><p>That was the framework; This post is about building <strong>an actual app</strong> on top of it. Where we left off, I teased that the end goal was porting <a href="https://github.com/tslmy/threejs-dice">my THREE.js dice roller</a> to this little device. The web version uses a browser’s GPU, a physics engine written in JavaScript, and environment lighting from an HDR panorama. The MMF has none of those luxuries. The question was: how much of that visual quality could survive the translation to a CPU-only ARM chip?</p><p>More than I expected, it turns out.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/480/1*VoqG-IiDXIqkrv6MOYBTWg.gif" /></figure><h3>Defining the dice: platonic solids in code</h3><p>The first job was describing the dice in a language the renderer understood. The web version had this in JavaScript; I translated it into a C++ header file.</p><p>A die is a <strong>platonic solid</strong>: a shape where every face is the same regular polygon. The d4 is a <em>tetrahedron</em> (4 triangular faces), the d6 a <em>cube</em>, d8 an <em>octahedron</em>, d10 a <em>pentagonal trapezohedron</em>, d12 a <em>dodecahedron</em>, and d20 an <em>icosahedron</em>.</p><p>These “-hedron” words took me right back to the crystallography course I took in undergrad when I was studying nanomaterials. I have to say, it’s considerably more entertaining to encounter icosahedra and dodecahedra in a tabletop games context than in crystal lattice diagrams.</p><pre>static const DiceDef DICE_DEFS[NUM_DICE_TYPES] = {<br>    { &quot;d4&quot;,  4, /* scaleFactor */ 1.20f, /* invertUpside */ true,  ... },<br>    { &quot;d6&quot;,  6, 0.90f, false, ... },<br>    ...<br>    { &quot;d20&quot;, 20, 1.00f, false, ... },<br>};</pre><p>Each DiceDef stores the &quot;canonical&quot; vertex coordinates (typically in a −1 to +1 cube), a scale factor to make all dice look visually proportional, and the faces as ordered lists of vertex indices. The face ordering, called <strong>winding order</strong>, determines which side is the front. Get it wrong and the die looks inside-out.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2ny4FMTyoobnp0wx4Ermfg.png" /></figure><h4>The d4 is weird</h4><p>The d4 has <a href="https://www.dicedragons.co.uk/blogs/dice-advice/how-to-read-a-d4">two common designs</a>: <strong>top-numbered</strong> (you read the apex vertex) and <strong>bottom-numbered</strong> (you read the face resting on the table). The THREE.js version was top-numbered, since it placed digits on the three vertices surrounding each face. That was a natural fit for a high-resolution screen.</p><p>On the MMF’s 750×560 framebuffer, squeezing three numbers onto a tiny triangular face looked terrible. So I switched to a <strong>bottom-numbered design</strong> in this C++ port: you read the face pointing downward, and the die stores a single centered digit per face. The invertUpside = true flag tells the face-detection code to look for the face whose normal points most <em>downward</em>, not upward. More on that when we get to face detection.</p><h3>Making them fall: Bullet3 physics</h3><p>A dice roller without physics is just a random number generator with a pretty skin. I added <a href="https://github.com/bulletphysics/bullet3">Bullet3</a> for the simulation.</p><p>There is actually a <strong>genealogical thread</strong> connecting the THREE.js version to this one. The web dice roller used <a href="https://github.com/pmndrs/cannon-es">cannon.js</a> for physics — and if you follow cannon.js back through its ancestry (like I did in the side note of <a href="https://blog.myli.page/lets-write-an-ai-keeper-for-call-of-cthulhu-part-iii-make-a-3d-scene-b437d00c7e3a">this blog post</a>), it is a JavaScript port of… Bullet. So in porting the dice roller to C++, we are paying tribute to the original ecosystem by returning to the source.</p><p>Bullet3’s job is straightforward: each die is a <strong>rigid body</strong> — an object with mass, position, rotation, and velocity. Every frame the engine advances the simulation by one timestep. It applies gravity, finds where bodies overlap (collision detection), and resolves those overlaps with realistic contact forces (bounce and friction).</p><p>For collision detection, Bullet uses a <strong>convex hull</strong> of each die’s vertices. Because all platonic solids are convex, this is exact — not an approximation. It’s also the fastest possible collision shape Bullet supports[1]. The scene is thus minimal:</p><ul><li>One infinite flat plane (the floor, mass = 0, static)</li><li>Up to 12 convex-hull rigid bodies (the dice, mass = 1)</li></ul><p><strong>Spawning a throw</strong> means placing each die above the table at a random position, giving it a random rotation and some angular velocity, and letting Bullet take over:</p><pre>d.body-&gt;setAngularVelocity(btVector3(RandF(-8,8), RandF(-8,8), RandF(-8,8)));<br>d.body-&gt;setLinearVelocity (btVector3(RandF(-2,2), RandF(-1,1), RandF(-2,2)));<br>world-&gt;addRigidBody(d.body);</pre><p>The floor and dice exist <strong>twice</strong> in the codebase: once as Bullet collision objects, and once as Raylib geometry for rendering. The physics floor is an infinite mathematical plane; the visual floor is a textured quad. They share the same ground height so dice don’t visually float above where they land.</p><h4>Detecting when a die has settled — and reading the result</h4><p>The JavaScript-based threejs-dice library takes an interesting shortcut with outcomes: you can <strong>prescribe</strong> which number each die will show before throwing. The trick is clever: let the physics simulation run to completion in an instant (which is much cheaper than the rendering 3D), observe which face landed up, and <em>then</em> paint the desired number onto that face. The rendered trajectory is real; only the paint job is rigged.</p><p>This C++ port takes a more authentic approach: both physics and rendering happen frame-by-frame, simultaneously. There is no “run ahead” — the simulation and the visuals are always in sync. I did vibe-code a “rig mode”[2], but it works after settling, by physically rotating the die to face the target upward. No post-effect paint job.</p><p>After throwing, the game polls each die every frame for stillness:</p><pre>bool IsDieSettled(const ActiveDie&amp; d) {<br>    btVector3 v = d.body-&gt;getLinearVelocity();<br>    btVector3 w = d.body-&gt;getAngularVelocity();<br>    return v.length() &lt; 0.3f &amp;&amp; w.length() &lt; 0.3f;<br>}</pre><p>A die is “settled” after it has been nearly motionless for 30 consecutive frames (~1 second at 30 FPS). Only then do we read its face-up value.</p><p>Face detection works by examining the die’s current rotation. For each face, we compute its outward normal (a perpendicular vector) and take the <strong>dot product</strong> with the world up-direction (0, 1, 0). The face with the highest dot product is pointing most skyward — that&#39;s the face you rolled. For the d4, invertUpside = true flips the query to (0, −1, 0), finding the face closest to the ground.</p><h3>The rendering pipeline: ten layers, painted back to front</h3><p>With positions coming from Bullet and geometry from the definitions, the renderer’s job is to turn all of that into pixels. The pipeline draws ten layers every frame, strictly in this order:</p><ol><li><strong>Skybox</strong> — the background</li><li><strong>Floor</strong> — the pre-lit hardwood quad</li><li><strong>Reflections</strong> — semi-transparent upside-down copies of the dice</li><li><strong>Shadows</strong> — dark silhouettes projected onto the floor</li><li><strong>Dice faces</strong> — translucent, shaded geometry</li><li><strong>Number decals</strong> — digit textures painted on each visible face</li><li><strong>Edge wireframes</strong> — subtle white outlines</li><li><strong>Scratch overlay</strong> — fine surface scratches lit in real time</li><li><strong>Bloom halos</strong> — specular glow (off by default; explained later)</li><li><strong>Post-processing</strong> — per-pixel bloom boost + depth fog</li></ol><p><strong>Order matters a lot here</strong>. The dice are semi-transparent, so the GPU’s depth buffer can’t sort them — that only works for opaque objects. Instead the renderer sorts all dice back-to-front before drawing (the <a href="https://en.wikipedia.org/wiki/Painter%27s_algorithm">painter’s algorithm</a>) and disables depth writes during the dice pass to prevent Z-fighting with the overlays.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/750/1*-VOZEVXsnqqRMFBZW3dZzg.gif" /></figure><h4>Shadows and reflections</h4><p>Two of the most grounding effects — things that make the dice feel like they exist in a real space — are the projected shadows and floor reflections.</p><p><strong>Projected shadows</strong> work by “flattening” each die onto the floor along the key light direction, like tracing a silhouette cast by a flashlight. For each die, we interpolate a ray passing each vertex to the floor, compute the convex hull of the projected points, and draw it as a dark polygon. The opacity fades as dice hang higher in the air, so that falling dice cast paler shadows than ones resting on the table.</p><p>Edges further away from the floor should, in reality, cast fuzzier shadows. Unfortunately, blurs are too heavy to compute without a GPU. To mimic the effect, we draw a <a href="https://en.wikipedia.org/wiki/Umbra,_penumbra_and_antumbra">penumbra</a>, which is a second, slightly larger and more transparent polygon around the outer edge. It fakes the soft falloff at the shadow boundary. But it looked too fake (since we <strong>can’t do blurring</strong> — more details later), so I turned it off by default.</p><p><strong>Floor reflections</strong> are drawn as an upside-down, y-flipped copy of each die, slightly transparent and blended into the floor surface. That’s not a reflection in the optics sense, not even the “<a href="https://docs.vulkan.org/tutorial/latest/Building_a_Simple_Engine/Advanced_Topics/Planar_Reflections.html">planar reflection</a>” trick you’d find in early video games (where you’d place another camera in the “mirror world”). Instead, it is literally the same polyhedrons drawn with scale.y = -1 and reduced alpha. The cheap trick works because the floor is flat and the camera is never below the surface.</p><h4>Lighting: a studio in a header file</h4><p>Real-world photography and film use a “key + fill” lighting setup. The <strong>key light</strong> is the main, bright source that creates defining shadows and highlights. The <strong>fill light</strong> is softer and comes from the opposite direction, preventing shadows from going pitch black.</p><p>Note that in my older THREE.js implementation, the fill light was implemented as an <a href="https://threejs.org/docs/#AmbientLight">ambient light</a>, which has no direction. In this Raylib implementation, however, both lights are directional. Conceptually, the two lights are infinitely far (like the sun), so we only need one <em>unit</em> vector to describe their direction. Thus, lighting a vertex is a dot product: a surface facing the light is bright; one facing away is dark.</p><p>I chose to make the fill light directional this time because I wanted to try something else with the <strong>environment lighting</strong>. In the JavaScript edition, THREE.js was able to use the brightness channel in a HDR photo for luminance from the skybox. I never studied how THREE.js implemented it, but since TinyGL doesn’t support it anyway, I wanted to try an approximation called <strong>Spherical Harmonics (SH)</strong> this time.</p><p>SH is a mathematical compression of the light coming from all directions[3]. At “level 2” (L2), SH captures:</p><ul><li>(L0) the overall brightness,</li><li>(L1) which direction is brightest, and</li><li>(L2) broad color variation across the environment,</li></ul><p>using just 27 floating-point numbers. At runtime, evaluating the SH for a given surface normal is just 9 multiplications &amp; additions, which is blazing fast. The coefficients are computed from the skybox image at build time using cmgen, so the device never has to process the panorama at runtime.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/422/1*F6-0dOG_J8iRsaGXI55K5g.png" /><figcaption><em>Spherical harmonics lobes. Source: Hellingspaul, </em><a href="https://creativecommons.org/licenses/by-sa/3.0"><em>CC BY-SA 3.0</em></a><em>, via </em><a href="https://commons.wikimedia.org/wiki/File:Spherical_harmonics_lobes.jpg"><em>Wikimedia Commons</em></a></figcaption></figure><p>To be candid, I put SH in for fun more than for visual effects. The “photo booth” skybox I used is too monochromatic to provide much color variation. The ambient contribution is subtly nice but nothing to write home about. I was curious about how the concept worked in a totally different context, as the first time I learned about SH was in a quantum mechanics class back in college. In that context, SH described the <strong>spatial probability distribution</strong> of where an electron is likely to be found in atoms. I can’t say which is more mentally demanding, atomic physics or computer graphics.</p><h4>The glass look: Fresnel and near-invisibility</h4><p>The dice are modeled after glass polyhedra. Real glass is nearly transparent when you look straight at it, but becomes mirror-like at glancing angles. That’s the <a href="https://www.dorian-iten.com/fresnel/">Fresnel effect</a>. It is one of the defining visual signatures of glass and water.</p><p>The Schlick approximation[4] gives a cheap formula: F ≈ F₀ + (1 − F₀)(1 − cos θ)⁵, where θ is the angle between the surface normal and view direction, and F₀ ≈ 0.04 for glass with an index of refraction of ~1.52.</p><p>The base opacity of each die face is set to DICE_ALPHA = 12 — essentially invisible. Fresnel then boosts the opacity at silhouette edges up toward full opacity. The result is a die that looks like a solid object at its edges but nearly disappears when you look at its face head-on.</p><p>This same Fresnel term also drives a rim glow at silhouette edges. A <a href="https://en.wikipedia.org/wiki/Blinn%E2%80%93Phong_reflection_model">Blinn-Phong specular</a> with a tight power-16 exponent creates the shiny hotspot. A clearcoat layer (power-32) adds a secondary, even tighter gloss. With a bit of tessellation[5] on the faces, the combination gives the dice a convincing polished-glass appearance, with zero ray-tracing.</p><h4>The skybox: a cylinder you live inside</h4><p>The web version used a proper spherical panorama for the skybox. OpenGL 1.1 has no concept of a cubemap or a sphere with infinite distance. So the skybox is faked as a large cylinder textured with horizontal strips sliced from the original panorama.</p><p>To avoid visible seams, the panorama is pre-sliced at build time into 8 tiles[6], and each tile is loaded as a separate TinyGL texture at startup. The cylinder uses clamped wrapping at the top and bottom edges to prevent smearing.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IFIidcvgxpmdCPFepFihJg.png" /></figure><p>This is a good example of working <em>with</em> constraints rather than against them. Rather than trying to hack infinite-distance geometry into TinyGL, the fake just needs to be big enough that no die ever leaves its interior.</p><h3>Things we can’t do</h3><p>So far, we’ve been talking about effects that we <em>managed</em> to squeeze into this little handheld, one way or another. Unfortunately, there are things that simply can’t fit into the package.</p><h4>Refraction</h4><p>Real glass <strong>bends light</strong> passing through it. A proper refraction effect would require either rendering the scene twice from different angles, or a screen-space trick where each pixel samples the framebuffer at an offset proportional to the surface normal. Both require reading from the framebuffer at arbitrary positions — something that is expensive on a CPU rendering into a fixed buffer, and that TinyGL’s OpenGL 1.1 interface does not support. Refraction stayed on the cutting room floor[7].</p><p>Refraction is expensive and hard to get right. Even in the THREE.js-based implementation, if you look closely into the banner image of <a href="https://github.com/tslmy/threejs-dice">my repo</a>, you’ll see that refractions are not compounded: Looking through both the D6 and the D4 in front of it appeared as if you are just looking through the D4 itself. It’s a major giveaway in this otherwise photorealistic render.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RJwVEteZA_lpqZFj4tx1wA.png" /><figcaption>The banner image of <a href="https://github.com/tslmy/threejs-dice">my fork of `threejs-dice`</a></figcaption></figure><h4>Material capture (matcap)</h4><p>Initially, I wanted to employ a <em>matcap</em>. It’s a technique that fakes environment reflections using a lookup texture photographed from a sphere. When rendering, you compute the surface normal in camera space, use it as a 2D UV coordinate, and sample the texture. The result would look very convincing and cost almost nothing at runtime.</p><p>The problem: I wanted the camera to be rotatable. A matcap is essentially a photograph taken from one viewpoint; it only looks correct from that exact angle. As soon as the camera moves, the “reflections” don’t follow the environment — the surface looks painted-on. The Fresnel + specular approach is slower but correct for any camera angle.</p><h4>Anything that involves blurring</h4><p>Real shadows rarely have defined boundaries. Reflections on wood plates shouldn’t look as sharp. Highlights would also have a dream-like glow, known as <a href="https://en.wikipedia.org/wiki/Bloom_(shader_effect)">the bloom</a>. All three effects would need some blurring computation to simulate, which is really tough on a CPU.</p><p>To blur a pixel, we need to know <em>what its neighbors look like</em>. Meanwhile, TinyGL’s glPostProcess callback delivers one pixel at a time with no API for reading neighbors. Implementing a blur ourselves would require a <strong>second framebuffer</strong> allocation and bypassing the callback entirely. But a separate framebuffer is exactly the bottleneck we eliminated by replacing rlsw with TinyGL in <a href="https://blog.myli.page/porting-raylib-to-a-60-gba-clone-6a22c268ff15">my previous post</a>. My coding agent <a href="https://gist.github.com/tslmy/08c31b28dbd5461e077f750d03b14322">estimated</a> a 15~50x demand in compute, depending on the quality we want. I would rather forego blurring in favor of a usable frame rate.</p><h3>Going faster: Optimizations</h3><p>At 30 FPS on a 1.5 GHz dual-core Cortex-A7, every cycle counts.</p><h4>SIMD vertex transforms</h4><p>Every frame, each die’s vertices must be transformed from local coordinates to world coordinates using a 4×4 matrix. Instead of processing one vertex at a time, the code processes four simultaneously. This was possible because the CPU has an extension for “<strong>single instruction, multiple data</strong>” (SIMD) operations, named <a href="https://developer.arm.com/Architectures/Neon">ARM NEON</a>. By providing flags like -mfpu=neon-vfpv4 in <a href="https://github.com/tslmy/raylib-on-miyoo-mini-flip/blob/dfe6adedb7911b1b3e138c72ab43baa9db9923eb/docker/build.sh#L53">our build script</a>, we are already telling GCC to optimize floating-point computations with it.</p><p>Beyond that, we can use the <a href="https://developer.arm.com/architectures/instruction-sets/intrinsics/">NEON intrinsics</a> to hand-roll our own optimizations. To do that, we organize the data in a Structure-of-Arrays (SoA) layout by separating contiguous arrays for x, y, and z. With this arrangement, a single vld1q_f32 instruction loads four consecutive x-values at once:</p><pre>struct alignas(16) V3Batch {<br>    float x[MAX_DIE_VERTS];<br>    float y[MAX_DIE_VERTS];<br>    float z[MAX_DIE_VERTS];<br>    int n;<br>};</pre><p>The same SoA layout powers the scratch overlay rasterizer, which bypasses TinyGL entirely. OpenGL 1.1 has no fragment shaders, so TinyGL’s per-face lighting is uniform across a face, and you can’t vary a value per pixel. The fine scratches on a glass surface need <em>per-pixel</em> bump lighting that responds to the die’s current rotation. The solution was <a href="https://github.com/tslmy/raylib-on-miyoo-mini-flip/blob/b3600e5c55d99c1ac602dff02ea730f882024701/src/neon_overlay.cpp#L229">a custom scanline rasterizer</a> that walks the framebuffer pixel-by-pixel, samples a normal map, and computes the tangent-space dot product on the fly using NEON at four pixels per cycle.</p><h4>Build-time asset baking</h4><p>A separate <a href="https://github.com/tslmy/raylib-on-miyoo-mini-flip/blob/b3600e5c55d99c1ac602dff02ea730f882024701/tools/prebake.c">“prebake” tool</a> runs on the x86 build host inside Docker before any ARM code runs. It pre-computes:</p><ol><li>The SH lighting coefficients (emitted as prebaked_sh.inc, a C header included at compile time)</li><li>The floor texture (lit and specularly highlighted per pixel, once, at build time)</li><li>The eight skybox tiles (pre-resized to 256×256)</li></ol><p>This turns boot time from “several seconds of computation” into “load some PNG files”.</p><h4>The number atlas: 21 glyphs in one texture</h4><p>To show numbers on die faces, the renderer draws a small textured quad floating 0.005 units above each face surface. Switching textures per draw call is expensive, so all 21 glyphs (0–20) are packed into a single 256×256 texture atlas at startup. (You might have heard of a special case of this technique: sprite sheet.) When face “17” needs its decal, the code computes the atlas UV:</p><pre>int col = value % 4, row = value / 4;<br>*u0 = (float)(col * 64) / 256.0f;  // left edge<br>*u1 = (float)((col+1) * 64) / 256.0f;  // right edge</pre><p>Multi-digit numbers get special treatment: each character is placed at a fixed spacing based on the widest digit (“8”), so “12” doesn’t look cramped. Each glyph is drawn three times with 1-pixel offsets to fake a bold/shadow effect, since Raylib’s built-in font produces thin strokes.</p><h3>The result</h3><p>The final binary runs at 20 FPS on the MMF with up to 12 dice simultaneously: d4, d6, d8, d10, d12, and d20 in any combination. It includes reflections, shadows, a glass shading model, number decals, surface scratches, a skybox, and a live-tunable settings panel with 18 parameters (IOR, rim strength, specular, clearcoat, shadow softness, and more).</p><p>The codebase, <a href="https://github.com/tslmy/raylib-on-miyoo-mini-flip">tslmy/raylib-on-miyoo-mini-flip</a>, is ~2,500 lines of C++ spread across eight source files. The binary is about 6 MB. Boot time on device is under five seconds. The full build — including Docker, cross-compilation, and asset prebaking — runs in roughly three minutes on a modern laptop.</p><p>This dice roller was vibe-coded, though I try to understand at least what each component does. There are many interesting parts I have to leave out, such as procedurally-generated norm maps for bumps and scratches. It was really impressive that AI agents could pull those off, but they simply didn’t fit the theme of “squeeze as much graphics as possible from a $60 handheld”, so I had to leave them out from this long article.</p><p>I hope you enjoyed learning the art of computer graphics with me. Building a practical utility program for an limited platform gave me great sense of achievement, and I wish you would find it inspiring for your own side projects. Until next time!</p><ol><li>The GJK (Gilbert–Johnson–Keerthi) algorithm computes the distance between two convex shapes in amortized constant time for typical inputs, making it the fastest collision detection Bullet offers.</li><li>Controlled by the RAYLIB_MMF_RIG environment variable in launch.sh. The B button cycles through rigged values.</li><li>SH are a set of mathematical functions defined on a sphere — analogous to Fourier basis functions in 1D. “L2” means we keep terms up to degree 2, giving 9 basis functions. Each captures a different spatial frequency of the lighting environment: L0 is constant (average brightness), L1 varies linearly across the sphere (which direction is brightest), and L2 captures the next level of variation. The full derivation is in <a href="https://cseweb.ucsd.edu/~ravir/6998/papers/envmap.pdf">Ramamoorthi &amp; Hanrahan 2001</a>.</li><li>Christophe Schlick, “An Inexpensive BRDF Model for Physically-based Rendering”, <em>Eurographics</em> 1994.</li><li>Required since we use <a href="https://en.wikipedia.org/wiki/Gouraud_shading">Gouraud shading</a> to avoid per-pixel work. Otherwise, the faces would appear to have their opacities on linear gradients from corner to corner.</li><li>Each is resized to 256×256, which is the original TinyGL’s only texture size. The fork I use seems to have made it <a href="https://github.com/jserv/tinygl/blob/c2e48591a6bfba1a85f1b87a78dcf1abf7dff57a/include/zfeatures.h#L57">configurable</a>, but I have not tried.</li><li>Screen-space refraction is feasible in some OpenGL contexts using glCopyTexSubImage2D to sample the scene behind the surface — but at ~1 ms per texture copy at 750×560, doing it per-die per-frame on a CPU renderer was not going to happen.</li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=08be2780ad64" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Porting Raylib to a $60 GBA clone]]></title>
            <link>https://lmy.medium.com/porting-raylib-to-a-60-gba-clone-6a22c268ff15?source=rss-7e8adfcc60fb------2</link>
            <guid isPermaLink="false">https://medium.com/p/6a22c268ff15</guid>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[gaming]]></category>
            <category><![CDATA[game-development]]></category>
            <dc:creator><![CDATA[Ming]]></dc:creator>
            <pubDate>Sun, 10 May 2026 04:05:32 GMT</pubDate>
            <atom:updated>2026-05-10T04:05:32.469Z</atom:updated>
            <content:encoded><![CDATA[<p>Making new games for old consoles has long been a joy for indie game developers, a practice nicknamed “homebrewing”. This fandom has arguably created a market for <a href="https://retrocatalog.com/retro-handhelds?displayMode=preview"><em>retro gaming handhelds</em></a>: low-power devices designed &amp; manufactured today but meant for emulating consoles from the past. <a href="https://steward-fu.github.io/website/index.htm">Steward Fu’s homepage</a> alone lists 187 such products.</p><p>Game Boy Advance (GBA), a handheld from 25 years ago, is one of homebrewers’ favorite target platform. Earlier this year, I dabbled in this hobby by <a href="https://github.com/tslmy/sips">hacking an idle game</a> created in 2023. As it was perhaps apparent in <a href="https://blog.myli.page/adding-unit-tests-to-a-game-for-the-game-boy-advance-e5e9578ac207">my previous post</a>, that was my <strong>first exposure to game development</strong>.</p><p>One may ask: What if you want to learn to make games, but aren’t feeling too nostalgic? <a href="https://www.raylib.com/"><strong>Raylib</strong></a><strong> is usually the answer</strong>. It’s a cross-platform framework that aims at putting fun into video game development. And it’s popular: <a href="https://itch.io/games/made-with-raylib">on itch.io alone</a>, more than 700 games are made with Raylib.</p><p>Won’t it be exciting to <strong>port Raylib to those budget handhelds</strong>, so that those developers can enjoy their creations on more tangible, dedicated gaming devices — with joy sticks, D-pads, and everything? This shouldn’t be hard, since many of those retro gaming handhelds run some kinds of Linux. To make it more interesting, I decided to raise the bar and aim at <strong>rendering 3D scenes</strong>.</p><h3>First iteration</h3><p>I started with the <a href="https://lomiyoo.com/products/miyoo-mini-flip">Miyoo Mini Flip (MMF)</a> that I bought in March. It presented an exciting challenge[1]:</p><ul><li>The SSD202D SoC (dual-core ARM Cortex-A7) has <strong>no 3D GPU</strong>.</li><li>EGL/GLES libraries are on disk, but eglInitialize failed.</li><li>The <a href="https://www.libsdl.org/">Simple DirectMedia Layer (SDL)</a> library, which I <a href="https://blog.myli.page/sdl-the-bridge-between-native-apps-and-the-web-917d1a80ead4">highly regard</a>, couldn’t create a GL context on MMF.</li><li>The only hint I could find is /dev/fb0, which represents the <strong>framebuffer</strong>[2].</li></ul><p>But besides /dev/fb0, MMF&#39;s Linux gives so little on the graphics stack that, had the OS been absent, it probably wouldn&#39;t make a difference at all.</p><p>It’s funny that, only 4 months before I started this project, Raylib actually <a href="https://github.com/raysan5/raylib/commit/47a8b554bce936596e910aaeecf3d524c83ba97c">started</a> <strong>adding support for OS-less situations</strong>. Referred to as PLATFORM_MEMORY in <a href="https://github.com/raysan5/raylib/blob/c5fc7716229cef1727e7baf325a695a0ac00cf27/src/Makefile#L37-L38">the makefile</a>, this configuration draws directly to the framebuffer. This is almost exactly what I needed. I just needed to <a href="https://github.com/tslmy/raylib/commit/443acc300f948c6f99daf8f4deaed005b5730fdb#diff-347102e6d31df9888d75e091e8719bae10a768ef3201fc8499c8160a9f59cc6dR595-R624">wire it up</a> with /dev/fb0. The plan is thus:</p><ol><li>Render the next frame in the program’s memory space</li><li>Copy that frame into /dev/fb0</li><li>Tell the display to “flip the buffer”[3]</li></ol><p>Coding agent soon gave me a workable <a href="https://github.com/tslmy/raylib-on-miyoo-mini-flip/commit/e1b9aa6e020c9a4085af09c16ae5bdea9b8b11a0">build script</a>. But unfortunately, even when it’s as rudimentary as <a href="https://github.com/tslmy/raylib-on-miyoo-mini-flip/commit/5f6c70a42246a24d063b9c51e10dceef57734aa4">a spinning cube</a>, the scene can barely surpass 7 FPS on my MMP:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/828/1*0lfYn6NupKdmKD4NAGNGqg.png" /></figure><h3>Swapping the software renderer</h3><p>PLATFORM_MEMORY<a href="https://github.com/raysan5/raylib/blob/c5fc7716229cef1727e7baf325a695a0ac00cf27/src/platforms/rcore_memory.c#L491"> requires</a> that we use a software renderer, which by default wires to Raylib’s <a href="https://github.com/raysan5/raylib/blob/c5fc7716229cef1727e7baf325a695a0ac00cf27/src/external/rlsw.h#L3">own </a><a href="https://github.com/raysan5/raylib/blob/c5fc7716229cef1727e7baf325a695a0ac00cf27/src/external/rlsw.h#L3">rlsw</a>. This young library renders into its own internal framebuffer, then you must call glReadPixels (via rlCopyFramebuffer) to transfer it to system memory every frame. I could confirm that <strong>it was the bottleneck</strong>: When I skipped the copying step (so the frames are rendered but never sent to the display), the FPS jumped to 25 FPS immediately.</p><p>Fortunately, rlsw implements <a href="https://registry.khronos.org/OpenGL/specs/gl/glspec11.pdf">OpenGL 1.1</a>. This version was released in 1997, when 3D rendering still heavily relied on CPUs. Even GPUs were, quoting <a href="https://www.reddit.com/r/GraphicsProgramming/comments/ll7pvw/comment/gnnzd5x/?utm_source=share&amp;utm_medium=web3x&amp;utm_name=web3xcss&amp;utm_term=1&amp;utm_content=share_button">Reddit</a>, “often actually an esoteric CPU running a software rasterizer.” Therefore, this legacy edition has long been a popular target for software-based renderers. To us, this means <strong>there are replacements</strong>.</p><p>One of such replacements is <a href="https://bellard.org/TinyGL/">TinyGL</a>, which debuted in 2002. Compared to rlsw, which was <a href="https://github.com/raysan5/raylib/pull/4832">introduced</a> only in 2025, TinyGL had more time to polish. Most prominently, it renders directly into platform.pixels, so there&#39;s no memory copy involved. I decided to <strong>swap </strong><strong>rlsw with TinyGL</strong>.</p><p>To make them cooperate, I had to patch both <a href="https://github.com/tslmy/tinygl/tree/mmf">TinyGL</a> and <a href="https://github.com/tslmy/raylib/tree/mmf">Raylib</a>, in addition to some <a href="https://github.com/tslmy/raylib-on-miyoo-mini-flip/commit/470be77674b3add4aa315b9bffebe9c4c46841bd">build-time wiring &amp; stubbing</a>. Finally, it was time to switch the RAYLIB_GRAPHICS flag -- We gave something to Raylib that is so mature and usable that we could switch it from &quot;use the built-in software rasterizer&quot; (GRAPHICS_API_OPENGL_SOFTWARE) to &quot;use a provided OpenGL 1.1 API&quot; (GRAPHICS_API_OPENGL_11). It felt like a great achievement.</p><p>The result speaks for itself: Frame rate jumped to 25 FPS.</p><h3>What’s next?</h3><p>I plan to write about how I managed to port my dice roller from THREE.js to this little device, which I teased about in <a href="https://blog.myli.page/adding-unit-tests-to-a-game-for-the-game-boy-advance-e5e9578ac207">my previous post</a>. Although we are only half way there, this is a good point to take a pause: Compared to a journey of how a specific app is built, the proof that a whole framework can run on budget gaming consoles will inspire more readers.</p><p>I hope you are one of them. If you have a made something with Raylib and only played it on a computer, try putting it on a cheap handheld. It will feel different in your palms — I promise.</p><ol><li>Snippets of probe programs, their console logs on MMF, and more notes are available <a href="https://github.com/tslmy/raylib-on-miyoo-mini-flip/blob/3e03953eefe12d397fca3f8288537b7df9e95f78/docs/raylib-port-notes.md">here</a>.</li><li>The framebuffer is a region in the memory, but <a href="https://linux.die.net/man/4/fbdev">a Linux kernel driver</a> exposes at /dev/fb0, as if it&#39;s a separate device. This virtual device is often referred to as &quot;FBDEV&quot;.</li><li>A prober program told me that this framebuffer is actually double the size of the actual display (actual 640x480; virtual 640x960). This means we are dealing with a <a href="https://www.lenovo.com/us/en/glossary/double-buffering/">double buffering</a> situation, requiring us to “pan the display” from one buffer to the other upon every frame (using FBIOPAN_DISPLAY).</li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6a22c268ff15" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[SDL: The Bridge Between Native Apps and the Web]]></title>
            <link>https://lmy.medium.com/sdl-the-bridge-between-native-apps-and-the-web-917d1a80ead4?source=rss-7e8adfcc60fb------2</link>
            <guid isPermaLink="false">https://medium.com/p/917d1a80ead4</guid>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[game-development]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[operating-systems]]></category>
            <category><![CDATA[software-development]]></category>
            <dc:creator><![CDATA[Ming]]></dc:creator>
            <pubDate>Tue, 14 Apr 2026 08:50:28 GMT</pubDate>
            <atom:updated>2026-04-14T08:50:28.391Z</atom:updated>
            <content:encoded><![CDATA[<p>Most people think of <a href="https://www.libsdl.org/">Simple DirectMedia Layer (SDL)</a> as a multimedia library for games.</p><p>That’s true — but it’s also underselling it.</p><p>SDL is better understood as a <strong>Platform Abstraction Layer</strong> (PAL) — a role much closer to what a browser runtime plays on the web. Both sit between your code and the platform. Both smooth over fragmentation. Both define what “portable” means in their domain.</p><p>Once you see it that way, a lot of things click:</p><ul><li>why SDL looks the way it does</li><li>why it never became “the web for native apps”</li><li>and why it quietly powers much of today’s WebAssembly ecosystem</li></ul><p>Let’s unpack that.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WfUZ1aeCn1K8q44mp8Pkvg.png" /></figure><h3>What it is</h3><p>SDL abstracts away differences across operating systems in accessing audio, keyboard, mouse, and graphics hardware. Notable “subsystems” include:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wfVHWlLKRxC6W7gz8AGymA.png" /><figcaption>Interestingly, <strong>Networking</strong> is notably absent from the core SDL library. To keep the core “simple”, networking is handled by a separate official extension called <a href="https://www.libsdl.org/projects/old/SDL_net/docs/index.html">SDL_net</a>.</figcaption></figure><h3>A higher layer of abstraction than operating systems</h3><p>While an OS abstracts the specific <strong>hardware</strong> (so you don’t care if it’s a Seagate or Western Digital drive), SDL abstracts the <strong>OS-specific abstractions</strong> themselves.</p><p>It’s also important to recognize where the abstraction “leaks”.</p><p>There are a few things that SDL <strong>doesn’t</strong> cover in Operating Systems’ “personality”.</p><ul><li>You are still compiling against different ABIs. (Building your software with SDL doesn’t magically make your Windows binary executable on a MacBook.)</li><li>If file operations are required, file systems and directory structures can still be a “gotcha”.</li></ul><p>Also, there are <strong>hardware design philosophies</strong> that SDL just can’t completely hide. For example, the Application Life Cycle (init, quit, etc.) works differently on a smartphone. Mobile OSs (<a href="https://developer.apple.com/documentation/uikit/managing-your-app-s-life-cycle">iOS</a>/Android) can “pause” or “kill” your app at any time to save battery. This means:</p><ul><li>In a standard desktop SDL app, you might just loop until a “Quit” event.</li><li>On mobile, you must <a href="https://theorangeduck.com/page/issues-sdl-ios-and-android#:~:text=But%20SDL%20still%20tries%20to,cause%20deadlocks%20or%20other%20issues.">listen for specific events</a> like SDL_APP_WILLENTERBACKGROUND to save your state immediately.</li></ul><h3>Web technology is another PAL</h3><p>In systems engineering, such technologies are called <strong>Platform Abstraction Layers</strong> (PALs)[1]. A more popular PAL is today’s web technology. In many ways, SDL is roughly the “native” equivalent of the Web Browser’s runtime.</p><p>If you look at the architecture, the comparison is almost one-to-one:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lzFqAwpmUsXFR5RPpva-1Q.png" /></figure><p>Since SDL generally abstracts at a lower level than browser engines do, it’s completely possible to <a href="https://discourse.libsdl.org/t/sdl-based-web-browser/15506">stack these two layers</a>. <a href="https://linuxdevices.org/open-source-webkit-based-browser-revd/">Announced in 2008</a>, <a href="https://en.wikipedia.org/wiki/Origyn_Web_Browser">Origyn Web Browser</a> (OWB) did exactly that. <a href="https://discourse.libsdl.org/t/sdl-based-web-browser/15506/2">This comment</a> from an OWB developer back then showed us what role people think SDL serves best in between all these abstraction layers:</p><blockquote>our first goal is to provide a better abstraction layer to webkit to ease webkit<br>portage and our reference implementation is SDL</blockquote><h3>Why SDL didn’t become “The Web”</h3><p>In an alternative universe, SDL would be the “thin client” everyone installed. The reason the Web won the popularity war comes down to three things SDL doesn’t do.</p><p>Most intuitively, web content is much <strong>easier to deliver to end users</strong>. Web apps are JIT-compiled (JavaScript/WASM) and streamed. SDL apps must be compiled specifically for the target CPU architecture (x86, ARM) and downloaded as an executable.</p><p>At the other end of the product cycle, <strong>the Document Model</strong> (DOM/CSS) <strong>speeds up value creation</strong>. When SDL gives you a blank canvas, the Web gives you a high-level layout engine (think: Bootstrap, Flexbox, etc.). In SDL, you have to manually calculate where every “div” goes.</p><p>Combining the “delivery model” and the “document metaphor”, we can see why web browsers must adopt a <strong>tighter security model</strong>:</p><ul><li>When you knowingly install a program, you imply that you approve its effects[2]. You’re committed to it, to some extent.</li><li>But if you are just reading a document (i.e., webpage), you shouldn’t be manipulated by whatever malicious instructions written on it. Reading a flyer doesn’t automatically sign you up for that fitness class.</li></ul><p>Thus, modern browsers are <strong>secure by design</strong>. They run untrusted code from the internet safely, using technologies like sandboxes. On the other hand, SDL apps are native binaries; they have largely unrestricted access to your system.</p><p>In short: easier consumption for end users, faster value creation during development, and better safety measures.</p><h3>The Modern Intersection: WebAssembly (WASM)</h3><p>The irony is that these universes have actually collided. Because SDL is so good at abstracting hardware, it is one of the <strong>primary ways</strong> people port native C/C++ games to the web today.</p><p>The primary mechanism for this porting is <a href="https://emscripten.org/">Emscripten</a> (<a href="https://www.reddit.com/r/C_Programming/comments/xm3zys/porting_sdl2_game_to_the_web_emscripten_tutorial/">how</a>), a compiler toolchain that leverages SDL to bridge the gap between native code and the browser.</p><p>When you see a complex 3D game running in a browser tab, it is almost always an <strong>SDL codebase</strong> that thinks it’s talking to a graphics card, but SDL is actually “translating” those calls into <strong>WebGL and Browser APIs</strong>.</p><p>In that sense, SDL has quietly become the bridge that lets the <strong>native world masquerade as the web</strong>.</p><p>SDL never became “the web for native apps”, but it didn’t need to. Instead, it became the thin layer that makes the native world <em>portable enough</em> — including into the browser itself.</p><p>And that’s a more interesting fate: not replacing the web, but quietly enabling the two worlds to converge.</p><h3>Games: the elephant in the room</h3><p>All of this abstraction talk might sound general-purpose — but in practice, SDL’s center of gravity has always been <strong>gaming</strong>. Some people even <a href="https://www.reddit.com/r/sdl/comments/1ivh8k1/comment/me5ui84">claim that</a> SDL was specifically created to facilitate game development from the start.</p><p><strong>Big names use SDL.</strong> Both <a href="https://dev.epicgames.com/documentation/unreal-engine/updating-unreal-engine-on-linux-to-sdl3">Unreal Engine</a> and <a href="https://docs.unity3d.com/6000.3/Documentation/Manual/embedded-linux-deploy.html#:~:text=On%20the%20Embedded%20Linux%20player,Vulkan%20from%20the%20user%20space.">Unity</a> utilize SDL on Linux. To me, SDL is the unsung hero like <a href="https://xkcd.com/2347/">XKCD 2347</a> depicts, minus the “maintained by just one person” part.</p><p>Interestingly, neither game engine appears to use SDL <strong>on Windows</strong>. Was it mostly informed by a budget concern in development[3], a need for heavy optimization on Windows itself, or just a legacy / tech debt that predates SDL? I don’t yet have an answer.</p><p>Another fact that I wasn’t aware of is Valve’s stewardship in SDL. Gemini says:</p><blockquote>Valve’s <strong>Source Engine</strong> and <strong>Source 2</strong> are arguably the most prominent “heavyweight” users of SDL in the industry. While Unreal and Unity use it for specific platforms, Valve has integrated SDL into its core DNA to drive its push for Linux and the Steam Deck.</blockquote><blockquote>Valve doesn’t just use SDL; they help build it. This allows Valve to <a href="https://www.phoronix.com/news/New-Steam-Controller-SDL">upstream</a> features specifically needed for their hardware, like <strong>Steam Controller</strong> support and <strong>Steam Deck</strong> optimizations. One of the lead developers of SDL, Sam Lantinga, is a Valve employee.</blockquote><h3>This ties back to why I wrote this</h3><p><a href="https://blog.myli.page/adding-unit-tests-to-a-game-for-the-game-boy-advance-e5e9578ac207">I bought a “retro gaming handheld” recently</a>, and it runs Linux, so I wanted to see what I can build for it. Cross-compiling TUI programs like <a href="https://github.com/tslmy/compile-htop-for-miyoo-mini-flip">htop didn&#39;t take me too long</a>, so I challenged myself with something with a GUI. I found two good examples:</p><ul><li><a href="https://github.com/danfragoso/miyoopod">MiyooPod</a>: a MP3 player. In Go.</li><li><a href="https://github.com/Yorisoft/pokedex_miyoo">RetroDex</a>: a <a href="https://simple.wikipedia.org/wiki/Pok%C3%A9dex#:~:text=In%20Pok%C3%A9mon%20Black%20and%20White,quest%20The%20Basics%20of%20Crafting">pokédex</a>. In C++.</li></ul><p>Both use a library called SDL. Had not heard of SDL before, I spent some time orienting myself in its ecosystem, hence this article.</p><h3>The end</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5Z5p7Vu2LvXdJAjPmSsFcQ.png" /><figcaption>Image generated by ChatGPT</figcaption></figure><p>I started this journey just trying to build something for a tiny Linux handheld. What I didn’t expect was to stumble into a layer of the stack that quietly connects desktop apps, game engines, and even the modern web.</p><p>SDL sits in that strange middle ground — low-level enough to feel like native code, but abstract enough to travel across platforms, even into the browser. That tension is exactly what makes it powerful — and occasionally frustrating. I’ll explore both in the next post.</p><ol><li>Or you may say SDL is a type of Cross-Platform Support Middleware (CPSM), but the difference between PALs and CPSMs is another rabbit hole that deserves its own post in the future.</li><li>To some degree, at least; runtime security features like <a href="https://en.wikipedia.org/wiki/User_Account_Control">User Account Control</a> are relatively recent inventions.</li><li>Either “diversity in hardware that can run Linux calls for an additional abstraction layer”, or “Linux gaming wasn’t popular enough to justify more dedicated efforts into its low-level programming”.</li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=917d1a80ead4" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Adding unit tests to a game for the Game Boy Advance]]></title>
            <link>https://lmy.medium.com/adding-unit-tests-to-a-game-for-the-game-boy-advance-e5e9578ac207?source=rss-7e8adfcc60fb------2</link>
            <guid isPermaLink="false">https://medium.com/p/e5e9578ac207</guid>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[coding]]></category>
            <category><![CDATA[software-testing]]></category>
            <category><![CDATA[retro-gaming]]></category>
            <category><![CDATA[game-development]]></category>
            <dc:creator><![CDATA[Ming]]></dc:creator>
            <pubDate>Sun, 12 Apr 2026 20:51:20 GMT</pubDate>
            <atom:updated>2026-04-12T20:51:20.439Z</atom:updated>
            <content:encoded><![CDATA[<h3><strong>Adding unit tests to a game for Game Boy Advance</strong></h3><h4>Or a journal of AI failure modes</h4><p>A <a href="https://hackerdojo.org/">Hacker Dojo</a> meetup showed me how big the hobby “retro video gaming” is. Having completed a few GBA titles (<a href="https://en.wikipedia.org/wiki/Pok%C3%A9mon_Ruby_and_Sapphire"><em>Pokémon Ruby</em></a> and <a href="https://en.wikipedia.org/wiki/The_Urbz:_Sims_in_the_City"><em>The Urbz: Sims in the City</em></a> fans, anyone?) back in elementary school, I bought myself a <a href="https://lomiyoo.com/products/miyoo-mini-flip">Miyoo Mini Flip</a> (MMF) to revisit the good old ages.</p><h3>The dawn of a side project</h3><p>Being a software engineer, I entertained the idea of tinkering with it more than just gaming on it. The first thing I looked into was <strong>homebrew GBA ROMs</strong>. I needed an example to learn from, and the idle game <a href="https://hh.gbdev.io/game/sips"><em>Sips</em></a> was a good fit: It’s written with only <a href="https://github.com/foopod/sips">a few hundred lines of C++ code</a>, and it’s built upon a modern GBA gamedev framework called <a href="https://github.com/GValiente/butano">Butano</a>.</p><p><em>Sips</em> is a <a href="https://en.wikipedia.org/wiki/Cozy_game">cozy game</a> that simulates a coffee shop with customers. They walk into your cafe, line up, buy stuff, and leave. One thing can be improved: Not everyone walking past your storefront will patronize your business. “Passers-by” sounded like a behavior that I can add to the game comfortably.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cESfZLfxtcNIjBtvlYt0OA.png" /><figcaption><em>Photo of my Miyoo Mini Flip running my fork of the cozy game “Sips”</em></figcaption></figure><p>The enter-buy-leave logic was implemented as a <a href="https://en.wikipedia.org/wiki/Finite-state_machine">finite state machine</a> (FSM). My day job, which revolves around <em>stateless</em> web services, didn’t expose me a lot to FSMs, but it did teach me to <strong>run tests before modifying someone else’s code</strong>. So I looked for them. As one may expect from a GameJam product, there wasn’t any. That’s fine; I said to myself, I can add tests <em>for</em> them.</p><p>That’s where the rabbit hole began. You see, I barely had any experience in cross-compilation, and I’m too spoiled by mature, “de facto standard” <strong>testing frameworks</strong> like <a href="https://docs.pytest.org/en/stable/">pytest</a> and <a href="https://junit.org/">JUnit</a>. The platform where I run tests is where I’ll be running production code. But this parity often won’t hold in embedded development: If I wanted to utilize familiar, generic frameworks (like <a href="https://catch2.org/">Catch2</a>) in tests, I should run them on my host machine, leaving the ROM with pure game logic.</p><h3>Adding tests for a GBA ROM</h3><p>But GBA headers (from Butano) won’t run on a MacBook. To reduce the import footprint to only the bare minimum, I had to extract functions from the monolithic main.cpp to helper files (<a href="https://github.com/tslmy/sips/commit/d28df8edea631999d959f0478fdfc7be0c73590b">example</a>). This side effect was satisfying, as I prefer a more modularized codebase.</p><p>However, my experience wasn’t as positive with the <strong>pathfinder algorithm</strong>. Specifically, I wanted to add test to the get_next_step function. Its <a href="https://github.com/tslmy/sips/blob/122e433244c68a42fe51714f736912920c532b5d/src/ti_helpers.cpp#L19">original implementation</a> relied on trigonometric functions like sin and cos. On GBA hardware, Butano provides a method for both of them degrees_sin_and_cos, but it won&#39;t compile on a Mac. So I asked GitHub Copilot what to do.</p><p>The first solution it proposed was to <a href="https://github.com/tslmy/sips/commit/846fa12e3fd1f85c8cb3cf27b61c8baac2bf175a#diff-5a1b1a0783342490a812e5392572b56b717df52c5dbad91a551beb4f88c101aa">stub out Butano math</a>. That added ~100 LoC in stubs just to power ~30 LoC of test cases. I’m sure it <em>would have been</em> the right thing to do, had I planned to add so many tests (think: 500 LoC) that they would amortize away the cognitive cost of these stubs. But that really wasn’t the case; I was just trying to get comfortable with ROM hacks before I move on to other MMF tinkering ideas. This isn’t a serious software engineering project that I intend to maintain for years.</p><p>So I created a new session. I told Copilot that I’d rather use std::sin and ::cos, but GBA doesn&#39;t have &lt;cmath&gt;. This time, <a href="https://github.com/tslmy/sips/commit/dde8730b5323cda25677d19569f4b2a891245b8e#diff-a3d7094ff48c8caa4a380869d0fc6ace00c9e2f7bcd8b1fcc132b2a4e7286936R24">the solution</a> was utterly AI slop: It used a macro that switches to an entirely different implementation for get_next_step during tests. Perhaps the way I mentioned &lt;cmath&gt; was misleading; AI seemed to think that &quot;the user wants to make use of &lt;cmath&gt; at all costs&quot;, or &quot;the goal is to make use of &lt;cmath&gt;&quot;. This behavior is reminiscent of junior developers who would fixate on introducing a specific technology just for the sake of using it. It further makes me think: maybe the training data inherently biases LLMs towards Résumé-Driven Development (RDD) -- &quot;Wow, you know how to use <em>that</em>? Nice. Now reinforce!&quot;</p><p><a href="https://github.com/tslmy/sips/commit/dde8730b5323cda25677d19569f4b2a891245b8e#diff-a3d7094ff48c8caa4a380869d0fc6ace00c9e2f7bcd8b1fcc132b2a4e7286936R24">Use real `butano`; remove stubs. · tslmy/sips@dde8730</a></p><p>This ugly situation wasn’t addressed until I got comfortable with the source code enough to ask the AI “what optimizations can you think of”. This time, it rewrote the get_next_step function with <a href="https://github.com/tslmy/sips/commit/06054149065b855c6bce8a4b329e0f590bad30a8">approximations</a> that avoid trigonometric functions entirely. At last, we can run the same pieces of code across testing and playing now.</p><h3>What came next</h3><p>From there, I was able to add a few realistic behaviors to the NPCs:</p><ul><li>They may simply pass by, not coming in for a drink.</li><li>They may loiter on the street for a short while.</li><li>They avoids running into each other.</li></ul><p>And to visually inspect the FSM, I added a “highlight” mode to the gameplay. When you press L+R together, a customer will be selected, and his/her live state will be printed at the bottom of the screen. Pressing L/R will cycle the cursor around all customer on screen, and another L+R combo brings you back to the “sit back and watch” mode. This gave me a strong <strong>intuition about how FSMs are used on NPCs</strong> in video games, to a point that, if I were to teach a gamedev course, I’ll use this ROM as an apparatus.</p><p>This side project soon has finished serving its purpose. I moved on to look at cross-compiling other stuff for the MMF. To hype you up for my next blog post, I’ve figured out <a href="https://github.com/tslmy/raylib-on-miyoo-mini-flip/tree/bullet">how to compile Raylib + Bullet3</a> for this little handheld device.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/300/1*rSsu2VNNcCnXF04OXqvR3Q.gif" /><figcaption>Demo of my dice roller app running on my Miyoo Mini Flip</figcaption></figure><p>Stay tuned!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e5e9578ac207" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Turning the ladder into a ledger]]></title>
            <link>https://lmy.medium.com/turning-the-ladder-into-a-ledger-bbcd05b17d2c?source=rss-7e8adfcc60fb------2</link>
            <guid isPermaLink="false">https://medium.com/p/bbcd05b17d2c</guid>
            <category><![CDATA[careers]]></category>
            <category><![CDATA[management]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[teamwork]]></category>
            <category><![CDATA[growth]]></category>
            <dc:creator><![CDATA[Ming]]></dc:creator>
            <pubDate>Mon, 12 Jan 2026 17:31:02 GMT</pubDate>
            <atom:updated>2026-01-12T17:31:02.199Z</atom:updated>
            <content:encoded><![CDATA[<h4>How I Use the IC Career Ladder (Now That I’m a Manager)</h4><p>After switching from an IC role in 25Q2, I’m coming close to my first full year as an engineering manager. Right now, an once-in-a-career opportunity presents itself: I have <strong>seen the SWE career ladder from two angles</strong> — as an IC and as a manager — and both can be called <em>recent</em>. It’s high time that I write something down, before time etches one away.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dTj81o4AfVRjrLRwo0B0FQ.jpeg" /><figcaption>Photo by <a href="https://unsplash.com/@kanhaiyasharma?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText">Kanhaiya Sharma</a> on <a href="https://unsplash.com/photos/text-T_l246EK19I?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText">Unsplash</a></figcaption></figure><p>At the company I work for, like many mature tech companies, we have a single “software engineering IC career framework.” It’s one big spreadsheet. Rows of skills. Columns of levels. Carefully worded expectations designed to apply to wildly different teams.</p><p>It’s also vague by necessity.</p><p>Calibrations and promotions, however, are not.</p><p>This post is how I bridge that gap for my team.</p><h3>From a ladder to ledgers</h3><p>Our framework looks roughly like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3uvMEAPyv66hT8VEpgt_Yg.png" /></figure><p>If you’ve never seen one of these, <a href="https://dropbox.github.io/dbx-career-framework/ic5_staff_software_engineer.html">Dropbox publishes a public version</a>. If you have seen one, you already know the problem: everyone reads the <strong>same words</strong> and imagines <strong>different bars</strong>.</p><p>To make this actionable, I ask each of my reports to turn the framework into a <strong>ledger of evidence</strong>. For each engineer, I copy:</p><ul><li>the column for their <strong>current level</strong></li><li>the column for the <strong>next level</strong></li></ul><p>into our 1:1 doc. I add a column after each and ask them to track their work against the expectations.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_lCpjzNPAo_8XFe2hwFABg.png" /></figure><p>Think of this as a long-living <a href="https://jvns.ca/blog/brag-documents/">brag document</a>, but tabulated and explicit.</p><p>Each cell needs <strong>stories in SBI format</strong> (Situation–Behavior–Impact), backed by hyperlinks to artifacts:</p><ul><li>(for technical skills) designs (which, as <a href="https://blog.myli.page/are-entry-level-software-engineers-engineers-89b4c0c02396">I explained</a>, can be design docs or roadmaps), pull requests and reviews thereof,</li><li>(for impact skills) launch announcements, write-ups</li><li>(for cultural skills) talks, slides, recordings</li></ul><p>For <a href="https://www.noidea.dog/glue">glue work</a>, I even ask my team members to consolidate email threads, Slack conversations, and meeting notes to Google docs, so that they don’t get lost due to data retention policies.</p><p>Not because I love paperwork — but because promotions are decided by people who <strong>weren’t there</strong>. By allowing a stranger to <strong>independently verify the claim</strong>, artifacts kill “trust me bro” narratives.</p><h3>How this document is used</h3><p>I tell each of my reports the same message:</p><blockquote><strong>How stories are used:</strong></blockquote><ul><li><strong>Current</strong>-level stories feed directly into end-of-year <em>performance calibration</em>.</li><li><strong>Next</strong>-level stories become raw material for a <em>promotion packet</em>.</li></ul><blockquote><strong>How expectations are evaluated: </strong>In each box,</blockquote><ul><li>putting <strong>one</strong> story is called “show traits” of the corresponding expectation.</li><li>Multiple, <strong>evenly-spaced</strong> stories are required to say “consistently meeting” it.</li></ul><blockquote>Stealing a metaphor from our VP: Dunk a basketball once, you are called lucky; Dunk it repeatedly, you are called MVP. (which is one “M” more than “VP”. Isn’t that nice?)</blockquote><p>Off-line, I review new entries they provide. I highlight what perfectly fits the expectation and should be repeated, and I point out what doesn’t align. With a small team, I can afford to repeat this for everybody at least bi-weekly. This <strong>made calibration prep incremental</strong> instead of a Q4 panic attack.</p><h4>Promotions?</h4><p>For those who are not yet at their <a href="https://www.reddit.com/r/levels_fyi/comments/1pzwqv0/whats_your_companys_terminal_level/?utm_source=share&amp;utm_medium=web3x&amp;utm_name=web3xcss&amp;utm_term=1&amp;utm_content=share_button">terminal levels</a>, I also clarify</p><blockquote><strong>How you get promoted:</strong></blockquote><ol><li>Consistently meeting most <strong>next-level</strong> expectations earns you a <em>promotion packet</em>.</li><li>A promo packet, plus 7 or more <strong>upvotes</strong> from <strong>senior-than-you</strong> colleagues, lands you a <em>trial run</em> in front of the promotion committee.</li><li>In the committee, a good <strong>business justification</strong> (“why do we need another person at this level, and why it has to be this person”) and <strong>sufficient budget</strong> get you promoted.</li></ol><blockquote>Miss any one of these, and the answer is “not yet,” regardless of performance.</blockquote><blockquote><strong>How we work towards it together:</strong></blockquote><ol><li>We <strong>co-author your promo packet</strong> as we go. I happen to be very good at writing.</li><li>We can <strong>ask people for feedback anytime</strong>; don’t wait for promo seasons. Request it via Workday yourself, or let me know if you want me to.</li><li>When the promo season approaches, I write the <strong>business justification</strong>. If you are a staff engineer, you write it first.</li></ol><blockquote>After the committee, we cross our fingers in hope of a sufficient budget.</blockquote><h3>Why artifacts are often misaligned</h3><p>When an artifact doesn’t match an expectation, it’s rarely malicious. It usually comes from two forces:</p><ul><li><strong>Generality</strong>: The framework is vague by design. It <em>has</em> to apply to many teams doing very different things.</li><li><strong>Eagerness</strong>: Most people want to grow. It’s tempting to glorify an otherwise mundane achievement.</li></ul><p>My job is to narrow that gap — by annotating expectations with concrete examples and reviewing entries continuously. If something doesn’t align, we fix it early, not at calibration &amp; promotion time.</p><p>Surprises are a management failure.</p><h3>Closing</h3><p>Career frameworks don’t calibrate or promote people. Evidence does.</p><p>My goal as a manager isn’t to relax or tighten the ladder, but to clarify it. I do so such that no one gets surprised by their rating, their promotion outcome, or what “next level” actually means.</p><p>If this feels mechanical, good! Career design should be (almost boringly) predictable; The work is hard enough already.</p><p>In the next post, I’ll describe how I assign work and create opportunities for my team members to shine, proving themselves capable of each skill the ladder demands.</p><p><strong>Disclaimer &amp; appendix</strong>. This post used ChatGPT for polishing prose, as well as generating the following appendix of <strong>common failure modes</strong>:</p><ul><li><strong>Drive-by staff work</strong>: One impressive moment mistaken for readiness. Fix: traits ≠ consistency.</li><li><strong>Invisible ownership</strong>: Critical systems kept alive with no artifacts. Fix: require durable evidence.</li><li><strong>Manager telepathy</strong>: The bar exists only in someone’s head. Fix: annotate, review, repeat.</li><li><strong>Calibration surprise</strong>: “Not even close,” revealed at EoY. Fix: if it’s a surprise, management failed.</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bbcd05b17d2c" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Treating friends with MoSCoW in my kitchen]]></title>
            <link>https://lmy.medium.com/treating-friends-with-moscow-in-my-kitchen-d9ebc560646a?source=rss-7e8adfcc60fb------2</link>
            <guid isPermaLink="false">https://medium.com/p/d9ebc560646a</guid>
            <category><![CDATA[planning]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[product-management]]></category>
            <category><![CDATA[cooking]]></category>
            <category><![CDATA[food]]></category>
            <dc:creator><![CDATA[Ming]]></dc:creator>
            <pubDate>Thu, 09 Oct 2025 08:37:08 GMT</pubDate>
            <atom:updated>2025-10-09T08:47:12.285Z</atom:updated>
            <content:encoded><![CDATA[<blockquote>“Must, Should, Could, Won’t — and What’s for Lunch” — title suggested by GPT</blockquote><p>In software, we use <a href="https://en.wikipedia.org/wiki/MoSCoW_method">the MoSCoW method</a> to prioritize requirements: must-haves, should-haves, could-haves, and won’t-haves.</p><p>Maybe I’m taking it too far, but I’ve been running my kitchen this way.</p><h3>The Must</h3><p>When I invite friends for lunch, there <em>has</em> to be a “wow” dish — something worth their time and appetite. It might be a hard-to-find local specialty, a nostalgic favorite that transports them back to childhood, or a recipe that’s been hyped in our group chat for weeks.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RiCrwGXApd6rYSXR2SKsCg.jpeg" /></figure><p>It’s the reason they drive 10 miles each way, skip breakfast, and snap a Instagram post.</p><p>This centerpiece is usually rich and meaty. Lately, that’s been <a href="https://www.youtube.com/watch?v=Yif_61VKEYA">lamb spine stew</a> (羊蝎子), pressure-cooked ham hocks (大棒骨), or stir-fried clams (炒花蛤). Most can be cooked the night before, giving me a chance to quietly cancel if things go sideways.</p><p>So I go bold. I order exotic ingredients, try rare recipes, and improvise proportions. Only when the main dish is off the stove do I think about the rest. It’s either the <em>spotlight</em> or the <em>stoplight</em>.</p><h3>The Should</h3><p>There <em>should</em> be one or two veggie-based dishes. Light, clean, and refreshing. They balance out the heavy main and make everyone feel less guilty for indulging. Even the most diet-conscious guest should find it easier going for seconds.</p><p>Here, I play it safe: microwave-steamed <a href="https://thewoksoflife.com/cooked-lettuce-with-oyster-sauce-garlic/">lettuce with oyster sauce</a> (蚝油生菜), or a <a href="https://healthynibblesandbits.com/chinese-smashed-cucumber-salad/">smashed cucumber salad</a> (拍黄瓜). They take under 15 minutes, yet they bring freshness and color to the table.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ucxyIyG__Zs7_ozbIcCGJg.jpeg" /><figcaption>Okra salad</figcaption></figure><p>When the main course is drenched in gravy, guests expect to see greens. If I can’t deliver, no excuse will save me — only a bag of frozen broccoli florets might. That’s why these are <em>should-haves</em>.</p><h3>The Could</h3><p>Asian meals usually need a carb component.</p><p>I’m from China northern, so my carb choice wheaten (<em>skrrrr</em>). If I’ve got time the night before, I’ll knead something from scratch. Flavored flatbreads (椒盐饼, 香河肉饼, 麻酱糖饼, 千层饼, 葱花饼, …) or steamed buns (馒头, 花卷, …) are often a hit.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eHrbLvxDEqHOi6M4Z0lpmw.jpeg" /></figure><p>After the meal, I like sending guests home with a few pieces of my pastry, sealed in a Ziploc bag. (Oh yess… It just <em>has</em> to be a Ziploc, like how mama packed their school lunches back then.) It’s a small, edible souvenir of the day, a reminder — during their future meals — that <em>Ming’s a good cook</em>.</p><p>But I never stress over it. It’s a <em>could-have</em>: delightful, but optional. When time runs short, my trusty rice cooker ensures no one leaves hungry.</p><h3>The Won’t</h3><p>Then there are the <em>won’ts</em>.</p><p>Some friends adore foie gras or fancy pastries, but I’m no trained chef — most delicacies and desserts are out of my reach. Unless I have an unusual amount of time (and perhaps a touch of boredom or a need to flatter), I usually skip their culinary wishlists. I’ll get there one day — just not in this release cycle.</p><p>A side note: I confused myself while writing about the won’ts. My first draft went:</p><blockquote>I avoid serving bottled soda, canned food, or pre-packaged salads. They dilute the cozy, intentional feel of home cooking. A home meal should taste like time and attention, not convenience. Unless a guest explicitly asks for a refrigerated Coke, the only drinks they’ll get are my home-brewed kvass or kombucha.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*edaMMTL1XdLLX6XTVcjyIw.jpeg" /><figcaption>Okay. Gotta admit that I left the paragraph here to show off my kvass.</figcaption></figure><p>But that’s not quite right — in MoSCoW, <em>won’t-haves</em> aren’t <a href="https://creatorscience.com/goals/">anti-goals</a>; they’re features deferred to future versions. The difference matters.</p><h3>Afterthoughts</h3><p>In software, we use MoSCoW to decide what <em>must</em> be built now and what <em>could</em> wait.<br>At home, I use it to remind myself what makes a meal meaningful.</p><p>To rehash the analogy:</p><ul><li><strong>Must</strong>: The reason they come to <em>your</em> dining room instead of a diner.</li><li><strong>Should</strong>: The expected features whose absence would make the l(a)unch a shame.</li><li><strong>Could</strong>: The extra delights that extend the pleasant experience. They may even trigger re-engagement.</li><li><strong>Won’t</strong>: The desired features we’re not ready to deliver just yet.</li></ul><p>Good hosting, like good product management, is about prioritizing — whether you’re feeding a table of friends or shipping a product with limited resources.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d9ebc560646a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Residential Thumbturn Orientation Standard (RTOS-001–2025)]]></title>
            <link>https://lmy.medium.com/residential-thumbturn-orientation-standard-rtos-001-2025-ff799807af1a?source=rss-7e8adfcc60fb------2</link>
            <guid isPermaLink="false">https://medium.com/p/ff799807af1a</guid>
            <category><![CDATA[locks]]></category>
            <category><![CDATA[legal]]></category>
            <category><![CDATA[house]]></category>
            <category><![CDATA[standards]]></category>
            <category><![CDATA[parody]]></category>
            <dc:creator><![CDATA[Ming]]></dc:creator>
            <pubDate>Mon, 22 Sep 2025 03:59:42 GMT</pubDate>
            <atom:updated>2025-09-22T03:59:42.658Z</atom:updated>
            <content:encoded><![CDATA[<h4>Version 1.0 · Issued September 2025 · Public Draft</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ansFp5kEysaURz-M.png" /><figcaption>Generated by Microsoft Copilot</figcaption></figure><h3>§1. Preamble</h3><p>In recognition of the operational ambiguity, domestic confusion, and occasional hallway disputes arising from inconsistent interpretations of thumbturn orientation on residential door locks, this document hereby establishes the Residential Thumbturn Orientation Standard (hereinafter referred to as “RTOS”) for public reference, voluntary adoption, and ceremonial citation.</p><p>This standard is intended to provide a definitive framework for interpreting the rotational position of interior thumbturn mechanisms as they relate to the locked or unlocked status of a residential door. It shall apply to manually operated, non-electronic, single-cylinder deadbolt locks installed on the interior side of residential entry doors.</p><p>The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL<br> NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and<br> “OPTIONAL” in this document are to be interpreted as described in<br> RFC 2119.</p><h3>§2. Definitions</h3><p><strong>2.1 “Thumbturn”</strong> SHALL refer to the rotatable component affixed to the interior side of a door lock, typically operated by hand, and used to extend or retract the locking bolt (also known as the latch or deadbolt).<br><strong>2.2 “Locked Position”</strong> SHALL refer to the state in which the bolt is extended into the strike plate, thereby preventing the door from opening without external key or internal manipulation.<br><strong>2.3 “Unlocked Position”</strong> SHALL refer to the state in which the bolt is retracted, allowing the door to open freely.<br><strong>2.4 “Horizontal Orientation”</strong> SHALL refer to the thumbturn being aligned parallel to the floor plane.<br><strong>2.5 “Vertical Orientation” </strong>SHALL refer to the thumbturn being aligned perpendicular to the floor plane.</p><h3>§3. Operational Standard</h3><p>3.1 When the thumbturn is in the Horizontal Orientation, the lock SHALL be interpreted as being in the Locked Position.<br>3.2 When the thumbturn is in the Vertical Orientation, the lock SHALL be interpreted as being in the Unlocked Position.<br>3.3 Intermediate or diagonal orientations SHALL be considered indeterminate and subject to local interpretation, user intuition, or direct physical verification.<br>3.4 In the absence of visual indicators, users are encouraged to gently test the door before assuming its status.</p><h3>§4. Compliance References</h3><p>4.1 This standard draws conceptual inspiration from, but does not claim formal alignment with, the following regulatory and industry documents:</p><ul><li>International Building Code (IBC) 2021, §1010.1.9.1</li><li>ANSI/BHMA A156.2–2017: Bored and Preassembled Locks and Latches</li><li>California Building Code, Chapter 11B (Accessibility)</li></ul><p>4.2 RTOS SHALL not be construed as a legally binding document, nor SHALL its adoption imply compliance with any jurisdictional building code, insurance requirement, or spiritual doctrine.</p><h3>§5. Scope of Applicability</h3><p>5.1 RTOS applies to residential environments, including but not limited to single-family homes, apartments, condominiums, and any dwelling unit where thumbturn locks are present.<br>5.2 RTOS does not apply to commercial buildings, electronic locking systems, biometric access devices, or doors operated by sentient artificial intelligences.<br>5.3 RTOS may be ceremonially adopted by households seeking clarity, consistency, or a sense of procedural gravitas.</p><h3>§6. Ceremonial Adoption and Household Ratification</h3><p>6.1 Any household, dwelling unit, or semi-organized living arrangement MAY adopt RTOS-001–2025 through one or more of the following mechanisms:</p><ul><li>6.1.1 Verbal Affirmation: A household member SHALL declare, aloud and with conviction, “Horizontal means locked. Vertical means unlocked. So say we all.”</li><li>6.1.2 Printed Proclamation: A physical copy of RTOS MAY be affixed near the relevant door, preferably laminated, framed, or otherwise protected from humidity and passive-aggressive editing.</li><li>6.1.3 Digital Citation: RTOS MAY be linked, bookmarked, or referenced in household group chats, shared drives, or smart home dashboards.</li><li>6.1.4 Annual Reaffirmation: Households MAY choose to reaffirm RTOS during a designated Lock Awareness Day, ideally coinciding with daylight saving time or other calendrical rituals.</li><li>6.2 Adoption of RTOS SHALL NOT require notarization, legal counsel, or HOA approval, though such embellishments MAY be pursued for dramatic effect.</li><li>6.3 In multi-occupant dwellings, unanimous consent is recommended but not required. Dissenting parties MAY submit formal objections via sticky note.</li></ul><h3>§7. Disclaimer</h3><p>This document is provided “as-is” without warranty of any kind, express or implied. The authors disclaim all liability for lockouts, break-ins, marital disputes, or existential crises resulting from misinterpretation of thumbturn orientation. Users are advised to exercise common sense, caution, and door-testing behavior.</p><p>This document is drafted with assistance from artificial intelligence (AI) technology, as the author is not eloquent in legalese themselves.</p><h3>§8. Revision and Citation</h3><p>RTOS-001–2025 is a public draft and may be revised, forked, or parodied at any time. Citation is encouraged, especially in hallway debates, family group chats, or passive-aggressive signage.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ff799807af1a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Are entry-level software engineers “engineers”?]]></title>
            <link>https://lmy.medium.com/are-entry-level-software-engineers-engineers-89b4c0c02396?source=rss-7e8adfcc60fb------2</link>
            <guid isPermaLink="false">https://medium.com/p/89b4c0c02396</guid>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[careers]]></category>
            <category><![CDATA[jobs]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[human-resources]]></category>
            <dc:creator><![CDATA[Ming]]></dc:creator>
            <pubDate>Mon, 21 Jul 2025 17:11:48 GMT</pubDate>
            <atom:updated>2025-07-21T17:11:48.480Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>“To what do I owe this honor — having the word ‘engineer’ in my job title?”</em></blockquote><p>That question humbled me when I landed my first job out of college.</p><p>How dare we call ourselves “engineers”? To my ears, the term carries a weighty sense of responsibility. When a <strong>civil engineer</strong> makes a mistake in production, bridges collapse, buildings fall, and lives may be lost. In contrast, a `NullPointerException` on a landing page? Nobody even notices.</p><p>My expectation for all (software|civil|electricity) engineers — in contrast to (programmers|construction workers|electricians) — is to <strong>design</strong>.</p><p>Design, as I see it, comes in two forms:</p><ul><li><strong>Architecting</strong>. This reflects your understanding of <em>what</em> we want. The key activity is <strong>making choices</strong> — weighing trade-offs, comparing options, and shaping systems with intention. Your skill here is shown through artifacts like design docs (for us the software engineers) and blueprints (for the civil type).</li><li><strong>Planning</strong>. This reflects your understanding of <em>how</em> to get there. The key activity is <strong>breaking down objectives</strong> — goals into milestones, milestones into tasks. Your skill here is reflected in roadmaps.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dx6IpW7M2ai-ggWhMxtTeg.png" /></figure><p>Entry-level software engineers are not expected to do either. At least I wasn’t back then, and won’t for my team today.</p><p>Your definition may differ, but here are my expectations:</p><ol><li>An <strong>entry-level</strong> software engineer can implement with assistance.</li><li>A <strong>less junior</strong> software engineer can implement something from start to finish on their own. They start <em>contributing</em> to designs.</li><li>A <strong>terminal-level</strong> engineer both implements and designs on their own.</li><li>A <strong>senior</strong> engineer codes less but designs more. They also sway designs from teammates.</li><li>A <strong>staff</strong> engineer codes even less. They steer designs <strong>across</strong> teams.</li><li>A <strong>principal</strong> engineer doesn’t code or write designs themselves. They oversee designs.</li></ol><p>For the first five years of my career, I wanted the industry to be more honest with job titles. <strong>I thought we should rename</strong> “entry-level software engineers” to <em>software developers</em> or <em>programmers</em>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4CEgKAP7KBE6tWrz_lnMSg.png" /><figcaption>Speaking of mismatches in the terminology of job titles, there are companies where you start off as a “member of technical staff”, a term that is reserved for senior engineers at some other companies. Source: levels.fyi.</figcaption></figure><p>But last year, with the rise of AI coding tools, my thinking shifted. Echoing Steve Yegge’s controversial essay <a href="https://sourcegraph.com/blog/the-death-of-the-junior-developer"><em>The Death of the Junior Developer</em></a>, a troubling idea emerged: companies may <strong>stop hiring for entry-level roles</strong>.</p><p>And that has consequences for both sides of the job market:</p><ul><li><strong>New grads</strong> will have a harder time finding a job — at a time when it’s <a href="https://technical.ly/professional-development/why-is-it-so-hard-to-find-entry-level-software-engineering-jobs/">already tough</a>. With the entry door shutting off, there are less opportunities to grow into proper engineers.</li><li><strong>Companies</strong> are headed for a cliff: if no one hires junior engineers today, there will be <strong>no senior engineers</strong> to run the business tomorrow.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LMKJ531kHLKumOFxUrPe7A.png" /><figcaption>Photo by <a href="https://unsplash.com/@hans_isaacson?utm_content=creditCopyText&amp;utm_medium=referral&amp;utm_source=unsplash">Hans Isaacson</a> on <a href="https://unsplash.com/photos/a-neon-sign-that-says-vacancy-on-it-cHzJFI-sOhU?utm_content=creditCopyText&amp;utm_medium=referral&amp;utm_source=unsplash">Unsplash</a></figcaption></figure><p>That’s when I began to appreciate the hidden value in keeping the “engineer” title for junior roles.</p><p>How <strong>static</strong> was my view in how job titles were designed! The word is there not because candidates must already be engineers in the purest sense, but because the role invites them to become one. That’s why it’s also called “<strong>entry</strong>-level” — they’re shown the door in, given a seat at the table. They observe, learn, and grow. And that growth is intentional. The company isn’t just hiring them to write code — they’re investing in someone who might, someday, design.</p><p>Contrast this with how many <strong>outsourced programmers</strong> are treated.</p><p>They’re usually handed highly detailed specs written by a full-time employee — someone who <em>does</em> qualify for the “engineer” title in its classical sense. They’re expected to implement the software, sometimes with handholding, sometimes completely independently. But they’re rarely invited to design meetings. They’re rarely offered training or mentorship. They’re hired to deliver output, not to evolve.</p><p>This is the key distinction I’ve come to believe in:</p><p>“Programmer” and “entry-level engineer” may do similar work, but they are not the same role. The difference lies in the <strong>employer’s expectation — and commitment — to help them grow</strong>.</p><p>So please, hiring managers: <strong>keep your junior engineer postings</strong>.</p><p>Call them engineers — not because they already are, but because you believe they can be. Because you’re willing to grow them into someone who will one day take on responsibility, make hard choices, and design systems that last.</p><p><em>Further reading: Doug Turnbull’s </em><a href="https://softwaredoug.com/blog/2024/09/07/your-team-needs-juniors"><em>Your Company Needs Junior Devs</em></a><em> expands on why investing in juniors benefits more than just your future talent pipeline. It also drives innovation, diversity of thought, and organizational resilience.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=89b4c0c02396" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Software testing is like clinical trials]]></title>
            <link>https://lmy.medium.com/software-testing-is-like-clinical-trials-bdd4ac7e0dea?source=rss-7e8adfcc60fb------2</link>
            <guid isPermaLink="false">https://medium.com/p/bdd4ac7e0dea</guid>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[pharmaceutical]]></category>
            <category><![CDATA[testing]]></category>
            <category><![CDATA[medicine]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Ming]]></dc:creator>
            <pubDate>Sat, 19 Jul 2025 07:27:24 GMT</pubDate>
            <atom:updated>2025-07-19T07:27:24.497Z</atom:updated>
            <content:encoded><![CDATA[<p>In both software engineering and medicine, the journey from concept to reality unfolds through a series of deliberate, often painstaking, steps.</p><p>In pharmaceuticals, this path is well-charted — beginning with isolated experiments in glass tubes and culminating in trials among living, breathing humans. Each stage offers a different lens on truth, balancing control and complexity, certainty and ambiguity.</p><p>Software development, while less bound by regulation, follows a similar arc: from simple unit tests to vast, live user experiments. Understanding this parallel invites us to reconsider how our creations interact with a complex world.</p><h3>Stages</h3><p>We can draw the following analogies:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uzVrn_c0XcC2w4D3kRzx4w.png" /></figure><p>Let’s now break that comparison down stage-by-stage.</p><h4>Unit Testing → In Vitro (in petri dishes)</h4><p>The drug is first examined in the quiet isolation of a petri dish. Here, the world is distilled to the barest essentials — a molecule, a receptor, a reaction. Truth is found in controlled conditions, far removed from the noisy complexity of life.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*KpmlOrmBwt74_0gdBKs1Xg.png" /><figcaption>Photo by <a href="https://unsplash.com/@drew_hays?utm_content=creditCopyText&amp;utm_medium=referral&amp;utm_source=unsplash">Drew Hays</a> on <a href="https://unsplash.com/photos/person-holding-round-clear-container-tGYrlchfObE?utm_content=creditCopyText&amp;utm_medium=referral&amp;utm_source=unsplash">Unsplash</a></figcaption></figure><p>Likewise, unit tests verify the smallest units of code. These are the foundations of trust, where behavior is deterministic and outcomes are certain. But this certainty comes at a cost: such tests tell us little about how parts fit together or respond to real users.</p><h4>Integration Testing → In Situ (e.g., in cut-off tissues)</h4><p>Step by step, complexity grows. <em>In situ</em> (“in its original place”) tests observe molecules within their native tissue environment — a place of relationships and constraints. The context matters, though the organism is not yet whole.</p><p>In software, integration tests reveal how components cooperate. They catch the frictions and misalignments invisible in isolation, yet still in a semi-controlled setting.</p><h4>Dogfooding → Animal Testing</h4><p>From this point on, we go <em>in vivo</em> (“in the living”).</p><p>The transition to living systems — animals or cell cultures — brings new challenges. The whole is greater than the sum of its parts; individual characteristics disturb results unpredictably.</p><p>Dogfooding invites employees to live with the product. Just like how lab rats aren’t the species that drugs are usually designed for (namely, humans), your colleagues are often not the targeted end users of the feature under test. In terms of improving fidelity, we still have a long way to go.</p><h4>Alpha Testing → Phase I Trials</h4><p>The first humans touch the drug, cautiously. Phase I is a study in restraint and observation — small groups, intense monitoring, uncertainty measured carefully. EKG electrodes are wired on. Breathing masks are attached.</p><p>A whole medical team stands by, in case of mishaps.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SB-q_B2jghn9_X8ITiOygw.png" /><figcaption>Photo by <a href="https://unsplash.com/@judy_beth_morris_idaho?utm_content=creditCopyText&amp;utm_medium=referral&amp;utm_source=unsplash">Judy Beth Morris</a> on <a href="https://unsplash.com/photos/a-hospital-room-with-a-monitor-on-a-tripod-p-uJyWmr6BE?utm_content=creditCopyText&amp;utm_medium=referral&amp;utm_source=unsplash">Unsplash</a></figcaption></figure><p>Alpha testing mirrors this. A trusted circle — often loyal users, by invitation — pokes around the software. Eyeballs are tracked. Screens are recorded. Pointer movements are stored.</p><p>Bugs here have a controlled blast radius.</p><h4>Beta Testing → Phase II Trials</h4><p>The lens widens. Efficacy and side effects begin to reveal themselves when the drug is tested in a broader population.</p><p>Beta testing exposes the software to end users who (often spontaneously) opted in. Feedback may be uncomfortable but is indispensable. This is where assumptions meet experience.</p><h4>A/B Testing → Phase III, Randomized Controlled Trials</h4><p>Randomized Controlled Trials are the pinnacle of scientific rigor in drug development — isolating cause and effect amid complexity.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mlfCl4LZDCCPufb2o1gfjw.png" /><figcaption>Photo by <a href="https://unsplash.com/@towfiqu999999?utm_content=creditCopyText&amp;utm_medium=referral&amp;utm_source=unsplash">Towfiqu barbhuiya</a> on <a href="https://unsplash.com/photos/orange-and-white-medication-pill-w8p9cQDLX7I?utm_content=creditCopyText&amp;utm_medium=referral&amp;utm_source=unsplash">Unsplash</a></figcaption></figure><p>A/B testing carries this torch in software. Carefully controlled experiments tease out what truly moves the needle, separating intuition from evidence.</p><h4>Public Launch → Phase IV, Post-Marketing Surveillance</h4><p>Approval granted, the drug enters the world, its story far from over. Post-market surveillance seeks the faintest signals of trouble, the rare side effects that eluded trials.</p><p>Software launch is no different. Monitoring, observability, and iteration are lifelong companions. The work does not end; it merely begins anew.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jYKyp0kOCtsWfdpo2MKyzQ.png" /><figcaption>A summary of the stages we’ve walked through</figcaption></figure><h3>Environments</h3><p>In software engineering, environments such as staging, pre-production, and production represent carefully graduated spaces of exposure — each step closer to the real world, each carrying greater risk and complexity.</p><p>These environments map naturally onto the biological testing continuum:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*p0xVNtiytys6YsUJoVmfeg.png" /></figure><p>Far from mere formality, these environments represent a philosophical acknowledgment: we cannot skip the journey from known to unknown. They are the quiet guardians of quality, preparing us for the unpredictable realities of production.</p><h3>Final thoughts</h3><p>We seek control, yet confront chaos. We demand certainty, yet accept provisional truths. Each test, each trial, is a measured step into the unknown.</p><p>Recognizing this can transform how we approach testing. It is not a hurdle, but a discipline. Not a burden, but an inquiry.</p><p>The world is never as simple as our tests pretend. Neither code nor cure emerges fully formed.</p><p>In embracing the layered, iterative nature of validation — from in vitro simplicity to in vivo complexity — we cultivate not only better products, but wiser practitioners.</p><p>May this reflection serve as a quiet invitation: to test with intention, to release with respect, and to build with humility.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bdd4ac7e0dea" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Coffee: An essay]]></title>
            <link>https://lmy.medium.com/coffee-an-essay-f7f3853e9f9a?source=rss-7e8adfcc60fb------2</link>
            <guid isPermaLink="false">https://medium.com/p/f7f3853e9f9a</guid>
            <category><![CDATA[stories]]></category>
            <category><![CDATA[memories]]></category>
            <category><![CDATA[memoir]]></category>
            <category><![CDATA[essay]]></category>
            <category><![CDATA[coffee]]></category>
            <dc:creator><![CDATA[Ming]]></dc:creator>
            <pubDate>Sun, 04 May 2025 01:39:38 GMT</pubDate>
            <atom:updated>2025-05-04T01:45:31.915Z</atom:updated>
            <content:encoded><![CDATA[<h3>Coffee: an essay</h3><p>My high school ran 15 hours each day. Getting up at 6:30 and to bed at 11PM meant constant sleep deprivation for the teenage me. What kept me going was instant coffee.</p><p>Class breaks weren’t enough for a proper brew. Not even <em>a</em> brew, to be exact. I used to chew instant coffee right from the stick-shaped package. It was one of those snacks that you wouldn’t be socially pressured to share.</p><p>As stupid as any 15-year-old can be, I once tried to sniff it into my nostrils with a paper-rolled straw, as if I was a drug dealer in a crime film. That one choke kept me off the streets forever.</p><p>Later in college, I moved to Waterloo, Canada. Before I could move in to the student housing, I had to stay in a BnB for two nights. My first morning in Canada started at 4AM, thanks to jet lag. Although the host had invited me only to breakfast by 7AM, I got bored and decided to wait in the dining room. Sitting on the table was a jar of brownish liquid.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*28IkVEkFULeXvFZzba-Ppg.png" /><figcaption>Image generated by ChatGPT</figcaption></figure><p>“I could use a cup of coffee today,” I told myself.</p><p>So I poured myself a mug. It was suspiciously viscous, but I blamed it on the northern temperature.</p><p>The first mouthful was terribly sweet.</p><p>You see, any normal foreigner would have stopped and questioned the liquid right at that point. I was different; I’ve seen this, or so I thought. On my flight from Beijing to Toronto, I had read the brochure about Canadian specialities.</p><p>“This must be the famous ‘double-double’ coffee,” the fresh-off-the-boat me thought, continuing with the sips.</p><p>It was half an hour — and half a mug — later when the host showed up and greeted me with two questions,</p><p>“How was your sleep, and why are you drink maple syrup?”</p><p>I should’ve read further.</p><p>It impressed me that the 1k-acre campus sported 5 Tim Hortons shops. Joining other culinarily-challenged undergrads there, I solved my breakfast needs at a Tim Hortons every morning. The diet always consisted of a farmer’s wrap or a blueberry muffin, with a medium double-double.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dasbsIOyVg_DPfufFCJ_6g.png" /></figure><p>Thus, it shouldn’t surprise you that I consider myself addicted to this particular coffee brand. It was so well-known that, when my friends from <a href="https://hellamaid.ca/blog/why-is-toronto-called-the-six-the-story-behind-its-nickname/">the Six</a> visited me in California back in 2023, they brought me half a gallon of ground coffee:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_2XWur8lkLynfgmtoDKAVw.png" /></figure><p>Despite being a software developer, I once had to read <a href="https://www.cambridge.org/highereducation/books/an-introduction-to-law/61C097DF4185E09FC7EB712F0048EC07?utm_campaign=shareaholic&amp;utm_medium=copy_link&amp;utm_source=bookmark">a law school textbook</a> from cover to cover. A friend in UK made me do it.</p><p>At that time, he had just become a barrister (similar to litigators in the states). I misheard him and thought he said <em>barista</em>.</p><p>“Starbucks or Costa?” I asked.</p><p>He blinked. “I don’t handle corporate cases.”</p><p>Naturally, I took that to mean, <em>I don’t work at chain stores</em>. So I followed up with, “Oh, where do you work then?”</p><p>“Chambers at [Generic &amp; Sons LLP].”</p><p>“Nice. Lawyers basically live on caffeine, amirite?”</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0KmCzKNgvf_Xn0LAiPPhMg.png" /><figcaption>Image generated via ChatGPT</figcaption></figure><p>Taking that as a strange turn in topic, he paused, then said, “I don’t actually like coffee.”</p><p>This broke me. “Then why become a barista?”</p><p>My friend, who must thought that I was philosophizing about the legal profession, responded seriously: “Many barristers don’t drink coffee.”</p><p>I nodded thoughtfully. “Ah, like how some bartenders go teetotal.”</p><p>The air went still. The silence was so sharp, you could get paper cuts off it.</p><p>Then he asked, cautiously, “What do you think <em>barrister</em> means?”</p><p>“…Someone who makes coffee?”</p><p>He closed his eyes in agony. “That’s <em>barista</em>. I’m a <em>barrister</em>. A lawyer.”</p><p>Cue the longest pause in the history of our friendship.</p><p>“Sooooo… wanna learn how to pour a heart?”</p><p>If there’s one entry that I’d wish <a href="https://www.gutenberg.org/files/972/972-h/972-h.htm"><em>the Devil’s Dictionary</em></a> had included, it would be this:</p><blockquote>Programmer (noun): A machine that turns coffee into code.</blockquote><p>Indeed, I can’t start my day without a cup of Joe. Over the past 5 years, I have brewed 16 kg of coffee at home alone. That is excluding brews I had at work and coffee shop adventures. Over time, I’ve tried so many types of coffee beans that I have started an album of their packaging:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*36DCP-VbMWcHjMG2aYwElA.png" /><figcaption>If my math serves, adding the weights printed on those packages up would total to just shy of 16 kg.</figcaption></figure><p>During those stay-at-home years, I came to learn about and admire digital nomads; a certain <a href="https://xn--gckvb8fzb.com/">Marius</a> in particular, who <a href="https://xn--gckvb8fzb.com/coffee/">keeps record of all the coffee beans they’d tried around the world</a>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hKOr22ZMvSX8YWtfBWNRHg.png" /></figure><p>That clicked with me. I want to keep a table like that. I wanted to do it so badly that I paid for Notion just to try out their “publish a database to the web” offering. But embarrassment strapped me:</p><ul><li><strong>I’m a lousy barista</strong>, paying no attention to the technique, timing, or temperature at all. I guess I got it from my Asian lineage: In the kitchen, we always measure “by the feel” (<a href="https://www.chinese-forums.com/forums/topic/57154-chinese-recipes-specific-quantities/">c.f.</a>). I can never be like <a href="https://www.jameshoffmann.co.uk/">James Hoffmann</a>.</li><li><strong>I can’t taste the premium</strong>. My tongue tells no difference between artisan roasters and Starbucks. As my purchase history revails, I generally grab whatever grabs my eyes from the shelf of my local corner store.</li><li><strong>I can’t smell the aroma</strong>. Maybe that sniff back in high school really did something to me.</li></ul><p><strong>So I never did. </strong>I didn’t even dare to write a blog post about coffee.</p><p>That is until I read the essay, <a href="https://www.seriouseats.com/the-case-for-bad-coffee"><em>The Case for Bad Coffee</em></a>, by <a href="https://www.seriouseats.com/keith-pandolfi-5118800">Keith Pandolfi</a>. The gist: it’s not about coffee, but rather the occasion when you had it.</p><p>To Keith, the best coffee was the sip his father left at the breakfast table when he was 9 years old. The best coffee was from a foam cup when he saw his father becoming sober at 14. The best coffee was with his high school friends before applying for universities and without them after getting accepted to a different college.</p><p><strong>I’m getting it. </strong>Perhaps my best coffee was a Nescafé 3-in-1 I chucked down my throat back in high school. Or it may be a CA$2 Tim Hortons that I <a href="https://www.timhortons.ca/rollup">rolled up and won</a> nothing. Then it may be actually a mug of syrup. Heck, it might not even <em>been here </em>just yet; someday, a certain English lawyer may treat me to a hand-made latte.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f7f3853e9f9a" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>