// Blog

OUTBREAK: The Signal now gets new chapters without an app update

A story game lives on new chapters. For OUTBREAK: The Signal, our survival RPG for Android, that raised a practical question early on: does every new chapter have to wait for a new build of the app?

Our answer is no, and as of this week that is how the game works. When the game starts, the launch screen checks for new story content, downloads it, checks every file and switches it on. Chapter 4 is the first real chapter we shipped that way, without an app update.

Below is what we built, why we built it, and the small bugs that taught us the most. The game is still in development, so this is a story about engineering, not a launch.

Why content should not wait for a build

In a native mobile app, everything inside the build is frozen until the next build ships. Every change means a new binary, a trip through the store and players who have to install the update before they see anything new. That is fine for code. It is a poor fit for a story we want to grow chapter by chapter.

The web works differently. A web app updates the moment you reload it, which is one reason we ship installable web apps before native apps for most business tools. For a native game, downloadable content packs win back much of that freedom for the part that changes most often: the content.

We had a head start. OUTBREAK runs on a shared TypeScript game engine, and its story content is already data, validated with Zod, a TypeScript library that checks data against a schema (a description of what valid data looks like). The same rules run the mobile game and its web playtest client. So a chapter was already a bundle of data. The new part was getting that bundle onto a phone after the app is installed.

What players see

From the player's side the change is small, which is the point.

  • The launch screen fetches new content. A content strip follows the download progress while new material comes in.
  • The game says so. When a new pack arrives, or a pack the player already has is updated, the game tells them new transmissions were received.
  • Every screen picks it up. The Journal, the Radio and the map rebuild their lists as soon as a pack switches on, so new places and recordings appear straight away.
  • There is something to do between chapters. Players can keep watch at the end of a chapter and in the shelter, and the days they keep extend the world clock. Map, world-event and community encounters no longer use up the watch's cards.

Chapter 4 is the first real chapter delivered this way: twenty cards, six radio fragments, and new paintings for the chapter's three lead places and the relay desk. Scene titles now appear on cards as hints, and places appear as labels.

How a pack reaches the phone

A content pack is a set of files plus a manifest: a small index that lists every file in the pack. Each entry in our manifest carries the file's MD5 checksum, a short fingerprint calculated from the file's bytes. After a download, the phone calculates the fingerprint itself, natively, and compares the two. If even one byte differs, they do not match. Expo's FileSystem module, for example, can compute a file's MD5 digest on the device.

A word on MD5, because someone always asks. It is fine for catching a truncated or corrupted download. It is not a security feature: RFC 6151 says MD5 is no longer acceptable where collision resistance is required. If your worry is deliberate tampering rather than a flaky connection, you need a signature or a stronger hash delivered over a channel you trust.

Pack content is validated as well. In simplified form, validating a manifest with Zod looks like this sketch (an illustration, not our production code):

import { z } from "zod";

const Manifest = z.object({
  version: z.number().int(),
  files: z.array(
    z.object({
      path: z.string(),
      md5: z.string().regex(/^[0-9a-f]{32}$/),
    })
  ),
});

export function readManifest(json: unknown) {
  const result = Manifest.safeParse(json);
  return result.success ? result.data : null; // null: keep the content we have
}

Packs also have an audience. Chapter 4 went to our playtest group first, and then we released it to everyone. Being able to choose who gets a pack, without a new build, turned out to be one of the most useful parts of the system.

The last design choice matters most on Android: keep packs to data such as story text, card definitions, paintings and recordings, not code. Google Play's Device and Network Abuse policy says an app may not update itself outside Google Play's update mechanism, or download executable code such as dex, JAR or .so files from anywhere else. Keeping packs as validated data keeps you well away from that territory, and validated data can be rejected before it is used. If you plan something similar, read the full policy yourself; this is our design reasoning, not legal advice.

Rehearse with packs that change nothing

Before Chapter 4 went near a player, we shipped content that did nothing interesting on purpose.

The first test pack went to testers only: one new card, a new painting and label for an existing scene, and a new recording. The next version restored the originals. After that we published a series of packs whose whole job was to change nothing, so we could check the download, the progress strip, the launch flow and the world clock end to end, with no risk to anyone's game.

The lesson travels well beyond games: rehearse your delivery pipeline with harmless content before you trust it with real content. A pack that changes nothing is a cheap way to prove the plumbing works, and to find out what you missed.

What the bugs taught us

Publish the exact bytes you tested

Checksums are strict by design, so the files that go out have to be exactly the files the manifest describes. We tightened our publishing step so pack files are published byte-for-byte, whatever state the checkout they come from is in.

If you build something similar, watch for tools that quietly rewrite files. Git's line-ending conversion is the classic case: depending on core.autocrlf and the text attribute, a checkout can change line endings, and a changed line ending is a changed checksum. The gitattributes documentation explains that unsetting text on a path tells Git not to convert line endings for it:

# leave content files exactly as committed
*.json -text

A missing dependency looks like a stuck progress bar

For a while the content strip did not follow the download progress properly. Our first fix targeted late progress events that left the strip stuck at 100%. We reverted it, because the real cause was simpler: a memoised value was missing entries in its dependency list.

In React, useMemo caches a calculation and recomputes it only when something in its dependency list changes, and that list should include every reactive value the calculation reads. Leave one out and the screen keeps showing an old answer. Two lessons: when a fix feels like a patch over a symptom, back it out and look for the cause, and let the React hooks linter check your dependency lists.

Content that changes at runtime touches every screen

Before packs, story content never changed while the app was running, so screens could build their lists once. With packs, the Journal, Radio and map had to rebuild when a pack switched on. In the same way, the "new transmissions received" message first appeared only for new packs, not for updates to a pack that was already active.

Both are obvious in hindsight and easy to miss. Once content can change under a running app, every screen that reads it needs to hear about it.

Tests should not read the real clock

One engine test made two calls to the real clock, and those calls could land a millisecond apart, enough to fail the test now and then. We pinned the clock in that test. Any test that reads the current time twice is a flaky test waiting to happen.

What to take from this

If you run a mobile app whose content changes more often than its code, whether that is a game, a catalogue, a training course or an event guide, our advice is to separate the two early:

  • Keep content as data with a schema, and validate it before using it.
  • Ship a manifest with a checksum per file, and publish the exact bytes you tested.
  • Release to a small audience first, then widen it.
  • Rehearse with packs that change nothing.
  • Keep code changes for real app updates, and read your store's rules before your app downloads anything.

OUTBREAK: The Signal is still in development. You can follow it on our work page.

If you are planning an app whose content needs to move faster than its release cycle, we are happy to think it through with you. Get in touch and tell us what you are building.