Field Notes: The Apprentice

Community Article
Published June 15, 2026

Hi guys, We built a small game called The Apprentice for the Build Small Hackathon. The app takes its inspiration from the wild scenarios in D&D games with wild thinking and great DMs. We try to capture the merit of this through an LLM story teller. In our app, a player starts as a mentor and writes five short oracles into a parchment. They can be useful advice, random words, emoji, names, typos, or whatever else comes to mind. After that, the player does not get to explain anything.

The apprentice then goes through five trials. At each trial, he draws one oracle at random. Whatever the parchment gives him, a fine-tuned Qwen2.5-14B has to treat it like meaningful guidance and turn it into a way to survive.

That was the core joke. The player can write nonsense, but the world has to take it seriously.

The hackathon rule was to stay under 32B parameters. We took that as part of the design rather than just a constraint. Instead of relying on a huge model to smooth over everything, we tried to build a small system where the model, the prompts, the UI, and the story structure each had a clear job.

This is a build diary of what we made, what broke, and what made the project fun.

Part 1: What we built

Getting the model to understand the bit

The first version was playable pretty quickly. We could pass in an obstacle and an oracle, and Qwen would write a scene. The problem was that the scene often sounded like a normal assistant being helpful.

If the oracle was “phone,” the model might say the apprentice uses communication to solve the problem. That is reasonable, but it is not the game. In this world, “phone” should be taken too seriously. Maybe the apprentice tries to call a dragon. Maybe he hears a ringing inside a beetle shell. Maybe the word is completely misunderstood and still somehow saves him.

Prompting helped a little. We could tell the model to be absurd, or to take the word literally, or to create a comic misunderstanding. But the behavior moved around too much. Some generations were too plain. Some used the same joke shape again and again. Some had the right idea but wandered away from the format the app needed.

So we trained a LoRA to give the game a steadier sense of humor.

We used three humor modes. They were simple enough to control, but different enough to make runs feel less repetitive.

Mode A: Wild Imagination The oracle gets taken too literally. A single word becomes a strange object, command, ritual, or misunderstanding inside the scene.

Mode B: Accidental Trip The apprentice reads the oracle wrong and survives because of the mistake. The useful part is not wisdom, but clumsiness.

Mode C: Last-Minute Revelation The apprentice starts with the wrong interpretation. Then something in the environment makes the meaning click.

The training data was a mix of accident stories, absurd-news-style writing, prompt-writing examples, and a smaller set of distilled outputs that followed our exact JSON shape. The larger text pools gave the model different kinds of comic rhythm. The distilled examples kept the output usable by the app.

That format part mattered more than we expected. A funny paragraph is not enough if the UI cannot separate the narration from the tactic. We were not only training for tone. We were also training the model to behave inside a game loop.

Making the game feel bigger than it was

We did not have time to write a huge amount of content by hand, but we still wanted a second run to feel different from the first one. The solution was to split the game into structure and skin.

The structure is one shared story tree. The skin is the theme.

A fantasy run might have parchments, a wizard mentor, and a dragon-like finale. A western run might use cartridges and a gunslinger voice. Another theme might frame the same journey through signals, sealed scrolls, or star charts.

The underlying path stays the same. What changes is the language around it: what the oracle object is called, how the apprentice is described, what kind of world the trial belongs to, and what kind of boss waits at the end.

This let us reuse the same game logic without making every run look identical. It also kept the content manageable. Instead of writing every obstacle separately for every theme, we wrote theme-neutral story nodes first. A node might only describe the situation in plain terms: someone blocks a crossing, a trap is hidden in the road, a crowd refuses to listen. The theme layer turns that into actual prose.

For a hackathon, this was one of the more useful design choices. It gave the app room to feel playful without turning the writing task into the whole project.

From random trials to a branching path

Our earliest build used five random trials in a row. It was fine as a prototype, but the run did not really have a shape. The oracle changed how each obstacle was solved, but it did not change where the apprentice went.

We replaced that with a small branching story tree.

Everyone starts from the same opening. After that, the path can lean toward different kinds of problems: force, trickery, danger, social confusion, and eventually different finale routes. The tree is not large, but it gives the journey a memory.

At each fork, we use a small LLM call to choose the next node. This call is intentionally boring. It gets one oracle and a few candidate directions, then picks the branch that feels most connected. It does not write a scene. It just nudges the route.

That small choice changed the feel of the game more than adding more random trials would have. The player still only writes five oracles at the beginning, but those words now affect both the immediate scene and the path through the story.

The story tree also made the project easier to think about. Once the skeleton was there, we could adjust pacing, add themes, or rewrite a trial without touching everything else.

The model’s jobs inside the loop

The most visible model call is the trial resolution. The app gives it an obstacle and an oracle, then it returns a short narration and a tactic.

But the model is doing other things too.

It expands theme-neutral nodes into themed scene text. It chooses branches. It writes short interludes between trials. Near the end, it turns a small ending seed into an epilogue.

We liked this because the model was not just a paragraph generator sitting behind a button. It became part of the game machinery. The player gives the system five small pieces of text at the start, and the game keeps finding ways to reuse them.

During testing, this was where the project started to become fun. You could put in something stupid, forget about it for a trial or two, then see it come back as if the world had been waiting for it.

