{"id":330,"date":"2026-05-31T13:34:22","date_gmt":"2026-05-31T20:34:22","guid":{"rendered":"https:\/\/secondhandcarrot.com\/blog\/?p=330"},"modified":"2026-05-31T13:35:43","modified_gmt":"2026-05-31T20:35:43","slug":"moving-like-clockwork","status":"publish","type":"post","link":"https:\/\/secondhandcarrot.com\/blog\/uncategorized\/moving-like-clockwork\/","title":{"rendered":"Moving Like Clockwork"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A cozy life-sim lives or dies on the feeling that the town keeps turning when you&#8217;re not looking. Walk into the same building two mornings running and the same people should be there doing the same work \u2014 until the day something changes them. The naive way to build that is to simulate every villager all the time and write each one&#8217;s position into the save file. We don&#8217;t do either. This post walks through the architecture we landed on instead, why we made the calls we made, and a couple of the bugs the design is specifically shaped to avoid.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Fair warning: this is a background, inside-baseball post. If you came here for screenshots, this isn&#8217;t that one. If you came here to argue about save schemas, pull up a chair.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One more caveat before we dig in: this is <strong>work in progress<\/strong>. What follows is the architecture as we&#8217;ve designed it, and parts of it are running in the vertical slice today \u2014 but it&#8217;s not all battle-tested across the full game yet, and some of the open questions below are genuinely still open. Treat this as a snapshot of how we&#8217;re building NPC scheduling, not a victory lap. Some of these calls will probably look different by the time the game ships, and if they do, that&#8217;s the process working.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Four layers, kept separate on purpose<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The first thing worth saying is that &#8220;NPC behavior&#8221; isn&#8217;t one system. We split it into four, and a lot of our sanity comes from never letting them bleed into each other:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Entity state<\/strong> \u2014 facts <em>about<\/em> the NPC. Whether they&#8217;ve met the player, their relationship standing, mood, per-NPC inventory, any active overrides. This lives on a component on the NPC, and only the fields we explicitly mark as save-relevant get persisted.<\/li>\n\n\n\n<li><strong>Schedule data<\/strong> \u2014 a static-ish table mapping a time block to a location, an activity, and the dialogue the NPC draws from while there. This is authored content, not runtime state.<\/li>\n\n\n\n<li><strong>StateTree<\/strong> \u2014 Unreal&#8217;s StateTree, carrying the behavior logic. What the NPC is <em>doing<\/em> right now: walking a path, claiming a workstation, standing idle. It reads the other layers as inputs and is rebuilt from scratch on load.<\/li>\n\n\n\n<li><strong>Persistence<\/strong> \u2014 the save struct and the load hooks that rehydrate everything.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The distinction people most often collapse is entity state versus the StateTree. They are not the same thing. Entity state is <em>data<\/em>; the StateTree is <em>logic that consumes that data<\/em>. Keeping them apart is what lets us throw the entire StateTree away on save and reconstruct it on load without losing anything that matters.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The reframe: positions are not a thing we save<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the decision everything else hangs off of. <strong>We do not persist NPC positions.<\/strong> Where an NPC physically stands is never written to disk.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That sounds reckless until you flip it around: the schedule plus the NPC&#8217;s entity state already <em>is<\/em> the source of truth for where someone should be. If you know it&#8217;s mid-morning and you know this NPC&#8217;s schedule and current overrides, you know where they are. Saving the position too is not just redundant \u2014 it&#8217;s a second source of truth that can disagree with the first, and now you&#8217;ve got a bug class to chase.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So schedule-driven spawning isn&#8217;t a fallback we reach for when the saved position is missing. It&#8217;s the <em>only<\/em> spawn path. Load a save five minutes after you made it, or three in-game days later, and the same code resolves where everyone is. There&#8217;s no special &#8220;long gap&#8221; branch, because there&#8217;s no ticking we have to fast-forward through.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Compiling a day<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If schedules drive everything, the obvious question is: do you run a schedule lookup every frame to figure out what an NPC is doing? No. That would mean re-walking the schedule rows and the override priority chain constantly, and it scatters the &#8220;which rule won&#8221; logic all over the runtime.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Instead we <strong>compile<\/strong>. Once per NPC per day, we take the schedule rows plus whatever overrides are active and produce a concrete, ordered list of events for that day. The runtime then just plays through that list. It doesn&#8217;t reason about priorities or preconditions; it reads the next event and does it.<\/p>\n\n\n\n<pre class=\"wp-block-code has-ast-global-color-3-color has-ast-global-color-7-background-color has-text-color has-background has-link-color wp-elements-00073cd5cd2988a5242cd71e41473d1c\"><code>  schedule rows  \u2510\n  override slots \u253c\u2500\u2500\u25b6 &#91; DAILY COMPILE ] \u2500\u2500\u25b6  events list  \u2500\u2500\u25b6  runtime plays it\n  (5 sources)    \u2518     priority resolved       concrete,         reads next event,\n                       once, right here        ordered           no priority logic<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This buys us a few things. The &#8220;which override won&#8221; question gets answered exactly once, in one deterministic pass, instead of being smeared across every frame. The compiled events list becomes a real, inspectable object \u2014 you can dump it, diff it, and debug it independently of the inputs that produced it. And a compile-time bug looks visibly different from a runtime bug, which matters enormously when you&#8217;re staring at an NPC who&#8217;s in the wrong place and trying to figure out <em>why<\/em>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The in-engine debugger view we&#8217;re building for this reflects the split: it&#8217;ll show both the compiled events list for the day <em>and<\/em> the raw inputs (schedule rows, active override slots) side by side. If the list is wrong but the inputs are right, that&#8217;s a compiler bug. If both look right and the NPC still misbehaves, the problem is downstream in the StateTree. Separating those two failure modes should save hours.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Overrides: priority by source, not by number<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most of the time an NPC runs their default routine. Sometimes a quest needs them positioned where the player can find them; sometimes a scripted story beat needs them at a specific spot; sometimes a town-wide calendar event pulls everyone somewhere. Those are overrides, and overrides need a priority order.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The tempting design is numeric priority \u2014 give each override a number, highest wins. We deliberately didn&#8217;t do that, because numeric priority has a nasty failure mode: two overrides both claim priority 100 and now your resolution order is undefined. Whoever wrote those two systems months apart never coordinated, and you get a nondeterministic bug that only shows up when both fire on the same NPC on the same frame.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Instead, priority is <strong>by source<\/strong>, in a fixed order: Story Event beats Quest Event beats Calendar Event beats Work Schedule beats Default Daily Routine. Each NPC carries one slot per source. A system writes into its own slot and nowhere else; resolution reads the slots top to bottom and takes the first one that&#8217;s filled. There&#8217;s no number to collide on, and the resolution code is trivial \u2014 a fixed walk down five slots.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The within-source rule is just as boring on purpose: at most one override per source is active at a time. A new override from the same source replaces the old one. No stacking, no sub-priority arithmetic. If a genuinely hard collision ever shows up \u2014 say a long-running background quest and a cutscene both wanting the same NPC at the same instant \u2014 we&#8217;d rather hit that as a loud, specific case to design around than paper over it with ad-hoc precedence rules today.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One important conceptual note that took us a while to get crisp: <strong>quests don&#8217;t trigger story beats.<\/strong> A quest is a collection of goals; it tracks what you need to do. A story event is a scripted beat that fires when world and NPC state meet its preconditions. Finishing a quest can <em>open<\/em> a story event \u2014 but only because the quest changed some state that the story event was watching for, not because the quest reached out and fired the script. Keeping those decoupled is how we avoid the classic open-world bug where a quest and a cutscene both try to own the same character at the same moment.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Offscreen, the clock doesn&#8217;t tick \u2014 it gets sampled<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">We don&#8217;t run a simulation for villagers you can&#8217;t see. There&#8217;s no per-NPC tick budget burning CPU on someone three zones away. The schedule is sampled on demand: when we need to know where an NPC is, we ask the schedule, we don&#8217;t ask a simulation that&#8217;s been grinding away.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">But &#8220;offscreen&#8221; isn&#8217;t uniform, so the representation splits:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Overworld NPCs<\/strong> still traverse the world&#8217;s navigation graph \u2014 ZoneGraph, in Unreal terms \u2014 and can show up on your map as a moving dot, so you can spot someone crossing town from a distance. That sense of a populated world is worth keeping.<\/li>\n\n\n\n<li><strong>Interior NPCs<\/strong> collapse down to &#8220;they&#8217;re inside <em>here<\/em>, doing <em>this<\/em>.&#8221; No path, no map presence, just a virtual location and an activity state.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">These two &#8220;no full actor right now&#8221; cases have completely different reasons behind them, and we&#8217;re careful never to conflate them. An overworld NPC with no spawned puppet is just a level-of-detail decision, handed off to Unreal&#8217;s Mass representation system \u2014 the player is far away. An interior NPC with no puppet is a <em>logical<\/em> state \u2014 they&#8217;re inside a building. Different lifecycles, different triggers, different correctness conditions. The day we start reasoning about &#8220;outdoor virtual NPCs&#8221; as a category is the day we&#8217;ve introduced a bug, because that category doesn&#8217;t exist in our model.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">NPCs in transit are their own problem<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The trickiest case is the one in between: an NPC walking from one place to another when you save, load, or just look away and back.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The wrong move is to snap them to their destination. Autosave fires while someone&#8217;s mid-walk, and if loading snaps them to the end of the path, you get a visible teleport \u2014 and worse, you break any scripted moment that depends on watching them <em>arrive<\/em>. So in-transit is its own first-class state. We store where they&#8217;re coming from, where they&#8217;re going, and when they left, plus every piece of world state the pathfinder actually consumed to route them. On load, we regenerate the path from those inputs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The hard requirement underneath this is <strong>path determinism<\/strong>: the path has to be a pure function of (origin, destination, world-state-at-departure). If anything that affects routing isn&#8217;t captured in that transit record, the recomputed path drifts away from the &#8220;remembered&#8221; one. To catch exactly that, we&#8217;re adding a debugger overlay that draws a line between where the interpolated path says the NPC should be and where the actual puppet is standing. That drift line is designed to be our canary \u2014 if it ever stretches, we&#8217;ve missed an input, and we can go find it before a player ever does.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What we <em>do<\/em> save, and the asymmetry that decided it<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">So if positions aren&#8217;t saved, what is? Entity state&#8217;s persistent fields \u2014 met-player, relationship, mood, inventory, the override slots \u2014 and, perhaps surprisingly, <strong>the compiled events list for the current day<\/strong>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">We went back and forth on that last one. Saving only the inputs and recompiling on load is smaller. But the cost of getting a recompile wrong is asymmetric, and that&#8217;s what decided it. A drift bug on a single transit path is one visible teleport \u2014 annoying, obvious, easy to spot and fix. A drift bug in a recompiled events list silently corrupts an NPC&#8217;s <em>entire day<\/em>, in a way that&#8217;s much harder to notice and much worse when it bites. Picture loading a Tuesday save and finding a villager asleep at home when they should be at their workbench \u2014 no crash, no error, just someone quietly living the wrong day because the recompile resolved it differently than the save that made it. Bytes are cheap; a quietly broken day is not. So we persist the list the runtime was actually playing, and only recompile at the day rollover from current inputs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That same compile step does double duty. When a story, quest, or calendar system drops an override mid-day, we don&#8217;t surgically splice it into the events list \u2014 we recompile the rest of the day from the current in-game time forward, preserving everything already played. It&#8217;s the exact same code path as the daily rollover compile, which means there&#8217;s one compile path to test and trust, not two that can subtly disagree.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why bother with all this<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every one of these calls trades something. Source-based priority gives up fine-grained control to buy deterministic resolution. Not-saving-positions gives up a tiny bit of load-time convenience to buy a single source of truth. Recompile-on-override gives up a micro-optimization to buy one well-tested code path. Saving the compiled list spends bytes to buy a drift-free day.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The through-line is that we&#8217;d rather pay in places that are cheap and visible than in places that are expensive and silent. For a two-person studio that can&#8217;t afford to chase nondeterministic save-corruption bugs for a week, &#8220;boring and deterministic&#8221; is the whole strategy. The town keeps turning whether or not you&#8217;re watching \u2014 and, just as importantly, it turns the <em>same way<\/em> every time you come back to it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">All of which is to say: this is where the design stands today, mid-build. Ask us again in a few months and some of it will have changed under contact with the rest of the game \u2014 we&#8217;ll write that up too when it does.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><em>More from the workbench soon. If you want the louder updates, that&#8217;s what the socials are for \u2014 this corner of the site is where we leave the long notes.<\/em><\/p>\n","protected":false},"excerpt":{"rendered":"<p>A cozy life-sim lives or dies on the feeling that the town keeps turning when you&#8217;re not looking. Walk into [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":61,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"give_campaign_id":0,"site-sidebar-layout":"default","site-content-layout":"","ast-site-content-layout":"default","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","ast-disable-related-posts":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"default","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"advanced_seo_description":"Explore our design philosophy for NPC scheduling in our cozy life-sim, focusing on architecture choices and persistent elements.","jetpack_seo_html_title":"Crafting NPC Behavior: Behind the Scenes of Our Life-Sim","jetpack_seo_noindex":false,"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":false,"jetpack_social_options":{"image_generator_settings":{"template":"edge","default_image_id":27,"font":"","enabled":true,"token":"eyJpbWciOiJodHRwczpcL1wvc2Vjb25kaGFuZGNhcnJvdC5jb21cL2Jsb2dcL3dwLWNvbnRlbnRcL3VwbG9hZHNcLzIwMjZcLzA0XC9GZXlsaWdodEJheUljb25AMngucG5nIiwidHh0IjoiTW92aW5nIExpa2UgQ2xvY2t3b3JrIiwidGVtcGxhdGUiOiJlZGdlIiwiZm9udCI6IiIsImJsb2dfaWQiOjI1NDEzODg1N30.kGhncXJgVKf6Ynql2-ks1Msv27PeJLD3DGziad-ncoEMQ"},"version":2},"_wpas_customize_per_network":false,"jetpack_post_was_ever_published":false},"categories":[1],"tags":[],"class_list":["post-330","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized"],"jetpack_publicize_connections":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/secondhandcarrot.com\/blog\/wp-content\/uploads\/2026\/04\/FeylightBayIcon%402x.png?fit=%2C&quality=80&ssl=1","jetpack_likes_enabled":true,"jetpack_sharing_enabled":true,"jetpack-related-posts":[],"_links":{"self":[{"href":"https:\/\/secondhandcarrot.com\/blog\/wp-json\/wp\/v2\/posts\/330","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/secondhandcarrot.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/secondhandcarrot.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/secondhandcarrot.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/secondhandcarrot.com\/blog\/wp-json\/wp\/v2\/comments?post=330"}],"version-history":[{"count":2,"href":"https:\/\/secondhandcarrot.com\/blog\/wp-json\/wp\/v2\/posts\/330\/revisions"}],"predecessor-version":[{"id":332,"href":"https:\/\/secondhandcarrot.com\/blog\/wp-json\/wp\/v2\/posts\/330\/revisions\/332"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/secondhandcarrot.com\/blog\/wp-json\/wp\/v2\/media\/61"}],"wp:attachment":[{"href":"https:\/\/secondhandcarrot.com\/blog\/wp-json\/wp\/v2\/media?parent=330"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/secondhandcarrot.com\/blog\/wp-json\/wp\/v2\/categories?post=330"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/secondhandcarrot.com\/blog\/wp-json\/wp\/v2\/tags?post=330"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}