Player architecture

Why the Romergo Player runs on the ordinary DOM

A visual novel engine is generally not as complex as the engine of a 3D shooter or a real-time strategy game. It does not need to calculate the physics of a large world, the behavior of hundreds of objects, and complex lighting every second. But that does not mean it is enough to simply swap images on click.

A typical visual novel scene consists of a fairly understandable set of elements:

All the magic comes from changing these parameters over time. What matters is not only what to show, but when to show it, what to leave unchanged, how to continue music between scenes, which language to use, and where to go after the player's action.

I spent a long time looking for an engine and chose the browser

I tried building the scene with Godot, PixiJS, and Three.js. With varying degrees of success, all of them worked, and the engines themselves were not at fault. But for the way I create quests, they added another layer that I had to negotiate with separately.

I needed more than a finished game screen. I wanted to see every change in Builder immediately, launch a single scene without building the whole game, select and move elements directly in the preview, and then get the same behavior in the published Player.

In the end, the most effective solution was the simplest one: the browser's ordinary DOM.

The background, characters, and dialogue remain familiar browser layers. Most of the composition and effects are built with CSS. Canvas 2D is used for individual visual artifacts, while a WebGL canvas is connected where shader transitions between images are genuinely needed.

Sound works through the browser's HTMLAudioElement. The runtime creates audio for active clips, controls volume and looping, and preserves the same music during a transition when its asset has not changed.

This is not a universal recipe for every game. But for a visual novel, the DOM proved to be not a compromise but a very precise tool.

A simple scene and a smart runtime

I mentally divide the Player into two parts:

The scene should not know why a particular character appeared, which condition unlocked an answer, or which chapter comes next. It receives clips, the current time, language, screen mode, and media references. It then renders the result and returns the player's actions.

The runtime receives the quest description and playthrough state. It knows the current chapter, node, and scene, variable values, choices made, checkpoints, and completed chapters. When the player clicks the scene or selects an answer, the runtime applies effects, finds the next step, and gives the scene a ready state again.

In a heavily simplified form, the loop looks like this:

Quest JSON + saved progress
          ↓
runtime determines the current step
          ↓
the scene displays clips and media
          ↓
the player's action returns to the runtime
          ↓
the next step — and the loop repeats until the ending

In a published game, the Player loads a fixed runtime snapshot of the whole quest. In Builder, the preview creates a compatible runtime from the current draft. These are therefore not two similar players that gradually begin to behave differently, but one shared RuntimePlayer, SceneStage, and viewport inside different shells.

How continuity is preserved between scenes

During a transition, the runtime recalculates the state of the new scene. Repeated music is recognized by asset ID and continues without a new start or another fade-in. The browser loads and caches images, so reusing a background does not mean downloading the file from the network again.

The optimization therefore does not live in one large “do not send anything” condition, but at the correct layers: the runtime preserves playback continuity, the resolver returns stable resources, and the browser cache does not download known media again.

Two screens for one scene

The most unpleasant task for me was not rendering itself, but adapting the scene for phones. Simply shrinking a horizontal scene makes characters too small, squeezes the dialogue, and easily pushes important background details beyond the edge.

In Romergo, a scene has two fixed virtual viewports: horizontal 1280 × 720 and vertical 390 × 693. The Player selects the appropriate mode based on the device and orientation, then scales the finished scene as a whole.

The desktop layout remains the main one. Separate character positions and scale can be set for a phone; if there is no override, the desktop version is used with additional reduction. The author therefore edits not two independent scenes, but one scene with targeted mobile adjustments.

For the background, bgX, bgY, and scale are shared between both modes, while the vertical viewport crops the same image in its own way. Before release, the framing must therefore be checked in both formats and a shared focal point selected. Separate mobile background settings are not part of the runtime contract.

Repetitive character adjustments can be delegated to an AI agent through MCP and then checked in the real preview of both viewports. That is why I call the adaptation semi-automatic rather than fully automatic.

How not to show the player a black screen

A simple scene does not help if the required image has not arrived over the network yet. Loading therefore also became part of the runtime contract.

Before the first render, the Player collects the active media of the starting scene and waits for them to load. During this time, the player sees a proper progress indicator instead of an empty background. After launch, the runtime waits briefly and warms the resources for the current scene and scenes reachable within the next two graph steps. By default, the depth is two transitions ahead, including all possible branches at both steps.

Preloading follows the transition graph, and its depth can be configured separately. A fixed number of scenes is not used here: a linear sequence and a branch create different loads even when the same number of steps is formally ahead.

There is another mode for offline play: the user can download the entire quest in advance. The runtime snapshot, media, and PWA shell then remain in the browser cache and open without a network connection. Preloading the next scene provides smooth online play, while the complete download provides true offline play.

Telegram as a shell, Discord as a separate system

My love of browsers paid off especially well when embedding the Player in Telegram and Discord. In both cases, the same web runtime opens inside the platform, so the scene and playthrough rules did not have to be rewritten for a new game engine.

Telegram mainly required a platform session, quest launch, local progress, and navigation between the Mini App and the ordinary Player. The game itself remained the same.

Discord is more complex because several people need to see the same point in the story. Every participant does run a local runtime, but the host is the source of truth. The host's Player sends state when changes occur and approximately every 750 milliseconds. The realtime room receives the command over WebSocket, stores the snapshot, and broadcasts it to the participants. Spectator runtimes apply the remote state, restore the scene, and continue local timekeeping between synchronizations.

This separation produced a useful result: the story state is shared, while the language and screen size remain local. One participant can view the scene in Russian on a phone, another in English on a large screen, and both remain in sync with the host. Spectators can also vote for answer choices without becoming a second host.

One foundation for every mode

The Player has long gone beyond the basic set of “background, characters, and dialogue.” It now includes branches, variables, checkpoints, rewinding, interactive interface scenes, translations, offline play, and synchronized playthroughs.

But the basic decision survived that growth. The scene still handles image and sound. The runtime still handles state and transitions. Browser APIs cover rendering, media, caching, and embedding, while specialized layers are added only where they are genuinely necessary.

Because of this, the same Player can be maintained across Builder preview, tests, ordinary browser play, Telegram, and Discord. For me, this is a good example of how the simplest and slightly blunt solution can become the most flexible one—if the boundary of responsibility is drawn correctly.

Back to dev blog