The Blackboard: News on the Town Square, Edited Live in a Browser
Every town in Dekiro now has a notice board, and the papers pinned to it are the game's changelog, announcements and event posters, written in Storyblok. To edit them, the game itself runs in the CMS's preview: an 18 MB web export of one scene that redraws the paper while you type.
I can envision a blackboard with multiple papers on it. Each paper is a new entry, and they stack on top of each other so the newest is always on top. They can overlap. You interact with the board using F, and a new window opens with these letters. The letters should be stylised and have random variance to fit the theme.
A board instead of a button #
A changelog in a game usually means a "News" button and a scrolling text box. When the developer and the coordinator talked about what Storyblok could do for the game itself, the developer asked for something you walk up to instead: a board on the market square, next to the bounty board, with the news pinned to it as papers.
That fits the world. In the Vale, Captain Rhea already hands out bounties on that square, and townsfolk with routines walk past. A notice board is the kind of thing that would stand there anyway. It also turns reading patch notes into a small trip into town, which suits a game where you return to town all the time.
The idea went to the top of the backlog. Its one condition was the editing preview: the developer wanted to see a paper on the real board while writing it. That preview is the second half of this post.
Papers with a seed #
The board itself is a new Blender model, built by script like every building in the game: wooden posts, a plank face, a small tiled roof, a red plaque and a paper lantern. Where it stands is map data: each map generator calls a helper that puts one board on the town square, or in the gate camp on the maps that have no town. In the Vale it stands beside Captain Rhea.
Every news entry from Storyblok becomes a paper, and its kind field picks the paper:
| Kind | Paper |
|---|---|
patch_notes |
parchment, with torn edges and a stain or two |
event |
a poster with a red frame and a wax seal |
announcement |
a small note, sometimes with a folded corner |
"Random variance" had one requirement the developer didn't have to state: a paper must look the same on every machine and every time you visit. So nothing is random at draw time. PaperLook seeds a random number generator with the story's id in Storyblok and takes everything from it: a tilt of up to 5 degrees for parchment, 3.5 for a poster and 7 for a note; an offset from its slot on the board; one of three or four paper tints, nudged in hue, saturation and brightness; one of five pin colours; one of three inks; the shape of every torn edge. Two players on the same square see the same crooked note, and it stays crooked after a restart.1 The papers are drawn pixel by pixel into images, not loaded as textures. The same function draws a 22 x 28 pixel decal with ink scribbles for the board in the world and a 106 x 128 paper for the window.
The board shows the ten newest entries, newest on top, overlapping. Press F and the board window opens with the papers at reading size. Click one and it comes to the front and opens as a letter, set in the game's body font, in the ink colour of its paper. Long patch notes turn pages, with arrows, the mouse wheel or a swipe on a phone.
A "!" hangs over the board while there's anything you haven't read, and when a new paper arrives you get one chat line: "A new notice hangs on the board in the square." What you have read is stored per PC, not per character, so you don't read the same patch notes again with your second character.
The letter's text is Storyblok rich text, converted to Godot's BBCode. The game supports paragraphs, bold and italic, lists and one heading size; the Storyblok field's toolbar was limited to exactly those, so an editor can't write something the board can't show.
One entry, three places #
The changelog moved into Storyblok's game space as one entry per day. Each entry is shown in three places:
- the website's Changelog page, which the Astro site builds from the game space (previous post);
- the Blackboard in every town, as parchment;
- the start menu, where a small News box in the corner shows the three newest papers. A click opens the same letter as on the board.
One entry has a title, a date, a kind, a one- or two-line summary (the paper's face, the menu line, the website's card) and a body (the letter, the changelog entry). Writing it once in Storyblok updates all three.
The server fetches, players receive #
The game has no official server. Every world is hosted by a player, or on a dedicated server someone runs. So who asks Storyblok for the news?
The answer follows the game's networking rule: the server decides, the clients display.2 Post 3 describes the networking: four roles per actor, one spawn function, and the server deciding everything that matters. A node called NewsService runs on the server (in single player, that is your own game). A few seconds after a world starts, and then every ten minutes, it fetches the published news/ and events/ folders from Storyblok's content delivery API, with a 12-second timeout. It validates what came back, stores it in a cache file, and sends it to every player in the world. Players joining later get it when they spawn. The client checks the content once more on arrival, because a modified server could send anything.
Clients never call Storyblok for anything that affects play. That matters for the event calendar below: everyone in one world must agree on which event is running, so only one machine may decide it.
The game has to work without internet, so the news has three possible sources:
- a snapshot shipped with every build (
data/storyblok_snapshot.json, written by a script and committed); - the cache from the last successful fetch;
- a fresh fetch.
The newest of the three wins, compared as whole sources rather than story by story, so a story unpublished in Storyblok disappears from the board instead of lingering in an older source. No internet, a timeout or an error from the API all leave the last known content in place. A setting, "Load news from the internet", turns fetching off entirely.
Content also carries a min_game_version. A paper about a feature that a build doesn't have yet is skipped by that build, so an old copy of the game never shows notes it can't understand.
The public token that the game reads with is allowed to ship; the preview token that reads drafts never does.3 The public token's home is tools/storyblok/config.json, which Godot never sees and exports never include. The snapshot script copies its public parts to data/storyblok_config.json, which ships, and a test fails if the two disagree.
The event calendar #
Storyblok also holds a calendar. An event_slot names one of the game's world events (Shadow Surge, Harvest Moon, Gilded Night, a cult ritual, a sandstorm), a start and an end in UTC, a title and the text of its poster. The rule from the previous post applies: the CMS schedules, git defines. The slot can't change what a Harvest Moon does.
The server doesn't trust the calendar blindly. It drops a slot with an unknown event id, one that ends before it starts, one longer than seven days, or one that overlaps another slot of the same event, and logs why. A valid slot starts its event at its start time and runs it until its end. It replaces a random event or one a Game Master started, and nothing else may start while it runs (a Game Master can still stop it). Each slot runs once, and that is saved with the world, so a server restarted in the middle of a weekend event carries on with it. If nobody is online when a slot begins, it starts when the first player joins, for the time that's left.
From fourteen days before it starts until it ends, a slot also hangs on the board as a red-sealed poster, and its date line says it the way a poster would: "from 3 Nov", "on Thursday", "this weekend", "tomorrow", "now, until Sunday 18:00 UTC". A news entry can point at its slot, and while the poster is up the two become one paper, so the announcement doesn't hang twice.
Editing on the real board #
Storyblok's visual editor shows a web page in a frame next to the fields. For the website that page is the site itself. For the game space it has to be the board, which only exists inside the game.
A plain HTML copy of the papers would have been cheap to build. It would also have drifted from the game the first time anyone changed how a paper is drawn. The developer suggested the other way:
Could we isolate the Blackboard plus the background animation and the NPC Captain Rhea for the editing preview? I would imagine that we would not need much more in terms of preview.
So the preview is the game: a Godot web export of one scene, board_preview.tscn, with the game's own board window, paper drawing and letter code. It has no world, no network play, no saves and no Steam, but the papers it draws come from the same functions as in the game.
The web export has two constraints. It uses the Compatibility renderer (WebGL 2), not the Forward+ renderer that gives the Vale its light, fog and shadows. And it is built without threads, which keeps it simple to embed anywhere, the editor's frame included.4 Threads in a browser need SharedArrayBuffer, which requires the page to be "cross-origin isolated" through two response headers, a browser rule for any embedded page. One scene doesn't need threads, so the single-threaded build skips that setup entirely.
The square behind the board comes from the real game. A bake script renders the Vale's square in Forward+, with its sky, fog, ambient occlusion and buildings, but without the papers, Captain Rhea, townsfolk and the HUD. It saves the picture, a mask of where the sky is, and the camera. The preview draws that picture and puts a 3D view with the same camera on top, where only two things are live: the papers on the board and Captain Rhea, idle, breathing, in her real sprite. So the parts that change are real, and the part that never changes looks like Forward+ because it was rendered in Forward+.
A little motion keeps it from looking like a photo: cloud shadows drift over the square, clouds move in the sky (a shader that uses the bake's sky mask), birds cross, leaves blow past and the lantern on the board flickers.
Eighteen megabytes and a 25 MiB limit #
The download is about 18 MB. Godot's web engine is a 39.5 MB WebAssembly file, and Cloudflare Pages, where the preview is meant to be hosted, refuses single files over 25 MiB. So the build gzips the engine to 10 MB and the page unpacks it in the browser with the built-in DecompressionStream. The game's data pack is 7.6 MB, mostly the body font, which covers Chinese and Japanese characters. Everything else is about a third of a megabyte. The export includes only the 331 files that one scene needs, not the game's 200 MB.
Unsaved edits, forwarded into Godot #
The page around the engine loads the Storyblok Bridge, the script that talks to the editor. When the preview opens inside the editor, it loads the drafts of the game space and opens the letter of the story being edited. Then, on every change in a field, the Bridge sends an input event with the whole unsaved story.
The page forwards that story into the running game. Godot's JavaScriptBridge lets the scene hand a callback to the page; the page calls it with the story as JSON, and the scene replaces its copy of that story and redraws the paper, the board decal and the open letter. Nothing is saved or published along the way. The paper follows the typing.
In the editor, the board sits on the left and the story's fields on the right, each with a line that says what it does:
The preview also says when the game would not show a story. An event slot more than fourteen days away is drawn anyway, as its poster, with a red banner: "Not on the game's boards now: this poster hangs there from 15 Feb 2027 until 2 Mar 2027." An unknown event id or a version newer than the game gets a banner that says so. The editor shouldn't have to guess why a paper is missing in the game.
The page has to stay private. To show drafts, it carries the game space's preview token, so the built folder is ignored by git and may only be hosted behind a login (Cloudflare Access), like the website's preview. Locally it runs on a self-signed https address, which the editor's frame requires.
A Refresh button, and not downloading it twice #
The developer's first try with the preview brought two requests:
In the preview I would like a refresh button; that should give us a kind of real-time view as well. And if it runs in the web and I switch windows, the whole thing loads again. I don't want to load 18 MB every time I open a preview.
Live edits only cover the story you are typing in. If someone saves another story, or you publish one in another tab, the board doesn't know. So the preview got a Refresh button in the corner, with R as its key, that reads the drafts again and redraws in place. The open letter stays open, and unsaved edits from the Bridge stay on top until their story is saved. The page also refreshes by itself every 20 seconds while it is visible, and pauses in a hidden tab. A small "updated 19:47:39" beside the button shows when it last read.
Storyblok gives every story its own preview URL, which keeps each one cleanly addressable; with an 18 MB engine behind it, caching is what makes that fast. Two changes make every story after the first open almost instantly:
- Content-hashed file names. The build renames the engine and the pack after a hash of their contents (
index.7ae89b8d1c.wasm.gz) and serves them as immutable for a year. A new build gets new names, so nobody ever sees a stale engine. - A service worker that answers those hashed files from its own cache first, and drops older versions when a new one arrives. The page itself and the drafts still come from the network every time.
The automated check now measures it, against a local server: the first load downloads 16.3 MB in six requests, the second load 0.08 MB in three.
Tested automatically #
The preview has its own test, npm run check:board. It serves the build, opens it in headless Edge, and stubs both Storyblok's API and its Bridge: the stubbed API answers with the snapshot's stories, and the stubbed Bridge records the page's handlers so the test can fire input events itself. It then checks that the drafts load, that the focused story opens as its letter, that R picks up a story changed elsewhere and keeps the letter open, that an edit shows on the paper and the letter, that a far-off event shows its banner, and that the cache works. It does all of that on a desktop and a phone screen, and fails on any console error.
The game side has its own tests, from every invalid calendar slot to letters turning pages, and the network bot now prints news=10/1 (ten entries, one slot) on the host, the client and the dedicated server.
One bug was found by accident. Under the test runner, the script that remembers read papers was compiled twice, and the second copy lost its setting for where to save. Test runs had been writing read marks into the developer's real user folder. Its state now lives in engine metadata that every copy shares, and the stray file was deleted.
Takeaways #
- Put news where players already go. A board on the square costs one model and some drawing code, and it makes a patch note part of the world.
- Seed "random" looks from a stable id. Variance that changes between machines or visits reads as a bug.
- The server fetches, the clients receive, whenever content can affect play. Ship a snapshot and keep the last good copy so the game never depends on the internet.
- Preview with the real code. A copy of the renderer drifts; a web export of one scene, with a backdrop baked by the full renderer, looks right and stays right.
- Big web builds want hashed names and a cache. Immutable files plus a service worker turned 16 MB into 80 KB for every reload after the first.
What's next #
The preview runs on the developer's machine today. Hosting it behind Cloudflare Access is the next step, so the editor works from a phone too. After that, the backlog has rotating content: caravan stock, featured bounties and a weekly dungeon, chosen in Storyblok from lists of ids the game knows, and checked by the server like the calendar.