<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator>
  <link href="https://nuo27.github.io/feed.xml" rel="self" type="application/atom+xml" />
  <link href="https://nuo27.github.io/" rel="alternate" type="text/html" />
  <updated>2026-07-29T21:11:54+00:00</updated>
  <id>https://nuo27.github.io/feed.xml</id>
  <title type="html">Nuo</title>
  <subtitle>Gameplay programmer specializing in multiplayer systems across Unity (C#) and Unreal Engine 5 (C++). UTS Game Development graduate, Sydney.</subtitle>
  <author><name>Nuo Chen</name></author>

  
  
  <entry>
    <title type="html">What Leading a Game Team Actually Looks Like</title>
    <link href="https://nuo27.github.io/articles/leading-a-game-team/" rel="alternate" type="text/html" />
    <published>2026-07-29T21:11:54+00:00</published>
    <updated>2026-07-29T21:11:54+00:00</updated>
    <id>https://nuo27.github.io/articles/leading-a-game-team/</id>
    <content type="html" xml:base="https://nuo27.github.io/articles/leading-a-game-team/"><![CDATA[<p>On paper, being “team leader and lead programmer” sounds glamorous. In practice, it means spending half your time in standup meetings and the other half writing code at 2 AM because someone’s level script broke the build.</p>

<p>Here’s what I learned leading <strong>The Tragedy of Pondiberry Lodge</strong> - a 3D exploration detective game built in Unity with a 5-person team.</p>

<h2 id="you-are-not-the-best-programmer-in-the-room">You Are Not the Best Programmer in the Room</h2>

<p>…and that’s fine. My job wasn’t to write the most code - it was to make sure the code we shipped worked together. When you’re the only programmer, you become the integration layer. Four teammates each build a level, and your job is to make sure the interaction system, UI framework, and progression logic hold up across all of them.</p>

<p><strong>Lesson:</strong> Architecture decisions made in week 1 determine whether week 8 is smooth or hellish. Design your interfaces early.</p>

<h2 id="scope-will-kill-you">Scope Will Kill You</h2>

<p>We planned 5 levels - a tutorial plus 4 themed levels. Each team member owned one level. Sounds clean, right?</p>

<p>The problem: “each member does one level” means <strong>5 people need to understand the gameplay framework</strong>. Documentation isn’t optional. I wrote integration guides, code conventions, and scene setup walkthroughs. Not because I love writing docs - because without them, I’d be debugging 5 different interpretations of “how the door system works.”</p>

<p><strong>Lesson:</strong> When you’re the systems person, your most valuable output isn’t code. It’s clarity.</p>

<h2 id="the-work-nobody-sees">The Work Nobody Sees</h2>

<p>A surprising amount of leadership is invisible:</p>

<ul>
  <li><strong>Merging</strong> - Resolving conflicts when two people edit the same prefab</li>
  <li><strong>Playtesting</strong> - Finding the bug where Level 3’s key doesn’t spawn because Level 1’s script ran first</li>
  <li><strong>Translation</strong> - Explaining a programmer’s constraint to a designer, and a designer’s vision to a programmer (even if that programmer is also you)</li>
  <li><strong>Saying no</strong> - “We can’t add a second ending, we have 3 weeks left”</li>
</ul>

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<ol>
  <li><strong>Smaller scope.</strong> 5 levels was too many for a student timeline. 3 polished levels beats 5 janky ones.</li>
  <li><strong>Earlier playtesting.</strong> We tested individually but didn’t do full playthroughs until late. Integration bugs exploded.</li>
  <li><strong>Branch discipline.</strong> Git merge conflicts on Unity scenes are painful. I’d enforce scene-based ownership and prefab workflows from day one.</li>
</ol>

<h2 id="the-payoff">The Payoff</h2>

<p>Despite the chaos, we shipped. The game had a tutorial, 4 themed levels, a complete progression system, and a cohesive narrative. It was selected as our final project submission, and the experience taught me more about software architecture and team coordination than any single course could.</p>

<p>Leading a team is the fastest way to level up as a developer - not because you write better code, but because you learn to think about code as a <strong>shared artifact</strong> that has to survive contact with other humans.</p>]]></content>
    <category term="tech" />
    <category term="Leadership" /><category term="GameDev" /><category term="Unity" />
    <summary type="html"><![CDATA[Lessons from being team leader and sole programmer on a 5-person student project.]]></summary>
  </entry>
  
  <entry>
    <title type="html">Markdown Reference</title>
    <link href="https://nuo27.github.io/articles/markdown-reference/" rel="alternate" type="text/html" />
    <published>2026-07-29T21:11:54+00:00</published>
    <updated>2026-07-29T21:11:54+00:00</updated>
    <id>https://nuo27.github.io/articles/markdown-reference/</id>
    <content type="html" xml:base="https://nuo27.github.io/articles/markdown-reference/"><![CDATA[<h2 id="headings">Headings</h2>

<h1 id="h1-heading">H1 Heading</h1>

<h2 id="h2-heading">H2 Heading</h2>

<h3 id="h3-heading">H3 Heading</h3>

<h4 id="h4-heading">H4 Heading</h4>

<h5 id="h5-heading">H5 Heading</h5>

<h6 id="h6-heading">H6 Heading</h6>

<h2 id="inline-formatting">Inline Formatting</h2>

<p>Body text with <strong>bold</strong>, <em>italic</em>, <strong><em>bold italic</em></strong>, <del>strikethrough</del>, <code class="language-plaintext highlighter-rouge">inline code</code>, and <a href="https://example.com">a link</a>.</p>

<p>A paragraph mixing everything: <strong>bold with <em>italic</em> and <code class="language-plaintext highlighter-rouge">code</code> inside</strong>, followed by <a href="https://example.com">a link <strong>with bold</strong></a> and <del>deleted text</del>.</p>

<h2 id="lists">Lists</h2>

<p>Unordered:</p>

<ul>
  <li>First item</li>
  <li>Second item
    <ul>
      <li>Nested under second</li>
      <li>Another nested
        <ul>
          <li>Deeper still</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>Back to top level</li>
</ul>

<p>Ordered:</p>

<ol>
  <li>Open the file</li>
  <li>Run the build</li>
  <li>Verify output
    <ol>
      <li>Check <code class="language-plaintext highlighter-rouge">_site/</code></li>
      <li>Check console for errors</li>
    </ol>
  </li>
  <li>Ship it</li>
</ol>

<p>Task list:</p>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" checked="checked" />Set up Jekyll</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" checked="checked" />Write content</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Add tests</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Deploy</li>
</ul>

<h2 id="code-blocks">Code Blocks</h2>

<p>Inline: run <code class="language-plaintext highlighter-rouge">bundle exec jekyll serve</code> to start.</p>

<div class="language-scss highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@use</span> <span class="s2">"variables"</span> <span class="nt">as</span> <span class="o">*</span><span class="p">;</span>
<span class="k">@use</span> <span class="s2">"mixins"</span> <span class="nt">as</span> <span class="o">*</span><span class="p">;</span>

<span class="nc">.button</span> <span class="p">{</span>
  <span class="k">@include</span> <span class="nd">phosphor-glow</span><span class="p">;</span>
  <span class="nl">background</span><span class="p">:</span> <span class="nv">$phosphor</span><span class="p">;</span>
  <span class="nl">color</span><span class="p">:</span> <span class="nv">$bg-void</span><span class="p">;</span>
  <span class="nl">padding</span><span class="p">:</span> <span class="m">1rem</span> <span class="m">2rem</span><span class="p">;</span>

  <span class="k">&amp;</span><span class="nd">:hover</span> <span class="p">{</span> <span class="nl">background</span><span class="p">:</span> <span class="nv">$phosphor-bright</span><span class="p">;</span> <span class="p">}</span>
  <span class="k">&amp;</span><span class="nd">:active</span> <span class="p">{</span> <span class="nl">transform</span><span class="p">:</span> <span class="nf">translateY</span><span class="p">(</span><span class="m">1px</span><span class="p">);</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Render the site</span>
<span class="n">site</span> <span class="o">=</span> <span class="no">Jekyll</span><span class="o">::</span><span class="no">Site</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="no">Jekyll</span><span class="p">.</span><span class="nf">configuration</span><span class="p">({}))</span>
<span class="n">site</span><span class="p">.</span><span class="nf">process</span>
<span class="nb">puts</span> <span class="s2">"Built in </span><span class="si">#{</span><span class="n">site</span><span class="p">.</span><span class="nf">time</span><span class="si">}</span><span class="s2">ms"</span>
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">example</span>
<span class="na">title</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Sample</span><span class="nv"> </span><span class="s">Article"</span>
<span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">Tag1</span><span class="pi">,</span> <span class="nv">Tag2</span><span class="pi">]</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Plain code block — no language hint.
Should still render readably.
</code></pre></div></div>

<h2 id="blockquotes">Blockquotes</h2>

<blockquote>
  <p>A blockquote with <strong>bold</strong>, <em>italic</em>, and <code class="language-plaintext highlighter-rouge">code</code> inside it.</p>

  <p>Second paragraph in the same quote.</p>

  <blockquote>
    <p>Nested blockquote — quieter accent stripe.</p>
  </blockquote>
</blockquote>

<h2 id="tables">Tables</h2>

<table>
  <thead>
    <tr>
      <th>Element</th>
      <th>Dark Mode</th>
      <th>Light Mode</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Body text</td>
      <td><code class="language-plaintext highlighter-rouge">--ink</code></td>
      <td><code class="language-plaintext highlighter-rouge">--carbon</code></td>
    </tr>
    <tr>
      <td>Accent</td>
      <td><code class="language-plaintext highlighter-rouge">--phosphor</code></td>
      <td><code class="language-plaintext highlighter-rouge">--print-phosphor</code></td>
    </tr>
    <tr>
      <td>Substrate</td>
      <td><code class="language-plaintext highlighter-rouge">--bg-void</code></td>
      <td><code class="language-plaintext highlighter-rouge">--paper</code></td>
    </tr>
    <tr>
      <td>Code bg</td>
      <td><code class="language-plaintext highlighter-rouge">--bg-deep</code></td>
      <td><code class="language-plaintext highlighter-rouge">--paper-deep</code></td>
    </tr>
  </tbody>
</table>

<h2 id="horizontal-rule">Horizontal Rule</h2>

<p>Text above.</p>

<hr />

<p>Text below.</p>

<h2 id="image">Image</h2>

<p><img src="/assets/image/bio-photo.png" alt="Bio photo" /></p>

<h2 id="definition-list">Definition List</h2>

<dl>
  <dt>Jekyll</dt>
  <dd>Static site generator written in Ruby.</dd>
  <dt>Bundler</dt>
  <dd>Manages Ruby gem dependencies via <code class="language-plaintext highlighter-rouge">Gemfile.lock</code>.</dd>
  <dt>Rouge</dt>
  <dd>Pure-Ruby syntax highlighter used by Jekyll.</dd>
</dl>

<h2 id="footnote">Footnote</h2>

<p>The site uses a PJAX router for soft navigation<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>. It also supports a custom cursor<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup> on fine-pointer devices.</p>

<h2 id="keyboard--special">Keyboard &amp; Special</h2>

<p>Press <kbd>Ctrl</kbd> + <kbd>C</kbd> to copy. Use <kbd>Esc</kbd> to close the lightbox. The <mark>highlighted phrase</mark> stands out from surrounding text. Hover over <abbr title="Hypertext Markup Language">HTML</abbr> for the expansion.</p>

<h2 id="mixed-content">Mixed Content</h2>

<ol>
  <li>
    <p><strong>First</strong>, set up the project:</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle init
bundle add jekyll
</code></pre></div>    </div>
  </li>
  <li>
    <p><strong>Then</strong>, configure <code class="language-plaintext highlighter-rouge">_config.yml</code>:</p>

    <blockquote>
      <p>The config file controls collections, permalinks, and excludes. Keep it minimal.</p>
    </blockquote>
  </li>
  <li>
    <p><strong>Finally</strong>, build and serve:</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">exec </span>jekyll serve <span class="nt">--livereload</span>
</code></pre></div>    </div>

    <p>The site runs at <code class="language-plaintext highlighter-rouge">http://127.0.0.1:4000/</code>.</p>
  </li>
</ol>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>Intercepts link clicks, fetches HTML, swaps <code class="language-plaintext highlighter-rouge">&lt;main id="app"&gt;</code>. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:2">
      <p>Inverted ring with per-element state changes — hover, zoom, pulse, drag. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content>
    <category term="tech" />
    <category term="Reference" />
    <summary type="html"><![CDATA[Every markdown element rendered in one place — the kitchen sink for styling verification.]]></summary>
  </entry>
  
  <entry>
    <title type="html">Notion as a CMS for a Static Site</title>
    <link href="https://nuo27.github.io/articles/notion-as-cms/" rel="alternate" type="text/html" />
    <published>2026-07-29T21:11:54+00:00</published>
    <updated>2026-07-29T21:11:54+00:00</updated>
    <id>https://nuo27.github.io/articles/notion-as-cms/</id>
    <content type="html" xml:base="https://nuo27.github.io/articles/notion-as-cms/"><![CDATA[<p>The goal: keep writing articles in Notion — the editor, the mobile app, the share-with-a-friend-to-edit flow — without giving up a static site. The solution is a sync that mirrors a Notion workspace into a directory of Markdown files. Here is what it actually does and where the sharp edges are.</p>

<h2 id="what-you-give-up-by-going-static">What you give up by going static</h2>

<p>A typical static site is plain HTML + CSS + a tiny bit of JS. No database, no server, no admin panel. Every article is a Markdown file under <code class="language-plaintext highlighter-rouge">_articles/</code> with a YAML frontmatter block. Build it with Jekyll, push the result to a CDN, done.</p>

<p>The downside is that writing an article means SSH-ing into a server (or <code class="language-plaintext highlighter-rouge">git push</code>ing), editing Markdown, waiting for a build, hoping you didn’t typo a tag. Notion is the opposite: open the app, type, hit publish. The ideal is the second experience without giving up the first.</p>

<p>The obvious move is to build the whole site <em>in</em> Notion. NotionNext (the well-known one) does that: it scrapes your workspace, mounts every page as a route, ships a React app. The cost is that Notion becomes the single source of truth and you inherit its constraints — search ranking, performance, uptime, link rot when a share is revoked. A gentler middle ground: write in Notion, build a static site, keep the site canonical.</p>

<h2 id="why-the-official-api-not-token_v2">Why the official API, not <code class="language-plaintext highlighter-rouge">token_v2</code></h2>

<p>Notion has two access paths:</p>

<ol>
  <li><strong>Internal integration</strong> — a bot identity with a <code class="language-plaintext highlighter-rouge">ntn_…</code> token and explicit <code class="language-plaintext highlighter-rouge">Read content</code> capability. Calls the public REST API at <code class="language-plaintext highlighter-rouge">api.notion.com</code>. This is the supported, documented way.</li>
  <li><strong>Session cookie (<code class="language-plaintext highlighter-rouge">token_v2</code>)</strong> — the same one your browser sends. Powers NotionNext, lets you read any workspace the cookie can see. Not supported, can break without notice, and tying your deploy to it means your site dies when the session expires.</li>
</ol>

<p>Option (1) is the better fit. It is slower to set up — creating an integration, sharing each database with it, copying a token — but the surface is small and stable. Rate limits are documented (~3 req/s). Errors are typed.</p>

<p>The one annoyance: each database must be shared explicitly with the integration. The integration can read anything shared with it, nothing else. For a single-section blog this is one database; for a heavier workspace, every nested database needs its own share too, which is the single biggest source of “why is this not showing up” confusion. (We will come back to it.)</p>

<h2 id="the-shape-of-a-notion-page">The shape of a Notion page</h2>

<p>A Notion page has two pieces:</p>

<ul>
  <li><strong>Properties</strong> — typed columns (Title, Status, Tags, Slug, Description…). These come from the database the page lives in. For a sub-page (no database), there is just a title.</li>
  <li><strong>Blocks</strong> — the content: headings, paragraphs, images, tables, but also <code class="language-plaintext highlighter-rouge">child_page</code>, <code class="language-plaintext highlighter-rouge">child_database</code>, <code class="language-plaintext highlighter-rouge">link_to_page</code>. Those last three are the interesting ones. They are how Notion expresses the page tree.</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">child_page</code> is a sub-page. <code class="language-plaintext highlighter-rouge">child_database</code> is a database created inline. <code class="language-plaintext highlighter-rouge">link_to_page</code> is a reference to an existing page or database (think <code class="language-plaintext highlighter-rouge">@mention</code> or drag-to-link). All three are stored the same way: as a block inside the parent’s body, with a reference to the target.</p>

<p>To mirror a Notion workspace into a tree of Markdown files, you have to traverse those blocks. Recursively. Because a page can contain a sub-page which contains a database which contains rows which are pages which can contain sub-pages. Notion goes arbitrarily deep, and you want the site to match.</p>

<h2 id="the-data-flow">The data flow</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Notion DB(s)
   │  NOTION_TOKEN, NOTION_DB_&lt;slug&gt; per category
   ▼
scripts/notion-sync/index.js
   │  reads _data/categories.yml (single source of truth for category list)
   │  scans existing files for notion_id map
   │  two-pass: gather → render (with incremental-skip via last_edited)
   ▼
_articles/notion/&lt;path&gt;.md
   │  explicit permalink, notion_id + last_edited for ID tracking
   ▼
Jekyll build → static site → CDN
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">_data/categories.yml</code> looks like this:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">slug</span><span class="pi">:</span> <span class="s">tech</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">Tech</span>
  <span class="na">eyebrow</span><span class="pi">:</span> <span class="s2">"</span><span class="s">//</span><span class="nv"> </span><span class="s">CHANNEL_TECH"</span>
  <span class="na">tagline</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Engineering</span><span class="nv"> </span><span class="s">deep-dives,</span><span class="nv"> </span><span class="s">tooling,</span><span class="nv"> </span><span class="s">and</span><span class="nv"> </span><span class="s">writeups."</span>
<span class="pi">-</span> <span class="na">slug</span><span class="pi">:</span> <span class="s">notes</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">Notes</span>
  <span class="na">eyebrow</span><span class="pi">:</span> <span class="s2">"</span><span class="s">//</span><span class="nv"> </span><span class="s">CHANNEL_NOTES"</span>
  <span class="na">tagline</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Loose</span><span class="nv"> </span><span class="s">notes,</span><span class="nv"> </span><span class="s">snippets,</span><span class="nv"> </span><span class="s">and</span><span class="nv"> </span><span class="s">reflections."</span>
</code></pre></div></div>

<p>The script derives everything else from that file: the env-var name (<code class="language-plaintext highlighter-rouge">NOTION_DB_&lt;SLUG_UPPER&gt;</code>), the nav eyebrow, the listing subtitle. Add a category by appending one entry to this YAML and one secret to your repo. No code change.</p>

<h2 id="pass-one-gather-the-whole-tree">Pass one: gather the whole tree</h2>

<p>The first pass queries every top-level database (status filter <code class="language-plaintext highlighter-rouge">= Published</code>), then walks each page recursively, following <code class="language-plaintext highlighter-rouge">child_page</code> / <code class="language-plaintext highlighter-rouge">link_to_page</code> / <code class="language-plaintext highlighter-rouge">child_database</code> blocks. The goal is a flat list of <code class="language-plaintext highlighter-rouge">{ page, slug, path, title, blocks }</code> plus a <code class="language-plaintext highlighter-rouge">pageIdToMeta</code> map that turns any page id into its URL path.</p>

<p>Two things matter here that are easy to get wrong.</p>

<p><strong>First</strong>, the recursion is depth-first and queries run in series. If page A references page B, and page B is also a top-level published article, the recursion reaches B <em>as a child of A</em> before the top-level pass gets to it. B ends up nested under A and the top-level B is skipped as a duplicate. The fix is to pre-collect every top-level page id into a set, and inside the recursion skip any reference whose target is in that set. The link in A still points to the right URL — the URL comes from the global <code class="language-plaintext highlighter-rouge">pageIdToMeta</code> map, which the top-level pass filled in.</p>

<p><strong>Second</strong>, an embedded database looks the same as a top-level one in the API. Both are <code class="language-plaintext highlighter-rouge">child_database</code> blocks. To distinguish them the script checks the database’s own <code class="language-plaintext highlighter-rouge">is_inline</code> field (returned by <code class="language-plaintext highlighter-rouge">databases.retrieve</code>). Inline databases render as a table inside the parent body; full-page databases become their own nested page with the rows hanging off it. If the field is missing (it was for a while on some block objects), it falls back to probing with <code class="language-plaintext highlighter-rouge">pages.retrieve</code> — a full-page database is also a page, so a successful probe means full-page, a failure means inline.</p>

<p><strong>Third</strong> — and this took a few iterations to get right — <em>pages need a stable identity that survives slug renames</em>. Tracking files by disk path is fragile: rename a Notion <code class="language-plaintext highlighter-rouge">Slug</code> property from <code class="language-plaintext highlighter-rouge">test</code> to <code class="language-plaintext highlighter-rouge">test-note</code> and you get a new file at the new path while the old file lingers. The fix is to store the Notion page UUID in the frontmatter (<code class="language-plaintext highlighter-rouge">notion_id:</code>) and treat that as the authoritative identity. Orphan detection then becomes ID-based: scan existing files, build a <code class="language-plaintext highlighter-rouge">notionId → filepath/path/lastEdited</code> map, delete any file whose <code class="language-plaintext highlighter-rouge">notionId</code> is no longer in the current Notion query. Slug renames become a stale-location check: same <code class="language-plaintext highlighter-rouge">notionId</code>, different <code class="language-plaintext highlighter-rouge">path</code> → delete old file, write new one. As a bonus, you can skip re-rendering a page whose <code class="language-plaintext highlighter-rouge">last_edited</code> hasn’t changed (NotionNext uses the same trick for its cache key).</p>

<h2 id="pass-two-render-and-write">Pass two: render and write</h2>

<p>The second pass turns each collected entry into a Markdown file. Each file gets an explicit <code class="language-plaintext highlighter-rouge">permalink:</code> frontmatter so the URL is exactly what we want regardless of where the file lives on disk.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>_articles/notion/notion-link-test.md                      → /articles/notion-link-test/
_articles/notion/notion-link-test/inside-page.md          → /articles/notion-link-test/inside-page/
_articles/notion/notion-link-test/3312.md                 → /articles/notion-link-test/3312/
_articles/notion/notion-link-test/page-database.md        → /articles/notion-link-test/page-database/
_articles/notion/notion-link-test/page-database/32.md      → /articles/notion-link-test/page-database/32/
_articles/notion/notion-sync-test.md                      → /articles/notion-sync-test/
</code></pre></div></div>

<p>Notice that <code class="language-plaintext highlighter-rouge">notion-sync-test</code> is a top-level article — even though <code class="language-plaintext highlighter-rouge">notion-link-test</code> links to it. The recursion skipped the link (top-level set), the top-level pass claimed it, and the parent’s link resolved to <code class="language-plaintext highlighter-rouge">/articles/notion-sync-test/</code>. Same logic works at any depth.</p>

<p>For each block fetched during pass one, the script also collects its blocks once and caches them — <code class="language-plaintext highlighter-rouge">notion-to-md</code> will re-fetch them later when converting to Markdown, and for a deep tree the duplicate API calls add up.</p>

<p>Notion content becomes Markdown via <code class="language-plaintext highlighter-rouge">notion-to-md</code>, a thin wrapper around the Notion API that knows how to convert each block type to its Markdown equivalent. It handles headings, paragraphs, lists, tables, code, callouts, toggles, images, etc. The custom-transformers hook lets you override specific block types — the script uses it for <code class="language-plaintext highlighter-rouge">child_database</code> and <code class="language-plaintext highlighter-rouge">link_to_page</code> because the defaults (database → just the title, link_to_page → ugly default text) are not what you want.</p>

<p>After rendering, a single regex pass replaces every <code class="language-plaintext highlighter-rouge">notion.so/&lt;pageId&gt;</code> markdown link with <code class="language-plaintext highlighter-rouge">/articles/&lt;path&gt;/</code>. That handles inline @-mentions and <code class="language-plaintext highlighter-rouge">link_to_page</code> blocks in one go.</p>

<h2 id="the-listing-problem">The listing problem</h2>

<p><code class="language-plaintext highlighter-rouge">site.articles</code> includes every page in the collection — top-level and nested. The listing at <code class="language-plaintext highlighter-rouge">/articles/</code> would otherwise show every sub-page and every database row alongside the real articles. A <code class="language-plaintext highlighter-rouge">nested: true</code> frontmatter flag, set automatically on any non-top-level page, lets the listing template skip them. So:</p>

<div class="language-liquid highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{%</span><span class="w"> </span><span class="nt">for</span><span class="w"> </span><span class="nv">article</span><span class="w"> </span><span class="nt">in</span><span class="w"> </span><span class="nv">articles</span><span class="w"> </span><span class="cp">%}</span>
  <span class="cp">{%</span><span class="w"> </span><span class="nt">if</span><span class="w"> </span><span class="nv">article</span><span class="p">.</span><span class="nv">nested</span><span class="w"> </span><span class="cp">%}{%</span><span class="w"> </span><span class="kr">continue</span><span class="w"> </span><span class="cp">%}{%</span><span class="w"> </span><span class="nt">endif</span><span class="w"> </span><span class="cp">%}</span>
  ...
<span class="cp">{%</span><span class="w"> </span><span class="nt">endfor</span><span class="w"> </span><span class="cp">%}</span>
</code></pre></div></div>

<p>Sub-pages are reachable via their parent’s link, via the URL, and via search. They are deliberately absent from the listing.</p>

<h2 id="orphans-restructuring-and-did-everything-get-deleted">Orphans, restructuring, and “did everything get deleted?”</h2>

<p>Deletion is the hard case. If you unpublish a page in Notion, the next sync should remove its file. If you move a page (Notion allows renaming sub-pages), the next sync should rewrite the file at the new path. If you delete a database, every file under it should go.</p>

<p>The implementation has three pieces:</p>

<ul>
  <li>
    <p><strong>Identity is the Notion page UUID, not the file path.</strong> Every generated file carries <code class="language-plaintext highlighter-rouge">notion_id: &lt;page.id&gt;</code> in its frontmatter. On each run, the script scans <code class="language-plaintext highlighter-rouge">_articles/notion/**</code>, reads every notion-managed file’s frontmatter, and builds a map <code class="language-plaintext highlighter-rouge">{ notionId → { filepath, lastEdited, path } }</code>. “Which file is which Notion page?” is answered by this map.</p>
  </li>
  <li>
    <p><strong>Path changes are detected by comparing paths for the same id.</strong> When the renderer writes a page, it looks up <code class="language-plaintext highlighter-rouge">existingById[id]</code>. If the existing entry’s <code class="language-plaintext highlighter-rouge">path</code> differs from the new one, the old file is deleted before the new one is written. Slug renames in Notion become a one-shot move on disk, not a duplication.</p>
  </li>
  <li>
    <p><strong>Orphans are files whose notion_id is no longer in the current sync.</strong> After rendering, the script walks <code class="language-plaintext highlighter-rouge">existingById</code> and deletes every entry whose <code class="language-plaintext highlighter-rouge">notionId</code> is not in <code class="language-plaintext highlighter-rouge">currentPageIds</code> (pages the current Notion query returned, including all recursively-discovered nested pages). This is robust to <code class="language-plaintext highlighter-rouge">git</code> restoring old files after a branch switch: the next sync finds them without a matching id and removes them. Old notion-managed files generated before this change existed (no <code class="language-plaintext highlighter-rouge">notion_id</code> in frontmatter) are skipped by the scanner; clean them up with a one-time <code class="language-plaintext highlighter-rouge">git rm</code>.</p>
  </li>
</ul>

<p>The “disk path” approach (track which paths you wrote, delete anything else) is simpler but breaks on slug renames and on <code class="language-plaintext highlighter-rouge">git</code> operations. The “Notion id” approach survives both. The cost is one extra scan at the start of every sync.</p>

<h2 id="a-note-on-template-injection">A note on template injection</h2>

<p>The Notion content lives inside a Jekyll-processed file. If a Notion page contains <code class="language-plaintext highlighter-rouge">{"slug"=&gt;"tech", "name"=&gt;"Tech", "eyebrow"=&gt;"// CHANNEL_TECH", "tagline"=&gt;"Engineering deep-dives, tooling, and writeups."}{"slug"=&gt;"notes", "name"=&gt;"Notes", "eyebrow"=&gt;"// CHANNEL_NOTES", "tagline"=&gt;"Loose notes, snippets, and reflections."}</code> or <code class="language-plaintext highlighter-rouge">&amp;#123;% include head.html %&amp;#125;</code>, Jekyll will evaluate it at build time. For a personal site where only the owner edits Notion, that is fine. For a public workspace it is a real XSS-shaped foot-gun. The fix is to wrap every generated body in <code class="language-plaintext highlighter-rouge">&amp;#123;% raw %&amp;#125;…&amp;#123;% endraw %&amp;#125;</code>, which tells Jekyll to pass the contents through unchanged. The cost is that bodies literally containing the string <code class="language-plaintext highlighter-rouge">&amp;#123;% endraw %&amp;#125;</code> would break the wrap; for a personal blog this is acceptable.</p>

<h2 id="the-cron--branch-plumbing">The cron / branch plumbing</h2>

<p><code class="language-plaintext highlighter-rouge">schedule</code> triggers in GitHub Actions run the workflow file from the <em>default</em> branch. If you keep your whole site on a <code class="language-plaintext highlighter-rouge">deploy</code> branch, the schedule silently does nothing. The fix is to make the default branch minimal: a README and a copy of the workflow file. The workflow always checks out <code class="language-plaintext highlighter-rouge">ref: deploy</code> regardless of which trigger fired, runs the sync there, commits the new files to <code class="language-plaintext highlighter-rouge">deploy</code>, then builds and deploys. The thin default branch exists so the cron trigger can find something to run.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">deploy</span><span class="pi">]</span> <span class="pi">}</span>
  <span class="na">schedule</span><span class="pi">:</span> <span class="pi">[{</span> <span class="nv">cron</span><span class="pi">:</span> <span class="s1">'</span><span class="s">*/10</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*'</span> <span class="pi">}]</span>
  <span class="na">workflow_dispatch</span><span class="pi">:</span>
<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
        <span class="na">with</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">ref</span><span class="pi">:</span> <span class="nv">deploy</span> <span class="pi">}</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-node@v4</span>
        <span class="na">with</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">node-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">20'</span> <span class="pi">}</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">npm ci</span>
        <span class="na">working-directory</span><span class="pi">:</span> <span class="s">scripts/notion-sync</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">node scripts/notion-sync/index.js</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">NOTION_TOKEN</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">NOTION_DB_TECH</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">NOTION_DB_NOTES</span><span class="pi">:</span> <span class="s">$</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">git config user.name "github-actions[bot]"</span>
          <span class="s">git add _articles</span>
          <span class="s">git diff --staged --quiet || (git commit -m "chore: sync notion articles" &amp;&amp; git push)</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">bundle exec jekyll build</span>
</code></pre></div></div>

<h2 id="the-trade-offs-you-should-know-about">The trade-offs you should know about</h2>

<p><strong>Notion-hosted images expire.</strong> The official API returns signed S3 URLs that live about an hour. For a daily-updated site that is fine. For a post that sits in an archive, it is not. The accepted workaround for now is to leave the URLs alone; the real fix is downloading images into <code class="language-plaintext highlighter-rouge">assets/articles/&lt;slug&gt;/</code> during sync, which is a one-day project not yet implemented.</p>

<p><strong>Notion blocks degrade.</strong> A <code class="language-plaintext highlighter-rouge">callout</code> becomes a blockquote. A <code class="language-plaintext highlighter-rouge">column_list</code> flattens. An embedded database’s children collapse into the database’s own row listing. Embedded YouTube / Figma / tweet cards become plain links. If you design your Notion content with these constraints in mind — mostly text, occasional embeds you accept as links — the result reads fine.</p>

<p><strong>Notion’s API does not expose every block property.</strong> The <code class="language-plaintext highlighter-rouge">is_inline</code> flag on <code class="language-plaintext highlighter-rouge">child_database</code> was missing from some block responses for a while, forcing the <code class="language-plaintext highlighter-rouge">pages.retrieve</code> fallback. Some databases return zero rows even when rows clearly exist, usually because the integration was not shared with the database the row actually lives in. The diagnostic logs (<code class="language-plaintext highlighter-rouge">… db "&lt;name&gt;": db.is_inline=true</code> and <code class="language-plaintext highlighter-rouge">cols=X rows=Y</code>) make both kinds of problem obvious in seconds.</p>

<p><strong>Jekyll will happily evaluate template syntax in your Notion content.</strong> Wrap the body in <code class="language-plaintext highlighter-rouge">&amp;#123;% raw %&amp;#125;</code> — the script does this — and this is a non-issue. Without it, a single misplaced `` in a Notion article becomes a build-time information disclosure.</p>

<p><strong>The whole thing is fragile to schema changes.</strong> If a Notion column rename or a property-type change ripples through your database, the sync can silently produce wrong files. There is no schema versioning yet. For a personal site this is acceptable; for anything shared you would want a migration step or a “schema unchanged since” guard.</p>

<p><strong>Jekyll’s auto-excerpt will warn about Liquid blocks.</strong> The fix is <code class="language-plaintext highlighter-rouge">excerpt_separator: &lt;!-- end_excerpt --&gt;</code> in the generated frontmatter, telling Jekyll to use a custom string for the excerpt split so it does not complain about <code class="language-plaintext highlighter-rouge">\n\n</code> inside our <code class="language-plaintext highlighter-rouge">&amp;#123;% raw %&amp;#125;</code> wrapper.</p>

<h2 id="why-this-and-not-notionnext">Why this, and not NotionNext</h2>

<p>NotionNext is the right tool if you want the site to <em>be</em> Notion — every page lives in Notion, navigation, search, comments, everything. The cost is that you are betting the whole site on Notion’s availability, its HTML generation, and the longevity of your <code class="language-plaintext highlighter-rouge">token_v2</code> session cookie.</p>

<p>Internally, NotionNext caches block fetches with a key like <code class="language-plaintext highlighter-rouge">page_block_&lt;pageId&gt;_&lt;lastEditedDate&gt;</code> — the same Notion page UUID + edit-time idea this project uses, just kept in memory + file + Redis rather than written into frontmatter. That cache key pattern is what makes the <code class="language-plaintext highlighter-rouge">notion_id</code> + <code class="language-plaintext highlighter-rouge">last_edited</code> frontmatter fields here work too: a page whose <code class="language-plaintext highlighter-rouge">last_edited</code> has not changed is skipped on the next sync, the same way NotionNext skips a cache hit. NotionNext does this at runtime in a Next.js process; this setup does it at build time against a checked-in tree of Markdown files.</p>

<p>This setup does the opposite: Notion is the <em>editor</em>, the static site is the <em>product</em>. Notion can be down for a day and the site still serves. You can move databases between workspaces. You can migrate off Notion entirely by exporting the database, writing a one-shot Markdown converter, and committing the result — no re-platforming of the site required.</p>

<p>Both are valid. This setup picks the second one.</p>]]></content>
    <category term="tech" />
    <category term="Jekyll" /><category term="Notion" /><category term="API" /><category term="Design" />
    <summary type="html"><![CDATA[How to wire Notion into a Jekyll site via the official API: recursive mirroring of the page tree, inline vs full-page databases, link rewriting, and CI plumbing.]]></summary>
  </entry>
  
  <entry>
    <title type="html">Rebuilding the Portfolio: From Theme to Toolkit</title>
    <link href="https://nuo27.github.io/articles/rebuilding-the-portfolio/" rel="alternate" type="text/html" />
    <published>2026-07-29T21:11:54+00:00</published>
    <updated>2026-07-29T21:11:54+00:00</updated>
    <id>https://nuo27.github.io/articles/rebuilding-the-portfolio/</id>
    <content type="html" xml:base="https://nuo27.github.io/articles/rebuilding-the-portfolio/"><![CDATA[<p>For two years my portfolio ran on <a href="https://github.com/YoussefRaafatNasry/portfolYOU">portfolYOU</a>, a clean Jekyll theme that did exactly what a new-grad portfolio needed: projects, blog posts, an about page, and a search box. It was fast, it worked, and I never had to think about it. Then I started thinking about it.</p>

<p>This is the story of throwing it all out and building something custom — why I did it, the design language I landed on, and the architecture that holds it together.</p>

<h2 id="why-redesign">Why Redesign</h2>

<p>The honest trigger was a job hunt. Every gameplay programmer portfolio I saw fell into one of two camps: a generic Bootstrap template that screamed “I don’t care about UX”, or a hand-built WebGL showcase that screamed “I have six months and a 3D artist friend”. Neither fit. I’m a programmer — I wanted the site itself to demonstrate the things I claim on the about page: craft, polish, attention to detail.</p>

<p>The specific gripes with the old theme were familiar:</p>

<ul>
  <li><strong>It looked like everyone else’s portfolio.</strong> The card grid, the muted palette, the typography — once you’ve seen one portfolYOU site, you’ve seen them all.</li>
  <li><strong>The motion was nonexistent.</strong> Page transitions were full browser reloads. Links were links. Hover states were CSS <code class="language-plaintext highlighter-rouge">:hover</code> and nothing else.</li>
  <li><strong>The architecture was theme-shaped, not mine.</strong> Customising anything meant overriding someone else’s mental model of how a portfolio should be built.</li>
</ul>

<p>I wanted a site that felt like the kind of software I want to write: opinionated, responsive, with a clear point of view.</p>

<h2 id="the-design-language-tactical-telemetry">The Design Language: Tactical Telemetry</h2>

<p>Every meaningful redesign starts with one strong image. Mine was an aviation HUD at three in the morning — phosphor green on near-black, monospace numerals, scanlines, a cursor that felt like it was <em>pointing</em> at something rather than just hovering. Tactical telemetry. CRT. The aesthetic of people who take their interfaces seriously.</p>

<p>From that image I derived a complete token system. Every colour, every easing curve, every shadow is a named variable in <code class="language-plaintext highlighter-rouge">_sass/_tokens.scss</code>:</p>

<table>
  <thead>
    <tr>
      <th>Token</th>
      <th>Value</th>
      <th>Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--bg-void</code></td>
      <td><code class="language-plaintext highlighter-rouge">#0B0D14</code></td>
      <td>Page substrate — lifted off pure black so the cursor doesn’t disappear</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--phosphor</code></td>
      <td><code class="language-plaintext highlighter-rouge">#00FF9C</code></td>
      <td>Signature electric green — interactive, active, glow</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--phosphor-soft</code></td>
      <td><code class="language-plaintext highlighter-rouge">#1FB886</code></td>
      <td>Editorial green — borders, sustained surfaces</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--ink</code></td>
      <td><code class="language-plaintext highlighter-rouge">#ECEEF5</code></td>
      <td>Soft off-white text — never pure white</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--cyan</code></td>
      <td><code class="language-plaintext highlighter-rouge">#2BE4FF</code></td>
      <td>HUD cyan — links and secondary data</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--hazard</code></td>
      <td><code class="language-plaintext highlighter-rouge">#FF2E4C</code></td>
      <td>Aviation red — alerts only</td>
    </tr>
  </tbody>
</table>

<p>A light theme was non-negotiable — the site needed to work on paper, on a projector, in a brightly-lit interview room. So every token has a <code class="language-plaintext highlighter-rouge">[data-theme="light"]</code> counterpart: paper substrate (<code class="language-plaintext highlighter-rouge">#E8E5DC</code>, unbleached documentation paper), carbon ink, forest-green phosphor. The same layout, the same components, two completely different moods.</p>

<h2 id="architecture-stay-boring-where-it-matters">Architecture: Stay Boring Where It Matters</h2>

<p>I made one big architectural decision early, and it shaped everything else: <strong>keep Jekyll, ditch the theme</strong>.</p>

<p>Jekyll is unsexy. It’s a static site generator from 2008, it builds Markdown into HTML, it has no JavaScript runtime. That’s exactly why I picked it. I didn’t want to learn a new framework, I didn’t want server-side rendering, I didn’t want a deploy pipeline more complicated than <code class="language-plaintext highlighter-rouge">git push</code>. Jekyll builds, GitHub Pages hosts, done.</p>

<p>What I <em>did</em> want was a richer client. So the architecture split became:</p>

<ul>
  <li><strong>Jekyll</strong> builds the HTML shell, the Markdown body, the Liquid includes.</li>
  <li><strong>Vanilla JS</strong> (no framework, no jQuery for app code) takes over the shell on first paint and runs the show.</li>
  <li><strong>A PJAX soft router</strong> (<code class="language-plaintext highlighter-rouge">assets/js/router.js</code>) intercepts internal link clicks, fetches the destination HTML, and swaps only <code class="language-plaintext highlighter-rouge">&lt;main id="app"&gt;</code> into the live document.</li>
</ul>

<p>That last decision is what makes the site feel like an application instead of a stack of pages. The navbar, the custom cursor, the ambient atmosphere layer, the scroll bar — all of it lives for the lifetime of the tab. Navigation is a cross-fade, not a reload. Where supported, it’s wrapped in <code class="language-plaintext highlighter-rouge">document.startViewTransition</code> so even browsers’ built-in transition API gets involved.</p>

<h2 id="the-details-that-took-the-longest">The Details That Took the Longest</h2>

<p>The interesting work is never the layout. The interesting work is the invisible stuff.</p>

<p><strong>The custom cursor.</strong> A small inverted ring that lags the pointer by a few frames, with per-element state changes: it grows on links, zooms over gallery items, pulses on buttons, drags on scrollable surfaces. The hard part wasn’t the rendering — it was keeping hover state correct when the page isn’t actually scrolling (smooth-scroll translates a wrapper via CSS transform, so <code class="language-plaintext highlighter-rouge">window.scrollY</code> never changes). I ended up with one rAF loop running <code class="language-plaintext highlighter-rouge">elementsFromPoint</code> every frame against the cursor’s last-known viewport coordinates. One source of truth, no event-listener sprawl.</p>

<p><strong>Card tracing.</strong> On the portfolio grid, a phosphor arc lights up the border of whatever card the cursor entered from. The angle is computed from the entry point, animated via a <code class="language-plaintext highlighter-rouge">--trace-spread</code> custom property registered with <code class="language-plaintext highlighter-rouge">@property</code> so the Web Animations API can interpolate it. Sounds simple. Took three days.</p>

<p><strong>Design tokens.</strong> Splitting the design system into substrate / ink / accent / geometry / type / motion categories, mirroring SCSS variables into CSS custom properties so inline <code class="language-plaintext highlighter-rouge">&lt;style&gt;</code> blocks in the head can read the same values. Boring. Critical. Every later component took a quarter of the time it would have without it.</p>

<p><strong>Editorial link underlines.</strong> Links don’t get <code class="language-plaintext highlighter-rouge">text-decoration: underline</code>. They get a phosphor-tinted swipe that animates from left to right on hover, controlled by <code class="language-plaintext highlighter-rouge">background-size</code> transition. One mixin, used everywhere.</p>

<h2 id="what-id-change">What I’d Change</h2>

<p>The custom cursor is the highest-risk component on the site. It’s the thing most likely to break on a new browser, the thing that disables itself on touch devices, the thing that has to fall back gracefully when <code class="language-plaintext highlighter-rouge">prefers-reduced-motion</code> is set. It’s also the thing visitors mention first. I’d build it again, but I’d budget twice the time for edge cases.</p>

<p>The PJAX router is the second-highest-risk component. Every soft navigation has to perfectly preserve scroll position, focus state, and event listener cleanup across the swap. Browsers’ <code class="language-plaintext highlighter-rouge">startViewTransition</code> API is still new enough that its behaviour shifts between releases. I’d keep it, but I’d write more tests.</p>

<p>What I wouldn’t change: Jekyll, the token system, the tactical telemetry aesthetic. Those are the load-bearing decisions, and a year in they still feel right.</p>

<h2 id="the-lesson">The Lesson</h2>

<p>A portfolio isn’t a deliverable. It’s a running statement about what you care about. The version I shipped last week is already wrong in a dozen small ways — the type scale needs work, the mobile breakpoints are too aggressive, the cursor needs to respect <code class="language-plaintext highlighter-rouge">pointer: coarse</code> more carefully. That’s fine. The point of building it yourself is that you can keep fixing it, and every fix teaches you something about your own taste.</p>

<p>Pick a strong image. Build boring where it matters. Spend your novelty budget on the things visitors will actually notice. Ship it, then keep editing.</p>]]></content>
    <category term="tech" />
    <category term="Design" /><category term="Jekyll" /><category term="Frontend" /><category term="Portfolio" />
    <summary type="html"><![CDATA[Why I threw out a working Jekyll theme and built my own — the tactical telemetry aesthetic, the architecture, and the details that took the longest.]]></summary>
  </entry>
  
  <entry>
    <title type="html">Unity vs Unreal: Choosing Your First Engine</title>
    <link href="https://nuo27.github.io/articles/unity-vs-unreal/" rel="alternate" type="text/html" />
    <published>2026-07-29T21:11:54+00:00</published>
    <updated>2026-07-29T21:11:54+00:00</updated>
    <id>https://nuo27.github.io/articles/unity-vs-unreal/</id>
    <content type="html" xml:base="https://nuo27.github.io/articles/unity-vs-unreal/"><![CDATA[<p>I’ve shipped games in both Unity (C#) and Unreal Engine 5 (C++), and I get asked this question a lot: <em>which engine should I start with?</em> The honest answer is - it depends on what you’re trying to build and how you learn best. But here’s a framework that might help.</p>

<h2 id="the-tldr">The TL;DR</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Unity</th>
      <th>Unreal Engine 5</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Language</strong></td>
      <td>C#</td>
      <td>C++ + Blueprints</td>
    </tr>
    <tr>
      <td><strong>Best for</strong></td>
      <td>2D, mobile, indie, prototypes</td>
      <td>3D AAA, realistic graphics, large teams</td>
    </tr>
    <tr>
      <td><strong>Learning curve</strong></td>
      <td>Gentler</td>
      <td>Steeper</td>
    </tr>
    <tr>
      <td><strong>Asset store</strong></td>
      <td>Massive</td>
      <td>High-quality, fewer options</td>
    </tr>
  </tbody>
</table>

<h2 id="when-i-reach-for-unity">When I Reach for Unity</h2>

<p>Unity shines when you need to <strong>move fast</strong>. C# is a friendly, expressive language, and Unity’s component-based architecture makes prototyping gameplay mechanics incredibly quick.</p>

<p>For <strong>Wistful</strong> - a 3D puzzle exploration game - Unity was the obvious choice. We had a 6-person student team, a semester deadline, and needed low-poly stylized visuals. Unity’s rapid iteration let us test puzzle ideas in minutes.</p>

<p>Unity is also the king of <strong>mobile and 2D</strong>. When I built <strong>Reserve Now</strong> (an iOS restaurant booking app), the equivalent game dev skills transferred directly - C# in Unity is close enough to Swift in iOS that the mental models overlap.</p>

<h2 id="when-i-reach-for-unreal">When I Reach for Unreal</h2>

<p>Unreal Engine 5 is a powerhouse for <strong>3D realism and multiplayer</strong>. The first time I opened UE5, the learning curve hit hard - C++ header files, the build system, Blueprint integration - but the payoff is enormous.</p>

<p>For <strong>Shatter</strong> - a 1v1 multiplayer movement shooter - UE5 was the right call. We needed:</p>
<ul>
  <li><strong>Epic Online Services</strong> for matchmaking (built-in, first-class)</li>
  <li><strong>Blueprint visual scripting</strong> for rapid designer iteration alongside C++ systems</li>
  <li><strong>High-fidelity rendering</strong> for the neon arena aesthetic</li>
  <li><strong>FMOD integration</strong> for adaptive audio (collaborating with UTS music students)</li>
</ul>

<p>Unreal’s multiplayer framework is years ahead of anything you’d cobble together in Unity without a paid asset. The replication system, RPCs, and network authority model are baked in at the engine level.</p>

<h2 id="the-real-answer">The Real Answer</h2>

<p>Stop agonizing over the engine. <strong>Pick one, build something small, ship it.</strong> The skills transfer more than you think - game loops, state management, input handling, and design thinking are engine-agnostic. I learned gameplay programming in Unity, then applied those same mental models when I picked up UE5.</p>

<p>The engine is a tool. The craft is yours.</p>]]></content>
    <category term="tech" />
    <category term="Unity" /><category term="Unreal" /><category term="GameDev" />
    <summary type="html"><![CDATA[After shipping projects in both engines, here's how I think about the decision.]]></summary>
  </entry>
  
</feed>