Part 2: What broke

Chinese support did not survive the LoRA cleanly

We wanted the app to support both English and Chinese. Since the base Qwen model can handle Chinese, we assumed that ability would stay mostly intact after the LoRA.

The training data, though, was basically all English. Once we tested Chinese prompts through the LoRA, the problem was obvious. The model often answered in English. Sometimes it inserted a short Chinese phrase and then returned to English immediately.

There was no time to rebuild the corpus properly, so we used a practical split. English requests use the LoRA. Chinese requests go to the base model. The Chinese output loses some of the fine-tuned humor style, but it is much more reliable as Chinese.

We also changed the prompt format. Instead of placing the language request only near the end, we put it at the top and bottom. It felt blunt, but the earlier version was too easy for the model to ignore.

This was a useful warning for us. The base model’s abilities are not automatically protected during fine-tuning. If we care about a behavior, it should appear in the training setup, even if the base model already knows how to do it.

The UI had a small but annoying flicker

One of the first UI problems was a little flicker when clicking through the game. It did not break anything, but it made the app feel rougher than it actually was.

The issue came from how some Gradio HTML updates rebuilt parts of the page. Our background animations restarted after those updates, so the screen would jump slightly during transitions.

The fix was mostly visual: we changed the animations so the restart point looked like a natural resting frame. The restart still existed, but it no longer caught the eye.

We also turned off some default loading indicators that briefly appeared before our own loading state. After that, the app felt much calmer when moving from one trial to the next.

This was a small change, but it affected the feel of the whole game. In a project like this, the difference between “toy” and “cheap toy” can be one tiny flash at the wrong time.

Local model mode took a detour

We also tried to add a local mode, so the model could run without the hosted backend. The conversion went fine. The annoying part came from installation.

A package we needed kept downloading as a corrupted file. For a while it looked like something was wrong with our environment. After trying a few paths, we realized the file we were getting was already bad at the source we were using.

Switching sources solved it.

This did not affect the design of the game, but it did eat time in the way build problems often do. You are not thinking about story, model behavior, or UI anymore. You are just trying to make the machine accept the thing it clearly does not want to accept.

Deployment had several unrelated problems wearing the same mask

The Hugging Face Space was the messiest part of the build.

The app could run locally, but the hosted version did not show up correctly at first. We checked Gradio versions, launch behavior, file paths, CSS loading, image paths, and backend connections. A few of those were actual problems. Others were distractions.

The frustrating part was that several unrelated bugs looked almost the same from the outside. If CSS is missing, the app looks broken. If images do not load, it also looks broken. If a page update fails, same thing. It is easy to chase the wrong cause because the visible symptom is always just “nothing looks right.”

Some issues were from the framework setup. Some came from our own shortcuts. A few image files also turned into Git LFS pointer text after a migration, so the app was trying to load files that looked like images from the outside but were not real images anymore.

We eventually got through it, but the process was less elegant than the rest of the build. Next time, we would put a tiny version online much earlier, even before the full UI is ready. Local success hides too much.

The hosted version started too heavy

After the Space rendered properly, the first load was still slow.

The reason was simple: we were sending too much at the beginning. A lot of pixel art was embedded directly into the page, so the browser had to receive a large initial response before the player could do anything.

We moved those assets into normal file loading instead. That made the first page much lighter and let the browser cache images properly. We also simplified a few heavy backgrounds and cached repeated HTML generation.

The hosted Space now defaults to a lighter visual mode. The local demo can still use the fuller version. That split felt right: the public version should open quickly, while the local version can afford to be prettier.

Reflection

The nicest part of building The Apprentice was how quickly it became fun to test.

At the beginning, it was just a strange idea: let the player write five oracles, then force a small model to make them useful. Once the loop worked, we kept trying new words just to see what would happen. “Spoon.” “Tax form.” “Blue.” Random names. Bad advice. The model did not always land the joke, but when it did, the game suddenly had a real personality.

Working with a smaller model made the project feel more direct. We could see its limits. We could see where prompting was enough and where it was not. The LoRA did not make the model perfect, but it gave the game a more specific voice. That was more interesting than just asking a much larger model to be funny.

The 32B rule also helped us divide the problem. The model did not need to do everything. The story tree handled structure. The prompts handled constraints. The UI gave the world its shape. The fine-tune pushed the tone in the right direction. Each part was small, but together they made the experience feel more complete than any single prompt would have.

Gradio gave us enough freedom to build something game-like without turning the hackathon into a frontend project. We could make panels, transitions, loading states, images, and branching screens while still staying mostly in Python. It was not frictionless, especially at deployment time, but it let us keep moving.

That freedom changed how we worked. A prompt tweak, a new theme phrase, a different branch, or a small UI adjustment could immediately change the feel of a run. The project stayed close to the surface. We did not have to wait long to see whether an idea worked.

The final version is still a hackathon build. Some parts are rough, and some corners are held together by practical choices. But it does what we wanted it to do: the player writes something strange, and the apprentice treats it like destiny.

That is what made the project satisfying. It was not a small model pretending to be a giant assistant. It was a small model inside a strange little game, doing one job with enough personality to make the world feel alive.

Community

Sign up or log in to comment